diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b6a453e --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Tor Control Port Address +# The address:port where the Tor Control Port is listening +# Default: 127.0.0.1:9051 (container-internal) +# For external Tor server: TOR_CONTROL_ADDR=192.168.1.100:9051 +TOR_CONTROL_ADDR=127.0.0.1:9051 + +# Tor Control Port Password +# This password is used by the healthcheck to authenticate with the Tor control port +# Change this to a secure password if you want to use the Control Port anywhere else than localhost!!! +TOR_CONTROL_PASSWORD=password + +# Optional: Enable debug mode for troubleshooting +# DEBUG=true diff --git a/Dockerfile b/Dockerfile index 4601e9b..c77d5fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,33 @@ # syntax=docker/dockerfile:1 +FROM golang:alpine AS builder + +WORKDIR /build +COPY healthcheck/main.go . +RUN go build -ldflags="-s -w" -o healthcheck main.go + FROM alpine:edge -RUN apk add --no-cache curl tor bash nyx lyrebird && rm -rf /var/cache/apk/* && \ +RUN apk add --no-cache tor bash nyx lyrebird && rm -rf /var/cache/apk/* && \ sed "1s/^/SocksPort 0.0.0.0:9050\n/" /etc/tor/torrc.sample > /etc/tor/torrc +RUN apk add --no-cache tor bash nyx lyrebird && rm -rf /var/cache/apk/* + +COPY --from=builder /build/healthcheck /usr/local/bin/healthcheck +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Fallbacks +ENV TOR_CONTROL_ADDR=127.0.0.1:9051 +ENV TOR_CONTROL_PASSWORD=password EXPOSE 9050 9051 -HEALTHCHECK --interval=300s --timeout=15s --start-period=60s --start-interval=10s \ - CMD curl -x socks5h://127.0.0.1:9050 'https://check.torproject.org/api/ip' | grep -qm1 -E '"IsTor"\s*:\s*true' +HEALTHCHECK --interval=600s --timeout=30s --start-period=60s --start-interval=60s \ + CMD ["/usr/local/bin/healthcheck"] VOLUME ["/etc/tor"] VOLUME ["/var/lib/tor"] USER tor -CMD ["tor"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD [] diff --git a/compose.yml b/compose.yml index 2f0e32b..6b0aa81 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,14 @@ services: tor: + build: + context: . + dockerfile: Dockerfile image: dockurr/tor container_name: tor + environment: + - TOR_CONTROL_PASSWORD=password + # Optional: Connect healthcheck to external Tor server + # - TOR_CONTROL_ADDR=192.168.1.100:9051 ports: - 9050:9050 - 9051:9051 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..a00d502 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +# Get control password from environment (default: "password") +TOR_CONTROL_PASSWORD="${TOR_CONTROL_PASSWORD:-password}" + +echo "Generating Tor control port password hash..." + +# Generate hashed password using Tor +# tor --hash-password outputs the hash on the last line +HASHED_PASSWORD=$(tor --hash-password "$TOR_CONTROL_PASSWORD" | tail -n 1) + +if [ -z "$HASHED_PASSWORD" ]; then + echo "ERROR: Failed to generate password hash" >&2 + exit 1 +fi + +echo "Hash generated successfully" + +# Create defaults file with default settings for Docker healthcheck +# These can be overridden by user's /etc/tor/torrc +cat > /tmp/torrc-defaults <` and put the result in torrc + cmd := fmt.Sprintf("AUTHENTICATE \"%s\"\r\n", controlPassword) + + if _, err := conn.Write([]byte(cmd)); err != nil { + return err + } + + response, err := reader.ReadString('\n') + if err != nil { + return err + } + + if !strings.HasPrefix(response, "250") { + return fmt.Errorf("authentication failed: %s", strings.TrimSpace(response)) + } + + return nil +} + +func getFingerprint(conn net.Conn, reader *bufio.Reader) (string, error) { + if _, err := conn.Write([]byte("GETINFO fingerprint\r\n")); err != nil { + return "", err + } + + // Parse response + var fingerprint string + for { + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimSpace(line) + + // Debug: Show raw response from Tor Control Protocol + if debugMode { + fmt.Fprintf(os.Stderr, "DEBUG: Tor response: %q\n", line) + } + + // Looks for the Fingerprint line + if strings.HasPrefix(line, "250-fingerprint=") { + fingerprint = strings.TrimPrefix(line, "250-fingerprint=") + // Removes spaces to be compatible with Onionoo API + fingerprint = strings.ReplaceAll(fingerprint, " ", "") + // Convert to uppercase for Onionoo API + fingerprint = strings.ToUpper(fingerprint) + } else if strings.HasPrefix(line, "250 ") { + // End of response + break + } else if strings.HasPrefix(line, "551") { + // Not running as relay + return "", fmt.Errorf("not running as a relay: %s", line) + } + } + + if fingerprint == "" { + return "", fmt.Errorf("fingerprint not found in response") + } + + // Validation: must be 40 characters long + if len(fingerprint) != 40 { + return "", fmt.Errorf("invalid fingerprint length: got %d chars, expected 40", len(fingerprint)) + } + + // Validation: must only contain hex characters + for _, char := range fingerprint { + if !((char >= '0' && char <= '9') || (char >= 'A' && char <= 'F')) { + return "", fmt.Errorf("invalid fingerprint: contains non-hex character '%char'", char) + } + } + + return fingerprint, nil +} + +func checkOnionoo(fingerprint string) error { + client := &http.Client{ + Timeout: timeout, + } + + url := fmt.Sprintf(onionooAPIURL, fingerprint) + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to query onionoo API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("onionoo API returned status %d", resp.StatusCode) + } + + var buf bytes.Buffer + if _, err := buf.ReadFrom(resp.Body); err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var onionoo OnionooResponse + if err := json.Unmarshal(buf.Bytes(), &onionoo); err != nil { + return fmt.Errorf("failed to parse onionoo response: %w", err) + } + + // Checks if the relay is, consensus-wise, running + if len(onionoo.Relays) == 0 { + return fmt.Errorf("relay %s not found in onionoo database (new relays may take hours to appear)", fingerprint) + } + + relay := onionoo.Relays[0] + + // Print relay details + fmt.Fprintf(os.Stderr, "Found relay: %s (fingerprint: %s)\n", relay.Nickname, relay.Fingerprint) + + // Return error if fingerprints don't match + if !strings.EqualFold(relay.Fingerprint, fingerprint) { + return fmt.Errorf("fingerprint mismatch: requested %s, got %s", fingerprint, relay.Fingerprint) + } + + // Return error if relay is not running + if !relay.Running { + return fmt.Errorf("relay %s is not running according to onionoo", relay.Nickname) + } + + // Return error if relay has no flags + if len(relay.Flags) == 0 { + return fmt.Errorf("relay %s has no flags (not yet in consensus or not listed)", relay.Nickname) + } + + // Log successful validation + fmt.Fprintf(os.Stderr, "Relay is running with flags: %v\n", relay.Flags) + + return nil +} diff --git a/readme.md b/readme.md index 8d50498..d334515 100644 --- a/readme.md +++ b/readme.md @@ -44,6 +44,97 @@ services: docker run -it --rm --name tor -p 9050:9050 -p 9051:9051 -v "${PWD:-.}/config:/etc/tor" -v "${PWD:-.}/data:/var/lib/tor" docker.io/dockurr/tor ``` +## Configuration ๐Ÿ”ง + +The container supports custom Tor configuration via mounted `torrc` file at `/etc/tor/torrc`. + +**Environment Variables:** + +- `TOR_CONTROL_ADDR` - Address of Tor Control Port (default: "127.0.0.1:9051") + - Default connects to Tor instance within the same container + - For external Tor server: `TOR_CONTROL_ADDR=192.168.1.100:9051` + - Useful for monitoring remote relays or separate Tor containers + +- `TOR_CONTROL_PASSWORD` - Password for Tor control port (default: "password") + - The container automatically generates the required hash + - Change this for production deployments + - Example: `TOR_CONTROL_PASSWORD=mySecurePassword123` + +- `DEBUG` - Enable debug output (default: "false") + - Set to "true" for troubleshooting + - Shows raw Tor Control Protocol responses + +**Default Settings:** + +- `SocksPort 0.0.0.0:9050` - SOCKS proxy enabled +- `ControlPort 127.0.0.1:9051` - Control port for healthcheck (container-local) +- `HashedControlPassword` - Generated automatically from `TOR_CONTROL_PASSWORD` + +**Custom Configuration:** + +You can provide your own `torrc` file with relay, exit node, or hidden service settings. The container will: +- Use your custom settings as the primary configuration +- Apply defaults only for options you don't specify +- Ensure required healthcheck settings are available + +**Example with custom password:** + +```yaml +services: + tor: + image: dockurr/tor + environment: + - TOR_CONTROL_PASSWORD=mySecurePassword123 + ports: + - 9050:9050 + volumes: + - ./config:/etc/tor + - ./data:/var/lib/tor +``` + +**Example custom torrc:** + +``` +# Your relay configuration +Nickname MyTorRelay +ContactInfo your@email.com +ORPort 9050 +DirPort 9030 +ExitRelay 0 +ExitPolicy reject *:* + +# SocksPort and ControlPort are set by default if not specified +# To override, simply add your own settings here +``` + +## Development & Testing ๐Ÿงช + +**Testing the healthcheck locally:** + +When testing `healthcheck/main.go` outside the container with `go run main.go`, you must set environment variables on your **host system** (not in compose.yml): + +```bash +# Windows PowerShell +$env:TOR_CONTROL_PASSWORD="yourpassword" +go run healthcheck/main.go + +# Windows CMD +set TOR_CONTROL_PASSWORD=yourpassword +go run healthcheck\main.go + +# Linux/macOS +export TOR_CONTROL_PASSWORD=yourpassword +go run healthcheck/main.go +``` + +**Important:** compose.yml environment variables only apply **inside the container**. For full end-to-end testing, build and run the container: + +```bash +docker compose build +docker compose up -d +docker compose logs -f tor +``` + ## Stars ๐ŸŒŸ [![Stars](https://starchart.cc/dockur/tor.svg?variant=adaptive)](https://starchart.cc/dockur/tor)