1use std::collections::{BTreeMap, HashMap};
22use std::fs::read;
23use std::num::NonZeroU32;
24use std::path::Path;
25use std::str::FromStr;
26use std::{path::PathBuf, sync::Arc, time::Duration};
27
28use garde::Validate;
29use rand::seq::SliceRandom;
30use rand::thread_rng;
31use russh::client::AuthResult;
32use russh::keys::PublicKeyOrCertificate;
33use russh::keys::agent::client::AgentClient;
34use russh::keys::ssh_key::known_hosts::Entry;
35use russh::keys::ssh_key::{HashAlg, PublicKey};
36use russh::{ChannelMsg, Disconnect, MethodSet, client};
37use serde::{Deserialize, Deserializer};
38use tokio::net::UnixStream;
39
40use crate::{Request, Response};
41
42#[derive(Debug, thiserror::Error)]
44pub enum Error {
45 #[error("Invalid options used: {0}")]
47 InvalidOptions(String),
48
49 #[error("Authentication failed")]
51 AuthFailed {
52 remaining_methods: MethodSet,
54
55 partial_success: bool,
58 },
59
60 #[error("User {user} is not defined in the config file")]
62 InvalidUser {
63 user: String,
65 },
66
67 #[error("Host {host} is not defined in the config file")]
69 InvalidSignstarHost {
70 host: String,
72 },
73
74 #[error("I/O error: {source} when processing {file}")]
76 Io {
77 file: PathBuf,
82
83 source: std::io::Error,
85 },
86
87 #[error("Program did not exit cleanly")]
89 UncleanExit,
90
91 #[error("Remote application failed with status code: {status_code}")]
93 RemoteApplicationFailure {
94 status_code: NonZeroU32,
96 },
97
98 #[error("SSH protocol error: {0}")]
100 SshProtocol(#[from] russh::Error),
101
102 #[error("SSH format error: {0}")]
104 SshFormat(#[from] russh::keys::Error),
105
106 #[error("SSH agent error: {0}")]
108 Agent(#[from] russh::AgentAuthError),
109
110 #[error("Serde serialization error: {0}")]
112 Serialization(#[from] serde_json::Error),
113}
114
115type Result<T> = std::result::Result<T, Error>;
116
117pub const DEFAULT_CONFIG: &str = "/usr/share/signstar/request-signature.toml";
119
120pub const RUN_OVERRIDE_CONFIG: &str = "/run/signstar/request-signature.toml";
122
123pub const ETC_OVERRIDE_CONFIG: &str = "/etc/signstar/request-signature.toml";
125
126#[derive(Debug, Deserialize, Validate)]
128pub struct SignstarHost {
129 #[garde(length(min = 1))]
131 host: String,
132
133 #[garde(range(min = 1))]
135 port: u16,
136
137 #[serde(deserialize_with = "deserialize_entries")]
139 #[garde(length(min = 1))]
140 known_hosts: Vec<Entry>,
141}
142
143#[derive(Debug, Default, Deserialize, Validate)]
147#[garde(custom(validate_host_ids))]
148pub struct ConnectConfig {
149 #[garde(
151 custom(validate_host_port_uniqueness),
152 custom(validate_known_hosts_consistency),
153 dive
154 )]
155 pub hosts: BTreeMap<String, SignstarHost>,
156
157 #[garde(custom(validate_ssh_public_key_consistency), dive)]
159 pub users: BTreeMap<String, ConnectOptions>,
160}
161
162impl ConnectConfig {
163 pub const CONFIG_ORDER: &[&str] = &[ETC_OVERRIDE_CONFIG, RUN_OVERRIDE_CONFIG, DEFAULT_CONFIG];
170
171 pub async fn connect(&self, username: &str) -> Result<Session> {
199 let user = &self.users.get(username).ok_or(Error::InvalidUser {
200 user: username.to_string(),
201 })?;
202 let host_id = user.hosts.choose(&mut thread_rng());
203 let target = if let Some(host_id) = host_id {
204 &self.hosts.get(host_id).ok_or(Error::InvalidSignstarHost {
205 host: host_id.to_string(),
206 })?
207 } else {
208 return Err(Error::InvalidOptions(
209 "at least one host must be defined".into(),
210 ));
211 };
212 let client_auth_public_key = &user.user_public_key;
213
214 let config = Arc::new(client::Config {
215 inactivity_timeout: Some(Duration::from_secs(5)),
216 ..Default::default()
217 });
218
219 let stream = UnixStream::connect(&user.agent_socket)
220 .await
221 .map_err(|source| Error::Io {
222 file: user.agent_socket.clone(),
223 source,
224 })?;
225 let mut future = AgentClient::connect(stream);
226 let mut session = client::connect(
227 config,
228 (target.host.clone(), target.port),
229 KeyValidator {
230 host: target.host.clone(),
231 port: target.port,
232 entries: target.known_hosts.clone(),
233 },
234 )
235 .await?;
236 let auth_res = session
237 .authenticate_publickey_with(
238 username,
239 client_auth_public_key.clone(),
240 Some(HashAlg::Sha512),
241 &mut future,
242 )
243 .await?;
244
245 if let AuthResult::Failure {
246 remaining_methods,
247 partial_success,
248 } = auth_res
249 {
250 return Err(Error::AuthFailed {
251 remaining_methods,
252 partial_success,
253 });
254 }
255
256 Ok(Session {
257 session,
258 host: target.host.clone(),
259 port: target.port,
260 })
261 }
262
263 fn first_existing_system_config() -> std::result::Result<PathBuf, crate::Error> {
272 let candidates = Self::CONFIG_ORDER.iter();
273 for file in candidates {
274 let file = PathBuf::from(file);
275 if std::fs::exists(&file).map_err(|source| crate::Error::Io {
276 file: file.clone(),
277 source,
278 })? {
279 return Ok(file);
280 }
281 }
282 Err(crate::Error::ConfigMissing)
283 }
284
285 pub fn from_first_system_config() -> std::result::Result<Self, crate::Error> {
296 Self::from_config_file(Self::first_existing_system_config()?)
297 }
298
299 pub fn from_config_file(file: impl AsRef<Path>) -> std::result::Result<Self, crate::Error> {
307 let contents = read(file.as_ref()).map_err(|source| crate::Error::Io {
308 file: file.as_ref().into(),
309 source,
310 })?;
311 let config: Self = toml::from_slice(&contents)?;
312 config
313 .validate()
314 .map_err(|source| crate::Error::Validation {
315 context: "reading configuration file".to_owned(),
316 source,
317 })?;
318 Ok(config)
319 }
320}
321
322fn validate_ssh_public_key_consistency(
328 users: &BTreeMap<String, ConnectOptions>,
329 _context: &(),
330) -> garde::Result {
331 let failures = users
332 .iter()
333 .map(|(user, connect_option)| (user, &connect_option.user_public_key))
334 .fold(
335 HashMap::<&PublicKey, Vec<&str>>::new(),
336 |mut acc, (user, user_public_key)| {
337 acc.entry(user_public_key).or_default().push(user);
338 acc
339 },
340 )
341 .into_iter()
342 .filter(|(_, users)| users.len() > 1)
343 .map(|(pk, users)| {
344 format!(
345 "Public key {pk} is shared by these users: {users}",
346 pk = pk.to_string(),
347 users = users.join(", ")
348 )
349 })
350 .collect::<Vec<_>>();
351 if !failures.is_empty() {
352 return Err(garde::Error::new(format!(
353 "The SSH public key used for a user cannot be used for another user:\n{failures}",
354 failures = failures.join("\n")
355 )));
356 }
357
358 Ok(())
359}
360
361fn validate_known_hosts_consistency(
368 connect_options: &BTreeMap<String, SignstarHost>,
369 _context: &(),
370) -> garde::Result {
371 let failures = connect_options
372 .iter()
373 .map(|(host, config)| (host, &config.known_hosts))
374 .fold(
375 HashMap::<String, Vec<&str>>::new(),
376 |mut acc, (host, known_hosts)| {
377 for known_host in known_hosts {
378 acc.entry(known_host.to_string()).or_default().push(host);
379 }
380 acc
381 },
382 )
383 .into_iter()
384 .filter(|(_, hosts)| hosts.len() > 1)
385 .map(|(entry, hosts)| {
386 format!(
387 "Entry {entry} is shared by these hosts: {hosts}",
388 hosts = hosts.join(", ")
389 )
390 })
391 .collect::<Vec<_>>();
392 if !failures.is_empty() {
393 return Err(garde::Error::new(format!(
394 "The known host entry used for a host cannot be used for another host:\n{failures}",
395 failures = failures.join("\n")
396 )));
397 }
398
399 Ok(())
400}
401
402fn validate_host_port_uniqueness(
408 connect_options: &BTreeMap<String, SignstarHost>,
409 _context: &(),
410) -> garde::Result {
411 let failures = connect_options
412 .iter()
413 .map(|(host_id, config)| (host_id, &config.host, config.port))
414 .fold(
415 HashMap::<(&str, u16), Vec<&str>>::new(),
416 |mut acc, (host_id, host, port)| {
417 acc.entry((host, port)).or_default().push(host_id);
418
419 acc
420 },
421 )
422 .into_iter()
423 .filter(|(_, host_ids)| host_ids.len() > 1)
424 .map(|((host, port), host_ids)| {
425 format!(
426 "Host {host}:{port} is shared by these hosts: {host_ids}",
427 host_ids = host_ids.join(", ")
428 )
429 })
430 .collect::<Vec<_>>();
431 if !failures.is_empty() {
432 return Err(garde::Error::new(format!(
433 "Two connections cannot have the same host:port combinations:\n{failures}",
434 failures = failures.join("\n")
435 )));
436 }
437
438 Ok(())
439}
440
441fn validate_host_ids(config: &ConnectConfig, _context: &()) -> garde::Result {
447 let failures = config
448 .users
449 .iter()
450 .flat_map(|(user_id, options)| {
451 options
452 .hosts
453 .iter()
454 .filter(|host| !config.hosts.contains_key(*host))
455 .map(move |invalid_host| (user_id, invalid_host))
456 })
457 .map(|(user, invalid_host)| {
458 format!("User {user} is using host_id which is not defined: {invalid_host}",)
459 })
460 .collect::<Vec<_>>();
461
462 if !failures.is_empty() {
463 return Err(garde::Error::new(failures.join("\n")));
464 }
465
466 Ok(())
467}
468
469#[derive(Debug, Deserialize, Validate)]
487pub struct ConnectOptions {
488 #[garde(skip)]
491 pub agent_socket: PathBuf,
492
493 #[garde(skip)]
495 pub user_public_key: PublicKey,
496
497 #[garde(length(min = 1))]
499 pub hosts: Vec<String>,
500}
501
502impl ConnectOptions {
503 pub fn agent_socket(mut self, agent_socket: impl Into<PathBuf>) -> Self {
505 self.agent_socket = agent_socket.into();
506 self
507 }
508
509 pub fn user_public_key(mut self, user_public_key: impl Into<String>) -> Result<Self> {
531 self.user_public_key =
532 PublicKey::from_openssh(&user_public_key.into()).map_err(russh::keys::Error::SshKey)?;
533 Ok(self)
534 }
535
536 pub fn new(host: String, client_auth_public_key: impl AsRef<str>) -> Result<Self> {
538 let client_auth_public_key = PublicKey::from_openssh(client_auth_public_key.as_ref())
539 .map_err(russh::keys::Error::SshKey)?;
540 Ok(Self {
541 hosts: vec![host],
542 agent_socket: Default::default(),
543 user_public_key: client_auth_public_key,
544 })
545 }
546}
547
548fn deserialize_entries<'de, D>(deserializer: D) -> std::result::Result<Vec<Entry>, D::Error>
549where
550 D: Deserializer<'de>,
551{
552 Vec::<String>::deserialize(deserializer)?
553 .into_iter()
554 .map(|entry: String| Entry::from_str(&entry).map_err(serde::de::Error::custom))
555 .collect::<std::result::Result<_, _>>()
556}
557
558struct KeyValidator {
565 host: String,
566 port: u16,
567 entries: Vec<Entry>,
568}
569
570impl client::Handler for KeyValidator {
571 type Error = Error;
572
573 async fn check_server_key(
580 &mut self,
581 server_public_key: &PublicKeyOrCertificate,
582 ) -> Result<bool> {
583 if let PublicKeyOrCertificate::PublicKey { key, .. } = server_public_key {
584 Ok(crate::ssh::known_hosts::is_server_known(
585 self.entries.iter(),
586 &self.host,
587 self.port,
588 key,
589 ))
590 } else {
591 Ok(false)
592 }
593 }
594}
595
596pub struct Session {
598 session: client::Handle<KeyValidator>,
599 host: String,
600 port: u16,
601}
602
603impl std::fmt::Debug for Session {
604 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605 write!(
606 f,
607 "SSH session for host {} on port {}",
608 self.host, self.port
609 )
610 }
611}
612
613impl Session {
614 pub async fn send(&mut self, data: &Request) -> Result<Response> {
640 let mut channel = self.session.channel_open_session().await?;
641 let command_name = b"";
644 channel.exec(true, command_name).await?;
645 let data = serde_json::to_vec(&data)?;
646 channel.data(data.as_ref()).await?;
647 channel.eof().await?;
648
649 let mut code = None;
650 let mut stdout = vec![];
651
652 while let Some(msg) = channel.wait().await {
653 match msg {
654 ChannelMsg::Data { ref data } => {
655 stdout.extend(data.iter());
656 }
657 ChannelMsg::ExitStatus { exit_status } => {
658 code = Some(exit_status);
659 }
661 _ => {}
662 }
663 }
664
665 if let Some(code) = code {
666 if let Some(status_code) = NonZeroU32::new(code) {
667 Err(Error::RemoteApplicationFailure { status_code })
668 } else {
669 Ok(serde_json::from_slice(&stdout)?)
670 }
671 } else {
672 Err(Error::UncleanExit)
673 }
674 }
675
676 pub async fn close(self) -> Result<()> {
710 self.session
711 .disconnect(
712 Disconnect::ByApplication,
713 "Client is closing the connection",
714 "en",
715 )
716 .await?;
717 Ok(())
718 }
719}
720
721#[cfg(test)]
722mod tests {
723
724 use garde::Validate;
725 use insta::assert_snapshot;
726 use testresult::TestResult;
727
728 use super::*;
729
730 #[test]
731 fn validate_success() -> TestResult {
732 let config = ConnectConfig {
733 users: [("test-user".into(), ConnectOptions::new(
734 "local".into(),
735 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
736 )?)].into_iter().collect(),
737 hosts: [("local".into(), SignstarHost {
738 host: "127.0.0.1".into(),
739 port: 22,
740 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
741 })].into_iter().collect(),
742 };
743
744 config.validate()?;
745
746 Ok(())
747 }
748
749 #[test]
750 fn validate_ssh_public_key_cannot_be_reused() -> TestResult {
751 let config = ConnectConfig {
752 users: [("test-user".into(), ConnectOptions::new(
753 "local".into(),
754 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
755 )?),("test-user2".into(), ConnectOptions::new(
756 "local".into(),
757 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
758 )?)].into_iter().collect(),
759 hosts: [("local".into(), SignstarHost {
760 host: "127.0.0.1".into(),
761 port: 22,
762 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
763 })].into_iter().collect(),
764 };
765
766 let error_msg = match config.validate() {
767 Ok(()) => {
768 panic!("Expected to fail with garde::Error, but succeeded instead.")
769 }
770 Err(error) => error.to_string(),
771 };
772
773 assert_snapshot!(error_msg);
774
775 Ok(())
776 }
777
778 #[test]
779 fn validate_known_hosts_entries_unique_per_host_port_of_the_user() -> TestResult {
780 let config = ConnectConfig {
781 users: [("test-user".into(), ConnectOptions::new(
782 "local".into(),
783 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
784 )?),("test-user2".into(), ConnectOptions::new(
785 "local2".into(),
786 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
787 )?)].into_iter().collect(),
788 hosts: [("local".into(), SignstarHost {
789 host: "127.0.0.1".into(),
790 port: 22,
791 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
792 }),("local2".into(), SignstarHost {
793 host: "127.0.0.2".into(),
794 port: 22,
795 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
796 })].into_iter().collect(),
797 };
798
799 let error_msg = match config.validate() {
800 Ok(()) => {
801 panic!("Expected to fail with garde::Error, but succeeded instead.")
802 }
803 Err(error) => error.to_string(),
804 };
805
806 assert_snapshot!(error_msg);
807
808 Ok(())
809 }
810
811 #[test]
812 fn validate_host_port_uniqueness() -> TestResult {
813 let config = ConnectConfig {
814 users: [("test-user".into(), ConnectOptions::new(
815 "local".into(),
816 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
817 )?),("test-user2".into(), ConnectOptions::new(
818 "local2".into(),
819 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
820 )?)].into_iter().collect(),
821 hosts: [("local".into(), SignstarHost {
822 host: "127.0.0.1".into(),
823 port: 22,
824 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAD")?],
825 }),("local2".into(), SignstarHost {
826 host: "127.0.0.1".into(),
827 port: 22,
828 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
829 })].into_iter().collect(),
830 };
831
832 let error_msg = match config.validate() {
833 Ok(()) => {
834 panic!("Expected to fail with garde::Error, but succeeded instead.")
835 }
836 Err(error) => error.to_string(),
837 };
838
839 assert_snapshot!(error_msg);
840
841 Ok(())
842 }
843
844 #[test]
845 fn validate_host_ids() -> TestResult {
846 let config = ConnectConfig {
847 users: [("test-user".into(), ConnectOptions::new(
848 "test".into(),
849 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
850 )?),("test-user2".into(), ConnectOptions::new(
851 "test".into(),
852 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
853 )?)].into_iter().collect(),
854 hosts: [("test2".into(), SignstarHost {
855 host: "127.0.0.1".into(),
856 port: 22,
857 known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
858 })].into_iter().collect(),
859 };
860
861 let error_msg = match config.validate() {
862 Ok(()) => {
863 panic!("Expected to fail with garde::Error, but succeeded instead.")
864 }
865 Err(error) => error.to_string(),
866 };
867
868 assert_snapshot!(error_msg);
869
870 Ok(())
871 }
872}