Torex

CircleCIHex.pmHex DocsHex.pm DownloadsLicense

Elixir HTTP client for making requests through the Tor network. Wraps HTTPoison with SOCKS5 proxy support for routing traffic through a local Tor node.

Requirements

Installation

Add torex to your dependencies in mix.exs:

def deps do
  [{:torex, "~> 0.2.0"}]
end

Tor Setup

macOS

brew install tor
brew services start tor

Linux (Debian/Ubuntu)

sudo apt install tor
sudo systemctl start tor

Docker

docker run -d -p 9050:9050 dperson/torproxy

Tor runs on port 9050 by default.

Configuration

Add to your config/config.exs:

config :torex,
  tor_host: ~c"127.0.0.1",
  tor_port: 9050,
  # Optional: for circuit renewal (new exit node IP)
  control_port: 9051,
  control_password: "your_password"

Note: tor_host uses a charlist (~c"...") as required by the underlying :hackney library.

Usage

GET requests

{:ok, body} = Torex.get("http://example.onion")

case Torex.get("http://check.torproject.org") do
  {:ok, body} ->
    IO.puts("Response: #{body}")
  {:error, {:http_error, status, body}} ->
    IO.puts("HTTP #{status}: #{body}")
  {:error, %{reason: reason}} ->
    IO.puts("Request failed: #{reason}")
end

POST requests

POST requests automatically encode the body as JSON:

{:ok, response} = Torex.post("http://example.onion/api", %{
  username: "user",
  password: "secret"
})

Error Handling

Torex returns tagged tuples for all responses:

case Torex.get(url) do
  {:ok, body} ->
    # Success - HTTP 200
    process(body)

  {:error, {:http_error, status_code, body}} ->
    # Non-200 HTTP response
    Logger.warning("HTTP #{status_code}: #{body}")

  {:error, %{reason: :econnrefused}} ->
    # Tor not running or unreachable
    Logger.error("Cannot connect to Tor")

  {:error, %{reason: :timeout}} ->
    # Request timed out
    Logger.error("Request timed out")

  {:error, error} ->
    # Other errors
    Logger.error("Request failed: #{inspect(error)}")
end

Verifying Tor Connection

Test that your traffic is routing through Tor:

{:ok, body} = Torex.get("https://check.torproject.org/api/ip")
IO.inspect(Jason.decode!(body))
# => %{"IsTor" => true, "IP" => "..."}

Circuit Renewal (IP Rotation)

For scraping or when you need a fresh exit node IP, use renew_circuit/0:

# Get current IP
{:ok, body} = Torex.get("https://api.ipify.org")
IO.puts("Current IP: #{body}")

# Request new circuit (new exit node)
:ok = Torex.renew_circuit()

# Wait a moment for the new circuit
Process.sleep(1000)

# Verify new IP
{:ok, body} = Torex.get("https://api.ipify.org")
IO.puts("New IP: #{body}")

Tor Control Port Setup

Circuit renewal requires the Tor control port. Add to your torrc:

ControlPort 9051
HashedControlPassword <your_hashed_password>

Generate a hashed password:

tor --hash-password "your_password"

Scraping Example with IP Rotation

defmodule Scraper do
  @max_requests_per_ip 10

  def scrape(urls) do
    urls
    |> Enum.chunk_every(@max_requests_per_ip)
    |> Enum.flat_map(fn chunk ->
      results = Enum.map(chunk, &fetch/1)

      # Rotate IP after each batch
      Torex.renew_circuit()
      Process.sleep(1000)

      results
    end)
  end

  defp fetch(url) do
    case Torex.get(url) do
      {:ok, body} -> {:ok, url, body}
      {:error, reason} -> {:error, url, reason}
    end
  end
end

Note: Tor rate-limits circuit renewal to once per 10 seconds. Calling more frequently succeeds but won't change the circuit.

License

MIT