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