diff --git a/ocaml-ssh/CHANGES.md b/ocaml-ssh/CHANGES.md index fedd54c866..9df6b6d172 100644 --- a/ocaml-ssh/CHANGES.md +++ b/ocaml-ssh/CHANGES.md @@ -1,5 +1,12 @@ ## unreleased +* `Config` reads `ServerAliveInterval`, `ServerAliveCountMax` and + `ConnectTimeout`, times in OpenSSH's convtime form. `Client.alive_check` is + OpenSSH's server alive check, and the client takes the answer to its global + request. ssh-eio: `Client.connect` takes `?alive` and `?connect_timeout`, a + session that ends so raises its reason in the operation waiting on the server + and in each one after, and `channel_reader` raises it rather than ending the + data. * Add `Ssh.Cert`, OpenSSH certificates (draft-ietf-sshm-cert-01), `Ssh.Sshsig`, SSHSIG signatures (draft-josefsson-sshsig-format-03), and `Ssh.Krl`, key revocation lists (PROTOCOL.krl): the three formats Git's SSH signing checks a diff --git a/ocaml-ssh/README.md b/ocaml-ssh/README.md index e20c7cef37..e069b702f2 100644 --- a/ocaml-ssh/README.md +++ b/ocaml-ssh/README.md @@ -120,7 +120,12 @@ let run_client The `ssh-eio` package wraps a `Client` over an `Eio.Flow.two_way` with `Ssh_eio.connect`, `read_event`, `write` and `close`. Another backend connects -the same byte queues to its own transport. +the same byte queues to its own transport. `Ssh_eio.Client.connect` bounds a +session as OpenSSH's client does with ssh_config(5)'s `ConnectTimeout`, +`ServerAliveInterval` and `ServerAliveCountMax`, which `Ssh.Config` reads: a +server silent for one interval is sent a `keepalive@openssh.com` request, and +the session ends once more than the count went unanswered, so a server that +stopped answering ends a session in a stated time rather than holding it. ## Status diff --git a/ocaml-ssh/lib/client.ml b/ocaml-ssh/lib/client.ml index 78e7f6687f..25966dcd1f 100644 --- a/ocaml-ssh/lib/client.ml +++ b/ocaml-ssh/lib/client.ml @@ -208,6 +208,9 @@ type t = { sec 11.4's SSH_MSG_UNIMPLEMENTED names it. *) server_version : string; (* peer version, kept for the rekey exchange hash *) rekeying : bool; (* a rekey KEX is in progress (returns to Established) *) + alive_probes : int; + (* keepalives sent since the last packet from the server (OpenSSH's + keep_alive_timeouts, which every packet received resets) *) held : Message.t list; (* connection-protocol replies generated while [keying] is set, in reverse order. RFC 4253 sec 7.1 forbids sending non-KEX messages between our @@ -394,6 +397,7 @@ let v ~authenticator ~user credential = rx_seq = 0l; server_version = ""; rekeying = false; + alive_probes = 0; held = []; } in @@ -946,6 +950,11 @@ let connection_msg t msg = m "declining global request (want reply %B)" want_reply); let out = if want_reply then [ Request_failure ] else [] in Ok (t, out, []) + | Request_success _ | Request_failure -> + (* RFC 4254 sec 4: the answer to a global request of ours, the only + one being {!alive_check}'s keepalive, whose answer is that it + came. *) + Ok (t, [], []) | Channel_data (id, data) -> let* t, out, id, data = channel_data t id data in Ok (t, out, [ `Channel_data (id, data) ]) @@ -1112,7 +1121,8 @@ let input_msg t msg now = | _, Disconnect (code, msg, lang) -> log_disconnect code msg lang; Error "disconnected" - | _, Global_request _ when authenticated t -> + | _, (Global_request _ | Request_success _ | Request_failure) + when authenticated t -> (* OpenSSH sends hostkeys-00@openssh.com right after userauth-success, before our channel-open is confirmed ([Opening_channel]). Before that success there is no connection protocol to address, so a global request falls through to @@ -1143,6 +1153,7 @@ let drain (t : t) ~(rx : Rx.t) now = | Some msg -> rx.r <- rx.r + (avail - String.length rest); debug_msg "<<<" msg; + let t = { t with alive_probes = 0 } in let* t', replies', events' = input_msg t msg now in let t'', replies' = output_msgs t' replies' in (* The version line may be followed by the first packet @@ -1159,7 +1170,7 @@ let drain (t : t) ~(rx : Rx.t) now = | Ok None -> Ok (t, replies, events) | Ok (Some (plain, off, len, consumed, keys_stoc)) -> rx.r <- rx.r + consumed; - let t = { t with keys_stoc; rx_seq } in + let t = { t with keys_stoc; rx_seq; alive_probes = 0 } in let* msg = Codec.of_substring plain ~off ~len in debug_msg "<<<" msg; let* t', replies', events' = input_msg t msg now in @@ -1187,6 +1198,26 @@ let incoming t now reader = in go t [] [] +(* OpenSSH clientloop.c server_alive_check: the probe goes out with 'want + reply' set, so a server that runs answers it, and once more than + [count_max] went unanswered the session is over. *) +let keepalive = + Message.Global_request + ("keepalive@openssh.com", true, Message.Unknown_request "") + +let alive_check t ~count_max = + let probes = t.alive_probes + 1 in + if probes > count_max then + err "Timeout, server not responding: no answer to %d keepalives" + t.alive_probes + else + let t = { t with alive_probes = probes } in + if not (authenticated t) then Ok (t, []) + else if t.keying then Ok ({ t with held = keepalive :: t.held }, []) + else + let t, buf = output_msg t keepalive in + Ok (t, [ buf ]) + let outgoing_request t ?(id = 0l) ?(want_reply = false) req = let* () = require_established t in let* c = Channel.require id t.channels in diff --git a/ocaml-ssh/lib/client.mli b/ocaml-ssh/lib/client.mli index ede883662f..2e1c9d3e01 100644 --- a/ocaml-ssh/lib/client.mli +++ b/ocaml-ssh/lib/client.mli @@ -113,6 +113,17 @@ val maybe_rekey : t -> Mtime.t -> (t * string list) option without re-authenticating. [None] otherwise. The driver loop should call this between application I/O. *) +val alive_check : t -> count_max:int -> (t * string list, string) result +(** [alive_check t ~count_max] is OpenSSH's server alive check (ssh_config(5), + [ServerAliveCountMax]), for a server that has sent nothing for one + [ServerAliveInterval]: [t] with one more keepalive unanswered, and the + [keepalive@openssh.com] global request, 'want reply' set, to send (RFC 4254 + section 4). It is [Error] naming the count once more than [count_max] + keepalives went unanswered; any packet from the server answers them all. + Before authentication there is no connection protocol to carry the request, + so the count rises and nothing is sent; during a key exchange the request + waits for its end (RFC 4253 section 7.1). *) + val open_channel : t -> (t * string list * int32, string) result (** [open_channel t] opens an additional session channel once the connection is established, returning the bytes to send and the fresh local channel id. The diff --git a/ocaml-ssh/lib/config.ml b/ocaml-ssh/lib/config.ml index b9e7ad8871..0c2f291dc1 100644 --- a/ocaml-ssh/lib/config.ml +++ b/ocaml-ssh/lib/config.ml @@ -10,6 +10,9 @@ type opt = | User_known_hosts of string list | Global_known_hosts of string list | Host_key_alias of string + | Server_alive_interval of int + | Server_alive_count_max of int + | Connect_timeout of int type item = | Host_block of string list @@ -75,11 +78,62 @@ let criteria line args = in go [] args +(* misc.c convtime: a sequence of numbers each with a qualifier, s (or none), m, + h, d or w, the seconds alone fractional, truncated to whole seconds. *) +let seconds_of_time s = + let n = String.length s in + let rec go acc seen_seconds i = + if i >= n then Some (int_of_float acc) + else + let j = ref i in + while + !j < n && match s.[!j] with '0' .. '9' | '.' -> true | _ -> false + do + incr j + done; + if !j = i then None + else + let num = String.sub s i (!j - i) in + let unit_, next = + if !j >= n then ('s', !j) else (Char.lowercase_ascii s.[!j], !j + 1) + in + let mult = + match unit_ with + | 's' -> Some 1. + | 'm' -> Some 60. + | 'h' -> Some 3600. + | 'd' -> Some 86400. + | 'w' -> Some 604800. + | _ -> None + in + let fractional = String.contains num '.' in + match (mult, float_of_string_opt num) with + | Some m, Some v + when (not (fractional && m > 1.)) + && (not (unit_ = 's' && seen_seconds)) + && num.[String.length num - 1] <> '.' -> + let acc = acc +. (v *. m) in + if acc > float_of_int Int.max_int then None + else go acc (seen_seconds || unit_ = 's') next + | _ -> None + in + if s = "" then None else go 0. false 0 + let yes_no = function | "yes" | "true" -> Some true | "no" | "false" -> Some false | _ -> None +(* readconf.c parse_time: [none] sets nothing, so a later line may. *) +let time line name args k = + match args with + | [ "none" ] -> Ok None + | [ v ] -> ( + match seconds_of_time v with + | Some n -> Ok (Some (k n)) + | None -> err line "%s %s is not a time" name v) + | _ -> err line "%s takes one argument" name + (* The options this module reads, checked here so a malformed value is refused with its line rather than ignored when a host is resolved. *) let opt line kw args = @@ -111,6 +165,17 @@ let opt line kw args = | "userknownhostsfile" -> if args = [] then err line "UserKnownHostsFile takes a file" else Ok (Some (User_known_hosts args)) + | "serveraliveinterval" -> + time line "ServerAliveInterval" args (fun v -> Server_alive_interval v) + | "connecttimeout" -> + time line "ConnectTimeout" args (fun v -> Connect_timeout v) + | "serveralivecountmax" -> ( + match args with + | [ v ] -> ( + match int_of_string_opt v with + | Some n when n >= 0 -> Ok (Some (Server_alive_count_max n)) + | _ -> err line "ServerAliveCountMax %s is not a count" v) + | _ -> err line "ServerAliveCountMax takes one argument") | "globalknownhostsfile" -> if args = [] then err line "GlobalKnownHostsFile takes a file" else Ok (Some (Global_known_hosts args)) @@ -186,6 +251,9 @@ type options = { user_known_hosts_files : string list option; global_known_hosts_files : string list option; host_key_alias : string option; + server_alive_interval : int option; + server_alive_count_max : int option; + connect_timeout : int option; unsupported : string list; } @@ -200,6 +268,9 @@ let empty = user_known_hosts_files = None; global_known_hosts_files = None; host_key_alias = None; + server_alive_interval = None; + server_alive_count_max = None; + connect_timeout = None; unsupported = []; } @@ -219,6 +290,11 @@ let apply o opt = | Global_known_hosts v -> { o with global_known_hosts_files = first o.global_known_hosts_files v } | Host_key_alias v -> { o with host_key_alias = first o.host_key_alias v } + | Server_alive_interval v -> + { o with server_alive_interval = first o.server_alive_interval v } + | Server_alive_count_max v -> + { o with server_alive_count_max = first o.server_alive_count_max v } + | Connect_timeout v -> { o with connect_timeout = first o.connect_timeout v } let host_matches pats host = Pattern.list pats (String.lowercase_ascii host) diff --git a/ocaml-ssh/lib/config.mli b/ocaml-ssh/lib/config.mli index 2b1a217fc4..9d5b511a27 100644 --- a/ocaml-ssh/lib/config.mli +++ b/ocaml-ssh/lib/config.mli @@ -7,8 +7,11 @@ [all], [host] and [originalhost] are evaluated; a block under any other criterion is not applied and is named in {!field-unsupported}. Keywords are case-insensitive and take their argument after spaces or one [=]; - double-quoted arguments may hold spaces. Keywords this module does not read - are ignored. *) + double-quoted arguments may hold spaces. A time ([ServerAliveInterval], + [ConnectTimeout]) is read as OpenSSH's convtime reads it: numbers each + followed by [s] (or nothing), [m], [h], [d] or [w], only seconds fractional, + truncated to whole seconds; [none] sets nothing. Keywords this module does + not read are ignored. *) type t (** The type for a parsed configuration. *) @@ -35,6 +38,15 @@ type options = { user_known_hosts_files : string list option; (** [UserKnownHostsFile]. *) global_known_hosts_files : string list option; (** [GlobalKnownHostsFile]. *) host_key_alias : string option; (** [HostKeyAlias]. *) + server_alive_interval : int option; + (** [ServerAliveInterval], in seconds: how long the server may stay silent + before the client asks it to answer; [0] sends no such request. *) + server_alive_count_max : int option; + (** [ServerAliveCountMax]: how many of those requests may go unanswered + before the client ends the session; OpenSSH's default is [3]. *) + connect_timeout : int option; + (** [ConnectTimeout], in seconds: the bound on the TCP connection and the + server's identification string; [0] is no bound. *) unsupported : string list; (** The [Match] lines whose criteria were not evaluated, so their blocks were not applied. *) diff --git a/ocaml-ssh/lib/eio/ssh_eio.ml b/ocaml-ssh/lib/eio/ssh_eio.ml index 0f31ec7bca..678fc7e8ac 100644 --- a/ocaml-ssh/lib/eio/ssh_eio.ml +++ b/ocaml-ssh/lib/eio/ssh_eio.ml @@ -65,9 +65,15 @@ let flow_reader flow = module Client = struct module C = Ssh.Client + type alive = { interval : Mtime.Span.t; count_max : int } + type t = { mutable client : C.t; now : unit -> Mtime.t; + sleep : Mtime.Span.t -> unit; + alive : alive option; + banner_deadline : Mtime.t option; + (* ConnectTimeout's end, for the server's identification string *) reader : Bytesrw.Bytes.Reader.t; (* one flow-backed reader; [step] pulls slices into the client's ring. Read by the single read fiber only. *) @@ -91,6 +97,11 @@ module Client = struct paced by the peer's send window waits on it: a CHANNEL_WINDOW_ADJUST that credits the window, or a NEWKEYS that ends a key exchange, wakes it to flush the next window-bounded chunk. *) + mutable failure : string option; + (* why the session ended, once it has: each operation after it fails + with this *) + dead : unit Eio.Promise.t; + kill : unit Eio.Promise.u; } let emit t bufs = @@ -103,12 +114,21 @@ module Client = struct end) bufs - (* Block until everything enqueued so far has been written to the flow. Must - not hold [t.mutex]. *) + let reason t = + Option.value t.failure + ~default:"connection closed while sending on the channel" + + (* Block until everything enqueued so far has been written to the flow, or + the session ends: a writer stuck on a peer that reads nothing never + resolves the flush. Must not hold [t.mutex]. *) let flush t = let p, u = Eio.Promise.create () in Eio.Stream.add t.out (`Flush u); - Eio.Promise.await p + Eio.Fiber.first + (fun () -> Eio.Promise.await p) + (fun () -> + Eio.Promise.await t.dead; + err (reason t)) (* All of the following hold [t.mutex] while they touch [t.client] and enqueue; they never block (the blocking flow write happens in the writer @@ -129,8 +149,28 @@ module Client = struct true | _ -> false + (* The session ends for [why]: a writer waiting on the window and a flush + wake, and each later operation fails with [why]. Must not hold + [t.mutex]. *) + let end_session t why = + locked t (fun () -> + if Option.is_none t.failure then begin + t.failure <- Some why; + t.eof <- true; + Eio.Promise.resolve t.kill (); + Eio.Condition.broadcast t.win_cond + end) + (* A failure of [t], as {!Userauth_failed} while it authenticates. *) - let fail t e = if authenticating t then raise (Userauth_failed e) else err e + let fail t e = + end_session t e; + if authenticating t then raise (Userauth_failed e) else err e + + (* A server that stopped answering, which is never a refusal of the + credentials. *) + let time_out t why = + end_session t why; + err why (* Drive the proactive rekey policy (RFC 4253 sec 9 byte/time quota). Must run under [t.mutex]. *) @@ -141,13 +181,88 @@ module Client = struct emit t out | None -> () + (* Before authentication OpenSSH reads each packet with its packet timeout, + ServerAliveInterval * ServerAliveCountMax (packet.c); there is no + connection protocol yet to carry a keepalive. *) + let before_auth = function + | `Await_kexinit | `Await_kex_reply | `Await_newkeys | `Await_service + | `Userauth_initial | `Userauth_publickey | `Userauth_password + | `Userauth_keyboard_interactive -> + true + | `Await_version | `Opening_channel | `Established | `Rekey_await_kexinit + | `Rekey_await_kex_reply | `Rekey_await_newkeys -> + false + + (* How long a read waits on a silent server, and what the silence then + means: the end of the banner exchange (kex_exchange_identification), a + packet timeout, or a keepalive to send (clientloop.c). *) + let quiet t = + let alive_on { interval; count_max } = + (not (Mtime.Span.equal interval Mtime.Span.zero)) && count_max >= 0 + in + match (C.phase t.client, t.alive) with + | `Await_version, _ -> + Option.map + (fun d -> + let now = t.now () in + let left = + if Mtime.is_later now ~than:d then Mtime.Span.zero + else Mtime.span now d + in + (left, `Banner)) + t.banner_deadline + | ph, Some ({ interval; count_max } as a) when before_auth ph -> + if alive_on a && count_max > 0 then + Some (Mtime.Span.(count_max * interval), `Packet) + else None + | _, Some ({ interval; count_max } as a) when alive_on a -> + Some (interval, `Probe count_max) + | _ -> None + + (* One slice from the server, or the end the silence of a bounded wait + means. A slice the read took as the wait expired is kept. *) + let rec read t = + match quiet t with + | None -> Bytesrw.Bytes.Reader.read t.reader + | Some (span, on_quiet) -> ( + match + Eio.Fiber.first + ~combine:(fun a b -> match a with `Read _ -> a | `Quiet -> b) + (fun () -> `Read (Bytesrw.Bytes.Reader.read t.reader)) + (fun () -> + t.sleep span; + `Quiet) + with + | `Read s -> s + | `Quiet -> ( + match on_quiet with + | `Banner -> + time_out t "Connection timed out during banner exchange" + | `Packet -> + time_out t + (Fmt.str + "Connection timed out: the server sent nothing for %a" + Mtime.Span.pp span) + | `Probe count_max -> ( + match + locked t (fun () -> + match C.alive_check t.client ~count_max with + | Error _ as e -> e + | Ok (client, out) -> + t.client <- client; + emit t out; + Ok ()) + with + | Ok () -> read t + | Error why -> time_out t why))) + (* Read one slice (the only blocking call) outside [mutex] so the writer fiber can keep sending window-adjusts while we wait on the socket, then blit it into the ring and decrypt -- both non-blocking -- under the lock. [feed] copies the slice out before the next read, which the [~reuse] reader needs. The read side is a single fiber, so the reader and ring need no lock. *) let step t = - let s = Bytesrw.Bytes.Reader.read t.reader in + let s = read t in if Bytesrw.Bytes.Slice.is_eod s then (* End of the flow: wake any writer parked on [win_cond] so it observes [eof] and errors out, rather than waiting forever on a rekey or a spent @@ -184,11 +299,13 @@ module Client = struct t.pending <- rest; C.emit_probe e; Some e - | [] -> - if t.eof then None - else ( - step t; - read_event t) + | [] -> ( + match (t.eof, t.failure) with + | true, Some why -> err why + | true, None -> None + | false, _ -> + step t; + read_event t) let write ?(id = 0l) t data = with_channel ~role:"client" ~direction:"tx" ~channel:id @@ -214,7 +331,7 @@ module Client = struct done; (* A dead connection cannot credit the window or finish the rekey, so stop waiting and surface the failure instead of blocking forever. *) - if t.eof then err "connection closed while sending on the channel"; + if t.eof then err (reason t); let n = min (C.send_window t.client ~id ()) (len - !pos) in (match C.outgoing_data t.client ~id (String.sub data !pos n) with | Ok (client, out) -> @@ -287,7 +404,10 @@ module Client = struct id) else if List.mem (`Channel_open_failure id) t.pending then err "peer refused the channel" - else if t.eof then err "connection closed while opening a channel" + else if t.eof then + err + (Option.value t.failure + ~default:"connection closed while opening a channel") else ( step t; wait ()) @@ -300,44 +420,72 @@ module Client = struct large payload stays unblocked by the peer's window credits. *) let chunks = Eio.Stream.create 64 in let exit_status = ref None in - let close () = Eio.Stream.add chunks None in + let close () = Eio.Stream.add chunks `End in Eio.Fiber.fork ~sw (fun () -> let rec loop () = match - try read_event t with End_of_file | Ssh_error _ | Eio.Io _ -> None + try Ok (read_event t) with + | End_of_file | Eio.Io _ -> Ok None + | Ssh_error why -> Error why with - | None -> close () - | Some (`Channel_data (cid, d)) when Int32.equal cid id -> - Eio.Stream.add chunks (Some d); + | Error why -> Eio.Stream.add chunks (`Failed why) + | Ok None -> close () + | Ok (Some (`Channel_data (cid, d))) when Int32.equal cid id -> + Eio.Stream.add chunks (`Data d); loop () - | Some (`Channel_stderr (cid, msg)) when Int32.equal cid id -> + | Ok (Some (`Channel_stderr (cid, msg))) when Int32.equal cid id -> on_stderr msg; loop () - | Some (`Channel_exit_status (cid, status)) when Int32.equal cid id -> + | Ok (Some (`Channel_exit_status (cid, status))) + when Int32.equal cid id -> (* Exit status is metadata, not end-of-data: a server sends it before the final data has drained off the channel. Keep reading; only channel EOF or a closed connection ends the stream. *) exit_status := Some status; loop () - | Some (`Channel_eof cid) when Int32.equal cid id -> close () - | Some `Disconnected -> close () - | Some _ -> loop () + | Ok (Some (`Channel_eof cid)) when Int32.equal cid id -> close () + | Ok (Some `Disconnected) -> close () + | Ok (Some _) -> loop () in loop ()); - (Bytesrw_eio.bytes_reader_of_stream chunks, fun () -> !exit_status) + (* A session that ended raises its reason on the reader, as on every read + after it; an empty chunk is no slice, and after the end the stream is + not taken again. *) + let ended = ref None in + let rec read () = + match !ended with + | Some `End -> Bytesrw.Bytes.Slice.eod + | Some (`Failed why) -> err why + | None -> ( + match Eio.Stream.take chunks with + | `Data "" -> read () + | `Data d -> Bytesrw.Bytes.Slice.of_string d + | (`End | `Failed _) as e -> + ended := Some e; + read ()) + in + (Bytesrw.Bytes.Reader.make read, fun () -> !exit_status) - let connect ~sw ~mono ?(set_nodelay = fun _ -> ()) flow ~authenticator ~user - cred = + let connect ~sw ~mono ?(set_nodelay = fun _ -> ()) ?alive ?connect_timeout + flow ~authenticator ~user cred = Probe.with_span connection_span { role = "client" } @@ fun () -> set_nodelay flow; let reader = flow_reader flow in let now () = Eio.Time.Mono.now mono in let client, init = C.v ~authenticator ~user cred in + let dead, kill = Eio.Promise.create () in let t = { client; now; + sleep = Eio.Time.Mono.sleep_span mono; + alive; + banner_deadline = + Option.bind connect_timeout (fun s -> Mtime.add_span (now ()) s); + failure = None; + dead; + kill; reader; rx = Ssh.Rx.v (); out = Eio.Stream.create max_int; diff --git a/ocaml-ssh/lib/eio/ssh_eio.mli b/ocaml-ssh/lib/eio/ssh_eio.mli index e21e59273c..e5fe34f99e 100644 --- a/ocaml-ssh/lib/eio/ssh_eio.mli +++ b/ocaml-ssh/lib/eio/ssh_eio.mli @@ -17,10 +17,22 @@ module Client : sig type t (** A connected SSH client session. *) + type alive = { + interval : Mtime.Span.t; + (** [ServerAliveInterval]: how long the server may stay silent before a + keepalive is sent; {!Mtime.Span.zero} sends none. *) + count_max : int; + (** [ServerAliveCountMax]: how many keepalives may go unanswered. *) + } + (** The type for how the client tells a server that stopped answering, as + ssh_config(5) words it. *) + val connect : sw:Eio.Switch.t -> mono:_ Eio.Time.Mono.t -> ?set_nodelay:('a Eio.Flow.two_way -> unit) -> + ?alive:alive -> + ?connect_timeout:Mtime.Span.t -> 'a Eio.Flow.two_way -> authenticator:Ssh.Keys.authenticator -> user:string -> @@ -33,7 +45,20 @@ module Client : sig session. [authenticator] validates the server host key and has no default. A writer fiber is forked into [sw]. Sending and receiving may run in two fibers; the receiving side must be one. Raises {!Userauth_failed} when - authentication fails and {!Ssh_error} on any other protocol failure. *) + authentication fails and {!Ssh_error} on any other protocol failure. + + [connect_timeout] (default: none) bounds the wait for the server's + identification string, ending it with + ["Connection timed out during banner exchange"]. With [alive] (default: + none), as OpenSSH's client does: before authentication a read that waits + {!field-count_max} intervals ends the session as timed out; once + authenticated, each interval in which the server sent nothing while the + receiving side waits on it sends a [keepalive@openssh.com] request + ({!Ssh.Client.alive_check}), and the session ends once more than + {!field-count_max} went unanswered. A session that ends so raises + {!Ssh_error} naming why in the operation waiting on the server, and each + later operation, a {!write} waiting on the window and a {!flush} among + them, raises it too. *) val read_event : t -> Ssh.Client.event option (** [read_event t] returns the next event from the peer, reading from the flow @@ -55,7 +80,9 @@ module Client : sig (** [channel_reader ~sw t ~id] presents channel [id]'s data as a pull reader and takes over the receiving side: a fiber pumps {!read_event}, routes data and stderr, and records the exit status. Read it to the end before - consulting the status; call no other receive function. *) + consulting the status; call no other receive function. A read raises + {!Ssh_error} once the session has ended for a reason, such as a server + that stopped answering, rather than ending the data there. *) val exec : t -> id:int32 -> string -> unit (** [exec t ~id command] sends an exec request running [command] on channel @@ -84,7 +111,8 @@ module Client : sig val flush : t -> unit (** [flush t] blocks until every byte queued so far has been written to the flow. Use it before tearing down the underlying flow if you need the last - packets to have reached the peer. *) + packets to have reached the peer. It raises {!Ssh_error} once the session + has ended for a reason. *) val close : ?id:int32 -> t -> unit (** [close ?id t] closes channel [id] (default: the first) and flushes the