req-tor
Activity
Contributors
README
ReqTor
ReqTor is a 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.
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.
# 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:
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
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.
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.
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:
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.
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.
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:
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.
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:
:tor_browserset totruesendsMozilla/5.0 (Windows NT 10.0; rv:128.0) Gecko/20100101 Firefox/128.0.:user_agentset to a string sends that string.- A
user-agentheader already on the request is left alone. - 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. :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{}.
:reasonis the failure.:hostand:portare the proxy, not the destination.
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: closewhen 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
:intofunction for a ranged download. Content-Lengthis 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.
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.
ReqTor.get!(
plug: fn conn ->
Plug.Conn.send_resp(conn, 200, "ok")
end,
tor_enabled: false
)
:plug needs Plug in the application, the same as it does for Req. Req.Test stubs work through that option.
Development
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.