use std::{
collections::{BTreeMap, HashMap},
fs::{File, OpenOptions, read_to_string},
io::Read,
num::NonZeroU32,
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
time::Duration,
};
use garde::Validate;
use log::{error, info};
use rand::{rng, seq::IndexedRandom};
use russh::{
ChannelMsg,
Disconnect,
MethodSet,
client::{AuthResult, Config, Handle, Handler, connect},
keys::{
PublicKeyOrCertificate,
agent::client::AgentClient,
ssh_key::{
known_hosts::Entry,
{HashAlg, PublicKey},
},
},
};
use serde::{Deserialize, Deserializer};
use signstar_common::request_api::{Request, Response};
use tokio::net::UnixStream;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Invalid options used: {0}")]
InvalidOptions(String),
#[error("Authentication failed")]
AuthFailed {
remaining_methods: MethodSet,
partial_success: bool,
},
#[error("User {user} is not defined in the config file")]
InvalidUser {
user: String,
},
#[error("Host {host} is not defined in the config file")]
InvalidSignstarHost {
host: String,
},
#[error("I/O error: {source} when processing {file}")]
Io {
file: PathBuf,
source: std::io::Error,
},
#[error("Program did not exit cleanly")]
UncleanExit,
#[error("Remote application failed with status code: {status_code}")]
RemoteApplicationFailure {
status_code: NonZeroU32,
},
#[error("SSH protocol error: {0}")]
SshProtocol(#[from] russh::Error),
#[error("SSH format error: {0}")]
SshFormat(#[from] russh::keys::Error),
#[error("SSH key error: {0}")]
SshKey(#[from] russh::keys::ssh_key::Error),
#[error("SSH agent error: {0}")]
Agent(#[from] russh::AgentAuthError),
#[error("Serde serialization error: {source}")]
Serialization {
source: serde_json::Error,
},
#[error("Serde derialization error: {source}")]
Deserialization {
source: serde_json::Error,
},
}
pub const DEFAULT_CONFIG: &str = "/usr/share/signstar/request-signature.toml";
pub const RUN_OVERRIDE_CONFIG: &str = "/run/signstar/request-signature.toml";
pub const ETC_OVERRIDE_CONFIG: &str = "/etc/signstar/request-signature.toml";
#[derive(Debug, Deserialize, Validate)]
pub struct SignstarHost {
#[garde(length(min = 1))]
host: String,
#[garde(range(min = 1))]
port: u16,
#[serde(deserialize_with = "deserialize_entries")]
#[garde(length(min = 1))]
known_hosts: Vec<Entry>,
}
#[derive(Debug, Default, Deserialize, Validate)]
#[garde(custom(validate_host_ids))]
pub struct ConnectConfig {
#[garde(
custom(validate_host_port_uniqueness),
custom(validate_known_hosts_consistency),
dive
)]
pub hosts: BTreeMap<String, SignstarHost>,
#[garde(custom(validate_ssh_public_key_consistency), dive)]
pub users: BTreeMap<String, ConnectOptions>,
}
impl ConnectConfig {
pub const CONFIG_ORDER: &[&str] = &[ETC_OVERRIDE_CONFIG, RUN_OVERRIDE_CONFIG, DEFAULT_CONFIG];
pub async fn connect(&self, username: &str) -> Result<Session, crate::Error> {
let user = &self.users.get(username).ok_or(Error::InvalidUser {
user: username.to_string(),
})?;
let host_id = user.hosts.sample(&mut rng(), 1).next();
let target = if let Some(host_id) = host_id.as_ref() {
&self.hosts.get(*host_id).ok_or(Error::InvalidSignstarHost {
host: host_id.to_string(),
})?
} else {
return Err(Error::InvalidOptions("at least one host must be defined".into()).into());
};
let client_auth_public_key = &user.user_public_key;
let config = Arc::new(Config {
inactivity_timeout: Some(Duration::from_secs(5)),
..Default::default()
});
let stream = UnixStream::connect(&user.agent_socket)
.await
.map_err(|source| crate::Error::IoPath {
path: user.agent_socket.clone(),
context: "connecting to the Unix socket".to_string(),
source,
})?;
let mut future = AgentClient::connect(stream);
let mut session = connect(
config,
(target.host.clone(), target.port),
KeyValidator {
host: target.host.clone(),
port: target.port,
entries: target.known_hosts.clone(),
},
)
.await?;
let auth_res = session
.authenticate_publickey_with(
username,
client_auth_public_key.clone(),
Some(HashAlg::Sha512),
&mut future,
)
.await
.map_err(Error::Agent)?;
if let AuthResult::Failure {
remaining_methods,
partial_success,
} = auth_res
{
return Err(Error::AuthFailed {
remaining_methods,
partial_success,
}
.into());
}
Ok(Session {
session,
host: target.host.clone(),
port: target.port,
})
}
fn first_existing_system_config() -> Result<(File, PathBuf), crate::Error> {
for path in Self::CONFIG_ORDER.iter().map(PathBuf::from) {
let Ok(path) = path.canonicalize() else {
info!("Unable to canonicalize path for configuration {path:?}. Skipping...");
continue;
};
let Ok(file) = OpenOptions::new().read(true).open(&path) else {
error!("Unable to open configuration {path:?}. Skipping...");
continue;
};
return Ok((file, path));
}
Err(crate::Error::ConfigMissing)
}
pub fn from_first_system_config() -> Result<Self, crate::Error> {
let (mut file, path) = Self::first_existing_system_config()?;
let mut data = String::new();
file.read_to_string(&mut data)
.map_err(|source| crate::Error::IoPath {
path,
context: "reading the config file".to_string(),
source,
})?;
Self::from_str(&data)
}
pub fn from_config_file(file: impl AsRef<Path>) -> Result<Self, crate::Error> {
let path = file.as_ref();
let contents = read_to_string(path).map_err(|source| crate::Error::IoPath {
path: path.to_path_buf(),
context: "reading a configuration file".to_string(),
source,
})?;
Self::from_str(&contents)
}
}
impl FromStr for ConnectConfig {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let config: ConnectConfig = toml::from_str(s)?;
config
.validate()
.map_err(|source| crate::Error::Validation {
context: "reading configuration file".to_owned(),
source,
})?;
Ok(config)
}
}
fn validate_ssh_public_key_consistency(
users: &BTreeMap<String, ConnectOptions>,
_context: &(),
) -> garde::Result {
let failures = users
.iter()
.map(|(user, connect_option)| (user, &connect_option.user_public_key))
.fold(
HashMap::<&PublicKey, Vec<&str>>::new(),
|mut acc, (user, user_public_key)| {
acc.entry(user_public_key).or_default().push(user);
acc
},
)
.into_iter()
.filter(|(_, users)| users.len() > 1)
.map(|(pk, users)| {
format!(
"Public key {pk} is shared by these users: {users}",
pk = pk.to_string(),
users = users.join(", ")
)
})
.collect::<Vec<_>>();
if !failures.is_empty() {
return Err(garde::Error::new(format!(
"The SSH public key used for a user cannot be used for another user:\n{failures}",
failures = failures.join("\n")
)));
}
Ok(())
}
fn validate_known_hosts_consistency(
connect_options: &BTreeMap<String, SignstarHost>,
_context: &(),
) -> garde::Result {
let failures = connect_options
.iter()
.map(|(host, config)| (host, &config.known_hosts))
.fold(
HashMap::<String, Vec<&str>>::new(),
|mut acc, (host, known_hosts)| {
for known_host in known_hosts {
acc.entry(known_host.to_string()).or_default().push(host);
}
acc
},
)
.into_iter()
.filter(|(_, hosts)| hosts.len() > 1)
.map(|(entry, hosts)| {
format!(
"Entry {entry} is shared by these hosts: {hosts}",
hosts = hosts.join(", ")
)
})
.collect::<Vec<_>>();
if !failures.is_empty() {
return Err(garde::Error::new(format!(
"The known host entry used for a host cannot be used for another host:\n{failures}",
failures = failures.join("\n")
)));
}
Ok(())
}
fn validate_host_port_uniqueness(
connect_options: &BTreeMap<String, SignstarHost>,
_context: &(),
) -> garde::Result {
let failures = connect_options
.iter()
.map(|(host_id, config)| (host_id, &config.host, config.port))
.fold(
HashMap::<(&str, u16), Vec<&str>>::new(),
|mut acc, (host_id, host, port)| {
acc.entry((host, port)).or_default().push(host_id);
acc
},
)
.into_iter()
.filter(|(_, host_ids)| host_ids.len() > 1)
.map(|((host, port), host_ids)| {
format!(
"Host {host}:{port} is shared by these hosts: {host_ids}",
host_ids = host_ids.join(", ")
)
})
.collect::<Vec<_>>();
if !failures.is_empty() {
return Err(garde::Error::new(format!(
"Two connections cannot have the same host:port combinations:\n{failures}",
failures = failures.join("\n")
)));
}
Ok(())
}
fn validate_host_ids(config: &ConnectConfig, _context: &()) -> garde::Result {
let failures = config
.users
.iter()
.flat_map(|(user_id, options)| {
options
.hosts
.iter()
.filter(|host| !config.hosts.contains_key(*host))
.map(move |invalid_host| (user_id, invalid_host))
})
.map(|(user, invalid_host)| {
format!("User {user} is using host_id which is not defined: {invalid_host}",)
})
.collect::<Vec<_>>();
if !failures.is_empty() {
return Err(garde::Error::new(failures.join("\n")));
}
Ok(())
}
#[derive(Debug, Deserialize, Validate)]
pub struct ConnectOptions {
#[garde(skip)]
pub agent_socket: PathBuf,
#[garde(skip)]
pub user_public_key: PublicKey,
#[garde(length(min = 1))]
pub hosts: Vec<String>,
}
impl ConnectOptions {
pub fn agent_socket(mut self, agent_socket: impl Into<PathBuf>) -> Self {
self.agent_socket = agent_socket.into();
self
}
pub fn user_public_key(
mut self,
user_public_key: impl Into<String>,
) -> Result<Self, crate::Error> {
self.user_public_key =
PublicKey::from_openssh(&user_public_key.into()).map_err(Error::SshKey)?;
Ok(self)
}
pub fn new(
host: String,
client_auth_public_key: impl AsRef<str>,
) -> Result<Self, crate::Error> {
let client_auth_public_key =
PublicKey::from_openssh(client_auth_public_key.as_ref()).map_err(Error::SshKey)?;
Ok(Self {
hosts: vec![host],
agent_socket: Default::default(),
user_public_key: client_auth_public_key,
})
}
}
fn deserialize_entries<'de, D>(deserializer: D) -> Result<Vec<Entry>, D::Error>
where
D: Deserializer<'de>,
{
Vec::<String>::deserialize(deserializer)?
.into_iter()
.map(|entry: String| Entry::from_str(&entry).map_err(serde::de::Error::custom))
.collect::<Result<_, _>>()
}
struct KeyValidator {
host: String,
port: u16,
entries: Vec<Entry>,
}
impl Handler for KeyValidator {
type Error = Error;
async fn check_server_key(
&mut self,
server_public_key: &PublicKeyOrCertificate,
) -> Result<bool, Error> {
if let PublicKeyOrCertificate::PublicKey { key, .. } = server_public_key {
Ok(crate::ssh::known_hosts::is_server_known(
self.entries.iter(),
&self.host,
self.port,
key,
))
} else {
Ok(false)
}
}
}
pub struct Session {
session: Handle<KeyValidator>,
host: String,
port: u16,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"SSH session for host {} on port {}",
self.host, self.port
)
}
}
impl Session {
pub async fn send(&mut self, data: &Request) -> Result<Response, crate::Error> {
let mut channel = self
.session
.channel_open_session()
.await
.map_err(Error::SshProtocol)?;
let command_name = b"";
channel
.exec(true, command_name)
.await
.map_err(Error::SshProtocol)?;
let data = serde_json::to_vec(&data).map_err(|source| Error::Serialization { source })?;
channel
.data(data.as_ref())
.await
.map_err(Error::SshProtocol)?;
channel.eof().await.map_err(Error::SshProtocol)?;
let mut code = None;
let mut stdout = vec![];
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { ref data } => {
stdout.extend(data.iter());
}
ChannelMsg::ExitStatus { exit_status } => {
code = Some(exit_status);
}
_ => {}
}
}
if let Some(code) = code {
if let Some(status_code) = NonZeroU32::new(code) {
Err(Error::RemoteApplicationFailure { status_code }.into())
} else {
Ok(serde_json::from_slice(&stdout)
.map_err(|source| Error::Deserialization { source })?)
}
} else {
Err(Error::UncleanExit.into())
}
}
pub async fn close(self) -> Result<(), crate::Error> {
Ok(self
.session
.disconnect(
Disconnect::ByApplication,
"Client is closing the connection",
"en",
)
.await
.map_err(Error::SshProtocol)?)
}
}
#[cfg(test)]
mod tests {
use garde::Validate;
use insta::assert_snapshot;
use testresult::TestResult;
use super::*;
#[test]
fn validate_success() -> TestResult {
let config = ConnectConfig {
users: [("test-user".into(), ConnectOptions::new(
"local".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
)?)].into_iter().collect(),
hosts: [("local".into(), SignstarHost {
host: "127.0.0.1".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
})].into_iter().collect(),
};
config.validate()?;
Ok(())
}
#[test]
fn validate_ssh_public_key_cannot_be_reused() -> TestResult {
let config = ConnectConfig {
users: [("test-user".into(), ConnectOptions::new(
"local".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
)?),("test-user2".into(), ConnectOptions::new(
"local".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
)?)].into_iter().collect(),
hosts: [("local".into(), SignstarHost {
host: "127.0.0.1".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
})].into_iter().collect(),
};
let error_msg = match config.validate() {
Ok(()) => {
panic!("Expected to fail with garde::Error, but succeeded instead.")
}
Err(error) => error.to_string(),
};
assert_snapshot!(error_msg);
Ok(())
}
#[test]
fn validate_known_hosts_entries_unique_per_host_port_of_the_user() -> TestResult {
let config = ConnectConfig {
users: [("test-user".into(), ConnectOptions::new(
"local".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
)?),("test-user2".into(), ConnectOptions::new(
"local2".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
)?)].into_iter().collect(),
hosts: [("local".into(), SignstarHost {
host: "127.0.0.1".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
}),("local2".into(), SignstarHost {
host: "127.0.0.2".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
})].into_iter().collect(),
};
let error_msg = match config.validate() {
Ok(()) => {
panic!("Expected to fail with garde::Error, but succeeded instead.")
}
Err(error) => error.to_string(),
};
assert_snapshot!(error_msg);
Ok(())
}
#[test]
fn validate_host_port_uniqueness() -> TestResult {
let config = ConnectConfig {
users: [("test-user".into(), ConnectOptions::new(
"local".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
)?),("test-user2".into(), ConnectOptions::new(
"local2".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
)?)].into_iter().collect(),
hosts: [("local".into(), SignstarHost {
host: "127.0.0.1".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAD")?],
}),("local2".into(), SignstarHost {
host: "127.0.0.1".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
})].into_iter().collect(),
};
let error_msg = match config.validate() {
Ok(()) => {
panic!("Expected to fail with garde::Error, but succeeded instead.")
}
Err(error) => error.to_string(),
};
assert_snapshot!(error_msg);
Ok(())
}
#[test]
fn validate_host_ids() -> TestResult {
let config = ConnectConfig {
users: [("test-user".into(), ConnectOptions::new(
"test".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPp",
)?),("test-user2".into(), ConnectOptions::new(
"test".into(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPF9G0NQMEIBWR0NBc7sVBc2uxkKwY3SWvzRWQAtLPP",
)?)].into_iter().collect(),
hosts: [("test2".into(), SignstarHost {
host: "127.0.0.1".into(),
port: 22,
known_hosts: vec![Entry::from_str("test1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOh8eDowbkS5cA/50DhIsOUI5bDf5Kx0sSJZDQgfoRAd")?],
})].into_iter().collect(),
};
let error_msg = match config.validate() {
Ok(()) => {
panic!("Expected to fail with garde::Error, but succeeded instead.")
}
Err(error) => error.to_string(),
};
assert_snapshot!(error_msg);
Ok(())
}
}