%%% @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).