rustpbx 0.4.9

A SIP PBX implementation in Rust
Documentation
use crate::call::user::SipUser;
use crate::models::{department, extension};
use crate::proxy::auth::AuthError;
use anyhow::Result;
use async_trait::async_trait;
use lru::LruCache;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use super::user::UserBackend;

pub type ExtensionCache = LruCache<(String, Option<String>), (Option<SipUser>, Instant)>;

pub struct ExtensionUserBackend {
    db: DatabaseConnection,
    cache: Arc<Mutex<ExtensionCache>>,
    ttl: Duration,
}

impl ExtensionUserBackend {
    pub fn new(db: DatabaseConnection, ttl_secs: u64) -> Self {
        Self {
            db,
            cache: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(10000).unwrap()))),
            ttl: Duration::from_secs(ttl_secs),
        }
    }

    pub async fn connect(database_url: &str, ttl_secs: u64) -> Result<Self> {
        let db = crate::models::connect_db(database_url).await?;
        Ok(Self::new(db, ttl_secs))
    }

    async fn fetch_extension(
        &self,
        ext: &str,
    ) -> Result<Option<(extension::Model, Vec<department::Model>)>> {
        let mut results = extension::Entity::find()
            .filter(extension::Column::Extension.eq(ext))
            .find_with_related(department::Entity)
            .all(&self.db)
            .await?;

        Ok(results.pop())
    }

    fn build_sip_user(
        model: extension::Model,
        departments: Vec<department::Model>,
        realm: Option<&str>,
    ) -> SipUser {
        let department_names = if departments.is_empty() {
            None
        } else {
            let deps: Vec<String> = departments.iter().map(|d| d.name.clone()).collect();
            Some(deps)
        };

        SipUser {
            id: model.id as u64,
            username: model.extension,
            password: model.sip_password,
            enabled: !model.login_disabled,
            realm: realm.map(|r| r.to_string()),
            call_forwarding_mode: model.call_forwarding_mode,
            call_forwarding_destination: model.call_forwarding_destination,
            call_forwarding_timeout: model.call_forwarding_timeout,
            departments: department_names,
            display_name: model.display_name,
            email: model.email,
            note: model.notes,
            allow_guest_calls: model.allow_guest_calls,
            voicemail_disabled: model.voicemail_disabled,
            ..Default::default()
        }
    }
}

#[async_trait]
impl UserBackend for ExtensionUserBackend {
    async fn is_same_realm(&self, realm: &str) -> bool {
        realm.is_empty()
    }

    async fn get_user(
        &self,
        username: &str,
        realm: Option<&str>,
        _request: Option<&rsipstack::sip::Request>,
    ) -> Result<Option<SipUser>, AuthError> {
        if username.trim().is_empty() {
            return Ok(None);
        }

        let cache_key = (username.to_string(), realm.map(|r| r.to_string()));

        // Check cache
        if self.ttl.as_secs() > 0 {
            let mut cache = self.cache.lock().unwrap();
            if let Some((user, timestamp)) = cache.get(&cache_key)
                && timestamp.elapsed() < self.ttl
            {
                return Ok(user.clone());
            }
        }

        let result = self
            .fetch_extension(username)
            .await
            .map_err(AuthError::from)?;
        let user = if let Some((model, departments)) = result {
            Some(Self::build_sip_user(model, departments, realm))
        } else {
            None
        };

        // Update cache
        if self.ttl.as_secs() > 0 {
            let mut cache = self.cache.lock().unwrap();
            cache.put(cache_key, (user.clone(), Instant::now()));
        }

        Ok(user)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{department, extension_department};
    use sea_orm::{ActiveModelTrait, ActiveValue::Set, Database};
    use sea_orm_migration::{MigrationTrait, SchemaManager};

    async fn setup_db() -> DatabaseConnection {
        let db = Database::connect("sqlite::memory:")
            .await
            .expect("connect in-memory sqlite");
        let manager = SchemaManager::new(&db);
        department::Migration
            .up(&manager)
            .await
            .expect("department migration should succeed");
        extension::Migration
            .up(&manager)
            .await
            .expect("extension migration should succeed");
        extension_department::Migration
            .up(&manager)
            .await
            .expect("extension department migration should succeed");
        db
    }

    #[tokio::test]
    async fn get_user_returns_extension() {
        let db = setup_db().await;

        let _ = extension::ActiveModel {
            extension: Set("1001".to_string()),
            sip_password: Set(Some("secret".to_string())),
            allow_guest_calls: Set(true),
            ..Default::default()
        }
        .insert(&db)
        .await
        .expect("insert extension");

        let backend = ExtensionUserBackend::new(db.clone(), 30);

        let user = backend
            .get_user("1001", Some("rustpbx.com"), None)
            .await
            .expect("query user")
            .expect("user exists");

        assert_eq!(user.username, "1001");
        assert_eq!(user.password.as_deref(), Some("secret"));
        assert!(user.allow_guest_calls);
        assert_eq!(user.realm.as_deref(), Some("rustpbx.com"));
        assert!(user.enabled);
    }

    #[tokio::test]
    async fn missing_user_returns_none() {
        let db = setup_db().await;
        let backend = ExtensionUserBackend::new(db, 30);
        assert!(
            backend
                .get_user("2001", None, None)
                .await
                .unwrap()
                .is_none()
        );
    }

    // ── voicemail_disabled mapping ─────────────────────────────────────────

    #[tokio::test]
    async fn voicemail_disabled_false_by_default() {
        // An extension whose voicemail_disabled column was never set should
        // produce a SipUser with voicemail_disabled = false (voicemail active).
        let db = setup_db().await;

        extension::ActiveModel {
            extension: Set("2001".to_string()),
            sip_password: Set(Some("pw".to_string())),
            // voicemail_disabled defaults to false in the schema
            ..Default::default()
        }
        .insert(&db)
        .await
        .unwrap();

        let backend = ExtensionUserBackend::new(db, 30);
        let user = backend
            .get_user("2001", None, None)
            .await
            .unwrap()
            .expect("extension must exist");

        assert!(
            !user.voicemail_disabled,
            "voicemail_disabled should be false when not explicitly set"
        );
    }

    #[tokio::test]
    async fn voicemail_disabled_true_is_mapped_from_db() {
        // An extension with voicemail_disabled = true must flow through to the
        // SipUser so that the routing layer can skip voicemail chaining.
        let db = setup_db().await;

        extension::ActiveModel {
            extension: Set("3001".to_string()),
            sip_password: Set(Some("pw".to_string())),
            voicemail_disabled: Set(true),
            ..Default::default()
        }
        .insert(&db)
        .await
        .unwrap();

        let backend = ExtensionUserBackend::new(db, 30);
        let user = backend
            .get_user("3001", None, None)
            .await
            .unwrap()
            .expect("extension must exist");

        assert!(
            user.voicemail_disabled,
            "voicemail_disabled = true in DB must be reflected in SipUser"
        );
    }

    #[tokio::test]
    async fn voicemail_enabled_extension_returns_false_disabled() {
        // Explicitly verify the positive path: voicemail_disabled = false
        // means voicemail IS enabled (i.e. !voicemail_disabled == true).
        let db = setup_db().await;

        extension::ActiveModel {
            extension: Set("4001".to_string()),
            sip_password: Set(Some("pw".to_string())),
            voicemail_disabled: Set(false),
            ..Default::default()
        }
        .insert(&db)
        .await
        .unwrap();

        let backend = ExtensionUserBackend::new(db, 30);
        let user = backend
            .get_user("4001", None, None)
            .await
            .unwrap()
            .expect("extension must exist");

        assert!(
            !user.voicemail_disabled,
            "voicemail_disabled = false means voicemail is enabled"
        );
        // Invariant used by call.rs: dialplan.voicemail_enabled = !callee.voicemail_disabled
        assert!(!user.voicemail_disabled);
    }
}