Current section

Files

Jump to
etacacs_plus src etacacs_plus_server.erl
Raw

src/etacacs_plus_server.erl

%%%-------------------------------------------------------------------
%%% @author Torbjorn Tornkvist <kruskakli@gmail.com>
%%% @copyright (C) 2023, Torbjorn Tornkvist
%%% @doc TACACS+ according to RFC-8907.
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(etacacs_plus_server).
-behaviour(gen_server).
%% API
-export([start_link/0,
log_filter/2
]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3, format_status/2]).
%%-define(DEBUG, true).
-include("etacacs_plus.hrl").
-define(SERVER, ?MODULE).
-record(packet,
{ type
, seq_no
, flags
, session_id
, msg
}).
-record(start_authentication,
{ action
, priv_lvl
, auth_type
, auth_service
, user
, port
, rem_addr
, data
}).
-record(authentication_continue,
{
state,
user_msg,
data
}).
-record(start_authorization,
{ auth_method
, priv_lvl
, auth_type
, auth_service
, user
, port
, rem_addr
, args
}).
-record(state,
{
key,
listen_ip,
port,
db_conf_file,
lpid
}).
%% Worker state
-define(INIT, init).
-define(GET_PASS, get_pass).
-define(FINISHED, finished).
-record(wstate,
{
state = ?INIT,
key,
user = -1,
user_data = []
}).
%%%===================================================================
%%% API
%%%===================================================================
%%--------------------------------------------------------------------
%% @doc
%% Starts the server
%% @end
%%--------------------------------------------------------------------
-spec start_link() -> {ok, Pid :: pid()} |
{error, Error :: {already_started, pid()}} |
{error, Error :: term()} |
ignore.
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
%% We want to filter out all log messages that are
%% not generated by us.
log_filter(MsgMap, NoMatchAction) ->
try
case maps:get(meta, MsgMap) of
MetaMap when is_map(MetaMap) ->
case maps:is_key(etacacs_plus, MetaMap) of
true ->
?debug("--- log_filter: ~p~n",[MsgMap]),
NewMetaMap = maps:remove(etacacs_plus, MetaMap),
maps:update(meta, NewMetaMap, MsgMap);
false ->
nomatch_action(NoMatchAction)
end;
_ ->
nomatch_action(NoMatchAction)
end
catch
_:_ ->
nomatch_action(NoMatchAction)
end.
%% We want to be able to control this via the filter fun
%% definition in the 'etacacs_plus.config' file.
nomatch_action(stop) -> stop;
nomatch_action(_) -> ignore.
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
%%--------------------------------------------------------------------
%% @private
%% @doc
%% Initializes the server
%% @end
%%--------------------------------------------------------------------
-spec init(Args :: term()) -> {ok, State :: term()} |
{ok, State :: term(), Timeout :: timeout()} |
{ok, State :: term(), hibernate} |
{stop, Reason :: term()} |
ignore.
init([]) ->
erlang:process_flag(trap_exit, true),
?ETACACS_LOG(#{msg => "starting..."}),
%%logger:log(notice, "~p starting...", [?MODULE]),
{ok, Key0} = application:get_env(etacacs_plus, key),
{ok, ListenIp} = application:get_env(etacacs_plus, listen_ip),
{ok, Port} = application:get_env(etacacs_plus, port),
{ok, DbConfFile} = application:get_env(etacacs_plus, db_conf_file),
Key = list_to_binary(Key0),
Self = self(),
Pid = erlang:spawn_link(
fun() ->
erlang:process_flag(trap_exit, true),
{ok, ListenSock} = gen_tcp:listen(Port,
[{ip, ListenIp},
{reuseaddr, true},
binary,
{active, false}]),
?debug("--- ~p: listening to port: ~p~n",[self(), Port]),
Lself = self(),
Apid = spawn_link(fun() -> acceptor(Lself, Key, ListenSock) end),
lloop(Self, Key, ListenSock, Apid)
end),
{ok, #state{key = Key,
listen_ip = ListenIp,
port = Port,
db_conf_file = DbConfFile,
lpid = Pid}}.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling call messages
%% @end
%%--------------------------------------------------------------------
-spec handle_call(Request :: term(), From :: {pid(), term()}, State :: term()) ->
{reply, Reply :: term(), NewState :: term()} |
{reply, Reply :: term(), NewState :: term(), Timeout :: timeout()} |
{reply, Reply :: term(), NewState :: term(), hibernate} |
{noreply, NewState :: term()} |
{noreply, NewState :: term(), Timeout :: timeout()} |
{noreply, NewState :: term(), hibernate} |
{stop, Reason :: term(), Reply :: term(), NewState :: term()} |
{stop, Reason :: term(), NewState :: term()}.
handle_call(_Request, _From, State) ->
Reply = ok,
{reply, Reply, State}.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling cast messages
%% @end
%%--------------------------------------------------------------------
-spec handle_cast(Request :: term(), State :: term()) ->
{noreply, NewState :: term()} |
{noreply, NewState :: term(), Timeout :: timeout()} |
{noreply, NewState :: term(), hibernate} |
{stop, Reason :: term(), NewState :: term()}.
handle_cast(_Request, State) ->
{noreply, State}.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% Handling all non call/cast messages
%% @end
%%--------------------------------------------------------------------
-spec handle_info(Info :: timeout() | term(), State :: term()) ->
{noreply, NewState :: term()} |
{noreply, NewState :: term(), Timeout :: timeout()} |
{noreply, NewState :: term(), hibernate} |
{stop, Reason :: normal | term(), NewState :: term()}.
handle_info(_Info, State) ->
{noreply, State}.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any
%% necessary cleaning up. When it returns, the gen_server terminates
%% with Reason. The return value is ignored.
%% @end
%%--------------------------------------------------------------------
-spec terminate(Reason :: normal | shutdown | {shutdown, term()} | term(),
State :: term()) -> any().
terminate(_Reason, _State) ->
ok.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% Convert process state when code is changed
%% @end
%%--------------------------------------------------------------------
-spec code_change(OldVsn :: term() | {down, term()},
State :: term(),
Extra :: term()) -> {ok, NewState :: term()} |
{error, Reason :: term()}.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%--------------------------------------------------------------------
%% @private
%% @doc
%% This function is called for changing the form and appearance
%% of gen_server status when it is returned from sys:get_status/1,2
%% or when it appears in termination error logs.
%% @end
%%--------------------------------------------------------------------
-spec format_status(Opt :: normal | terminate,
Status :: list()) -> Status :: term().
format_status(_Opt, Status) ->
Status.
%%%===================================================================
%%% Internal functions
%%%===================================================================
%%
%% Listen server
%%
lloop(Controller, Key, ListenSock, Acceptor) ->
receive
{Acceptor, connected} ->
Self = self(),
Apid = spawn_link(fun() -> acceptor(Self, Key, ListenSock) end),
lloop(Self, Key, ListenSock, Apid);
{'EXIT', Acceptor, _Reason} ->
Self = self(),
Apid = spawn_link(fun() -> acceptor(Self, Key, ListenSock) end),
lloop(Self, Key, ListenSock, Apid);
{'EXIT', Controller, _Reason} ->
exit(shutdown)
end.
%%
%% Worker process
%%
acceptor(LPid, Key, ListenSocket) ->
{ok, Socket} = gen_tcp:accept(ListenSocket),
LPid ! {self(), connected},
wloop(Socket, #wstate{key = Key}).
wloop(Socket, #wstate{state = ?FINISHED}) ->
?debug("--- worker ~p finished!~n",[self()]),
gen_tcp:close(Socket),
exit(normal);
%%
wloop(Socket, State) ->
inet:setopts(Socket, [{active, once}]),
receive
{tcp, Socket, Msg} ->
Packet = decode_packet(State, Msg),
NewState = process_packet(Socket, State, Packet),
wloop(Socket, NewState);
{tcp_closed, Socket} ->
exit(normal)
end.
process_packet(Socket,
#wstate{state = ?INIT, key = Key} = State,
#packet{msg = Msg = #start_authorization{}} = Req) ->
?debug("--- start_authorization msg: ~p~n",[Msg]),
SrvMsgLen = DataLen = 0,
User = Msg#start_authorization.user,
Args = Msg#start_authorization.args,
?debug("--- start_authorization Args0: ~p~n",[Args]),
{Success, ReturnedData, Body} =
case etacacs_plus_db:authorize_user(User, Args) of
{ok, AData} ->
%% Example of AData content:
%%
%% [<<"groups=admin netadmin private">>,
%% <<"uid=1000">>,
%% <<"gid=100">>,
%% <<"home=/tmp">>]
%%
?debug("--- AData: ~p~n",[AData]),
ArgCnt = erlang:length(AData),
BodyHead = <<?AUTHOR_STATUS_PASS_ADD:8,
ArgCnt:8,
SrvMsgLen:16,
DataLen:16>>,
ArgLenBin = lists:foldr(
fun(Len, Acc) ->
<<Len:8,Acc/binary>>
end, <<"">>, [size(A) || A <- AData]),
ArgBin = lists:foldr(
fun(Arg, Acc) ->
<<Arg/binary,Acc/binary>>
end, <<"">>, AData),
{true,
AData,
<<BodyHead/binary, ArgLenBin/binary, ArgBin/binary>>};
_ ->
ArgCnt = 0,
{false,
[],
<<?AUTHOR_STATUS_FAIL:8,
ArgCnt:8,
SrvMsgLen:16,
DataLen:16>>}
end,
Reply = mk_packet(Key,
?AUTHORIZATION, % type
Req#packet.seq_no + 1,
0 , % flags
Req#packet.session_id,
Body),
ok = gen_tcp:send(Socket, Reply),
%% Log the reply
UserStr = binary_to_list(User),
InDataStr = string:join(
[binary_to_list(X)++"="++binary_to_list(Y)
|| {X,Y} <- Args,
is_binary(X) andalso is_binary(Y)],
" "),
if Success ->
ReturnedDataStr = string:join([binary_to_list(X)
|| X <- ReturnedData],
" "),
MsgMap = #{authorization => "PASS",
in_data => InDataStr,
user => UserStr,
out_data => ReturnedDataStr},
?ETACACS_LOG(MsgMap);
true ->
MsgMap = #{authorization => "FAIL",
in_data => InDataStr,
user => UserStr},
?ETACACS_LOG(MsgMap)
end,
State#wstate{state = ?FINISHED,
user = Msg#start_authorization.user};
%%
process_packet(Socket,
#wstate{state = ?INIT, key = Key} = State,
#packet{msg = Msg = #start_authentication{}} = Req) ->
Flags = SrvMsgLen = DataLen = 0,
ReplyBody = <<?STATUS_GETPASS:8, Flags:8, SrvMsgLen:16, DataLen:16>>,
Reply = mk_packet(Key,
?AUTHENTICATION, % type
Req#packet.seq_no + 1,
0 , % flags
Req#packet.session_id,
ReplyBody),
?debug("--- Sending reply packet: ~p~n",[Reply]),
ok = gen_tcp:send(Socket, Reply),
State#wstate{state = ?GET_PASS,
user = Msg#start_authentication.user};
%%
process_packet(Socket,
#wstate{state = ?GET_PASS,
key = Key,
user = User} = State,
#packet{msg = #authentication_continue{
user_msg = Passwd
}
} = Req) ->
{Status, UserData} = login(User, Passwd),
Flags = SrvMsgLen = DataLen = 0,
ReplyBody = <<Status:8, Flags:8, SrvMsgLen:16, DataLen:16>>,
Reply = mk_packet(Key,
?AUTHENTICATION, % type
Req#packet.seq_no + 1,
0 , % flags
Req#packet.session_id,
ReplyBody),
?debug("--- Sending reply packet (Status=~p): ~p~n",[Status,Reply]),
ok = gen_tcp:send(Socket, Reply),
%% Log the reply
UserStr = binary_to_list(User),
if (Status == ?STATUS_PASS) ->
MsgMap = #{authentication => "PASS",
user => UserStr},
?ETACACS_LOG(MsgMap);
true ->
MsgMap = #{authentication => "FAIL",
user => UserStr},
?ETACACS_LOG(MsgMap)
end,
State#wstate{state = ?FINISHED,
user_data = UserData}.
login(User, Passwd) ->
case etacacs_plus_db:login_user(User, Passwd) of
{ok, UserData} ->
{?STATUS_PASS, UserData};
_ ->
{?STATUS_FAIL, []}
end.
%%
%% Construct a packet (header + body)
%%
mk_packet(Key, Type, SeqNo, Flags, SessionId, UnhashedBody) ->
BodyLen = size(UnhashedBody),
Version = <<16#c:4, 0:4>>,
PseudoPad = pseudo_pad(BodyLen,
<<SessionId:32>>,
Key,
Version,
<<SeqNo:8>>),
Body = obfuscate(UnhashedBody, PseudoPad),
<<Version/binary,
Type:8,
SeqNo:8,
Flags:8,
SessionId:32,
BodyLen:32,
Body/binary>>.
%%
%% Decode a packet (header + body)
%%
decode_packet(#wstate{key = Key} = State,
<<MajVsn:4, % major TACACS+ version number (0xc)
MinVsn:4, % minor TACACS+ version number
%% default (0x0) , one (0x1)
Type:8, % type, Authentication (0x01)
%% Authorization (0x02)
%% Accounting (0x03)
SeqNo:8, % sequence number of the current packet
Flags:8, % various bitmapped flags
%% unencrypted_flag (0x01)
%% single_connect_flag (0x04)
SessionId:32, % id for this TACACS+ session
Length:32, % total length of the packet body
%% (not including the header)
Body0/binary>> % packet body
) ->
?debug("--- ~p worker got msg:~n"
" MajVsn = ~p~n"
" MinVsn = ~p~n"
" Type = ~p~n"
" SeqNo = ~p~n"
" Flags = ~p~n"
" SessId = ~p~n"
" Length = ~p~n"
" Body0 = ~p~n",
[self(),MajVsn,MinVsn,
type_to_str(Type),
SeqNo,Flags,SessionId,Length,Body0]
),
Version = <<MajVsn:4, MinVsn:4>>,
PseudoPad = pseudo_pad(Length,
<<SessionId:32>>,
Key,
Version,
<<SeqNo:8>>),
Body = deobfuscate(is_obfuscated(Flags), Body0, PseudoPad),
Msg = decode_body(State, Type, Body),
#packet{type = Type,
seq_no = SeqNo,
flags = Flags,
session_id = SessionId,
msg = Msg}.
is_obfuscated(Flags) when (Flags band ?UNENCRYPTED_FLAG) == 1 -> false;
is_obfuscated(_Flags) -> true.
deobfuscate(true, Body0, PseudoPad) ->
obfuscate(Body0, PseudoPad);
deobfuscate(false, Body0, _PseudoPad) ->
Body0.
obfuscate(<<B:8>>, <<P:8>>) ->
X = B bxor P,
<<X:8>>;
obfuscate(<<B:8,Brest/binary>>, <<P:8,Prest/binary>>) ->
X = B bxor P,
Bin = obfuscate(Brest, Prest),
<<X:8, Bin/binary>>.
decode_body(#wstate{state = ?INIT},
?AUTHORIZATION, % packet type
<<AuthMethod:8,
PrivLvl:8,
AuthType:8,
AuthService:8,
UserLen:8,
PortLen:8,
RemAddrLen:8,
ArgCnt:8,
Rest/binary
>>) ->
?debug(" ------~n"
" AuthMethod = ~p~n"
" PrivLvl = ~p~n"
" AuthType = ~p~n"
" AuthService = ~p~n"
" UserLen = ~p~n"
" PortLen = ~p~n"
" RemAddrLen = ~p~n"
" ArgCnt = ~p~n"
" Rest = ~p~n",
[auth_method_to_str(AuthMethod),
PrivLvl,
auth_type_to_str(AuthType),
auth_service_to_str(AuthService),
UserLen,
PortLen,
RemAddrLen,
ArgCnt,
Rest]),
{ArgLengths, Body} = get_arg_lengths(ArgCnt, Rest),
{Items, _Left} =
lists:mapfoldl(
fun(Len, Bin) ->
get_item(Len, Bin)
end,
Body,
[UserLen, PortLen, RemAddrLen | ArgLengths]),
?debug("--- got Items: ~p~n",[Items]),
[User, Port, RemAddr | ArgsStrings] = Items,
Args = parse_args_strings(ArgsStrings),
#start_authorization{
auth_method = AuthMethod,
priv_lvl = PrivLvl,
auth_type = AuthType,
auth_service = AuthService,
user = User,
port = Port,
rem_addr = RemAddr,
args = Args
};
%%
decode_body(#wstate{state = ?INIT},
?AUTHENTICATION, % packet type
<<Action:8,
PrivLvl:8,
AuthType:8,
AuthService:8,
UserLen:8,
PortLen:8,
RemAddrLen:8,
DataLen:8,
Rest/binary
>>) ->
?debug(" ------~n"
" Action = ~p~n"
" PrivLvl = ~p~n"
" AuthType = ~p~n"
" AuthService = ~p~n"
" UserLen = ~p~n"
" PortLen = ~p~n"
" RemAddrLen = ~p~n"
" DataLen = ~p~n"
" Rest = ~p~n",
[action_to_str(Action),
PrivLvl,
auth_type_to_str(AuthType),
auth_service_to_str(AuthService),
UserLen,
PortLen,
RemAddrLen,
DataLen,
%%User,
Rest]),
{Items, _Left} =
lists:mapfoldl(
fun(Len, Bin) ->
get_item(Len, Bin)
end,
Rest,
[UserLen, PortLen, RemAddrLen, DataLen]),
[User,Port,RemAddr,Data] = Items,
?debug(" ------~n"
" User = ~p~n"
" Port = ~p~n"
" RemAddr = ~p~n"
" Data = ~p~n",
[User, Port, RemAddr, Data]),
#start_authentication{action = Action,
priv_lvl = PrivLvl,
auth_type = AuthType,
auth_service = AuthService,
user = User,
port = Port,
rem_addr = RemAddr,
data = Data
};
%%
decode_body(#wstate{state = ?GET_PASS},
?AUTHENTICATION, % packet type
<<UserMsgLen:16,
DataLen:16,
Flags:8,
Rest/binary
>>) ->
if ?is_set(Flags, ?CONTINUE_FLAG_ABORT) ->
?debug("...ABORTING...~n",[]),
exit(abort); % FIXME do something better...
true ->
true
end,
{Items, _Left} =
lists:mapfoldl(
fun(Len, Bin) ->
get_item(Len, Bin)
end,
Rest,
[UserMsgLen, DataLen]),
[UserMsg,Data] = Items,
?debug(" ------~n"
" Password = ~p~n"
" Data = ~p~n",
[UserMsg , Data]),
#authentication_continue{state = ?GET_PASS,
user_msg = UserMsg,
data = Data}.
%% If Length == 0 then no Item exist, else
%% extract the Item according to its Length.
get_item(0 = _ItemLen, Bin) ->
{<<"">>, Bin};
get_item(ItemLen, Bin) ->
<<Item:ItemLen/binary-unit:8, Rest/binary>> = Bin,
{Item, Rest}.
%% Get all arg-len fields in an Authorization package.
get_arg_lengths(ArgCnt, Rest) ->
get_arg_lengths(ArgCnt, Rest, []).
get_arg_lengths(0, Rest, Acc) ->
{lists:reverse(Acc), Rest};
get_arg_lengths(ArgCnt, <<Len:8, Rest/binary>>, Acc) ->
get_arg_lengths(ArgCnt - 1, Rest, [Len | Acc]).
parse_args_strings([]) ->
[];
parse_args_strings([H|T]) ->
case string:split(H, "=") of
[K,V] ->
[{K,V} | parse_args_strings(T)];
_ ->
?debug("--- ignoring arg: ~p~n",[H]),
parse_args_strings(T)
end.
%%
%% The pad is generated by concatenating a series of MD5 hashes
%% (each 16 bytes long) and truncating it to the length of the input data.
%%
%% The first MD5 hash is generated by concatenating the session_id,
%% the secret key, the version number, and the sequence number, and then
%% running MD5 over that stream.
%%
%% Subsequent hashes are generated by using the same input stream but
%% concatenating the previous hash value at the end of the input stream.
%%
pseudo_pad(Length, SessionId, SecretKey, Version, SeqNo)
when is_binary(SessionId) andalso
is_binary(SecretKey) andalso
is_binary(Version) andalso
is_binary(SeqNo) ->
N = Length div 16,
Unhashed = <<SessionId/binary,
SecretKey/binary,
Version/binary,
SeqNo/binary>>,
X = erlang:md5(Unhashed),
Res = pseudo_pad(N, SessionId, SecretKey, Version, SeqNo, [X]),
B = list_to_binary(lists:reverse(Res)),
<<Pad:Length/binary-unit:8,_/binary>> = B,
Pad.
pseudo_pad(0, _SessionId, _SecretKey, _Version, _SeqNo, Acc) ->
Acc;
pseudo_pad(N, SessionId, SecretKey, Version, SeqNo, [MDn_1|_] = Acc) ->
X = erlang:md5(<<SessionId/binary,
SecretKey/binary,
Version/binary,
SeqNo/binary,
MDn_1/binary>>),
pseudo_pad(N-1, SessionId, SecretKey, Version, SeqNo, [X|Acc]).
-ifdef(DEBUG).
bin2hexstring(Bin) ->
[io_lib:format("0x~2.16.0b ", [Byte]) || Byte <- erlang:binary_to_list(Bin)].
type_to_str(1) -> "Authenticate";
type_to_str(2) -> "Authorize";
type_to_str(3) -> "Accounting".
action_to_str(1) -> "LOGIN";
action_to_str(2) -> "CHPASS";
action_to_str(4) -> "SENDAUTH".
auth_type_to_str(1) -> "ASCII";
auth_type_to_str(2) -> "PAP";
auth_type_to_str(3) -> "CHAP";
auth_type_to_str(5) -> "MSCHAP";
auth_type_to_str(6) -> "MSCHAPV2".
auth_service_to_str(0) -> "NONE";
auth_service_to_str(1) -> "LOGIN";
auth_service_to_str(2) -> "ENABLE";
auth_service_to_str(3) -> "PPP";
auth_service_to_str(5) -> "PT";
auth_service_to_str(6) -> "RCMD";
auth_service_to_str(7) -> "X25";
auth_service_to_str(8) -> "NASI";
auth_service_to_str(9) -> "FWPROXY".
auth_method_to_str(?METH_NOT_SET) -> "NOT_SET";
auth_method_to_str(?METH_NONE) -> "NONE";
auth_method_to_str(?METH_KRB5) -> "KRB5";
auth_method_to_str(?METH_LINE) -> "LINE";
auth_method_to_str(?METH_ENABLE) -> "ENABLE";
auth_method_to_str(?METH_LOCAL) -> "LOCAL";
auth_method_to_str(?METH_TACACSPLUS) -> "TACACSPLUS";
auth_method_to_str(?METH_GUEST) -> "GUEST";
auth_method_to_str(?METH_RADIUS) -> "RADIUS";
auth_method_to_str(?METH_KRB4) -> "KRB4";
auth_method_to_str(?METH_RCMD) -> "RCMD".
-endif.