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 (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 518usermessage_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 553prologmessage(ssh_server(create_host_keys(Dir))) --> 554 [ 'SSH Server: Creating host keys in "~w"'-[Dir] ]