Skip to main content

signstar_client/ssh/
client.rs

1//! SSH-client for sending signing requests.
2//!
3//! This module provides Signstar client. The client is used to
4//! connect to a Signstar host and request signatures for given files.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! # async fn sign() -> testresult::TestResult {
10//! use signstar_client::ssh::client::ConnectConfig;
11//! use signstar_common::request_api::Request;
12//!
13//! let options = ConnectConfig::from_first_system_config()?;
14//!
15//! let mut session = options.connect("user").await?;
16//! let request = Request::for_file("package")?;
17//! let response = session.send(&request).await?;
18//! // process response
19//! #     Ok(()) }
20//! ```
21use std::{
22    collections::{BTreeMap, HashMap},
23    fs::{File, OpenOptions, read_to_string},
24    io::Read,
25    num::NonZeroU32,
26    path::{Path, PathBuf},
27    str::FromStr,
28    sync::Arc,
29    time::Duration,
30};
31
32use garde::Validate;
33use log::{error, info};
34use rand::{rng, seq::IndexedRandom};
35use russh::{
36    ChannelMsg,
37    Disconnect,
38    MethodSet,
39    client::{AuthResult, Config, Handle, Handler, connect},
40    keys::{
41        PublicKeyOrCertificate,
42        agent::client::AgentClient,
43        ssh_key::{
44            known_hosts::Entry,
45            {HashAlg, PublicKey},
46        },
47    },
48};
49use serde::{Deserialize, Deserializer};
50use signstar_common::request_api::{Request, Response};
51use tokio::net::UnixStream;
52
53/// SSH communication error.
54#[derive(Debug, thiserror::Error)]
55pub enum Error {
56    /// Invalid options used.
57    #[error("Invalid options used: {0}")]
58    InvalidOptions(String),
59
60    /// Authentication failed.
61    #[error("Authentication failed")]
62    AuthFailed {
63        /// The server suggests to proceed with these authentication methods.
64        remaining_methods: MethodSet,
65
66        /// The server says that though authentication method has been accepted, further
67        /// authentication is required.
68        partial_success: bool,
69    },
70
71    /// Invalid user specified.
72    #[error("User {user} is not defined in the config file")]
73    InvalidUser {
74        /// User name that is missing.
75        user: String,
76    },
77
78    /// Invalid Signstar host specified.
79    #[error("Host {host} is not defined in the config file")]
80    InvalidSignstarHost {
81        /// Host name that is not defined.
82        host: String,
83    },
84
85    /// I/O error occurred.
86    #[error("I/O error: {source} when processing {file}")]
87    Io {
88        /// File being processed.
89        ///
90        /// This field will be empty ([`PathBuf::new`]) if the error
91        /// was encountered when processing generic I/O streams.
92        file: PathBuf,
93
94        /// Source error.
95        source: std::io::Error,
96    },
97
98    /// The remote program did not exit cleanly.
99    #[error("Program did not exit cleanly")]
100    UncleanExit,
101
102    /// The remote application returned a non-zero status code.
103    #[error("Remote application failed with status code: {status_code}")]
104    RemoteApplicationFailure {
105        /// Status code returned by the application.
106        status_code: NonZeroU32,
107    },
108
109    /// Internal `russh` protocol error.
110    #[error("SSH protocol error: {0}")]
111    SshProtocol(#[from] russh::Error),
112
113    /// SSH format error.
114    #[error("SSH format error: {0}")]
115    SshFormat(#[from] russh::keys::Error),
116
117    /// SSH format error.
118    #[error("SSH key error: {0}")]
119    SshKey(#[from] russh::keys::ssh_key::Error),
120
121    /// Internal `russh` client agent error.
122    #[error("SSH agent error: {0}")]
123    Agent(#[from] russh::AgentAuthError),
124
125    /// JSON serialization error.
126    #[error("Serde serialization error: {source}")]
127    Serialization {
128        /// The error source.
129        source: serde_json::Error,
130    },
131
132    /// JSON deserialization error.
133    #[error("Serde derialization error: {source}")]
134    Deserialization {
135        /// The error source.
136        source: serde_json::Error,
137    },
138}
139
140/// The default config file below "/usr/".
141pub const DEFAULT_CONFIG: &str = "/usr/share/signstar/request-signature.toml";
142
143/// The override config file below "/run/".
144pub const RUN_OVERRIDE_CONFIG: &str = "/run/signstar/request-signature.toml";
145
146/// The override config file below "/etc/".
147pub const ETC_OVERRIDE_CONFIG: &str = "/etc/signstar/request-signature.toml";
148
149/// The connection to a Signstar system a specific user can use.
150#[derive(Debug, Deserialize, Validate)]
151pub struct SignstarHost {
152    /// The host name of the Signstar system.
153    #[garde(length(min = 1))]
154    host: String,
155
156    /// The SSH port of the Signstar system.
157    #[garde(range(min = 1))]
158    port: u16,
159
160    /// The known_hosts entries for the Signstar system.
161    #[serde(deserialize_with = "deserialize_entries")]
162    #[garde(length(min = 1))]
163    known_hosts: Vec<Entry>,
164}
165
166/// Connection configuration for sending a signature request.
167///
168/// The configuration tracks a list of all valid targets for connecting.
169#[derive(Debug, Default, Deserialize, Validate)]
170#[garde(custom(validate_host_ids))]
171pub struct ConnectConfig {
172    /// List of hosts configured.
173    #[garde(
174        custom(validate_host_port_uniqueness),
175        custom(validate_known_hosts_consistency),
176        dive
177    )]
178    pub hosts: BTreeMap<String, SignstarHost>,
179
180    /// List of users.
181    #[garde(custom(validate_ssh_public_key_consistency), dive)]
182    pub users: BTreeMap<String, ConnectOptions>,
183}
184
185impl ConnectConfig {
186    /// The order of configuration files.
187    ///
188    /// The following files are inspected, in descending priority:
189    /// - `/etc/signstar/request-signature.toml`
190    /// - `/run/signstar/request-signature.toml`
191    /// - `/usr/share/signstar/request-signature.toml`
192    pub const CONFIG_ORDER: &[&str] = &[ETC_OVERRIDE_CONFIG, RUN_OVERRIDE_CONFIG, DEFAULT_CONFIG];
193
194    /// Connects to a host over SSH and returns a [`Session`] object.
195    ///
196    /// This function sets up an authenticated, bidirectional channel
197    /// between the client and the server. No signing requests are exchanged at this point but any
198    /// number of them can be issued later using [`Session::send`] function.
199    ///
200    /// # Examples
201    ///
202    /// ```no_run
203    /// # async fn sign() -> testresult::TestResult {
204    /// use signstar_client::ssh::client::ConnectConfig;
205    ///
206    /// let options = ConnectConfig::from_first_system_config()?;
207    ///
208    /// let mut session = options.connect("user").await?;
209    /// // use session to send signing requests
210    /// #     Ok(()) }
211    /// ```
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if:
216    /// - the client public key is not set,
217    /// - the server public key is not present in the provided SSH `known_hosts` data,
218    /// - the client public key is not recognized by the server,
219    /// - the client authentication with the agent fails,
220    /// - an SSH protocol error is encountered.
221    pub async fn connect(&self, username: &str) -> Result<Session, crate::Error> {
222        let user = &self.users.get(username).ok_or(Error::InvalidUser {
223            user: username.to_string(),
224        })?;
225        let host_id = user.hosts.sample(&mut rng(), 1).next();
226        let target = if let Some(host_id) = host_id.as_ref() {
227            &self.hosts.get(*host_id).ok_or(Error::InvalidSignstarHost {
228                host: host_id.to_string(),
229            })?
230        } else {
231            return Err(Error::InvalidOptions("at least one host must be defined".into()).into());
232        };
233        let client_auth_public_key = &user.user_public_key;
234
235        let config = Arc::new(Config {
236            inactivity_timeout: Some(Duration::from_secs(5)),
237            ..Default::default()
238        });
239
240        let stream = UnixStream::connect(&user.agent_socket)
241            .await
242            .map_err(|source| crate::Error::IoPath {
243                path: user.agent_socket.clone(),
244                context: "connecting to the Unix socket".to_string(),
245                source,
246            })?;
247        let mut future = AgentClient::connect(stream);
248        let mut session = connect(
249            config,
250            (target.host.clone(), target.port),
251            KeyValidator {
252                host: target.host.clone(),
253                port: target.port,
254                entries: target.known_hosts.clone(),
255            },
256        )
257        .await?;
258        let auth_res = session
259            .authenticate_publickey_with(
260                username,
261                client_auth_public_key.clone(),
262                Some(HashAlg::Sha512),
263                &mut future,
264            )
265            .await
266            .map_err(Error::Agent)?;
267
268        if let AuthResult::Failure {
269            remaining_methods,
270            partial_success,
271        } = auth_res
272        {
273            return Err(Error::AuthFailed {
274                remaining_methods,
275                partial_success,
276            }
277            .into());
278        }
279
280        Ok(Session {
281            session,
282            host: target.host.clone(),
283            port: target.port,
284        })
285    }
286
287    /// Return the first configuration file that exists in the filesystem out of
288    /// [`Self::CONFIG_ORDER`] list or returns a [`crate::Error::ConfigMissing`] error.
289    ///
290    /// # Errors
291    ///
292    /// Returns an error, if no configuration file is found.
293    fn first_existing_system_config() -> Result<(File, PathBuf), crate::Error> {
294        for path in Self::CONFIG_ORDER.iter().map(PathBuf::from) {
295            let Ok(path) = path.canonicalize() else {
296                info!("Unable to canonicalize path for configuration {path:?}. Skipping...");
297                continue;
298            };
299            let Ok(file) = OpenOptions::new().read(true).open(&path) else {
300                error!("Unable to open configuration {path:?}. Skipping...");
301                continue;
302            };
303
304            return Ok((file, path));
305        }
306        Err(crate::Error::ConfigMissing)
307    }
308
309    /// Return the configuration created from the first configuration file that exists in the
310    /// filesystem out of [`Self::CONFIG_ORDER`] list or returns a
311    /// [`crate::Error::ConfigMissing`] error.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error, if
316    /// - a configuration file cannot be found
317    /// - the file cannot be read to string
318    /// - the contents are not valid TOML
319    /// - the configuration fails validation
320    pub fn from_first_system_config() -> Result<Self, crate::Error> {
321        let (mut file, path) = Self::first_existing_system_config()?;
322        let mut data = String::new();
323        file.read_to_string(&mut data)
324            .map_err(|source| crate::Error::IoPath {
325                path,
326                context: "reading the config file".to_string(),
327                source,
328            })?;
329
330        Self::from_str(&data)
331    }
332
333    /// Reads the configuration file, validates it and returns the [`ConnectConfig`].
334    ///
335    /// # Errors
336    ///
337    /// Returns an error:
338    /// - [`crate::Error::Io`] if reading the config file fails
339    /// - [`crate::Error::Validation`] if the config file has invalid contents
340    pub fn from_config_file(file: impl AsRef<Path>) -> Result<Self, crate::Error> {
341        let path = file.as_ref();
342        let contents = read_to_string(path).map_err(|source| crate::Error::IoPath {
343            path: path.to_path_buf(),
344            context: "reading a configuration file".to_string(),
345            source,
346        })?;
347
348        Self::from_str(&contents)
349    }
350}
351
352impl FromStr for ConnectConfig {
353    type Err = crate::Error;
354
355    fn from_str(s: &str) -> Result<Self, Self::Err> {
356        let config: ConnectConfig = toml::from_str(s)?;
357        config
358            .validate()
359            .map_err(|source| crate::Error::Validation {
360                context: "reading configuration file".to_owned(),
361                source,
362            })?;
363
364        Ok(config)
365    }
366}
367
368/// Ensures, that SSH public keys are unique per user.
369///
370/// # Errors
371///
372/// Returns an error if an SSH public key is used by more than one user.
373fn validate_ssh_public_key_consistency(
374    users: &BTreeMap<String, ConnectOptions>,
375    _context: &(),
376) -> garde::Result {
377    let failures = users
378        .iter()
379        .map(|(user, connect_option)| (user, &connect_option.user_public_key))
380        .fold(
381            HashMap::<&PublicKey, Vec<&str>>::new(),
382            |mut acc, (user, user_public_key)| {
383                acc.entry(user_public_key).or_default().push(user);
384                acc
385            },
386        )
387        .into_iter()
388        .filter(|(_, users)| users.len() > 1)
389        .map(|(pk, users)| {
390            format!(
391                "Public key {pk} is shared by these users: {users}",
392                pk = pk.to_string(),
393                users = users.join(", ")
394            )
395        })
396        .collect::<Vec<_>>();
397    if !failures.is_empty() {
398        return Err(garde::Error::new(format!(
399            "The SSH public key used for a user cannot be used for another user:\n{failures}",
400            failures = failures.join("\n")
401        )));
402    }
403
404    Ok(())
405}
406
407/// Ensures, that the `known_hosts` entries are unique per `host:port` combinations.
408///
409/// # Errors
410///
411/// Returns an error if the `known_hosts` entries differ for any instance of a `host:port`
412/// combination.
413fn validate_known_hosts_consistency(
414    connect_options: &BTreeMap<String, SignstarHost>,
415    _context: &(),
416) -> garde::Result {
417    let failures = connect_options
418        .iter()
419        .map(|(host, config)| (host, &config.known_hosts))
420        .fold(
421            HashMap::<String, Vec<&str>>::new(),
422            |mut acc, (host, known_hosts)| {
423                for known_host in known_hosts {
424                    acc.entry(known_host.to_string()).or_default().push(host);
425                }
426                acc
427            },
428        )
429        .into_iter()
430        .filter(|(_, hosts)| hosts.len() > 1)
431        .map(|(entry, hosts)| {
432            format!(
433                "Entry {entry} is shared by these hosts: {hosts}",
434                hosts = hosts.join(", ")
435            )
436        })
437        .collect::<Vec<_>>();
438    if !failures.is_empty() {
439        return Err(garde::Error::new(format!(
440            "The known host entry used for a host cannot be used for another host:\n{failures}",
441            failures = failures.join("\n")
442        )));
443    }
444
445    Ok(())
446}
447
448/// Ensures, that each host:port combination is unique.
449///
450/// # Errors
451///
452/// Returns an error if there is a duplicate host:port combination.
453fn validate_host_port_uniqueness(
454    connect_options: &BTreeMap<String, SignstarHost>,
455    _context: &(),
456) -> garde::Result {
457    let failures = connect_options
458        .iter()
459        .map(|(host_id, config)| (host_id, &config.host, config.port))
460        .fold(
461            HashMap::<(&str, u16), Vec<&str>>::new(),
462            |mut acc, (host_id, host, port)| {
463                acc.entry((host, port)).or_default().push(host_id);
464
465                acc
466            },
467        )
468        .into_iter()
469        .filter(|(_, host_ids)| host_ids.len() > 1)
470        .map(|((host, port), host_ids)| {
471            format!(
472                "Host {host}:{port} is shared by these hosts: {host_ids}",
473                host_ids = host_ids.join(", ")
474            )
475        })
476        .collect::<Vec<_>>();
477    if !failures.is_empty() {
478        return Err(garde::Error::new(format!(
479            "Two connections cannot have the same host:port combinations:\n{failures}",
480            failures = failures.join("\n")
481        )));
482    }
483
484    Ok(())
485}
486
487/// Ensures, that each `host_id` in a [`ConnectConfig`] is defined.
488///
489/// # Errors
490///
491/// Returns an error if there are host_ids which are not defined.
492fn validate_host_ids(config: &ConnectConfig, _context: &()) -> garde::Result {
493    let failures = config
494        .users
495        .iter()
496        .flat_map(|(user_id, options)| {
497            options
498                .hosts
499                .iter()
500                .filter(|host| !config.hosts.contains_key(*host))
501                .map(move |invalid_host| (user_id, invalid_host))
502        })
503        .map(|(user, invalid_host)| {
504            format!("User {user} is using host_id which is not defined: {invalid_host}",)
505        })
506        .collect::<Vec<_>>();
507
508    if !failures.is_empty() {
509        return Err(garde::Error::new(failures.join("\n")));
510    }
511
512    Ok(())
513}
514
515/// Connection options for sending a signature request.
516///
517/// The options capture target host parameters and all necessary
518/// information related to authentication for both the client
519/// (client's public key and authentication agent) and server (a list
520/// of valid and known server public keys).
521///
522/// # Examples
523///
524/// ```no_run
525/// # fn main() -> testresult::TestResult {
526/// use signstar_client::ssh::client::ConnectOptions;
527///
528/// let options = ConnectOptions::new("localhost".into(), "ssh-ed25519 ...")?
529///     .agent_socket(std::env::var("SSH_AUTH_SOCK")?);
530/// # Ok(()) }
531/// ```
532#[derive(Debug, Deserialize, Validate)]
533pub struct ConnectOptions {
534    /// The path to the SSH agent socket used for the connection to `user` on the Signstar
535    /// system(s).
536    #[garde(skip)]
537    pub agent_socket: PathBuf,
538
539    /// The SSH public key used for the connections to `user` on the Signstar system(s).
540    #[garde(skip)]
541    pub user_public_key: PublicKey,
542
543    /// The list of Signstar hosts that the `user` can connect to.
544    #[garde(length(min = 1))]
545    pub hosts: Vec<String>,
546}
547
548impl ConnectOptions {
549    /// Sets the path to an OpenSSH agent socket for client authentication.
550    pub fn agent_socket(mut self, agent_socket: impl Into<PathBuf>) -> Self {
551        self.agent_socket = agent_socket.into();
552        self
553    }
554
555    /// Sets an SSH public key of a client for SSH authentication.
556    ///
557    /// # Examples
558    ///
559    /// ```
560    /// # fn main() -> testresult::TestResult {
561    /// use signstar_client::ssh::client::ConnectOptions;
562    ///
563    /// let client_pk =
564    ///     "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILHCXBJYlPPkrt2WYyP3SZoMx43lDBB5QALjE762EQlc";
565    /// let options = ConnectOptions::new("localhost".into(), client_pk)?;
566    /// #     Ok(()) }
567    /// ```
568    ///
569    /// # Errors
570    ///
571    /// Returns an error if the public key is not well-formatted. This
572    /// function only accepts public keys following the
573    /// [`authorized_keys` file format].
574    ///
575    /// [`authorized_keys` file format]: https://man.archlinux.org/man/core/openssh/sshd.8.en#AUTHORIZED_KEYS_FILE_FORMAT.
576    pub fn user_public_key(
577        mut self,
578        user_public_key: impl Into<String>,
579    ) -> Result<Self, crate::Error> {
580        self.user_public_key =
581            PublicKey::from_openssh(&user_public_key.into()).map_err(Error::SshKey)?;
582        Ok(self)
583    }
584
585    /// Constructs a new [`ConnectOptions`] with target host and client public key.
586    pub fn new(
587        host: String,
588        client_auth_public_key: impl AsRef<str>,
589    ) -> Result<Self, crate::Error> {
590        let client_auth_public_key =
591            PublicKey::from_openssh(client_auth_public_key.as_ref()).map_err(Error::SshKey)?;
592        Ok(Self {
593            hosts: vec![host],
594            agent_socket: Default::default(),
595            user_public_key: client_auth_public_key,
596        })
597    }
598}
599
600fn deserialize_entries<'de, D>(deserializer: D) -> Result<Vec<Entry>, D::Error>
601where
602    D: Deserializer<'de>,
603{
604    Vec::<String>::deserialize(deserializer)?
605        .into_iter()
606        .map(|entry: String| Entry::from_str(&entry).map_err(serde::de::Error::custom))
607        .collect::<Result<_, _>>()
608}
609
610/// Validator for a host's SSH keys and a list of `known_hosts` entries.
611///
612/// Tracks a `host` and its `port`, as well as a list of `entries` in the [SSH `known_hosts` file
613/// format].
614///
615/// [SSH `known_hosts` file format]: https://man.archlinux.org/man/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT
616struct KeyValidator {
617    host: String,
618    port: u16,
619    entries: Vec<Entry>,
620}
621
622impl Handler for KeyValidator {
623    type Error = Error;
624
625    /// Checks whether a set of server details can be found in SSH `known_hosts` data.
626    ///
627    /// Based on a `host` and its `port`, this function evaluates whether a supplied `key` is part
628    /// of a list of `entries` in the SSH known_hosts file format. Returns `true`, if the
629    /// combination of `key`, `host` and `port` matches an entry in the list of `entries` and that
630    /// entry is not a CA key or a revoked key. Returns `false` in all other cases.
631    async fn check_server_key(
632        &mut self,
633        server_public_key: &PublicKeyOrCertificate,
634    ) -> Result<bool, Error> {
635        if let PublicKeyOrCertificate::PublicKey { key, .. } = server_public_key {
636            Ok(crate::ssh::known_hosts::is_server_known(
637                self.entries.iter(),
638                &self.host,
639                self.port,
640                key,
641            ))
642        } else {
643            Ok(false)
644        }
645    }
646}
647
648/// An open session with a host that can be used to send multiple signing requests.
649pub struct Session {
650    session: Handle<KeyValidator>,
651    host: String,
652    port: u16,
653}
654
655impl std::fmt::Debug for Session {
656    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657        write!(
658            f,
659            "SSH session for host {} on port {}",
660            self.host, self.port
661        )
662    }
663}
664
665impl Session {
666    /// Send a signing request to the server and return a signing response.
667    ///
668    /// # Examples
669    ///
670    /// ```no_run
671    /// # async fn sign() -> testresult::TestResult {
672    /// use signstar_client::ssh::client::ConnectConfig;
673    /// use signstar_common::request_api::Request;
674    ///
675    /// let options = ConnectConfig::from_first_system_config()?;
676    ///
677    /// let mut session = options.connect("user").await?;
678    /// let request = Request::for_file("package")?;
679    /// let response = session.send(&request).await?;
680    /// // process response
681    /// #     Ok(()) }
682    /// ```
683    ///
684    /// # Errors
685    ///
686    /// Returns an error if sending or processing the signing request fails:
687    /// - if the remote server rejects the signing request,
688    /// - if the remote application exits unexpectedly,
689    /// - the returned data cannot be deserialized into a [`Response`],
690    /// - if an SSH protocol error is encountered.
691    pub async fn send(&mut self, data: &Request) -> Result<Response, crate::Error> {
692        let mut channel = self
693            .session
694            .channel_open_session()
695            .await
696            .map_err(Error::SshProtocol)?;
697        // the command name is empty as it is assumed that the server will
698        // pick correct binary anyway
699        let command_name = b"";
700        channel
701            .exec(true, command_name)
702            .await
703            .map_err(Error::SshProtocol)?;
704        let data = serde_json::to_vec(&data).map_err(|source| Error::Serialization { source })?;
705        channel
706            .data(data.as_ref())
707            .await
708            .map_err(Error::SshProtocol)?;
709        channel.eof().await.map_err(Error::SshProtocol)?;
710
711        let mut code = None;
712        let mut stdout = vec![];
713
714        while let Some(msg) = channel.wait().await {
715            match msg {
716                ChannelMsg::Data { ref data } => {
717                    stdout.extend(data.iter());
718                }
719                ChannelMsg::ExitStatus { exit_status } => {
720                    code = Some(exit_status);
721                    // cannot leave the loop immediately, there might still be more data to receive
722                }
723                _ => {}
724            }
725        }
726
727        if let Some(code) = code {
728            if let Some(status_code) = NonZeroU32::new(code) {
729                Err(Error::RemoteApplicationFailure { status_code }.into())
730            } else {
731                Ok(serde_json::from_slice(&stdout)
732                    .map_err(|source| Error::Deserialization { source })?)
733            }
734        } else {
735            Err(Error::UncleanExit.into())
736        }
737    }
738
739    /// Close the authentication session.
740    ///
741    /// This function cleanly closes the session and informs the
742    /// server that no further requests will be sent.
743    ///
744    /// # Examples
745    ///
746    /// This example shows that after the [`Session::close`] function is invoked no further requests
747    /// can be sent.
748    ///
749    /// ```compile_fail
750    /// # async fn sign() -> testresult::TestResult {
751    /// use signstar_client::ssh::client::ConnectConfig;
752    ///
753    /// let options = ConnectConfig::from_first_system_config();
754    ///
755    /// let mut session = options.connect("user").await?;
756    /// session.close();
757    ///
758    /// // the session object has been consumed and cannot be reused
759    /// let request = Request::for_file("package")?;
760    /// let response = session.send(&request).await?;
761    /// #     Ok(()) }
762    /// ```
763    ///
764    /// # Errors
765    ///
766    /// Returns an error if at any stage of the connecting process fails:
767    /// - if the client public key is not set,
768    /// - if the server public key is not pinned in the known hosts file,
769    /// - if the client public key is not recognized by the server,
770    /// - if the client authentication with the agent fails,
771    /// - if an SSH protocol error is encountered.
772    pub async fn close(self) -> Result<(), crate::Error> {
773        Ok(self
774            .session
775            .disconnect(
776                Disconnect::ByApplication,
777                "Client is closing the connection",
778                "en",
779            )
780            .await
781            .map_err(Error::SshProtocol)?)
782    }
783}
784
785#[cfg(test)]
786mod tests {
787
788    use garde::Validate;
789    use insta::assert_snapshot;
790    use testresult::TestResult;
791
792    use super::*;
793
794    #[test]
795    fn validate_success() -> TestResult {
796        let config = ConnectConfig {
797                users: [("test-user".into(), ConnectOptions::new(
798                    "local".into(),
799                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
800                )?)].into_iter().collect(),
801                hosts: [("local".into(), SignstarHost {
802                    host: "127.0.0.1".into(),
803                    port: 22,
804                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
805                })].into_iter().collect(),
806            };
807
808        config.validate()?;
809
810        Ok(())
811    }
812
813    #[test]
814    fn validate_ssh_public_key_cannot_be_reused() -> TestResult {
815        let config = ConnectConfig {
816                users: [("test-user".into(), ConnectOptions::new(
817                    "local".into(),
818                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
819                )?),("test-user2".into(), ConnectOptions::new(
820                    "local".into(),
821                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
822                )?)].into_iter().collect(),
823                hosts: [("local".into(), SignstarHost {
824                    host: "127.0.0.1".into(),
825                    port: 22,
826                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
827                })].into_iter().collect(),
828            };
829
830        let error_msg = match config.validate() {
831            Ok(()) => {
832                panic!("Expected to fail with garde::Error, but succeeded instead.")
833            }
834            Err(error) => error.to_string(),
835        };
836
837        assert_snapshot!(error_msg);
838
839        Ok(())
840    }
841
842    #[test]
843    fn validate_known_hosts_entries_unique_per_host_port_of_the_user() -> TestResult {
844        let config = ConnectConfig {
845                users: [("test-user".into(), ConnectOptions::new(
846                    "local".into(),
847                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
848                )?),("test-user2".into(), ConnectOptions::new(
849                    "local2".into(),
850                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
851                )?)].into_iter().collect(),
852                hosts: [("local".into(), SignstarHost {
853                    host: "127.0.0.1".into(),
854                    port: 22,
855                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
856                }),("local2".into(), SignstarHost {
857                    host: "127.0.0.2".into(),
858                    port: 22,
859                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
860                })].into_iter().collect(),
861            };
862
863        let error_msg = match config.validate() {
864            Ok(()) => {
865                panic!("Expected to fail with garde::Error, but succeeded instead.")
866            }
867            Err(error) => error.to_string(),
868        };
869
870        assert_snapshot!(error_msg);
871
872        Ok(())
873    }
874
875    #[test]
876    fn validate_host_port_uniqueness() -> TestResult {
877        let config = ConnectConfig {
878                users: [("test-user".into(), ConnectOptions::new(
879                    "local".into(),
880                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
881                )?),("test-user2".into(), ConnectOptions::new(
882                    "local2".into(),
883                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
884                )?)].into_iter().collect(),
885                hosts: [("local".into(), SignstarHost {
886                    host: "127.0.0.1".into(),
887                    port: 22,
888                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAD")?],
889                }),("local2".into(), SignstarHost {
890                    host: "127.0.0.1".into(),
891                    port: 22,
892                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
893                })].into_iter().collect(),
894            };
895
896        let error_msg = match config.validate() {
897            Ok(()) => {
898                panic!("Expected to fail with garde::Error, but succeeded instead.")
899            }
900            Err(error) => error.to_string(),
901        };
902
903        assert_snapshot!(error_msg);
904
905        Ok(())
906    }
907
908    #[test]
909    fn validate_host_ids() -> TestResult {
910        let config = ConnectConfig {
911                users: [("test-user".into(), ConnectOptions::new(
912                    "test".into(),
913                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
914                )?),("test-user2".into(), ConnectOptions::new(
915                    "test".into(),
916                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
917                )?)].into_iter().collect(),
918                hosts: [("test2".into(), SignstarHost {
919                    host: "127.0.0.1".into(),
920                    port: 22,
921                    known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
922                })].into_iter().collect(),
923            };
924
925        let error_msg = match config.validate() {
926            Ok(()) => {
927                panic!("Expected to fail with garde::Error, but succeeded instead.")
928            }
929            Err(error) => error.to_string(),
930        };
931
932        assert_snapshot!(error_msg);
933
934        Ok(())
935    }
936}