Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/ssh/src/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ MODULES= \
ssh_tcpip_forward_acceptor_sup \
ssh_tcpip_forward_acceptor \
ssh_transport \
ssh_xfer
ssh_xfer \
ssh_event

HRL_FILES =

Expand Down
1 change: 1 addition & 0 deletions lib/ssh/src/ssh.app.src
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ssh_channel,
ssh_connection,
ssh_connection_handler,
ssh_event,
ssh_fsm_kexinit,
ssh_fsm_userauth_client,
ssh_fsm_userauth_server,
Expand Down
3 changes: 1 addition & 2 deletions lib/ssh/src/ssh.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -1313,7 +1313,6 @@ Experimental options that should not to be used in products.
-type mod_args() :: {Module::atom(), Args::list()} .
-type mod_fun_args() :: {Module::atom(), Function::atom(), Args::list()} .


%% Records
-record(address, {address,
port,
Expand Down Expand Up @@ -1469,6 +1468,6 @@ Experimental options that should not to be used in products.
(fun() ->
#{level := __Level} = logger:get_primary_config(),
__Fun(__Level)
end)()).
end)()).

-endif. % SSH_HRL defined
241 changes: 133 additions & 108 deletions lib/ssh/src/ssh_connection_handler.erl

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions lib/ssh/src/ssh_event.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
%%
%% %CopyrightBegin%
%%
%% SPDX-License-Identifier: Apache-2.0
%%
%% Copyright Ericsson AB 2026. All Rights Reserved.
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%
%% %CopyrightEnd%
%%
%%----------------------------------------------------------------------
%% Purpose: Support functionality for the event_funs option
%% ----------------------------------------------------------------------

-module(ssh_event).
-moduledoc false.

-export([message_received/2, connected/2, disconnected/2]).

-export([disconnectfun/1, connectfun/1]).

-include("ssh.hrl").
-include("ssh_connect.hrl").
-include("ssh_auth.hrl").
-include("ssh_transport.hrl").

-include("ssh_fsm.hrl").

message_received(Msg, D) ->
Context = #{msg_type => msg_type(Msg),
msg_type_name => element(1, Msg)
},
process_event(?FUNCTION_NAME, Context, D).

connected(Method, D) ->
Context = #{user_auth => Method},
%% TODO [AP-14]: Reject legacy callbacks (connectfun, disconnectfun)
%% at option validation time when event_funs is configured. Return
%% {error, {eoptions, ...}} to force users to migrate.
handle_legacy_fun(connectfun, ?FUNCTION_NAME, Context, D),
process_event(?FUNCTION_NAME, Context, D).

disconnected(Context, D) ->
handle_legacy_fun(disconnectfun, ?FUNCTION_NAME, Context, D),
process_event(?FUNCTION_NAME, Context, D).

process_event(Event, EventSpecificContext, D) ->
EventFuns = ?GET_OPT(event_funs, (D#data.ssh_params)#ssh.opts),
case maps:get(Event, EventFuns, undefined) of
undefined -> ok;
EventFun -> process_event(Event, EventSpecificContext, D, EventFun)
end.

process_event(Event, EventSpecificContext, D, EventFun) ->
ConnInfo = ssh_connection_handler:connection_info_server(D),
Role = (D#data.ssh_params)#ssh.role,
Context = EventSpecificContext#{connection_ref => self(),
connection_info => ConnInfo,
role => Role},
EventFun(Event, Context).

%% TODO [AP-10]: Skip when legacy fun is the default no-op to avoid
%% unnecessary connection_info_server/1 call and fun wrapping overhead.
handle_legacy_fun(FunName, Event, Context, D) ->
LegacyFun = ?GET_OPT(FunName, (D#data.ssh_params)#ssh.opts),
EventFun = ?MODULE:FunName(LegacyFun),
process_event(Event, Context, D, EventFun).

disconnectfun(Fun) ->
fun(disconnected, #{description := Desc}) ->
Fun(Desc)
end.

connectfun(Fun) ->
fun(connected, #{user_auth := Method, connection_info := ConnInfo}) ->
User = proplists:get_value(user, ConnInfo),
{_,Peer} = proplists:get_value(peer, ConnInfo),
Fun(User, Peer, Method)
end.


%%%================================================================
%%%
%%% msg_type/1 – given a message record, return its SSH packet type byte
%%%
%%% msg_record_tag/1 – given a packet type byte, return the record
%%% tag atom (or a list of candidates for shared
%%% type bytes).
%%%
%%% Note: several KEX record types share type bytes 30/31/32/33/34
%%% because the KEX family is chosen by algorithm negotiation, not
%%% by the type byte alone.

%% Transport layer (RFC 4253)
msg_type(#ssh_msg_disconnect{}) -> ?SSH_MSG_DISCONNECT; % 1
msg_type(#ssh_msg_ignore{}) -> ?SSH_MSG_IGNORE; % 2
msg_type(#ssh_msg_unimplemented{}) -> ?SSH_MSG_UNIMPLEMENTED; % 3
msg_type(#ssh_msg_debug{}) -> ?SSH_MSG_DEBUG; % 4
msg_type(#ssh_msg_service_request{}) -> ?SSH_MSG_SERVICE_REQUEST; % 5
msg_type(#ssh_msg_service_accept{}) -> ?SSH_MSG_SERVICE_ACCEPT; % 6
msg_type(#ssh_msg_ext_info{}) -> ?SSH_MSG_EXT_INFO; % 7
msg_type(#ssh_msg_kexinit{}) -> ?SSH_MSG_KEXINIT; % 20
msg_type(#ssh_msg_newkeys{}) -> ?SSH_MSG_NEWKEYS; % 21
%% KEX messages – all families reuse 30/31 (and 32/33/34 for GEX)
msg_type(#ssh_msg_kexdh_init{}) -> ?SSH_MSG_KEXDH_INIT; % 30
msg_type(#ssh_msg_kexdh_reply{}) -> ?SSH_MSG_KEXDH_REPLY; % 31
msg_type(#ssh_msg_kex_dh_gex_request_old{}) -> ?SSH_MSG_KEX_DH_GEX_REQUEST_OLD; % 30
msg_type(#ssh_msg_kex_dh_gex_group{}) -> ?SSH_MSG_KEX_DH_GEX_GROUP; % 31
msg_type(#ssh_msg_kex_dh_gex_init{}) -> ?SSH_MSG_KEX_DH_GEX_INIT; % 32
msg_type(#ssh_msg_kex_dh_gex_reply{}) -> ?SSH_MSG_KEX_DH_GEX_REPLY; % 33
msg_type(#ssh_msg_kex_dh_gex_request{}) -> ?SSH_MSG_KEX_DH_GEX_REQUEST;% 34
msg_type(#ssh_msg_kex_ecdh_init{}) -> ?SSH_MSG_KEX_ECDH_INIT; % 30
msg_type(#ssh_msg_kex_ecdh_reply{}) -> ?SSH_MSG_KEX_ECDH_REPLY; % 31
msg_type(#ssh_msg_kex_hybrid_init{}) -> ?SSH_MSG_KEX_HYBRID_INIT; % 30
msg_type(#ssh_msg_kex_hybrid_reply{}) -> ?SSH_MSG_KEX_HYBRID_REPLY; % 31
%% User-authentication layer (RFC 4252)
msg_type(#ssh_msg_userauth_request{}) -> ?SSH_MSG_USERAUTH_REQUEST; % 50
msg_type(#ssh_msg_userauth_failure{}) -> ?SSH_MSG_USERAUTH_FAILURE; % 51
msg_type(#ssh_msg_userauth_success{}) -> ?SSH_MSG_USERAUTH_SUCCESS; % 52
msg_type(#ssh_msg_userauth_banner{}) -> ?SSH_MSG_USERAUTH_BANNER; % 53
msg_type(#ssh_msg_userauth_pk_ok{}) -> ?SSH_MSG_USERAUTH_PK_OK; % 60
msg_type(#ssh_msg_userauth_passwd_changereq{}) -> ?SSH_MSG_USERAUTH_PASSWD_CHANGEREQ; % 60
msg_type(#ssh_msg_userauth_info_request{}) -> ?SSH_MSG_USERAUTH_INFO_REQUEST; % 60
msg_type(#ssh_msg_userauth_info_response{}) -> ?SSH_MSG_USERAUTH_INFO_RESPONSE; % 61
%% Connection layer (RFC 4254)
msg_type(#ssh_msg_global_request{}) -> ?SSH_MSG_GLOBAL_REQUEST; % 80
msg_type(#ssh_msg_request_success{}) -> ?SSH_MSG_REQUEST_SUCCESS; % 81
msg_type(#ssh_msg_request_failure{}) -> ?SSH_MSG_REQUEST_FAILURE; % 82
msg_type(#ssh_msg_channel_open{}) -> ?SSH_MSG_CHANNEL_OPEN; % 90
msg_type(#ssh_msg_channel_open_confirmation{}) -> ?SSH_MSG_CHANNEL_OPEN_CONFIRMATION; % 91
msg_type(#ssh_msg_channel_open_failure{}) -> ?SSH_MSG_CHANNEL_OPEN_FAILURE; % 92
msg_type(#ssh_msg_channel_window_adjust{}) -> ?SSH_MSG_CHANNEL_WINDOW_ADJUST; % 93
msg_type(#ssh_msg_channel_data{}) -> ?SSH_MSG_CHANNEL_DATA; % 94
msg_type(#ssh_msg_channel_extended_data{}) -> ?SSH_MSG_CHANNEL_EXTENDED_DATA; % 95
msg_type(#ssh_msg_channel_eof{}) -> ?SSH_MSG_CHANNEL_EOF; % 96
msg_type(#ssh_msg_channel_close{}) -> ?SSH_MSG_CHANNEL_CLOSE; % 97
msg_type(#ssh_msg_channel_request{}) -> ?SSH_MSG_CHANNEL_REQUEST; % 98
msg_type(#ssh_msg_channel_success{}) -> ?SSH_MSG_CHANNEL_SUCCESS; % 99
msg_type(#ssh_msg_channel_failure{}) -> ?SSH_MSG_CHANNEL_FAILURE. % 100
14 changes: 9 additions & 5 deletions lib/ssh/src/ssh_fsm.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,16 @@
%%====================================================================
%% Macros
%%====================================================================
-define(send_disconnect(Code, DetailedText, StateName, State),
ssh_connection_handler:send_disconnect(Code, DetailedText, ?MODULE, ?LINE, StateName, State)).

-define(send_disconnect(Code, Reason, DetailedText, StateName, State),
ssh_connection_handler:send_disconnect(Code, Reason, DetailedText, ?MODULE, ?LINE, StateName, State)).
-define(SEND_DISCONNECT(Code, Details, StateName, State),
ssh_connection_handler:send_disconnect(#{code => Code, state_name => StateName,
details => Details, module => ?MODULE, line => ?LINE},
State)).

-define(SEND_DISCONNECT(Code, Reason, Details, StateName, State),
ssh_connection_handler:send_disconnect(Reason,
#{code => Code, state_name => StateName,
details => Details, module => ?MODULE, line => ?LINE},
State)).

-define(CALL_FUN(Key,D), catch (?GET_OPT(Key, (D#data.ssh_params)#ssh.opts)) ).

Expand Down
11 changes: 5 additions & 6 deletions lib/ssh/src/ssh_fsm_kexinit.erl
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,9 @@ callback_mode() ->
state_enter].

%%--------------------------------------------------------------------


handle_event(Type, Event = prepare_next_packet, StateName, D) ->
ssh_connection_handler:handle_event(Type, Event, StateName, D);
handle_event(Type, Event = {send_disconnect, _, _, _, _}, StateName, D) ->
handle_event(Type, Event = {send_disconnect, _}, StateName, D) ->
ssh_connection_handler:handle_event(Type, Event, StateName, D);

%%% ######## {kexinit, client|server, init|renegotiate} ####
Expand Down Expand Up @@ -172,9 +170,10 @@ handle_event(internal, _Event, {key_exchange,_Role,init},
#data{ssh_params = #ssh{algorithms = #alg{kex_strict_negotiated = true},
send_sequence = SendSeq,
recv_sequence = RecvSeq}}) ->
?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED,
io_lib:format("KEX strict violation: send_sequence = ~p recv_sequence = ~p",
[SendSeq, RecvSeq]));
Details =
io_lib:format("KEX strict violation: send_sequence = ~p recv_sequence = ~p",
[SendSeq, RecvSeq]),
?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Details);

%%% ######## {key_exchange_dh_gex_init, server, init|renegotiate} ####
handle_event(internal, #ssh_msg_kex_dh_gex_init{} = Msg, {key_exchange_dh_gex_init,server,ReNeg}, D) ->
Expand Down
18 changes: 8 additions & 10 deletions lib/ssh/src/ssh_fsm_userauth_client.erl
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,9 @@ handle_event(internal, #ssh_msg_userauth_success{}, {userauth,client}, D0=#data{
%%---- userauth failure response to clientfrom the server
handle_event(internal, #ssh_msg_userauth_failure{}, {userauth,client}=StateName,
#data{ssh_params = #ssh{userauth_methods = []}} = D0) ->
Details = io_lib:format("User auth failed for: ~p",[D0#data.auth_user]),
{Shutdown, D} =
?send_disconnect(?SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE,
io_lib:format("User auth failed for: ~p",[D0#data.auth_user]),
StateName, D0),
?SEND_DISCONNECT(?SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE, Details, StateName, D0),
{stop, Shutdown, D};

handle_event(internal, #ssh_msg_userauth_failure{authentications = Methods}, StateName={userauth,client},
Expand All @@ -92,16 +91,15 @@ handle_event(internal, #ssh_msg_userauth_failure{authentications = Methods}, Sta
none ->
%% Server tells us which authentication methods that are allowed
Ssh0#ssh{userauth_methods = string:tokens(Methods, ",")};
_ ->
%% We already know...
Ssh0
end,
_ ->
%% We already know...
Ssh0
end,
case ssh_auth:userauth_request_msg(Ssh1) of
{send_disconnect, Code, Ssh} ->
Details = io_lib:format("User auth failed for: ~p",[D0#data.auth_user]),
{Shutdown, D} =
?send_disconnect(Code,
io_lib:format("User auth failed for: ~p",[D0#data.auth_user]),
StateName, D0#data{ssh_params = Ssh}),
?SEND_DISCONNECT(Code, Details, StateName, D0#data{ssh_params = Ssh}),
{stop, Shutdown, D};
{"keyboard-interactive", {Msg, Ssh}} ->
D = ssh_connection_handler:send_msg(Msg, D0#data{ssh_params = Ssh}),
Expand Down
22 changes: 9 additions & 13 deletions lib/ssh/src/ssh_fsm_userauth_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,9 @@ handle_event(internal,
%% {ServiceName, Expected, Method} when Expected =/= ServiceName -> Do what?

{ServiceName, _, _} when ServiceName =/= "ssh-connection" ->
{Shutdown, D} =
?send_disconnect(?SSH_DISCONNECT_SERVICE_NOT_AVAILABLE,
io_lib:format("Unknown service: ~p",[ServiceName]),
StateName, D1),
Details = io_lib:format("Unknown service: ~p",[ServiceName]),
{Shutdown, D} =
?SEND_DISCONNECT(?SSH_DISCONNECT_SERVICE_NOT_AVAILABLE, Details, StateName, D1),
{stop, Shutdown, D}
end;

Expand Down Expand Up @@ -174,11 +173,12 @@ connected_state(Reply, Ssh1, User, Method, D0) ->
D1 = #data{ssh_params=Ssh} =
ssh_connection_handler:send_msg(Reply, D0#data{ssh_params = Ssh1}),
ssh_connection_handler:handshake(ssh_connected, D1),
connected_fun(User, Method, D1),
D1#data{auth_user=User,
%% Note: authenticated=true MUST NOT be sent
%% before send_msg!
ssh_params = Ssh#ssh{authenticated = true}}.
D = D1#data{auth_user=User,
%% Note: authenticated=true MUST NOT be sent
%% before send_msg!
ssh_params = Ssh#ssh{authenticated = true}},
ssh_event:connected(Method, D),
D.

set_alive_timeout(#data{ssh_params = #ssh{opts=Opts}}) ->
{_AliveCount, AliveInterval} = ?GET_ALIVE_OPT(Opts),
Expand All @@ -187,10 +187,6 @@ set_alive_timeout(#data{ssh_params = #ssh{opts=Opts}}) ->
set_max_initial_idle_timeout(#data{ssh_params = #ssh{opts=Opts}}) ->
{{timeout,max_initial_idle_time}, ?GET_OPT(max_initial_idle_time,Opts), none}.

connected_fun(User, Method, #data{ssh_params = #ssh{peer = {_,Peer}}} = D) ->
?CALL_FUN(connectfun,D)(User, Peer, Method).


retry_fun(_, undefined, _) ->
ok;
retry_fun(User, Reason, #data{ssh_params = #ssh{opts = Opts,
Expand Down
23 changes: 23 additions & 0 deletions lib/ssh/src/ssh_options.erl
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@
option_key() => any()
}.

%%%================================================================
%%% Macros

%% TODO: Add message_sent event when send_msg is wired to ssh_event
-define(EVENT_FUNS_DEFAULT, #{connected => fun(_,_) -> void end,
disconnected => fun(_,_) -> void end,
message_received => fun(_,_) -> void end}).

%%%================================================================
%%%
%%% Get an option
Expand Down Expand Up @@ -823,6 +831,21 @@ default(common) ->
class => user_option
},

event_funs =>
#{default => ?EVENT_FUNS_DEFAULT,
chk => fun(V0) when is_map(V0) ->
V = maps:merge(?EVENT_FUNS_DEFAULT, V0),
lists:all(fun({K, F}) ->
lists:member(K, [connected,
disconnected,
message_received]) andalso
check_function2(F)
end, maps:to_list(V));
(_) -> false
end,
class => user_option
},

unexpectedfun =>
#{default => fun(_,_) -> report end,
chk => fun(V) -> check_function2(V) end,
Expand Down
Loading
Loading