use rustls_pki_types::CertificateDer;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::time::{Duration, timeout};
#[allow(unused)]
use tracing::{debug, error, info, trace, warn};
use shhoq::{AuthMethod, Client, ClientTlsConfig, Config};
use crate::error::RegentError;
use crate::hosts::command::CommandResult;
use crate::hosts::handlers::HostHandler;
use crate::hosts::handlers::final_command;
use crate::hosts::handlers::localhost::WhichUser;
use crate::hosts::handlers::shell_quote;
use crate::hosts::privilege::Credentials;
use crate::hosts::privilege::LoginKey;
use crate::hosts::privilege::LoginKeyRef;
use crate::hosts::privilege::Privilege;
use crate::secrets::SecretReference;
const DEFAULT_SSHOQ_PORT: u16 = 443;
const SSHOQ_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_SSHOQ_URL_PATH: &str = "/sshoq-server";
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub enum SshoqTlsConfig {
#[default]
SystemRoots,
Insecure,
KnownHostsFile(String),
#[serde(rename_all = "PascalCase")]
Custom {
known_hosts_file: Option<String>,
additional_roots: Vec<String>,
},
}
impl SshoqTlsConfig {
pub fn system_roots() -> Self {
SshoqTlsConfig::SystemRoots
}
pub fn insecure() -> Self {
SshoqTlsConfig::Insecure
}
pub fn known_hosts_file(path: impl Into<String>) -> Self {
SshoqTlsConfig::KnownHostsFile(path.into())
}
pub fn custom(
known_hosts_file: Option<impl Into<String>>,
additional_roots: Vec<impl Into<String>>,
) -> Self {
SshoqTlsConfig::Custom {
known_hosts_file: known_hosts_file.map(Into::into),
additional_roots: additional_roots.into_iter().map(Into::into).collect(),
}
}
pub fn to_client_tls_config(&self) -> Result<ClientTlsConfig, RegentError> {
match self {
SshoqTlsConfig::SystemRoots => Ok(ClientTlsConfig::with_system_roots()),
SshoqTlsConfig::Insecure => Ok(ClientTlsConfig::insecure()),
SshoqTlsConfig::KnownHostsFile(path) => Ok(
ClientTlsConfig::with_system_roots().with_known_hosts_file(path.clone()),
),
SshoqTlsConfig::Custom {
known_hosts_file,
additional_roots,
} => {
let mut config = ClientTlsConfig::with_system_roots();
if let Some(path) = known_hosts_file {
config = config.with_known_hosts_file(path.clone());
}
for root_path in additional_roots {
let bytes = std::fs::read(root_path).map_err(|e| {
RegentError::FailedInitialization(format!(
"Failed to read root certificate file '{}': {}",
root_path, e
))
})?;
config = config.with_root_certificate(CertificateDer::from(bytes));
}
Ok(config)
}
}
}
}
#[derive(Clone)]
pub struct SshoqConnectionParams {
auth_method: SshoqAuthMethod,
path: Option<String>,
tls_config: ClientTlsConfig,
}
impl SshoqConnectionParams {
pub fn from(
auth_method: SshoqAuthMethod,
path: Option<String>,
tls_config: ClientTlsConfig,
) -> Self {
Self {
auth_method,
path,
tls_config,
}
}
pub fn username_password(credentials: Credentials, tls_config: ClientTlsConfig) -> Self {
Self::from(
SshoqAuthMethod::UsernamePassword(credentials),
None,
tls_config,
)
}
pub fn key(login_key: LoginKey, tls_config: ClientTlsConfig) -> Self {
Self::from(SshoqAuthMethod::Key(login_key), None, tls_config)
}
pub fn bearer_token(token: String, tls_config: ClientTlsConfig) -> Self {
Self::from(SshoqAuthMethod::BearerToken(token), None, tls_config)
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
}
impl std::fmt::Debug for SshoqConnectionParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshoqConnectionParams")
.field("auth_method", &self.auth_method)
.field("path", &self.path)
.field("tls_config", &self.tls_config)
.finish()
}
}
pub enum SshoqHostHandler {
NotConnected(SshoqConnectionParams),
Connected(SshoqConnectionParams, Arc<Client>),
}
impl Clone for SshoqHostHandler {
fn clone(&self) -> Self {
match self {
SshoqHostHandler::NotConnected(params) => {
SshoqHostHandler::NotConnected(params.clone())
}
SshoqHostHandler::Connected(params, _) => {
SshoqHostHandler::NotConnected(params.clone())
}
}
}
}
impl std::fmt::Debug for SshoqHostHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SshoqHostHandler::NotConnected(params) => {
f.debug_tuple("NotConnected").field(params).finish()
}
SshoqHostHandler::Connected(params, _) => f
.debug_tuple("Connected")
.field(params)
.field(&"*SSHOQ CLIENT*")
.finish(),
}
}
}
impl HostHandler for SshoqHostHandler {
async fn connect(&mut self, endpoint: &str) -> Result<(), RegentError> {
if self.is_connected().await {
return Ok(());
}
let address_and_port: Vec<&str> = endpoint.split(':').collect();
if address_and_port.is_empty() {
return Err(RegentError::FailedInitialization(
"empty address".to_string(),
));
}
let hostname = address_and_port[0];
let port: u16 = match address_and_port.get(1) {
Some(port_str) => match port_str.parse::<u16>() {
Ok(p) => p,
Err(e) => {
return Err(RegentError::FailedInitialization(format!(
"invalid port: {}",
e
)));
}
},
None => DEFAULT_SSHOQ_PORT,
};
if let SshoqHostHandler::NotConnected(params) = self {
let (username, auth_methods) = build_auth_methods(¶ms.auth_method)?;
let url_path = params
.path
.clone()
.unwrap_or_else(|| DEFAULT_SSHOQ_URL_PATH.to_string());
let config = Config::new(username, hostname, port, &url_path).map_err(|e| {
RegentError::FailedInitialization(format!(
"Failed to build SSHOQ config: {:?}",
e
))
})?;
let client = match timeout(
SSHOQ_CONNECT_TIMEOUT,
Client::connect(&config, &auth_methods, ¶ms.tls_config),
)
.await
{
Ok(Ok(client)) => client,
Ok(Err(e)) => {
return Err(RegentError::FailureToEstablishConnection(format!(
"SSHOQ connection failed: {:?}",
e
)));
}
Err(_) => {
return Err(RegentError::FailureToEstablishConnection(format!(
"SSHOQ connection timeout elapsed ({} ms)",
SSHOQ_CONNECT_TIMEOUT.as_millis()
)));
}
};
*self = SshoqHostHandler::Connected(params.clone(), Arc::new(client));
Ok(())
} else {
Ok(())
}
}
async fn is_connected(&mut self) -> bool {
match self {
SshoqHostHandler::NotConnected(_) => false,
SshoqHostHandler::Connected(params, client) => {
match client.run_command("true").await {
Ok(_) => true,
Err(_) => {
*self = SshoqHostHandler::NotConnected(params.clone());
false
}
}
}
}
}
async fn disconnect(&mut self) -> Result<(), RegentError> {
match self {
SshoqHostHandler::NotConnected(_) => Ok(()),
SshoqHostHandler::Connected(params, client) => {
client.close();
*self = SshoqHostHandler::NotConnected(params.clone());
Ok(())
}
}
}
async fn is_this_command_available(
&mut self,
command: &str,
privilege: &Privilege,
) -> Result<bool, RegentError> {
let check_cmd_content = format!("command -v {}", shell_quote(command));
let check_cmd_result = self
.run_command(check_cmd_content.as_str(), privilege)
.await;
match check_cmd_result {
Ok(cmd_result) => Ok(cmd_result.return_code == 0),
Err(e) => Err(RegentError::FailureToRunCommand(format!("{:?}", e))),
}
}
async fn run_command(
&mut self,
command: &str,
privilege: &Privilege,
) -> Result<CommandResult, RegentError> {
match self {
SshoqHostHandler::NotConnected(_) => Err(RegentError::NotConnectedToHost),
SshoqHostHandler::Connected(params, client) => {
let effective_user = which_user_for_auth_method(¶ms.auth_method);
let final_cmd = final_command(command, privilege, &effective_user);
match client.run_command(&final_cmd).await {
Ok(output) => Ok(CommandResult {
return_code: output.exit_status as i64,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}),
Err(e) => Err(RegentError::FailureToRunCommand(format!(
"SSHOQ command execution failed: {:?}",
e
))),
}
}
}
}
#[cfg(feature = "windows")]
async fn run_windows_command(&mut self, command: &str) -> Result<CommandResult, RegentError> {
match self {
SshoqHostHandler::NotConnected(_) => Err(RegentError::NotConnectedToHost),
SshoqHostHandler::Connected(_params, client) => {
let final_cmd = format!("cmd /C {}", command);
match client.run_command(&final_cmd).await {
Ok(output) => Ok(CommandResult {
return_code: output.exit_status as i64,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}),
Err(e) => Err(RegentError::FailureToRunCommand(format!(
"SSHOQ Windows command execution failed: {:?}",
e
))),
}
}
}
}
async fn get_file(&mut self, path: PathBuf) -> Result<Vec<u8>, RegentError> {
match self {
SshoqHostHandler::NotConnected(_) => Err(RegentError::NotConnectedToHost),
SshoqHostHandler::Connected(_params, client) => {
let cmd = format!("base64 {}", shell_quote(&path.display().to_string()));
match client.run_command(&cmd).await {
Ok(output) => {
if output.exit_status != 0 {
return Err(RegentError::FailedToGetFile(format!(
"Failed to read file {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr)
)));
}
let encoded = String::from_utf8_lossy(&output.stdout);
let encoded_clean: String =
encoded.chars().filter(|c| !c.is_whitespace()).collect();
match base64_decode(&encoded_clean) {
Ok(bytes) => Ok(bytes),
Err(e) => Err(RegentError::FailedToGetFile(format!(
"Failed to decode base64 content of {}: {}",
path.display(),
e
))),
}
}
Err(e) => Err(RegentError::FailedToGetFile(format!(
"SSHOQ file retrieval failed for {}: {:?}",
path.display(),
e
))),
}
}
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum SshoqAuthMethod {
UsernamePassword(Credentials),
Key(LoginKey),
BearerToken(String),
}
impl std::fmt::Debug for SshoqAuthMethod {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
SshoqAuthMethod::UsernamePassword(creds) => {
write!(
f,
"UsernamePassword(Credentials {{ username: {:?}, password: \"*REDACTED*\" }})",
creds.username()
)
}
SshoqAuthMethod::Key(login_key) => {
write!(f, "Key(({:?}, *REDACTED*))", login_key.username())
}
SshoqAuthMethod::BearerToken(_) => {
write!(f, "BearerToken(*REDACTED*)")
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum SshoqAuthReference {
UsernamePassword(SecretReference),
Key(LoginKeyRef),
BearerToken(SecretReference),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[serde(deny_unknown_fields)]
pub struct SshoqAuth {
pub auth_method: SshoqAuthReference,
pub path: Option<String>,
pub tls_config: Option<SshoqTlsConfig>,
}
impl SshoqAuth {
pub fn from(
auth_method: SshoqAuthReference,
path: Option<String>,
tls_config: Option<SshoqTlsConfig>,
) -> Self {
Self {
auth_method,
path,
tls_config,
}
}
pub fn username_password(credentials: SecretReference) -> Self {
Self::from(
SshoqAuthReference::UsernamePassword(credentials),
None,
None,
)
}
pub fn key(username: &str, key_secret_reference: SecretReference) -> Self {
Self::from(
SshoqAuthReference::Key(LoginKeyRef::from(
username.to_string(),
key_secret_reference,
)),
None,
None,
)
}
pub fn bearer_token(token: SecretReference) -> Self {
Self::from(SshoqAuthReference::BearerToken(token), None, None)
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
pub fn with_tls_config(mut self, tls_config: SshoqTlsConfig) -> Self {
self.tls_config = Some(tls_config);
self
}
}
fn build_auth_methods(auth_method: &SshoqAuthMethod) -> Result<(String, Vec<AuthMethod>), RegentError> {
match auth_method {
SshoqAuthMethod::UsernamePassword(credentials) => {
let username = credentials.username().to_string();
let auth = vec![AuthMethod::password(credentials.password())];
Ok((username, auth))
}
SshoqAuthMethod::Key(login_key) => {
let username = login_key.username().to_string();
let private_key = shhoq::PrivateKey::from_pem(login_key.key().as_bytes(), None)
.map_err(|e| {
RegentError::FailedInitialization(format!(
"Failed to parse SSHOQ private key: {:?}",
e
))
})?;
let auth = vec![AuthMethod::private_key(private_key)];
Ok((username, auth))
}
SshoqAuthMethod::BearerToken(token) => {
let auth = vec![AuthMethod::bearer_token(token)];
Ok(("sshoq".to_string(), auth))
}
}
}
fn which_user_for_auth_method(auth_method: &SshoqAuthMethod) -> WhichUser {
match auth_method {
SshoqAuthMethod::UsernamePassword(credentials) => {
WhichUser::CurrentUserWithSudoPassword(credentials.password().to_string())
}
SshoqAuthMethod::Key(_) | SshoqAuthMethod::BearerToken(_) => WhichUser::CurrentUser,
}
}
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut lookup = [255u8; 256];
for (i, &c) in TABLE.iter().enumerate() {
lookup[c as usize] = i as u8;
}
let bytes: Vec<u8> = input.bytes().filter(|&b| b != b'=').collect();
if bytes.len() % 4 == 1 {
return Err("invalid base64 length".to_string());
}
let mut output = Vec::with_capacity(bytes.len() * 3 / 4);
let chunks: Vec<&[u8]> = bytes.chunks(4).collect();
for (i, chunk) in chunks.iter().enumerate() {
let is_last = i == chunks.len() - 1;
let pad = if is_last {
bytes.len() - (chunks.len() - 1) * 4
} else {
4
};
let _ = pad;
let mut vals = [0u8; 4];
for (j, &b) in chunk.iter().enumerate() {
vals[j] = lookup[b as usize];
if vals[j] == 255 {
return Err(format!("invalid base64 character: '{}'", b as char));
}
}
let n = ((vals[0] as u32) << 18)
| ((vals[1] as u32) << 12)
| ((vals[2] as u32) << 6)
| (vals[3] as u32);
output.push((n >> 16) as u8);
if chunk.len() > 2 {
output.push((n >> 8) as u8);
}
if chunk.len() > 3 {
output.push(n as u8);
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_username_password() {
let yaml = r#"
!UsernamePassword
SecRef: /path/to/creds
Provider: files
"#;
let result = yaml_serde::from_str::<SshoqAuthReference>(yaml);
assert!(matches!(
result,
Ok(SshoqAuthReference::UsernamePassword(_))
));
}
#[test]
fn test_deserialize_bearer_token() {
let yaml = r#"
!BearerToken
SecRef: /path/to/token
Provider: files
"#;
let result = yaml_serde::from_str::<SshoqAuthReference>(yaml);
assert!(matches!(result, Ok(SshoqAuthReference::BearerToken(_))));
}
#[test]
fn test_deserialize_key() {
let yaml = r#"
!Key
Username: testuser
Key:
SecRef: /path/to/key
Provider: files
"#;
let result = yaml_serde::from_str::<SshoqAuthReference>(yaml);
assert!(matches!(result, Ok(SshoqAuthReference::Key(_))));
}
#[test]
fn test_deserialize_sshoq_auth_with_path() {
let yaml = r#"
AuthMethod: !UsernamePassword
SecRef: /path/to/creds
Provider: files
Path: /my-sshoq-endpoint
"#;
let result = yaml_serde::from_str::<SshoqAuth>(yaml);
assert!(result.is_ok());
let auth = result.unwrap();
assert_eq!(auth.path, Some("/my-sshoq-endpoint".to_string()));
assert_eq!(auth.tls_config, None);
}
#[test]
fn test_deserialize_sshoq_auth_without_path() {
let yaml = r#"
AuthMethod: !UsernamePassword
SecRef: /path/to/creds
Provider: files
"#;
let result = yaml_serde::from_str::<SshoqAuth>(yaml);
assert!(result.is_ok());
let auth = result.unwrap();
assert_eq!(auth.path, None);
assert_eq!(auth.tls_config, None);
}
#[test]
fn test_deserialize_tls_system_roots() {
let yaml = r#"
!SystemRoots
"#;
let result = yaml_serde::from_str::<SshoqTlsConfig>(yaml);
assert!(matches!(result, Ok(SshoqTlsConfig::SystemRoots)));
}
#[test]
fn test_deserialize_tls_insecure() {
let yaml = r#"
!Insecure
"#;
let result = yaml_serde::from_str::<SshoqTlsConfig>(yaml);
assert!(matches!(result, Ok(SshoqTlsConfig::Insecure)));
}
#[test]
fn test_deserialize_tls_known_hosts_file() {
let yaml = r#"
!KnownHostsFile /etc/regent/sshoq_known_hosts
"#;
let result = yaml_serde::from_str::<SshoqTlsConfig>(yaml);
assert!(matches!(result, Ok(SshoqTlsConfig::KnownHostsFile(_))));
}
#[test]
fn test_deserialize_tls_custom() {
let yaml = r#"
!Custom
KnownHostsFile: /etc/regent/sshoq_known_hosts
AdditionalRoots:
- /etc/regent/ca1.der
- /etc/regent/ca2.der
"#;
let result = yaml_serde::from_str::<SshoqTlsConfig>(yaml);
if let Err(e) = &result {
panic!("deserialization failed: {:?}", e);
}
assert!(result.is_ok());
if let Ok(SshoqTlsConfig::Custom {
known_hosts_file,
additional_roots,
}) = result
{
assert_eq!(
known_hosts_file,
Some("/etc/regent/sshoq_known_hosts".to_string())
);
assert_eq!(additional_roots.len(), 2);
} else {
panic!("expected Custom variant");
}
}
#[test]
fn test_deserialize_sshoq_auth_with_tls_config() {
let yaml = r#"
AuthMethod: !UsernamePassword
SecRef: /path/to/creds
Provider: files
TlsConfig: !Insecure
"#;
let result = yaml_serde::from_str::<SshoqAuth>(yaml);
assert!(result.is_ok());
let auth = result.unwrap();
assert_eq!(auth.tls_config, Some(SshoqTlsConfig::Insecure));
}
#[test]
fn test_tls_builder_system_roots() {
assert_eq!(SshoqTlsConfig::system_roots(), SshoqTlsConfig::SystemRoots);
}
#[test]
fn test_tls_builder_insecure() {
assert_eq!(SshoqTlsConfig::insecure(), SshoqTlsConfig::Insecure);
}
#[test]
fn test_tls_builder_known_hosts_file() {
assert_eq!(
SshoqTlsConfig::known_hosts_file("/etc/regent/known_hosts"),
SshoqTlsConfig::KnownHostsFile("/etc/regent/known_hosts".to_string()),
);
}
#[test]
fn test_tls_builder_custom() {
assert_eq!(
SshoqTlsConfig::custom(
Some("/etc/regent/known_hosts"),
vec!["/etc/regent/ca.der"],
),
SshoqTlsConfig::Custom {
known_hosts_file: Some("/etc/regent/known_hosts".to_string()),
additional_roots: vec!["/etc/regent/ca.der".to_string()],
},
);
}
#[test]
fn test_tls_builder_custom_no_known_hosts() {
assert_eq!(
SshoqTlsConfig::custom(Option::<&str>::None, vec!["/etc/regent/ca.der"]),
SshoqTlsConfig::Custom {
known_hosts_file: None,
additional_roots: vec!["/etc/regent/ca.der".to_string()],
},
);
}
#[test]
fn test_tls_config_to_client_system_roots() {
let tls = SshoqTlsConfig::system_roots().to_client_tls_config();
assert!(tls.is_ok());
assert!(!tls.unwrap().is_insecure());
}
#[test]
fn test_tls_config_to_client_insecure() {
let tls = SshoqTlsConfig::insecure().to_client_tls_config();
assert!(tls.is_ok());
assert!(tls.unwrap().is_insecure());
}
#[test]
fn test_base64_decode() {
let decoded = base64_decode("aGVsbG8=").unwrap();
assert_eq!(decoded, b"hello");
}
#[test]
fn test_base64_decode_no_padding() {
let decoded = base64_decode("aGVsbG8").unwrap();
assert_eq!(decoded, b"hello");
}
#[test]
fn test_base64_decode_binary() {
let decoded = base64_decode("AAECAwQF").unwrap();
assert_eq!(decoded, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn test_base64_decode_empty() {
let decoded = base64_decode("").unwrap();
assert_eq!(decoded, Vec::<u8>::new());
}
#[test]
fn test_base64_decode_with_newlines() {
let input: String = "aGVs\nbG8=".chars().filter(|c| !c.is_whitespace()).collect();
let decoded = base64_decode(&input).unwrap();
assert_eq!(decoded, b"hello");
}
}