├── example ├── .gitignore ├── apps │ ├── plug_upstream │ │ ├── test │ │ │ ├── test_helper.exs │ │ │ └── plug_upstream_test.exs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── config │ │ │ └── config.exs │ │ ├── lib │ │ │ ├── plug_upstream │ │ │ │ └── router.ex │ │ │ └── plug_upstream.ex │ │ └── mix.exs │ └── reverse_proxy_server │ │ ├── test │ │ ├── test_helper.exs │ │ └── reverse_proxy_server_test.exs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── config │ │ └── config.exs │ │ ├── lib │ │ └── reverse_proxy_server.ex │ │ └── mix.exs ├── README.md ├── mix.lock ├── config │ └── config.exs └── mix.exs ├── .gitignore ├── test ├── reverse_proxy_test.exs ├── fixtures │ ├── failure_http.exs │ ├── failure_plug.exs │ ├── cache.exs │ ├── chunked_response.exs │ ├── success_http.exs │ ├── body_length.exs │ └── success_plug.exs ├── test_helper.exs └── reverse_proxy │ ├── cache_test.exs │ ├── router_test.exs │ └── runner_test.exs ├── .travis.yml ├── config └── config.exs ├── lib ├── reverse_proxy │ ├── cache.ex │ ├── router.ex │ └── runner.ex └── reverse_proxy.ex ├── LICENSE ├── mix.exs ├── README.md └── mix.lock /example/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | doc/ 6 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ReverseProxyExample 2 | =================== 3 | 4 | ** TODO: Add description ** 5 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/README.md: -------------------------------------------------------------------------------- 1 | PlugUpstream 2 | ============ 3 | 4 | ** TODO: Add description ** 5 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/README.md: -------------------------------------------------------------------------------- 1 | ReverseProxyServer 2 | ================== 3 | 4 | ** TODO: Add description ** 5 | -------------------------------------------------------------------------------- /test/reverse_proxy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/test/plug_upstream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugUpstreamTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/failure_http.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.FailureHTTP do 2 | def request(_method, _url, _body, _headers, _opts \\ []) do 3 | {:error, "failure"} 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, :console, 4 | level: :info, 5 | format: "$date $time [$level] $metadata$message\n", 6 | metadata: [:user_id] 7 | -------------------------------------------------------------------------------- /test/fixtures/failure_plug.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.FailurePlug do 2 | def init(opts), do: opts 3 | def call(conn, _) do 4 | conn |> Plug.Conn.resp(500, "failure") 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.3 4 | - 1.4 5 | script: 6 | - mix test 7 | - MIX_ENV=test mix credo --strict 8 | after_success: 9 | - MIX_ENV=test mix coveralls.travis 10 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/test/reverse_proxy_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyServerTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/cache.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.Cache do 2 | def serve(conn, upstream) do 3 | upstream.(conn) 4 | |> Map.update!(:resp_body, fn b -> 5 | "cached #{b}" 6 | end) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | "./test/fixtures" 2 | |> File.ls! 3 | |> Enum.filter(fn f -> String.ends_with?(f, ".exs") end) 4 | |> Enum.map(fn f -> "./test/fixtures/#{f}" end) 5 | |> Enum.map(&Code.require_file/1) 6 | 7 | ExUnit.start() 8 | -------------------------------------------------------------------------------- /test/fixtures/chunked_response.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.ChunkedResponse do 2 | def request(_method, _url, _body, _headers, _opts \\ []) do 3 | {:ok, %{ 4 | :headers => [{"transfer-encoding", "chunked"}], 5 | :status_code => 200, 6 | :body => "" 7 | }} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, :console, 4 | level: :info, 5 | format: "$date $time [$level] $metadata$message\n", 6 | metadata: [:user_id] 7 | 8 | config :reverse_proxy, 9 | upstreams: %{ 10 | "example.com" => {PlugUpstream.Router, []} 11 | } 12 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/lib/plug_upstream/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugUpstream.Router do 2 | use Plug.Router 3 | 4 | plug :match 5 | plug :dispatch 6 | 7 | get "/" do 8 | conn |> send_resp(200, "Hello, PlugUpstream") 9 | end 10 | 11 | match _ do 12 | conn |> send_resp(418, "i'm a teapot") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{"cowboy": {:hex, :cowboy, "1.0.2"}, 2 | "cowlib": {:hex, :cowlib, "1.0.1"}, 3 | "hackney": {:hex, :hackney, "1.3.1"}, 4 | "httpoison": {:hex, :httpoison, "0.7.1"}, 5 | "idna": {:hex, :idna, "1.0.2"}, 6 | "plug": {:hex, :plug, "0.14.0"}, 7 | "ranch": {:hex, :ranch, "1.1.0"}, 8 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}} 9 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config(:reverse_proxy, 4 | upstreams: %{ 5 | # You could add foobar.localhost to /etc/hosts to test this 6 | "foobar.localhost" => ["http://www.example.com"], 7 | "api." => {ReverseProxyTest.SuccessPlug, []}, 8 | "example.com" => {ReverseProxyTest.SuccessPlug, []}, 9 | "badgateway.com" => ["http://localhost:1"] }) 10 | -------------------------------------------------------------------------------- /test/reverse_proxy/cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.CacheTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | test "cache miss" do 6 | callback = fn conn -> 7 | conn |> Plug.Conn.send_resp(200, "success") 8 | end 9 | conn = conn(:get, "/") 10 | 11 | conn = ReverseProxy.Cache.serve(conn, callback) 12 | 13 | assert conn.status == 200 14 | assert conn.resp_body == "success" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/success_http.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.SuccessHTTP do 2 | def request(_method, _url, _body, _headers, _opts \\ []) do 3 | {:ok, %{ 4 | :headers => headers(), 5 | :status_code => 200, 6 | :body => "success" 7 | }} 8 | end 9 | def headers do 10 | [ 11 | {"cache-control", "max-age=0, private, must-revalidate"}, 12 | {"x-header-1", "yes"}, 13 | {"x-header-2", "yes"} 14 | ] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/body_length.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.BodyLength do 2 | def request(_method, _url, body, _headers, _opts \\ []) do 3 | body_length = case body do 4 | {:stream, body} -> 5 | body 6 | |> Enum.join("") 7 | |> byte_size 8 | body -> 9 | body 10 | |> byte_size 11 | end 12 | 13 | {:ok, %{ 14 | :headers => [], 15 | :status_code => 200, 16 | :body => "#{body_length}" 17 | }} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/fixtures/success_plug.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyTest.SuccessPlug do 2 | def init(opts), do: opts 3 | def call(conn, opts) do 4 | opts = opts |> Keyword.put_new(:headers, []) 5 | conn 6 | |> put_resp_headers(opts[:headers]) 7 | |> Plug.Conn.resp(200, "success") 8 | end 9 | defp put_resp_headers(conn, headers) do 10 | headers 11 | |> Enum.reduce(conn, fn {h, v}, c -> 12 | c |> Plug.Conn.put_resp_header(h, v) 13 | end) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # The configuration defined here will only affect the dependencies 4 | # in the apps directory when commands are executed from the umbrella 5 | # project. For this reason, it is preferred to configure each child 6 | # application directly and import its configuration, as done below. 7 | import_config "../apps/*/config/config.exs" 8 | 9 | # Sample configuration (overrides the imported configuration above): 10 | # 11 | # config :logger, :console, 12 | # level: :info, 13 | # format: "$date $time [$level] $metadata$message\n", 14 | # metadata: [:user_id] 15 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/lib/plug_upstream.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugUpstream do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [ 10 | # Define workers and child supervisors to be supervised 11 | # worker(PlugUpstream.Worker, [arg1, arg2, arg3]) 12 | ] 13 | 14 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 15 | # for other strategies and supported options 16 | opts = [strategy: :one_for_one, name: PlugUpstream.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyExample.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [apps_path: "apps", 6 | build_embedded: Mix.env == :prod, 7 | start_permanent: Mix.env == :prod, 8 | deps: deps] 9 | end 10 | 11 | # Dependencies can be Hex packages: 12 | # 13 | # {:mydep, "~> 0.3.0"} 14 | # 15 | # Or git/path repositories: 16 | # 17 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 18 | # 19 | # Type `mix help deps` for more examples and options. 20 | # 21 | # Dependencies listed here are available only for this project 22 | # and cannot be accessed from applications inside the apps folder 23 | defp deps do 24 | [] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/reverse_proxy/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.Cache do 2 | @moduledoc """ 3 | A basic caching layer for `ReverseProxy`. 4 | 5 | Upstream content servers may be slow. SSL/TLS 6 | negotiation may be slow. Caching a response from the 7 | upstream increases the potential performance of 8 | `ReverseProxy`. 9 | """ 10 | 11 | @typedoc "Callback to retreive an upstream response" 12 | @type callback :: (Plug.Conn.t -> Plug.Conn.t) 13 | 14 | @doc """ 15 | Entrypoint to serve content from the cache when available 16 | (cache hit) and from the upstream when not available 17 | (cache miss). 18 | """ 19 | @spec serve(Plug.Conn.t, callback) :: Plug.Conn.t 20 | def serve(conn, upstream) do 21 | upstream.(conn) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/reverse_proxy/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.Router do 2 | @moduledoc """ 3 | A Plug for routing requests to either be served from cache 4 | or from a set of upstream servers. 5 | """ 6 | 7 | use Plug.Router 8 | 9 | @default_used false 10 | 11 | plug :match 12 | plug :dispatch 13 | 14 | for {host, upstream} <- Application.get_env(:reverse_proxy, :upstreams, []) do 15 | @upstream upstream 16 | host = 17 | if host == :_ do 18 | @default_used true 19 | nil 20 | else 21 | host 22 | end 23 | match _, host: host do 24 | ReverseProxy.call(conn, upstream: @upstream) 25 | end 26 | end 27 | 28 | unless @default_used do 29 | match _, do: conn |> send_resp(400, "Bad Request") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/lib/reverse_proxy_server.ex: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyServer do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [ 10 | # Define workers and child supervisors to be supervised 11 | # worker(ReverseProxyServer.Worker, [arg1, arg2, arg3]) 12 | worker(Plug.Adapters.Cowboy, [ReverseProxy.Router, [], []], function: :http) 13 | ] 14 | 15 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 16 | # for other strategies and supported options 17 | opts = [strategy: :one_for_one, name: ReverseProxyServer.Supervisor] 18 | Supervisor.start_link(children, opts) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /example/apps/plug_upstream/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugUpstream.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :plug_upstream, 6 | version: "0.0.1", 7 | deps_path: "../../deps", 8 | lockfile: "../../mix.lock", 9 | elixir: "~> 1.0", 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | deps: deps] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type `mix help compile.app` for more information 18 | def application do 19 | [applications: [:logger, :cowboy, :plug]] 20 | end 21 | 22 | # Dependencies can be Hex packages: 23 | # 24 | # {:mydep, "~> 0.3.0"} 25 | # 26 | # Or git/path repositories: 27 | # 28 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 29 | # 30 | # To depend on another app inside the umbrella: 31 | # 32 | # {:myapp, in_umbrella: true} 33 | # 34 | # Type `mix help deps` for more examples and options 35 | defp deps do 36 | [{:cowboy, "~> 1.0.2"}, 37 | {:plug, "~> 0.14.0"}] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Shane Logsdon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/apps/reverse_proxy_server/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxyServer.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :reverse_proxy_server, 6 | version: "0.0.1", 7 | deps_path: "../../deps", 8 | lockfile: "../../mix.lock", 9 | elixir: "~> 1.0", 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | deps: deps] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type `mix help compile.app` for more information 18 | def application do 19 | [applications: [:logger, :reverse_proxy], 20 | mod: {ReverseProxyServer, []}] 21 | end 22 | 23 | # Dependencies can be Hex packages: 24 | # 25 | # {:mydep, "~> 0.3.0"} 26 | # 27 | # Or git/path repositories: 28 | # 29 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 30 | # 31 | # To depend on another app inside the umbrella: 32 | # 33 | # {:myapp, in_umbrella: true} 34 | # 35 | # Type `mix help deps` for more examples and options 36 | defp deps do 37 | [{:reverse_proxy, path: "../../../elixir-reverse-proxy"}, 38 | {:plug_upstream, in_umbrella: true}] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :reverse_proxy, 6 | version: "0.3.1", 7 | elixir: "~> 1.0", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps(), 11 | name: "ReverseProxy", 12 | description: description(), 13 | package: package(), 14 | docs: [extras: ["README.md"], 15 | main: "readme"], 16 | test_coverage: [tool: ExCoveralls]] 17 | end 18 | 19 | def application do 20 | [applications: [:logger, :plug, :cowboy, :httpoison], 21 | mod: {ReverseProxy, []}] 22 | end 23 | 24 | defp deps do 25 | [{:plug, "~> 1.2"}, 26 | {:cowboy, "~> 1.0"}, 27 | {:httpoison, "~> 0.9"}, 28 | 29 | {:earmark, "~> 1.0", only: :dev}, 30 | {:ex_doc, "~> 0.14", only: :dev}, 31 | 32 | {:credo, "~> 0.5", only: [:dev, :test]}, 33 | {:excoveralls, "~> 0.5", only: :test}, 34 | {:dialyze, "~> 0.2", only: :test}] 35 | end 36 | 37 | defp description do 38 | """ 39 | A Plug based reverse proxy server. 40 | """ 41 | end 42 | 43 | defp package do 44 | %{maintainers: ["Shane Logsdon"], 45 | licenses: ["MIT"], 46 | links: %{"GitHub" => "https://github.com/slogsdon/elixir-reverse-proxy"}} 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/reverse_proxy.ex: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy do 2 | @moduledoc """ 3 | A Plug based, reverse proxy server. 4 | 5 | `ReverseProxy` can act as a standalone service or as part of a plug 6 | pipeline in an existing application. 7 | 8 | From [Wikipedia](https://wikipedia.org/wiki/Reverse_proxy): 9 | 10 | > In computer networks, a reverse proxy is a type of proxy server 11 | > that retrieves resources on behalf of a client from one or more 12 | > servers. These resources are then returned to the client as 13 | > though they originated from the proxy server itself. While a 14 | > forward proxy acts as an intermediary for its associated clients 15 | > to contact any server, a reverse proxy acts as an intermediary 16 | > for its associated servers to be contacted by any client. 17 | """ 18 | 19 | use Application 20 | @behaviour Plug 21 | 22 | @spec init(Keyword.t) :: Keyword.t 23 | def init(opts), do: opts 24 | 25 | @spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t 26 | def call(conn, opts) do 27 | upstream = Keyword.get(opts, :upstream, []) 28 | callback = fn conn -> 29 | runner = Application.get_env(:reverse_proxy, :runner, ReverseProxy.Runner) 30 | runner.retreive(conn, upstream) 31 | end 32 | 33 | if Application.get_env(:reverse_proxy, :cache, false) do 34 | cacher = Application.get_env(:reverse_proxy, :cacher, ReverseProxy.Cache) 35 | cacher.serve(conn, callback) 36 | else 37 | callback.(conn) 38 | end 39 | end 40 | 41 | @spec start(term, term) :: {:error, term} 42 | | {:ok, pid} 43 | | {:ok, pid, term} 44 | def start(_type, _args) do 45 | import Supervisor.Spec, warn: false 46 | 47 | children = [ 48 | ] 49 | 50 | opts = [strategy: :one_for_one, name: ReverseProxy.Supervisor] 51 | Supervisor.start_link(children, opts) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/reverse_proxy/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.RouterTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | setup do 6 | Application.put_env(:reverse_proxy, :cache, false) 7 | Application.put_env(:reverse_proxy, :cacher, ReverseProxy.Cache) 8 | end 9 | 10 | test "request without a host" do 11 | conn = conn(:get, "/") 12 | 13 | conn = ReverseProxy.Router.call(conn, []) 14 | 15 | assert conn.status == 400 16 | assert conn.resp_body == "Bad Request" 17 | end 18 | 19 | test "request with unknown host" do 20 | conn = conn(:get, "/") 21 | |> Map.put(:host, "google.com") 22 | 23 | conn = ReverseProxy.Router.call(conn, []) 24 | 25 | assert conn.status == 400 26 | assert conn.resp_body == "Bad Request" 27 | end 28 | 29 | test "request with known host (domain)" do 30 | conn = conn(:get, "/") 31 | |> Map.put(:host, "example.com") 32 | 33 | conn = ReverseProxy.Router.call(conn, []) 34 | 35 | assert conn.status == 200 36 | assert conn.resp_body == "success" 37 | end 38 | 39 | test "request with known host (subdomain)" do 40 | conn = conn(:get, "/") 41 | |> Map.put(:host, "api.example.com") 42 | 43 | conn = ReverseProxy.Router.call(conn, []) 44 | 45 | assert conn.status == 200 46 | assert conn.resp_body == "success" 47 | end 48 | 49 | test "request with known host (subdomain only)" do 50 | conn = conn(:get, "/") 51 | |> Map.put(:host, "api.example2.com") 52 | 53 | conn = ReverseProxy.Router.call(conn, []) 54 | 55 | assert conn.status == 200 56 | assert conn.resp_body == "success" 57 | end 58 | 59 | test "request with known host (not responsive)" do 60 | conn = conn(:get, "/") 61 | |> Map.put(:host, "badgateway.com") 62 | 63 | conn = ReverseProxy.Router.call(conn, []) 64 | 65 | assert conn.status == 502 66 | assert conn.resp_body == "Bad Gateway" 67 | end 68 | 69 | test "request with known host from cache miss" do 70 | Application.put_env(:reverse_proxy, :cache, true) 71 | conn = conn(:get, "/") 72 | |> Map.put(:host, "example.com") 73 | 74 | conn = ReverseProxy.Router.call(conn, []) 75 | 76 | assert conn.status == 200 77 | assert conn.resp_body == "success" 78 | end 79 | 80 | test "request with known host from cache hit" do 81 | Application.put_env(:reverse_proxy, :cache, true) 82 | Application.put_env(:reverse_proxy, :cacher, ReverseProxyTest.Cache) 83 | conn = conn(:get, "/") 84 | |> Map.put(:host, "example.com") 85 | 86 | conn = ReverseProxy.Router.call(conn, []) 87 | 88 | assert conn.status == 200 89 | assert conn.resp_body == "cached success" 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/reverse_proxy/runner_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.RunnerTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | test "retreive/2 - plug - success" do 6 | conn = conn(:get, "/") 7 | 8 | conn = ReverseProxy.Runner.retreive( 9 | conn, 10 | {ReverseProxyTest.SuccessPlug, []} 11 | ) 12 | 13 | assert conn.status == 200 14 | assert conn.resp_body == "success" 15 | end 16 | 17 | test "retreive/2 - plug - success with response headers" do 18 | conn = conn(:get, "/") 19 | headers = [ 20 | {"cache-control", "max-age=0, private, must-revalidate"}, 21 | {"x-header-1", "yes"}, 22 | {"x-header-2", "yes"} 23 | ] 24 | 25 | conn = ReverseProxy.Runner.retreive( 26 | conn, 27 | {ReverseProxyTest.SuccessPlug, headers: headers} 28 | ) 29 | 30 | assert conn.status == 200 31 | assert conn.resp_body == "success" 32 | assert conn.resp_headers == headers 33 | end 34 | 35 | test "retreive/2 - plug - failure" do 36 | conn = conn(:get, "/") 37 | 38 | conn = ReverseProxy.Runner.retreive( 39 | conn, 40 | {ReverseProxyTest.FailurePlug, []} 41 | ) 42 | 43 | assert conn.status == 500 44 | assert conn.resp_body == "failure" 45 | end 46 | 47 | test "retreive/3 - http - success" do 48 | conn = conn(:get, "/") 49 | 50 | conn = ReverseProxy.Runner.retreive( 51 | conn, 52 | ["localhost"], 53 | ReverseProxyTest.SuccessHTTP 54 | ) 55 | 56 | assert conn.status == 200 57 | assert conn.resp_body == "success" 58 | end 59 | 60 | test "retrieve/3 - partial body" do 61 | conn = 62 | conn(:post, "/", String.duplicate("_", 8_000_000 + 1)) 63 | |> put_req_header("content-type", "application/json") 64 | |> ReverseProxy.Runner.retreive( 65 | ["localhost"], 66 | ReverseProxyTest.BodyLength 67 | ) 68 | 69 | assert conn.resp_body == "8000001" 70 | end 71 | 72 | test "retrieve/3 - chunked response" do 73 | conn = 74 | conn(:get, "/") 75 | |> ReverseProxy.Runner.retreive( 76 | ["localhost"], 77 | ReverseProxyTest.ChunkedResponse 78 | ) 79 | 80 | assert get_resp_header(conn, "transfer-encoding") == [] 81 | end 82 | 83 | test "retreive/3 - http - success with response headers" do 84 | conn = conn(:get, "/") 85 | headers = ReverseProxyTest.SuccessHTTP.headers 86 | 87 | conn = ReverseProxy.Runner.retreive( 88 | conn, 89 | ["localhost"], 90 | ReverseProxyTest.SuccessHTTP 91 | ) 92 | 93 | assert conn.status == 200 94 | assert conn.resp_body == "success" 95 | assert conn.resp_headers == headers 96 | end 97 | 98 | test "retreive/3 - http - failure" do 99 | conn = conn(:get, "/") 100 | 101 | conn = ReverseProxy.Runner.retreive( 102 | conn, 103 | ["localhost"], 104 | ReverseProxyTest.FailureHTTP 105 | ) 106 | 107 | assert conn.status == 502 108 | assert conn.resp_body == "Bad Gateway" 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReverseProxy 2 | [![Build Status](https://travis-ci.org/slogsdon/elixir-reverse-proxy.svg?branch=master)](https://travis-ci.org/slogsdon/elixir-reverse-proxy) 3 | [![Coverage Status](https://coveralls.io/repos/slogsdon/elixir-reverse-proxy/badge.svg?branch=master&service=github)](https://coveralls.io/github/slogsdon/elixir-reverse-proxy?branch=master) 4 | 5 | A Plug based, reverse proxy server. 6 | 7 | `ReverseProxy` can act as a standalone service or as part of a plug pipeline in an existing application. 8 | 9 | From [Wikipedia](https://wikipedia.org/wiki/Reverse_proxy): 10 | 11 | > In computer networks, a reverse proxy is a type of proxy server that retrieves resources on behalf of a client from one or more servers. These resources are then returned to the client as though they originated from the proxy server itself. While a forward proxy acts as an intermediary for its associated clients to contact any server, a reverse proxy acts as an intermediary for its associated servers to be contacted by any client. 12 | 13 | ## Goals 14 | 15 | - Domain based proxying 16 | - Path based proxying 17 | - Proxy cache 18 | - SSL/TLS termination 19 | 20 | ## Non-goals 21 | 22 | - Replace production reverse proxy solutions 23 | 24 | ## Configuration 25 | 26 | ### `:upstreams` 27 | 28 | Upstream servers can be listed per-domain in the following forms: 29 | 30 | - List of remote nodes, e.g. `["http://host:4000", "http://host:4001"]` 31 | - A `{plug, options}` tuple, useful for umbrella applications 32 | 33 | > Note: This structure may change in the future as the project progresses. 34 | 35 | ```elixir 36 | config :reverse_proxy, 37 | # ... 38 | upstreams: %{ "foobar.localhost" => ["http://www.example.com"], 39 | "api." => ["http://localhost:4000"], 40 | "slogsdon.com" => ["http://localhost:4001"] } 41 | ``` 42 | 43 | You might need to create `foobar.localhost in `/etc/hosts` and replace 44 | example.com with an actual site. 45 | 46 | ### `:cache` 47 | 48 | Enables the caching of the responses from the upstream server. 49 | 50 | > Note: This feature has not yet been built to completion. The current implementation treats all requests as hit misses. 51 | 52 | ```elixir 53 | config :reverse_proxy, 54 | # ... 55 | cache: false 56 | ``` 57 | 58 | ## Running 59 | 60 | ```elixir 61 | plug_adapter = Plug.Adapters.Cowboy 62 | options = [] 63 | adapter_options = [] 64 | 65 | plug_adapter.http ReverseProxy.Router, options, adapter_options 66 | ``` 67 | 68 | ## Embedding 69 | 70 | `ReverseProxy` can be embedded into an existing Plug application to proxy requests to required resources in cases where CORS or JSONP are unavailable. 71 | 72 | > Note: This feature has not been thoroughly flushed out, so it might not yet act as described. 73 | 74 | The following code leverages `Plug.Router.forward/2` to pass requests to the `/google` path to `ReverseProxy`: 75 | 76 | ```elixir 77 | defmodule PlugReverseProxy.Router do 78 | use Plug.Router 79 | 80 | plug :match 81 | plug :dispatch 82 | 83 | forward "/google", to: ReverseProxy, upstream: ["google.com"] 84 | end 85 | ``` 86 | 87 | ## License 88 | 89 | ReverseProxy is released under the MIT License. 90 | 91 | See [LICENSE](https://github.com/slogsdon/elixir-reverse-proxy/blob/master/LICENSE) for details. 92 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], []}, 2 | "certifi": {:hex, :certifi, "0.4.0", "a7966efb868b179023618d29a407548f70c52466bf1849b9e8ebd0e34b7ea11f", [:rebar3], []}, 3 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:make, :rebar], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, 4 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 5 | "credo": {:hex, :credo, "0.6.1", "a941e2591bd2bd2055dc92b810c174650b40b8290459c89a835af9d59ac4a5f8", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, optional: false]}]}, 6 | "dialyze": {:hex, :dialyze, "0.2.1", "9fb71767f96649020d769db7cbd7290059daff23707d6e851e206b1fdfa92f9d", [:mix], []}, 7 | "dogma": {:hex, :dogma, "0.1.9", "8f71a6afbae12ef50248a84916db6d4084d0e50aae069c49dbe124d513b71425", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, optional: false]}]}, 8 | "earmark": {:hex, :earmark, "1.0.1", "2c2cd903bfdc3de3f189bd9a8d4569a075b88a8981ded9a0d95672f6e2b63141", [:mix], []}, 9 | "ex_doc": {:hex, :ex_doc, "0.14.2", "c89d60db464e8a0849a35dbcd6eed71f2b076c339d0b05b3bb5c90d6bab31e4f", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 10 | "excoveralls": {:hex, :excoveralls, "0.5.6", "35a903f6f78619ee7f951448dddfbef094b3a0d8581657afaf66465bc930468e", [:mix], [{:exjsx, "~> 3.0", [hex: :exjsx, optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, optional: false]}]}, 11 | "exjsx": {:hex, :exjsx, "3.2.1", "1bc5bf1e4fd249104178f0885030bcd75a4526f4d2a1e976f4b428d347614f0f", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, optional: false]}]}, 12 | "hackney": {:hex, :hackney, "1.6.1", "ddd22d42db2b50e6a155439c8811b8f6df61a4395de10509714ad2751c6da817", [:rebar3], [{:certifi, "0.4.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.0", [hex: :ssl_verify_fun, optional: false]}]}, 13 | "httpoison": {:hex, :httpoison, "0.9.2", "a211a8e87403a043c41218e64df250d321f236ac57f786c6a0ccf3e9e817c819", [:mix], [{:hackney, "~> 1.6.0", [hex: :hackney, optional: false]}]}, 14 | "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, 15 | "jsx": {:hex, :jsx, "2.8.0", "749bec6d205c694ae1786d62cea6cc45a390437e24835fd16d12d74f07097727", [:mix, :rebar], []}, 16 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 17 | "mime": {:hex, :mime, "1.0.1", "05c393850524767d13a53627df71beeebb016205eb43bfbd92d14d24ec7a1b51", [:mix], []}, 18 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 19 | "plug": {:hex, :plug, "1.2.2", "cfbda521b54c92ab8ddffb173fbaabed8d8fc94bec07cd9bb58a84c1c501b0bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, 20 | "poison": {:hex, :poison, "3.0.0", "625ebd64d33ae2e65201c2c14d6c85c27cc8b68f2d0dd37828fde9c6920dd131", [:mix], []}, 21 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:make, :rebar], []}, 23 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}} 24 | -------------------------------------------------------------------------------- /lib/reverse_proxy/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule ReverseProxy.Runner do 2 | @moduledoc """ 3 | Retreives content from an upstream. 4 | """ 5 | 6 | alias Plug.Conn 7 | 8 | @typedoc "Representation of an upstream service." 9 | @type upstream :: [String.t] | {Atom.t, Keyword.t} 10 | 11 | @spec retreive(Conn.t, upstream) :: Conn.t 12 | def retreive(conn, upstream) 13 | def retreive(conn, {plug, opts}) when plug |> is_atom do 14 | options = plug.init(opts) 15 | plug.call(conn, options) 16 | end 17 | 18 | @spec retreive(Conn.t, upstream, Atom.t) :: Conn.t 19 | def retreive(conn, servers, client \\ HTTPoison) do 20 | server = upstream_select(servers) 21 | {method, url, body, headers} = prepare_request(server, conn) 22 | 23 | method 24 | |> client.request(url, body, headers, timeout: 5_000) 25 | |> process_response(conn) 26 | end 27 | 28 | @spec prepare_request(String.t, Conn.t) :: {Atom.t, 29 | String.t, 30 | String.t, 31 | [{String.t, String.t}]} 32 | defp prepare_request(server, conn) do 33 | conn = conn 34 | |> Conn.put_req_header( 35 | "x-forwarded-for", 36 | conn.remote_ip |> :inet.ntoa |> to_string 37 | ) 38 | |> Conn.delete_req_header("host") 39 | |> Conn.delete_req_header( 40 | "transfer-encoding" 41 | ) 42 | method = conn.method |> String.downcase |> String.to_atom 43 | url = "#{prepare_server(conn.scheme, server)}#{conn.request_path}?#{conn.query_string}" 44 | headers = conn.req_headers 45 | body = case Conn.read_body(conn) do 46 | {:ok, body, _conn} -> 47 | body 48 | {:more, body, conn} -> 49 | {:stream, 50 | Stream.resource( 51 | fn -> {body, conn} end, 52 | fn 53 | {body, conn} -> 54 | {[body], conn} 55 | nil -> 56 | {:halt, nil} 57 | conn -> 58 | case Conn.read_body(conn) do 59 | {:ok, body, _conn} -> 60 | {[body], nil} 61 | {:more, body, conn} -> 62 | {[body], conn} 63 | end 64 | end, 65 | fn _ -> nil end 66 | ) 67 | } 68 | end 69 | 70 | {method, url, body, headers} 71 | end 72 | 73 | @spec prepare_server(String.t, String.t) :: String.t 74 | defp prepare_server(scheme, server) 75 | defp prepare_server(_, "http://" <> _ = server), do: server 76 | defp prepare_server(_, "https://" <> _ = server), do: server 77 | defp prepare_server(scheme, server) do 78 | "#{scheme}://#{server}" 79 | end 80 | 81 | @spec process_response({Atom.t, Map.t}, Conn.t) :: Conn.t 82 | defp process_response({:error, _}, conn) do 83 | conn |> Conn.send_resp(502, "Bad Gateway") 84 | end 85 | defp process_response({:ok, response}, conn) do 86 | conn 87 | |> put_resp_headers(response.headers) 88 | |> Conn.delete_resp_header("transfer-encoding") 89 | |> Conn.send_resp(response.status_code, response.body) 90 | end 91 | 92 | @spec put_resp_headers(Conn.t, [{String.t, String.t}]) :: Conn.t 93 | defp put_resp_headers(conn, []), do: conn 94 | defp put_resp_headers(conn, [{header, value} | rest]) do 95 | conn 96 | |> Conn.put_resp_header(header |> String.downcase, value) 97 | |> put_resp_headers(rest) 98 | end 99 | 100 | defp upstream_select(servers) do 101 | servers |> hd 102 | end 103 | end 104 | --------------------------------------------------------------------------------