ReverseIt (ReverseIt v0.3.0)

Copy Markdown View Source

A hardened HTTP/1.1, optional HTTP/2, and WebSocket reverse proxy for Elixir.

Built using Finch (HTTP) and Mint (WebSockets), ReverseIt is designed to work seamlessly within Phoenix/Plug pipelines as a standard Plug module.

WebSocket proxying currently supports Bandit. Cowboy's WebSocket process handoff is not supported: the upstream socket belongs to the HTTP request process and can close during the upgrade. ReverseIt logs a warning on Cowboy WebSocket attempts; use Bandit for WebSocket routes. Ordinary HTTP proxying is unaffected.

Streamed downloads preserve the backend's Content-Length without buffering the complete response. Use Bandit 1.12.2 or newer when response compression is enabled so the server does not compress a length-delimited stream and invalidate its length.

Features

  • Full HTTP Support: HTTP/1.1 proxying by default, optional HTTP/2 upstreams, and streaming request/response bodies
  • Connection Pooling: Uses Finch for automatic connection pooling and reuse across requests
  • 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 body, header, timeout, response, and WebSocket limits
  • 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:

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

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

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

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]
  )
)

:unix_socket selects the transport address while the backend host remains the HTTP authority. Unix-socket upstreams support local, unencrypted HTTP/1.1 and WebSocket traffic. They always use a fresh connection and never enter the Finch pool.

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)
  • :conn_max_idle_time - Idle timeout for pooled backend HTTP/1 connections (default: 90_000)
  • :protocols - Upstream protocols for pooled Finch requests (default: [:http1])

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 tuple to dial while retaining the backend hostname for TLS and HTTP; requires :one_shot and 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 before proxying
  • :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)
  • :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: 100MB, :infinity for unlimited)
  • :request_body_buffer_size - Bytes buffered before switching to request streaming (default: 1MB)
  • :max_response_body_size - Maximum response body size in bytes (default: :infinity)
  • :max_request_target_bytes - Maximum request path/query bytes (default: 8KB)
  • :max_request_header_line_bytes - Maximum single request header bytes (default: 8KB)
  • :max_request_header_bytes - Maximum total request header bytes (default: 64KB)
  • :max_request_headers - Maximum request header count (default: 100)
  • :max_response_header_bytes - Maximum backend response header bytes (default: 64KB)
  • :forwarded_headers - :append, :replace, or false for X-Forwarded-* behavior (default: :append)
  • :protocols - List of supported upstream protocols (default: [:http1])
  • :max_websocket_frame_size - Maximum WebSocket frame/message size (default: 16MB)

Connection Pooling

ReverseIt uses Finch for HTTP requests, which provides automatic connection pooling:

  • Pool Size: 50 connections per backend by default
  • Reuse: Connections are automatically reused across requests
  • HTTP/2 Support: Upstream HTTP/2 can be enabled with protocols: [:http1, :http2]
  • Performance: Eliminates TCP/TLS handshake overhead for subsequent requests

You configure the pool when adding ReverseIt to your supervisor tree.

Examples

Basic HTTP Proxying

# Proxy all requests to a backend server
forward "/", ReverseIt,
  name: MyApp.ReverseProxy,
  backend: "http://localhost:4001"

Path Stripping

# Strip /api prefix before forwarding
# Request to /api/users becomes /users at backend
forward "/api", ReverseIt,
  name: MyApp.ReverseProxy,
  backend: "http://api-server:4000",
  strip_path: "/api"

WebSocket Proxying

# Proxy WebSocket connections
forward "/ws", ReverseIt,
  name: MyApp.ReverseProxy,
  backend: "ws://websocket-server:4000"

Custom Timeouts and Protocols

# Configure timeouts and HTTP protocols
forward "/", ReverseIt,
  name: MyApp.ReverseProxy,
  backend: "https://backend:443",
  upstream_idle_timeout: 60_000,
  protocols: [:http1, :http2]

Customizing Requests and Responses

You can wrap ReverseIt in your own Plug to modify request/response headers, add authentication, logging, etc. Use Plug.Conn.register_before_send/2 to modify responses before they're sent to the client.

defmodule MyApp.APIProxy do
  @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

Summary

Types

A complete, unsent response returned by request/3.

Functions

Child spec for starting ReverseIt with a Finch connection pool.

Proxies HTTP with caller-owned response handling, without halting the conn.

Sends a buffered response returned by request/3, without halting the conn.

Types

buffered_response()

@type buffered_response() :: %{
  status: pos_integer(),
  headers: [{String.t(), String.t()}],
  body: binary()
}

A complete, unsent response returned by request/3.

Functions

child_spec(opts)

Child spec for starting ReverseIt with a Finch connection pool.

Add this to your application's supervision tree:

children = [
  {ReverseIt, name: MyApp.ReverseProxy, pool_size: 100}
]

Options

  • :name (required) - Name for the Finch 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 connections in ms (default: 55_000)
  • :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)

request(conn, config, opts \\ [])

@spec request(Plug.Conn.t(), ReverseIt.Config.t(), keyword()) ::
  {:ok, Plug.Conn.t(), term()}
  | {:buffered, buffered_response(), Plug.Conn.t(), term()}
  | {:error, term(), Plug.Conn.t(), term()}

Proxies HTTP with caller-owned response handling, without halting the conn.

Returns {:ok, conn, handler_state} for a sent/streamed response, {:buffered, %{status: status, headers: headers, body: body}, conn, handler_state} for an unsent buffered response, or {:error, reason, conn, handler_state} for a failure before commitment. Returned errors are not logged; the caller owns logging. After commitment, failures exit the request process to abort the downstream response. With a response handler, terminate/2 receives the failure before that exit and the caller owns logging; otherwise ReverseIt logs the failure. WebSocket upgrades are rejected.

The optional third argument accepts per-request options:

  • :request_body - Already-read binary bytes to forward, including "". nil (the default) reads the body from the conn. Body limits still apply.
  • :response_handler - {module, initial_state} for response processing. nil (the default) streams the response unchanged.

Initialize the static config once, then pass fresh options for each request. These options are rejected by init/1; handler modules are checked at request time rather than while the router is compiling. See ReverseIt.ResponseHandler for the callback contract. Buffered/error results leave the response unsent so the caller can retry or send its own response. Only replay explicitly supplied bodies; the original conn body is consumed.

send_buffered(conn, map)

@spec send_buffered(Plug.Conn.t(), buffered_response()) :: Plug.Conn.t()

Sends a buffered response returned by request/3, without halting the conn.

Replaces existing response headers, including Plug's default Cache-Control, while preserving repeated upstream headers such as Set-Cookie. Registered Plug.Conn.register_before_send/2 callbacks still run. Headers set by earlier plugs, such as request IDs and CORS headers, are replaced too; restore those headers in a Plug.Conn.register_before_send/2 callback to retain them.

If you change the buffered body, update its representation headers (such as Content-Length and Content-Encoding) before sending it.