feat: Implement SSH master connector and CLI for certificate management

This commit is contained in:
GW_MC
2026-03-21 03:07:58 +00:00
parent 2fcdc7d0df
commit 1a453a7e5c
8 changed files with 687 additions and 3 deletions

View File

@@ -0,0 +1,333 @@
use config::{Config, ConfigError, Environment, File};
use serde::{Deserialize, Deserializer, Serialize};
use std::{os::unix::fs::PermissionsExt, str::FromStr};
use tracing::level_filters::LevelFilter;
const NGINX_BINARY_PATH_TEMPLATE: &str = "{{nginx_binary_path}}";
const NGINX_DEFAULT_BINARY: &str = "nginx";
type ValidationError = String;
trait Validate {
fn validate(&self) -> Result<(), ValidationError>;
}
/// Agent settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub grpc: GrpcSettings,
#[serde(default)]
pub log: LogSettings,
pub nginx: Option<NginxSettings>,
}
/// gRPC client settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrpcSettings {
pub connection_string: String,
pub m_auth: MAuthSettings,
#[serde(default)]
pub cors: Option<CorsSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MAuthSettings {
Tls(TLSSettings),
}
/// TLS certificate settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TLSSettings {
RawPath {
ca_path: String,
cert_path: String,
key_path: String,
},
ZipPath {
cert_zip_path: String,
},
}
/// CORS settings
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CorsSettings {
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default)]
pub allowed_methods: Vec<String>,
#[serde(default)]
pub allowed_headers: Vec<String>,
#[serde(default)]
pub allow_credentials: bool,
}
/// Logging settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogSettings {
#[serde(
deserialize_with = "deserialize_level_filter",
serialize_with = "serialize_level_filter"
)]
pub level: LevelFilter,
}
impl Default for LogSettings {
fn default() -> Self {
Self {
level: default_log_level(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NginxSettings {
#[serde(default = "default_nginx_config_path")]
pub nginx_config_path: String,
// #[serde(default = "default_nginx_binary_path")]
#[serde(default)]
pub nginx_binary_path: Option<String>,
// commands
#[serde(default = "default_nginx_reload_command")]
pub override_nginx_reload_command: Vec<String>,
#[serde(default = "default_nginx_test_command")]
pub override_nginx_test_command: Vec<String>,
// timeouts
#[serde(default = "default_nginx_reload_timeout_seconds")]
pub nginx_reload_timeout_seconds: u64,
#[serde(default = "default_nginx_test_timeout_seconds")]
pub nginx_test_timeout_seconds: u64,
}
impl Validate for Settings {
fn validate(&self) -> Result<(), ValidationError> {
self.grpc.validate()?;
if let Some(nginx) = &self.nginx {
nginx.validate()?;
}
Ok(())
}
}
impl Settings {
/// Load settings from config files and environment
pub fn load() -> Result<Self, ConfigError> {
let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
let settings = Config::builder()
.add_source(File::with_name("config/default").required(false))
.add_source(File::with_name(&format!("config/{}", run_mode)).required(false))
.add_source(File::with_name("config/agent/default").required(false))
.add_source(File::with_name(&format!("config/agent/{}", run_mode)).required(false))
.add_source(Environment::with_prefix("NXMESH").separator("__"))
.build()?;
let mut settings: Self = settings.try_deserialize()?;
settings.validate().map_err(ConfigError::Message)?;
if let Some(nginx) = &mut settings.nginx {
nginx.validate().map_err(ConfigError::Message)?;
// replace binary path template in commands with actual binary path, if the template is present
nginx
.override_nginx_reload_command
.iter_mut()
.for_each(|cmd| {
*cmd = cmd.replace(
NGINX_BINARY_PATH_TEMPLATE,
&nginx
.nginx_binary_path
.clone()
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
);
});
nginx
.override_nginx_test_command
.iter_mut()
.for_each(|cmd| {
*cmd = cmd.replace(
NGINX_BINARY_PATH_TEMPLATE,
&nginx
.nginx_binary_path
.clone()
.unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()),
);
});
}
Ok(settings)
}
}
impl Validate for GrpcSettings {
fn validate(&self) -> Result<(), ValidationError> {
if self.connection_string.is_empty() {
return Err("gRPC connection string cannot be empty".into());
}
self.m_auth.validate()?;
if let Some(cors) = &self.cors {
cors.validate()?;
}
Ok(())
}
}
impl Validate for MAuthSettings {
fn validate(&self) -> Result<(), ValidationError> {
match self {
MAuthSettings::Tls(tls_settings) => tls_settings.validate()?,
}
Ok(())
}
}
impl Validate for TLSSettings {
fn validate(&self) -> Result<(), ValidationError> {
match self {
TLSSettings::RawPath {
ca_path,
cert_path,
key_path,
} => {
if !std::path::Path::new(ca_path).exists() {
return Err(format!("CA file not found: {}", ca_path));
}
if !std::path::Path::new(cert_path).exists() {
return Err(format!("Certificate file not found: {}", cert_path));
}
if !std::path::Path::new(key_path).exists() {
return Err(format!("Key file not found: {}", key_path));
}
}
TLSSettings::ZipPath { cert_zip_path } => {
if !std::path::Path::new(cert_zip_path).exists() {
return Err(format!("Certificate zip file not found: {}", cert_zip_path));
}
}
}
Ok(())
}
}
impl Validate for CorsSettings {
fn validate(&self) -> Result<(), ValidationError> {
Ok(())
}
}
impl Validate for NginxSettings {
fn validate(&self) -> Result<(), ValidationError> {
match &self.nginx_binary_path {
Some(path) if path.is_empty() => {
return Err("Nginx binary path cannot be empty".into());
}
Some(path) if !std::path::Path::new(path).exists() => {
return Err(format!("Nginx binary not found: {}", path));
}
Some(path)
if !std::fs::metadata(path)
.map_err(|e| format!("Failed to read nginx binary metadata: {}", e))?
.permissions()
.mode()
& 0o111
!= 0 =>
{
return Err(format!("Nginx binary is not executable: {}", path));
}
_ => {}
}
if self.nginx_config_path.is_empty() {
return Err("Nginx config path cannot be empty".into());
}
if !std::path::Path::new(&self.nginx_config_path).exists() {
return Err(format!(
"Nginx config file not found: {}",
self.nginx_config_path
));
}
// ensure reload and test commands contain the binary path template
if !&self
.override_nginx_reload_command
.join(" ")
.contains(NGINX_BINARY_PATH_TEMPLATE)
{
return Err(format!(
"Nginx reload command must contain the binary path template '{}': {}",
NGINX_BINARY_PATH_TEMPLATE,
self.override_nginx_reload_command.join(" ")
));
}
if !&self
.override_nginx_test_command
.join(" ")
.contains(NGINX_BINARY_PATH_TEMPLATE)
{
return Err(format!(
"Nginx test command must contain the binary path template '{}': {}",
NGINX_BINARY_PATH_TEMPLATE,
self.override_nginx_test_command.join(" ")
));
}
Ok(())
}
}
fn default_log_level() -> LevelFilter {
LevelFilter::INFO
}
fn default_nginx_config_path() -> String {
"/etc/nginx/nginx.conf".into()
}
fn default_nginx_reload_command() -> Vec<String> {
vec![
NGINX_BINARY_PATH_TEMPLATE.to_string(),
"-s".to_string(),
"reload".to_string(),
]
}
fn default_nginx_test_command() -> Vec<String> {
vec![NGINX_BINARY_PATH_TEMPLATE.to_string(), "-t".to_string()]
}
fn default_nginx_reload_timeout_seconds() -> u64 {
30
}
fn default_nginx_test_timeout_seconds() -> u64 {
30
}
fn deserialize_level_filter<'de, D>(deserializer: D) -> Result<LevelFilter, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
LevelFilter::from_str(&s).map_err(serde::de::Error::custom)
}
fn serialize_level_filter<S>(level: &LevelFilter, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&level.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_esnure_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Settings>();
assert_send_sync::<GrpcSettings>();
assert_send_sync::<TLSSettings>();
assert_send_sync::<CorsSettings>();
assert_send_sync::<LogSettings>();
assert_send_sync::<NginxSettings>();
}
}