rustpbx 0.4.9

A SIP PBX implementation in Rust
Documentation
use crate::addons::{Addon, SidebarItem};
use crate::app::AppState;
use async_trait::async_trait;
use axum::{
    Extension, Router,
    routing::{get, post},
};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::RwLock as TokioRwLock;

pub mod config;
pub use config::AcmeConfig;

mod handlers;

#[derive(Clone, Debug, Serialize)]
pub enum AcmeStatus {
    None,
    Running(String),
    Success(String),
    Error(String),
}

#[derive(Clone)]
pub struct AcmeState {
    pub challenges: Arc<RwLock<HashMap<String, String>>>,
    pub status: Arc<RwLock<AcmeStatus>>,
    /// Auto-renewal configuration (loaded from config.toml)
    pub auto_renew_config: Arc<TokioRwLock<AcmeConfig>>,
}

pub struct AcmeAddon {
    state: AcmeState,
}

impl Default for AcmeAddon {
    fn default() -> Self {
        Self::new()
    }
}

impl AcmeAddon {
    pub fn new() -> Self {
        Self {
            state: AcmeState {
                challenges: Arc::new(RwLock::new(HashMap::new())),
                status: Arc::new(RwLock::new(AcmeStatus::None)),
                auto_renew_config: Arc::new(TokioRwLock::new(AcmeConfig::default())),
            },
        }
    }

    /// Load auto-renew configuration from addon-specific config file.
    /// Tries `<config_dir>/acme.toml` first, falls back to defaults.
    pub async fn load_config(&self, config_path: &Option<String>) {
        let mut loaded = false;
        if let Some(path) = config_path {
            let config_dir = std::path::Path::new(path).parent();
            if let Some(dir) = config_dir {
                let addon_config_path = dir.join("acme.toml");
                if addon_config_path.exists() {
                    match tokio::fs::read_to_string(&addon_config_path).await {
                        Ok(content) => match toml::from_str::<AcmeConfig>(&content) {
                            Ok(acme_config) => {
                                let mut auto_renew = self.state.auto_renew_config.write().await;
                                *auto_renew = acme_config;
                                loaded = true;
                                tracing::info!(
                                    "ACME config loaded from {}",
                                    addon_config_path.display()
                                );
                            }
                            Err(e) => {
                                tracing::warn!("Failed to parse acme.toml: {}", e);
                            }
                        },
                        Err(e) => {
                            tracing::warn!("Failed to read acme.toml: {}", e);
                        }
                    }
                }
            }
        }
        if !loaded {
            tracing::info!("ACME using default configuration (no acme.toml found)");
        }
    }
}

#[async_trait]
impl Addon for AcmeAddon {
    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn id(&self) -> &'static str {
        "acme"
    }
    fn name(&self) -> &'static str {
        "SSL Certificates"
    }
    fn description(&self) -> &'static str {
        "Manage SSL certificates via Let's Encrypt"
    }
    fn screenshots(&self) -> Vec<&'static str> {
        vec!["/static/acme/screenshot.png"]
    }
    async fn initialize(&self, state: AppState) -> anyhow::Result<()> {
        // Load auto-renew configuration from addon-specific config file
        self.load_config(&state.config_path).await;

        // Spawn background task for certificate expiry checking
        let state_clone = self.state.clone();
        let app_state = state.clone();
        crate::utils::spawn(async move {
            handlers::spawn_auto_renew_checker(state_clone, app_state).await;
        });

        tracing::info!("ACME Addon initialized");
        Ok(())
    }

    fn router(&self, state: AppState) -> Option<Router> {
        if state.config().demo_mode {
            return None;
        }

        let static_fs_path = if std::path::Path::new("src/addons/acme/static").exists() {
            "src/addons/acme/static"
        } else {
            "static/acme"
        };
        let static_url_prefix = state.config().static_path();

        // Get base_path and api_prefix from console config if available
        let (base_path, api_prefix) = state
            .console
            .as_ref()
            .map(|c| (c.base_path().to_string(), c.api_prefix().to_string()))
            .unwrap_or_else(|| ("/console".to_string(), "/api".to_string()));

        let mut protected = Router::new()
            .nest_service(
                &format!("{}/acme", static_url_prefix),
                tower_http::services::ServeDir::new(static_fs_path),
            )
            .route(&format!("{base_path}/acme"), get(handlers::ui_index))
            .route(
                &format!("{api_prefix}/acme/request"),
                post(handlers::request_cert),
            )
            .route(&format!("{api_prefix}/acme/status"), get(handlers::status))
            .route(
                &format!("{api_prefix}/acme/auto-renew"),
                get(handlers::get_auto_renew_config),
            )
            .route(
                &format!("{api_prefix}/acme/auto-renew"),
                post(handlers::set_auto_renew_config),
            );

        #[cfg(feature = "console")]
        if let Some(console_state) = state.console.clone() {
            protected = protected.route_layer(axum::middleware::from_extractor_with_state::<
                crate::console::middleware::AuthRequired,
                std::sync::Arc<crate::console::ConsoleState>,
            >(console_state));
        }

        let public = Router::new().route(
            "/.well-known/acme-challenge/{token}",
            get(handlers::challenge),
        );

        let r = Router::new()
            .merge(protected)
            .merge(public)
            .with_state(state)
            .layer(Extension(self.state.clone()));
        Some(r)
    }

    fn locales_dir(&self) -> Option<String> {
        let dev = "src/addons/acme/locales";
        let deployed = "locales/acme";
        if std::path::Path::new(dev).exists() {
            Some(dev.to_string())
        } else {
            Some(deployed.to_string())
        }
    }

    fn sidebar_items(&self, state: AppState) -> Vec<SidebarItem> {
        if state.config().demo_mode {
            return vec![];
        }
        let base_path = state
            .console
            .as_ref()
            .map(|c| c.base_path().to_string())
            .unwrap_or_else(|| "/console".to_string());
        vec![SidebarItem {
            name: "SSL Certificates".to_string(),
            name_key: Some("acme.sidebar_name".to_string()),
            icon: r#"<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" /></svg>"#.to_string(),
            url: format!("{}/acme", base_path),
            permission: None,
        }]
    }
}