/* Part of SWI-Prolog Author: Jan Wielemaker E-mail: J.Wielemaker@vu.nl WWW: http://www.swi-prolog.org Copyright (c) 2019-2023, VU University Amsterdam SWI-Prolog Solutions b.v. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ :- module(ssh_server, [ ssh_server/0, ssh_server/1, % +Options capture_messages/1 % +Level ]). :- use_module(library(debug)). :- use_module(library(option)). :- use_module(library(settings)). :- use_foreign_library(foreign(sshd4pl)). /** Embedded SSH server This module defines an embedded SSH server for SWI-Prolog on top of [libssh](https://libssh.org). This module allows for a safe secondary access point to a running Prolog process. A typical use case is to provide a safe channal or inspection and maintenance of servers or embedded Prolog instances. If possible, a _login_ to the Prolog process uses a _pseudo terminal_ to realise normal terminal interaction, including processing of ^C to interrupt running queries. If `libedit` (editline) is used as the command line editor this is installed (see el_wrap/0), providing advanced command line editing and history. The library currently support _login_ to the Prolog process. Future versions may also use the client access and exploit the SSH subsystem interface to achieve safe interaction between Prolog peers. ## The client session A new connection creates a Prolog thread that handles the connection. The new thread's standard streams (`user_input`, `user_output`, `user_error`, `current_input` and `current_output`) are attached to the new connection. Some of the environment is shared as Prolog flags. The following flags are defined: - ssh_tty Provides the name of the _pseudo terminal_ if such a terminal us allocated for this connection. - ssh_term Provides the ``TERM`` environment variable passed from the client. - ssh_user Provides the name of the user logged on. If a _pseudo terminal_ is used and the `ssh_term` flag is not `dump`, library(ansi_term) is connected to provide colorized output. If a _pseudo terminal_ is used and library(editline) is available, this library is used to enable command line editing. ## Executing commands Using ``ssh ``, ```` is executed without a terminal (unless the ``-t`` option is given to `ssh` to force a terminal) and otherwise as a single Prolog toplevel command. For example: ``` ssh -p 2020 localhost "writeln('Hello world')" Hello world true. ``` If the query is nondeterministic alternative answers can be requested in the same way as using the interactive toplevel. The exit code is defined as follows: - 0 The query succeeded - 1 The query failed - 2 The query produced an exception (the system prints a backtrace) - 3 The query itself was not syntactically correct. ### Aborting the server If a Prolor process with an embedded ssh server misbehaves it can be forcefully aborted using the `abort` command. This calls C `abort()` as soon as possible and thus should function even if Prolog is, for example, stuck in a deadlock. ssh -p 2020 localhost abort @tbd Currently only supports Unix. A Windows port is probably doable. It mostly requires finding a sensible replacement for the Unix pseudo terminal. @tbd Implement running other commands than the Prolog toplevel. */ :- multifile verify_password/3. % +ServerName, +User, +Password :- predicate_options( ssh_server/1, 1, [ name(atom), port(integer), bind_address(atom), host_key_file(atom), authorized_keys_file(atom), auth_methods(list(oneof([password,public_key]))) ]). :- setting(port, positive_integer, 2020, "Default port for SWI-Prolog SSH server"). :- setting(color_term, boolean, true, "Enable ANSI color output on SSH terminal"). %! ssh_server is det. %! ssh_server(+PortOrOptions) is det. % % Create an embedded SSH server in the current Prolog process. If the % argument is an integer it is interpreted as % ssh_server([port(Integer)]). Options: % % - name(+Atom) % Name the server. Passed as first argument to verify_password/3 % to identify multiple servers. % - port(+Integer) % Port to listen on. Default is 2020. % - bind_address(+Name) % Interface to listen to. Default is `localhost`. Use `*` % to grant acccess from all network interfaces. % - host_key_file(+File) % % File name for the host private key. If omitted it searches for % `etc/ssh` below the current directory and user_app_config('etc/ssh') % (normally ``~/.config/swi-prolog/etc/ssh``). On failure it % creates, a directory `etc/ssh` with default host keys and uses % these. % - auth_methods(+ListOfMethod) % Set allowed authentication methods. ListOfMethod is a list of % - password % Allow password login (see verify_password/3) % - public_key % Allow key based login (see `authorized_keys_file` below) % The default is derived from the `authorized_keys_file` option % and whether or not verify_password/3 is defined. % - authorized_keys_file(+File) % File name for a file holding the public keys for users that % are allows to login. Activates auth_methods([public_key]). % This file is in OpenSSH format and contains a certificate % per line in the format % % % % The the file `~/.ssh/authorized_keys` is present, this will % be used as default, granting anyone with access to this account % to access the server with the same keys. If the option is % present with value `[]` (empty list), no key file is used. ssh_server :- ssh_server([]). ssh_server(Port) :- integer(Port), !, ssh_server([port(Port)]). ssh_server(Options) :- setting(port, DefPort), merge_options(Options, [ port(DefPort), bind_address(localhost) ], Options1), ( option(name(Name), Options) -> Alias = Name ; option(port(Port), Options1), format(atom(Alias), 'sshd@~w', [Port]) ), ensure_host_keys(Options1, Options2), add_authorized_keys(Options2, Options3), add_auth_methods(Options3, Options4), setup_signals(Options4), thread_create(ssh_server_nt(Options4), _, [ alias(Alias), detached(true) ]). %! ensure_host_keys(+Options0, -Options) is det. % % Provide a host key: % % 1. If the key file is given, use it. % 2. If there is a key in `etc/ssh`, use it. % 3. If there is a key in user_app_config('etc/ssh'), use it. % 4. Try to create a key in user_app_config('etc/ssh') % 5. Try to create a key in `etc/ssh` ensure_host_keys(Options, Options) :- option(host_key_file(KeyFile), Options), !, ( access_file(KeyFile, read) -> true ; permission_error(read, ssh_host_key_file, KeyFile) ). ensure_host_keys(Options0, Options) :- exists_file('etc/ssh/ssh_host_ecdsa_key'), !, Options = [host_key_file('etc/ssh/ssh_host_ecdsa_key')|Options0]. ensure_host_keys(Options0, Options) :- absolute_file_name(user_app_config('etc/ssh'), Dir, [ file_type(directory), access(exist), file_errors(fail) ]), !, directory_file_path(Dir, ssh_host_ecdsa_key, KeyFile), Options = [host_key_file(KeyFile)|Options0]. ensure_host_keys(Options0, Options) :- absolute_file_name(user_app_config('etc/ssh'), Dir, [ solutions(all), file_errors(fail) ]), Error = error(_,_), catch(make_directory_path(Dir), Error, fail), file_directory_name(Dir, P0), file_directory_name(P0, ConfigDir), format(string(KeyCmd), 'ssh-keygen -A -f ~w', [ConfigDir]), print_message(informational, ssh_server(create_host_keys(Dir))), shell(KeyCmd), !, directory_file_path(Dir, ssh_host_ecdsa_key, KeyFile), Options = [host_key_file(KeyFile)|Options0]. ensure_host_keys(Options, [ host_key_file('etc/ssh/ssh_host_ecdsa_key') | Options ]) :- print_message(informational, ssh_server(create_host_keys('etc/ssh'))), make_directory_path('etc/ssh'), shell('ssh-keygen -A -f .'). add_auth_methods(Options, Options) :- option(auth_methods(_), Options), !. add_auth_methods(Options, [auth_methods(Methods)|Options]) :- findall(Method, option_auth_method(Options, Method), Methods). option_auth_method(Options, public_key) :- option(authorized_keys_file(_), Options). option_auth_method(_Options, password) :- predicate_property(verify_password(_,_,_), number_of_clauses(N)), N > 0. add_authorized_keys(Options0, Options) :- option(authorized_keys_file(AuthKeysFile), Options0), !, ( AuthKeysFile == [] -> select_option(authorized_keys_file(AuthKeysFile), Options0, Options) ; Options = Options0 ). add_authorized_keys(Options, [authorized_keys_file(AuthKeysFile)|Options]) :- expand_file_name('~/.ssh/authorized_keys', [AuthKeysFile]), access_file(AuthKeysFile, read), !. add_authorized_keys(Options, Options). %! setup_signals(+Options) % % Re-installs the `int` signal to start the debugger. Notably % library(http/http_unix_daemon) binds this to terminates the process. setup_signals(_Options) :- E = error(_,_), catch(on_signal(int, _, debug), E, print_message(warning, E)). %! run_client(+Server, +In, +Out, +Err, +Command, -RetCode) is det. % % Run Command using I/O from the triple and bind % RetCode to the ssh shell return code. :- public run_client/6. run_client(Server, In, Out, Err, Command, RetCode) :- set_alias, setup_console(Server, In, Out, Err, Cleanup), call_cleanup(ssh_toplevel(Command, RetCode), shutdown_console(Cleanup)). :- if(current_predicate(thread_alias/1)). set_alias :- current_prolog_flag(ssh_user, User), thread_self(Me), thread_property(Me, id(Id)), format(atom(Alias), '~w@ssh/~w', [User, Id]), thread_alias(Alias). :- endif. set_alias. % Used by has_console/0 in thread_util. :- dynamic thread_util:has_console/4. setup_console(Server, In, Out, Err, clean(Me, Cleanup)) :- thread_self(Me), assertz(thread_util:has_console(Me, In, Out, Err)), set_stream(In, alias(user_input)), set_stream(Out, alias(user_output)), set_stream(Err, alias(user_error)), set_stream(In, alias(current_input)), set_stream(Out, alias(current_output)), enable_colors, enable_line_editing(Mode), load_history(Mode, Server, Cleanup). shutdown_console(clean(TID, History)) :- retractall(thread_util:has_console(TID, _In, _Out, _Err)), save_history(History), disable_line_editing. :- if(setting(color_term, true)). :- use_module(library(ansi_term)). :- endif. %! enable_colors is det. % % Enable ANSI colors on the remote shell. This is controlled by the % setting `color_term`. Note that we do not wish to inherit this as % the server may have different preferences. enable_colors :- stream_property(user_input, tty(true)), setting(color_term, true), current_prolog_flag(ssh_term, Term), Term \== dump, !, set_prolog_flag(color_term, true). enable_colors :- set_prolog_flag(color_term, false). %! enable_line_editing is det. % % Enable line editing for the SSH session. We can do this if the SSH % session uses a pseudo terminal and we are using library(editline) as % command line editor (GNU readline uses global variables and thus can % only handle a single tty in the process). use_editline :- exists_source(library(editline)), ( current_prolog_flag(readline, editline) -> true ; \+ current_prolog_flag(readline, _) ). :- if(use_editline). :- use_module(library(editline)). enable_line_editing(editline) :- stream_property(user_input, tty(true)), !, debug(ssh(server), 'Setting up line editing', []), set_prolog_flag(tty_control, true), el_wrap. :- else. enable_line_editing(tty) :- stream_property(user_input, tty(true)), !, set_prolog_flag(tty_control, true). :- endif. enable_line_editing(none) :- set_prolog_flag(tty_control, false). :- if(current_predicate(el_unwrap/1)). disable_line_editing :- el_wrapped(user_input), !, Error = error(_,_), catch(el_unwrap(user_input), Error, true). :- endif. disable_line_editing. %! verify_password(+ServerName, +User:atom, +Passwd:string) is semidet. % % Hook that can be used to accept password based logins. This % predicate must succeeds to accept the User/Passwd combination. % % @arg ServerName is the name provided with the name(Name) option when % creating the server or the empty list. /******************************* * HISTORY * *******************************/ :- multifile prolog:history/2. %! load_history(+EditMode, +Server, -Cleanup) is det. % % Load command line history for Server, binding Cleanup to the % required command for save_history/1 load_history(editline, Server, save(File)) :- history_file(Server, File, [ access(read), file_errors(fail) ]), !, prolog:history(user_input, load(File)). load_history(editline, Server, create(Server)) :- !. load_history(_, _, nosave). %! save_history(+Action) is det. % % Save the history information according to action. save_history(save(File)) :- catch(write_history(File), _, true), !. save_history(create(Server)) :- history_file(Server, File, [ file_errors(fail), solutions(all) ]), catch(write_history(File), _, true), !. save_history(_). write_history(File) :- file_directory_name(File, Dir), make_directory_path(Dir), prolog:history(user_input, save(File)). history_file(Server, Path, Options) :- ( Server == [] -> SName = default ; SName = Server ), current_prolog_flag(ssh_user, User), atomic_list_concat([ssh, history, SName, User], /, File), absolute_file_name(user_app_config(File), Path, Options). %! ssh_toplevel(+Command, -RetCode) % % Run the toplevel goal for the SSH session. The default is `prolog`, % running the toplevel. Otherwise the argument is processed as a % single toplevel goal. ssh_toplevel(prolog, 0) :- !, version, prolog. ssh_toplevel(Command, RetCode) :- catch(term_string(Query, Command, [variable_names(Bindings)]), Error, true), ( var(Error) -> catch_with_backtrace('$execute_query'(Query, Bindings, Truth), E2, true), toplevel_finish(Truth, E2, RetCode) ; print_message(error, Error), RetCode = 3 ). toplevel_finish(_, Error, 2) :- nonvar(Error), !, print_message(error, Error). toplevel_finish(true, _, 0). toplevel_finish(false, _, 1). /******************************* * CAPTURE MESSAGES * *******************************/ :- dynamic captured_messages/3. :- thread_local thread_error_stream/1. user:message_property(Level, stream(S)) :- captured_messages(Level, S, _). %! capture_messages(+Level) is det. % % Redirect all messages of the indicated level to the console of the % current thread. This is part of the SSH library as it is notably % practical when connected through SSH. Consider using trace/1 on % some predicate. We catch capture the output using: % % ?- capture_messages(debug). % ?- trace(p/1). capture_messages(Level) :- ( thread_error_stream(S) -> true ; thread_self(Me), stream_property(S, alias(user_error)), asserta(thread_error_stream(S)), thread_at_exit(cleanup_message_capture) ), asserta(captured_messages(Level, S, Me)). cleanup_message_capture :- thread_self(Me), retractall(captured_messages(_,_,Me)). /******************************* * MESSAGES * *******************************/ :- multifile prolog:message//1. prolog:message(ssh_server(create_host_keys(Dir))) --> [ 'SSH Server: Creating host keys in "~w"'-[Dir] ].