View source with formatted comments or as raw
    1/*  Part of SWI-Prolog
    2
    3    Author:        Jan Wielemaker
    4    E-mail:        jan@swi-prolog.org
    5    WWW:           https://www.swi-prolog.org
    6    Copyright (c)  2019-2026, VU University Amsterdam
    7			      SWI-Prolog Solutions b.v.
    8    All rights reserved.
    9
   10    Redistribution and use in source and binary forms, with or without
   11    modification, are permitted provided that the following conditions
   12    are met:
   13
   14    1. Redistributions of source code must retain the above copyright
   15       notice, this list of conditions and the following disclaimer.
   16
   17    2. Redistributions in binary form must reproduce the above copyright
   18       notice, this list of conditions and the following disclaimer in
   19       the documentation and/or other materials provided with the
   20       distribution.
   21
   22    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
   23    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
   24    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
   25    FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
   26    COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
   27    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
   28    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
   29    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
   30    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
   31    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
   32    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
   33    POSSIBILITY OF SUCH DAMAGE.
   34*/
   35
   36:- module(ssh_server,
   37          [ ssh_server/0,
   38            ssh_server/1,       % +Options
   39	    capture_messages/1  % +Level
   40	  ]).   41:- use_module(library(debug)).   42:- use_module(library(option)).   43:- use_module(library(settings)).   44:- use_module(library(threadutil)).   45
   46:- use_foreign_library(foreign(sshd4pl)).   47
   48/** <module> Embedded SSH server
   49
   50This module defines an embedded SSH  server   for  SWI-Prolog  on top of
   51[libssh](https://libssh.org). This module allows for   a  safe secondary
   52access point to a running  Prolog  process.   A  typical  use case is to
   53provide a safe channal  or  inspection   and  maintenance  of servers or
   54embedded Prolog instances.
   55
   56If possible, a _login_ to the Prolog process uses a _pseudo terminal_ to
   57realise normal terminal  interaction,  including   processing  of  ^C to
   58interrupt running queries. If  `libedit`  (editline)   is  used  as  the
   59command  line  editor  this  is  installed  (see  el_wrap/0),  providing
   60advanced command line editing and history.
   61
   62The library currently support _login_  to   the  Prolog  process. Future
   63versions may also use the client access   and  exploit the SSH subsystem
   64interface to achieve safe interaction between Prolog peers.
   65
   66## The client session
   67
   68A new connection creates a Prolog   thread  that handles the connection.
   69The  new  thread's  standard    streams   (`user_input`,  `user_output`,
   70`user_error`, `current_input` and `current_output`) are  attached to the
   71new connection. Some of the environment is   shared as Prolog flags. The
   72following flags are defined:
   73
   74  - ssh_tty
   75    Provides the name of the _pseudo terminal_ if such a terminal us
   76    allocated for this connection.
   77  - ssh_term
   78    Provides the ``TERM`` environment variable passed from the client.
   79  - ssh_user
   80    Provides the name of the user logged on.
   81
   82If a _pseudo terminal_ is used and   the  `ssh_term` flag is not `dump`,
   83library(ansi_term) is connected to provide colorized output.
   84
   85If a _pseudo terminal_ is used  and library(editline) is available, this
   86library is used to enable command line editing.
   87
   88## Executing commands
   89
   90Using ``ssh <options> <server> <command>``,   ``<command>``  is executed
   91without a terminal (unless the ``-t`` option  is given to `ssh` to force
   92a terminal) and otherwise  as  a   single  Prolog  toplevel command. For
   93example:
   94
   95```
   96ssh -p 2020 localhost "writeln('Hello world')"
   97Hello world
   98true.
   99```
  100
  101If the query is nondeterministic alternative answers can be requested in
  102the same way as using the interactive toplevel. The exit code is defined
  103as follows:
  104
  105  - 0
  106    The query succeeded
  107  - 1
  108    The query failed
  109  - 2
  110    The query produced an exception (the system prints a backtrace)
  111  - 3
  112    The query itself was not syntactically correct.
  113
  114### Aborting the server
  115
  116If a Prolor process with an  embedded   ssh  server misbehaves it can be
  117forcefully aborted using the `abort` command.  This calls C `abort()` as
  118soon as possible and  thus  should  function   even  if  Prolog  is, for
  119example, stuck in a deadlock.
  120
  121    ssh -p 2020 localhost abort
  122
  123@tbd Currently only supports Unix. A Windows port is probably doable. It
  124mostly requires finding a  sensible  replacement   for  the  Unix pseudo
  125terminal.
  126
  127@tbd Implement running other commands than the Prolog toplevel.
  128*/
  129
  130:- multifile
  131    verify_password/3.                  % +ServerName, +User, +Password
  132
  133:- predicate_options(
  134       ssh_server/1, 1,
  135       [ name(atom),
  136         port(integer),
  137         bind_address(atom),
  138         host_key_file(atom),
  139         authorized_keys_file(atom),
  140         auth_methods(list(oneof([password,public_key])))
  141       ]).  142
  143:- setting(port, positive_integer, 2020,
  144           "Default port for SWI-Prolog SSH server").  145:- setting(color_term, boolean, true,
  146           "Enable ANSI color output on SSH terminal").  147
  148%!  ssh_server is det.
  149%!  ssh_server(+PortOrOptions) is det.
  150%
  151%   Create an embedded SSH server in the  current Prolog process. If the
  152%   argument    is    an    integer     it      is     interpreted    as
  153%   ssh_server([port(Integer)]). Options:
  154%
  155%     - name(+Atom)
  156%       Name the server.  Passed as first argument to verify_password/3
  157%       to identify multiple servers.
  158%     - port(+Integer)
  159%       Port to listen on.  Default is 2020.
  160%     - bind_address(+Name)
  161%       Interface to listen to.  Default is `localhost`.  Use `*`
  162%       to grant acccess from all network interfaces.
  163%     - host_key_file(+File)
  164%
  165%       File name for the host private key. If omitted it searches for
  166%       `etc/ssh` below the current directory and user_app_config('etc/ssh')
  167%       (normally ``~/.config/swi-prolog/etc/ssh``). On failure it
  168%       creates, a directory `etc/ssh` with default host keys and uses
  169%       these.
  170%     - auth_methods(+ListOfMethod)
  171%       Set allowed authentication methods.  ListOfMethod is a list of
  172%       - password
  173%         Allow password login (see verify_password/3)
  174%       - public_key
  175%         Allow key based login (see `authorized_keys_file` below)
  176%       The default is derived from the `authorized_keys_file` option
  177%       and whether or not verify_password/3 is defined.
  178%     - authorized_keys_file(+File)
  179%       File name for a file holding the public keys for users that
  180%       are allows to login.  Activates auth_methods([public_key]).
  181%       This file is in OpenSSH format and contains a certificate
  182%       per line in the format
  183%
  184%           <type> <base64-key> <comment>
  185%
  186%       The the file `~/.ssh/authorized_keys` is present, this will
  187%       be used as default, granting anyone with access to this account
  188%       to access the server with the same keys. If the option is
  189%       present with value `[]` (empty list), no key file is used.
  190
  191
  192ssh_server :-
  193    ssh_server([]).
  194
  195ssh_server(Port) :-
  196    integer(Port),
  197    !,
  198    ssh_server([port(Port)]).
  199ssh_server(Options) :-
  200    setting(port, DefPort),
  201    merge_options(Options,
  202                  [ port(DefPort),
  203                    bind_address(localhost)
  204                  ], Options1),
  205    (   option(name(Name), Options)
  206    ->  Alias = Name
  207    ;   option(port(Port), Options1),
  208        format(atom(Alias), 'sshd@~w', [Port])
  209    ),
  210    ensure_host_keys(Options1, Options2),
  211    add_authorized_keys(Options2, Options3),
  212    add_auth_methods(Options3, Options4),
  213    setup_signals(Options4),
  214    thread_create(ssh_server_nt(Options4), _,
  215                  [ alias(Alias),
  216                    detached(true),
  217                    class(service)
  218                  ]).
  219
  220%!  ensure_host_keys(+Options0, -Options) is det.
  221%
  222%   Provide a host key:
  223%
  224%     1. If the key file is given, use it.
  225%     2. If there is a key in `etc/ssh`, use it.
  226%     3. If there is a key in user_app_config('etc/ssh'), use it.
  227%     4. Try to create a key in user_app_config('etc/ssh')
  228%     5. Try to create a key in `etc/ssh`
  229
  230ensure_host_keys(Options, Options) :-
  231    option(host_key_file(KeyFile), Options),
  232    !,
  233    (   access_file(KeyFile, read)
  234    ->  true
  235    ;   permission_error(read, ssh_host_key_file, KeyFile)
  236    ).
  237ensure_host_keys(Options0, Options) :-
  238    exists_file('etc/ssh/ssh_host_ecdsa_key'),
  239    !,
  240    Options = [host_key_file('etc/ssh/ssh_host_ecdsa_key')|Options0].
  241ensure_host_keys(Options0, Options) :-
  242    absolute_file_name(user_app_config('etc/ssh'), Dir,
  243                       [ file_type(directory),
  244                         access(exist),
  245                         file_errors(fail)
  246                       ]),
  247    !,
  248    directory_file_path(Dir, ssh_host_ecdsa_key, KeyFile),
  249    Options = [host_key_file(KeyFile)|Options0].
  250ensure_host_keys(Options0, Options) :-
  251    absolute_file_name(user_app_config('etc/ssh'), Dir,
  252                       [ solutions(all),
  253                         file_errors(fail)
  254                       ]),
  255    Error = error(_,_),
  256    catch(make_directory_path(Dir), Error, fail),
  257    file_directory_name(Dir, P0),
  258    file_directory_name(P0, ConfigDir),
  259    format(string(KeyCmd), 'ssh-keygen -A -f ~w', [ConfigDir]),
  260    print_message(informational, ssh_server(create_host_keys(Dir))),
  261    shell(KeyCmd),
  262    !,
  263    directory_file_path(Dir, ssh_host_ecdsa_key, KeyFile),
  264    Options = [host_key_file(KeyFile)|Options0].
  265ensure_host_keys(Options,
  266                 [ host_key_file('etc/ssh/ssh_host_ecdsa_key')
  267                 | Options
  268                 ]) :-
  269    print_message(informational, ssh_server(create_host_keys('etc/ssh'))),
  270    make_directory_path('etc/ssh'),
  271    shell('ssh-keygen -A -f .').
  272
  273add_auth_methods(Options, Options) :-
  274    option(auth_methods(_), Options),
  275    !.
  276add_auth_methods(Options, [auth_methods(Methods)|Options]) :-
  277    findall(Method, option_auth_method(Options, Method), Methods).
  278
  279option_auth_method(Options, public_key) :-
  280    option(authorized_keys_file(_), Options).
  281option_auth_method(_Options, password) :-
  282    predicate_property(verify_password(_,_,_), number_of_clauses(N)),
  283    N > 0.
  284
  285add_authorized_keys(Options0, Options) :-
  286    option(authorized_keys_file(AuthKeysFile), Options0),
  287    !,
  288    (   AuthKeysFile == []
  289    ->  select_option(authorized_keys_file(AuthKeysFile), Options0, Options)
  290    ;   Options = Options0
  291    ).
  292add_authorized_keys(Options, [authorized_keys_file(AuthKeysFile)|Options]) :-
  293    expand_file_name('~/.ssh/authorized_keys', [AuthKeysFile]),
  294    access_file(AuthKeysFile, read),
  295    !.
  296add_authorized_keys(Options, Options).
  297
  298%!  setup_signals(+Options)
  299%
  300%   Re-installs  the  `int`  signal  to   start  the  debugger.  Notably
  301%   library(http/http_unix_daemon) binds this to terminates the process.
  302
  303setup_signals(_Options) :-
  304    E = error(_,_),
  305    catch(on_signal(int, _, debug), E, print_message(warning, E)).
  306
  307%!  run_client(+Server, +In, +Out, +Err, +Command, -RetCode) is det.
  308%
  309%   Run Command using I/O from  the  triple   <In,  Out,  Err>  and bind
  310%   RetCode to the ssh shell return code.
  311
  312:- public run_client/6.  313
  314run_client(Server, In, Out, Err, Command, RetCode) :-
  315    set_properties,
  316    setup_console(Server, In, Out, Err, Cleanup),
  317    call_cleanup(ssh_toplevel(Command, RetCode),
  318                 shutdown_console(Cleanup)).
  319
  320:- if(thread_property(main,class(_))).  321set_properties :-
  322    current_prolog_flag(ssh_user, User),
  323    thread_self(Me),
  324    thread_property(Me, id(Id)),
  325    format(atom(Alias), '~w@ssh/~w', [User, Id]),
  326    set_thread(Me, alias(Alias)),
  327    set_thread(Me, class(console)).
  328:- elif(current_predicate(thread_alias/1)).  329set_properties :-
  330    current_prolog_flag(ssh_user, User),
  331    thread_self(Me),
  332    thread_property(Me, id(Id)),
  333    format(atom(Alias), '~w@ssh/~w', [User, Id]),
  334    thread_alias(Alias).
  335:- endif.  336set_properties.
  337
  338% Used by has_console/0 in thread_util.
  339
  340:- dynamic thread_util:has_console/4.  341
  342setup_console(Server, In, Out, Err, clean(Me, Cleanup)) :-
  343    thread_self(Me),
  344    set_stream(In,  alias(user_input)),
  345    set_stream(Out, alias(user_output)),
  346    set_stream(Err, alias(user_error)),
  347    set_stream(In,  alias(current_input)),
  348    set_stream(Out, alias(current_output)),
  349    enable_colors,
  350    enable_line_editing(Mode),
  351    load_history(Mode, Server, Cleanup).
  352
  353shutdown_console(clean(_TID, History)) :-
  354    save_history(History),
  355    disable_line_editing.
  356
  357:- if(setting(color_term, true)).  358:- use_module(library(ansi_term)).  359:- endif.  360
  361%!  enable_colors is det.
  362%
  363%   Enable ANSI colors on the remote shell.  This is controlled by the
  364%   setting `color_term`.  Note that we do not wish to inherit this as
  365%   the server may have different preferences.
  366
  367enable_colors :-
  368    stream_property(user_input, tty(true)),
  369    setting(color_term, true),
  370    current_prolog_flag(ssh_term, Term),
  371    Term \== dump,
  372    !,
  373    set_prolog_flag(color_term, true).
  374enable_colors :-
  375    set_prolog_flag(color_term, false).
  376
  377%!  enable_line_editing is det.
  378%
  379%   Enable line editing for the SSH session. We   can do this if the SSH
  380%   session uses a pseudo terminal and we are using library(editline) as
  381%   command line editor (GNU readline uses global variables and thus can
  382%   only handle a single tty in the process).
  383
  384use_editline :-
  385    exists_source(library(editline)),
  386    (   current_prolog_flag(readline, editline)
  387    ->  true
  388    ;   \+ current_prolog_flag(readline, _)
  389    ).
  390
  391:- if(use_editline).  392:- use_module(library(editline)).  393enable_line_editing(editline) :-
  394    stream_property(user_input, tty(true)),
  395    !,
  396    debug(ssh(server), 'Setting up line editing', []),
  397    set_prolog_flag(tty_control, true),
  398    el_wrap.
  399:- else.  400enable_line_editing(tty) :-
  401    stream_property(user_input, tty(true)),
  402    !,
  403    set_prolog_flag(tty_control, true).
  404:- endif.  405enable_line_editing(none) :-
  406    set_prolog_flag(tty_control, false).
  407
  408:- if(current_predicate(el_unwrap/1)).  409disable_line_editing :-
  410    el_wrapped(user_input),
  411    !,
  412    Error = error(_,_),
  413    catch(el_unwrap(user_input), Error, true).
  414:- endif.  415disable_line_editing.
  416
  417%!  verify_password(+ServerName, +User:atom, +Passwd:string) is semidet.
  418%
  419%   Hook that can  be  used  to   accept  password  based  logins.  This
  420%   predicate must succeeds to accept the User/Passwd combination.
  421%
  422%   @arg ServerName is the name provided with the name(Name) option when
  423%   creating the server or the empty list.
  424
  425
  426		 /*******************************
  427		 *            HISTORY		*
  428		 *******************************/
  429
  430:- multifile
  431    prolog:history/2.  432
  433%!  load_history(+EditMode, +Server, -Cleanup) is det.
  434%
  435%   Load command line history for Server, binding Cleanup to the
  436%   required command for save_history/1
  437
  438load_history(editline, Server, save(File)) :-
  439    history_file(Server, File,
  440                 [ access(read),
  441                   file_errors(fail)
  442                 ]),
  443    !,
  444    prolog:history(user_input, load(File)).
  445load_history(editline, Server, create(Server)) :-
  446    !.
  447load_history(_, _, nosave).
  448
  449%!  save_history(+Action) is det.
  450%
  451%   Save the history information according to action.
  452
  453save_history(save(File)) :-
  454    catch(write_history(File), _, true),
  455    !.
  456save_history(create(Server)) :-
  457    history_file(Server, File,
  458                 [ file_errors(fail),
  459                   solutions(all)
  460                 ]),
  461    catch(write_history(File), _, true),
  462    !.
  463save_history(_).
  464
  465write_history(File) :-
  466    file_directory_name(File, Dir),
  467    make_directory_path(Dir),
  468    prolog:history(user_input, save(File)).
  469
  470history_file(Server, Path, Options) :-
  471    (   Server == []
  472    ->  SName = default
  473    ;   SName = Server
  474    ),
  475    current_prolog_flag(ssh_user, User),
  476    atomic_list_concat([ssh, history, SName, User], /, File),
  477    absolute_file_name(user_app_config(File), Path, Options).
  478
  479
  480
  481%!  ssh_toplevel(+Command, -RetCode)
  482%
  483%   Run the toplevel goal for the SSH  session. The default is `prolog`,
  484%   running the toplevel. Otherwise  the  argument   is  processed  as a
  485%   single toplevel goal.
  486
  487ssh_toplevel(prolog, 0) :-
  488    !,
  489    version,
  490    prolog.
  491ssh_toplevel(Command, RetCode) :-
  492    catch(term_string(Query, Command, [variable_names(Bindings)]),
  493          Error, true),
  494    (   var(Error)
  495    ->  catch_with_backtrace('$execute_query'(Query, Bindings, Truth), E2, true),
  496        toplevel_finish(Truth, E2, RetCode)
  497    ;   print_message(error, Error),
  498        RetCode = 3
  499    ).
  500
  501toplevel_finish(_, Error, 2) :-
  502    nonvar(Error),
  503    !,
  504    print_message(error, Error).
  505toplevel_finish(true, _, 0).
  506toplevel_finish(false, _, 1).
  507
  508
  509		 /*******************************
  510		 *       CAPTURE MESSAGES       *
  511		 *******************************/
  512
  513:- dynamic
  514       captured_messages/3.  515:- thread_local
  516       thread_error_stream/1.  517
  518user:message_property(Level, stream(S)) :-
  519    captured_messages(Level, S, _).
  520
  521%!  capture_messages(+Level) is det.
  522%
  523%   Redirect all messages of the indicated level to the console of the
  524%   current thread.  This is part of  the SSH library as it is notably
  525%   practical when  connected through SSH.  Consider  using trace/1 on
  526%   some predicate.  We catch capture the output using:
  527%
  528%       ?- capture_messages(debug).
  529%       ?- trace(p/1).
  530
  531capture_messages(Level) :-
  532    (   thread_error_stream(S)
  533    ->  true
  534    ;   thread_self(Me),
  535	stream_property(S, alias(user_error)),
  536	asserta(thread_error_stream(S)),
  537	thread_at_exit(cleanup_message_capture)
  538    ),
  539    asserta(captured_messages(Level, S, Me)).
  540
  541cleanup_message_capture :-
  542    thread_self(Me),
  543    retractall(captured_messages(_,_,Me)).
  544
  545
  546		 /*******************************
  547		 *           MESSAGES		*
  548		 *******************************/
  549
  550:- multifile
  551    prolog:message//1.  552
  553prolog:message(ssh_server(create_host_keys(Dir))) -->
  554    [ 'SSH Server: Creating host keys in "~w"'-[Dir] ]