%-*-Mode:erlang;coding:utf-8;tab-width:4;c-basic-offset:4;indent-tabs-mode:()-*-
% ex: set ft=erlang fenc=utf-8 sts=4 ts=4 sw=4 et nomod:
%%%
%%%------------------------------------------------------------------------
%%% @doc
%%% ==CloudI Router Service SSH Client==
%%% @end
%%%
%%% MIT License
%%%
%%% Copyright (c) 2018-2020 Michael Truog <mjtruog at protonmail dot com>
%%%
%%% Permission is hereby granted, free of charge, to any person obtaining a
%%% copy of this software and associated documentation files (the "Software"),
%%% to deal in the Software without restriction, including without limitation
%%% the rights to use, copy, modify, merge, publish, distribute, sublicense,
%%% and/or sell copies of the Software, and to permit persons to whom the
%%% Software is furnished to do so, subject to the following conditions:
%%%
%%% The above copyright notice and this permission notice shall be included in
%%% all copies or substantial portions of the Software.
%%%
%%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
%%% IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
%%% FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
%%% AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
%%% LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
%%% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
%%% DEALINGS IN THE SOFTWARE.
%%%
%%% @author Michael Truog <mjtruog at protonmail dot com>
%%% @copyright 2018-2021 Michael Truog
%%% @version 2.0.2 {@date} {@time}
%%%------------------------------------------------------------------------

-module(cloudi_service_router_ssh_client).
-author('mjtruog at protonmail dot com').

-behaviour(gen_server).

%% external interface
-export([forward/11,
         new/3]).

%% internal callbacks
-export([silently_accept_hosts/3]).

%% gen_server callbacks
-export([init/1,
         handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).

-include_lib("cloudi_core/include/cloudi_logger.hrl").
-include("cloudi_service_router_ssh.hrl").

-define(DEFAULT_HOST_NAME,              undefined).
-define(DEFAULT_USER,                   "${USER}").
-define(DEFAULT_PASSWORD,                      "").
-define(DEFAULT_DSA_PASSPHRASE,         undefined).
-define(DEFAULT_RSA_PASSPHRASE,         undefined).
-define(DEFAULT_ECDSA_PASSPHRASE,       undefined).

-type connection_handle() :: {ssh:connection_ref(),
                              ssh:channel_id()}.

-record(ssh_client,
    {
        process :: pid()
    }).

-record(ssh_client_connection,
    {
        host_name :: string(),
        port :: pos_integer(),
        options :: list(),
        compression :: 0..9,
        handle = undefined :: undefined | connection_handle()
    }).

-type state() :: #ssh_client{}.
-type options() :: list({host_name, string()} |
                        {port, pos_integer()} |
                        {inet, inet | inet6 | undefined} |
                        {user_dir, string()} |
                        {system_dir, string()}).
-export_type([state/0,
              options/0]).

-define(TIMEOUT_DELTA, 100). % milliseconds
-define(TIMEOUT_SEND_MIN, 100). % milliseconds (>= FORWARD_DELTA)
-define(TIMEOUT_RECONNECT_MIN, 5000). % milliseconds

-ifdef(OTP_RELEASE).
% able to use -if/-elif here
-if(?OTP_RELEASE >= 24).
-define(ERLANG_OTP_VERSION_24_FEATURES, true).
-endif.
-endif.

%%%------------------------------------------------------------------------
%%% External interface functions
%%%------------------------------------------------------------------------

-spec forward(RequestType :: cloudi_service:request_type(),
              Name :: cloudi_service:service_name(),
              Pattern :: cloudi_service:service_name_pattern(),
              NewName :: cloudi_service:service_name(),
              RequestInfo :: cloudi_service:request_info(),
              Request :: cloudi_service:request(),
              Timeout :: cloudi_service:timeout_value_milliseconds(),
              Priority :: cloudi_service:priority_value(),
              TransId :: cloudi_service:trans_id(),
              Source :: cloudi_service:source(),
              State :: state()) ->
    ok | timeout.

forward(RequestType, Name, Pattern, NewName, RequestInfo, Request,
        Timeout, Priority, TransId, Source,
        #ssh_client{process = Pid}) ->
    try gen_server:call(Pid,
                        {forward,
                         RequestType, Name, Pattern, NewName,
                         RequestInfo, Request,
                         Timeout, Priority, TransId, Source},
                        Timeout + ?TIMEOUT_DELTA)
    catch
        exit:{_, _} ->
            timeout
    end.

-spec new(Options :: options(),
          Environment :: cloudi_environment:lookup(),
          SSH :: cloudi_service_router_ssh_server:state() | undefined) ->
    state().

new(Options, Environment, SSH)
    when is_list(Options) ->
    Defaults = [
        {host_name,                     ?DEFAULT_HOST_NAME},
        {port,
         cloudi_service_router_ssh_server:config_port(SSH)},
        {inet,
         cloudi_service_router_ssh_server:config_inet(SSH)},
        {compression,
         cloudi_service_router_ssh_server:config_compression(SSH)},
        {user_dir,
         cloudi_service_router_ssh_server:config_user_dir(SSH)},
        {system_dir,
         cloudi_service_router_ssh_server:config_system_dir(SSH)},
        {user,                          ?DEFAULT_USER},
        {password,                      ?DEFAULT_PASSWORD},
        {dsa_passphrase,                ?DEFAULT_DSA_PASSPHRASE},
        {rsa_passphrase,                ?DEFAULT_RSA_PASSPHRASE},
        {ecdsa_passphrase,              ?DEFAULT_ECDSA_PASSPHRASE}],
    [HostNameRaw, Port, Inet, Compression, UserDirRaw, SystemDirRaw,
     UserRaw, PasswordRaw, DSAPassphraseRaw, RSAPassphraseRaw,
     ECDSAPassphraseRaw] = cloudi_proplists:take_values(Defaults, Options),
    true = is_list(HostNameRaw) andalso is_integer(hd(HostNameRaw)),
    true = is_integer(Port) andalso (Port > 0),
    true = is_integer(Compression) andalso
           (Compression >= 0) andalso (Compression =< 9),
    true = is_list(UserDirRaw) andalso is_integer(hd(UserDirRaw)),
    true = is_list(SystemDirRaw) andalso is_integer(hd(SystemDirRaw)),
    true = is_list(UserRaw) andalso is_integer(hd(UserRaw)),
    true = is_list(PasswordRaw),
    HostName = cloudi_environment:transform(HostNameRaw, Environment),
    UserDir = cloudi_environment:transform(UserDirRaw, Environment),
    SystemDir = cloudi_environment:transform(SystemDirRaw, Environment),
    User = cloudi_environment:transform(UserRaw, Environment),
    Password = cloudi_environment:transform(PasswordRaw, Environment),
    ClientOptions0 = if
        Inet =:= inet; Inet =:= inet6 ->
            [{inet, Inet}];
        Inet =:= undefined ->
            []
    end,
    ClientOptions1 = if
        DSAPassphraseRaw =:= undefined ->
            ClientOptions0;
        is_list(DSAPassphraseRaw) andalso
        is_integer(hd(DSAPassphraseRaw)) ->
            [{dsa_pass_phrase,
              cloudi_environment:transform(DSAPassphraseRaw, Environment)} |
             ClientOptions0]
    end,
    ClientOptions2 = if
        RSAPassphraseRaw =:= undefined ->
            ClientOptions1;
        is_list(RSAPassphraseRaw) andalso
        is_integer(hd(RSAPassphraseRaw)) ->
            [{rsa_pass_phrase,
              cloudi_environment:transform(RSAPassphraseRaw, Environment)} |
             ClientOptions1]
    end,
    ClientOptions3 = if
        ECDSAPassphraseRaw =:= undefined ->
            ClientOptions2;
        is_list(ECDSAPassphraseRaw) andalso
        is_integer(hd(ECDSAPassphraseRaw)) ->
            [{ecdsa_pass_phrase,
              cloudi_environment:transform(ECDSAPassphraseRaw, Environment)} |
             ClientOptions2]
    end,
    SilentlyAcceptHostsF = fun(PeerName, FingerPrint) ->
        ?MODULE:silently_accept_hosts(PeerName, FingerPrint, SystemDir)
    end,
    ClientOptionsN = [{user_interaction, false},
                      {user_dir, UserDir},
                      {silently_accept_hosts, SilentlyAcceptHostsF},
                      {user, User},
                      {password, Password} | ClientOptions3],

    % The ssh application and its dependencies are only started
    % if they are necessary for the cloudi_service_router processes
    ok = ssh:start(),

    {ok, Pid} = gen_server:start_link(?MODULE,
                                      [HostName, Port, ClientOptionsN,
                                       Compression], []),
    #ssh_client{process = Pid}.

%%%------------------------------------------------------------------------
%%% Callback functions used internally
%%%------------------------------------------------------------------------

-spec silently_accept_hosts(PeerName :: string(),
                            FingerPrint :: string(),
                            SystemDir :: string()) ->
    boolean().

-ifdef(ERLANG_OTP_VERSION_24_FEATURES).
silently_accept_hosts(_PeerName, FingerPrint, SystemDir) ->
    KeyFileName = filename:join(SystemDir, "ssh_host_rsa_key.pub"),
    case filelib:is_file(KeyFileName) of
        true ->
            {ok, KeyData} = file:read_file(KeyFileName),
            [{Key, _}] = ssh_file:decode(KeyData, public_key),
            ssh:hostkey_fingerprint(Key) == FingerPrint;
        false ->
            false
    end.
-else.
silently_accept_hosts(_PeerName, FingerPrint, SystemDir) ->
    KeyFileName = filename:join(SystemDir, "ssh_host_rsa_key.pub"),
    case filelib:is_file(KeyFileName) of
        true ->
            {ok, KeyData} = file:read_file(KeyFileName),
            [{Key, _}] = public_key:ssh_decode(KeyData, public_key),
            public_key:ssh_hostkey_fingerprint(Key) == FingerPrint;
        false ->
            false
    end.
-endif.

%%%------------------------------------------------------------------------
%%% Callback functions from gen_server
%%%------------------------------------------------------------------------

init([HostName, Port, ClientOptions, Compression]) ->
    {ok, #ssh_client_connection{host_name = HostName,
                                port = Port,
                                options = ClientOptions,
                                compression = Compression}}.

handle_call({forward,
             RequestType, Name, Pattern, NewName, RequestInfo, Request,
             Timeout, Priority, TransId, Source}, _, State) ->
    {Reply,
     NewState} = handle_forward(RequestType, Name, Pattern, NewName,
                                RequestInfo, Request,
                                Timeout, Priority, TransId, Source, State),
    {reply, Reply, NewState};
handle_call(Request, _, State) ->
    {stop, cloudi_string:format("Unknown call \"~w\"", [Request]), State}.

handle_cast(Request, State) ->
    {stop, cloudi_string:format("Unknown cast \"~w\"", [Request]), State}.

handle_info({ssh_cm, Connection,
             {closed, ChannelId}},
            #ssh_client_connection{handle = {Connection, ChannelId}} = State) ->
    {noreply, State#ssh_client_connection{handle = undefined}};
handle_info({ssh_cm, Connection,
             {data, ChannelId, 0, DataIn}},
            #ssh_client_connection{handle = {Connection, ChannelId}} = State) ->
    try erlang:binary_to_term(DataIn) of
        {ReturnType,
         _NewName, _NewPattern, _ResponseInfo, _Response,
         _NewTimeout, _TransId, Source} = DataInDecoded
        when ReturnType =:= 'cloudi_service_return_async' orelse
             ReturnType =:= 'cloudi_service_return_sync' ->
            Source ! DataInDecoded,
            ssh_connection:adjust_window(Connection, ChannelId,
                                         byte_size(DataIn)),
            {noreply, State}
    catch
        error:badarg ->
            ssh_connection:close(Connection, ChannelId),
            ?LOG_ERROR("received invalid data, connection closed", []),
            {noreply, State#ssh_client_connection{handle = undefined}}
    end;
handle_info({ssh_cm, Connection,
             {data, ChannelId, 1, DataIn}},
            #ssh_client_connection{handle = {Connection, ChannelId}} = State) ->
    ?LOG_ERROR("~s", [DataIn]),
    {noreply, State};
handle_info({ssh_cm, _, _}, State) ->
    {noreply, State};
handle_info(Request, State) ->
    {stop, cloudi_string:format("Unknown info \"~w\"", [Request]), State}.

terminate(_, #ssh_client_connection{handle = ConnectionHandle}) ->
    case ConnectionHandle of
        {Connection, ChannelId} ->
            ssh_connection:close(Connection, ChannelId);
        undefined ->
            ok
    end,
    ok.

code_change(_, State, _) ->
    {ok, State}.

%%%------------------------------------------------------------------------
%%% Private functions
%%%------------------------------------------------------------------------

handle_forward(_, _, _, _, _, _, Timeout, _, _, _, State)
    when Timeout < ?TIMEOUT_SEND_MIN ->
    {timeout, State};
handle_forward(RequestType, Name, Pattern, NewName, RequestInfo, Request,
               Timeout, Priority, TransId, Source,
               #ssh_client_connection{
                   host_name = HostName,
                   port = Port,
                   options = Options,
                   handle = undefined} = State) ->
    ConnectTimeStart = cloudi_timestamp:milliseconds_monotonic(),
    case ssh:connect(HostName, Port, Options) of
        {ok, Connection} ->
            case ssh_connection:session_channel(Connection, infinity) of
                {ok, ChannelId} ->
                    Subsystem = ?SSH_SUBSYSTEM,
                    case ssh_connection:subsystem(Connection, ChannelId,
                                                  Subsystem, infinity) of
                        success ->
                            ConnectTime =
                                cloudi_timestamp:milliseconds_monotonic() - 
                                ConnectTimeStart,
                            NewTimeout = if
                                ConnectTime >= Timeout ->
                                    0;
                                true ->
                                    Timeout - ConnectTime
                            end,
                            ConnectionHandle = {Connection, ChannelId},
                            handle_forward(RequestType, Name, Pattern, NewName,
                                           RequestInfo, Request,
                                           NewTimeout, Priority,
                                           TransId, Source,
                                           State#ssh_client_connection{
                                               handle = ConnectionHandle});
                        failure ->
                            ?LOG_DEBUG("subsystem(~s) ~s:~w failure",
                                       [Subsystem, HostName, Port]),
                            ok = ssh_connection:close(Connection, ChannelId),
                            {timeout, State};
                        {error, Reason} ->
                            ?LOG_DEBUG("subsystem(~s) ~s:~w error: ~w",
                                       [Subsystem, HostName, Port, Reason]),
                            ok = ssh_connection:close(Connection, ChannelId),
                            {timeout, State}
                    end;
                {error, Reason} ->
                    ok = ssh:close(Connection),
                    ?LOG_DEBUG("channel ~s:~w error: ~w",
                               [HostName, Port, Reason]),
                    {timeout, State}
            end;
        {error, Reason} ->
            ?LOG_DEBUG("connect ~s:~w error: ~w",
                       [HostName, Port, Reason]),
            {timeout, State}
    end;
handle_forward(RequestType, Name, Pattern, NewName, RequestInfo, Request,
               Timeout, Priority, TransId, Source,
               #ssh_client_connection{
                   compression = Compression,
                   handle = {Connection, ChannelId}} = State) ->
    ForwardType = if
        RequestType =:= 'send_async' ->
            'cloudi_service_forward_async_retry';
        RequestType =:= 'send_sync' ->
            'cloudi_service_forward_sync_retry'
    end,
    DataOutDecoded = {ForwardType, Name, Pattern, NewName,
                      RequestInfo, Request,
                      Timeout, Priority, TransId, Source},
    DataOut = erlang:term_to_binary(DataOutDecoded,
                                    [{compressed, Compression}]),
    case ssh_connection:send(Connection, ChannelId,
                             DataOut, Timeout) of
        ok ->
            {ok, State};
        {error, timeout} ->
            NewState = if
                Timeout >= ?TIMEOUT_RECONNECT_MIN ->
                    ok = ssh_connection:close(Connection, ChannelId),
                    State#ssh_client_connection{handle = undefined};
                true ->
                    State
            end,
            {timeout, NewState};
        {error, closed} ->
            {timeout, State#ssh_client_connection{handle = undefined}}
    end.

