1use 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#[derive(Debug, thiserror::Error)]
55pub enum Error {
56 #[error("Invalid options used: {0}")]
58 InvalidOptions(String),
59
60 #[error("Authentication failed")]
62 AuthFailed {
63 remaining_methods: MethodSet,
65
66 partial_success: bool,
69 },
70
71 #[error("User {user} is not defined in the config file")]
73 InvalidUser {
74 user: String,
76 },
77
78 #[error("Host {host} is not defined in the config file")]
80 InvalidSignstarHost {
81 host: String,
83 },
84
85 #[error("I/O error: {source} when processing {file}")]
87 Io {
88 file: PathBuf,
93
94 source: std::io::Error,
96 },
97
98 #[error("Program did not exit cleanly")]
100 UncleanExit,
101
102 #[error("Remote application failed with status code: {status_code}")]
104 RemoteApplicationFailure {
105 status_code: NonZeroU32,
107 },
108
109 #[error("SSH protocol error: {0}")]
111 SshProtocol(#[from] russh::Error),
112
113 #[error("SSH format error: {0}")]
115 SshFormat(#[from] russh::keys::Error),
116
117 #[error("SSH key error: {0}")]
119 SshKey(#[from] russh::keys::ssh_key::Error),
120
121 #[error("SSH agent error: {0}")]
123 Agent(#[from] russh::AgentAuthError),
124
125 #[error("Serde serialization error: {source}")]
127 Serialization {
128 source: serde_json::Error,
130 },
131
132 #[error("Serde derialization error: {source}")]
134 Deserialization {
135 source: serde_json::Error,
137 },
138}
139
140pub const DEFAULT_CONFIG: &str = "/usr/share/signstar/request-signature.toml";
142
143pub const RUN_OVERRIDE_CONFIG: &str = "/run/signstar/request-signature.toml";
145
146pub const ETC_OVERRIDE_CONFIG: &str = "/etc/signstar/request-signature.toml";
148
149#[derive(Debug, Deserialize, Validate)]
151pub struct SignstarHost {
152 #[garde(length(min = 1))]
154 host: String,
155
156 #[garde(range(min = 1))]
158 port: u16,
159
160 #[serde(deserialize_with = "deserialize_entries")]
162 #[garde(length(min = 1))]
163 known_hosts: Vec<Entry>,
164}
165
166#[derive(Debug, Default, Deserialize, Validate)]
170#[garde(custom(validate_host_ids))]
171pub struct ConnectConfig {
172 #[garde(
174 custom(validate_host_port_uniqueness),
175 custom(validate_known_hosts_consistency),
176 dive
177 )]
178 pub hosts: BTreeMap<String, SignstarHost>,
179
180 #[garde(custom(validate_ssh_public_key_consistency), dive)]
182 pub users: BTreeMap<String, ConnectOptions>,
183}
184
185impl ConnectConfig {
186 pub const CONFIG_ORDER: &[&str] = &[ETC_OVERRIDE_CONFIG, RUN_OVERRIDE_CONFIG, DEFAULT_CONFIG];
193
194 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 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 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 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
368fn 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
407fn 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
448fn 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
487fn 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#[derive(Debug, Deserialize, Validate)]
533pub struct ConnectOptions {
534 #[garde(skip)]
537 pub agent_socket: PathBuf,
538
539 #[garde(skip)]
541 pub user_public_key: PublicKey,
542
543 #[garde(length(min = 1))]
545 pub hosts: Vec<String>,
546}
547
548impl ConnectOptions {
549 pub fn agent_socket(mut self, agent_socket: impl Into<PathBuf>) -> Self {
551 self.agent_socket = agent_socket.into();
552 self
553 }
554
555 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 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
610struct KeyValidator {
617 host: String,
618 port: u16,
619 entries: Vec<Entry>,
620}
621
622impl Handler for KeyValidator {
623 type Error = Error;
624
625 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
648pub 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 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 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 }
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 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}