Skip to main content

src/masque_tls.erl

%%% @doc Safe TLS client options for MASQUE's HTTP/1.1 rung.
%%%
%%% Centralises the TLS options every h1 client session sends to
%%% `ssl:connect/4'. Defaults match the posture `erlang_h1' uses on
%%% its own TLS client: verify the peer, trust the system CA store,
%%% check the hostname against the certificate, and advertise
%%% `http/1.1' in ALPN. IPv6 literals are not valid SNI values
%%% (RFC 6066 section 3), so SNI is omitted when the proxy host is
%%% an IP literal.
%%%
%%% Caller overrides win: anything on `ssl_opts' in the session opts
%%% is merged on top of the defaults, and the top-level `verify' opt
%%% shorthand is honoured for parity with the h2/h3 sessions.
-module(masque_tls).

-export([client_opts/2]).

-export_type([proxy_host/0]).

-type proxy_host() :: binary() | string().

%% @doc Build a merged list of `ssl:tls_client_option()' suitable for
%% `ssl:connect/4' when dialing a MASQUE proxy on HTTP/1.1. Accepts
%% the proxy host (as used on the wire) and the session-level opts
%% map. The following opts keys are consumed:
%%
%%   `verify'   : `verify_peer | verify_none' (default `verify_peer')
%%   `ssl_opts' : list of extra `ssl:tls_client_option()' merged last
%%
%% Everything else in the opts map is ignored. Returns a plain list
%% ready to pass through to `ssl:connect/4'.
-spec client_opts(proxy_host(), map()) -> [ssl:tls_client_option()].
client_opts(Host, Opts) ->
    HostBin = to_bin(Host),
    IsIpLiteral = is_ip_literal(HostBin),
    Base = [
        {mode, binary},
        {active, false},
        {alpn_advertised_protocols, [<<"http/1.1">>]},
        {verify, maps:get(verify, Opts, verify_peer)},
        {cacerts, cacerts()},
        {customize_hostname_check,
            [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}
    ] ++ sni_opt(HostBin, IsIpLiteral),
    User = maps:get(ssl_opts, Opts, []),
    merge(Base, User).

%%====================================================================
%% Internal
%%====================================================================

cacerts() ->
    try public_key:cacerts_get()
    catch _:_ -> []
    end.

sni_opt(_HostBin, true) ->
    [];
sni_opt(HostBin, false) ->
    [{server_name_indication, binary_to_list(HostBin)}].

is_ip_literal(HostBin) when is_binary(HostBin) ->
    case inet:parse_address(binary_to_list(HostBin)) of
        {ok, _} -> true;
        _       -> false
    end.

%% Caller-supplied options win on a per-key basis. Non-tuple atoms
%% (e.g. `binary') and tuples with any arity are both supported.
merge(Default, Override) ->
    Over = sort_unique(Override),
    Kept = [D || D <- Default, not has_key(key_of(D), Over)],
    Kept ++ Over.

sort_unique(Opts) ->
    %% Drop earlier duplicates on the same key; keep the last value.
    lists:foldl(
      fun(O, Acc) ->
          K = key_of(O),
          [O | [X || X <- Acc, key_of(X) =/= K]]
      end, [], Opts).

has_key(K, L) ->
    lists:any(fun(X) -> key_of(X) =:= K end, L).

key_of(T) when is_tuple(T), tuple_size(T) >= 1 -> element(1, T);
key_of(A) when is_atom(A)                      -> A.

to_bin(X) when is_binary(X) -> X;
to_bin(X) when is_list(X)   -> list_to_binary(X);
to_bin(X) when is_atom(X)   -> atom_to_binary(X, utf8).