Skip to main content

src/barrel_p2p_dist.erl

%%% -*- erlang -*-
%%% Copyright (c) 2026 Benoit Chesneau
%%% SPDX-License-Identifier: Apache-2.0
%%%
%%% Barrel P2P proto_dist alt-dist module.
%%%
%%% Boot a barrel_p2p node with just:
%%%
%%%   -proto_dist barrel_p2p
%%%   -epmd_module barrel_p2p_epmd
%%%   -start_epmd false
%%%
%%% Everything else (TLS cert generation, auth callback wiring,
%%% discovery module, on-disk cert/key paths) is filled in here
%%% before delegating to upstream `quic_dist'. Values the user sets
%%% explicitly under `{quic, [{dist, [...]}]}' or via `-quic_dist_*'
%%% init args are preserved.
%%%
%%% The shim is intentionally tiny: it owns the listen-time defaults
%%% and forwards the other alt-dist callbacks straight through. That
%%% keeps upstream `?MODULE'-relative spawns (do_setup, acceptor_loop,
%%% accept_connection inline fun) resolving to the right module.

-module(barrel_p2p_dist).

%% Distribution module callbacks (the alt-dist contract).
-export([
    listen/1,
    listen/2,
    accept/1,
    accept_connection/5,
    setup/5,
    close/1,
    select/1,
    address/0,
    is_node_name/1,
    listen_port/0,
    project_defaults/0,
    validate_auth_config/1
]).

%%====================================================================
%% Distribution Module Callbacks
%%====================================================================

listen(Name) ->
    listen(Name, #{}).

listen(Name, ExtraOpts) ->
    ok = ensure_modules_loaded(),
    ok = project_init_args(),
    case ensure_cert() of
        ok ->
            ok = project_defaults(),
            ok = project_listen_port(),
            %% Snapshot the SHA-256 of the effective listener cert now
            %% that quic.dist is final, for the auth channel binding (H1).
            %% This is exactly the cert quic_dist:listen serves below.
            ok = barrel_p2p_dist_auth:cache_server_cert_binding(),
            quic_dist:listen(Name, ExtraOpts);
        {error, _} = Err ->
            Err
    end.

accept(Listener) ->
    quic_dist:accept(Listener).

accept_connection(AcceptPid, DistCtrl, MyNode, Allowed, SetupTime) ->
    quic_dist:accept_connection(AcceptPid, DistCtrl, MyNode, Allowed, SetupTime).

setup(Node, Type, MyNode, LongOrShortNames, SetupTime) ->
    %% Replicate upstream quic_dist:setup/5 but stash the dialed Node
    %% in the setup process's dictionary. barrel_p2p_dist_auth_callback
    %% reads it back to gate the AUTH_OK short-circuit on the client's
    %% own cookie_only_nodes whitelist.
    Kernel = self(),
    spawn_opt(
        fun() ->
            erlang:put(barrel_p2p_dial_target, Node),
            quic_dist:do_setup(
                Kernel, Node, Type, MyNode, LongOrShortNames, SetupTime
            )
        end,
        [link, {priority, max}]
    ).

close(Listener) ->
    quic_dist:close(Listener).

select(Node) ->
    quic_dist:select(Node).

address() ->
    quic_dist:address().

is_node_name(Node) ->
    quic_dist:is_node_name(Node).

%% Inspect the configured local distribution listen port, the value
%% projected into {quic, dist_port}. Returns undefined before the port
%% has been resolved (i.e. before the proto_dist listener has booted).
-spec listen_port() -> {ok, inet:port_number()} | undefined.
listen_port() ->
    case application:get_env(quic, dist_port) of
        {ok, Port} when is_integer(Port) -> {ok, Port};
        _ -> undefined
    end.

%%====================================================================
%% Internal: early-boot wiring
%%====================================================================

%% Modules referenced by quic_dist at runtime that may not be auto-
%% loaded yet during early boot (proto_dist listen/1 runs before the
%% application controller starts barrel_p2p).
ensure_modules_loaded() ->
    lists:foreach(
        fun(M) -> _ = code:ensure_loaded(M) end,
        [
            public_key,
            barrel_p2p_quic_cert,
            barrel_p2p_discovery,
            barrel_p2p_discovery_static,
            barrel_p2p_discovery_file,
            barrel_p2p_discovery_dns,
            barrel_p2p_dist_auth_callback,
            barrel_p2p_dist_auth_stream,
            barrel_p2p_dist_auth
        ]
    ),
    ok.

%% Lazily materialise the QUIC TLS cert/key if they aren't on disk
%% yet. quic_dist:load_credentials runs straight after this and would
%% otherwise fail with the less direct {credentials, no_credentials}.
%% Propagate the real reason so listen/2 short-circuits here.
ensure_cert() ->
    CertDir = cert_dir(),
    case barrel_p2p_quic_cert:ensure_cert(CertDir) of
        ok ->
            ok;
        {error, Reason} ->
            logger:error("barrel_p2p_dist: cert ensure failed: ~p", [Reason]),
            {error, {cert_ensure_failed, Reason}}
    end.

%% Resolve the cert dir at listen time. App env may not be set yet,
%% so check init args first, fall through to app env, then default.
cert_dir() ->
    case init:get_argument(barrel_p2p_dist_cert_dir) of
        {ok, [[CD] | _]} -> CD;
        _ -> application:get_env(barrel_p2p, quic_cert_dir, "data/quic")
    end.

%% Project barrel_p2p defaults into the {quic, dist, ...} app env that
%% upstream quic_dist:load_config/0 reads. User-supplied values win:
%% we only fill keys that are absent.
%%
%% Loads the `quic' app before reading its env: sys.config entries
%% for unloaded apps are pending until load, so without this the
%% `{quic, [{dist, [...]}]}' the operator set in sys.config is
%% invisible at proto_dist-boot time.
project_defaults() ->
    _ = application:load(quic),
    _ = application:load(barrel_p2p),
    User = application:get_env(quic, dist, []),
    Defaults = build_defaults(),
    Merged = merge_defaults(User, Defaults),
    application:set_env(quic, dist, Merged),
    validate_auth_config(Merged),
    ok.

%% Refuse to boot if barrel_p2p auth is enabled but the projected
%% quic.dist config has no auth_callback (or has it explicitly set
%% to undefined). The PR-1 default flip means an unset auth_enabled
%% is `true', so a user who silently overrides auth_callback would
%% otherwise ship unauthenticated peers without warning.
validate_auth_config(QuicDist) ->
    AuthEnabled = application:get_env(barrel_p2p, auth_enabled, true),
    Callback = proplists:get_value(auth_callback, QuicDist),
    case AuthEnabled andalso (Callback =:= undefined) of
        true ->
            erlang:error({barrel_p2p_dist, auth_enabled_without_callback});
        false ->
            ok
    end,
    %% Ed25519 off leaves only the dist cookie over an unauthenticated TLS
    %% channel - warn loudly so it is never a silent default.
    case AuthEnabled of
        false ->
            logger:warning(
                "barrel_p2p: auth_enabled=false - Ed25519 peer authentication is "
                "OFF. Peers are gated by the dist cookie only, with no "
                "protection against an active MITM."
            );
        true ->
            ok
    end.

build_defaults() ->
    CertDir = cert_dir(),
    CertFile = filename:join(CertDir, "node.crt"),
    KeyFile = filename:join(CertDir, "node.key"),
    AuthTimeout = application:get_env(barrel_p2p, auth_handshake_timeout, 10000),
    %% register_with_epmd intentionally left at upstream default
    %% (false). barrel_p2p_app:start/2 publishes the node into the
    %% discovery chain itself once sys.config envs are live, using
    %% the full atom node name; the listen-time path passes a bare
    %% name string which the file backend doesn't accept.
    [
        {auth_callback, {barrel_p2p_dist_auth_callback, authenticate}},
        {auth_handshake_timeout, AuthTimeout},
        {discovery_module, barrel_p2p_discovery},
        {cert_file, list_to_binary(CertFile)},
        {key_file, list_to_binary(KeyFile)}
    ].

%% Translate `-barrel_p2p_dist_*' init args to their `quic.dist'
%% equivalents so users never have to type the upstream knob names.
%% Recognised today: -barrel_p2p_dist_port N.
project_init_args() ->
    case init:get_argument(barrel_p2p_dist_port) of
        {ok, [[PortStr] | _]} ->
            case catch list_to_integer(PortStr) of
                P when is_integer(P), P >= 0 ->
                    application:set_env(quic, dist_port, P);
                _ ->
                    ok
            end;
        _ ->
            ok
    end,
    ok.

%% Project barrel_p2p.listen_port -> quic.dist_port so users keep a
%% single {barrel_p2p, [{listen_port, ...}]} knob. Upstream's
%% -quic_dist_port init arg still wins over this (it's read straight
%% from init args, not the app env).
project_listen_port() ->
    case application:get_env(quic, dist_port) of
        {ok, _} ->
            ok;
        undefined ->
            case application:get_env(barrel_p2p, listen_port) of
                {ok, Port} when is_integer(Port) ->
                    application:set_env(quic, dist_port, Port);
                _ ->
                    ok
            end
    end,
    ok.

merge_defaults(User, Defaults) ->
    lists:foldl(
        fun({K, V}, Acc) ->
            case proplists:is_defined(K, Acc) of
                true -> Acc;
                false -> [{K, V} | Acc]
            end
        end,
        User,
        Defaults
    ).