feat: Implement SSH Agent Connector and gRPC server
- Added `AgentConnectorTrait` and `AgentConnector` for managing agent connections. - Introduced `SshAgentConnector` to handle SSH-related functionalities and start a gRPC server. - Created database entities for `agents`, `certificates`, `organizations`, `public_key_revocations`, `setup_tokens`, `upstreams`, `users`, `virtual_hosts`, and `workspaces` using SeaORM. - Developed `CertificateService` for managing certificate generation and retrieval. - Implemented the main server logic to initialize the database connection and start the agent server. - Configured development settings in `development.toml` for server and database connections.
This commit is contained in:
3
apps/nxmesh-master/src/bin/gen-openapi.rs
Normal file
3
apps/nxmesh-master/src/bin/gen-openapi.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
// TODO:
|
||||
}
|
||||
52
apps/nxmesh-master/src/cli/gen_agent_certs.rs
Normal file
52
apps/nxmesh-master/src/cli/gen_agent_certs.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{config::settings::Settings, db, service};
|
||||
|
||||
pub async fn gen_agent_certs(
|
||||
settings: &Settings,
|
||||
output: String,
|
||||
agent_id: String,
|
||||
zip: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Generating certificates to output directory: {}", output);
|
||||
use service::certificate::CertificateService;
|
||||
//
|
||||
let cert_service = service::certificate::CertificateServiceImpl::new(
|
||||
#[expect(clippy::expect_used)]
|
||||
db::establish_connection(&settings.database.url)
|
||||
.await
|
||||
.expect("Failed to connect to database"),
|
||||
output.clone(),
|
||||
Arc::new(settings.clone()),
|
||||
);
|
||||
|
||||
let output = cert_service
|
||||
.generate_agent_certs(&agent_id, &output)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to generate agent certificates: {}", e);
|
||||
std::process::exit(1);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
info!(
|
||||
"Successfully generated agent certificates at: cert path: {}, key path: {}, ca cert path: {}",
|
||||
output.cert_path, output.key_path, output.ca_cert_path
|
||||
);
|
||||
if zip {
|
||||
// Implementation for zipping certificates
|
||||
info!("Zipping generated certificates...");
|
||||
if let Err(e) = cert_service
|
||||
.zip_certificates(&output.cert_path, &output.key_path, &output.ca_cert_path)
|
||||
.await
|
||||
{
|
||||
error!("Failed to zip certificates: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
info!("Successfully zipped certificates.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
31
apps/nxmesh-master/src/cli/gen_certs.rs
Normal file
31
apps/nxmesh-master/src/cli/gen_certs.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{config::settings::Settings, db, service};
|
||||
|
||||
pub async fn gen_certs(
|
||||
settings: &Settings,
|
||||
output: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Generating CA certificate to output directory: {}", output);
|
||||
use service::certificate::CertificateService;
|
||||
let cert_service = service::certificate::CertificateServiceImpl::new(
|
||||
#[expect(clippy::expect_used)]
|
||||
db::establish_connection(&settings.database.url)
|
||||
.await
|
||||
.expect("Failed to connect to database"),
|
||||
output.to_string(),
|
||||
Arc::new(settings.clone()),
|
||||
);
|
||||
cert_service
|
||||
.generate_ca_cert()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Failed to generate CA certificate: {}", e);
|
||||
std::process::exit(1);
|
||||
})
|
||||
.unwrap();
|
||||
info!("Successfully generated CA certificate at: {}", output);
|
||||
Ok(())
|
||||
}
|
||||
63
apps/nxmesh-master/src/cli/mod.rs
Normal file
63
apps/nxmesh-master/src/cli/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
mod gen_agent_certs;
|
||||
mod gen_certs;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::{
|
||||
cli::{gen_agent_certs::gen_agent_certs, gen_certs::gen_certs},
|
||||
config::settings::Settings,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// Start the master server
|
||||
#[arg(short, long, group = "mode")]
|
||||
pub serve: bool,
|
||||
|
||||
/// generate CA for key signing if not exist
|
||||
/// If the CA already exists, generating CA will be skipped and the existing CA will be used
|
||||
/// If the CA does not exist, a new CA will be generated and saved to the default location (./certs/ca.crt and ./certs/ca.key)
|
||||
/// The generated CA will be used for signing agent certificates
|
||||
/// If not specified, the server will check if the CA already exists and use it if available, otherwise exit with an error
|
||||
#[arg(long)]
|
||||
pub generate_ca: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
GenCerts {
|
||||
/// Output directory for generated certificates
|
||||
#[arg(short, long, default_value = "./certs")]
|
||||
output: String,
|
||||
},
|
||||
/// Generate certificates for agent
|
||||
#[command(about = "Generate certificates for agent")]
|
||||
GenAgentCerts {
|
||||
/// Output directory for generated certificates
|
||||
#[arg(short, long, default_value = "./certs")]
|
||||
output: String,
|
||||
#[arg(long, default_value = "agent-id-placeholder")]
|
||||
agent_id: String,
|
||||
#[arg(short, long, default_value = "false")]
|
||||
zip: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn handle_sub_command(
|
||||
settings: &Settings,
|
||||
command: Commands,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// run as a CLI tool for other commands
|
||||
match command {
|
||||
Commands::GenCerts { output } => Ok(gen_certs(settings, output).await?),
|
||||
Commands::GenAgentCerts {
|
||||
output,
|
||||
agent_id,
|
||||
zip,
|
||||
} => Ok(gen_agent_certs(settings, output, agent_id, zip).await?),
|
||||
}
|
||||
}
|
||||
1
apps/nxmesh-master/src/config/mod.rs
Normal file
1
apps/nxmesh-master/src/config/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod settings;
|
||||
351
apps/nxmesh-master/src/config/settings.rs
Normal file
351
apps/nxmesh-master/src/config/settings.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use rcgen::string::Ia5String;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::{net::IpAddr, str::FromStr};
|
||||
use tracing::level_filters::LevelFilter;
|
||||
|
||||
type ValidationError = String;
|
||||
|
||||
trait Validate {
|
||||
fn validate(&self) -> Result<(), ValidationError>;
|
||||
}
|
||||
|
||||
/// Master server settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub server: ServerSettings,
|
||||
pub database: DatabaseSettings,
|
||||
pub grpc: GrpcSettings,
|
||||
pub auth: AuthSettings,
|
||||
#[serde(default)]
|
||||
pub log: LogSettings,
|
||||
}
|
||||
|
||||
/// HTTP server settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerSettings {
|
||||
#[serde(default = "default_server_bind_address")]
|
||||
pub bind_address: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub certificate: CertificateSettings,
|
||||
#[serde(default)]
|
||||
pub cors: Option<CorsSettings>,
|
||||
}
|
||||
|
||||
/// Database connection settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseSettings {
|
||||
pub url: String,
|
||||
pub max_connections: Option<u32>,
|
||||
}
|
||||
|
||||
/// gRPC server settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GrpcSettings {
|
||||
#[serde(default = "default_grpc_bind_address")]
|
||||
pub bind_address: String,
|
||||
#[serde(default = "default_grpc_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub certificate: CertificateSettings,
|
||||
#[serde(default)]
|
||||
pub cors: Option<CorsSettings>,
|
||||
}
|
||||
|
||||
/// Authentication settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthSettings {
|
||||
pub jwt_secret: String,
|
||||
#[serde(default = "default_jwt_expiration_hours")]
|
||||
pub jwt_expiration_hours: u64,
|
||||
}
|
||||
|
||||
/// TLS certificate settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CertificateSettings {
|
||||
#[serde(default = "default_cert_folder")]
|
||||
pub cert_dir: String,
|
||||
#[serde(
|
||||
default,
|
||||
serialize_with = "serialize_ia5string_vec",
|
||||
deserialize_with = "deserialize_ia5string_vec"
|
||||
)]
|
||||
pub san_dns: Vec<Ia5String>,
|
||||
#[serde(default)]
|
||||
pub san_ip: Vec<IpAddr>,
|
||||
#[serde(default)]
|
||||
cert_path: Option<String>,
|
||||
#[serde(default)]
|
||||
key_path: Option<String>,
|
||||
}
|
||||
|
||||
impl CertificateSettings {
|
||||
pub fn cert_path(&self) -> Option<String> {
|
||||
self.cert_path
|
||||
.as_ref()
|
||||
.map(|p| format!("{}/{}", self.cert_dir, p))
|
||||
}
|
||||
|
||||
pub fn key_path(&self) -> Option<String> {
|
||||
self.key_path
|
||||
.as_ref()
|
||||
.map(|p| format!("{}/{}", self.cert_dir, p))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for Settings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
self.server.validate()?;
|
||||
self.grpc.validate()?;
|
||||
self.database.validate()?;
|
||||
self.auth.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/master/default").required(false))
|
||||
.add_source(File::with_name(&format!("config/master/{}", run_mode)).required(false))
|
||||
.add_source(Environment::with_prefix("NXMESH").separator("__"))
|
||||
.build()?;
|
||||
|
||||
let settings: Self = settings.try_deserialize()?;
|
||||
|
||||
settings.validate().map_err(ConfigError::Message)?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for ServerSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.bind_address.is_empty() {
|
||||
return Err("Server bind address cannot be empty".into());
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err("Server port must be greater than 0".into());
|
||||
}
|
||||
self.certificate.validate()?;
|
||||
if let Some(cors) = &self.cors {
|
||||
cors.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for GrpcSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.bind_address.is_empty() {
|
||||
return Err("gRPC bind address cannot be empty".into());
|
||||
}
|
||||
if self.port == 0 {
|
||||
return Err("gRPC port must be greater than 0".into());
|
||||
}
|
||||
self.certificate.validate()?;
|
||||
if let Some(cors) = &self.cors {
|
||||
cors.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for DatabaseSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.url.is_empty() {
|
||||
return Err("Database URL cannot be empty".into());
|
||||
}
|
||||
if let Some(max_connections) = self.max_connections
|
||||
&& max_connections == 0
|
||||
{
|
||||
return Err("Max database connections must be greater than 0".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for AuthSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
if self.jwt_secret.is_empty() {
|
||||
return Err("JWT secret cannot be empty".into());
|
||||
}
|
||||
if self.jwt_expiration_hours == 0 {
|
||||
return Err("JWT expiration hours must be greater than 0".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for CertificateSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
let base_path = std::path::Path::new(&self.cert_dir);
|
||||
if !base_path.exists() {
|
||||
// create the cert directory if it doesn't exist
|
||||
std::fs::create_dir_all(base_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create certificate directory {:?}: {}",
|
||||
base_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let cert_path = self.cert_path.as_ref().map(|p| base_path.join(p));
|
||||
let key_path = self.key_path.as_ref().map(|p| base_path.join(p));
|
||||
if (cert_path.is_some() && key_path.is_none())
|
||||
|| (cert_path.is_none() && key_path.is_some())
|
||||
{
|
||||
return Err("Both certificate and key paths must be provided for TLS".into());
|
||||
}
|
||||
if let (Some(cert_path), Some(key_path)) = (&cert_path, &key_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));
|
||||
}
|
||||
}
|
||||
|
||||
// validate for SAN entries - must be valid DNS names or IP addresses
|
||||
for dns in &self.san_dns {
|
||||
if dns.to_string().is_empty() {
|
||||
return Err("SAN DNS entries cannot be empty".into());
|
||||
}
|
||||
}
|
||||
for ip in &self.san_ip {
|
||||
if ip.is_unspecified() {
|
||||
return Err("SAN IP entries cannot be unspecified".into());
|
||||
}
|
||||
}
|
||||
// require at least one SAN entry for the generated certificate
|
||||
if self.san_dns.is_empty() && self.san_ip.is_empty() {
|
||||
return Err(
|
||||
"At least one SAN entry (DNS or IP) must be provided for the certificate".into(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Validate for CorsSettings {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_jwt_expiration_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_server_bind_address() -> String {
|
||||
"0.0.0.0".into()
|
||||
}
|
||||
|
||||
fn default_server_port() -> u16 {
|
||||
8080
|
||||
}
|
||||
|
||||
fn default_grpc_bind_address() -> String {
|
||||
"0.0.0.0".into()
|
||||
}
|
||||
|
||||
fn default_grpc_port() -> u16 {
|
||||
50051
|
||||
}
|
||||
|
||||
fn default_log_level() -> LevelFilter {
|
||||
LevelFilter::INFO
|
||||
}
|
||||
|
||||
fn default_cert_folder() -> String {
|
||||
"./certs".into()
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn deserialize_ia5string_vec<'de, D>(deserializer: D) -> Result<Vec<Ia5String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let vec = Vec::<String>::deserialize(deserializer)?;
|
||||
vec.into_iter()
|
||||
.map(|s| Ia5String::try_from(s).map_err(serde::de::Error::custom))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize_ia5string_vec<S>(vec: &Vec<Ia5String>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let string_vec: Vec<String> = vec.iter().map(|ia5| ia5.to_string()).collect();
|
||||
string_vec.serialize(serializer)
|
||||
}
|
||||
|
||||
#[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::<ServerSettings>();
|
||||
assert_send_sync::<DatabaseSettings>();
|
||||
assert_send_sync::<GrpcSettings>();
|
||||
assert_send_sync::<AuthSettings>();
|
||||
assert_send_sync::<CertificateSettings>();
|
||||
assert_send_sync::<CorsSettings>();
|
||||
assert_send_sync::<LogSettings>();
|
||||
}
|
||||
}
|
||||
40
apps/nxmesh-master/src/connector/agent/mod.rs
Normal file
40
apps/nxmesh-master/src/connector/agent/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tonic::transport::Server;
|
||||
|
||||
pub mod ssh;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait AgentConnectorTrait: Send + Sync {
|
||||
async fn start_server(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
connection: DatabaseConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
pub struct AgentConnector {
|
||||
connector: Box<dyn AgentConnectorTrait>,
|
||||
}
|
||||
|
||||
impl AgentConnector {
|
||||
pub fn new(connector: Box<dyn AgentConnectorTrait>) -> Self {
|
||||
Self { connector }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentConnectorTrait for AgentConnector {
|
||||
async fn start_server(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
connection: DatabaseConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.connector
|
||||
.start_server(settings, cert_service, connection)
|
||||
.await
|
||||
}
|
||||
}
|
||||
110
apps/nxmesh-master/src/connector/agent/ssh.rs
Normal file
110
apps/nxmesh-master/src/connector/agent/ssh.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nxmesh_proto::{
|
||||
agent_service_server::AgentServiceServer,
|
||||
auth::ssh_auth::{CertificateValidationProvider, create_ssh_auth_interceptor},
|
||||
};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use tonic::transport::Server;
|
||||
|
||||
use crate::{db::entities::public_key_revocations, service::agent::AgentServerService};
|
||||
|
||||
use super::AgentConnectorTrait;
|
||||
|
||||
const MAX_CERTS_TO_CHECK: usize = 50;
|
||||
|
||||
pub struct SshAgentConnector {
|
||||
// router: Router<Stack<AsyncInterceptorLayer<SshAuthInterceptor>, Identity>>,
|
||||
settings: Arc<crate::config::settings::Settings>,
|
||||
}
|
||||
|
||||
impl SshAgentConnector {
|
||||
pub fn new(
|
||||
settings: impl Into<Arc<crate::config::settings::Settings>>,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(Self {
|
||||
settings: settings.into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_tls_config(
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
) -> Result<tonic::transport::ServerTlsConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (san_ips, san_dns) =
|
||||
cert_service.get_sans(crate::service::certificate::ConnectionType::GRPC);
|
||||
let (cert_pem, key_pem) = cert_service
|
||||
.generate_pub_cert_pair(san_ips, san_dns)
|
||||
.await?;
|
||||
let (ca_cert_path, _) = cert_service.get_ca_cert().await?;
|
||||
let ca_cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
||||
|
||||
let tls_config = tonic::transport::ServerTlsConfig::new()
|
||||
.identity(tonic::transport::Identity::from_pem(cert_pem, key_pem))
|
||||
.client_ca_root(tonic::transport::Certificate::from_pem(ca_cert_pem));
|
||||
Ok(tls_config)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentConnectorTrait for SshAgentConnector {
|
||||
async fn start_server(
|
||||
&mut self,
|
||||
settings: &crate::config::settings::Settings,
|
||||
cert_service: Arc<dyn crate::service::certificate::CertificateService>,
|
||||
connection: DatabaseConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let addr = settings.grpc.bind_address.clone().parse()?;
|
||||
let port = settings.grpc.port;
|
||||
let addr = std::net::SocketAddr::new(addr, port);
|
||||
|
||||
// Create the gRPC server
|
||||
let cert_validation_provider = Arc::new(CertificateValidationProviderImpl::new(connection));
|
||||
let ssh_interceptor = create_ssh_auth_interceptor(cert_validation_provider);
|
||||
let agent_server_service = AgentServiceServer::new(AgentServerService::default());
|
||||
|
||||
let tls_config = Self::get_tls_config(cert_service.clone()).await?;
|
||||
|
||||
let router = Server::builder()
|
||||
.tls_config(tls_config)?
|
||||
.layer(ssh_interceptor)
|
||||
.add_service(agent_server_service);
|
||||
|
||||
router.serve(addr).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct CertificateValidationProviderImpl {
|
||||
connection: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl CertificateValidationProviderImpl {
|
||||
pub fn new(connection: DatabaseConnection) -> Self {
|
||||
CertificateValidationProviderImpl { connection }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CertificateValidationProvider for CertificateValidationProviderImpl {
|
||||
async fn is_authorized(
|
||||
&self,
|
||||
certs: &Arc<Vec<tonic::transport::CertificateDer<'_>>>,
|
||||
) -> Result<bool, tonic::Status> {
|
||||
// check if the certificate's public key matches any agent's public key in the database
|
||||
let found = public_key_revocations::Entity::find()
|
||||
.filter(public_key_revocations::Column::PublicKeyHash.is_in(
|
||||
certs.iter().take(MAX_CERTS_TO_CHECK).map(|cert| {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(cert.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
}),
|
||||
))
|
||||
.one(&self.connection)
|
||||
.await
|
||||
.map_err(|e| tonic::Status::internal(format!("Database query failed: {}", e)))?
|
||||
.is_some();
|
||||
|
||||
Ok(!found)
|
||||
}
|
||||
}
|
||||
1
apps/nxmesh-master/src/connector/mod.rs
Normal file
1
apps/nxmesh-master/src/connector/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod agent;
|
||||
27
apps/nxmesh-master/src/db/entities/agents.rs
Normal file
27
apps/nxmesh-master/src/db/entities/agents.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "agents")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub ip_address: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub state: String,
|
||||
pub deployment_mode: Option<String>,
|
||||
pub last_seen_at: Option<DateTimeWithTimeZone>,
|
||||
pub capabilities: Option<Json>,
|
||||
pub public_key_hash: Option<String>,
|
||||
pub labels: Option<Json>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
45
apps/nxmesh-master/src/db/entities/certificates.rs
Normal file
45
apps/nxmesh-master/src/db/entities/certificates.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "certificates")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub domain: String,
|
||||
pub is_wildcard: bool,
|
||||
pub provider: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub issued_at: Option<DateTimeWithTimeZone>,
|
||||
pub expires_at: Option<DateTimeWithTimeZone>,
|
||||
pub auto_renew: bool,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub certificate_pem: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub private_key_pem: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::workspaces::Entity",
|
||||
from = "Column::WorkspaceId",
|
||||
to = "super::workspaces::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Workspaces,
|
||||
}
|
||||
|
||||
impl Related<super::workspaces::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Workspaces.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
6
apps/nxmesh-master/src/db/entities/mod.rs
Normal file
6
apps/nxmesh-master/src/db/entities/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod agents;
|
||||
pub mod public_key_revocations;
|
||||
39
apps/nxmesh-master/src/db/entities/organizations.rs
Normal file
39
apps/nxmesh-master/src/db/entities/organizations.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "organizations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[sea_orm(unique)]
|
||||
pub slug: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
pub settings: Option<Json>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::users::Entity")]
|
||||
Users,
|
||||
#[sea_orm(has_many = "super::workspaces::Entity")]
|
||||
Workspaces,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::workspaces::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Workspaces.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
4
apps/nxmesh-master/src/db/entities/prelude.rs
Normal file
4
apps/nxmesh-master/src/db/entities/prelude.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
pub use super::agents::Entity as Agents;
|
||||
pub use super::public_key_revocations::Entity as PublicKeyRevocations;
|
||||
18
apps/nxmesh-master/src/db/entities/public_key_revocations.rs
Normal file
18
apps/nxmesh-master/src/db/entities/public_key_revocations.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "public_key_revocations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub public_key_hash: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
21
apps/nxmesh-master/src/db/entities/setup_tokens.rs
Normal file
21
apps/nxmesh-master/src/db/entities/setup_tokens.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "setup_tokens")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub token_hash: String,
|
||||
pub expires_at: DateTimeWithTimeZone,
|
||||
pub used_at: Option<DateTimeWithTimeZone>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
40
apps/nxmesh-master/src/db/entities/upstreams.rs
Normal file
40
apps/nxmesh-master/src/db/entities/upstreams.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "upstreams")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub name: String,
|
||||
pub algorithm: String,
|
||||
pub servers: Option<Json>,
|
||||
pub health_check: Option<Json>,
|
||||
pub keepalive_connections: Option<i32>,
|
||||
pub keepalive_timeout: Option<i32>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::workspaces::Entity",
|
||||
from = "Column::WorkspaceId",
|
||||
to = "super::workspaces::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Workspaces,
|
||||
}
|
||||
|
||||
impl Related<super::workspaces::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Workspaces.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
39
apps/nxmesh-master/src/db/entities/users.rs
Normal file
39
apps/nxmesh-master/src/db/entities/users.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub name: Option<String>,
|
||||
pub role: String,
|
||||
pub organization_id: Option<Uuid>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::organizations::Entity",
|
||||
from = "Column::OrganizationId",
|
||||
to = "super::organizations::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "SetNull"
|
||||
)]
|
||||
Organizations,
|
||||
}
|
||||
|
||||
impl Related<super::organizations::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Organizations.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
apps/nxmesh-master/src/db/entities/virtual_hosts.rs
Normal file
44
apps/nxmesh-master/src/db/entities/virtual_hosts.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "virtual_hosts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub name: String,
|
||||
pub server_name: String,
|
||||
pub listen_port: i32,
|
||||
pub ssl_enabled: bool,
|
||||
pub ssl_certificate_id: Option<Uuid>,
|
||||
pub locations: Option<Json>,
|
||||
pub http2_enabled: bool,
|
||||
pub http3_enabled: bool,
|
||||
pub gzip_enabled: bool,
|
||||
pub target_agents: Option<Json>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::workspaces::Entity",
|
||||
from = "Column::WorkspaceId",
|
||||
to = "super::workspaces::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Workspaces,
|
||||
}
|
||||
|
||||
impl Related<super::workspaces::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Workspaces.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
70
apps/nxmesh-master/src/db/entities/workspaces.rs
Normal file
70
apps/nxmesh-master/src/db/entities/workspaces.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "workspaces")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
#[sea_orm(unique_key = "idx_workspaces_org_slug")]
|
||||
pub organization_id: Uuid,
|
||||
pub name: String,
|
||||
#[sea_orm(unique_key = "idx_workspaces_org_slug")]
|
||||
pub slug: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::agents::Entity")]
|
||||
Agents,
|
||||
#[sea_orm(has_many = "super::certificates::Entity")]
|
||||
Certificates,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::organizations::Entity",
|
||||
from = "Column::OrganizationId",
|
||||
to = "super::organizations::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Organizations,
|
||||
#[sea_orm(has_many = "super::upstreams::Entity")]
|
||||
Upstreams,
|
||||
#[sea_orm(has_many = "super::virtual_hosts::Entity")]
|
||||
VirtualHosts,
|
||||
}
|
||||
|
||||
impl Related<super::agents::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Agents.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::certificates::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Certificates.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::organizations::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Organizations.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::upstreams::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Upstreams.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::virtual_hosts::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::VirtualHosts.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
11
apps/nxmesh-master/src/db/mod.rs
Normal file
11
apps/nxmesh-master/src/db/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use sea_orm::{Database, DatabaseConnection};
|
||||
|
||||
pub mod entities;
|
||||
|
||||
pub(crate) async fn establish_connection(
|
||||
url: &str,
|
||||
) -> Result<DatabaseConnection, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Database::connect(url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to database: {}", e).into())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#![forbid(unsafe_code, unused_must_use)]
|
||||
#![deny(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
||||
|
||||
use clap::{CommandFactory, Parser};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
Layer, filter::LevelFilter, fmt, layer::SubscriberExt, registry::Registry, reload,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
use crate::cli::{Cli, handle_sub_command};
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
mod connector;
|
||||
mod db;
|
||||
mod service;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// install a global subscriber for logging
|
||||
let reload_handle = install_tracing_subscriber();
|
||||
// Load configuration settings
|
||||
let settings = match config::settings::Settings::load() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to load configuration: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
reload_handle
|
||||
.modify(|filter| *filter = Box::new(settings.log.level))
|
||||
.inspect_err(|e| {
|
||||
error!(
|
||||
"Failed to set log level: {}. Continuing with default level.",
|
||||
e
|
||||
)
|
||||
})
|
||||
// ignore errors here since we can still run with the default log level
|
||||
.ok();
|
||||
|
||||
// print the loaded settings for debugging
|
||||
// info!("Loaded settings: {:#?}", settings);
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.serve {
|
||||
info!("Starting master server...");
|
||||
if let Err(e) = service::start_master_server(settings, cli).await {
|
||||
error!("Failed to start master server: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else if let Some(command) = cli.command {
|
||||
handle_sub_command(&settings, command)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Error handling command: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
} else {
|
||||
error!("No mode specified.");
|
||||
// display help message
|
||||
#[allow(clippy::expect_used)]
|
||||
Cli::command()
|
||||
.print_help()
|
||||
.expect("Failed to print help message");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn install_tracing_subscriber()
|
||||
-> reload::Handle<Box<dyn tracing_subscriber::layer::Layer<Registry> + Send + Sync>, Registry> {
|
||||
let filter = LevelFilter::INFO;
|
||||
let (filter_layer, reload_handle) =
|
||||
reload::Layer::new(Box::new(fmt::layer().with_filter(filter))
|
||||
as Box<dyn tracing_subscriber::layer::Layer<Registry> + Send + Sync>);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(fmt::Layer::default())
|
||||
.init();
|
||||
|
||||
reload_handle
|
||||
}
|
||||
|
||||
54
apps/nxmesh-master/src/service/agent/mod.rs
Normal file
54
apps/nxmesh-master/src/service/agent/mod.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use nxmesh_proto::{
|
||||
Ack, AgentMessage, HealthReport, MasterMessage, MetricsBatch,
|
||||
agent_service_server::AgentService,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AgentServerService {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AgentService for AgentServerService {
|
||||
#[doc = " Server streaming response type for the Stream method."]
|
||||
type StreamStream = tonic::codec::Streaming<MasterMessage>;
|
||||
|
||||
#[doc = " Stream establishes a persistent connection for real-time communication"]
|
||||
#[allow(
|
||||
mismatched_lifetime_syntaxes,
|
||||
clippy::type_complexity,
|
||||
clippy::type_repetition_in_bounds
|
||||
)]
|
||||
async fn stream(
|
||||
&self,
|
||||
request: tonic::Request<tonic::Streaming<AgentMessage>>,
|
||||
) -> Result<tonic::Response<Self::StreamStream>, tonic::Status> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[doc = " ReportHealth sends a health report to the master"]
|
||||
#[allow(
|
||||
mismatched_lifetime_syntaxes,
|
||||
clippy::type_complexity,
|
||||
clippy::type_repetition_in_bounds
|
||||
)]
|
||||
async fn report_health(
|
||||
&self,
|
||||
request: tonic::Request<HealthReport>,
|
||||
) -> Result<tonic::Response<Ack>, tonic::Status> {
|
||||
warn!("Received health report: {:?}", request.get_ref());
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[doc = " ReportMetrics sends metrics batch to the master"]
|
||||
#[allow(
|
||||
mismatched_lifetime_syntaxes,
|
||||
clippy::type_complexity,
|
||||
clippy::type_repetition_in_bounds
|
||||
)]
|
||||
async fn report_metrics(
|
||||
&self,
|
||||
request: tonic::Request<MetricsBatch>,
|
||||
) -> Result<tonic::Response<Ack>, tonic::Status> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
349
apps/nxmesh-master/src/service/certificate/mod.rs
Normal file
349
apps/nxmesh-master/src/service/certificate/mod.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
use std::{io::Write, os::unix::fs::PermissionsExt, path::Path, sync::Arc};
|
||||
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
|
||||
KeyUsagePurpose, SanType, string::Ia5String,
|
||||
};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::debug;
|
||||
|
||||
// TODO: cert rotation, revocation, and CRL support
|
||||
|
||||
pub enum ConnectionType {
|
||||
GRPC,
|
||||
HTTP,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait CertificateService: Sync + Send {
|
||||
/// Get the CA certificate path, if the CA certificate does not exist, return an error
|
||||
async fn get_ca_cert(
|
||||
&self,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Generate an in memory public and private key pair, sign it with the CA certificate and key, and return the signed public and private key as PEM string, if the CA certificate does not exist, return an error, if the CA certificate already exists, return an error
|
||||
async fn generate_pub_cert_pair(
|
||||
&self,
|
||||
san_ips: Vec<std::net::IpAddr>,
|
||||
san_dns: Vec<Ia5String>,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Generate a new CA certificate and save it to the specified path, if the CA certificate already exists, return an error
|
||||
async fn generate_ca_cert(
|
||||
&self,
|
||||
) -> Result<CertPathInfo, Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Generate certificates for agent and save them to the specified output directory, the output directory should be created if it does not exist
|
||||
async fn generate_agent_certs(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
output_dir: &str,
|
||||
) -> Result<AgentCertPathInfo, Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Zip the generated agent certificates, the input should be the cert path and key path, the output should be a zip file containing the cert and key
|
||||
async fn zip_certificates(
|
||||
&self,
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
ca_cert_path: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>>;
|
||||
/// Get the sans to be included in the generated certificates, this is used to support IP-based connections to the agent, the SANs will be included in both the CA certificate and the agent certificates, if the SANs are not specified, some common local addresses will be included by default
|
||||
fn get_sans(&self, connection_type: ConnectionType) -> (Vec<std::net::IpAddr>, Vec<Ia5String>);
|
||||
}
|
||||
|
||||
pub struct CertificateServiceImpl {
|
||||
db: DatabaseConnection,
|
||||
/// The path to the CA certificate, the CA certificate and private key will be saved to this path when generating a new CA certificate
|
||||
cert_folder_path: String,
|
||||
settings: Arc<crate::config::settings::Settings>,
|
||||
}
|
||||
|
||||
impl CertificateServiceImpl {
|
||||
pub fn new(
|
||||
db: DatabaseConnection,
|
||||
cert_folder_path: String,
|
||||
settings: Arc<crate::config::settings::Settings>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
cert_folder_path,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertPathInfo {
|
||||
pub private_key: String,
|
||||
pub cert_pem: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentCertPathInfo {
|
||||
pub cert_path: String,
|
||||
pub key_path: String,
|
||||
pub ca_cert_path: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CertificateService for CertificateServiceImpl {
|
||||
async fn get_ca_cert(
|
||||
&self,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if Path::new(&self.cert_folder_path).exists() {
|
||||
let cert_path = Path::new(&self.cert_folder_path).join("ca.crt");
|
||||
let key_path = Path::new(&self.cert_folder_path).join("ca.key");
|
||||
if cert_path.exists() && key_path.exists() {
|
||||
Ok((
|
||||
cert_path.to_string_lossy().to_string(),
|
||||
key_path.to_string_lossy().to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"CA certificate or key not found",
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"CA certificate folder not found",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn generate_pub_cert_pair(
|
||||
&self,
|
||||
san_ips: Vec<std::net::IpAddr>,
|
||||
san_dns: Vec<Ia5String>,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (ca_cert_path, ca_key_path) = self.get_ca_cert().await?;
|
||||
let ca_cert_pem = std::fs::read_to_string(ca_cert_path)?;
|
||||
let ca_key_pem = std::fs::read_to_string(ca_key_path)?;
|
||||
|
||||
let ca_key = KeyPair::from_pem(&ca_key_pem)?;
|
||||
let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)?;
|
||||
|
||||
// TODO: require input to set the SANs for the generated certificate, for now we will include some common local addresses to support IP-based connections to the agent, but in the future we should allow users to specify the SANs for the generated certificates
|
||||
// Include SANs for common local addresses to support IP-based connections
|
||||
let subject_alt_names: Vec<SanType> = [
|
||||
san_ips
|
||||
.into_iter()
|
||||
.map(SanType::IpAddress)
|
||||
.collect::<Vec<SanType>>(),
|
||||
san_dns
|
||||
.into_iter()
|
||||
.map(|dns| SanType::DnsName(dns))
|
||||
.collect::<Vec<SanType>>(),
|
||||
]
|
||||
.concat();
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params.subject_alt_names = subject_alt_names;
|
||||
params.is_ca = IsCa::NoCa;
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ServerAuth);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ClientAuth);
|
||||
params.serial_number = Some(rand::random::<u64>().into()); // Unique serial
|
||||
|
||||
let (not_before, not_after) = validity_period();
|
||||
params.not_before = not_before;
|
||||
params.not_after = not_after;
|
||||
|
||||
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ED25519)?;
|
||||
let cert = params.signed_by(&key_pair, &issuer)?;
|
||||
Ok((cert.pem(), key_pair.serialize_pem()))
|
||||
}
|
||||
|
||||
async fn generate_ca_cert(
|
||||
&self,
|
||||
) -> Result<CertPathInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// check if the CA certificate already exists in the folder
|
||||
let cert_folder_path = Path::new(&self.cert_folder_path);
|
||||
let cert_path = cert_folder_path.join("ca.crt");
|
||||
let key_path = cert_folder_path.join("ca.key");
|
||||
let pub_path = cert_folder_path.join("ca.pub");
|
||||
if !cert_folder_path.exists() {
|
||||
std::fs::create_dir_all(cert_folder_path)?;
|
||||
}
|
||||
if cert_path.exists() || key_path.exists() || pub_path.exists() {
|
||||
return Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"CA certificate already exists",
|
||||
)));
|
||||
}
|
||||
|
||||
let kp = KeyPair::generate_for(&rcgen::PKCS_ED25519)?;
|
||||
let mut params = CertificateParams::new(Vec::default())?;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::OrganizationName, "MasterCA");
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
|
||||
params.key_usages.push(KeyUsagePurpose::CrlSign);
|
||||
|
||||
let (not_before, not_after) = validity_period();
|
||||
params.not_before = not_before;
|
||||
params.not_after = not_after;
|
||||
|
||||
let ca_cert = params.self_signed(&kp)?;
|
||||
let cert_pem = ca_cert.pem();
|
||||
let private_key = kp.serialize_pem();
|
||||
let public_key = kp.public_key_pem();
|
||||
|
||||
// save the CA certificate and private key to the specified path
|
||||
|
||||
std::fs::write(&cert_path, cert_pem.as_bytes())?;
|
||||
std::fs::set_permissions(cert_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
std::fs::write(&key_path, private_key.as_bytes())?;
|
||||
std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
std::fs::write(&pub_path, public_key.as_bytes())?;
|
||||
std::fs::set_permissions(pub_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
Ok(CertPathInfo {
|
||||
private_key,
|
||||
cert_pem,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_agent_certs(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
output_dir: &str,
|
||||
) -> Result<AgentCertPathInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!(
|
||||
"Generating agent certificates for agent_id: {}, output_dir: {}",
|
||||
agent_id, output_dir
|
||||
);
|
||||
let output_path_dir = Path::new(output_dir).join(agent_id);
|
||||
let cert_path = output_path_dir.join("cert.pem");
|
||||
let key_path = output_path_dir.join("key.pem");
|
||||
|
||||
// validate output parent directory exists
|
||||
if !std::path::Path::new(output_dir).exists() {
|
||||
// TODO: custom error type
|
||||
return Err(Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Output parent directory does not exist",
|
||||
)));
|
||||
}
|
||||
|
||||
// create output directory if it does not exist
|
||||
if !output_path_dir.exists() {
|
||||
std::fs::create_dir_all(&output_path_dir)?;
|
||||
}
|
||||
|
||||
// Check if CA certificate exists
|
||||
let (ca_cert_path, ca_key_path) = self.get_ca_cert().await?;
|
||||
|
||||
// Read CA certificate and key from disk
|
||||
debug!("Reading CA certificate from path: {:?}", ca_cert_path);
|
||||
let ca_cert_pem = std::fs::read_to_string(ca_cert_path.clone())?;
|
||||
let ca_key_pem = std::fs::read_to_string(ca_key_path)?;
|
||||
|
||||
// Parse CA key and create issuer
|
||||
debug!("Parsing CA key and creating issuer");
|
||||
let ca_key = KeyPair::from_pem(&ca_key_pem)?;
|
||||
let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)?;
|
||||
|
||||
// Generate agent keypair
|
||||
let agent_keypair = KeyPair::generate_for(&rcgen::PKCS_ED25519)?;
|
||||
|
||||
// Params for agent leaf cert
|
||||
let mut params = CertificateParams::new(vec![agent_id.to_string()])?;
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, agent_id.to_string());
|
||||
params.use_authority_key_identifier_extension = true;
|
||||
params.key_usages.push(KeyUsagePurpose::DigitalSignature);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ServerAuth);
|
||||
params
|
||||
.extended_key_usages
|
||||
.push(ExtendedKeyUsagePurpose::ClientAuth);
|
||||
params.serial_number = Some(rand::random::<u64>().into()); // Unique serial
|
||||
|
||||
let (not_before, not_after) = validity_period();
|
||||
params.not_before = not_before;
|
||||
params.not_after = not_after;
|
||||
|
||||
// Sign with CA
|
||||
let agent_cert = params.signed_by(&agent_keypair, &issuer)?;
|
||||
let agent_cert_pem = agent_cert.pem();
|
||||
let agent_key_pem = agent_keypair.serialize_pem();
|
||||
|
||||
// Save agent certificate and private key to output directory
|
||||
|
||||
debug!(
|
||||
"Saving agent certificate and key to output directory: {:?}",
|
||||
output_path_dir
|
||||
);
|
||||
std::fs::write(&cert_path, agent_cert_pem.as_bytes())?;
|
||||
std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
std::fs::write(&key_path, agent_key_pem.as_bytes())?;
|
||||
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
Ok(AgentCertPathInfo {
|
||||
cert_path: cert_path.to_string_lossy().to_string(),
|
||||
key_path: key_path.to_string_lossy().to_string(),
|
||||
ca_cert_path: ca_cert_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn zip_certificates(
|
||||
&self,
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
ca_cert_path: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let zip_path = format!("{}.zip", cert_path.trim_end_matches(".pem"));
|
||||
let file = std::fs::File::create(&zip_path)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o600);
|
||||
|
||||
zip.start_file("cert.pem", options)?;
|
||||
let cert_data = std::fs::read(cert_path)?;
|
||||
zip.write_all(&cert_data)?;
|
||||
|
||||
zip.start_file("key.pem", options)?;
|
||||
let key_data = std::fs::read(key_path)?;
|
||||
zip.write_all(&key_data)?;
|
||||
|
||||
zip.start_file("ca.pem", options)?;
|
||||
let ca_cert_data = std::fs::read(ca_cert_path)?;
|
||||
zip.write_all(&ca_cert_data)?;
|
||||
|
||||
zip.finish()?;
|
||||
Ok(zip_path)
|
||||
}
|
||||
|
||||
fn get_sans(&self, connection_type: ConnectionType) -> (Vec<std::net::IpAddr>, Vec<Ia5String>) {
|
||||
let cert_settings = match connection_type {
|
||||
ConnectionType::GRPC => &self.settings.grpc.certificate,
|
||||
ConnectionType::HTTP => &self.settings.server.certificate,
|
||||
};
|
||||
|
||||
(cert_settings.san_ip.clone(), cert_settings.san_dns.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn validity_period() -> (OffsetDateTime, OffsetDateTime) {
|
||||
let year = Duration::new(365 * 86400, 0);
|
||||
let not_before = OffsetDateTime::now_utc();
|
||||
let not_after = match not_before.checked_add(year) {
|
||||
Some(v) => v,
|
||||
None => not_before,
|
||||
};
|
||||
(not_before, not_after)
|
||||
}
|
||||
40
apps/nxmesh-master/src/service/mod.rs
Normal file
40
apps/nxmesh-master/src/service/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{connector::agent::AgentConnectorTrait, service::certificate::CertificateService};
|
||||
|
||||
pub mod agent;
|
||||
pub mod certificate;
|
||||
|
||||
pub async fn start_master_server(
|
||||
settings: crate::config::settings::Settings,
|
||||
cli: crate::cli::Cli,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Initialize database connection
|
||||
let db_connection = crate::db::establish_connection(&settings.database.url).await?;
|
||||
|
||||
// Initialize certificate service with default cert folder path
|
||||
let cert_service = Arc::new(crate::service::certificate::CertificateServiceImpl::new(
|
||||
db_connection.clone(),
|
||||
settings.grpc.certificate.cert_dir.clone(),
|
||||
Arc::new(settings.clone()),
|
||||
));
|
||||
|
||||
// if generate_ca is set, generate a new certificate and exit
|
||||
if cli.generate_ca {
|
||||
// TODO: check the error type and return a more specific error message
|
||||
cert_service.generate_ca_cert().await.ok();
|
||||
println!("Certificate generated and stored successfully.");
|
||||
}
|
||||
|
||||
// Initialize agent connector
|
||||
let mut agent_connector = crate::connector::agent::AgentConnector::new(Box::new(
|
||||
crate::connector::agent::ssh::SshAgentConnector::new(settings.clone())?,
|
||||
));
|
||||
|
||||
// Start the agent server
|
||||
agent_connector
|
||||
.start_server(&settings, cert_service, db_connection)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user