%%% @doc Built-in MASQUE handler that bridges a CONNECT-UDP tunnel to %%% a real UDP flow on the server. %%% %%% For every accepted tunnel the handler opens a `gen_udp' socket %%% bound to ephemeral port and a specific family, resolves the %%% target hostname, and relays bytes in both directions: %%% %%% %%% %%% Configure policy via `handler_opts': %%% -module(masque_udp_proxy_handler). -behaviour(masque_handler). -export([accept/1, init/2, handle_packet/2, handle_info/2, terminate/2]). -record(state, { socket :: gen_udp:socket(), target_ip :: inet:ip_address(), target_port :: 1..65535 }). %%==================================================================== %% Behaviour callbacks %%==================================================================== -spec accept(masque_handler:req()) -> masque_handler:accept_result(). accept(#{target_host := Host, target_port := Port} = Req) -> Opts = maps:get(handler_opts, Req, #{}), AllowFun = maps:get(allow, Opts, fun default_allow/1), case AllowFun({Host, Port}) of true -> accept; false -> {reject, forbidden} end. -spec init(masque_handler:req(), term()) -> {ok, #state{}} | {stop, term()}. init(#{target_host := Host, target_port := Port} = Req, Opts) -> ResolverFun = maps:get(resolver, Opts, fun default_resolver/1), Family = pick_family(maps:get(family, Opts, auto), Host), SocketOpts = [binary, {active, true} | maps:get(socket_opts, Opts, [])], BindPort = maps:get(port, Opts, 0), AllowPrivate = maps:get(allow_private, Opts, false), case resolve(ResolverFun, Host, Family) of {ok, IP, BindFamily} -> case AllowPrivate orelse masque_ip:is_public(IP) of false -> {stop, {resolution_failed, private_address}}; true -> open_udp(IP, Port, BindPort, BindFamily, SocketOpts) end; {error, Reason} -> _ = Req, {stop, {resolution_failed, {resolve, Reason}}} end. -spec handle_packet(binary(), #state{}) -> {ok, #state{}} | {stop, term(), #state{}}. handle_packet(Data, #state{socket = S} = State) -> case gen_udp:send(S, Data) of ok -> {ok, State}; {error, Reason} when Reason =:= closed; Reason =:= einval; Reason =:= enotconn -> %% Socket is unusable - close the tunnel rather than %% silently black-holing every packet. {stop, {target_socket_lost, Reason}, State}; {error, _Transient} -> %% Transient send errors (ENOBUFS, EAGAIN, etc.): drop and %% keep going - HTTP Datagrams are unreliable by design. {ok, State} end. %% Defensive double-check: on connected UDP the kernel already drops %% non-target sources, but we re-validate at application level in %% case a platform ever weakens that guarantee. -spec handle_info(term(), #state{}) -> {ok, #state{}} | {ok, #state{}, [term()]} | {stop, term(), #state{}}. handle_info({udp, Socket, FromIP, FromPort, Bytes}, #state{socket = Socket, target_ip = IP, target_port = Port} = State) when FromIP =:= IP, FromPort =:= Port -> {ok, State, [{send, Bytes}]}; handle_info({udp, Socket, _FromIP, _FromPort, _Bytes}, #state{socket = Socket} = State) -> %% Source mismatch - drop silently. {ok, State}; handle_info({udp_passive, Socket}, #state{socket = Socket} = State) -> %% Only hit if the user passed `{active, N}` in socket_opts. _ = inet:setopts(Socket, [{active, true}]), {ok, State}; handle_info({udp_error, Socket, Reason}, #state{socket = Socket} = State) -> {stop, {target_socket_error, Reason}, State}; handle_info({udp_closed, Socket}, #state{socket = Socket} = State) -> {stop, target_socket_closed, State}; handle_info(_Other, State) -> {ok, State}. -spec terminate(term(), #state{}) -> ok. terminate(_Reason, #state{socket = S}) -> _ = gen_udp:close(S), ok. %%==================================================================== %% Policy defaults %%==================================================================== default_allow({_Host, _Port}) -> true. default_resolver(Host) when is_binary(Host) -> default_resolver(binary_to_list(Host)); default_resolver(Host) when is_list(Host) -> case inet_parse:address(Host) of {ok, IP} -> {ok, IP}; _ -> case inet:getaddr(Host, inet) of {ok, IP} -> {ok, IP}; {error, _} -> inet:getaddr(Host, inet6) end end. open_udp(IP, Port, BindPort, BindFamily, SocketOpts) -> case gen_udp:open(BindPort, [BindFamily | SocketOpts]) of {ok, Socket} -> case gen_udp:connect(Socket, IP, Port) of ok -> {ok, #state{socket = Socket, target_ip = IP, target_port = Port}}; {error, CReason} -> _ = gen_udp:close(Socket), {stop, {resolution_failed, {connect, CReason}}} end; {error, Reason} -> {stop, {resolution_failed, {udp_open, Reason}}} end. %%==================================================================== %% Helpers %%==================================================================== pick_family(inet, _Host) -> inet; pick_family(inet6, _Host) -> inet6; pick_family(auto, Host) -> %% Auto-pick based on whether the host string looks like an IPv6 literal. HostStr = if is_binary(Host) -> binary_to_list(Host); true -> Host end, case inet:parse_address(HostStr) of {ok, {_, _, _, _, _, _, _, _}} -> inet6; _ -> inet end. resolve(ResolverFun, Host, Family) -> case ResolverFun(Host) of {ok, IP} -> ActualFamily = if tuple_size(IP) =:= 8 -> inet6; true -> Family end, {ok, IP, ActualFamily}; Err -> Err end.