defmodule Salemove.HttpClient.Middleware.Proxy do
@behaviour Tesla.Middleware
@moduledoc """
Adds proxy server options to a request if either of the following conditions are met:
* Environment variable `http_proxy` (or `HTTP_PROXY`) is set and requested protocol is `http://`
* Environment variable `https_proxy` (or `HTTPS_PROXY`) is set and requested protocol is `https://`
Proxy options are not injected if either of the following conditions are met:
* Requested host is included into `no_proxy` (or `NO_PROXY`) environment variable
* Http client is configured with option `proxy: false`
## Finch limitation
`Tesla.Adapter.Finch` (the default adapter) does not support per-request
proxy options — Finch configures proxies at pool level. With the Finch adapter this
middleware is a no-op: the `:proxy` option and the proxy environment variables are
silently ignored. To proxy requests with Finch, configure the proxy on the pool:
config :salemove_http_client,
finch_pools: %{
default: [conn_opts: [proxy: {:http, "proxy.example.com", 3128, []}]]
}
or use `Tesla.Adapter.Hackney` for clients that need per-request proxies.
"""
def call(env, next, opts) do
env =
if proxy_supported?(opts) and Keyword.get(opts, :proxy, []) do
inject_proxy(env, opts)
else
inject_adapter(env, opts)
end
Tesla.run(env, next)
end
# Tesla.Adapter.Finch has no per-request proxy support (Finch configures
# proxies at pool level), so proxy resolution is skipped entirely.
defp proxy_supported?(opts) do
opts[:adapter] != Tesla.Adapter.Finch
end
# The code below is copied from https://github.com/edgurgel/httpoison/blob/fa2238cfb9833776e5eebdb2d73d0e1a0093a356/lib/httpoison/base.ex#L818-L851
defp inject_proxy(env, opts) do
proxy =
if Keyword.has_key?(opts, :proxy) do
Keyword.get(opts, :proxy) |> check_no_proxy(env.url)
else
case URI.parse(env.url).scheme do
"http" -> System.get_env("HTTP_PROXY") || System.get_env("http_proxy")
"https" -> System.get_env("HTTPS_PROXY") || System.get_env("https_proxy")
_ -> nil
end
|> check_no_proxy(env.url)
end
proxy_options =
opts
|> Keyword.take(~w[proxy_auth socks5_user socks5_pass]a)
|> Keyword.put(:proxy, proxy)
new_options =
opts
|> adapter_options()
|> Keyword.update(:__adapter_options, proxy_options, fn adapter_options ->
adapter_options ++ proxy_options
end)
%{env | opts: Keyword.merge(env.opts, new_options)}
end
defp adapter_options(opts) do
[__adapter: opts[:adapter], __adapter_options: opts[:adapter_options]]
end
defp inject_adapter(env, opts) do
%{env | opts: Keyword.merge(env.opts, adapter_options(opts))}
end
defp check_no_proxy(nil, _) do
# Don't bother to check no_proxy if there's no proxy to use anyway.
nil
end
defp check_no_proxy(proxy, request_url) do
request_host = URI.parse(request_url).host
should_bypass_proxy =
get_no_proxy_system_env()
|> String.split(",")
|> Enum.any?(fn domain -> matches_no_proxy_value?(request_host, String.trim(domain)) end)
if should_bypass_proxy do
nil
else
proxy
end
end
defp get_no_proxy_system_env() do
System.get_env("NO_PROXY") || System.get_env("no_proxy") || ""
end
defp matches_no_proxy_value?(request_host, no_proxy_value) do
cond do
no_proxy_value == "" ->
false
String.starts_with?(no_proxy_value, ".") ->
String.ends_with?(request_host, no_proxy_value) || request_host == String.trim_leading(no_proxy_value, ".")
String.contains?(no_proxy_value, "*") ->
matches_wildcard?(request_host, no_proxy_value)
true ->
request_host == no_proxy_value
end
end
defp matches_wildcard?(request_host, wildcard_domain) do
Regex.escape(wildcard_domain)
|> String.replace("\\*", ".*")
|> Regex.compile!()
|> Regex.match?(request_host)
end
end