Packages

A full-featured reverse proxy for Phoenix and Plug applications with HTTP and WebSocket support.

Current section

Files

Jump to
reverse_it README.md
Raw

README.md

# ReverseIt - Elixir HTTP/WebSocket Reverse Proxy
A full-featured HTTP/1.1, optional HTTP/2, and WebSocket reverse proxy for Elixir, built using Finch (HTTP) and Mint (WebSockets). Designed to work seamlessly within Phoenix/Plug pipelines.
**WebSocket server compatibility:** Use Bandit for WebSocket proxy routes. Cowboy's WebSocket
process handoff is not supported: the upstream socket belongs to the HTTP request process
and can close during the upgrade. ReverseIt warns on Cowboy WebSocket attempts, not merely
when Cowboy is installed. Ordinary HTTP proxying is unaffected.
## Features
- **Full HTTP Support**: HTTP/1.1 proxying by default, optional HTTP/2 upstreams, and streaming request/response bodies
- **Connection Pooling**: Automatic connection pooling via Finch (50 connections per backend)
- **One-Shot Upstreams**: Fresh HTTP/1.1 connections over TCP or Unix-domain sockets
- **HTTP/2 Support**: Opt-in upstream HTTP/2 support with `protocols: [:http1, :http2]`
- **WebSocket Proxying**: Bidirectional WebSocket frame forwarding with full protocol support
- **Plug Integration**: Works as a standard Plug module in any Phoenix or Plug application
- **Header Management**: Automatic X-Forwarded-* header injection and hop-by-hop header filtering
- **DoS Protection**: Configurable request/response, header, timeout, and WebSocket limits with router-style defaults
- **Path Manipulation**: Strip path prefixes and add backend path prefixes
- **Protocol Detection**: Automatic detection and routing for HTTP vs WebSocket upgrades
## Setup
First, add ReverseIt to your application's supervision tree with a connection pool:
```elixir
defmodule MyApp.Application do
def start(_type, _args) do
children = [
# Start ReverseIt with a connection pool
{ReverseIt, name: MyApp.ReverseProxy, pool_size: 100},
# ... other children
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end
```
## Usage
### In a Phoenix Router
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# Regular Phoenix routes
scope "/", MyAppWeb do
get "/", PageController, :index
end
# Proxy API requests to backend service
scope "/api" do
forward "/", ReverseIt,
name: MyApp.ReverseProxy,
backend: "http://backend-api:4000",
strip_path: "/api"
end
# Proxy WebSocket connections
scope "/socket" do
forward "/", ReverseIt,
name: MyApp.ReverseProxy,
backend: "ws://backend-ws:4000"
end
end
```
### As a Plug
```elixir
defmodule MyApp.ProxyPlug do
use Plug.Router
plug :match
plug :dispatch
forward "/", ReverseIt,
name: MyApp.ReverseProxy,
backend: "http://localhost:4001",
upstream_idle_timeout: 60_000,
protocols: [:http1, :http2]
end
```
### One-Shot Unix-Socket Upstreams
Use a one-shot upstream when every proxied request or WebSocket upgrade must
receive a fresh connection to a local Unix-domain socket:
```elixir
ReverseIt.call(
conn,
ReverseIt.init(
name: MyApp.ReverseProxy,
backend: "http://provider-tunnel",
unix_socket: "/run/my_app/provider-tunnel.sock",
upstream_connection: :one_shot,
protocols: [:http1]
)
)
```
The backend host remains the HTTP `Host` and WebSocket authority while
`:unix_socket` selects the transport address. Unix-socket upstreams are local,
unencrypted HTTP/1.1 only and always one-shot; ReverseIt never returns these
connections to the Finch pool.
## Customizing Requests and Responses
You can wrap ReverseIt in your own Plug to modify request headers, add response headers, implement authentication, logging, etc. Use `Plug.Conn.register_before_send/2` to modify responses before they're sent to the client.
```elixir
defmodule MyApp.APIProxy do
@moduledoc """
Custom proxy that adds authentication and custom headers.
"""
@behaviour Plug
def init(opts), do: opts
def call(conn, _opts) do
# Modify request before proxying
conn
|> Plug.Conn.put_req_header("x-api-key", "...")
# Register callback to modify response after backend responds
|> Plug.Conn.register_before_send(fn conn ->
conn
|> Plug.Conn.put_resp_header("x-proxy-by", "MyApp")
|> Plug.Conn.put_resp_header("x-proxy-version", "1.0")
|> log_request()
end)
# Proxy to backend
|> ReverseIt.call(
ReverseIt.init(
name: MyApp.ReverseProxy,
backend: "http://backend-api:4000",
strip_path: "/api"
)
)
end
defp log_request(conn) do
Logger.info("Proxied #{conn.method} #{conn.request_path} → #{conn.status}")
conn
end
end
# In your router:
scope "/api" do
forward "/", MyApp.APIProxy
end
```
## Application-controlled HTTP responses
Use `ReverseIt.request/3` when your application needs to inspect or transform
response bytes, buffer an error before deciding whether to retry, or forward a
request body it has already read and validated. Ordinary `ReverseIt.call/2`
continues to act as a Plug and sends the response automatically.
```elixir
defmodule MyApp.ResponseHandler do
@behaviour ReverseIt.ResponseHandler
@impl true
def handle_headers(status, _headers, state) when status >= 400 do
{:buffer, 1_048_576, state}
end
def handle_headers(_status, headers, state) do
{:stream, headers, state}
end
@impl true
def handle_data(bytes, state) do
{:ok, bytes, %{state | bytes: state.bytes + byte_size(bytes)}}
end
end
config = ReverseIt.init(
name: MyApp.ReverseProxy,
backend: "https://api.example.com",
forwarded_headers: false,
protocols: [:http1]
)
request_opts = [
request_body: validated_body,
response_handler: {MyApp.ResponseHandler, %{bytes: 0}}
]
case ReverseIt.request(conn, config, request_opts) do
{:ok, conn, _state} ->
# The response has already been sent/streamed.
Plug.Conn.halt(conn)
{:buffered, response, conn, _state} ->
# Still unsent: inspect response.status/body, rewrite it, or decide whether
# this operation is safe to retry using the same explicitly supplied body.
conn
|> ReverseIt.send_buffered(response)
|> Plug.Conn.halt()
{:error, _reason, conn, _state} ->
# Only pre-commit failures return here. Do not expose raw error details.
conn |> Plug.Conn.send_resp(502, "Upstream unavailable") |> Plug.Conn.halt()
end
```
`request_body` accepts a binary, including `""`; `nil` keeps normal conn body
reading/streaming. Explicit bodies still obey `max_request_body_size`, and their
Content-Length is recalculated. Initialize the static config once and reuse it.
Pass the body and fresh handler state in the third argument for each request;
`init/1` rejects these options. Handler modules are checked at request time, so
initializing a router does not depend on their compilation order.
### Per-request options (`request/3`)
- `:request_body` — Already-read binary bytes, including `""`.
Defaults to `nil`, which reads from the conn.
- `:response_handler` — `{module, initial_state}` implementing
`ReverseIt.ResponseHandler`. Defaults to `nil`, which forwards the response
unchanged.
Omitting the third argument is equivalent to passing `[]`. Unlike the Plug entry
point, `request/3` leaves halting the conn and sending buffered/error results to
the caller. `ReverseIt.send_buffered/2` sends a buffered result while preserving
repeated headers such as Set-Cookie and replacing Plug's default response
headers. It runs registered before-send callbacks and leaves halting to the
caller. Headers set by earlier plugs, such as request IDs and CORS headers,
are also replaced; restore them in a `Plug.Conn.register_before_send/2` callback
if needed. If you rewrite the body, update its representation headers before
sending it.
The required `handle_headers/3` callback chooses streaming, finite buffering, or
`{:error, reason, state}` before commitment. Optional `handle_data/2` and
`handle_end/1` return `{:ok, iodata, state}` or `{:error, reason, state}`. Data
callbacks receive arbitrary transport chunks, not complete JSON/SSE events.
Applications must bound their own parser state. Buffered responses skip data/end
callbacks and return the original bytes for caller-owned processing.
Optional `terminate/2` receives `:ok`, `:buffered`, or `{:error, reason}` and the
final state. It can run with the initial state before `handle_headers/3` when
client validation, connection setup, or request-body handling fails. Invalid
header-callback returns produce `{:invalid_handler_return, :handle_headers}`
and run cleanup with the original callback state. Downstream write failures
use `{:downstream, reason}`. Callback exceptions propagate; `terminate/2` is not
guaranteed for programming errors or process termination. It should be a
lightweight observer and must not raise.
The same callbacks run for pooled and one-shot HTTP. Errors returned by
`request/3` are not logged; callers choose how to log them. With a response
handler, failures after commitment also leave logging to the caller: `terminate/2`
receives the failure before the stream exits. Without a handler, ReverseIt logs
post-commit failures once before aborting the stream. Handled streams drop
the upstream Content-Length when a body is allowed. Both received and emitted
bytes obey `max_response_body_size`. Buffering also requires its own finite
limit.
Header filtering/validation remains enforced before and after header callbacks.
The proxy does not decompress responses; handlers can reject unsupported encodings
before sending anything. HTTP/1 is recommended for synchronous backpressure.
By default, handled streams commit when a callback first emits nonempty bytes.
If all output is empty, commitment waits for a successful end callback. This rule
is the same for finite and infinite response limits, so a first-chunk rejection
returns an unsent error. Headers are handled only once; informational responses
and trailers do not rerun the header callback.
For quiet SSE or long-polling responses, `handle_headers/3` can return
`{:stream, headers, state, commit: :headers}` to send the validated status and
headers immediately. This avoids waiting for a body chunk before a load balancer
sees the response. With this opt-in, even a first-chunk rejection aborts the
response because its headers have already been sent. Returning an empty options
list or `commit: :output` explicitly selects the default deferred behavior.
After commitment, transport or callback-reported failures abort the downstream
stream rather than completing a truncated body. Never catch and convert such an
exit into a successful response. Configuring a handler disables automatic
`response_header_retries`; retry decisions remain with the caller. WebSocket
upgrades are rejected by `request/3`; the existing Plug WebSocket API is unchanged.
### Pinning an upstream address
Keep the logical hostname in `backend` and supply the already-vetted IP separately:
```elixir
ReverseIt.init(
name: MyApp.ReverseProxy,
backend: "https://api.example.com/v1",
connect_ip: {203, 0, 113, 10},
upstream_connection: :one_shot,
protocols: [:http1]
)
```
No DNS resolution occurs when dialing this address. TLS certificate verification,
SNI, and the default Host header still use the backend hostname. IPv4 and IPv6
address tuples are supported. The application remains responsible for resolving
and authorizing the destination; this option itself does not implement SSRF policy.
Pinned addresses require one-shot connections and cannot be combined with
`unix_socket`. This avoids reusing a pooled connection across different IP/hostname
identities. The shared direct transport also supports pinning WebSocket upstreams.
## Configuration Options
### Supervisor Options (when starting ReverseIt)
- `:name` (required) - Name for the Finch connection pool
- `:pool_size` - Max connections per backend (default: 50)
- `:pool_count` - Number of connection pools (default: 1)
- `:connect_timeout` - Backend connection timeout in ms (default: 5,000)
- `:upstream_send_timeout` - Socket write timeout for pooled upstream connections (default: 55,000); timed-out sockets are closed rather than reused
- `:conn_max_idle_time` - Idle timeout for pooled backend HTTP/1 connections (default: 90,000)
- `:protocols` - Upstream protocols for pooled Finch requests (default: `[:http1]`)
- `:inet6` - Try IPv6 before IPv4 for pooled connections (default: `false`). Enable for IPv6 backends, e.g. `backend: "http://[::1]:4000"`. Direct one-shot and WebSocket connections detect IPv6 literals automatically.
### Plug Options (when using as a Plug)
- `:name` (required) - Name of the Finch pool to use
- `:backend` (required) - Backend URL (http://, https://, ws://, or wss://)
- `:connect_ip` - Vetted IPv4/IPv6 address tuple to dial while retaining the backend hostname for TLS verification, SNI, and HTTP authority. Requires `:one_shot`; cannot be combined with `:unix_socket`.
- `:unix_socket` - Connect through this Unix-domain socket instead of the backend host/port
- `:upstream_connection` - `:pooled` or `:one_shot` (default: `:pooled`)
- `:strip_path` - Path prefix to strip from incoming requests
- `:connect_timeout` - Backend connection timeout in milliseconds (default: 5,000)
- `:pool_timeout` - Finch pool checkout timeout in milliseconds (default: 5,000)
- `:response_header_timeout` - Time to wait for backend response headers in streaming paths (default: 30,000)
- `:upstream_idle_timeout` - Rolling idle timeout while receiving backend data (default: 55,000)
- `:upstream_send_timeout` - Socket write timeout for direct one-shot HTTP and WebSocket connections (default: 55,000). For pooled HTTP, set this on the supervisor child instead. This bounds blocked writes independently of receive timeouts; it does not add full-duplex forwarding of early backend responses.
- `:request_body_read_timeout` - Rolling timeout while reading client request bodies (default: 55,000)
- `:max_request_body_size` - Maximum request body size in bytes (default: 104,857,600 / 100MB, `:infinity` for unlimited)
- `:request_body_buffer_size` - Body bytes buffered before switching to request streaming (default: 1,048,576 / 1MB)
- `:max_response_body_size` - Maximum response body size in bytes (default: `:infinity`)
- `:max_request_target_bytes` - Maximum request path/query bytes (default: 8,192)
- `:max_request_header_line_bytes` - Maximum single request header bytes (default: 8,192)
- `:max_request_header_bytes` - Maximum total request header bytes (default: 65,536)
- `:max_request_headers` - Maximum request header count (default: 100)
- `:max_response_header_bytes` - Maximum backend response header bytes (default: 65,536)
- `:forwarded_headers` - `:append`, `:replace`, or `false` for X-Forwarded-* behavior (default: `:append`)
- `:add_headers` / `:remove_headers` - Backend request header policy
- `:verify_tls` - Verify backend TLS certificates (default: `true`)
- `:protocols` - List of supported upstream protocols (default: `[:http1]`)
- `:websocket_idle_timeout` - WebSocket idle timeout in milliseconds (default: 55,000)
- `:websocket_backend_upgrade_timeout` - Backend WebSocket upgrade timeout (default: 5,000)
- `:max_websocket_upgrade_response_body_size` - Maximum buffered backend upgrade rejection body (default: 65,536 / 64KB). Must be a finite non-negative integer; a smaller `:max_response_body_size` also applies. Oversized rejections use `:error_response` instead of buffering indefinitely.
- `:max_websocket_frame_size` - Maximum WebSocket frame/message size (default: 16,777,216 / 16MB)
- `:max_websocket_pending_bytes` - Maximum bytes buffered before backend upgrade completes (default: 1,048,576 / 1MB)
- `:max_websocket_pending_frames` - Maximum frame count buffered before backend upgrade completes (default: 16)
- `:websocket_compress` - Negotiate client WebSocket compression (default: `false`)
### Router-Style Defaults
ReverseIt’s defaults are intentionally broad enough for general HTTP routers while still bounding common DoS vectors:
- 30s backend response-header timeout for streaming paths
- 55s rolling backend/client body idle timeouts
- 90s pooled backend HTTP/1 keepalive idle timeout
- 8KB request target and per-header line limits
- 64KB aggregate request/response header limits
- 100MB maximum request body with a 1MB in-memory request buffer threshold
- 16MB WebSocket frame/message limit and bounded pre-upgrade frame buffering
If ReverseIt runs behind a trusted edge proxy, set `forwarded_headers: :replace` at the edge-facing ReverseIt instance. Use `:append` only when downstream applications treat X-Forwarded-* as informational rather than trusted identity.
## Testing
The project includes comprehensive test coverage with test servers that start automatically during test runs:
```bash
# Run all tests
# Test servers start automatically on available local ports
mix test
# Run only WebSocket tests
mix test --only websocket
```
**Note:** Test servers are only started during `mix test` and are not included in the library when used as a dependency.
### Continuous Integration
GitHub Actions runs on pushes, pull requests, manual dispatch, and a weekly schedule.
The test matrix covers Elixir 1.18 on OTP 25 and 27, Elixir 1.19 on OTP 28,
Elixir 1.20 on OTP 27, and the latest stable Elixir/OTP pair. Minor-version lanes
use their newest patches; the latest-stable lane excludes release candidates.
Every lane treats project compilation and test compilation warnings as errors.
Formatting is checked only on the latest stable Elixir to avoid conflicting
formatter output between versions. To run the same checks locally:
```bash
export MIX_ENV=test
mix deps.get --check-locked
mix format --check-formatted
mix compile --warnings-as-errors
mix test --warnings-as-errors
```
### Interactive Testing
For manual/interactive testing, start the dedicated example servers in a separate terminal.
They bind to loopback on ports 4000 (proxy) and 4001 (backend) and remain running until stopped:
```bash
# Terminal 1: Keep example servers running
MIX_ENV=test mix run --no-halt examples/server.exs
# Terminal 2: Run example clients
node examples/node_client.js
python3 examples/python_client.py
# Or use curl/wscat
curl http://localhost:4000/hello
wscat -c ws://localhost:4000/ws
```
### Example Clients
The `examples/` directory contains full test clients in multiple languages:
```bash
# Node.js client (requires: npm install ws)
node examples/node_client.js
# Python client (requires: pip install requests websocket-client)
python3 examples/python_client.py
# Quick curl examples
bash examples/curl_examples.sh
```
See [examples/README.md](examples/README.md) for detailed usage.
## Architecture
### HTTP Proxy Flow
```
Client → Phoenix/Bandit → ReverseIt (Plug) → Finch (connection pool) → Backend
↑
50 pooled HTTP/1.1 connections by default
```
For `upstream_connection: :one_shot`, ReverseIt uses a fresh passive Mint
HTTP/1.1 connection instead of Finch. This path supports both TCP and
Unix-domain sockets and closes the upstream after the response.
Pooled HTTP/1 requests retain their checked-out connection while streaming
request bodies in bounded chunks. Large uploads therefore remain memory-bounded
without giving up upstream connection reuse.
Response bodies are streamed without buffering the complete download. When the
backend supplies `Content-Length`, ReverseIt preserves it so clients can report
download progress; HTTP/1.1 responses without a length use chunked transfer
encoding instead. Use Bandit 1.12.2 or newer when response compression is enabled:
older versions can compress a length-delimited stream without updating its
declared length.
### WebSocket Proxy Flow
```
Client ↔ Phoenix/Bandit ↔ ReverseIt (Plug) ↔ ReverseIt.WebSocketProxy (WebSock) ↔ Mint.WebSocket ↔ Backend
```
## Connection Pooling
ReverseIt uses [Finch](https://hexdocs.pm/finch) for HTTP requests, providing:
- **Automatic pooling**: 50 connections per backend by default
- **Connection reuse**: HTTP connections are reused across requests
- **HTTP/2 support**: Upstream HTTP/2 can be enabled with `protocols: [:http1, :http2]`
- **Performance**: Eliminates TCP/TLS handshake overhead
- **Production-ready**: Battle-tested in production Elixir applications
You configure the pool when adding ReverseIt to your supervisor tree:
```elixir
children = [
{ReverseIt, name: MyApp.ReverseProxy, pool_size: 100, pool_count: 2}
]
```
## Project Structure
```
lib/
├── reverse_it.ex # Main Plug module with protocol detection
└── reverse_it/
├── application.ex # OTP application supervisor
├── config.ex # Configuration parser and validator
├── http_proxy.ex # HTTP request proxying logic
├── upstream.ex # TCP/Unix one-shot Mint connections
└── websocket_proxy.ex # WebSocket proxy handler (WebSock behavior)
test/
└── support/
├── test_backend.ex # Test backend server
└── test_proxy.ex # Test proxy server
```
## Implementation Status
**HTTP Proxying:**
- HTTP/1.1 proxying by default; HTTP/2 upstream support is opt-in with `protocols: [:http1, :http2]`
- Request body streaming above the configured buffer threshold
- Response streaming through Finch/Mint without buffering complete responses
- Header forwarding, validation, and RFC hop-by-hop filtering
- X-Forwarded-* headers
- Connection pooling
- Fresh one-shot TCP or Unix-domain socket connections
- Path manipulation (strip_path, path_prefix)
- Plug integration
- Configuration module with validation
**WebSocket Proxying:**
- WebSocket upgrade detection and routing
- WebSocket proxy handler (WebSock behavior)
- Bidirectional frame forwarding (text, binary, ping, pong, close)
- Async initialization with frame buffering
- Bounded frame sizes, pending buffers, and idle/upgrade timeouts
- Backend connection via Mint.WebSocket
- Multiple concurrent connections
- Large message handling