This commit is contained in:
anlicheng 2026-06-27 15:32:23 +08:00
parent d6fb2a480f
commit 9d5bc114ed
5 changed files with 129 additions and 38 deletions

View File

@ -25,6 +25,20 @@ Each UDP datagram is one `RKP1` frame:
stream_id(8), payload_len(4), payload(payload_len)
Frame types are `open = 1`, `data = 2`, `close = 3`, and `error = 4`.
`open` payload is RelayKit JSON: `host`, `port`, `useTLS`, and `method`.
The server is a transparent TCP relay: `useTLS` is parsed for client
compatibility, but the upstream connection is always raw TCP.
`open` payload is binary encoded as:
host_len(2), host(host_len), port(2),
username_len(2), username(username_len),
password_len(2), password(password_len)
Integer fields are unsigned big-endian values. `host`, `username`, and
`password` are binary strings. The server is a transparent TCP relay, so TLS
negotiation, HTTP methods, and request bytes are carried inside subsequent
`data` frames without being interpreted by the server.
`username` and `password` are checked against the `relay_server` application
`users` config:
{users, [
{admin, "password"}
]}

View File

@ -7,7 +7,7 @@
-behaviour(gen_server).
-export([start_link/4]).
-export([start_link/4, start_link/5]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
@ -27,20 +27,25 @@
peer :: {inet:ip_address(), inet:port_number()},
idle_timeout :: timeout(),
connect_timeout :: timeout(),
users = #{} :: #{binary() => binary()},
streams = #{} :: #{non_neg_integer() => stream()},
sockets = #{} :: #{inet:socket() => non_neg_integer()}
}).
start_link(Transport, Peer, IdleTimeout, ConnectTimeout) ->
gen_server:start_link(?MODULE, [Transport, Peer, IdleTimeout, ConnectTimeout], []).
start_link(Transport, Peer, IdleTimeout, ConnectTimeout, load_users()).
init([{udp, Server, _Sock}, Peer, IdleTimeout, ConnectTimeout]) ->
start_link(Transport, Peer, IdleTimeout, ConnectTimeout, Users) ->
gen_server:start_link(?MODULE, [Transport, Peer, IdleTimeout, ConnectTimeout, Users], []).
init([{udp, Server, _Sock}, Peer, IdleTimeout, ConnectTimeout, Users]) ->
logger:debug("UDP peer connected: ~s", [esockd:format(Peer)]),
{ok, #state{
server = Server,
peer = Peer,
idle_timeout = IdleTimeout,
connect_timeout = ConnectTimeout
connect_timeout = ConnectTimeout,
users = normalize_users(Users)
}, IdleTimeout}.
handle_call(_Request, _From, State = #state{idle_timeout = IdleTimeout}) ->
@ -54,12 +59,14 @@ handle_info({datagram, Server, <<"stop">>}, State = #state{server = Server, peer
{stop, normal, State};
handle_info({datagram, Server, Packet}, State = #state{server = Server}) ->
handle_datagram(Packet, State);
handle_info({tcp, Socket, Data}, State) ->
handle_remote_data(Socket, Data, State);
handle_info({tcp_closed, Socket}, State) ->
handle_remote_closed(Socket, State);
handle_info({tcp_error, Socket, Reason}, State) ->
handle_remote_error(Socket, Reason, State);
handle_info(timeout, State = #state{peer = Peer}) ->
logger:debug("UDP peer idle timeout: ~s", [esockd:format(Peer)]),
{stop, normal, State};
@ -97,7 +104,15 @@ handle_open(StreamId, Payload, State = #state{streams = Streams}) ->
false ->
case relay_server_udp_protocol:decode_open_request(Payload) of
{ok, Request} ->
open_stream(StreamId, Request, State);
case authenticate(Request, State#state.users) of
ok ->
open_stream(StreamId, Request, State);
error ->
logger:warning("UDP relay stream ~p authentication failed from ~s",
[StreamId, esockd:format(State#state.peer)]),
send_error(StreamId, <<"authentication failed">>, State),
State
end;
{error, Reason} ->
send_error(StreamId, format_error(Reason), State),
State
@ -225,3 +240,54 @@ format_error(Reason) when is_binary(Reason) ->
Reason;
format_error(Reason) ->
iolist_to_binary(io_lib:format("~p", [Reason])).
load_users() ->
application:get_env(relay_server, users, []).
normalize_users(Users) when is_list(Users) ->
lists:foldl(fun normalize_user/2, #{}, Users);
normalize_users(_Users) ->
#{}.
normalize_user({Username0, Password0}, Users) ->
case {to_credential_binary(Username0), to_credential_binary(Password0)} of
{{ok, Username}, {ok, Password}} when byte_size(Username) > 0 ->
maps:put(Username, Password, Users);
_ ->
Users
end;
normalize_user(_User, Users) ->
Users.
to_credential_binary(Value) when is_binary(Value) ->
{ok, Value};
to_credential_binary(Value) when is_atom(Value) ->
{ok, atom_to_binary(Value, utf8)};
to_credential_binary(Value) when is_list(Value) ->
try unicode:characters_to_binary(Value) of
Binary when is_binary(Binary) -> {ok, Binary};
_Other -> error
catch
_Class:_Reason -> error
end;
to_credential_binary(_Value) ->
error.
authenticate(#{username := Username, password := Password}, Users) ->
case maps:get(Username, Users, undefined) of
undefined ->
error;
ExpectedPassword ->
case secure_equal(Password, ExpectedPassword) of
true -> ok;
false -> error
end
end.
secure_equal(Left, Right) when is_binary(Left), is_binary(Right) ->
(byte_size(Left) =:= byte_size(Right)) andalso secure_equal(Left, Right, 0) =:= 0.
secure_equal(<<L:8, LRest/binary>>, <<R:8, RRest/binary>>, Diff) ->
secure_equal(LRest, RRest, Diff bor (L bxor R));
secure_equal(<<>>, <<>>, Diff) ->
Diff.

View File

@ -43,6 +43,7 @@ init([]) ->
IdleTimeout = proplists:get_value(idle_timeout, Props, ?DEFAULT_IDLE_TIMEOUT),
ConnectTimeout = proplists:get_value(connect_timeout, Props, ?DEFAULT_CONNECT_TIMEOUT),
Users = application:get_env(relay_server, users, []),
UdpOptions = proplists:get_value(udp_options, Props, ?DEFAULT_UDP_OPTIONS),
MaxConnections = proplists:get_value(max_connections, Props, ?DEFAULT_MAX_CONNECTIONS),
AccessRules = proplists:get_value(access_rules, Props, [{allow, all}]),
@ -54,7 +55,7 @@ init([]) ->
],
Opts = maybe_add(max_conn_rate, Props, BaseOpts),
MFA = {relay_server_udp_handler, start_link, [IdleTimeout, ConnectTimeout]},
MFA = {relay_server_udp_handler, start_link, [IdleTimeout, ConnectTimeout, Users]},
case esockd:open_udp(Listener, ListenOn, Opts, MFA) of
{ok, Pid} ->
logger:info("UDP listener ~p started on ~s",

View File

@ -5,7 +5,8 @@
-module(relay_server_udp_protocol).
-export([decode/1, encode/3, decode_open_request/1]).
-export([decode/1, encode/3, decode_open_request/1, encode_open_request/2,
encode_open_request/4]).
-define(MAGIC, <<"RKP1">>).
-define(VERSION, 1).
@ -31,34 +32,39 @@ encode(Type, StreamId, Payload) when is_binary(Payload) ->
PayloadLen = byte_size(Payload),
<<"RKP1", ?VERSION:8, TypeNo:8, 0:16, StreamId:64, PayloadLen:32, Payload/binary>>.
decode_open_request(Payload) ->
try json:decode(Payload) of
#{
<<"host">> := Host,
<<"port">> := Port,
<<"useTLS">> := UseTLS,
<<"method">> := Method
} when
is_binary(Host),
byte_size(Host) > 0,
is_integer(Port),
Port > 0,
Port =< 65535,
is_boolean(UseTLS),
is_binary(Method)
->
{ok, #{
host => Host,
port => Port,
use_tls => UseTLS,
method => Method
}};
_Other ->
{error, invalid_open_request}
catch
_Class:_Reason ->
{error, invalid_open_request}
end.
decode_open_request(<<HostLen:16, Host:HostLen/binary, Port:16,
UsernameLen:16, Username:UsernameLen/binary,
PasswordLen:16, Password:PasswordLen/binary>>)
when HostLen > 0, Port > 0 ->
{ok, #{
host => Host,
port => Port,
username => Username,
password => Password
}};
decode_open_request(_Payload) ->
{error, invalid_open_request}.
encode_open_request(Host, Port) ->
encode_open_request(Host, Port, <<>>, <<>>).
encode_open_request(Host, Port, Username, Password) when
is_binary(Host),
byte_size(Host) > 0,
byte_size(Host) =< 65535,
is_integer(Port),
Port > 0,
Port =< 65535,
is_binary(Username),
byte_size(Username) =< 65535,
is_binary(Password),
byte_size(Password) =< 65535 ->
HostLen = byte_size(Host),
UsernameLen = byte_size(Username),
PasswordLen = byte_size(Password),
<<HostLen:16, Host/binary, Port:16,
UsernameLen:16, Username/binary,
PasswordLen:16, Password/binary>>.
type(1) -> open;
type(2) -> data;

View File

@ -8,6 +8,10 @@
{connect_timeout, 5000},
{max_connections, 1024},
{udp_options, [binary, {reuseaddr, true}]}
]},
{users, [
{admin, "v7@Qm!2z#R8$pL4^xT?K"}
]}
]},