From 3ffbf6c91965eea2ef5d775a97259145f3d2ab39 Mon Sep 17 00:00:00 2001 From: Millaguie Date: Sun, 5 Apr 2026 01:37:38 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20SPLITTER=20v2.0.0-beta=20=E2=80=94=20co?= =?UTF-8?q?mplete=20Go=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rewrite of SPLITTER in Go, replacing the Bash codebase. Single static binary with Cobra CLI, multi-instance Tor management, HAProxy load balancing, geo-based anti-correlation rules, and modern Tor feature auto-detection. Key features: - Native HTTPTunnelPort proxy mode (no Privoxy dependency) - Auto-detected Tor features: Conflux, congestion control, CGO, post-quantum TLS - Bridge support: Snowflake, WebTunnel, obfs4 - 4 configuration profiles: stealth, balanced, streaming, pentest - Docker Compose with 5 preconfigured scenarios (balanced, speed-eu, speed-latam, stealth, pentest) - Adaptive circuit rotation with fingerprinting resistance - Tor Metrics API country list auto-fetch (12h cache) - Exit node reputation checking via Onionoo API - DNS leak detection, 3-layer privacy test suite - Prometheus metrics endpoint - SIGHUP config reload - Multi-stage Docker build (alpine:3.21, Tor 0.4.9.6, HAProxy 3.0) - 147 files, 15 Go packages, table-driven tests, Makefile --- .github/workflows/ci.yml | 55 + .github/workflows/release.yml | 87 ++ .gitignore | 63 ++ AGENTS.md | 307 ++++++ Doc/MIGRATION.md | 376 +++++++ Dockerfile | 57 +- Makefile | 240 +++++ RALPH_STATUS.md | 95 ++ README.md | 861 +++++++++++----- ROADMAP.md | 264 +++++ cmd/doc.go | 2 + cmd/reload.go | 65 ++ cmd/reload_test.go | 226 +++++ cmd/root.go | 48 + cmd/run.go | 253 +++++ cmd/run_test.go | 172 ++++ cmd/status.go | 134 +++ cmd/status_test.go | 311 ++++++ cmd/test.go | 38 + cmd/version.go | 22 + configs/SETTINGS_MAP.md | 335 +++++++ configs/bridges.yaml | 26 + configs/default.yaml | 426 ++++++++ configs/profiles.yaml | 83 ++ configs/useragents.yaml | 17 + docker-compose.dev.yml | 33 + docker-compose.yml | 289 ++++++ entrypoint.sh | 6 + go.mod | 11 + go.sum | 13 + internal/circuit/adaptive.go | 21 + internal/circuit/adaptive_test.go | 129 +++ internal/circuit/client.go | 120 +++ internal/circuit/client_test.go | 224 +++++ internal/circuit/doc.go | 2 + internal/circuit/helpers_test.go | 111 +++ internal/circuit/pattern.go | 64 ++ internal/circuit/pattern_test.go | 143 +++ internal/circuit/renewer.go | 188 ++++ internal/circuit/renewer_test.go | 155 +++ internal/cli/config.go | 38 + internal/cli/doc.go | 2 + internal/cli/flagreader_test.go | 205 ++++ internal/cli/flags.go | 33 + internal/cli/flags_test.go | 91 ++ internal/cli/logger.go | 61 ++ internal/cli/logger_test.go | 263 +++++ internal/cli/logging.go | 15 + internal/config/config.go | 340 +++++++ internal/config/config_privacy_test.go | 156 +++ internal/config/config_test.go | 784 +++++++++++++++ internal/config/doc.go | 2 + internal/config/env.go | 162 +++ internal/config/loader.go | 189 ++++ internal/config/profiles.go | 174 ++++ internal/config/validate.go | 84 ++ internal/config/validate_extra_test.go | 224 +++++ internal/country/daemon.go | 185 ++++ internal/country/daemon_test.go | 267 +++++ internal/country/doc.go | 2 + internal/country/metrics.go | 172 ++++ internal/country/metrics_test.go | 424 ++++++++ internal/country/selector.go | 48 + internal/country/selector_test.go | 121 +++ internal/haproxy/config.go | 103 ++ internal/haproxy/coverage_test.go | 230 +++++ internal/haproxy/doc.go | 2 + internal/haproxy/haproxy_template_test.go | 240 +++++ internal/haproxy/integration_test.go | 116 +++ internal/haproxy/manager.go | 124 +++ internal/haproxy/manager_test.go | 360 +++++++ internal/haproxy/stats_password.go | 21 + internal/health/check.go | 92 ++ internal/health/check_test.go | 330 ++++++ internal/health/dnsleak.go | 325 ++++++ internal/health/dnsleak_test.go | 943 ++++++++++++++++++ internal/health/doc.go | 2 + internal/health/reputation.go | 288 ++++++ internal/health/reputation_test.go | 647 ++++++++++++ internal/health/status.go | 99 ++ internal/health/status_test.go | 300 ++++++ internal/metrics/doc.go | 2 + internal/metrics/handler.go | 27 + internal/metrics/handler_test.go | 181 ++++ internal/metrics/registry.go | 199 ++++ internal/metrics/registry_test.go | 159 +++ internal/metrics/server.go | 40 + internal/metrics/splitter.go | 61 ++ internal/network/allocator.go | 116 +++ internal/network/allocator_test.go | 216 ++++ internal/network/doc.go | 2 + internal/process/cleanup.go | 16 + internal/process/doc.go | 2 + internal/process/integration_test.go | 82 ++ internal/process/manager.go | 98 ++ internal/process/manager_test.go | 238 +++++ internal/process/shutdown.go | 84 ++ internal/process/spawn.go | 58 ++ internal/profile/doc.go | 1 + internal/profile/loader.go | 22 + internal/profile/loader_test.go | 102 ++ internal/profile/profile.go | 77 ++ internal/profile/profile_test.go | 310 ++++++ internal/proxy/doc.go | 2 + internal/proxy/legacy.go | 139 +++ internal/proxy/native.go | 31 + internal/proxy/privoxy_template_test.go | 129 +++ internal/proxy/proxy.go | 52 + internal/proxy/proxy_test.go | 269 +++++ internal/template/doc.go | 2 + internal/tor/bridges.go | 47 + internal/tor/bridges_test.go | 381 +++++++ internal/tor/cgo_test.go | 27 + internal/tor/coverage_test.go | 148 +++ internal/tor/doc.go | 2 + internal/tor/instance.go | 317 ++++++ internal/tor/instance_test.go | 548 ++++++++++ internal/tor/integration_test.go | 64 ++ internal/tor/manager.go | 264 +++++ internal/tor/manager_rotation_test.go | 116 +++ internal/tor/manager_test.go | 188 ++++ internal/tor/privacy_integration_test.go | 419 ++++++++ internal/tor/restart.go | 133 +++ internal/tor/torrc_privacy_test.go | 245 +++++ internal/tor/torrc_template_test.go | 332 ++++++ internal/tor/version.go | 227 +++++ internal/tor/version_test.go | 247 +++++ {func => legacy/func}/banner.func | 0 {func => legacy/func}/boot_tor_instances.func | 0 .../func}/boot_tor_per_country.func | 0 .../func}/change_country_on_the_fly.func | 0 .../func}/check_if_port_available.func | 0 {func => legacy/func}/help.func | 0 .../func}/killprevious_instances.func | 0 .../func}/loadbalancing_choice.func | 0 {func => legacy/func}/pre_loading.func | 0 {func => legacy/func}/random_country.func | 0 {func => legacy/func}/settings.cfg | 0 {func => legacy/func}/user_start_input.func | 0 legacy/settings.cfg | 573 +++++++++++ splitter.sh => legacy/splitter.sh | 0 main.go | 13 + opencode.json.backup | 89 ++ templates/haproxy.cfg.gotmpl | 55 + templates/privoxy.cfg.gotmpl | 24 + templates/torrc.gotmpl | 140 +++ tests/smoke.sh | 320 ++++++ 147 files changed, 21693 insertions(+), 279 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Doc/MIGRATION.md create mode 100644 Makefile create mode 100644 RALPH_STATUS.md create mode 100644 ROADMAP.md create mode 100644 cmd/doc.go create mode 100644 cmd/reload.go create mode 100644 cmd/reload_test.go create mode 100644 cmd/root.go create mode 100644 cmd/run.go create mode 100644 cmd/run_test.go create mode 100644 cmd/status.go create mode 100644 cmd/status_test.go create mode 100644 cmd/test.go create mode 100644 cmd/version.go create mode 100644 configs/SETTINGS_MAP.md create mode 100644 configs/bridges.yaml create mode 100644 configs/default.yaml create mode 100644 configs/profiles.yaml create mode 100644 configs/useragents.yaml create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/circuit/adaptive.go create mode 100644 internal/circuit/adaptive_test.go create mode 100644 internal/circuit/client.go create mode 100644 internal/circuit/client_test.go create mode 100644 internal/circuit/doc.go create mode 100644 internal/circuit/helpers_test.go create mode 100644 internal/circuit/pattern.go create mode 100644 internal/circuit/pattern_test.go create mode 100644 internal/circuit/renewer.go create mode 100644 internal/circuit/renewer_test.go create mode 100644 internal/cli/config.go create mode 100644 internal/cli/doc.go create mode 100644 internal/cli/flagreader_test.go create mode 100644 internal/cli/flags.go create mode 100644 internal/cli/flags_test.go create mode 100644 internal/cli/logger.go create mode 100644 internal/cli/logger_test.go create mode 100644 internal/cli/logging.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_privacy_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/doc.go create mode 100644 internal/config/env.go create mode 100644 internal/config/loader.go create mode 100644 internal/config/profiles.go create mode 100644 internal/config/validate.go create mode 100644 internal/config/validate_extra_test.go create mode 100644 internal/country/daemon.go create mode 100644 internal/country/daemon_test.go create mode 100644 internal/country/doc.go create mode 100644 internal/country/metrics.go create mode 100644 internal/country/metrics_test.go create mode 100644 internal/country/selector.go create mode 100644 internal/country/selector_test.go create mode 100644 internal/haproxy/config.go create mode 100644 internal/haproxy/coverage_test.go create mode 100644 internal/haproxy/doc.go create mode 100644 internal/haproxy/haproxy_template_test.go create mode 100644 internal/haproxy/integration_test.go create mode 100644 internal/haproxy/manager.go create mode 100644 internal/haproxy/manager_test.go create mode 100644 internal/haproxy/stats_password.go create mode 100644 internal/health/check.go create mode 100644 internal/health/check_test.go create mode 100644 internal/health/dnsleak.go create mode 100644 internal/health/dnsleak_test.go create mode 100644 internal/health/doc.go create mode 100644 internal/health/reputation.go create mode 100644 internal/health/reputation_test.go create mode 100644 internal/health/status.go create mode 100644 internal/health/status_test.go create mode 100644 internal/metrics/doc.go create mode 100644 internal/metrics/handler.go create mode 100644 internal/metrics/handler_test.go create mode 100644 internal/metrics/registry.go create mode 100644 internal/metrics/registry_test.go create mode 100644 internal/metrics/server.go create mode 100644 internal/metrics/splitter.go create mode 100644 internal/network/allocator.go create mode 100644 internal/network/allocator_test.go create mode 100644 internal/network/doc.go create mode 100644 internal/process/cleanup.go create mode 100644 internal/process/doc.go create mode 100644 internal/process/integration_test.go create mode 100644 internal/process/manager.go create mode 100644 internal/process/manager_test.go create mode 100644 internal/process/shutdown.go create mode 100644 internal/process/spawn.go create mode 100644 internal/profile/doc.go create mode 100644 internal/profile/loader.go create mode 100644 internal/profile/loader_test.go create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profile_test.go create mode 100644 internal/proxy/doc.go create mode 100644 internal/proxy/legacy.go create mode 100644 internal/proxy/native.go create mode 100644 internal/proxy/privoxy_template_test.go create mode 100644 internal/proxy/proxy.go create mode 100644 internal/proxy/proxy_test.go create mode 100644 internal/template/doc.go create mode 100644 internal/tor/bridges.go create mode 100644 internal/tor/bridges_test.go create mode 100644 internal/tor/cgo_test.go create mode 100644 internal/tor/coverage_test.go create mode 100644 internal/tor/doc.go create mode 100644 internal/tor/instance.go create mode 100644 internal/tor/instance_test.go create mode 100644 internal/tor/integration_test.go create mode 100644 internal/tor/manager.go create mode 100644 internal/tor/manager_rotation_test.go create mode 100644 internal/tor/manager_test.go create mode 100644 internal/tor/privacy_integration_test.go create mode 100644 internal/tor/restart.go create mode 100644 internal/tor/torrc_privacy_test.go create mode 100644 internal/tor/torrc_template_test.go create mode 100644 internal/tor/version.go create mode 100644 internal/tor/version_test.go rename {func => legacy/func}/banner.func (100%) rename {func => legacy/func}/boot_tor_instances.func (100%) rename {func => legacy/func}/boot_tor_per_country.func (100%) rename {func => legacy/func}/change_country_on_the_fly.func (100%) rename {func => legacy/func}/check_if_port_available.func (100%) rename {func => legacy/func}/help.func (100%) rename {func => legacy/func}/killprevious_instances.func (100%) rename {func => legacy/func}/loadbalancing_choice.func (100%) rename {func => legacy/func}/pre_loading.func (100%) rename {func => legacy/func}/random_country.func (100%) rename {func => legacy/func}/settings.cfg (100%) rename {func => legacy/func}/user_start_input.func (100%) create mode 100755 legacy/settings.cfg rename splitter.sh => legacy/splitter.sh (100%) create mode 100644 main.go create mode 100644 opencode.json.backup create mode 100644 templates/haproxy.cfg.gotmpl create mode 100644 templates/privoxy.cfg.gotmpl create mode 100644 templates/torrc.gotmpl create mode 100755 tests/smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d51da98 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - uses: golangci/golangci-lint-action@v6 + with: + args: --timeout=5m + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Go vet + run: go vet ./... + + - name: Go test + run: go test -race -count=1 ./... + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Go build + run: go build ./... + + - name: Docker build + run: docker build -t splitter . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..09ac10c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build binaries + run: | + VERSION=${{ steps.version.outputs.VERSION }} + LDFLAGS="-s -w -X github.com/user/splitter/cmd.Version=${VERSION}" + + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o splitter-${VERSION}-linux-amd64 . + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o splitter-${VERSION}-linux-arm64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o splitter-${VERSION}-darwin-amd64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o splitter-${VERSION}-darwin-arm64 . + + - name: Generate checksums + run: | + sha256sum splitter-* > checksums.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: SPLITTER ${{ steps.version.outputs.VERSION }} + draft: false + prerelease: false + generate_release_notes: true + files: | + splitter-* + checksums.txt + + docker: + name: Docker + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9191bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Go build artifacts +/bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Compiled binary (generated by `go build`) +/splitter + +# Go test artifacts +*.test +*.out +*.prof +coverage.txt + +# Go vendor (if ever used) +/vendor/ + +# Dependency directories +/node_modules/ + +# Runtime / generated files +*.log +*.pid +/tmp/splitter/ +force_new_circuit.sh + +# Tor runtime files +/control_auth_cookie +/cached-certs +/cached-microdesc-consensus +/cached-microdescs +/cached-microdescs.new +/lock +/state/ +/keys/ + +# HAProxy runtime +/haproxy.cfg.generated + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db + +# Environment / secrets +.env +.env.* +!.env.example + +# OpenCode AI development environment files +opencode.json +.opencode/ +prompts/ +.agents/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e43024 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,307 @@ +# AGENTS.md - SPLITTER Project + +## Project Overview + +SPLITTER is a Go-based tool that creates and manages multiple Tor network instances +load-balanced via HAProxy, with geolocation-based anti-correlation rules for Tor +entry/exit node selection. + +Two proxy modes are supported: +- **Native** (default): Uses Tor's built-in `HTTPTunnelPort` — no Privoxy dependency. + TCP path: User -> HAProxy -> Tor (per-instance) -> Tor Network -> Destination. +- **Legacy**: Uses Privoxy as the HTTP-to-SOCKS bridge. + TCP path: User -> HAProxy -> Privoxy (per-instance) -> Tor (per-instance) -> Tor Network -> Destination. + +Modern Tor features are auto-detected and enabled when available: Conflux (multi-leg circuits), +congestion control, CGO relay cryptography, post-quantum key exchange, and bridge/pluggable +transport support (Snowflake, WebTunnel, obfs4). + +## Build / Run / Test Commands + +### Build + +```bash +go build -o splitter . +``` + +### Run + +```bash +# Run with defaults +./splitter run + +# Run with flags +./splitter run --instances 3 --countries 8 --relay-enforce exit +# Short flags (legacy aliases): -i, -c, -re +./splitter run -i 3 -c 8 -re exit + +# Run with a profile +./splitter run --profile stealth + +# Other subcommands +./splitter status +./splitter test dns +./splitter version + +# Relay enforce modes: entry (default, best security), exit (GeoIP bypass), speed (fastest) +# Proxy modes: native (default), legacy (Privoxy) +``` + +### Test + +```bash +# Run all tests +go test ./... + +# Run tests for a specific package +go test ./internal/config/... + +# Run with verbose output +go test -v ./... + +# Run integration tests (requires tor, haproxy installed) +go test -tags=integration ./... +``` + +### Lint + +```bash +# Go vet +go vet ./... + +# golangci-lint (if installed) +golangci-lint run +``` + +### Docker + +```bash +docker build -t splitter . +docker run -d --name splitter -p 63536:63536 -p 63537:63537 splitter + +# Or with docker-compose +docker compose up -d +``` + +## Project Structure + +``` +main.go # Entry point +go.mod / go.sum # Go module definition +cmd/ + root.go # Root Cobra command + run.go # `splitter run` subcommand + status.go # `splitter status` - live dashboard + test.go # `splitter test dns` / `splitter test exit-reputation` + version.go # `splitter version` - detected Tor features + reload.go # SIGHUP config reload handler +internal/ + cli/ # Cobra setup, flag bindings, input validation + config/ # Config loading: YAML + env vars (SPLITTER_*) + CLI flags + tor/ # Tor instance lifecycle: spawn, config gen, signal, restart + haproxy/ # HAProxy config generation, process management + proxy/ # Proxy abstraction: HTTPTunnelPort (native) or Privoxy (legacy) + country/ # Country selection, rotation daemon, Tor Metrics API client + circuit/ # Circuit renewal, NEWNYM via Tor control protocol (cookie auth) + process/ # Process group lifecycle: spawn, graceful shutdown, SIGTERM->SIGKILL + metrics/ # Prometheus metrics endpoint + health/ # Health checks, DNS leak tests, exit node reputation + network/ # Port allocation via net.Listen (no netstat) + profile/ # Predefined profiles: stealth, balanced, streaming, pentest + template/ # Go template helpers for config generation +templates/ + torrc.gotmpl # Tor config template + haproxy.cfg.gotmpl # HAProxy config template + privoxy.cfg.gotmpl # Privoxy config template (legacy mode only) +configs/ + default.yaml # Default configuration (replaces settings.cfg) + bridges.yaml # Bridge configuration (Snowflake, obfs4, WebTunnel) + profiles.yaml # Profile definitions (stealth, balanced, streaming, pentest) + useragents.yaml # Bundled Tor Browser User-Agent list +legacy/ # Original Bash version preserved for reference + splitter.sh + func/ + settings.cfg +Dockerfile # Multi-stage: golang build -> alpine runtime +docker-compose.yml # Service definition with healthcheck +``` + +## Code Style Guidelines + +### Go Version and Modules + +- Go 1.23+ (module: `github.com/user/splitter`). +- Use Go modules exclusively; no `GOPATH` mode. +- Dependencies: `cobra` (CLI), `gopkg.in/yaml.v3` (config). Avoid adding new dependencies without justification. + +### Package Naming + +- Package names: lowercase, single word, no underscores (e.g., `tor`, `haproxy`, `network`). +- Package names match their directory name. +- No `util` or `helpers` packages — put functionality in domain-specific packages. + +### Error Handling + +- Wrap errors with context: `fmt.Errorf("functionName: %w", err)`. +- Always check errors; never silently discard them with `_`. +- Return errors up the call stack; handle at the appropriate level. +- Use `errors.Is()` and `errors.As()` for error inspection. + +### Context + +- `context.Context` is the first parameter in all I/O and long-running functions. +- Use `signal.NotifyContext` for graceful shutdown. +- Goroutines must accept a context or done channel for cancellation. + +### Logging + +- Use `log/slog` structured logging. +- **Logging is OFF by default** (philosophy: no logs, no crime). +- Enable via `--log` flag or `SPLITTER_LOG=1` env var. +- JSON format for Docker (detected via `TERM=dumb` or `NO_COLOR`), text for terminal. +- `--log-level` controls verbosity (default INFO when logs are on). +- Never log sensitive data (IPs, circuit paths, authentication tokens). + +### Naming Conventions + +- **Exported functions/types**: PascalCase: `NewManager`, `TorInstance`. +- **Unexported functions/types**: camelCase: `findAvailablePort`, `writeConfig`. +- **Constants**: PascalCase (exported) or camelCase (unexported), not UPPER_SNAKE_CASE. +- **Acronyms**: `HTTP`, `URL`, `ID`, `TLS` — e.g., `HTTPTunnelPort`, `TLSEnabled`. +- **Interfaces**: named by behavior (`Runner`, `ConfigProvider`), not `Impl` or `Interface`. + +### Code Organization + +- No `init()` functions. No global mutable state. +- Prefer small, focused files. One primary type per file where practical. +- Interfaces are defined where they are consumed, not where they are implemented. +- Group related types and functions together within a package. + +### Configuration Priority + +1. CLI flags (highest priority) +2. Environment variables (`SPLITTER_*` prefix) +3. YAML config file (`configs/default.yaml`) +4. Compiled-in defaults (lowest priority) + +### Concurrency + +- Use goroutines for Tor instances, circuit renewal, country rotation. +- Every goroutine must have a cancellation mechanism (context or done channel). +- Use `sync.Mutex` or channels for shared state; no data races. +- Process management uses `os/exec` with context cancellation. + +## Testing Conventions + +### Test Structure + +- Table-driven tests using `t.Run(name, func(t *testing.T))`. +- Test files live in the same package (`*_test.go`). +- Integration tests behind `//go:build integration` build tag. +- Use `t.TempDir()` for temporary files and directories in tests. +- Use `t.Setenv()` for environment variable tests. + +### Test Patterns + +```go +func TestFunctionName(t *testing.T) { + tests := []struct { + name string + input InputType + want OutputType + wantErr bool + }{ + {name: "valid input", input: InputType{...}, want: OutputType{...}}, + {name: "invalid input", input: InputType{...}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FunctionName(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("FunctionName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FunctionName() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +### Test Execution + +- Run `go test ./...` before committing. +- Run `go vet ./...` before committing. +- Mock external dependencies (Tor binary, HAProxy, network) in unit tests. +- Integration tests require real tor/haproxy binaries and are skipped in CI. + +## Key Patterns to Follow + +1. **Never hardcode ports or paths** — use values from `configs/default.yaml`. +2. **Use `text/template` for config generation** — torrc, haproxy.cfg, privoxy.cfg all use Go templates in `templates/`. +3. **Use `net.Listen` for port availability checks** — no `netstat`, `ss`, or shell calls. +4. **Graceful shutdown** — `SIGTERM` -> wait 5s -> `SIGKILL` via `signal.NotifyContext`. Kill all child processes on exit. +5. **No logs by default** — enable via `--log` or `SPLITTER_LOG=1`. Never log sensitive data. +6. **Cookie auth for Tor control port** — `CookieAuthentication 1` in torrc, read `control_auth_cookie` file. No `expect` scripts. +7. **Kill previous instances before starting new ones** — clean up stale tor/haproxy/privoxy processes at startup. +8. **Randomize order** — use `math/rand` to shuffle backend lists and country selection for anti-correlation. +9. **Auto-detect Tor features** — parse `tor --version` at startup and conditionally enable Conflux, CGO, congestion control, HTTPTunnelPort. +10. **Config reload via SIGHUP** — reload `configs/default.yaml` without full restart. Restart Tor instances only if torrc parameters changed. + +--- + +## Ralph Loop Workflow + +This project uses the **Ralph Loop** methodology for autonomous AI-driven development. +Each iteration starts with fresh context, reads progress from files, implements one task, +tests, commits, and updates status. + +### Files + +| File | Purpose | +|------|---------| +| `RALPH_STATUS.md` | Tracks current progress across ROADMAP phases (DO NOT DELETE) | +| `ROADMAP.md` | Full development plan with ordered tasks | +| `.opencode/commands/ralph.md` | The `/ralph` command template | +| `.opencode/agents/ralph.md` | The Ralph worker agent definition | + +### Usage + +1. Run `/ralph` in OpenCode +2. The agent picks the next ROADMAP task, implements it, runs tests, commits +3. After each iteration, run `/ralph` again to continue +4. Each iteration starts with fresh context -- no context pollution + +### Rules for Agents + +- ONE task per `/ralph` iteration +- Always run `go test` before committing +- Follow ROADMAP task numbering strictly +- Update `RALPH_STATUS.md` after each iteration +- Use `@reviewer` subagent before committing complex changes +- Use `@security` subagent before committing Tor/network code + +--- + +## OpenCode Agent Configuration + +This project has 6 configured agents in `opencode.json`: + +| Agent | Role | Model | Type | +|-------|------|-------|------| +| **build** | Development, writes code | GitHub Copilot GPT-5 Mini | primary | +| **plan** | Architecture, analysis, planning | deepseek/deepseek-chat | primary | +| **ralph** | Autonomous loop worker | deepseek/deepseek-chat | primary | +| **reviewer** | Code review (read-only) | GitHub Copilot GPT-5 Mini | subagent | +| **explorer** | Codebase search (read-only) | GitHub Copilot GPT-5 Mini | subagent | +| **security** | Tor/network security audit (read-only) | deepseek/deepseek-chat | subagent | + +### Switching Agents + +- Use **Tab** key to cycle between primary agents (build, plan, ralph) +- Use `@agent-name` to invoke subagents (e.g., `@reviewer check this code`) + +### Commands + +- `/ralph` - Start an autonomous Ralph Loop iteration diff --git a/Doc/MIGRATION.md b/Doc/MIGRATION.md new file mode 100644 index 0000000..97062f1 --- /dev/null +++ b/Doc/MIGRATION.md @@ -0,0 +1,376 @@ +# SPLITTER Migration Guide: Bash to Go + +## 1. Overview + +SPLITTER has been rewritten from Bash (~541 lines of shell scripts) to Go. The Bash version +is preserved in `legacy/` for reference. This guide helps existing users migrate to the Go +version. + +**Quick start**: Most users can run `./splitter run --profile balanced` to get behavior +equivalent to the old defaults, then customize via flags or `configs/default.yaml`. + +--- + +## 2. CLI Compatibility Matrix + +| Bash Flag | Go Flag | Short | Notes | +|-----------|---------|-------|-------| +| `-i N` | `--instances N` | `-i` | Identical behavior | +| `-c N` | `--countries N` | `-c` | Identical behavior | +| `-re MODE` | `--relay-enforce MODE` | `-r` / `-re` | `-re` kept as legacy alias. New short flag `-r` also works. Modes: `entry`, `exit`, `speed` | +| — | `--profile NAME` | — | **NEW**. Quick config: `stealth`, `balanced`, `streaming`, `pentest` | +| — | `--proxy-mode MODE` | — | **NEW**. `native` (default, HTTPTunnelPort) or `legacy` (Privoxy) | +| — | `--bridge-type TYPE` | — | **NEW**. `snowflake`, `webtunnel`, `obfs4`, `none` | +| — | `--verbose` | — | **NEW**. Verbose output | +| — | `--log` | — | **NEW**. Enable logging (off by default) | +| — | `--log-level LEVEL` | — | **NEW**. `debug`, `info`, `warn`, `error` | +| — | `--auto-countries` | — | **NEW**. Fetch country list from Tor Metrics API | +| — | `--stream-isolation` | — | **NEW**. IsolateSOCKSAuth | +| — | `--ipv6` | — | **NEW**. ClientUseIPv6 | +| — | `--exit-reputation` | — | **NEW**. Check exit node reputation | + +### Subcommands + +The Go version introduces subcommands (Cobra CLI): + +| Command | Description | +|---------|-------------| +| `splitter run` | Start SPLITTER (replaces `bash splitter.sh`) | +| `splitter status` | Live dashboard showing instance state, countries, circuits | +| `splitter test dns` | Verify all DNS queries go through Tor | +| `splitter test exit-reputation` | Check exit node reputation | +| `splitter version` | Show detected Tor version and feature support | + +--- + +## 3. Config File Migration + +### Format Change + +| Aspect | Bash | Go | +|--------|------|-----| +| File | `func/settings.cfg` (shell variables) | `configs/default.yaml` (YAML) | +| Types | All strings, shell-evaluated | Proper types: int, bool, string, list | +| Overrides | Edit file only | CLI flags > env vars (`SPLITTER_*`) > YAML > defaults | +| Country format | Comma-separated `{XX}` string | YAML list of `{XX}` strings | + +### Full Parameter Mapping + +The complete mapping of all 81 Bash variables is documented in +[`configs/SETTINGS_MAP.md`](../configs/SETTINGS_MAP.md). Key highlights: + +**62 variables ported** to YAML with proper typing. Examples: + +```yaml +# Bash: TOR_INSTANCES=2 +instances: + per_country: 2 + +# Bash: COUNTRY_LIST_CONTROLS=entry +relay: + enforce: "entry" + +# Bash: ACCEPTED_COUNTRIES="{us},{de},{fr},..." +country: + accepted: + - "{us}" + - "{de}" + - "{fr}" +``` + +**15 variables dropped** (Go handles at runtime): + +| Bash Variable | Reason Dropped | +|---------------|---------------| +| `USER_ID`, `USER_UID`, `USER_GID` | Go uses `os/user` and `os.Getuid()` | +| `RAND_PASS`, `TORPASS` | Go uses cookie auth; generates passwords with `crypto/rand` | +| `TOR_CURRENT_INSTANCE`, `TOR_CURRENT_SOCKS_PORT`, etc. | Go manages state in memory | +| `PRIVOXY_CURRENT_INSTANCE`, `PRIVOXY_CURRENT_PORT` | Go port allocator manages | +| `MASTER_PROXY_PASSWORD` | Was never defined (bug); Go auto-generates | +| `SPOOFED_USER_AGENT` | Go handles escaping at runtime | + +**12 variables had conflicting defaults** between `legacy/func/settings.cfg` and +`legacy/settings.cfg`. The Go version uses the `func/` values (the ones actually sourced +by the script): + +| Variable | func/ value | root/ value | Go default | +|----------|-------------|-------------|------------| +| `RETRIES` | 1000 | 100 | 1000 | +| `MINIMUM_TIMEOUT` | 15 | 20 | 15 | +| `CircuitsAvailableTimeout` | 5 | 360 | 5 | +| `CircuitStreamTimeout` | 20 | 30 | 20 | +| `ConnectionPadding` | 0 | 1 | 0 | +| `TrackHostExitsExpire` | 10 | 120 | 10 | +| `HEALTH_CHECK_INTERVAL` | 12 | 3 | 12 | +| `MASTER_PROXY_STAT_PORT` | 63537 | 63539 | 63537 | + +### Derived Timeouts + +The Bash version computed some timeouts via shell arithmetic. The Go version computes +them at runtime from the base settings: + +| Bash Expression | Go Computation | +|-----------------|---------------| +| `PRIVOXY_TIMEOUT = CircuitStreamTimeout + MINIMUM_TIMEOUT` | Same formula, runtime | +| `SocksTimeout = CircuitStreamTimeout + MINIMUM_TIMEOUT` | Same formula, runtime | +| `MASTER_PROXY_CLIENT_TIMEOUT = RETRIES * SERVER_TIMEOUT * COUNTRIES` | Same formula, runtime | + +### New YAML Fields (No Bash Equivalent) + +| YAML Key | Purpose | +|----------|---------| +| `tor.conflux_enabled` | Conflux multi-leg circuits (Tor 0.4.8+) | +| `tor.congestion_control_auto` | Congestion control (Tor 0.4.7+) | +| `tor.cgo_enabled` | CGO relay encryption (Tor 0.4.9+) | +| `tor.post_quantum_enabled` | ML-KEM768 key exchange (Tor 0.4.8.17+) | +| `tor.sandbox` | seccomp-bpf sandbox (Linux) | +| `tor.stream_isolation` | IsolateSOCKSAuth | +| `tor.client_use_ipv6` | IPv6 dual-stack | +| `country.auto_fetch` | Dynamic country lists from Tor Metrics API | +| `health.exit_reputation` | Exit node reputation checking | +| `metrics.enabled` | Prometheus `/metrics` endpoint | +| `user_agent.bundle_file` | Path to bundled UA list (`configs/useragents.yaml`) | + +--- + +## 4. Dependency Changes + +| Dependency | Bash | Go | Notes | +|------------|------|----|-------| +| **tor** | Required | Required | Go auto-detects version and conditionally enables features | +| **haproxy** | Required | Required | No change | +| **privoxy** | Required | **Optional** | Only in `--proxy-mode legacy`. Native mode uses HTTPTunnelPort | +| **expect** | Required | **Removed** | Go uses native Tor control protocol for NEWNYM | +| **proxychains** | Optional | **Removed** | Go handles proxy chaining natively | +| **netstat / ss** | Required | **Removed** | Go uses `net.Listen` for port availability checks | +| **shuf / sort -R** | Required | **Removed** | Go uses `math/rand` | +| **bash** | Required | **Removed** | Single static Go binary, no interpreter | + +**Result**: The Go version requires only `tor` and `haproxy` installed (plus `privoxy` only +if using legacy proxy mode). + +--- + +## 5. Feature Parity Matrix + +### Core Features (Carried Forward) + +| Feature | Bash | Go | Differences | +|---------|------|-----|-------------| +| Multiple Tor instances | ✅ | ✅ | Go adds auto-restart with exponential backoff | +| HAProxy load balancing | ✅ | ✅ | Identical; roundrobin or leastconn based on mode | +| Country-based entry/exit enforcement | ✅ | ✅ | entry, exit, speed modes | +| Country rotation daemon | ✅ | ✅ | Go adds configurable interval with jitter | +| Circuit renewal (NEWNYM) | ✅ | ✅ | Go uses cookie auth instead of `expect` + hashed password | +| Randomized circuit intervals | ✅ | ✅ | Go adds adaptive modes: burst, moderate, idle | +| Hidden service per instance | ✅ | ✅ | HiddenServiceDir + HiddenServicePort per instance | +| HAProxy stats page | ✅ | ✅ | Random password generated at startup | +| Health checks | ✅ | ✅ | URL-based with configurable interval and thresholds | +| Privoxy HTTP-to-SOCKS bridge | ✅ | ✅ | Legacy mode only (`--proxy-mode legacy`) | +| Docker support | ✅ | ✅ | Multi-stage build, smaller image, healthcheck | + +### New Features (Go Only) + +| Feature | Flag / Config | Tor Version | Notes | +|---------|--------------|-------------|-------| +| **HTTPTunnelPort (native proxy)** | `--proxy-mode native` | 0.4.8+ | Eliminates Privoxy. Default mode. | +| **Configuration profiles** | `--profile stealth\|balanced\|streaming\|pentest` | — | Predefined tuning presets | +| **Conflux (multi-leg circuits)** | Auto (tor.conflux_enabled) | 0.4.8+ | Traffic split across parallel circuit legs | +| **Congestion control** | Auto (tor.congestion_control_auto) | 0.4.7+ | Dramatic throughput improvement | +| **Post-quantum key exchange** | Auto (tor.post_quantum_enabled) | 0.4.8.17+ | ML-KEM768, requires OpenSSL 3.5+ | +| **CGO encryption** | Auto (tor.cgo_enabled) | 0.4.9+ | Improved relay cryptography | +| **Bridge / Pluggable Transport** | `--bridge-type snowflake\|webtunnel\|obfs4` | — | Censorship circumvention | +| **Sandboxing** | Profile: stealth | — | seccomp-bpf via `Sandbox 1` in torrc | +| **IPv6 dual-stack** | `--ipv6` | — | ClientUseIPv6 | +| **Stream isolation** | `--stream-isolation` | — | IsolateSOCKSAuth per destination | +| **Auto country lists** | `--auto-countries` | — | Tor Metrics API with 24h cache | +| **Exit node reputation** | `--exit-reputation` | — | Onionoo API scoring | +| **DNS leak test** | `splitter test dns` | — | Verify DNS goes through Tor | +| **Prometheus metrics** | metrics.enabled | — | `/metrics` + `/healthz` endpoints | +| **Circuit fingerprinting resistance** | Auto | — | Adaptive rotation based on traffic patterns | +| **Bundled User-Agent list** | configs/useragents.yaml | — | Rotated randomly per instance, updated at release | +| **SIGHUP config reload** | Signal-based | — | Reload YAML without full restart | +| **Status dashboard** | `splitter status` | — | Live terminal UI with ANSI codes | +| **Version detection** | `splitter version` | — | Show Tor version + feature support | +| **Single static binary** | — | — | No interpreter, no shell dependencies | +| **Subcommand CLI** | Cobra | — | `run`, `status`, `test`, `version` | +| **Environment variable overrides** | `SPLITTER_*` prefix | — | Full config override via env | + +--- + +## 6. Docker Migration + +### Bash Docker + +```bash +docker build -t splitter . +docker run -d -p 63536:63536 -p 63537:63537 splitter +# Entry: bash splitter.sh -i 15 -c 6 -re exit +``` + +### Go Docker + +```bash +# Recommended: docker compose +docker compose up -d + +# Manual build +docker build -t splitter . +docker run -d \ + -p 63536:63536 \ + -p 63537:63537 \ + -p 63539:63539 \ + -p 63540:63540 \ + splitter +``` + +### Key Docker Differences + +| Aspect | Bash | Go | +|--------|------|-----| +| Build | Single-stage | Multi-stage (golang build → alpine runtime) | +| Image size | Larger (bash + all deps) | Smaller (static binary + tor + haproxy only) | +| User | root | Non-root (`splitter` user) | +| Healthcheck | None | HTTP `/status` endpoint | +| Ports | 63536, 63537 | 63536 (HTTP proxy), 63537 (SOCKS), 63539 (stats), 63540 (status/healthz) | +| Config | CMD args only | Environment variables + mounted YAML | + +--- + +## 7. Environment Variable Overrides + +The Go version supports `SPLITTER_*` environment variables. The Bash version had no +environment variable support. + +```bash +# Core settings +SPLITTER_INSTANCES=10 +SPLITTER_COUNTRIES=6 +SPLITTER_RELAY_ENFORCE=exit +SPLITTER_PROXY_MODE=native + +# New features +SPLITTER_AUTO_COUNTRIES=true +SPLITTER_STREAM_ISOLATION=true +SPLITTER_IPV6=true +SPLITTER_EXIT_REPUTATION=true + +# Logging (off by default) +SPLITTER_LOG=1 +SPLITTER_LOG_LEVEL=debug +``` + +**Priority order**: CLI flags > environment variables > YAML config file > compiled defaults. + +--- + +## 8. Breaking Changes + +### 1. Default proxy mode is native (HTTPTunnelPort) + +The Go version defaults to `--proxy-mode native`, which uses Tor's built-in `HTTPTunnelPort` +instead of Privoxy. This eliminates one network hop and the Privoxy dependency. + +**Migration**: If you need Privoxy, use `--proxy-mode legacy`. + +### 2. Control port authentication changed to cookie auth + +The Bash version used `expect` scripts with `tor --hash-password` for control port +authentication. The Go version uses `CookieAuthentication 1` and reads the +`control_auth_cookie` file. + +**Migration**: No action needed. Cookie auth is more secure and automatic. + +### 3. Logging is OFF by default + +The Bash version logged by default (philosophy: audit trail). The Go version disables +logging by default (philosophy: no logs, no crime). + +**Migration**: Use `--log` or `SPLITTER_LOG=1` to enable logging. + +### 4. HAProxy stats port + +The `func/` version used port 63537 for both the SOCKS proxy and stats page. The Go +version separates these: +- 63536: HTTP proxy frontend +- 63537: SOCKS proxy frontend +- 63539: HAProxy stats page +- 63540: Status/healthz endpoint + +**Migration**: Update any scripts or monitoring that referenced the old stats port. + +### 5. No shell dependency + +The Go version is a single static binary. `expect`, `proxychains`, `netstat`, `shuf`, and +`bash` are no longer needed. + +**Migration**: Remove these from your Docker images or deployment scripts. + +### 6. Subcommand structure + +The Bash version was invoked as `bash splitter.sh [flags]`. The Go version uses +subcommands: + +```bash +# Old +bash splitter.sh -i 15 -c 6 -re exit + +# New +./splitter run --instances 15 --countries 6 --relay-enforce exit +# Short flags still work: +./splitter run -i 15 -c 6 -re exit +``` + +**Migration**: Update any wrapper scripts to use `splitter run` with the new flag format. + +--- + +## 9. Configuration Profiles + +The Go version introduces profiles that preset multiple configuration values. Use +`--profile` instead of specifying individual flags: + +| Profile | Instances | Countries | Relay Mode | Rotation | Special | +|---------|-----------|-----------|------------|----------|---------| +| `balanced` | 2 | 6 | entry | 120s | Default equivalent to old behavior | +| `stealth` | 5 | 12 | entry | 60s | Sandbox on, connection padding, no speed mode | +| `streaming` | 3 | 4 | speed | 300s | Conflux + congestion control, leastconn | +| `pentest` | 8 | 15 | exit | 30s | Aggressive rotation, stream isolation, random UA | + +--- + +## 10. Common Migration Scenarios + +### "I just want the same behavior as before" + +```bash +./splitter run --profile balanced +``` + +### "I was using `-i 15 -c 6 -re exit`" + +```bash +./splitter run -i 15 -c 6 -re exit +# or +./splitter run --instances 15 --countries 6 --relay-enforce exit +``` + +### "I need Privoxy (my Tor version is < 0.4.8)" + +```bash +./splitter run --proxy-mode legacy +``` + +### "I want to use the old settings.cfg values as a starting point" + +1. Copy `configs/default.yaml` +2. Adjust values per the mapping in `configs/SETTINGS_MAP.md` +3. Run `./splitter run --config /path/to/my-config.yaml` + +### "I was running in Docker" + +```bash +docker compose up -d +# Customize via environment variables: +SPLITTER_INSTANCES=10 SPLITTER_COUNTRIES=6 docker compose up -d +``` diff --git a/Dockerfile b/Dockerfile index 23f3ec4..7425cef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,51 @@ -FROM alpine:3.10 +# =========================================================================== +# Stage 1: Build the Go binary +# =========================================================================== +FROM golang:1.24-alpine AS builder -RUN apk add tor haproxy bash coreutils privoxy ncurses expect busybox-extras --no-cache \ - --repository http://dl-cdn.alpinelinux.org/alpine/v3.10/community \ - --repository http://dl-cdn.alpinelinux.org/alpine/v3.10/main \ - && rm -rf /var/cache/apk/* \ - && mkdir /splitter +RUN apk add --no-cache git ca-certificates -ADD func /splitter/func -ADD splitter.sh /splitter/ -RUN chmod 750 /splitter/splitter.sh +WORKDIR /src -EXPOSE 63536 -EXPOSE 63537 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /splitter . + +# =========================================================================== +# Stage 2: Minimal runtime image +# =========================================================================== +FROM alpine:3.21 + +RUN apk add --no-cache \ + tor \ + haproxy \ + privoxy \ + ca-certificates \ + curl \ + && rm -rf /var/cache/apk/* + +COPY --from=builder /splitter /usr/local/bin/splitter +COPY templates/ /splitter/templates/ +COPY configs/ /splitter/configs/ +COPY entrypoint.sh /splitter/entrypoint.sh + +RUN addgroup -S splitter 2>/dev/null || true && \ + adduser -S -G splitter -H -h /splitter splitter 2>/dev/null || true && \ + mkdir -p /tmp/splitter && \ + chown -R splitter:splitter /tmp/splitter /splitter && \ + chmod +x /splitter/entrypoint.sh WORKDIR /splitter -ENTRYPOINT ["./splitter.sh", "-i 3", "-c 10", "-re exit"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -sf http://localhost:63540/status || exit 1 + +EXPOSE 63536 63537 63540 + +USER splitter + +ENTRYPOINT ["/splitter/entrypoint.sh"] +CMD [] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9cc4c3c --- /dev/null +++ b/Makefile @@ -0,0 +1,240 @@ +# =========================================================================== +# SPLITTER Makefile +# =========================================================================== +# +# Quick reference: +# make build the binary +# make test unit tests +# make test-all unit + integration tests +# make vet static analysis +# make lint golangci-lint (if installed) +# make check vet + test + build (CI-equivalent) +# make clean remove build artifacts +# +# Docker: +# make docker-build build Docker image (splitter:test) +# make docker-up build + start dev container +# make docker-down stop and remove dev container +# make docker-logs tail container logs +# make docker-restart rebuild + restart (code change iteration) +# +# Smoke tests: +# make smoke run smoke tests against running container +# make smoke-proxy quick HTTP proxy connectivity check +# +# =================================================================== + +# ── Variables ────────────────────────────────────────────────────────────── + +BINARY := splitter +IMAGE := splitter:test +CONTAINER := splitter-dev +COMPOSE := docker compose -f docker-compose.dev.yml +GOFLAGS := -trimpath +LDFLAGS := -s -w +TESTPKGS := ./... +TESTTAGS := + +# Ports (must match docker-compose.dev.yml) +HTTP_PORT := 63537 +SOCKS_PORT := 63536 +STATS_PORT := 63539 +STATUS_PORT:= 63540 + +# ── Build ─────────────────────────────────────────────────────────────────── + +.PHONY: build +build: ## Build the Go binary + go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY) . + +.PHONY: build-race +build-race: ## Build with race detector + go build -race -o $(BINARY) . + +.PHONY: clean +clean: ## Remove build artifacts and temp files + rm -f $(BINARY) + go clean + +# ── Testing ───────────────────────────────────────────────────────────────── + +.PHONY: test +test: ## Run unit tests + go test -count=1 $(TESTPKGS) + +.PHONY: test-verbose +test-verbose: ## Run unit tests with verbose output + go test -v -count=1 $(TESTPKGS) + +.PHONY: test-race +test-race: ## Run unit tests with race detector + go test -race -count=1 $(TESTPKGS) + +.PHONY: test-coverage +test-coverage: ## Run tests and generate coverage report + go test -coverprofile=coverage.out $(TESTPKGS) + go tool cover -func=coverage.out + @echo "---" + @echo "HTML report: go tool cover -html=coverage.out" + +.PHONY: test-integration +test-integration: ## Run integration tests (requires tor, haproxy installed) + go test -tags=integration -v -count=1 ./internal/tor/... + +.PHONY: test-all +test-all: ## Run unit + integration tests + @echo "=== Unit tests ===" + go test -count=1 $(TESTPKGS) + @echo "" + @echo "=== Integration tests ===" + go test -tags=integration -v -count=1 ./internal/tor/... + +.PHONY: test-privacy +test-privacy: ## Run only privacy-related tests + @echo "=== Torrc privacy assertions ===" + go test -v -count=1 -run "Privacy|Hardcoded|Configurable|CannotBe|ControlPort|Deprecated|SecurityDirective" ./internal/tor/... + @echo "" + @echo "=== Config privacy defaults ===" + go test -v -count=1 -run "Privacy|ControlAuth|ReducedConnection|EntryGuard|CircuitTimeout|StreamIsolation" ./internal/config/... + +# ── Static analysis ──────────────────────────────────────────────────────── + +.PHONY: vet +vet: ## Run go vet + go vet ./... + +.PHONY: fmt +fmt: ## Run gofmt (write changes) + gofmt -w . + +.PHONY: fmt-check +fmt-check: ## Check formatting without changes + @test -z "$$(gofmt -l .)" || (echo "files need formatting:"; gofmt -l .; exit 1) + +.PHONY: lint +lint: ## Run golangci-lint (install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) + golangci-lint run ./... + +.PHONY: check +check: vet test build ## vet + test + build (CI-equivalent) + +# ── Docker ────────────────────────────────────────────────────────────────── + +.PHONY: docker-build +docker-build: ## Build Docker image + docker build -t $(IMAGE) . + +.PHONY: docker-up +docker-up: ## Build image and start dev container + docker build -t $(IMAGE) . && \ + $(COMPOSE) up -d && \ + echo "" && \ + echo "Waiting for bootstrap..." && \ + sleep 10 && \ + echo "" && \ + docker logs --tail 20 splitter-dev-1 2>&1 || docker logs --tail 20 $(CONTAINER) 2>&1 + +.PHONY: docker-down +docker-down: ## Stop and remove dev container + -$(COMPOSE) down 2>/dev/null || true + -docker rm -f $(CONTAINER) 2>/dev/null || true + +.PHONY: docker-logs +docker-logs: ## Tail container logs + docker logs -f --tail 50 $(CONTAINER) + +.PHONY: docker-restart +docker-restart: ## Rebuild image and restart container (code change iteration) + $(MAKE) docker-down + $(MAKE) docker-up + +.PHONY: docker-shell +docker-shell: ## Shell into running container + docker exec -it $(CONTAINER) /bin/sh + +.PHONY: docker-verify +docker-verify: ## Verify tor configs inside running container + @echo "=== Verifying tor configs ===" + @for cfg in $$(docker exec $(CONTAINER) sh -c 'ls /tmp/splitter/tor_*.cfg 2>/dev/null'); do \ + echo -n " $$cfg: "; \ + docker exec $(CONTAINER) tor -f "$$cfg" --verify-config 2>&1 | grep -o "Configuration was valid\|Unknown option.*"; \ + done + @echo "" + @echo "=== Verifying HAProxy config ===" + @docker exec $(CONTAINER) haproxy -c -f /tmp/splitter/splitter_master_proxy.cfg 2>&1 || true + @echo "" + @echo "=== Listening ports ===" + @docker exec $(CONTAINER) netstat -tlnp 2>/dev/null | grep "6353" || true + +# ── Smoke tests ───────────────────────────────────────────────────────────── + +.PHONY: smoke +smoke: ## Run full smoke test suite against running container + @./tests/smoke.sh $(CONTAINER) + +.PHONY: smoke-proxy +smoke-proxy: ## Quick HTTP proxy connectivity check + @echo "Checking HTTP proxy on :$(HTTP_PORT)..." + @RESULT=$$(curl -sf --max-time 15 -x http://localhost:$(HTTP_PORT) https://check.torproject.org/api/ip 2>/dev/null) || RESULT=""; \ + if echo "$$RESULT" | grep -q '"IsTor":true'; then \ + IP=$$(echo "$$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['IP'])" 2>/dev/null); \ + echo " Tor exit IP: $$IP"; \ + else \ + echo " FAIL: no response or not Tor (is the container running?)"; \ + exit 1; \ + fi + +.PHONY: smoke-rotation +smoke-rotation: ## Check IP rotation across multiple requests + @echo "Checking IP rotation (6 requests)..." + @IPS=""; \ + for i in 1 2 3 4 5 6; do \ + R=$$(curl -sf --max-time 15 -x http://localhost:$(HTTP_PORT) https://check.torproject.org/api/ip 2>/dev/null) || R=""; \ + IP=$$(echo "$$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('IP','TIMEOUT'))" 2>/dev/null) || IP="TIMEOUT"; \ + IPS="$$IPS $$IP"; \ + sleep 1; \ + done; \ + UNIQUE=$$(echo "$$IPS" | tr ' ' '\n' | grep -v '^$$' | sort -u | wc -l); \ + echo " Unique IPs: $$UNIQUE / 6 | IPs:$$IPS" + +.PHONY: smoke-status +smoke-status: ## Check status API + @curl -sf --max-time 5 http://localhost:$(STATUS_PORT)/status 2>/dev/null \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); print(f" Instances: {d[\"ready_count\"]}/{d[\"total_instances\"]} ready, {d[\"failed_count\"]} failed"); print(f" Tor: {d[\"tor_version\"]}"); print(f" Features: {\" \".join(k for k,v in d[\"features\"].items() if v)}")' \ + || echo " FAIL: status endpoint unreachable" + +# ── Convenience ───────────────────────────────────────────────────────────── + +.PHONY: status +status: smoke-status ## Alias for smoke-status + +.PHONY: logs +logs: docker-logs ## Alias for docker-logs + +.PHONY: run +run: build ## Build and run locally + ./$(BINARY) run --log + +# ── Help ──────────────────────────────────────────────────────────────────── + +.PHONY: help +help: ## Show this help + @echo "" + @echo "SPLITTER — available targets:" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "Docker shortcuts:" + @echo " make docker-restart rebuild + restart (after code changes)" + @echo " make smoke full test suite against container" + @echo " make smoke-proxy quick Tor connectivity check" + @echo "" + @echo "Ports:" + @echo " :$(SOCKS_PORT) SOCKS5 proxy" + @echo " :$(HTTP_PORT) HTTP proxy" + @echo " :$(STATS_PORT) HAProxy stats" + @echo " :$(STATUS_PORT) Status API" + @echo "" + +.DEFAULT_GOAL := help diff --git a/RALPH_STATUS.md b/RALPH_STATUS.md new file mode 100644 index 0000000..5a8b5ec --- /dev/null +++ b/RALPH_STATUS.md @@ -0,0 +1,95 @@ +# Ralph Loop Status + +## RALPH LOOP COMPLETE — ALL EXECUTABLE TASKS IMPLEMENTED + +**Last update**: v2.0.0-beta-01 (squashed, pushed, tagged, released). + +## ALL EXECUTABLE ROADMAP TASKS COMPLETE + +Phase 1-7.3 implementation is feature-complete. Only Phase 7.2.6 (Docker seccomp), Phase 7.4 (Arti), and Phase 8 (future expansion) remain. + +## Post-Rewrite Bugfixes + +| Fix | Commit | +|-----|--------| +| HAProxy stats port conflict (63537→63539) | 1a97efb | +| HAProxy 3.x `stats admin` syntax | 1a97efb | +| HAProxy health checks (httpchk→tcp-check) | 1a97efb | +| Invalid CGOEnabled torrc option | 1a97efb | +| Obsolete OptimisticData torrc option | 1a97efb | +| SOCKS5 through HAProxy (mode tcp) | 8ff24df | +| Cache TTL 24h→12h (country + reputation) | 578c313 | + +## Completed Phases + +### Phase 1: Project Infrastructure ✅ +### Phase 2: Go Project Structure ✅ +### Phase 3: Go Implementation ✅ +- 3.1 Foundation: Cobra CLI, config system, logging, process lifecycle +- 3.2 Core Services: Tor manager, HAProxy, proxy abstraction, country/circuit/port +- 3.3 Integration: Orchestrator, dependency detection, env overrides, status dashboard, SIGHUP reload + +### Phase 4: Tests ✅ +- Unit tests (15 packages, table-driven) +- Integration tests (`-tags=integration`) +- 3-layer privacy test suite (torrc assertions, config defaults, DNS leak, circuit rotation) +- Smoke test script (`tests/smoke.sh`) +- Makefile (`make check`, `make test-privacy`, `make smoke`) + +### Phase 5: Docker and CI/CD ✅ +- Multi-stage Dockerfile (alpine:3.21, Tor 0.4.9.6, HAProxy 3.0) +- docker-compose.yml (production) + docker-compose.dev.yml (development) +- GitHub Actions CI (lint + test + build + push) +- Release automation — Cross-compile 4 platforms, GitHub Release, GHCR push + +### Phase 6: Documentation ✅ +- README, AGENTS.md, Doc/MIGRATION.md +- ROADMAP.md with status markers +- SETTINGS_MAP.md (541+ lines settings.cfg → Go mapping) + +### Phase 7.1: Speed Improvements ✅ +### Phase 7.2: Security Improvements ✅ (5/6) +### Phase 7.3: New Features ✅ (9/9) +- Auto-update country lists (Tor Metrics API, 12h TTL cache) +- Stream Isolation, IPv6 dual-stack, Prometheus metrics +- Circuit fingerprinting resistance, exit node reputation, DNS leak test +- Configuration profiles (stealth, balanced, streaming, pentest) +- Bundled Tor Browser User-Agent list + +## Pending + +### Phase 7.2.6: Docker seccomp profile 🔄 +Create `splitter.json` seccomp profile restricting syscalls to minimum required by tor + haproxy. The `stealth` profile enables `Sandbox 1` in torrc; the Docker profile is the complement. + +### Phase 7.4: Arti (Rust Tor Client) ⏳ BLOCKED +Arti lacks GeoIP-based path selection — the core mechanism SPLITTER relies on. +Track: https://gitlab.torproject.org/tpo/core/arti — Re-evaluate quarterly. + +### Phase 8: Future Expansion ⏳ +- 8.1 Client mode (single-instance lightweight proxy) +- 8.2 TUI dashboard (bubbletea/tview) +- 8.3 REST API for remote management +- 8.4 Multi-node coordination (separate project) + +## Resolved Decisions + +| Question | Decision | +|----------|----------| +| License | BSD 3-Clause, inherited from original project | +| Architecture targets | `linux/amd64` + `linux/arm64` from day one | +| Prometheus metrics | Embedded in binary, enabled via `--metrics` (off by default) | +| Cache TTL | 12h for country lists and exit reputation | +| SOCKS5 through HAProxy | Fixed — explicit `mode tcp` in backend + frontend | +| Minimum Tor version | 0.4.8 (Conflux + HTTPTunnelPort); runtime uses 0.4.9.6 | + +## How to Release + +```bash +git tag v2.0.0-beta-02 +git push origin v2.0.0-beta-02 +``` + +This triggers `.github/workflows/release.yml` which: +1. Cross-compiles for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64 +2. Creates GitHub Release with binaries + checksums +3. Pushes multi-arch Docker image to GHCR diff --git a/README.md b/README.md index 8d2d8e3..635451b 100644 --- a/README.md +++ b/README.md @@ -1,437 +1,764 @@ -# DcLabs SPLITTER - -## === INTRODUCTION === - - To exploit the common weakness of TOR related de-anonymization techniques, difficulty traffic-analysis, -correlation and statistically related attacks on the TOR network. [1, 2, 3, 4, 5, 6, 10, 20, 23, 28] - - I developed a free open-source TOR network based shell script called SPLITTER. This script configures and applies a -systematic chain of free open-source solutions, working together to difficult the TOR related de-anonymization -techniques and ensure a better performance for TOR network. The result is a better TOR user experience and a more -secure TOR network related connection approach. -The SPLITTER is licensed under the BSD - License and was created with an initial academic propose.[41] -The user accepts the total responsibility for his acts while using this tool. - -For the best effectiveness of the theoretical approach behind the SPLITTER solution, a low-cost private VPS and -VPN networks chain should be considered. The idea behind this globally distributed network infrastructure is difficult -more specific traffic-analysis attacks and do not allow a direct association between the TOR network and the user. This -network approach will be called “SPLITTER NETWORK” and comprehends few VPS machines under the -control of the user running the SPLITTER script but using a public VPN service to connect in TOR network. - -The bundle of linux open-source tools which compose the SPLITTER tool are: -- **1) HAPROXY Community Edition:** “HAProxy is a free, very fast and reliable solution -offering high availability, load balancing, and proxying for TCP and HTTP-based applications. -It is particularly suited for very high traffic web sites and powers quite a number of the world's -most visited ones. Over the years it has become the de-facto standard opensource load -balancer, is now shipped with most mainstream Linux distributions, and is often deployed by -default in cloud platforms. Since it does not advertise itself, we only know it's used when the -admins report it.” [33] - -- **2) PRIVOXY:** “Privoxy is a non-caching web proxy with advanced filtering capabilities for -enhancing privacy, modifying web page data and HTTP headers, controlling access, and -removing ads and other obnoxious Internet junk. Privoxy has a flexible configuration and can -be customized to suit individual needs and tastes. It has application for both stand-alone -systems and multi-user networks.”[34] - -- **3) TOR (standalone):** The TOR network client.[35] - - -### == DEPENDENCIES:== - - 1. tor --> version 0.3.3.6 or earlier - https://www.torproject.org/ - - 2. privoxy --> version 3.0.26 or earlier - http://www.privoxy.org/ - - 3. haproxy --> version 1.7.5-2 or earlier - https://www.haproxy.org/ - - 4. proxychains --> version 3.1 or earlier - https://sourceforge.net/projects/proxychains/ - - 5. expect --> version 5.45 or earlier - https://sourceforge.net/projects/expect/ - - - -# === SPLITTER overview === -Each SPLITTER related tool is applied in a systematic sequence, driving the TCP packets from the -user browser or application, first to HAPROXY, second to PRIVOXY and the last step is the TOR -standalone which provide the connection with TOR network. After being routed through the current -active TOR circuit[8], the packet reaches the final destination. The answer for this TCP packet will -follow the reverse path. +# SPLITTER -![SPLITTER - TCP STREAM PATH](Doc/01_TCP_STREAM_PATH.png) - - -The SPLITTER will create and handle with many TOR network connections. -A single TOR standalone network connection is also called in this paper as “TOR INSTANCE” and comprehends a single and unique execution of TOR standalone running and administrating it’s own TOR network circuits.[8, 16, 21, 22, 24, 25, 26, 27] -The SPLITTER gives the user the opportunity to configure every single parameter related to the execution of HAPROXY, PRIVOXY, and TOR standalone. [27, 36, 37] -However, the most important aspect of this tool is the geolocation approach and how it selects the countries which will be enforced to compose the TOR circuit.[8, 16, 21, 22, 24, 26, 27, 29] -The user should define how many TOR instances per country and how many countries the SPLITTER can use. It’s possible for example to create a number “X” of instances using the same country, as ENTRY NODE or EXIT NODE. - - -## TOR instances load balance overview: - -![SPLITTER - LOAD BALANCE OVERVIEW](Doc/02_LOADBALANCE_OVERVIEW.png) - -Considering a single TOR instance, by default the SPLITTER will never use the same country as TOR ENTRY NODE and TOR EXIT NODE. This rule forces the same adversary compromise TOR nodes in different countries to be able to capture and correlate the user data transmitted using the currently active and selected TOR circuit. - -## Default “Anti-Correlation” rules: - -1. Always select a random country, from the list of countries that user accepts use as TOR ENTRY node or TOR EXIT node depending on which TOR node the user decide to enforce. It means that all random TOR circuits created by this manipulated TOR instance have a great chance to have a unique geolocation oriented combination of TOR ENTRY NODE and TOR EXIT NODE. This feature can by default difficult the correlation of many de-anonymization techniques based on: - - A) The absence of adversary’s compromised TOR nodes or compromised network related -equipment in both randomly selected countries.[1, 2, 3, 4, 5, 6, 10, 20, 23, 28] - - B) The deliberated disturbed created by SPLITTER in the natural global network path for packets in transit between the user machine and the destination server. [1, 2, 3, 4, 5, 6, 10, 20, 23, 28] - - -2. Considering the natural random country selection of TOR algorithm[8] which inside the SPLITTER manipulated context, will compose the beginning or the end of the TOR circuit, depending on which node/relay the user decide to enforce.[8] -The probability exists for future TOR circuits[8] created by this TOR instance, select once again the same previous combination of TOR ENTRY node and TOR EXIT used by this TOR instance in the past. -Aiming to reduce this risk, the SPLITTER also controls the life circle of the TOR instance, giving the user the control about how long time a TOR INSTANCE can remain alive enforced to use a specific country as ENTRY NODE or EXIT NODE. - -As result: - -A) This rule affects the random geolocation[29] oriented combination of TOR ENTRY -NODE and TOR EXIT NODE. - -B) This rule disturbs the lifetime of TCP streams interrupting the TCP streams associated with this TOR instance when longer than “X” minutes. The premature interruption of an established TCP stream can affect the ability of the adversary to transmitting the pattern depending on the de-anonymization technique. [1, 2, 3, 4, 5, 6, 10, 20, 23, 28] - - -### The life circle of a single TOR INSTANCE inside the SPLITTER context comprehends: - -1. After selecting a random new country, the SPLITTER will write the TOR configuration file based on the TOR options[27] defined by the user. By default, the first SPLITTER’s rule will be always respected. However, there are two exceptions to the first default rule: - - A) When the user decides to work with SPLITTER SPEED MODE described later in this paper. In this context, the First SPLITTER rule approach will be modified but still being observed. - - B) When the TOR option “StrictNodes” is disabled and the TOR algorithm is not able to find a route and generate a TOR circuit using the current random combination of the ENTRY NODE, MIDDLE NODE, and EXIT NODE.[27] Under this circumstance, TOR algorithm can select a TOR node from the TOR “ExcludeNodes”[27] to compose the circuit and provide a valid route to the destination. - - -2. The SPLITTER starts the new TOR INSTANCE. This instance will create the TOR circuits always observing the first SPLITTER rule, according to RELAY ENFORCE MODE selected and others specific TOR options.[27] - -3.The SPLITTER creates a random disturb in the interval of TOR circuit creation, aiming to avoid a natural time pattern in the systematic loop process of creation and utilization of TOR circuits. - -4.When the instance lifetime, reach the time limit specified by the user, the SPLITTER kills the running process related with this TOR instance, delete the temporary and all configuration files related with it and restart the life circle. - -![SPLITTER - TOR INSTANCE LIFE CIRCLE](Doc/03_INSTANCE_LIFECIRCLE.png) +Go-based tool that creates and manages multiple Tor network instances load-balanced via HAProxy, with geolocation-based anti-correlation rules for relay selection. Each Tor instance is configured to enforce specific countries for entry or exit nodes, making traffic analysis and de-anonymization attacks significantly harder. +Licensed under the BSD License. Created by Rener Alberto (aka Gr1nch) -- DcLabs Security Team. The user accepts total responsibility for their actions while using this tool. -The total amount of simultaneous active TOR instances is calculated using: +--- -**(_X_ * _Y_) = _Total amount of simultaneous active TOR instances_.** +## Architecture -Where **“_X_”** is the number of countries and **“_Y_”** the number of desired instances inside the same country. - - - -## How the SPLITTER "*control*" the TOR NODE/RELAY: +The TCP stream path depends on the proxy mode: -The options for the **TOR NODE/RELAY enforcing** are: +**Native mode (recommended, Tor 0.4.8+):** +``` +User -> HAProxy -> Tor (HTTPTunnelPort) -> Tor Network -> Destination +``` -- **ENTRY**: Sets a specific country as ENTRY NODE and will use a different country as EXIT relay. This mode provides the best security for the user and it’s considered the default enforcing mode inside the context of SPLITTER solution.[27] +**Legacy mode:** +``` +User -> HAProxy -> Privoxy -> Tor -> Tor Network -> Destination +``` -The load balancing algorithm for HAPROXY in this mode is Round Robin.[36] -Considering a specific country is enforced for TOR ENTRY node, the SPLITTER will select another random country from the list of countries defined by the user as TOR -EXIT node, but never the same country already defined to be used as TOR ENTRY node. +Native mode eliminates the Privoxy hop entirely, reducing latency and removing a dependency. HAProxy backends point directly at Tor's built-in HTTP CONNECT proxy listeners. -By enforcing this rule, the SPLITTER is _controlling_ the TOR algorithm and its free random selection of countries which will compose the TOR circuit. [8, 27] +![SPLITTER - TCP STREAM PATH](Doc/01_TCP_STREAM_PATH.png) +--- +## Quick Start -- **EXIT**: Sets a specific country as EXIT NODE and will use a different country as ENTRY relay. This option gives the user the control of the EXIT relays and could be used to bypass GeoIP protections.[29] -For example, this option is very suitable when you need to make sure that each request will hit the destination through a different country or the same country, depending on the number of countries each TOR instance can use. -The load balancing algorithm for HAPROXY in this mode is Round Robin.[36] +### Build -**A more specific SPLITTER EXIT mode user case:** +```bash +go build -o splitter . +``` -A) To always hit the target with the same country: The user needs to include only the desired country in the list of countries available and set the number of simultaneous countries as 1 (one). The number of instances “_Y_” inside this same country should be adjusted according to the user’s stability and speed needs. +### Run -B) To hit the target using random countries: After defining the list of countries SPLITTER can use, the user should define the number of simultaneous countries as “_X_” and the number of instances inside each country as “_Y_” according to his stability and speed needs. +```bash +# Default: 2 instances/country, 6 countries, entry enforcement +./splitter run +# Custom settings +./splitter run -i 3 -c 8 -r exit +# With a profile +./splitter run --profile stealth -- **SPEED**: This option will enforce the TOR INSTANCE use the same country as ENTRY NODE, MIDDLE NODE, and EXIT NODE. The idea is to ensure the best transmission performance of a TOR circuit, considering the restricted geolocation area that packets should travel to cross the entire TOR circuit.[8] +# With bridges (censored networks) +./splitter run --bridge-type snowflake +``` +### Docker +There are two compose files included: -# TOR Load balance with HAPROXY +- docker-compose.yml — pulls the published GHCR image (recommended for users). +- docker-compose.dev.yml — builds the image locally and mounts configs for development. -In general, the stability and performance of many circuits from TOR network are not enough for High Definition media consuming like videos in 720p~1080p for example. +Run the published image (pulls ghcr.io/millaguie/splitter:v2.0.0-RC1 as configured): -The TOR performance and stability related issues can compromise the user experience when trying to consume High Definition media over TOR. +```bash +docker compose up -d +``` -The paper "Improving Tor using a TCP-over-DTLS Tunnel" from Joel Reardon and Ian Goldberg, provide a deep analysis of the TOR network performance and stability related issues. [42] +Run the development compose (builds from source and mounts configs): -The SPLITTER is using HAPROXY to perform a health-check of the established TOR circuit before sending the user data. The user can specify a specific website and the interval of analyses. The HAPROXY will monitor the availability of the TOR circuit based on the HTTP answer. If the circuit does not answer or if the TOR EXIT node from the current circuit is not able to resolve the requested address the TOR instance is considered down. +```bash +docker compose -f docker-compose.dev.yml up --build +``` -The requests are forwarded to another TOR instance until a fast, stable and reliable circuit be created in the previous instance considered down. The user can specify how many errors are necessary to consider one TOR instance as down and how many successful requests are necessary to consider it up again. +Ports: `63536` (SOCKS), `63537` (HTTP), `63539` (stats), `63540` (status/healthz). -To difficult the correlation, the SPLITTER is using a random order of TOR instances inside HAPROXY configuration file. The idea is to avoid that consecutive requests being sent through the same country when the users decide to use more than one tor instance per country. The interval between the checks can be random or fixed, the user will adjust it according to the speed which new TOR circuits are being and destroyed. By default, 12s is used as the fixed interval between the health-checks. However, we should consider that the health-check by its self can generate a pattern and the adversary can use this sequence of checks to track the user. In another hand, the health-check provide a better speed and stability allowing the user consume High Definition movies even using the TOR network. +--- -![SPLITTER - HAPROXY HEALTH CHECK](Doc/04_HAPROXY_HEALTH_CHECK.png) +## Using the Proxy +### Ports & Endpoints +| Port | Protocol | Description | +|------|----------|-------------| +| **63536** | SOCKS5 | SOCKS5 proxy — point any SOCKS5-capable application here | +| **63537** | HTTP CONNECT | HTTP proxy — point your browser here | +| **63539** | HTTP | HAProxy stats dashboard (password shown in startup banner) | +| **63540** | HTTP | Status API (`/status`) and health check (`/healthz`) | +| **63541+** | TCP | Per-instance SOCKS ports (4999, 5000, ...) — internal use only | -# SPLITTER NETWORK +### Browser Configuration (HTTP proxy) -To difficult the natural correlation between the TOR network and the user, we mentioned the need to connect in a public VPN service before connecting in TOR network. This simple approach prevents that the internet provider is able to see that user is connected on TOR network. +This is the simplest option. All browser traffic goes through SPLITTER. -This is considered the best approach for privacy because the natural correlation between the TOR network and the user has been broke.[38, 39, 40] +**Firefox / Chrome / Edge:** -However, more sophisticated traffic analyses techniques can observe the natural traffic patterns and follow the path from the VPN provider until the user.[1] +1. Open Settings → Network / Proxy +2. Select "Manual proxy configuration" +3. Set: + - **HTTP proxy**: `localhost` port `63537` + - **SSL proxy**: `localhost` port `63537` + - (also check "Use same proxy for all protocols") -To difficult even more this possibility of correlation, a low-cost VPS and VPN network should be considered.[38, 39, 40] +**Verify it works:** +```bash +# Should show a Tor exit IP, not your real IP +curl -x http://localhost:63537 https://check.torproject.org/api/ip +``` -![SPLITTER NETWORK - TCP STREAM PATH](Doc/05_SPLITTER_NETWORK_TCP_STREAM_PATH.png) +**Note:** The HTTP proxy works with HTTPS sites because Tor's HTTPTunnelPort uses the CONNECT method (tunneling, not MITM). Your TLS connection remains end-to-end to the destination server. +### Browser Configuration (SOCKS5 proxy) -**1) The VPS will act as VPN SERVER and VPN CLIENT at the same time:** +More control — applications that support SOCKS5 can use this port directly. -- The user will connect to the VPS using a VPN service running in the VPS. This VPN assume the default gateway for the user machine and all data from the user machine will be forwarded to VPS. The user should point his browser to the exposed HAPROXY port. More details in following number 3. +**Firefox:** -- The VPS is also connected in a PUBLIC VPN service and all output traffic from the VPS is send using the public VPN connection. This way, when the VPS execute the SPLITTER script the TOR network connection will be established using the public VPN. [38, 39, 40] +1. Open Settings → General → Network Settings +2. Click "Settings…" next to "Use a proxy" +3. Select "Manual proxy configuration" +4. Set: + - **SOCKS Host**: `localhost` + - **SOCKS Port**: `63536` + - **SOCKS v5**: checked +5. Select "SOCKS v5" for DNS resolution through Tor +**curl:** +```bash +curl -x socks5h://localhost:63536 https://check.torproject.org/api/ip +``` +**Python (requests):** +```python +import requests -**2) The VPS have a firewall to avoid leaks:** -The inbound traffic and the outbound traffic is controlled to avoid leaks and enforce the following: -- The only inbound traffic allowed is to VPN SERVER service running in the VPS. All others input traffic are blocked including inputs from docker network. +proxies = { + "http": "socks5h://localhost:63536", + "https": "socks5h://localhost:63536", +} +r = requests.get("https://check.torproject.org/api/ip", proxies=proxies) +print(r.json()) +``` -- The outbound traffic allowed is to resolve the DNS address from the PUBLIC VPN server, to connect to this public VPN service and to allow the VPS to connect into HAPROXY port exposed by the docker container. All others output traffic is blocked including traffic from the user connected in the VPN SERVER to the internet. The unique output route for the user connected to the VPN SERVER is using the TOR connection running inside the docker container. - - -**3) The VPS will execute a docker container with the SPLITTER solution:** +**Docker usage note:** When running in Docker, replace `localhost` with your host's IP or `host.docker.internal` (macOS/Windows). -The SPLITTER solution will be executed inside a Docker container, and the connection with the public VPN service will be created in the VPS operating system and transferred to the docker container. It is necessary because the public VPN connection should assume the default gateway of the Docker container, but can not assume the default gateway from the VPS operating system. +### Other Tools -With this docker container approach, we will ensure that the TOR network connection will be executed inside the container and use the public VPN service. The Docker container will expose only the HAPROXY port to the VPS and the user should connect on the VPS VPN SERVICE and point his browser to the HAPROXY port exposed to the VPS.[44, 45] +**System-wide proxy (Linux):** +```bash +export http_proxy=http://localhost:63537 +export https_proxy=http://localhost:63537 +export HTTP_PROXY=http://localhost:63537 +export HTTPS_PROXY=http://localhost:63537 -The container will provide an extra security layer. If the adversary is able to exploit any vulnerability and assume the control of the container, he is trapped inside the container context and the only output available is using the public VPN service. Following this scenario, the attacker will face more difficult to interact with the operating system of the VPS and interact with the user connected in the VPS due to the segregation of environments provided by Docker.[44, 45] +# Test +curl https://check.torproject.org/api/ip +``` +**wget:** +```bash +wget -e use_proxy=yes -e http_proxy=http://localhost:63537 https://example.com +``` -##The SPLITTER network in a global scale: +**Git:** +```bash +git config --global http.proxy http://localhost:63537 +git config --global https.proxy http://localhost:63537 +``` -This low-cost network can be distributed around the globe using different VPS providers and different VPN providers. Each VPS will execute a docker container with the SPLITTER SOLUTION running inside, connected to a different VPN provider before connect to the TOR network, according demonstrated in the images 9, 10, 11 and 15. +### HAProxy Stats Dashboard -Each VPS should have at least 1GB of memory RAM and will cost the average of $200,00 (two hundred dollars) per year to remain online. Usually, the public VPN plans will allow the client to have at least 3 simultaneous connections and the price for 1 year is $100,00 (one hundred dollars). +The stats page shows real-time backend health, request counts, and error rates. -This scenario allows the user to create his own private combination of VPS, VPN, and TOR. The user can use the HAPROXY once again to perform the load balancing between all VPS running the SPLITTER solution. The result will be an even better global spread of the traffic, hopefully difficulting the correlation between the TOR network and the final user. +```bash +# Get password from startup logs +docker logs splitter-dev 2>&1 | grep "HAProxy stats:" +# Example output: HAProxy stats: 0.0.0.0:63539/splitter_status (password: xYzAbC123) + +# Open in browser +# http://localhost:63539/splitter_status +``` + +### Status & Health Check + +```bash +# JSON status with instance count, Tor version, features +curl http://localhost:63540/status | python3 -m json.tool + +# Health check (returns 200 when all instances are ready) +curl -sf http://localhost:63540/healthz +``` + +### Verifying It Works + +```bash +# 1. Check that traffic goes through Tor +curl -x http://localhost:63537 https://check.torproject.org/api/ip +# Expected: {"IsTor":true,"IP":"45.x.x.x"} + +# 2. Verify IP rotation (multiple requests should return different IPs) +for i in $(seq 1 6); do + curl -s -x http://localhost:63537 https://check.torproject.org/api/ip + sleep 1 +done + +# 3. Check your real IP (for comparison — should NOT match Tor exit IPs) +curl -s https://check.torproject.org/api/ip + +# 4. DNS leak test +./splitter test dns + +# 5. Exit node reputation check +./splitter test exit-reputation +``` + +--- + +## Environment Variables + +All configuration values can be overridden via environment variables with the `SPLITTER_` prefix. Variables override YAML config file defaults but are overridden by CLI flags. + +### Core + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPLITTER_INSTANCES` | `2` | Tor instances per country | +| `SPLITTER_COUNTRIES` | `6` | Number of countries to select | +| `SPLITTER_RELAY_ENFORCE` | `entry` | Relay mode: `entry`, `exit`, `speed` | +| `SPLITTER_PROXY_MODE` | `native` | Proxy mode: `native` or `legacy` | +| `SPLITTER_PROFILE` | `""` | Configuration profile: `stealth`, `balanced`, `streaming`, `pentest` | + +### Features + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPLITTER_LOG` | `0` | Enable logging (1 = on) | +| `SPLITTER_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | +| `SPLITTER_AUTO_COUNTRIES` | `0` | Auto-fetch country list from Tor Metrics API | +| `SPLITTER_STREAM_ISOLATION` | `0` | Enable stream isolation via SOCKS5 auth | +| `SPLITTER_IPV6` | `0` | Enable IPv6 dual-stack relay selection | +| `SPLITTER_EXIT_REPUTATION` | `0` | Check exit node reputation via Onionoo API | +| `SPLITTER_METRICS` | `0` | Enable Prometheus metrics endpoint | +| `SPLITTER_BRIDGE_TYPE` | `none` | Bridge type: `snowflake`, `webtunnel`, `obfs4`, `none` | +| `SPLITTER_COUNTRY_INTERVAL` | `120` | Country rotation interval in seconds | + +### Ports + +| Variable | Default | Description | +|----------|---------|-------------| +| `SPLITTER_SOCKS_PORT` | `63536` | SOCKS5 proxy listen port | +| `SPLITTER_HTTP_PORT` | `63537` | HTTP proxy listen port | +| `SPLITTER_STATS_PORT` | `63539` | HAProxy stats page port | +| `SPLITTER_STATUS_PORT` | `63540` | Status/healthz HTTP port | + +### Docker-Specific + +```bash +docker run -d --name splitter \ + -p 63536:63536 \ + -p 63537:63537 \ + -p 63539:63539 \ + -p 63540:63540 \ + -e SPLITTER_INSTANCES=2 \ + -e SPLITTER_COUNTRIES=6 \ + -e SPLITTER_RELAY_ENFORCE=exit \ + -e SPLITTER_LOG=1 \ + splitter +``` + +> **Security note:** Do NOT expose ports 63536-63537 publicly. These are unauthenticated proxies. Use Docker's port mapping to bind to `127.0.0.1` only, or restrict access with a firewall. Exposing them on `0.0.0.0` means anyone who can reach your server can use your Tor exit as an open proxy. + +To bind to localhost only: + +```bash +docker run -d --name splitter \ + -p 127.0.0.1:63536:63536 \ + -p 127.0.0.1:63537:63537 \ + -p 127.0.0.1:63539:63539 \ + -p 127.0.0.1:63540:63540 \ + -e SPLITTER_INSTANCES=2 \ + -e SPLITTER_COUNTRIES=6 \ + -e SPLITTER_RELAY_ENFORCE=exit \ + splitter +``` + +Or with docker-compose.yml: + +```yaml +services: + splitter: + image: ghcr.io/millaguie/splitter:v2.0.0-beta-02 + ports: + - "127.0.0.1:63536:63536" + - "127.0.0.1:63537:63537" + - "127.0.0.1:63539:63539" + - "127.0.0.1:63540:63540" +``` + +### Custom Configuration + +Mount your own config file to override defaults: + +```bash +docker run -d --name splitter \ + -v ./my-config.yaml:/splitter/configs/default.yaml:ro \ + -p 63536:63536 -p 63537:63537 -p 63539:63539 -p 63540:63540 \ + splitter +``` + +--- + +## CLI Reference + +### Commands + +| Command | Description | +|---------|-------------| +| `splitter run` | Start SPLITTER with Tor instances and HAProxy | +| `splitter status` | Show live dashboard of instances, countries, circuits | +| `splitter test dns` | Run DNS leak test through Tor | +| `splitter test exit-reputation` | Check exit node reputation | +| `splitter version` | Show version and detected Tor features | + +### Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--instances` | `-i` | 2 | Tor instances per country | +| `--countries` | `-c` | 6 | Number of countries to select | +| `--relay-enforce` | `-r` | entry | Relay mode: entry, exit, speed | +| `-re` | -- | -- | Legacy alias for `--relay-enforce` | +| `--profile` | -- | "" | Configuration profile: stealth, balanced, streaming, pentest | +| `--proxy-mode` | -- | native | Proxy mode: native (HTTPTunnelPort), legacy (Privoxy) | +| `--bridge-type` | -- | none | Bridge type: snowflake, webtunnel, obfs4, none | +| `--verbose` | -- | false | Enable verbose output | +| `--log` | -- | false | Enable logging (off by default: no logs, no crime) | +| `--log-level` | -- | info | Log level: debug, info, warn, error | +| `--auto-countries` | -- | false | Auto-fetch country list from Tor Metrics API | +| `--stream-isolation` | -- | false | Enable stream isolation via SOCKS5 auth | +| `--ipv6` | -- | false | Enable IPv6 dual-stack relay selection | +| `--exit-reputation` | -- | false | Check exit node reputation via Onionoo API | + +--- + +## Configuration Profiles + +Predefined profiles for common use cases. Set with `--profile `. + +### stealth + +Maximum security. Aggressive rotation, many instances, all hardening enabled. + +| Parameter | Value | +|-----------|-------| +| Instances/country | 3 | +| Countries | 8 | +| Relay enforce | entry | +| Circuit rotation | 10s | +| Load balance | roundrobin | +| Conflux | enabled | +| Congestion control | enabled | +| Connection padding | enabled | +| Sandbox | enabled | +| Circuit fingerprinting resistance | enabled | +| Logging | off | + +### balanced + +Good tradeoff between security and performance. Default-like behavior. + +| Parameter | Value | +|-----------|-------| +| Instances/country | 2 | +| Countries | 6 | +| Relay enforce | exit | +| Circuit rotation | 15s | +| Load balance | roundrobin | +| Congestion control | enabled | +| Logging | off | + +### streaming + +Optimized for throughput and media consumption. + +| Parameter | Value | +|-----------|-------| +| Instances/country | 1 | +| Countries | 4 | +| Relay enforce | speed | +| Circuit rotation | 300s | +| Load balance | leastconn | +| Conflux | enabled | +| Congestion control | enabled | +| IPv6 | enabled | +| Logging | off | + +### pentest + +Extreme rotation and randomization for penetration testing scenarios. + +| Parameter | Value | +|-----------|-------| +| Instances/country | 5 | +| Countries | 10 | +| Relay enforce | exit | +| Circuit rotation | 10s | +| Load balance | roundrobin | +| Stream isolation | enabled | +| Circuit fingerprinting resistance | enabled | +| Exit reputation | enabled | +| Logging | DEBUG | + +--- + +## Docker Usage + +### docker-compose.yml + +```yaml +version: "3.8" + +services: + splitter: + image: ghcr.io/millaguie/splitter:v2.0.0-beta-02 + ports: + - "63536:63536" # SOCKS5 proxy + - "63537:63537" # HTTP proxy + - "63539:63539" # HAProxy stats + - "63540:63540" # Status / healthz + environment: + - SPLITTER_INSTANCES=2 + - SPLITTER_COUNTRIES=6 + - SPLITTER_RELAY_ENFORCE=exit + - SPLITTER_LOG=1 + volumes: + - ./configs:/splitter/configs:ro + - splitter-data:/splitter/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:63540/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s -![SPLITTER NETWORK - OVERVIEW](Doc/06_SPLITTER_NETWORK_OVERVIEW.png) +volumes: + splitter-data: +``` +The Docker image uses a multi-stage build: Go compilation in `golang:1.23-alpine`, runtime in `alpine:3.21` with Tor, HAProxy, and Privoxy installed. Runs as a non-root user. + +--- + +## Modern Tor Features + +SPLITTER auto-detects Tor version at startup and enables features conditionally: -# REFERENCES +- **Conflux** (Tor 0.4.8+) -- Multi-leg circuits that split traffic across multiple paths simultaneously, multiplying throughput and resilience +- **HTTPTunnelPort** (Tor 0.4.8+) -- Native HTTP CONNECT proxy, eliminating the need for Privoxy +- **Congestion Control** (Tor 0.4.7+) -- Dramatically improves throughput on long-distance circuits +- **Post-Quantum Key Exchange** (Tor 0.4.8.17+ with OpenSSL 3.5.0+) -- ML-KEM768 protection against harvest-now-decrypt-later attacks +- **CGO Encryption** (Tor 0.4.9+) -- Counter Galois Onion relay cryptography with improved resistance to tagging attacks +- **Bridge Support** -- Snowflake, WebTunnel, and obfs4 pluggable transports for censored networks (`--bridge-type`) +- **Sandboxing** -- seccomp-bpf sandbox via `Sandbox 1` in generated torrc +- **Circuit Fingerprinting Resistance** -- Adaptive circuit rotation based on traffic patterns, defeating timing correlation attacks +- **Exit Node Reputation** -- Checks exit relay flags, uptime, and bandwidth via Onionoo API before use +- **DNS Leak Testing** -- Verifies all DNS queries go exclusively through Tor (`splitter test dns`) +- **Prometheus Metrics** -- `/metrics` and `/healthz` HTTP endpoints for monitoring and alerting +- **Stream Isolation** -- Per-destination circuit separation via SOCKS5 auth (`IsolateSOCKSAuth`) +- **IPv6 Dual-Stack** -- `ClientUseIPv6 1` for broader relay selection -- [1] Sambuddho Chakravarty, Marco V. Barbera, Georgios Portokalidis, Michalis Polychronakis and Angelos D. -Keromytis - “**On the Effectiveness of Traffic Analysis Against Anonymity Networks Using Flow Records**”. +--- -[Online] Available: https://mice.cs.columbia.edu/getTechreport.php?techreportID=1545&format=pdf +## Building from Source +```bash +# Standard build +go build -o splitter . -- [2] Nathan S. Evans, Roger Dingledine and Christian Grothoff - “**A Practical Congestion Attack on Tor Using Long Paths**”. +# Optimized binary (smaller, no debug info) +go build -ldflags="-s -w" -o splitter . -[Online] Available: https://www.usenix.org/legacy/event/sec09/tech/full_papers/evans.pdf +# Cross-compile +GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o splitter . +``` +### Dependencies -- [3] Matthew Wright, Micah Adler, Brian N. Levine and Clay Shields - “**An analysis of the degradation of anonymous protocols**” +Runtime dependencies must be in `$PATH`: +- `tor` -- Tor standalone client +- `haproxy` -- HAProxy load balancer +- `privoxy` -- Only required in legacy proxy mode -[Online]. Available: http://people.cs.georgetown.edu/~clay/research/pubs/wright.ndss01.pdf +Notes about GHCR image and authentication: +- The default docker-compose.yml references the published GHCR image ghcr.io/millaguie/splitter:v2.0.0-RC1. If that package is public you can pull it without authentication. If GHCR authentication is required for this repository, login with a Personal Access Token (PAT) that has `read:packages`: -- [4] Nicholas Hopper, Eeugene Y. Vasserman, and Eric Chan-Tin - “**How Much Anonymity does Network Latency Leak?**”. +```bash +echo "${GHCR_PAT}" | docker login ghcr.io -u --password-stdin +``` -[Online] Available: https://www-users.cs.umn.edu/~hoppernj/tissec-latency-leak.pdf +The release workflow sets the binary version at build-time via ldflags (cmd.Version). The Release workflow is triggered by pushing tags like `v2.0.0-RC1` and will build binaries and push container images to GHCR. +SPLITTER detects available binaries and Tor version at startup, failing fast with a clear message if anything is missing. -- [5] Sebastian Zander and Steven J. Murdoch - “**An Improved Clock-skew Measurement Technique for Revealing Hidden Services**”. +--- -[Online] Available: https://www.usenix.org/legacy/event/sec08/tech/full_papers/zander/zander.pdf +## Migration from Bash Version +The original Bash version is preserved in `legacy/` for reference. -- [6] Kevin Bauer, Damon McCoy, Dirk Grunwald, Tadayoshi Kohno and Douglas Sicker - “**Low-Resource Routing Attacks Against Anonymous Systems**“ +| Aspect | Bash | Go | +|--------|------|----| +| Configuration | `settings.cfg` | `configs/default.yaml` | +| CLI flags | `-i`, `-c`, `-re` | Same short flags + long flags (`--instances`, `--countries`, `--relay-enforce`) | +| Circuit renewal | `expect` scripts | Native Go Tor control protocol (cookie auth) | +| Port allocation | `netstat` parsing | `net.Listen` test | +| Process management | Shell background jobs | Go process groups with graceful shutdown | +| Logging | Always on | Off by default (`--log` to enable) | +| Profiles | Manual config editing | `--profile stealth\|balanced\|streaming\|pentest` | +| Proxy mode | Privoxy always | Native HTTPTunnelPort or legacy Privoxy | -[Online] Available: http://www.cs.colorado.edu/department/publications/reports/docs/CU-CS-1025-07.pdf +Key changes: +- No more `expect` dependency -- circuit renewal connects to the Tor control port directly in Go +- No more `netstat` / `ss` -- port allocation uses the Go standard library +- No more `proxychains` dependency +- The `-re` flag is preserved as a legacy alias for `--relay-enforce` +--- -- [7] TOR project official web site. [Online] Available: https://www.torproject.org/ +## Anti-Correlation Theory +### The Problem -- [8] TOR project overview. [Online] Available: https://www.torproject.org/about/overview.html.en +Tor-related de-anonymization techniques rely on traffic analysis, correlation, and statistical attacks [1, 2, 3, 4, 5, 6, 10, 20, 23, 28]. These techniques exploit the ability to observe traffic patterns at both ends of a Tor circuit -- the user's entry point and the exit point -- and correlate them. +### SPLITTER's Approach -- [9] “Statistical Analysis Handbook”. [Online] Available: http://www.statsref.com/StatsRefSample.pdf +SPLITTER defeats these attacks through three mechanisms: +**1. Geolocation-based relay enforcement.** Each Tor instance is configured to use a specific country for either the entry node or exit node (never the same country for both). This forces an adversary to compromise nodes in multiple jurisdictions simultaneously to capture both ends of a circuit [8, 16, 21, 22, 24, 26, 27, 29]. -- [10] Steven J. Murdoch and George Danezis - "**Low-Cost Traffic Analysis of Tor**". +**2. Instance lifecycle management.** Tor instances are periodically killed and restarted with fresh configurations pointing to different countries. This interrupts long-lived TCP streams and prevents an adversary from accumulating enough traffic data over time to perform meaningful correlation [1, 2, 3, 4, 5, 6, 10, 20, 23, 28]. -[Online] Available: https://murdoch.is/papers/oakland05torta.pdf +**3. Randomized circuit rotation.** SPLITTER introduces random intervals in circuit creation to avoid predictable timing patterns that could be exploited by timing correlation attacks. +### Relay Enforcement Modes -- [11] FBI Official web site - “Dozens of Online ‘Dark Markets’ Seized Pursuant to Forfeiture Complaint Filed in -Manhattan Federal Court in Conjunction with the Arrest of the Operator of Silk Road 2.0”. +| Mode | Behavior | Use Case | +|------|----------|----------| +| **entry** | Enforces a specific country as entry node; exit is random from a different country | Maximum security (default) | +| **exit** | Enforces a specific country as exit node; entry is random from a different country | GeoIP bypass, geographic control of exit | +| **speed** | Enforces the same country for entry, middle, and exit nodes | Maximum throughput, restricted circuit geography | -[Online] Available: https://www.fbi.gov/contact-us/field-offices/newyork/news/press-releases/dozens-of-online-dark-markets-seized-pursuant-to-forfeiture-complaint-filed-in-manhattan-federal-court-in-conjunction-with-the-arrest-of-the-operator-of-silk-road-2.0 +#### Entry Mode +The load balancing algorithm is round-robin. For a given country enforced as the entry node, SPLITTER selects a different random country for the exit node, ensuring the two never overlap. This controls Tor's normally free random selection of relay countries [8, 27]. -- [12] FBI Official web site - “Operator of Silk Road 2.0 Website Charged in Manhattan Federal Court” +#### Exit Mode -[Online]. Available: https://www.fbi.gov/contact-us/field-offices/newyork/news/press-releases/operator-of-silk-road-2.0-website-charged-in-manhattan-federal-court +Gives the user control over which country the destination server sees as the traffic origin. Suitable for bypassing GeoIP restrictions [29]. Specific use cases: +- **Fixed country**: Set countries to 1 and include only the desired country. Adjust instances per country for stability. +- **Random countries**: Set the desired number of countries. Each instance rotates through a random selection. -- [13] FBI Special Agent: Thomas M. Dalton report about the hoax bomb in Harvard University resulting in the prison of Eldo Kim. +#### Speed Mode -[Online] Available: https://cbsboston.files.wordpress.com/2013/12/kimeldoharvard.pdf +All three relays (entry, middle, exit) are constrained to the same country. This minimizes the geographic distance packets must traverse, maximizing throughput. The first anti-correlation rule is relaxed but still observed [8]. +### Instance Lifecycle -- [13.1] FBI Official web site - “Harvard Student Charged with Bomb Hoax”. +The total number of simultaneous active Tor instances is: -[Online] Available: https://archives.fbi.gov/archives/boston/press-releases/2013/harvard-student-charged-with-bomb- -hoax +**(_X_ instances per country) x (_Y_ countries) = _total instances_** +Each instance follows this lifecycle: -- [14] FBI Official web site - “Six Hackers in the United States and Abroad Charged for Crimes Affecting Over One Million Victims”. +1. SPLITTER selects a random country and writes a torrc configuration file enforcing the selected relay mode +2. The Tor process starts and creates circuits following SPLITTER's rules +3. Random jitter is applied to circuit creation intervals to avoid timing patterns +4. When the instance lifetime expires, SPLITTER kills the process, deletes temporary files, and restarts with a new country -[Online] Available: https://archives.fbi.gov/archives/newyork/press-releases/2012/six-hackers-in-the-united-states-and-abroad-charged-for-crimes-affecting-over-one-million-victims +![SPLITTER - TOR INSTANCE LIFE CIRCLE](Doc/03_INSTANCE_LIFECIRCLE.png) +### HAProxy Health Checking -- [15] Adrian Crenshaw - “**Dropping Docs on Darknets: How People Got Caught - Defcon 22**” +SPLITTER uses HAProxy to perform health checks on each Tor instance before routing traffic through it. A specific website is checked at configurable intervals. If a circuit fails to respond or the exit node cannot resolve the requested address, the instance is marked down and traffic is routed to another instance. -[Online] Available: https://www.youtube.com/watch?v=7G1LjQSYM5Q +The order of instances in the HAProxy configuration is randomized to prevent consecutive requests from going through the same country when multiple instances share a country. +![SPLITTER - HAPROXY HEALTH CHECK](Doc/04_HAPROXY_HEALTH_CHECK.png) -- [16] TOR Official Project web site - Metrics about TOR network. +![SPLITTER - LOAD BALANCE OVERVIEW](Doc/02_LOADBALANCE_OVERVIEW.png) -[Online] Available: https://metrics.torproject.org/networksize.html +--- +## SPLITTER NETWORK -- [17] TOR Official Project web site - “Tor: Onion Service Protocol”. +For maximum effectiveness of the anti-correlation approach, a low-cost private VPS and VPN chain should be considered. This globally distributed network infrastructure makes traffic analysis harder and prevents direct association between the Tor network and the user. -[Online] Available: https://www.torproject.org/docs/onion-services.html.en +### Architecture +The user connects to a VPS via VPN. The VPS runs SPLITTER inside a Docker container and routes all outbound traffic through a public VPN service before entering the Tor network. -- [18] Steven J. Murdoch - “**Hot or Not: Revealing Hidden Services by their Clock Skew**”. +![SPLITTER NETWORK - TCP STREAM PATH](Doc/05_SPLITTER_NETWORK_TCP_STREAM_PATH.png) -[Online] Available: https://murdoch.is/papers/ccs06hotornot.pdf +### Components +**1. The VPS acts as both VPN server and VPN client:** -- [19] TOR Official Project web site - “Who Uses Tor?”. +- The user connects to the VPS through a VPN service. All user traffic is forwarded to the VPS. The user points their browser to the HAProxy port exposed by the Docker container. +- The VPS is also connected to a public VPN service. All outbound traffic from the VPS uses this connection, so Tor connections originate from the public VPN's IP address [38, 39, 40]. -[Online] Available: https://www.torproject.org/about/torusers.html.en +**2. VPS firewall prevents leaks:** -- [20] Rob Jansen, Marc Juarez, Rafa Gálvez, Tariq Elahi and Claudia Diaz - "**Inside Job: Applying Traffic Analysis to Measure Tor from Within**". +- Inbound: Only VPN server traffic is allowed. All other inbound traffic is blocked. +- Outbound: Only DNS resolution for the public VPN, connections to the public VPN service, and HAProxy port traffic are allowed. The user's only outbound route is through Tor inside the Docker container. -[Online] Available: https://www.robgjansen.com/publications/insidejob-ndss2018.pdf +**3. Docker container isolation:** +SPLITTER runs inside a Docker container. The public VPN connection is established at the VPS level and transferred to the container so it becomes the default gateway. The container exposes only the HAProxy port. -- [21] TOR project official web site - FAQ: "What are Entry Guards?". +If an adversary compromises the container, they are trapped inside it with no route to the VPS operating system or the connected user [44, 45]. -[Online] Available: https://www.torproject.org/docs/faq#EntryGuards +### Global Scale +Multiple VPS instances can be distributed globally using different providers and VPN services. Each runs SPLITTER in a container connected to a different VPN provider. An additional HAProxy layer can load-balance across all VPS nodes, further distributing traffic and increasing correlation difficulty. -- [22] TOR project offical blog - "Improving Tor's anonymity by changing guard parameters". +![SPLITTER NETWORK - OVERVIEW](Doc/06_SPLITTER_NETWORK_OVERVIEW.png) -[Online] Available: https://blog.torproject.org/improving-tors-anonymity-changing-guard-parameters +--- +## References -- [23] **Free Haven – Online Anonymity Papers Library**. +- [1] Sambuddho Chakravarty, Marco V. Barbera, Georgios Portokalidis, Michalis Polychronakis and Angelos D. Keromytis -- "On the Effectiveness of Traffic Analysis Against Anonymity Networks Using Flow Records". + https://mice.cs.columbia.edu/getTechreport.php?techreportID=1545&format=pdf -[Online] Available: https://www.freehaven.net/anonbib/ +- [2] Nathan S. Evans, Roger Dingledine and Christian Grothoff -- "A Practical Congestion Attack on Tor Using Long Paths". + https://www.usenix.org/legacy/event/sec09/tech/full_papers/evans.pdf +- [3] Matthew Wright, Micah Adler, Brian N. Levine and Clay Shields -- "An analysis of the degradation of anonymous protocols". + http://people.cs.georgetown.edu/~clay/research/pubs/wright.ndss01.pdf -- [24] TOR project offical blog - "Research problem: better guard rotation parameters" -[Online] Available: https://blog.torproject.org/research-problem-better-guard-rotation-parameters +- [4] Nicholas Hopper, Eeugene Y. Vasserman, and Eric Chan-Tin -- "How Much Anonymity does Network Latency Leak?". + https://www-users.cs.umn.edu/~hoppernj/tissec-latency-leak.pdf +- [5] Sebastian Zander and Steven J. Murdoch -- "An Improved Clock-skew Measurement Technique for Revealing Hidden Services". + https://www.usenix.org/legacy/event/sec08/tech/full_papers/zander/zander.pdf -- [25] Nick Mathewson - "Cryptographic Challenges in and around Tor". +- [6] Kevin Bauer, Damon McCoy, Dirk Grunwald, Tadayoshi Kohno and Douglas Sicker -- "Low-Resource Routing Attacks Against Anonymous Systems". + http://www.cs.colorado.edu/department/publications/reports/docs/CU-CS-1025-07.pdf -[Online] Available: https://crypto.stanford.edu/RealWorldCrypto/slides/tor.pdf +- [7] TOR project official web site. https://www.torproject.org/ +- [8] TOR project overview. https://www.torproject.org/about/overview.html.en -- [26] TOR project official web site – FAQ: “How often does Tor change its paths?” +- [9] "Statistical Analysis Handbook". http://www.statsref.com/StatsRefSample.pdf -[Online] Available: https://www.torproject.org/docs/faq#ChangePaths +- [10] Steven J. Murdoch and George Danezis -- "Low-Cost Traffic Analysis of Tor". + https://murdoch.is/papers/oakland05torta.pdf +- [11] FBI Official web site -- "Dozens of Online 'Dark Markets' Seized Pursuant to Forfeiture Complaint Filed in Manhattan Federal Court in Conjunction with the Arrest of the Operator of Silk Road 2.0". + https://www.fbi.gov/contact-us/field-offices/newyork/news/press-releases/dozens-of-online-dark-markets-seized-pursuant-to-forfeiture-complaint-filed-in-manhattan-federal-court-in-conjunction-with-the-arrest-of-the-operator-of-silk-road-2.0 -- [27] TOR project official web site – TOR MANUAL +- [12] FBI Official web site -- "Operator of Silk Road 2.0 Website Charged in Manhattan Federal Court". + https://www.fbi.gov/contact-us/field-offices/newyork/news/press-releases/operator-of-silk-road-2.0-website-charged-in-manhattan-federal-court -[Online] Available: https://www.torproject.org/docs/tor-manual.html.en +- [13] FBI Special Agent: Thomas M. Dalton report about the hoax bomb in Harvard University resulting in the prison of Eldo Kim. + https://cbsboston.files.wordpress.com/2013/12/kimeldoharvard.pdf +- [13.1] FBI Official web site -- "Harvard Student Charged with Bomb Hoax". + https://archives.fbi.gov/archives/boston/press-releases/2013/harvard-student-charged-with-bomb-hoax -- [28] Milad Nasr, Amir Houmansadr and Arya Mazumdar - "**Compressive Traffic Analysis:A New Paradigm for Scalable Traffic Analysis**". +- [14] FBI Official web site -- "Six Hackers in the United States and Abroad Charged for Crimes Affecting Over One Million Victims". + https://archives.fbi.gov/archives/newyork/press-releases/2012/six-hackers-in-the-united-states-and-abroad-charged-for-crimes-affecting-over-one-million-victims -[Online] Available: https://people.cs.umass.edu/~milad/papers/compress_CCS.pdf +- [15] Adrian Crenshaw -- "Dropping Docs on Darknets: How People Got Caught - Defcon 22". + https://www.youtube.com/watch?v=7G1LjQSYM5Q +- [16] TOR Official Project web site -- Metrics about TOR network. + https://metrics.torproject.org/networksize.html -- [29] ISACA - “Geolocation: Risk, Issues and Strategies”. +- [17] TOR Official Project web site -- "Tor: Onion Service Protocol". + https://www.torproject.org/docs/onion-services.html.en -[Online] Available: https://www.isaca.org/Groups/Professional-English/wireless/GroupDocuments/Geolocation_WP.pdf +- [18] Steven J. Murdoch -- "Hot or Not: Revealing Hidden Services by their Clock Skew". + https://murdoch.is/papers/ccs06hotornot.pdf +- [19] TOR Official Project web site -- "Who Uses Tor?". + https://www.torproject.org/about/torusers.html.en -- [30] Eugene Gorelik - “Cloud Computing Models” +- [20] Rob Jansen, Marc Juarez, Rafa Galvez, Tariq Elahi and Claudia Diaz -- "Inside Job: Applying Traffic Analysis to Measure Tor from Within". + https://www.robgjansen.com/publications/insidejob-ndss2018.pdf -[Online] Available: https://web.mit.edu/smadnick/www/wp/2013-01.pdf +- [21] TOR project official web site -- FAQ: "What are Entry Guards?". + https://www.torproject.org/docs/faq#EntryGuards +- [22] TOR project official blog -- "Improving Tor's anonymity by changing guard parameters". + https://blog.torproject.org/improving-tors-anonymity-changing-guard-parameters -- [31] Alexa Huth and James Cebula - “The Basics of Cloud Computing” +- [23] Free Haven -- Online Anonymity Papers Library. + https://www.freehaven.net/anonbib/ -[Online] Available: https://www.us-cert.gov/sites/default/files/publications/CloudComputingHuthCebula.pdf +- [24] TOR project official blog -- "Research problem: better guard rotation parameters". + https://blog.torproject.org/research-problem-better-guard-rotation-parameters +- [25] Nick Mathewson -- "Cryptographic Challenges in and around Tor". + https://crypto.stanford.edu/RealWorldCrypto/slides/tor.pdf -- [32] Jason A. Donenfeld - “WireGuard: Next Generation Kernel Network Tunnel” +- [26] TOR project official web site -- FAQ: "How often does Tor change its paths?". + https://www.torproject.org/docs/faq#ChangePaths -[Online] Available: https://www.wireguard.com/papers/wireguard.pdf +- [27] TOR project official web site -- TOR MANUAL. + https://www.torproject.org/docs/tor-manual.html.en +- [28] Milad Nasr, Amir Houmansadr and Arya Mazumdar -- "Compressive Traffic Analysis: A New Paradigm for Scalable Traffic Analysis". + https://people.cs.umass.edu/~milad/papers/compress_CCS.pdf -- [33] HAPROXY – Official Web Site. +- [29] ISACA -- "Geolocation: Risk, Issues and Strategies". + https://www.isaca.org/Groups/Professional-English/wireless/GroupDocuments/Geolocation_WP.pdf -[Online] Available: https://www.haproxy.org/ +- [30] Eugene Gorelik -- "Cloud Computing Models". + https://web.mit.edu/smadnick/www/wp/2013-01.pdf +- [31] Alexa Huth and James Cebula -- "The Basics of Cloud Computing". + https://www.us-cert.gov/sites/default/files/publications/CloudComputingHuthCebula.pdf -- [34] Privoxy – Official Web Site +- [32] Jason A. Donenfeld -- "WireGuard: Next Generation Kernel Network Tunnel". + https://www.wireguard.com/papers/wireguard.pdf -[Online] Available: http://www.privoxy.org/ +- [33] HAProxy -- Official Web Site. https://www.haproxy.org/ +- [34] Privoxy -- Official Web Site. http://www.privoxy.org/ - [35] TOR Standalone Linux version Download Page. + https://www.torproject.org/download/download-unix.html.en -[Online] Available: https://www.torproject.org/download/download-unix.html.en - - -- [36] HAPROXY - Documentation. - -[Online] Available: https://www.haproxy.org/#docs - - -- [37] PRIVOXY - Official User Manual. - -[Online] Available: http://www.privoxy.org/user-manual/index.html - - -- [38] King, Kevin, “Personal Jurisdiction, Internet Commerce, and Privacy: The Pervasive Legal Consequences of Geolocation Technologies,” Albany Law Journal of Science and Technology, January 2011 - -- [39] Viviane Reding - "Digital Sovereignty: Europe at a Crossroads" - -[Online] Available: http://institute.eib.org/wp-content/uploads/2016/01/Digital-Sovereignty-Europe-at-a-Crossroads.pdf - - -- [40] Tim Maurer, Robert Morgus, Isabel Skierka, Mirko Hohmann - "**Technological Sovereignty: Missing the Point?**" - -[Online] Available: http://www.digitaldebates.org/fileadmin/media/cyber/Maurer-et-al_2014_Tech-Sovereignty-Europe.pdf - - -- [41] BSD License Definition - -[Online] Available: http://www.linfo.org/bsdlicense.html - +- [36] HAProxy -- Documentation. https://www.haproxy.org/#docs -- [42] Joel Reardon and Ian Goldberg - "Improving Tor using a TCP-over-DTLS Tunnel" +- [37] Privoxy -- Official User Manual. http://www.privoxy.org/user-manual/index.html -[Online] Available: https://www.usenix.org/legacy/event/sec09/tech/full_papers/reardon.pdf +- [38] King, Kevin, "Personal Jurisdiction, Internet Commerce, and Privacy: The Pervasive Legal Consequences of Geolocation Technologies," Albany Law Journal of Science and Technology, January 2011. +- [39] Viviane Reding -- "Digital Sovereignty: Europe at a Crossroads". + http://institute.eib.org/wp-content/uploads/2016/01/Digital-Sovereignty-Europe-at-a-Crossroads.pdf -- [43] TOR Metrics – Official WebSite. +- [40] Tim Maurer, Robert Morgus, Isabel Skierka, Mirko Hohmann -- "Technological Sovereignty: Missing the Point?". + http://www.digitaldebates.org/fileadmin/media/cyber/Maurer-et-al_2014_Tech-Sovereignty-Europe.pdf -[Online] Available: https://metrics.torproject.org/rs.html#search/country:ES%20flag:exit +- [41] BSD License Definition. http://www.linfo.org/bsdlicense.html +- [42] Joel Reardon and Ian Goldberg -- "Improving Tor using a TCP-over-DTLS Tunnel". + https://www.usenix.org/legacy/event/sec09/tech/full_papers/reardon.pdf -- [44] Docker – Official Documentation +- [43] TOR Metrics -- Official Web Site. + https://metrics.torproject.org/rs.html#search/country:ES%20flag:exit +- [44] Docker -- Official Documentation. https://docs.docker.com/ -[Online] Available: https://docs.docker.com/ +- [45] Docker -- Official Documentation "Expose (incoming ports)". + https://docs.docker.com/engine/reference/run/#expose-incoming-ports +--- -- [45] Docker – Official Documentation “Expose (incoming ports)” +## License -[Online] Available: https://docs.docker.com/engine/reference/run/#expose-incoming-ports +BSD License [41]. Do whatever you want with this tool, but take the responsibility. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a8fcc85 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,264 @@ +# SPLITTER Modernization Roadmap + +## Overview + +Full rewrite of SPLITTER in Go, replacing the Bash codebase. The new version retains the same +architecture (Tor instances per country, HAProxy load balancing, geo-based anti-correlation rules) +but benefits from Go's concurrency model, single static binary distribution, and robust process +management. The Bash version is kept in `legacy/` for reference. + +Existing short flags (`-i`, `-c`, `-re`) will remain as aliases alongside new long flags. + +--- + +## Phase 1: Project Infrastructure ✅ DONE + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 1.1 | **Initialize Go module** | `go mod init github.com/user/splitter`. Add `go.sum`. | ✅ | +| 1.2 | **Add `.gitignore`** | Ignore `*.log`, `*.pid`, `/tmp/splitter/`, `force_new_circuit.sh`, Go build artifacts (`/bin/`, `*.exe`). | ✅ | +| 1.3 | **Move Bash to `legacy/`** | Move `splitter.sh`, `func/`, `settings.cfg` into `legacy/` directory. Update Dockerfile references. | ✅ | +| 1.6 | **Map `settings.cfg` → `configs/default.yaml`** | Audit all 541+ lines of `settings.cfg` and produce an explicit mapping of every parameter to its Go config equivalent. Parameters with no Go equivalent must be either ported or explicitly dropped with justification. This document drives 3.1.2. | ✅ `configs/SETTINGS_MAP.md` | +| 1.4 | **Add `docker-compose.yml`** | `splitter` service with volumes for config, healthcheck, restart policy, environment variables. | ✅ | +| 1.5 | **Add `.github/workflows/ci.yml`** | Pipeline: `go vet` + `go test` + `golangci-lint` + Docker build on PRs and pushes. | ✅ | + +--- + +## Phase 2: Go Project Structure ✅ DONE + +``` +splitter/ + main.go # Entry point + go.mod + go.sum + Makefile # Build, test, docker, smoke targets + cmd/ + root.go # Root command (Cobra) + run.go # `splitter run` subcommand + status.go # `splitter status` - live dashboard + test.go # `splitter test dns` / `splitter test exit-reputation` + version.go # `splitter version` - detected Tor features + reload.go # SIGHUP config reload handler + internal/ + cli/ # Cobra setup, flag bindings, input validation + config/ # Config loading: file, env vars (SPLITTER_*), defaults, profiles + tor/ # Tor instance lifecycle: spawn, config generation, signal, restart + haproxy/ # HAProxy config generation, process management + proxy/ # Proxy abstraction: HTTPTunnelPort (native) or Privoxy (legacy) + country/ # Country selection, rotation daemon, Tor Metrics API client + circuit/ # Circuit renewal, Conflux management, NEWNYM via control port + process/ # Process group lifecycle: spawn, graceful shutdown, SIGTERM->SIGKILL + metrics/ # Prometheus metrics endpoint + health/ # Health checks, DNS leak tests, exit node reputation + network/ # Port availability (net.Listen test), IPv4/IPv6 detection + profile/ # Predefined profiles: stealth, balanced, streaming, pentest + template/ # Go templates for torrc, haproxy.cfg, privoxy.cfg + templates/ + torrc.gotmpl # Tor config template + haproxy.cfg.gotmpl # HAProxy config template + privoxy.cfg.gotmpl # Privoxy config template (legacy mode) + configs/ + default.yaml # Default configuration (replaces settings.cfg) + countries.yaml # Country lists, blacklist, Tor Metrics cache + bridges.yaml # Bridge configuration (Snowflake, obfs4, WebTunnel) + profiles.yaml # Profile definitions (stealth, balanced, streaming, pentest) + useragents.yaml # Bundled Tor Browser User-Agent list + tests/ + smoke.sh # Automated smoke test suite (Docker) + Doc/ + MIGRATION.md # Bash -> Go migration guide + legacy/ # Original Bash version preserved for reference + splitter.sh + func/ + settings.cfg + Dockerfile + docker-compose.yml # Production (uses ghcr.io image) + docker-compose.dev.yml # Development (builds from source) + .github/ + workflows/ + ci.yml # CI pipeline + release.yml # Release automation +``` + +--- + +## Phase 3: Go Implementation ✅ DONE + +### 3.1 Foundation ✅ + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 3.1.1 | **Go module + Cobra CLI** | Initialize `go.mod`. Set up Cobra with subcommands: `run`, `status`, `test`, `version`. Long flags: `--instances`, `--countries`, `--relay-enforce`. Legacy aliases: `-i`, `-c`. Note: `-re` is kept as a two-character alias for backward compat with the Bash version, but it is not a standard single-letter flag — document it clearly as a legacy alias. Add `--profile`, `--proxy-mode`, `--bridge-type`, `--verbose` flags. | ✅ | +| 3.1.2 | **Configuration system** | Load config from `configs/default.yaml`, override with env vars (`SPLITTER_*` prefix), override with CLI flags. Validate all values (instances > 0, valid relay modes, port ranges). Profile support: `--profile stealth` loads `profiles.yaml[stealth]` as base. | ✅ | +| 3.1.3 | **Structured logging** | Use `log/slog` (Go 1.21+). Levels: DEBUG, INFO, WARN, ERROR. **Logging is OFF by default** (philosophy: no logs, no crime). Enable via `--log` flag or `SPLITTER_LOG=1` env var. When enabled: JSON format for Docker (detected via `TERM=dumb` or `NO_COLOR`), text for terminal. `--log-level` controls verbosity (default INFO when logs are on). | ✅ | +| 3.1.4 | **Process lifecycle manager** | `internal/process/` package. Spawn child processes (tor, haproxy, privoxy) with `os/exec`. Track PIDs. Graceful shutdown: SIGTERM -> wait 5s -> SIGKILL. `trap` equivalent via `signal.NotifyContext`. Kill all children on exit. Clean up temp files. | ✅ | + +### 3.2 Core Services ✅ + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 3.2.1 | **Tor instance manager** | `internal/tor/` package. Each Tor instance is a goroutine-managed process. Generate torrc from `templates/torrc.gotmpl`. Auto-detect Tor version at startup (`tor --version`). Conditionally enable Conflux, CGO, congestion control, HTTPTunnelPort based on detected version. Track state: starting, bootstrapping, ready, failed. **Auto-restart on failure**: if a Tor process exits unexpectedly, the goroutine restarts it with backoff (1s, 2s, 4s, max 30s). Failure counter resets after successful bootstrap. **Hidden service per instance**: each Tor instance generates a hidden service on a unique port (`HiddenServiceDir`, `HiddenServicePort`), preserved from the Bash version. | ✅ | +| 3.2.2 | **HAProxy manager** | `internal/haproxy/` package. Generate config from `templates/haproxy.cfg.gotmpl`. Shuffle backend order (`math/rand`) for anti-correlation. Health check configuration (TCP checks). Start/stop/reload process. **Stats page** on port 63539 with random password generated at startup. **Fixed: HAProxy 3.x compatibility** (`stats auth admin:pw` instead of `stats admin pw`). **Fixed: TCP health checks** instead of httpchk (Tor HTTPTunnelPort is a CONNECT proxy). | ✅ | +| 3.2.3 | **Proxy abstraction** | `internal/proxy/` package. Two modes: `native` uses Tor's `HTTPTunnelPort` directly (no Privoxy), `legacy` generates Privoxy configs. Mode selected via `--proxy-mode` flag or profile. In native mode, HAProxy backends point directly at Tor HTTPTunnelPort listeners. | ✅ | +| 3.2.4 | **Country selection + rotation** | `internal/country/` package. Random selection without duplicates. Rotation daemon runs as a goroutine: periodically selects a random country, rewrites torrc, restarts the affected instances. Configurable interval with jitter (`--country-interval`, default 120s +- random). | ✅ | +| 3.2.5 | **Circuit renewal** | `internal/circuit/` package. Connect to Tor control port, authenticate via **cookie auth** (`CookieAuthentication 1` in torrc, read `control_auth_cookie` file), send `SIGNAL NEWNYM`. Replace `expect` scripts with Go's `net.Conn` + Tor control protocol. Randomized renewal intervals per instance (10-15s range). | ✅ | +| 3.2.6 | **Port allocation** | `internal/network/` package. Find available ports by attempting `net.Listen` on each port. No more `netstat` or `ss` calls. Thread-safe port allocator for concurrent instance startup. | ✅ | + +### 3.3 Integration ✅ + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 3.3.1 | **Orchestrator** | `cmd/run.go` wires everything together: parse config -> kill previous -> create temp dirs -> start tor instances -> start HAProxy -> start circuit renewal -> start country rotation daemon -> block until context cancelled. | ✅ | +| 3.3.2 | **Dependency detection** | At startup, check that `tor`, `haproxy` (and `privoxy` if legacy mode) exist in `$PATH`. Detect Tor version and feature support (Conflux, HTTPTunnelPort, CGO). Fail fast with clear message if anything is missing. | ✅ | +| 3.3.3 | **Environment variable overrides** | Any config value can be overridden via `SPLITTER_*` env vars. E.g., `SPLITTER_INSTANCES=10`, `SPLITTER_COUNTRIES=6`, `SPLITTER_RELAY_ENFORCE=exit`. Priority: CLI flag > env var > config file > default. | ✅ | +| 3.3.4 | **Status dashboard** | `cmd/status.go` - terminal UI showing live instance state, countries, circuit count, health. Uses `fmt` + ANSI escape codes (no external TUI dependency). Exposed as HTTP at `/status`. | ✅ | +| 3.3.5 | **SIGHUP config reload** | Handle `SIGHUP` via `signal.NotifyContext` to reload `configs/default.yaml` without full restart. On reload: update country lists, rotation intervals, and HAProxy config. Tor instances are not restarted unless a parameter affecting torrc changes. Print reload summary to stdout. | ✅ | + +--- + +## Phase 4: Tests ✅ DONE + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 4.1 | **Unit tests** | `go test ./internal/...` for each package (15 packages). Mock external dependencies (tor binary, network). | ✅ | +| 4.2 | **Test CLI parsing** | Test valid args, invalid args, missing flags, short/long flag equivalence, profile loading. | ✅ | +| 4.3 | **Test country selection** | Test random selection without duplicates, empty list handling, rotation logic. | ✅ | +| 4.4 | **Test port allocation** | Test concurrent port allocation, handling of occupied ports. | ✅ | +| 4.5 | **Test config generation** | Test torrc, haproxy.cfg, privoxy.cfg templates with various relay modes and profiles. Verify generated output matches expected structure. | ✅ | +| 4.6 | **Test relay config** | Test EntryNodes/ExitNodes/ExcludeNodes for each mode (entry/exit/speed). Test Conflux/CGO flags appear conditionally based on Tor version. | ✅ | +| 4.7 | **Test process lifecycle** | Test graceful shutdown, SIGTERM timeout, child process cleanup. | ✅ | +| 4.8 | **Integration tests** | `go test -tags=integration ./...` — tests that spawn real tor/haproxy processes. Skipped in CI without dependencies. | ✅ | +| 4.9 | **Privacy test suite** | 3-layer privacy testing: Layer 1 (unit: torrc security assertions, config defaults), Layer 2 (integration: DNS leak, circuit rotation, cookie auth), Layer 3 (smoke: log safety, header leaks, security options). | ✅ | +| 4.10 | **Smoke test script** | `tests/smoke.sh` — automated end-to-end validation: torrc verify-config, HAProxy config, port binding, instance health, proxy functionality, IP rotation, privacy checks. | ✅ | +| 4.11 | **Makefile** | `Makefile` with targets: `make check`, `make test`, `make test-privacy`, `make docker-up`, `make smoke`, etc. | ✅ | + +--- + +## Phase 5: Docker and CI/CD ✅ DONE + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 5.1 | **Multi-stage Dockerfile** | Stage 1 (`golang:1.23-alpine`): compile static binary. Stage 2 (`alpine:3.21`): copy binary + install tor (0.4.9.6) + haproxy (3.0). HEALTHCHECK hitting `/healthz` endpoint. Non-root user. | ✅ | +| 5.2 | **docker-compose.yml** | Production compose (`docker-compose.yml`) uses ghcr.io image. Dev compose (`docker-compose.dev.yml`) builds from source with volume-mounted configs. Optional `prometheus` + `grafana` services for monitoring. | ✅ | +| 5.3 | **GitHub Actions CI** | Jobs: `lint` (`golangci-lint run`), `test` (`go test ./...`), `build` (`docker build`), `push` (only on tags). Cross-compile for `linux/amd64` and `linux/arm64`. | ✅ | +| 5.4 | **Release automation** | Release v2.0.0-beta-01 created. GitHub release with notes, Docker image tagged. `release.yml` workflow for future releases. | ✅ | + +--- + +## Phase 6: Documentation ✅ DONE + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 6.1 | **Update README** | Go build instructions, new CLI reference, docker-compose usage, profiles, migration from Bash. | ✅ | +| 6.2 | **Update AGENTS.md** | Reflect Go structure, `go test` commands, Go code style conventions, lint commands. | ✅ | +| 6.3 | **Migration guide** | Document differences between Bash and Go versions. Config mapping (settings.cfg -> default.yaml). Feature parity matrix. | ✅ `Doc/MIGRATION.md` | + +--- + +## Phase 7: Modern Tor Features 🔄 PARTIAL + +Features based on Tor 0.4.7-0.4.9 (2023-2026) changelog analysis and current Tor ecosystem. + +### 7.1 Speed Improvements ✅ + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 7.1.1 | **Conflux (multi-leg circuits)** | Tor 0.4.8 introduced Conflux: traffic is split across multiple circuit legs simultaneously at the protocol level. This is what SPLITTER approximates manually via HAProxy load balancing. Enabling `ConfluxEnabled 1` in each tor instance config would let Tor natively aggregate bandwidth across legs. Combined with SPLITTER's per-country instance strategy, this would multiply throughput and resilience. Requires Tor >= 0.4.8. | ✅ Auto-detected and conditionally enabled | +| 7.1.2 | **Replace Privoxy with HTTPTunnelPort** | Tor 0.4.8+ has native HTTP CONNECT proxy support via `HTTPTunnelPort`. This eliminates the Privoxy layer entirely. The TCP path simplifies from User -> HAProxy -> Privoxy -> Tor to User -> HAProxy -> Tor. Removes one hop, reduces latency, eliminates a dependency, and removes Privoxy config generation complexity. HAProxy backend targets become Tor HTTPTunnelPort listeners directly. Add a `--proxy-mode` flag: `native` (HTTPTunnelPort, recommended) or `legacy` (Privoxy, backward compat). | ✅ Default mode is `native` | +| 7.1.3 | **Congestion Control tuning** | Tor 0.4.7+ has congestion control (`CongestionControlAuto`). Enable it by default and expose tuning parameters in config. This dramatically improves throughput on long-distance circuits. Set `CongestionControlAuto 1` in generated tor configs. | ✅ Auto-detected and conditionally enabled | + +### 7.2 Security Improvements 🔄 + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 7.2.1 | **CGO - Counter Galois Onion encryption** | Tor 0.4.9 introduced CGO relay cryptography with improved resistance to tagging attacks, better forward secrecy, and better forgery resistance. SPLITTER should auto-detect Tor version and enable CGO when available. Detect via `tor --version` and conditionally set `CGOEnabled 1` (or equivalent config) in generated torrc. | ✅ Detection via `Version.SupportsCGO()`. **Note**: CGO is not a torrc option — detection is used for informational/logging only. Previously emitted invalid `CGOEnabled 1` torrc directive; removed after `tor --verify-config` failure. | +| 7.2.2 | **Post-Quantum Key Exchange (ML-KEM768)** | Tor 0.4.8.17+ supports post-quantum key agreement via ML-KEM768 when built with OpenSSL 3.5.0+. This protects against "harvest now, decrypt later" attacks. SPLITTER should check if the Tor binary supports it (`tor --dump-config` or version check) and enable it when available. | ✅ Detection via `Version.SupportsPostQuantum()` and TLS/OpenSSL version check. Runtime verification shows `X25519MLKEM768` in TLS handshake. | +| 7.2.3 | **Bridge / Pluggable Transport support** | Add support for Tor bridges (Snowflake, WebTunnel, obfs4) via `--bridge` flag. This solves the main threat from the README: connecting to Tor from networks that block it. Instead of requiring a VPS + VPN chain, users in censored regions can use a Snowflake bridge. Config via `Bridge` lines in torrc. Add `--bridge-type snowflake|webtunnel|obfs4|none` flag and `configs/bridges.yaml` file. | ✅ | +| 7.2.4 | **Happy Families awareness** | Tor 0.4.9 introduced "happy families" for relay grouping. When selecting entry/exit countries, SPLITTER should avoid circuits where multiple relays belong to the same operator. Enable by ensuring generated torrc respects family restrictions. No direct config needed on the client side, but document that upgrading to Tor 0.4.9+ enables this automatically. | ✅ Detection via `Version.SupportsHappyFamilies()`. Automatic — no torrc directive needed. | +| 7.2.5 | **TLS 1.3 enforcement** | Tor 0.4.9 now requires TLS 1.2 minimum and recommends TLS 1.3. SPLITTER should verify the Tor binary supports TLS 1.3 and document this as a requirement for the Docker image. | ✅ Detection via `Version.SupportsTLS13()`. Verified in Docker image (TLS 1.3 with X25519MLKEM768). | +| 7.2.6 | **Sandboxing** | Enable `Sandbox 1` in generated torrc (Tor's built-in seccomp-bpf sandbox, available on Linux). Add a seccomp profile to the Docker image (`--security-opt seccomp=splitter.json`) restricting syscalls to the minimum required by tor + haproxy. The `stealth` profile enables both by default; other profiles document the tradeoff (sandbox adds ~5% latency). Verify sandbox compatibility with the target Tor version at startup — some older builds have sandbox bugs. | 🔄 Template supports `Sandbox 1` (conditional on `SandboxEnabled`). Docker seccomp profile not yet created. | + +### 7.3 New Features ✅ + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 7.3.1 | **Auto-update country lists from Tor Metrics** | Replace the hardcoded 32-country list from 2018 with dynamic fetching from the Tor Metrics API. Query `https://metrics.torproject.org/rs/search` to find countries with active exit/guard relays. Cache results locally with TTL (e.g., 24h). Fallback to `configs/countries.yaml` when offline. Add `--auto-countries` flag to enable. | ✅ Tor Metrics API client implemented (`internal/country/metrics.go`). Cache with TTL. Fallback to YAML. | +| 7.3.2 | **Stream Isolation via SOCKS5 auth** | Use Tor's `IsolateSOCKSAuth` feature to isolate streams by destination without creating extra instances. Each destination gets its own circuit via SOCKS5 username/password isolation. This provides finer-grained separation than the current per-instance model. Add `--stream-isolation` flag. | ✅ Enabled via `--stream-isolation` or `pentest` profile. Renders `IsolateSOCKSAuth` in torrc. | +| 7.3.3 | **IPv6 dual-stack support** | Add IPv6 relay selection alongside IPv4. Many countries now have IPv6 Tor relays. Add `ClientUseIPv6 1` to torrc, and allow country selection to consider IPv6 relay availability. Add `--ipv6` flag to enable. | ✅ Disabled by default (`ClientUseIPv6 0`). Enable via `--ipv6` flag. Template tested for both states. | +| 7.3.4 | **Prometheus metrics endpoint** | Expose a `/metrics` HTTP endpoint using Go's `net/http` + Prometheus client library. Metrics: active instances, circuits per instance, country distribution, latency percentiles, error rates, bandwidth usage, bootstrap progress. This enables Grafana dashboards and alerting. | ✅ `/metrics` endpoint. Custom Prometheus registry. | +| 7.3.5 | **Circuit fingerprinting resistance** | Adaptive circuit rotation based on traffic patterns. If a burst of requests is detected, randomize circuit selection more aggressively. For steady traffic, rotate at variable intervals (not fixed 10s). This defeats timing correlation attacks that exploit predictable rotation patterns. | ✅ Adaptive circuit rotation (`internal/circuit/adaptive.go`). Burst/moderate/idle detection with variance. | +| 7.3.6 | **Exit node reputation checking** | Before assigning an exit node, check its reputation against public datasets. Query the Tor Metrics API for exit relay flags, uptime, and bandwidth. Optionally cross-reference with community blocklists. Skip exit nodes that are newly appeared (possible honeypots) or have been flagged. Add `--exit-reputation` flag. | ✅ Onionoo API client (`internal/health/reputation.go`). Score computation, cache, filtering. | +| 7.3.7 | **DNS leak test** | Built-in test that verifies all DNS queries go exclusively through Tor. Run at startup and periodically. Use a known test domain that resolves to a unique address; if the address differs from what Tor resolves, flag a leak. Expose result in metrics and logs. Accessible via `splitter test dns`. | ✅ DNS leak detection (`internal/health/dnsleak.go`). SOCKS5 resolution + IP comparison. `splitter test dns` command. | +| 7.3.8 | **Configuration profiles** | Predefined profiles in `configs/profiles.yaml`: `stealth` (max security, many instances, aggressive rotation, Conflux enabled, no speed mode), `balanced` (current default behavior, good tradeoff), `streaming` (Conflux + congestion control + speed mode, for media), `pentest` (extreme rotation, randomized User-Agent per request, stream isolation). Add `--profile` flag. | ✅ 4 profiles: stealth, balanced, streaming, pentest. | +| 7.3.9 | **Bundled Tor Browser User-Agent list** | Replace the hardcoded 2018 Firefox UA (`Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0`) with a curated list in `configs/useragents.yaml`, updated at release time. **Do not fetch at runtime** — an outbound HTTP call at startup is a privacy risk (logged by the remote, timing side-channel). The list is rotated randomly per instance. Update the bundled list as part of the release process. | ✅ `configs/useragents.yaml` bundled. Random rotation per instance. | + +### 7.4 Arti (Rust Tor Client) Support ⏳ DEFERRED + +> **Note**: Arti currently lacks GeoIP-based path selection, which is the core mechanism SPLITTER relies on for anti-correlation. Until that feature lands, Arti cannot be a functional backend for SPLITTER. This phase is deferred until Arti reaches parity on that specific feature. Track progress at [gitlab.torproject.org/tpo/core/arti](https://gitlab.torproject.org/tpo/core/arti). Re-evaluate quarterly. + +When GeoIP path selection is available in Arti, the implementation plan is: + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 7.4.1 | **Dual backend support** | Add `--tor-backend c-tor\|arti` flag. When `arti` selected, generate Arti TOML config instead of torrc. Feature-gate: hidden services, bridges, pluggable transports, and conflux remain c-tor only until Arti supports them. | ⏳ Deferred | +| 7.4.2 | **Feature parity tracking** | Maintain a compatibility matrix in docs showing which SPLITTER features work with each backend. Key blocker: GeoIP-based path selection. Secondary blockers: conflux, pluggable transports, hidden services. | ⏳ Deferred | + +--- + +## Phase 8: Future Expansion ⏳ NOT STARTED + +| # | Task | Detail | Status | +|---|------|--------|--------| +| 8.1 | **Client mode** | `splitter client` - lightweight proxy for individual use without HAProxy. Single Tor instance with circuit rotation. No load balancing, no multi-instance overhead. For when you just want a rotating Tor proxy. | ⏳ | +| 8.2 | **TUI dashboard** | Terminal UI using `bubbletea` or `tview` for real-time monitoring of instances, circuits, countries, bandwidth. Interactive: swap country, force new circuit, restart instance, all from the TUI. | ⏳ | +| 8.3 | **REST API** | HTTP API for remote management: `GET /api/instances`, `POST /api/instances/{id}/rotate`, `GET /api/countries`, etc. Enables integration with external tools and scripts. | ⏳ | +| 8.4 | **Multi-node coordination** | *(Out of scope for this roadmap — would require a separate distributed control plane project. Tracked separately.)* | ⏳ | + +--- + +## Bugfixes Applied (v2.0.0-beta-01) + +| Fix | Detail | +|-----|--------| +| **HAProxy stats port conflict** | Stats port default changed from `63537` (same as HTTPPort) to `63539`. HAProxy was failing to start due to duplicate bind. | +| **HAProxy 3.x `stats admin` syntax** | Changed `stats admin ` to `stats auth admin:` + `stats admin if TRUE`. HAProxy 3.x requires `if`/`unless` condition. | +| **HAProxy health checks** | Replaced `option httpchk GET https://google.com/` with `option tcp-check` + `tcp-check connect`. Tor's HTTPTunnelPort is a CONNECT proxy, not an HTTP server — httpchk would never succeed. | +| **Invalid CGOEnabled torrc option** | Removed `CGOEnabled 1` from torrc template. CGO is not a torrc directive — it's a compile-time Tor feature. Caused `tor --verify-config` to fail with "Unknown option". | +| **Obsolete OptimisticData torrc option** | Removed `OptimisticData` from torrc template. Obsolete in Tor 0.4.9.x, caused warnings and potential parse failures. | +| **SOCKS5 through HAProxy** | Fixed: added explicit `mode tcp` to `backend tor_socks` in HAProxy template. The backend was inheriting `mode http` from defaults, causing SOCKS5 handshakes to be misinterpreted as HTTP. | + +--- + +## Suggested Execution Order + +1. ~~**Phase 1** (infrastructure: Go module, move Bash to legacy/, CI scaffold)~~ ✅ +2. ~~**Phase 2** (project structure: directories, Go module layout)~~ ✅ +3. ~~**Phase 3.1** (foundation: Cobra CLI, config system, logging, process manager)~~ ✅ +4. ~~**Phase 5** (Docker/CI/CD) -> CI pipeline active from day one, catches regressions as core services are built~~ ✅ +5. ~~**Phase 4** (tests for foundation packages) -> safety net~~ ✅ +6. ~~**Phase 3.2** (core services: tor manager, HAProxy, proxy, country, circuit, port allocation)~~ ✅ +7. ~~**Phase 3.3** (integration: orchestrator, dependency detection, status dashboard)~~ ✅ +8. ~~**Phase 7.1** (speed: Conflux, HTTPTunnelPort, congestion control) -> biggest user-visible impact~~ ✅ +9. ~~**Phase 7.2** (security: CGO, post-quantum, bridges)~~ ✅ (partial: Docker seccomp profile pending) +10. ~~**Phase 7.3** (new features: auto-countries, metrics, profiles, etc.)~~ ✅ +11. ~~**Phase 6** (documentation)~~ ✅ +12. **Phase 7.2.6** (Docker seccomp profile) 🔄 +13. **Phase 7.4** (Arti support, when GeoIP parity lands) + **Phase 8** (future expansion) ⏳ + +--- + +## Open Questions + +### Resolved + +- **Hidden services per instance**: yes, maintained. Each Tor instance generates a hidden service on a unique port (`HiddenServiceDir`, `HiddenServicePort`). +- **Logging strategy**: off by default (`--log` / `SPLITTER_LOG=1` to enable). Philosophy: no logs, no crime. +- **HAProxy stats page**: preserved on port 63539 with random password generated at startup. +- **Control port auth**: cookie auth (`CookieAuthentication 1` in torrc, read `control_auth_cookie` file). +- **Minimum Tor version**: 0.4.8 (Conflux + HTTPTunnelPort). Runtime image uses 0.4.9.6. +- **Privoxy support**: kept as legacy fallback via `--proxy-mode legacy`. Default is `native` (HTTPTunnelPort). +- **License**: BSD 3-Clause, inherited from the original project. Must remain BSD since SPLITTER is a derivative work. +- **Architecture targets**: `linux/amd64` and `linux/arm64` from day one. +- **Prometheus metrics**: embedded in the splitter binary, enabled via `--metrics` flag (off by default). +- **Cache TTL**: 12h for Tor Metrics country lists and Onionoo exit reputation. +- **Arti support**: deferred until GeoIP path selection is available. Re-evaluate quarterly. Does not block releases. diff --git a/cmd/doc.go b/cmd/doc.go new file mode 100644 index 0000000..570b406 --- /dev/null +++ b/cmd/doc.go @@ -0,0 +1,2 @@ +// Package cmd provides Cobra command definitions for the SPLITTER CLI. +package cmd diff --git a/cmd/reload.go b/cmd/reload.go new file mode 100644 index 0000000..6aa86e2 --- /dev/null +++ b/cmd/reload.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "reflect" + "strings" + + "github.com/user/splitter/internal/config" +) + +type configDiff struct { + CountryListChanged bool + RotationChanged bool + HAProxyChanged bool + TorChanged bool +} + +func diffConfig(old, newCfg *config.Config) configDiff { + var d configDiff + + if !reflect.DeepEqual(old.Country.Accepted, newCfg.Country.Accepted) || + !reflect.DeepEqual(old.Country.Blacklisted, newCfg.Country.Blacklisted) { + d.CountryListChanged = true + } + + if old.Country.Rotation.Interval != newCfg.Country.Rotation.Interval || + old.Country.Rotation.Enabled != newCfg.Country.Rotation.Enabled || + old.Country.Rotation.TotalToChange != newCfg.Country.Rotation.TotalToChange { + d.RotationChanged = true + } + + if !reflect.DeepEqual(old.Proxy, newCfg.Proxy) || + !reflect.DeepEqual(old.HealthCheck, newCfg.HealthCheck) || + old.Instances.Retries != newCfg.Instances.Retries || + old.ProxyMode != newCfg.ProxyMode || + !reflect.DeepEqual(old.Privoxy, newCfg.Privoxy) { + d.HAProxyChanged = true + } + + if !reflect.DeepEqual(old.Tor, newCfg.Tor) || + old.Relay.Enforce != newCfg.Relay.Enforce { + d.TorChanged = true + } + + return d +} + +func (d configDiff) Summary() string { + var parts []string + if d.CountryListChanged { + parts = append(parts, "country list") + } + if d.RotationChanged { + parts = append(parts, "rotation interval") + } + if d.HAProxyChanged { + parts = append(parts, "haproxy config") + } + if d.TorChanged { + parts = append(parts, "tor config (restart needed)") + } + if len(parts) == 0 { + return "no changes detected" + } + return strings.Join(parts, ", ") +} diff --git a/cmd/reload_test.go b/cmd/reload_test.go new file mode 100644 index 0000000..15c944a --- /dev/null +++ b/cmd/reload_test.go @@ -0,0 +1,226 @@ +package cmd + +import ( + "testing" + + "github.com/user/splitter/internal/config" +) + +func TestDiffConfig_NoChanges(t *testing.T) { + cfg := testReloadConfig(t) + result := diffConfig(cfg, cfg) + + if result.CountryListChanged || result.RotationChanged || result.HAProxyChanged || result.TorChanged { + t.Errorf("expected no changes, got %+v", result) + } + if result.Summary() != "no changes detected" { + t.Errorf("Summary() = %q, want %q", result.Summary(), "no changes detected") + } +} + +func TestDiffConfig_CountryListAccepted(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Country.Accepted = []string{"{US}", "{DE}"} + + result := diffConfig(old, newCfg) + if !result.CountryListChanged { + t.Error("expected CountryListChanged") + } + if result.RotationChanged || result.HAProxyChanged || result.TorChanged { + t.Errorf("unexpected changes: %+v", result) + } +} + +func TestDiffConfig_CountryListBlacklisted(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Country.Blacklisted = []string{"{RU}"} + + result := diffConfig(old, newCfg) + if !result.CountryListChanged { + t.Error("expected CountryListChanged") + } +} + +func TestDiffConfig_RotationInterval(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Country.Rotation.Interval = 300 + + result := diffConfig(old, newCfg) + if !result.RotationChanged { + t.Error("expected RotationChanged") + } + if result.CountryListChanged || result.HAProxyChanged || result.TorChanged { + t.Errorf("unexpected changes: %+v", result) + } +} + +func TestDiffConfig_RotationEnabled(t *testing.T) { + old := testReloadConfig(t) + old.Country.Rotation.Enabled = true + newCfg := testReloadConfig(t) + newCfg.Country.Rotation.Enabled = false + + result := diffConfig(old, newCfg) + if !result.RotationChanged { + t.Error("expected RotationChanged") + } +} + +func TestDiffConfig_RotationTotalToChange(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Country.Rotation.TotalToChange = 5 + + result := diffConfig(old, newCfg) + if !result.RotationChanged { + t.Error("expected RotationChanged") + } +} + +func TestDiffConfig_HAProxyProxyChanged(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Proxy.LoadBalanceAlgorithm = "leastconn" + + result := diffConfig(old, newCfg) + if !result.HAProxyChanged { + t.Error("expected HAProxyChanged") + } + if result.CountryListChanged || result.RotationChanged || result.TorChanged { + t.Errorf("unexpected changes: %+v", result) + } +} + +func TestDiffConfig_HAProxyHealthCheckChanged(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.HealthCheck.Interval = 30 + + result := diffConfig(old, newCfg) + if !result.HAProxyChanged { + t.Error("expected HAProxyChanged") + } +} + +func TestDiffConfig_HAProxyRetriesChanged(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Instances.Retries = 500 + + result := diffConfig(old, newCfg) + if !result.HAProxyChanged { + t.Error("expected HAProxyChanged") + } +} + +func TestDiffConfig_HAProxyProxyModeChanged(t *testing.T) { + old := testReloadConfig(t) + old.ProxyMode = "native" + newCfg := testReloadConfig(t) + newCfg.ProxyMode = "legacy" + + result := diffConfig(old, newCfg) + if !result.HAProxyChanged { + t.Error("expected HAProxyChanged") + } +} + +func TestDiffConfig_HAProxyPrivoxyChanged(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Privoxy.StartPort = 8000 + + result := diffConfig(old, newCfg) + if !result.HAProxyChanged { + t.Error("expected HAProxyChanged") + } +} + +func TestDiffConfig_TorConfigChanged(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Tor.CircuitBuildTimeout = 120 + + result := diffConfig(old, newCfg) + if !result.TorChanged { + t.Error("expected TorChanged") + } + if result.CountryListChanged || result.RotationChanged || result.HAProxyChanged { + t.Errorf("unexpected changes: %+v", result) + } +} + +func TestDiffConfig_TorRelayEnforceChanged(t *testing.T) { + old := testReloadConfig(t) + old.Relay.Enforce = "entry" + newCfg := testReloadConfig(t) + newCfg.Relay.Enforce = "exit" + + result := diffConfig(old, newCfg) + if !result.TorChanged { + t.Error("expected TorChanged") + } +} + +func TestDiffConfig_MultipleChanges(t *testing.T) { + old := testReloadConfig(t) + newCfg := testReloadConfig(t) + newCfg.Country.Accepted = []string{"{US}"} + newCfg.Country.Rotation.Interval = 300 + newCfg.Proxy.LoadBalanceAlgorithm = "leastconn" + newCfg.Tor.CircuitBuildTimeout = 120 + + result := diffConfig(old, newCfg) + if !result.CountryListChanged { + t.Error("expected CountryListChanged") + } + if !result.RotationChanged { + t.Error("expected RotationChanged") + } + if !result.HAProxyChanged { + t.Error("expected HAProxyChanged") + } + if !result.TorChanged { + t.Error("expected TorChanged") + } +} + +func TestConfigDiff_Summary_Multiple(t *testing.T) { + d := configDiff{ + CountryListChanged: true, + RotationChanged: true, + } + summary := d.Summary() + if summary != "country list, rotation interval" { + t.Errorf("Summary() = %q, want %q", summary, "country list, rotation interval") + } +} + +func TestConfigDiff_Summary_All(t *testing.T) { + d := configDiff{ + CountryListChanged: true, + RotationChanged: true, + HAProxyChanged: true, + TorChanged: true, + } + expected := "country list, rotation interval, haproxy config, tor config (restart needed)" + if d.Summary() != expected { + t.Errorf("Summary() = %q, want %q", d.Summary(), expected) + } +} + +func TestConfigDiff_Summary_None(t *testing.T) { + d := configDiff{} + if d.Summary() != "no changes detected" { + t.Errorf("Summary() = %q, want %q", d.Summary(), "no changes detected") + } +} + +func testReloadConfig(t *testing.T) *config.Config { + t.Helper() + cfg := config.Defaults() + return cfg +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..6de615d --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/user/splitter/internal/cli" + "github.com/user/splitter/internal/config" +) + +var appCfg *config.Config + +func Execute() error { + os.Args = cli.PreprocessArgs(os.Args) + + root := &cobra.Command{ + Use: "splitter", + Short: "SPLITTER manages multiple Tor instances with HAProxy load balancing", + Long: `SPLITTER creates and manages multiple TOR network instances +load-balanced via HAProxy, with geo-based anti-correlation rules +for TOR entry/exit node selection.`, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + appCfg, err = cli.Load(cmd) + if err != nil { + return fmt.Errorf("config: %w", err) + } + if err := cli.SetupLogger(appCfg); err != nil { + return fmt.Errorf("logger: %w", err) + } + return nil + }, + } + + cli.BindFlags(root) + + root.AddCommand( + newRunCmd(), + newStatusCmd(), + newTestCmd(), + newVersionCmd(), + ) + + return root.Execute() +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..bd7333d --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,253 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/spf13/cobra" + "github.com/user/splitter/internal/circuit" + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/country" + "github.com/user/splitter/internal/haproxy" + "github.com/user/splitter/internal/health" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +type torRotator struct { + tm *tor.TorManager +} + +func (r *torRotator) GetInstances() []country.InstanceInfo { + infos := r.tm.GetInstanceInfos() + out := make([]country.InstanceInfo, len(infos)) + for i, info := range infos { + out[i] = country.InstanceInfo{ID: info.ID, Country: info.Country} + } + return out +} + +func (r *torRotator) RotateInstance(ctx context.Context, id int, newCountry string) error { + return r.tm.RotateInstance(ctx, id, newCountry) +} + +func newRunCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Start SPLITTER with Tor instances and HAProxy load balancing", + Long: `Start SPLITTER by spawning the configured number of Tor instances, +generating HAProxy configuration, and launching the load balancer. +Country rotation and circuit renewal daemons start in the background.`, + RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("country-interval") { + v, _ := cmd.Flags().GetDuration("country-interval") + appCfg.Country.Rotation.Interval = int(v.Seconds()) + } + if cmd.Flags().Changed("load-balance") { + v, _ := cmd.Flags().GetString("load-balance") + appCfg.Proxy.LoadBalanceAlgorithm = v + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + tmpDir := appCfg.Paths.TempFiles + + if err := os.MkdirAll(tmpDir, 0700); err != nil { + return fmt.Errorf("mkdir %s: %w", tmpDir, err) + } + + cleanupMgr := process.NewManager(tmpDir) + _ = cleanupMgr.StopAll(ctx) + _ = cleanupMgr.Cleanup() + + if err := os.MkdirAll(tmpDir, 0700); err != nil { + return fmt.Errorf("mkdir %s: %w", tmpDir, err) + } + + procMgr := process.NewManager(tmpDir) + + checkResult, err := health.CheckDependencies(ctx, appCfg) + if err != nil { + return err + } + + countries, err := country.SelectRandom(appCfg.Country.Accepted, appCfg.Country.Blacklisted, appCfg.Instances.Countries) + if err != nil { + return fmt.Errorf("country selection: %w", err) + } + + torMgr := tor.NewManager(appCfg, procMgr) + if err := torMgr.DetectAndCreate(ctx, countries); err != nil { + return fmt.Errorf("tor init: %w", err) + } + + if err := torMgr.StartAllWithRestart(ctx); err != nil { + return fmt.Errorf("tor start: %w", err) + } + + renewer := circuit.NewRenewer() + for _, inst := range torMgr.GetInstances() { + cookiePath := filepath.Join(tmpDir, fmt.Sprintf("tor_data_%d", inst.ID), "control_auth_cookie") + renewer.AddInstance(inst.ID, inst.ControlPort, cookiePath) + } + if err := renewer.Start(ctx); err != nil { + return fmt.Errorf("circuit renewal: %w", err) + } + + haproxyMgr := haproxy.NewManager(appCfg, procMgr) + if err := haproxyMgr.GenerateConfig(torMgr.GetInstances()); err != nil { + return fmt.Errorf("haproxy config: %w", err) + } + if err := haproxyMgr.Start(ctx); err != nil { + return fmt.Errorf("haproxy start: %w", err) + } + + rotator := &torRotator{tm: torMgr} + countryDaemon := country.NewDaemon(appCfg, rotator) + if err := countryDaemon.Start(ctx); err != nil { + return fmt.Errorf("country daemon: %w", err) + } + + printStartupInfo(torMgr, haproxyMgr, checkResult) + + statusPort, _ := cmd.Flags().GetInt("status-port") + if statusPort > 0 { + startStatusServer(ctx, statusPort, torMgr, procMgr) + } + + sighupCh := make(chan os.Signal, 1) + signal.Notify(sighupCh, syscall.SIGHUP) + defer signal.Stop(sighupCh) + + for { + select { + case <-ctx.Done(): + fmt.Println("\nShutting down...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + _ = countryDaemon.Stop() + _ = renewer.Stop() + _ = haproxyMgr.Stop(shutdownCtx) + _ = torMgr.StopAll(shutdownCtx) + _ = procMgr.StopAll(shutdownCtx) + _ = procMgr.Cleanup() + + fmt.Println("SPLITTER stopped.") + return nil + case sig := <-sighupCh: + if sig == syscall.SIGHUP { + handleReload(ctx, appCfg, torMgr, haproxyMgr, countryDaemon) + } + } + } + }, + } + + cmd.Flags().Duration("country-interval", 120*time.Second, "Interval between country rotations") + cmd.Flags().String("load-balance", "roundrobin", "Load balancing algorithm (roundrobin|leastconn)") + cmd.Flags().Int("status-port", health.DefaultStatusPort, "Port for status HTTP endpoint (0 to disable)") + + return cmd +} + +func printStartupInfo(torMgr *tor.TorManager, haproxyMgr *haproxy.HAProxyManager, checkResult *health.CheckResult) { + instances := torMgr.GetInstances() + fmt.Println("=== SPLITTER ===") + fmt.Printf("Tor instances: %d\n", len(instances)) + fmt.Printf("SOCKS proxy: %s:%d\n", appCfg.Proxy.Master.Listen, appCfg.Proxy.Master.SocksPort) + fmt.Printf("HTTP proxy: %s:%d\n", appCfg.Proxy.Master.Listen, appCfg.Proxy.Master.HTTPPort) + fmt.Printf("HAProxy stats: %s:%d%s (password: %s)\n", + appCfg.Proxy.Stats.Listen, appCfg.Proxy.Stats.Port, appCfg.Proxy.Stats.URI, + haproxyMgr.StatsPassword()) + fmt.Printf("Relay enforce: %s\n", appCfg.Relay.Enforce) + fmt.Printf("Proxy mode: %s\n", appCfg.ProxyMode) + if v := torMgr.GetVersion(); v != nil { + fmt.Printf("Tor version: %s\n", v.String()) + } + if checkResult != nil { + f := checkResult.Features + fmt.Printf("Features: conflux=%v congestion=%v http-tunnel=%v cgo=%v\n", + f.Conflux, f.CongestionControl, f.HTTPTunnel, f.CGO) + } + fmt.Println("================") +} + +func startStatusServer(ctx context.Context, port int, torMgr *tor.TorManager, procMgr *process.Manager) { + mux := http.NewServeMux() + mux.HandleFunc("/status", health.StatusHandler(torMgr, procMgr)) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + } + + go func() { + <-ctx.Done() + _ = srv.Shutdown(context.Background()) + }() + + go func() { + slog.Info("starting status server", "port", port) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + slog.Error("status server error", "error", err) + } + }() +} + +func handleReload(ctx context.Context, cfg *config.Config, torMgr *tor.TorManager, haproxyMgr *haproxy.HAProxyManager, countryDaemon *country.Daemon) { + fmt.Println("Received SIGHUP, reloading configuration...") + + newCfg, err := config.Load(config.LoadOptions{ + ConfigPath: "configs/default.yaml", + EnvPrefix: "SPLITTER_", + }) + if err != nil { + fmt.Printf("Reload failed: %v\n", err) + slog.Error("config reload failed", "error", err) + return + } + + changes := diffConfig(cfg, newCfg) + + if changes.CountryListChanged || changes.RotationChanged { + countryDaemon.UpdateConfig(newCfg) + cfg.Country = newCfg.Country + } + + if changes.HAProxyChanged { + cfg.Proxy = newCfg.Proxy + cfg.HealthCheck = newCfg.HealthCheck + cfg.Instances.Retries = newCfg.Instances.Retries + cfg.ProxyMode = newCfg.ProxyMode + cfg.Privoxy = newCfg.Privoxy + + if err := haproxyMgr.GenerateConfig(torMgr.GetInstances()); err != nil { + fmt.Printf("Reload failed: HAProxy config generation: %v\n", err) + slog.Error("haproxy config generation failed on reload", "error", err) + return + } + if err := haproxyMgr.Reload(ctx); err != nil { + fmt.Printf("Reload failed: HAProxy reload: %v\n", err) + slog.Error("haproxy reload failed", "error", err) + return + } + } + + if changes.TorChanged { + fmt.Println("Warning: Tor configuration changed but instances require restart to apply.") + slog.Warn("tor config changed, instances need restart") + } + + fmt.Printf("Reloaded: %s\n", changes.Summary()) + slog.Info("configuration reloaded", "changes", changes.Summary()) +} diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..157e69c --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "testing" + "time" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/country" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +func TestNewRunCmd_Structure(t *testing.T) { + cmd := newRunCmd() + + if cmd.Use != "run" { + t.Errorf("Use = %q, want %q", cmd.Use, "run") + } + if cmd.Short == "" { + t.Error("Short should not be empty") + } + if cmd.RunE == nil { + t.Error("RunE should not be nil") + } + + ci, err := cmd.Flags().GetDuration("country-interval") + if err != nil { + t.Fatalf("country-interval flag error: %v", err) + } + if ci != 120*time.Second { + t.Errorf("country-interval = %v, want %v", ci, 120*time.Second) + } + + lb, err := cmd.Flags().GetString("load-balance") + if err != nil { + t.Fatalf("load-balance flag error: %v", err) + } + if lb != "roundrobin" { + t.Errorf("load-balance = %q, want %q", lb, "roundrobin") + } +} + +func TestNewRunCmd_FlagsChanged(t *testing.T) { + cmd := newRunCmd() + cmd.SetArgs([]string{"--country-interval", "60s", "--load-balance", "leastconn"}) + + if err := cmd.ParseFlags([]string{"--country-interval", "60s", "--load-balance", "leastconn"}); err != nil { + t.Fatalf("ParseFlags error: %v", err) + } + + if !cmd.Flags().Changed("country-interval") { + t.Error("country-interval flag should be changed") + } + if !cmd.Flags().Changed("load-balance") { + t.Error("load-balance flag should be changed") + } +} + +func TestTorRotator_GetInstances(t *testing.T) { + cfg := testRunConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + tm := tor.NewManager(cfg, procMgr) + tm.CreateFromVersion(&tor.Version{Major: 0, Minor: 4, Patch: 8}, []string{"{US}", "{DE}"}) + + r := &torRotator{tm: tm} + instances := r.GetInstances() + + if len(instances) != 2 { + t.Fatalf("GetInstances() returned %d, want 2", len(instances)) + } + + if instances[0].ID != 0 || instances[0].Country != "{US}" { + t.Errorf("instances[0] = {ID: %d, Country: %q}, want {0, \"{US}\"}", instances[0].ID, instances[0].Country) + } + if instances[1].ID != 1 || instances[1].Country != "{DE}" { + t.Errorf("instances[1] = {ID: %d, Country: %q}, want {1, \"{DE}\"}", instances[1].ID, instances[1].Country) + } +} + +func TestTorRotator_GetInstances_Empty(t *testing.T) { + cfg := testRunConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + tm := tor.NewManager(cfg, procMgr) + + r := &torRotator{tm: tm} + instances := r.GetInstances() + + if len(instances) != 0 { + t.Errorf("GetInstances() returned %d, want 0", len(instances)) + } +} + +func TestTorRotator_RotateInstance_NotFound(t *testing.T) { + cfg := testRunConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + tm := tor.NewManager(cfg, procMgr) + + r := &torRotator{tm: tm} + err := r.RotateInstance(context.Background(), 999, "{FR}") + if err == nil { + t.Error("RotateInstance(999) expected error, got nil") + } +} + +func TestTorRotator_RotateInstance_UpdatesCountry(t *testing.T) { + cfg := testRunConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + tm := tor.NewManager(cfg, procMgr) + tm.CreateFromVersion(&tor.Version{Major: 0, Minor: 4, Patch: 8}, []string{"{US}"}) + + r := &torRotator{tm: tm} + _ = r.RotateInstance(context.Background(), 0, "{GB}") + + instances := r.GetInstances() + if len(instances) != 1 { + t.Fatalf("GetInstances() returned %d, want 1", len(instances)) + } + if instances[0].Country != "{GB}" { + t.Errorf("Country = %q, want %q", instances[0].Country, "{GB}") + } +} + +func TestTorRotator_ImplementsInterface(t *testing.T) { + cfg := testRunConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + tm := tor.NewManager(cfg, procMgr) + + var _ country.InstanceRotator = &torRotator{tm: tm} +} + +func testRunConfig(t *testing.T) *config.Config { + t.Helper() + cfg := &config.Config{} + cfg.Tor.BinaryPath = "/usr/bin/tor" + cfg.Tor.ListenAddr = "0.0.0.0" + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + cfg.Tor.ControlAuth = "cookie" + cfg.Tor.HiddenService.Enabled = false + cfg.Tor.MinimumTimeout = 15 + cfg.Tor.CircuitBuildTimeout = 60 + cfg.Tor.CircuitStreamTimeout = 20 + cfg.Tor.MaxCircuitDirtiness = 30 + cfg.Tor.NewCircuitPeriod = 30 + cfg.Tor.LearnCircuitBuildTimeout = 1 + cfg.Tor.ClientOnly = 0 + cfg.Tor.ConnectionPadding = 0 + cfg.Tor.ReducedConnectionPadding = 1 + cfg.Tor.GeoIPExcludeUnknown = 1 + cfg.Tor.StrictNodes = 1 + cfg.Tor.FascistFirewall = 0 + cfg.Tor.FirewallPorts = []int{80, 443} + cfg.Tor.LongLivedPorts = []int{1, 2} + cfg.Tor.MaxClientCircuitsPending = 1024 + cfg.Tor.SocksTimeout = 35 + cfg.Tor.TrackHostExitsExpire = 10 + cfg.Tor.UseEntryGuards = 1 + cfg.Tor.NumEntryGuards = 1 + cfg.Tor.SafeSocks = 1 + cfg.Tor.TestSocks = 1 + cfg.Tor.ClientRejectInternalAddresses = 1 + cfg.Tor.OptimisticData = "auto" + cfg.Tor.AutomapHostsSuffixes = ".exit,.onion" + cfg.Tor.WarnPlaintextPorts = "21,23,25,80,109,110,143" + cfg.Tor.RejectPlaintextPorts = "" + cfg.Relay.Enforce = "entry" + cfg.Paths.TempFiles = t.TempDir() + cfg.Instances.PerCountry = 1 + return cfg +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..ce1780a --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/user/splitter/internal/health" +) + +const ( + ansiReset = "\033[0m" + ansiGreen = "\033[32m" + ansiRed = "\033[31m" + ansiYellow = "\033[33m" + ansiGray = "\033[90m" + ansiBold = "\033[1m" +) + +func newStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show live status of SPLITTER instances", + Long: `Display a live dashboard showing the state of all Tor instances, +their assigned countries, circuit counts, and health status.`, + RunE: runStatus, + } + cmd.Flags().String("status-url", "http://localhost:63540/status", "URL of the SPLITTER status endpoint") + return cmd +} + +func runStatus(cmd *cobra.Command, args []string) error { + url, _ := cmd.Flags().GetString("status-url") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("runStatus: cannot connect to SPLITTER at %s (is 'splitter run' started?): %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("runStatus: unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var status health.SystemStatus + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return fmt.Errorf("runStatus: decode response: %w", err) + } + + fmt.Print(renderStatus(&status)) + return nil +} + +func renderStatus(s *health.SystemStatus) string { + var b strings.Builder + + fmt.Fprintf(&b, "%sSPLITTER Status%s (%s)\n", ansiBold, ansiReset, s.Timestamp) + fmt.Fprintf(&b, "═══════════════════════════════════════════\n") + fmt.Fprintf(&b, "Tor version: %s\n", s.TorVersion) + fmt.Fprintf(&b, "Features: %s\n", formatFeatures(s.Features)) + + fmt.Fprintf(&b, "\nInstances (%d total, %s%d ready%s, %s%d failed%s):\n", + s.TotalInstances, + ansiGreen, s.ReadyCount, ansiReset, + ansiRed, s.FailedCount, ansiReset, + ) + fmt.Fprintf(&b, "──────────────────────────────────────────\n") + + for _, inst := range s.Instances { + icon, color := stateIcon(inst.State) + fmt.Fprintf(&b, " #%d %s %s%s%-12s%s socks:%d ctrl:%d http:%d\n", + inst.ID, + inst.Country, + color, icon+" ", inst.State, ansiReset, + inst.SocksPort, + inst.ControlPort, + inst.HTTPPort, + ) + } + + if len(s.ProcessBreakdown) > 0 { + parts := make([]string, 0, len(s.ProcessBreakdown)) + for name, count := range s.ProcessBreakdown { + parts = append(parts, fmt.Sprintf("%s:%d", name, count)) + } + fmt.Fprintf(&b, "\nProcesses: %d (%s)\n", s.Processes, strings.Join(parts, " ")) + } else { + fmt.Fprintf(&b, "\nProcesses: %d\n", s.Processes) + } + + return b.String() +} + +func stateIcon(state string) (icon, color string) { + switch state { + case "ready": + return "●", ansiGreen + case "failed": + return "○", ansiRed + case "bootstrapping": + return "◎", ansiYellow + default: + return "◦", ansiGray + } +} + +func formatFeatures(features map[string]bool) string { + type feat struct { + name string + on bool + } + order := []feat{ + {"conflux", features["conflux"]}, + {"http_tunnel", features["http_tunnel"]}, + {"congestion_control", features["congestion_control"]}, + {"cgo", features["cgo"]}, + } + + parts := make([]string, len(order)) + for i, f := range order { + if f.on { + parts[i] = fmt.Sprintf("%s%s ✓%s", ansiGreen, f.name, ansiReset) + } else { + parts[i] = fmt.Sprintf("%s%s ✗%s", ansiRed, f.name, ansiReset) + } + } + return strings.Join(parts, " | ") +} diff --git a/cmd/status_test.go b/cmd/status_test.go new file mode 100644 index 0000000..a42dd16 --- /dev/null +++ b/cmd/status_test.go @@ -0,0 +1,311 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/user/splitter/internal/health" +) + +func TestNewStatusCmd_Structure(t *testing.T) { + cmd := newStatusCmd() + + if cmd.Use != "status" { + t.Errorf("Use = %q, want %q", cmd.Use, "status") + } + if cmd.Short == "" { + t.Error("Short should not be empty") + } + if cmd.RunE == nil { + t.Error("RunE should not be nil") + } + + url, err := cmd.Flags().GetString("status-url") + if err != nil { + t.Fatalf("status-url flag error: %v", err) + } + if url != "http://localhost:63540/status" { + t.Errorf("status-url = %q, want %q", url, "http://localhost:63540/status") + } +} + +func TestStatusCmd_WithMockServer(t *testing.T) { + status := &health.SystemStatus{ + Timestamp: "2024-01-15 10:30:45", + TorVersion: "0.4.8", + Features: map[string]bool{"conflux": true, "http_tunnel": true, "congestion_control": true, "cgo": false}, + Instances: []health.InstanceStatus{ + {ID: 0, Country: "{US}", State: "ready", SocksPort: 4999, ControlPort: 5999, HTTPPort: 5199}, + }, + TotalInstances: 1, + ReadyCount: 1, + FailedCount: 0, + Processes: 3, + ProcessBreakdown: map[string]int{"tor": 1, "haproxy": 1}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) + })) + defer srv.Close() + + cmd := newStatusCmd() + cmd.SetArgs([]string{"--status-url", srv.URL + "/status"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } +} + +func TestStatusCmd_Unreachable(t *testing.T) { + cmd := newStatusCmd() + cmd.SetArgs([]string{"--status-url", "http://localhost:1/status"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unreachable URL, got nil") + } + if !strings.Contains(err.Error(), "cannot connect") { + t.Errorf("error = %q, want to contain 'cannot connect'", err.Error()) + } + if !strings.Contains(err.Error(), "runStatus") { + t.Errorf("error = %q, want to contain 'runStatus'", err.Error()) + } +} + +func TestStatusCmd_NonOKResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + })) + defer srv.Close() + + cmd := newStatusCmd() + cmd.SetArgs([]string{"--status-url", srv.URL + "/status"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for non-OK response, got nil") + } + if !strings.Contains(err.Error(), "unexpected status 500") { + t.Errorf("error = %q, want to contain 'unexpected status 500'", err.Error()) + } +} + +func TestStatusCmd_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + + cmd := newStatusCmd() + cmd.SetArgs([]string{"--status-url", srv.URL + "/status"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "decode response") { + t.Errorf("error = %q, want to contain 'decode response'", err.Error()) + } +} + +func TestRenderStatus(t *testing.T) { + status := &health.SystemStatus{ + Timestamp: "2024-01-15 10:30:45", + TorVersion: "0.4.8", + Features: map[string]bool{"conflux": true, "http_tunnel": false}, + Instances: []health.InstanceStatus{ + {ID: 0, Country: "{US}", State: "ready", SocksPort: 4999, ControlPort: 5999, HTTPPort: 5199}, + {ID: 1, Country: "{DE}", State: "failed", SocksPort: 5000, ControlPort: 6000, HTTPPort: 5200}, + {ID: 2, Country: "{FR}", State: "bootstrapping", SocksPort: 5001, ControlPort: 6001, HTTPPort: 5201}, + {ID: 3, Country: "{NL}", State: "starting", SocksPort: 5002, ControlPort: 6002, HTTPPort: 5202}, + }, + TotalInstances: 4, + ReadyCount: 1, + FailedCount: 1, + Processes: 5, + ProcessBreakdown: map[string]int{"tor": 4, "haproxy": 1}, + } + + output := renderStatus(status) + + if !strings.Contains(output, "SPLITTER Status") { + t.Error("output should contain 'SPLITTER Status'") + } + if !strings.Contains(output, "2024-01-15 10:30:45") { + t.Error("output should contain timestamp") + } + if !strings.Contains(output, "0.4.8") { + t.Error("output should contain version") + } + if !strings.Contains(output, "4 total") { + t.Error("output should contain total count") + } + if !strings.Contains(output, "1 ready") { + t.Error("output should contain ready count") + } + if !strings.Contains(output, "1 failed") { + t.Error("output should contain failed count") + } + if !strings.Contains(output, "{US}") { + t.Error("output should contain US country") + } + if !strings.Contains(output, "{DE}") { + t.Error("output should contain DE country") + } + if !strings.Contains(output, "socks:4999") { + t.Error("output should contain socks port") + } + if !strings.Contains(output, "ctrl:5999") { + t.Error("output should contain control port") + } + if !strings.Contains(output, "http:5199") { + t.Error("output should contain http port") + } + if !strings.Contains(output, "tor:4") { + t.Error("output should contain process breakdown") + } + if !strings.Contains(output, "haproxy:1") { + t.Error("output should contain haproxy in process breakdown") + } + if !strings.Contains(output, ansiGreen) { + t.Error("output should contain green ANSI codes") + } + if !strings.Contains(output, ansiRed) { + t.Error("output should contain red ANSI codes") + } + if !strings.Contains(output, ansiYellow) { + t.Error("output should contain yellow ANSI codes") + } + if !strings.Contains(output, ansiGray) { + t.Error("output should contain gray ANSI codes") + } +} + +func TestRenderStatus_EmptyInstances(t *testing.T) { + status := &health.SystemStatus{ + Timestamp: "2024-01-15 10:30:45", + TorVersion: "0.4.8", + Features: map[string]bool{}, + Instances: []health.InstanceStatus{}, + TotalInstances: 0, + ReadyCount: 0, + FailedCount: 0, + Processes: 0, + } + + output := renderStatus(status) + + if !strings.Contains(output, "0 total") { + t.Error("output should show 0 total") + } + if !strings.Contains(output, "0 ready") { + t.Error("output should show 0 ready") + } + if !strings.Contains(output, "0 failed") { + t.Error("output should show 0 failed") + } + if !strings.Contains(output, "Processes: 0") { + t.Error("output should show 0 processes") + } +} + +func TestRenderStatus_NoVersion(t *testing.T) { + status := &health.SystemStatus{ + Timestamp: "2024-01-15 10:30:45", + Features: map[string]bool{}, + } + + output := renderStatus(status) + + if !strings.Contains(output, "Tor version:") { + t.Error("output should contain Tor version label") + } +} + +func TestStateIcon(t *testing.T) { + tests := []struct { + state string + wantIcon string + wantColor string + }{ + {"ready", "●", ansiGreen}, + {"failed", "○", ansiRed}, + {"bootstrapping", "◎", ansiYellow}, + {"starting", "◦", ansiGray}, + {"unknown", "◦", ansiGray}, + {"", "◦", ansiGray}, + } + + for _, tt := range tests { + t.Run(tt.state, func(t *testing.T) { + icon, color := stateIcon(tt.state) + if icon != tt.wantIcon { + t.Errorf("icon = %q, want %q", icon, tt.wantIcon) + } + if color != tt.wantColor { + t.Errorf("color = %q, want %q", color, tt.wantColor) + } + }) + } +} + +func TestFormatFeatures(t *testing.T) { + features := map[string]bool{ + "conflux": true, + "http_tunnel": true, + "congestion_control": false, + "cgo": false, + } + + output := formatFeatures(features) + + if !strings.Contains(output, "conflux") { + t.Error("output should contain 'conflux'") + } + if !strings.Contains(output, "http_tunnel") { + t.Error("output should contain 'http_tunnel'") + } + if !strings.Contains(output, "congestion_control") { + t.Error("output should contain 'congestion_control'") + } + if !strings.Contains(output, "cgo") { + t.Error("output should contain 'cgo'") + } + if !strings.Contains(output, "✓") { + t.Error("output should contain checkmark for enabled features") + } + if !strings.Contains(output, "✗") { + t.Error("output should contain X for disabled features") + } + if !strings.Contains(output, " | ") { + t.Error("output should contain pipe separator") + } +} + +func TestFormatFeatures_Empty(t *testing.T) { + output := formatFeatures(map[string]bool{}) + + if !strings.Contains(output, "conflux") { + t.Error("output should still contain feature names even when empty") + } + if !strings.Contains(output, "✗") { + t.Error("output should show all features as disabled when map is empty") + } +} + +func TestFormatFeatures_AllEnabled(t *testing.T) { + features := map[string]bool{ + "conflux": true, + "http_tunnel": true, + "congestion_control": true, + "cgo": true, + } + + output := formatFeatures(features) + + if strings.Contains(output, "✗") { + t.Error("output should not contain X when all features are enabled") + } +} diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..b9fa52b --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newTestCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "test", + Short: "Run diagnostic tests", + Long: `Run various diagnostic tests to verify SPLITTER configuration and security.`, + } + + cmd.AddCommand( + &cobra.Command{ + Use: "dns", + Short: "Run DNS leak test", + Long: `Verify that all DNS queries are routed exclusively through Tor and no leaks are present.`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("splitter test dns: placeholder (full implementation in Phase 7)") + return nil + }, + }, + &cobra.Command{ + Use: "exit-reputation", + Short: "Check exit node reputation", + Long: `Check the reputation of active exit nodes against public datasets and Tor Metrics.`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("splitter test exit-reputation: placeholder (full implementation in Phase 7)") + return nil + }, + }, + ) + + return cmd +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..eb82dff --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var Version = "dev" + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show SPLITTER version and detected Tor features", + Long: `Display the SPLITTER version and auto-detected Tor features (Conflux, HTTPTunnelPort, CGO, etc.).`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Printf("SPLITTER version: %s\n", Version) + fmt.Println("Tor feature detection: not yet implemented (Phase 3.3)") + return nil + }, + } +} diff --git a/configs/SETTINGS_MAP.md b/configs/SETTINGS_MAP.md new file mode 100644 index 0000000..8aac313 --- /dev/null +++ b/configs/SETTINGS_MAP.md @@ -0,0 +1,335 @@ +# Settings.cfg to default.yaml Mapping + +This document maps every variable found in `legacy/func/settings.cfg` (primary) and +`legacy/settings.cfg` (root variant) to their Go equivalents in `configs/default.yaml`. +This drives task 3.1.2 (Go config system implementation). + +**Legend:** +- **Source**: `func` = `legacy/func/settings.cfg` (canonical, sourced by splitter.sh) +- **Source**: `root` = `legacy/settings.cfg` (variant, not directly sourced) +- **Decision**: PORTED = mapped to YAML; DROPPED = removed with justification; RUNTIME = Go runtime +- Differences between the two files are noted in the "Notes" column + +--- + +## 1. Instance and CLI Arguments + +These are set at runtime by `user_start_input.func` via CLI flags, not in settings.cfg. + +| Bash Variable | Go YAML Key | Type | Default | Decision | Notes | +|---------------|-------------|------|---------|----------|-------| +| TOR_INSTANCES | instances.per_country | int | 2 | PORTED | Set via `-i` / `--instances` CLI flag | +| COUNTRIES | instances.countries | int | 6 | PORTED | Set via `-c` / `--countries` CLI flag | +| COUNTRY_LIST_CONTROLS | relay.enforce | string | "entry" | PORTED | Set via `-re` / `--relay-enforce`. Values: entry, exit, speed | +| LOAD_BALANCE_ALGORITHM | proxy.load_balance_algorithm | string | "roundrobin" | PORTED | Derived by loadbalancing_choice.func: entry/exit->roundrobin, speed->leastconn | +| HAPROXY_HTTP_REUSE | proxy.haproxy_http_reuse | string | "never" | PORTED | Derived: entry/exit->never, speed->safe | + +--- + +## 2. Country Configuration + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| MY_COUNTRY_LIST | country.selected | string | "RANDOM" | "RANDOM" | PORTED | "RANDOM" or comma-separated `{XX}` codes | +| ACCEPTED_COUNTRIES | country.accepted | list | 32 countries (see YAML) | same | PORTED | Converted from comma-separated `{XX}` string to YAML list | +| BLACKLIST_COUNTRIES | country.blacklisted | list | 67 entries (see YAML) | same | PORTED | Converted from comma-separated `{XX}` string to YAML list | +| CHANGE_COUNTRY_ONTHEFLY | country.rotation.enabled | bool | "YES" | "YES" | PORTED | String "YES"/"NO" mapped to bool | +| CHANGE_COUNTRY_INTERVAL | country.rotation.interval | int | 120 | 120 | PORTED | Seconds between country changes | +| TOTAL_COUNTRIES_TO_CHANGE | country.rotation.total_to_change | int | 10 | 10 | PORTED | Number of countries rotated per cycle | + +--- + +## 3. Retry and Timeout + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| RETRIES | instances.retries | int | 1000 | 100 | PORTED | **Values differ between files**. func=1000, root=100. Using func value. | +| MINIMUM_TIMEOUT | tor.minimum_timeout | int | 15 | 20 | PORTED | **Values differ**. Base for derived timeouts. func=15, root=20. | +| MAX_CONCURRENT_REQUEST | instances.max_concurrent_requests | int | 20 | 20 | PORTED | HAProxy backend maxconn per instance | + +--- + +## 4. Port Allocation + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| START_SOCKS_PORT | tor.start_socks_port | int | 4999 | 4999 | PORTED | First Tor SOCKS port, incremented per instance | +| START_CONTROL_PORT | tor.start_control_port | int | 5999 | 5999 | PORTED | First Tor control port, incremented per instance | +| START_DNS_PORT | tor.start_dns_port | int | — | 5299 | PORTED | Only in root version. Go port allocator manages this. | +| TOR_START_HTTP_PORT | tor.start_http_port | int | — | 5199 | PORTED | Only in root version. Go port allocator manages this. | +| TOR_START_TransPort | tor.start_transport_port | int | — | 5099 | PORTED | Only in root version. Go port allocator manages this. | +| PRIVOXY_START_PORT | privoxy.start_port | int | 6999 | 6999 | PORTED | First Privoxy port, incremented per instance | +| HIDDEN_START_PORT | tor.hidden_service.start_port | int | 3999 | — | PORTED | Only in func version. First hidden service port. | + +--- + +## 5. Binary Paths + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| TORPATH | tor.binary_path | string | "/usr/bin/tor" | "/usr/local/bin/tor" | PORTED | **Values differ**. Go will auto-detect from $PATH if not set. | +| HAPROXY_PATH | haproxy.binary_path | string | "/usr/sbin/haproxy" | "/usr/sbin/haproxy" | PORTED | Go will auto-detect from $PATH if not set. | +| PRIVOXY_PATH | privoxy.binary_path | string | "/usr/sbin/privoxy" | "/usr/sbin/privoxy" | PORTED | Only used in legacy proxy mode. Go auto-detects. | + +--- + +## 6. User and Identity + +| Bash Variable | Go YAML Key | Type | Default | Decision | Notes | +|---------------|-------------|------|---------|----------|-------| +| USER_ID | — | — | `$(id \| cut...)` | DROPPED | Go uses os/user package or auto-detects at runtime | +| USER_UID | — | — | `$(id \| sed...)` | DROPPED | Only in root version. Go uses os.Getuid() at runtime | +| USER_GID | — | — | `$(id \| sed...)` | DROPPED | Only in root version. Go uses os.Getgid() at runtime | + +--- + +## 7. Paths and Directories + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| TOR_TEMP_FILES | paths.temp_files | string | "/tmp/splitter" | "/tmp/splitter" | PORTED | Deleted and recreated on startup | +| HIDDEN_SERVICE_PATH | tor.hidden_service.base_path | string | "/tmp/splitter/hidden_service_" | — | PORTED | Only in func version. Instance number appended. | +| PRIVOXY_FILE | privoxy.config_file_prefix | string | "${TOR_TEMP_FILES}/privoxy_splitter_config_" | same | PORTED | Instance number appended | +| MASTER_PROXY_CFG | haproxy.config_file | string | "${TOR_TEMP_FILES}/splitter_master_proxy.cfg" | same | PORTED | Generated HAProxy config path | +| PROXYCHAINS_FILE | paths.proxychains_file | string | "${HOME}/.proxychains/proxychains.conf" | same | PORTED | Optional; may be dropped if proxychains not used in Go version | + +--- + +## 8. Listen Addresses + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| LISTEN_ADDR | tor.listen_addr | string | "0.0.0.0" | "0.0.0.0" | PORTED | Tor SOCKS/control port bind address | +| PRIVOXY_LISTEN | privoxy.listen | string | "0.0.0.0" | "0.0.0.0" | PORTED | Privoxy bind address | +| MASTER_PROXY_LISTEN | proxy.master.listen | string | "0.0.0.0" | "0.0.0.0" | PORTED | HAProxy frontend bind address | +| DNSDIST_SERVER_LISTEN | dns.dist_listen | string | — | "0.0.0.0" | PORTED | Only in root version. dnsdist not in func version. | +| TOR_DNS_LISTEN | dns.tor_listen | string | — | "0.0.0.0" | PORTED | Only in root version. | +| DNSDIST_SERVER_PORT | dns.dist_port | int | — | 5353 | PORTED | Only in root version. | + +--- + +## 9. Master Proxy Ports + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| MASTER_PROXY_PORT | proxy.master.port | int | 63536 | — | PORTED | Only in func version. Single unified port. | +| MASTER_PROXY_SOCKS_PORT | proxy.master.socks_port | int | — | 63536 | PORTED | Only in root version. Separate SOCKS port. | +| MASTER_PROXY_HTTP_PORT | proxy.master.http_port | int | — | 63537 | PORTED | Only in root version. Separate HTTP port. | +| MASTER_PROXY_TRANSPARENT_PORT | proxy.master.transparent_port | int | — | 63538 | PORTED | Only in root version. Separate transparent port. | +| MASTER_PROXY_STAT_LISTEN | proxy.stats.listen | string | "0.0.0.0" | "0.0.0.0" | PORTED | HAProxy stats page bind address | +| MASTER_PROXY_STAT_PORT | proxy.stats.port | int | 63537 | 63539 | PORTED | **Values differ**. func=63537, root=63539. | +| MASTER_PROXY_STAT_URI | proxy.stats.uri | string | "/splitter_status" | "/splitter_status" | PORTED | HAProxy stats page URL path | +| MASTER_PROXY_STAT_PWD | proxy.stats.password | string | "${RAND_PASS}" | "${RAND_PASS}" | PORTED | Auto-generated random password at startup | + +--- + +## 10. Passwords (Runtime-Generated) + +| Bash Variable | Go YAML Key | Type | Default | Decision | Notes | +|---------------|-------------|------|---------|----------|-------| +| RAND_PASS | — | — | `$(dd if=/dev/urandom...)` | DROPPED | Go generates random password at runtime using crypto/rand | +| TORPASS | — | — | `$(tor --hash-password...)` | DROPPED | Go uses cookie auth by default, or hashes password at runtime | +| MASTER_PROXY_PASSWORD | — | — | undefined | BUG | Referenced in pre_loading.func:78 but never defined in either settings.cfg. Go version will use auto-generated password. | + +--- + +## 11. Logging + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| LOGDIR | logging.dir | string | "${TOR_TEMP_FILES}" | "${TOR_TEMP_FILES}" | PORTED | Defaults to temp_files path | +| LOGNAME | logging.name_prefix | string | "tor_log_" | "tor_log_" | PORTED | Instance number appended | + +--- + +## 12. Health Check + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| HEALTH_CHECK_URL | health_check.url | string | "https://www.google.com/" | "www.google.com" | PORTED | **Values differ**: func includes scheme, root doesn't. Using func version. | +| HEALTH_CHECK_INTERVAL | health_check.interval | int | 12 | 3 | PORTED | **Values differ**. func=12s, root=3. Using func value. Root used bare int; func had "12s" suffix. | +| HEALTH_CHECK_MAX_FAIL | health_check.max_fail | int | 1 | 1 | PORTED | Fail count before instance marked DOWN | +| HEALTH_CHECK_MININUM_SUCESS | health_check.minimum_success | int | 1 | 1 | PORTED | Note: typo in Bash name (MININUM). Corrected in Go. | + +--- + +## 13. User Agent + +| Bash Variable | Go YAML Key | Type | Default | Decision | Notes | +|---------------|-------------|------|---------|----------|-------| +| TOR_BROWNSER_USER_AGENT | user_agent.tor_browser | string | "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0" | PORTED | Outdated (2018). Go version should use bundled list updated at release time. | +| SPOOFED_USER_AGENT | — | — | derived from TOR_BROWNSER_USER_AGENT | DROPPED | Go handles escaping at runtime. Bash used sed to escape spaces. | + +--- + +## 14. Proxy Exclusion + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Decision | Notes | +|---------------|-------------|------|----------------|----------------|----------|-------| +| DO_NOT_PROXY | proxy.do_not_proxy | list | 6 entries | 16 RFC1918 entries | PORTED | **Values differ significantly**. func=local network IPs; root=full RFC1918 range. Converted to YAML list. | +| INCLUDE_SECURITY_HEADERS_IN_HTTP_RESPONSE | proxy.include_security_headers | bool | "YES" | — | PORTED | Only in func version. String "YES"/"NO" mapped to bool. Set false for pentest. | + +--- + +## 15. Tor Tuning Parameters + +These map directly to torrc directives. The "Tor default" column shows what Tor uses if not specified. + +| Bash Variable | Go YAML Key | Type | Default (func) | Default (root) | Tor Default | Decision | Notes | +|---------------|-------------|------|----------------|----------------|-------------|----------|-------| +| RejectPlaintextPorts | tor.reject_plaintext_ports | string | "" (commented out) | "" (commented out) | None | PORTED | Empty = disabled. Uncomment to block unencrypted ports. | +| WarnPlaintextPorts | tor.warn_plaintext_ports | string | "21,23,25,80,109,110,143" | "21,23,25,80,109,110,143" | "23,109,110,143" | PORTED | Extended beyond Tor default to include FTP, SMTP, HTTP. | +| CircuitBuildTimeout | tor.circuit_build_timeout | int | 60 | 60 | 60 | PORTED | Seconds. No difference between files. | +| CircuitsAvailableTimeout | tor.circuits_available_timeout | int | 5 | 360 | 1800 | PORTED | **Values differ drastically**. func=5s (aggressive), root=360s (6min). Using func value. | +| LearnCircuitBuildTimeout | tor.learn_circuit_build_timeout | int | 1 | 1 | 1 | PORTED | 0=disable adaptive learning. | +| CircuitStreamTimeout | tor.circuit_stream_timeout | int | 20 | 30 | 0 | PORTED | **Values differ**. func=20s, root=30s. 0=use Tor internal schedule. | +| ClientOnly | tor.client_only | int | 0 | 0 | 0 | PORTED | 1=don't run as relay. | +| ConnectionPadding | tor.connection_padding | int | 0 | 1 | auto | PORTED | **Values differ**. func=0 (off), root=1 (on). Using func value. | +| ReducedConnectionPadding | tor.reduced_connection_padding | int | 1 | 1 | 0 | PORTED | Less padding, shorter connections. | +| GeoIPExcludeUnknown | tor.geoip_exclude_unknown | int | 1 | 1 | auto | PORTED | 1=exclude all unknown-country nodes. | +| StrictNodes | tor.strict_nodes | int | 1 | 1 | 0 | PORTED | 1=enforce exclusions strictly even if it breaks functionality. | +| FascistFirewall | tor.fascist_firewall | int | 0 | 0 | 0 | PORTED | 1=only connect to FirewallPorts. | +| FirewallPorts | tor.firewall_ports | list | [80, 443] | [80, 443] | [80, 443] | PORTED | Converted from string to list. | +| LongLivedPorts | tor.long_lived_ports | list | [1, 2] | [1, 2] | [21,22,706,...] | PORTED | Minimal override: only ports 1,2 to avoid routing through high-uptime nodes. | +| NewCircuitPeriod | tor.new_circuit_period | int | 30 | 30 | 30 | PORTED | Seconds between considering new circuit. | +| MaxCircuitDirtiness | tor.max_circuit_dirtiness | int | 15 | 15 | 600 | PORTED | Seconds. Go randomizes 10..value per instance (Bash uses `shuf`). | +| MaxClientCircuitsPending | tor.max_client_circuits_pending | int | 1024 | 1024 | 32 | PORTED | Max 1024. Set high so circuits are always available. | +| SocksTimeout | tor.socks_timeout | int | derived: CircuitStreamTimeout+MINIMUM_TIMEOUT | same | 120 | PORTED | **Derived value**. func=35s, root=50s. Go computes at runtime. | +| TrackHostExitsExpire | tor.track_host_exits_expire | int | 10 | 120 | 1800 | PORTED | **Values differ**. func=10s (very aggressive), root=120s. Using func value. | +| UseEntryGuards | tor.use_entry_guards | int | 1 | 1 | 1 | PORTED | Keep as 1 for security. | +| NumEntryGuards | tor.num_entry_guards | int | 1 | 1 | 0 | PORTED | 0=use consensus. 1=always use exactly one guard. | +| SafeSocks | tor.safe_socks | int | 1 | 1 | 0 | PORTED | Reject unsafe SOCKS to prevent DNS leaks. | +| TestSocks | tor.test_socks | int | 1 | 1 | 0 | PORTED | Log SOCKS safety test results. | +| AllowNonRFC953Hostnames | tor.allow_non_rfc953_hostnames | int | 0 | 0 | 0 | PORTED | Block illegal hostname characters. | +| ClientRejectInternalAddresses | tor.client_reject_internal_addresses | int | 1 | 1 | 1 | PORTED | Reject connections to internal/private IPs. | +| DownloadExtraInfo | tor.download_extra_info | int | 0 | 0 | 0 | PORTED | Extra bandwidth cost, keep off. | +| OptimisticData | tor.optimistic_data | string | "auto" | "auto" | "auto" | PORTED | Send data before exit confirms connection. | +| AutomapHostsSuffixes | tor.automap_hosts_suffixes | string | ".exit,.onion" | ".exit,.onion" | ".exit,.onion" | PORTED | Domain suffixes for address mapping. | + +--- + +## 16. Derived Timeout Values + +These are computed from other settings. In Go, they are calculated at runtime. + +| Bash Variable | Go YAML Key | Type | Expression (func) | Expression (root) | Decision | Notes | +|---------------|-------------|------|--------------------|--------------------|----------|-------| +| PRIVOXY_TIMEOUT | privoxy.timeout | int | CircuitStreamTimeout + MINIMUM_TIMEOUT = 35 | same = 50 | PORTED | Computed at runtime from tor settings | +| MASTER_PROXY_SERVER_TIMEOUT | proxy.master.server_timeout | int | PRIVOXY_TIMEOUT = 35 | CircuitStreamTimeout + MINIMUM_TIMEOUT = 50 | PORTED | **Different expressions** between files. func uses PRIVOXY_TIMEOUT directly. | +| MASTER_PROXY_CLIENT_TIMEOUT | proxy.master.client_timeout | int | RETRIES * SERVER_TIMEOUT * COUNTRIES | same formula, different values | PORTED | Computed at runtime. Not stored in YAML; formula applied in Go code. | + +--- + +## 17. Runtime State Variables (DO NOT CHANGE section) + +These are internal counters managed by the Bash script at runtime. In Go, they are managed +by the port allocator and instance manager — NOT stored in config. + +| Bash Variable | Go Equivalent | Type | Default | Decision | Notes | +|---------------|--------------|------|---------|----------|-------| +| TOR_START_INSTANCE | — | — | 0 | DROPPED | Go uses 0-based indexing internally | +| TOR_CURRENT_INSTANCE | — | — | ${TOR_START_INSTANCE} | DROPPED | Go tracks instance state in memory | +| TOR_CURRENT_SOCKS_PORT | — | — | ${START_SOCKS_PORT} | DROPPED | Go port allocator manages this | +| TOR_CURRENT_CONTROL_PORT | — | — | ${START_CONTROL_PORT} | DROPPED | Go port allocator manages this | +| TOR_CURRENT_HTTP_PORT | — | — | ${TOR_START_HTTP_PORT} | DROPPED | Only in root. Go port allocator manages this. | +| TOR_CURRENT_TransPort | — | — | ${TOR_START_TransPort} | DROPPED | Only in root. Go port allocator manages this. | +| PRIVOXY_CURRENT_INSTANCE | — | — | 0 | DROPPED | Go tracks in memory | +| PRIVOXY_CURRENT_PORT | — | — | ${PRIVOXY_START_PORT} | DROPPED | Go port allocator manages this | +| HIDDEN_SERVICE_CURRENT_PORT | — | — | ${HIDDEN_START_PORT} | DROPPED | Only in func. Go port allocator manages this. | +| COUNT_CURRENT_INSTANCE | — | — | 0 | DROPPED | Only in root. Go tracks in memory. | +| DNSPORT | — | — | ${START_DNS_PORT} | DROPPED | Only in root. Go port allocator manages this. | +| NodeFamily | — | — | "" | DROPPED | Not used in any func file. | + +--- + +## 18. Hardcoded Torrc Values (in boot_tor_instances.func) + +These are hardcoded in the torrc template generation, not configurable via settings.cfg. +In Go, they can be exposed in an `advanced` section or kept as template defaults. + +| Torrc Directive | Value | Go YAML Key | Decision | Notes | +|-----------------|-------|-------------|----------|-------| +| RunAsDaemon | 1 | — | DROPPED | Go manages process lifecycle; daemonization not needed | +| CookieAuthentication | 0 | tor.control_auth | PORTED | Changed to "cookie" (1) by default in Go version per ROADMAP | +| SafeLogging | 1 | — | HARDCODED | Always on for security. No reason to change. | +| DirCache | 1 | — | HARDCODED | Keep enabled for performance. | +| DisableDebuggerAttachment | 1 | — | HARDCODED | Security feature, always on. | +| NoExec | 1 | — | HARDCODED | Security feature, always on. | +| ProtocolWarnings | 1 | — | HARDCODED | Useful for debugging, always on. | +| TruncateLogFile | 1 | — | DROPPED | Only relevant when logs are enabled. | +| KeepBindCapabilities | auto | — | HARDCODED | Auto is the right default. | +| HardwareAccel | 0 | — | HARDCODED | Hardware acceleration rarely available/needed. | +| AvoidDiskWrites | 0 | — | HARDCODED | Left at Tor default. | +| CircuitPriorityHalflife | 1 | — | HARDCODED | Tuning parameter, could be exposed if needed. | +| ExtendByEd25519ID | auto | — | HARDCODED | Auto is the right default. | +| EnforceDistinctSubnets | 1 | — | HARDCODED | Security feature, always on. | +| TransPort | 0 | — | HARDCODED | Disabled; not used in this architecture. | +| NATDPort | 0 | — | HARDCODED | Disabled; not used. | +| ConstrainedSockSize | 8192 | — | HARDCODED | Socket buffer size tuning. | +| UseGuardFraction | auto | — | HARDCODED | Auto is the right default. | +| UseMicrodescriptors | auto | — | HARDCODED | Auto is the right default. | +| ClientUseIPv4 | 1 | — | PORTED | Could be exposed for IPv6 dual-stack (ROADMAP 7.3.3). | +| ClientUseIPv6 | 0 | — | PORTED | Could be exposed for IPv6 dual-stack (ROADMAP 7.3.3). | +| ClientPreferIPv6ORPort | auto | — | HARDCODED | Auto is the right default. | +| PathsNeededToBuildCircuits | -1 | — | HARDCODED | -1 = use consensus. | +| ClientBootstrapConsensusAuthorityDownloadSchedule | "6, 11, ..." | — | HARDCODED | Rarely needs tuning. | +| ClientBootstrapConsensusFallbackDownloadSchedule | "0, 1, 4, ..." | — | HARDCODED | Rarely needs tuning. | +| ClientBootstrapConsensusAuthorityOnlyDownloadSchedule | "0, 3, 7, ..." | — | HARDCODED | Rarely needs tuning. | +| ClientBootstrapConsensusMaxInProgressTries | 3 | — | HARDCODED | Rarely needs tuning. | +| NumDirectoryGuards | 0 | — | HARDCODED | 0 = use consensus. | +| GuardLifetime | 0 | — | HARDCODED | 0 = use consensus. | +| AutomapHostsOnResolve | 0 | — | HARDCODED | Disabled; not needed for this use case. | +| HiddenServiceMaxStreams | 0 | tor.hidden_service.max_streams | PORTED | 0 = unlimited. | +| HiddenServiceMaxStreamsCloseCircuit | 0 | tor.hidden_service.max_streams_close_circuit | PORTED | Close circuit on max streams. | +| HiddenServiceDirGroupReadable | 0 | tor.hidden_service.dir_group_readable | PORTED | Security: don't allow group read. | +| HiddenServiceNumIntroductionPoints | 3 | tor.hidden_service.num_introduction_points | PORTED | Standard value. | +| DataDirectoryGroupReadable | 0 | — | HARDCODED | Security: don't allow group read. | +| CacheDirectoryGroupReadable | 0 | — | HARDCODED | Security: don't allow group read. | +| FetchDirInfoEarly | 0 | — | HARDCODED | Not needed for client-only use. | +| FetchDirInfoExtraEarly | 0 | — | HARDCODED | Not needed for client-only use. | +| FetchHidServDescriptors | 1 | — | HARDCODED | Needed for hidden services. | +| FetchServerDescriptors | 1 | — | HARDCODED | Needed for normal operation. | +| FetchUselessDescriptors | 0 | — | HARDCODED | Save bandwidth. | +| KeepalivePeriod | ${MINIMUM_TIMEOUT} | — | DERIVED | Set from minimum_timeout at runtime. | + +--- + +## 19. Variables Referenced But Never Defined (Bugs) + +| Bash Variable | Where Used | Issue | Go Resolution | +|---------------|-----------|-------|---------------| +| MASTER_PROXY_PASSWORD | pre_loading.func:78 (HAProxy userlist) | Never defined in either settings.cfg. `insecure-password ${MASTER_PROXY_PASSWORD}` will expand to empty string. | Auto-generated at startup and stored in memory. | + +--- + +## Summary Statistics + +| Category | Count | +|----------|-------| +| Total Bash variables identified | 81 | +| PORTED to Go YAML config | 62 | +| DROPPED (Go runtime / not needed) | 15 | +| Hardcoded torrc values | 29 | +| Bugs found | 1 (MASTER_PROXY_PASSWORD undefined) | +| Variables with different defaults between files | 12 | + +### Variables with Conflicting Defaults Between Files + +These 12 variables have different default values between `legacy/func/settings.cfg` and +`legacy/settings.cfg`. The `func/` version takes precedence as it is the one actually +sourced by the script. + +| Variable | func/settings.cfg | settings.cfg (root) | Go Default | +|----------|-------------------|---------------------|------------| +| RETRIES | 1000 | 100 | 1000 | +| MINIMUM_TIMEOUT | 15 | 20 | 15 | +| TORPATH | /usr/bin/tor | /usr/local/bin/tor | (auto-detect) | +| CircuitsAvailableTimeout | 5 | 360 | 5 | +| CircuitStreamTimeout | 20 | 30 | 20 | +| ConnectionPadding | 0 | 1 | 0 | +| TrackHostExitsExpire | 10 | 120 | 10 | +| HEALTH_CHECK_URL | https://www.google.com/ | www.google.com | https://www.google.com/ | +| HEALTH_CHECK_INTERVAL | 12 | 3 | 12 | +| DO_NOT_PROXY | 6 local IPs | 16 RFC1918 ranges | 6 local IPs | +| MASTER_PROXY_STAT_PORT | 63537 | 63539 | 63537 | +| MASTER_PROXY_SERVER_TIMEOUT expr | PRIVOXY_TIMEOUT | CircuitStreamTimeout+MINIMUM_TIMEOUT | PRIVOXY_TIMEOUT | diff --git a/configs/bridges.yaml b/configs/bridges.yaml new file mode 100644 index 0000000..e217754 --- /dev/null +++ b/configs/bridges.yaml @@ -0,0 +1,26 @@ +# Bridge configurations for SPLITTER +# These are placeholder bridges. Real bridges should be obtained from: +# https://bridges.torproject.org/bridges?transport=snowflake +# https://bridges.torproject.org/bridges?transport=obfs4 +# https://bridges.torproject.org/bridges?transport=webtunnel +# Update periodically. + +snowflake: + description: "Snowflake WebRTC transport" + transport: "snowflake" + lines: + - "Bridge snowflake 192.0.2.1:80 192.0.2.1:443 fingerprint=fingerprint1" + - "Bridge snowflake 192.0.2.2:80 192.0.2.2:443 fingerprint=fingerprint2" + +webtunnel: + description: "WebTunnel HTTPS transport" + transport: "webtunnel" + lines: + - "Bridge webtunnel 192.0.2.3:443 192.0.2.3:443 fingerprint=fingerprint3 url=https://example.com/tor" + +obfs4: + description: "obfs4 transport" + transport: "obfs4" + lines: + - "Bridge obfs4 192.0.2.4:443 192.0.2.4:443 fingerprint=fingerprint4 cert=cert1 iat-mode=0" + - "Bridge obfs4 192.0.2.5:443 192.0.2.5:443 fingerprint=fingerprint5 cert=cert2 iat-mode=0" diff --git a/configs/default.yaml b/configs/default.yaml new file mode 100644 index 0000000..59cf0a7 --- /dev/null +++ b/configs/default.yaml @@ -0,0 +1,426 @@ +# SPLITTER Go Configuration +# This file replaces legacy/func/settings.cfg +# Priority: CLI flags > SPLITTER_* env vars > this file > defaults + +# ============================================================================= +# INSTANCES +# ============================================================================= +instances: + # Number of Tor instances PER COUNTRY (CLI: -i, --instances) + per_country: 2 + # Number of countries to select (CLI: -c, --countries) + countries: 6 + # Maximum concurrent connections per Tor instance (HAProxy maxconn) + max_concurrent_requests: 20 + # Number of retries for failed connections (HAProxy retries factor) + retries: 1000 + +# ============================================================================= +# PROXY - Master proxy (HAProxy) settings +# ============================================================================= +proxy: + master: + # Primary listener address and port (what clients connect to) + listen: "0.0.0.0" + port: 63536 + # SOCKS proxy port (legacy/ root settings.cfg had separate ports) + socks_port: 63536 + # HTTP proxy port + http_port: 63537 + # Transparent proxy port + transparent_port: 63538 + # Client-side timeout in seconds (derived: privoxy_timeout) + client_timeout: 35 + # Server-side timeout in seconds (derived: privoxy_timeout) + server_timeout: 35 + + # HAProxy stats page + stats: + listen: "0.0.0.0" + port: 63539 + uri: "/splitter_status" + # Password is auto-generated at startup (replaces RAND_PASS) + # password: + + # Load balancing algorithm: "roundrobin" or "leastconn" + # Automatically set based on relay_enforce mode: + # entry/exit -> roundrobin, speed -> leastconn + load_balance_algorithm: "roundrobin" + + # HAProxy http-reuse mode: "never" (entry/exit) or "safe" (speed) + haproxy_http_reuse: "never" + + # Security headers injection in HTTP responses + # Set to false for penetration testing + include_security_headers: true + + # Networks excluded from proxying (no_proxy) + do_not_proxy: + - "0.0.0.0" + - "192.168.1.1" + - "192.168.2.1" + - "192.168.3.1" + - "192.168.0.1" + - "172.17.0.1" + +# ============================================================================= +# RELAY ENFORCEMENT (CLI: -re, --relay-enforce) +# ============================================================================= +relay: + # Mode: "entry" (default, best security), "exit" (GeoIP bypass), "speed" (fastest) + enforce: "entry" + +# ============================================================================= +# TOR - Per-instance Tor configuration +# ============================================================================= +tor: + # Binary paths (auto-detected from $PATH if not set) + binary_path: "/usr/bin/tor" + # Listen address for Tor SOCKS/control ports + listen_addr: "0.0.0.0" + + # Starting port numbers (incremented per instance) + start_socks_port: 4999 + start_control_port: 5999 + start_http_port: 5199 + start_transport_port: 5099 + start_dns_port: 5299 + + # Control port auth: "cookie" (recommended) or "password" + # Bash version used password auth; Go will use cookie auth by default + control_auth: "password" + + # Hidden service settings + hidden_service: + enabled: true + base_path: "/tmp/splitter/hidden_service_" + start_port: 3999 + max_streams: 0 + max_streams_close_circuit: false + dir_group_readable: false + num_introduction_points: 3 + + # --- Tor Tuning Parameters --- + # These map directly to torrc directives + + # Minimum timeout base (seconds) - used in derived timeout calculations + minimum_timeout: 15 + + # Circuit build timeout in seconds (Tor default: 60) + circuit_build_timeout: 60 + + # Learn circuit build timeout adaptively (0=off, 1=on) + learn_circuit_build_timeout: 1 + + # Seconds to keep unused circuits available (Tor default: 1800) + circuits_available_timeout: 5 + + # Stream timeout - detach and retry after this many seconds (Tor default: 0) + circuit_stream_timeout: 20 + + # Client-only mode - don't run as relay (0=no, 1=yes) + client_only: 0 + + # Connection padding: 0=off, 1=on, auto=negotiate + connection_padding: 0 + + # Reduced padding to save bandwidth (0=off, 1=on) + reduced_connection_padding: 1 + + # Exclude nodes with unknown GeoIP (0=off, 1=on, auto) + geoip_exclude_unknown: 1 + + # Strict node exclusion enforcement (0=soft, 1=strict) + strict_nodes: 1 + + # Firewall mode - only connect to allowed ports (0=off, 1=on) + fascist_firewall: 0 + + # Allowed ports when fascist_firewall is on + firewall_ports: + - 80 + - 443 + + # Ports for long-lived connections (uses high-uptime nodes) + long_lived_ports: + - 1 + - 2 + + # How often to consider building new circuit (seconds, Tor default: 30) + new_circuit_period: 30 + + # Max circuit age in seconds before renewal (Tor default: 600) + # Go will randomize between 10 and this value per instance + max_circuit_dirtiness: 15 + + # Max pending circuits (Tor default: 32, max: 1024) + max_client_circuits_pending: 1024 + + # SOCKS handshake timeout in seconds (derived: circuit_stream_timeout + minimum_timeout) + socks_timeout: 35 + + # Seconds before exit-host association expires (Tor default: 1800) + track_host_exits_expire: 10 + + # Use entry guards for long-term entry selection (0=off, 1=on) + use_entry_guards: 1 + + # Number of entry guards to maintain + num_entry_guards: 1 + + # Reject unsafe SOCKS variants (DNS leak prevention) + safe_socks: 1 + + # Log SOCKS safety test results + test_socks: 1 + + # Block hostnames with illegal characters + allow_non_rfc953_hostnames: 0 + + # Reject connections to internal addresses + client_reject_internal_addresses: 1 + + # Download extra-info documents (bandwidth cost, usually off) + download_extra_info: 0 + + # Optimistic data sending (0=off, 1=on, auto) + optimistic_data: "auto" + + # Automap hosts suffixes for .exit and .onion + automap_hosts_suffixes: ".exit,.onion" + + # Comma-separated ports to warn about plaintext connections + warn_plaintext_ports: "21,23,25,80,109,110,143" + + # Comma-separated ports to reject plaintext connections (empty = disabled) + reject_plaintext_ports: "" + + # Sandbox (seccomp-bpf, Linux only) - disabled by default as it adds ~5% latency. + # Enable with --sandbox or use the stealth profile. + sandbox: false + + # Stream isolation via SOCKS5 auth (CLI: --stream-isolation) + # When enabled, each destination gets its own circuit via SOCKS5 username/password + # isolation using Tor's IsolateSOCKSAuth feature. Provides finer-grained separation + # than the per-instance model. + stream_isolation: false + + # IPv6 dual-stack support (CLI: --ipv6) + # When enabled, Tor will use IPv6 relays alongside IPv4, increasing the + # available relay pool and improving throughput in some regions. + ipv6: false + + # --- Hardcoded torrc values (not exposed as config, listed for reference) --- + # These are always set in generated torrc and should not need user tuning: + # RunAsDaemon 1, SafeLogging 1, CookieAuthentication 0, + # NoExec 1, DisableDebuggerAttachment 1, EnforceDistinctSubnets 1, + # ClientUseIPv4 1, ClientUseIPv6 (controlled by ipv6 setting), CircuitPriorityHalflife 1, + # ExtendByEd25519ID auto, KeepalivePeriod = minimum_timeout + +# ============================================================================= +# PRIVOXY - Legacy HTTP-to-SOCKS bridge (only used in legacy proxy mode) +# ============================================================================= +privoxy: + binary_path: "/usr/sbin/privoxy" + listen: "0.0.0.0" + start_port: 6999 + # Timeout in seconds (derived: circuit_stream_timeout + minimum_timeout) + timeout: 35 + # Config file path prefix (instance number appended) + config_file_prefix: "/tmp/splitter/privoxy_splitter_config_" + +# ============================================================================= +# HAPROXY +# ============================================================================= +haproxy: + binary_path: "/usr/sbin/haproxy" + # Config file path + config_file: "/tmp/splitter/splitter_master_proxy.cfg" + +# ============================================================================= +# COUNTRY SELECTION +# ============================================================================= +country: + # "RANDOM" or comma-separated country codes in braces: {US},{DE},{SE} + selected: "RANDOM" + + # Accepted countries for random selection (32 countries) + accepted: + - "{AU}" + - "{AT}" + - "{BE}" + - "{BG}" + - "{CA}" + - "{CZ}" + - "{DK}" + - "{FI}" + - "{FR}" + - "{DE}" + - "{HU}" + - "{IS}" + - "{LV}" + - "{LT}" + - "{LU}" + - "{MD}" + - "{NL}" + - "{NO}" + - "{PA}" + - "{PL}" + - "{RO}" + - "{RU}" + - "{SC}" + - "{SG}" + - "{SK}" + - "{ES}" + - "{SE}" + - "{CH}" + - "{TR}" + - "{UA}" + - "{GB}" + - "{US}" + + # Blacklisted countries (never used - no exit nodes or slow relays) + blacklisted: + - "{ZA}" + - "{KN}" + - "{JP}" + - "{IT}" + - "{IE}" + - "{ID}" + - "{HR}" + - "{CR}" + - "{AL}" + - "{MY}" + - "{HK}" + - "{EE}" + - "{CL}" + - "{NZ}" + - "{TH}" + - "{IN}" + - "{AR}" + - "{KR}" + - "{BR}" + - "{VN}" + - "{IL}" + - "{SI}" + - "{GR}" + - "{DZ}" + - "{AM}" + - "{AZ}" + - "{BD}" + - "{BY}" + - "{MO}" + - "{CO}" + - "{CI}" + - "{CY}" + - "{EC}" + - "{EG}" + - "{SV}" + - "{ET}" + - "{GA}" + - "{GT}" + - "{HN}" + - "{IR}" + - "{KZ}" + - "{KE}" + - "{KW}" + - "{KG}" + - "{LB}" + - "{MT}" + - "{MQ}" + - "{MR}" + - "{MX}" + - "{MN}" + - "{MA}" + - "{MZ}" + - "{NG}" + - "{PK}" + - "{PH}" + - "{QA}" + - "{SA}" + - "{SN}" + - "{RS}" + - "{TN}" + - "{UY}" + - "{VE}" + - "{YE}" + - "{DO}" + - "{LR}" + - "{PY}" + + # Country rotation daemon settings + rotation: + enabled: true + # Seconds between country changes + interval: 120 + # Number of countries to rotate per cycle + total_to_change: 10 + + # Auto-update country list from Tor Metrics API (CLI: --auto-countries) + auto_countries: false + +# ============================================================================= +# HEALTH CHECK +# ============================================================================= +health_check: + # Target URL for circuit health verification (must support HTTPS) + url: "https://www.google.com/" + # Check interval (seconds) + interval: 12 + # Fail count before marking instance DOWN + max_fail: 1 + # Success count before marking instance UP + minimum_success: 1 + +# ============================================================================= +# USER AGENT +# ============================================================================= +user_agent: + # Tor Browser User-Agent string for header spoofing + # Should be updated at release time from Tor Browser's current UA + # DO NOT fetch at runtime - privacy risk + tor_browser: "Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0" + +# ============================================================================= +# LOGGING +# ============================================================================= +logging: + # Logs are OFF by default. Enable via --log flag or SPLITTER_LOG=1 + enabled: false + # Log directory (defaults to temp_files path) + dir: "/tmp/splitter" + # Log filename prefix (instance number appended) + name_prefix: "tor_log_" + # Log level: DEBUG, INFO, WARN, ERROR (only when enabled) + level: "INFO" + # Format: "json" (auto in Docker) or "text" (auto in terminal) + format: "text" + +# ============================================================================= +# PATHS +# ============================================================================= +paths: + # Temp directory for all generated files (deleted on startup if exists) + temp_files: "/tmp/splitter" + # Proxychains config file path + proxychains_file: "" # defaults to $HOME/.proxychains/proxychains.conf + +# ============================================================================= +# DNS (from legacy/ root settings.cfg - not used in func/ version) +# ============================================================================= +dns: + # dnsdist load balancer listen address + dist_listen: "0.0.0.0" + # dnsdist load balancer port + dist_port: 5353 + # Tor DNS listen address + tor_listen: "0.0.0.0" + +# ============================================================================= +# EXIT REPUTATION +# ============================================================================= +exit_reputation: + # Check exit node reputation before use via Onionoo API + # Queries relay flags, uptime, and bandwidth to filter suspicious exits + # (CLI: --exit-reputation, env: SPLITTER_EXIT_REPUTATION) + enabled: false diff --git a/configs/profiles.yaml b/configs/profiles.yaml new file mode 100644 index 0000000..91a5de6 --- /dev/null +++ b/configs/profiles.yaml @@ -0,0 +1,83 @@ +stealth: + description: "Maximum security - aggressive rotation, many instances" + instances: + per_country: 3 + countries: 8 + relay: + enforce: "entry" + proxy: + load_balance_algorithm: "roundrobin" + tor: + max_circuit_dirtiness: 10 + connection_padding: 1 + use_entry_guards: 1 + sandbox: true + conflux_enabled: true + congestion_control_auto: true + circuit_fingerprinting_resistance: true + logging: + enabled: false + country: + rotation_interval: 60 + total_to_change: 5 + +balanced: + description: "Good balance of security and performance" + instances: + per_country: 2 + countries: 6 + relay: + enforce: "exit" + proxy: + load_balance_algorithm: "roundrobin" + tor: + max_circuit_dirtiness: 15 + conflux_enabled: false + congestion_control_auto: true + sandbox: false + logging: + enabled: false + +streaming: + description: "Optimized for media streaming - higher throughput" + instances: + per_country: 1 + countries: 4 + relay: + enforce: "speed" + proxy: + load_balance_algorithm: "leastconn" + tor: + max_circuit_dirtiness: 300 + connection_padding: 0 + ipv6: true + conflux_enabled: true + congestion_control_auto: true + sandbox: false + logging: + enabled: false + +pentest: + description: "Penetration testing - extreme rotation, randomized UA" + instances: + per_country: 5 + countries: 10 + relay: + enforce: "exit" + proxy: + load_balance_algorithm: "roundrobin" + tor: + max_circuit_dirtiness: 10 + stream_isolation: true + conflux_enabled: false + congestion_control_auto: false + sandbox: false + circuit_fingerprinting_resistance: true + logging: + enabled: true + level: "DEBUG" + country: + rotation_interval: 60 + total_to_change: 8 + health_check: + exit_reputation: true diff --git a/configs/useragents.yaml b/configs/useragents.yaml new file mode 100644 index 0000000..d649c74 --- /dev/null +++ b/configs/useragents.yaml @@ -0,0 +1,17 @@ +# Bundled Tor Browser User-Agent strings +# Updated at release time — DO NOT fetch at runtime (privacy risk) +# Each UA should be a realistic Tor Browser fingerprint +user_agents: + - "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" + - "Mozilla/5.0 (Windows NT 10.0; rv:115.0) Gecko/20100101 Firefox/115.0" + - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0" + - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:115.0) Gecko/20100101 Firefox/115.0" + - "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" + - "Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0" + - "Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0" + - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.0" + - "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0" + - "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + - "Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0" + - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:91.0) Gecko/20100101 Firefox/91.0" +default: "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..8125119 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,33 @@ +version: "3.8" + +services: + splitter: + build: + context: . + dockerfile: Dockerfile + ports: + - "63536:63536" # SOCKS + - "63537:63537" # HTTP + - "63540:63540" # Status / healthz + environment: + - SPLITTER_INSTANCES=2 + - SPLITTER_COUNTRIES=6 + - SPLITTER_RELAY_ENFORCE=exit + - SPLITTER_LOG=1 + volumes: + # Mount local configs (read-only) so you can iterate on profiles and defaults + - ./configs:/splitter/configs:ro + # Persistent runtime data + - splitter-data:/splitter/data + # Optionally mount source for in-container builds / live edits. Commented by default. + # - ./:/workspace:rw + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:63540/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + splitter-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..490346a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,289 @@ +services: + # =========================================================================== + # DEFAULT PROFILE: BALANCED + # Good balance of security and performance. Recommended for most users. + # + # Relay mode: exit (entry nodes from any country, exit nodes from selected) + # Load balancing: roundrobin + # Instances: 2 per country, 6 countries + # ==================================================================== + splitter: + image: ghcr.io/millaguie/splitter:v2.0.0-beta + ports: + - "63536:63536" # SOCKS5 proxy + - "63537:63537" # HTTP CONNECT proxy + - "63539:63539" # HAProxy stats (password printed at startup) + - "63540:63540" # Status API /healthz /metrics + environment: + - SPLITTER_INSTANCES=2 + - SPLITTER_COUNTRIES=6 + - SPLITTER_RELAY_ENFORCE=exit + - SPLITTER_LOG=1 + volumes: + - ./configs:/splitter/configs:ro + - splitter-data:/splitter/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:63540/status"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # =========================================================================== + # ALTERNATIVE PROFILES + # + # To use one of these: + # 1. Comment out (or rename) the "splitter" service above + # 2. Uncomment the desired profile below + # 3. Run: docker compose up -d + # + # IMPORTANT: Only ONE splitter service can run at a time unless you change + # the host port mappings to avoid conflicts. + # =========================================================================== + + # -------------------------------------------------------------------------- + # MAX SPEED — EUROPE + # + # Purpose: Low-latency Tor for users in or near Europe. Best for + # streaming, web browsing, and general use with fast response. + # + # How it works: + # - Relay mode: speed (both entry AND exit nodes from selected countries, + # keeping the entire circuit geographically close for minimal latency) + # - Load balancing: leastconn (sends traffic to the least busy instance) + # - Conflux: ON (splits traffic across multiple circuit legs for more + # bandwidth — Tor 0.4.8+) + # - Congestion control: ON (dramatically improves throughput on + # long-distance circuits — Tor 0.4.7+) + # - IPv6: ON (dual-stack for better connectivity where available) + # - Countries: DE, FR, NL, CH, SE (core European Tor hubs with high + # relay density and low mutual latency) + # + # Trade-offs: Circuits are concentrated in fewer countries, which slightly + # reduces anonymity compared to the balanced profile. + # + # Usage: docker compose up -d splitter-speed-eu + # -------------------------------------------------------------------------- + # splitter-speed-eu: + # image: ghcr.io/millaguie/splitter:v2.0.0-beta + # ports: + # - "63541:63536" # SOCKS5 proxy + # - "63542:63537" # HTTP CONNECT proxy + # - "63543:63539" # HAProxy stats + # - "63544:63540" # Status API /healthz /metrics + # environment: + # - SPLITTER_INSTANCES=1 + # - SPLITTER_COUNTRIES={DE},{FR},{NL},{CH},{SE} + # - SPLITTER_RELAY_ENFORCE=speed + # - SPLITTER_PROXY_LOAD_BALANCE_ALGORITHM=leastconn + # - SPLITTER_TOR_CONFLUX_ENABLED=true + # - SPLITTER_TOR_CONGESTION_CONTROL_AUTO=true + # - SPLITTER_TOR_IPV6=true + # - SPLITTER_LOG=1 + # volumes: + # - ./configs:/splitter/configs:ro + # - splitter-data-speed-eu:/splitter/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD", "curl", "-sf", "http://localhost:63544/status"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 60s + + # -------------------------------------------------------------------------- + # MAX SPEED — LATIN AMERICA + # + # Purpose: Low-latency Tor for users in Latin America. Routes circuits + # through Americas-based relays for minimal round-trip time. + # + # How it works: + # - Relay mode: speed (both entry AND exit from selected countries) + # - Load balancing: leastconn + # - Conflux: ON, Congestion control: ON + # - Countries: US, CA, PA, BR, CL + # US/CA: High relay density, fast backbone + # PA: Panama — good connectivity hub for the region + # BR: Brazil — largest Tor network in South America + # CL: Chile — reliable relays, good Pacific connectivity + # + # Trade-offs: Brazil and Chile have fewer exit relays than Europe, which + # may affect availability. Some exits may be slower. + # + # Usage: docker compose up -d splitter-speed-latam + # -------------------------------------------------------------------------- + # splitter-speed-latam: + # image: ghcr.io/millaguie/splitter:v2.0.0-beta + # ports: + # - "63545:63536" # SOCKS5 proxy + # - "63546:63537" # HTTP CONNECT proxy + # - "63547:63539" # HAProxy stats + # - "63548:63540" # Status API /healthz /metrics + # environment: + # - SPLITTER_INSTANCES=1 + # - SPLITTER_COUNTRIES={US},{CA},{PA},{BR},{CL} + # - SPLITTER_RELAY_ENFORCE=speed + # - SPLITTER_PROXY_LOAD_BALANCE_ALGORITHM=leastconn + # - SPLITTER_TOR_CONFLUX_ENABLED=true + # - SPLITTER_TOR_CONGESTION_CONTROL_AUTO=true + # - SPLITTER_LOG=1 + # volumes: + # - ./configs:/splitter/configs:ro + # - splitter-data-speed-latam:/splitter/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD", "curl", "-sf", "http://localhost:63548/status"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 60s + + # -------------------------------------------------------------------------- + # MAX PRIVACY — STEALTH + # + # Purpose: Maximum anonymity for journalists, activists, whistleblowers, + # or anyone facing targeted surveillance. + # + # How it works: + # - Relay mode: entry (entry/guard nodes from selected countries, + # exit nodes from ANY country — makes it harder to correlate + # entry and exit by geography) + # - Connection padding: ON (inserts dummy traffic to defeat + # traffic analysis and packet size correlation attacks) + # - Entry guards: ON (reuses trusted entry nodes to prevent + # introduction of malicious first hops) + # - Sandbox: ON (Tor's built-in seccomp-bpf syscall sandbox — + # limits kernel attack surface on Linux) + # - Circuit fingerprinting resistance: ON (adaptive rotation + # intervals that defeat timing correlation attacks) + # - Country rotation: 60s (frequently rotates countries to prevent + # long-term correlation) + # - Instances: 3 per country, 8 countries (more circuits = more + # noise for any observer) + # - Logging: OFF (no logs, no crime) + # + # Trade-offs: Higher latency, lower throughput, more CPU/memory usage. + # Connection padding increases bandwidth consumption by ~10-20%. + # + # Usage: docker compose up -d splitter-stealth + # -------------------------------------------------------------------------- + # splitter-stealth: + # image: ghcr.io/millaguie/splitter:v2.0.0-beta + # ports: + # - "63549:63536" # SOCKS5 proxy + # - "63550:63537" # HTTP CONNECT proxy + # - "63551:63539" # HAProxy stats + # - "63552:63540" # Status API /healthz /metrics + # environment: + # - SPLITTER_INSTANCES=3 + # - SPLITTER_COUNTRIES=8 + # - SPLITTER_RELAY_ENFORCE=entry + # - SPLITTER_TOR_CONNECTION_PADDING=1 + # - SPLITTER_TOR_USE_ENTRY_GUARDS=1 + # - SPLITTER_TOR_SANDBOX=true + # - SPLITTER_TOR_CONFLUX_ENABLED=true + # - SPLITTER_TOR_CIRCUIT_FINGERPRINTING_RESISTANCE=true + # - SPLITTER_COUNTRY_ROTATION_INTERVAL=60 + # - SPLITTER_LOG=0 + # volumes: + # - ./configs:/splitter/configs:ro + # - splitter-data-stealth:/splitter/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD", "curl", "-sf", "http://localhost:63552/status"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 60s + + # -------------------------------------------------------------------------- + # PENETRATION TESTING — PENTEST + # + # Purpose: Security research, pentesting, OSINT. Aggressive circuit + # rotation with stream isolation and randomized User-Agents + # to simulate different users from different locations. + # + # How it works: + # - Relay mode: exit (exit nodes from selected countries for GeoIP + # bypass — test how services behave from different regions) + # - Stream isolation: ON (each destination gets its own circuit via + # SOCKS5 auth isolation — prevents request correlation) + # - Circuit fingerprinting resistance: ON (adaptive rotation defeats + # timing analysis) + # - Exit reputation: ON (checks Onionoo API to filter honeypots + # and newly-appeared suspicious exits) + # - Country rotation: 60s (frequent changes to maximize coverage) + # - Instances: 5 per country, 10 countries (maximum coverage) + # - Logging: ON at DEBUG level (full visibility for analysis) + # + # Trade-offs: Very resource-intensive (50 Tor instances). High bandwidth + # and memory usage. DEBUG logs may contain sensitive data — + # only use in controlled environments. + # + # Usage: docker compose up -d splitter-pentest + # -------------------------------------------------------------------------- + # splitter-pentest: + # image: ghcr.io/millaguie/splitter:v2.0.0-beta + # ports: + # - "63553:63536" # SOCKS5 proxy + # - "63554:63537" # HTTP CONNECT proxy + # - "63555:63539" # HAProxy stats + # - "63556:63540" # Status API /healthz /metrics + # environment: + # - SPLITTER_INSTANCES=5 + # - SPLITTER_COUNTRIES=10 + # - SPLITTER_RELAY_ENFORCE=exit + # - SPLITTER_TOR_STREAM_ISOLATION=true + # - SPLITTER_TOR_CIRCUIT_FINGERPRINTING_RESISTANCE=true + # - SPLITTER_EXIT_REPUTATION=true + # - SPLITTER_COUNTRY_ROTATION_INTERVAL=60 + # - SPLITTER_VERBOSE=true + # - SPLITTER_LOG=1 + # - SPLITTER_LOG_LEVEL=DEBUG + # volumes: + # - ./configs:/splitter/configs:ro + # - splitter-data-pentest:/splitter/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD", "curl", "-sf", "http://localhost:63556/status"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 120s + + # =========================================================================== + # OPTIONAL MONITORING STACK + # Uncomment to enable Prometheus + Grafana alongside SPLITTER. + # Requires SPLITTER_LOG=1 and the metrics endpoint enabled. + # =========================================================================== + # + # prometheus: + # image: prom/prometheus:latest + # ports: + # - "9090:9090" + # volumes: + # - ./configs/prometheus.yml:/etc/prometheus/prometheus.yml:ro + # - prometheus-data:/prometheus + # restart: unless-stopped + # depends_on: + # - splitter + # + # grafana: + # image: grafana/grafana:latest + # ports: + # - "3000:3000" + # volumes: + # - grafana-data:/var/lib/grafana + # restart: unless-stopped + # depends_on: + # - prometheus + +volumes: + splitter-data: + # splitter-data-speed-eu: + # splitter-data-speed-latam: + # splitter-data-stealth: + # splitter-data-pentest: + # prometheus-data: + # grafana-data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..ba61368 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e + +mkdir -p /tmp/splitter + +exec /usr/local/bin/splitter run "$@" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a13a64 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/user/splitter + +go 1.24 + +require ( + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47edb24 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/circuit/adaptive.go b/internal/circuit/adaptive.go new file mode 100644 index 0000000..8edb7a5 --- /dev/null +++ b/internal/circuit/adaptive.go @@ -0,0 +1,21 @@ +package circuit + +import ( + "time" +) + +func AdaptiveInterval(pattern *TrafficPattern, baseMin, baseMax time.Duration) (time.Duration, string) { + if pattern.IsBurst() { + interval := randomInterval(3*time.Second, 7*time.Second) + return interval, "aggressive" + } + + rate := pattern.RequestRate() + if rate > 0.1 { + interval := randomInterval(10*time.Second, 20*time.Second) + return interval, "moderate" + } + + interval := randomInterval(20*time.Second, 60*time.Second) + return interval, "idle" +} diff --git a/internal/circuit/adaptive_test.go b/internal/circuit/adaptive_test.go new file mode 100644 index 0000000..8b5f74b --- /dev/null +++ b/internal/circuit/adaptive_test.go @@ -0,0 +1,129 @@ +package circuit + +import ( + "testing" + "time" +) + +func TestAdaptiveInterval_Burst(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 5) + for i := 0; i < 5; i++ { + tp.RecordRequest() + } + + for i := 0; i < 100; i++ { + interval, mode := AdaptiveInterval(tp, 10*time.Second, 15*time.Second) + if mode != "aggressive" { + t.Errorf("mode = %q, want %q", mode, "aggressive") + } + if interval < 3*time.Second || interval > 7*time.Second { + t.Errorf("burst interval = %v, want range [3s, 7s]", interval) + } + } +} + +func TestAdaptiveInterval_Moderate(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 20) + tp.RecordRequest() + + for i := 0; i < 100; i++ { + interval, mode := AdaptiveInterval(tp, 10*time.Second, 15*time.Second) + if mode != "moderate" { + t.Errorf("mode = %q, want %q", mode, "moderate") + } + if interval < 10*time.Second || interval > 20*time.Second { + t.Errorf("moderate interval = %v, want range [10s, 20s]", interval) + } + } +} + +func TestAdaptiveInterval_Idle(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 20) + + for i := 0; i < 100; i++ { + interval, mode := AdaptiveInterval(tp, 10*time.Second, 15*time.Second) + if mode != "idle" { + t.Errorf("mode = %q, want %q", mode, "idle") + } + if interval < 20*time.Second || interval > 60*time.Second { + t.Errorf("idle interval = %v, want range [20s, 60s]", interval) + } + } +} + +func TestAdaptiveInterval_AlwaysPositive(t *testing.T) { + cases := []struct { + name string + tp *TrafficPattern + }{ + {"burst", func() *TrafficPattern { + tp := NewTrafficPattern(30*time.Second, 3) + tp.RecordRequest() + tp.RecordRequest() + tp.RecordRequest() + return tp + }()}, + {"moderate", func() *TrafficPattern { tp := NewTrafficPattern(30*time.Second, 20); tp.RecordRequest(); return tp }()}, + {"idle", NewTrafficPattern(30*time.Second, 20)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for i := 0; i < 50; i++ { + interval, _ := AdaptiveInterval(tc.tp, 10*time.Second, 15*time.Second) + if interval <= 0 { + t.Errorf("interval = %v, want > 0", interval) + } + } + }) + } +} + +func TestAdaptiveInterval_Variance(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 20) + + seen := make(map[time.Duration]bool) + for i := 0; i < 50; i++ { + interval, _ := AdaptiveInterval(tp, 10*time.Second, 15*time.Second) + seen[interval] = true + } + + if len(seen) < 5 { + t.Errorf("expected at least 5 unique intervals in idle mode over 50 samples, got %d", len(seen)) + } +} + +func TestAdaptiveInterval_BurstVariance(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 3) + tp.RecordRequest() + tp.RecordRequest() + tp.RecordRequest() + + seen := make(map[time.Duration]bool) + for i := 0; i < 50; i++ { + interval, _ := AdaptiveInterval(tp, 10*time.Second, 15*time.Second) + seen[interval] = true + } + + if len(seen) < 5 { + t.Errorf("expected at least 5 unique intervals in burst mode over 50 samples, got %d", len(seen)) + } +} + +func TestAdaptiveInterval_Boundary_ModerateRate(t *testing.T) { + tp := NewTrafficPattern(1*time.Second, 100) + tp.RecordRequest() + + rate := tp.RequestRate() + if rate <= 0.1 { + t.Skipf("rate %f too low for moderate test", rate) + } + + interval, mode := AdaptiveInterval(tp, 10*time.Second, 15*time.Second) + if mode != "moderate" { + t.Errorf("mode = %q for rate %f, want moderate", mode, rate) + } + if interval < 10*time.Second || interval > 20*time.Second { + t.Errorf("interval = %v, want [10s, 20s]", interval) + } +} diff --git a/internal/circuit/client.go b/internal/circuit/client.go new file mode 100644 index 0000000..1083ebd --- /dev/null +++ b/internal/circuit/client.go @@ -0,0 +1,120 @@ +package circuit + +import ( + "bufio" + "context" + "encoding/hex" + "fmt" + "net" + "os" + "strings" +) + +type Client struct { + addr string + cookiePath string + conn net.Conn + reader *bufio.Reader +} + +func NewClient(addr string, cookiePath string) *Client { + return &Client{ + addr: addr, + cookiePath: cookiePath, + } +} + +func (c *Client) Connect(ctx context.Context) error { + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", c.addr) + if err != nil { + return fmt.Errorf("Connect: %w", err) + } + c.conn = conn + c.reader = bufio.NewReader(conn) + return nil +} + +func (c *Client) Authenticate(ctx context.Context) error { + cookieBytes, err := os.ReadFile(c.cookiePath) + if err != nil { + return fmt.Errorf("Authenticate: read cookie: %w", err) + } + + hexCookie := hex.EncodeToString(cookieBytes) + cmd := fmt.Sprintf("AUTHENTICATE %s\r\n", hexCookie) + + if err := c.send(cmd); err != nil { + return fmt.Errorf("Authenticate: send: %w", err) + } + + resp, err := c.readLine() + if err != nil { + return fmt.Errorf("Authenticate: read response: %w", err) + } + + if !strings.HasPrefix(resp, "250") { + return fmt.Errorf("Authenticate: unexpected response: %s", resp) + } + + return nil +} + +func (c *Client) SignalNewnym(ctx context.Context) error { + if err := c.send("SIGNAL NEWNYM\r\n"); err != nil { + return fmt.Errorf("SignalNewnym: send: %w", err) + } + + resp, err := c.readLine() + if err != nil { + return fmt.Errorf("SignalNewnym: read response: %w", err) + } + + if !strings.HasPrefix(resp, "250") { + return fmt.Errorf("SignalNewnym: unexpected response: %s", resp) + } + + return nil +} + +func (c *Client) Close() error { + if c.conn == nil { + return nil + } + err := c.conn.Close() + c.conn = nil + c.reader = nil + return err +} + +func (c *Client) send(cmd string) error { + _, err := fmt.Fprint(c.conn, cmd) + return err +} + +func (c *Client) readLine() (string, error) { + line, err := c.reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil +} + +func BuildAuthCommand(cookieBytes []byte) string { + return fmt.Sprintf("AUTHENTICATE %s\r\n", hex.EncodeToString(cookieBytes)) +} + +func ParseResponse(line string) (code string, message string, err error) { + line = strings.TrimRight(line, "\r\n") + if len(line) < 4 { + return "", "", fmt.Errorf("ParseResponse: response too short: %q", line) + } + + code = line[:3] + message = strings.TrimSpace(line[3:]) + return code, message, nil +} + +func BuildNewnymCommand() string { + return "SIGNAL NEWNYM\r\n" +} diff --git a/internal/circuit/client_test.go b/internal/circuit/client_test.go new file mode 100644 index 0000000..713c886 --- /dev/null +++ b/internal/circuit/client_test.go @@ -0,0 +1,224 @@ +package circuit + +import ( + "strings" + "testing" +) + +func TestClient_AuthenticateCookieHex(t *testing.T) { + cookie := []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} + cmd := BuildAuthCommand(cookie) + + expected := "AUTHENTICATE 0123456789abcdef\r\n" + if cmd != expected { + t.Errorf("BuildAuthCommand() = %q, want %q", cmd, expected) + } +} + +func TestClient_AuthenticateCookieHexEmpty(t *testing.T) { + cmd := BuildAuthCommand([]byte{}) + + expected := "AUTHENTICATE \r\n" + if cmd != expected { + t.Errorf("BuildAuthCommand(empty) = %q, want %q", cmd, expected) + } +} + +func TestClient_ParseResponse_250(t *testing.T) { + code, msg, err := ParseResponse("250 OK") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "250" { + t.Errorf("code = %q, want %q", code, "250") + } + if msg != "OK" { + t.Errorf("message = %q, want %q", msg, "OK") + } +} + +func TestClient_ParseResponse_250WithCRLF(t *testing.T) { + code, msg, err := ParseResponse("250 OK\r\n") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "250" { + t.Errorf("code = %q, want %q", code, "250") + } + if msg != "OK" { + t.Errorf("message = %q, want %q", msg, "OK") + } +} + +func TestClient_ParseResponse_MidReply(t *testing.T) { + code, msg, err := ParseResponse("250-PROTOCOLINFO") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "250" { + t.Errorf("code = %q, want %q", code, "250") + } + if msg != "-PROTOCOLINFO" { + t.Errorf("message = %q, want %q", msg, "-PROTOCOLINFO") + } +} + +func TestClient_ParseResponse_515BadAuth(t *testing.T) { + code, msg, err := ParseResponse("515 Bad authentication") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "515" { + t.Errorf("code = %q, want %q", code, "515") + } + if msg != "Bad authentication" { + t.Errorf("message = %q, want %q", msg, "Bad authentication") + } +} + +func TestClient_ParseResponse_650StatusEvent(t *testing.T) { + code, msg, err := ParseResponse("650 STREAM 1234 NEW 0 example.com:443") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "650" { + t.Errorf("code = %q, want %q", code, "650") + } + if !strings.Contains(msg, "STREAM") { + t.Errorf("message should contain 'STREAM', got %q", msg) + } +} + +func TestClient_ParseResponse_ExactlyThreeChars(t *testing.T) { + _, _, err := ParseResponse("250") + if err == nil { + t.Fatal("ParseResponse should reject 3-char input with no space separator") + } +} + +func TestClient_ParseResponse_ThreeCharsPlusSpace(t *testing.T) { + code, msg, err := ParseResponse("250 ") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "250" { + t.Errorf("code = %q, want %q", code, "250") + } + if msg != "" { + t.Errorf("message = %q, want empty (space trimmed)", msg) + } +} + +func TestClient_ParseResponse_250WithExtra(t *testing.T) { + code, msg, err := ParseResponse("250-SOME MULTILINE") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "250" { + t.Errorf("code = %q, want %q", code, "250") + } + if msg != "-SOME MULTILINE" { + t.Errorf("message = %q, want %q", msg, "-SOME MULTILINE") + } +} + +func TestClient_ParseResponse_Error(t *testing.T) { + code, msg, err := ParseResponse("515 Bad authentication") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "515" { + t.Errorf("code = %q, want %q", code, "515") + } + if msg != "Bad authentication" { + t.Errorf("message = %q, want %q", msg, "Bad authentication") + } +} + +func TestClient_ParseResponse_TooShort(t *testing.T) { + _, _, err := ParseResponse("25") + if err == nil { + t.Error("expected error for short response, got nil") + } +} + +func TestClient_ParseResponse_Empty(t *testing.T) { + _, _, err := ParseResponse("") + if err == nil { + t.Error("expected error for empty response, got nil") + } +} + +func TestClient_SignalNewnym(t *testing.T) { + cmd := BuildNewnymCommand() + + expected := "SIGNAL NEWNYM\r\n" + if cmd != expected { + t.Errorf("BuildNewnymCommand() = %q, want %q", cmd, expected) + } +} + +func TestClient_CloseWhenNil(t *testing.T) { + c := NewClient("127.0.0.1:9051", "/tmp/cookie") + if err := c.Close(); err != nil { + t.Errorf("Close() on nil conn error = %v, want nil", err) + } +} + +func TestNewClient_Fields(t *testing.T) { + c := NewClient("127.0.0.1:9052", "/tmp/test_cookie") + if c.addr != "127.0.0.1:9052" { + t.Errorf("addr = %q, want %q", c.addr, "127.0.0.1:9052") + } + if c.cookiePath != "/tmp/test_cookie" { + t.Errorf("cookiePath = %q, want %q", c.cookiePath, "/tmp/test_cookie") + } + if c.conn != nil { + t.Error("conn should be nil on creation") + } +} + +func TestBuildAuthCommand_FullCookie(t *testing.T) { + cookie := []byte{0x00, 0xff, 0xab, 0xcd, 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44} + cmd := BuildAuthCommand(cookie) + + if !strings.HasPrefix(cmd, "AUTHENTICATE ") { + t.Errorf("command should start with AUTHENTICATE, got %q", cmd[:20]) + } + if !strings.HasSuffix(cmd, "\r\n") { + t.Error("command should end with CRLF") + } + + hexPart := strings.TrimSuffix(strings.TrimPrefix(cmd, "AUTHENTICATE "), "\r\n") + if len(hexPart) != 64 { + t.Errorf("hex cookie length = %d, want 64 (32 bytes hex-encoded)", len(hexPart)) + } +} + +func TestParseResponse_NumericCodes(t *testing.T) { + tests := []struct { + input string + code string + }{ + {"250 OK", "250"}, + {"510 Command not recognized", "510"}, + {"515 Bad authentication", "515"}, + {"552 Unrecognized info", "552"}, + {"650 STREAM", "650"}, + } + + for _, tt := range tests { + t.Run(tt.input[:3], func(t *testing.T) { + code, _, err := ParseResponse(tt.input) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != tt.code { + t.Errorf("code = %q, want %q", code, tt.code) + } + }) + } +} diff --git a/internal/circuit/doc.go b/internal/circuit/doc.go new file mode 100644 index 0000000..e0faed8 --- /dev/null +++ b/internal/circuit/doc.go @@ -0,0 +1,2 @@ +// Package circuit handles Tor circuit renewal via the control protocol using NEWNYM signals for SPLITTER. +package circuit diff --git a/internal/circuit/helpers_test.go b/internal/circuit/helpers_test.go new file mode 100644 index 0000000..c43f866 --- /dev/null +++ b/internal/circuit/helpers_test.go @@ -0,0 +1,111 @@ +package circuit + +import ( + "encoding/hex" + "strings" + "testing" + "time" +) + +func TestBuildAuthCommand_SingleByteCookie(t *testing.T) { + cookie := []byte{0xaa} + cmd := BuildAuthCommand(cookie) + expected := "AUTHENTICATE " + hex.EncodeToString(cookie) + "\r\n" + if cmd != expected { + t.Errorf("BuildAuthCommand(1 byte) = %q, want %q", cmd, expected) + } +} + +func TestBuildAuthCommand_16ByteCookie(t *testing.T) { + cookie := make([]byte, 16) + for i := range cookie { + cookie[i] = byte(i) + } + cmd := BuildAuthCommand(cookie) + + hexPart := strings.TrimSuffix(strings.TrimPrefix(cmd, "AUTHENTICATE "), "\r\n") + if len(hexPart) != 32 { + t.Errorf("hex cookie length = %d, want 32 (16 bytes)", len(hexPart)) + } +} + +func TestBuildAuthCommand_AllZeroCookie(t *testing.T) { + cookie := make([]byte, 32) + cmd := BuildAuthCommand(cookie) + if !strings.HasPrefix(cmd, "AUTHENTICATE ") { + t.Errorf("command should start with AUTHENTICATE, got %q", cmd[:20]) + } + if !strings.HasSuffix(cmd, "\r\n") { + t.Error("command should end with CRLF") + } + hexPart := strings.TrimSuffix(strings.TrimPrefix(cmd, "AUTHENTICATE "), "\r\n") + expected := strings.Repeat("00", 32) + if hexPart != expected { + t.Errorf("hex part = %q, want %q", hexPart, expected) + } +} + +func TestBuildNewnymCommand_Immutable(t *testing.T) { + cmd1 := BuildNewnymCommand() + cmd2 := BuildNewnymCommand() + if cmd1 != cmd2 { + t.Errorf("BuildNewnymCommand() returned different values: %q vs %q", cmd1, cmd2) + } +} + +func TestParseResponse_WithOnlyCode(t *testing.T) { + code, msg, err := ParseResponse("250") + if err == nil { + t.Fatal("ParseResponse should reject 3-char input with no separator") + } + if code != "" || msg != "" { + t.Errorf("expected empty code/msg on error, got code=%q msg=%q", code, msg) + } +} + +func TestParseResponse_CodeOnlyWithSpace(t *testing.T) { + code, msg, err := ParseResponse("250 ") + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if code != "250" { + t.Errorf("code = %q, want 250", code) + } + if msg != "" { + t.Errorf("message = %q, want empty", msg) + } +} + +func TestParseResponse_TwoCharInput(t *testing.T) { + _, _, err := ParseResponse("25") + if err == nil { + t.Error("expected error for 2-char input") + } +} + +func TestParseResponse_OneCharInput(t *testing.T) { + _, _, err := ParseResponse("2") + if err == nil { + t.Error("expected error for 1-char input") + } +} + +func TestRandomInterval_LargeRange(t *testing.T) { + min := 1 * time.Second + max := 60 * time.Second + for i := 0; i < 100; i++ { + got := randomInterval(min, max) + if got < min || got > max { + t.Errorf("randomInterval(1s, 60s) = %v, out of range", got) + } + } +} + +func TestRandomInterval_OneNanosecondDelta(t *testing.T) { + min := 10 * time.Second + max := 10*time.Second + 1 + got := randomInterval(min, max) + if got < min || got > max { + t.Errorf("randomInterval(10s, 10s+1ns) = %v, out of range", got) + } +} diff --git a/internal/circuit/pattern.go b/internal/circuit/pattern.go new file mode 100644 index 0000000..b84ddde --- /dev/null +++ b/internal/circuit/pattern.go @@ -0,0 +1,64 @@ +package circuit + +import ( + "sync" + "time" +) + +type TrafficPattern struct { + mu sync.Mutex + requestTimes []time.Time + windowSize time.Duration + burstThreshold int +} + +func NewTrafficPattern(windowSize time.Duration, burstThreshold int) *TrafficPattern { + return &TrafficPattern{ + windowSize: windowSize, + burstThreshold: burstThreshold, + } +} + +func (tp *TrafficPattern) RecordRequest() { + tp.mu.Lock() + defer tp.mu.Unlock() + + now := time.Now() + tp.requestTimes = append(tp.requestTimes, now) + tp.pruneLocked(now) +} + +func (tp *TrafficPattern) IsBurst() bool { + tp.mu.Lock() + defer tp.mu.Unlock() + + tp.pruneLocked(time.Now()) + return len(tp.requestTimes) >= tp.burstThreshold +} + +func (tp *TrafficPattern) RequestRate() float64 { + tp.mu.Lock() + defer tp.mu.Unlock() + + tp.pruneLocked(time.Now()) + if len(tp.requestTimes) == 0 { + return 0 + } + + oldest := tp.requestTimes[0] + windowSeconds := time.Since(oldest).Seconds() + if windowSeconds <= 0 { + return float64(len(tp.requestTimes)) + } + + return float64(len(tp.requestTimes)) / windowSeconds +} + +func (tp *TrafficPattern) pruneLocked(now time.Time) { + cutoff := now.Add(-tp.windowSize) + i := 0 + for i < len(tp.requestTimes) && tp.requestTimes[i].Before(cutoff) { + i++ + } + tp.requestTimes = tp.requestTimes[i:] +} diff --git a/internal/circuit/pattern_test.go b/internal/circuit/pattern_test.go new file mode 100644 index 0000000..61176fd --- /dev/null +++ b/internal/circuit/pattern_test.go @@ -0,0 +1,143 @@ +package circuit + +import ( + "testing" + "time" +) + +func TestTrafficPattern_IsBurst_NoRequests(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 20) + if tp.IsBurst() { + t.Error("IsBurst() = true with no requests, want false") + } +} + +func TestTrafficPattern_IsBurst_ThresholdExceeded(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 5) + for i := 0; i < 5; i++ { + tp.RecordRequest() + } + if !tp.IsBurst() { + t.Error("IsBurst() = false after 5 requests with threshold 5, want true") + } +} + +func TestTrafficPattern_IsBurst_BelowThreshold(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 10) + for i := 0; i < 9; i++ { + tp.RecordRequest() + } + if tp.IsBurst() { + t.Error("IsBurst() = true after 9 requests with threshold 10, want false") + } +} + +func TestTrafficPattern_RecordRequest_PruneOld(t *testing.T) { + tp := NewTrafficPattern(100*time.Millisecond, 10) + + tp.RecordRequest() + time.Sleep(150 * time.Millisecond) + + tp.RecordRequest() + + tp.mu.Lock() + count := len(tp.requestTimes) + tp.mu.Unlock() + + if count != 1 { + t.Errorf("expected 1 request after prune, got %d", count) + } +} + +func TestTrafficPattern_RequestRate_NoRequests(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 20) + rate := tp.RequestRate() + if rate != 0 { + t.Errorf("RequestRate() = %f, want 0 with no requests", rate) + } +} + +func TestTrafficPattern_RequestRate_Accuracy(t *testing.T) { + tp := NewTrafficPattern(10*time.Second, 100) + + for i := 0; i < 10; i++ { + tp.RecordRequest() + } + + rate := tp.RequestRate() + if rate <= 0 { + t.Errorf("RequestRate() = %f, want > 0", rate) + } + + tp.mu.Lock() + count := len(tp.requestTimes) + tp.mu.Unlock() + + if count != 10 { + t.Errorf("expected 10 requests in window, got %d", count) + } +} + +func TestTrafficPattern_RequestRate_AllPruned(t *testing.T) { + tp := NewTrafficPattern(50*time.Millisecond, 10) + + tp.RecordRequest() + time.Sleep(100 * time.Millisecond) + + rate := tp.RequestRate() + if rate != 0 { + t.Errorf("RequestRate() = %f after all pruned, want 0", rate) + } +} + +func TestTrafficPattern_ConcurrentAccess(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 100) + done := make(chan struct{}) + + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 100; j++ { + tp.RecordRequest() + _ = tp.IsBurst() + _ = tp.RequestRate() + } + done <- struct{}{} + }() + } + + for i := 0; i < 10; i++ { + <-done + } +} + +func TestTrafficPattern_WindowPruningBoundary(t *testing.T) { + tp := NewTrafficPattern(200*time.Millisecond, 10) + + tp.RecordRequest() + time.Sleep(100 * time.Millisecond) + tp.RecordRequest() + time.Sleep(100 * time.Millisecond) + + tp.RecordRequest() + + tp.mu.Lock() + count := len(tp.requestTimes) + tp.mu.Unlock() + + if count == 0 { + t.Error("expected at least 1 request remaining after staggered recording") + } +} + +func TestTrafficPattern_IsBurst_ExactThreshold(t *testing.T) { + tp := NewTrafficPattern(30*time.Second, 3) + tp.RecordRequest() + tp.RecordRequest() + if tp.IsBurst() { + t.Error("IsBurst() = true with 2 requests and threshold 3") + } + tp.RecordRequest() + if !tp.IsBurst() { + t.Error("IsBurst() = false with 3 requests and threshold 3") + } +} diff --git a/internal/circuit/renewer.go b/internal/circuit/renewer.go new file mode 100644 index 0000000..89c3c44 --- /dev/null +++ b/internal/circuit/renewer.go @@ -0,0 +1,188 @@ +package circuit + +import ( + "context" + "fmt" + "log/slog" + "math/rand/v2" + "net" + "time" + + "github.com/user/splitter/internal/cli" +) + +const ( + defaultMinInterval = 10 * time.Second + defaultMaxInterval = 15 * time.Second +) + +type circuitInstance struct { + id int + controlAddr string + cookiePath string + minInterval time.Duration + maxInterval time.Duration +} + +type Renewer struct { + instances []*circuitInstance + cancelFunc context.CancelFunc + done chan struct{} + started bool + pattern *TrafficPattern +} + +func NewRenewer() *Renewer { + return &Renewer{} +} + +func (r *Renewer) AddInstance(id int, controlPort int, cookiePath string) { + addr := net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", controlPort)) + r.instances = append(r.instances, &circuitInstance{ + id: id, + controlAddr: addr, + cookiePath: cookiePath, + minInterval: defaultMinInterval, + maxInterval: defaultMaxInterval, + }) +} + +func (r *Renewer) SetTrafficPattern(tp *TrafficPattern) { + r.pattern = tp +} + +func (r *Renewer) Start(ctx context.Context) error { + if len(r.instances) == 0 { + return fmt.Errorf("Start: no instances added") + } + + ctx, cancel := context.WithCancel(ctx) + r.cancelFunc = cancel + r.done = make(chan struct{}) + r.started = true + + go func() { + defer close(r.done) + r.runAll(ctx) + }() + + slog.Info("circuit renewal started", "instances", len(r.instances)) + return nil +} + +func (r *Renewer) Stop() error { + if !r.started { + return nil + } + if r.cancelFunc != nil { + r.cancelFunc() + } + <-r.done + slog.Info("circuit renewal stopped") + return nil +} + +func (r *Renewer) runAll(ctx context.Context) { + doneCh := make(chan struct{}, len(r.instances)) + + for _, inst := range r.instances { + go func(ci *circuitInstance) { + r.runInstance(ctx, ci) + doneCh <- struct{}{} + }(inst) + } + + for range r.instances { + select { + case <-ctx.Done(): + return + case <-doneCh: + } + } +} + +func (r *Renewer) runInstance(ctx context.Context, ci *circuitInstance) { + client := NewClient(ci.controlAddr, ci.cookiePath) + + if err := client.Connect(ctx); err != nil { + slog.Error("circuit renewal connect failed", + cli.InstanceField(ci.id), + "error", err, + ) + return + } + defer func() { _ = client.Close() }() + + if err := client.Authenticate(ctx); err != nil { + slog.Error("circuit renewal auth failed", + cli.InstanceField(ci.id), + "error", err, + ) + return + } + + slog.Info("circuit renewal connected", + cli.InstanceField(ci.id), + "addr", ci.controlAddr, + ) + + for { + var interval time.Duration + var mode string + if r.pattern != nil { + interval, mode = AdaptiveInterval(r.pattern, ci.minInterval, ci.maxInterval) + } else { + interval = randomInterval(ci.minInterval, ci.maxInterval) + mode = "fixed" + } + t := time.NewTimer(interval) + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + } + + if err := client.SignalNewnym(ctx); err != nil { + slog.Error("circuit renewal NEWNYM failed, reconnecting", + cli.InstanceField(ci.id), + "error", err, + ) + + _ = client.Close() + + if err := r.reconnect(ctx, client); err != nil { + slog.Error("circuit renewal reconnect failed", + cli.InstanceField(ci.id), + "error", err, + ) + return + } + continue + } + + slog.Debug("circuit renewed", + cli.InstanceField(ci.id), + "interval", interval, + "mode", mode, + ) + } +} + +func (r *Renewer) reconnect(ctx context.Context, client *Client) error { + if err := client.Connect(ctx); err != nil { + return fmt.Errorf("reconnect: connect: %w", err) + } + if err := client.Authenticate(ctx); err != nil { + return fmt.Errorf("reconnect: auth: %w", err) + } + return nil +} + +func randomInterval(min, max time.Duration) time.Duration { + delta := max - min + if delta <= 0 { + return min + } + return min + time.Duration(rand.Int64N(int64(delta))) +} diff --git a/internal/circuit/renewer_test.go b/internal/circuit/renewer_test.go new file mode 100644 index 0000000..1637192 --- /dev/null +++ b/internal/circuit/renewer_test.go @@ -0,0 +1,155 @@ +package circuit + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestRenewer_AddInstance(t *testing.T) { + r := NewRenewer() + r.AddInstance(0, 5999, "/tmp/splitter/tor_data_0/control_auth_cookie") + r.AddInstance(1, 6000, "/tmp/splitter/tor_data_1/control_auth_cookie") + + if len(r.instances) != 2 { + t.Fatalf("expected 2 instances, got %d", len(r.instances)) + } + + if r.instances[0].id != 0 { + t.Errorf("instance[0].id = %d, want 0", r.instances[0].id) + } + if r.instances[0].controlAddr != "127.0.0.1:5999" { + t.Errorf("instance[0].controlAddr = %q, want %q", r.instances[0].controlAddr, "127.0.0.1:5999") + } + if r.instances[1].id != 1 { + t.Errorf("instance[1].id = %d, want 1", r.instances[1].id) + } + if r.instances[1].controlAddr != "127.0.0.1:6000" { + t.Errorf("instance[1].controlAddr = %q, want %q", r.instances[1].controlAddr, "127.0.0.1:6000") + } +} + +func TestRenewer_RandomInterval(t *testing.T) { + min := 10 * time.Second + max := 15 * time.Second + + for i := 0; i < 1000; i++ { + interval := randomInterval(min, max) + if interval < min || interval > max { + t.Errorf("randomInterval() = %v, want range [%v, %v]", interval, min, max) + } + } + + uniqueCount := make(map[time.Duration]bool) + for i := 0; i < 100; i++ { + interval := randomInterval(min, max) + uniqueCount[interval] = true + } + if len(uniqueCount) < 10 { + t.Errorf("expected at least 10 unique intervals across 100 samples, got %d", len(uniqueCount)) + } +} + +func TestRenewer_RandomInterval_SameMinMax(t *testing.T) { + d := 10 * time.Second + interval := randomInterval(d, d) + if interval != d { + t.Errorf("randomInterval(10s, 10s) = %v, want %v", interval, d) + } +} + +func TestRenewer_StartStop(t *testing.T) { + r := NewRenewer() + r.AddInstance(0, 5999, "/tmp/nonexistent/cookie") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := r.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if !r.started { + t.Error("expected started = true") + } + + if err := r.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } +} + +func TestRenewer_StartNoInstances(t *testing.T) { + r := NewRenewer() + ctx := context.Background() + + err := r.Start(ctx) + if err == nil { + t.Error("expected error when starting with no instances") + } +} + +func TestRenewer_StopBeforeStart(t *testing.T) { + r := NewRenewer() + if err := r.Stop(); err != nil { + t.Fatalf("Stop() before Start() error = %v", err) + } +} + +func TestRenewer_InstanceIntervals(t *testing.T) { + r := NewRenewer() + r.AddInstance(0, 5999, "/tmp/cookie") + + inst := r.instances[0] + if inst.minInterval != defaultMinInterval { + t.Errorf("minInterval = %v, want %v", inst.minInterval, defaultMinInterval) + } + if inst.maxInterval != defaultMaxInterval { + t.Errorf("maxInterval = %v, want %v", inst.maxInterval, defaultMaxInterval) + } +} + +func TestRenewer_RandomInterval_InvertedMinMax(t *testing.T) { + min := 15 * time.Second + max := 10 * time.Second + + interval := randomInterval(min, max) + if interval != min { + t.Errorf("randomInterval(15s, 10s) = %v, want %v (min when delta <= 0)", interval, min) + } +} + +func TestRenewer_AddInstanceMultiple(t *testing.T) { + r := NewRenewer() + for i := 0; i < 5; i++ { + r.AddInstance(i, 5999+i, fmt.Sprintf("/tmp/cookie_%d", i)) + } + + if len(r.instances) != 5 { + t.Fatalf("expected 5 instances, got %d", len(r.instances)) + } + + for i, inst := range r.instances { + if inst.id != i { + t.Errorf("instance[%d].id = %d, want %d", i, inst.id, i) + } + expectedAddr := fmt.Sprintf("127.0.0.1:%d", 5999+i) + if inst.controlAddr != expectedAddr { + t.Errorf("instance[%d].controlAddr = %q, want %q", i, inst.controlAddr, expectedAddr) + } + } +} + +func TestRenewer_NewRenewerFields(t *testing.T) { + r := NewRenewer() + + if r.started { + t.Error("new renewer should not be started") + } + if r.cancelFunc != nil { + t.Error("new renewer should have nil cancelFunc") + } + if len(r.instances) != 0 { + t.Errorf("new renewer should have 0 instances, got %d", len(r.instances)) + } +} diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..0df3683 --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,38 @@ +package cli + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/user/splitter/internal/config" +) + +type CobraFlagReader struct { + flags *pflag.FlagSet +} + +func NewCobraFlagReader(flags *pflag.FlagSet) *CobraFlagReader { + return &CobraFlagReader{flags: flags} +} + +func (r *CobraFlagReader) Changed(name string) bool { + return r.flags.Changed(name) +} + +func (r *CobraFlagReader) GetInt(name string) (int, error) { + return r.flags.GetInt(name) +} + +func (r *CobraFlagReader) GetString(name string) (string, error) { + return r.flags.GetString(name) +} + +func (r *CobraFlagReader) GetBool(name string) (bool, error) { + return r.flags.GetBool(name) +} + +func Load(cmd *cobra.Command) (*config.Config, error) { + return config.Load(config.LoadOptions{ + ConfigPath: "configs/default.yaml", + Flags: NewCobraFlagReader(cmd.Flags()), + }) +} diff --git a/internal/cli/doc.go b/internal/cli/doc.go new file mode 100644 index 0000000..9261fda --- /dev/null +++ b/internal/cli/doc.go @@ -0,0 +1,2 @@ +// Package cli provides Cobra setup, flag bindings, and input validation for SPLITTER. +package cli diff --git a/internal/cli/flagreader_test.go b/internal/cli/flagreader_test.go new file mode 100644 index 0000000..c40ca29 --- /dev/null +++ b/internal/cli/flagreader_test.go @@ -0,0 +1,205 @@ +package cli + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestNewCobraFlagReader_NilFlags(t *testing.T) { + reader := NewCobraFlagReader(nil) + if reader == nil { + t.Fatal("NewCobraFlagReader(nil) returned nil") + } + if reader.flags != nil { + t.Error("expected nil flags") + } +} + +func TestCobraFlagReader_Changed(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Int("instances", 2, "instances") + cmd.Flags().String("profile", "", "profile") + + reader := NewCobraFlagReader(cmd.Flags()) + + if reader.Changed("instances") { + t.Error("instances should not be changed before Set") + } + + _ = cmd.Flags().Set("instances", "10") + + if !reader.Changed("instances") { + t.Error("instances should be changed after Set") + } + if reader.Changed("profile") { + t.Error("profile should not be changed") + } +} + +func TestCobraFlagReader_GetInt(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Int("count", 5, "count") + + reader := NewCobraFlagReader(cmd.Flags()) + + val, err := reader.GetInt("count") + if err != nil { + t.Fatalf("GetInt(count) error = %v", err) + } + if val != 5 { + t.Errorf("GetInt(count) = %d, want 5", val) + } + + _ = cmd.Flags().Set("count", "20") + val, err = reader.GetInt("count") + if err != nil { + t.Fatalf("GetInt(count) after set error = %v", err) + } + if val != 20 { + t.Errorf("GetInt(count) = %d, want 20 after set", val) + } +} + +func TestCobraFlagReader_GetString(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("mode", "native", "mode") + + reader := NewCobraFlagReader(cmd.Flags()) + + val, err := reader.GetString("mode") + if err != nil { + t.Fatalf("GetString(mode) error = %v", err) + } + if val != "native" { + t.Errorf("GetString(mode) = %q, want native", val) + } + + _ = cmd.Flags().Set("mode", "legacy") + val, err = reader.GetString("mode") + if err != nil { + t.Fatalf("GetString(mode) after set error = %v", err) + } + if val != "legacy" { + t.Errorf("GetString(mode) = %q, want legacy after set", val) + } +} + +func TestCobraFlagReader_GetBool(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", false, "verbose") + + reader := NewCobraFlagReader(cmd.Flags()) + + val, err := reader.GetBool("verbose") + if err != nil { + t.Fatalf("GetBool(verbose) error = %v", err) + } + if val { + t.Error("GetBool(verbose) = true, want false") + } + + _ = cmd.Flags().Set("verbose", "true") + val, err = reader.GetBool("verbose") + if err != nil { + t.Fatalf("GetBool(verbose) after set error = %v", err) + } + if !val { + t.Error("GetBool(verbose) = false, want true after set") + } +} + +func TestCobraFlagReader_GetNonexistentFlag(t *testing.T) { + cmd := &cobra.Command{} + reader := NewCobraFlagReader(cmd.Flags()) + + _, err := reader.GetInt("nonexistent") + if err == nil { + t.Error("expected error for nonexistent int flag") + } + + _, err = reader.GetString("nonexistent") + if err == nil { + t.Error("expected error for nonexistent string flag") + } + + _, err = reader.GetBool("nonexistent") + if err == nil { + t.Error("expected error for nonexistent bool flag") + } +} + +func TestBindFlags_RegistersAllFlags(t *testing.T) { + cmd := &cobra.Command{} + BindFlags(cmd) + + expectedFlags := []string{ + "instances", "countries", "relay-enforce", + "profile", "proxy-mode", "bridge-type", + "verbose", "log", "log-level", "auto-countries", + } + + for _, name := range expectedFlags { + f := cmd.PersistentFlags().Lookup(name) + if f == nil { + t.Errorf("flag %q not registered by BindFlags", name) + } + } +} + +func TestBindFlags_DefaultValues(t *testing.T) { + cmd := &cobra.Command{} + BindFlags(cmd) + + tests := []struct { + name string + want string + }{ + {"instances", "2"}, + {"countries", "6"}, + {"relay-enforce", "entry"}, + {"profile", ""}, + {"proxy-mode", "native"}, + {"bridge-type", "none"}, + {"verbose", "false"}, + {"log", "false"}, + {"log-level", "info"}, + {"auto-countries", "false"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := cmd.PersistentFlags().Lookup(tt.name) + if f == nil { + t.Fatalf("flag %q not found", tt.name) + } + if f.DefValue != tt.want { + t.Errorf("flag %q default = %q, want %q", tt.name, f.DefValue, tt.want) + } + }) + } +} + +func TestBindFlags_ShortAliases(t *testing.T) { + cmd := &cobra.Command{} + BindFlags(cmd) + + shorthandTests := []struct { + name string + shorthand string + }{ + {"instances", "i"}, + {"countries", "c"}, + {"relay-enforce", "r"}, + } + + for _, tt := range shorthandTests { + f := cmd.PersistentFlags().Lookup(tt.name) + if f == nil { + t.Fatalf("flag %q not found", tt.name) + } + if f.Shorthand != tt.shorthand { + t.Errorf("flag %q shorthand = %q, want %q", tt.name, f.Shorthand, tt.shorthand) + } + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go new file mode 100644 index 0000000..9ec87a8 --- /dev/null +++ b/internal/cli/flags.go @@ -0,0 +1,33 @@ +package cli + +import "github.com/spf13/cobra" + +func BindFlags(cmd *cobra.Command) { + p := cmd.PersistentFlags() + + p.IntP("instances", "i", 2, "Number of Tor instances per country") + p.IntP("countries", "c", 6, "Number of countries to select") + p.StringP("relay-enforce", "r", "entry", "Relay enforcement mode (entry|exit|speed) [legacy: -re]") + p.String("profile", "", "Configuration profile (stealth|balanced|streaming|pentest)") + p.String("proxy-mode", "native", "Proxy mode (native|legacy)") + p.String("bridge-type", "none", "Bridge type (snowflake|webtunnel|obfs4|none)") + p.Bool("verbose", false, "Enable verbose output") + p.Bool("log", false, "Enable logging (off by default)") + p.String("log-level", "info", "Log level (debug|info|warn|error)") + p.Bool("auto-countries", false, "Auto-fetch country list from Tor Metrics API") + p.Bool("stream-isolation", false, "Enable stream isolation via SOCKS5 auth (IsolateSOCKSAuth)") + p.Bool("ipv6", false, "Enable IPv6 dual-stack relay selection (ClientUseIPv6)") + p.Bool("exit-reputation", false, "Check exit node reputation before use ( Onionoo API)") +} + +func PreprocessArgs(args []string) []string { + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + if args[i] == "-re" { + out = append(out, "--relay-enforce") + continue + } + out = append(out, args[i]) + } + return out +} diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go new file mode 100644 index 0000000..2d4f913 --- /dev/null +++ b/internal/cli/flags_test.go @@ -0,0 +1,91 @@ +package cli + +import ( + "reflect" + "testing" +) + +func TestPreprocessArgs_NoReFlag(t *testing.T) { + args := []string{"splitter", "run", "-i", "5", "-c", "3"} + result := PreprocessArgs(args) + + if len(result) != len(args) { + t.Fatalf("PreprocessArgs length = %d, want %d", len(result), len(args)) + } + for i := range args { + if result[i] != args[i] { + t.Errorf("result[%d] = %q, want %q", i, result[i], args[i]) + } + } +} + +func TestPreprocessArgs_ReFlag(t *testing.T) { + args := []string{"splitter", "-re", "exit"} + result := PreprocessArgs(args) + expected := []string{"splitter", "--relay-enforce", "exit"} + + if len(result) != len(expected) { + t.Fatalf("length = %d, want %d", len(result), len(expected)) + } + for i := range expected { + if result[i] != expected[i] { + t.Errorf("result[%d] = %q, want %q", i, result[i], expected[i]) + } + } +} + +func TestPreprocessArgs_MultipleReFlags(t *testing.T) { + args := []string{"-re", "exit", "-re", "entry"} + result := PreprocessArgs(args) + expected := []string{"--relay-enforce", "exit", "--relay-enforce", "entry"} + + if !reflect.DeepEqual(result, expected) { + t.Errorf("PreprocessArgs() = %v, want %v", result, expected) + } +} + +func TestPreprocessArgs_EmptySlice(t *testing.T) { + result := PreprocessArgs([]string{}) + + if result == nil { + t.Error("PreprocessArgs(empty) returned nil, want empty slice") + } + if len(result) != 0 { + t.Errorf("PreprocessArgs(empty) returned %d elements, want 0", len(result)) + } +} + +func TestPreprocessArgs_NilSlice(t *testing.T) { + result := PreprocessArgs(nil) + + if result == nil { + t.Error("PreprocessArgs(nil) returned nil, want empty slice") + } + if len(result) != 0 { + t.Errorf("PreprocessArgs(nil) returned %d elements, want 0", len(result)) + } +} + +func TestPreprocessArgs_MixedFlags(t *testing.T) { + args := []string{"splitter", "-re", "exit", "-i", "3", "--profile", "stealth"} + result := PreprocessArgs(args) + expected := []string{"splitter", "--relay-enforce", "exit", "-i", "3", "--profile", "stealth"} + + if !reflect.DeepEqual(result, expected) { + t.Errorf("PreprocessArgs() = %v, want %v", result, expected) + } +} + +func TestPreprocessArgs_PreservesOtherFlags(t *testing.T) { + args := []string{"--profile", "stealth", "--proxy-mode", "legacy"} + result := PreprocessArgs(args) + + if len(result) != len(args) { + t.Fatalf("length = %d, want %d", len(result), len(args)) + } + for i := range args { + if result[i] != args[i] { + t.Errorf("result[%d] = %q, want %q", i, result[i], args[i]) + } + } +} diff --git a/internal/cli/logger.go b/internal/cli/logger.go new file mode 100644 index 0000000..13434cf --- /dev/null +++ b/internal/cli/logger.go @@ -0,0 +1,61 @@ +package cli + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/user/splitter/internal/config" +) + +func SetupLogger(cfg *config.Config) error { + if !cfg.Logging.Enabled { + discard := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(discard) + return nil + } + + level, err := parseLogLevel(cfg.Logging.Level) + if err != nil { + return fmt.Errorf("SetupLogger: %w", err) + } + + opts := &slog.HandlerOptions{Level: level} + + if isContainer() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, opts))) + } else { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, opts))) + } + + return nil +} + +func parseLogLevel(s string) (slog.Level, error) { + switch strings.ToUpper(s) { + case "DEBUG": + return slog.LevelDebug, nil + case "INFO": + return slog.LevelInfo, nil + case "WARN": + return slog.LevelWarn, nil + case "ERROR": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("parseLogLevel: unknown level %q", s) + } +} + +func isContainer() bool { + if os.Getenv("TERM") == "dumb" { + return true + } + if os.Getenv("NO_COLOR") != "" { + return true + } + return false +} diff --git a/internal/cli/logger_test.go b/internal/cli/logger_test.go new file mode 100644 index 0000000..329fc78 --- /dev/null +++ b/internal/cli/logger_test.go @@ -0,0 +1,263 @@ +package cli + +import ( + "bytes" + "context" + "log/slog" + "os" + "strings" + "testing" + + "github.com/user/splitter/internal/config" +) + +func TestSetupLogger(t *testing.T) { + tests := []struct { + name string + enabled bool + level string + termEnv string + noColorEnv string + wantHandler string + wantLevel slog.Level + wantOutputNone bool + }{ + { + name: "disabled discards all output", + enabled: false, + wantOutputNone: true, + }, + { + name: "enabled with text handler in terminal", + enabled: true, + level: "INFO", + termEnv: "xterm", + wantHandler: "text", + wantLevel: slog.LevelInfo, + }, + { + name: "enabled with json handler when TERM=dumb", + enabled: true, + level: "INFO", + termEnv: "dumb", + wantHandler: "json", + wantLevel: slog.LevelInfo, + }, + { + name: "enabled with json handler when NO_COLOR set", + enabled: true, + level: "WARN", + noColorEnv: "1", + wantHandler: "json", + wantLevel: slog.LevelWarn, + }, + { + name: "debug level enabled", + enabled: true, + level: "DEBUG", + wantHandler: "text", + wantLevel: slog.LevelDebug, + }, + { + name: "error level enabled", + enabled: true, + level: "ERROR", + wantHandler: "text", + wantLevel: slog.LevelError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.termEnv != "" { + t.Setenv("TERM", tt.termEnv) + } else { + t.Setenv("TERM", "xterm-256color") + } + if tt.noColorEnv != "" { + t.Setenv("NO_COLOR", tt.noColorEnv) + } else { + t.Setenv("NO_COLOR", "") + } + + cfg := &config.Config{ + Logging: config.LoggingConfig{ + Enabled: tt.enabled, + Level: tt.level, + }, + } + + err := SetupLogger(cfg) + if err != nil { + t.Fatalf("SetupLogger() error = %v", err) + } + + if tt.wantOutputNone { + handler := slog.Default().Handler() + if _, ok := handler.(*slog.TextHandler); !ok { + t.Fatalf("expected TextHandler for discard, got %T", handler) + } + return + } + + handler := slog.Default().Handler() + switch tt.wantHandler { + case "json": + if _, ok := handler.(*slog.JSONHandler); !ok { + t.Errorf("expected JSONHandler, got %T", handler) + } + case "text": + if _, ok := handler.(*slog.TextHandler); !ok { + t.Errorf("expected TextHandler, got %T", handler) + } + } + }) + } +} + +func TestSetupLogger_DisabledDiscardsOutput(t *testing.T) { + cfg := &config.Config{ + Logging: config.LoggingConfig{ + Enabled: false, + Level: "INFO", + }, + } + + if err := SetupLogger(cfg); err != nil { + t.Fatalf("SetupLogger() error = %v", err) + } + + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + logger.Info("this should appear in buf") + if buf.Len() == 0 { + t.Error("test logger wrote nothing to buf — test setup issue") + } +} + +func TestSetupLogger_DebugLevelShowsDebug(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + t.Setenv("NO_COLOR", "") + + cfg := &config.Config{ + Logging: config.LoggingConfig{ + Enabled: true, + Level: "DEBUG", + }, + } + + if err := SetupLogger(cfg); err != nil { + t.Fatalf("SetupLogger() error = %v", err) + } + + handler := slog.Default().Handler() + + enabled := handler.Enabled(context.TODO(), slog.LevelDebug) + if !enabled { + t.Error("expected debug level to be enabled") + } + + enabledInfo := handler.Enabled(context.TODO(), slog.LevelInfo) + if !enabledInfo { + t.Error("expected info level to be enabled") + } +} + +func TestSetupLogger_InfoLevelHidesDebug(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + t.Setenv("NO_COLOR", "") + + cfg := &config.Config{ + Logging: config.LoggingConfig{ + Enabled: true, + Level: "INFO", + }, + } + + if err := SetupLogger(cfg); err != nil { + t.Fatalf("SetupLogger() error = %v", err) + } + + handler := slog.Default().Handler() + + enabled := handler.Enabled(context.TODO(), slog.LevelDebug) + if enabled { + t.Error("expected debug level to be hidden at INFO level") + } + + enabledInfo := handler.Enabled(context.TODO(), slog.LevelInfo) + if !enabledInfo { + t.Error("expected info level to be enabled") + } +} + +func TestSetupLogger_InvalidLevel(t *testing.T) { + cfg := &config.Config{ + Logging: config.LoggingConfig{ + Enabled: true, + Level: "bogus", + }, + } + + err := SetupLogger(cfg) + if err == nil { + t.Fatal("expected error for invalid log level") + } + if !strings.Contains(err.Error(), "bogus") { + t.Errorf("error should mention invalid level, got: %v", err) + } +} + +func TestLogFieldHelpers(t *testing.T) { + tests := []struct { + name string + field slog.Attr + key string + val string + }{ + {"instance", InstanceField(5), "instance", "5"}, + {"country", CountryField("US"), "country", "US"}, + {"port", PortField(9050), "port", "9050"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.field.Key != tt.key { + t.Errorf("key = %q, want %q", tt.field.Key, tt.key) + } + got := tt.field.Value.String() + if got != tt.val { + t.Errorf("value = %q, want %q", got, tt.val) + } + }) + } +} + +func TestIsContainer(t *testing.T) { + tests := []struct { + name string + term string + noColor string + wantResult bool + }{ + {"terminal", "xterm", "", false}, + {"dumb", "dumb", "", true}, + {"no_color", "xterm", "1", true}, + {"no_color_empty", "xterm", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TERM", tt.term) + if tt.noColor != "" { + t.Setenv("NO_COLOR", tt.noColor) + } else { + _ = os.Unsetenv("NO_COLOR") + } + got := isContainer() + if got != tt.wantResult { + t.Errorf("isContainer() = %v, want %v", got, tt.wantResult) + } + }) + } +} diff --git a/internal/cli/logging.go b/internal/cli/logging.go new file mode 100644 index 0000000..1fac44e --- /dev/null +++ b/internal/cli/logging.go @@ -0,0 +1,15 @@ +package cli + +import "log/slog" + +func InstanceField(id int) slog.Attr { + return slog.Int("instance", id) +} + +func CountryField(code string) slog.Attr { + return slog.String("country", code) +} + +func PortField(port int) slog.Attr { + return slog.Int("port", port) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e17b25e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,340 @@ +package config + +import "math/rand" + +type Config struct { + Instances InstancesConfig `yaml:"instances"` + Proxy ProxyConfig `yaml:"proxy"` + Relay RelayConfig `yaml:"relay"` + Tor TorConfig `yaml:"tor"` + Privoxy PrivoxyConfig `yaml:"privoxy"` + HAProxy HAProxyConfig `yaml:"haproxy"` + Country CountryConfig `yaml:"country"` + HealthCheck HealthCheckConfig `yaml:"health_check"` + UserAgent UserAgentConfig `yaml:"user_agent"` + Logging LoggingConfig `yaml:"logging"` + Paths PathsConfig `yaml:"paths"` + DNS DNSConfig `yaml:"dns"` + ExitReputation ExitReputationConfig `yaml:"exit_reputation"` + + Profile string + ProxyMode string + BridgeType string + Verbose bool + Log bool + LogLevel string +} + +type InstancesConfig struct { + PerCountry int `yaml:"per_country"` + Countries int `yaml:"countries"` + MaxConcurrentRequests int `yaml:"max_concurrent_requests"` + Retries int `yaml:"retries"` +} + +type ProxyConfig struct { + Master ProxyMasterConfig `yaml:"master"` + Stats ProxyStatsConfig `yaml:"stats"` + LoadBalanceAlgorithm string `yaml:"load_balance_algorithm"` + HAProxyHTTPReuse string `yaml:"haproxy_http_reuse"` + IncludeSecurityHeaders bool `yaml:"include_security_headers"` + DoNotProxy []string `yaml:"do_not_proxy"` +} + +type ProxyMasterConfig struct { + Listen string `yaml:"listen"` + Port int `yaml:"port"` + SocksPort int `yaml:"socks_port"` + HTTPPort int `yaml:"http_port"` + TransparentPort int `yaml:"transparent_port"` + ClientTimeout int `yaml:"client_timeout"` + ServerTimeout int `yaml:"server_timeout"` +} + +type ProxyStatsConfig struct { + Listen string `yaml:"listen"` + Port int `yaml:"port"` + URI string `yaml:"uri"` +} + +type RelayConfig struct { + Enforce string `yaml:"enforce"` +} + +type TorConfig struct { + BinaryPath string `yaml:"binary_path"` + ListenAddr string `yaml:"listen_addr"` + StartSocksPort int `yaml:"start_socks_port"` + StartControlPort int `yaml:"start_control_port"` + StartHTTPPort int `yaml:"start_http_port"` + StartTransportPort int `yaml:"start_transport_port"` + StartDNSPort int `yaml:"start_dns_port"` + ControlAuth string `yaml:"control_auth"` + HiddenService TorHiddenServiceConfig `yaml:"hidden_service"` + MinimumTimeout int `yaml:"minimum_timeout"` + CircuitBuildTimeout int `yaml:"circuit_build_timeout"` + LearnCircuitBuildTimeout int `yaml:"learn_circuit_build_timeout"` + CircuitsAvailableTimeout int `yaml:"circuits_available_timeout"` + CircuitStreamTimeout int `yaml:"circuit_stream_timeout"` + ClientOnly int `yaml:"client_only"` + ConnectionPadding int `yaml:"connection_padding"` + ReducedConnectionPadding int `yaml:"reduced_connection_padding"` + GeoIPExcludeUnknown int `yaml:"geoip_exclude_unknown"` + StrictNodes int `yaml:"strict_nodes"` + FascistFirewall int `yaml:"fascist_firewall"` + FirewallPorts []int `yaml:"firewall_ports"` + LongLivedPorts []int `yaml:"long_lived_ports"` + NewCircuitPeriod int `yaml:"new_circuit_period"` + MaxCircuitDirtiness int `yaml:"max_circuit_dirtiness"` + MaxClientCircuitsPending int `yaml:"max_client_circuits_pending"` + SocksTimeout int `yaml:"socks_timeout"` + TrackHostExitsExpire int `yaml:"track_host_exits_expire"` + UseEntryGuards int `yaml:"use_entry_guards"` + NumEntryGuards int `yaml:"num_entry_guards"` + SafeSocks int `yaml:"safe_socks"` + TestSocks int `yaml:"test_socks"` + AllowNonRFC953Hostnames int `yaml:"allow_non_rfc953_hostnames"` + ClientRejectInternalAddresses int `yaml:"client_reject_internal_addresses"` + DownloadExtraInfo int `yaml:"download_extra_info"` + OptimisticData string `yaml:"optimistic_data"` + AutomapHostsSuffixes string `yaml:"automap_hosts_suffixes"` + WarnPlaintextPorts string `yaml:"warn_plaintext_ports"` + RejectPlaintextPorts string `yaml:"reject_plaintext_ports"` + Sandbox bool `yaml:"sandbox"` + StreamIsolation bool `yaml:"stream_isolation"` + IPv6 bool `yaml:"ipv6"` + ConfluxEnabled bool `yaml:"conflux_enabled"` + CongestionControlAuto bool `yaml:"congestion_control_auto"` + CircuitFingerprintingResistance bool `yaml:"circuit_fingerprinting_resistance"` +} + +type TorHiddenServiceConfig struct { + Enabled bool `yaml:"enabled"` + BasePath string `yaml:"base_path"` + StartPort int `yaml:"start_port"` + MaxStreams int `yaml:"max_streams"` + MaxStreamsCloseCircuit bool `yaml:"max_streams_close_circuit"` + DirGroupReadable bool `yaml:"dir_group_readable"` + NumIntroductionPoints int `yaml:"num_introduction_points"` +} + +type PrivoxyConfig struct { + BinaryPath string `yaml:"binary_path"` + Listen string `yaml:"listen"` + StartPort int `yaml:"start_port"` + Timeout int `yaml:"timeout"` + ConfigFilePrefix string `yaml:"config_file_prefix"` +} + +type HAProxyConfig struct { + BinaryPath string `yaml:"binary_path"` + ConfigFile string `yaml:"config_file"` +} + +type CountryConfig struct { + Selected string `yaml:"selected"` + Accepted []string `yaml:"accepted"` + Blacklisted []string `yaml:"blacklisted"` + Rotation CountryRotationConfig `yaml:"rotation"` + AutoCountries bool `yaml:"auto_countries"` +} + +type CountryRotationConfig struct { + Enabled bool `yaml:"enabled"` + Interval int `yaml:"interval"` + TotalToChange int `yaml:"total_to_change"` +} + +type HealthCheckConfig struct { + URL string `yaml:"url"` + Interval int `yaml:"interval"` + MaxFail int `yaml:"max_fail"` + MinimumSuccess int `yaml:"minimum_success"` +} + +type UserAgentConfig struct { + TorBrowser string `yaml:"tor_browser"` + UserAgents []string `yaml:"user_agents"` + Default string `yaml:"default"` +} + +type LoggingConfig struct { + Enabled bool `yaml:"enabled"` + Dir string `yaml:"dir"` + NamePrefix string `yaml:"name_prefix"` + Level string `yaml:"level"` + Format string `yaml:"format"` +} + +type PathsConfig struct { + TempFiles string `yaml:"temp_files"` + ProxychainsFile string `yaml:"proxychains_file"` +} + +type DNSConfig struct { + DistListen string `yaml:"dist_listen"` + DistPort int `yaml:"dist_port"` + TorListen string `yaml:"tor_listen"` +} + +type ExitReputationConfig struct { + Enabled bool `yaml:"enabled"` +} + +func Defaults() *Config { + return &Config{ + Instances: InstancesConfig{ + PerCountry: 2, + Countries: 6, + MaxConcurrentRequests: 20, + Retries: 1000, + }, + Proxy: ProxyConfig{ + Master: ProxyMasterConfig{ + Listen: "0.0.0.0", + Port: 63536, + SocksPort: 63536, + HTTPPort: 63537, + TransparentPort: 63538, + ClientTimeout: 35, + ServerTimeout: 35, + }, + Stats: ProxyStatsConfig{ + Listen: "0.0.0.0", + Port: 63539, + URI: "/splitter_status", + }, + LoadBalanceAlgorithm: "roundrobin", + HAProxyHTTPReuse: "never", + IncludeSecurityHeaders: true, + DoNotProxy: []string{ + "0.0.0.0", "192.168.1.1", "192.168.2.1", + "192.168.3.1", "192.168.0.1", "172.17.0.1", + }, + }, + Relay: RelayConfig{ + Enforce: "entry", + }, + Tor: TorConfig{ + BinaryPath: "/usr/bin/tor", + ListenAddr: "0.0.0.0", + StartSocksPort: 4999, + StartControlPort: 5999, + StartHTTPPort: 5199, + StartTransportPort: 5099, + StartDNSPort: 5299, + ControlAuth: "password", + HiddenService: TorHiddenServiceConfig{ + Enabled: true, + BasePath: "/tmp/splitter/hidden_service_", + StartPort: 3999, + MaxStreams: 0, + MaxStreamsCloseCircuit: false, + DirGroupReadable: false, + NumIntroductionPoints: 3, + }, + MinimumTimeout: 15, + CircuitBuildTimeout: 60, + LearnCircuitBuildTimeout: 1, + CircuitsAvailableTimeout: 5, + CircuitStreamTimeout: 20, + ClientOnly: 0, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + GeoIPExcludeUnknown: 1, + StrictNodes: 1, + FascistFirewall: 0, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + NewCircuitPeriod: 30, + MaxCircuitDirtiness: 15, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + SafeSocks: 1, + TestSocks: 1, + AllowNonRFC953Hostnames: 0, + ClientRejectInternalAddresses: 1, + DownloadExtraInfo: 0, + OptimisticData: "auto", + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25,80,109,110,143", + RejectPlaintextPorts: "", + IPv6: false, + }, + Privoxy: PrivoxyConfig{ + BinaryPath: "/usr/sbin/privoxy", + Listen: "0.0.0.0", + StartPort: 6999, + Timeout: 35, + ConfigFilePrefix: "/tmp/splitter/privoxy_splitter_config_", + }, + HAProxy: HAProxyConfig{ + BinaryPath: "/usr/sbin/haproxy", + ConfigFile: "/tmp/splitter/splitter_master_proxy.cfg", + }, + Country: CountryConfig{ + Selected: "RANDOM", + Accepted: []string{ + "{AU}", "{AT}", "{BE}", "{BG}", "{CA}", "{CZ}", "{DK}", + "{FI}", "{FR}", "{DE}", "{HU}", "{IS}", "{LV}", "{LT}", + "{LU}", "{MD}", "{NL}", "{NO}", "{PA}", "{PL}", "{RO}", + "{RU}", "{SC}", "{SG}", "{SK}", "{ES}", "{SE}", "{CH}", + "{TR}", "{UA}", "{GB}", "{US}", + }, + Rotation: CountryRotationConfig{ + Enabled: true, + Interval: 120, + TotalToChange: 10, + }, + }, + HealthCheck: HealthCheckConfig{ + URL: "https://www.google.com/", + Interval: 12, + MaxFail: 1, + MinimumSuccess: 1, + }, + UserAgent: UserAgentConfig{ + TorBrowser: "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0", + UserAgents: nil, + Default: "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0", + }, + Logging: LoggingConfig{ + Enabled: false, + Dir: "/tmp/splitter", + NamePrefix: "tor_log_", + Level: "INFO", + Format: "text", + }, + Paths: PathsConfig{ + TempFiles: "/tmp/splitter", + ProxychainsFile: "", + }, + DNS: DNSConfig{ + DistListen: "0.0.0.0", + DistPort: 5353, + TorListen: "0.0.0.0", + }, + ExitReputation: ExitReputationConfig{ + Enabled: false, + }, + Profile: "", + ProxyMode: "native", + BridgeType: "none", + Verbose: false, + Log: false, + LogLevel: "", + } +} + +func (u *UserAgentConfig) PickUserAgent() string { + if len(u.UserAgents) > 0 { + return u.UserAgents[rand.Intn(len(u.UserAgents))] + } + if u.Default != "" { + return u.Default + } + return u.TorBrowser +} diff --git a/internal/config/config_privacy_test.go b/internal/config/config_privacy_test.go new file mode 100644 index 0000000..f3adeda --- /dev/null +++ b/internal/config/config_privacy_test.go @@ -0,0 +1,156 @@ +package config + +import ( + "testing" +) + +// TestConfigDefaults_PrivacySettings verifies that the compiled-in default +// configuration has privacy-safe values. If any of these defaults are changed +// to insecure values, these tests will catch it. +func TestConfigDefaults_PrivacySettings(t *testing.T) { + cfg := Defaults() + + tests := []struct { + name string + got int + want int + insecure string // description of what insecure means + }{ + { + name: "safe_socks must be 1 (prevents DNS leaks via SOCKS)", + got: cfg.Tor.SafeSocks, + want: 1, + insecure: "DNS can leak via SOCKS hostname resolution", + }, + { + name: "strict_nodes must be 1 (never fall back to unlisted nodes)", + got: cfg.Tor.StrictNodes, + want: 1, + insecure: "Traffic may use nodes outside selected countries", + }, + { + name: "client_reject_internal_addresses must be 1", + got: cfg.Tor.ClientRejectInternalAddresses, + want: 1, + insecure: "Connections to internal/private IPs would be allowed", + }, + { + name: "geoip_exclude_unknown must be 1", + got: cfg.Tor.GeoIPExcludeUnknown, + want: 1, + insecure: "Nodes from unknown jurisdictions can be selected", + }, + { + name: "test_socks must be 1 (log SOCKS safety rejections)", + got: cfg.Tor.TestSocks, + want: 1, + insecure: "SOCKS protocol violations go unlogged", + }, + { + name: "client_only must be 0 (no relay, client-only)", + got: cfg.Tor.ClientOnly, + want: 0, + insecure: "Running as relay exposes your IP as a Tor node", + }, + { + name: "allow_non_rfc953_hostnames must be 0", + got: cfg.Tor.AllowNonRFC953Hostnames, + want: 0, + insecure: "Malformed hostnames could cause DNS leaks", + }, + { + name: "download_extra_info must be 0 (reduce bandwidth fingerprint)", + got: cfg.Tor.DownloadExtraInfo, + want: 0, + insecure: "Extra downloads increase bandwidth fingerprint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("default = %d, want %d (SECURITY: %s)", tt.got, tt.want, tt.insecure) + } + }) + } +} + +// TestConfigDefaults_PrivacyStrings verifies string-valued privacy defaults. +func TestConfigDefaults_PrivacyStrings(t *testing.T) { + cfg := Defaults() + + if cfg.Tor.AutomapHostsSuffixes == "" { + t.Error("automap_hosts_suffixes is empty — .onion resolution will not work") + } + if cfg.Tor.WarnPlaintextPorts == "" { + t.Error("warn_plaintext_ports is empty — users won't be warned about plaintext traffic") + } +} + +// TestConfigDefaults_ControlAuth verifies control port auth defaults. +func TestConfigDefaults_ControlAuth(t *testing.T) { + cfg := Defaults() + + // Cookie auth should be a supported value + validAuth := map[string]bool{ + "cookie": true, + "password": true, + } + if !validAuth[cfg.Tor.ControlAuth] { + t.Errorf("control_auth = %q, want 'cookie' or 'password'", cfg.Tor.ControlAuth) + } +} + +// TestConfigDefaults_ReducedConnectionPadding verifies padding defaults +// for traffic analysis resistance. +func TestConfigDefaults_ReducedConnectionPadding(t *testing.T) { + cfg := Defaults() + + // Reduced padding should be on by default (saves bandwidth while + // maintaining some padding protection) + if cfg.Tor.ReducedConnectionPadding != 1 { + t.Errorf("reduced_connection_padding = %d, want 1", cfg.Tor.ReducedConnectionPadding) + } +} + +// TestConfigDefaults_EntryGuards verifies entry guard defaults for +// long-term entry node protection. +func TestConfigDefaults_EntryGuards(t *testing.T) { + cfg := Defaults() + + if cfg.Tor.UseEntryGuards != 1 { + t.Errorf("use_entry_guards = %d, want 1 (SECURITY: entry nodes change frequently without guards)", cfg.Tor.UseEntryGuards) + } + if cfg.Tor.NumEntryGuards < 1 { + t.Errorf("num_entry_guards = %d, want >= 1", cfg.Tor.NumEntryGuards) + } +} + +// TestConfigDefaults_CircuitTimeouts verifies circuit timeout defaults +// are reasonable for privacy (not too short, not too long). +func TestConfigDefaults_CircuitTimeouts(t *testing.T) { + cfg := Defaults() + + if cfg.Tor.CircuitBuildTimeout < 30 { + t.Errorf("circuit_build_timeout = %d, want >= 30 (too short causes circuit failures)", cfg.Tor.CircuitBuildTimeout) + } + if cfg.Tor.NewCircuitPeriod < 10 { + t.Errorf("new_circuit_period = %d, want >= 10 (too short causes excessive circuit building)", cfg.Tor.NewCircuitPeriod) + } + if cfg.Tor.MaxCircuitDirtiness < 10 { + t.Errorf("max_circuit_dirtiness = %d, want >= 10 (too short reduces circuit reuse)", cfg.Tor.MaxCircuitDirtiness) + } + if cfg.Tor.MaxClientCircuitsPending < 32 { + t.Errorf("max_client_circuits_pending = %d, want >= 32 (too low limits parallel connections)", cfg.Tor.MaxClientCircuitsPending) + } +} + +// TestConfigDefaults_StreamIsolationDefault verifies stream isolation +// is off by default (it's a tradeoff: more isolation vs more circuits). +func TestConfigDefaults_StreamIsolationDefault(t *testing.T) { + cfg := Defaults() + + if cfg.Tor.StreamIsolation { + t.Error("stream_isolation should be false by default (enable explicitly or via pentest profile)") + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0101856 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,784 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_DefaultsOnly(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "missing.yaml") + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_X_", + }) + if err != nil { + t.Fatalf("Load with missing file: %v", err) + } + + if cfg.Instances.PerCountry != 2 { + t.Errorf("PerCountry = %d, want 2", cfg.Instances.PerCountry) + } + if cfg.Instances.Countries != 6 { + t.Errorf("Countries = %d, want 6", cfg.Instances.Countries) + } + if cfg.Relay.Enforce != "entry" { + t.Errorf("Enforce = %q, want %q", cfg.Relay.Enforce, "entry") + } + if cfg.ProxyMode != "native" { + t.Errorf("ProxyMode = %q, want %q", cfg.ProxyMode, "native") + } + if cfg.BridgeType != "none" { + t.Errorf("BridgeType = %q, want %q", cfg.BridgeType, "none") + } +} + +func TestLoad_YAMLFile(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 5 + countries: 3 +relay: + enforce: "speed" +tor: + max_circuit_dirtiness: 60 +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_YAML_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Instances.PerCountry != 5 { + t.Errorf("PerCountry = %d, want 5", cfg.Instances.PerCountry) + } + if cfg.Instances.Countries != 3 { + t.Errorf("Countries = %d, want 3", cfg.Instances.Countries) + } + if cfg.Relay.Enforce != "speed" { + t.Errorf("Enforce = %q, want %q", cfg.Relay.Enforce, "speed") + } + if cfg.Tor.MaxCircuitDirtiness != 60 { + t.Errorf("MaxCircuitDirtiness = %d, want 60", cfg.Tor.MaxCircuitDirtiness) + } +} + +func TestLoad_EnvOverrides(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SPLITTER_TEST_INSTANCES", "10") + t.Setenv("SPLITTER_TEST_COUNTRIES", "12") + t.Setenv("SPLITTER_TEST_RELAY_ENFORCE", "exit") + t.Setenv("SPLITTER_TEST_PROXY_MODE", "legacy") + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Instances.PerCountry != 10 { + t.Errorf("PerCountry = %d, want 10 (env override)", cfg.Instances.PerCountry) + } + if cfg.Instances.Countries != 12 { + t.Errorf("Countries = %d, want 12 (env override)", cfg.Instances.Countries) + } + if cfg.Relay.Enforce != "exit" { + t.Errorf("Enforce = %q, want %q (env override)", cfg.Relay.Enforce, "exit") + } + if cfg.ProxyMode != "legacy" { + t.Errorf("ProxyMode = %q, want %q (env override)", cfg.ProxyMode, "legacy") + } +} + +func TestLoad_FlagOverrides(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SPLITTER_TEST_FLAG_INSTANCES", "10") + + flags := &stubFlagReader{ + changed: map[string]bool{ + "instances": true, + "countries": true, + "relay-enforce": true, + }, + ints: map[string]int{ + "instances": 7, + "countries": 4, + }, + strings: map[string]string{ + "relay-enforce": "speed", + }, + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_FLAG_", + Flags: flags, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Instances.PerCountry != 7 { + t.Errorf("PerCountry = %d, want 7 (flag override)", cfg.Instances.PerCountry) + } + if cfg.Instances.Countries != 4 { + t.Errorf("Countries = %d, want 4 (flag override)", cfg.Instances.Countries) + } + if cfg.Relay.Enforce != "speed" { + t.Errorf("Enforce = %q, want %q (flag override)", cfg.Relay.Enforce, "speed") + } +} + +func TestLoad_PriorityOrder(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SPLITTER_TEST_PR_INSTANCES", "10") + + flags := &stubFlagReader{ + changed: map[string]bool{"instances": true}, + ints: map[string]int{"instances": 99}, + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_PR_", + Flags: flags, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Instances.PerCountry != 99 { + t.Errorf("PerCountry = %d, want 99 (flag > env > yaml)", cfg.Instances.PerCountry) + } +} + +func TestLoad_Profile(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + profilesPath := filepath.Join(tmpDir, "profiles.yaml") + + yamlContent := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +` + if err := os.WriteFile(cfgPath, []byte(yamlContent), 0644); err != nil { + t.Fatal(err) + } + + profileContent := ` +stealth: + description: "test stealth" + instances: + per_country: 3 + countries: 8 + relay: + enforce: "entry" + tor: + max_circuit_dirtiness: 10 + connection_padding: 1 +` + if err := os.WriteFile(profilesPath, []byte(profileContent), 0644); err != nil { + t.Fatal(err) + } + + flags := &stubFlagReader{ + changed: map[string]bool{"profile": true}, + strings: map[string]string{"profile": "stealth"}, + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + ProfilesPath: profilesPath, + EnvPrefix: "SPLITTER_TEST_PROF_", + Flags: flags, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Instances.PerCountry != 3 { + t.Errorf("PerCountry = %d, want 3 (stealth profile)", cfg.Instances.PerCountry) + } + if cfg.Instances.Countries != 8 { + t.Errorf("Countries = %d, want 8 (stealth profile)", cfg.Instances.Countries) + } + if cfg.Tor.MaxCircuitDirtiness != 10 { + t.Errorf("MaxCircuitDirtiness = %d, want 10 (stealth profile)", cfg.Tor.MaxCircuitDirtiness) + } + if cfg.Tor.ConnectionPadding != 1 { + t.Errorf("ConnectionPadding = %d, want 1 (stealth profile)", cfg.Tor.ConnectionPadding) + } +} + +func TestValidate_ValidConfig(t *testing.T) { + tests := []struct { + name string + modify func(*Config) + wantErr bool + }{ + { + name: "default config is valid", + modify: func(c *Config) {}, + wantErr: false, + }, + { + name: "zero per_country", + modify: func(c *Config) { c.Instances.PerCountry = 0 }, + wantErr: true, + }, + { + name: "negative per_country", + modify: func(c *Config) { c.Instances.PerCountry = -1 }, + wantErr: true, + }, + { + name: "zero countries", + modify: func(c *Config) { c.Instances.Countries = 0 }, + wantErr: true, + }, + { + name: "invalid relay enforce", + modify: func(c *Config) { c.Relay.Enforce = "invalid" }, + wantErr: true, + }, + { + name: "invalid proxy mode", + modify: func(c *Config) { c.ProxyMode = "socks" }, + wantErr: true, + }, + { + name: "invalid bridge type", + modify: func(c *Config) { c.BridgeType = "meek" }, + wantErr: true, + }, + { + name: "invalid profile", + modify: func(c *Config) { c.Profile = "unknown" }, + wantErr: true, + }, + { + name: "invalid log level", + modify: func(c *Config) { c.Logging.Level = "trace" }, + wantErr: true, + }, + { + name: "port out of range", + modify: func(c *Config) { c.Proxy.Master.Port = 70000 }, + wantErr: true, + }, + { + name: "port negative", + modify: func(c *Config) { c.Tor.StartSocksPort = -1 }, + wantErr: true, + }, + { + name: "valid exit mode", + modify: func(c *Config) { c.Relay.Enforce = "exit" }, + wantErr: false, + }, + { + name: "valid speed mode", + modify: func(c *Config) { c.Relay.Enforce = "speed" }, + wantErr: false, + }, + { + name: "empty profile is valid", + modify: func(c *Config) { c.Profile = "" }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Defaults() + tt.modify(cfg) + err := Validate(cfg) + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidate_SOCKSPortOverflow(t *testing.T) { + cfg := Defaults() + cfg.Tor.StartSocksPort = 65000 + cfg.Instances.PerCountry = 100 + cfg.Instances.Countries = 10 + + err := Validate(cfg) + if err == nil { + t.Error("Validate() expected error for port overflow, got nil") + } +} + +func TestApplyEnvOverrides_BoolParsing(t *testing.T) { + tests := []struct { + val string + want bool + }{ + {"1", true}, + {"true", true}, + {"True", true}, + {"TRUE", true}, + {"yes", true}, + {"on", true}, + {"0", false}, + {"false", false}, + {"no", false}, + } + + for _, tt := range tests { + t.Run(tt.val, func(t *testing.T) { + cfg := Defaults() + cfg.Logging.Enabled = false + t.Setenv("SPLITTER_TEST_BOOL_LOGGING_ENABLED", tt.val) + _ = applyEnvOverrides(cfg, "SPLITTER_TEST_BOOL_") + if cfg.Logging.Enabled != tt.want { + t.Errorf("parseBool(%q) = %v, want %v", tt.val, cfg.Logging.Enabled, tt.want) + } + }) + } +} + +func TestLoad_YAMLMalformed(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "bad.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances: [broken yaml"), 0644); err != nil { + t.Fatal(err) + } + + _, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_MALFORMED_", + }) + if err == nil { + t.Error("Load() expected error for malformed YAML, got nil") + } +} + +func TestLoad_UnknownProfile(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + profilesPath := filepath.Join(tmpDir, "profiles.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances:\n per_country: 2\n countries: 6\nrelay:\n enforce: entry\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(profilesPath, []byte("stealth:\n description: test\n"), 0644); err != nil { + t.Fatal(err) + } + + flags := &stubFlagReader{ + changed: map[string]bool{"profile": true}, + strings: map[string]string{"profile": "nonexistent"}, + } + + _, err := Load(LoadOptions{ + ConfigPath: cfgPath, + ProfilesPath: profilesPath, + EnvPrefix: "SPLITTER_TEST_UNKPROF_", + Flags: flags, + }) + if err == nil { + t.Error("Load() expected error for unknown profile, got nil") + } +} + +func TestLoad_LogFlagEnablesLogging(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances:\n per_country: 2\n countries: 6\nrelay:\n enforce: entry\n"), 0644); err != nil { + t.Fatal(err) + } + + flags := &stubFlagReader{ + changed: map[string]bool{"log": true}, + bools: map[string]bool{"log": true}, + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_LOGFLAG_", + Flags: flags, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Logging.Enabled { + t.Error("Logging.Enabled should be true when log flag is set") + } +} + +func TestLoad_StreamIsolation_Default(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances:\n per_country: 2\n countries: 6\nrelay:\n enforce: entry\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_SI_DEFAULT_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Tor.StreamIsolation { + t.Error("StreamIsolation should be false by default") + } +} + +func TestLoad_StreamIsolation_YAML(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +tor: + stream_isolation: true +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_SI_YAML_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if !cfg.Tor.StreamIsolation { + t.Error("StreamIsolation should be true from YAML") + } +} + +func TestLoad_StreamIsolation_EnvOverride(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +tor: + stream_isolation: false +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SPLITTER_TEST_SI_ENV_TOR_STREAM_ISOLATION", "true") + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_SI_ENV_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if !cfg.Tor.StreamIsolation { + t.Error("StreamIsolation should be true from env override") + } +} + +func TestLoad_StreamIsolation_FlagOverride(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + content := ` +instances: + per_country: 2 + countries: 6 +relay: + enforce: "entry" +tor: + stream_isolation: false +` + if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + flags := &stubFlagReader{ + changed: map[string]bool{"stream-isolation": true}, + bools: map[string]bool{"stream-isolation": true}, + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + EnvPrefix: "SPLITTER_TEST_SI_FLAG_", + Flags: flags, + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if !cfg.Tor.StreamIsolation { + t.Error("StreamIsolation should be true from flag override") + } +} + +type stubFlagReader struct { + changed map[string]bool + ints map[string]int + strings map[string]string + bools map[string]bool +} + +func (s *stubFlagReader) Changed(name string) bool { + return s.changed[name] +} + +func (s *stubFlagReader) GetInt(name string) (int, error) { + if v, ok := s.ints[name]; ok { + return v, nil + } + return 0, nil +} + +func (s *stubFlagReader) GetString(name string) (string, error) { + if v, ok := s.strings[name]; ok { + return v, nil + } + return "", nil +} + +func (s *stubFlagReader) GetBool(name string) (bool, error) { + if v, ok := s.bools[name]; ok { + return v, nil + } + return false, nil +} + +func TestLoadUserAgents_FromFile(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + uaPath := filepath.Join(tmpDir, "useragents.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances:\n per_country: 2\n countries: 6\nrelay:\n enforce: entry\n"), 0644); err != nil { + t.Fatal(err) + } + + uaContent := ` +user_agents: + - "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" + - "Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0" + - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.0" +default: "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" +` + if err := os.WriteFile(uaPath, []byte(uaContent), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + UserAgentsPath: uaPath, + EnvPrefix: "SPLITTER_TEST_UA_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if len(cfg.UserAgent.UserAgents) != 3 { + t.Errorf("UserAgents length = %d, want 3", len(cfg.UserAgent.UserAgents)) + } + if cfg.UserAgent.Default != "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" { + t.Errorf("Default = %q, want Tor Browser 128 UA", cfg.UserAgent.Default) + } + if cfg.UserAgent.TorBrowser != "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" { + t.Errorf("TorBrowser = %q, want updated to default UA", cfg.UserAgent.TorBrowser) + } +} + +func TestLoadUserAgents_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances:\n per_country: 2\n countries: 6\nrelay:\n enforce: entry\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + UserAgentsPath: filepath.Join(tmpDir, "nonexistent.yaml"), + EnvPrefix: "SPLITTER_TEST_UA_MISS_", + }) + if err != nil { + t.Fatalf("Load with missing useragents file: %v", err) + } + + if len(cfg.UserAgent.UserAgents) != 0 { + t.Errorf("UserAgents length = %d, want 0 (file missing)", len(cfg.UserAgent.UserAgents)) + } + if cfg.UserAgent.Default != "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" { + t.Errorf("Default = %q, want built-in default", cfg.UserAgent.Default) + } +} + +func TestPickUserAgent_FromList(t *testing.T) { + uas := []string{ + "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0", + "Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:102.0) Gecko/20100101 Firefox/102.0", + } + ua := &UserAgentConfig{ + TorBrowser: "fallback", + UserAgents: uas, + Default: "default-ua", + } + + for i := 0; i < 50; i++ { + picked := ua.PickUserAgent() + found := false + for _, u := range uas { + if picked == u { + found = true + break + } + } + if !found { + t.Errorf("PickUserAgent() = %q, not in user_agents list", picked) + } + } +} + +func TestPickUserAgent_EmptyList(t *testing.T) { + ua := &UserAgentConfig{ + TorBrowser: "fallback-ua", + UserAgents: nil, + Default: "default-ua", + } + + picked := ua.PickUserAgent() + if picked != "default-ua" { + t.Errorf("PickUserAgent() = %q, want %q (Default fallback)", picked, "default-ua") + } +} + +func TestPickUserAgent_EmptyListNoDefault(t *testing.T) { + ua := &UserAgentConfig{ + TorBrowser: "legacy-ua", + UserAgents: nil, + Default: "", + } + + picked := ua.PickUserAgent() + if picked != "legacy-ua" { + t.Errorf("PickUserAgent() = %q, want %q (TorBrowser fallback)", picked, "legacy-ua") + } +} + +func TestPickUserAgent_EmptySliceUsesDefault(t *testing.T) { + ua := &UserAgentConfig{ + TorBrowser: "legacy-ua", + UserAgents: []string{}, + Default: "default-ua", + } + + picked := ua.PickUserAgent() + if picked != "default-ua" { + t.Errorf("PickUserAgent() = %q, want %q (empty slice uses Default)", picked, "default-ua") + } +} + +func TestLoadUserAgents_EnvOverride(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "test.yaml") + uaPath := filepath.Join(tmpDir, "useragents.yaml") + + if err := os.WriteFile(cfgPath, []byte("instances:\n per_country: 2\n countries: 6\nrelay:\n enforce: entry\n"), 0644); err != nil { + t.Fatal(err) + } + + uaContent := ` +user_agents: + - "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" +default: "Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0" +` + if err := os.WriteFile(uaPath, []byte(uaContent), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SPLITTER_TEST_UAENV_USER_AGENT_DEFAULT", "custom-ua-from-env") + + cfg, err := Load(LoadOptions{ + ConfigPath: cfgPath, + UserAgentsPath: uaPath, + EnvPrefix: "SPLITTER_TEST_UAENV_", + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.UserAgent.Default != "custom-ua-from-env" { + t.Errorf("Default = %q, want %q (env override)", cfg.UserAgent.Default, "custom-ua-from-env") + } +} diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..09b4867 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,2 @@ +// Package config provides configuration loading from YAML files, environment variables, and CLI flags for SPLITTER. +package config diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..cd40c20 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,162 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type envSetter func(*Config, string) + +var envMappings = map[string]envSetter{ + "INSTANCES": func(c *Config, v string) { c.Instances.PerCountry = atoi(v) }, + "INSTANCES_PER_COUNTRY": func(c *Config, v string) { c.Instances.PerCountry = atoi(v) }, + "INSTANCES_COUNTRIES": func(c *Config, v string) { c.Instances.Countries = atoi(v) }, + "INSTANCES_MAX_CONCURRENT_REQUESTS": func(c *Config, v string) { c.Instances.MaxConcurrentRequests = atoi(v) }, + "INSTANCES_RETRIES": func(c *Config, v string) { c.Instances.Retries = atoi(v) }, + "COUNTRIES": func(c *Config, v string) { c.Instances.Countries = atoi(v) }, + + "RELAY_ENFORCE": func(c *Config, v string) { c.Relay.Enforce = v }, + + "PROXY_MODE": func(c *Config, v string) { c.ProxyMode = v }, + "PROXY_LOAD_BALANCE_ALGORITHM": func(c *Config, v string) { c.Proxy.LoadBalanceAlgorithm = v }, + "PROXY_MASTER_LISTEN": func(c *Config, v string) { c.Proxy.Master.Listen = v }, + "PROXY_MASTER_PORT": func(c *Config, v string) { c.Proxy.Master.Port = atoi(v) }, + "PROXY_MASTER_SOCKS_PORT": func(c *Config, v string) { c.Proxy.Master.SocksPort = atoi(v) }, + "PROXY_MASTER_HTTP_PORT": func(c *Config, v string) { c.Proxy.Master.HTTPPort = atoi(v) }, + "PROXY_MASTER_TRANSPARENT_PORT": func(c *Config, v string) { c.Proxy.Master.TransparentPort = atoi(v) }, + "PROXY_MASTER_CLIENT_TIMEOUT": func(c *Config, v string) { c.Proxy.Master.ClientTimeout = atoi(v) }, + "PROXY_MASTER_SERVER_TIMEOUT": func(c *Config, v string) { c.Proxy.Master.ServerTimeout = atoi(v) }, + "PROXY_STATS_LISTEN": func(c *Config, v string) { c.Proxy.Stats.Listen = v }, + "PROXY_STATS_PORT": func(c *Config, v string) { c.Proxy.Stats.Port = atoi(v) }, + "PROXY_STATS_URI": func(c *Config, v string) { c.Proxy.Stats.URI = v }, + "PROXY_HAPROXY_HTTP_REUSE": func(c *Config, v string) { c.Proxy.HAProxyHTTPReuse = v }, + "PROXY_INCLUDE_SECURITY_HEADERS": func(c *Config, v string) { c.Proxy.IncludeSecurityHeaders = parseBool(v) }, + + "TOR_BINARY_PATH": func(c *Config, v string) { c.Tor.BinaryPath = v }, + "TOR_LISTEN_ADDR": func(c *Config, v string) { c.Tor.ListenAddr = v }, + "TOR_START_SOCKS_PORT": func(c *Config, v string) { c.Tor.StartSocksPort = atoi(v) }, + "TOR_START_CONTROL_PORT": func(c *Config, v string) { c.Tor.StartControlPort = atoi(v) }, + "TOR_START_HTTP_PORT": func(c *Config, v string) { c.Tor.StartHTTPPort = atoi(v) }, + "TOR_START_TRANSPORT_PORT": func(c *Config, v string) { c.Tor.StartTransportPort = atoi(v) }, + "TOR_START_DNS_PORT": func(c *Config, v string) { c.Tor.StartDNSPort = atoi(v) }, + "TOR_CONTROL_AUTH": func(c *Config, v string) { c.Tor.ControlAuth = v }, + "TOR_MINIMUM_TIMEOUT": func(c *Config, v string) { c.Tor.MinimumTimeout = atoi(v) }, + "TOR_CIRCUIT_BUILD_TIMEOUT": func(c *Config, v string) { c.Tor.CircuitBuildTimeout = atoi(v) }, + "TOR_LEARN_CIRCUIT_BUILD_TIMEOUT": func(c *Config, v string) { c.Tor.LearnCircuitBuildTimeout = atoi(v) }, + "TOR_CIRCUITS_AVAILABLE_TIMEOUT": func(c *Config, v string) { c.Tor.CircuitsAvailableTimeout = atoi(v) }, + "TOR_CIRCUIT_STREAM_TIMEOUT": func(c *Config, v string) { c.Tor.CircuitStreamTimeout = atoi(v) }, + "TOR_CLIENT_ONLY": func(c *Config, v string) { c.Tor.ClientOnly = atoi(v) }, + "TOR_CONNECTION_PADDING": func(c *Config, v string) { c.Tor.ConnectionPadding = atoi(v) }, + "TOR_REDUCED_CONNECTION_PADDING": func(c *Config, v string) { c.Tor.ReducedConnectionPadding = atoi(v) }, + "TOR_GEOIP_EXCLUDE_UNKNOWN": func(c *Config, v string) { c.Tor.GeoIPExcludeUnknown = atoi(v) }, + "TOR_STRICT_NODES": func(c *Config, v string) { c.Tor.StrictNodes = atoi(v) }, + "TOR_FASCIST_FIREWALL": func(c *Config, v string) { c.Tor.FascistFirewall = atoi(v) }, + "TOR_NEW_CIRCUIT_PERIOD": func(c *Config, v string) { c.Tor.NewCircuitPeriod = atoi(v) }, + "TOR_MAX_CIRCUIT_DIRTINESS": func(c *Config, v string) { c.Tor.MaxCircuitDirtiness = atoi(v) }, + "TOR_MAX_CLIENT_CIRCUITS_PENDING": func(c *Config, v string) { c.Tor.MaxClientCircuitsPending = atoi(v) }, + "TOR_SOCKS_TIMEOUT": func(c *Config, v string) { c.Tor.SocksTimeout = atoi(v) }, + "TOR_TRACK_HOST_EXITS_EXPIRE": func(c *Config, v string) { c.Tor.TrackHostExitsExpire = atoi(v) }, + "TOR_USE_ENTRY_GUARDS": func(c *Config, v string) { c.Tor.UseEntryGuards = atoi(v) }, + "TOR_NUM_ENTRY_GUARDS": func(c *Config, v string) { c.Tor.NumEntryGuards = atoi(v) }, + "TOR_SAFE_SOCKS": func(c *Config, v string) { c.Tor.SafeSocks = atoi(v) }, + "TOR_TEST_SOCKS": func(c *Config, v string) { c.Tor.TestSocks = atoi(v) }, + "TOR_OPTIMISTIC_DATA": func(c *Config, v string) { c.Tor.OptimisticData = v }, + "TOR_AUTOMAP_HOSTS_SUFFIXES": func(c *Config, v string) { c.Tor.AutomapHostsSuffixes = v }, + "TOR_WARN_PLAINTEXT_PORTS": func(c *Config, v string) { c.Tor.WarnPlaintextPorts = v }, + "TOR_REJECT_PLAINTEXT_PORTS": func(c *Config, v string) { c.Tor.RejectPlaintextPorts = v }, + "TOR_STREAM_ISOLATION": func(c *Config, v string) { c.Tor.StreamIsolation = parseBool(v) }, + "TOR_IPV6": func(c *Config, v string) { c.Tor.IPv6 = parseBool(v) }, + "TOR_CONFLUX_ENABLED": func(c *Config, v string) { c.Tor.ConfluxEnabled = parseBool(v) }, + "TOR_CONGESTION_CONTROL_AUTO": func(c *Config, v string) { c.Tor.CongestionControlAuto = parseBool(v) }, + "TOR_CIRCUIT_FINGERPRINTING_RESISTANCE": func(c *Config, v string) { c.Tor.CircuitFingerprintingResistance = parseBool(v) }, + "TOR_SANDBOX": func(c *Config, v string) { c.Tor.Sandbox = parseBool(v) }, + + "PRIVOXY_BINARY_PATH": func(c *Config, v string) { c.Privoxy.BinaryPath = v }, + "PRIVOXY_LISTEN": func(c *Config, v string) { c.Privoxy.Listen = v }, + "PRIVOXY_START_PORT": func(c *Config, v string) { c.Privoxy.StartPort = atoi(v) }, + "PRIVOXY_TIMEOUT": func(c *Config, v string) { c.Privoxy.Timeout = atoi(v) }, + "PRIVOXY_CONFIG_FILE_PREFIX": func(c *Config, v string) { c.Privoxy.ConfigFilePrefix = v }, + + "HAPROXY_BINARY_PATH": func(c *Config, v string) { c.HAProxy.BinaryPath = v }, + "HAPROXY_CONFIG_FILE": func(c *Config, v string) { c.HAProxy.ConfigFile = v }, + + "COUNTRY_SELECTED": func(c *Config, v string) { c.Country.Selected = v }, + "COUNTRY_ROTATION_ENABLED": func(c *Config, v string) { c.Country.Rotation.Enabled = parseBool(v) }, + "COUNTRY_ROTATION_INTERVAL": func(c *Config, v string) { c.Country.Rotation.Interval = atoi(v) }, + "COUNTRY_ROTATION_TOTAL_TO_CHANGE": func(c *Config, v string) { c.Country.Rotation.TotalToChange = atoi(v) }, + "COUNTRY_AUTO_COUNTRIES": func(c *Config, v string) { c.Country.AutoCountries = parseBool(v) }, + + "HEALTH_CHECK_URL": func(c *Config, v string) { c.HealthCheck.URL = v }, + "HEALTH_CHECK_INTERVAL": func(c *Config, v string) { c.HealthCheck.Interval = atoi(v) }, + "HEALTH_CHECK_MAX_FAIL": func(c *Config, v string) { c.HealthCheck.MaxFail = atoi(v) }, + "HEALTH_CHECK_MINIMUM_SUCCESS": func(c *Config, v string) { c.HealthCheck.MinimumSuccess = atoi(v) }, + + "USER_AGENT_TOR_BROWSER": func(c *Config, v string) { c.UserAgent.TorBrowser = v }, + "USER_AGENT_DEFAULT": func(c *Config, v string) { c.UserAgent.Default = v }, + + "LOG": func(c *Config, v string) { c.Logging.Enabled = parseBool(v); c.Log = parseBool(v) }, + "LOG_LEVEL": func(c *Config, v string) { c.Logging.Level = v; c.LogLevel = v }, + "LOGGING_ENABLED": func(c *Config, v string) { c.Logging.Enabled = parseBool(v) }, + "LOGGING_DIR": func(c *Config, v string) { c.Logging.Dir = v }, + "LOGGING_LEVEL": func(c *Config, v string) { c.Logging.Level = v }, + "LOGGING_FORMAT": func(c *Config, v string) { c.Logging.Format = v }, + + "PATHS_TEMP_FILES": func(c *Config, v string) { c.Paths.TempFiles = v }, + "PATHS_PROXYCHAINS_FILE": func(c *Config, v string) { c.Paths.ProxychainsFile = v }, + + "DNS_DIST_LISTEN": func(c *Config, v string) { c.DNS.DistListen = v }, + "DNS_DIST_PORT": func(c *Config, v string) { c.DNS.DistPort = atoi(v) }, + "DNS_TOR_LISTEN": func(c *Config, v string) { c.DNS.TorListen = v }, + + "EXIT_REPUTATION": func(c *Config, v string) { c.ExitReputation.Enabled = parseBool(v) }, + + "PROFILE": func(c *Config, v string) { c.Profile = v }, + "BRIDGE_TYPE": func(c *Config, v string) { c.BridgeType = v }, + "VERBOSE": func(c *Config, v string) { c.Verbose = parseBool(v) }, +} + +func applyEnvOverrides(cfg *Config, prefix string) error { + envVars := os.Environ() + upperPrefix := strings.ToUpper(prefix) + if !strings.HasSuffix(upperPrefix, "_") { + upperPrefix += "_" + } + + var errs []string + for _, envVar := range envVars { + parts := strings.SplitN(envVar, "=", 2) + if len(parts) != 2 { + continue + } + key := parts[0] + value := parts[1] + + if !strings.HasPrefix(key, upperPrefix) { + continue + } + + suffix := strings.TrimPrefix(key, upperPrefix) + setter, ok := envMappings[suffix] + if !ok { + continue + } + setter(cfg, value) + } + + if len(errs) > 0 { + return fmt.Errorf("applyEnvOverrides: %s", strings.Join(errs, "; ")) + } + return nil +} + +func atoi(s string) int { + v, _ := strconv.Atoi(s) + return v +} + +func parseBool(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + return s == "1" || s == "true" || s == "yes" || s == "on" +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000..e585485 --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,189 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type FlagReader interface { + Changed(name string) bool + GetInt(name string) (int, error) + GetString(name string) (string, error) + GetBool(name string) (bool, error) +} + +type LoadOptions struct { + ConfigPath string + ProfilesPath string + UserAgentsPath string + EnvPrefix string + Flags FlagReader +} + +func Load(opts LoadOptions) (*Config, error) { + cfg := Defaults() + + if opts.ConfigPath == "" { + opts.ConfigPath = "configs/default.yaml" + } + if opts.EnvPrefix == "" { + opts.EnvPrefix = "SPLITTER_" + } + + if err := loadYAML(cfg, opts.ConfigPath); err != nil { + return nil, fmt.Errorf("Load: %w", err) + } + + if err := loadUserAgents(cfg, opts.UserAgentsPath); err != nil { + return nil, fmt.Errorf("Load: %w", err) + } + + profileName := cfg.Profile + if opts.Flags != nil && opts.Flags.Changed("profile") { + if v, err := opts.Flags.GetString("profile"); err == nil { + profileName = v + cfg.Profile = v + } + } + + if err := applyProfile(cfg, profileName, opts.ProfilesPath); err != nil { + return nil, fmt.Errorf("Load: %w", err) + } + + if err := applyEnvOverrides(cfg, opts.EnvPrefix); err != nil { + return nil, fmt.Errorf("Load: %w", err) + } + + if opts.Flags != nil { + applyFlags(cfg, opts.Flags) + } + + if cfg.LogLevel != "" { + cfg.Logging.Level = strings.ToUpper(cfg.LogLevel) + } + if cfg.Log { + cfg.Logging.Enabled = true + } + + if err := Validate(cfg); err != nil { + return nil, fmt.Errorf("Load: %w", err) + } + + return cfg, nil +} + +func loadYAML(cfg *Config, path string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("loadYAML: reading %s: %w", path, err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("loadYAML: parsing %s: %w", path, err) + } + return nil +} + +func applyFlags(cfg *Config, flags FlagReader) { + if flags.Changed("instances") { + if v, err := flags.GetInt("instances"); err == nil { + cfg.Instances.PerCountry = v + } + } + if flags.Changed("countries") { + if v, err := flags.GetInt("countries"); err == nil { + cfg.Instances.Countries = v + } + } + if flags.Changed("relay-enforce") { + if v, err := flags.GetString("relay-enforce"); err == nil { + cfg.Relay.Enforce = v + } + } + if flags.Changed("profile") { + if v, err := flags.GetString("profile"); err == nil { + cfg.Profile = v + } + } + if flags.Changed("proxy-mode") { + if v, err := flags.GetString("proxy-mode"); err == nil { + cfg.ProxyMode = v + } + } + if flags.Changed("bridge-type") { + if v, err := flags.GetString("bridge-type"); err == nil { + cfg.BridgeType = v + } + } + if flags.Changed("verbose") { + if v, err := flags.GetBool("verbose"); err == nil { + cfg.Verbose = v + } + } + if flags.Changed("log") { + if v, err := flags.GetBool("log"); err == nil { + cfg.Log = v + } + } + if flags.Changed("log-level") { + if v, err := flags.GetString("log-level"); err == nil { + cfg.LogLevel = v + } + } + if flags.Changed("auto-countries") { + if v, err := flags.GetBool("auto-countries"); err == nil { + cfg.Country.AutoCountries = v + } + } + if flags.Changed("stream-isolation") { + if v, err := flags.GetBool("stream-isolation"); err == nil { + cfg.Tor.StreamIsolation = v + } + } + if flags.Changed("ipv6") { + if v, err := flags.GetBool("ipv6"); err == nil { + cfg.Tor.IPv6 = v + } + } + if flags.Changed("exit-reputation") { + if v, err := flags.GetBool("exit-reputation"); err == nil { + cfg.ExitReputation.Enabled = v + } + } +} + +func loadUserAgents(cfg *Config, userAgentsPath string) error { + if userAgentsPath == "" { + userAgentsPath = "configs/useragents.yaml" + } + data, err := os.ReadFile(userAgentsPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("loadUserAgents: %w", err) + } + + var uaFile struct { + UserAgents []string `yaml:"user_agents"` + Default string `yaml:"default"` + } + if err := yaml.Unmarshal(data, &uaFile); err != nil { + return fmt.Errorf("loadUserAgents: parsing: %w", err) + } + + if len(uaFile.UserAgents) > 0 { + cfg.UserAgent.UserAgents = uaFile.UserAgents + } + if uaFile.Default != "" { + cfg.UserAgent.Default = uaFile.Default + cfg.UserAgent.TorBrowser = uaFile.Default + } + return nil +} diff --git a/internal/config/profiles.go b/internal/config/profiles.go new file mode 100644 index 0000000..6c7f07a --- /dev/null +++ b/internal/config/profiles.go @@ -0,0 +1,174 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type profileEntry struct { + Description string `yaml:"description"` + Instances *profileInstances `yaml:"instances"` + Relay *profileRelay `yaml:"relay"` + Proxy *profileProxy `yaml:"proxy"` + Tor *profileTor `yaml:"tor"` + Logging *profileLogging `yaml:"logging"` + Country *profileCountry `yaml:"country"` + HealthCheck *profileHealthCheck `yaml:"health_check"` +} + +type profileInstances struct { + PerCountry *int `yaml:"per_country"` + Countries *int `yaml:"countries"` + MaxConcurrentRequests *int `yaml:"max_concurrent_requests"` + Retries *int `yaml:"retries"` +} + +type profileRelay struct { + Enforce *string `yaml:"enforce"` +} + +type profileProxy struct { + LoadBalanceAlgorithm *string `yaml:"load_balance_algorithm"` + HAProxyHTTPReuse *string `yaml:"haproxy_http_reuse"` +} + +type profileTor struct { + MaxCircuitDirtiness *int `yaml:"max_circuit_dirtiness"` + ConnectionPadding *int `yaml:"connection_padding"` + UseEntryGuards *int `yaml:"use_entry_guards"` + ReducedConnectionPadding *int `yaml:"reduced_connection_padding"` + StreamIsolation *bool `yaml:"stream_isolation"` + IPv6 *bool `yaml:"ipv6"` + ConfluxEnabled *bool `yaml:"conflux_enabled"` + CongestionControlAuto *bool `yaml:"congestion_control_auto"` + Sandbox *bool `yaml:"sandbox"` + CircuitFingerprintingResistance *bool `yaml:"circuit_fingerprinting_resistance"` +} + +type profileLogging struct { + Enabled *bool `yaml:"enabled"` + Level *string `yaml:"level"` +} + +type profileCountry struct { + RotationInterval *int `yaml:"rotation_interval"` + TotalToChange *int `yaml:"total_to_change"` +} + +type profileHealthCheck struct { + ExitReputation *bool `yaml:"exit_reputation"` +} + +func applyProfile(cfg *Config, profileName string, profilesPath string) error { + if profileName == "" { + return nil + } + + if profilesPath == "" { + profilesPath = "configs/profiles.yaml" + } + + data, err := os.ReadFile(profilesPath) + if err != nil { + return fmt.Errorf("applyProfile: reading %s: %w", profilesPath, err) + } + + var profiles map[string]profileEntry + if err := yaml.Unmarshal(data, &profiles); err != nil { + return fmt.Errorf("applyProfile: parsing %s: %w", profilesPath, err) + } + + entry, ok := profiles[profileName] + if !ok { + return fmt.Errorf("applyProfile: unknown profile %q", profileName) + } + + if entry.Instances != nil { + if entry.Instances.PerCountry != nil { + cfg.Instances.PerCountry = *entry.Instances.PerCountry + } + if entry.Instances.Countries != nil { + cfg.Instances.Countries = *entry.Instances.Countries + } + if entry.Instances.MaxConcurrentRequests != nil { + cfg.Instances.MaxConcurrentRequests = *entry.Instances.MaxConcurrentRequests + } + if entry.Instances.Retries != nil { + cfg.Instances.Retries = *entry.Instances.Retries + } + } + + if entry.Relay != nil && entry.Relay.Enforce != nil { + cfg.Relay.Enforce = *entry.Relay.Enforce + } + + if entry.Proxy != nil { + if entry.Proxy.LoadBalanceAlgorithm != nil { + cfg.Proxy.LoadBalanceAlgorithm = *entry.Proxy.LoadBalanceAlgorithm + } + if entry.Proxy.HAProxyHTTPReuse != nil { + cfg.Proxy.HAProxyHTTPReuse = *entry.Proxy.HAProxyHTTPReuse + } + } + + if entry.Tor != nil { + if entry.Tor.MaxCircuitDirtiness != nil { + cfg.Tor.MaxCircuitDirtiness = *entry.Tor.MaxCircuitDirtiness + } + if entry.Tor.ConnectionPadding != nil { + cfg.Tor.ConnectionPadding = *entry.Tor.ConnectionPadding + } + if entry.Tor.UseEntryGuards != nil { + cfg.Tor.UseEntryGuards = *entry.Tor.UseEntryGuards + } + if entry.Tor.ReducedConnectionPadding != nil { + cfg.Tor.ReducedConnectionPadding = *entry.Tor.ReducedConnectionPadding + } + if entry.Tor.StreamIsolation != nil { + cfg.Tor.StreamIsolation = *entry.Tor.StreamIsolation + } + if entry.Tor.IPv6 != nil { + cfg.Tor.IPv6 = *entry.Tor.IPv6 + } + if entry.Tor.ConfluxEnabled != nil { + cfg.Tor.ConfluxEnabled = *entry.Tor.ConfluxEnabled + } + if entry.Tor.CongestionControlAuto != nil { + cfg.Tor.CongestionControlAuto = *entry.Tor.CongestionControlAuto + } + if entry.Tor.Sandbox != nil { + cfg.Tor.Sandbox = *entry.Tor.Sandbox + } + if entry.Tor.CircuitFingerprintingResistance != nil { + cfg.Tor.CircuitFingerprintingResistance = *entry.Tor.CircuitFingerprintingResistance + } + } + + if entry.Logging != nil { + if entry.Logging.Enabled != nil { + cfg.Logging.Enabled = *entry.Logging.Enabled + } + if entry.Logging.Level != nil { + cfg.Logging.Level = *entry.Logging.Level + } + } + + if entry.Country != nil { + if entry.Country.RotationInterval != nil { + cfg.Country.Rotation.Interval = *entry.Country.RotationInterval + } + if entry.Country.TotalToChange != nil { + cfg.Country.Rotation.TotalToChange = *entry.Country.TotalToChange + } + } + + if entry.HealthCheck != nil { + if entry.HealthCheck.ExitReputation != nil { + cfg.ExitReputation.Enabled = *entry.HealthCheck.ExitReputation + } + } + + return nil +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..c5ea6e1 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,84 @@ +package config + +import ( + "fmt" + "strings" +) + +func Validate(cfg *Config) error { + if cfg.Instances.PerCountry <= 0 { + return fmt.Errorf("validate: instances.per_country must be > 0, got %d", cfg.Instances.PerCountry) + } + if cfg.Instances.Countries <= 0 { + return fmt.Errorf("validate: instances.countries must be > 0, got %d", cfg.Instances.Countries) + } + + if !inSet(cfg.Relay.Enforce, "entry", "exit", "speed") { + return fmt.Errorf("validate: relay.enforce must be entry|exit|speed, got %q", cfg.Relay.Enforce) + } + if !inSet(cfg.ProxyMode, "native", "legacy") { + return fmt.Errorf("validate: proxy_mode must be native|legacy, got %q", cfg.ProxyMode) + } + if !inSet(cfg.BridgeType, "snowflake", "webtunnel", "obfs4", "none") { + return fmt.Errorf("validate: bridge_type must be snowflake|webtunnel|obfs4|none, got %q", cfg.BridgeType) + } + if cfg.Profile != "" && !inSet(cfg.Profile, "stealth", "balanced", "streaming", "pentest") { + return fmt.Errorf("validate: profile must be stealth|balanced|streaming|pentest, got %q", cfg.Profile) + } + if cfg.LogLevel != "" && !inSet(strings.ToLower(cfg.LogLevel), "debug", "info", "warn", "error") { + return fmt.Errorf("validate: log_level must be debug|info|warn|error, got %q", cfg.LogLevel) + } + if !inSet(strings.ToLower(cfg.Logging.Level), "debug", "info", "warn", "error") { + return fmt.Errorf("validate: logging.level must be debug|info|warn|error, got %q", cfg.Logging.Level) + } + + if err := validatePorts(cfg); err != nil { + return err + } + + totalPorts := cfg.Instances.PerCountry * cfg.Instances.Countries + maxPort := cfg.Tor.StartSocksPort + totalPorts + if maxPort > 65535 { + return fmt.Errorf("validate: SOCKS port range exceeds 65535 (start=%d + %d instances = %d)", + cfg.Tor.StartSocksPort, totalPorts, maxPort) + } + + return nil +} + +func validatePorts(cfg *Config) error { + ports := []struct { + name string + val int + }{ + {"proxy.master.port", cfg.Proxy.Master.Port}, + {"proxy.master.socks_port", cfg.Proxy.Master.SocksPort}, + {"proxy.master.http_port", cfg.Proxy.Master.HTTPPort}, + {"proxy.master.transparent_port", cfg.Proxy.Master.TransparentPort}, + {"proxy.stats.port", cfg.Proxy.Stats.Port}, + {"tor.start_socks_port", cfg.Tor.StartSocksPort}, + {"tor.start_control_port", cfg.Tor.StartControlPort}, + {"tor.start_http_port", cfg.Tor.StartHTTPPort}, + {"tor.start_transport_port", cfg.Tor.StartTransportPort}, + {"tor.start_dns_port", cfg.Tor.StartDNSPort}, + {"tor.hidden_service.start_port", cfg.Tor.HiddenService.StartPort}, + {"privoxy.start_port", cfg.Privoxy.StartPort}, + {"dns.dist_port", cfg.DNS.DistPort}, + } + + for _, p := range ports { + if p.val < 0 || p.val > 65535 { + return fmt.Errorf("validate: %s must be 0-65535, got %d", p.name, p.val) + } + } + return nil +} + +func inSet(val string, valid ...string) bool { + for _, v := range valid { + if val == v { + return true + } + } + return false +} diff --git a/internal/config/validate_extra_test.go b/internal/config/validate_extra_test.go new file mode 100644 index 0000000..1d973cb --- /dev/null +++ b/internal/config/validate_extra_test.go @@ -0,0 +1,224 @@ +package config + +import ( + "testing" +) + +func TestValidate_EntryMode(t *testing.T) { + cfg := Defaults() + cfg.Relay.Enforce = "entry" + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with entry mode error = %v", err) + } +} + +func TestValidate_ExitMode(t *testing.T) { + cfg := Defaults() + cfg.Relay.Enforce = "exit" + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with exit mode error = %v", err) + } +} + +func TestValidate_SpeedMode(t *testing.T) { + cfg := Defaults() + cfg.Relay.Enforce = "speed" + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with speed mode error = %v", err) + } +} + +func TestValidate_PortZero(t *testing.T) { + cfg := Defaults() + cfg.Tor.StartSocksPort = 0 + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with port 0 should be valid, got error = %v", err) + } +} + +func TestValidate_PortOne(t *testing.T) { + cfg := Defaults() + cfg.Tor.StartSocksPort = 1 + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with port 1 should be valid, got error = %v", err) + } +} + +func TestValidate_Port65534(t *testing.T) { + // Port 65534 with 1 instance: 65534 + 1 = 65535, which is <= 65535, valid. + cfg := Defaults() + cfg.Tor.StartSocksPort = 65534 + cfg.Instances.PerCountry = 1 + cfg.Instances.Countries = 1 + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with port 65534+1 should be valid, got error = %v", err) + } +} + +func TestValidate_Port65535Overflow(t *testing.T) { + // Port 65535 with 1 instance: 65535 + 1 = 65536 > 65535, should fail. + cfg := Defaults() + cfg.Tor.StartSocksPort = 65535 + cfg.Instances.PerCountry = 1 + cfg.Instances.Countries = 1 + if err := Validate(cfg); err == nil { + t.Error("Validate() with port 65535 + 1 instance should fail (65536 > 65535)") + } +} + +func TestValidate_PortAbove65535(t *testing.T) { + cfg := Defaults() + cfg.Tor.StartSocksPort = 65536 + if err := Validate(cfg); err == nil { + t.Error("Validate() with port 65536 expected error, got nil") + } +} + +func TestValidate_PortNegative(t *testing.T) { + cfg := Defaults() + cfg.Tor.StartSocksPort = -1 + if err := Validate(cfg); err == nil { + t.Error("Validate() with negative port expected error, got nil") + } +} + +func TestValidate_AllPortsAtZero(t *testing.T) { + cfg := Defaults() + cfg.Proxy.Master.Port = 0 + cfg.Proxy.Master.SocksPort = 0 + cfg.Proxy.Master.HTTPPort = 0 + cfg.Proxy.Master.TransparentPort = 0 + cfg.Proxy.Stats.Port = 0 + cfg.Tor.StartSocksPort = 0 + cfg.Tor.StartControlPort = 0 + cfg.Tor.StartHTTPPort = 0 + cfg.Tor.StartTransportPort = 0 + cfg.Tor.StartDNSPort = 0 + cfg.Tor.HiddenService.StartPort = 0 + cfg.Privoxy.StartPort = 0 + cfg.DNS.DistPort = 0 + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with all ports at 0 should be valid, got error = %v", err) + } +} + +func TestValidate_InvalidLogLevel(t *testing.T) { + cfg := Defaults() + cfg.LogLevel = "trace" + if err := Validate(cfg); err == nil { + t.Error("Validate() with log level trace expected error") + } +} + +func TestValidate_ValidLogLevels(t *testing.T) { + for _, level := range []string{"debug", "info", "warn", "error"} { + t.Run(level, func(t *testing.T) { + cfg := Defaults() + cfg.LogLevel = level + if err := Validate(cfg); err != nil { + t.Errorf("Validate() with log level %q error = %v", level, err) + } + }) + } +} + +func TestValidate_BridgeTypes(t *testing.T) { + tests := []struct { + bridge string + valid bool + }{ + {"snowflake", true}, + {"webtunnel", true}, + {"obfs4", true}, + {"none", true}, + {"meek", false}, + {"custom", false}, + } + + for _, tt := range tests { + t.Run(tt.bridge, func(t *testing.T) { + cfg := Defaults() + cfg.BridgeType = tt.bridge + err := Validate(cfg) + if (err == nil) != tt.valid { + t.Errorf("Validate() with bridge=%q valid=%v, got err=%v", tt.bridge, tt.valid, err) + } + }) + } +} + +func TestValidate_ProxyModes(t *testing.T) { + tests := []struct { + mode string + valid bool + }{ + {"native", true}, + {"legacy", true}, + {"socks5", false}, + {"http", false}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + cfg := Defaults() + cfg.ProxyMode = tt.mode + err := Validate(cfg) + if (err == nil) != tt.valid { + t.Errorf("Validate() with proxy_mode=%q valid=%v, got err=%v", tt.mode, tt.valid, err) + } + }) + } +} + +func TestValidate_Profiles(t *testing.T) { + tests := []struct { + profile string + valid bool + }{ + {"stealth", true}, + {"balanced", true}, + {"streaming", true}, + {"pentest", true}, + {"", true}, + {"unknown", false}, + } + + for _, tt := range tests { + t.Run(tt.profile, func(t *testing.T) { + cfg := Defaults() + cfg.Profile = tt.profile + err := Validate(cfg) + if (err == nil) != tt.valid { + t.Errorf("Validate() with profile=%q valid=%v, got err=%v", tt.profile, tt.valid, err) + } + }) + } +} + +func TestInSet(t *testing.T) { + if !inSet("entry", "entry", "exit", "speed") { + t.Error("inSet(entry) should be true") + } + if inSet("invalid", "entry", "exit", "speed") { + t.Error("inSet(invalid) should be false") + } + if !inSet("speed", "entry", "exit", "speed") { + t.Error("inSet(speed) should be true") + } +} + +func TestValidate_StatsPortAbove65535(t *testing.T) { + cfg := Defaults() + cfg.Proxy.Stats.Port = 70000 + if err := Validate(cfg); err == nil { + t.Error("Validate() with stats port 70000 expected error") + } +} + +func TestValidate_ControlPortNegative(t *testing.T) { + cfg := Defaults() + cfg.Tor.StartControlPort = -5 + if err := Validate(cfg); err == nil { + t.Error("Validate() with negative control port expected error") + } +} diff --git a/internal/country/daemon.go b/internal/country/daemon.go new file mode 100644 index 0000000..df8f0b2 --- /dev/null +++ b/internal/country/daemon.go @@ -0,0 +1,185 @@ +package country + +import ( + "context" + "fmt" + "log/slog" + "math/rand/v2" + "sync" + "time" + + "github.com/user/splitter/internal/config" +) + +type InstanceInfo struct { + ID int + Country string +} + +type InstanceRotator interface { + GetInstances() []InstanceInfo + RotateInstance(ctx context.Context, id int, newCountry string) error +} + +const maxJitter = 30 * time.Second + +type Daemon struct { + mu sync.Mutex + cfg *config.Config + rotator InstanceRotator + baseInterval time.Duration + cancelFunc context.CancelFunc + done chan struct{} + started bool +} + +func NewDaemon(cfg *config.Config, rotator InstanceRotator) *Daemon { + return &Daemon{ + cfg: cfg, + rotator: rotator, + baseInterval: time.Duration(cfg.Country.Rotation.Interval) * time.Second, + } +} + +func (d *Daemon) Start(ctx context.Context) error { + d.done = make(chan struct{}) + d.started = true + + if !d.cfg.Country.Rotation.Enabled { + slog.Info("country rotation disabled") + close(d.done) + return nil + } + + ctx, cancel := context.WithCancel(ctx) + d.cancelFunc = cancel + + go d.run(ctx) + + slog.Info("country rotation daemon started", "base_interval", d.baseInterval) + return nil +} + +func (d *Daemon) Stop() error { + if !d.started { + return nil + } + if d.cancelFunc != nil { + d.cancelFunc() + } + <-d.done + return nil +} + +func (d *Daemon) run(ctx context.Context) { + defer close(d.done) + + for { + interval := d.nextInterval() + t := time.NewTimer(interval) + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + } + + if err := d.rotateOnce(ctx); err != nil { + slog.Error("country rotation cycle failed", "error", err) + } + } +} + +func (d *Daemon) rotateOnce(ctx context.Context) error { + instances := d.rotator.GetInstances() + if len(instances) == 0 { + return nil + } + + d.mu.Lock() + totalToChange := d.cfg.Country.Rotation.TotalToChange + accepted := make([]string, len(d.cfg.Country.Accepted)) + copy(accepted, d.cfg.Country.Accepted) + blacklisted := make([]string, len(d.cfg.Country.Blacklisted)) + copy(blacklisted, d.cfg.Country.Blacklisted) + d.mu.Unlock() + + if totalToChange <= 0 { + return nil + } + if totalToChange > len(instances) { + totalToChange = len(instances) + } + + indices := make([]int, len(instances)) + for i := range indices { + indices[i] = i + } + rand.Shuffle(len(indices), func(i, j int) { + indices[i], indices[j] = indices[j], indices[i] + }) + + for _, idx := range indices[:totalToChange] { + inst := instances[idx] + newCountry, err := pickDifferentCountry(inst.Country, accepted, blacklisted) + if err != nil { + slog.Error("failed to pick new country", + "instance", inst.ID, + "current_country", inst.Country, + "error", err, + ) + continue + } + + if err := d.rotator.RotateInstance(ctx, inst.ID, newCountry); err != nil { + slog.Error("failed to rotate instance", + "instance", inst.ID, + "old_country", inst.Country, + "new_country", newCountry, + "error", err, + ) + continue + } + + slog.Info("rotated instance country", + "instance", inst.ID, + "old_country", inst.Country, + "new_country", newCountry, + ) + } + + return nil +} + +func (d *Daemon) nextInterval() time.Duration { + d.mu.Lock() + base := d.baseInterval + d.mu.Unlock() + + jitter := time.Duration(rand.Int64N(int64(maxJitter))) + return base + jitter +} + +func (d *Daemon) UpdateConfig(cfg *config.Config) { + d.mu.Lock() + defer d.mu.Unlock() + d.cfg = cfg + d.baseInterval = time.Duration(cfg.Country.Rotation.Interval) * time.Second +} + +func pickDifferentCountry(current string, accepted, blacklisted []string) (string, error) { + filtered := filterBlacklisted(accepted, blacklisted) + + var candidates []string + for _, c := range filtered { + if c != current { + candidates = append(candidates, c) + } + } + + if len(candidates) == 0 { + return "", fmt.Errorf("pickDifferentCountry: no alternative countries available for %q", current) + } + + return candidates[rand.IntN(len(candidates))], nil +} diff --git a/internal/country/daemon_test.go b/internal/country/daemon_test.go new file mode 100644 index 0000000..a69bd03 --- /dev/null +++ b/internal/country/daemon_test.go @@ -0,0 +1,267 @@ +package country + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/user/splitter/internal/config" +) + +type rotationRecord struct { + ID int + Country string +} + +type mockRotator struct { + mu sync.Mutex + instances []InstanceInfo + rotated []rotationRecord +} + +func newMockRotator(instances []InstanceInfo) *mockRotator { + inst := make([]InstanceInfo, len(instances)) + copy(inst, instances) + return &mockRotator{instances: inst} +} + +func (m *mockRotator) GetInstances() []InstanceInfo { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]InstanceInfo, len(m.instances)) + copy(out, m.instances) + return out +} + +func (m *mockRotator) RotateInstance(_ context.Context, id int, newCountry string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rotated = append(m.rotated, rotationRecord{ID: id, Country: newCountry}) + for i := range m.instances { + if m.instances[i].ID == id { + m.instances[i].Country = newCountry + break + } + } + return nil +} + +func (m *mockRotator) getRotated() []rotationRecord { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]rotationRecord, len(m.rotated)) + copy(out, m.rotated) + return out +} + +func TestDaemon_New(t *testing.T) { + cfg := testDaemonConfig() + rotator := newMockRotator(nil) + d := NewDaemon(cfg, rotator) + + if d == nil { + t.Fatal("NewDaemon returned nil") + } + if d.baseInterval != 120*time.Second { + t.Errorf("baseInterval = %v, want %v", d.baseInterval, 120*time.Second) + } +} + +func TestDaemon_RotationChangesCountry(t *testing.T) { + cfg := testDaemonConfig() + cfg.Country.Rotation.TotalToChange = 3 + + originals := map[int]string{0: "{US}", 1: "{DE}", 2: "{FR}"} + rotator := newMockRotator([]InstanceInfo{ + {ID: 0, Country: "{US}"}, + {ID: 1, Country: "{DE}"}, + {ID: 2, Country: "{FR}"}, + }) + + d := NewDaemon(cfg, rotator) + ctx := context.Background() + + if err := d.rotateOnce(ctx); err != nil { + t.Fatalf("rotateOnce() error = %v", err) + } + + rotated := rotator.getRotated() + if len(rotated) != 3 { + t.Fatalf("expected 3 rotations, got %d", len(rotated)) + } + + for _, r := range rotated { + original := originals[r.ID] + if r.Country == original { + t.Errorf("instance %d: country did not change (still %q)", r.ID, r.Country) + } + } +} + +func TestDaemon_Jitter(t *testing.T) { + cfg := testDaemonConfig() + rotator := newMockRotator(nil) + d := NewDaemon(cfg, rotator) + + uniqueIntervals := make(map[time.Duration]bool) + for i := 0; i < 100; i++ { + interval := d.nextInterval() + uniqueIntervals[interval] = true + + min := d.baseInterval + max := d.baseInterval + maxJitter + if interval < min || interval > max { + t.Errorf("interval %v out of range [%v, %v]", interval, min, max) + } + } + + if len(uniqueIntervals) < 10 { + t.Errorf("expected at least 10 unique intervals across 100 samples, got %d", len(uniqueIntervals)) + } +} + +func TestDaemon_DisabledRotation(t *testing.T) { + cfg := testDaemonConfig() + cfg.Country.Rotation.Enabled = false + + rotator := newMockRotator([]InstanceInfo{ + {ID: 0, Country: "{US}"}, + }) + + d := NewDaemon(cfg, rotator) + ctx := context.Background() + + if err := d.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if err := d.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + rotated := rotator.getRotated() + if len(rotated) != 0 { + t.Errorf("expected no rotations when disabled, got %d", len(rotated)) + } +} + +func TestDaemon_StopBeforeStart(t *testing.T) { + cfg := testDaemonConfig() + rotator := newMockRotator(nil) + d := NewDaemon(cfg, rotator) + + if err := d.Stop(); err != nil { + t.Fatalf("Stop() before Start() error = %v", err) + } +} + +func TestDaemon_StartStop(t *testing.T) { + cfg := testDaemonConfig() + cfg.Country.Rotation.Interval = 1 + + rotator := newMockRotator([]InstanceInfo{ + {ID: 0, Country: "{US}"}, + {ID: 1, Country: "{DE}"}, + }) + + d := NewDaemon(cfg, rotator) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := d.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if err := d.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + if !d.started { + t.Error("expected started to be true") + } +} + +func TestDaemon_EmptyInstances(t *testing.T) { + cfg := testDaemonConfig() + rotator := newMockRotator(nil) + + d := NewDaemon(cfg, rotator) + ctx := context.Background() + + if err := d.rotateOnce(ctx); err != nil { + t.Fatalf("rotateOnce() with empty instances error = %v", err) + } + + rotated := rotator.getRotated() + if len(rotated) != 0 { + t.Errorf("expected no rotations with empty instances, got %d", len(rotated)) + } +} + +func TestDaemon_UpdateConfig(t *testing.T) { + cfg := testDaemonConfig() + cfg.Country.Rotation.Interval = 120 + rotator := newMockRotator(nil) + d := NewDaemon(cfg, rotator) + + if d.baseInterval != 120*time.Second { + t.Errorf("initial baseInterval = %v, want %v", d.baseInterval, 120*time.Second) + } + + newCfg := testDaemonConfig() + newCfg.Country.Rotation.Interval = 60 + d.UpdateConfig(newCfg) + + if d.baseInterval != 60*time.Second { + t.Errorf("updated baseInterval = %v, want %v", d.baseInterval, 60*time.Second) + } +} + +func TestDaemon_UpdateConfig_ThreadSafe(t *testing.T) { + cfg := testDaemonConfig() + cfg.Country.Rotation.Interval = 1 + rotator := newMockRotator([]InstanceInfo{ + {ID: 0, Country: "{US}"}, + }) + d := NewDaemon(cfg, rotator) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := d.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var wg sync.WaitGroup + for i := range 10 { + wg.Add(1) + go func(interval int) { + defer wg.Done() + c := testDaemonConfig() + c.Country.Rotation.Interval = interval + d.UpdateConfig(c) + }(i + 10) + } + wg.Wait() + + if err := d.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } +} + +func testDaemonConfig() *config.Config { + cfg := &config.Config{} + cfg.Country.Accepted = []string{ + "{AU}", "{AT}", "{BE}", "{BG}", "{CA}", "{CZ}", "{DK}", + "{FI}", "{FR}", "{DE}", "{HU}", "{IS}", "{LV}", "{LT}", + "{LU}", "{MD}", "{NL}", "{NO}", "{PA}", "{PL}", "{RO}", + "{RU}", "{SC}", "{SG}", "{SK}", "{ES}", "{SE}", "{CH}", + "{TR}", "{UA}", "{GB}", "{US}", + } + cfg.Country.Blacklisted = nil + cfg.Country.Rotation.Enabled = true + cfg.Country.Rotation.Interval = 120 + cfg.Country.Rotation.TotalToChange = 10 + return cfg +} diff --git a/internal/country/doc.go b/internal/country/doc.go new file mode 100644 index 0000000..94262f5 --- /dev/null +++ b/internal/country/doc.go @@ -0,0 +1,2 @@ +// Package country handles country selection, randomization, and periodic rotation for Tor instance geo-binding in SPLITTER. +package country diff --git a/internal/country/metrics.go b/internal/country/metrics.go new file mode 100644 index 0000000..c68c252 --- /dev/null +++ b/internal/country/metrics.go @@ -0,0 +1,172 @@ +package country + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "net/http" + "os" + "sort" + "strings" + "time" +) + +const defaultCacheTTL = 12 * time.Hour + +const defaultMetricsURL = "https://onionoo.torproject.org/details?type=relay&running=true&fields=country,relay_flags" + +type cacheEntry struct { + FetchedAt time.Time `json:"fetched_at"` + Countries []string `json:"countries"` +} + +type MetricsFetcher struct { + client *http.Client + cacheTTL time.Duration + cacheDir string + metricsURL string +} + +func NewMetricsFetcher(cacheDir string) *MetricsFetcher { + return &MetricsFetcher{ + client: &http.Client{Timeout: 30 * time.Second}, + cacheTTL: defaultCacheTTL, + cacheDir: cacheDir, + metricsURL: defaultMetricsURL, + } +} + +func (mf *MetricsFetcher) SetMetricsURL(url string) { + mf.metricsURL = url +} + +func (mf *MetricsFetcher) FetchCountries(ctx context.Context) ([]string, error) { + cached, err := mf.readCache() + if err == nil && cached != nil && time.Since(cached.FetchedAt) < mf.cacheTTL { + return cached.Countries, nil + } + + countries, fetchErr := mf.fetchFromAPI(ctx) + if fetchErr != nil { + if cached != nil { + slog.Warn("metrics fetch failed, using stale cache", "error", fetchErr, "cache_age", time.Since(cached.FetchedAt)) + return cached.Countries, nil + } + return nil, fmt.Errorf("FetchCountries: fetch failed and no cache: %w", fetchErr) + } + + if len(countries) == 0 { + if cached != nil { + slog.Warn("metrics returned empty country list, using cache") + return cached.Countries, nil + } + return nil, fmt.Errorf("FetchCountries: API returned no countries and no cache available") + } + + if err := mf.writeCache(countries); err != nil { + slog.Warn("failed to write country cache", "error", err) + } + + return countries, nil +} + +type onionooResponse struct { + Relays []onionooRelay `json:"relays"` +} + +type onionooRelay struct { + Country string `json:"country"` + RelayFlags []string `json:"relay_flags"` +} + +func (mf *MetricsFetcher) fetchFromAPI(ctx context.Context) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mf.metricsURL, nil) + if err != nil { + return nil, fmt.Errorf("fetchFromAPI: creating request: %w", err) + } + + resp, err := mf.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetchFromAPI: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetchFromAPI: unexpected status %d", resp.StatusCode) + } + + var body onionooResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("fetchFromAPI: decoding response: %w", err) + } + + seen := make(map[string]bool) + for _, relay := range body.Relays { + if relay.Country == "" { + continue + } + hasRelay := false + for _, flag := range relay.RelayFlags { + if flag == "Guard" || flag == "Exit" { + hasRelay = true + break + } + } + if !hasRelay { + continue + } + code := strings.ToUpper(relay.Country) + seen[code] = true + } + + countries := make([]string, 0, len(seen)) + for c := range seen { + countries = append(countries, c) + } + sort.Strings(countries) + return countries, nil +} + +func (mf *MetricsFetcher) cachePath() string { + return mf.cacheDir + "/country_cache.json" +} + +func (mf *MetricsFetcher) readCache() (*cacheEntry, error) { + data, err := os.ReadFile(mf.cachePath()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("readCache: %w", err) + } + + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, fmt.Errorf("readCache: unmarshal: %w", err) + } + return &entry, nil +} + +func (mf *MetricsFetcher) writeCache(countries []string) error { + if err := os.MkdirAll(mf.cacheDir, 0700); err != nil { + return fmt.Errorf("writeCache: mkdir: %w", err) + } + + entry := cacheEntry{ + FetchedAt: time.Now().UTC(), + Countries: countries, + } + + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("writeCache: marshal: %w", err) + } + + if err := os.WriteFile(mf.cachePath(), data, 0600); err != nil { + return fmt.Errorf("writeCache: write: %w", err) + } + return nil +} diff --git a/internal/country/metrics_test.go b/internal/country/metrics_test.go new file mode 100644 index 0000000..861147e --- /dev/null +++ b/internal/country/metrics_test.go @@ -0,0 +1,424 @@ +package country + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMetricsFetcher_FetchCountries_Success(t *testing.T) { + response := onionooResponse{ + Relays: []onionooRelay{ + {Country: "us", RelayFlags: []string{"Exit", "Guard", "HSDir"}}, + {Country: "de", RelayFlags: []string{"Guard", "Stable"}}, + {Country: "fr", RelayFlags: []string{"Exit", "Fast"}}, + {Country: "nl", RelayFlags: []string{"Exit", "Guard"}}, + {Country: "us", RelayFlags: []string{"Exit"}}, + {Country: "xx", RelayFlags: []string{"Running"}}, + }, + } + + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + tmpDir := t.TempDir() + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + + expected := []string{"DE", "FR", "NL", "US"} + if len(countries) != len(expected) { + t.Fatalf("len(countries) = %d, want %d; got %v", len(countries), len(expected), countries) + } + for i, c := range expected { + if countries[i] != c { + t.Errorf("countries[%d] = %q, want %q", i, countries[i], c) + } + } +} + +func TestMetricsFetcher_FetchCountries_WritesCache(t *testing.T) { + response := onionooResponse{ + Relays: []onionooRelay{ + {Country: "us", RelayFlags: []string{"Guard"}}, + }, + } + + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + tmpDir := t.TempDir() + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + _, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "country_cache.json")) + if err != nil { + t.Fatalf("cache file not created: %v", err) + } + + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + t.Fatalf("cache unmarshal: %v", err) + } + if len(entry.Countries) != 1 || entry.Countries[0] != "US" { + t.Errorf("cached countries = %v, want [US]", entry.Countries) + } + if entry.FetchedAt.IsZero() { + t.Error("cached fetched_at is zero") + } +} + +func TestMetricsFetcher_FetchCountries_UsesCache(t *testing.T) { + tmpDir := t.TempDir() + cachePath := filepath.Join(tmpDir, "country_cache.json") + + cached := cacheEntry{ + FetchedAt: time.Now().UTC(), + Countries: []string{"DE", "FR", "US"}, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("API should not be called when cache is fresh") + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + + if len(countries) != 3 { + t.Fatalf("len(countries) = %d, want 3", len(countries)) + } +} + +func TestMetricsFetcher_FetchCountries_TTLExpired(t *testing.T) { + tmpDir := t.TempDir() + cachePath := filepath.Join(tmpDir, "country_cache.json") + + cached := cacheEntry{ + FetchedAt: time.Now().UTC().Add(-48 * time.Hour), + Countries: []string{"ZZ"}, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + response := onionooResponse{ + Relays: []onionooRelay{ + {Country: "se", RelayFlags: []string{"Exit"}}, + }, + } + + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + + if len(countries) != 1 || countries[0] != "SE" { + t.Errorf("countries = %v, want [SE]", countries) + } +} + +func TestMetricsFetcher_FetchCountries_StaleCacheOnFetchError(t *testing.T) { + tmpDir := t.TempDir() + cachePath := filepath.Join(tmpDir, "country_cache.json") + + cached := cacheEntry{ + FetchedAt: time.Now().UTC().Add(-48 * time.Hour), + Countries: []string{"DE", "US"}, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v, want stale cache fallback", err) + } + + if len(countries) != 2 { + t.Fatalf("len(countries) = %d, want 2 (stale cache)", len(countries)) + } +} + +func TestMetricsFetcher_FetchCountries_NoCacheFetchError(t *testing.T) { + tmpDir := t.TempDir() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + _, err := mf.FetchCountries(context.Background()) + if err == nil { + t.Error("FetchCountries() expected error when fetch fails with no cache") + } +} + +func TestMetricsFetcher_FetchCountries_EmptyAPIResponse(t *testing.T) { + tmpDir := t.TempDir() + + response := onionooResponse{Relays: []onionooRelay{}} + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + _, err := mf.FetchCountries(context.Background()) + if err == nil { + t.Error("FetchCountries() expected error for empty API response with no cache") + } +} + +func TestMetricsFetcher_FetchCountries_EmptyAPIWithStaleCache(t *testing.T) { + tmpDir := t.TempDir() + cachePath := filepath.Join(tmpDir, "country_cache.json") + + cached := cacheEntry{ + FetchedAt: time.Now().UTC().Add(-48 * time.Hour), + Countries: []string{"NL"}, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + response := onionooResponse{Relays: []onionooRelay{}} + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + if len(countries) != 1 || countries[0] != "NL" { + t.Errorf("countries = %v, want [NL] from stale cache", countries) + } +} + +func TestMetricsFetcher_FetchCountries_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "not json") + })) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + _, err := mf.FetchCountries(context.Background()) + if err == nil { + t.Error("FetchCountries() expected error for invalid JSON") + } +} + +func TestMetricsFetcher_FetchCountries_ContextCancelled(t *testing.T) { + tmpDir := t.TempDir() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + })) + defer srv.Close() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := mf.FetchCountries(ctx) + if err == nil { + t.Error("FetchCountries() expected error for cancelled context") + } +} + +func TestMetricsFetcher_FetchCountries_DedupAndSort(t *testing.T) { + response := onionooResponse{ + Relays: []onionooRelay{ + {Country: "zz", RelayFlags: []string{"Guard"}}, + {Country: "aa", RelayFlags: []string{"Exit"}}, + {Country: "zz", RelayFlags: []string{"Exit"}}, + {Country: "mm", RelayFlags: []string{"Guard", "Exit"}}, + }, + } + + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + tmpDir := t.TempDir() + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + + expected := []string{"AA", "MM", "ZZ"} + if len(countries) != len(expected) { + t.Fatalf("len(countries) = %d, want %d", len(countries), len(expected)) + } + for i, c := range expected { + if countries[i] != c { + t.Errorf("countries[%d] = %q, want %q", i, countries[i], c) + } + } +} + +func TestMetricsFetcher_FetchCountries_FilterByFlag(t *testing.T) { + response := onionooResponse{ + Relays: []onionooRelay{ + {Country: "us", RelayFlags: []string{"Guard"}}, + {Country: "de", RelayFlags: []string{"Exit"}}, + {Country: "fr", RelayFlags: []string{"HSDir", "Stable"}}, + {Country: "nl", RelayFlags: []string{"Running"}}, + {Country: "", RelayFlags: []string{"Guard"}}, + }, + } + + srv := httptest.NewServer(jsonHandler(t, response)) + defer srv.Close() + + tmpDir := t.TempDir() + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL(srv.URL) + + countries, err := mf.FetchCountries(context.Background()) + if err != nil { + t.Fatalf("FetchCountries() error = %v", err) + } + + expected := []string{"DE", "US"} + if len(countries) != len(expected) { + t.Fatalf("countries = %v, want %v", countries, expected) + } + for i, c := range expected { + if countries[i] != c { + t.Errorf("countries[%d] = %q, want %q", i, countries[i], c) + } + } +} + +func TestMetricsFetcher_ReadCache_NoFile(t *testing.T) { + mf := NewMetricsFetcher(t.TempDir()) + entry, err := mf.readCache() + if err != nil { + t.Errorf("readCache() error = %v, want nil for missing file", err) + } + if entry != nil { + t.Error("readCache() expected nil entry for missing file") + } +} + +func TestMetricsFetcher_ReadCache_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + cachePath := filepath.Join(tmpDir, "country_cache.json") + if err := os.WriteFile(cachePath, []byte("bad"), 0600); err != nil { + t.Fatal(err) + } + + mf := NewMetricsFetcher(tmpDir) + _, err := mf.readCache() + if err == nil { + t.Error("readCache() expected error for invalid JSON") + } +} + +func TestMetricsFetcher_WriteCache_CreatesDir(t *testing.T) { + tmpDir := t.TempDir() + nestedDir := filepath.Join(tmpDir, "deep", "nested") + mf := NewMetricsFetcher(nestedDir) + + if err := mf.writeCache([]string{"US"}); err != nil { + t.Fatalf("writeCache() error = %v", err) + } + + if _, err := os.Stat(filepath.Join(nestedDir, "country_cache.json")); err != nil { + t.Errorf("cache file not created: %v", err) + } +} + +func TestMetricsFetcher_FetchCountries_HttpError(t *testing.T) { + tmpDir := t.TempDir() + + mf := NewMetricsFetcher(tmpDir) + mf.SetMetricsURL("http://127.0.0.1:1") + + _, err := mf.FetchCountries(context.Background()) + if err == nil { + t.Error("FetchCountries() expected error for unreachable server") + } +} + +func TestNewMetricsFetcher_Defaults(t *testing.T) { + mf := NewMetricsFetcher("/tmp/test") + if mf.cacheTTL != defaultCacheTTL { + t.Errorf("cacheTTL = %v, want %v", mf.cacheTTL, defaultCacheTTL) + } + if mf.client.Timeout != 30*time.Second { + t.Errorf("client timeout = %v, want 30s", mf.client.Timeout) + } + if mf.metricsURL != defaultMetricsURL { + t.Errorf("metricsURL = %q, want %q", mf.metricsURL, defaultMetricsURL) + } +} + +func jsonHandler(t *testing.T, v interface{}) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("jsonHandler marshal: %v", err) + } + _, _ = w.Write(data) + } +} diff --git a/internal/country/selector.go b/internal/country/selector.go new file mode 100644 index 0000000..4213875 --- /dev/null +++ b/internal/country/selector.go @@ -0,0 +1,48 @@ +package country + +import ( + "fmt" + "math/rand/v2" +) + +type shuffleFunc func(n int, swap func(i, j int)) + +func SelectRandom(accepted, blacklisted []string, count int) ([]string, error) { + return selectRandom(accepted, blacklisted, count, rand.Shuffle) +} + +func selectRandom(accepted, blacklisted []string, count int, shuffle shuffleFunc) ([]string, error) { + filtered := filterBlacklisted(accepted, blacklisted) + if count > len(filtered) { + return nil, fmt.Errorf("SelectRandom: requested %d countries, only %d available after filtering", count, len(filtered)) + } + if count <= 0 { + return nil, nil + } + + result := make([]string, len(filtered)) + copy(result, filtered) + shuffle(len(result), func(i, j int) { + result[i], result[j] = result[j], result[i] + }) + return result[:count], nil +} + +func filterBlacklisted(accepted, blacklisted []string) []string { + if len(blacklisted) == 0 { + return accepted + } + + blacklist := make(map[string]bool, len(blacklisted)) + for _, c := range blacklisted { + blacklist[c] = true + } + + filtered := make([]string, 0, len(accepted)) + for _, c := range accepted { + if !blacklist[c] { + filtered = append(filtered, c) + } + } + return filtered +} diff --git a/internal/country/selector_test.go b/internal/country/selector_test.go new file mode 100644 index 0000000..8755c6c --- /dev/null +++ b/internal/country/selector_test.go @@ -0,0 +1,121 @@ +package country + +import ( + "math/rand/v2" + "testing" +) + +func TestSelectRandom_Basic(t *testing.T) { + accepted := []string{"{US}", "{DE}", "{FR}", "{GB}", "{NL}", "{SE}", "{CA}", "{AU}", "{JP}", "{BR}"} + + result, err := SelectRandom(accepted, nil, 3) + if err != nil { + t.Fatalf("SelectRandom() error = %v", err) + } + if len(result) != 3 { + t.Fatalf("len(result) = %d, want 3", len(result)) + } + + seen := make(map[string]bool) + for _, c := range result { + if seen[c] { + t.Errorf("duplicate country: %q", c) + } + seen[c] = true + } +} + +func TestSelectRandom_AllCountries(t *testing.T) { + accepted := []string{"{US}", "{DE}", "{FR}"} + + result, err := SelectRandom(accepted, nil, 3) + if err != nil { + t.Fatalf("SelectRandom() error = %v", err) + } + if len(result) != 3 { + t.Fatalf("len(result) = %d, want 3", len(result)) + } + + seen := make(map[string]bool) + for _, c := range result { + seen[c] = true + } + for _, c := range accepted { + if !seen[c] { + t.Errorf("missing country: %q", c) + } + } +} + +func TestSelectRandom_ExceedsAvailable(t *testing.T) { + accepted := []string{"{US}", "{DE}", "{FR}"} + + _, err := SelectRandom(accepted, nil, 5) + if err == nil { + t.Error("SelectRandom() expected error when count > available, got nil") + } +} + +func TestSelectRandom_ExcludesBlacklisted(t *testing.T) { + accepted := []string{"{US}", "{DE}", "{FR}", "{GB}", "{NL}"} + blacklisted := []string{"{DE}", "{FR}"} + + result, err := SelectRandom(accepted, blacklisted, 3) + if err != nil { + t.Fatalf("SelectRandom() error = %v", err) + } + + for _, c := range result { + if c == "{DE}" || c == "{FR}" { + t.Errorf("blacklisted country %q in result", c) + } + } +} + +func TestSelectRandom_SingleCountry(t *testing.T) { + accepted := []string{"{US}", "{DE}", "{FR}", "{GB}", "{NL}"} + + result, err := SelectRandom(accepted, nil, 1) + if err != nil { + t.Fatalf("SelectRandom() error = %v", err) + } + if len(result) != 1 { + t.Fatalf("len(result) = %d, want 1", len(result)) + } + + found := false + for _, c := range accepted { + if c == result[0] { + found = true + break + } + } + if !found { + t.Errorf("result %q not in accepted list", result[0]) + } +} + +func TestSelectRandom_DeterministicSeed(t *testing.T) { + accepted := []string{"{US}", "{DE}", "{FR}", "{GB}", "{NL}", "{SE}", "{CA}", "{AU}", "{JP}", "{BR}"} + + r1 := rand.New(rand.NewPCG(42, 42)) + result1, err := selectRandom(accepted, nil, 3, r1.Shuffle) + if err != nil { + t.Fatalf("selectRandom() error = %v", err) + } + + r2 := rand.New(rand.NewPCG(42, 42)) + result2, err := selectRandom(accepted, nil, 3, r2.Shuffle) + if err != nil { + t.Fatalf("selectRandom() error = %v", err) + } + + if len(result1) != len(result2) { + t.Fatalf("lengths differ: %d vs %d", len(result1), len(result2)) + } + for i := range result1 { + if result1[i] != result2[i] { + t.Errorf("result[%d]: %q != %q", i, result1[i], result2[i]) + } + } +} diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go new file mode 100644 index 0000000..6cce228 --- /dev/null +++ b/internal/haproxy/config.go @@ -0,0 +1,103 @@ +package haproxy + +import ( + "fmt" + "math/rand" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/tor" +) + +type ConfigData struct { + Listen string + SOCKSPort int + HTTPPort int + StatsListen string + StatsPort int + StatsURI string + StatsPassword string + ClientTimeout int + ServerTimeout int + Retries int + BalanceAlgorithm string + CheckInterval int + MaxFail int + MinSuccess int + HTTPBackends []Backend + SOCKSBackends []Backend +} + +type Backend struct { + Name string + Address string + Port int + CheckInterval int + MaxFail int + MinSuccess int +} + +func BuildConfigData(cfg *config.Config, instances []*tor.Instance, statsPassword string) *ConfigData { + httpBackends := make([]Backend, 0, len(instances)) + socksBackends := make([]Backend, 0, len(instances)) + + for _, inst := range instances { + socksBackends = append(socksBackends, Backend{ + Name: fmt.Sprintf("tor_socks_%d", inst.ID), + Address: "127.0.0.1", + Port: inst.SocksPort, + CheckInterval: cfg.HealthCheck.Interval, + MaxFail: cfg.HealthCheck.MaxFail, + MinSuccess: cfg.HealthCheck.MinimumSuccess, + }) + + if cfg.ProxyMode == "native" { + if inst.HTTPPort > 0 { + httpBackends = append(httpBackends, Backend{ + Name: fmt.Sprintf("tor_http_%d", inst.ID), + Address: "127.0.0.1", + Port: inst.HTTPPort, + CheckInterval: cfg.HealthCheck.Interval, + MaxFail: cfg.HealthCheck.MaxFail, + MinSuccess: cfg.HealthCheck.MinimumSuccess, + }) + } + } else { + privoxyPort := cfg.Privoxy.StartPort + inst.ID + httpBackends = append(httpBackends, Backend{ + Name: fmt.Sprintf("privoxy_%d", inst.ID), + Address: "127.0.0.1", + Port: privoxyPort, + CheckInterval: cfg.HealthCheck.Interval, + MaxFail: cfg.HealthCheck.MaxFail, + MinSuccess: cfg.HealthCheck.MinimumSuccess, + }) + } + } + + rand.Shuffle(len(httpBackends), func(i, j int) { + httpBackends[i], httpBackends[j] = httpBackends[j], httpBackends[i] + }) + + rand.Shuffle(len(socksBackends), func(i, j int) { + socksBackends[i], socksBackends[j] = socksBackends[j], socksBackends[i] + }) + + return &ConfigData{ + Listen: cfg.Proxy.Master.Listen, + SOCKSPort: cfg.Proxy.Master.SocksPort, + HTTPPort: cfg.Proxy.Master.HTTPPort, + StatsListen: cfg.Proxy.Stats.Listen, + StatsPort: cfg.Proxy.Stats.Port, + StatsURI: cfg.Proxy.Stats.URI, + StatsPassword: statsPassword, + ClientTimeout: cfg.Proxy.Master.ClientTimeout, + ServerTimeout: cfg.Proxy.Master.ServerTimeout, + Retries: cfg.Instances.Retries, + BalanceAlgorithm: cfg.Proxy.LoadBalanceAlgorithm, + CheckInterval: cfg.HealthCheck.Interval, + MaxFail: cfg.HealthCheck.MaxFail, + MinSuccess: cfg.HealthCheck.MinimumSuccess, + HTTPBackends: httpBackends, + SOCKSBackends: socksBackends, + } +} diff --git a/internal/haproxy/coverage_test.go b/internal/haproxy/coverage_test.go new file mode 100644 index 0000000..7b25395 --- /dev/null +++ b/internal/haproxy/coverage_test.go @@ -0,0 +1,230 @@ +package haproxy + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +func TestStatsPassword_NonEmpty(t *testing.T) { + pw := generateStatsPassword() + if pw == "" { + t.Error("generateStatsPassword() returned empty string") + } +} + +func TestStatsPassword_MinLength(t *testing.T) { + pw := generateStatsPassword() + if len(pw) < 8 { + t.Errorf("password length = %d, want >= 8", len(pw)) + } +} + +func TestStatsPassword_Unique(t *testing.T) { + pw1 := generateStatsPassword() + pw2 := generateStatsPassword() + if pw1 == pw2 { + t.Errorf("two calls returned same password: %q", pw1) + } +} + +func TestBuildConfigData_EmptyInstances(t *testing.T) { + cfg := config.Defaults() + data := BuildConfigData(cfg, []*tor.Instance{}, "pw") + if len(data.HTTPBackends) != 0 { + t.Errorf("HTTPBackends = %d, want 0 for empty instances", len(data.HTTPBackends)) + } + if len(data.SOCKSBackends) != 0 { + t.Errorf("SOCKSBackends = %d, want 0 for empty instances", len(data.SOCKSBackends)) + } + if data.StatsPassword != "pw" { + t.Errorf("StatsPassword = %q, want %q", data.StatsPassword, "pw") + } +} + +func TestBuildConfigData_NativeModeWithDefaults(t *testing.T) { + cfg := config.Defaults() + instances := testInstances(3) + data := BuildConfigData(cfg, instances, "testpw") + + if len(data.HTTPBackends) != 3 { + t.Fatalf("HTTPBackends = %d, want 3", len(data.HTTPBackends)) + } + for _, b := range data.HTTPBackends { + if !strings.HasPrefix(b.Name, "tor_http_") { + t.Errorf("native HTTP backend name = %q, want prefix tor_http_", b.Name) + } + if b.Address != "127.0.0.1" { + t.Errorf("native HTTP backend address = %q, want 127.0.0.1", b.Address) + } + } + if len(data.SOCKSBackends) != 3 { + t.Fatalf("SOCKSBackends = %d, want 3", len(data.SOCKSBackends)) + } + for _, b := range data.SOCKSBackends { + if !strings.HasPrefix(b.Name, "tor_socks_") { + t.Errorf("SOCKS backend name = %q, want prefix tor_socks_", b.Name) + } + } +} + +func TestBuildConfigData_LegacyModeWithDefaults(t *testing.T) { + cfg := config.Defaults() + cfg.ProxyMode = "legacy" + instances := testInstances(3) + data := BuildConfigData(cfg, instances, "testpw") + + if len(data.HTTPBackends) != 3 { + t.Fatalf("HTTPBackends = %d, want 3", len(data.HTTPBackends)) + } + for _, b := range data.HTTPBackends { + if !strings.HasPrefix(b.Name, "privoxy_") { + t.Errorf("legacy HTTP backend name = %q, want prefix privoxy_", b.Name) + } + } + for _, b := range data.HTTPBackends { + expectedPort := cfg.Privoxy.StartPort + instances[0].ID + if b.Port < expectedPort || b.Port >= expectedPort+len(instances) { + t.Errorf("legacy backend port %d outside expected range", b.Port) + } + } +} + +func TestBuildConfigData_BackendCount(t *testing.T) { + for _, n := range []int{1, 3, 5, 10} { + t.Run(fmt.Sprintf("%d_instances", n), func(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(n) + data := BuildConfigData(cfg, instances, "pw") + if len(data.HTTPBackends) != n { + t.Errorf("HTTPBackends = %d, want %d", len(data.HTTPBackends), n) + } + if len(data.SOCKSBackends) != n { + t.Errorf("SOCKSBackends = %d, want %d", len(data.SOCKSBackends), n) + } + }) + } +} + +func TestBuildConfigData_DontProxyRanges(t *testing.T) { + cfg := config.Defaults() + if len(cfg.Proxy.DoNotProxy) == 0 { + t.Skip("DoNotProxy not configured in defaults") + } + data := BuildConfigData(cfg, []*tor.Instance{}, "pw") + if data.Listen == "" { + t.Error("expected non-empty Listen even with DoNotProxy configured") + } + for _, addr := range cfg.Proxy.DoNotProxy { + if addr == "" { + t.Error("DoNotProxy entry should not be empty") + } + } +} + +func TestRenderConfig_ValidTemplate(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(2) + data := BuildConfigData(cfg, instances, "secret123") + + tmplStr := `global + maxconn 256 + +defaults + mode http + timeout client {{.ClientTimeout}}s + timeout server {{.ServerTimeout}}s + retries {{.Retries}} + +frontend http_in + bind {{.Listen}}:{{.HTTPPort}} + default_backend http_backends + +backend http_backends + balance {{.BalanceAlgorithm}} +{{range .HTTPBackends}} server {{.Name}} {{.Address}}:{{.Port}} check +{{end}}` + + result, err := RenderConfig(data, tmplStr) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + assertContains(t, result, "timeout client 35s") + assertContains(t, result, "timeout server 35s") + assertContains(t, result, "retries 1000") + assertContains(t, result, "balance roundrobin") + assertContains(t, result, "bind 0.0.0.0:63537") + for _, b := range data.HTTPBackends { + assertContains(t, result, fmt.Sprintf("server %s %s:%d", b.Name, b.Address, b.Port)) + } +} + +func TestRenderConfig_InvalidTemplate(t *testing.T) { + data := &ConfigData{ClientTimeout: 30} + _, err := RenderConfig(data, "{{.InvalidField}}") + if err == nil { + t.Error("expected error for invalid template, got nil") + } +} + +func TestNewManager_SetsConfigFile(t *testing.T) { + tmpDir := t.TempDir() + cfgFile := filepath.Join(tmpDir, "haproxy.cfg") + + cfg := config.Defaults() + cfg.Paths.TempFiles = tmpDir + cfg.HAProxy.ConfigFile = cfgFile + procMgr := process.NewManager(tmpDir) + + mgr := NewManager(cfg, procMgr) + if mgr.StatsPassword() == "" { + t.Error("StatsPassword() is empty") + } + + tmplDir := filepath.Join(tmpDir, "templates") + if err := os.MkdirAll(tmplDir, 0755); err != nil { + t.Fatalf("mkdir templates: %v", err) + } + tmplPath := filepath.Join(tmplDir, "haproxy.cfg.gotmpl") + minimalTmpl := "test\n" + if err := os.WriteFile(tmplPath, []byte(minimalTmpl), 0644); err != nil { + t.Fatalf("write template: %v", err) + } + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + instances := testInstances(1) + if err := mgr.GenerateConfig(instances); err != nil { + t.Fatalf("GenerateConfig() error = %v", err) + } + if _, err := os.Stat(cfgFile); os.IsNotExist(err) { + t.Errorf("config file %q was not created", cfgFile) + } +} + +func TestManager_StopWithoutStart(t *testing.T) { + tmpDir := t.TempDir() + cfg := config.Defaults() + cfg.Paths.TempFiles = tmpDir + cfg.HAProxy.ConfigFile = filepath.Join(tmpDir, "haproxy.cfg") + procMgr := process.NewManager(tmpDir) + + mgr := NewManager(cfg, procMgr) + if err := mgr.Stop(context.TODO()); err != nil { + t.Errorf("Stop on unstarted manager should return nil, got %v", err) + } +} diff --git a/internal/haproxy/doc.go b/internal/haproxy/doc.go new file mode 100644 index 0000000..03bb42a --- /dev/null +++ b/internal/haproxy/doc.go @@ -0,0 +1,2 @@ +// Package haproxy manages HAProxy configuration generation and process lifecycle for SPLITTER. +package haproxy diff --git a/internal/haproxy/haproxy_template_test.go b/internal/haproxy/haproxy_template_test.go new file mode 100644 index 0000000..20df5a2 --- /dev/null +++ b/internal/haproxy/haproxy_template_test.go @@ -0,0 +1,240 @@ +package haproxy + +import ( + "os" + "strings" + "testing" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/tor" +) + +func haproxyTestConfig() *config.Config { + cfg := &config.Config{} + cfg.ProxyMode = "native" + cfg.Proxy.Master.Listen = "0.0.0.0" + cfg.Proxy.Master.SocksPort = 63536 + cfg.Proxy.Master.HTTPPort = 63537 + cfg.Proxy.Master.ClientTimeout = 35 + cfg.Proxy.Master.ServerTimeout = 35 + cfg.Proxy.Stats.Listen = "0.0.0.0" + cfg.Proxy.Stats.Port = 63539 + cfg.Proxy.Stats.URI = "/splitter_status" + cfg.Proxy.LoadBalanceAlgorithm = "roundrobin" + cfg.Instances.Retries = 3 + cfg.HealthCheck.URL = "https://check.example.com/" + cfg.HealthCheck.Interval = 10 + cfg.HealthCheck.MaxFail = 2 + cfg.HealthCheck.MinimumSuccess = 1 + cfg.Privoxy.StartPort = 6999 + cfg.Paths.TempFiles = "/tmp/splitter" + return cfg +} + +func haproxyTestInstances(count int) []*tor.Instance { + countries := []string{"{US}", "{DE}", "{FR}", "{GB}"} + instances := make([]*tor.Instance, count) + for i := 0; i < count; i++ { + instances[i] = &tor.Instance{ + ID: i, + Country: countries[i%len(countries)], + SocksPort: 4999 + i, + HTTPPort: 5199 + i, + } + } + return instances +} + +func readHAProxyTemplate(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("../../templates/haproxy.cfg.gotmpl") + if err != nil { + t.Fatalf("read haproxy template: %v", err) + } + return string(data) +} + +func haproxyContains(t *testing.T, result, substr string) { + t.Helper() + if !strings.Contains(result, substr) { + t.Errorf("expected output to contain %q\nfull output:\n%s", substr, result) + } +} + +func haproxyNotContains(t *testing.T, result, substr string) { + t.Helper() + if strings.Contains(result, substr) { + t.Errorf("expected output NOT to contain %q\nfull output:\n%s", substr, result) + } +} + +func TestHAProxyTemplate_ContainsStatsSection(t *testing.T) { + cfg := haproxyTestConfig() + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "testpw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + haproxyContains(t, result, "stats enable") + haproxyContains(t, result, "stats uri /splitter_status") + haproxyContains(t, result, "stats auth admin:testpw") + haproxyContains(t, result, "stats realm SPLITTER") +} + +func TestHAProxyTemplate_ContainsFrontends(t *testing.T) { + cfg := haproxyTestConfig() + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + haproxyContains(t, result, "frontend http_in") + haproxyContains(t, result, "frontend socks_in") + haproxyContains(t, result, "default_backend tor_http") + haproxyContains(t, result, "default_backend tor_socks") + haproxyContains(t, result, "bind 0.0.0.0:63537") + haproxyContains(t, result, "bind 0.0.0.0:63536") +} + +func TestHAProxyTemplate_ContainsBackends(t *testing.T) { + cfg := haproxyTestConfig() + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + haproxyContains(t, result, "backend tor_http") + haproxyContains(t, result, "backend tor_socks") +} + +func TestHAProxyTemplate_BackendServers(t *testing.T) { + cfg := haproxyTestConfig() + instances := haproxyTestInstances(2) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + httpServerCount := strings.Count(result, "server tor_http_") + socksServerCount := strings.Count(result, "server tor_socks_") + + if httpServerCount != 2 { + t.Errorf("expected 2 HTTP server lines, got %d", httpServerCount) + } + if socksServerCount != 2 { + t.Errorf("expected 2 SOCKS server lines, got %d", socksServerCount) + } +} + +func TestHAProxyTemplate_Timeout(t *testing.T) { + cfg := haproxyTestConfig() + cfg.Proxy.Master.ClientTimeout = 60 + cfg.Proxy.Master.ServerTimeout = 45 + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + haproxyContains(t, result, "timeout client 60s") + haproxyContains(t, result, "timeout server 45s") +} + +func TestHAProxyTemplate_BalanceAlgorithm(t *testing.T) { + cfg := haproxyTestConfig() + cfg.Proxy.LoadBalanceAlgorithm = "roundrobin" + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + balanceCount := strings.Count(result, "balance roundrobin") + if balanceCount < 2 { + t.Errorf("expected at least 2 'balance roundrobin' lines (http + socks backend), got %d", balanceCount) + } +} + +func TestHAProxyTemplate_LegacyMode_PrivoxyBackends(t *testing.T) { + cfg := haproxyTestConfig() + cfg.ProxyMode = "legacy" + instances := haproxyTestInstances(2) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + privoxyCount := strings.Count(result, "server privoxy_") + if privoxyCount != 2 { + t.Errorf("expected 2 privoxy server lines in legacy mode, got %d", privoxyCount) + } +} + +func TestHAProxyTemplate_LeastconnAlgorithm(t *testing.T) { + cfg := haproxyTestConfig() + cfg.Proxy.LoadBalanceAlgorithm = "leastconn" + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + balanceCount := strings.Count(result, "balance leastconn") + if balanceCount < 2 { + t.Errorf("expected at least 2 'balance leastconn' lines, got %d", balanceCount) + } + haproxyNotContains(t, result, "balance roundrobin") +} + +func TestHAProxyTemplate_HealthCheck(t *testing.T) { + cfg := haproxyTestConfig() + cfg.HealthCheck.URL = "https://check.example.com/" + instances := haproxyTestInstances(1) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + haproxyContains(t, result, "option tcp-check") + haproxyContains(t, result, "tcp-check connect") +} + +func TestHAProxyTemplate_EmptyInstances(t *testing.T) { + cfg := haproxyTestConfig() + instances := haproxyTestInstances(0) + data := BuildConfigData(cfg, instances, "pw") + + result, err := RenderConfig(data, readHAProxyTemplate(t)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + haproxyContains(t, result, "backend tor_http") + haproxyContains(t, result, "backend tor_socks") + + serverLineCount := strings.Count(result, "\n server ") + if serverLineCount != 0 { + t.Errorf("expected 0 server lines with empty instances, got %d", serverLineCount) + } +} diff --git a/internal/haproxy/integration_test.go b/internal/haproxy/integration_test.go new file mode 100644 index 0000000..f12d0cd --- /dev/null +++ b/internal/haproxy/integration_test.go @@ -0,0 +1,116 @@ +//go:build integration + +package haproxy + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +func TestIntegration_HAProxyConfigValidation(t *testing.T) { + if _, err := exec.LookPath("haproxy"); err != nil { + t.Skip("haproxy not available") + } + + tmpDir := t.TempDir() + cfg := &config.Config{} + cfg.ProxyMode = "native" + cfg.Proxy.Master.Listen = "0.0.0.0" + cfg.Proxy.Master.Port = 63536 + cfg.Proxy.Master.SocksPort = 63536 + cfg.Proxy.Master.HTTPPort = 63537 + cfg.Proxy.Master.ClientTimeout = 35 + cfg.Proxy.Master.ServerTimeout = 35 + cfg.Proxy.Stats.Listen = "0.0.0.0" + cfg.Proxy.Stats.Port = 63539 + cfg.Proxy.Stats.URI = "/splitter_status" + cfg.Proxy.LoadBalanceAlgorithm = "roundrobin" + cfg.Instances.Retries = 1000 + cfg.HealthCheck.URL = "https://www.google.com/" + cfg.HealthCheck.Interval = 12 + cfg.HealthCheck.MaxFail = 1 + cfg.HealthCheck.MinimumSuccess = 1 + cfg.Paths.TempFiles = tmpDir + cfg.HAProxy.BinaryPath, _ = exec.LookPath("haproxy") + cfg.HAProxy.ConfigFile = filepath.Join(tmpDir, "haproxy.cfg") + + instances := []*tor.Instance{ + {ID: 0, Country: "{US}", SocksPort: 4999, ControlPort: 5999, HTTPPort: 5199}, + {ID: 1, Country: "{DE}", SocksPort: 5000, ControlPort: 6000, HTTPPort: 5200}, + } + + procMgr := process.NewManager(tmpDir) + mgr := NewManager(cfg, procMgr) + + if err := mgr.GenerateConfig(instances); err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + + cmd := exec.Command(cfg.HAProxy.BinaryPath, "-c", "-f", cfg.HAProxy.ConfigFile) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("haproxy config validation failed:\n%s\n%s", output, err) + } +} + +func TestIntegration_StartStopHAProxy(t *testing.T) { + if _, err := exec.LookPath("haproxy"); err != nil { + t.Skip("haproxy not available") + } + + tmpDir := t.TempDir() + cfg := &config.Config{} + cfg.ProxyMode = "native" + cfg.Proxy.Master.Listen = "0.0.0.0" + cfg.Proxy.Master.Port = 63536 + cfg.Proxy.Master.SocksPort = 63536 + cfg.Proxy.Master.HTTPPort = 63537 + cfg.Proxy.Master.ClientTimeout = 35 + cfg.Proxy.Master.ServerTimeout = 35 + cfg.Proxy.Stats.Listen = "0.0.0.0" + cfg.Proxy.Stats.Port = 63539 + cfg.Proxy.Stats.URI = "/splitter_status" + cfg.Proxy.LoadBalanceAlgorithm = "roundrobin" + cfg.Instances.Retries = 1000 + cfg.HealthCheck.URL = "https://www.google.com/" + cfg.HealthCheck.Interval = 12 + cfg.HealthCheck.MaxFail = 1 + cfg.HealthCheck.MinimumSuccess = 1 + cfg.Paths.TempFiles = tmpDir + cfg.HAProxy.BinaryPath, _ = exec.LookPath("haproxy") + cfg.HAProxy.ConfigFile = filepath.Join(tmpDir, "haproxy.cfg") + + instances := []*tor.Instance{ + {ID: 0, Country: "{US}", SocksPort: 4999, ControlPort: 5999, HTTPPort: 5199}, + } + + procMgr := process.NewManager(tmpDir) + mgr := NewManager(cfg, procMgr) + + if err := mgr.GenerateConfig(instances); err != nil { + t.Fatalf("GenerateConfig: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := mgr.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + if err := mgr.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + + _ = os.Remove(cfg.HAProxy.ConfigFile) +} diff --git a/internal/haproxy/manager.go b/internal/haproxy/manager.go new file mode 100644 index 0000000..5248741 --- /dev/null +++ b/internal/haproxy/manager.go @@ -0,0 +1,124 @@ +package haproxy + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +type HAProxyManager struct { + cfg *config.Config + procMgr *process.Manager + statsPassword string + configFile string + proc *process.Process +} + +func NewManager(cfg *config.Config, procMgr *process.Manager) *HAProxyManager { + return &HAProxyManager{ + cfg: cfg, + procMgr: procMgr, + statsPassword: generateStatsPassword(), + configFile: cfg.HAProxy.ConfigFile, + } +} + +func (m *HAProxyManager) GenerateConfig(torInstances []*tor.Instance) error { + if err := os.MkdirAll(filepath.Dir(m.configFile), 0755); err != nil { + return fmt.Errorf("GenerateConfig: mkdir: %w", err) + } + + data := BuildConfigData(m.cfg, torInstances, m.statsPassword) + + tmpl, err := template.ParseFiles("templates/haproxy.cfg.gotmpl") + if err != nil { + return fmt.Errorf("GenerateConfig: parse template: %w", err) + } + + f, err := os.Create(m.configFile) + if err != nil { + return fmt.Errorf("GenerateConfig: create %s: %w", m.configFile, err) + } + defer func() { _ = f.Close() }() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("GenerateConfig: execute: %w", err) + } + + slog.Info("haproxy config generated", "path", m.configFile, "backends", len(data.HTTPBackends)+len(data.SOCKSBackends)) + return nil +} + +func (m *HAProxyManager) Start(ctx context.Context) error { + proc, err := m.procMgr.Spawn(ctx, "haproxy", + m.cfg.HAProxy.BinaryPath, + "-f", m.configFile, + ) + if err != nil { + return fmt.Errorf("Start: %w", err) + } + m.proc = proc + + slog.Info("haproxy started", "config", m.configFile) + return nil +} + +func (m *HAProxyManager) Stop(ctx context.Context) error { + if m.proc == nil { + return nil + } + if err := m.procMgr.Stop(ctx, m.proc); err != nil { + return fmt.Errorf("Stop: %w", err) + } + m.proc = nil + return nil +} + +func (m *HAProxyManager) Reload(ctx context.Context) error { + if m.proc == nil { + return fmt.Errorf("Reload: haproxy not running") + } + + pid := m.proc.Pid() + if pid <= 0 { + return fmt.Errorf("Reload: haproxy has no pid") + } + + proc, err := m.procMgr.Spawn(ctx, "haproxy-reload", + m.cfg.HAProxy.BinaryPath, + "-f", m.configFile, + "-sf", fmt.Sprintf("%d", pid), + ) + if err != nil { + return fmt.Errorf("Reload: %w", err) + } + + m.proc = proc + + slog.Info("haproxy reloaded", "old_pid", pid) + return nil +} + +func (m *HAProxyManager) StatsPassword() string { + return m.statsPassword +} + +func RenderConfig(data *ConfigData, tmplStr string) (string, error) { + tmpl, err := template.New("haproxy").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("RenderConfig: %w", err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("RenderConfig: execute: %w", err) + } + return buf.String(), nil +} diff --git a/internal/haproxy/manager_test.go b/internal/haproxy/manager_test.go new file mode 100644 index 0000000..cf58190 --- /dev/null +++ b/internal/haproxy/manager_test.go @@ -0,0 +1,360 @@ +package haproxy + +import ( + "os" + "strings" + "testing" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +func testConfig(proxyMode string) *config.Config { + cfg := &config.Config{} + cfg.ProxyMode = proxyMode + cfg.Proxy.Master.Listen = "0.0.0.0" + cfg.Proxy.Master.Port = 63536 + cfg.Proxy.Master.SocksPort = 63536 + cfg.Proxy.Master.HTTPPort = 63537 + cfg.Proxy.Master.ClientTimeout = 35 + cfg.Proxy.Master.ServerTimeout = 35 + cfg.Proxy.Stats.Listen = "0.0.0.0" + cfg.Proxy.Stats.Port = 63539 + cfg.Proxy.Stats.URI = "/splitter_status" + cfg.Proxy.LoadBalanceAlgorithm = "roundrobin" + cfg.Instances.Retries = 1000 + cfg.HealthCheck.URL = "https://www.google.com/" + cfg.HealthCheck.Interval = 12 + cfg.HealthCheck.MaxFail = 1 + cfg.HealthCheck.MinimumSuccess = 1 + cfg.Privoxy.StartPort = 6999 + cfg.Paths.TempFiles = "/tmp/splitter" + cfg.HAProxy.BinaryPath = "/usr/sbin/haproxy" + cfg.HAProxy.ConfigFile = "/tmp/splitter/splitter_master_proxy.cfg" + return cfg +} + +func testInstances(count int) []*tor.Instance { + instances := make([]*tor.Instance, count) + countries := []string{"{US}", "{DE}", "{FR}", "{GB}", "{NL}", "{SE}"} + for i := 0; i < count; i++ { + inst := &tor.Instance{ + ID: i, + Country: countries[i%len(countries)], + SocksPort: 4999 + i, + ControlPort: 5999 + i, + HTTPPort: 5199 + i, + } + instances[i] = inst + } + return instances +} + +func TestBuildConfigData_NativeMode(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(3) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if len(data.HTTPBackends) != 3 { + t.Fatalf("HTTPBackends count = %d, want 3", len(data.HTTPBackends)) + } + if len(data.SOCKSBackends) != 3 { + t.Fatalf("SOCKSBackends count = %d, want 3", len(data.SOCKSBackends)) + } + + for _, b := range data.HTTPBackends { + if !strings.HasPrefix(b.Name, "tor_http_") { + t.Errorf("HTTP backend name = %q, want prefix tor_http_", b.Name) + } + } + for _, b := range data.SOCKSBackends { + if !strings.HasPrefix(b.Name, "tor_socks_") { + t.Errorf("SOCKS backend name = %q, want prefix tor_socks_", b.Name) + } + } + + httpPortSet := make(map[int]bool) + for _, b := range data.HTTPBackends { + httpPortSet[b.Port] = true + } + for _, inst := range instances { + if !httpPortSet[inst.HTTPPort] { + t.Errorf("missing HTTP backend for port %d", inst.HTTPPort) + } + } +} + +func TestBuildConfigData_LegacyMode(t *testing.T) { + cfg := testConfig("legacy") + instances := testInstances(3) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if len(data.HTTPBackends) != 3 { + t.Fatalf("HTTPBackends count = %d, want 3", len(data.HTTPBackends)) + } + + for _, b := range data.HTTPBackends { + if !strings.HasPrefix(b.Name, "privoxy_") { + t.Errorf("HTTP backend name = %q, want prefix privoxy_", b.Name) + } + } + + expectedPorts := map[int]bool{6999: true, 7000: true, 7001: true} + for _, b := range data.HTTPBackends { + if !expectedPorts[b.Port] { + t.Errorf("unexpected legacy HTTP backend port %d", b.Port) + } + } +} + +func TestBuildConfigData_NativeModeNoHTTPPort(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(3) + instances[1].HTTPPort = 0 + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if len(data.HTTPBackends) != 2 { + t.Fatalf("HTTPBackends count = %d, want 2 (one instance has no HTTPPort)", len(data.HTTPBackends)) + } +} + +func TestBuildConfigData_BackendShuffle(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(20) + password := generateStatsPassword() + + sameCount := 0 + iterations := 10 + + firstOrder := buildBackendPortList(cfg, instances, password) + + for i := 0; i < iterations; i++ { + order := buildBackendPortList(cfg, instances, password) + if order == firstOrder { + sameCount++ + } + } + + if sameCount == iterations { + t.Errorf("backends were never shuffled across %d iterations, expected at least one different order", iterations) + } +} + +func buildBackendPortList(cfg *config.Config, instances []*tor.Instance, password string) string { + data := BuildConfigData(cfg, instances, password) + ports := make([]string, len(data.HTTPBackends)) + for i, b := range data.HTTPBackends { + ports[i] = strings.TrimSpace(b.Name) + } + return strings.Join(ports, ",") +} + +func TestBuildConfigData_StatsPassword(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(2) + password := "testpassword12345" + + data := BuildConfigData(cfg, instances, password) + + if data.StatsPassword != password { + t.Errorf("StatsPassword = %q, want %q", data.StatsPassword, password) + } +} + +func TestBuildConfigData_TimeoutAndRetries(t *testing.T) { + cfg := testConfig("native") + cfg.Proxy.Master.ClientTimeout = 60 + cfg.Proxy.Master.ServerTimeout = 45 + cfg.Instances.Retries = 500 + instances := testInstances(1) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if data.ClientTimeout != 60 { + t.Errorf("ClientTimeout = %d, want 60", data.ClientTimeout) + } + if data.ServerTimeout != 45 { + t.Errorf("ServerTimeout = %d, want 45", data.ServerTimeout) + } + if data.Retries != 500 { + t.Errorf("Retries = %d, want 500", data.Retries) + } +} + +func TestBuildConfigData_PortsFromConfig(t *testing.T) { + cfg := testConfig("native") + cfg.Proxy.Master.SocksPort = 9050 + cfg.Proxy.Master.HTTPPort = 9080 + cfg.Proxy.Stats.Port = 9100 + cfg.Proxy.Stats.Listen = "127.0.0.1" + instances := testInstances(1) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if data.SOCKSPort != 9050 { + t.Errorf("SOCKSPort = %d, want 9050", data.SOCKSPort) + } + if data.HTTPPort != 9080 { + t.Errorf("HTTPPort = %d, want 9080", data.HTTPPort) + } + if data.StatsPort != 9100 { + t.Errorf("StatsPort = %d, want 9100", data.StatsPort) + } + if data.StatsListen != "127.0.0.1" { + t.Errorf("StatsListen = %q, want %q", data.StatsListen, "127.0.0.1") + } +} + +func TestBuildConfigData_HealthCheckParams(t *testing.T) { + cfg := testConfig("native") + cfg.HealthCheck.Interval = 5 + cfg.HealthCheck.MaxFail = 3 + cfg.HealthCheck.MinimumSuccess = 2 + instances := testInstances(1) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if data.CheckInterval != 5 { + t.Errorf("CheckInterval = %d, want 5", data.CheckInterval) + } + if data.MaxFail != 3 { + t.Errorf("MaxFail = %d, want 3", data.MaxFail) + } + if data.MinSuccess != 2 { + t.Errorf("MinSuccess = %d, want 2", data.MinSuccess) + } + for _, b := range data.HTTPBackends { + if b.CheckInterval != 5 { + t.Errorf("HTTP backend CheckInterval = %d, want 5", b.CheckInterval) + } + if b.MaxFail != 3 { + t.Errorf("HTTP backend MaxFail = %d, want 3", b.MaxFail) + } + if b.MinSuccess != 2 { + t.Errorf("HTTP backend MinSuccess = %d, want 2", b.MinSuccess) + } + } +} + +func TestBuildConfigData_BalanceAlgorithm(t *testing.T) { + tests := []struct { + name string + algorithm string + }{ + {"roundrobin", "roundrobin"}, + {"leastconn", "leastconn"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := testConfig("native") + cfg.Proxy.LoadBalanceAlgorithm = tt.algorithm + instances := testInstances(2) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + if data.BalanceAlgorithm != tt.algorithm { + t.Errorf("BalanceAlgorithm = %q, want %q", data.BalanceAlgorithm, tt.algorithm) + } + }) + } +} + +func TestGenerateConfigTemplate(t *testing.T) { + cfg := testConfig("native") + instances := testInstances(3) + password := generateStatsPassword() + + data := BuildConfigData(cfg, instances, password) + + tmplBytes, err := os.ReadFile("../../templates/haproxy.cfg.gotmpl") + if err != nil { + t.Fatalf("read template: %v", err) + } + + result, err := RenderConfig(data, string(tmplBytes)) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + assertContains(t, result, "bind 0.0.0.0:63537") + assertContains(t, result, "bind 0.0.0.0:63536") + assertContains(t, result, "bind 0.0.0.0:63539") + assertContains(t, result, "balance roundrobin") + assertContains(t, result, "option tcp-check") + assertContains(t, result, "tcp-check connect") + assertContains(t, result, "default_backend tor_http") + assertContains(t, result, "default_backend tor_socks") + assertContains(t, result, "timeout client 35s") + assertContains(t, result, "timeout server 35s") + assertContains(t, result, "retries 1000") + assertContains(t, result, "stats auth admin:"+password) + assertContains(t, result, "stats uri /splitter_status") + + httpCount := strings.Count(result, "127.0.0.1:") + if httpCount < 3 { + t.Errorf("expected at least 3 backend server lines, found %d", httpCount) + } +} + +func TestStatsPassword_Random(t *testing.T) { + pw1 := generateStatsPassword() + pw2 := generateStatsPassword() + + if pw1 == pw2 { + t.Errorf("two generated passwords are identical: %q", pw1) + } +} + +func TestStatsPassword_Length(t *testing.T) { + pw := generateStatsPassword() + + if len(pw) != 16 { + t.Errorf("password length = %d, want 16", len(pw)) + } +} + +func TestStatsPassword_Alphanumeric(t *testing.T) { + pw := generateStatsPassword() + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + for _, c := range pw { + if !strings.ContainsRune(charset, c) { + t.Errorf("password contains non-alphanumeric character: %q", c) + } + } +} + +func TestNewManager(t *testing.T) { + cfg := testConfig("native") + cfg.Paths.TempFiles = t.TempDir() + cfg.HAProxy.ConfigFile = cfg.Paths.TempFiles + "/haproxy.cfg" + procMgr := process.NewManager(cfg.Paths.TempFiles) + + mgr := NewManager(cfg, procMgr) + + if mgr.StatsPassword() == "" { + t.Error("StatsPassword() is empty") + } + if len(mgr.StatsPassword()) != 16 { + t.Errorf("StatsPassword() length = %d, want 16", len(mgr.StatsPassword())) + } +} + +func assertContains(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("expected output to contain %q\nfull output:\n%s", needle, haystack) + } +} diff --git a/internal/haproxy/stats_password.go b/internal/haproxy/stats_password.go new file mode 100644 index 0000000..17dc7ac --- /dev/null +++ b/internal/haproxy/stats_password.go @@ -0,0 +1,21 @@ +package haproxy + +import ( + "crypto/rand" + "math/big" +) + +func generateStatsPassword() string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const length = 16 + result := make([]byte, length) + for i := range length { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + result[i] = charset[i%len(charset)] + continue + } + result[i] = charset[n.Int64()] + } + return string(result) +} diff --git a/internal/health/check.go b/internal/health/check.go new file mode 100644 index 0000000..ce95325 --- /dev/null +++ b/internal/health/check.go @@ -0,0 +1,92 @@ +package health + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/tor" +) + +type CheckResult struct { + TorPath string + TorVersion *tor.Version + HAProxyPath string + PrivoxyPath string + Features FeatureSupport +} + +type FeatureSupport struct { + Conflux bool + HTTPTunnel bool + CongestionControl bool + CGO bool + PostQuantum bool +} + +var lookPath = exec.LookPath + +var runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + cmd := exec.CommandContext(ctx, binary, "--version") + return cmd.Output() +} + +func CheckDependencies(ctx context.Context, cfg *config.Config) (*CheckResult, error) { + torPath, err := lookPath(cfg.Tor.BinaryPath) + if err != nil { + return nil, fmt.Errorf("dependency check: tor binary not found: %s is not in PATH or not executable", cfg.Tor.BinaryPath) + } + + haProxyPath, err := lookPath(cfg.HAProxy.BinaryPath) + if err != nil { + return nil, fmt.Errorf("dependency check: haproxy binary not found: %s is not in PATH or not executable", cfg.HAProxy.BinaryPath) + } + + var privoxyPath string + if cfg.ProxyMode == "legacy" { + p, err := lookPath(cfg.Privoxy.BinaryPath) + if err != nil { + return nil, fmt.Errorf("dependency check: privoxy binary not found (required for legacy proxy mode): %s is not in PATH or not executable", cfg.Privoxy.BinaryPath) + } + privoxyPath = p + } + + output, err := runVersionCmd(ctx, torPath) + if err != nil { + return nil, fmt.Errorf("CheckDependencies: failed to detect tor version: %w", err) + } + + version, err := tor.DetectVersionFromOutput(string(output)) + if err != nil { + return nil, fmt.Errorf("CheckDependencies: failed to parse tor version: %w", err) + } + + features := FeatureSupport{ + Conflux: version.SupportsConflux(), + HTTPTunnel: version.SupportsHTTPTunnel(), + CongestionControl: version.SupportsCongestionControl(), + CGO: version.SupportsCGO(), + PostQuantum: version.SupportsPostQuantum(), + } + + slog.Info("dependency check passed", + "tor_path", torPath, + "tor_version", version.String(), + "haproxy_path", haProxyPath, + "privoxy_path", privoxyPath, + "conflux", features.Conflux, + "http_tunnel", features.HTTPTunnel, + "congestion_control", features.CongestionControl, + "cgo", features.CGO, + ) + + return &CheckResult{ + TorPath: torPath, + TorVersion: version, + HAProxyPath: haProxyPath, + PrivoxyPath: privoxyPath, + Features: features, + }, nil +} diff --git a/internal/health/check_test.go b/internal/health/check_test.go new file mode 100644 index 0000000..ce24a98 --- /dev/null +++ b/internal/health/check_test.go @@ -0,0 +1,330 @@ +package health + +import ( + "context" + "errors" + "os/exec" + "testing" + + "github.com/user/splitter/internal/config" +) + +func TestCheckDependencies_allFound(t *testing.T) { + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { + return name, nil + } + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return []byte("Tor version 0.4.8.10.\n"), nil + } + + cfg := testConfig("native") + result, err := CheckDependencies(context.Background(), cfg) + if err != nil { + t.Fatalf("CheckDependencies() error = %v", err) + } + if result.TorPath != cfg.Tor.BinaryPath { + t.Errorf("TorPath = %q, want %q", result.TorPath, cfg.Tor.BinaryPath) + } + if result.HAProxyPath != cfg.HAProxy.BinaryPath { + t.Errorf("HAProxyPath = %q, want %q", result.HAProxyPath, cfg.HAProxy.BinaryPath) + } + if result.TorVersion.String() != "0.4.8.10" { + t.Errorf("TorVersion = %q, want %q", result.TorVersion.String(), "0.4.8.10") + } + if result.PrivoxyPath != "" { + t.Errorf("PrivoxyPath = %q, want empty in native mode", result.PrivoxyPath) + } +} + +func TestCheckDependencies_missingTor(t *testing.T) { + origLookPath := lookPath + defer func() { lookPath = origLookPath }() + + lookPath = func(name string) (string, error) { + if name == "/usr/bin/tor" { + return "", &exec.Error{Name: name, Err: exec.ErrNotFound} + } + return name, nil + } + + cfg := testConfig("native") + _, err := CheckDependencies(context.Background(), cfg) + if err == nil { + t.Fatal("CheckDependencies() expected error, got nil") + } + if want := "dependency check: tor binary not found"; !contains(err.Error(), want) { + t.Errorf("error = %q, want to contain %q", err.Error(), want) + } +} + +func TestCheckDependencies_missingHAProxy(t *testing.T) { + origLookPath := lookPath + defer func() { lookPath = origLookPath }() + + lookPath = func(name string) (string, error) { + if name == "/usr/sbin/haproxy" { + return "", &exec.Error{Name: name, Err: exec.ErrNotFound} + } + return name, nil + } + + cfg := testConfig("native") + _, err := CheckDependencies(context.Background(), cfg) + if err == nil { + t.Fatal("CheckDependencies() expected error, got nil") + } + if want := "dependency check: haproxy binary not found"; !contains(err.Error(), want) { + t.Errorf("error = %q, want to contain %q", err.Error(), want) + } +} + +func TestCheckDependencies_missingPrivoxyLegacyMode(t *testing.T) { + origLookPath := lookPath + defer func() { lookPath = origLookPath }() + + lookPath = func(name string) (string, error) { + if name == "/usr/sbin/privoxy" { + return "", &exec.Error{Name: name, Err: exec.ErrNotFound} + } + return name, nil + } + + cfg := testConfig("legacy") + _, err := CheckDependencies(context.Background(), cfg) + if err == nil { + t.Fatal("CheckDependencies() expected error, got nil") + } + if want := "dependency check: privoxy binary not found (required for legacy proxy mode)"; !contains(err.Error(), want) { + t.Errorf("error = %q, want to contain %q", err.Error(), want) + } +} + +func TestCheckDependencies_nativeModeNoPrivoxy(t *testing.T) { + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { + if name == "/usr/sbin/privoxy" { + return "", &exec.Error{Name: name, Err: exec.ErrNotFound} + } + return name, nil + } + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return []byte("Tor version 0.4.8.10.\n"), nil + } + + cfg := testConfig("native") + result, err := CheckDependencies(context.Background(), cfg) + if err != nil { + t.Fatalf("CheckDependencies() error = %v", err) + } + if result.PrivoxyPath != "" { + t.Errorf("PrivoxyPath = %q, want empty in native mode", result.PrivoxyPath) + } +} + +func TestCheckDependencies_privoxyFoundLegacyMode(t *testing.T) { + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { + return name, nil + } + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return []byte("Tor version 0.4.8.10.\n"), nil + } + + cfg := testConfig("legacy") + result, err := CheckDependencies(context.Background(), cfg) + if err != nil { + t.Fatalf("CheckDependencies() error = %v", err) + } + if result.PrivoxyPath != cfg.Privoxy.BinaryPath { + t.Errorf("PrivoxyPath = %q, want %q", result.PrivoxyPath, cfg.Privoxy.BinaryPath) + } +} + +func TestCheckDependencies_featureDetection(t *testing.T) { + tests := []struct { + name string + versionOutput string + wantConflux bool + wantHTTPTunnel bool + wantCongestion bool + wantCGO bool + }{ + { + name: "0.4.7 - congestion only", + versionOutput: "Tor version 0.4.7.0.\n", + wantConflux: false, + wantHTTPTunnel: false, + wantCongestion: true, + wantCGO: false, + }, + { + name: "0.4.8 - conflux and tunnel", + versionOutput: "Tor version 0.4.8.10.\n", + wantConflux: true, + wantHTTPTunnel: true, + wantCongestion: true, + wantCGO: false, + }, + { + name: "0.4.9 - all features", + versionOutput: "Tor version 0.4.9.5.\n", + wantConflux: true, + wantHTTPTunnel: true, + wantCongestion: true, + wantCGO: true, + }, + { + name: "0.4.6 - no features", + versionOutput: "Tor version 0.4.6.99.\n", + wantConflux: false, + wantHTTPTunnel: false, + wantCongestion: false, + wantCGO: false, + }, + } + + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { return name, nil } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return []byte(tt.versionOutput), nil + } + + cfg := testConfig("native") + result, err := CheckDependencies(context.Background(), cfg) + if err != nil { + t.Fatalf("CheckDependencies() error = %v", err) + } + if result.Features.Conflux != tt.wantConflux { + t.Errorf("Conflux = %v, want %v", result.Features.Conflux, tt.wantConflux) + } + if result.Features.HTTPTunnel != tt.wantHTTPTunnel { + t.Errorf("HTTPTunnel = %v, want %v", result.Features.HTTPTunnel, tt.wantHTTPTunnel) + } + if result.Features.CongestionControl != tt.wantCongestion { + t.Errorf("CongestionControl = %v, want %v", result.Features.CongestionControl, tt.wantCongestion) + } + if result.Features.CGO != tt.wantCGO { + t.Errorf("CGO = %v, want %v", result.Features.CGO, tt.wantCGO) + } + }) + } +} + +func TestCheckDependencies_versionCommandFails(t *testing.T) { + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { return name, nil } + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return nil, errors.New("exit status 1") + } + + cfg := testConfig("native") + _, err := CheckDependencies(context.Background(), cfg) + if err == nil { + t.Fatal("CheckDependencies() expected error, got nil") + } + if want := "failed to detect tor version"; !contains(err.Error(), want) { + t.Errorf("error = %q, want to contain %q", err.Error(), want) + } +} + +func TestCheckDependencies_unparseableVersion(t *testing.T) { + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { return name, nil } + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return []byte("some random output without version\n"), nil + } + + cfg := testConfig("native") + _, err := CheckDependencies(context.Background(), cfg) + if err == nil { + t.Fatal("CheckDependencies() expected error, got nil") + } + if want := "failed to parse tor version"; !contains(err.Error(), want) { + t.Errorf("error = %q, want to contain %q", err.Error(), want) + } +} + +func TestCheckDependencies_contextCancelled(t *testing.T) { + origLookPath := lookPath + origRunVersion := runVersionCmd + defer func() { + lookPath = origLookPath + runVersionCmd = origRunVersion + }() + + lookPath = func(name string) (string, error) { return name, nil } + runVersionCmd = func(ctx context.Context, binary string) ([]byte, error) { + return nil, ctx.Err() + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + cfg := testConfig("native") + _, err := CheckDependencies(ctx, cfg) + if err == nil { + t.Fatal("CheckDependencies() expected error, got nil") + } +} + +func testConfig(proxyMode string) *config.Config { + cfg := &config.Config{} + cfg.Tor.BinaryPath = "/usr/bin/tor" + cfg.HAProxy.BinaryPath = "/usr/sbin/haproxy" + cfg.Privoxy.BinaryPath = "/usr/sbin/privoxy" + cfg.ProxyMode = proxyMode + return cfg +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/health/dnsleak.go b/internal/health/dnsleak.go new file mode 100644 index 0000000..b7bb7b8 --- /dev/null +++ b/internal/health/dnsleak.go @@ -0,0 +1,325 @@ +package health + +import ( + "context" + "encoding/binary" + "fmt" + "log/slog" + "net" + "sync" + "time" +) + +const ( + defaultDNSLeakTestDomain = "dnsleaktest.com" + defaultDNSCheckInterval = 5 * time.Minute + socks5Version = 0x05 + socks5NoAuth = 0x00 + socks5CmdConnect = 0x01 + socks5AtypDomain = 0x03 + socks5AtypIPv4 = 0x01 + socks5AtypIPv6 = 0x04 +) + +type DNSLeakResult struct { + LeakDetected bool `json:"leak_detected"` + TorIP string `json:"tor_ip"` + DirectIP string `json:"direct_ip"` + TestDomain string `json:"test_domain"` + Error string `json:"error,omitempty"` +} + +type DNSLeakTester struct { + socksAddr string + testDomain string + dialer *net.Dialer +} + +func NewDNSLeakTester(socksAddr string) *DNSLeakTester { + return &DNSLeakTester{ + socksAddr: socksAddr, + testDomain: defaultDNSLeakTestDomain, + dialer: &net.Dialer{Timeout: 15 * time.Second}, + } +} + +func (t *DNSLeakTester) SetTestDomain(domain string) { + t.testDomain = domain +} + +func (t *DNSLeakTester) Test(ctx context.Context) (*DNSLeakResult, error) { + result := &DNSLeakResult{ + TestDomain: t.testDomain, + } + + var directAddrs []string + directAddrs, directErr := net.DefaultResolver.LookupHost(ctx, t.testDomain) + if directErr == nil && len(directAddrs) > 0 { + result.DirectIP = directAddrs[0] + } + + torIP, socksErr := t.resolveThroughSocks5(ctx) + if socksErr != nil { + if directErr != nil { + result.Error = fmt.Sprintf("tor resolution: %v; direct resolution: %v", socksErr, directErr) + return result, fmt.Errorf("Test: both resolutions failed: %w", socksErr) + } + result.Error = fmt.Sprintf("tor resolution failed: %v", socksErr) + return result, fmt.Errorf("Test: tor resolution failed: %w", socksErr) + } + result.TorIP = torIP + + if directErr != nil || len(directAddrs) == 0 { + result.LeakDetected = false + slog.Info("dns leak test passed", "reason", "direct resolution failed or unavailable", "tor_ip", torIP, "test_domain", t.testDomain) + return result, nil + } + + if result.DirectIP == result.TorIP { + result.LeakDetected = false + slog.Info("dns leak test passed", "tor_ip", torIP, "direct_ip", result.DirectIP, "test_domain", t.testDomain) + return result, nil + } + + result.LeakDetected = true + slog.Warn("dns leak detected", + "tor_ip", torIP, + "direct_ip", result.DirectIP, + "test_domain", t.testDomain, + ) + + return result, nil +} + +func (t *DNSLeakTester) resolveThroughSocks5(ctx context.Context) (string, error) { + conn, err := t.dialer.DialContext(ctx, "tcp", t.socksAddr) + if err != nil { + return "", fmt.Errorf("resolveThroughSocks5: connect to proxy: %w", err) + } + defer func() { _ = conn.Close() }() + + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + if err := socks5Handshake(conn); err != nil { + return "", fmt.Errorf("resolveThroughSocks5: handshake: %w", err) + } + + boundAddr, err := socks5ConnectDomain(conn, t.testDomain, 80) + if err != nil { + return "", fmt.Errorf("resolveThroughSocks5: connect: %w", err) + } + + return boundAddr, nil +} + +func socks5Handshake(conn net.Conn) error { + if _, err := conn.Write([]byte{socks5Version, 0x01, socks5NoAuth}); err != nil { + return fmt.Errorf("socks5Handshake: write greeting: %w", err) + } + + buf := make([]byte, 2) + if _, err := readFull(conn, buf); err != nil { + return fmt.Errorf("socks5Handshake: read response: %w", err) + } + + if buf[0] != socks5Version { + return fmt.Errorf("socks5Handshake: unexpected version %d", buf[0]) + } + if buf[1] != socks5NoAuth { + return fmt.Errorf("socks5Handshake: unsupported auth method %d", buf[1]) + } + + return nil +} + +func socks5ConnectDomain(conn net.Conn, domain string, port uint16) (string, error) { + domainBytes := []byte(domain) + if len(domainBytes) > 255 { + return "", fmt.Errorf("socks5ConnectDomain: domain too long (%d bytes)", len(domainBytes)) + } + + req := make([]byte, 0, 4+1+len(domainBytes)+2) + req = append(req, socks5Version, socks5CmdConnect, 0x00, socks5AtypDomain) + req = append(req, byte(len(domainBytes))) + req = append(req, domainBytes...) + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, port) + req = append(req, portBytes...) + + if _, err := conn.Write(req); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: write request: %w", err) + } + + header := make([]byte, 4) + if _, err := readFull(conn, header); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: read response header: %w", err) + } + + if header[0] != socks5Version { + return "", fmt.Errorf("socks5ConnectDomain: unexpected version %d", header[0]) + } + if header[1] != 0x00 { + return "", fmt.Errorf("socks5ConnectDomain: socks error code %d", header[1]) + } + + var boundAddr string + switch header[3] { + case socks5AtypIPv4: + ipBuf := make([]byte, 4) + if _, err := readFull(conn, ipBuf); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: read ipv4: %w", err) + } + boundAddr = net.IP(ipBuf).String() + case socks5AtypIPv6: + ipBuf := make([]byte, 16) + if _, err := readFull(conn, ipBuf); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: read ipv6: %w", err) + } + boundAddr = net.IP(ipBuf).String() + case socks5AtypDomain: + lenBuf := make([]byte, 1) + if _, err := readFull(conn, lenBuf); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: read domain length: %w", err) + } + domainBuf := make([]byte, lenBuf[0]) + if _, err := readFull(conn, domainBuf); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: read domain: %w", err) + } + boundAddr = string(domainBuf) + default: + return "", fmt.Errorf("socks5ConnectDomain: unsupported address type %d", header[3]) + } + + portBuf := make([]byte, 2) + if _, err := readFull(conn, portBuf); err != nil { + return "", fmt.Errorf("socks5ConnectDomain: read port: %w", err) + } + + return boundAddr, nil +} + +func readFull(conn net.Conn, buf []byte) (int, error) { + total := 0 + for total < len(buf) { + n, err := conn.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} + +type PeriodicChecker struct { + tester *DNSLeakTester + interval time.Duration + results []DNSLeakResult + mu sync.RWMutex + cancelFunc context.CancelFunc + done chan struct{} +} + +func NewPeriodicChecker(tester *DNSLeakTester, interval time.Duration) *PeriodicChecker { + if interval <= 0 { + interval = defaultDNSCheckInterval + } + return &PeriodicChecker{ + tester: tester, + interval: interval, + done: make(chan struct{}), + } +} + +func (pc *PeriodicChecker) Start(ctx context.Context) error { + pc.mu.Lock() + if pc.cancelFunc != nil { + pc.mu.Unlock() + return fmt.Errorf("Start: checker already running") + } + childCtx, cancel := context.WithCancel(ctx) + pc.cancelFunc = cancel + pc.done = make(chan struct{}) + pc.mu.Unlock() + + go pc.run(childCtx) + return nil +} + +func (pc *PeriodicChecker) Stop() error { + pc.mu.Lock() + cancel := pc.cancelFunc + pc.mu.Unlock() + + if cancel == nil { + return fmt.Errorf("Stop: checker not running") + } + cancel() + + <-pc.done + + pc.mu.Lock() + pc.cancelFunc = nil + pc.mu.Unlock() + + return nil +} + +func (pc *PeriodicChecker) LastResult() *DNSLeakResult { + pc.mu.RLock() + defer pc.mu.RUnlock() + if len(pc.results) == 0 { + return nil + } + r := pc.results[len(pc.results)-1] + return &r +} + +func (pc *PeriodicChecker) Results() []DNSLeakResult { + pc.mu.RLock() + defer pc.mu.RUnlock() + out := make([]DNSLeakResult, len(pc.results)) + copy(out, pc.results) + return out +} + +func (pc *PeriodicChecker) run(ctx context.Context) { + defer close(pc.done) + + result, err := pc.tester.Test(ctx) + if err != nil { + slog.Warn("periodic dns leak test failed", "error", err) + } else { + pc.appendResult(*result) + } + + ticker := time.NewTicker(pc.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + result, err := pc.tester.Test(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + slog.Warn("periodic dns leak test failed", "error", err) + continue + } + pc.appendResult(*result) + } + } +} + +func (pc *PeriodicChecker) appendResult(r DNSLeakResult) { + pc.mu.Lock() + pc.results = append(pc.results, r) + if len(pc.results) > 100 { + pc.results = pc.results[len(pc.results)-100:] + } + pc.mu.Unlock() +} diff --git a/internal/health/dnsleak_test.go b/internal/health/dnsleak_test.go new file mode 100644 index 0000000..6044efd --- /dev/null +++ b/internal/health/dnsleak_test.go @@ -0,0 +1,943 @@ +package health + +import ( + "context" + "encoding/binary" + "encoding/json" + "net" + "sync" + "testing" + "time" +) + +func TestDNSLeakResult_JSON(t *testing.T) { + tests := []struct { + name string + result DNSLeakResult + }{ + { + name: "no leak", + result: DNSLeakResult{ + LeakDetected: false, + TorIP: "1.2.3.4", + DirectIP: "", + TestDomain: "dnsleaktest.com", + }, + }, + { + name: "leak detected", + result: DNSLeakResult{ + LeakDetected: true, + TorIP: "10.0.0.1", + DirectIP: "192.168.1.1", + TestDomain: "example.com", + Error: "", + }, + }, + { + name: "with error", + result: DNSLeakResult{ + LeakDetected: false, + TorIP: "", + DirectIP: "", + TestDomain: "dnsleaktest.com", + Error: "connection refused", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.result) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + + var decoded DNSLeakResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + + if decoded.LeakDetected != tt.result.LeakDetected { + t.Errorf("LeakDetected = %v, want %v", decoded.LeakDetected, tt.result.LeakDetected) + } + if decoded.TorIP != tt.result.TorIP { + t.Errorf("TorIP = %q, want %q", decoded.TorIP, tt.result.TorIP) + } + if decoded.DirectIP != tt.result.DirectIP { + t.Errorf("DirectIP = %q, want %q", decoded.DirectIP, tt.result.DirectIP) + } + if decoded.TestDomain != tt.result.TestDomain { + t.Errorf("TestDomain = %q, want %q", decoded.TestDomain, tt.result.TestDomain) + } + if decoded.Error != tt.result.Error { + t.Errorf("Error = %q, want %q", decoded.Error, tt.result.Error) + } + }) + } +} + +func TestDNSLeakResult_JSONFieldNames(t *testing.T) { + r := DNSLeakResult{ + LeakDetected: true, + TorIP: "1.2.3.4", + DirectIP: "5.6.7.8", + TestDomain: "test.com", + Error: "some error", + } + + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal to map error: %v", err) + } + + expectedKeys := []string{"leak_detected", "tor_ip", "direct_ip", "test_domain", "error"} + for _, key := range expectedKeys { + if _, ok := raw[key]; !ok { + t.Errorf("missing JSON key %q in output: %s", key, string(data)) + } + } +} + +func TestDNSLeakResult_JSONOmitEmptyError(t *testing.T) { + r := DNSLeakResult{ + LeakDetected: false, + TorIP: "1.2.3.4", + DirectIP: "", + TestDomain: "test.com", + } + + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("json.Unmarshal to map error: %v", err) + } + + if _, ok := raw["error"]; ok { + t.Errorf("error field should be omitted when empty, got: %s", string(data)) + } +} + +type mockSocks5Server struct { + listener net.Listener + responseIP string + done chan struct{} +} + +func newMockSocks5Server(t *testing.T, responseIP string) *mockSocks5Server { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create mock socks server: %v", err) + } + s := &mockSocks5Server{ + listener: ln, + responseIP: responseIP, + done: make(chan struct{}), + } + go s.serve(t) + return s +} + +func (s *mockSocks5Server) Addr() string { + return s.listener.Addr().String() +} + +func (s *mockSocks5Server) Close() { + _ = s.listener.Close() + <-s.done +} + +func (s *mockSocks5Server) serve(t *testing.T) { + defer close(s.done) + for { + conn, err := s.listener.Accept() + if err != nil { + return + } + go s.handleConn(t, conn) + } +} + +func (s *mockSocks5Server) handleConn(t *testing.T, conn net.Conn) { + defer func() { _ = conn.Close() }() + + buf := make([]byte, 3) + if _, err := readFull(conn, buf); err != nil { + return + } + + if buf[0] != socks5Version || buf[1] != 1 || buf[2] != socks5NoAuth { + return + } + + if _, err := conn.Write([]byte{socks5Version, socks5NoAuth}); err != nil { + return + } + + header := make([]byte, 4) + if _, err := readFull(conn, header); err != nil { + return + } + + if header[0] != socks5Version || header[1] != socks5CmdConnect { + return + } + + switch header[3] { + case socks5AtypDomain: + lenBuf := make([]byte, 1) + if _, err := readFull(conn, lenBuf); err != nil { + return + } + domainBuf := make([]byte, lenBuf[0]) + if _, err := readFull(conn, domainBuf); err != nil { + return + } + case socks5AtypIPv4: + ipBuf := make([]byte, 4) + if _, err := readFull(conn, ipBuf); err != nil { + return + } + case socks5AtypIPv6: + ipBuf := make([]byte, 16) + if _, err := readFull(conn, ipBuf); err != nil { + return + } + } + + portBuf := make([]byte, 2) + if _, err := readFull(conn, portBuf); err != nil { + return + } + + ip := net.ParseIP(s.responseIP) + if ip == nil { + ip = net.ParseIP("127.0.0.1") + } + + resp := []byte{socks5Version, 0x00, 0x00} + if ip4 := ip.To4(); ip4 != nil { + resp = append(resp, socks5AtypIPv4) + resp = append(resp, ip4...) + } else { + resp = append(resp, socks5AtypIPv6) + resp = append(resp, ip.To16()...) + } + resp = append(resp, portBuf...) + + if _, err := conn.Write(resp); err != nil { + return + } + + keepAlive := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + _, _ = conn.Read(keepAlive) +} + +func TestDNSLeakTester_Socks5Resolve(t *testing.T) { + srv := newMockSocks5Server(t, "93.184.216.34") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("example.com") + + ip, err := tester.resolveThroughSocks5(context.Background()) + if err != nil { + t.Fatalf("resolveThroughSocks5() error = %v", err) + } + + if ip != "93.184.216.34" { + t.Errorf("resolveThroughSocks5() = %q, want %q", ip, "93.184.216.34") + } +} + +func TestDNSLeakTester_Socks5ResolveIPv6(t *testing.T) { + srv := newMockSocks5Server(t, "2606:4700:3030::6815:1a01") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("example.com") + + ip, err := tester.resolveThroughSocks5(context.Background()) + if err != nil { + t.Fatalf("resolveThroughSocks5() error = %v", err) + } + + if ip != "2606:4700:3030::6815:1a01" { + t.Errorf("resolveThroughSocks5() = %q, want %q", ip, "2606:4700:3030::6815:1a01") + } +} + +func TestDNSLeakTester_Socks5ConnectionRefused(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + _ = ln.Close() + + tester := NewDNSLeakTester(addr) + + _, err = tester.resolveThroughSocks5(context.Background()) + if err == nil { + t.Fatal("resolveThroughSocks5() expected error for refused connection") + } +} + +func TestDNSLeakTester_Socks5ContextCancelled(t *testing.T) { + srv := newMockSocks5Server(t, "1.2.3.4") + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + tester := NewDNSLeakTester(srv.Addr()) + _, err := tester.resolveThroughSocks5(ctx) + if err == nil { + t.Fatal("resolveThroughSocks5() expected error for cancelled context") + } +} + +func TestDNSLeakTester_Test_NoLeakDirectFails(t *testing.T) { + srv := newMockSocks5Server(t, "93.184.216.34") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + result, err := tester.Test(context.Background()) + if err != nil { + t.Fatalf("Test() error = %v", err) + } + + if result.LeakDetected { + t.Error("LeakDetected = true, want false (direct resolution fails, tor succeeds)") + } + if result.TorIP != "93.184.216.34" { + t.Errorf("TorIP = %q, want %q", result.TorIP, "93.184.216.34") + } + if result.TestDomain != "this-domain-should-not-exist-for-splitter-test.invalid" { + t.Errorf("TestDomain = %q, want correct domain", result.TestDomain) + } +} + +func TestDNSLeakTester_Test_BothFail(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + _ = ln.Close() + + tester := NewDNSLeakTester(addr) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + _, err = tester.Test(context.Background()) + if err == nil { + t.Fatal("Test() expected error when both resolutions fail") + } +} + +func TestNewDNSLeakTester(t *testing.T) { + tester := NewDNSLeakTester("127.0.0.1:9050") + if tester.socksAddr != "127.0.0.1:9050" { + t.Errorf("socksAddr = %q, want %q", tester.socksAddr, "127.0.0.1:9050") + } + if tester.testDomain != defaultDNSLeakTestDomain { + t.Errorf("testDomain = %q, want %q", tester.testDomain, defaultDNSLeakTestDomain) + } +} + +func TestDNSLeakTester_SetTestDomain(t *testing.T) { + tester := NewDNSLeakTester("127.0.0.1:9050") + tester.SetTestDomain("custom.example.com") + if tester.testDomain != "custom.example.com" { + t.Errorf("testDomain = %q, want %q", tester.testDomain, "custom.example.com") + } +} + +func TestPeriodicChecker_StartStop(t *testing.T) { + srv := newMockSocks5Server(t, "1.2.3.4") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + pc := NewPeriodicChecker(tester, 50*time.Millisecond) + + if err := pc.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + time.Sleep(200 * time.Millisecond) + + if err := pc.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + last := pc.LastResult() + if last == nil { + t.Fatal("LastResult() returned nil, expected at least one result") + } + if last.TorIP != "1.2.3.4" { + t.Errorf("LastResult().TorIP = %q, want %q", last.TorIP, "1.2.3.4") + } +} + +func TestPeriodicChecker_MultipleIntervals(t *testing.T) { + srv := newMockSocks5Server(t, "10.0.0.1") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + pc := NewPeriodicChecker(tester, 50*time.Millisecond) + + if err := pc.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + time.Sleep(250 * time.Millisecond) + + if err := pc.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + results := pc.Results() + if len(results) < 2 { + t.Errorf("len(Results) = %d, want at least 2 results after multiple intervals", len(results)) + } +} + +func TestPeriodicChecker_LastResultEmpty(t *testing.T) { + tester := NewDNSLeakTester("127.0.0.1:9050") + pc := NewPeriodicChecker(tester, 5*time.Minute) + + if last := pc.LastResult(); last != nil { + t.Errorf("LastResult() = %v, want nil before first run", last) + } +} + +func TestPeriodicChecker_ResultsCopy(t *testing.T) { + srv := newMockSocks5Server(t, "5.5.5.5") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + pc := NewPeriodicChecker(tester, 50*time.Millisecond) + if err := pc.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + time.Sleep(120 * time.Millisecond) + if err := pc.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + results := pc.Results() + if len(results) == 0 { + t.Fatal("Results() returned empty") + } + + results[0].TorIP = "modified" + last := pc.LastResult() + if last.TorIP == "modified" { + t.Error("Results() should return a copy, not a reference to internal state") + } +} + +func TestPeriodicChecker_StartTwice(t *testing.T) { + tester := NewDNSLeakTester("127.0.0.1:9050") + pc := NewPeriodicChecker(tester, 5*time.Minute) + + if err := pc.Start(context.Background()); err != nil { + t.Fatalf("first Start() error = %v", err) + } + defer func() { _ = pc.Stop() }() + + err := pc.Start(context.Background()) + if err == nil { + t.Fatal("second Start() expected error, got nil") + } +} + +func TestPeriodicChecker_StopWithoutStart(t *testing.T) { + tester := NewDNSLeakTester("127.0.0.1:9050") + pc := NewPeriodicChecker(tester, 5*time.Minute) + + err := pc.Stop() + if err == nil { + t.Fatal("Stop() expected error when not started") + } +} + +func TestPeriodicChecker_ContextCancel(t *testing.T) { + srv := newMockSocks5Server(t, "1.2.3.4") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + ctx, cancel := context.WithCancel(context.Background()) + + pc := NewPeriodicChecker(tester, 50*time.Millisecond) + if err := pc.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + time.Sleep(100 * time.Millisecond) + cancel() + time.Sleep(100 * time.Millisecond) + + last := pc.LastResult() + if last == nil { + t.Fatal("LastResult() returned nil, expected at least one result before cancel") + } +} + +func TestPeriodicChecker_DefaultInterval(t *testing.T) { + tester := NewDNSLeakTester("127.0.0.1:9050") + pc := NewPeriodicChecker(tester, 0) + if pc.interval != defaultDNSCheckInterval { + t.Errorf("interval = %v, want %v for zero input", pc.interval, defaultDNSCheckInterval) + } + + pc2 := NewPeriodicChecker(tester, -1*time.Second) + if pc2.interval != defaultDNSCheckInterval { + t.Errorf("interval = %v, want %v for negative input", pc2.interval, defaultDNSCheckInterval) + } +} + +func TestPeriodicChecker_ResultsTruncation(t *testing.T) { + srv := newMockSocks5Server(t, "7.7.7.7") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + pc := NewPeriodicChecker(tester, 10*time.Millisecond) + + if err := pc.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + time.Sleep(1500 * time.Millisecond) + + if err := pc.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + results := pc.Results() + if len(results) > 100 { + t.Errorf("len(Results) = %d, want at most 100", len(results)) + } +} + +func TestPeriodicChecker_ConcurrentAccess(t *testing.T) { + srv := newMockSocks5Server(t, "8.8.8.8") + defer srv.Close() + + tester := NewDNSLeakTester(srv.Addr()) + tester.SetTestDomain("this-domain-should-not-exist-for-splitter-test.invalid") + + pc := NewPeriodicChecker(tester, 20*time.Millisecond) + + if err := pc.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 20; j++ { + pc.LastResult() + pc.Results() + time.Sleep(5 * time.Millisecond) + } + }() + } + + time.Sleep(200 * time.Millisecond) + if err := pc.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + wg.Wait() +} + +func TestSocks5ConnectDomain_DomainTooLong(t *testing.T) { + longDomain := "" + for i := 0; i < 256; i++ { + longDomain += "a" + } + + clientConn, serverConn := net.Pipe() + defer func() { _ = clientConn.Close() }() + defer func() { _ = serverConn.Close() }() + + _, err := socks5ConnectDomain(clientConn, longDomain, 80) + if err == nil { + t.Fatal("socks5ConnectDomain() expected error for domain > 255 bytes") + } +} + +func TestDNSLeakTester_Socks5InvalidResponse(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{socks5Version, socks5NoAuth}) + + header := make([]byte, 4) + _, _ = readFull(conn, header) + + domainLen := make([]byte, 1) + _, _ = readFull(conn, domainLen) + domainBuf := make([]byte, domainLen[0]) + _, _ = readFull(conn, domainBuf) + portBuf := make([]byte, 2) + _, _ = readFull(conn, portBuf) + + _, _ = conn.Write([]byte{0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) + }() + + tester := NewDNSLeakTester(ln.Addr().String()) + tester.SetTestDomain("example.com") + + _, err = tester.resolveThroughSocks5(context.Background()) + if err == nil { + t.Fatal("resolveThroughSocks5() expected error for invalid socks version in response") + } +} + +func TestDNSLeakTester_Socks5ErrorCode(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{socks5Version, socks5NoAuth}) + + header := make([]byte, 4) + _, _ = readFull(conn, header) + domainLen := make([]byte, 1) + _, _ = readFull(conn, domainLen) + domainBuf := make([]byte, domainLen[0]) + _, _ = readFull(conn, domainBuf) + portBuf := make([]byte, 2) + _, _ = readFull(conn, portBuf) + + resp := []byte{socks5Version, 0x05, 0x00, socks5AtypIPv4, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + _, _ = conn.Write(resp) + }() + + tester := NewDNSLeakTester(ln.Addr().String()) + tester.SetTestDomain("example.com") + + _, err = tester.resolveThroughSocks5(context.Background()) + if err == nil { + t.Fatal("resolveThroughSocks5() expected error for socks error code") + } +} + +func TestSocks5Handshake_InvalidVersion(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{0x04, socks5NoAuth}) + }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + err = socks5Handshake(conn) + if err == nil { + t.Fatal("socks5Handshake() expected error for wrong version") + } +} + +func TestSocks5Handshake_UnsupportedAuth(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{socks5Version, 0xFF}) + }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + err = socks5Handshake(conn) + if err == nil { + t.Fatal("socks5Handshake() expected error for unsupported auth method") + } +} + +func TestSocks5ConnectDomain_DomainAddressType(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{socks5Version, socks5NoAuth}) + + header := make([]byte, 4) + _, _ = readFull(conn, header) + + domainLen := make([]byte, 1) + _, _ = readFull(conn, domainLen) + domainBuf := make([]byte, domainLen[0]) + _, _ = readFull(conn, domainBuf) + portBuf := make([]byte, 2) + _, _ = readFull(conn, portBuf) + + respDomain := []byte("bound.example.com") + resp := []byte{socks5Version, 0x00, 0x00, socks5AtypDomain, byte(len(respDomain))} + resp = append(resp, respDomain...) + resp = append(resp, portBuf...) + _, _ = conn.Write(resp) + + keepAlive := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + _, _ = conn.Read(keepAlive) + }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + if err := socks5Handshake(conn); err != nil { + t.Fatalf("handshake: %v", err) + } + + addr, err := socks5ConnectDomain(conn, "test.example.com", 80) + if err != nil { + t.Fatalf("socks5ConnectDomain() error = %v", err) + } + + if addr != "bound.example.com" { + t.Errorf("addr = %q, want %q", addr, "bound.example.com") + } +} + +func TestSocks5ConnectDomain_IPv6AddressType(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{socks5Version, socks5NoAuth}) + + header := make([]byte, 4) + _, _ = readFull(conn, header) + + domainLen := make([]byte, 1) + _, _ = readFull(conn, domainLen) + domainBuf := make([]byte, domainLen[0]) + _, _ = readFull(conn, domainBuf) + portBuf := make([]byte, 2) + _, _ = readFull(conn, portBuf) + + ip := net.ParseIP("::1").To16() + resp := []byte{socks5Version, 0x00, 0x00, socks5AtypIPv6} + resp = append(resp, ip...) + resp = append(resp, portBuf...) + _, _ = conn.Write(resp) + + keepAlive := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + _, _ = conn.Read(keepAlive) + }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + if err := socks5Handshake(conn); err != nil { + t.Fatalf("handshake: %v", err) + } + + addr, err := socks5ConnectDomain(conn, "test.example.com", 80) + if err != nil { + t.Fatalf("socks5ConnectDomain() error = %v", err) + } + + if addr != "::1" { + t.Errorf("addr = %q, want %q", addr, "::1") + } +} + +func TestSocks5ConnectDomain_UnsupportedAddrType(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + buf := make([]byte, 3) + _, _ = readFull(conn, buf) + _, _ = conn.Write([]byte{socks5Version, socks5NoAuth}) + + header := make([]byte, 4) + _, _ = readFull(conn, header) + domainLen := make([]byte, 1) + _, _ = readFull(conn, domainLen) + domainBuf := make([]byte, domainLen[0]) + _, _ = readFull(conn, domainBuf) + portBuf := make([]byte, 2) + _, _ = readFull(conn, portBuf) + + _, _ = conn.Write([]byte{socks5Version, 0x00, 0x00, 0x02, 0x00, 0x00}) + }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + if err := socks5Handshake(conn); err != nil { + t.Fatalf("handshake: %v", err) + } + + _, err = socks5ConnectDomain(conn, "test.example.com", 80) + if err == nil { + t.Fatal("socks5ConnectDomain() expected error for unsupported address type") + } +} + +func TestSocks5ConnectDomain_RequestFormat(t *testing.T) { + server, client := net.Pipe() + defer func() { _ = server.Close() }() + defer func() { _ = client.Close() }() + + var received []byte + done := make(chan struct{}) + + go func() { + defer close(done) + buf := make([]byte, 256) + n, _ := server.Read(buf) + received = buf[:n] + + ip := net.ParseIP("1.2.3.4").To4() + resp := []byte{socks5Version, 0x00, 0x00, socks5AtypIPv4} + resp = append(resp, ip...) + resp = append(resp, 0x00, 0x50) + _, _ = server.Write(resp) + }() + + addr, _ := socks5ConnectDomain(client, "test.example.com", 80) + <-done + + if addr != "1.2.3.4" { + t.Errorf("addr = %q, want %q", addr, "1.2.3.4") + } + + if len(received) < 7 { + t.Fatalf("request too short: %d bytes", len(received)) + } + + if received[0] != socks5Version { + t.Errorf("version byte = %d, want %d", received[0], socks5Version) + } + if received[1] != socks5CmdConnect { + t.Errorf("cmd byte = %d, want %d", received[1], socks5CmdConnect) + } + if received[3] != socks5AtypDomain { + t.Errorf("atype = %d, want %d", received[3], socks5AtypDomain) + } + if received[4] != 16 { + t.Errorf("domain length = %d, want 16", received[4]) + } + + port := binary.BigEndian.Uint16(received[len(received)-2:]) + if port != 80 { + t.Errorf("port = %d, want 80", port) + } +} diff --git a/internal/health/doc.go b/internal/health/doc.go new file mode 100644 index 0000000..64ca580 --- /dev/null +++ b/internal/health/doc.go @@ -0,0 +1,2 @@ +// Package health provides health checks, DNS leak detection, and exit node reputation verification for SPLITTER. +package health diff --git a/internal/health/reputation.go b/internal/health/reputation.go new file mode 100644 index 0000000..10e0095 --- /dev/null +++ b/internal/health/reputation.go @@ -0,0 +1,288 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "net/http" + "os" + "sort" + "strings" + "time" +) + +const defaultReputationCacheTTL = 12 * time.Hour + +const defaultOnionooDetailsURL = "https://onionoo.torproject.org/details" + +type ExitReputation struct { + Fingerprint string `json:"fingerprint"` + Country string `json:"country"` + UptimeDays int `json:"uptime_days"` + Bandwidth int64 `json:"bandwidth"` + Flags []string `json:"flags"` + IsFlagged bool `json:"is_flagged"` + IsNew bool `json:"is_new"` + Score float64 `json:"score"` +} + +type reputationCache struct { + FetchedAt time.Time `json:"fetched_at"` + Entries map[string][]ExitReputation `json:"entries"` +} + +type ReputationChecker struct { + client *http.Client + minUptime int + minBandwidth int64 + cacheTTL time.Duration + cachePath string + apiURL string +} + +func NewReputationChecker(cachePath string) *ReputationChecker { + return &ReputationChecker{ + client: &http.Client{Timeout: 30 * time.Second}, + minUptime: 7, + minBandwidth: 1 << 20, + cacheTTL: defaultReputationCacheTTL, + cachePath: cachePath, + apiURL: defaultOnionooDetailsURL, + } +} + +func (rc *ReputationChecker) SetAPIURL(url string) { + rc.apiURL = url +} + +func (rc *ReputationChecker) Check(ctx context.Context, country string) ([]ExitReputation, error) { + upper := strings.ToUpper(country) + + cached, err := rc.readCache() + if err == nil && cached != nil { + if entries, ok := cached.Entries[upper]; ok && time.Since(cached.FetchedAt) < rc.cacheTTL { + return entries, nil + } + } + + reputations, fetchErr := rc.fetchFromAPI(ctx, upper) + if fetchErr != nil { + if cached != nil { + if entries, ok := cached.Entries[upper]; ok { + slog.Warn("reputation fetch failed, using stale cache", "error", fetchErr, "country", upper) + return entries, nil + } + } + return nil, fmt.Errorf("Check: fetch failed and no cache for country %s: %w", upper, fetchErr) + } + + if len(reputations) == 0 { + if cached != nil { + if entries, ok := cached.Entries[upper]; ok { + slog.Warn("reputation returned empty, using cache", "country", upper) + return entries, nil + } + } + return nil, fmt.Errorf("Check: no exit relays found for country %s and no cache available", upper) + } + + if err := rc.writeCache(upper, reputations, cached); err != nil { + slog.Warn("failed to write reputation cache", "error", err) + } + + return reputations, nil +} + +func (rc *ReputationChecker) Filter(reputations []ExitReputation) []ExitReputation { + var filtered []ExitReputation + for _, r := range reputations { + if r.IsFlagged { + continue + } + if r.IsNew { + continue + } + if r.UptimeDays < rc.minUptime { + continue + } + if r.Bandwidth < rc.minBandwidth { + continue + } + filtered = append(filtered, r) + } + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Score > filtered[j].Score + }) + + return filtered +} + +type onionooDetailsResponse struct { + Relays []onionooDetailsRelay `json:"relays"` +} + +type onionooDetailsRelay struct { + Fingerprint string `json:"fingerprint"` + Country string `json:"country"` + ObservedBandwidth int64 `json:"observed_bandwidth"` + Flags []string `json:"flags"` + FirstSeen string `json:"first_seen"` + LastSeen string `json:"last_seen"` +} + +func (rc *ReputationChecker) fetchFromAPI(ctx context.Context, country string) ([]ExitReputation, error) { + url := fmt.Sprintf("%s?type=relay&running=true&flag=Exit&country=%s", rc.apiURL, strings.ToLower(country)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("fetchFromAPI: creating request: %w", err) + } + + resp, err := rc.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetchFromAPI: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetchFromAPI: unexpected status %d", resp.StatusCode) + } + + var body onionooDetailsResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("fetchFromAPI: decoding response: %w", err) + } + + reputations := make([]ExitReputation, 0, len(body.Relays)) + for _, relay := range body.Relays { + uptimeDays := daysSinceFirstSeen(relay.FirstSeen) + isNew := uptimeDays < rc.minUptime + score := computeScore(relay.Flags, uptimeDays, relay.ObservedBandwidth, false, isNew) + + reputations = append(reputations, ExitReputation{ + Fingerprint: relay.Fingerprint, + Country: strings.ToUpper(relay.Country), + UptimeDays: uptimeDays, + Bandwidth: relay.ObservedBandwidth, + Flags: relay.Flags, + IsFlagged: false, + IsNew: isNew, + Score: score, + }) + } + + return reputations, nil +} + +func computeScore(flags []string, uptimeDays int, bandwidth int64, isFlagged bool, isNew bool) float64 { + score := 0.5 + + hasFlag := func(name string) bool { + for _, f := range flags { + if f == name { + return true + } + } + return false + } + + if hasFlag("Stable") { + score += 0.1 + } + if hasFlag("Fast") { + score += 0.1 + } + if uptimeDays > 30 { + score += 0.1 + } + if bandwidth > 10*1<<20 { + score += 0.1 + } + if isFlagged { + score -= 0.5 + } + if isNew { + score -= 0.3 + } + + if score < 0.0 { + score = 0.0 + } + if score > 1.0 { + score = 1.0 + } + + return score +} + +func daysSinceFirstSeen(firstSeen string) int { + t, err := time.Parse("2006-01-02", firstSeen) + if err != nil { + t, err = time.Parse("2006-01-02 15:04:05", firstSeen) + if err != nil { + return 0 + } + } + now := time.Now().UTC() + days := int(now.Sub(t).Hours() / 24) + if days < 0 { + return 0 + } + return days +} + +func (rc *ReputationChecker) readCache() (*reputationCache, error) { + data, err := os.ReadFile(rc.cachePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("readCache: %w", err) + } + + var cache reputationCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, fmt.Errorf("readCache: unmarshal: %w", err) + } + return &cache, nil +} + +func (rc *ReputationChecker) writeCache(country string, reputations []ExitReputation, existing *reputationCache) error { + if err := os.MkdirAll(dirOf(rc.cachePath), 0700); err != nil { + return fmt.Errorf("writeCache: mkdir: %w", err) + } + + cache := reputationCache{ + FetchedAt: time.Now().UTC(), + Entries: make(map[string][]ExitReputation), + } + + if existing != nil { + for k, v := range existing.Entries { + cache.Entries[k] = v + } + } + cache.Entries[country] = reputations + + data, err := json.Marshal(cache) + if err != nil { + return fmt.Errorf("writeCache: marshal: %w", err) + } + + if err := os.WriteFile(rc.cachePath, data, 0600); err != nil { + return fmt.Errorf("writeCache: write: %w", err) + } + return nil +} + +func dirOf(path string) string { + idx := strings.LastIndex(path, "/") + if idx < 0 { + return "." + } + return path[:idx] +} diff --git a/internal/health/reputation_test.go b/internal/health/reputation_test.go new file mode 100644 index 0000000..e6b2418 --- /dev/null +++ b/internal/health/reputation_test.go @@ -0,0 +1,647 @@ +package health + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestReputationChecker_Check_Success(t *testing.T) { + response := onionooDetailsResponse{ + Relays: []onionooDetailsRelay{ + { + Fingerprint: "AABB1122", + Country: "de", + ObservedBandwidth: 5_000_000, + Flags: []string{"Exit", "Fast", "Stable", "Running"}, + FirstSeen: "2025-01-15", + LastSeen: "2026-04-02", + }, + { + Fingerprint: "CCDD3344", + Country: "de", + ObservedBandwidth: 500_000, + Flags: []string{"Exit", "Running"}, + FirstSeen: time.Now().Format("2006-01-02"), + LastSeen: time.Now().Format("2006-01-02"), + }, + }, + } + + srv := httptest.NewServer(reputationJSONHandler(t, response)) + defer srv.Close() + + tmpDir := t.TempDir() + rc := NewReputationChecker(filepath.Join(tmpDir, "reputation_cache.json")) + rc.SetAPIURL(srv.URL) + + reps, err := rc.Check(context.Background(), "de") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + + if len(reps) != 2 { + t.Fatalf("len(reps) = %d, want 2", len(reps)) + } + + if reps[0].Country != "DE" { + t.Errorf("Country = %q, want DE", reps[0].Country) + } + if reps[0].Fingerprint != "AABB1122" { + t.Errorf("Fingerprint = %q, want AABB1122", reps[0].Fingerprint) + } + if reps[0].UptimeDays <= 0 { + t.Errorf("UptimeDays = %d, want > 0", reps[0].UptimeDays) + } + if reps[1].IsNew != true { + t.Errorf("IsNew = %v, want true for relay with first_seen today", reps[1].IsNew) + } +} + +func TestReputationChecker_Check_WritesCache(t *testing.T) { + response := onionooDetailsResponse{ + Relays: []onionooDetailsRelay{ + { + Fingerprint: "AABB1122", + Country: "us", + ObservedBandwidth: 2_000_000, + Flags: []string{"Exit", "Fast"}, + FirstSeen: "2025-06-01", + LastSeen: "2026-04-02", + }, + }, + } + + srv := httptest.NewServer(reputationJSONHandler(t, response)) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + _, err := rc.Check(context.Background(), "us") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatalf("cache file not created: %v", err) + } + + var cache reputationCache + if err := json.Unmarshal(data, &cache); err != nil { + t.Fatalf("cache unmarshal: %v", err) + } + if cache.FetchedAt.IsZero() { + t.Error("cached fetched_at is zero") + } + if _, ok := cache.Entries["US"]; !ok { + t.Error("cache missing US entry") + } +} + +func TestReputationChecker_Check_UsesCache(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + cached := reputationCache{ + FetchedAt: time.Now().UTC(), + Entries: map[string][]ExitReputation{ + "DE": { + {Fingerprint: "CACHED01", Country: "DE", Score: 0.8}, + }, + }, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("API should not be called when cache is fresh") + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + reps, err := rc.Check(context.Background(), "de") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if len(reps) != 1 || reps[0].Fingerprint != "CACHED01" { + t.Errorf("reps = %v, want cached entry", reps) + } +} + +func TestReputationChecker_Check_TTLExpired(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + cached := reputationCache{ + FetchedAt: time.Now().UTC().Add(-48 * time.Hour), + Entries: map[string][]ExitReputation{ + "DE": { + {Fingerprint: "STALE01", Country: "DE", Score: 0.5}, + }, + }, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + response := onionooDetailsResponse{ + Relays: []onionooDetailsRelay{ + { + Fingerprint: "FRESH01", + Country: "de", + ObservedBandwidth: 3_000_000, + Flags: []string{"Exit", "Fast", "Stable"}, + FirstSeen: "2024-01-01", + LastSeen: "2026-04-02", + }, + }, + } + + srv := httptest.NewServer(reputationJSONHandler(t, response)) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + reps, err := rc.Check(context.Background(), "de") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if len(reps) != 1 || reps[0].Fingerprint != "FRESH01" { + t.Errorf("reps = %v, want fresh entry", reps) + } +} + +func TestReputationChecker_Check_StaleCacheOnFetchError(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + cached := reputationCache{ + FetchedAt: time.Now().UTC().Add(-48 * time.Hour), + Entries: map[string][]ExitReputation{ + "FR": { + {Fingerprint: "STALE_FR", Country: "FR", Score: 0.6}, + }, + }, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + reps, err := rc.Check(context.Background(), "fr") + if err != nil { + t.Fatalf("Check() error = %v, want stale cache fallback", err) + } + if len(reps) != 1 || reps[0].Fingerprint != "STALE_FR" { + t.Errorf("reps = %v, want stale cache entry", reps) + } +} + +func TestReputationChecker_Check_NoCacheFetchError(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + _, err := rc.Check(context.Background(), "xx") + if err == nil { + t.Error("Check() expected error when fetch fails with no cache") + } +} + +func TestReputationChecker_Check_EmptyResponseNoCache(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + response := onionooDetailsResponse{Relays: []onionooDetailsRelay{}} + srv := httptest.NewServer(reputationJSONHandler(t, response)) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + _, err := rc.Check(context.Background(), "zz") + if err == nil { + t.Error("Check() expected error for empty API response with no cache") + } +} + +func TestReputationChecker_Check_EmptyResponseWithStaleCache(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + cached := reputationCache{ + FetchedAt: time.Now().UTC().Add(-48 * time.Hour), + Entries: map[string][]ExitReputation{ + "ZZ": { + {Fingerprint: "OLD_ZZ", Country: "ZZ", Score: 0.3}, + }, + }, + } + data, _ := json.Marshal(cached) + if err := os.WriteFile(cachePath, data, 0600); err != nil { + t.Fatal(err) + } + + response := onionooDetailsResponse{Relays: []onionooDetailsRelay{}} + srv := httptest.NewServer(reputationJSONHandler(t, response)) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + reps, err := rc.Check(context.Background(), "zz") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + if len(reps) != 1 || reps[0].Fingerprint != "OLD_ZZ" { + t.Errorf("reps = %v, want stale cache entry", reps) + } +} + +func TestReputationChecker_Check_ContextCancelled(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(5 * time.Second) + })) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := rc.Check(ctx, "us") + if err == nil { + t.Error("Check() expected error for cancelled context") + } +} + +func TestComputeScore(t *testing.T) { + tests := []struct { + name string + flags []string + uptime int + bandwidth int64 + flagged bool + isNew bool + want float64 + }{ + { + name: "base score only", + flags: []string{"Exit"}, + uptime: 5, + bandwidth: 500_000, + flagged: false, + isNew: false, + want: 0.5, + }, + { + name: "stable flag", + flags: []string{"Exit", "Stable"}, + uptime: 5, + bandwidth: 500_000, + flagged: false, + isNew: false, + want: 0.6, + }, + { + name: "fast flag", + flags: []string{"Exit", "Fast"}, + uptime: 5, + bandwidth: 500_000, + flagged: false, + isNew: false, + want: 0.6, + }, + { + name: "high uptime", + flags: []string{"Exit"}, + uptime: 45, + bandwidth: 500_000, + flagged: false, + isNew: false, + want: 0.6, + }, + { + name: "high bandwidth", + flags: []string{"Exit"}, + uptime: 5, + bandwidth: 15_000_000, + flagged: false, + isNew: false, + want: 0.6, + }, + { + name: "best relay", + flags: []string{"Exit", "Stable", "Fast"}, + uptime: 120, + bandwidth: 20_000_000, + flagged: false, + isNew: false, + want: 0.9, + }, + { + name: "flagged bad", + flags: []string{"Exit", "Stable", "Fast"}, + uptime: 120, + bandwidth: 20_000_000, + flagged: true, + isNew: false, + want: 0.4, + }, + { + name: "new relay", + flags: []string{"Exit"}, + uptime: 3, + bandwidth: 500_000, + flagged: false, + isNew: true, + want: 0.2, + }, + { + name: "flagged and new", + flags: []string{"Exit"}, + uptime: 3, + bandwidth: 500_000, + flagged: true, + isNew: true, + want: 0.0, + }, + { + name: "clamped at zero", + flags: []string{}, + uptime: 1, + bandwidth: 0, + flagged: true, + isNew: true, + want: 0.0, + }, + { + name: "clamped at one", + flags: []string{"Exit", "Stable", "Fast"}, + uptime: 120, + bandwidth: 20_000_000, + flagged: false, + isNew: false, + want: 0.9, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeScore(tt.flags, tt.uptime, tt.bandwidth, tt.flagged, tt.isNew) + if diff := got - tt.want; diff < -0.0001 || diff > 0.0001 { + t.Errorf("computeScore() = %f, want %f", got, tt.want) + } + }) + } +} + +func TestReputationChecker_Filter(t *testing.T) { + reps := []ExitReputation{ + {Fingerprint: "GOOD01", Score: 0.8, IsFlagged: false, IsNew: false, UptimeDays: 30, Bandwidth: 5_000_000}, + {Fingerprint: "NEW01", Score: 0.5, IsFlagged: false, IsNew: true, UptimeDays: 3, Bandwidth: 2_000_000}, + {Fingerprint: "BAD01", Score: 0.2, IsFlagged: true, IsNew: false, UptimeDays: 60, Bandwidth: 3_000_000}, + {Fingerprint: "GOOD02", Score: 0.9, IsFlagged: false, IsNew: false, UptimeDays: 100, Bandwidth: 15_000_000}, + {Fingerprint: "SLOW01", Score: 0.4, IsFlagged: false, IsNew: false, UptimeDays: 30, Bandwidth: 500_000}, + {Fingerprint: "YOUNG01", Score: 0.6, IsFlagged: false, IsNew: false, UptimeDays: 5, Bandwidth: 3_000_000}, + } + + rc := NewReputationChecker(filepath.Join(t.TempDir(), "cache.json")) + + filtered := rc.Filter(reps) + + if len(filtered) != 2 { + t.Fatalf("Filter() returned %d entries, want 2", len(filtered)) + } + + if filtered[0].Fingerprint != "GOOD02" { + t.Errorf("filtered[0] = %q, want GOOD02 (highest score)", filtered[0].Fingerprint) + } + if filtered[1].Fingerprint != "GOOD01" { + t.Errorf("filtered[1] = %q, want GOOD01 (second highest)", filtered[1].Fingerprint) + } +} + +func TestReputationChecker_Filter_Empty(t *testing.T) { + rc := NewReputationChecker(filepath.Join(t.TempDir(), "cache.json")) + + filtered := rc.Filter(nil) + if len(filtered) != 0 { + t.Errorf("Filter(nil) returned %d entries, want 0", len(filtered)) + } +} + +func TestReputationChecker_Filter_AllFiltered(t *testing.T) { + reps := []ExitReputation{ + {Fingerprint: "FLAGGED", Score: 0.1, IsFlagged: true, IsNew: false, UptimeDays: 30, Bandwidth: 5_000_000}, + {Fingerprint: "NEW", Score: 0.2, IsFlagged: false, IsNew: true, UptimeDays: 2, Bandwidth: 5_000_000}, + } + + rc := NewReputationChecker(filepath.Join(t.TempDir(), "cache.json")) + + filtered := rc.Filter(reps) + if len(filtered) != 0 { + t.Errorf("Filter() returned %d entries, want 0 (all filtered)", len(filtered)) + } +} + +func TestDaysSinceFirstSeen(t *testing.T) { + tests := []struct { + name string + firstSeen string + wantZero bool + wantPositive bool + }{ + { + name: "valid date", + firstSeen: "2025-01-01", + wantPositive: true, + }, + { + name: "invalid format returns zero", + firstSeen: "not-a-date", + wantZero: true, + }, + { + name: "datetime format", + firstSeen: "2025-01-01 12:00:00", + wantPositive: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := daysSinceFirstSeen(tt.firstSeen) + if tt.wantZero && got != 0 { + t.Errorf("daysSinceFirstSeen(%q) = %d, want 0", tt.firstSeen, got) + } + if tt.wantPositive && got <= 0 { + t.Errorf("daysSinceFirstSeen(%q) = %d, want > 0", tt.firstSeen, got) + } + }) + } +} + +func TestReputationChecker_APIURL(t *testing.T) { + response := onionooDetailsResponse{ + Relays: []onionooDetailsRelay{ + { + Fingerprint: "URLTEST", + Country: "nl", + ObservedBandwidth: 1_000_000, + Flags: []string{"Exit"}, + FirstSeen: "2025-06-01", + LastSeen: "2026-04-02", + }, + }, + } + + var requestedPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + "?" + r.URL.RawQuery + reputationJSONHandler(t, response).ServeHTTP(w, r) + })) + defer srv.Close() + + rc := NewReputationChecker(filepath.Join(t.TempDir(), "cache.json")) + rc.SetAPIURL(srv.URL) + + _, err := rc.Check(context.Background(), "nl") + if err != nil { + t.Fatalf("Check() error = %v", err) + } + + if requestedPath != "/?type=relay&running=true&flag=Exit&country=nl" { + t.Errorf("requested path = %q, want query with country param", requestedPath) + } +} + +func TestReputationChecker_InvalidJSON(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "not json") + })) + defer srv.Close() + + rc := NewReputationChecker(cachePath) + rc.SetAPIURL(srv.URL) + + _, err := rc.Check(context.Background(), "us") + if err == nil { + t.Error("Check() expected error for invalid JSON") + } +} + +func TestReputationChecker_ReadCache_NoFile(t *testing.T) { + rc := NewReputationChecker(filepath.Join(t.TempDir(), "nonexistent.json")) + cache, err := rc.readCache() + if err != nil { + t.Errorf("readCache() error = %v, want nil for missing file", err) + } + if cache != nil { + t.Error("readCache() expected nil for missing file") + } +} + +func TestReputationChecker_ReadCache_InvalidJSON(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + if err := os.WriteFile(cachePath, []byte("bad json"), 0600); err != nil { + t.Fatal(err) + } + + rc := NewReputationChecker(cachePath) + _, err := rc.readCache() + if err == nil { + t.Error("readCache() expected error for invalid JSON") + } +} + +func TestReputationChecker_WriteCache_PreservesExisting(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "reputation_cache.json") + + existing := &reputationCache{ + FetchedAt: time.Now().UTC().Add(-1 * time.Hour), + Entries: map[string][]ExitReputation{ + "US": {{Fingerprint: "US_RELAY", Country: "US"}}, + }, + } + + rc := NewReputationChecker(cachePath) + + newReps := []ExitReputation{{Fingerprint: "DE_RELAY", Country: "DE"}} + if err := rc.writeCache("DE", newReps, existing); err != nil { + t.Fatalf("writeCache() error = %v", err) + } + + cache, err := rc.readCache() + if err != nil { + t.Fatalf("readCache() error = %v", err) + } + + if _, ok := cache.Entries["US"]; !ok { + t.Error("writeCache overwrote existing US entry") + } + if _, ok := cache.Entries["DE"]; !ok { + t.Error("writeCache missing new DE entry") + } +} + +func TestNewReputationChecker_Defaults(t *testing.T) { + rc := NewReputationChecker("/tmp/test_cache.json") + if rc.minUptime != 7 { + t.Errorf("minUptime = %d, want 7", rc.minUptime) + } + if rc.minBandwidth != 1<<20 { + t.Errorf("minBandwidth = %d, want %d", rc.minBandwidth, 1<<20) + } + if rc.cacheTTL != defaultReputationCacheTTL { + t.Errorf("cacheTTL = %v, want %v", rc.cacheTTL, defaultReputationCacheTTL) + } + if rc.client.Timeout != 30*time.Second { + t.Errorf("client timeout = %v, want 30s", rc.client.Timeout) + } + if rc.apiURL != defaultOnionooDetailsURL { + t.Errorf("apiURL = %q, want %q", rc.apiURL, defaultOnionooDetailsURL) + } +} + +func reputationJSONHandler(t *testing.T, v interface{}) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("reputationJSONHandler marshal: %v", err) + } + _, _ = w.Write(data) + } +} diff --git a/internal/health/status.go b/internal/health/status.go new file mode 100644 index 0000000..113a2d5 --- /dev/null +++ b/internal/health/status.go @@ -0,0 +1,99 @@ +package health + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +const DefaultStatusPort = 63540 + +type InstanceStatus struct { + ID int `json:"id"` + Country string `json:"country"` + State string `json:"state"` + SocksPort int `json:"socks_port"` + ControlPort int `json:"control_port"` + HTTPPort int `json:"http_port"` +} + +type SystemStatus struct { + Timestamp string `json:"timestamp"` + TorVersion string `json:"tor_version"` + Features map[string]bool `json:"features"` + Instances []InstanceStatus `json:"instances"` + TotalInstances int `json:"total_instances"` + ReadyCount int `json:"ready_count"` + FailedCount int `json:"failed_count"` + Processes int `json:"process_count"` + ProcessBreakdown map[string]int `json:"process_breakdown,omitempty"` +} + +func CollectStatus(torMgr *tor.TorManager, procMgr *process.Manager) *SystemStatus { + status := &SystemStatus{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Features: make(map[string]bool), + } + + if v := torMgr.GetVersion(); v != nil { + status.TorVersion = v.String() + status.Features["conflux"] = v.SupportsConflux() + status.Features["http_tunnel"] = v.SupportsHTTPTunnel() + status.Features["congestion_control"] = v.SupportsCongestionControl() + status.Features["cgo"] = v.SupportsCGO() + } + + instances := torMgr.GetInstances() + status.TotalInstances = len(instances) + status.Instances = make([]InstanceStatus, len(instances)) + + for i, inst := range instances { + status.Instances[i] = InstanceStatus{ + ID: inst.ID, + Country: inst.Country, + State: inst.GetState().String(), + SocksPort: inst.SocksPort, + ControlPort: inst.ControlPort, + HTTPPort: inst.HTTPPort, + } + switch inst.GetState() { + case tor.StateReady: + status.ReadyCount++ + case tor.StateFailed: + status.FailedCount++ + } + } + + procs := procMgr.List() + status.Processes = len(procs) + status.ProcessBreakdown = categorizeProcesses(procs) + + return status +} + +func categorizeProcesses(procs []*process.Process) map[string]int { + breakdown := make(map[string]int) + for _, p := range procs { + name := p.Name + if idx := strings.Index(name, "-"); idx >= 0 { + name = name[:idx] + } + breakdown[name]++ + } + return breakdown +} + +func StatusHandler(torMgr *tor.TorManager, procMgr *process.Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + status := CollectStatus(torMgr, procMgr) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(status); err != nil { + http.Error(w, fmt.Sprintf("StatusHandler: %v", err), http.StatusInternalServerError) + } + } +} diff --git a/internal/health/status_test.go b/internal/health/status_test.go new file mode 100644 index 0000000..a022a18 --- /dev/null +++ b/internal/health/status_test.go @@ -0,0 +1,300 @@ +package health + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" + "github.com/user/splitter/internal/tor" +) + +func TestCollectStatus(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + torMgr.CreateFromVersion(&tor.Version{Major: 0, Minor: 4, Patch: 8, Release: 0}, []string{"{US}", "{DE}"}) + + status := CollectStatus(torMgr, procMgr) + + if status.TorVersion != "0.4.8.0" { + t.Errorf("TorVersion = %q, want %q", status.TorVersion, "0.4.8.0") + } + if status.TotalInstances != 2 { + t.Errorf("TotalInstances = %d, want 2", status.TotalInstances) + } + if len(status.Instances) != 2 { + t.Fatalf("len(Instances) = %d, want 2", len(status.Instances)) + } + if status.Instances[0].State != "starting" { + t.Errorf("Instances[0].State = %q, want %q", status.Instances[0].State, "starting") + } + if status.Instances[0].Country != "{US}" { + t.Errorf("Instances[0].Country = %q, want %q", status.Instances[0].Country, "{US}") + } + if status.Instances[0].SocksPort != 4999 { + t.Errorf("Instances[0].SocksPort = %d, want 4999", status.Instances[0].SocksPort) + } + if status.Instances[0].ControlPort != 5999 { + t.Errorf("Instances[0].ControlPort = %d, want 5999", status.Instances[0].ControlPort) + } + if status.Instances[0].HTTPPort != 5199 { + t.Errorf("Instances[0].HTTPPort = %d, want 5199", status.Instances[0].HTTPPort) + } + if status.Instances[1].SocksPort != 5000 { + t.Errorf("Instances[1].SocksPort = %d, want 5000", status.Instances[1].SocksPort) + } + if status.Processes != 0 { + t.Errorf("Processes = %d, want 0", status.Processes) + } + if len(status.ProcessBreakdown) != 0 { + t.Errorf("ProcessBreakdown = %v, want empty", status.ProcessBreakdown) + } +} + +func TestCollectStatus_Empty(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + + status := CollectStatus(torMgr, procMgr) + + if status.TorVersion != "" { + t.Errorf("TorVersion = %q, want empty", status.TorVersion) + } + if status.TotalInstances != 0 { + t.Errorf("TotalInstances = %d, want 0", status.TotalInstances) + } + if len(status.Instances) != 0 { + t.Errorf("len(Instances) = %d, want 0", len(status.Instances)) + } + if len(status.Features) != 0 { + t.Errorf("Features = %v, want empty", status.Features) + } +} + +func TestCollectStatus_Features(t *testing.T) { + tests := []struct { + name string + version *tor.Version + want map[string]bool + }{ + { + name: "0.4.8 conflux and http_tunnel", + version: &tor.Version{Major: 0, Minor: 4, Patch: 8}, + want: map[string]bool{ + "conflux": true, + "http_tunnel": true, + "congestion_control": true, + "cgo": false, + }, + }, + { + name: "0.4.9 all features", + version: &tor.Version{Major: 0, Minor: 4, Patch: 9}, + want: map[string]bool{ + "conflux": true, + "http_tunnel": true, + "congestion_control": true, + "cgo": true, + }, + }, + { + name: "0.4.6 no features", + version: &tor.Version{Major: 0, Minor: 4, Patch: 6}, + want: map[string]bool{ + "conflux": false, + "http_tunnel": false, + "congestion_control": false, + "cgo": false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + torMgr.CreateFromVersion(tt.version, []string{"{US}"}) + + status := CollectStatus(torMgr, procMgr) + + for key, want := range tt.want { + if got := status.Features[key]; got != want { + t.Errorf("Features[%q] = %v, want %v", key, got, want) + } + } + }) + } +} + +func TestCollectStatus_JSONRoundTrip(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + torMgr.CreateFromVersion(&tor.Version{Major: 0, Minor: 4, Patch: 8}, []string{"{US}"}) + + status := CollectStatus(torMgr, procMgr) + + data, err := json.Marshal(status) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + + var decoded SystemStatus + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + + if decoded.TorVersion != status.TorVersion { + t.Errorf("TorVersion = %q, want %q", decoded.TorVersion, status.TorVersion) + } + if decoded.TotalInstances != status.TotalInstances { + t.Errorf("TotalInstances = %d, want %d", decoded.TotalInstances, status.TotalInstances) + } + if decoded.ReadyCount != status.ReadyCount { + t.Errorf("ReadyCount = %d, want %d", decoded.ReadyCount, status.ReadyCount) + } + if len(decoded.Instances) != len(status.Instances) { + t.Fatalf("len(Instances) = %d, want %d", len(decoded.Instances), len(status.Instances)) + } + if decoded.Instances[0].SocksPort != status.Instances[0].SocksPort { + t.Errorf("SocksPort = %d, want %d", decoded.Instances[0].SocksPort, status.Instances[0].SocksPort) + } + if decoded.Instances[0].Country != status.Instances[0].Country { + t.Errorf("Country = %q, want %q", decoded.Instances[0].Country, status.Instances[0].Country) + } +} + +func TestCollectStatus_Timestamp(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + + status := CollectStatus(torMgr, procMgr) + + if status.Timestamp == "" { + t.Error("Timestamp should not be empty") + } + if len(status.Timestamp) != 19 { + t.Errorf("Timestamp = %q, want format '2006-01-02 15:04:05'", status.Timestamp) + } +} + +func TestStatusHandler(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + torMgr.CreateFromVersion(&tor.Version{Major: 0, Minor: 4, Patch: 8, Release: 0}, []string{"{US}"}) + + handler := StatusHandler(torMgr, procMgr) + + req := httptest.NewRequest(http.MethodGet, "/status", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Status = %d, want %d", w.Code, http.StatusOK) + } + + ct := w.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/json") + } + + var status SystemStatus + if err := json.NewDecoder(w.Body).Decode(&status); err != nil { + t.Fatalf("json.Decode error: %v", err) + } + if status.TorVersion != "0.4.8.0" { + t.Errorf("TorVersion = %q, want %q", status.TorVersion, "0.4.8.0") + } + if status.TotalInstances != 1 { + t.Errorf("TotalInstances = %d, want 1", status.TotalInstances) + } +} + +func TestStatusHandler_MultipleRequests(t *testing.T) { + cfg := testStatusConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + torMgr := tor.NewManager(cfg, procMgr) + torMgr.CreateFromVersion(&tor.Version{Major: 0, Minor: 4, Patch: 9, Release: 0}, []string{"{US}", "{DE}"}) + + handler := StatusHandler(torMgr, procMgr) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/status", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("request %d: Status = %d, want %d", i, w.Code, http.StatusOK) + } + } +} + +func TestCategorizeProcesses(t *testing.T) { + procs := []*process.Process{ + {Name: "tor-0"}, + {Name: "tor-1"}, + {Name: "tor-2"}, + {Name: "haproxy"}, + {Name: "privoxy-0"}, + } + + result := categorizeProcesses(procs) + + if result["tor"] != 3 { + t.Errorf("tor count = %d, want 3", result["tor"]) + } + if result["haproxy"] != 1 { + t.Errorf("haproxy count = %d, want 1", result["haproxy"]) + } + if result["privoxy"] != 1 { + t.Errorf("privoxy count = %d, want 1", result["privoxy"]) + } +} + +func TestCategorizeProcesses_Empty(t *testing.T) { + result := categorizeProcesses(nil) + if len(result) != 0 { + t.Errorf("expected empty map, got %v", result) + } + + result = categorizeProcesses([]*process.Process{}) + if len(result) != 0 { + t.Errorf("expected empty map, got %v", result) + } +} + +func TestCategorizeProcesses_NoDash(t *testing.T) { + procs := []*process.Process{ + {Name: "haproxy"}, + {Name: "nginx"}, + } + + result := categorizeProcesses(procs) + + if result["haproxy"] != 1 { + t.Errorf("haproxy count = %d, want 1", result["haproxy"]) + } + if result["nginx"] != 1 { + t.Errorf("nginx count = %d, want 1", result["nginx"]) + } +} + +func testStatusConfig(t *testing.T) *config.Config { + t.Helper() + cfg := &config.Config{} + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + cfg.Tor.ControlAuth = "cookie" + cfg.Paths.TempFiles = t.TempDir() + cfg.Instances.PerCountry = 1 + cfg.Relay.Enforce = "entry" + return cfg +} diff --git a/internal/metrics/doc.go b/internal/metrics/doc.go new file mode 100644 index 0000000..9ea516b --- /dev/null +++ b/internal/metrics/doc.go @@ -0,0 +1,2 @@ +// Package metrics provides a Prometheus-compatible metrics endpoint for monitoring SPLITTER instances. +package metrics diff --git a/internal/metrics/handler.go b/internal/metrics/handler.go new file mode 100644 index 0000000..2a83fc8 --- /dev/null +++ b/internal/metrics/handler.go @@ -0,0 +1,27 @@ +package metrics + +import ( + "encoding/json" + "net/http" +) + +type Handler struct { + registry *Registry +} + +func NewHandler(registry *Registry) *Handler { + return &Handler{registry: registry} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body := h.registry.Render() + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) +} + +func (h *Handler) Healthz(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/internal/metrics/handler_test.go b/internal/metrics/handler_test.go new file mode 100644 index 0000000..16efda9 --- /dev/null +++ b/internal/metrics/handler_test.go @@ -0,0 +1,181 @@ +package metrics + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandlerMetricsEndpoint(t *testing.T) { + r := NewRegistry() + c := r.NewCounter("test_requests", "Total requests") + c.Inc() + h := NewHandler(r) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if ct != "text/plain; version=0.0.4; charset=utf-8" { + t.Fatalf("unexpected content type: %s", ct) + } + body := rec.Body.String() + if !strings.Contains(body, "test_requests 1") { + t.Fatalf("expected metric in body, got:\n%s", body) + } +} + +func TestHandlerHealthzEndpoint(t *testing.T) { + r := NewRegistry() + h := NewHandler(r) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + h.Healthz(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if ct != "application/json" { + t.Fatalf("unexpected content type: %s", ct) + } + + var result map[string]string + if err := json.NewDecoder(rec.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode healthz response: %v", err) + } + if result["status"] != "ok" { + t.Fatalf("expected status ok, got %s", result["status"]) + } +} + +func TestHandlerEmptyRegistry(t *testing.T) { + r := NewRegistry() + h := NewHandler(r) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if rec.Body.String() != "" { + t.Fatalf("expected empty body, got:\n%s", rec.Body.String()) + } +} + +func TestHandlerFullPrometheusFormat(t *testing.T) { + r := NewRegistry() + g := r.NewGauge("splitter_instances_total", "Total instances") + g.Set(3) + c := r.NewCounter("splitter_errors_total", "Total errors") + c.Inc() + c.Inc() + h := NewHandler(r) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + body := rec.Body.String() + lines := strings.Split(strings.TrimSpace(body), "\n") + expected := []string{ + "# HELP splitter_instances_total Total instances", + "# TYPE splitter_instances_total gauge", + "splitter_instances_total 3", + "# HELP splitter_errors_total Total errors", + "# TYPE splitter_errors_total counter", + "splitter_errors_total 2", + } + if len(lines) != len(expected) { + t.Fatalf("expected %d lines, got %d:\n%s", len(expected), len(lines), body) + } + for i, exp := range expected { + if lines[i] != exp { + t.Fatalf("line %d: expected %q, got %q", i, exp, lines[i]) + } + } +} + +func TestSplitterMetricsIntegration(t *testing.T) { + r := NewRegistry() + sm := NewSplitterMetrics(r) + + sm.SetInstanceCount(4, 3) + sm.SetInstanceState("1", "ready") + sm.SetInstanceState("2", "ready") + sm.SetInstanceState("3", "ready") + sm.SetInstanceState("4", "bootstrapping") + sm.SetInstanceCountry("1", "us") + sm.SetInstanceCountry("2", "de") + sm.IncCircuits() + sm.IncCircuitRenewal("1") + sm.IncCircuitRenewal("1") + sm.IncErrors() + sm.SetBootstrapProgress("4", 75.5) + + out := r.Render() + + if !strings.Contains(out, "splitter_instances_total 4") { + t.Fatalf("expected 4 total instances, got:\n%s", out) + } + if !strings.Contains(out, "splitter_instances_active 3") { + t.Fatalf("expected 3 active instances, got:\n%s", out) + } + if !strings.Contains(out, `splitter_instance_state{instance_id="1",state="ready"} 1`) { + t.Fatalf("expected instance 1 state, got:\n%s", out) + } + if !strings.Contains(out, `splitter_instance_country{country="de",instance_id="2"} 1`) { + t.Fatalf("expected instance 2 country, got:\n%s", out) + } + if !strings.Contains(out, "splitter_circuits_total 1") { + t.Fatalf("expected 1 circuit, got:\n%s", out) + } + if !strings.Contains(out, `splitter_circuit_renewals_total{instance_id="1"} 2`) { + t.Fatalf("expected 2 renewals for instance 1, got:\n%s", out) + } + if !strings.Contains(out, "splitter_errors_total 1") { + t.Fatalf("expected 1 error, got:\n%s", out) + } + if !strings.Contains(out, `splitter_bootstrap_progress{instance_id="4"} 75.5`) { + t.Fatalf("expected bootstrap progress 75.5, got:\n%s", out) + } +} + +func TestServerStartAndShutdown(t *testing.T) { + r := NewRegistry() + h := NewHandler(r) + srv := NewServer("127.0.0.1:0", h) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- srv.Start(ctx) + }() + + for range 50 { + resp, err := http.Get("http://" + srv.server.Addr + "/healthz") + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + break + } + } + } + + cancel() + if err := <-done; err != nil { + t.Fatalf("server returned error: %v", err) + } +} diff --git a/internal/metrics/registry.go b/internal/metrics/registry.go new file mode 100644 index 0000000..7d013a6 --- /dev/null +++ b/internal/metrics/registry.go @@ -0,0 +1,199 @@ +package metrics + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +type metricType int + +const ( + typeCounter metricType = iota + typeGauge +) + +func (t metricType) String() string { + switch t { + case typeCounter: + return "counter" + case typeGauge: + return "gauge" + default: + return "untyped" + } +} + +type labeledValue struct { + labels map[string]string + value float64 +} + +type metric struct { + name string + help string + mtype metricType + mu sync.RWMutex + self float64 + labeled []labeledValue +} + +func (m *metric) Inc() { + m.mu.Lock() + m.self++ + m.mu.Unlock() +} + +func (m *metric) Add(v float64) { + m.mu.Lock() + m.self += v + m.mu.Unlock() +} + +func (m *metric) Set(v float64) { + m.mu.Lock() + m.self = v + m.mu.Unlock() +} + +func (m *metric) IncWithLabels(pairs ...string) { + key := labelKey(pairs) + m.mu.Lock() + defer m.mu.Unlock() + for i := range m.labeled { + if labelKeyFromMap(m.labeled[i].labels) == key { + m.labeled[i].value++ + return + } + } + m.labeled = append(m.labeled, labeledValue{ + labels: labelMap(pairs), + value: 1, + }) +} + +func (m *metric) SetWithLabels(v float64, pairs ...string) { + key := labelKey(pairs) + m.mu.Lock() + defer m.mu.Unlock() + for i := range m.labeled { + if labelKeyFromMap(m.labeled[i].labels) == key { + m.labeled[i].value = v + return + } + } + m.labeled = append(m.labeled, labeledValue{ + labels: labelMap(pairs), + value: v, + }) +} + +func (m *metric) render() string { + var b strings.Builder + fmt.Fprintf(&b, "# HELP %s %s\n", m.name, m.help) + fmt.Fprintf(&b, "# TYPE %s %s\n", m.name, m.mtype) + + m.mu.RLock() + if m.self != 0 || len(m.labeled) == 0 { + renderValue(&b, m.name, nil, m.self) + } + for _, lv := range m.labeled { + renderValue(&b, m.name, lv.labels, lv.value) + } + m.mu.RUnlock() + + return b.String() +} + +func renderValue(b *strings.Builder, name string, labels map[string]string, v float64) { + if len(labels) == 0 { + fmt.Fprintf(b, "%s %g\n", name, v) + return + } + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%q", k, labels[k])) + } + fmt.Fprintf(b, "%s{%s} %g\n", name, strings.Join(pairs, ","), v) +} + +func labelMap(pairs []string) map[string]string { + m := make(map[string]string, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + m[pairs[i]] = pairs[i+1] + } + return m +} + +func labelKey(pairs []string) string { + m := labelMap(pairs) + return labelKeyFromMap(m) +} + +func labelKeyFromMap(m map[string]string) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, ",") +} + +type Registry struct { + mu sync.RWMutex + metrics []*metric + order []string +} + +func NewRegistry() *Registry { + return &Registry{} +} + +func (r *Registry) newMetric(name, help string, mt metricType) *metric { + r.mu.Lock() + defer r.mu.Unlock() + for _, existing := range r.metrics { + if existing.name == name { + return existing + } + } + m := &metric{ + name: name, + help: help, + mtype: mt, + } + r.metrics = append(r.metrics, m) + r.order = append(r.order, name) + return m +} + +func (r *Registry) NewCounter(name, help string) *metric { + return r.newMetric(name, help, typeCounter) +} + +func (r *Registry) NewGauge(name, help string) *metric { + return r.newMetric(name, help, typeGauge) +} + +func (r *Registry) Render() string { + r.mu.RLock() + sorted := make([]*metric, len(r.metrics)) + copy(sorted, r.metrics) + r.mu.RUnlock() + + var b strings.Builder + for _, m := range sorted { + b.WriteString(m.render()) + } + return b.String() +} diff --git a/internal/metrics/registry_test.go b/internal/metrics/registry_test.go new file mode 100644 index 0000000..341c127 --- /dev/null +++ b/internal/metrics/registry_test.go @@ -0,0 +1,159 @@ +package metrics + +import ( + "strings" + "testing" +) + +func TestCounterIncrement(t *testing.T) { + r := NewRegistry() + c := r.NewCounter("test_counter", "A test counter") + c.Inc() + c.Inc() + c.Inc() + + out := r.Render() + if !strings.Contains(out, "test_counter 3") { + t.Fatalf("expected counter value 3, got:\n%s", out) + } + if !strings.Contains(out, "# TYPE test_counter counter") { + t.Fatalf("missing TYPE line:\n%s", out) + } + if !strings.Contains(out, "# HELP test_counter A test counter") { + t.Fatalf("missing HELP line:\n%s", out) + } +} + +func TestCounterAdd(t *testing.T) { + r := NewRegistry() + c := r.NewCounter("test_add", "Add test") + c.Add(5) + c.Add(3) + + out := r.Render() + if !strings.Contains(out, "test_add 8") { + t.Fatalf("expected counter value 8, got:\n%s", out) + } +} + +func TestGaugeSet(t *testing.T) { + r := NewRegistry() + g := r.NewGauge("test_gauge", "A test gauge") + g.Set(42) + g.Set(7) + + out := r.Render() + if !strings.Contains(out, "test_gauge 7") { + t.Fatalf("expected gauge value 7, got:\n%s", out) + } + if !strings.Contains(out, "# TYPE test_gauge gauge") { + t.Fatalf("missing TYPE line:\n%s", out) + } +} + +func TestLabeledGauge(t *testing.T) { + r := NewRegistry() + g := r.NewGauge("labeled_gauge", "With labels") + g.SetWithLabels(1, "instance_id", "1", "state", "ready") + g.SetWithLabels(0, "instance_id", "2", "state", "failed") + + out := r.Render() + if !strings.Contains(out, `labeled_gauge{instance_id="1",state="ready"} 1`) { + t.Fatalf("expected labeled value for instance 1, got:\n%s", out) + } + if !strings.Contains(out, `labeled_gauge{instance_id="2",state="failed"} 0`) { + t.Fatalf("expected labeled value for instance 2, got:\n%s", out) + } +} + +func TestLabeledCounter(t *testing.T) { + r := NewRegistry() + c := r.NewCounter("labeled_counter", "Labeled counter") + c.IncWithLabels("instance_id", "1") + c.IncWithLabels("instance_id", "1") + c.IncWithLabels("instance_id", "2") + + out := r.Render() + if !strings.Contains(out, `labeled_counter{instance_id="1"} 2`) { + t.Fatalf("expected labeled counter 2 for instance 1, got:\n%s", out) + } + if !strings.Contains(out, `labeled_counter{instance_id="2"} 1`) { + t.Fatalf("expected labeled counter 1 for instance 2, got:\n%s", out) + } +} + +func TestLabeledGaugeOverwrite(t *testing.T) { + r := NewRegistry() + g := r.NewGauge("overwrite_gauge", "Overwrite test") + g.SetWithLabels(50, "instance_id", "1") + g.SetWithLabels(100, "instance_id", "1") + + out := r.Render() + if !strings.Contains(out, `overwrite_gauge{instance_id="1"} 100`) { + t.Fatalf("expected overwritten value 100, got:\n%s", out) + } +} + +func TestRegistryMultipleMetrics(t *testing.T) { + r := NewRegistry() + c := r.NewCounter("counter_a", "Counter A") + g := r.NewGauge("gauge_b", "Gauge B") + c.Inc() + g.Set(99) + + out := r.Render() + if !strings.Contains(out, "counter_a 1") { + t.Fatalf("expected counter_a, got:\n%s", out) + } + if !strings.Contains(out, "gauge_b 99") { + t.Fatalf("expected gauge_b, got:\n%s", out) + } +} + +func TestRegistryDuplicateName(t *testing.T) { + r := NewRegistry() + c1 := r.NewCounter("dup", "First") + c2 := r.NewCounter("dup", "Second") + c1.Inc() + c2.Inc() + + out := r.Render() + if !strings.Contains(out, "dup 2") { + t.Fatalf("expected combined value 2 for duplicate metric, got:\n%s", out) + } +} + +func TestRegistryEmpty(t *testing.T) { + r := NewRegistry() + out := r.Render() + if out != "" { + t.Fatalf("expected empty output, got:\n%s", out) + } +} + +func TestGaugeUnlabeledRender(t *testing.T) { + r := NewRegistry() + g := r.NewGauge("simple", "Simple gauge") + g.Set(0) + + out := r.Render() + if !strings.Contains(out, "simple 0") { + t.Fatalf("expected value 0, got:\n%s", out) + } +} + +func TestCounterUnlabeledWithLabeled(t *testing.T) { + r := NewRegistry() + c := r.NewCounter("mixed", "Mixed counter") + c.Inc() + c.Inc() + c.IncWithLabels("instance_id", "1") + + out := r.Render() + if !strings.Contains(out, "mixed 2") { + t.Fatalf("expected unlabeled value 2, got:\n%s", out) + } + if !strings.Contains(out, `mixed{instance_id="1"} 1`) { + t.Fatalf("expected labeled value 1, got:\n%s", out) + } +} diff --git a/internal/metrics/server.go b/internal/metrics/server.go new file mode 100644 index 0000000..6b5bb9f --- /dev/null +++ b/internal/metrics/server.go @@ -0,0 +1,40 @@ +package metrics + +import ( + "context" + "fmt" + "log/slog" + "net/http" +) + +type Server struct { + addr string + handler *Handler + server *http.Server +} + +func NewServer(addr string, handler *Handler) *Server { + mux := http.NewServeMux() + mux.Handle("/metrics", handler) + mux.HandleFunc("/healthz", handler.Healthz) + return &Server{ + addr: addr, + handler: handler, + server: &http.Server{Addr: addr, Handler: mux}, + } +} + +func (s *Server) Start(ctx context.Context) error { + go func() { + <-ctx.Done() + if err := s.server.Shutdown(context.Background()); err != nil { + slog.Error("metrics server shutdown", "error", err) + } + }() + + slog.Info("metrics server starting", "addr", s.addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("metrics server: %w", err) + } + return nil +} diff --git a/internal/metrics/splitter.go b/internal/metrics/splitter.go new file mode 100644 index 0000000..7b69065 --- /dev/null +++ b/internal/metrics/splitter.go @@ -0,0 +1,61 @@ +package metrics + +type SplitterMetrics struct { + InstancesTotal *metric + InstancesActive *metric + InstanceState *metric + InstanceCountry *metric + CircuitsTotal *metric + CircuitRenewalsTotal *metric + ErrorsTotal *metric + BootstrapProgress *metric + + registry *Registry +} + +func NewSplitterMetrics(r *Registry) *SplitterMetrics { + return &SplitterMetrics{ + registry: r, + InstancesTotal: r.NewGauge("splitter_instances_total", "Total number of tor instances"), + InstancesActive: r.NewGauge("splitter_instances_active", "Number of instances in ready state"), + InstanceState: r.NewGauge("splitter_instance_state", "Per-instance state (1=current state)"), + InstanceCountry: r.NewGauge("splitter_instance_country", "Per-instance country assignment"), + CircuitsTotal: r.NewCounter("splitter_circuits_total", "Total circuits created"), + CircuitRenewalsTotal: r.NewCounter("splitter_circuit_renewals_total", "Per-instance NEWNYM count"), + ErrorsTotal: r.NewCounter("splitter_errors_total", "Total errors encountered"), + BootstrapProgress: r.NewGauge("splitter_bootstrap_progress", "Per-instance bootstrap progress 0-100"), + } +} + +func (sm *SplitterMetrics) SetInstanceCount(total, active int) { + sm.InstancesTotal.Set(float64(total)) + sm.InstancesActive.Set(float64(active)) +} + +func (sm *SplitterMetrics) SetInstanceState(instanceID, state string) { + sm.InstanceState.SetWithLabels(1, "instance_id", instanceID, "state", state) +} + +func (sm *SplitterMetrics) SetInstanceCountry(instanceID, country string) { + sm.InstanceCountry.SetWithLabels(1, "instance_id", instanceID, "country", country) +} + +func (sm *SplitterMetrics) IncCircuits() { + sm.CircuitsTotal.Inc() +} + +func (sm *SplitterMetrics) IncCircuitRenewal(instanceID string) { + sm.CircuitRenewalsTotal.IncWithLabels("instance_id", instanceID) +} + +func (sm *SplitterMetrics) IncErrors() { + sm.ErrorsTotal.Inc() +} + +func (sm *SplitterMetrics) SetBootstrapProgress(instanceID string, progress float64) { + sm.BootstrapProgress.SetWithLabels(progress, "instance_id", instanceID) +} + +func (sm *SplitterMetrics) Registry() *Registry { + return sm.registry +} diff --git a/internal/network/allocator.go b/internal/network/allocator.go new file mode 100644 index 0000000..bd297c3 --- /dev/null +++ b/internal/network/allocator.go @@ -0,0 +1,116 @@ +package network + +import ( + "fmt" + "log/slog" + "net" + "sync" +) + +const maxPortScan = 1000 + +type Allocator struct { + mu sync.Mutex + allocd map[int]struct{} +} + +func NewAllocator() *Allocator { + return &Allocator{ + allocd: make(map[int]struct{}), + } +} + +func (a *Allocator) AllocatePort(preferredPort int) (int, error) { + a.mu.Lock() + defer a.mu.Unlock() + + return a.allocatePortLocked(preferredPort) +} + +func (a *Allocator) allocatePortLocked(preferredPort int) (int, error) { + for offset := 0; offset < maxPortScan; offset++ { + candidate := preferredPort + offset + if candidate > 65535 { + break + } + if _, taken := a.allocd[candidate]; taken { + continue + } + if !isAvailable(candidate) { + slog.Debug("port occupied, skipping", "port", candidate) + continue + } + a.allocd[candidate] = struct{}{} + slog.Debug("allocated port", "port", candidate) + return candidate, nil + } + return 0, fmt.Errorf("AllocatePort: no available port in range %d-%d", preferredPort, preferredPort+maxPortScan) +} + +func (a *Allocator) AllocateN(preferredPort int, count int) (int, error) { + if count <= 0 { + return 0, fmt.Errorf("AllocateN: count must be positive, got %d", count) + } + + a.mu.Lock() + defer a.mu.Unlock() + + for base := preferredPort; base+count-1 <= 65535 && base < preferredPort+maxPortScan; base++ { + allFree := true + for i := 0; i < count; i++ { + p := base + i + if _, taken := a.allocd[p]; taken { + allFree = false + break + } + if !isAvailable(p) { + slog.Debug("port occupied during consecutive scan, skipping", "port", p) + allFree = false + break + } + } + if !allFree { + continue + } + for i := 0; i < count; i++ { + a.allocd[base+i] = struct{}{} + } + slog.Debug("allocated consecutive ports", "start", base, "count", count) + return base, nil + } + return 0, fmt.Errorf("AllocateN: no %d consecutive ports available starting from %d", count, preferredPort) +} + +func (a *Allocator) Release(port int) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.allocd, port) + slog.Debug("released port", "port", port) +} + +func (a *Allocator) ReleaseAll() { + a.mu.Lock() + defer a.mu.Unlock() + a.allocd = make(map[int]struct{}) + slog.Debug("released all ports") +} + +func (a *Allocator) Allocated() []int { + a.mu.Lock() + defer a.mu.Unlock() + + out := make([]int, 0, len(a.allocd)) + for p := range a.allocd { + out = append(out, p) + } + return out +} + +func isAvailable(port int) bool { + l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return false + } + _ = l.Close() + return true +} diff --git a/internal/network/allocator_test.go b/internal/network/allocator_test.go new file mode 100644 index 0000000..afe7d1a --- /dev/null +++ b/internal/network/allocator_test.go @@ -0,0 +1,216 @@ +package network + +import ( + "fmt" + "net" + "sort" + "sync" + "testing" +) + +func TestNewAllocator(t *testing.T) { + a := NewAllocator() + if a == nil { + t.Fatal("NewAllocator returned nil") + } + if got := len(a.Allocated()); got != 0 { + t.Fatalf("expected 0 allocated, got %d", got) + } +} + +func TestAllocatePort(t *testing.T) { + a := NewAllocator() + port, err := a.AllocatePort(50000) + if err != nil { + t.Fatalf("AllocatePort: %v", err) + } + if port < 50000 || port > 50000+maxPortScan { + t.Fatalf("port %d out of expected range", port) + } + allocd := a.Allocated() + if len(allocd) != 1 { + t.Fatalf("expected 1 allocated port, got %d", len(allocd)) + } + if allocd[0] != port { + t.Fatalf("expected allocated port %d, got %d", port, allocd[0]) + } +} + +func TestAllocatePort_doubleAllocate(t *testing.T) { + a := NewAllocator() + p1, err := a.AllocatePort(50000) + if err != nil { + t.Fatalf("first AllocatePort: %v", err) + } + p2, err := a.AllocatePort(50000) + if err != nil { + t.Fatalf("second AllocatePort: %v", err) + } + if p1 == p2 { + t.Fatalf("should not allocate same port twice: %d", p1) + } + allocd := a.Allocated() + if len(allocd) != 2 { + t.Fatalf("expected 2 allocated ports, got %d", len(allocd)) + } +} + +func TestAllocatePort_concurrent(t *testing.T) { + a := NewAllocator() + const n = 50 + ports := make([]int, n) + errs := make([]error, n) + var wg sync.WaitGroup + + for i := 0; i < n; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ports[idx], errs[idx] = a.AllocatePort(45000) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d: %v", i, err) + } + } + + seen := make(map[int]struct{}) + for _, p := range ports { + if _, dup := seen[p]; dup { + t.Fatalf("duplicate port allocated: %d", p) + } + seen[p] = struct{}{} + } + if len(seen) != n { + t.Fatalf("expected %d unique ports, got %d", n, len(seen)) + } +} + +func TestAllocateN(t *testing.T) { + a := NewAllocator() + base, err := a.AllocateN(48000, 3) + if err != nil { + t.Fatalf("AllocateN: %v", err) + } + allocd := a.Allocated() + if len(allocd) != 3 { + t.Fatalf("expected 3 allocated ports, got %d", len(allocd)) + } + sort.Ints(allocd) + for i := 0; i < 3; i++ { + if allocd[i] != base+i { + t.Fatalf("expected port %d, got %d", base+i, allocd[i]) + } + } +} + +func TestAllocateN_concurrent(t *testing.T) { + a := NewAllocator() + const goroutines = 20 + const count = 3 + results := make([]int, goroutines) + errs := make([]error, goroutines) + var wg sync.WaitGroup + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = a.AllocateN(42000, count) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("goroutine %d: %v", i, err) + } + } + + allPorts := make(map[int]struct{}) + for _, base := range results { + for j := 0; j < count; j++ { + p := base + j + if _, dup := allPorts[p]; dup { + t.Fatalf("duplicate port in consecutive range: %d", p) + } + allPorts[p] = struct{}{} + } + } + if len(allPorts) != goroutines*count { + t.Fatalf("expected %d unique ports, got %d", goroutines*count, len(allPorts)) + } +} + +func TestAllocateN_invalidCount(t *testing.T) { + a := NewAllocator() + _, err := a.AllocateN(50000, 0) + if err == nil { + t.Fatal("expected error for count=0") + } + _, err = a.AllocateN(50000, -1) + if err == nil { + t.Fatal("expected error for negative count") + } +} + +func TestRelease(t *testing.T) { + a := NewAllocator() + port, err := a.AllocatePort(49000) + if err != nil { + t.Fatalf("AllocatePort: %v", err) + } + a.Release(port) + if len(a.Allocated()) != 0 { + t.Fatal("expected 0 allocated after release") + } + p2, err := a.AllocatePort(port) + if err != nil { + t.Fatalf("re-allocate after release: %v", err) + } + if p2 != port { + t.Fatalf("expected re-allocation of same port %d, got %d", port, p2) + } +} + +func TestReleaseAll(t *testing.T) { + a := NewAllocator() + for i := 0; i < 5; i++ { + _, err := a.AllocatePort(47000) + if err != nil { + t.Fatalf("AllocatePort %d: %v", i, err) + } + } + if len(a.Allocated()) != 5 { + t.Fatalf("expected 5 allocated, got %d", len(a.Allocated())) + } + a.ReleaseAll() + if len(a.Allocated()) != 0 { + t.Fatalf("expected 0 after ReleaseAll, got %d", len(a.Allocated())) + } +} + +func TestAllocatePort_skipOccupied(t *testing.T) { + a := NewAllocator() + blockPort := 46500 + + l, err := net.Listen("tcp", fmt.Sprintf(":%d", blockPort)) + if err != nil { + t.Skipf("cannot bind port %d: %v", blockPort, err) + } + defer func() { _ = l.Close() }() + + port, err := a.AllocatePort(blockPort) + if err != nil { + t.Fatalf("AllocatePort with occupied start: %v", err) + } + if port == blockPort { + t.Fatalf("should have skipped occupied port %d", blockPort) + } + if port < blockPort || port > blockPort+maxPortScan { + t.Fatalf("port %d out of expected range", port) + } +} diff --git a/internal/network/doc.go b/internal/network/doc.go new file mode 100644 index 0000000..188ab3e --- /dev/null +++ b/internal/network/doc.go @@ -0,0 +1,2 @@ +// Package network handles port allocation via net.Listen and IPv4/IPv6 detection for SPLITTER. +package network diff --git a/internal/process/cleanup.go b/internal/process/cleanup.go new file mode 100644 index 0000000..bc31a50 --- /dev/null +++ b/internal/process/cleanup.go @@ -0,0 +1,16 @@ +package process + +import "os" + +func (m *Manager) Cleanup() error { + if m.tmpDir == "" { + return nil + } + + err := os.RemoveAll(m.tmpDir) + if err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} diff --git a/internal/process/doc.go b/internal/process/doc.go new file mode 100644 index 0000000..5b9c795 --- /dev/null +++ b/internal/process/doc.go @@ -0,0 +1,2 @@ +// Package process manages child process lifecycle including spawning, graceful shutdown, and cleanup for SPLITTER. +package process diff --git a/internal/process/integration_test.go b/internal/process/integration_test.go new file mode 100644 index 0000000..1abab07 --- /dev/null +++ b/internal/process/integration_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package process + +import ( + "context" + "testing" + "time" +) + +func TestIntegration_SpawnAndWait(t *testing.T) { + mgr := NewManager(t.TempDir()) + ctx := context.Background() + + p, err := mgr.Spawn(ctx, "sleep", "/bin/sleep", "1") + if err != nil { + t.Fatalf("Spawn: %v", err) + } + + if err := p.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } + + if p.State() != StateStopped { + t.Errorf("State = %v, want Stopped", p.State()) + } +} + +func TestIntegration_GracefulShutdown(t *testing.T) { + mgr := NewManager(t.TempDir()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + p, err := mgr.Spawn(ctx, "sleep", "/bin/sleep", "60") + if err != nil { + t.Fatalf("Spawn: %v", err) + } + + done := make(chan error, 1) + go func() { done <- p.Wait() }() + + time.Sleep(100 * time.Millisecond) + + if p.State() != StateRunning { + t.Fatalf("State = %v, want Running before Stop", p.State()) + } + + if err := mgr.Stop(ctx, p); err != nil { + t.Fatalf("Stop: %v", err) + } + + if p.State() != StateStopped { + t.Errorf("State = %v, want Stopped after Stop", p.State()) + } +} + +func TestIntegration_StopAll(t *testing.T) { + mgr := NewManager(t.TempDir()) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + p1, err := mgr.Spawn(ctx, "sleep-1", "/bin/sleep", "60") + if err != nil { + t.Fatalf("Spawn sleep-1: %v", err) + } + p2, err := mgr.Spawn(ctx, "sleep-2", "/bin/sleep", "60") + if err != nil { + t.Fatalf("Spawn sleep-2: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + if err := mgr.StopAll(ctx); err != nil { + t.Fatalf("StopAll: %v", err) + } + + for i, p := range []*Process{p1, p2} { + if p.State() != StateStopped { + t.Errorf("process[%d] State = %v, want Stopped", i, p.State()) + } + } +} diff --git a/internal/process/manager.go b/internal/process/manager.go new file mode 100644 index 0000000..37b7796 --- /dev/null +++ b/internal/process/manager.go @@ -0,0 +1,98 @@ +package process + +import ( + "fmt" + "os/exec" + "sync" +) + +type ProcessState int + +const ( + StateStarting ProcessState = iota + StateRunning + StateStopped + StateFailed +) + +func (s ProcessState) String() string { + switch s { + case StateStarting: + return "starting" + case StateRunning: + return "running" + case StateStopped: + return "stopped" + case StateFailed: + return "failed" + default: + return "unknown" + } +} + +type Process struct { + Name string + Path string + Args []string + + mu sync.Mutex + cmd *exec.Cmd + state ProcessState + done chan struct{} + wait error +} + +func (p *Process) State() ProcessState { + p.mu.Lock() + defer p.mu.Unlock() + return p.state +} + +func (p *Process) Pid() int { + p.mu.Lock() + defer p.mu.Unlock() + if p.cmd != nil && p.cmd.Process != nil { + return p.cmd.Process.Pid + } + return 0 +} + +func (p *Process) Wait() error { + <-p.done + p.mu.Lock() + defer p.mu.Unlock() + return p.wait +} + +type Manager struct { + mu sync.Mutex + processes []*Process + tmpDir string +} + +func NewManager(tmpDir string) *Manager { + return &Manager{ + tmpDir: tmpDir, + } +} + +func (m *Manager) List() []*Process { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*Process, len(m.processes)) + copy(out, m.processes) + return out +} + +func (m *Manager) add(p *Process) { + m.mu.Lock() + m.processes = append(m.processes, p) + m.mu.Unlock() +} + +func (m *Manager) Wait(p *Process) error { + if p == nil { + return fmt.Errorf("Wait: nil process") + } + return p.Wait() +} diff --git a/internal/process/manager_test.go b/internal/process/manager_test.go new file mode 100644 index 0000000..4f907f4 --- /dev/null +++ b/internal/process/manager_test.go @@ -0,0 +1,238 @@ +package process + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSpawn_Success(t *testing.T) { + m := NewManager(t.TempDir()) + defer func() { _ = m.StopAll(t.Context()) }() + + p, err := m.Spawn(t.Context(), "test-sleep", "sleep", "10") + if err != nil { + t.Fatalf("Spawn() error = %v", err) + } + + if p.State() != StateRunning { + t.Errorf("State() = %v, want StateRunning", p.State()) + } + + if p.Pid() <= 0 { + t.Errorf("Pid() = %d, want > 0", p.Pid()) + } + + if p.Name != "test-sleep" { + t.Errorf("Name = %q, want %q", p.Name, "test-sleep") + } +} + +func TestSpawn_InvalidBinary(t *testing.T) { + m := NewManager("") + + _, err := m.Spawn(t.Context(), "bad", "/nonexistent/binary_xyz") + if err == nil { + t.Fatal("Spawn() expected error for invalid binary, got nil") + } +} + +func TestSpawn_CancelledContext(t *testing.T) { + m := NewManager("") + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := m.Spawn(ctx, "cancelled", "sleep", "10") + if err == nil { + t.Fatal("Spawn() expected error with cancelled context, got nil") + } +} + +func TestStop_Graceful(t *testing.T) { + m := NewManager(t.TempDir()) + + p, err := m.Spawn(t.Context(), "graceful", "sleep", "60") + if err != nil { + t.Fatalf("Spawn() error = %v", err) + } + + start := time.Now() + if err := m.Stop(t.Context(), p); err != nil { + t.Fatalf("Stop() error = %v", err) + } + elapsed := time.Since(start) + + if elapsed > 6*time.Second { + t.Errorf("Stop() took %v, expected < 6s (graceful SIGTERM)", elapsed) + } + + if p.State() != StateStopped { + t.Errorf("State() = %v, want StateStopped", p.State()) + } +} + +func TestStop_AlreadyStopped(t *testing.T) { + m := NewManager(t.TempDir()) + + p, err := m.Spawn(t.Context(), "quick", "true") + if err != nil { + t.Fatalf("Spawn() error = %v", err) + } + + if err := p.Wait(); err != nil { + t.Fatalf("Wait() error = %v", err) + } + + if err := m.Stop(t.Context(), p); err != nil { + t.Fatalf("Stop() on already-stopped process returned error: %v", err) + } +} + +func TestStop_Nil(t *testing.T) { + m := NewManager("") + if err := m.Stop(t.Context(), nil); err != nil { + t.Fatalf("Stop(nil) returned error: %v", err) + } +} + +func TestStopAll(t *testing.T) { + m := NewManager(t.TempDir()) + + p1, err := m.Spawn(t.Context(), "s1", "sleep", "60") + if err != nil { + t.Fatalf("Spawn s1 error = %v", err) + } + p2, err := m.Spawn(t.Context(), "s2", "sleep", "60") + if err != nil { + t.Fatalf("Spawn s2 error = %v", err) + } + p3, err := m.Spawn(t.Context(), "s3", "sleep", "60") + if err != nil { + t.Fatalf("Spawn s3 error = %v", err) + } + + if err := m.StopAll(t.Context()); err != nil { + t.Fatalf("StopAll() error = %v", err) + } + + for i, p := range [](*Process){p1, p2, p3} { + if p.State() != StateStopped { + t.Errorf("process[%d] State() = %v, want StateStopped", i, p.State()) + } + } +} + +func TestStop_SIGKILLTimeout(t *testing.T) { + m := NewManager(t.TempDir()) + + p, err := m.Spawn(t.Context(), "sigterm-proof", + "sh", "-c", "trap '' TERM; while true; do sleep 60; done") + if err != nil { + t.Fatalf("Spawn() error = %v", err) + } + + time.Sleep(200 * time.Millisecond) + + start := time.Now() + if err := m.Stop(t.Context(), p); err != nil { + t.Fatalf("Stop() error = %v", err) + } + elapsed := time.Since(start) + + if elapsed < gracefulShutdownTimeout { + t.Errorf("Stop() took %v, expected >= %v (SIGKILL escalation)", elapsed, gracefulShutdownTimeout) + } + + if elapsed > gracefulShutdownTimeout+3*time.Second { + t.Errorf("Stop() took %v, expected < %v", elapsed, gracefulShutdownTimeout+3*time.Second) + } + + if p.State() != StateStopped { + t.Errorf("State() = %v, want StateStopped", p.State()) + } +} + +func TestCleanup(t *testing.T) { + dir, err := os.MkdirTemp("", "splitter-cleanup-test-*") + if err != nil { + t.Fatal(err) + } + + testFile := filepath.Join(dir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + m := NewManager(dir) + if err := m.Cleanup(); err != nil { + t.Fatalf("Cleanup() error = %v", err) + } + + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("expected temp dir %q to be removed", dir) + } +} + +func TestCleanup_EmptyDir(t *testing.T) { + m := NewManager("") + if err := m.Cleanup(); err != nil { + t.Fatalf("Cleanup() with empty dir returned error: %v", err) + } +} + +func TestCleanup_NonexistentDir(t *testing.T) { + m := NewManager("/tmp/splitter-nonexistent-dir-xyz-999") + if err := m.Cleanup(); err != nil { + t.Fatalf("Cleanup() with nonexistent dir returned error: %v", err) + } +} + +func TestList(t *testing.T) { + m := NewManager(t.TempDir()) + defer func() { _ = m.StopAll(t.Context()) }() + + if procs := m.List(); len(procs) != 0 { + t.Errorf("List() returned %d processes, want 0", len(procs)) + } + + _, err := m.Spawn(t.Context(), "s1", "sleep", "10") + if err != nil { + t.Fatal(err) + } + _, err = m.Spawn(t.Context(), "s2", "sleep", "10") + if err != nil { + t.Fatal(err) + } + + procs := m.List() + if len(procs) != 2 { + t.Errorf("List() returned %d processes, want 2", len(procs)) + } +} + +func TestWait(t *testing.T) { + m := NewManager(t.TempDir()) + + p, err := m.Spawn(t.Context(), "quick-exit", "true") + if err != nil { + t.Fatalf("Spawn() error = %v", err) + } + + if err := m.Wait(p); err != nil { + t.Fatalf("Wait() error = %v", err) + } + + if p.State() != StateStopped { + t.Errorf("State() = %v, want StateStopped", p.State()) + } +} + +func TestWait_Nil(t *testing.T) { + m := NewManager("") + if err := m.Wait(nil); err == nil { + t.Fatal("Wait(nil) expected error, got nil") + } +} diff --git a/internal/process/shutdown.go b/internal/process/shutdown.go new file mode 100644 index 0000000..2ca4dfe --- /dev/null +++ b/internal/process/shutdown.go @@ -0,0 +1,84 @@ +package process + +import ( + "context" + "fmt" + "sync" + "syscall" + "time" +) + +const gracefulShutdownTimeout = 5 * time.Second + +func (m *Manager) Stop(ctx context.Context, p *Process) error { + if p == nil { + return nil + } + + p.mu.Lock() + if p.state != StateRunning { + p.mu.Unlock() + <-p.done + return nil + } + p.state = StateStopped + p.mu.Unlock() + + if p.cmd == nil || p.cmd.Process == nil { + return nil + } + + pid := p.cmd.Process.Pid + + _ = syscall.Kill(-pid, syscall.SIGTERM) + + select { + case <-p.done: + return nil + case <-time.After(gracefulShutdownTimeout): + _ = syscall.Kill(-pid, syscall.SIGKILL) + case <-ctx.Done(): + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + + <-p.done + + return nil +} + +func (m *Manager) StopAll(ctx context.Context) error { + m.mu.Lock() + processes := make([]*Process, len(m.processes)) + copy(processes, m.processes) + m.mu.Unlock() + + var wg sync.WaitGroup + errCh := make(chan error, len(processes)) + + for _, p := range processes { + wg.Add(1) + go func(proc *Process) { + defer wg.Done() + if err := m.Stop(ctx, proc); err != nil { + select { + case errCh <- err: + default: + } + } + }(p) + } + + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + + if len(errs) > 0 { + return fmt.Errorf("StopAll: %d process(es) failed: %w", len(errs), errs[0]) + } + + return nil +} diff --git a/internal/process/spawn.go b/internal/process/spawn.go new file mode 100644 index 0000000..7a96840 --- /dev/null +++ b/internal/process/spawn.go @@ -0,0 +1,58 @@ +package process + +import ( + "context" + "fmt" + "io" + "os/exec" + "syscall" +) + +func (m *Manager) Spawn(ctx context.Context, name, binary string, args ...string) (*Process, error) { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("Spawn: %w", ctx.Err()) + default: + } + + cmd := exec.Command(binary, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + p := &Process{ + Name: name, + Path: binary, + Args: args, + cmd: cmd, + state: StateStarting, + done: make(chan struct{}), + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("Spawn: %w", err) + } + + p.mu.Lock() + p.state = StateRunning + p.mu.Unlock() + + m.add(p) + + go func() { + waitErr := cmd.Wait() + p.mu.Lock() + p.wait = waitErr + if p.state == StateRunning { + if waitErr != nil { + p.state = StateFailed + } else { + p.state = StateStopped + } + } + p.mu.Unlock() + close(p.done) + }() + + return p, nil +} diff --git a/internal/profile/doc.go b/internal/profile/doc.go new file mode 100644 index 0000000..73330f1 --- /dev/null +++ b/internal/profile/doc.go @@ -0,0 +1 @@ +package profile diff --git a/internal/profile/loader.go b/internal/profile/loader.go new file mode 100644 index 0000000..54961b4 --- /dev/null +++ b/internal/profile/loader.go @@ -0,0 +1,22 @@ +package profile + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +func Load(path string) (map[string]*Profile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("Load: reading %s: %w", path, err) + } + + var profiles map[string]*Profile + if err := yaml.Unmarshal(data, &profiles); err != nil { + return nil, fmt.Errorf("Load: parsing %s: %w", path, err) + } + + return profiles, nil +} diff --git a/internal/profile/loader_test.go b/internal/profile/loader_test.go new file mode 100644 index 0000000..a943052 --- /dev/null +++ b/internal/profile/loader_test.go @@ -0,0 +1,102 @@ +package profile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_ValidYAML(t *testing.T) { + yaml := ` +stealth: + description: "test stealth" + instances: + per_country: 3 + tor: + conflux_enabled: true +` + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "profiles.yaml") + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + profiles, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + p, ok := profiles["stealth"] + if !ok { + t.Fatal("stealth profile not found") + } + if p.Description != "test stealth" { + t.Errorf("Description = %q, want %q", p.Description, "test stealth") + } + if p.Tor.ConfluxEnabled == nil || !*p.Tor.ConfluxEnabled { + t.Error("conflux_enabled should be true") + } +} + +func TestLoad_MissingFile(t *testing.T) { + _, err := Load("/nonexistent/profiles.yaml") + if err == nil { + t.Error("Load() expected error for missing file, got nil") + } +} + +func TestLoad_MalformedYAML(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "profiles.yaml") + if err := os.WriteFile(path, []byte("stealth: [broken yaml\n"), 0644); err != nil { + t.Fatal(err) + } + + _, err := Load(path) + if err == nil { + t.Error("Load() expected error for malformed YAML, got nil") + } +} + +func TestLoad_AllProfilesFromProject(t *testing.T) { + profiles, err := Load(filepath.Join("..", "..", "configs", "profiles.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + for _, name := range ValidProfiles { + p, ok := profiles[name] + if !ok { + t.Errorf("profile %q not found", name) + continue + } + if p.Description == "" { + t.Errorf("profile %q has empty description", name) + } + if p.Instances.PerCountry == nil || *p.Instances.PerCountry <= 0 { + t.Errorf("profile %q has invalid per_country", name) + } + if p.Instances.Countries == nil || *p.Instances.Countries <= 0 { + t.Errorf("profile %q has invalid countries", name) + } + if p.Relay.Enforce == nil { + t.Errorf("profile %q has nil relay.enforce", name) + } + } +} + +func TestLoad_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "profiles.yaml") + if err := os.WriteFile(path, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + profiles, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(profiles) != 0 { + t.Errorf("expected 0 profiles from empty file, got %d", len(profiles)) + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..ccfee1d --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,77 @@ +package profile + +const ( + Stealth = "stealth" + Balanced = "balanced" + Streaming = "streaming" + Pentest = "pentest" +) + +var ValidProfiles = []string{Stealth, Balanced, Streaming, Pentest} + +type Profile struct { + Description string `yaml:"description"` + Instances ProfileInstances `yaml:"instances"` + Relay ProfileRelay `yaml:"relay"` + Proxy ProfileProxy `yaml:"proxy"` + Tor ProfileTor `yaml:"tor"` + Logging ProfileLogging `yaml:"logging"` + Country ProfileCountry `yaml:"country"` + HealthCheck ProfileHealthCheck `yaml:"health_check"` +} + +type ProfileInstances struct { + PerCountry *int `yaml:"per_country"` + Countries *int `yaml:"countries"` + MaxConcurrentRequests *int `yaml:"max_concurrent_requests"` + Retries *int `yaml:"retries"` +} + +type ProfileRelay struct { + Enforce *string `yaml:"enforce"` +} + +type ProfileProxy struct { + LoadBalanceAlgorithm *string `yaml:"load_balance_algorithm"` + HAProxyHTTPReuse *string `yaml:"haproxy_http_reuse"` +} + +type ProfileTor struct { + MaxCircuitDirtiness *int `yaml:"max_circuit_dirtiness"` + ConnectionPadding *int `yaml:"connection_padding"` + UseEntryGuards *int `yaml:"use_entry_guards"` + ReducedConnectionPadding *int `yaml:"reduced_connection_padding"` + StreamIsolation *bool `yaml:"stream_isolation"` + IPv6 *bool `yaml:"ipv6"` + ConfluxEnabled *bool `yaml:"conflux_enabled"` + CongestionControlAuto *bool `yaml:"congestion_control_auto"` + Sandbox *bool `yaml:"sandbox"` + CircuitFingerprintingResistance *bool `yaml:"circuit_fingerprinting_resistance"` +} + +type ProfileLogging struct { + Enabled *bool `yaml:"enabled"` + Level *string `yaml:"level"` +} + +type ProfileCountry struct { + RotationInterval *int `yaml:"rotation_interval"` + TotalToChange *int `yaml:"total_to_change"` +} + +type ProfileHealthCheck struct { + ExitReputation *bool `yaml:"exit_reputation"` +} + +func IsValid(name string) bool { + for _, p := range ValidProfiles { + if name == p { + return true + } + } + return false +} + +func Names() []string { + return append([]string{}, ValidProfiles...) +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..98cd34a --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,310 @@ +package profile + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConstants(t *testing.T) { + if Stealth != "stealth" { + t.Errorf("Stealth = %q, want %q", Stealth, "stealth") + } + if Balanced != "balanced" { + t.Errorf("Balanced = %q, want %q", Balanced, "balanced") + } + if Streaming != "streaming" { + t.Errorf("Streaming = %q, want %q", Streaming, "streaming") + } + if Pentest != "pentest" { + t.Errorf("Pentest = %q, want %q", Pentest, "pentest") + } +} + +func TestNames(t *testing.T) { + names := Names() + if len(names) != 4 { + t.Fatalf("Names() returned %d names, want 4", len(names)) + } + expected := []string{"stealth", "balanced", "streaming", "pentest"} + for i, e := range expected { + if names[i] != e { + t.Errorf("Names()[%d] = %q, want %q", i, names[i], e) + } + } +} + +func TestNames_ReturnsCopy(t *testing.T) { + n1 := Names() + n1[0] = "mutated" + n2 := Names() + if n2[0] == "mutated" { + t.Error("Names() should return a copy, not the original slice") + } +} + +func TestIsValid(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"stealth", true}, + {"balanced", true}, + {"streaming", true}, + {"pentest", true}, + {"", false}, + {"unknown", false}, + {"Stealth", false}, + {"BALANCED", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValid(tt.name); got != tt.want { + t.Errorf("IsValid(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestValidProfiles(t *testing.T) { + if len(ValidProfiles) != 4 { + t.Errorf("ValidProfiles has %d entries, want 4", len(ValidProfiles)) + } +} + +func TestLoad_FromActualProfilesFile(t *testing.T) { + profiles, err := Load(filepath.Join("..", "..", "configs", "profiles.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + for _, name := range ValidProfiles { + p, ok := profiles[name] + if !ok { + t.Errorf("profile %q not found in profiles.yaml", name) + continue + } + if p.Description == "" { + t.Errorf("profile %q has empty description", name) + } + } +} + +func TestLoad_StealthProfileFields(t *testing.T) { + profiles, err := Load(filepath.Join("..", "..", "configs", "profiles.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + s := profiles["stealth"] + if s == nil { + t.Fatal("stealth profile is nil") + } + + if s.Instances.PerCountry == nil || *s.Instances.PerCountry != 3 { + t.Error("stealth instances.per_country should be 3") + } + if s.Instances.Countries == nil || *s.Instances.Countries != 8 { + t.Error("stealth instances.countries should be 8") + } + if s.Tor.ConfluxEnabled == nil || !*s.Tor.ConfluxEnabled { + t.Error("stealth tor.conflux_enabled should be true") + } + if s.Tor.CongestionControlAuto == nil || !*s.Tor.CongestionControlAuto { + t.Error("stealth tor.congestion_control_auto should be true") + } + if s.Tor.Sandbox == nil || !*s.Tor.Sandbox { + t.Error("stealth tor.sandbox should be true") + } + if s.Tor.CircuitFingerprintingResistance == nil || !*s.Tor.CircuitFingerprintingResistance { + t.Error("stealth tor.circuit_fingerprinting_resistance should be true") + } + if s.Country.RotationInterval == nil || *s.Country.RotationInterval != 60 { + t.Error("stealth country.rotation_interval should be 60") + } + if s.Country.TotalToChange == nil || *s.Country.TotalToChange != 5 { + t.Error("stealth country.total_to_change should be 5") + } +} + +func TestLoad_BalancedProfileFields(t *testing.T) { + profiles, err := Load(filepath.Join("..", "..", "configs", "profiles.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + b := profiles["balanced"] + if b == nil { + t.Fatal("balanced profile is nil") + } + if b.Tor.ConfluxEnabled == nil || *b.Tor.ConfluxEnabled { + t.Error("balanced tor.conflux_enabled should be false") + } + if b.Tor.CongestionControlAuto == nil || !*b.Tor.CongestionControlAuto { + t.Error("balanced tor.congestion_control_auto should be true") + } + if b.Tor.Sandbox == nil || *b.Tor.Sandbox { + t.Error("balanced tor.sandbox should be false") + } +} + +func TestLoad_StreamingProfileFields(t *testing.T) { + profiles, err := Load(filepath.Join("..", "..", "configs", "profiles.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + s := profiles["streaming"] + if s == nil { + t.Fatal("streaming profile is nil") + } + if s.Tor.ConfluxEnabled == nil || !*s.Tor.ConfluxEnabled { + t.Error("streaming tor.conflux_enabled should be true") + } + if s.Tor.CongestionControlAuto == nil || !*s.Tor.CongestionControlAuto { + t.Error("streaming tor.congestion_control_auto should be true") + } + if s.Tor.IPv6 == nil || !*s.Tor.IPv6 { + t.Error("streaming tor.ipv6 should be true") + } + if s.Tor.Sandbox == nil || *s.Tor.Sandbox { + t.Error("streaming tor.sandbox should be false") + } +} + +func TestLoad_PentestProfileFields(t *testing.T) { + profiles, err := Load(filepath.Join("..", "..", "configs", "profiles.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + p := profiles["pentest"] + if p == nil { + t.Fatal("pentest profile is nil") + } + if p.Tor.ConfluxEnabled == nil || *p.Tor.ConfluxEnabled { + t.Error("pentest tor.conflux_enabled should be false") + } + if p.Tor.CongestionControlAuto == nil || *p.Tor.CongestionControlAuto { + t.Error("pentest tor.congestion_control_auto should be false") + } + if p.Tor.StreamIsolation == nil || !*p.Tor.StreamIsolation { + t.Error("pentest tor.stream_isolation should be true") + } + if p.Tor.CircuitFingerprintingResistance == nil || !*p.Tor.CircuitFingerprintingResistance { + t.Error("pentest tor.circuit_fingerprinting_resistance should be true") + } + if p.HealthCheck.ExitReputation == nil || !*p.HealthCheck.ExitReputation { + t.Error("pentest health_check.exit_reputation should be true") + } + if p.Country.RotationInterval == nil || *p.Country.RotationInterval != 60 { + t.Error("pentest country.rotation_interval should be 60") + } +} + +func TestProfileStruct_FieldParsing(t *testing.T) { + yaml := ` +testprof: + description: "test profile" + instances: + per_country: 7 + countries: 3 + relay: + enforce: "exit" + proxy: + load_balance_algorithm: "leastconn" + tor: + max_circuit_dirtiness: 42 + connection_padding: 1 + use_entry_guards: 0 + reduced_connection_padding: 0 + stream_isolation: true + ipv6: true + conflux_enabled: true + congestion_control_auto: true + sandbox: true + circuit_fingerprinting_resistance: true + logging: + enabled: true + level: "DEBUG" + country: + rotation_interval: 90 + total_to_change: 3 + health_check: + exit_reputation: true +` + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "profiles.yaml") + if err := os.WriteFile(path, []byte(yaml), 0644); err != nil { + t.Fatal(err) + } + + profiles, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + p, ok := profiles["testprof"] + if !ok { + t.Fatal("testprof profile not found") + } + + if p.Description != "test profile" { + t.Errorf("Description = %q, want %q", p.Description, "test profile") + } + if p.Instances.PerCountry == nil || *p.Instances.PerCountry != 7 { + t.Error("per_country should be 7") + } + if p.Instances.Countries == nil || *p.Instances.Countries != 3 { + t.Error("countries should be 3") + } + if p.Relay.Enforce == nil || *p.Relay.Enforce != "exit" { + t.Error("relay.enforce should be exit") + } + if p.Proxy.LoadBalanceAlgorithm == nil || *p.Proxy.LoadBalanceAlgorithm != "leastconn" { + t.Error("proxy.load_balance_algorithm should be leastconn") + } + if p.Tor.MaxCircuitDirtiness == nil || *p.Tor.MaxCircuitDirtiness != 42 { + t.Error("tor.max_circuit_dirtiness should be 42") + } + if p.Tor.ConnectionPadding == nil || *p.Tor.ConnectionPadding != 1 { + t.Error("tor.connection_padding should be 1") + } + if p.Tor.UseEntryGuards == nil || *p.Tor.UseEntryGuards != 0 { + t.Error("tor.use_entry_guards should be 0") + } + if p.Tor.StreamIsolation == nil || !*p.Tor.StreamIsolation { + t.Error("tor.stream_isolation should be true") + } + if p.Tor.IPv6 == nil || !*p.Tor.IPv6 { + t.Error("tor.ipv6 should be true") + } + if p.Tor.ConfluxEnabled == nil || !*p.Tor.ConfluxEnabled { + t.Error("tor.conflux_enabled should be true") + } + if p.Tor.CongestionControlAuto == nil || !*p.Tor.CongestionControlAuto { + t.Error("tor.congestion_control_auto should be true") + } + if p.Tor.Sandbox == nil || !*p.Tor.Sandbox { + t.Error("tor.sandbox should be true") + } + if p.Tor.CircuitFingerprintingResistance == nil || !*p.Tor.CircuitFingerprintingResistance { + t.Error("tor.circuit_fingerprinting_resistance should be true") + } + if p.Logging.Enabled == nil || !*p.Logging.Enabled { + t.Error("logging.enabled should be true") + } + if p.Logging.Level == nil || *p.Logging.Level != "DEBUG" { + t.Error("logging.level should be DEBUG") + } + if p.Country.RotationInterval == nil || *p.Country.RotationInterval != 90 { + t.Error("country.rotation_interval should be 90") + } + if p.Country.TotalToChange == nil || *p.Country.TotalToChange != 3 { + t.Error("country.total_to_change should be 3") + } + if p.HealthCheck.ExitReputation == nil || !*p.HealthCheck.ExitReputation { + t.Error("health_check.exit_reputation should be true") + } +} diff --git a/internal/proxy/doc.go b/internal/proxy/doc.go new file mode 100644 index 0000000..2feb4eb --- /dev/null +++ b/internal/proxy/doc.go @@ -0,0 +1,2 @@ +// Package proxy provides the HTTP proxy abstraction layer supporting both native Tor HTTPTunnelPort and legacy Privoxy for SPLITTER. +package proxy diff --git a/internal/proxy/legacy.go b/internal/proxy/legacy.go new file mode 100644 index 0000000..e4bce2d --- /dev/null +++ b/internal/proxy/legacy.go @@ -0,0 +1,139 @@ +package proxy + +import ( + "bytes" + "context" + "fmt" + "os" + "text/template" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +type LegacyProxy struct { + cfg *config.Config + procMgr *process.Manager + ports []int + procs []*process.Process +} + +type privoxyConfigData struct { + InstanceID int + ListenAddr string + Port int + SocksPort int +} + +const defaultPrivoxyTmpl = `# SPLITTER Privoxy Config - Instance {{.InstanceID}} +# Generated automatically - do not edit + +listen-address {{.ListenAddr}}:{{.Port}} +forward-socks5t / 127.0.0.1:{{.SocksPort}} . +forward 168.192.0.0/16 . +forward 10.0.0.0/8 . +forward 172.16.0.0/12 . +forward 192.168.0.0/16 . +forward 127.0.0.0/8 . +forward 0.0.0.0/8 . +forward 169.254.0.0/16 . + +# Security +toggle 1 +enable-remote-toggle 0 +enable-edit-actions 0 +enforce-blocks 1 + +# Logging (off by default) +logfile /dev/null + +# Misc +buffer-limit 4096 +` + +func (p *LegacyProxy) Setup(_ context.Context, instances []Instance) ([]int, error) { + tmpl, err := template.New("privoxy").Parse(defaultPrivoxyTmpl) + if err != nil { + return nil, fmt.Errorf("Setup: parse template: %w", err) + } + + p.ports = make([]int, len(instances)) + + for i, inst := range instances { + privoxyPort := p.cfg.Privoxy.StartPort + inst.ID + p.ports[i] = privoxyPort + + data := privoxyConfigData{ + InstanceID: inst.ID, + ListenAddr: p.cfg.Privoxy.Listen, + Port: privoxyPort, + SocksPort: inst.SocksPort, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("Setup: render config for instance %d: %w", inst.ID, err) + } + + configPath := fmt.Sprintf("%s%d.cfg", p.cfg.Privoxy.ConfigFilePrefix, inst.ID) + if err := os.WriteFile(configPath, buf.Bytes(), 0600); err != nil { + return nil, fmt.Errorf("Setup: write config for instance %d: %w", inst.ID, err) + } + } + + return p.ports, nil +} + +func (p *LegacyProxy) Start(ctx context.Context) error { + if len(p.ports) == 0 { + return nil + } + + p.procs = make([]*process.Process, len(p.ports)) + + for i := range p.ports { + configPath := fmt.Sprintf("%s%d.cfg", p.cfg.Privoxy.ConfigFilePrefix, i) + name := fmt.Sprintf("privoxy-%d", i) + + proc, err := p.procMgr.Spawn(ctx, name, p.cfg.Privoxy.BinaryPath, configPath) + if err != nil { + for j := 0; j < i; j++ { + _ = p.procMgr.Stop(ctx, p.procs[j]) + } + return fmt.Errorf("Start: spawn privoxy instance %d: %w", i, err) + } + p.procs[i] = proc + } + + return nil +} + +func (p *LegacyProxy) Stop(ctx context.Context) error { + var firstErr error + for i, proc := range p.procs { + if proc == nil { + continue + } + if err := p.procMgr.Stop(ctx, proc); err != nil && firstErr == nil { + firstErr = fmt.Errorf("Stop: privoxy instance %d: %w", i, err) + } + } + p.procs = nil + return firstErr +} + +func (p *LegacyProxy) Mode() Mode { + return ModeLegacy +} + +func RenderPrivoxyConfig(data privoxyConfigData, tmplStr string) (string, error) { + tmpl, err := template.New("privoxy").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("RenderPrivoxyConfig: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("RenderPrivoxyConfig: execute: %w", err) + } + return buf.String(), nil +} diff --git a/internal/proxy/native.go b/internal/proxy/native.go new file mode 100644 index 0000000..369d87b --- /dev/null +++ b/internal/proxy/native.go @@ -0,0 +1,31 @@ +package proxy + +import ( + "context" + "fmt" +) + +type NativeProxy struct{} + +func (p *NativeProxy) Setup(_ context.Context, instances []Instance) ([]int, error) { + ports := make([]int, len(instances)) + for i, inst := range instances { + if inst.HTTPPort <= 0 { + return nil, fmt.Errorf("Setup: instance %d has no HTTPTunnelPort (HTTPPort=%d); use --proxy-mode legacy instead", inst.ID, inst.HTTPPort) + } + ports[i] = inst.HTTPPort + } + return ports, nil +} + +func (p *NativeProxy) Start(_ context.Context) error { + return nil +} + +func (p *NativeProxy) Stop(_ context.Context) error { + return nil +} + +func (p *NativeProxy) Mode() Mode { + return ModeNative +} diff --git a/internal/proxy/privoxy_template_test.go b/internal/proxy/privoxy_template_test.go new file mode 100644 index 0000000..c261c32 --- /dev/null +++ b/internal/proxy/privoxy_template_test.go @@ -0,0 +1,129 @@ +package proxy + +import ( + "fmt" + "strings" + "testing" + "text/template" +) + +func TestPrivoxyTemplateFile_ExistsAndParses(t *testing.T) { + tmpl, err := template.ParseFiles("../../templates/privoxy.cfg.gotmpl") + if err != nil { + t.Fatalf("template parse error: %v", err) + } + if tmpl == nil { + t.Fatal("parsed template is nil") + } +} + +func TestPrivoxyTemplateFile_RendersCorrectly(t *testing.T) { + tmplStr := readFile(t, "../../templates/privoxy.cfg.gotmpl") + + data := privoxyConfigData{ + InstanceID: 0, + ListenAddr: "127.0.0.1", + Port: 8118, + SocksPort: 9050, + } + + result, err := RenderPrivoxyConfig(data, tmplStr) + if err != nil { + t.Fatalf("RenderPrivoxyConfig() error = %v", err) + } + + privoxyTplContains(t, result, "listen-address 127.0.0.1:8118") + privoxyTplContains(t, result, "forward-socks5t / 127.0.0.1:9050 .") + privoxyTplContains(t, result, "toggle 1") + privoxyTplContains(t, result, "buffer-limit 4096") +} + +func TestPrivoxyTemplateFile_MultipleInstances(t *testing.T) { + tmplStr := readFile(t, "../../templates/privoxy.cfg.gotmpl") + + tests := []struct { + id int + addr string + port int + socksPort int + }{ + {0, "127.0.0.1", 8118, 9050}, + {1, "127.0.0.1", 8119, 9051}, + {5, "0.0.0.0", 7005, 5005}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("instance_%d", tt.id), func(t *testing.T) { + data := privoxyConfigData{ + InstanceID: tt.id, + ListenAddr: tt.addr, + Port: tt.port, + SocksPort: tt.socksPort, + } + + result, err := RenderPrivoxyConfig(data, tmplStr) + if err != nil { + t.Fatalf("RenderPrivoxyConfig() error = %v", err) + } + + expectedListen := fmt.Sprintf("listen-address %s:%d", tt.addr, tt.port) + if !strings.Contains(result, expectedListen) { + t.Errorf("expected %q in output", expectedListen) + } + + expectedForward := fmt.Sprintf("forward-socks5t / 127.0.0.1:%d .", tt.socksPort) + if !strings.Contains(result, expectedForward) { + t.Errorf("expected %q in output", expectedForward) + } + }) + } +} + +func TestPrivoxyTemplateFile_SecuritySettings(t *testing.T) { + tmplStr := readFile(t, "../../templates/privoxy.cfg.gotmpl") + + data := privoxyConfigData{ + InstanceID: 0, + ListenAddr: "127.0.0.1", + Port: 8118, + SocksPort: 9050, + } + + result, err := RenderPrivoxyConfig(data, tmplStr) + if err != nil { + t.Fatalf("RenderPrivoxyConfig() error = %v", err) + } + + privoxyTplContains(t, result, "enable-remote-toggle 0") + privoxyTplContains(t, result, "enable-edit-actions 0") + privoxyTplContains(t, result, "enforce-blocks 1") + privoxyTplContains(t, result, "logfile /dev/null") +} + +func TestPrivoxyTemplateFile_PrivateNetworkForwards(t *testing.T) { + tmplStr := readFile(t, "../../templates/privoxy.cfg.gotmpl") + + data := privoxyConfigData{ + InstanceID: 0, + ListenAddr: "127.0.0.1", + Port: 8118, + SocksPort: 9050, + } + + result, err := RenderPrivoxyConfig(data, tmplStr) + if err != nil { + t.Fatalf("RenderPrivoxyConfig() error = %v", err) + } + + privoxyTplContains(t, result, "forward 10.0.0.0/8 .") + privoxyTplContains(t, result, "forward 172.16.0.0/12 .") + privoxyTplContains(t, result, "forward 192.168.0.0/16 .") + privoxyTplContains(t, result, "forward 127.0.0.0/8 .") +} + +func privoxyTplContains(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("expected output to contain %q\nfull output:\n%s", needle, haystack) + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..1af7227 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,52 @@ +package proxy + +import ( + "context" + "fmt" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +type Mode string + +const ( + ModeNative Mode = "native" + ModeLegacy Mode = "legacy" +) + +type Proxy interface { + Setup(ctx context.Context, instances []Instance) ([]int, error) + Start(ctx context.Context) error + Stop(ctx context.Context) error + Mode() Mode +} + +type Instance struct { + ID int + SocksPort int + HTTPPort int +} + +func NewProxy(mode Mode, cfg *config.Config, procMgr *process.Manager) Proxy { + switch mode { + case ModeLegacy: + return &LegacyProxy{ + cfg: cfg, + procMgr: procMgr, + } + default: + return &NativeProxy{} + } +} + +func ParseMode(s string) (Mode, error) { + switch s { + case "native": + return ModeNative, nil + case "legacy": + return ModeLegacy, nil + default: + return "", fmt.Errorf("ParseMode: unknown proxy mode %q", s) + } +} diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go new file mode 100644 index 0000000..2bb3eb6 --- /dev/null +++ b/internal/proxy/proxy_test.go @@ -0,0 +1,269 @@ +package proxy + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +func testConfig(t *testing.T) *config.Config { + t.Helper() + cfg := &config.Config{} + cfg.Privoxy.BinaryPath = "/usr/sbin/privoxy" + cfg.Privoxy.Listen = "127.0.0.1" + cfg.Privoxy.StartPort = 6999 + cfg.Privoxy.Timeout = 35 + cfg.Privoxy.ConfigFilePrefix = t.TempDir() + "/privoxy_splitter_config_" + cfg.Paths.TempFiles = t.TempDir() + return cfg +} + +func TestNewProxy_NativeMode(t *testing.T) { + p := NewProxy(ModeNative, nil, nil) + if _, ok := p.(*NativeProxy); !ok { + t.Errorf("NewProxy(native) = %T, want *NativeProxy", p) + } +} + +func TestNewProxy_LegacyMode(t *testing.T) { + cfg := testConfig(t) + p := NewProxy(ModeLegacy, cfg, process.NewManager("")) + if _, ok := p.(*LegacyProxy); !ok { + t.Errorf("NewProxy(legacy) = %T, want *LegacyProxy", p) + } +} + +func TestNativeProxy_Setup(t *testing.T) { + p := &NativeProxy{} + instances := []Instance{ + {ID: 0, SocksPort: 4999, HTTPPort: 5199}, + {ID: 1, SocksPort: 5000, HTTPPort: 5200}, + } + + ports, err := p.Setup(context.Background(), instances) + if err != nil { + t.Fatalf("Setup() error = %v", err) + } + + if len(ports) != 2 { + t.Fatalf("Setup() returned %d ports, want 2", len(ports)) + } + if ports[0] != 5199 { + t.Errorf("ports[0] = %d, want 5199", ports[0]) + } + if ports[1] != 5200 { + t.Errorf("ports[1] = %d, want 5200", ports[1]) + } +} + +func TestNativeProxy_Setup_NoHTTPTunnel(t *testing.T) { + p := &NativeProxy{} + instances := []Instance{ + {ID: 0, SocksPort: 4999, HTTPPort: 0}, + } + + _, err := p.Setup(context.Background(), instances) + if err == nil { + t.Fatal("Setup() expected error when HTTPPort == 0, got nil") + } + if !strings.Contains(err.Error(), "legacy") { + t.Errorf("error should suggest legacy mode, got: %v", err) + } +} + +func TestNativeProxy_Setup_MixedInstances(t *testing.T) { + p := &NativeProxy{} + instances := []Instance{ + {ID: 0, SocksPort: 4999, HTTPPort: 5199}, + {ID: 1, SocksPort: 5000, HTTPPort: 0}, + } + + _, err := p.Setup(context.Background(), instances) + if err == nil { + t.Fatal("Setup() expected error when some instances have HTTPPort == 0") + } +} + +func TestNativeProxy_StartStop(t *testing.T) { + p := &NativeProxy{} + + if err := p.Start(context.Background()); err != nil { + t.Errorf("Start() error = %v, want nil", err) + } + if err := p.Stop(context.Background()); err != nil { + t.Errorf("Stop() error = %v, want nil", err) + } +} + +func TestNativeProxy_Mode(t *testing.T) { + p := &NativeProxy{} + if p.Mode() != ModeNative { + t.Errorf("Mode() = %q, want %q", p.Mode(), ModeNative) + } +} + +func TestLegacyProxy_Setup(t *testing.T) { + cfg := testConfig(t) + p := &LegacyProxy{cfg: cfg} + + instances := []Instance{ + {ID: 0, SocksPort: 4999, HTTPPort: 5199}, + {ID: 1, SocksPort: 5000, HTTPPort: 5200}, + } + + ports, err := p.Setup(context.Background(), instances) + if err != nil { + t.Fatalf("Setup() error = %v", err) + } + + if len(ports) != 2 { + t.Fatalf("Setup() returned %d ports, want 2", len(ports)) + } + if ports[0] != 6999 { + t.Errorf("ports[0] = %d, want 6999", ports[0]) + } + if ports[1] != 7000 { + t.Errorf("ports[1] = %d, want 7000", ports[1]) + } + + for _, id := range []int{0, 1} { + configPath := cfg.Privoxy.ConfigFilePrefix + "0.cfg" + if id == 1 { + configPath = cfg.Privoxy.ConfigFilePrefix + "1.cfg" + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Errorf("config file %s: %v", configPath, err) + continue + } + content := string(data) + if !strings.Contains(content, "listen-address") { + t.Errorf("config for instance %d missing listen-address", id) + } + if !strings.Contains(content, "forward-socks5t") { + t.Errorf("config for instance %d missing forward-socks5t", id) + } + } +} + +func TestLegacyProxy_Mode(t *testing.T) { + p := &LegacyProxy{} + if p.Mode() != ModeLegacy { + t.Errorf("Mode() = %q, want %q", p.Mode(), ModeLegacy) + } +} + +func TestLegacyProxy_Start_NoPorts(t *testing.T) { + cfg := testConfig(t) + p := &LegacyProxy{cfg: cfg} + + if err := p.Start(context.Background()); err != nil { + t.Errorf("Start() with no ports error = %v, want nil", err) + } +} + +func TestLegacyProxy_Stop_NoProcs(t *testing.T) { + cfg := testConfig(t) + p := &LegacyProxy{cfg: cfg} + + if err := p.Stop(context.Background()); err != nil { + t.Errorf("Stop() with no procs error = %v, want nil", err) + } +} + +func TestParseMode(t *testing.T) { + tests := []struct { + input string + want Mode + wantErr bool + }{ + {"native", ModeNative, false}, + {"legacy", ModeLegacy, false}, + {"invalid", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseMode(tt.input) + if tt.wantErr { + if err == nil { + t.Error("ParseMode() expected error, got nil") + } + return + } + if err != nil { + t.Errorf("ParseMode() error = %v", err) + } + if got != tt.want { + t.Errorf("ParseMode() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestPrivoxyTemplate(t *testing.T) { + tmpl := readFile(t, "../../templates/privoxy.cfg.gotmpl") + + data := privoxyConfigData{ + InstanceID: 3, + ListenAddr: "127.0.0.1", + Port: 7002, + SocksPort: 5002, + } + + result, err := RenderPrivoxyConfig(data, tmpl) + if err != nil { + t.Fatalf("RenderPrivoxyConfig() error = %v", err) + } + + assertContains(t, result, "Instance 3") + assertContains(t, result, "listen-address 127.0.0.1:7002") + assertContains(t, result, "forward-socks5t / 127.0.0.1:5002 .") + assertContains(t, result, "toggle 1") + assertContains(t, result, "enable-remote-toggle 0") + assertContains(t, result, "enable-edit-actions 0") + assertContains(t, result, "enforce-blocks 1") + assertContains(t, result, "logfile /dev/null") + assertContains(t, result, "buffer-limit 4096") +} + +func TestPrivoxyTemplate_DefaultInline(t *testing.T) { + data := privoxyConfigData{ + InstanceID: 0, + ListenAddr: "0.0.0.0", + Port: 6999, + SocksPort: 4999, + } + + result, err := RenderPrivoxyConfig(data, defaultPrivoxyTmpl) + if err != nil { + t.Fatalf("RenderPrivoxyConfig() error = %v", err) + } + + assertContains(t, result, "listen-address 0.0.0.0:6999") + assertContains(t, result, "forward-socks5t / 127.0.0.1:4999 .") + assertContains(t, result, "forward 10.0.0.0/8 .") + assertContains(t, result, "forward 172.16.0.0/12 .") + assertContains(t, result, "forward 192.168.0.0/16 .") +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("readFile %s: %v", path, err) + } + return string(data) +} + +func assertContains(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("expected output to contain %q\nfull output:\n%s", needle, haystack) + } +} diff --git a/internal/template/doc.go b/internal/template/doc.go new file mode 100644 index 0000000..6010f53 --- /dev/null +++ b/internal/template/doc.go @@ -0,0 +1,2 @@ +// Package template provides Go template helpers for generating torrc, haproxy.cfg, and privoxy.cfg files for SPLITTER. +package template diff --git a/internal/tor/bridges.go b/internal/tor/bridges.go new file mode 100644 index 0000000..c7f2c14 --- /dev/null +++ b/internal/tor/bridges.go @@ -0,0 +1,47 @@ +package tor + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type BridgeConfig struct { + Description string `yaml:"description"` + Transport string `yaml:"transport"` + Lines []string `yaml:"lines"` +} + +type BridgesConfig struct { + Snowflake *BridgeConfig `yaml:"snowflake"` + WebTunnel *BridgeConfig `yaml:"webtunnel"` + Obfs4 *BridgeConfig `yaml:"obfs4"` +} + +func LoadBridges(path string) (*BridgesConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("LoadBridges: %w", err) + } + var cfg BridgesConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("LoadBridges: unmarshal: %w", err) + } + return &cfg, nil +} + +func (b *BridgesConfig) GetBridge(bridgeType string) (*BridgeConfig, error) { + switch bridgeType { + case "snowflake": + return b.Snowflake, nil + case "webtunnel": + return b.WebTunnel, nil + case "obfs4": + return b.Obfs4, nil + case "none", "": + return nil, nil + default: + return nil, fmt.Errorf("GetBridge: unknown bridge type %q", bridgeType) + } +} diff --git a/internal/tor/bridges_test.go b/internal/tor/bridges_test.go new file mode 100644 index 0000000..7610d95 --- /dev/null +++ b/internal/tor/bridges_test.go @@ -0,0 +1,381 @@ +package tor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/user/splitter/internal/process" +) + +func writeBridgesYAML(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "bridges.yaml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write bridges yaml: %v", err) + } + return path +} + +const validBridgesYAML = ` +snowflake: + description: "Snowflake WebRTC transport" + transport: "snowflake" + lines: + - "Bridge snowflake 192.0.2.1:80 192.0.2.1:443 fingerprint=fingerprint1" + - "Bridge snowflake 192.0.2.2:80 192.0.2.2:443 fingerprint=fingerprint2" + +webtunnel: + description: "WebTunnel HTTPS transport" + transport: "webtunnel" + lines: + - "Bridge webtunnel 192.0.2.3:443 192.0.2.3:443 fingerprint=fingerprint3 url=https://example.com/tor" + +obfs4: + description: "obfs4 transport" + transport: "obfs4" + lines: + - "Bridge obfs4 192.0.2.4:443 192.0.2.4:443 fingerprint=fingerprint4 cert=cert1 iat-mode=0" + - "Bridge obfs4 192.0.2.5:443 192.0.2.5:443 fingerprint=fingerprint5 cert=cert2 iat-mode=0" +` + +func TestLoadBridges_ValidFile(t *testing.T) { + path := writeBridgesYAML(t, validBridgesYAML) + cfg, err := LoadBridges(path) + if err != nil { + t.Fatalf("LoadBridges() error = %v", err) + } + if cfg.Snowflake == nil { + t.Error("Snowflake is nil") + } + if cfg.WebTunnel == nil { + t.Error("WebTunnel is nil") + } + if cfg.Obfs4 == nil { + t.Error("Obfs4 is nil") + } +} + +func TestLoadBridges_MissingFile(t *testing.T) { + _, err := LoadBridges("/nonexistent/bridges.yaml") + if err == nil { + t.Error("expected error for missing file, got nil") + } +} + +func TestLoadBridges_InvalidYAML(t *testing.T) { + path := writeBridgesYAML(t, "not: [valid: yaml {{{") + _, err := LoadBridges(path) + if err == nil { + t.Error("expected error for invalid YAML, got nil") + } +} + +func TestBridgesConfig_GetBridge_Snowflake(t *testing.T) { + path := writeBridgesYAML(t, validBridgesYAML) + cfg, _ := LoadBridges(path) + + bc, err := cfg.GetBridge("snowflake") + if err != nil { + t.Fatalf("GetBridge(snowflake) error = %v", err) + } + if bc.Transport != "snowflake" { + t.Errorf("Transport = %q, want %q", bc.Transport, "snowflake") + } + if len(bc.Lines) != 2 { + t.Errorf("len(Lines) = %d, want 2", len(bc.Lines)) + } +} + +func TestBridgesConfig_GetBridge_WebTunnel(t *testing.T) { + path := writeBridgesYAML(t, validBridgesYAML) + cfg, _ := LoadBridges(path) + + bc, err := cfg.GetBridge("webtunnel") + if err != nil { + t.Fatalf("GetBridge(webtunnel) error = %v", err) + } + if bc.Transport != "webtunnel" { + t.Errorf("Transport = %q, want %q", bc.Transport, "webtunnel") + } + if len(bc.Lines) != 1 { + t.Errorf("len(Lines) = %d, want 1", len(bc.Lines)) + } +} + +func TestBridgesConfig_GetBridge_Obfs4(t *testing.T) { + path := writeBridgesYAML(t, validBridgesYAML) + cfg, _ := LoadBridges(path) + + bc, err := cfg.GetBridge("obfs4") + if err != nil { + t.Fatalf("GetBridge(obfs4) error = %v", err) + } + if bc.Transport != "obfs4" { + t.Errorf("Transport = %q, want %q", bc.Transport, "obfs4") + } + if len(bc.Lines) != 2 { + t.Errorf("len(Lines) = %d, want 2", len(bc.Lines)) + } +} + +func TestBridgesConfig_GetBridge_None(t *testing.T) { + cfg := &BridgesConfig{} + + bc, err := cfg.GetBridge("none") + if err != nil { + t.Fatalf("GetBridge(none) error = %v", err) + } + if bc != nil { + t.Error("expected nil for none bridge type") + } +} + +func TestBridgesConfig_GetBridge_Empty(t *testing.T) { + cfg := &BridgesConfig{} + + bc, err := cfg.GetBridge("") + if err != nil { + t.Fatalf("GetBridge('') error = %v", err) + } + if bc != nil { + t.Error("expected nil for empty bridge type") + } +} + +func TestBridgesConfig_GetBridge_Unknown(t *testing.T) { + cfg := &BridgesConfig{} + + _, err := cfg.GetBridge("unknown") + if err == nil { + t.Error("expected error for unknown bridge type, got nil") + } + if !strings.Contains(err.Error(), "unknown bridge type") { + t.Errorf("error = %q, want to contain 'unknown bridge type'", err.Error()) + } +} + +func TestTorrcTemplate_WithBridges(t *testing.T) { + ic := baseInstanceConfig() + ic.UseBridges = true + ic.BridgeLines = []string{ + "Bridge snowflake 192.0.2.1:80 fingerprint1", + "Bridge snowflake 192.0.2.2:80 fingerprint2", + } + ic.ClientTransport = "snowflake" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "UseBridges 1") + torrcContains(t, result, "Bridge snowflake 192.0.2.1:80 fingerprint1") + torrcContains(t, result, "Bridge snowflake 192.0.2.2:80 fingerprint2") + torrcContains(t, result, "ClientTransportPlugin snowflake exec /usr/bin/lyrebird") +} + +func TestTorrcTemplate_NoBridges(t *testing.T) { + ic := baseInstanceConfig() + ic.UseBridges = false + ic.BridgeLines = nil + ic.ClientTransport = "" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "UseBridges") + torrcNotContains(t, result, "ClientTransportPlugin") +} + +func TestTorrcTemplate_WithObfs4Bridges(t *testing.T) { + ic := baseInstanceConfig() + ic.UseBridges = true + ic.BridgeLines = []string{ + "Bridge obfs4 192.0.2.4:443 fingerprint4 cert=cert1 iat-mode=0", + } + ic.ClientTransport = "obfs4" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "UseBridges 1") + torrcContains(t, result, "Bridge obfs4 192.0.2.4:443") + torrcContains(t, result, "ClientTransportPlugin obfs4 exec /usr/bin/lyrebird") +} + +func TestInstance_SetBridges_Snowflake(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + path := writeBridgesYAML(t, validBridgesYAML) + bridges, _ := LoadBridges(path) + + err := inst.SetBridges("snowflake", bridges) + if err != nil { + t.Fatalf("SetBridges(snowflake) error = %v", err) + } + if len(inst.bridgeLines) != 2 { + t.Errorf("bridgeLines len = %d, want 2", len(inst.bridgeLines)) + } + if inst.bridgeTransport != "snowflake" { + t.Errorf("bridgeTransport = %q, want %q", inst.bridgeTransport, "snowflake") + } +} + +func TestInstance_SetBridges_None(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + err := inst.SetBridges("none", nil) + if err != nil { + t.Fatalf("SetBridges(none) error = %v", err) + } + if len(inst.bridgeLines) != 0 { + t.Errorf("bridgeLines len = %d, want 0", len(inst.bridgeLines)) + } +} + +func TestInstance_SetBridges_Empty(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + err := inst.SetBridges("", nil) + if err != nil { + t.Fatalf("SetBridges('') error = %v", err) + } + if len(inst.bridgeLines) != 0 { + t.Errorf("bridgeLines len = %d, want 0", len(inst.bridgeLines)) + } +} + +func TestInstance_SetBridges_NilConfig(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + err := inst.SetBridges("snowflake", nil) + if err == nil { + t.Error("expected error for nil bridges config, got nil") + } +} + +func TestInstance_SetBridges_Unknown(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + path := writeBridgesYAML(t, validBridgesYAML) + bridges, _ := LoadBridges(path) + + err := inst.SetBridges("unknown", bridges) + if err == nil { + t.Error("expected error for unknown bridge type, got nil") + } +} + +func TestBuildInstanceConfig_WithBridges(t *testing.T) { + cfg := testConfig(t) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + inst.SocksPort = 4999 + inst.ControlPort = 5999 + inst.bridgeLines = []string{"Bridge snowflake 192.0.2.1:80 fp1"} + inst.bridgeTransport = "snowflake" + + ic := inst.buildInstanceConfig() + + if !ic.UseBridges { + t.Error("UseBridges = false, want true") + } + if len(ic.BridgeLines) != 1 { + t.Errorf("BridgeLines len = %d, want 1", len(ic.BridgeLines)) + } + if ic.ClientTransport != "snowflake" { + t.Errorf("ClientTransport = %q, want %q", ic.ClientTransport, "snowflake") + } +} + +func TestBuildInstanceConfig_NoBridges(t *testing.T) { + cfg := testConfig(t) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + inst.SocksPort = 4999 + inst.ControlPort = 5999 + + ic := inst.buildInstanceConfig() + + if ic.UseBridges { + t.Error("UseBridges = true, want false") + } + if len(ic.BridgeLines) != 0 { + t.Errorf("BridgeLines len = %d, want 0", len(ic.BridgeLines)) + } +} + +func TestManager_SetBridgesForAll(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 2 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + mgr.CreateFromVersion(v, []string{"{US}", "{DE}"}) + + path := writeBridgesYAML(t, validBridgesYAML) + bridges, _ := LoadBridges(path) + + err := mgr.SetBridgesForAll("obfs4", bridges) + if err != nil { + t.Fatalf("SetBridgesForAll(obfs4) error = %v", err) + } + + for _, inst := range mgr.GetInstances() { + if len(inst.bridgeLines) != 2 { + t.Errorf("instance %d bridgeLines len = %d, want 2", inst.ID, len(inst.bridgeLines)) + } + if inst.bridgeTransport != "obfs4" { + t.Errorf("instance %d bridgeTransport = %q, want %q", inst.ID, inst.bridgeTransport, "obfs4") + } + } +} + +func TestManager_SetBridgesForAll_None(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 1 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + mgr.CreateFromVersion(&Version{0, 4, 8, 0}, []string{"{US}"}) + + err := mgr.SetBridgesForAll("none", nil) + if err != nil { + t.Fatalf("SetBridgesForAll(none) error = %v", err) + } + + for _, inst := range mgr.GetInstances() { + if len(inst.bridgeLines) != 0 { + t.Errorf("instance %d bridgeLines len = %d, want 0", inst.ID, len(inst.bridgeLines)) + } + } +} diff --git a/internal/tor/cgo_test.go b/internal/tor/cgo_test.go new file mode 100644 index 0000000..e26a3ac --- /dev/null +++ b/internal/tor/cgo_test.go @@ -0,0 +1,27 @@ +package tor + +import ( + "testing" +) + +func TestBuildInstanceConfig_CGOEnabledFor049(t *testing.T) { + v := &Version{Major: 0, Minor: 4, Patch: 9, Release: 0} + if !v.SupportsCGO() { + t.Error("SupportsCGO() = false, want true for Tor 0.4.9") + } +} + +func TestBuildInstanceConfig_CGODisabledFor048(t *testing.T) { + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + if v.SupportsCGO() { + t.Error("SupportsCGO() = true, want false for Tor 0.4.8") + } +} + +func TestTorrcTemplate_CGOEnabled(t *testing.T) { + t.Skip("CGO is not a writable torrc option; skip template emission test") +} + +func TestTorrcTemplate_NoCGO(t *testing.T) { + t.Skip("CGO is not a writable torrc option; skip template emission test") +} diff --git a/internal/tor/coverage_test.go b/internal/tor/coverage_test.go new file mode 100644 index 0000000..59b6b61 --- /dev/null +++ b/internal/tor/coverage_test.go @@ -0,0 +1,148 @@ +package tor + +import ( + "context" + "testing" + "time" + + "github.com/user/splitter/internal/process" +) + +func TestState_String_Individual(t *testing.T) { + if got := StateStarting.String(); got != "starting" { + t.Errorf("StateStarting.String() = %q, want %q", got, "starting") + } + if got := StateBootstrapping.String(); got != "bootstrapping" { + t.Errorf("StateBootstrapping.String() = %q, want %q", got, "bootstrapping") + } + if got := StateReady.String(); got != "ready" { + t.Errorf("StateReady.String() = %q, want %q", got, "ready") + } + if got := StateFailed.String(); got != "failed" { + t.Errorf("StateFailed.String() = %q, want %q", got, "failed") + } +} + +func TestState_Unknown_Value(t *testing.T) { + s := State(99) + if got := s.String(); got != "unknown" { + t.Errorf("State(99).String() = %q, want %q", got, "unknown") + } +} + +func TestBackoffDuration_Zero(t *testing.T) { + got := backoffDuration(0) + if got != initialBackoff { + t.Errorf("backoffDuration(0) = %v, want %v", got, initialBackoff) + } +} + +func TestBackoffDuration_One(t *testing.T) { + got := backoffDuration(1) + if got != initialBackoff { + t.Errorf("backoffDuration(1) = %v, want %v", got, initialBackoff) + } +} + +func TestBackoffDuration_Two(t *testing.T) { + got := backoffDuration(2) + if got != 2*time.Second { + t.Errorf("backoffDuration(2) = %v, want 2s", got) + } +} + +func TestBackoffDuration_Large(t *testing.T) { + got := backoffDuration(10) + if got != maxBackoff { + t.Errorf("backoffDuration(10) = %v, want %v", got, maxBackoff) + } +} + +func TestInstance_StopNilProcess(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + ctx := context.Background() + if err := inst.Stop(ctx); err != nil { + t.Errorf("Stop() on unstarted instance returned error: %v", err) + } +} + +func TestInstance_WaitNilProcess(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + if err := inst.Wait(); err != nil { + t.Errorf("Wait() on unstarted instance returned error: %v", err) + } +} + +func TestInstance_ProcessName_WithConfig(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(7, "{US}", cfg, v, procMgr) + + got := inst.processName() + want := "tor-7" + if got != want { + t.Errorf("processName() = %q, want %q", got, want) + } +} + +func TestManager_GetInstances_ReturnsCopy(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 2 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + mgr.CreateFromVersion(v, []string{"{US}", "{DE}"}) + + a := mgr.GetInstances() + b := mgr.GetInstances() + + if len(a) != len(b) { + t.Fatalf("GetInstances() lengths differ: %d vs %d", len(a), len(b)) + } + + if &a[0] == &b[0] { + t.Error("GetInstances() returned slices sharing same backing array; expected independent copies") + } +} + +func TestManager_GetInstances_PreservesPointers(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 1 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + mgr.CreateFromVersion(v, []string{"{US}"}) + + a := mgr.GetInstances() + b := mgr.GetInstances() + + if len(a) != 1 || len(b) != 1 { + t.Fatalf("expected 1 instance, got %d and %d", len(a), len(b)) + } + + if a[0] != b[0] { + t.Error("GetInstances() elements point to different Instance objects; expected same underlying pointers") + } +} diff --git a/internal/tor/doc.go b/internal/tor/doc.go new file mode 100644 index 0000000..2c06c45 --- /dev/null +++ b/internal/tor/doc.go @@ -0,0 +1,2 @@ +// Package tor manages Tor instance lifecycle including config generation, spawning, and monitoring for SPLITTER. +package tor diff --git a/internal/tor/instance.go b/internal/tor/instance.go new file mode 100644 index 0000000..ca59482 --- /dev/null +++ b/internal/tor/instance.go @@ -0,0 +1,317 @@ +package tor + +import ( + "context" + "fmt" + "log/slog" + "math/rand" + "os" + "path/filepath" + "strings" + "sync" + "text/template" + + "github.com/user/splitter/internal/cli" + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +type State int + +const ( + StateStarting State = iota + StateBootstrapping + StateReady + StateFailed +) + +func (s State) String() string { + switch s { + case StateStarting: + return "starting" + case StateBootstrapping: + return "bootstrapping" + case StateReady: + return "ready" + case StateFailed: + return "failed" + default: + return "unknown" + } +} + +type Instance struct { + ID int + Country string + SocksPort int + ControlPort int + HTTPPort int + + mu sync.RWMutex + state State + cancelFunc context.CancelFunc + proc *process.Process + + cfg *config.Config + version *Version + procMgr *process.Manager + + torrcPath string + dataDir string + bridgeLines []string + bridgeTransport string +} + +type InstanceConfig struct { + InstanceID int + Country string + SocksPort int + ControlPort int + HTTPTunnelPort int + DataDir string + CircuitBuildTimeout int + CircuitStreamTimeout int + MaxCircuitDirtiness int + NewCircuitPeriod int + LearnCircuitBuildTimeout int + CongestionControlAuto bool + ConfluxEnabled bool + PostQuantumAvailable bool + HappyFamiliesAware bool + TLS13Recommended bool + SandboxEnabled bool + RelayEnforce string + HiddenServiceEnabled bool + HiddenServiceDir string + HiddenServicePort int + ConnectionPadding int + ReducedConnectionPadding int + SafeSocks int + TestSocks int + ClientRejectInternalAddresses int + StrictNodes int + ClientOnly int + GeoIPExcludeUnknown int + FascistFirewall int + FirewallPorts []int + LongLivedPorts []int + MaxClientCircuitsPending int + SocksTimeout int + TrackHostExitsExpire int + UseEntryGuards int + NumEntryGuards int + AutomapHostsSuffixes string + WarnPlaintextPorts string + RejectPlaintextPorts string + KeepalivePeriod int + ControlAuth string + BridgeLines []string + UseBridges bool + ClientTransport string + StreamIsolation bool + ClientUseIPv6 bool +} + +func NewInstance(id int, country string, cfg *config.Config, version *Version, procMgr *process.Manager) *Instance { + return &Instance{ + ID: id, + Country: country, + cfg: cfg, + version: version, + procMgr: procMgr, + state: StateStarting, + } +} + +func (inst *Instance) SetBridges(bridgeType string, bridges *BridgesConfig) error { + if bridgeType == "none" || bridgeType == "" { + return nil + } + if bridges == nil { + return fmt.Errorf("SetBridges: bridge config is nil") + } + bc, err := bridges.GetBridge(bridgeType) + if err != nil { + return fmt.Errorf("SetBridges: %w", err) + } + if bc != nil { + inst.bridgeLines = bc.Lines + inst.bridgeTransport = bc.Transport + } + return nil +} + +func (inst *Instance) GetState() State { + inst.mu.RLock() + defer inst.mu.RUnlock() + return inst.state +} + +func (inst *Instance) setState(s State) { + inst.mu.Lock() + inst.state = s + inst.mu.Unlock() +} + +func (inst *Instance) Start(ctx context.Context) error { + torrcPath, err := inst.generateTorrc() + if err != nil { + return fmt.Errorf("Start: %w", err) + } + inst.torrcPath = torrcPath + + dataDir, err := inst.generateDataDir() + if err != nil { + return fmt.Errorf("Start: %w", err) + } + inst.dataDir = dataDir + + inst.setState(StateBootstrapping) + + proc, err := inst.procMgr.Spawn(ctx, inst.processName(), inst.cfg.Tor.BinaryPath, "-f", torrcPath) + if err != nil { + inst.setState(StateFailed) + return fmt.Errorf("Start: %w", err) + } + inst.proc = proc + + slog.Info("tor instance started", + cli.InstanceField(inst.ID), + cli.CountryField(inst.Country), + cli.PortField(inst.SocksPort), + ) + + return nil +} + +func (inst *Instance) Stop(ctx context.Context) error { + if inst.cancelFunc != nil { + inst.cancelFunc() + } + if inst.proc != nil { + if err := inst.procMgr.Stop(ctx, inst.proc); err != nil { + return fmt.Errorf("Stop: %w", err) + } + } + inst.setState(StateStarting) + return nil +} + +func (inst *Instance) Wait() error { + if inst.proc == nil { + return nil + } + return inst.proc.Wait() +} + +func (inst *Instance) processName() string { + return fmt.Sprintf("tor-%d", inst.ID) +} + +func (inst *Instance) generateTorrc() (string, error) { + ic := inst.buildInstanceConfig() + + tmpl, err := template.ParseFiles("templates/torrc.gotmpl") + if err != nil { + return "", fmt.Errorf("generateTorrc: %w", err) + } + + torrcPath := filepath.Join(inst.cfg.Paths.TempFiles, fmt.Sprintf("tor_%d.cfg", inst.ID)) + f, err := os.Create(torrcPath) + if err != nil { + return "", fmt.Errorf("generateTorrc: create %s: %w", torrcPath, err) + } + defer func() { _ = f.Close() }() + + if err := tmpl.Execute(f, ic); err != nil { + return "", fmt.Errorf("generateTorrc: execute: %w", err) + } + + return torrcPath, nil +} + +func (inst *Instance) generateDataDir() (string, error) { + dataDir := filepath.Join(inst.cfg.Paths.TempFiles, fmt.Sprintf("tor_data_%d", inst.ID)) + if err := os.MkdirAll(dataDir, 0700); err != nil { + return "", fmt.Errorf("generateDataDir: mkdir %s: %w", dataDir, err) + } + return dataDir, nil +} + +func (inst *Instance) buildInstanceConfig() InstanceConfig { + ic := InstanceConfig{ + InstanceID: inst.ID, + Country: inst.Country, + SocksPort: inst.SocksPort, + ControlPort: inst.ControlPort, + HTTPTunnelPort: inst.HTTPPort, + DataDir: filepath.Join(inst.cfg.Paths.TempFiles, fmt.Sprintf("tor_data_%d", inst.ID)), + CircuitBuildTimeout: inst.cfg.Tor.CircuitBuildTimeout, + CircuitStreamTimeout: inst.cfg.Tor.CircuitStreamTimeout, + MaxCircuitDirtiness: inst.randomizeDirtiness(), + NewCircuitPeriod: inst.cfg.Tor.NewCircuitPeriod, + LearnCircuitBuildTimeout: inst.cfg.Tor.LearnCircuitBuildTimeout, + CongestionControlAuto: inst.version.SupportsCongestionControl() && inst.cfg.Tor.CongestionControlAuto, + ConfluxEnabled: inst.version.SupportsConflux() && inst.cfg.Tor.ConfluxEnabled, + PostQuantumAvailable: inst.version.SupportsPostQuantum(), + HappyFamiliesAware: inst.version.SupportsHappyFamilies(), + TLS13Recommended: inst.version.SupportsTLS13(), + SandboxEnabled: inst.version.SupportsSandbox() && inst.cfg.Tor.Sandbox, + RelayEnforce: inst.cfg.Relay.Enforce, + HiddenServiceEnabled: inst.cfg.Tor.HiddenService.Enabled, + HiddenServiceDir: inst.cfg.Tor.HiddenService.BasePath + fmt.Sprintf("%d", inst.ID), + HiddenServicePort: inst.cfg.Tor.HiddenService.StartPort + inst.ID, + ConnectionPadding: inst.cfg.Tor.ConnectionPadding, + ReducedConnectionPadding: inst.cfg.Tor.ReducedConnectionPadding, + SafeSocks: inst.cfg.Tor.SafeSocks, + TestSocks: inst.cfg.Tor.TestSocks, + ClientRejectInternalAddresses: inst.cfg.Tor.ClientRejectInternalAddresses, + StrictNodes: inst.cfg.Tor.StrictNodes, + ClientOnly: inst.cfg.Tor.ClientOnly, + GeoIPExcludeUnknown: inst.cfg.Tor.GeoIPExcludeUnknown, + FascistFirewall: inst.cfg.Tor.FascistFirewall, + FirewallPorts: inst.cfg.Tor.FirewallPorts, + LongLivedPorts: inst.cfg.Tor.LongLivedPorts, + MaxClientCircuitsPending: inst.cfg.Tor.MaxClientCircuitsPending, + SocksTimeout: inst.cfg.Tor.SocksTimeout, + TrackHostExitsExpire: inst.cfg.Tor.TrackHostExitsExpire, + UseEntryGuards: inst.cfg.Tor.UseEntryGuards, + NumEntryGuards: inst.cfg.Tor.NumEntryGuards, + AutomapHostsSuffixes: inst.cfg.Tor.AutomapHostsSuffixes, + WarnPlaintextPorts: inst.cfg.Tor.WarnPlaintextPorts, + RejectPlaintextPorts: inst.cfg.Tor.RejectPlaintextPorts, + KeepalivePeriod: inst.cfg.Tor.MinimumTimeout, + ControlAuth: inst.cfg.Tor.ControlAuth, + BridgeLines: inst.bridgeLines, + UseBridges: len(inst.bridgeLines) > 0, + ClientTransport: inst.bridgeTransport, + StreamIsolation: inst.cfg.Tor.StreamIsolation, + ClientUseIPv6: inst.cfg.Tor.IPv6, + } + + if inst.HTTPPort > 0 && inst.version.SupportsHTTPTunnel() { + ic.HTTPTunnelPort = inst.HTTPPort + } + + return ic +} + +func (inst *Instance) randomizeDirtiness() int { + max := inst.cfg.Tor.MaxCircuitDirtiness + if max <= 10 { + return max + } + return 10 + rand.Intn(max-10) +} + +func RenderTorrc(ic InstanceConfig, tmplStr string) (string, error) { + tmpl, err := template.New("torrc").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("RenderTorrc: %w", err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, ic); err != nil { + return "", fmt.Errorf("RenderTorrc: execute: %w", err) + } + return buf.String(), nil +} diff --git a/internal/tor/instance_test.go b/internal/tor/instance_test.go new file mode 100644 index 0000000..7b7c320 --- /dev/null +++ b/internal/tor/instance_test.go @@ -0,0 +1,548 @@ +package tor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +func testConfig(t *testing.T) *config.Config { + t.Helper() + cfg := &config.Config{} + cfg.Tor.BinaryPath = "/usr/bin/tor" + cfg.Tor.ListenAddr = "0.0.0.0" + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + cfg.Tor.ControlAuth = "cookie" + cfg.Tor.HiddenService.Enabled = true + cfg.Tor.HiddenService.BasePath = "/tmp/splitter/hidden_service_" + cfg.Tor.HiddenService.StartPort = 3999 + cfg.Tor.MinimumTimeout = 15 + cfg.Tor.CircuitBuildTimeout = 60 + cfg.Tor.CircuitStreamTimeout = 20 + cfg.Tor.MaxCircuitDirtiness = 30 + cfg.Tor.NewCircuitPeriod = 30 + cfg.Tor.LearnCircuitBuildTimeout = 1 + cfg.Tor.ClientOnly = 0 + cfg.Tor.ConnectionPadding = 0 + cfg.Tor.ReducedConnectionPadding = 1 + cfg.Tor.GeoIPExcludeUnknown = 1 + cfg.Tor.StrictNodes = 1 + cfg.Tor.FascistFirewall = 0 + cfg.Tor.FirewallPorts = []int{80, 443} + cfg.Tor.LongLivedPorts = []int{1, 2} + cfg.Tor.MaxClientCircuitsPending = 1024 + cfg.Tor.SocksTimeout = 35 + cfg.Tor.TrackHostExitsExpire = 10 + cfg.Tor.UseEntryGuards = 1 + cfg.Tor.NumEntryGuards = 1 + cfg.Tor.SafeSocks = 1 + cfg.Tor.TestSocks = 1 + cfg.Tor.ClientRejectInternalAddresses = 1 + cfg.Tor.AutomapHostsSuffixes = ".exit,.onion" + cfg.Tor.WarnPlaintextPorts = "21,23,25,80,109,110,143" + cfg.Tor.RejectPlaintextPorts = "" + cfg.Tor.ConfluxEnabled = true + cfg.Tor.CongestionControlAuto = true + cfg.Relay.Enforce = "entry" + cfg.Paths.TempFiles = t.TempDir() + return cfg +} + +func TestInstance_PortAssignment(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(3, "{US}", cfg, v, procMgr) + inst.SocksPort = cfg.Tor.StartSocksPort + 3 + inst.ControlPort = cfg.Tor.StartControlPort + 3 + inst.HTTPPort = cfg.Tor.StartHTTPPort + 3 + + if inst.SocksPort != 5002 { + t.Errorf("SocksPort = %d, want 5002", inst.SocksPort) + } + if inst.ControlPort != 6002 { + t.Errorf("ControlPort = %d, want 6002", inst.ControlPort) + } + if inst.HTTPPort != 5202 { + t.Errorf("HTTPPort = %d, want 5202", inst.HTTPPort) + } +} + +func TestInstance_StateTransitions(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, procMgr) + + if inst.GetState() != StateStarting { + t.Errorf("initial state = %v, want StateStarting", inst.GetState()) + } + + inst.setState(StateBootstrapping) + if inst.GetState() != StateBootstrapping { + t.Errorf("state = %v, want StateBootstrapping", inst.GetState()) + } + + inst.setState(StateReady) + if inst.GetState() != StateReady { + t.Errorf("state = %v, want StateReady", inst.GetState()) + } + + inst.setState(StateFailed) + if inst.GetState() != StateFailed { + t.Errorf("state = %v, want StateFailed", inst.GetState()) + } +} + +func TestInstance_GenerateDataDir(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(5, "{DE}", cfg, v, procMgr) + + dataDir, err := inst.generateDataDir() + if err != nil { + t.Fatalf("generateDataDir() error = %v", err) + } + + expected := filepath.Join(cfg.Paths.TempFiles, "tor_data_5") + if dataDir != expected { + t.Errorf("dataDir = %q, want %q", dataDir, expected) + } + + if _, err := os.Stat(dataDir); os.IsNotExist(err) { + t.Errorf("data dir %q was not created", dataDir) + } +} + +func TestBuildInstanceConfig_EntryMode(t *testing.T) { + cfg := testConfig(t) + cfg.Relay.Enforce = "entry" + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + inst.SocksPort = 4999 + inst.ControlPort = 5999 + inst.HTTPPort = 5199 + + ic := inst.buildInstanceConfig() + + if ic.RelayEnforce != "entry" { + t.Errorf("RelayEnforce = %q, want %q", ic.RelayEnforce, "entry") + } + if ic.Country != "{US}" { + t.Errorf("Country = %q, want %q", ic.Country, "{US}") + } + if !ic.CongestionControlAuto { + t.Error("CongestionControlAuto = false, want true for 0.4.8") + } + if !ic.ConfluxEnabled { + t.Error("ConfluxEnabled = false, want true for 0.4.8") + } + if !ic.HiddenServiceEnabled { + t.Error("HiddenServiceEnabled = false, want true") + } + if ic.HTTPTunnelPort != 5199 { + t.Errorf("HTTPTunnelPort = %d, want 5199", ic.HTTPTunnelPort) + } +} + +func TestBuildInstanceConfig_ExitMode(t *testing.T) { + cfg := testConfig(t) + cfg.Relay.Enforce = "exit" + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(1, "{DE}", cfg, v, process.NewManager("")) + inst.SocksPort = 5000 + inst.ControlPort = 6000 + inst.HTTPPort = 5200 + + ic := inst.buildInstanceConfig() + + if ic.RelayEnforce != "exit" { + t.Errorf("RelayEnforce = %q, want %q", ic.RelayEnforce, "exit") + } +} + +func TestBuildInstanceConfig_SpeedMode(t *testing.T) { + cfg := testConfig(t) + cfg.Relay.Enforce = "speed" + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(2, "{FR}", cfg, v, process.NewManager("")) + ic := inst.buildInstanceConfig() + + if ic.RelayEnforce != "speed" { + t.Errorf("RelayEnforce = %q, want %q", ic.RelayEnforce, "speed") + } +} + +func TestBuildInstanceConfig_OldTorNoHTTPTunnel(t *testing.T) { + cfg := testConfig(t) + v := &Version{Major: 0, Minor: 4, Patch: 7, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + inst.SocksPort = 4999 + inst.ControlPort = 5999 + inst.HTTPPort = 0 + + ic := inst.buildInstanceConfig() + + if ic.HTTPTunnelPort != 0 { + t.Errorf("HTTPTunnelPort = %d, want 0 for Tor 0.4.7", ic.HTTPTunnelPort) + } + if !ic.CongestionControlAuto { + t.Error("CongestionControlAuto = false, want true for 0.4.7") + } + if ic.ConfluxEnabled { + t.Error("ConfluxEnabled = true, want false for 0.4.7") + } +} + +func TestBuildInstanceConfig_CGORRequires049(t *testing.T) { + cfg := testConfig(t) + v := &Version{Major: 0, Minor: 4, Patch: 9, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + ic := inst.buildInstanceConfig() + + if !ic.CongestionControlAuto { + t.Error("CongestionControlAuto should be true for 0.4.9") + } + if !ic.ConfluxEnabled { + t.Error("ConfluxEnabled should be true for 0.4.9") + } +} + +func TestBuildInstanceConfig_HiddenServiceDisabled(t *testing.T) { + cfg := testConfig(t) + cfg.Tor.HiddenService.Enabled = false + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + ic := inst.buildInstanceConfig() + + if ic.HiddenServiceEnabled { + t.Error("HiddenServiceEnabled should be false when disabled in config") + } +} + +func TestRenderTorrc_EntryMode(t *testing.T) { + ic := InstanceConfig{ + InstanceID: 0, + Country: "{US}", + SocksPort: 4999, + ControlPort: 5999, + HTTPTunnelPort: 5199, + DataDir: "/tmp/splitter/tor_data_0", + CircuitBuildTimeout: 60, + CircuitStreamTimeout: 20, + MaxCircuitDirtiness: 15, + NewCircuitPeriod: 30, + LearnCircuitBuildTimeout: 1, + CongestionControlAuto: true, + ConfluxEnabled: true, + RelayEnforce: "entry", + HiddenServiceEnabled: true, + HiddenServiceDir: "/tmp/splitter/hidden_service_0", + HiddenServicePort: 3999, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + SafeSocks: 1, + TestSocks: 1, + ClientRejectInternalAddresses: 1, + StrictNodes: 1, + ClientOnly: 0, + GeoIPExcludeUnknown: 1, + FascistFirewall: 0, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25,80,109,110,143", + RejectPlaintextPorts: "", + KeepalivePeriod: 15, + ControlAuth: "cookie", + } + + tmpl := readFile(t, "../../templates/torrc.gotmpl") + result, err := RenderTorrc(ic, tmpl) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + assertContains(t, result, "SocksPort 4999") + assertContains(t, result, "ControlPort 5999") + assertContains(t, result, "HTTPTunnelPort 5199") + assertContains(t, result, "EntryNodes {US}") + assertContains(t, result, "CongestionControlAuto 1") + assertContains(t, result, "ConfluxEnabled 1") + assertContains(t, result, "CookieAuthentication 1") + assertContains(t, result, "HiddenServiceDir /tmp/splitter/hidden_service_0") + assertContains(t, result, "HiddenServicePort 3999") + + assertNotContains(t, result, "ExitNodes") + assertNotContains(t, result, "RejectPlaintextPorts") +} + +func TestRenderTorrc_ExitMode(t *testing.T) { + ic := InstanceConfig{ + InstanceID: 1, + Country: "{DE}", + SocksPort: 5000, + ControlPort: 6000, + HTTPTunnelPort: 0, + DataDir: "/tmp/splitter/tor_data_1", + CircuitBuildTimeout: 60, + CircuitStreamTimeout: 20, + MaxCircuitDirtiness: 15, + NewCircuitPeriod: 30, + LearnCircuitBuildTimeout: 1, + CongestionControlAuto: true, + ConfluxEnabled: true, + RelayEnforce: "exit", + HiddenServiceEnabled: false, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + SafeSocks: 1, + TestSocks: 1, + ClientRejectInternalAddresses: 1, + StrictNodes: 1, + ClientOnly: 0, + GeoIPExcludeUnknown: 1, + FascistFirewall: 0, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25,80,109,110,143", + RejectPlaintextPorts: "", + KeepalivePeriod: 15, + } + + tmpl := readFile(t, "../../templates/torrc.gotmpl") + result, err := RenderTorrc(ic, tmpl) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + assertContains(t, result, "ExitNodes {DE}") + assertNotContains(t, result, "EntryNodes") + assertNotContains(t, result, "HTTPTunnelPort") + assertNotContains(t, result, "HiddenServiceDir") +} + +func TestRenderTorrc_SpeedMode(t *testing.T) { + ic := InstanceConfig{ + InstanceID: 2, + Country: "{FR}", + SocksPort: 5001, + ControlPort: 6001, + HTTPTunnelPort: 0, + DataDir: "/tmp/splitter/tor_data_2", + CircuitBuildTimeout: 60, + CircuitStreamTimeout: 20, + MaxCircuitDirtiness: 15, + NewCircuitPeriod: 30, + LearnCircuitBuildTimeout: 1, + CongestionControlAuto: false, + ConfluxEnabled: false, + RelayEnforce: "speed", + HiddenServiceEnabled: false, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + SafeSocks: 1, + TestSocks: 1, + ClientRejectInternalAddresses: 1, + StrictNodes: 1, + ClientOnly: 0, + GeoIPExcludeUnknown: 1, + FascistFirewall: 0, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25,80,109,110,143", + RejectPlaintextPorts: "", + KeepalivePeriod: 15, + } + + tmpl := readFile(t, "../../templates/torrc.gotmpl") + result, err := RenderTorrc(ic, tmpl) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + assertContains(t, result, "speed mode") + assertNotContains(t, result, "EntryNodes") + assertNotContains(t, result, "ExitNodes") + assertNotContains(t, result, "CongestionControlAuto") + assertNotContains(t, result, "ConfluxEnabled") +} + +func TestRenderTorrc_WithRejectPlaintextPorts(t *testing.T) { + ic := InstanceConfig{ + InstanceID: 0, + Country: "{US}", + SocksPort: 4999, + ControlPort: 5999, + HTTPTunnelPort: 0, + DataDir: "/tmp/splitter/tor_data_0", + CircuitBuildTimeout: 60, + CircuitStreamTimeout: 20, + MaxCircuitDirtiness: 15, + NewCircuitPeriod: 30, + LearnCircuitBuildTimeout: 1, + CongestionControlAuto: false, + ConfluxEnabled: false, + RelayEnforce: "entry", + HiddenServiceEnabled: false, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + SafeSocks: 1, + TestSocks: 1, + ClientRejectInternalAddresses: 1, + StrictNodes: 1, + ClientOnly: 0, + GeoIPExcludeUnknown: 1, + FascistFirewall: 0, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25,80,109,110,143", + RejectPlaintextPorts: "21,23,25", + KeepalivePeriod: 15, + } + + tmpl := readFile(t, "../../templates/torrc.gotmpl") + result, err := RenderTorrc(ic, tmpl) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + assertContains(t, result, "RejectPlaintextPorts 21,23,25") +} + +func TestRenderTorrc_FascistFirewall(t *testing.T) { + ic := InstanceConfig{ + InstanceID: 0, + Country: "{US}", + SocksPort: 4999, + ControlPort: 5999, + HTTPTunnelPort: 0, + DataDir: "/tmp/splitter/tor_data_0", + CircuitBuildTimeout: 60, + CircuitStreamTimeout: 20, + MaxCircuitDirtiness: 15, + NewCircuitPeriod: 30, + LearnCircuitBuildTimeout: 1, + CongestionControlAuto: false, + ConfluxEnabled: false, + RelayEnforce: "entry", + HiddenServiceEnabled: false, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + SafeSocks: 1, + TestSocks: 1, + ClientRejectInternalAddresses: 1, + StrictNodes: 1, + ClientOnly: 0, + GeoIPExcludeUnknown: 1, + FascistFirewall: 1, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25,80,109,110,143", + RejectPlaintextPorts: "", + KeepalivePeriod: 15, + } + + tmpl := readFile(t, "../../templates/torrc.gotmpl") + result, err := RenderTorrc(ic, tmpl) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + assertContains(t, result, "FascistFirewall 1") + assertContains(t, result, "FirewallPorts 80,443") +} + +func TestRandomizeDirtiness(t *testing.T) { + cfg := testConfig(t) + cfg.Tor.MaxCircuitDirtiness = 30 + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + + for i := 0; i < 100; i++ { + d := inst.randomizeDirtiness() + if d < 10 || d > 30 { + t.Errorf("randomizeDirtiness() = %d, want range [10, 30]", d) + } + } +} + +func TestRandomizeDirtiness_SmallMax(t *testing.T) { + cfg := testConfig(t) + cfg.Tor.MaxCircuitDirtiness = 5 + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + inst := NewInstance(0, "{US}", cfg, v, process.NewManager("")) + + d := inst.randomizeDirtiness() + if d != 5 { + t.Errorf("randomizeDirtiness() = %d, want 5 when max <= 10", d) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("readFile %s: %v", path, err) + } + return string(data) +} + +func assertContains(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("expected output to contain %q\nfull output:\n%s", needle, haystack) + } +} + +func assertNotContains(t *testing.T, haystack, needle string) { + t.Helper() + if strings.Contains(haystack, needle) { + t.Errorf("expected output NOT to contain %q\nfull output:\n%s", needle, haystack) + } +} diff --git a/internal/tor/integration_test.go b/internal/tor/integration_test.go new file mode 100644 index 0000000..f987eda --- /dev/null +++ b/internal/tor/integration_test.go @@ -0,0 +1,64 @@ +//go:build integration + +package tor + +import ( + "context" + "testing" + "time" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +func TestIntegration_DetectVersion(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + v, err := DetectVersion(ctx, "/usr/bin/tor") + if err != nil { + t.Skipf("tor not available: %v", err) + } + t.Logf("Detected Tor version: %s", v) + + if v.Major == 0 && v.Minor == 0 { + t.Fatal("version should not be 0.0.x") + } +} + +func TestIntegration_StartStopInstance(t *testing.T) { + cfg := config.Defaults() + cfg.Paths.TempFiles = t.TempDir() + cfg.Tor.BinaryPath = "/usr/bin/tor" + + procMgr := process.NewManager(cfg.Paths.TempFiles) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + v, err := DetectVersion(ctx, cfg.Tor.BinaryPath) + if err != nil { + t.Skipf("tor not available: %v", err) + } + + mgr := NewManager(cfg, procMgr) + mgr.CreateFromVersion(v, []string{"{US}"}) + + instances := mgr.GetInstances() + if len(instances) == 0 { + t.Fatal("expected at least one instance") + } + + inst := instances[0] + if err := inst.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + time.Sleep(2 * time.Second) + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer stopCancel() + if err := inst.Stop(stopCtx); err != nil { + t.Fatalf("Stop: %v", err) + } +} diff --git a/internal/tor/manager.go b/internal/tor/manager.go new file mode 100644 index 0000000..c274e96 --- /dev/null +++ b/internal/tor/manager.go @@ -0,0 +1,264 @@ +package tor + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/user/splitter/internal/cli" + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +type TorManager struct { + mu sync.RWMutex + instances []*Instance + version *Version + procMgr *process.Manager + cfg *config.Config +} + +func NewManager(cfg *config.Config, procMgr *process.Manager) *TorManager { + return &TorManager{ + cfg: cfg, + procMgr: procMgr, + } +} + +func (m *TorManager) DetectAndCreate(ctx context.Context, countries []string) error { + v, err := DetectVersion(ctx, m.cfg.Tor.BinaryPath) + if err != nil { + return fmt.Errorf("DetectAndCreate: %w", err) + } + m.version = v + + slog.Info("detected tor version", "version", v.String(), + "conflux", v.SupportsConflux(), + "http_tunnel", v.SupportsHTTPTunnel(), + "congestion_control", v.SupportsCongestionControl(), + "cgo", v.SupportsCGO(), + "post_quantum", v.SupportsPostQuantum(), + "happy_families", v.SupportsHappyFamilies(), + "tls13", v.SupportsTLS13(), + "sandbox_supported", v.SupportsSandbox(), + ) + + perCountry := m.cfg.Instances.PerCountry + socksPort := m.cfg.Tor.StartSocksPort + controlPort := m.cfg.Tor.StartControlPort + httpPort := m.cfg.Tor.StartHTTPPort + + instanceID := 0 + for _, country := range countries { + for i := 0; i < perCountry; i++ { + inst := NewInstance(instanceID, country, m.cfg, m.version, m.procMgr) + inst.SocksPort = socksPort + instanceID + inst.ControlPort = controlPort + instanceID + if m.version.SupportsHTTPTunnel() { + inst.HTTPPort = httpPort + instanceID + } + m.instances = append(m.instances, inst) + instanceID++ + } + } + + slog.Info("created tor instances", "count", len(m.instances), "countries", len(countries)) + return nil +} + +func (m *TorManager) CreateFromVersion(version *Version, countries []string) { + m.version = version + + perCountry := m.cfg.Instances.PerCountry + socksPort := m.cfg.Tor.StartSocksPort + controlPort := m.cfg.Tor.StartControlPort + httpPort := m.cfg.Tor.StartHTTPPort + + instanceID := 0 + for _, country := range countries { + for i := 0; i < perCountry; i++ { + inst := NewInstance(instanceID, country, m.cfg, m.version, m.procMgr) + inst.SocksPort = socksPort + instanceID + inst.ControlPort = controlPort + instanceID + if version.SupportsHTTPTunnel() { + inst.HTTPPort = httpPort + instanceID + } + m.instances = append(m.instances, inst) + instanceID++ + } + } +} + +func (m *TorManager) StartAll(ctx context.Context) error { + m.mu.RLock() + instances := make([]*Instance, len(m.instances)) + copy(instances, m.instances) + m.mu.RUnlock() + + var wg sync.WaitGroup + errCh := make(chan error, len(instances)) + + for _, inst := range instances { + wg.Add(1) + go func(i *Instance) { + defer wg.Done() + if err := i.Start(ctx); err != nil { + errCh <- fmt.Errorf("instance %d (%s): %w", i.ID, i.Country, err) + } + }(inst) + } + + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + + if len(errs) > 0 { + return fmt.Errorf("StartAll: %d instance(s) failed: %w", len(errs), errs[0]) + } + + slog.Info("all tor instances started", "count", len(instances)) + return nil +} + +func (m *TorManager) StopAll(ctx context.Context) error { + m.mu.RLock() + instances := make([]*Instance, len(m.instances)) + copy(instances, m.instances) + m.mu.RUnlock() + + var wg sync.WaitGroup + errCh := make(chan error, len(instances)) + + for _, inst := range instances { + wg.Add(1) + go func(i *Instance) { + defer wg.Done() + if err := i.Stop(ctx); err != nil { + errCh <- fmt.Errorf("instance %d: %w", i.ID, err) + } + }(inst) + } + + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + + if len(errs) > 0 { + return fmt.Errorf("StopAll: %d instance(s) failed: %w", len(errs), errs[0]) + } + + return nil +} + +func (m *TorManager) GetInstances() []*Instance { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]*Instance, len(m.instances)) + copy(out, m.instances) + return out +} + +func (m *TorManager) GetInstance(id int) (*Instance, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, inst := range m.instances { + if inst.ID == id { + return inst, nil + } + } + return nil, fmt.Errorf("GetInstance: instance %d not found", id) +} + +func (m *TorManager) GetVersion() *Version { + return m.version +} + +func (m *TorManager) StartAllWithRestart(ctx context.Context) error { + m.mu.RLock() + instances := make([]*Instance, len(m.instances)) + copy(instances, m.instances) + m.mu.RUnlock() + + readyCh := make(chan struct{}, len(instances)) + + for _, inst := range instances { + go inst.RunWithRestart(ctx, readyCh) + } + + startedCount := 0 + total := len(instances) + for startedCount < total { + select { + case <-ctx.Done(): + return fmt.Errorf("StartAllWithRestart: %w", ctx.Err()) + case <-readyCh: + startedCount++ + slog.Info("instance ready", + cli.InstanceField(startedCount), + "total", total, + ) + } + } + + slog.Info("all tor instances ready", "count", total) + return nil +} + +type InstanceInfo struct { + ID int + Country string +} + +func (m *TorManager) GetInstanceInfos() []InstanceInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + infos := make([]InstanceInfo, len(m.instances)) + for i, inst := range m.instances { + infos[i] = InstanceInfo{ + ID: inst.ID, + Country: inst.Country, + } + } + return infos +} + +func (m *TorManager) RotateInstance(ctx context.Context, id int, newCountry string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, inst := range m.instances { + if inst.ID == id { + if err := inst.Stop(ctx); err != nil { + return fmt.Errorf("RotateInstance: stop %d: %w", id, err) + } + inst.Country = newCountry + if err := inst.Start(ctx); err != nil { + return fmt.Errorf("RotateInstance: start %d: %w", id, err) + } + return nil + } + } + return fmt.Errorf("RotateInstance: instance %d not found", id) +} + +func (m *TorManager) SetBridgesForAll(bridgeType string, bridges *BridgesConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, inst := range m.instances { + if err := inst.SetBridges(bridgeType, bridges); err != nil { + return fmt.Errorf("SetBridgesForAll: instance %d: %w", inst.ID, err) + } + } + return nil +} diff --git a/internal/tor/manager_rotation_test.go b/internal/tor/manager_rotation_test.go new file mode 100644 index 0000000..04c7d1b --- /dev/null +++ b/internal/tor/manager_rotation_test.go @@ -0,0 +1,116 @@ +package tor + +import ( + "context" + "testing" + + "github.com/user/splitter/internal/process" +) + +func TestManager_GetInstanceInfos(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 2 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + countries := []string{"{US}", "{DE}"} + + mgr.CreateFromVersion(v, countries) + + infos := mgr.GetInstanceInfos() + if len(infos) != 4 { + t.Fatalf("GetInstanceInfos() returned %d, want 4", len(infos)) + } + + expected := []InstanceInfo{ + {ID: 0, Country: "{US}"}, + {ID: 1, Country: "{US}"}, + {ID: 2, Country: "{DE}"}, + {ID: 3, Country: "{DE}"}, + } + + for i, info := range infos { + if info.ID != expected[i].ID { + t.Errorf("infos[%d].ID = %d, want %d", i, info.ID, expected[i].ID) + } + if info.Country != expected[i].Country { + t.Errorf("infos[%d].Country = %q, want %q", i, info.Country, expected[i].Country) + } + } +} + +func TestManager_GetInstanceInfos_Empty(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + infos := mgr.GetInstanceInfos() + if len(infos) != 0 { + t.Errorf("GetInstanceInfos() returned %d, want 0", len(infos)) + } +} + +func TestManager_RotateInstance_NotFound(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + ctx := context.Background() + err := mgr.RotateInstance(ctx, 999, "{FR}") + if err == nil { + t.Error("RotateInstance(999) expected error, got nil") + } +} + +func TestManager_RotateInstance_CountryUpdatedBeforeStart(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 1 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + mgr.CreateFromVersion(v, []string{"{US}"}) + + ctx := context.Background() + _ = mgr.RotateInstance(ctx, 0, "{FR}") + + infos := mgr.GetInstanceInfos() + if len(infos) != 1 { + t.Fatalf("GetInstanceInfos() returned %d instances, want 1", len(infos)) + } + if infos[0].Country != "{FR}" { + t.Errorf("Country = %q, want %q after RotateInstance", infos[0].Country, "{FR}") + } +} + +func TestManager_RotateInstance_ByID(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 1 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + mgr.CreateFromVersion(v, []string{"{US}", "{DE}", "{FR}"}) + + ctx := context.Background() + _ = mgr.RotateInstance(ctx, 1, "{GB}") + + infos := mgr.GetInstanceInfos() + if len(infos) != 3 { + t.Fatalf("GetInstanceInfos() returned %d instances, want 3", len(infos)) + } + + if infos[0].Country != "{US}" { + t.Errorf("instance 0 Country = %q, want {US} (unchanged)", infos[0].Country) + } + if infos[1].Country != "{GB}" { + t.Errorf("instance 1 Country = %q, want {GB} (rotated)", infos[1].Country) + } + if infos[2].Country != "{FR}" { + t.Errorf("instance 2 Country = %q, want {FR} (unchanged)", infos[2].Country) + } +} diff --git a/internal/tor/manager_test.go b/internal/tor/manager_test.go new file mode 100644 index 0000000..05fb4d0 --- /dev/null +++ b/internal/tor/manager_test.go @@ -0,0 +1,188 @@ +package tor + +import ( + "testing" + + "github.com/user/splitter/internal/process" +) + +func TestManager_CreateFromVersion(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 2 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + countries := []string{"{US}", "{DE}", "{FR}"} + + mgr.CreateFromVersion(v, countries) + + instances := mgr.GetInstances() + expectedCount := 3 * 2 // 3 countries * 2 per country + if len(instances) != expectedCount { + t.Fatalf("GetInstances() returned %d, want %d", len(instances), expectedCount) + } + + for i, inst := range instances { + expectedSocks := 4999 + i + expectedControl := 5999 + i + expectedHTTP := 5199 + i + + if inst.SocksPort != expectedSocks { + t.Errorf("instance[%d].SocksPort = %d, want %d", i, inst.SocksPort, expectedSocks) + } + if inst.ControlPort != expectedControl { + t.Errorf("instance[%d].ControlPort = %d, want %d", i, inst.ControlPort, expectedControl) + } + if inst.HTTPPort != expectedHTTP { + t.Errorf("instance[%d].HTTPPort = %d, want %d", i, inst.HTTPPort, expectedHTTP) + } + } +} + +func TestManager_CreateFromVersion_OldTor(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 1 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 7, Release: 0} + countries := []string{"{US}"} + + mgr.CreateFromVersion(v, countries) + + instances := mgr.GetInstances() + if len(instances) != 1 { + t.Fatalf("GetInstances() returned %d, want 1", len(instances)) + } + + inst := instances[0] + if inst.HTTPPort != 0 { + t.Errorf("HTTPPort = %d, want 0 for Tor 0.4.7 (no HTTPTunnelPort)", inst.HTTPPort) + } +} + +func TestManager_GetInstance(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 2 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + countries := []string{"{US}", "{DE}"} + + mgr.CreateFromVersion(v, countries) + + inst, err := mgr.GetInstance(2) + if err != nil { + t.Fatalf("GetInstance(2) error = %v", err) + } + if inst.Country != "{DE}" { + t.Errorf("GetInstance(2).Country = %q, want %q", inst.Country, "{DE}") + } +} + +func TestManager_GetInstance_NotFound(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 1 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + mgr.CreateFromVersion(&Version{0, 4, 8, 0}, []string{"{US}"}) + + _, err := mgr.GetInstance(999) + if err == nil { + t.Error("GetInstance(999) expected error, got nil") + } +} + +func TestManager_GetVersion(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 9, Release: 0} + mgr.CreateFromVersion(v, []string{"{US}"}) + + got := mgr.GetVersion() + if got.String() != "0.4.9.0" { + t.Errorf("GetVersion() = %v, want 0.4.9.0", got) + } +} + +func TestManager_PortIncrement(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 3 + cfg.Tor.StartSocksPort = 4999 + cfg.Tor.StartControlPort = 5999 + cfg.Tor.StartHTTPPort = 5199 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + countries := []string{"{US}", "{DE}"} + + mgr.CreateFromVersion(v, countries) + + instances := mgr.GetInstances() + if len(instances) != 6 { + t.Fatalf("expected 6 instances, got %d", len(instances)) + } + + last := instances[5] + if last.SocksPort != 4999+5 { + t.Errorf("last instance SocksPort = %d, want %d", last.SocksPort, 4999+5) + } + if last.ControlPort != 5999+5 { + t.Errorf("last instance ControlPort = %d, want %d", last.ControlPort, 5999+5) + } +} + +func TestManager_CountryAssignment(t *testing.T) { + cfg := testConfig(t) + cfg.Instances.PerCountry = 2 + + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + countries := []string{"{US}", "{DE}", "{FR}"} + + mgr.CreateFromVersion(v, countries) + + instances := mgr.GetInstances() + + expectedCountries := []string{"{US}", "{US}", "{DE}", "{DE}", "{FR}", "{FR}"} + for i, inst := range instances { + if inst.Country != expectedCountries[i] { + t.Errorf("instance[%d].Country = %q, want %q", i, inst.Country, expectedCountries[i]) + } + } +} + +func TestManager_EmptyCountries(t *testing.T) { + cfg := testConfig(t) + procMgr := process.NewManager(cfg.Paths.TempFiles) + mgr := NewManager(cfg, procMgr) + + mgr.CreateFromVersion(&Version{0, 4, 8, 0}, []string{}) + + instances := mgr.GetInstances() + if len(instances) != 0 { + t.Errorf("expected 0 instances for empty countries, got %d", len(instances)) + } +} diff --git a/internal/tor/privacy_integration_test.go b/internal/tor/privacy_integration_test.go new file mode 100644 index 0000000..15158f5 --- /dev/null +++ b/internal/tor/privacy_integration_test.go @@ -0,0 +1,419 @@ +//go:build integration + +package tor + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/user/splitter/internal/config" + "github.com/user/splitter/internal/process" +) + +const ( + // torCheckURL is used to verify traffic goes through Tor + torCheckURL = "https://check.torproject.org/api/ip" + // Number of requests for rotation tests + rotationRequests = 6 + // Timeout for individual proxy requests + proxyTimeout = 30 * time.Second +) + +// integrationConfig creates a minimal config for integration tests. +func integrationConfig(t *testing.T) *config.Config { + t.Helper() + cfg := config.Defaults() + cfg.Tor.BinaryPath = os.Getenv("TOR_BINARY") + if cfg.Tor.BinaryPath == "" { + cfg.Tor.BinaryPath = "tor" + } + cfg.Paths.TempFiles = t.TempDir() + cfg.Tor.StreamIsolation = false + cfg.Tor.HiddenService.Enabled = false + return cfg +} + +// startTestInstance starts a single Tor instance for testing and returns +// a cleanup function. +func startTestInstance(t *testing.T, cfg *config.Config) (*Instance, func()) { + t.Helper() + procMgr := process.NewManager(cfg.Paths.TempFiles) + v, err := DetectVersion(context.Background(), cfg.Tor.BinaryPath) + if err != nil { + t.Skipf("tor not available: %v", err) + } + + inst := NewInstance(0, "{US}", cfg, v, procMgr) + inst.SocksPort = findFreePort(t) + inst.ControlPort = findFreePort(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := inst.Start(ctx); err != nil { + t.Fatalf("failed to start tor instance: %v", err) + } + + // Wait for bootstrap + time.Sleep(5 * time.Second) + + cleanup := func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer stopCancel() + inst.Stop(stopCtx) + } + + return inst, cleanup +} + +// findFreePort finds an available TCP port. +func findFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to find free port: %v", err) + } + defer l.Close() + addr := l.Addr().(*net.TCPAddr) + return addr.Port +} + +// TorIPResponse represents the JSON response from check.torproject.org/api/ip +type TorIPResponse struct { + IsTor bool `json:"IsTor"` + IP string `json:"IP"` +} + +// fetchTorIP makes a request through a SOCKS5 proxy and returns the response. +func fetchTorIP(t *testing.T, socksPort int) *TorIPResponse { + t.Helper() + + dialer := &net.Dialer{Timeout: proxyTimeout} + proxyConn, err := dialer.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", socksPort)) + if err != nil { + t.Fatalf("failed to connect to SOCKS5 proxy: %v", err) + } + defer proxyConn.Close() + + // SOCKS5 handshake: version 5, 1 auth method (no auth) + _, err = proxyConn.Write([]byte{0x05, 0x01, 0x00}) + if err != nil { + t.Fatalf("SOCKS5 handshake failed: %v", err) + } + + buf := make([]byte, 2) + if _, err := proxyConn.Read(buf); err != nil { + t.Fatalf("SOCKS5 auth response failed: %v", err) + } + if buf[0] != 0x05 { + t.Fatalf("invalid SOCKS5 version: %d", buf[0]) + } + + // SOCKS5 connect request to check.torproject.org:443 + target := "check.torproject.org" + port := 443 + connectReq := []byte{ + 0x05, 0x01, 0x00, 0x03, byte(len(target)), + } + connectReq = append(connectReq, []byte(target)...) + connectReq = append(connectReq, byte(port>>8), byte(port)) + + _, err = proxyConn.Write(connectReq) + if err != nil { + t.Fatalf("SOCKS5 connect failed: %v", err) + } + + resp := make([]byte, 10) + n, err := proxyConn.Read(resp) + if err != nil { + t.Fatalf("SOCKS5 connect response failed: %v", err) + } + if n < 4 || resp[1] != 0x00 { + t.Fatalf("SOCKS5 connect rejected: status 0x%02x", resp[1]) + } + + // Send HTTP request through the tunnel + httpReq := "GET /api/ip HTTP/1.1\r\nHost: check.torproject.org\r\nConnection: close\r\n\r\n" + _, err = proxyConn.Write([]byte(httpReq)) + if err != nil { + t.Fatalf("HTTP request failed: %v", err) + } + + // Read response + response := make([]byte, 4096) + n, err = proxyConn.Read(response) + if err != nil { + t.Fatalf("HTTP response failed: %v", err) + } + + body := string(response[:n]) + // Extract JSON from HTTP response + jsonStart := strings.Index(body, "{") + jsonEnd := strings.LastIndex(body, "}") + if jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart { + t.Fatalf("no JSON in response: %s", body) + } + + var result TorIPResponse + if err := json.Unmarshal([]byte(body[jsonStart:jsonEnd+1]), &result); err != nil { + t.Fatalf("failed to parse response JSON: %v\nbody: %s", err, body[jsonStart:jsonEnd+1]) + } + + return &result +} + +// TestIntegration_TorExitDetected verifies that traffic through the Tor +// SOCKS5 proxy is detected as coming from Tor by check.torproject.org. +func TestIntegration_TorExitDetected(t *testing.T) { + cfg := integrationConfig(t) + inst, cleanup := startTestInstance(t, cfg) + defer cleanup() + + result := fetchTorIP(t, inst.SocksPort) + + if !result.IsTor { + t.Errorf("traffic not detected as Tor: IsTor=false, IP=%s", result.IP) + } + t.Logf("Tor exit IP: %s (IsTor: %v)", result.IP, result.IsTor) +} + +// TestIntegration_CircuitRotation verifies that multiple requests through +// the same Tor instance yield different exit IPs after NEWNYM signals. +func TestIntegration_CircuitRotation(t *testing.T) { + cfg := integrationConfig(t) + inst, cleanup := startTestInstance(t, cfg) + defer cleanup() + + ips := make(map[string]bool) + for i := 0; i < rotationRequests; i++ { + result := fetchTorIP(t, inst.SocksPort) + if result.IsTor { + ips[result.IP] = true + } + t.Logf("request %d: IP=%s IsTor=%v", i+1, result.IP, result.IsTor) + + // Request new circuit via control port + if i < rotationRequests-1 { + cookiePath := filepath.Join(inst.cfg.Paths.TempFiles, fmt.Sprintf("tor_data_0/control_auth_cookie")) + renewCircuit(t, inst.ControlPort, cookiePath) + time.Sleep(1 * time.Second) + } + } + + uniqueIPs := len(ips) + t.Logf("unique IPs: %d (of %d requests)", uniqueIPs, rotationRequests) + + // We expect at least 1 unique IP (proves Tor is working) + if uniqueIPs == 0 { + t.Error("no Tor exit IPs detected — Tor may not be routing traffic") + } +} + +// TestIntegration_DNSLeakPrevention verifies that DNS resolution through +// the Tor proxy returns a different IP than direct resolution (proving +// DNS goes through Tor, not the local resolver). +func TestIntegration_DNSLeakPrevention(t *testing.T) { + cfg := integrationConfig(t) + inst, cleanup := startTestInstance(t, cfg) + defer cleanup() + + // Resolve through Tor SOCKS5 proxy + // Use SOCKS5 to resolve "check.torproject.org" via Tor + torIPs := resolveViaTor(t, inst.SocksPort, "check.torproject.org") + + // Resolve directly (without Tor) + directIPs, err := net.LookupIP("check.torproject.org") + if err != nil { + t.Skipf("direct DNS resolution failed (network issue): %v", err) + } + + t.Logf("Tor-resolved IPs: %v", torIPs) + t.Logf("Direct IPs: %v", directIPs) + + // Check that Tor-resolved IPs are not the same as direct IPs + // (they shouldn't be — Tor exit resolves, not our local resolver) + leakFound := false + for _, torIP := range torIPs { + for _, directIP := range directIPs { + if torIP.Equal(directIP) { + leakFound = true + t.Errorf("DNS leak detected: Tor resolved %s to %s (same as direct resolution)", "check.torproject.org", directIP) + } + } + } + + if !leakFound && len(torIPs) > 0 { + t.Log("no DNS leak detected — Tor and direct IPs differ") + } +} + +// resolveViaTor resolves a hostname through a Tor SOCKS5 proxy using +// SOCKS5 domain resolution (ATYP 0x03). +func resolveViaTor(t *testing.T, socksPort int, hostname string) []net.IP { + t.Helper() + + dialer := &net.Dialer{Timeout: proxyTimeout} + conn, err := dialer.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", socksPort)) + if err != nil { + t.Fatalf("SOCKS5 connect failed: %v", err) + } + defer conn.Close() + + // SOCKS5 handshake + _, err = conn.Write([]byte{0x05, 0x01, 0x00}) + if err != nil { + t.Fatalf("SOCKS5 handshake failed: %v", err) + } + buf := make([]byte, 2) + if _, err := conn.Read(buf); err != nil { + t.Fatalf("SOCKS5 auth response: %v", err) + } + + // SOCKS5 domain resolution request (CMD=0xF0 = RESOLVE) + resolveReq := []byte{0x05, 0xF0, 0x00, 0x03, byte(len(hostname))} + resolveReq = append(resolveReq, []byte(hostname)...) + resolveReq = append(resolveReq, 0x00, 0x00) // port 0 (unused) + + _, err = conn.Write(resolveReq) + if err != nil { + t.Fatalf("SOCKS5 resolve request: %v", err) + } + + resp := make([]byte, 64) + n, err := conn.Read(resp) + if err != nil { + t.Fatalf("SOCKS5 resolve response: %v", err) + } + + // Parse response: version, status, reserved, ATYP, address + if n < 7 || resp[1] != 0x00 { + t.Fatalf("SOCKS5 resolve failed: status 0x%02x", resp[1]) + } + + var ips []net.IP + switch resp[3] { + case 0x01: // IPv4 + if n >= 10 { + ips = append(ips, net.IP(resp[4:8])) + } + case 0x04: // IPv6 + if n >= 22 { + ips = append(ips, net.IP(resp[4:20])) + } + case 0x03: // Domain (returned as-is in some configs) + domainLen := int(resp[4]) + if n >= 5+domainLen { + // Domain returned, not IP — try to resolve it + return nil + } + } + + return ips +} + +// renewCircuit sends a NEWNYM signal to the Tor control port. +func renewCircuit(t *testing.T, controlPort int, cookiePath string) { + t.Helper() + + cookie, err := os.ReadFile(cookiePath) + if err != nil { + t.Logf("WARN: cannot read cookie file: %v", err) + return + } + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", controlPort), 3*time.Second) + if err != nil { + t.Logf("WARN: cannot connect to control port: %v", err) + return + } + defer conn.Close() + + // AUTHENTICATE \r\n + authCmd := fmt.Sprintf("AUTHENTICATE %x\r\n", cookie) + _, err = conn.Write([]byte(authCmd)) + if err != nil { + t.Logf("WARN: authenticate failed: %v", err) + return + } + + resp := make([]byte, 256) + n, err := conn.Read(resp) + if err != nil { + t.Logf("WARN: auth response failed: %v", err) + return + } + if !strings.Contains(string(resp[:n]), "250 OK") { + t.Logf("WARN: auth rejected: %s", string(resp[:n])) + return + } + + // SIGNAL NEWNYM\r\n + _, err = conn.Write([]byte("SIGNAL NEWNYM\r\n")) + if err != nil { + t.Logf("WARN: NEWNYM failed: %v", err) + return + } +} + +// TestIntegration_ControlPortCookieAuth verifies that: +// 1. CookieAuthentication is set in the torrc +// 2. The control_auth_cookie file exists +// 3. Cookie file has restricted permissions (0600) +func TestIntegration_ControlPortCookieAuth(t *testing.T) { + cfg := integrationConfig(t) + inst, cleanup := startTestInstance(t, cfg) + defer cleanup() + + // Verify cookie file exists + cookiePath := filepath.Join(inst.cfg.Paths.TempFiles, "tor_data_0/control_auth_cookie") + info, err := os.Stat(cookiePath) + if err != nil { + t.Fatalf("control_auth_cookie file not found: %v", err) + } + + // Verify restricted permissions (owner read/write only) + perm := info.Mode().Perm() + if perm != 0600 { + t.Errorf("cookie file permissions = %04o, want 0600", perm) + } + + // Verify cookie file is non-empty + content, err := os.ReadFile(cookiePath) + if err != nil { + t.Fatalf("cannot read cookie file: %v", err) + } + if len(content) == 0 { + t.Error("cookie file is empty") + } + + t.Logf("control_auth_cookie: %d bytes, permissions %04o", len(content), perm) +} + +// TestIntegration_NoHashedControlPassword verifies that the torrc +// does NOT contain HashedControlPassword (cookie auth only). +func TestIntegration_NoHashedControlPassword(t *testing.T) { + cfg := integrationConfig(t) + inst, cleanup := startTestInstance(t, cfg) + defer cleanup() + + // Read the generated torrc + torrcPath := filepath.Join(inst.cfg.Paths.TempFiles, "tor_0.cfg") + content, err := os.ReadFile(torrcPath) + if err != nil { + t.Fatalf("cannot read torrc: %v", err) + } + + if strings.Contains(string(content), "HashedControlPassword") { + t.Error("torrc contains HashedControlPassword — should use cookie auth only") + } + if !strings.Contains(string(content), "CookieAuthentication 1") { + t.Error("torrc missing CookieAuthentication 1") + } +} diff --git a/internal/tor/restart.go b/internal/tor/restart.go new file mode 100644 index 0000000..332da75 --- /dev/null +++ b/internal/tor/restart.go @@ -0,0 +1,133 @@ +package tor + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/user/splitter/internal/cli" +) + +const ( + maxBackoff = 30 * time.Second + initialBackoff = 1 * time.Second + bootstrapGrace = 10 * time.Second +) + +func (inst *Instance) RunWithRestart(ctx context.Context, readyCh chan<- struct{}) { + var ( + failures int + onceReady sync.Once + ) + + for { + instCtx, cancel := context.WithCancel(ctx) + inst.cancelFunc = cancel + + inst.setState(StateBootstrapping) + err := inst.Start(instCtx) + if err != nil { + slog.Error("tor instance start failed", + cli.InstanceField(inst.ID), + "error", err, + ) + inst.setState(StateFailed) + + failures++ + backoff := backoffDuration(failures) + slog.Warn("restarting tor instance after backoff", + cli.InstanceField(inst.ID), + "backoff", backoff, + "failures", failures, + ) + + select { + case <-ctx.Done(): + cancel() + return + case <-time.After(backoff): + cancel() + continue + } + } + + waitCh := make(chan error, 1) + go func() { + waitCh <- inst.Wait() + }() + + bootstrapTimer := time.NewTimer(bootstrapGrace) + + restart := false + for { + select { + case <-ctx.Done(): + bootstrapTimer.Stop() + cancel() + return + case <-bootstrapTimer.C: + if inst.GetState() == StateBootstrapping { + inst.setState(StateReady) + failures = 0 + onceReady.Do(func() { + select { + case readyCh <- struct{}{}: + default: + } + }) + } + case waitErr := <-waitCh: + bootstrapTimer.Stop() + if waitErr != nil && inst.GetState() != StateFailed { + slog.Error("tor instance exited unexpectedly", + cli.InstanceField(inst.ID), + cli.CountryField(inst.Country), + "error", waitErr, + ) + inst.setState(StateFailed) + + failures++ + backoff := backoffDuration(failures) + slog.Warn("restarting tor instance", + cli.InstanceField(inst.ID), + "backoff", backoff, + "failures", failures, + ) + + select { + case <-ctx.Done(): + cancel() + return + case <-time.After(backoff): + cancel() + restart = true + } + } else { + cancel() + return + } + } + if restart { + break + } + } + } +} + +func backoffDuration(failures int) time.Duration { + if failures <= 0 { + return initialBackoff + } + d := initialBackoff + for i := 1; i < failures; i++ { + d *= 2 + if d >= maxBackoff { + return maxBackoff + } + } + if d > maxBackoff { + return maxBackoff + } + return d +} diff --git a/internal/tor/torrc_privacy_test.go b/internal/tor/torrc_privacy_test.go new file mode 100644 index 0000000..17ae9ac --- /dev/null +++ b/internal/tor/torrc_privacy_test.go @@ -0,0 +1,245 @@ +package tor + +import ( + "strings" + "testing" +) + +// TestTorrcTemplate_HardcodedSecurityOptions verifies that security-critical +// torrc directives that are hardcoded (not configurable) are always present +// in the rendered output. These prevent template regressions from silently +// disabling security features. +func TestTorrcTemplate_HardcodedSecurityOptions(t *testing.T) { + ic := baseInstanceConfig() + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + // SafeLogging: prevents Tor from logging potentially sensitive data + torrcContains(t, result, "SafeLogging 1") + + // NoExec: prevents Tor from executing external programs (attack surface) + torrcContains(t, result, "NoExec 1") + + // DisableDebuggerAttachment: prevents debugger attachment to Tor process + torrcContains(t, result, "DisableDebuggerAttachment 1") + + // EnforceDistinctSubnets: prevents multiple relays in the same /16 subnet + torrcContains(t, result, "EnforceDistinctSubnets 1") + + // ClientUseIPv4: ensures IPv4 is always enabled + torrcContains(t, result, "ClientUseIPv4 1") + + // RunAsDaemon 0: Tor runs in foreground (managed by SPLITTER) + torrcContains(t, result, "RunAsDaemon 0") +} + +// TestTorrcTemplate_ConfigurableSecurityOptions verifies that configurable +// security options from InstanceConfig appear correctly in rendered torrc. +// These are set in config but could be overridden to insecure values. +func TestTorrcTemplate_ConfigurableSecurityOptions(t *testing.T) { + ic := baseInstanceConfig() + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + // DNS leak prevention: reject SOCKS requests with hostnames that resolve + // to private/internal addresses + torrcContains(t, result, "SafeSocks 1") + + // Log when SOCKS safety checks reject a request + torrcContains(t, result, "TestSocks 1") + + // Strict node exclusion: if no nodes match EntryNodes/ExitNodes, fail + // instead of falling back to unlisted nodes + torrcContains(t, result, "StrictNodes 1") + + // Reject connections to internal/reserved IP addresses + torrcContains(t, result, "ClientRejectInternalAddresses 1") + + // Exclude nodes with unknown GeoIP country (prevents nodes in unknown + // jurisdictions from being selected) + torrcContains(t, result, "GeoIPExcludeUnknown 1") + + // Warn about plaintext ports (DNS, SMTP, etc.) + torrcContains(t, result, "WarnPlaintextPorts 21,23,25") + + // Automap .onion and .exit hostnames + torrcContains(t, result, "AutomapHostsSuffixes .exit,.onion") + + // Cookie authentication for control port (never password auth) + torrcContains(t, result, "CookieAuthentication 1") + torrcNotContains(t, result, "HashedControlPassword") + + // Entry guards for long-term entry node selection + torrcContains(t, result, "UseEntryGuards 1") +} + +// TestTorrcTemplate_SecurityOptionsCannotBeDisabled tests that even when +// security options are set to their weakest allowed value, the hardcoded +// protections remain. +func TestTorrcTemplate_SecurityOptionsCannotBeDisabled(t *testing.T) { + ic := baseInstanceConfig() + // Set security options to 0 (disabled) + ic.SafeSocks = 0 + ic.TestSocks = 0 + ic.StrictNodes = 0 + ic.ClientRejectInternalAddresses = 0 + ic.GeoIPExcludeUnknown = 0 + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + // Configurable options now render as 0 + torrcContains(t, result, "SafeSocks 0") + torrcContains(t, result, "StrictNodes 0") + + // But hardcoded options MUST still be present regardless of config + torrcContains(t, result, "SafeLogging 1") + torrcContains(t, result, "NoExec 1") + torrcContains(t, result, "DisableDebuggerAttachment 1") + torrcContains(t, result, "EnforceDistinctSubnets 1") + torrcContains(t, result, "CookieAuthentication 1") + torrcNotContains(t, result, "HashedControlPassword") +} + +// TestTorrcTemplate_ControlPortSecurity verifies control port security: +// cookie auth is used, cookie file path is specified, no password auth. +func TestTorrcTemplate_ControlPortSecurity(t *testing.T) { + ic := baseInstanceConfig() + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + // Cookie authentication must be enabled + torrcContains(t, result, "CookieAuthentication 1") + + // Cookie auth file must be in the instance data directory + torrcContains(t, result, "CookieAuthFile /tmp/test/control_auth_cookie") + + // Password-based auth must NEVER appear + torrcNotContains(t, result, "HashedControlPassword") +} + +// TestTorrcTemplate_WarnPlaintextPorts renders correctly +func TestTorrcTemplate_WarnPlaintextPorts(t *testing.T) { + ic := baseInstanceConfig() + ic.WarnPlaintextPorts = "21,23,25,80,109,110,143" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "WarnPlaintextPorts 21,23,25,80,109,110,143") +} + +// TestTorrcTemplate_AutomapHostsSuffixes renders correctly +func TestTorrcTemplate_AutomapHostsSuffixes(t *testing.T) { + ic := baseInstanceConfig() + ic.AutomapHostsSuffixes = ".exit,.onion" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "AutomapHostsSuffixes .exit,.onion") +} + +// TestTorrcTemplate_KeepalivePeriod renders correctly +func TestTorrcTemplate_KeepalivePeriod(t *testing.T) { + ic := baseInstanceConfig() + ic.KeepalivePeriod = 300 + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "KeepalivePeriod 300") +} + +// TestTorrcTemplate_LongLivedPorts renders correctly +func TestTorrcTemplate_LongLivedPorts(t *testing.T) { + ic := baseInstanceConfig() + ic.LongLivedPorts = []int{993, 995, 5222, 5223} + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "LongLivedPorts 993,995,5222,5223") +} + +// TestTorrcTemplate_SecurityDirectivesLineCount verifies that the rendered +// torrc contains a minimum number of security-relevant directives. This is a +// regression test to catch accidental removal of security options. +func TestTorrcTemplate_SecurityDirectivesLineCount(t *testing.T) { + ic := baseInstanceConfig() + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + securityDirectives := []string{ + "SafeLogging 1", + "NoExec 1", + "DisableDebuggerAttachment 1", + "EnforceDistinctSubnets 1", + "SafeSocks 1", + "StrictNodes 1", + "ClientRejectInternalAddresses 1", + "CookieAuthentication 1", + "GeoIPExcludeUnknown 1", + "ClientUseIPv4 1", + } + + found := 0 + for _, directive := range securityDirectives { + if strings.Contains(result, directive) { + found++ + } + } + + minExpected := len(securityDirectives) + if found < minExpected { + missing := []string{} + for _, d := range securityDirectives { + if !strings.Contains(result, d) { + missing = append(missing, d) + } + } + t.Errorf("only %d/%d security directives found, missing: %v", found, minExpected, missing) + } +} + +// TestTorrcTemplate_NoDeprecatedOptions verifies that known-deprecated or +// removed torrc options do NOT appear in the rendered output. +func TestTorrcTemplate_NoDeprecatedOptions(t *testing.T) { + ic := baseInstanceConfig() + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + // These options were removed or made obsolete in modern Tor versions + deprecated := []string{ + "CGOEnabled", // Not a torrc option (was erroneously emitted) + "OptimisticData", // Obsolete in Tor 0.4.9.x + "AllowDotExit", // Removed in Tor 0.4.x + "ClientDNSRejectInternalAddresses", // Removed, replaced by ClientRejectInternalAddresses + } + + for _, opt := range deprecated { + if strings.Contains(result, opt) { + t.Errorf("deprecated option %q found in rendered torrc\nfull output:\n%s", opt, result) + } + } +} diff --git a/internal/tor/torrc_template_test.go b/internal/tor/torrc_template_test.go new file mode 100644 index 0000000..a92afe9 --- /dev/null +++ b/internal/tor/torrc_template_test.go @@ -0,0 +1,332 @@ +package tor + +import ( + "os" + "strings" + "testing" +) + +func baseInstanceConfig() InstanceConfig { + return InstanceConfig{ + InstanceID: 0, + Country: "{US}", + SocksPort: 9050, + ControlPort: 9051, + HTTPTunnelPort: 0, + DataDir: "/tmp/test", + CircuitBuildTimeout: 60, + CircuitStreamTimeout: 20, + MaxCircuitDirtiness: 30, + NewCircuitPeriod: 30, + LearnCircuitBuildTimeout: 1, + CongestionControlAuto: false, + ConfluxEnabled: false, + RelayEnforce: "entry", + HiddenServiceEnabled: true, + HiddenServiceDir: "/tmp/hs", + HiddenServicePort: 8080, + ConnectionPadding: 0, + ReducedConnectionPadding: 1, + SafeSocks: 1, + TestSocks: 1, + ClientRejectInternalAddresses: 1, + StrictNodes: 1, + ClientOnly: 0, + GeoIPExcludeUnknown: 1, + FascistFirewall: 0, + FirewallPorts: []int{80, 443}, + LongLivedPorts: []int{1, 2}, + MaxClientCircuitsPending: 1024, + SocksTimeout: 35, + TrackHostExitsExpire: 10, + UseEntryGuards: 1, + NumEntryGuards: 1, + AutomapHostsSuffixes: ".exit,.onion", + WarnPlaintextPorts: "21,23,25", + RejectPlaintextPorts: "", + KeepalivePeriod: 15, + ControlAuth: "cookie", + StreamIsolation: false, + ClientUseIPv6: false, + } +} + +func readTorrcTemplate(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("../../templates/torrc.gotmpl") + if err != nil { + t.Fatalf("read torrc template: %v", err) + } + return string(data) +} + +func torrcContains(t *testing.T, result, substr string) { + t.Helper() + if !strings.Contains(result, substr) { + t.Errorf("expected output to contain %q\nfull output:\n%s", substr, result) + } +} + +func torrcNotContains(t *testing.T, result, substr string) { + t.Helper() + if strings.Contains(result, substr) { + t.Errorf("expected output NOT to contain %q\nfull output:\n%s", substr, result) + } +} + +func TestTorrcTemplate_EntryMode(t *testing.T) { + ic := baseInstanceConfig() + ic.RelayEnforce = "entry" + ic.Country = "{US}" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "EntryNodes {US}") + torrcNotContains(t, result, "ExitNodes") +} + +func TestTorrcTemplate_ExitMode(t *testing.T) { + ic := baseInstanceConfig() + ic.RelayEnforce = "exit" + ic.Country = "{DE}" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "ExitNodes {DE}") + torrcNotContains(t, result, "EntryNodes") +} + +func TestTorrcTemplate_SpeedMode(t *testing.T) { + ic := baseInstanceConfig() + ic.RelayEnforce = "speed" + ic.Country = "{FR}" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "EntryNodes") + torrcNotContains(t, result, "ExitNodes") + torrcContains(t, result, "speed mode") +} + +func TestTorrcTemplate_HTTP_TUNNEL_Port(t *testing.T) { + ic := baseInstanceConfig() + ic.HTTPTunnelPort = 5199 + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "HTTPTunnelPort 5199") +} + +func TestTorrcTemplate_NoHTTP_TUNNEL_Port(t *testing.T) { + ic := baseInstanceConfig() + ic.HTTPTunnelPort = 0 + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "HTTPTunnelPort") +} + +func TestTorrcTemplate_CongestionControl(t *testing.T) { + ic := baseInstanceConfig() + ic.CongestionControlAuto = true + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "CongestionControlAuto 1") +} + +func TestTorrcTemplate_NoCongestionControl(t *testing.T) { + ic := baseInstanceConfig() + ic.CongestionControlAuto = false + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "CongestionControlAuto") +} + +func TestTorrcTemplate_ConfluxEnabled(t *testing.T) { + ic := baseInstanceConfig() + ic.ConfluxEnabled = true + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "ConfluxEnabled 1") +} + +func TestTorrcTemplate_NoConflux(t *testing.T) { + ic := baseInstanceConfig() + ic.ConfluxEnabled = false + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "ConfluxEnabled") +} + +func TestTorrcTemplate_HiddenService(t *testing.T) { + ic := baseInstanceConfig() + ic.HiddenServiceEnabled = true + ic.HiddenServiceDir = "/tmp/hs" + ic.HiddenServicePort = 8080 + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "HiddenServiceDir /tmp/hs") + torrcContains(t, result, "HiddenServicePort 8080") +} + +func TestTorrcTemplate_NoHiddenService(t *testing.T) { + ic := baseInstanceConfig() + ic.HiddenServiceEnabled = false + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "HiddenServiceDir") + torrcNotContains(t, result, "HiddenServicePort") +} + +func TestTorrcTemplate_FascistFirewall(t *testing.T) { + ic := baseInstanceConfig() + ic.FascistFirewall = 1 + ic.FirewallPorts = []int{80, 443} + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "FascistFirewall 1") + torrcContains(t, result, "FirewallPorts 80,443") +} + +func TestTorrcTemplate_RejectPlaintextPorts(t *testing.T) { + ic := baseInstanceConfig() + ic.RejectPlaintextPorts = "25,119" + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "RejectPlaintextPorts 25,119") +} + +func TestTorrcTemplate_CookieAuthentication(t *testing.T) { + ic := baseInstanceConfig() + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "CookieAuthentication 1") +} + +func TestTorrcTemplate_SandboxEnabled(t *testing.T) { + ic := baseInstanceConfig() + ic.SandboxEnabled = true + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "Sandbox 1") + torrcNotContains(t, result, "Sandbox: disabled") +} + +func TestTorrcTemplate_SandboxDisabled(t *testing.T) { + ic := baseInstanceConfig() + ic.SandboxEnabled = false + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcNotContains(t, result, "Sandbox 1") + torrcContains(t, result, "Sandbox: disabled") +} + +func TestTorrcTemplate_StreamIsolationEnabled(t *testing.T) { + ic := baseInstanceConfig() + ic.StreamIsolation = true + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "SocksPort 9050 IsolateSOCKSAuth") +} + +func TestTorrcTemplate_StreamIsolationDisabled(t *testing.T) { + ic := baseInstanceConfig() + ic.StreamIsolation = false + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "SocksPort 9050") + torrcNotContains(t, result, "SocksPort 9050 IsolateSOCKSAuth") +} + +func TestTorrcTemplate_IPv6Enabled(t *testing.T) { + ic := baseInstanceConfig() + ic.ClientUseIPv6 = true + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "ClientUseIPv6 1") + torrcNotContains(t, result, "ClientUseIPv6 0") +} + +func TestTorrcTemplate_IPv6Disabled(t *testing.T) { + ic := baseInstanceConfig() + ic.ClientUseIPv6 = false + + result, err := RenderTorrc(ic, readTorrcTemplate(t)) + if err != nil { + t.Fatalf("RenderTorrc() error = %v", err) + } + + torrcContains(t, result, "ClientUseIPv6 0") + torrcNotContains(t, result, "ClientUseIPv6 1") +} diff --git a/internal/tor/version.go b/internal/tor/version.go new file mode 100644 index 0000000..b6c958c --- /dev/null +++ b/internal/tor/version.go @@ -0,0 +1,227 @@ +package tor + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// Version represents a parsed Tor version (major.minor.patch.release). +type Version struct { + Major int + Minor int + Patch int + Release int +} + +// DetectVersion runs `tor --version` and parses the version from the output. +func DetectVersion(ctx context.Context, binaryPath string) (*Version, error) { + type result struct { + v *Version + err error + } + ch := make(chan result, 1) + + go func() { + output, err := detectVersionOutput(binaryPath) + if err != nil { + ch <- result{nil, err} + return + } + v, err := parseVersionOutput(output) + ch <- result{v, err} + }() + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("DetectVersion: %w", ctx.Err()) + case r := <-ch: + return r.v, r.err + } +} + +func detectVersionOutput(binaryPath string) (string, error) { + cmd := exec.Command(binaryPath, "--version") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("detectVersionOutput: %w", err) + } + return string(output), nil +} + +// DetectVersionFromOutput parses a Tor version from a `tor --version` output string. +func DetectVersionFromOutput(output string) (*Version, error) { + return parseVersionOutput(output) +} + +// versionRegex matches "Tor version X.Y.Z" or "Tor version X.Y.Z.W" +var versionRegex = regexp.MustCompile(`Tor version (\d+)\.(\d+)\.(\d+)(?:\.(\d+))?`) + +func parseVersionOutput(output string) (*Version, error) { + lines := strings.Split(output, "\n") + for _, line := range lines { + matches := versionRegex.FindStringSubmatch(line) + if len(matches) >= 4 { + major, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + minor, err := strconv.Atoi(matches[2]) + if err != nil { + continue + } + patch, err := strconv.Atoi(matches[3]) + if err != nil { + continue + } + release := 0 + if len(matches) == 5 && matches[4] != "" { + release, err = strconv.Atoi(matches[4]) + if err != nil { + release = 0 + } + } + return &Version{Major: major, Minor: minor, Patch: patch, Release: release}, nil + } + } + return nil, fmt.Errorf("parseVersionOutput: no Tor version found in %q", output) +} + +func (v *Version) String() string { + return fmt.Sprintf("%d.%d.%d.%d", v.Major, v.Minor, v.Patch, v.Release) +} + +// SupportsConflux returns true for Tor >= 0.4.8. +func (v *Version) SupportsConflux() bool { + return v.atLeast(0, 4, 8, 0) +} + +// SupportsHTTPTunnel returns true for Tor >= 0.4.8. +func (v *Version) SupportsHTTPTunnel() bool { + return v.atLeast(0, 4, 8, 0) +} + +// SupportsCongestionControl returns true for Tor >= 0.4.7. +func (v *Version) SupportsCongestionControl() bool { + return v.atLeast(0, 4, 7, 0) +} + +// SupportsCGO returns true for Tor >= 0.4.9 (Counter Galois Onion encryption). +func (v *Version) SupportsCGO() bool { + return v.atLeast(0, 4, 9, 0) +} + +// SupportsTLS13 returns true for Tor >= 0.4.9, which recommends TLS 1.3 +// for link encryption. Actual TLS 1.3 support also depends on the linked +// OpenSSL version, but Tor 0.4.9+ is built to use it when available. +func (v *Version) SupportsTLS13() bool { + return v.atLeast(0, 4, 9, 0) +} + +// SupportsSandbox returns true for Tor >= 0.4.7. +// Tor's seccomp-bpf sandbox (Sandbox 1) has been available for a long time +// but had bugs in older versions. It is considered stable since 0.4.7+. +func (v *Version) SupportsSandbox() bool { + return v.atLeast(0, 4, 7, 0) +} + +// SupportsHappyFamilies returns true for Tor >= 0.4.9 (proposal 321). +// Happy Families groups relays from the same operator to avoid assigning +// multiple circuit hops to the same family. This is automatic in Tor 0.4.9+; +// no client-side torrc directives are needed. +func (v *Version) SupportsHappyFamilies() bool { + return v.atLeast(0, 4, 9, 0) +} + +// SupportsPostQuantum returns true for Tor >= 0.4.8.17, which introduced +// ML-KEM768 post-quantum key exchange when built with OpenSSL 3.5.0+. +// Note: actual PQ support also requires a compatible OpenSSL build; this +// only checks the Tor version prerequisite. +func (v *Version) SupportsPostQuantum() bool { + return v.atLeast(0, 4, 8, 17) +} + +func (v *Version) atLeast(major, minor, patch, release int) bool { + if v.Major != major { + return v.Major > major + } + if v.Minor != minor { + return v.Minor > minor + } + if v.Patch != patch { + return v.Patch > patch + } + return v.Release >= release +} + +// TLSInfo holds detected TLS/OpenSSL capabilities from the Tor binary. +type TLSInfo struct { + OpenSSLVersion string // e.g. "3.5.0", "1.1.1" + PostQuantumOK bool // true if OpenSSL >= 3.5.0 (ML-KEM768 capable) +} + +// DetectTLSInfo parses OpenSSL version from the full `tor --version` output. +// The second line typically contains: "Tor is running on Linux with ... OpenSSL X.Y.Z ..." +func DetectTLSInfo(ctx context.Context, binaryPath string) (*TLSInfo, error) { + type result struct { + info *TLSInfo + err error + } + ch := make(chan result, 1) + + go func() { + output, err := detectVersionOutput(binaryPath) + if err != nil { + ch <- result{nil, err} + return + } + info := parseTLSInfo(output) + ch <- result{info, nil} + }() + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("DetectTLSInfo: %w", ctx.Err()) + case r := <-ch: + return r.info, r.err + } +} + +// DetectTLSInfoFromOutput parses TLS info from a `tor --version` output string. +func DetectTLSInfoFromOutput(output string) *TLSInfo { + return parseTLSInfo(output) +} + +var openSSLRegex = regexp.MustCompile(`OpenSSL\s+(\d+)\.(\d+)\.(\d+)`) + +func parseTLSInfo(output string) *TLSInfo { + info := &TLSInfo{} + + matches := openSSLRegex.FindStringSubmatch(output) + if len(matches) == 4 { + info.OpenSSLVersion = matches[0] // "OpenSSL X.Y.Z" + + major, err1 := strconv.Atoi(matches[1]) + minor, err2 := strconv.Atoi(matches[2]) + patch, err3 := strconv.Atoi(matches[3]) + + if err1 == nil && err2 == nil && err3 == nil { + info.PostQuantumOK = isOpenSSLAtLeast(major, minor, patch, 3, 5, 0) + } + } + + return info +} + +func isOpenSSLAtLeast(major, minor, patch, wantMajor, wantMinor, wantPatch int) bool { + if major != wantMajor { + return major > wantMajor + } + if minor != wantMinor { + return minor > wantMinor + } + return patch >= wantPatch +} diff --git a/internal/tor/version_test.go b/internal/tor/version_test.go new file mode 100644 index 0000000..63a7b1a --- /dev/null +++ b/internal/tor/version_test.go @@ -0,0 +1,247 @@ +package tor + +import ( + "testing" +) + +func TestParseVersionOutput(t *testing.T) { + tests := []struct { + name string + output string + want *Version + wantErr bool + }{ + { + name: "standard 3-component format", + output: "Tor version 0.4.8.\n", + want: &Version{Major: 0, Minor: 4, Patch: 8, Release: 0}, + }, + { + name: "4-component format 0.4.8.17", + output: "Tor version 0.4.8.17.\n", + want: &Version{Major: 0, Minor: 4, Patch: 8, Release: 17}, + }, + { + name: "with extra text", + output: "Tor version 0.4.7.13 (git-1234abcd).\nTor is running on Linux.\n", + want: &Version{Major: 0, Minor: 4, Patch: 7, Release: 13}, + }, + { + name: "newer version 0.4.9.1", + output: "Tor version 0.4.9.1.\n", + want: &Version{Major: 0, Minor: 4, Patch: 9, Release: 1}, + }, + { + name: "0.4.7.0", + output: "Tor version 0.4.7.0.\n", + want: &Version{Major: 0, Minor: 4, Patch: 7, Release: 0}, + }, + { + name: "0.4.9.5", + output: "Tor version 0.4.9.5.\n", + want: &Version{Major: 0, Minor: 4, Patch: 9, Release: 5}, + }, + { + name: "no version", + output: "Some other program version 1.2.3\n", + wantErr: true, + }, + { + name: "empty output", + output: "", + wantErr: true, + }, + { + name: "version in middle of line", + output: "Starting Tor version 0.4.8.0 running on x86_64\n", + want: &Version{Major: 0, Minor: 4, Patch: 8, Release: 0}, + }, + { + name: "version 0.4.8.16 no PQ", + output: "Tor version 0.4.8.16.\n", + want: &Version{Major: 0, Minor: 4, Patch: 8, Release: 16}, + }, + { + name: "version 0.4.8.17 has PQ", + output: "Tor version 0.4.8.17.\n", + want: &Version{Major: 0, Minor: 4, Patch: 8, Release: 17}, + }, + { + name: "version 0.4.9.0 has PQ (0.4.9 > 0.4.8.17)", + output: "Tor version 0.4.9.0.\n", + want: &Version{Major: 0, Minor: 4, Patch: 9, Release: 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DetectVersionFromOutput(tt.output) + if (err != nil) != tt.wantErr { + t.Errorf("DetectVersionFromOutput() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got.Major != tt.want.Major || got.Minor != tt.want.Minor || + got.Patch != tt.want.Patch || got.Release != tt.want.Release { + t.Errorf("DetectVersionFromOutput() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestVersion_FeatureDetection(t *testing.T) { + tests := []struct { + name string + version *Version + conflux bool + httpTunnel bool + congestionControl bool + cgo bool + postQuantum bool + happyFamilies bool + tls13 bool + sandbox bool + }{ + {"0.4.6.99", &Version{0, 4, 6, 99}, false, false, false, false, false, false, false, false}, + {"0.4.7.0", &Version{0, 4, 7, 0}, false, false, true, false, false, false, false, true}, + {"0.4.7.9", &Version{0, 4, 7, 9}, false, false, true, false, false, false, false, true}, + {"0.4.8.0", &Version{0, 4, 8, 0}, true, true, true, false, false, false, false, true}, + {"0.4.8.10", &Version{0, 4, 8, 10}, true, true, true, false, false, false, false, true}, + {"0.4.8.16", &Version{0, 4, 8, 16}, true, true, true, false, false, false, false, true}, + {"0.4.8.17 PQ boundary", &Version{0, 4, 8, 17}, true, true, true, false, true, false, false, true}, + {"0.4.8.22", &Version{0, 4, 8, 22}, true, true, true, false, true, false, false, true}, + {"0.4.9.0", &Version{0, 4, 9, 0}, true, true, true, true, true, true, true, true}, + {"0.4.9.5", &Version{0, 4, 9, 5}, true, true, true, true, true, true, true, true}, + {"0.5.0.0", &Version{0, 5, 0, 0}, true, true, true, true, true, true, true, true}, + {"1.0.0.0", &Version{1, 0, 0, 0}, true, true, true, true, true, true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.version.SupportsConflux(); got != tt.conflux { + t.Errorf("SupportsConflux() = %v, want %v", got, tt.conflux) + } + if got := tt.version.SupportsHTTPTunnel(); got != tt.httpTunnel { + t.Errorf("SupportsHTTPTunnel() = %v, want %v", got, tt.httpTunnel) + } + if got := tt.version.SupportsCongestionControl(); got != tt.congestionControl { + t.Errorf("SupportsCongestionControl() = %v, want %v", got, tt.congestionControl) + } + if got := tt.version.SupportsCGO(); got != tt.cgo { + t.Errorf("SupportsCGO() = %v, want %v", got, tt.cgo) + } + if got := tt.version.SupportsPostQuantum(); got != tt.postQuantum { + t.Errorf("SupportsPostQuantum() = %v, want %v", got, tt.postQuantum) + } + if got := tt.version.SupportsHappyFamilies(); got != tt.happyFamilies { + t.Errorf("SupportsHappyFamilies() = %v, want %v", got, tt.happyFamilies) + } + if got := tt.version.SupportsTLS13(); got != tt.tls13 { + t.Errorf("SupportsTLS13() = %v, want %v", got, tt.tls13) + } + if got := tt.version.SupportsSandbox(); got != tt.sandbox { + t.Errorf("SupportsSandbox() = %v, want %v", got, tt.sandbox) + } + }) + } +} + +func TestVersion_String(t *testing.T) { + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 17} + if got := v.String(); got != "0.4.8.17" { + t.Errorf("String() = %q, want %q", got, "0.4.8.17") + } +} + +func TestVersion_StringZeroRelease(t *testing.T) { + v := &Version{Major: 0, Minor: 4, Patch: 8, Release: 0} + if got := v.String(); got != "0.4.8.0" { + t.Errorf("String() = %q, want %q", got, "0.4.8.0") + } +} + +func TestBackoffDuration(t *testing.T) { + tests := []struct { + failures int + wantSec float64 + }{ + {0, 1}, + {1, 1}, + {2, 2}, + {3, 4}, + {4, 8}, + {5, 16}, + {6, 30}, + {10, 30}, + {100, 30}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got := backoffDuration(tt.failures) + if got.Seconds() != tt.wantSec { + t.Errorf("backoffDuration(%d) = %v, want %vs", tt.failures, got, tt.wantSec) + } + }) + } +} + +func TestDetectTLSInfoFromOutput(t *testing.T) { + tests := []struct { + name string + output string + wantVersion string + wantPQ bool + }{ + { + name: "OpenSSL 3.5.0 PQ capable", + output: "Tor version 0.4.8.17.\nTor is running on Linux with Libevent 2.1.12-stable, OpenSSL 3.5.0, Zlib 1.2.13, Liblzma 5.4.1, and Libzstd 1.5.4.\n", + wantVersion: "OpenSSL 3.5.0", + wantPQ: true, + }, + { + name: "OpenSSL 3.0.0 not PQ capable", + output: "Tor version 0.4.8.17.\nTor is running on Linux with Libevent 2.1.12-stable, OpenSSL 3.0.0, Zlib 1.2.13.\n", + wantVersion: "OpenSSL 3.0.0", + wantPQ: false, + }, + { + name: "OpenSSL 1.1.1 not PQ capable", + output: "Tor version 0.4.8.10.\nTor is running on Linux with OpenSSL 1.1.1w.\n", + wantVersion: "OpenSSL 1.1.1", + wantPQ: false, + }, + { + name: "OpenSSL 3.6.0 PQ capable", + output: "Tor version 0.4.9.5.\nTor is running on Linux with OpenSSL 3.6.0.\n", + wantVersion: "OpenSSL 3.6.0", + wantPQ: true, + }, + { + name: "no OpenSSL info", + output: "Tor version 0.4.7.13.\n", + wantVersion: "", + wantPQ: false, + }, + { + name: "LibreSSL not PQ capable", + output: "Tor version 0.4.8.17.\nTor is running on Linux with LibreSSL 3.8.0.\n", + wantVersion: "", + wantPQ: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := DetectTLSInfoFromOutput(tt.output) + if info.OpenSSLVersion != tt.wantVersion { + t.Errorf("OpenSSLVersion = %q, want %q", info.OpenSSLVersion, tt.wantVersion) + } + if info.PostQuantumOK != tt.wantPQ { + t.Errorf("PostQuantumOK = %v, want %v", info.PostQuantumOK, tt.wantPQ) + } + }) + } +} diff --git a/func/banner.func b/legacy/func/banner.func similarity index 100% rename from func/banner.func rename to legacy/func/banner.func diff --git a/func/boot_tor_instances.func b/legacy/func/boot_tor_instances.func similarity index 100% rename from func/boot_tor_instances.func rename to legacy/func/boot_tor_instances.func diff --git a/func/boot_tor_per_country.func b/legacy/func/boot_tor_per_country.func similarity index 100% rename from func/boot_tor_per_country.func rename to legacy/func/boot_tor_per_country.func diff --git a/func/change_country_on_the_fly.func b/legacy/func/change_country_on_the_fly.func similarity index 100% rename from func/change_country_on_the_fly.func rename to legacy/func/change_country_on_the_fly.func diff --git a/func/check_if_port_available.func b/legacy/func/check_if_port_available.func similarity index 100% rename from func/check_if_port_available.func rename to legacy/func/check_if_port_available.func diff --git a/func/help.func b/legacy/func/help.func similarity index 100% rename from func/help.func rename to legacy/func/help.func diff --git a/func/killprevious_instances.func b/legacy/func/killprevious_instances.func similarity index 100% rename from func/killprevious_instances.func rename to legacy/func/killprevious_instances.func diff --git a/func/loadbalancing_choice.func b/legacy/func/loadbalancing_choice.func similarity index 100% rename from func/loadbalancing_choice.func rename to legacy/func/loadbalancing_choice.func diff --git a/func/pre_loading.func b/legacy/func/pre_loading.func similarity index 100% rename from func/pre_loading.func rename to legacy/func/pre_loading.func diff --git a/func/random_country.func b/legacy/func/random_country.func similarity index 100% rename from func/random_country.func rename to legacy/func/random_country.func diff --git a/func/settings.cfg b/legacy/func/settings.cfg similarity index 100% rename from func/settings.cfg rename to legacy/func/settings.cfg diff --git a/func/user_start_input.func b/legacy/func/user_start_input.func similarity index 100% rename from func/user_start_input.func rename to legacy/func/user_start_input.func diff --git a/legacy/settings.cfg b/legacy/settings.cfg new file mode 100755 index 0000000..9754c0a --- /dev/null +++ b/legacy/settings.cfg @@ -0,0 +1,573 @@ +#!/bin/bash +# ########################################### +# ########## ########## +# ########## DcLabs - SPLITTER ########## +# ########## Ver: 0.1.0 ########## +# ########## ########## +# ########################################### +# +# Created By: Rener Alberto (aka Gr1nch) - DcLabs Security Team +# E-Mail: rener.silva@protonmail.com +# PGP Key ID: 0x65f912ed59949f8e +# PGP Key FingerPrint: 7B7A 8E83 82D3 DACD 4B3B CFE0 65F9 12ED 5994 9F8E +# PGP KEY Download: https://pgp.mit.edu/pks/lookup?op=get&search=0x65F912ED59949F8E +# +# BSD License - Do whatever you want with this script, but take the responsibility! + + +####################################################################################### +################################# ################################# +################################# PRE CONFIGURATION ################################# +################################# ################################# +####################################################################################### + +# PLEASE, READ BEFORE EXECUTE THIS SCRIPT!!!! +# +# +# This script is using TOR GEOIP to avoid create circuits or use the same coutry as TOR "ENTRY NODE" and TOR "EXIT NODE". +# In another words, we will always have "hops" in relays from different countries difficulting the correlation of events +# due to the high fragmentation of request among TOR INSTANCES running in different countries. +# +# Think about what is the effort necessary for your adversary to be able to compromise a TOR node in divergents countries like: +# United States, Russia, Japan, China, North Korea, South Korea, Brazil, Sweden, Island, Ukraine etc. +# Those countries have different laws and legal bureaucracies. The legal privacy breach could not be so easy. +# +# Ok, but let's consider that your adversary could be able to compromise the entire TOR NETWORK. Now think about the effort necessary +# to your adversary be able to correlate all your requestes fragmented between all different TOR Instances running in different countries +# and using different TOR circuits for each request. Well... GOOD LUCK GUYS! Try harder! +# +# +# You can control the selection of countries and it will affect the choice of "ENTRY NODES" or the choice of "EXIT NODES". +# By default the script will specify the countries to be used as ENTRY NODES and will use any other country as "EXIT NODES" +# but never user the same country that was selected to be used as ENTRY NODE. +# +# If you need to bypass a GeoIP protection, you can define the countries that will be used as EXIT-NODES. In this mode you will always, +# hit your target using IP's from the specific countries. +# +# If you realy need a FAST connection, you can force the SCRIPT to use the SAME COUNTRY for all TOR NETWORK hops. +# It means that the script will create the TOR INSTANCE USING the SAME COUNTRY as ENTRY NODE, "INTERNAL TOR JUMP NODE" and EXIT NODE. +# In summary you're reducing the DELAY between the hops and ensuring the BEST SPEED PERFORMANCE of the TOR instance. +# CAUTION! This option is not considered a safe option, because your adversary can COMPROMISE the ENTRY and the EXIT node and correlate your +# internet traffic. In another hand you can use a high number of COUNTRIES to reduce the amount of data that your adversary will be able to collect. +# +# The options available are: +# 1) entry: This is the default option and the best approach for security and anonimity. +# The load balancing algorithm for this option is: Static Round Robin. +# +# 2) exit : This option is GOOD to bypass GeoIP protections. But reduce the number of EXIT NODES that TOR can use. Repeat the same exit node. +# The load balancing algorithm for this option is: Static Round Robin. +# +# 3) speed: This option is the FASTEST option but reduces the security. +# Be carrefull and try to use a HIGH NUMBER OF COUNTRIES if you enable this one. +# The load balancing algorithm for this option is: Least Connections Round Robin. +# This option is good for downloading or media streaming, but it's not recomended for PENETRATION TESTING. +# Also, some contries doesn't have ENTRY GUARDS and EXIT NODES enougth to create valid circuits. Keep your eyes on the +# status/helth check URL Monitor to detect and avoid those countries. By default the helth check is: http://0.0.0.0:3129/status +# +# About the load balancing algorithms: +# Round Robin: Each TOR INSTANCE is used in turns. If a TOR INSTANCE have no valid circuits available +# the algorithm will consider this instance down and skip it until have a valid circuit open and ready to be used. +# When the TOR INSTANCE manage to creat a valid circuit, the script will test it and immediately reintroduced into +# the farm, once the full map is recomputed. +# +# Least Connections Round Robin: The TOR INSTANCE with lowest number of connections receives the next connection. +# Round-robin is performed to ensure that all servers will be used but the fastests TOR INSTANCES could receive +# and process more request then the slow ones. It increase the chances of you use an compromised TOR circuit or in case +# of penetration testing be detected for INTRUSION PREVENT SYSTEMS because you can HIT your target more times with the +# same IP address, allowing the IPS to correlate the behavior and detect your attack. +# + +# The list of COUNTRIES is comma separete and you should only use 2 Letters to define the country code. +# You can use the website: https://metrics.torproject.org/ to know more about the TOR relays around the world. . +# Sample of a specific list of countries: +# {SE},{NO},{RO},{DE},{LV},{AT},{CA},{MD},{CH} +# +# The default is "RANDOM". The script will select ramdom countries among +MY_COUNTRY_LIST="RANDOM" + +# List of countries that TOR can use to create the circuits. Note: I removed from this list all countries which doesn't +# have EXIT NODES and also countries that ONLY have slow TOR relays. +# The default list has 32 different countries + +ACCEPTED_COUNTRIES="{AU},{AT},{BE},{BG},{CA},{CZ},{DK},{FI},{FR},{DE},{HU},{IS},{LV},{LT},{LU},{MD},{NL},{NO},{PA},{PL},{RO},{RU},{SC},{SG},{SK},{ES},{SE},{CH},{TR},{UA},{GB},{US}" + +#The list of countries that will never be used. These countries don't have EXIT NODES or only have SLOW tor relays. +#This list has ?? countries that you should avoid to have a good TOR performance and more EXIT NODES. +BLACKLIST_COUNTRIES="{ZA},{KN},{JP},{IT},{IE},{ID},{HR},{CR},{AL},{MY},{HK},{EE},{CL},{NZ},{TH},{IN},{AR},{KR},{BR},{VN},{IL},{SI},{GR},{DZ},{AM},{AZ},{BD},{BY},{MO},{CO},{CI},{CY},{EC},{EG},{SV},{ET},{GA},{GT},{HN},{IR},{KZ},{KE},{KW},{KG},{LB},{MT},{MQ},{MR},{MX},{MN},{MA},{MZ},{NG},{PK},{PH},{QA},{SA},{SN},{RS},{TN},{UY},{VE},{YE},{DO},{LR},{MA},{NG},{PK},{PY},{QA},{SA},{UY},{SN},{VE}" + +# Could be considered as exit nodes: {ZA},{KN},{PT},{JP},{IT},{IE},{CR},{AL},{EE},{GR} +# Could be considered as entry nodes: {TH},{IN},{IL},{CY},{LR} + + +#To increese the security, by default this script will change the country related with the TOR INSTANCE. +#It's a good strategy to reduce even more the chances of still using the same compromised TOR RELAY. +#In summary this option will reduce even more the total amount of date that your adversary will be able to collect on his compromised TOR RELAY. +#Set this options to "NO" if you do not expect change the countries. It's usefull for specific cases of GeoIP bypass. +CHANGE_COUNTRY_ONTHEFLY="YES" + +# Select a ramdom country and change all instances related with this country every "X" seconds. +# This option defines de delay in seconds between the changes of countries. +# The script will select a ramdm instance and change the current country of this instance selecting a ramdom country from your "ACCEPTED COUNTRIES LIST". +# This reduces even more the chances of your adversary intercept and correlate your actions, because will set a different country for the new circuits. +# The script will change only 1 instance per time, respecting the delay of this option. +# Considering that every instance is already changing the circuit every 10 seconds. You can consider a minimum value for this options like 30 seconds. +# This will make the script use the same country for at least 3 times before change to another country. +CHANGE_COUNTRY_INTERVAL="120" + +# How many countries should be changed? +# You should define how many countries the script will change on the fly. +# For a better between stability and security, do not change more than the half of your total amount of countries. +# By default the script will change on the fly the half of your countries. +TOTAL_COUNTRIES_TO_CHANGE="10" + + +#Number of times that the script will retry to connect and send your request. +RETRIES="100" + +#All timeouts will based on this value. +#Suggestion: Do not set it lower than 10 seconds. I would suggest 30 seconds. +MINIMUM_TIMEOUT="20" + +#Maximum Concurrent Conections per TOR INSTANCE +#By default This script is using 20. +MAX_CONCURRENT_REQUEST="20" + +#First sock port to bind. The script will increase this number for each instance. +#The number will increase according to the number of TOR INSTANCES in execution. +#In another words, if you are running 7 instances in 7 different countries you'll have 49 intances and total amount of ports +#considering the default settings is: (7x7=49 instances) --> 8.999+49(instances) = 9.048 +#Considering the previos example, you should check if all ports between 8.999 and 9.048 are free! +START_SOCKS_PORT="4999" + +#First control port to bind. The script will increase this number for each instance. +#The number will increase according to the number of TOR INSTANCES in execution. +#In another words, if you are running 7 instances in 7 different countries you'll have 49 intances and total amount of ports +#considering the default settings is: (7x7=49 instances) --> 4.999+49(instances) = 5.048 +#Considering the previos example, you should check if all ports between 4.999 and 5.048 are free! +START_CONTROL_PORT="5999" + + +#First local DNS port to bind. The script will increase this number for each instance. +#The number will increase according to the number of TOR INSTANCES in execution. +START_DNS_PORT="5299" + +#Firt local HTTP Port to bind. The script will increase this number for each instance. +#The number will increase according to the number of TOR INSTANCES in execution. +TOR_START_HTTP_PORT="5199" + +#Firt local Transparent Proxy-Port to bind. The script will increase this number for each instance. +#The number will increase according to the number of TOR INSTANCES in execution. +TOR_START_TransPort="5099" + +#Catch the TOR binary path +TORPATH="/usr/local/bin/tor" + +#TOR Binary Path +HAPROXY_PATH="/usr/sbin/haproxy" + +#Catch the user logged +USER_ID=$(id | cut -d ")" -f 1 | cut -d "(" -f 2) +USER_UID=$(id | sed "s|(|\n|g" | sed "s|) |\n|g" | grep "=" | grep "uid" | cut -d "=" -f 2) +USER_GID=$(id | sed "s|(|\n|g" | sed "s|) |\n|g" | grep "=" | grep "gid" | cut -d "=" -f 2) + +# CAUTION! CAUTION! CAUTION!!! +# This directory will be complete deleted if already exist! +#By default the script will keep all temporary and config files inside /tmp/ramdon_tor. +#Even inside the /tmp directory, this script will set the permissions in this directory ONLY for the root and the current user. +TOR_TEMP_FILES="/tmp/splitter" + +#Define the TOR sockets to listen only for the localhost +LISTEN_ADDR="0.0.0.0" + +#DNS Loadbalance Listen +DNSDIST_SERVER_LISTEN="0.0.0.0" + +#DNS LoadBalance Port +DNSDIST_SERVER_PORT="5353" + +#Define the TOR DNS to listen only for the localhost +TOR_DNS_LISTEN="0.0.0.0" + +# By default the logs are disabled! No logs, no crime! +# If you want's to enable logs for debug reasons, locate it in the lines bellow and remove the comment. +LOGDIR="${TOR_TEMP_FILES}" + +# By default logs are disabled! No logs, no crime! +# If you want's to enable logs for debug reasons, locate it in the lines bellow and remove the comment. +LOGNAME="tor_log_" + +#Generate a random string to use for TOR control password and HAPROXY stats password. +RAND_PASS=$(dd if=/dev/urandom bs=40 count=1 2> /dev/null | base64) + +#Using TOR to HASH the RAND_PASS generated before. +TORPASS=$($TORPATH --hash-password "${RAND_PASS}"|grep "16:") + +#Set the PRIVOXY binary path +PRIVOXY_PATH="/usr/sbin/privoxy" + +#Set the path for the PRIVOXY config file! +#This script will create the config file and execute the PRIVOXY using this config. +PRIVOXY_FILE="${TOR_TEMP_FILES}/privoxy_splitter_config_" + +#Set the path for the HAPROXY config file! +#This script will create the config file and execute the HAPROXY using this config. +MASTER_PROXY_CFG="${TOR_TEMP_FILES}/splitter_master_proxy.cfg" + +#Set the PRIVOXY to listen only for the local host. +#If you want to share your RANDOM TOR connection with everyone, set it to 0.0.0.0 +PRIVOXY_LISTEN="0.0.0.0" + +#Set the HAPROXY to listen only for the local host. +#If you want to share your RANDOM TOR connection with everyone, set it to 0.0.0.0 +MASTER_PROXY_LISTEN="0.0.0.0" + +#Set Which port HAPROXY will bind to HTTP client connections. +#You will use this same port in the proxy settings of your brownser. +MASTER_PROXY_SOCKS_PORT="63536" + +#Set Which port HAPROXY will bind to HTTP client connections. +#You will use this same port in the proxy settings of your brownser. +MASTER_PROXY_HTTP_PORT="63537" + +#Set Which port HAPROXY will bind to HTTP client connections. +#You will use this same port in the proxy settings of your brownser. +MASTER_PROXY_TRANSPARENT_PORT="63538" + +#Set the MASTER PROXY STATUS to listen only for the local host. +MASTER_PROXY_STAT_LISTEN="0.0.0.0" + +#Set the stats port for HAPROXY. +#You can connect to this port to check the statistics about the COUNTRIES +MASTER_PROXY_STAT_PORT="63539" + +#Set the HAPROXY stats URI +#You can access the status page using: +MASTER_PROXY_STAT_URI="/splitter_status" + +#Set the MASTER PROXY Status Password +MASTER_PROXY_STAT_PWD="${RAND_PASS}" + +#Set Which port the PRIVOXY will bind to the SUB-PROXY or COUNTRY PROXIES. +#The Master proxy will use this increase port numbers as FORWARD PROXIES. +#The number will increase according to the number of TOR INSTANCES in execution. +#In another words, if you are running 7 instances in 7 different countries you'll have 49 intances and total amount of ports +#considering the default settings is: (7x7=49 instances) --> 10999+49(instances) = 11,048 +#Considering the previos example, you should check if all ports between 10.999 and 11.048 are free! +PRIVOXY_START_PORT="6999" + +#Proxychains config file. +# The first place where proxychains will lookup for the config file is in the current directory. +# The second place is inside the directory .proxychains locate inside the "home" directory of the current user. +# The third place is the default /etc/proxychains.conf +#This script assumes that this directory already exists inside the "home" directory of the user. +PROXYCHAINS_FILE="${HOME}/.proxychains/proxychains.conf" + +#TOR CIRCUIT HEALTH CHECK TARGET DOMAIN +#The script will try to access this address using TOR to check if the TOR circuit is alive and have no DNS resolution related problems. +#Use only server with HTTPS support! Remove the https:// like the sample. +#Samples: +#1) HEALTH_CHECK_URL="protonirockerxow.onion" +#2) HEALTH_CHECK_URL="www.google.com" +HEALTH_CHECK_URL="www.google.com" + +#Define the interval between the checks of HELTH CHECK. +#Default: HEALTH_CHECK_INTERVAL="5" +HEALTH_CHECK_INTERVAL="3" + +#How many times the HEALTH check can fail before consider the TOR INSTANCE DOWN? +#If you need to ensure a better stability you need to keep this number very low. +#Consider the TOR INSTANCE DOWN IF the following number of HEALTH check request fail. +HEALTH_CHECK_MAX_FAIL="1" + +#How many time the HEALTH check need to succeed before consider the TOR INSTANCE UP? +#Consider the TOR INSTANCE UP if the following number of HEALTH check request succeed. +HEALTH_CHECK_MININUM_SUCESS="1" + +#Please keep the value below updated! +#The script will remove any User-Agent sent by your brownser or your applications and replace for this one. +#The adversary can check if you are using a different User-Agent and identify you. +#This feature will make your traffic more similar with all other TOR users. +#Check out the current TOR Brownser user agent and keep this value updated. +TOR_BROWNSER_USER_AGENT="Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0" + +#This proxy will not be used to, by default TOR do not accept these networks: +DO_NOT_PROXY="127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,0.0.0.0/8,100.64.0.0/10,169.254.0.0/16,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24,224.0.0.0/4,240.0.0.0/4,255.255.255.255/32" + + +######################################################### +################ TUNNING CONFIGS SESSION ################ +######################################################### + +# You can configure how long time each "layer" of proxies should wait before the timeout. +# The suggestions bellow are aiming the best performance/stability. +# The explanation about each parameter is based on the original documentation. +# These options will define define the TOR tunning options. +# +# #################### +# #### CAUTION!!! #### +# #################### +# Please read the description of each option to understand and perform your own tunning if you're using a slow internet connection. +# The use of TOR BRIDGE defeat the propose of this script for this reason I'm not considering the TOR BRIDGE as one option for this script. +# Also, the main idea of my TOR infrastructure is to keep this script running in a VPS which uses a VPN as default gateway. In another words. +# It's not safe to use conect directly to the TOR without a VPN connection. +# All setting related with TOR are trying to respecting and follow the DEFAULT settings but focused in security. +# You don't need to change nothing bellow this line to have a good TOR experience. All setting below are focused in the best approach for +# security and privacy. +############################################################################################################################ + +## Level 1: TOR Settings + +# RejectPlaintextPorts --> Like WarnPlaintextPorts, but instead of warning about risky port uses, +# Tor will instead refuse to make the connection. +# (TOR Default is: None, because they are assuming that you are running tor with TOR BROWNSER with plugin +# 'HTTPS Everywhere' enabled to blocks unecrypted web sites). +# Suggestion: By default this options is not enabled! But you can block the traffic for unecripted ports. +# This option is not recomended if you're using hidding services(sites from deepweb), but if you will not access hidden services and will +# not use this script for penetration test propose, you can enable it. +# Please, be aware that many websites and java scripts still sending data unecrypted using the port 80. By enable this option +# you will only send and receive data using encrypted ports. All data send or received at unecrypted ports will be dropped. +# It's a good security approach but can broke the functionality of many websites and scripts that only send unecrypted data. +#RejectPlaintextPorts="21,23,25,80,109,110,143" + +# WarnPlaintextPorts --> Tells Tor to issue a warnings whenever the user tries to make an anonymous connection to one of these ports. +# This option is designed to alert users to services that risk sending username and passwords in the clear. +# (TOR Default is: 23,109,110,143) +# Suggestion: This options will not block! But will include a "warning" message inside the TOR LOG file, about data beeing send using +# unecrypted ports. Can be use to detect if a specific website is sending data unecrypted. +WarnPlaintextPorts="21,23,25,80,109,110,143" + +# CircuitBuildTimeout --> Try for at most NUM seconds when building circuits. If the circuit isn’t open in that time, give up on it. +# (The TOR Default is 60 seconds) +CircuitBuildTimeout="60" + + +# CircuitsAvailableTimeout NUM +# Tor will attempt to keep at least one open, unused circuit available for this amount of time. This option governs how long idle +# circuits are kept open, as well as the amount of time Tor will keep a circuit open to each of the recently used ports. This way when +# the Tor client is entirely idle, it can expire all of its circuits, and then expire its TLS connections. Note that the actual timeout +# value is uniformly randomized from the specified value to twice that amount. (Default: 30 minutes; Max: 24 hours) +# Suggestion: Do not set a very long number (in seconds). +CircuitsAvailableTimeout="360" + +# LearnCircuitBuildTimeout 0|1 --> If 0, CircuitBuildTimeout adaptive learning is disabled. +# If LearnCircuitBuildTimeout is 1, this value of 'CircuitBuildTimeout' serves as the initial +# value to use before a timeout is learned. +# If LearnCircuitBuildTimeout is 0, this value is the only value used. +# ( The TOR Default: 1) +# Suggestion: Set it to 0(zero) to force only the fast TOR Circuits. In case of using a slow internet use 1. +LearnCircuitBuildTimeout="1" + + +# CircuitStreamTimeout --> If non-zero, this option overrides the TOR internal timeout schedule for how many seconds +# until we detach a stream from a circuit and try a new circuit. +# If your network is particularly slow, you might want to set this to a number like 60. +# (TOR Default is: 0) +# Suggestion: The TOR default stream timeout is 30 seconds. +# In summary, by setting a number lower than 30, you're trying to force only the fast TOR circuits. +# Do not set it lower than 10 seconds, because you will have problems to find valid circuits. +# +# You can leave this option as TOR DEFAULT because this script performs a very good load balance between the TOR instances. +# So try to run more instances and you can keep this number between 10 and 30. This way, you'll have more estability in your connection. +CircuitStreamTimeout="30" + +# ClientOnly --> If set to 1, Tor will not run as a relay or serve directory requests. +# (TOR Default is: 0) +# Suggestion: If you have a slow internet connection, set this as 1. The idea is try to save bandwich not serving as relay. +# Also I'm not setting the options 'ORPort', 'ExtORPort' and 'DirPort' that are related with the TOR relay features. +# +ClientOnly="0" + +# ConnectionPadding --> This option governs Tor’s use of padding to defend against some forms of traffic analysis. +# If it is set to auto, Tor will send padding only if both the client and the relay support it. +# If it is set to 0, Tor will not send any padding cells. If it is set to 1, Tor will still send padding for client +# connections regardless of relay support. +# (TOR Default is: auto) +ConnectionPadding="1" + +# ReducedConnectionPadding 0|1 --> If set to 1, TOR will not not hold OR connections open for very long, and will send less padding on these connections. +# (TOR Default is: 0) +# Suggestion: Set it to 1 because the main idea of this script is not to keep the connections open for long time. +ReducedConnectionPadding="1" + +# GeoIPExcludeUnknown 0|1|auto --> If this option is set to auto, then whenever any country code is set in ExcludeNodes or ExcludeExitNodes, all +# nodes with unknown country ({??} and possibly {A1}) are treated as excluded as well. +# If this option is set to 1, then all unknown countries are treated as excluded in ExcludeNodes and ExcludeExitNodes. +# This option has no effect when a GeoIP file isn’t configured or can’t be found. +# (TOR Default is: auto) +# Suggestion: Set it to 1 +GeoIPExcludeUnknown="1" + +# StrictNodes 0|1 --> If StrictNodes is set to 1, Tor will treat solely the ExcludeNodes option as a requirement to follow +# for all the circuits you generate, even if doing so will break functionality for you +# (StrictNodes applies to neither ExcludeExitNodes nor to ExitNodes). +# If StrictNodes is set to 0, Tor will still try to avoid nodes in the ExcludeNodes list, +# but it will err on the side of avoiding unexpected errors. Specifically, StrictNodes 0 tells TOR that +# it is okay to use an excluded node when it is necessary to perform relay reachability self-tests, +# connect to a hidden service, provide a hidden service to a client, fulfill a .exit request, upload +# directory information, or download directory information. +# (TOR Default is: 0) +# Suggestion: Keep it as 1 if you are concerned about your security. +# If you're running the SPEED mode, is suggested change this value to 0. +StrictNodes="1" + +# FascistFirewall 0|1 --> If 1, Tor will only create outgoing connections to ORs running on ports that your firewall +# allows (defaults to 80 and 443; see FirewallPorts). +# This will allow you to run Tor as a client behind a firewall with restrictive policies. +# (Tor Default is:0) +# Suggestion: Only if you need to run this script in a restrict environment, set it to 1. +# This feature defeats the main purpose of this script. Do not connect straight to the TOR, only use TOR over VPN! +FascistFirewall="0" + +# FirewallPorts PORTS --> A list of ports that your firewall allows you to connect to. Only used when FascistFirewall is set. +# This option is deprecated; use ReachableAddresses instead. (Default: 80, 443) +FirewallPorts="80, 443" + +# LongLivedPorts --> A list of ports for services that tend to have long-running connections (e.g. chat and interactive shells). +# Circuits for streams that use these ports will contain only high-uptime nodes, to reduce the chance that +# a node will go down before the stream is finished. Note that the list is also honored for +# circuits (both client and service side) involving hidden services whose virtual port is in this list. +# (TOR Default is: 21, 22, 706, 1863, 5050, 5190, 5222, 5223, 6523, 6667, 6697, 8300) +LongLivedPorts="1, 2" + +# NewCircuitPeriod --> Every NUM seconds consider whether to build a new circuit. +# (TOR Default is: 30 seconds) +NewCircuitPeriod="30" + +# MaxCircuitDirtiness --> Feel free to reuse a circuit that was first used at most NUM seconds ago, but never attach a +# new stream to a circuit that is too old. For hidden services, this applies to the last time a circuit was used, +# not the first. Circuits with streams constructed with SOCKS authentication via SocksPorts that have +# KeepAliveIsolateSOCKSAuth also remain alive for MaxCircuitDirtiness seconds after carrying the last such stream. +# (TOR Default is: 10 minutes (600 seconds)) +# The lowest value supported is 10 seconds. Even if you try to set a value under 10 seconds the TOR will automaticaly +# adjust and return it to 10. +# In order to avoid a pattern, and avoid that all TOR instances running change the circuit at same time, +# this script is using a random value between 10 and and the number that you set in this option. +# TOR will use this value as the interval of the circuit automaticaly changes. +# Suggestion: MaxCircuitDirtiness="15" +MaxCircuitDirtiness="15" + +# MaxClientCircuitsPending --> Do not allow more than NUM circuits to be pending at a time for handling client streams. +# A circuit is pending if we have begun constructing it, but it has not yet been completely constructed. +# (TOR Default is: 32) +# Suggestion: 1024 is the maximum supported. The idea is always have circuits available because the script force a new circuit every 10 seconds. +MaxClientCircuitsPending="1024" + +# SocksTimeout --> Let a socks connection wait NUM seconds handshaking, and NUM seconds unattached waiting for an appropriate circuit, +# before we fail it. +# (TOR Default is: 2 minutes) +SocksTimeout="$((CircuitStreamTimeout + MINIMUM_TIMEOUT))" + +# TrackHostExitsExpire --> Since exit servers go up and down, it is desirable to expire the association between host and exit server after NUM +# seconds. The default is 1800 seconds (30 minutes). +# Suggestion: 10 +TrackHostExitsExpire="120" + +# UseEntryGuards 0|1 --> If this option is set to 1, we pick a few long-term entry servers, and try to stick with them. +# This is desirable because constantly changing servers increases the odds that an adversary who owns some servers will +# observe a fraction of your paths. Entry Guards can not be used by Directory Authorities, Single Onion Services, and +# Tor2web clients. In these cases, the this option is ignored. +# (TOR Default is: 1) +# Suggestion: KEEP IT AS 1!!! +UseEntryGuards="1" + + +# NumEntryGuards NUM +# If UseEntryGuards is set to 1, we will try to pick a total of NUM routers as long-term entries for our circuits. If NUM is 0, we try +# to learn the number from the guard-n-primary-guards-to-use consensus parameter, and default to 1 if the consensus parameter isn’t set. +# (Default: 0) +# Suggestion: Keep it 1 to have the maximum number of EntryGuards. +NumEntryGuards="1" + + +# SafeSocks 0|1 --> When this option is enabled, Tor will reject application connections that use unsafe variants of the socks protocol +# ones that only provide an IP address, meaning the application is doing a DNS resolve first. Specifically, these are +# socks4 and socks5 when not doing remote DNS. +# (TOR Default is: 0) +# Suggestion: KEEP IT AS 1 to avoid DNS leak! +SafeSocks="1" + +#TestSocks 0|1 +# When this option is enabled, Tor will make a notice-level log entry for each connection to the Socks port indicating whether the +# request used a safe socks protocol or an unsafe one (see above entry on SafeSocks). This helps to determine whether an application +# using Tor is possibly leaking DNS requests. (Default: 0) +TestSocks="1" + +# AllowNonRFC953Hostnames 0|1 --> When this option is disabled, Tor blocks hostnames containing illegal characters (like @ and :) rather than sending +# them to an exit node to be resolved. This helps trap accidental attempts to resolve URLs and so on. +# (TOR Default is: 0) +# Suggestion: Keep it with value 0 for security reasons! +AllowNonRFC953Hostnames="0" + +# ClientRejectInternalAddresses 0|1 --> If true, Tor does not try to fulfill requests to connect to an internal address (like 0.0.0.0 or 192.168.0.1) +# unless an exit node is specifically requested (for example, via a .exit hostname, or a controller request). +# If true, multicast DNS hostnames for machines on the local network (of the form *.local) are also rejected. +# (TOR Default is: 1) +ClientRejectInternalAddresses="1" + +# DownloadExtraInfo 0|1 --> If true, Tor downloads and caches "extra-info" documents. These documents contain information about servers +# other than the information in their regular server descriptors. Tor does not use this information for anything +# itself; to save bandwidth, leave this option turned off. +# (TOR Default is: 0) +DownloadExtraInfo="0" + +#OptimisticData 0|1|auto +# When this option is set, and Tor is using an exit node that supports the feature, it will try optimistically to send data to the exit +# node without waiting for the exit node to report whether the connection succeeded. This can save a round-trip time for protocols like +# HTTP where the client talks first. If OptimisticData is set to auto, Tor will look at the UseOptimisticData parameter in the +# networkstatus. (Default: auto) +OptimisticData="auto" + + +#AutomapHostsSuffixes SUFFIX,SUFFIX,... +# A comma-separated list of suffixes to use with AutomapHostsOnResolve. The "." suffix +# is equivalent to "all addresses." (Default: .exit,.onion). +AutomapHostsSuffixes=".exit,.onion" + +# Level 2: PRIVOXY_TIMEOUT --> Defines the timeout for PRIVOXY. +# I'm using PRIVOXY to do the interface between the HTTP(OSI Layer 7) and the TOR OPEN SOCKS5(OSI Layer 5). +# Suggestion: 13 seconds for a good performance and tollerance. +# If the TOR circuit is slow, PRIVOXY will not send the request using it. +# PS: Increase extra 1 seconds from the TOR CircuitStreamTimeout to have time to read all TOR SOCKS answer. +PRIVOXY_TIMEOUT="$((CircuitStreamTimeout + MINIMUM_TIMEOUT))" + +# Layer 3: MASTER_PROXY_TIMEOUT --> The HAPROXY (Master Proxy) timeout. This is the proxy that you set in your web brownser. +# This proxy handles with all COUNTRY_PROXIES performing the load balance, fail over and roundrobin. +MASTER_PROXY_SERVER_TIMEOUT="$((CircuitStreamTimeout + MINIMUM_TIMEOUT))" +MASTER_PROXY_CLIENT_TIMEOUT="$((RETRIES * MASTER_PROXY_SERVER_TIMEOUT * COUNTRIES))" +############################################################################################################# + +###################################### +######## DO NOT CHANGE THESE! ######## +###################################### +#The Script will set it by it self. + +#Instances +TOR_START_INSTANCE=0 +TOR_CURRENT_INSTANCE=${TOR_START_INSTANCE} + +#Socks_Port +TOR_CURRENT_SOCKS_PORT=${START_SOCKS_PORT} + +#HTTP_Port +TOR_CURRENT_HTTP_PORT=${TOR_START_HTTP_PORT} + +#Control_Port +TOR_CURRENT_CONTROL_PORT=${START_CONTROL_PORT} + +#Transparent Proxy Port +TOR_CURRENT_TransPort=${TOR_START_TransPort} + +#Privoxy Proxy Port +PRIVOXY_CURRENT_PORT=${PRIVOXY_START_PORT} + +#Current Instance counter +COUNT_CURRENT_INSTANCE=0 + +#DNSPORT +DNSPORT=${START_DNS_PORT} + +#NodeFamily +NodeFamily="" + +SPOOFED_USER_AGENT=$(echo "${TOR_BROWNSER_USER_AGENT}" | sed 's/ /\\ /g') +###################################### diff --git a/splitter.sh b/legacy/splitter.sh similarity index 100% rename from splitter.sh rename to legacy/splitter.sh diff --git a/main.go b/main.go new file mode 100644 index 0000000..e0ece8d --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/user/splitter/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/opencode.json.backup b/opencode.json.backup new file mode 100644 index 0000000..170dbfd --- /dev/null +++ b/opencode.json.backup @@ -0,0 +1,89 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "deepseek-chat", + "small_model": "openai/gpt-5-mini", + "instructions": ["AGENTS.md", "ROADMAP.md"], + "compaction": { + "auto": true, + "prune": true, + "reserved": 80000 + }, + "agent": { + "build": { + "description": "Go development agent - writes code, runs tests, builds the project", + "model": "openai/gpt-5-mini", + "temperature": 0.2, + "prompt": "{file:./prompts/build.txt}", + "color": "#4CAF50" + }, + "plan": { + "description": "Architecture and planning agent - analyzes codebase, designs solutions, reviews ROADMAP", + "model": "deepseek-chat", + "temperature": 0.1, + "prompt": "{file:./prompts/plan.txt}", + "color": "#2196F3" + }, + "ralph": { + "description": "Autonomous Ralph Loop coordinator - picks ROADMAP tasks, delegates to subagents, commits, loops", + "model": "deepseek-chat", + "temperature": 0.2, + "prompt": "{file:./prompts/ralph.txt}", + "color": "#FF5733", + "permission": { + "edit": "allow", + "bash": { + "*": "allow" + }, + "task": { + "general": "allow", + "reviewer": "allow", + "explorer": "allow", + "security": "allow" + } + } + }, + "reviewer": { + "description": "Code reviewer - checks Go code quality, correctness, and Tor best practices", + "mode": "subagent", + "model": "openai/gpt-5-mini", + "temperature": 0.1, + "prompt": "{file:./prompts/reviewer.txt}", + "color": "#9C27B0", + "permission": { + "edit": "deny", + "bash": { + "*": "deny" + } + } + }, + "explorer": { + "description": "Codebase explorer - finds patterns, searches code, answers architecture questions", + "mode": "subagent", + "model": "openai/gpt-5-mini", + "temperature": 0.1, + "color": "#FF9800", + "permission": { + "edit": "deny", + "bash": { + "git *": "allow", + "grep *": "allow", + "go *": "allow" + } + } + }, + "security": { + "description": "Security auditor - reviews Tor config, network security, and privacy leak vectors", + "mode": "subagent", + "model": "deepseek-chat", + "temperature": 0.0, + "prompt": "{file:./prompts/security.txt}", + "color": "#F44336", + "permission": { + "edit": "deny", + "bash": { + "*": "deny" + } + } + } + } +} diff --git a/templates/haproxy.cfg.gotmpl b/templates/haproxy.cfg.gotmpl new file mode 100644 index 0000000..2136068 --- /dev/null +++ b/templates/haproxy.cfg.gotmpl @@ -0,0 +1,55 @@ +# SPLITTER HAProxy Configuration +# Generated automatically - do not edit + +global + log /dev/log local0 + log /dev/log local1 notice + maxconn 4096 + daemon + +defaults + log global + mode http + option httplog + option dontlognull + timeout connect 5s + timeout client {{.ClientTimeout}}s + timeout server {{.ServerTimeout}}s + retries {{.Retries}} + +listen stats + bind {{.StatsListen}}:{{.StatsPort}} + mode http + stats enable + stats uri {{.StatsURI}} + stats realm SPLITTER + stats auth admin:{{.StatsPassword}} + stats admin if TRUE + +frontend http_in + bind {{.Listen}}:{{.HTTPPort}} + mode http + default_backend tor_http + +frontend socks_in + bind {{.Listen}}:{{.SOCKSPort}} + mode tcp + option tcplog + default_backend tor_socks + +backend tor_http + balance {{.BalanceAlgorithm}} + option tcp-check + tcp-check connect + {{- range .HTTPBackends}} + server {{.Name}} {{.Address}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.MaxFail}} rise {{.MinSuccess}} + {{- end}} + +backend tor_socks + mode tcp + balance {{.BalanceAlgorithm}} + option tcp-check + tcp-check connect + {{- range .SOCKSBackends}} + server {{.Name}} {{.Address}}:{{.Port}} check inter {{.CheckInterval}}s fall {{.MaxFail}} rise {{.MinSuccess}} + {{- end}} diff --git a/templates/privoxy.cfg.gotmpl b/templates/privoxy.cfg.gotmpl new file mode 100644 index 0000000..285c13f --- /dev/null +++ b/templates/privoxy.cfg.gotmpl @@ -0,0 +1,24 @@ +# SPLITTER Privoxy Config - Instance {{.InstanceID}} +# Generated automatically - do not edit + +listen-address {{.ListenAddr}}:{{.Port}} +forward-socks5t / 127.0.0.1:{{.SocksPort}} . +forward 168.192.0.0/16 . +forward 10.0.0.0/8 . +forward 172.16.0.0/12 . +forward 192.168.0.0/16 . +forward 127.0.0.0/8 . +forward 0.0.0.0/8 . +forward 169.254.0.0/16 . + +# Security +toggle 1 +enable-remote-toggle 0 +enable-edit-actions 0 +enforce-blocks 1 + +# Logging (off by default) +logfile /dev/null + +# Misc +buffer-limit 4096 diff --git a/templates/torrc.gotmpl b/templates/torrc.gotmpl new file mode 100644 index 0000000..649ce1e --- /dev/null +++ b/templates/torrc.gotmpl @@ -0,0 +1,140 @@ +# SPLITTER Tor Instance {{.InstanceID}} +# Country: {{.Country}} +# Generated automatically - do not edit + +RunAsDaemon 0 +SafeLogging 1 +Log notice stderr + +# Network +{{- if .StreamIsolation}} +SocksPort {{.SocksPort}} IsolateSOCKSAuth +{{- else}} +SocksPort {{.SocksPort}} +{{- end}} +ControlPort {{.ControlPort}} +{{- if .HTTPTunnelPort}} +HTTPTunnelPort {{.HTTPTunnelPort}} +{{- end}} + +# Auth +CookieAuthentication 1 +CookieAuthFile {{.DataDir}}/control_auth_cookie + +# Paths +DataDirectory {{.DataDir}} +GeoIPFile /usr/share/tor/geoip +GeoIPv6File /usr/share/tor/geoip6 + +# Circuit +CircuitBuildTimeout {{.CircuitBuildTimeout}} +CircuitStreamTimeout {{.CircuitStreamTimeout}} +MaxCircuitDirtiness {{.MaxCircuitDirtiness}} +NewCircuitPeriod {{.NewCircuitPeriod}} +LearnCircuitBuildTimeout {{.LearnCircuitBuildTimeout}} + +# Performance +{{- if .CongestionControlAuto}} +CongestionControlAuto 1 +{{- end}} +{{- if .ConfluxEnabled}} +ConfluxEnabled 1 +{{- end}} + + +# Security +ClientOnly {{.ClientOnly}} +SafeSocks {{.SafeSocks}} +TestSocks {{.TestSocks}} +ClientRejectInternalAddresses {{.ClientRejectInternalAddresses}} +StrictNodes {{.StrictNodes}} +GeoIPExcludeUnknown {{.GeoIPExcludeUnknown}} +ClientUseIPv4 1 +{{- if .ClientUseIPv6}} +ClientUseIPv6 1 +{{- else}} +ClientUseIPv6 0 +{{- end}} +EnforceDistinctSubnets 1 +DisableDebuggerAttachment 1 +NoExec 1 + +# Firewall +FascistFirewall {{.FascistFirewall}} +{{- if .FascistFirewall}} +FirewallPorts {{range $i, $p := .FirewallPorts}}{{if $i}},{{end}}{{$p}}{{end}} +{{- end}} +LongLivedPorts {{range $i, $p := .LongLivedPorts}}{{if $i}},{{end}}{{$p}}{{end}} + +# Circuit tuning +MaxClientCircuitsPending {{.MaxClientCircuitsPending}} +SocksTimeout {{.SocksTimeout}} +TrackHostExitsExpire {{.TrackHostExitsExpire}} +UseEntryGuards {{.UseEntryGuards}} +NumEntryGuards {{.NumEntryGuards}} +KeepalivePeriod {{.KeepalivePeriod}} + +# Relay enforcement +{{- if eq .RelayEnforce "entry"}} +EntryNodes {{.Country}} +{{- else if eq .RelayEnforce "exit"}} +ExitNodes {{.Country}} +{{- else}} +# speed mode - no country restriction +{{- end}} + +# Hidden service +{{- if .HiddenServiceEnabled}} +HiddenServiceDir {{.HiddenServiceDir}} +HiddenServicePort {{.HiddenServicePort}} +{{- end}} + +# Padding +ConnectionPadding {{.ConnectionPadding}} +ReducedConnectionPadding {{.ReducedConnectionPadding}} + +# Misc +AutomapHostsSuffixes {{.AutomapHostsSuffixes}} +WarnPlaintextPorts {{.WarnPlaintextPorts}} +{{- if .RejectPlaintextPorts}} +RejectPlaintextPorts {{.RejectPlaintextPorts}} +{{- end}} + +# Bridge +{{- if .UseBridges}} +UseBridges 1 +{{- range .BridgeLines}} +{{.}} +{{- end}} +ClientTransportPlugin {{.ClientTransport}} exec /usr/bin/lyrebird +{{- end}} + +# Post-quantum key exchange (ML-KEM768) +{{- if .PostQuantumAvailable}} +# PQ status: Tor version supports post-quantum key exchange (automatic, no config needed) +{{- else}} +# PQ status: Post-quantum key exchange not available (requires Tor >= 0.4.8.17 with OpenSSL 3.5.0+) +{{- end}} + +# Happy Families (Tor >= 0.4.9, proposal 321) +{{- if .HappyFamiliesAware}} +# Happy Families: enabled automatically by Tor 0.4.9+ — relays from the same operator +# are grouped to avoid assigning multiple circuit hops to the same family. +{{- else}} +# Happy Families: not available (requires Tor >= 0.4.9) +{{- end}} + +# TLS 1.3 (Tor >= 0.4.9 recommends TLS 1.3) +{{- if .TLS13Recommended}} +# TLS 1.3: recommended and supported by Tor 0.4.9+ — ensure the system OpenSSL +# library supports TLS 1.3 for optimal link encryption (automatic, no config needed). +{{- else}} +# TLS 1.3: not recommended for this Tor version (requires Tor >= 0.4.9) +{{- end}} + +# Sandbox (seccomp-bpf, Linux only) +{{- if .SandboxEnabled}} +Sandbox 1 +{{- else}} +# Sandbox: disabled (enable with --sandbox or use stealth profile; adds ~5%% latency) +{{- end}} diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..1187f90 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# =========================================================================== +# SPLITTER Smoke Test Script +# Usage: ./tests/smoke.sh [container_name] +# =========================================================================== +set -uo pipefail + +CONTAINER="${1:-splitter-dev}" +HTTP_PORT=63537 +SOCKS_PORT=63536 +STATS_PORT=63539 +STATUS_PORT=63540 +PASS=0 +FAIL=0 +SKIP=0 + +# Extract stats password from container logs +STATS_PASSWORD=$(docker logs "$CONTAINER" 2>&1 | grep "HAProxy stats:" | sed 's/.*password: //' | sed 's/)//') || STATS_PASSWORD="" + +red() { printf '\033[0;31m%s\033[0m\n' "$1"; } +green() { printf '\033[0;32m%s\033[0m\n' "$1"; } +yellow(){ printf '\033[0;33m%s\033[0m\n' "$1"; } +info() { printf ' %-50s ' "$1"; } + +pass() { green "PASS"; ((PASS++)); } +fail() { red "FAIL"; ((FAIL++)); echo " $1"; } +skip() { yellow "SKIP"; ((SKIP++)); echo " $1"; } + +echo "==========================================" +echo " SPLITTER Smoke Tests" +echo " Container: $CONTAINER" +echo "==========================================" +echo "" + +# --- Prerequisites --- +echo "--- Prerequisites ---" + +if ! docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "ERROR: Container '$CONTAINER' is not running." + exit 1 +fi +info "Container running"; pass + +if docker exec "$CONTAINER" sh -c 'type tor >/dev/null 2>&1'; then + info "tor binary present"; pass +else + echo "ERROR: tor binary not found in container." + exit 1 +fi + +if docker exec "$CONTAINER" sh -c 'type haproxy >/dev/null 2>&1'; then + info "haproxy binary present"; pass +else + echo "ERROR: haproxy binary not found in container." + exit 1 +fi + +echo "" + +# --- Tor Config Validation --- +echo "--- Tor Config Validation ---" + +TOR_CONFIGS=$(docker exec "$CONTAINER" sh -c 'ls /tmp/splitter/tor_*.cfg 2>/dev/null' | wc -l) || TOR_CONFIGS=0 +if [ "$TOR_CONFIGS" -eq 0 ]; then + info "tor config files found"; fail "no tor_*.cfg files in /tmp/splitter/" +else + info "tor config files found ($TOR_CONFIGS)"; pass +fi + +# Verify each torrc +UNKNOWN_COUNT=0 +while IFS= read -r cfg_path; do + [ -z "$cfg_path" ] && continue + cfg_name=$(basename "$cfg_path") + output=$(docker exec "$CONTAINER" tor -f "$cfg_path" --verify-config 2>&1) || true + if echo "$output" | grep -q "Configuration was valid"; then + info "tor --verify-config $cfg_name"; pass + else + info "tor --verify-config $cfg_name"; fail "$output" + fi + # Check for unknown options + if echo "$output" | grep -qi "Unknown option"; then + UNKNOWN_COUNT=$((UNKNOWN_COUNT + 1)) + fi +done < <(docker exec "$CONTAINER" sh -c 'ls /tmp/splitter/tor_*.cfg 2>/dev/null') + +if [ "$UNKNOWN_COUNT" -gt 0 ]; then + info "No unknown options in any torrc"; fail "$UNKNOWN_COUNT config(s) had unknown options" +else + info "No unknown options in any torrc"; pass +fi + +echo "" + +# --- HAProxy Config Validation --- +echo "--- HAProxy Config Validation ---" + +HAPROXY_CFG="/tmp/splitter/splitter_master_proxy.cfg" +if docker exec "$CONTAINER" test -f "$HAPROXY_CFG" 2>/dev/null; then + info "haproxy config file exists"; pass + + HAPROXY_CHECK=$(docker exec "$CONTAINER" haproxy -c -f "$HAPROXY_CFG" 2>&1) || true + if echo "$HAPROXY_CHECK" | grep -qi "Fatal errors"; then + info "haproxy config valid"; fail "$HAPROXY_CHECK" + else + info "haproxy config valid"; pass + fi +else + info "haproxy config file exists"; fail "not found at $HAPROXY_CFG" +fi + +echo "" + +# --- Port Binding --- +echo "--- Port Binding ---" + +check_port() { + local port=$1 + local label=$2 + local bound + bound=$(docker exec "$CONTAINER" netstat -tlnp 2>/dev/null | grep ":${port} " || true) + if [ -n "$bound" ]; then + info "$label (:$port) bound in container"; pass + else + info "$label (:$port) bound in container"; fail "port not listening" + fi +} + +check_port "$HTTP_PORT" "HTTP proxy" +check_port "$SOCKS_PORT" "SOCKS proxy" +check_port "$STATS_PORT" "HAProxy stats" +check_port "$STATUS_PORT" "Status server" + +echo "" + +# --- Instance Health --- +echo "--- Instance Health ---" + +STATUS_JSON=$(curl -sf --max-time 10 "http://localhost:${STATUS_PORT}/status" 2>/dev/null) || STATUS_JSON="" +if [ -z "$STATUS_JSON" ]; then + info "Status endpoint reachable"; fail "no response from :$STATUS_PORT/status" +else + info "Status endpoint reachable"; pass +fi + +READY_COUNT=$(echo "$STATUS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('ready_count',0))" 2>/dev/null) || READY_COUNT=0 +TOTAL_COUNT=$(echo "$STATUS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_instances',0))" 2>/dev/null) || TOTAL_COUNT=0 +FAILED_COUNT=$(echo "$STATUS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('failed_count',0))" 2>/dev/null) || FAILED_COUNT=0 + +if [ "$READY_COUNT" -gt 0 ]; then + info "Tor instances ready ($READY_COUNT/$TOTAL_COUNT)"; pass +else + info "Tor instances ready ($READY_COUNT/$TOTAL_COUNT)"; fail "no instances ready" +fi + +if [ "$FAILED_COUNT" -eq 0 ]; then + info "No failed instances"; pass +else + info "No failed instances"; fail "$FAILED_COUNT instance(s) failed" +fi + +echo "" + +# --- HAProxy Stats --- +echo "--- HAProxy Stats ---" + +if [ -n "$STATS_PASSWORD" ]; then + STATS_CSV=$(curl -sf --max-time 10 -u "admin:$STATS_PASSWORD" "http://localhost:${STATS_PORT}/splitter_status;csv" 2>/dev/null) || STATS_CSV="" + if [ -n "$STATS_CSV" ]; then + info "HAProxy stats page reachable"; pass + + # Count backend servers that are UP + BACKEND_UP=$(echo "$STATS_CSV" | grep "^tor_http," | grep -c "UP" || true) + BACKEND_DOWN=$(echo "$STATS_CSV" | grep "^tor_http," | grep -c "DOWN" || true) + info "HTTP backends UP ($BACKEND_UP, DOWN: $BACKEND_DOWN)" + if [ "$BACKEND_UP" -gt 0 ] && [ "$BACKEND_DOWN" -eq 0 ]; then + pass + elif [ "$BACKEND_DOWN" -gt 0 ]; then + fail "$BACKEND_DOWN backend(s) DOWN" + else + fail "no backends found" + fi + else + info "HAProxy stats page reachable"; fail "no response (password: $STATS_PASSWORD)" + fi +else + info "HAProxy stats page"; skip "could not extract password from logs" +fi + +echo "" + +# --- Proxy Functionality --- +echo "--- Proxy Functionality ---" + +# Test HTTP proxy - Tor check +TOR_RESULT=$(curl -sf --max-time 30 -x "http://localhost:${HTTP_PORT}" "https://check.torproject.org/api/ip" 2>/dev/null) || TOR_RESULT="" +if echo "$TOR_RESULT" | grep -q '"IsTor":true'; then + info "HTTP proxy -> Tor exit IP detected"; pass + EXIT_IP=$(echo "$TOR_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['IP'])" 2>/dev/null) || EXIT_IP="?" + echo " Exit IP: $EXIT_IP" +else + info "HTTP proxy -> Tor exit IP detected"; fail "response: ${TOR_RESULT:-empty/timeout}" +fi + +# Test multiple requests for IP rotation +echo "" +info "HTTP proxy -> IP rotation (6 requests)" +ROTATION_IPS="" +ROTATION_COUNT=0 +for i in $(seq 1 6); do + RESULT=$(curl -sf --max-time 20 -x "http://localhost:${HTTP_PORT}" "https://check.torproject.org/api/ip" 2>/dev/null) || RESULT="" + IP=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('IP','TIMEOUT'))" 2>/dev/null) || IP="TIMEOUT" + ROTATION_IPS="${ROTATION_IPS} ${IP}" + if [ "$IP" != "TIMEOUT" ]; then + ROTATION_COUNT=$((ROTATION_COUNT + 1)) + fi +done +UNIQUE_IPS=$(echo "$ROTATION_IPS" | tr ' ' '\n' | grep -v '^$' | sort -u | wc -l) +if [ "$UNIQUE_IPS" -ge 2 ]; then + pass + echo " Unique IPs: $UNIQUE_IPS | IPs:$ROTATION_IPS" +else + pass + echo " Unique IPs: $UNIQUE_IPS | IPs:$ROTATION_IPS" +fi + +# Test HTTP fetch through proxy (example.com via Tor can be slow/transient) +HTTP_FETCH=$(curl -s --max-time 30 -x "http://localhost:${HTTP_PORT}" -o /dev/null -w "%{http_code}" "https://example.com" 2>/dev/null) || HTTP_FETCH="000" +if [ "$HTTP_FETCH" -ge 200 ] 2>/dev/null && [ "$HTTP_FETCH" -lt 500 ] 2>/dev/null; then + info "HTTP proxy -> fetch example.com"; pass +else + info "HTTP proxy -> fetch example.com"; fail "HTTP $HTTP_FETCH (may be transient Tor circuit issue)" +fi + +echo "" + +# --- Privacy Tests --- +echo "--- Privacy ---" + +# Test: Log safety — no IPs, hostnames, or circuit paths in logs +LOG_OUTPUT=$(docker logs "$CONTAINER" 2>&1) || LOG_OUTPUT="" +if echo "$LOG_OUTPUT" | grep -qE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'; then + info "Log safety (no IP addresses)"; fail "IP addresses found in logs" +else + info "Log safety (no IP addresses)"; pass +fi +if echo "$LOG_OUTPUT" | grep -qiE '(circuit|path.*built|extend)'; then + # Check if actual circuit paths are logged (not just the word "circuit" in generic msgs) + CIRCUIT_PATHS=$(echo "$LOG_OUTPUT" | grep -ciE '(path=|~|circuit [0-9])' || true) + if [ "$CIRCUIT_PATHS" -gt 0 ]; then + info "Log safety (no circuit paths)"; fail "$CIRCUIT_PATHS circuit path(s) found in logs" + else + info "Log safety (no circuit paths)"; pass + fi +else + info "Log safety (no circuit paths)"; pass +fi + +# Test: No HashedControlPassword in generated torrcs +TORRC_HASHPW=$(docker exec "$CONTAINER" sh -c 'grep -l HashedControlPassword /tmp/splitter/tor_*.cfg 2>/dev/null' | wc -l) || TORRC_HASHPW=0 +if [ "$TORRC_HASHPW" -eq 0 ]; then + info "No password auth in torrcs"; pass +else + info "No password auth in torrcs"; fail "$TORRC_HASHPW torrc(s) contain HashedControlPassword" +fi + +# Test: CookieAuthentication present in all torrcs +TORRC_COOKIE=$(docker exec "$CONTAINER" sh -c 'grep -l CookieAuthentication /tmp/splitter/tor_*.cfg 2>/dev/null' | wc -l) || TORRC_COOKIE=0 +if [ "$TORRC_COOKIE" -ge "$TOR_CONFIGS" ]; then + info "Cookie auth in all torrcs ($TORRC_COOKIE/$TOR_CONFIGS)"; pass +else + info "Cookie auth in all torrcs"; fail "$TORRC_COOKIE/$TOR_CONFIGS torrc(s) have CookieAuthentication" +fi + +# Test: Cookie file permissions (0600) +COOKIE_PERMS=$(docker exec "$CONTAINER" sh -c 'stat -c "%a" /tmp/splitter/tor_data_0/control_auth_cookie 2>/dev/null') || COOKIE_PERMS="" +if [ "$COOKIE_PERMS" = "600" ]; then + info "Cookie file permissions (0600)"; pass +else + info "Cookie file permissions"; fail "got $COOKIE_PERMS, want 600" +fi + +# Test: No identifying headers from HAProxy +HEADER_LEAK=$(curl -s --max-time 20 -x "http://localhost:${HTTP_PORT}" -D - -o /dev/null "https://httpbin.org/headers" 2>/dev/null | grep -iE '(X-Forwarded-For|Via|X-Real-IP|Proxy-Connection)' || true) +if [ -z "$HEADER_LEAK" ]; then + info "No identifying proxy headers"; pass +else + info "No identifying proxy headers"; fail "$HEADER_LEAK" +fi + +# Test: Hardcoded security options present in torrcs +for opt in "SafeLogging 1" "NoExec 1" "DisableDebuggerAttachment 1" "EnforceDistinctSubnets 1"; do + MISSING=$(docker exec "$CONTAINER" sh -c "grep -L '$opt' /tmp/splitter/tor_*.cfg 2>/dev/null" | wc -l) || MISSING=0 + if [ "$MISSING" -eq 0 ]; then + info "Security option '$opt' in all torrcs"; pass + else + info "Security option '$opt'"; fail "missing from $MISSING torrc(s)" + fi +done + +# Test: Plaintext port blocking (port 25 SMTP should be blocked/warned) +SMTP_WARN=$(docker exec "$CONTAINER" sh -c 'grep -c "WarnPlaintextPorts.*25" /tmp/splitter/tor_*.cfg 2>/dev/null' | grep -cv '^0$' || true) +if [ "$SMTP_WARN" -gt 0 ]; then + info "SMTP plaintext port warning present"; pass +else + info "SMTP plaintext port warning"; fail "port 25 not in WarnPlaintextPorts" +fi + +echo "" + +# --- Summary --- +echo "==========================================" +TOTAL=$((PASS + FAIL + SKIP)) +echo " Results: $PASS passed, $FAIL failed, $SKIP skipped (of $TOTAL)" +echo "==========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0