-module(nquic_token_cache). -moduledoc """ Per-instance NEW_TOKEN cache for QUIC client reconnection (RFC 9000 ยง8.1.3). Mirrors `nquic_session_cache`: each cache is a `gen_server` that owns one named ETS table and runs a periodic eviction sweep. The cache is opt-in; the caller starts it under its own supervisor and references it by name on `nquic:connect/3`. ## Usage ```erlang [nquic_token_cache:child_spec(my_quic_tokens, #{sweep_ms => 60_000})]. ``` ```erlang {ok, Conn} = nquic:connect(Host, Port, #{token_cache => my_quic_tokens}). ``` The client records any NEW_TOKEN frame received from the server into the cache. A subsequent `connect/4` to the same `{Host, Port}` reads back the token and attaches it to the outgoing Initial; the server validates it against its static key, treats the source address as already-validated, and skips the Retry round-trip. The cache stores raw token bytes; the server's HMAC binds the token to a specific address + lifetime, so a stale or address-mismatched token simply fails server-side validation and triggers a normal Retry. Keep the client TTL shorter than the server's `Lifetime` (default 24 h) to avoid presenting tokens the server will reject. ## Custom backends The `token_cache` connect option also accepts `{module, Mod}` where `Mod` exports: ```erlang store(Host, Port, Token) -> ok. lookup(Host, Port) -> {ok, binary()} | {error, not_found}. delete(Host, Port) -> ok. ``` """. -behaviour(gen_server). -compile({no_auto_import, [size/1]}). -export([ child_spec/1, child_spec/2, clear/1, delete/3, lookup/3, maybe_load_token/3, size/1, start_link/1, start_link/2, stop/1, store/4 ]). -export([handle_call/3, handle_cast/2, handle_info/2, init/1, terminate/2]). -export_type([cache_name/0, host/0]). -type cache_name() :: atom(). -type host() :: inet:hostname() | inet:ip_address(). -define(DEFAULT_SWEEP_MS, 60_000). -define(DEFAULT_TTL, 86400). -record(state, { name :: cache_name(), sweep_ms :: pos_integer(), sweep_ref :: reference() | undefined }). %%%----------------------------------------------------------------------------- %% API %%%----------------------------------------------------------------------------- -doc "Standard child spec with the default sweep interval.". -spec child_spec(cache_name()) -> supervisor:child_spec(). child_spec(Name) -> child_spec(Name, #{}). -doc "Standard child spec with custom options.". -spec child_spec(cache_name(), map()) -> supervisor:child_spec(). child_spec(Name, Opts) when is_atom(Name), is_map(Opts) -> #{ id => {?MODULE, Name}, start => {?MODULE, start_link, [Name, Opts]}, restart => permanent, shutdown => 5000, type => worker, modules => [?MODULE] }. -doc "Drop every entry in cache `Name`. Raises `badarg` if not started.". -spec clear(cache_name()) -> ok. clear(Name) when is_atom(Name) -> true = ets:delete_all_objects(Name), ok. -doc "Delete a token from cache `Name`. Raises `badarg` if not started.". -spec delete(cache_name(), host(), inet:port_number()) -> ok. delete(Name, Host, Port) when is_atom(Name) -> Key = cache_key(Host, Port), true = ets:delete(Name, Key), ok. -doc """ Look up a NEW_TOKEN for `{Host, Port}` in cache `Name`. Returns `{ok, Token}` for a live token, `{error, not_found}` for a miss or an expired entry. Raises `badarg` if `Name` is not a live cache. """. -spec lookup(cache_name(), host(), inet:port_number()) -> {ok, binary()} | {error, not_found}. lookup(Name, Host, Port) when is_atom(Name) -> Key = cache_key(Host, Port), case ets:lookup(Name, Key) of [{Key, Token, Expiry}] -> Now = erlang:system_time(second), case Now < Expiry of true -> {ok, Token}; false -> ets:delete(Name, Key), {error, not_found} end; [] -> {error, not_found} end. -doc """ If `Opts` does not already carry a `client_token`, resolve the configured `token_cache` selector and inject any cached NEW_TOKEN into `Opts`. Returns `Opts` unchanged on miss or when no cache is configured. Accepts the same selector shape as the `connect/3,4` option: `false` (no cache), an atom naming a started cache, or `{module, Mod}` for a custom backend exporting `lookup/2`. """. -spec maybe_load_token(host(), inet:port_number(), map()) -> map(). maybe_load_token(Host, Port, Opts) -> case maps:get(client_token, Opts, undefined) of undefined -> case resolve_lookup(maps:get(token_cache, Opts, false), Host, Port) of {ok, Token} -> Opts#{client_token => Token}; {error, _} -> Opts end; _ -> Opts end. -doc "Return the number of entries in cache `Name`.". -spec size(cache_name()) -> non_neg_integer(). size(Name) when is_atom(Name) -> ets:info(Name, size). %%%----------------------------------------------------------------------------- %% GEN_SERVER CALLBACKS %%%----------------------------------------------------------------------------- -spec handle_call(term(), gen_server:from(), #state{}) -> {reply, {error, unknown_request}, #state{}}. handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. -spec handle_cast(term(), #state{}) -> {noreply, #state{}}. handle_cast(_Msg, State) -> {noreply, State}. -spec handle_info(sweep | term(), #state{}) -> {noreply, #state{}}. handle_info(sweep, #state{name = Name, sweep_ms = Ms} = State) -> sweep_expired(Name), Ref = schedule_sweep(Ms), {noreply, State#state{sweep_ref = Ref}}; handle_info(_Info, State) -> {noreply, State}. -spec init({cache_name(), map()}) -> {ok, #state{}}. init({Name, Opts}) -> process_flag(trap_exit, true), _ = ets:new(Name, [ named_table, public, set, {read_concurrency, true}, {write_concurrency, true}, {decentralized_counters, true} ]), SweepMs = maps:get(sweep_ms, Opts, ?DEFAULT_SWEEP_MS), Ref = schedule_sweep(SweepMs), {ok, #state{name = Name, sweep_ms = SweepMs, sweep_ref = Ref}}. -doc "Start a cache registered under `Name` with default options.". -spec start_link(cache_name()) -> {ok, pid()} | ignore | {error, term()}. start_link(Name) -> start_link(Name, #{}). -doc """ Start a cache registered under `Name`. Accepted options: * `sweep_ms` - eviction interval in milliseconds (default 60_000). Returns `{error, {already_started, Pid}}` if a cache with this name is already registered. """. -spec start_link(cache_name(), map()) -> {ok, pid()} | ignore | {error, term()}. start_link(Name, Opts) when is_atom(Name), is_map(Opts) -> gen_server:start_link({local, Name}, ?MODULE, {Name, Opts}, []). -doc "Stop the cache and tear down its ETS table.". -spec stop(cache_name()) -> ok. stop(Name) when is_atom(Name) -> case whereis(Name) of undefined -> ok; _ -> gen_server:stop(Name) end. -doc """ Store a NEW_TOKEN under `{Host, Port}` for the cache `Name`. The optional `TTL` (seconds) bounds how long the cache will hand the token back; default 24 h. Keep this less-than-or-equal to the server's `new_token_lifetime` so the cache never serves a token the server would reject. """. -spec store(cache_name(), host(), inet:port_number(), binary()) -> ok. store(Name, Host, Port, Token) when is_atom(Name), is_binary(Token) -> Key = cache_key(Host, Port), Expiry = erlang:system_time(second) + ?DEFAULT_TTL, true = ets:insert(Name, {Key, Token, Expiry}), ok. -spec terminate(term(), #state{}) -> ok. terminate(_Reason, #state{name = Name}) -> case ets:info(Name) of undefined -> ok; _ -> ets:delete(Name) end, ok. %%%----------------------------------------------------------------------------- %% INTERNAL %%%----------------------------------------------------------------------------- -spec cache_key(host(), inet:port_number()) -> {host(), inet:port_number()}. cache_key(Host, Port) -> {Host, Port}. -spec resolve_lookup( false | cache_name() | {module, module()}, host(), inet:port_number() ) -> {ok, binary()} | {error, not_found}. resolve_lookup(false, _Host, _Port) -> {error, not_found}; resolve_lookup({module, Mod}, Host, Port) -> Mod:lookup(Host, Port); resolve_lookup(Name, Host, Port) when is_atom(Name) -> lookup(Name, Host, Port). -spec schedule_sweep(pos_integer()) -> reference(). schedule_sweep(Ms) -> erlang:send_after(Ms, self(), sweep). -spec sweep_expired(cache_name()) -> ok. sweep_expired(Name) -> Now = erlang:system_time(second), _ = ets:select_delete( Name, [{{'_', '_', '$1'}, [{'=<', '$1', Now}], [true]}] ), ok.