Packages

Req-compatible HTTP client that routes requests through a local Tor SOCKS5 proxy

Current section

Files

Jump to
req_tor README.md
Raw

README.md

# ReqTor

ReqTor is a [Req](https://hexdocs.pm/req) client that sends HTTP through a local Tor SOCKS5 proxy. Clearnet sites and `.onion` services both go through that proxy. `Req.get!/1` does not.

The value you build is a normal `%Req.Request{}`. JSON, forms, auth, params, redirects, retries, decompression, and body decoding still belong to Req. ReqTor replaces the Finch transport: it connects to the proxy, asks Tor to open the destination, and speaks HTTP/1.1 on that socket. The hostname is part of the SOCKS5 request, so DNS for these calls happens at Tor.

Tor has to be running before you call. ReqTor does not start it, and it does not speak the Tor control protocol.

## Installation

Elixir `~> 1.14`. Req is brought in by this package.

```elixir
def deps do
  [
    {:req_tor, "~> 0.1.2"}
  ]
end
```

## Run Tor

The default proxy is `127.0.0.1:9050`, which is the Tor daemon. Tor Browser's proxy is `127.0.0.1:9150`. Pass that port with `tor_port: 9150`. Any SOCKS5 listener works if you set `:tor_host` and `:tor_port`. Pass `:tor_username` and `:tor_password` when the listener expects them. Tor's default `IsolateSOCKSAuth` uses that pair as the circuit key, so two calls with different pairs do not share a circuit.

```bash
# macOS
brew install tor
brew services start tor

# Debian, Ubuntu
sudo apt install tor
sudo systemctl start tor
```

Check the daemon before blaming the client:

```bash
curl --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip
```

`IsTor` should be `true`. `--socks5-hostname` matters: a plain `--socks5` resolves DNS on the machine and leaks the name.

## Usage

```elixir
ReqTor.get!("https://check.torproject.org/api/ip").body
#=> %{"IP" => "185.220.101.42", "IsTor" => true}

ReqTor.get!("http://example.onion/")

req = ReqTor.new(tor_port: 9150, base_url: "http://example.onion")
ReqTor.get!(req, url: "/")

# One call, straight out, on a client that otherwise uses Tor.
ReqTor.get!(req, url: "https://example.com", tor_enabled: false)
```

`get/2`, `head/2`, `post/2`, `put/2`, `patch/2`, `delete/2`, and `request/2` return `{:ok, response}` or `{:error, exception}`. The bang forms return the response or raise.

```elixir
case ReqTor.get("http://example.onion/", retry: false) do
  {:ok, %{status: 200, body: body}} -> body
  {:error, %ReqTor.Error{} = error} -> Exception.message(error)
end
```

The first argument is a URL, a `URI`, a keyword list, or a request. The second argument is options and overrides the request.

```elixir
ReqTor.post!("https://httpbin.org/post", json: %{ok: true}, retry: false)

ReqTor.get!("https://example.com/large.bin",
  tor_username: "circuit-a",
  tor_password: "circuit-a",
  retry: false,
  into: fn {:data, chunk}, acc ->
    IO.binwrite(chunk)
    {:cont, acc}
  end
)

req = ReqTor.new()
ReqTor.request!(req, url: "https://httpbin.org/put", method: :put, body: "hello")
```

`request/2` keeps the request's method unless you pass `:method`. A request from `new/1` starts as `:get`.

Req options you already use keep working, because their steps run before the Tor step and their response steps run after it:

```elixir
req =
  ReqTor.new(
    base_url: "http://example.onion",
    auth: {:bearer, token},
    receive_timeout: 120_000
  )

ReqTor.get!(req, url: "/items", params: [q: "caps"])
```

`redirect: false` leaves a 30x response alone. The default follows up to 10 redirects, and each hop opens its own Tor connection. Auth is sent again only when Req would send it (`redirect_trusted: true` sends it to any host).

## Building a request

`new/1` builds a client. `merge/2` changes only the keys you pass, and attaches Tor if the request does not have it yet.

```elixir
req =
  ReqTor.new(base_url: "http://example.onion")
  |> ReqTor.merge(tor_port: 9150, auth: {:basic, "user:pass"})
```

`attach/2` puts Tor on a request that already has other steps or plugins. Call a ReqTor verb afterwards. `Req.get!/2` on that same struct also goes through Tor, because the step is stored on the request. `Req.get!("https://...")` never does.

```elixir
req = Req.new(base_url: "http://example.onion") |> ReqTor.attach()
ReqTor.get!(req, url: "/")
```

`attached?/1` is `true` when that step is present. `tor_enabled: false` does not remove it. It only skips the proxy for the call, which is how a stub can run:

```elixir
ReqTor.attached?(ReqTor.new(tor_enabled: false))
#=> true

ReqTor.attached?(Req.new())
#=> false
```

A second `attach/2` is not a merge. Every Tor option is written again, and any option you omit goes back to its default. `merge/2` is the call that changes one key.

```elixir
req = ReqTor.new(tor_port: 9150, tor_browser: true)
req = ReqTor.attach(req, tor_host: "localhost")
req.options[:tor_port]
#=> 9050
```

Unknown keys raise `ArgumentError` from Req.

## User-Agent

The header is chosen in this order:

1. `:tor_browser` set to `true` sends `Mozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0`.
2. `:user_agent` set to a string sends that string.
3. A `user-agent` header already on the request is left alone.
4. Otherwise the header is `ReqTor/` plus this library's version.

`:tor_browser` is only that header. It does not copy the rest of Tor Browser. Setting `:tor_browser` back to `false` does not restore the previous header. Pass `:user_agent` when you want a specific value.

## Onion names and TLS

`:tor_insecure` defaults to `:auto`.

| Value | Certificate check |
| --- | --- |
| `:auto` | Verify clearnet names. Skip the check when the host ends in `.onion`. |
| `true` | Skip the check for every host. |
| `false` | Verify every host, including onions. |

SNI is the destination host in all three modes. The CA store is `:public_key.cacerts_get()`. ALPN offers only `http/1.1`.

`:auto` is the useful default. A v3 onion name is the service's identity, and a lot of onion HTTPS uses a certificate that a public CA will not chain. `:false` reports those certificates as a TLS failure. `:true` also turns verification off for clearnet, which gives the exit node a chance to substitute a certificate. Prefer `:auto`.

Use `http://` for onions that do not speak TLS. An HTTP onion is readable by the relays that carry it. An HTTPS onion is not, apart from the name in SNI, which those relays already have.

## Options

| Option | Default | What it does |
| --- | --- | --- |
| `:tor_host` | `"127.0.0.1"` | SOCKS5 proxy host |
| `:tor_port` | `9050` | SOCKS5 proxy port. Tor Browser is `9150` |
| `:tor_enabled` | `true` | `false` skips the proxy for that call and leaves the request attached |
| `:tor_insecure` | `:auto` | TLS verification. See the table above |
| `:tor_browser` | `false` | Replace the User-Agent with the Firefox 128 string above |
| `:tor_username` | `nil` | SOCKS5 username. Set it with `:tor_password` |
| `:tor_password` | `nil` | SOCKS5 password. Set it with `:tor_username` |
| `:receive_timeout` | `60000` | Milliseconds for the TCP connect, the SOCKS5 handshake, the TLS handshake, and each socket read |
| `:user_agent` | `ReqTor/<version>` | Used unless `:tor_browser` is set or a `user-agent` header is already present |

`:receive_timeout` is one socket timeout, applied to each of those operations. It is not a deadline for the whole body. A value that is negative or not an integer is treated as `60000`. Req's `:connect_options` are not read.

Anything else is a [Req option](https://hexdocs.pm/req/Req.html#new/1). `:into` streams the response body: `nil` buffers it, a 2-arity function is called for each chunk, `:self` delivers chunks to the calling process, and a collectable is used only when the status is 200. A 206 range response needs the function form. These Req transport options do nothing while Tor is enabled, because Finch never runs: `:adapter`, `:plug`, `:finch`, `:finch_request`, `:finch_private`, `:connect_options`, `:inet6`, `:pool_timeout`, `:unix_socket`, and `:pool_max_idle_time`.

## Errors

A proxy, SOCKS5, TLS, or socket failure is `%ReqTor.Error{}`.

* `:reason` is the failure.
* `:host` and `:port` are the proxy, not the destination.

```elixir
error = %ReqTor.Error{reason: :econnrefused, host: "127.0.0.1", port: 9050}

Exception.message(error)
#=> "Cannot connect to Tor SOCKS5 proxy at 127.0.0.1:9050 - is Tor running?"
```

`:econnrefused` means the proxy port refused the TCP connection. The destination refusing the connection is `:socks5_connection_refused` instead. The messages for every reason are in `ReqTor.Error`.

Retries follow Req, with one adjustment. `:timeout` and `:closed` are turned into `%Req.TransportError{}` when a retry is allowed, because that is the error Req knows how to retry. The default mode is `:safe_transient`, so this applies to GET and HEAD. If those retries run out, the error you get is `Req.TransportError`, not `ReqTor.Error`. `retry: false` keeps the `ReqTor.Error` and does not retry. POST and the other methods are not retried unless you pass `retry: :transient` or a function.

`:econnrefused` and the SOCKS5 reasons are not retried. HTTP 408, 429, 500, 502, 503, and 504 are retried by Req's usual response step, and each attempt opens a new Tor connection.

## Limits

* One HTTP/1.1 request per connection. The client adds `Connection: close` when that header is missing, then closes the socket. There is no pool and no HTTP/2.
* A `{:stream, enumerable}` request body is read to the end before it is sent. The response body streams only through `:into`.
* A collectable receives the body only when the status is 200. Other statuses, including 206, are buffered. Use an `:into` function for a ranged download.
* `Content-Length` is added for a non-empty POST, PUT, or PATCH body. Other methods send the body without that header.
* A hostname longer than 255 bytes fails with `:socks5_address_type_not_supported`.
* ReqTor cannot rotate circuits. That requires the Tor control port.

Req steps that finish the request early never open a proxy connection. A cache hit is the usual case.

## Is this Tor?

`check_tor?/1` requests `https://check.torproject.org/api/ip` and returns `true` only when `IsTor` is `true`. A down proxy, a timeout, and a non-200 response all return `false`. Use `exit_ip/1` or an ordinary request when you need the error.

`exit_ip/1` reads the `IP` field from the same URL.

```elixir
req = ReqTor.new()

ReqTor.check_tor?(req)
#=> true

ReqTor.exit_ip(req)
#=> {:ok, "185.220.101.42"}

ReqTor.exit_ip(tor_port: 9150)
#=> {:ok, "185.220.101.42"}
```

Both functions take a request or a keyword list, the same options as `new/1`. They do not take a second argument. A 200 response with no `IP` field is `{:ok, nil}`. Any other status is `{:error, {:http_error, status}}`. A transport failure is `{:error, exception}`.

## Testing without the network

`:plug` and `:adapter` run only when Tor is skipped. Leave them on an enabled request and the Tor step never reaches them.

```elixir
ReqTor.get!(
  plug: fn conn ->
    Plug.Conn.send_resp(conn, 200, "ok")
  end,
  tor_enabled: false
)
```

`:plug` needs [Plug](https://hexdocs.pm/plug) in the application, the same as it does for Req. `Req.Test` stubs work through that option.

## Development

```bash
mix test
mix docs
```

The tests under `"live Tor"` call `check.torproject.org` and `httpbin.org` through `127.0.0.1:9050`.

## License

BSD 3-Clause. Copyright (c) 2026, Zachary Hunsaker. See [LICENSE](LICENSE).