%%%------------------------------------------------------------------- %%% @author Torbjorn Tornkvist %%% @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(GET_USER, get_user). -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. %% %% AUTHORIZATION %% 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 = <>, ArgLenBin = lists:foldr( fun(Len, Acc) -> <> end, <<"">>, [size(A) || A <- AData]), ArgBin = lists:foldr( fun(Arg, Acc) -> <> end, <<"">>, AData), {true, AData, <>}; _ -> ArgCnt = 0, {false, [], <>} 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}; %% %% AUTHENTICATION - request User %% %% Authentication as been initiated but no User was given %% so we need to request a 'User' to be supplied! %% process_packet(Socket, #wstate{state = ?INIT, key = Key} = State, #packet{msg = #start_authentication{ user = User } } = Req ) -> {ReplyMsg, NextState} = if (User == undefined) -> {?STATUS_GETUSER, ?GET_USER}; true -> {?STATUS_GETPASS, ?GET_PASS} end, ?debug("--- State=~p , NextState=~p , User=~p~n",[?INIT,NextState,User]), Flags = SrvMsgLen = DataLen = 0, ReplyBody = <>, 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 = NextState, user = User}; %% %% AUTHENTICATION - request Password %% %% Authentication as been initiated but no Password was given %% so we need to request a 'Password' to be supplied! %% process_packet(Socket, #wstate{state = ?GET_USER, key = Key} = State, #packet{msg = #authentication_continue{ user_msg = User } } = Req) -> Flags = SrvMsgLen = DataLen = 0, ReplyBody = <>, ?debug("--- State=~p , User=~p~n",[?GET_USER,User]), 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 = User}; %% %% AUTHENTICATION - finish %% %% Authentication are in progress, we have got a User and Password %% which will be tried against our DB; depending on the outcome, %% a PASS- or FAIL reply will be returned to the client. %% 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 = <>, 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, <>, Key, Version, <>), Body = obfuscate(UnhashedBody, PseudoPad), <>. %% %% Decode a packet (header + body) %% decode_packet(#wstate{key = Key} = State, <> % 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 = <>, PseudoPad = pseudo_pad(Length, <>, Key, Version, <>), 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(<>, <>) -> X = B bxor P, <>; obfuscate(<>, <>) -> X = B bxor P, Bin = obfuscate(Brest, Prest), <>. decode_body(#wstate{state = ?INIT}, ?AUTHORIZATION, % packet type <>) -> ?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]), [User0, Port, RemAddr | ArgsStrings] = Items, Args = parse_args_strings(ArgsStrings), User = if (UserLen == 0) -> undefined; true -> User0 end, #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 <>) -> ?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_USER}, ?AUTHENTICATION, % packet type <>) -> 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" " User = ~p~n" " Data = ~p~n", [UserMsg , Data]), #authentication_continue{state = ?GET_USER, user_msg = UserMsg, data = Data}; %% decode_body(#wstate{state = ?GET_PASS}, ?AUTHENTICATION, % packet type <>) -> 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) -> <> = 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, <>, 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 = <>, X = erlang:md5(Unhashed), Res = pseudo_pad(N, SessionId, SecretKey, Version, SeqNo, [X]), B = list_to_binary(lists:reverse(Res)), <> = B, Pad. pseudo_pad(0, _SessionId, _SecretKey, _Version, _SeqNo, Acc) -> Acc; pseudo_pad(N, SessionId, SecretKey, Version, SeqNo, [MDn_1|_] = Acc) -> X = erlang:md5(<>), 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.