%%% -*- erlang -*- %%% %%% This file is part of hackney released under the Apache 2 license. %%% See the NOTICE for more information. %%% %%% Copyright (c) 2024-2026 Benoit Chesneau %%% %%% @doc Alt-Svc header parsing and caching for HTTP/3 discovery. %%% %%% This module parses Alt-Svc response headers to discover HTTP/3 endpoints %%% and caches them for future requests. %%% %%% == Alt-Svc Header Format == %%% ``` %%% Alt-Svc: h3=":443"; ma=86400, h3-29=":443" %%% Alt-Svc: clear %%% ''' %%% %%% == Usage == %%% After receiving an HTTP/1.1 or HTTP/2 response, check for Alt-Svc: %%% ``` %%% case hackney_altsvc:parse_and_cache(Host, Headers) of %%% {ok, h3, Port} -> %% HTTP/3 available on Port %%% none -> %% No HTTP/3 advertised %%% end %%% ''' %%% %%% Before connecting, check the cache: %%% ``` %%% case hackney_altsvc:lookup(Host, Port) of %%% {ok, h3, H3Port} -> %% Try HTTP/3 on H3Port %%% none -> %% No cached Alt-Svc %%% end %%% ''' -module(hackney_altsvc). -export([ init/0, parse/1, parse_and_cache/3, lookup/2, cache/4, clear/2, clear_all/0, is_h3_blocked/2, mark_h3_blocked/2 ]). -define(ALTSVC_TABLE, hackney_altsvc_cache). -define(DEFAULT_MAX_AGE, 86400). %% 24 hours -define(BLOCKED_TTL, 300). %% 5 minutes for negative cache %%==================================================================== %% API %%==================================================================== %% @doc Initialize the Alt-Svc cache. Called at application start. -spec init() -> ok. init() -> case ets:info(?ALTSVC_TABLE) of undefined -> _ = ets:new(?ALTSVC_TABLE, [set, public, named_table, {read_concurrency, true}]), ok; _ -> ok end. %% @doc Parse an Alt-Svc header value. %% Returns list of {Protocol, Host, Port, MaxAge} tuples. %% Protocol is h3 atom for HTTP/3 variants, or binary for other protocols. %% Host is 'same' if not specified (use origin host). -spec parse(binary() | string()) -> [{h3 | binary(), same | binary(), inet:port_number(), non_neg_integer()}]. parse(<<"clear">>) -> []; parse("clear") -> []; parse(Header) when is_list(Header) -> parse(list_to_binary(Header)); parse(Header) when is_binary(Header) -> parse_entries(Header, []). %% @doc Parse Alt-Svc header from response headers and cache if h3 found. %% %% Honors RFC 7838: a `clear' value invalidates any cached entry for the %% origin. Multiple `Alt-Svc' headers are merged before parsing as if %% they had been comma-concatenated (RFC 7230 §3.2.2). %% %% Returns `{ok, h3, Port}' if HTTP/3 is now cached for the origin, %% `cleared' if the cache was invalidated, or `none' otherwise. -spec parse_and_cache(Host :: binary() | string(), Port :: inet:port_number(), Headers :: [{binary(), binary()}]) -> {ok, h3, inet:port_number()} | cleared | none. parse_and_cache(Host, OrigPort, Headers) -> case collect_altsvc_headers(Headers) of <<>> -> none; Combined -> case is_clear_directive(Combined) of true -> clear(Host, OrigPort), cleared; false -> Entries = parse(Combined), case find_h3_entry(Entries) of {ok, H3Port, MaxAge} -> cache(Host, OrigPort, H3Port, MaxAge), {ok, h3, H3Port}; none -> none end end end. %% @doc Lookup cached Alt-Svc for a host/port. %% Returns {ok, h3, H3Port} if HTTP/3 is available and not expired. -spec lookup(Host :: binary() | string(), Port :: inet:port_number()) -> {ok, h3, inet:port_number()} | none. lookup(Host, Port) -> Key = make_key(Host, Port), case ets:lookup(?ALTSVC_TABLE, Key) of [{Key, {h3, H3Port, Expiry}}] -> Now = erlang:system_time(second), case Now < Expiry of true -> {ok, h3, H3Port}; false -> ets:delete(?ALTSVC_TABLE, Key), none end; _ -> none end. %% @doc Cache an Alt-Svc entry. -spec cache(Host :: binary() | string(), OrigPort :: inet:port_number(), H3Port :: inet:port_number(), MaxAge :: non_neg_integer()) -> ok. cache(Host, OrigPort, H3Port, MaxAge) -> Key = make_key(Host, OrigPort), Expiry = erlang:system_time(second) + MaxAge, ets:insert(?ALTSVC_TABLE, {Key, {h3, H3Port, Expiry}}), ok. %% @doc Clear cached Alt-Svc for a host/port. -spec clear(Host :: binary() | string(), Port :: inet:port_number()) -> ok. clear(Host, Port) -> Key = make_key(Host, Port), ets:delete(?ALTSVC_TABLE, Key), ok. %% @doc Clear all cached Alt-Svc entries. -spec clear_all() -> ok. clear_all() -> ets:delete_all_objects(?ALTSVC_TABLE), ok. %% @doc Check if HTTP/3 is blocked for a host (negative cache). -spec is_h3_blocked(Host :: binary() | string(), Port :: inet:port_number()) -> boolean(). is_h3_blocked(Host, Port) -> Key = {blocked, make_key(Host, Port)}, case ets:lookup(?ALTSVC_TABLE, Key) of [{Key, Expiry}] -> Now = erlang:system_time(second), case Now < Expiry of true -> true; false -> ets:delete(?ALTSVC_TABLE, Key), false end; _ -> false end. %% @doc Mark HTTP/3 as blocked for a host (negative cache for 5 min). -spec mark_h3_blocked(Host :: binary() | string(), Port :: inet:port_number()) -> ok. mark_h3_blocked(Host, Port) -> Key = {blocked, make_key(Host, Port)}, Expiry = erlang:system_time(second) + ?BLOCKED_TTL, ets:insert(?ALTSVC_TABLE, {Key, Expiry}), ok. %%==================================================================== %% Internal functions %%==================================================================== make_key(Host, Port) when is_list(Host) -> make_key(list_to_binary(Host), Port); make_key(Host, Port) when is_binary(Host) -> {string:lowercase(Host), Port}. %% Collect every alt-svc header value and join with ", " so multiple %% header lines are parsed as a single combined entry list per RFC 7230. collect_altsvc_headers(Headers) -> Values = [to_binary(V) || {K, V} <- Headers, string:lowercase(to_binary(K)) =:= <<"alt-svc">>], join_with_comma(Values). join_with_comma([]) -> <<>>; join_with_comma([V]) -> V; join_with_comma([V | Rest]) -> iolist_to_binary([V, ", ", join_with_comma(Rest)]). to_binary(B) when is_binary(B) -> B; to_binary(L) when is_list(L) -> iolist_to_binary(L). %% RFC 7838 §3: a value of "clear" (case-insensitive) invalidates the %% origin's cached alternatives. Surrounding whitespace is allowed. is_clear_directive(Value) -> case string:lowercase(string:trim(Value)) of <<"clear">> -> true; _ -> false end. find_h3_entry([]) -> none; find_h3_entry([{Protocol, _Host, Port, MaxAge} | Rest]) -> case is_h3_protocol(Protocol) of true -> {ok, Port, MaxAge}; false -> find_h3_entry(Rest) end. %% Protocol is already normalized to h3 atom by normalize_protocol/1 is_h3_protocol(h3) -> true; is_h3_protocol(_) -> false. %% Parse comma-separated Alt-Svc entries parse_entries(<<>>, Acc) -> lists:reverse(Acc); parse_entries(Data, Acc) -> Stripped = skip_ws(Data), {Entry, Rest} = parse_entry(Stripped), %% GHSA-6cp8: ensure forward progress. A leading non-token byte %% (`!`, `@`, `=`, `;`, `.`, ...) makes parse_entry return %% {undefined, Rest=Stripped}, which would otherwise loop forever. %% Skip to the next ',' so at most one malformed entry is dropped. Rest1 = case Entry of undefined when Rest =:= Stripped -> seek_next_entry(Stripped); _ -> Rest end, case Entry of undefined -> parse_entries(skip_comma(Rest1), Acc); _ -> parse_entries(skip_comma(Rest1), [Entry | Acc]) end. seek_next_entry(<<>>) -> <<>>; seek_next_entry(<<$,, _/binary>> = Data) -> Data; seek_next_entry(<<_, Rest/binary>>) -> seek_next_entry(Rest). %% Parse a single Alt-Svc entry: protocol="host:port"; ma=N parse_entry(<<>>) -> {undefined, <<>>}; parse_entry(Data) -> case parse_protocol(Data) of {undefined, Rest} -> {undefined, Rest}; {Protocol, Rest1} -> case skip_ws(Rest1) of <<"=", Rest2/binary>> -> {HostPort, Rest3} = parse_quoted_or_token(skip_ws(Rest2)), {Host, Port} = parse_host_port(HostPort), {MaxAge, Rest4} = parse_params(Rest3), {{Protocol, Host, Port, MaxAge}, Rest4}; Rest2 -> {undefined, Rest2} end end. parse_protocol(Data) -> parse_token(Data, <<>>). parse_token(<>, Acc) when C >= $a, C =< $z; C >= $A, C =< $Z; C >= $0, C =< $9; C =:= $-; C =:= $_ -> parse_token(Rest, <>); parse_token(Rest, <<>>) -> {undefined, Rest}; parse_token(Rest, Acc) -> {normalize_protocol(Acc), Rest}. normalize_protocol(<<"h3">>) -> h3; normalize_protocol(<<"h3-", _/binary>>) -> h3; % h3-29, h3-34, etc. normalize_protocol(Other) -> Other. parse_quoted_or_token(<<$", Rest/binary>>) -> parse_quoted(Rest, <<>>); parse_quoted_or_token(Data) -> parse_token_value(Data, <<>>). parse_quoted(<<$", Rest/binary>>, Acc) -> {Acc, Rest}; parse_quoted(<<$\\, C, Rest/binary>>, Acc) -> parse_quoted(Rest, <>); parse_quoted(<>, Acc) -> parse_quoted(Rest, <>); parse_quoted(<<>>, Acc) -> {Acc, <<>>}. parse_token_value(<>, Acc) when C =/= $;, C =/= $,, C =/= $ -> parse_token_value(Rest, <>); parse_token_value(Rest, Acc) -> {Acc, Rest}. parse_host_port(<<$:, PortBin/binary>>) -> %% No host, just ":port" Port = binary_to_integer(PortBin), {same, Port}; parse_host_port(HostPort) -> case binary:split(HostPort, <<":">>) of [Host, PortBin] -> Port = binary_to_integer(PortBin), {Host, Port}; [PortBin] -> %% Just port number Port = binary_to_integer(PortBin), {same, Port} end. parse_params(Data) -> parse_params(skip_ws(Data), ?DEFAULT_MAX_AGE). parse_params(<<$;, Rest/binary>>, MaxAge) -> {Key, Rest2} = parse_token(skip_ws(Rest), <<>>), case skip_ws(Rest2) of <<"=", Rest3/binary>> -> {Value, Rest4} = parse_token_value(skip_ws(Rest3), <<>>), case Key of <<"ma">> -> NewMaxAge = try binary_to_integer(Value) catch error:badarg -> MaxAge end, parse_params(Rest4, NewMaxAge); _ -> parse_params(Rest4, MaxAge) end; Rest3 -> parse_params(Rest3, MaxAge) end; parse_params(Rest, MaxAge) -> {MaxAge, Rest}. skip_ws(<<$\s, Rest/binary>>) -> skip_ws(Rest); skip_ws(<<$\t, Rest/binary>>) -> skip_ws(Rest); skip_ws(Data) -> Data. skip_comma(<<$,, Rest/binary>>) -> skip_ws(Rest); skip_comma(Data) -> Data.