Clean architecture implemented
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# Clean Architecture Refactoring - Complete
|
||||
|
||||
## Summary
|
||||
Successfully refactored the DDNS updater from a monolithic design to a clean architecture with Domain-Driven Design principles. The new architecture supports multiple web servers through trait-based abstractions.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Domain Layer (`src/domain/`)
|
||||
- **entities.rs**: Core domain entities (IpEntry, WebServerConfig, WebServerType)
|
||||
- **ports.rs**: Trait definitions for dependency inversion (Repository patterns, Service interfaces)
|
||||
- **services.rs**: Business logic implementation (DdnsUpdateService)
|
||||
- **value_objects.rs**: Value objects with validation (ConfigPath, Hostname, BackupRetention)
|
||||
|
||||
### Infrastructure Layer (`src/infrastructure/`)
|
||||
- **repositories.rs**: File-based and in-memory IP storage implementations
|
||||
- **webservers/nginx.rs**: Nginx configuration handler
|
||||
- **webservers/apache.rs**: Apache configuration handler
|
||||
- **network.rs**: HTTP-based network service for IP discovery
|
||||
- **notifications.rs**: Console, log, and composite notification services
|
||||
- **config_discovery.rs**: Configuration file type detection
|
||||
|
||||
### Application Layer (`src/application/`)
|
||||
- **services.rs**: Service factory and application configuration
|
||||
- **use_cases.rs**: Use case orchestration (UpdateDdnsUseCase, ConfigValidationUseCase, DdnsApplication)
|
||||
|
||||
### Interface Layer (`src/interface/`)
|
||||
- **cli_interface.rs**: Clean async CLI implementation using tokio runtime
|
||||
|
||||
## Key Features
|
||||
|
||||
### Multi-Server Support
|
||||
- ✅ Nginx: Complete implementation with location block handling
|
||||
- ✅ Apache: Complete implementation with Directory/Location block handling
|
||||
- 🔲 Caddy: Framework ready for implementation
|
||||
- 🔲 Traefik: Framework ready for implementation
|
||||
|
||||
### Clean Architecture Benefits
|
||||
- **Dependency Inversion**: All dependencies flow inward to the domain layer
|
||||
- **Testability**: Each layer can be unit tested independently
|
||||
- **Extensibility**: New web servers can be added by implementing WebServerHandler trait
|
||||
- **Maintainability**: Clear separation of concerns and single responsibility
|
||||
|
||||
### Async Architecture
|
||||
- Full async/await support with tokio runtime
|
||||
- Send + Sync error handling for thread safety
|
||||
- Async trait implementations across all service boundaries
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Trait-Based Design
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait WebServerHandler {
|
||||
async fn update_allow_list(&self, config: &WebServerConfig, hostname: &str, old_ip: Option<IpAddr>, new_ip: IpAddr) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn validate_config(&self, config: &WebServerConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn create_backup(&self, config: &WebServerConfig) -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn test_configuration(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn reload_server(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
- ServiceFactory pattern for creating configured service instances
|
||||
- Arc<dyn Trait> for shared ownership of trait objects
|
||||
- Constructor injection for testability
|
||||
|
||||
### Error Handling
|
||||
- Consistent error types: `Box<dyn std::error::Error + Send + Sync>`
|
||||
- Thread-safe error propagation
|
||||
- Domain-specific error types where appropriate
|
||||
|
||||
## Migration Status
|
||||
- ✅ Domain layer: Complete
|
||||
- ✅ Infrastructure layer: Complete
|
||||
- ✅ Application layer: Complete
|
||||
- ✅ Interface layer: Complete
|
||||
- ✅ Compilation: Success
|
||||
- ✅ CLI interface: Working
|
||||
- ✅ Backward compatibility: Maintained
|
||||
|
||||
## Usage
|
||||
The application maintains the same CLI interface while now supporting multiple web server types:
|
||||
|
||||
```bash
|
||||
# Update nginx configuration (auto-detected)
|
||||
./ddns_updater --config /etc/nginx/sites-available/mysite.conf --verbose
|
||||
|
||||
# Update apache configuration (auto-detected)
|
||||
./ddns_updater --config /etc/apache2/sites-available/mysite.conf --verbose
|
||||
|
||||
# Process directory of configurations (mixed types supported)
|
||||
./ddns_updater --config-dir /etc/nginx/sites-available/ --verbose
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
1. **Add Caddy Support**: Implement WebServerHandler for Caddy configurations
|
||||
2. **Add Traefik Support**: Implement WebServerHandler for Traefik dynamic configurations
|
||||
3. **Plugin System**: Dynamic loading of web server handlers
|
||||
4. **Configuration Validation**: Enhanced validation for each server type
|
||||
5. **Testing Suite**: Comprehensive integration tests for all server types
|
||||
|
||||
The clean architecture foundation makes all these enhancements straightforward to implement.
|
||||
@@ -6,3 +6,12 @@ default-run = "ddns_updater"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
regex = "1.0"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
url = "2.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod services;
|
||||
pub mod use_cases;
|
||||
|
||||
pub use services::*;
|
||||
pub use use_cases::*;
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
use crate::domain::entities::WebServerType;
|
||||
use crate::domain::ports::{IpRepository, WebServerHandler, NetworkService, NotificationService, ConfigDiscoveryService};
|
||||
use crate::infrastructure::{
|
||||
FileIpRepository, HttpNetworkService, ConsoleNotificationService,
|
||||
FileSystemConfigDiscovery,
|
||||
};
|
||||
use crate::infrastructure::webservers::{NginxHandler, ApacheHandler};
|
||||
|
||||
/// Application service factory for creating configured services
|
||||
pub struct ServiceFactory;
|
||||
|
||||
impl ServiceFactory {
|
||||
/// Create an IP repository with the given storage directory
|
||||
pub fn create_ip_repository(storage_dir: std::path::PathBuf) -> Result<Arc<dyn IpRepository>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let repo = FileIpRepository::new(storage_dir)?;
|
||||
Ok(Arc::new(repo))
|
||||
}
|
||||
|
||||
/// Create a web server handler for the given server type
|
||||
pub fn create_web_server_handler(server_type: WebServerType) -> Arc<dyn WebServerHandler> {
|
||||
match server_type {
|
||||
WebServerType::Nginx => Arc::new(NginxHandler::new()),
|
||||
WebServerType::Apache => Arc::new(ApacheHandler::new()),
|
||||
WebServerType::Caddy => {
|
||||
// TODO: Implement Caddy handler
|
||||
Arc::new(NginxHandler::new()) // Fallback to Nginx for now
|
||||
}
|
||||
WebServerType::Traefik => {
|
||||
// TODO: Implement Traefik handler
|
||||
Arc::new(NginxHandler::new()) // Fallback to Nginx for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a network service for retrieving public IP addresses
|
||||
pub fn create_network_service() -> Arc<dyn NetworkService> {
|
||||
Arc::new(HttpNetworkService::new())
|
||||
}
|
||||
|
||||
/// Create a notification service based on configuration
|
||||
pub fn create_notification_service(verbose: bool) -> Arc<dyn NotificationService> {
|
||||
Arc::new(ConsoleNotificationService::new(verbose))
|
||||
}
|
||||
|
||||
/// Create a configuration discovery service
|
||||
pub fn create_config_discovery_service() -> Arc<dyn ConfigDiscoveryService> {
|
||||
Arc::new(FileSystemConfigDiscovery::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Application configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub storage_dir: std::path::PathBuf,
|
||||
pub verbose: bool,
|
||||
pub backup_retention_days: u16,
|
||||
pub max_backups: u16,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_dir: std::path::PathBuf::from("/var/lib/ddns-updater"),
|
||||
verbose: false,
|
||||
backup_retention_days: 30,
|
||||
max_backups: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_storage_dir(mut self, dir: std::path::PathBuf) -> Self {
|
||||
self.storage_dir = dir;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_verbose(mut self, verbose: bool) -> Self {
|
||||
self.verbose = verbose;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_backup_retention(mut self, days: u16, max_backups: u16) -> Self {
|
||||
self.backup_retention_days = days;
|
||||
self.max_backups = max_backups;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::sync::Arc;
|
||||
use crate::domain::services::{DdnsUpdateService, UpdateResult, ValidationResult};
|
||||
use crate::domain::entities::{WebServerConfig, IpEntry};
|
||||
use crate::domain::ports::{IpRepository, WebServerHandler, NetworkService, NotificationService, ConfigDiscoveryService};
|
||||
use crate::application::services::{ServiceFactory, AppConfig};
|
||||
|
||||
/// Use case for updating DDNS entries
|
||||
pub struct UpdateDdnsUseCase {
|
||||
service: DdnsUpdateService,
|
||||
}
|
||||
|
||||
impl UpdateDdnsUseCase {
|
||||
pub fn new(
|
||||
ip_repository: Arc<dyn IpRepository>,
|
||||
web_server_handler: Arc<dyn WebServerHandler>,
|
||||
network_service: Arc<dyn NetworkService>,
|
||||
notification_service: Arc<dyn NotificationService>,
|
||||
) -> Self {
|
||||
let service = DdnsUpdateService::new(
|
||||
ip_repository,
|
||||
web_server_handler,
|
||||
network_service,
|
||||
notification_service,
|
||||
);
|
||||
|
||||
Self { service }
|
||||
}
|
||||
|
||||
/// Execute the DDNS update for a hostname and configuration
|
||||
pub async fn execute(
|
||||
&self,
|
||||
hostname: &str,
|
||||
config: &WebServerConfig,
|
||||
) -> Result<UpdateResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.service.update_ddns(hostname, config).await
|
||||
}
|
||||
|
||||
/// List all stored IP entries
|
||||
pub async fn list_entries(&self) -> Result<Vec<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.service.list_entries().await
|
||||
}
|
||||
|
||||
/// Remove an IP entry
|
||||
pub async fn remove_entry(&self, hostname: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.service.remove_entry(hostname).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Use case for discovering and validating configurations
|
||||
pub struct ConfigValidationUseCase {
|
||||
config_discovery: Arc<dyn ConfigDiscoveryService>,
|
||||
}
|
||||
|
||||
impl ConfigValidationUseCase {
|
||||
pub fn new(config_discovery: Arc<dyn ConfigDiscoveryService>) -> Self {
|
||||
Self { config_discovery }
|
||||
}
|
||||
|
||||
/// Discover configuration files
|
||||
pub async fn discover_configs(
|
||||
&self,
|
||||
pattern: Option<&str>,
|
||||
) -> Result<Vec<WebServerConfig>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.config_discovery.discover_configs(pattern).await
|
||||
}
|
||||
|
||||
/// Validate multiple configurations
|
||||
pub async fn validate_configs(
|
||||
&self,
|
||||
configs: &[WebServerConfig],
|
||||
web_server_handler: Arc<dyn WebServerHandler>,
|
||||
) -> Result<Vec<ValidationResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let service = DdnsUpdateService::new(
|
||||
ServiceFactory::create_ip_repository(std::path::PathBuf::from("/tmp"))?,
|
||||
web_server_handler,
|
||||
ServiceFactory::create_network_service(),
|
||||
ServiceFactory::create_notification_service(false),
|
||||
);
|
||||
|
||||
service.validate_configs(configs).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Application facade that provides a high-level interface
|
||||
pub struct DdnsApplication {
|
||||
config: AppConfig,
|
||||
ip_repository: Arc<dyn IpRepository>,
|
||||
network_service: Arc<dyn NetworkService>,
|
||||
notification_service: Arc<dyn NotificationService>,
|
||||
config_discovery: Arc<dyn ConfigDiscoveryService>,
|
||||
}
|
||||
|
||||
impl DdnsApplication {
|
||||
/// Create a new application instance with the given configuration
|
||||
pub fn new(config: AppConfig) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let ip_repository = ServiceFactory::create_ip_repository(config.storage_dir.clone())?;
|
||||
let network_service = ServiceFactory::create_network_service();
|
||||
let notification_service = ServiceFactory::create_notification_service(config.verbose);
|
||||
let config_discovery = ServiceFactory::create_config_discovery_service();
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
ip_repository,
|
||||
network_service,
|
||||
notification_service,
|
||||
config_discovery,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update DDNS for a specific hostname and configuration file
|
||||
pub async fn update_ddns(
|
||||
&self,
|
||||
hostname: &str,
|
||||
config_path: std::path::PathBuf,
|
||||
) -> Result<UpdateResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Detect server type
|
||||
let server_type = self.config_discovery.detect_server_type(&config_path).await?;
|
||||
let config = WebServerConfig::new(config_path, server_type.clone());
|
||||
|
||||
// Create appropriate web server handler
|
||||
let web_server_handler = ServiceFactory::create_web_server_handler(server_type);
|
||||
|
||||
// Create and execute the use case
|
||||
let use_case = UpdateDdnsUseCase::new(
|
||||
self.ip_repository.clone(),
|
||||
web_server_handler,
|
||||
self.network_service.clone(),
|
||||
self.notification_service.clone(),
|
||||
);
|
||||
|
||||
use_case.execute(hostname, &config).await
|
||||
}
|
||||
|
||||
/// Update DDNS for multiple configuration files
|
||||
pub async fn update_ddns_multiple(
|
||||
&self,
|
||||
hostname: &str,
|
||||
config_paths: Vec<std::path::PathBuf>,
|
||||
) -> Result<Vec<UpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for config_path in config_paths {
|
||||
match self.update_ddns(hostname, config_path.clone()).await {
|
||||
Ok(result) => results.push(result),
|
||||
Err(e) => {
|
||||
self.notification_service
|
||||
.notify_error(&e.to_string(), Some(&format!("config: {}", config_path.display())))
|
||||
.await?;
|
||||
// Continue with other configs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Discover configuration files using pattern
|
||||
pub async fn discover_configs(
|
||||
&self,
|
||||
pattern: Option<&str>,
|
||||
) -> Result<Vec<WebServerConfig>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let use_case = ConfigValidationUseCase::new(self.config_discovery.clone());
|
||||
use_case.discover_configs(pattern).await
|
||||
}
|
||||
|
||||
/// List all stored IP entries
|
||||
pub async fn list_entries(&self) -> Result<Vec<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ip_repository.list_all_entries().await
|
||||
}
|
||||
|
||||
/// Remove an IP entry
|
||||
pub async fn remove_entry(&self, hostname: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ip_repository.delete_entry(hostname).await
|
||||
}
|
||||
|
||||
/// Get current public IP without updating anything
|
||||
pub async fn get_current_ip(&self) -> Result<std::net::IpAddr, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.network_service.get_public_ip().await
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
use crate::config::is_nginx_config_file;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -153,7 +154,7 @@ impl Args {
|
||||
&& self.matches_pattern(filename, pattern)
|
||||
{
|
||||
// Validate that it's actually an nginx config file
|
||||
match crate::is_nginx_config_file(&path.to_string_lossy()) {
|
||||
match is_nginx_config_file(&path.to_string_lossy()) {
|
||||
Ok(true) => {
|
||||
config_files.push(path);
|
||||
}
|
||||
|
||||
+5
-5
@@ -18,7 +18,7 @@ pub fn write_file(path: &str, contents: &str) -> Result<(), std::io::Error> {
|
||||
}
|
||||
|
||||
/// Check if a file is likely an nginx configuration file
|
||||
pub fn is_nginx_config_file(path: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
pub fn is_nginx_config_file(path: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let content = open_and_read_file(path)?;
|
||||
Ok(is_nginx_config_content(&content))
|
||||
}
|
||||
@@ -136,7 +136,7 @@ pub fn update_nginx_allow_ip(
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
comment: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config_content = open_and_read_file(config_path)?;
|
||||
let mut lines: Vec<String> = config_content.lines().map(|s| s.to_string()).collect();
|
||||
let comment_text = comment.unwrap_or("DDNS");
|
||||
@@ -254,7 +254,7 @@ pub fn update_nginx_allow_ip(
|
||||
}
|
||||
|
||||
/// Create a backup of nginx config file
|
||||
pub fn backup_nginx_config(config_path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
pub fn backup_nginx_config(config_path: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
backup_nginx_config_to_dir(config_path, None)
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ pub fn backup_nginx_config(config_path: &str) -> Result<String, Box<dyn std::err
|
||||
pub fn backup_nginx_config_to_dir(
|
||||
config_path: &str,
|
||||
backup_dir: Option<&std::path::Path>,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use std::path::Path;
|
||||
|
||||
let config_file = Path::new(config_path);
|
||||
@@ -302,7 +302,7 @@ pub fn is_nginx_installed() -> bool {
|
||||
}
|
||||
|
||||
/// Reload nginx configuration (only if nginx is installed)
|
||||
pub fn reload_nginx() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn reload_nginx() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use std::process::Command;
|
||||
|
||||
if !is_nginx_installed() {
|
||||
|
||||
@@ -4,14 +4,14 @@ use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
|
||||
/// Store an IP address to a file
|
||||
pub fn store_ip(ip: IpAddr, file_path: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn store_ip(ip: IpAddr, file_path: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut file = fs::File::create(file_path)?;
|
||||
writeln!(file, "{}", ip)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load an IP address from a file
|
||||
pub fn load_ip(file_path: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
pub fn load_ip(file_path: &str) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let content = fs::read_to_string(file_path)?;
|
||||
let ip_str = content.trim();
|
||||
let ip: IpAddr = ip_str.parse()?;
|
||||
@@ -22,7 +22,7 @@ pub fn load_ip(file_path: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
pub fn check_and_update_ip(
|
||||
current_ip: IpAddr,
|
||||
file_path: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let ip_changed = if Path::new(file_path).exists() {
|
||||
match load_ip(file_path) {
|
||||
Ok(stored_ip) => {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use std::net::{IpAddr, UdpSocket};
|
||||
|
||||
/// Check if a host is online and return its IP address
|
||||
pub fn get_host_ip(host: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
pub fn get_host_ip(host: &str) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Use a UDP socket to force a fresh DNS resolution
|
||||
let addr = format!("{}:80", host);
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::net::IpAddr;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Domain entity representing an IP address entry in a configuration
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct IpEntry {
|
||||
pub ip: IpAddr,
|
||||
pub hostname: String,
|
||||
pub comment: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl IpEntry {
|
||||
pub fn new(ip: IpAddr, hostname: String, comment: Option<String>) -> Self {
|
||||
let now = chrono::Utc::now();
|
||||
Self {
|
||||
ip,
|
||||
hostname,
|
||||
comment,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ip(&mut self, new_ip: IpAddr) {
|
||||
self.ip = new_ip;
|
||||
self.updated_at = chrono::Utc::now();
|
||||
}
|
||||
|
||||
pub fn update_comment(&mut self, comment: Option<String>) {
|
||||
self.comment = comment;
|
||||
self.updated_at = chrono::Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration entry for a web server
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebServerConfig {
|
||||
pub path: std::path::PathBuf,
|
||||
pub server_type: WebServerType,
|
||||
pub backup_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl WebServerConfig {
|
||||
pub fn new(path: std::path::PathBuf, server_type: WebServerType) -> Self {
|
||||
Self {
|
||||
path,
|
||||
server_type,
|
||||
backup_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_backup(mut self, backup_path: std::path::PathBuf) -> Self {
|
||||
self.backup_path = Some(backup_path);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported web server types
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WebServerType {
|
||||
Nginx,
|
||||
Apache,
|
||||
Caddy,
|
||||
Traefik,
|
||||
}
|
||||
|
||||
impl fmt::Display for WebServerType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
WebServerType::Nginx => write!(f, "nginx"),
|
||||
WebServerType::Apache => write!(f, "apache"),
|
||||
WebServerType::Caddy => write!(f, "caddy"),
|
||||
WebServerType::Traefik => write!(f, "traefik"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for WebServerType {
|
||||
type Err = DomainError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"nginx" => Ok(WebServerType::Nginx),
|
||||
"apache" | "apache2" | "httpd" => Ok(WebServerType::Apache),
|
||||
"caddy" => Ok(WebServerType::Caddy),
|
||||
"traefik" => Ok(WebServerType::Traefik),
|
||||
_ => Err(DomainError::InvalidWebServerType(s.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain-specific errors
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DomainError {
|
||||
InvalidIpAddress(String),
|
||||
InvalidHostname(String),
|
||||
InvalidWebServerType(String),
|
||||
ConfigurationNotFound(String),
|
||||
IpEntryNotFound(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for DomainError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DomainError::InvalidIpAddress(ip) => write!(f, "Invalid IP address: {}", ip),
|
||||
DomainError::InvalidHostname(hostname) => write!(f, "Invalid hostname: {}", hostname),
|
||||
DomainError::InvalidWebServerType(server_type) => {
|
||||
write!(f, "Unsupported web server type: {}", server_type)
|
||||
}
|
||||
DomainError::ConfigurationNotFound(path) => {
|
||||
write!(f, "Configuration not found: {}", path)
|
||||
}
|
||||
DomainError::IpEntryNotFound(hostname) => {
|
||||
write!(f, "IP entry not found for hostname: {}", hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DomainError {}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod entities;
|
||||
pub mod ports;
|
||||
pub mod services;
|
||||
pub mod value_objects;
|
||||
|
||||
pub use entities::*;
|
||||
pub use ports::*;
|
||||
pub use services::*;
|
||||
pub use value_objects::*;
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::net::IpAddr;
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::entities::{IpEntry, WebServerConfig, DomainError};
|
||||
|
||||
/// Repository trait for IP storage operations
|
||||
#[async_trait]
|
||||
pub trait IpRepository: Send + Sync {
|
||||
async fn store_ip(&self, hostname: &str, ip: IpAddr) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn load_ip(&self, hostname: &str) -> Result<Option<IpAddr>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn get_ip_entry(&self, hostname: &str) -> Result<Option<IpEntry>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn list_all_entries(&self) -> Result<Vec<IpEntry>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn delete_entry(&self, hostname: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
/// Web server configuration handler trait
|
||||
#[async_trait]
|
||||
pub trait WebServerHandler: Send + Sync {
|
||||
async fn update_allow_list(
|
||||
&self,
|
||||
config: &WebServerConfig,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
async fn validate_config(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
async fn reload_server(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
async fn create_backup(&self, config: &WebServerConfig) -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
async fn test_configuration(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
fn server_type(&self) -> crate::domain::entities::WebServerType;
|
||||
}
|
||||
|
||||
/// Network service for retrieving public IP addresses
|
||||
#[async_trait]
|
||||
pub trait NetworkService: Send + Sync {
|
||||
async fn get_public_ip(&self) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn resolve_hostname(&self, hostname: &str) -> Result<Vec<IpAddr>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn is_reachable(&self, ip: IpAddr) -> Result<bool, Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
/// Configuration discovery service
|
||||
#[async_trait]
|
||||
pub trait ConfigDiscoveryService: Send + Sync {
|
||||
async fn discover_configs(&self, pattern: Option<&str>) -> Result<Vec<WebServerConfig>, Box<dyn std::error::Error + Send + Sync>>;
|
||||
async fn detect_server_type(&self, config_path: &std::path::Path) -> Result<crate::domain::entities::WebServerType, DomainError>;
|
||||
}
|
||||
|
||||
/// Notification service for alerting on changes
|
||||
#[async_trait]
|
||||
pub trait NotificationService: Send + Sync {
|
||||
async fn notify_ip_change(
|
||||
&self,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
async fn notify_error(
|
||||
&self,
|
||||
error: &str,
|
||||
context: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use crate::domain::entities::{IpEntry, WebServerConfig};
|
||||
use crate::domain::ports::{IpRepository, NetworkService, NotificationService, WebServerHandler};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Core DDNS update service - implements the main business logic
|
||||
pub struct DdnsUpdateService {
|
||||
ip_repository: Arc<dyn IpRepository>,
|
||||
web_server_handler: Arc<dyn WebServerHandler>,
|
||||
network_service: Arc<dyn NetworkService>,
|
||||
notification_service: Arc<dyn NotificationService>,
|
||||
}
|
||||
|
||||
impl DdnsUpdateService {
|
||||
pub fn new(
|
||||
ip_repository: Arc<dyn IpRepository>,
|
||||
web_server_handler: Arc<dyn WebServerHandler>,
|
||||
network_service: Arc<dyn NetworkService>,
|
||||
notification_service: Arc<dyn NotificationService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ip_repository,
|
||||
web_server_handler,
|
||||
network_service,
|
||||
notification_service,
|
||||
}
|
||||
}
|
||||
|
||||
/// Main update operation - checks current IP and updates configuration if changed
|
||||
pub async fn update_ddns(
|
||||
&self,
|
||||
hostname: &str,
|
||||
config: &WebServerConfig,
|
||||
) -> Result<UpdateResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get current public IP
|
||||
let current_ip = self.network_service.get_public_ip().await?;
|
||||
|
||||
// Get stored IP for this hostname
|
||||
let stored_ip = self.ip_repository.load_ip(hostname).await?;
|
||||
|
||||
// Check if IP has changed
|
||||
if let Some(old_ip) = stored_ip {
|
||||
if old_ip == current_ip {
|
||||
return Ok(UpdateResult::NoChange { ip: current_ip });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate web server configuration before making changes
|
||||
self.web_server_handler.validate_config(config).await?;
|
||||
|
||||
// Create backup before modification
|
||||
let backup_path = self.web_server_handler.create_backup(config).await?;
|
||||
|
||||
// Update the web server configuration
|
||||
let updated = self
|
||||
.web_server_handler
|
||||
.update_allow_list(config, hostname, stored_ip, current_ip)
|
||||
.await?;
|
||||
|
||||
if updated {
|
||||
// Test the new configuration
|
||||
if !self.web_server_handler.test_configuration(config).await? {
|
||||
return Err("Configuration test failed after update".into());
|
||||
}
|
||||
|
||||
// Reload the web server
|
||||
self.web_server_handler.reload_server().await?;
|
||||
|
||||
// Store the new IP
|
||||
self.ip_repository.store_ip(hostname, current_ip).await?;
|
||||
|
||||
// Send notification
|
||||
self.notification_service
|
||||
.notify_ip_change(hostname, stored_ip, current_ip)
|
||||
.await?;
|
||||
|
||||
Ok(UpdateResult::Updated {
|
||||
hostname: hostname.to_string(),
|
||||
old_ip: stored_ip,
|
||||
new_ip: current_ip,
|
||||
backup_path,
|
||||
})
|
||||
} else {
|
||||
Ok(UpdateResult::NoChange { ip: current_ip })
|
||||
}
|
||||
}
|
||||
|
||||
/// List all stored IP entries
|
||||
pub async fn list_entries(
|
||||
&self,
|
||||
) -> Result<Vec<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ip_repository.list_all_entries().await
|
||||
}
|
||||
|
||||
/// Remove an IP entry
|
||||
pub async fn remove_entry(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ip_repository.delete_entry(hostname).await
|
||||
}
|
||||
|
||||
/// Validate multiple configurations
|
||||
pub async fn validate_configs(
|
||||
&self,
|
||||
configs: &[WebServerConfig],
|
||||
) -> Result<Vec<ValidationResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for config in configs {
|
||||
let result = match self.web_server_handler.validate_config(config).await {
|
||||
Ok(valid) => ValidationResult {
|
||||
config_path: config.path.clone(),
|
||||
valid,
|
||||
error: None,
|
||||
},
|
||||
Err(e) => ValidationResult {
|
||||
config_path: config.path.clone(),
|
||||
valid: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a DDNS update operation
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UpdateResult {
|
||||
Updated {
|
||||
hostname: String,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
backup_path: std::path::PathBuf,
|
||||
},
|
||||
NoChange {
|
||||
ip: IpAddr,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of configuration validation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationResult {
|
||||
pub config_path: std::path::PathBuf,
|
||||
pub valid: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::path::PathBuf;
|
||||
use std::fmt;
|
||||
|
||||
/// Value object for configuration paths with validation
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigPath {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfigPath {
|
||||
pub fn new(path: PathBuf) -> Result<Self, ConfigPathError> {
|
||||
if !path.exists() {
|
||||
return Err(ConfigPathError::NotFound(path));
|
||||
}
|
||||
|
||||
if !path.is_file() {
|
||||
return Err(ConfigPathError::NotAFile(path));
|
||||
}
|
||||
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
pub fn as_path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn into_path_buf(self) -> PathBuf {
|
||||
self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.path.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostname value object with validation
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Hostname {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Hostname {
|
||||
pub fn new(value: String) -> Result<Self, HostnameError> {
|
||||
if value.is_empty() {
|
||||
return Err(HostnameError::Empty);
|
||||
}
|
||||
|
||||
if value.len() > 253 {
|
||||
return Err(HostnameError::TooLong(value.len()));
|
||||
}
|
||||
|
||||
// Basic hostname validation (RFC compliant validation would be more complex)
|
||||
if !value.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') {
|
||||
return Err(HostnameError::InvalidCharacters(value));
|
||||
}
|
||||
|
||||
Ok(Self { value })
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Hostname {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Hostname {
|
||||
type Err = HostnameError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::new(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Backup retention policy
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BackupRetention {
|
||||
pub max_backups: u16,
|
||||
pub max_age_days: u16,
|
||||
}
|
||||
|
||||
impl Default for BackupRetention {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_backups: 10,
|
||||
max_age_days: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupRetention {
|
||||
pub fn new(max_backups: u16, max_age_days: u16) -> Result<Self, BackupRetentionError> {
|
||||
if max_backups == 0 {
|
||||
return Err(BackupRetentionError::InvalidBackupCount);
|
||||
}
|
||||
|
||||
if max_age_days == 0 {
|
||||
return Err(BackupRetentionError::InvalidAgeDays);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
max_backups,
|
||||
max_age_days,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration path errors
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfigPathError {
|
||||
NotFound(PathBuf),
|
||||
NotAFile(PathBuf),
|
||||
PermissionDenied(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigPathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ConfigPathError::NotFound(path) => write!(f, "Configuration file not found: {}", path.display()),
|
||||
ConfigPathError::NotAFile(path) => write!(f, "Path is not a regular file: {}", path.display()),
|
||||
ConfigPathError::PermissionDenied(path) => write!(f, "Permission denied accessing: {}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigPathError {}
|
||||
|
||||
/// Hostname validation errors
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HostnameError {
|
||||
Empty,
|
||||
TooLong(usize),
|
||||
InvalidCharacters(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for HostnameError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
HostnameError::Empty => write!(f, "Hostname cannot be empty"),
|
||||
HostnameError::TooLong(len) => write!(f, "Hostname too long: {} characters (max 253)", len),
|
||||
HostnameError::InvalidCharacters(hostname) => write!(f, "Invalid characters in hostname: {}", hostname),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for HostnameError {}
|
||||
|
||||
/// Backup retention configuration errors
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackupRetentionError {
|
||||
InvalidBackupCount,
|
||||
InvalidAgeDays,
|
||||
}
|
||||
|
||||
impl fmt::Display for BackupRetentionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BackupRetentionError::InvalidBackupCount => write!(f, "Backup count must be greater than 0"),
|
||||
BackupRetentionError::InvalidAgeDays => write!(f, "Age in days must be greater than 0"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BackupRetentionError {}
|
||||
@@ -0,0 +1,162 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use async_trait::async_trait;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::domain::entities::{WebServerConfig, WebServerType, DomainError};
|
||||
use crate::domain::ports::ConfigDiscoveryService;
|
||||
|
||||
/// File system-based configuration discovery service
|
||||
pub struct FileSystemConfigDiscovery;
|
||||
|
||||
impl FileSystemConfigDiscovery {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Detect server type based on file content and location
|
||||
async fn detect_server_type_from_content(&self, path: &Path) -> Result<WebServerType, DomainError> {
|
||||
if !path.exists() {
|
||||
return Err(DomainError::ConfigurationNotFound(path.display().to_string()));
|
||||
}
|
||||
|
||||
// First, try to detect by common path patterns
|
||||
let path_str = path.to_string_lossy().to_lowercase();
|
||||
|
||||
if path_str.contains("nginx") || path_str.contains("/etc/nginx/") {
|
||||
return Ok(WebServerType::Nginx);
|
||||
}
|
||||
|
||||
if path_str.contains("apache") || path_str.contains("httpd") ||
|
||||
path_str.contains("/etc/apache2/") || path_str.contains("/etc/httpd/") {
|
||||
return Ok(WebServerType::Apache);
|
||||
}
|
||||
|
||||
// If path doesn't give us a clue, examine file content
|
||||
match fs::read_to_string(path).await {
|
||||
Ok(content) => {
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
// Look for Nginx-specific directives
|
||||
if content_lower.contains("server_name") &&
|
||||
(content_lower.contains("location") || content_lower.contains("listen")) {
|
||||
return Ok(WebServerType::Nginx);
|
||||
}
|
||||
|
||||
// Look for Apache-specific directives
|
||||
if content_lower.contains("<virtualhost") ||
|
||||
content_lower.contains("<directory") ||
|
||||
content_lower.contains("documentroot") {
|
||||
return Ok(WebServerType::Apache);
|
||||
}
|
||||
|
||||
// Look for Caddy-specific syntax
|
||||
if content_lower.contains("caddyfile") ||
|
||||
(content_lower.contains("{") && content_lower.contains("reverse_proxy")) {
|
||||
return Ok(WebServerType::Caddy);
|
||||
}
|
||||
|
||||
// Default to Nginx if we can't determine
|
||||
Ok(WebServerType::Nginx)
|
||||
}
|
||||
Err(_) => Err(DomainError::ConfigurationNotFound(path.display().to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Common configuration directory patterns
|
||||
fn get_common_config_patterns(&self) -> Vec<&'static str> {
|
||||
vec![
|
||||
"/etc/nginx/sites-available/*",
|
||||
"/etc/nginx/sites-enabled/*",
|
||||
"/etc/nginx/conf.d/*.conf",
|
||||
"/etc/apache2/sites-available/*",
|
||||
"/etc/apache2/sites-enabled/*",
|
||||
"/etc/apache2/conf.d/*.conf",
|
||||
"/etc/httpd/conf.d/*.conf",
|
||||
"/etc/httpd/sites-available/*",
|
||||
"/usr/local/etc/nginx/*",
|
||||
"/usr/local/etc/apache2*/*",
|
||||
]
|
||||
}
|
||||
|
||||
/// Expand glob pattern to actual file paths
|
||||
async fn expand_glob_pattern(&self, pattern: &str) -> Result<Vec<PathBuf>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Simple glob expansion - in a real implementation you'd use a glob library
|
||||
if pattern.ends_with("/*") {
|
||||
let dir_path = &pattern[..pattern.len() - 2];
|
||||
let dir = Path::new(dir_path);
|
||||
|
||||
if dir.exists() && dir.is_dir() {
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.file_type().await?.is_file() {
|
||||
paths.push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if pattern.ends_with("/*.conf") {
|
||||
let dir_path = &pattern[..pattern.len() - 7];
|
||||
let dir = Path::new(dir_path);
|
||||
|
||||
if dir.exists() && dir.is_dir() {
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |ext| ext == "conf") {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct file path
|
||||
let path = Path::new(pattern);
|
||||
if path.exists() {
|
||||
paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigDiscoveryService for FileSystemConfigDiscovery {
|
||||
async fn discover_configs(&self, pattern: Option<&str>) -> Result<Vec<WebServerConfig>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut configs = Vec::new();
|
||||
|
||||
let patterns = if let Some(custom_pattern) = pattern {
|
||||
vec![custom_pattern]
|
||||
} else {
|
||||
self.get_common_config_patterns()
|
||||
};
|
||||
|
||||
for pattern in patterns {
|
||||
let paths = self.expand_glob_pattern(pattern).await?;
|
||||
|
||||
for path in paths {
|
||||
match self.detect_server_type_from_content(&path).await {
|
||||
Ok(server_type) => {
|
||||
let config = WebServerConfig::new(path, server_type);
|
||||
configs.push(config);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Could not detect server type for {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
async fn detect_server_type(&self, config_path: &Path) -> Result<WebServerType, DomainError> {
|
||||
self.detect_server_type_from_content(config_path).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FileSystemConfigDiscovery {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod repositories;
|
||||
pub mod webservers;
|
||||
pub mod network;
|
||||
pub mod notifications;
|
||||
pub mod config_discovery;
|
||||
|
||||
pub use repositories::*;
|
||||
pub use webservers::*;
|
||||
pub use network::*;
|
||||
pub use notifications::*;
|
||||
pub use config_discovery::*;
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::net::IpAddr;
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::ports::NetworkService;
|
||||
|
||||
/// HTTP-based network service implementation
|
||||
pub struct HttpNetworkService {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HttpNetworkService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NetworkService for HttpNetworkService {
|
||||
async fn get_public_ip(&self) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let endpoints = [
|
||||
"https://api.ipify.org",
|
||||
"https://ipinfo.io/ip",
|
||||
"https://icanhazip.com",
|
||||
];
|
||||
|
||||
let mut last_error: Option<Box<dyn std::error::Error + Send + Sync>> = None;
|
||||
|
||||
for endpoint in &endpoints {
|
||||
match self.client
|
||||
.get(*endpoint)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
match response.text().await {
|
||||
Ok(text) => {
|
||||
let ip_str = text.trim();
|
||||
match ip_str.parse::<IpAddr>() {
|
||||
Ok(ip) => return Ok(ip),
|
||||
Err(e) => {
|
||||
last_error = Some(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Failed to get public IP from all endpoints",
|
||||
))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_hostname(&self, hostname: &str) -> Result<Vec<IpAddr>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let addrs = tokio::net::lookup_host(format!("{}:80", hostname)).await
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
||||
let ips: Vec<IpAddr> = addrs.map(|addr| addr.ip()).collect();
|
||||
Ok(ips)
|
||||
}
|
||||
|
||||
async fn is_reachable(&self, ip: IpAddr) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Simple HTTP connectivity check
|
||||
let url = format!("http://{}:80", ip);
|
||||
match self.client.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false), // Not reachable or no HTTP service
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HttpNetworkService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::net::IpAddr;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::domain::ports::NotificationService;
|
||||
|
||||
/// Console-based notification service
|
||||
pub struct ConsoleNotificationService {
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
impl ConsoleNotificationService {
|
||||
pub fn new(verbose: bool) -> Self {
|
||||
Self { verbose }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationService for ConsoleNotificationService {
|
||||
async fn notify_ip_change(
|
||||
&self,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
match old_ip {
|
||||
Some(old) => {
|
||||
println!("✅ IP updated for {}: {} → {}", hostname, old, new_ip);
|
||||
if self.verbose {
|
||||
println!(" Changed at: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
println!("✅ New IP registered for {}: {}", hostname, new_ip);
|
||||
if self.verbose {
|
||||
println!(" Registered at: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn notify_error(
|
||||
&self,
|
||||
error: &str,
|
||||
context: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
match context {
|
||||
Some(ctx) => eprintln!("❌ Error in {}: {}", ctx, error),
|
||||
None => eprintln!("❌ Error: {}", error),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Log-based notification service (writes to system log)
|
||||
pub struct LogNotificationService;
|
||||
|
||||
impl LogNotificationService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationService for LogNotificationService {
|
||||
async fn notify_ip_change(
|
||||
&self,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let message = match old_ip {
|
||||
Some(old) => format!("DDNS IP updated for {}: {} -> {}", hostname, old, new_ip),
|
||||
None => format!("DDNS new IP registered for {}: {}", hostname, new_ip),
|
||||
};
|
||||
|
||||
// In a real implementation, this would use a proper logging library
|
||||
// For now, we'll write to stderr with a timestamp
|
||||
eprintln!("[{}] INFO: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"), message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn notify_error(
|
||||
&self,
|
||||
error: &str,
|
||||
context: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let message = match context {
|
||||
Some(ctx) => format!("DDNS error in {}: {}", ctx, error),
|
||||
None => format!("DDNS error: {}", error),
|
||||
};
|
||||
|
||||
eprintln!("[{}] ERROR: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"), message);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LogNotificationService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite notification service that can send to multiple services
|
||||
pub struct CompositeNotificationService {
|
||||
services: Vec<Box<dyn NotificationService>>,
|
||||
}
|
||||
|
||||
impl CompositeNotificationService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
services: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_service(mut self, service: Box<dyn NotificationService>) -> Self {
|
||||
self.services.push(service);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationService for CompositeNotificationService {
|
||||
async fn notify_ip_change(
|
||||
&self,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
for service in &self.services {
|
||||
if let Err(e) = service.notify_ip_change(hostname, old_ip, new_ip).await {
|
||||
eprintln!("Warning: Notification service failed: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn notify_error(
|
||||
&self,
|
||||
error: &str,
|
||||
context: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
for service in &self.services {
|
||||
if let Err(e) = service.notify_error(error, context).await {
|
||||
eprintln!("Warning: Notification service failed: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompositeNotificationService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::fs;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::fs as async_fs;
|
||||
|
||||
use crate::domain::entities::IpEntry;
|
||||
use crate::domain::ports::IpRepository;
|
||||
|
||||
/// File-based IP repository implementation
|
||||
pub struct FileIpRepository {
|
||||
storage_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FileIpRepository {
|
||||
pub fn new(storage_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !storage_dir.exists() {
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
}
|
||||
|
||||
Ok(Self { storage_dir })
|
||||
}
|
||||
|
||||
fn get_file_path(&self, hostname: &str) -> PathBuf {
|
||||
self.storage_dir.join(format!("{}.json", hostname))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IpRepository for FileIpRepository {
|
||||
async fn store_ip(
|
||||
&self,
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let entry = IpEntry::new(ip, hostname.to_string(), None);
|
||||
let file_path = self.get_file_path(hostname);
|
||||
let json = serde_json::to_string_pretty(&entry)?;
|
||||
async_fs::write(file_path, json).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_ip(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<Option<IpAddr>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = self.get_file_path(hostname);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = async_fs::read_to_string(file_path).await?;
|
||||
let entry: IpEntry = serde_json::from_str(&content)?;
|
||||
Ok(Some(entry.ip))
|
||||
}
|
||||
|
||||
async fn get_ip_entry(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<Option<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = self.get_file_path(hostname);
|
||||
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = async_fs::read_to_string(file_path).await?;
|
||||
let entry: IpEntry = serde_json::from_str(&content)?;
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
async fn list_all_entries(
|
||||
&self,
|
||||
) -> Result<Vec<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut entries = Vec::new();
|
||||
let mut dir = async_fs::read_dir(&self.storage_dir).await?;
|
||||
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
if let Some(ext) = entry.path().extension() {
|
||||
if ext == "json" {
|
||||
let content = async_fs::read_to_string(entry.path()).await?;
|
||||
if let Ok(ip_entry) = serde_json::from_str::<IpEntry>(&content) {
|
||||
entries.push(ip_entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.hostname.cmp(&b.hostname));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn delete_entry(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = self.get_file_path(hostname);
|
||||
|
||||
if file_path.exists() {
|
||||
async_fs::remove_file(file_path).await?;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory IP repository for testing
|
||||
pub struct InMemoryIpRepository {
|
||||
entries: std::sync::Arc<tokio::sync::RwLock<std::collections::HashMap<String, IpEntry>>>,
|
||||
}
|
||||
|
||||
impl InMemoryIpRepository {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: std::sync::Arc::new(
|
||||
tokio::sync::RwLock::new(std::collections::HashMap::new()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IpRepository for InMemoryIpRepository {
|
||||
async fn store_ip(
|
||||
&self,
|
||||
hostname: &str,
|
||||
ip: IpAddr,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut entries = self.entries.write().await;
|
||||
let entry = IpEntry::new(ip, hostname.to_string(), None);
|
||||
entries.insert(hostname.to_string(), entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_ip(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<Option<IpAddr>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let entries = self.entries.read().await;
|
||||
Ok(entries.get(hostname).map(|entry| entry.ip))
|
||||
}
|
||||
|
||||
async fn get_ip_entry(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<Option<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let entries = self.entries.read().await;
|
||||
Ok(entries.get(hostname).cloned())
|
||||
}
|
||||
|
||||
async fn list_all_entries(
|
||||
&self,
|
||||
) -> Result<Vec<IpEntry>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let entries = self.entries.read().await;
|
||||
let mut result: Vec<IpEntry> = entries.values().cloned().collect();
|
||||
result.sort_by(|a, b| a.hostname.cmp(&b.hostname));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete_entry(
|
||||
&self,
|
||||
hostname: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut entries = self.entries.write().await;
|
||||
Ok(entries.remove(hostname).is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryIpRepository {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use async_trait::async_trait;
|
||||
use tokio::fs;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::domain::entities::{WebServerConfig, WebServerType};
|
||||
use crate::domain::ports::WebServerHandler;
|
||||
|
||||
/// Apache web server handler
|
||||
pub struct ApacheHandler;
|
||||
|
||||
impl ApacheHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
async fn backup_file(&self, config_path: &std::path::Path) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let backup_path = config_path.with_extension(format!("bak.{}", timestamp));
|
||||
fs::copy(config_path, &backup_path).await?;
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
async fn update_apache_config(
|
||||
&self,
|
||||
config_path: &std::path::Path,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let content = fs::read_to_string(config_path).await?;
|
||||
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
|
||||
let mut updated = false;
|
||||
|
||||
// Comment pattern for hostname identification
|
||||
let hostname_comment = format!("# DDNS: {}", hostname);
|
||||
|
||||
// Remove old entries for this hostname
|
||||
if let Some(old_ip) = old_ip {
|
||||
let old_require_pattern = format!("Require ip {}", old_ip);
|
||||
let has_hostname_comment = lines.iter().any(|l| l.contains(&hostname_comment));
|
||||
lines.retain(|line| {
|
||||
!line.trim().starts_with(&old_require_pattern) || !has_hostname_comment
|
||||
});
|
||||
}
|
||||
|
||||
// Find Directory or Location blocks and add new Require rule
|
||||
let directory_regex = Regex::new(r"^\s*<Directory\s+.*>\s*$")?;
|
||||
let location_regex = Regex::new(r"^\s*<Location\s+.*>\s*$")?;
|
||||
let virtualhost_regex = Regex::new(r"^\s*<VirtualHost\s+.*>\s*$")?;
|
||||
|
||||
for i in 0..lines.len() {
|
||||
if directory_regex.is_match(&lines[i]) ||
|
||||
location_regex.is_match(&lines[i]) ||
|
||||
virtualhost_regex.is_match(&lines[i]) {
|
||||
|
||||
// Find the corresponding closing tag
|
||||
let tag_name = if lines[i].contains("<Directory") {
|
||||
"Directory"
|
||||
} else if lines[i].contains("<Location") {
|
||||
"Location"
|
||||
} else {
|
||||
"VirtualHost"
|
||||
};
|
||||
|
||||
let closing_tag = format!("</{}>", tag_name);
|
||||
|
||||
for j in (i + 1)..lines.len() {
|
||||
if lines[j].contains(&closing_tag) {
|
||||
// Insert Require rule before closing tag
|
||||
let indent = " "; // Standard Apache indentation
|
||||
lines.insert(j, format!("{}Require ip {} {}", indent, new_ip, hostname_comment));
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
let new_content = lines.join("\n");
|
||||
fs::write(config_path, new_content).await?;
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebServerHandler for ApacheHandler {
|
||||
async fn update_allow_list(
|
||||
&self,
|
||||
config: &WebServerConfig,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.update_apache_config(&config.path, hostname, old_ip, new_ip).await
|
||||
}
|
||||
|
||||
async fn validate_config(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !config.path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Try both common Apache command names
|
||||
let commands = ["apache2ctl", "apachectl", "httpd"];
|
||||
|
||||
for cmd in &commands {
|
||||
if let Ok(output) = Command::new(cmd)
|
||||
.arg("-t")
|
||||
.arg("-f")
|
||||
.arg(&config.path)
|
||||
.output() {
|
||||
return Ok(output.status.success());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Apache command not found (tried apache2ctl, apachectl, httpd)".into())
|
||||
}
|
||||
|
||||
async fn reload_server(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Try different service names for Apache
|
||||
let services = ["apache2", "httpd"];
|
||||
|
||||
for service in &services {
|
||||
if let Ok(output) = Command::new("systemctl")
|
||||
.arg("reload")
|
||||
.arg(service)
|
||||
.output() {
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Failed to reload Apache (tried apache2, httpd services)".into())
|
||||
}
|
||||
|
||||
async fn create_backup(&self, config: &WebServerConfig) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.backup_file(&config.path).await
|
||||
}
|
||||
|
||||
async fn test_configuration(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.validate_config(config).await
|
||||
}
|
||||
|
||||
fn server_type(&self) -> WebServerType {
|
||||
WebServerType::Apache
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ApacheHandler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod nginx;
|
||||
pub mod apache;
|
||||
|
||||
pub use nginx::NginxHandler;
|
||||
pub use apache::ApacheHandler;
|
||||
@@ -0,0 +1,142 @@
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use async_trait::async_trait;
|
||||
use tokio::fs;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::domain::entities::{WebServerConfig, WebServerType};
|
||||
use crate::domain::ports::WebServerHandler;
|
||||
|
||||
/// Nginx web server handler
|
||||
pub struct NginxHandler;
|
||||
|
||||
impl NginxHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
async fn backup_file(&self, config_path: &std::path::Path) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let backup_path = config_path.with_extension(format!("bak.{}", timestamp));
|
||||
fs::copy(config_path, &backup_path).await?;
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
async fn update_nginx_config(
|
||||
&self,
|
||||
config_path: &std::path::Path,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let content = fs::read_to_string(config_path).await?;
|
||||
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
|
||||
let mut updated = false;
|
||||
|
||||
// Comment pattern for hostname identification
|
||||
let hostname_comment = format!("# DDNS: {}", hostname);
|
||||
|
||||
// Remove old entries for this hostname
|
||||
if let Some(old_ip) = old_ip {
|
||||
let old_allow_pattern = format!("allow {};", old_ip);
|
||||
let has_hostname_comment = lines.iter().any(|l| l.contains(&hostname_comment));
|
||||
lines.retain(|line| {
|
||||
!line.trim().starts_with(&old_allow_pattern) || !has_hostname_comment
|
||||
});
|
||||
}
|
||||
|
||||
// Find location blocks and add new allow rule
|
||||
let location_regex = Regex::new(r"^\s*location\s+.*\{\s*$")?;
|
||||
let server_regex = Regex::new(r"^\s*server\s*\{\s*$")?;
|
||||
|
||||
for i in 0..lines.len() {
|
||||
if location_regex.is_match(&lines[i]) || server_regex.is_match(&lines[i]) {
|
||||
// Look for the next closing brace to find insertion point
|
||||
let mut brace_count = 1;
|
||||
for j in (i + 1)..lines.len() {
|
||||
if lines[j].contains('{') {
|
||||
brace_count += lines[j].matches('{').count();
|
||||
}
|
||||
if lines[j].contains('}') {
|
||||
brace_count -= lines[j].matches('}').count();
|
||||
if brace_count == 0 {
|
||||
// Insert allow rule before closing brace
|
||||
let indent = " "; // Standard nginx indentation
|
||||
lines.insert(j, format!("{}allow {}; {}", indent, new_ip, hostname_comment));
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
let new_content = lines.join("\n");
|
||||
fs::write(config_path, new_content).await?;
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebServerHandler for NginxHandler {
|
||||
async fn update_allow_list(
|
||||
&self,
|
||||
config: &WebServerConfig,
|
||||
hostname: &str,
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.update_nginx_config(&config.path, hostname, old_ip, new_ip).await
|
||||
}
|
||||
|
||||
async fn validate_config(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !config.path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let output = Command::new("nginx")
|
||||
.arg("-t")
|
||||
.arg("-c")
|
||||
.arg(&config.path)
|
||||
.output()?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
async fn reload_server(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let output = Command::new("systemctl")
|
||||
.arg("reload")
|
||||
.arg("nginx")
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to reload nginx: {}", error).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_backup(&self, config: &WebServerConfig) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.backup_file(&config.path).await
|
||||
}
|
||||
|
||||
async fn test_configuration(&self, config: &WebServerConfig) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.validate_config(config).await
|
||||
}
|
||||
|
||||
fn server_type(&self) -> WebServerType {
|
||||
WebServerType::Nginx
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NginxHandler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::path::PathBuf;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::application::{AppConfig, DdnsApplication};
|
||||
use crate::domain::services::UpdateResult;
|
||||
|
||||
/// CLI interface for the DDNS updater using clean architecture
|
||||
pub struct CliInterface;
|
||||
|
||||
impl CliInterface {
|
||||
/// Main CLI entry point
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let args = crate::cli::Args::parse_args();
|
||||
|
||||
// Create async runtime
|
||||
let rt = Runtime::new()?;
|
||||
|
||||
rt.block_on(async { Self::run_async(args).await })
|
||||
}
|
||||
|
||||
/// Async implementation of the CLI logic
|
||||
async fn run_async(
|
||||
args: crate::cli::Args,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Create application configuration
|
||||
let app_config = AppConfig::new()
|
||||
.with_verbose(args.verbose)
|
||||
.with_storage_dir(PathBuf::from("/var/lib/ddns-updater"));
|
||||
|
||||
// Create application instance
|
||||
let app = DdnsApplication::new(app_config)?;
|
||||
|
||||
if args.verbose {
|
||||
println!("DDNS Updater - Multi-Server Allow List Manager (verbose mode)");
|
||||
println!("Host: {}", args.host);
|
||||
} else {
|
||||
println!("DDNS Updater - Multi-Server Allow List Manager");
|
||||
}
|
||||
|
||||
// Get configuration paths
|
||||
let config_paths = match args.get_nginx_config_paths() {
|
||||
Ok(paths) => {
|
||||
if args.verbose {
|
||||
if paths.len() == 1 {
|
||||
println!("Using configuration: {}", paths[0].display());
|
||||
} else {
|
||||
println!("Processing {} configuration files", paths.len());
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute DDNS update for all configurations
|
||||
let results = app.update_ddns_multiple(&args.host, config_paths).await?;
|
||||
|
||||
// Display results
|
||||
Self::display_results(&args.host, &results, args.verbose).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Display the results of DDNS updates
|
||||
async fn display_results(hostname: &str, results: &[UpdateResult], verbose: bool) {
|
||||
if results.is_empty() {
|
||||
println!("No configurations were updated.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut updated_count = 0;
|
||||
let mut no_change_count = 0;
|
||||
|
||||
for result in results {
|
||||
match result {
|
||||
UpdateResult::Updated {
|
||||
old_ip,
|
||||
new_ip,
|
||||
backup_path,
|
||||
..
|
||||
} => {
|
||||
updated_count += 1;
|
||||
match old_ip {
|
||||
Some(old) => {
|
||||
println!("✅ Updated {}: {} → {}", hostname, old, new_ip);
|
||||
}
|
||||
None => {
|
||||
println!("✅ Added {}: {}", hostname, new_ip);
|
||||
}
|
||||
}
|
||||
if verbose {
|
||||
println!(" Backup created: {}", backup_path.display());
|
||||
}
|
||||
}
|
||||
UpdateResult::NoChange { ip } => {
|
||||
no_change_count += 1;
|
||||
if verbose {
|
||||
println!("ℹ️ No change needed for {}: {}", hostname, ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n📊 Summary:");
|
||||
println!(" Updated: {}", updated_count);
|
||||
println!(" No change: {}", no_change_count);
|
||||
println!(" Total processed: {}", results.len());
|
||||
|
||||
if updated_count > 0 {
|
||||
println!("\n🔥 Server configuration updated and reloaded successfully!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cli_interface;
|
||||
|
||||
pub use cli_interface::*;
|
||||
+3
-3
@@ -4,14 +4,14 @@ use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
|
||||
/// Store an IP address to a file
|
||||
pub fn store_ip(ip: IpAddr, file_path: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn store_ip(ip: IpAddr, file_path: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut file = fs::File::create(file_path)?;
|
||||
writeln!(file, "{}", ip)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load an IP address from a file
|
||||
pub fn load_ip(file_path: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
pub fn load_ip(file_path: &str) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let content = fs::read_to_string(file_path)?;
|
||||
let ip_str = content.trim();
|
||||
let ip: IpAddr = ip_str.parse()?;
|
||||
@@ -22,7 +22,7 @@ pub fn load_ip(file_path: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
pub fn check_and_update_ip(
|
||||
current_ip: IpAddr,
|
||||
file_path: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let ip_changed = if Path::new(file_path).exists() {
|
||||
match load_ip(file_path) {
|
||||
Ok(stored_ip) => {
|
||||
|
||||
+9
-175
@@ -1,183 +1,17 @@
|
||||
pub mod application;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod core;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
pub mod interface;
|
||||
|
||||
pub use cli::*;
|
||||
pub use config::*;
|
||||
pub use core::*;
|
||||
pub use interface::CliInterface;
|
||||
|
||||
/// Main entry point for the DDNS updater
|
||||
pub fn run() {
|
||||
let args = Args::parse_args();
|
||||
run_with_args(args);
|
||||
}
|
||||
|
||||
/// Run DDNS updater with provided arguments
|
||||
pub fn run_with_args(args: Args) {
|
||||
if args.verbose {
|
||||
println!("DDNS Updater - Nginx Allow List Manager (verbose mode)");
|
||||
println!("Host: {}", args.host);
|
||||
if let Some(config) = &args.nginx_config {
|
||||
println!("Config file: {}", config.display());
|
||||
}
|
||||
if let Some(config_dir) = &args.config_dir {
|
||||
println!("Config directory: {}", config_dir.display());
|
||||
println!("Pattern: {}", args.pattern);
|
||||
}
|
||||
} else {
|
||||
println!("DDNS Updater - Nginx Allow List Manager");
|
||||
}
|
||||
|
||||
let host = &args.host;
|
||||
|
||||
// Get nginx config paths from command line arguments (may be multiple when using --config-dir)
|
||||
let nginx_config_paths = match args.get_nginx_config_paths() {
|
||||
Ok(paths) => {
|
||||
if args.verbose {
|
||||
if paths.len() == 1 {
|
||||
println!("Using nginx config: {}", paths[0].display());
|
||||
} else {
|
||||
println!("Processing {} nginx config files", paths.len());
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate that all files are actually nginx config files
|
||||
for nginx_config_path in &nginx_config_paths {
|
||||
let config_path_str = nginx_config_path.to_string_lossy();
|
||||
if let Err(e) = validate_nginx_config_file(&config_path_str, args.verbose) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current IP
|
||||
match get_host_ip(host) {
|
||||
Ok(current_ip) => {
|
||||
println!("Current IP for {}: {}", host, current_ip);
|
||||
|
||||
let ip_file = get_ip_file_path(host);
|
||||
|
||||
// Get the old IP BEFORE checking for changes (since check_and_update_ip will update the file)
|
||||
let old_ip = load_ip(&ip_file).ok();
|
||||
|
||||
// Check if IP has changed
|
||||
match check_and_update_ip(current_ip, &ip_file) {
|
||||
Ok(changed) => {
|
||||
if changed {
|
||||
println!("IP address has changed! Updating nginx allow list...");
|
||||
|
||||
// Process each config file
|
||||
let mut updated_files = 0;
|
||||
let mut failed_files = 0;
|
||||
|
||||
for nginx_config_path in &nginx_config_paths {
|
||||
let config_path_str = nginx_config_path.to_string_lossy();
|
||||
|
||||
if args.verbose && nginx_config_paths.len() > 1 {
|
||||
println!("\nProcessing: {}", config_path_str);
|
||||
}
|
||||
|
||||
// Get backup directory from args
|
||||
let backup_result = match args.get_backup_dir() {
|
||||
Ok(backup_dir) => {
|
||||
if args.verbose {
|
||||
println!(
|
||||
"Using backup directory: {}",
|
||||
backup_dir.display()
|
||||
);
|
||||
}
|
||||
backup_nginx_config_to_dir(&config_path_str, Some(&backup_dir))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: {}", e);
|
||||
eprintln!("Falling back to default backup location");
|
||||
backup_nginx_config(&config_path_str)
|
||||
}
|
||||
};
|
||||
|
||||
match backup_result {
|
||||
Ok(backup_path) => {
|
||||
if args.verbose {
|
||||
println!("Config backed up to: {}", backup_path);
|
||||
}
|
||||
|
||||
// Update nginx allow list
|
||||
match update_nginx_allow_ip(
|
||||
&config_path_str,
|
||||
old_ip,
|
||||
current_ip,
|
||||
Some(&format!("DDNS for {}", host)),
|
||||
) {
|
||||
Ok(updated) => {
|
||||
if updated {
|
||||
updated_files += 1;
|
||||
if nginx_config_paths.len() == 1 {
|
||||
println!("Nginx config updated successfully");
|
||||
} else if args.verbose {
|
||||
println!("✓ Updated: {}", config_path_str);
|
||||
}
|
||||
} else if args.verbose {
|
||||
println!(
|
||||
"- No changes needed: {}",
|
||||
config_path_str
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failed_files += 1;
|
||||
eprintln!(
|
||||
"✗ Error updating {}: {}",
|
||||
config_path_str, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failed_files += 1;
|
||||
eprintln!(
|
||||
"✗ Error creating backup for {}: {}",
|
||||
config_path_str, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary for multiple files
|
||||
if nginx_config_paths.len() > 1 {
|
||||
println!("\nUpdate Summary:");
|
||||
println!(" {} files updated", updated_files);
|
||||
if failed_files > 0 {
|
||||
println!(" {} files failed", failed_files);
|
||||
}
|
||||
println!(" {} files processed", nginx_config_paths.len());
|
||||
}
|
||||
|
||||
// Reload nginx only once after processing all files
|
||||
if updated_files > 0 {
|
||||
if !args.no_reload {
|
||||
reload_nginx_if_available();
|
||||
} else if args.verbose {
|
||||
println!("Nginx reload skipped (--no-reload specified)");
|
||||
}
|
||||
} else if nginx_config_paths.len() == 1 {
|
||||
println!("No nginx config changes were needed");
|
||||
}
|
||||
} else {
|
||||
println!("No IP change detected. Nginx config unchanged.");
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Error checking IP: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => println!("Failed to get current IP: {}", e),
|
||||
}
|
||||
|
||||
println!("DDNS update check complete.");
|
||||
if let Err(e) = interface::CliInterface::run() {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
use std::net::{IpAddr, UdpSocket};
|
||||
|
||||
/// Check if a host is online and return its IP address
|
||||
pub fn get_host_ip(host: &str) -> Result<IpAddr, Box<dyn std::error::Error>> {
|
||||
pub fn get_host_ip(host: &str) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Use a UDP socket to force a fresh DNS resolution
|
||||
let addr = format!("{}:80", host);
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@ pub fn update_nginx_allow_ip(
|
||||
old_ip: Option<IpAddr>,
|
||||
new_ip: IpAddr,
|
||||
comment: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config_content = open_and_read_file(config_path)?;
|
||||
let mut lines: Vec<String> = config_content.lines().map(|s| s.to_string()).collect();
|
||||
let mut updated = false;
|
||||
@@ -117,7 +117,7 @@ pub fn update_nginx_allow_ip(
|
||||
}
|
||||
|
||||
/// Create a backup of nginx config file
|
||||
pub fn backup_nginx_config(config_path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
pub fn backup_nginx_config(config_path: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let backup_path = format!(
|
||||
"{}.backup.{}",
|
||||
config_path,
|
||||
@@ -134,7 +134,7 @@ pub fn backup_nginx_config(config_path: &str) -> Result<String, Box<dyn std::err
|
||||
}
|
||||
|
||||
/// Reload nginx configuration
|
||||
pub fn reload_nginx() -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn reload_nginx() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use std::process::Command;
|
||||
|
||||
println!("Reloading nginx configuration...");
|
||||
|
||||
Reference in New Issue
Block a user