diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22f19c1..8465051 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,46 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.2.0)' + required: true + type: string + prerelease: + description: 'Mark as pre-release' + required: false + type: boolean + default: false permissions: contents: write jobs: + create-tag: + name: Create Release Tag + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + outputs: + tag: ${{ steps.create_tag.outputs.tag }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create and push tag + id: create_tag + run: | + TAG="v${{ github.event.inputs.version }}" + echo "tag=$TAG" >> $GITHUB_OUTPUT + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release version ${{ github.event.inputs.version }}" + git push origin "$TAG" + test: name: Run tests runs-on: ubuntu-latest + needs: [create-tag] + if: always() && (needs.create-tag.result == 'success' || needs.create-tag.result == 'skipped') steps: - name: Checkout code uses: actions/checkout@v4 @@ -234,29 +266,49 @@ jobs: create-release: name: Create GitHub Release runs-on: ubuntu-latest - needs: [build-linux, build-macos, build-macos-arm, build-windows] - if: startsWith(github.ref, 'refs/tags/') + needs: [create-tag, build-linux, build-macos, build-macos-arm, build-windows] + if: always() && (needs.build-linux.result == 'success' && needs.build-macos.result == 'success' && needs.build-macos-arm.result == 'success' && needs.build-windows.result == 'success') steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts + - name: Determine tag and prerelease status + id: release_info + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="v${{ github.event.inputs.version }}" + PRERELEASE="${{ github.event.inputs.prerelease }}" + else + TAG="${{ github.ref_name }}" + if [[ "$TAG" =~ -rc|-beta|-alpha ]]; then + PRERELEASE="true" + else + PRERELEASE="false" + fi + fi + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT + - name: Create release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.release_info.outputs.tag }} draft: false - prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') }} + prerelease: ${{ steps.release_info.outputs.prerelease }} files: | artifacts/rustalk-linux-x86_64/rustalk-linux-x86_64.tar.gz artifacts/rustalk-macos-x86_64/rustalk-macos-x86_64.tar.gz artifacts/rustalk-macos-arm64/rustalk-macos-arm64.tar.gz artifacts/rustalk-windows-x86_64/rustalk-windows-x86_64.zip body: | - ## RusTalk Release ${{ github.ref_name }} + ## RusTalk Release ${{ steps.release_info.outputs.tag }} High-performance SIP B2BUA / PBX / MS-Teams-compatible SBC built in Rust. diff --git a/Cargo.lock b/Cargo.lock index ca1df8f..8c0ceab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,7 +1696,7 @@ dependencies = [ [[package]] name = "rustalk-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -1712,7 +1712,7 @@ dependencies = [ [[package]] name = "rustalk-cloud" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "axum", @@ -1729,7 +1729,7 @@ dependencies = [ [[package]] name = "rustalk-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -1758,7 +1758,7 @@ dependencies = [ [[package]] name = "rustalk-edge" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "rustalk-core", diff --git a/Cargo.toml b/Cargo.toml index 26065a4..2c9f5d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" authors = ["RusTalk Contributors"] license = "MIT" diff --git a/RELEASE.md b/RELEASE.md index a275ada..b2b9757 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -22,7 +22,63 @@ The workflow automatically determines the release type based on the tag: ## Creating a Release -### 1. Prepare the Release +### Method 1: Manual Trigger via GitHub UI (Recommended) + +This method allows you to create a release without needing to checkout the repository locally. + +#### 1. Prepare the Release + +Ensure the version is updated in a PR: + +- Update version in `Cargo.toml` +- Update `Cargo.lock` with `cargo update -p rustalk-core -p rustalk-edge -p rustalk-cloud -p rustalk-cli` +- All tests pass: `cargo test --workspace` +- Code is properly formatted: `cargo fmt --all` +- Clippy checks are addressed: `cargo clippy --workspace` + +#### 2. Merge the PR + +Merge the version bump PR to the main branch. + +#### 3. Trigger Release Workflow + +1. Go to the [Actions tab](https://github.com/halcycon/RusTalk/actions) +2. Select the "Release" workflow from the left sidebar +3. Click "Run workflow" button +4. Enter the version number (e.g., `0.2.0` - without the 'v' prefix) +5. Optionally check "Mark as pre-release" for release candidates, betas, or alphas +6. Click "Run workflow" + +The workflow will automatically: +- Create and push the git tag (e.g., `v0.2.0`) +- Run tests on all platforms +- Build release binaries +- Create GitHub release with all artifacts + +#### 4. Monitor the Build + +1. The workflow run will appear in the [Actions tab](https://github.com/halcycon/RusTalk/actions) +2. Watch the progress through each job: + - Create Release Tag (for manual triggers) + - Run tests + - Build for Linux, macOS (x86_64 and ARM64), and Windows + - Create GitHub Release + +The entire process typically takes 15-30 minutes. + +#### 5. Verify the Release + +1. Go to the [Releases page](https://github.com/halcycon/RusTalk/releases) +2. Check that the new release appears with all platform archives +3. Download and test one or more archives to verify they work + +--- + +### Method 2: Manual Tag Push (Traditional) + +This method requires local repository access. + +#### 1. Prepare the Release Before creating a release, ensure: @@ -30,9 +86,8 @@ Before creating a release, ensure: - Code is properly formatted: `cargo fmt --all` - Clippy checks are addressed: `cargo clippy --workspace` - Documentation is up to date -- CHANGELOG is updated (if you maintain one) -### 2. Update Version Numbers +#### 2. Update Version Numbers Update the version in `Cargo.toml`: @@ -47,7 +102,7 @@ Then update the lock file: cargo update -p rustalk-core -p rustalk-edge -p rustalk-cloud -p rustalk-cli ``` -### 3. Commit Version Update +#### 3. Commit Version Update ```bash git add Cargo.toml Cargo.lock @@ -55,7 +110,7 @@ git commit -m "Bump version to 0.2.0" git push origin main ``` -### 4. Create and Push the Tag +#### 4. Create and Push the Tag ```bash # Create an annotated tag @@ -65,23 +120,11 @@ git tag -a v0.2.0 -m "Release version 0.2.0" git push origin v0.2.0 ``` -### 5. Monitor the Build - -1. Go to the [Actions tab](https://github.com/halcycon/RusTalk/actions) on GitHub -2. Watch the "Release" workflow run -3. The workflow will: - - Run tests on all platforms - - Build release binaries for each platform - - Create release archives - - Publish a GitHub release with all artifacts - -The entire process typically takes 15-30 minutes. +#### 5. Monitor and Verify -### 6. Verify the Release +Follow steps 4-5 from Method 1 above to monitor the build and verify the release. -1. Go to the [Releases page](https://github.com/halcycon/RusTalk/releases) -2. Check that the new release appears with all platform archives -3. Download and test one or more archives to verify they work +--- ## Manual Release (if needed) diff --git a/rustalk-cli/src/cert.rs b/rustalk-cli/src/cert.rs index 4477757..c69cea5 100644 --- a/rustalk-cli/src/cert.rs +++ b/rustalk-cli/src/cert.rs @@ -34,14 +34,19 @@ async fn request_certificate( println!("🔐 Requesting Let's Encrypt certificate"); println!(" Domains: {}", domains.join(", ")); println!(" Email: {}", email); - println!(" Environment: {}", if staging { "Staging" } else { "Production" }); + println!( + " Environment: {}", + if staging { "Staging" } else { "Production" } + ); println!(" Challenge: {}", challenge_type); println!(); // Load config to get certificate directories let config = Config::from_file(&config_path).await?; let acme_config = config.acme.as_ref().ok_or_else(|| { - anyhow::anyhow!("ACME configuration not found in config file. Please add an 'acme' section.") + anyhow::anyhow!( + "ACME configuration not found in config file. Please add an 'acme' section." + ) })?; // Create ACME config @@ -60,12 +65,16 @@ async fn request_certificate( let challenge = match challenge_type.as_str() { "http-01" => ChallengeType::Http01, "dns-01" => ChallengeType::Dns01, - _ => return Err(anyhow::anyhow!("Invalid challenge type. Use 'http-01' or 'dns-01'")), + _ => { + return Err(anyhow::anyhow!( + "Invalid challenge type. Use 'http-01' or 'dns-01'" + )) + } }; // Request certificate println!("⏳ Requesting certificate from Let's Encrypt..."); - + if matches!(challenge, ChallengeType::Http01) { println!("⚠️ Important: This tool must be run with root privileges to bind to port 80."); println!("⚠️ Ensure port 80 is accessible from the internet for HTTP-01 validation."); @@ -80,7 +89,7 @@ async fn request_certificate( println!("✅ Certificate issued successfully!"); println!(); println!("Certificate details:"); - + // Show certificate info let cert_info = client.storage().get_certificate_info(&domains[0]).await?; println!(" Certificate: {}", cert_info.cert_path.display()); @@ -101,9 +110,10 @@ async fn renew_certificate(config_path: PathBuf, domain: String) -> Result<()> { // Load config let config = Config::from_file(&config_path).await?; - let acme_config = config.acme.as_ref().ok_or_else(|| { - anyhow::anyhow!("ACME configuration not found in config file") - })?; + let acme_config = config + .acme + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ACME configuration not found in config file"))?; // Create ACME config let acme_client_config = AcmeConfig { @@ -119,7 +129,10 @@ async fn renew_certificate(config_path: PathBuf, domain: String) -> Result<()> { // Check if certificate exists if !client.storage().certificate_exists(&domain).await { - return Err(anyhow::anyhow!("Certificate not found for domain: {}", domain)); + return Err(anyhow::anyhow!( + "Certificate not found for domain: {}", + domain + )); } // Check if renewal is needed @@ -138,7 +151,7 @@ async fn renew_certificate(config_path: PathBuf, domain: String) -> Result<()> { println!("✅ Certificate renewed successfully!"); println!(); - + // Show updated certificate info let cert_info = client.storage().get_certificate_info(&domain).await?; println!("Certificate details:"); @@ -154,9 +167,10 @@ async fn renew_certificate(config_path: PathBuf, domain: String) -> Result<()> { async fn certificate_status(config_path: PathBuf, domain: Option) -> Result<()> { // Load config let config = Config::from_file(&config_path).await?; - let acme_config = config.acme.as_ref().ok_or_else(|| { - anyhow::anyhow!("ACME configuration not found in config file") - })?; + let acme_config = config + .acme + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ACME configuration not found in config file"))?; // Create ACME config let acme_client_config = AcmeConfig { @@ -181,32 +195,41 @@ async fn certificate_status(config_path: PathBuf, domain: Option) -> Res } let cert_info = client.storage().get_certificate_info(&domain).await?; - - println!(" Status: {}", if cert_info.days_until_expiry > 30 { - "✅ Valid" - } else if cert_info.days_until_expiry > 0 { - "⚠️ Expiring soon" - } else { - "❌ Expired" - }); + + println!( + " Status: {}", + if cert_info.days_until_expiry > 30 { + "✅ Valid" + } else if cert_info.days_until_expiry > 0 { + "⚠️ Expiring soon" + } else { + "❌ Expired" + } + ); println!(" Domains: {}", cert_info.domains.join(", ")); println!(" Certificate: {}", cert_info.cert_path.display()); println!(" Private key: {}", cert_info.key_path.display()); println!(" Expires: {}", cert_info.expires_at); println!(" Days until expiry: {}", cert_info.days_until_expiry); println!(" Serial: {}", cert_info.serial); - + if cert_info.days_until_expiry < 30 && cert_info.days_until_expiry > 0 { println!(); - println!("💡 Certificate should be renewed soon. Run: rustalk cert renew -d {}", domain); + println!( + "💡 Certificate should be renewed soon. Run: rustalk cert renew -d {}", + domain + ); } else if cert_info.days_until_expiry <= 0 { println!(); - println!("⚠️ Certificate has expired! Run: rustalk cert renew -d {}", domain); + println!( + "⚠️ Certificate has expired! Run: rustalk cert renew -d {}", + domain + ); } } else { // Show status for all certificates let certificates = client.storage().list_certificates().await?; - + if certificates.is_empty() { println!("No certificates found."); println!(); @@ -219,7 +242,7 @@ async fn certificate_status(config_path: PathBuf, domain: Option) -> Res for domain in certificates { let cert_info = client.storage().get_certificate_info(&domain).await?; - + let status = if cert_info.days_until_expiry > 30 { "✅ Valid" } else if cert_info.days_until_expiry > 0 { @@ -229,7 +252,10 @@ async fn certificate_status(config_path: PathBuf, domain: Option) -> Res }; println!(" {} - {}", domain, status); - println!(" Expires: {} ({} days)", cert_info.expires_at, cert_info.days_until_expiry); + println!( + " Expires: {} ({} days)", + cert_info.expires_at, cert_info.days_until_expiry + ); println!(" Domains: {}", cert_info.domains.join(", ")); println!(); } @@ -242,9 +268,10 @@ async fn certificate_status(config_path: PathBuf, domain: Option) -> Res async fn list_certificates(config_path: PathBuf) -> Result<()> { // Load config let config = Config::from_file(&config_path).await?; - let acme_config = config.acme.as_ref().ok_or_else(|| { - anyhow::anyhow!("ACME configuration not found in config file") - })?; + let acme_config = config + .acme + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ACME configuration not found in config file"))?; // Create ACME config let acme_client_config = AcmeConfig { @@ -259,7 +286,7 @@ async fn list_certificates(config_path: PathBuf) -> Result<()> { let client = AcmeClient::new(acme_client_config)?; let certificates = client.storage().list_certificates().await?; - + if certificates.is_empty() { println!("No certificates found."); return Ok(()); @@ -273,13 +300,16 @@ async fn list_certificates(config_path: PathBuf) -> Result<()> { println!(" • {}", domain); println!(" Path: {}", cert_info.cert_path.display()); println!(" Expires: {}", cert_info.expires_at); - println!(" Status: {}", if cert_info.days_until_expiry > 30 { - "Valid" - } else if cert_info.days_until_expiry > 0 { - "Expiring soon" - } else { - "Expired" - }); + println!( + " Status: {}", + if cert_info.days_until_expiry > 30 { + "Valid" + } else if cert_info.days_until_expiry > 0 { + "Expiring soon" + } else { + "Expired" + } + ); println!(); } } diff --git a/rustalk-cli/src/console.rs b/rustalk-cli/src/console.rs index cbbd141..39c8836 100644 --- a/rustalk-cli/src/console.rs +++ b/rustalk-cli/src/console.rs @@ -68,11 +68,11 @@ pub enum ModuleActionType { /// Parse a console command from input string pub fn parse_command(input: &str) -> Result { let parts: Vec<&str> = input.trim().split_whitespace().collect(); - + if parts.is_empty() { anyhow::bail!("Empty command"); } - + match parts[0].to_lowercase().as_str() { "help" | "?" => Ok(ConsoleCommand::Help), "exit" | "quit" | "q" => Ok(ConsoleCommand::Exit), @@ -81,7 +81,10 @@ pub fn parse_command(input: &str) -> Result { "load" => parse_load_command(&parts[1..]), "unload" => parse_unload_command(&parts[1..]), "reload" => parse_reload_command(&parts[1..]), - cmd => anyhow::bail!("Unknown command: {}. Type 'help' for available commands.", cmd), + cmd => anyhow::bail!( + "Unknown command: {}. Type 'help' for available commands.", + cmd + ), } } @@ -89,7 +92,7 @@ fn parse_show_command(args: &[&str]) -> Result { if args.is_empty() { anyhow::bail!("show command requires a target (acls, profiles, status, calls)"); } - + let target = match args[0].to_lowercase().as_str() { "acls" | "acl" => ShowTarget::Acls, "profiles" | "profile" => ShowTarget::Profiles, @@ -97,7 +100,7 @@ fn parse_show_command(args: &[&str]) -> Result { "calls" | "call" => ShowTarget::Calls, other => anyhow::bail!("Unknown show target: {}", other), }; - + Ok(ConsoleCommand::Show(target)) } @@ -105,7 +108,7 @@ fn parse_profile_command(args: &[&str]) -> Result { if args.len() < 2 { anyhow::bail!("profile command requires: profile "); } - + let name = args[0].to_string(); let action = match args[1].to_lowercase().as_str() { "start" => ProfileActionType::Start, @@ -114,7 +117,7 @@ fn parse_profile_command(args: &[&str]) -> Result { "rescan" => ProfileActionType::Rescan, other => anyhow::bail!("Unknown profile action: {}", other), }; - + Ok(ConsoleCommand::Profile(ProfileAction { name, action })) } @@ -122,7 +125,7 @@ fn parse_load_command(args: &[&str]) -> Result { if args.is_empty() { anyhow::bail!("load command requires a module name"); } - + Ok(ConsoleCommand::Module(ModuleAction { name: args[0].to_string(), action: ModuleActionType::Load, @@ -133,7 +136,7 @@ fn parse_unload_command(args: &[&str]) -> Result { if args.is_empty() { anyhow::bail!("unload command requires a module name"); } - + Ok(ConsoleCommand::Module(ModuleAction { name: args[0].to_string(), action: ModuleActionType::Unload, @@ -144,7 +147,7 @@ fn parse_reload_command(args: &[&str]) -> Result { if args.is_empty() { anyhow::bail!("reload command requires a module name"); } - + Ok(ConsoleCommand::Module(ModuleAction { name: args[0].to_string(), action: ModuleActionType::Reload, @@ -212,12 +215,15 @@ async fn execute_show_command(target: ShowTarget, config_path: &PathBuf) -> Resu ShowTarget::Profiles => { println!("\nSIP Profiles:"); println!("============="); - + // Load config to get profile information if let Ok(config) = rustalk_core::Config::from_file(config_path).await { println!(" [default]"); println!(" Domain: {}", config.sip.domain); - println!(" Bind: {}:{}", config.server.bind_address, config.server.bind_port); + println!( + " Bind: {}:{}", + config.server.bind_address, config.server.bind_port + ); println!(" Protocols: {}", config.transport.protocols.join(", ")); println!(" Status: configured"); } else { @@ -291,30 +297,30 @@ pub async fn run_console(config_path: PathBuf) -> Result<()> { println!("==========================="); println!("Type 'help' for available commands, 'exit' to quit"); println!(); - + let mut rl = DefaultEditor::new()?; - + // Load history if it exists let history_file = dirs::home_dir() .map(|p| p.join(".rustalk_history")) .unwrap_or_else(|| PathBuf::from(".rustalk_history")); - + let _ = rl.load_history(&history_file); - + loop { let readline = rl.readline("rustalk> "); match readline { Ok(line) => { let line = line.trim(); - + // Skip empty lines if line.is_empty() { continue; } - + // Add to history let _ = rl.add_history_entry(line); - + // Parse and execute command match parse_command(line) { Ok(ConsoleCommand::Exit) => { @@ -345,31 +351,31 @@ pub async fn run_console(config_path: PathBuf) -> Result<()> { } } } - + // Save history let _ = rl.save_history(&history_file); - + Ok(()) } #[cfg(test)] mod tests { use super::*; - + #[test] fn test_parse_help_command() { assert_eq!(parse_command("help").unwrap(), ConsoleCommand::Help); assert_eq!(parse_command("?").unwrap(), ConsoleCommand::Help); assert_eq!(parse_command("HELP").unwrap(), ConsoleCommand::Help); } - + #[test] fn test_parse_exit_command() { assert_eq!(parse_command("exit").unwrap(), ConsoleCommand::Exit); assert_eq!(parse_command("quit").unwrap(), ConsoleCommand::Exit); assert_eq!(parse_command("q").unwrap(), ConsoleCommand::Exit); } - + #[test] fn test_parse_show_commands() { assert!(matches!( @@ -389,7 +395,7 @@ mod tests { ConsoleCommand::Show(ShowTarget::Calls) )); } - + #[test] fn test_parse_profile_commands() { let cmd = parse_command("profile default start").unwrap(); @@ -399,7 +405,7 @@ mod tests { } else { panic!("Expected Profile command"); } - + let cmd = parse_command("profile external restart").unwrap(); if let ConsoleCommand::Profile(action) = cmd { assert_eq!(action.name, "external"); @@ -408,7 +414,7 @@ mod tests { panic!("Expected Profile command"); } } - + #[test] fn test_parse_module_commands() { let cmd = parse_command("load mod_sofia").unwrap(); @@ -418,7 +424,7 @@ mod tests { } else { panic!("Expected Module command"); } - + let cmd = parse_command("unload mod_conference").unwrap(); if let ConsoleCommand::Module(action) = cmd { assert_eq!(action.name, "mod_conference"); @@ -426,7 +432,7 @@ mod tests { } else { panic!("Expected Module command"); } - + let cmd = parse_command("reload mod_sofia").unwrap(); if let ConsoleCommand::Module(action) = cmd { assert_eq!(action.name, "mod_sofia"); @@ -435,7 +441,7 @@ mod tests { panic!("Expected Module command"); } } - + #[test] fn test_invalid_commands() { assert!(parse_command("invalid").is_err()); diff --git a/rustalk-cli/src/main.rs b/rustalk-cli/src/main.rs index cc61efb..aed2ec9 100644 --- a/rustalk-cli/src/main.rs +++ b/rustalk-cli/src/main.rs @@ -1,7 +1,7 @@ //! RusTalk CLI - Admin tool for managing RusTalk SIP servers -mod console; mod cert; +mod console; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -147,50 +147,62 @@ async fn main() -> Result<()> { async fn start_server(config_path: PathBuf) -> Result<()> { let config = Config::from_file(&config_path).await?; - + println!("Server configuration:"); - println!(" Bind address: {}:{}", config.server.bind_address, config.server.bind_port); + println!( + " Bind address: {}:{}", + config.server.bind_address, config.server.bind_port + ); println!(" SIP domain: {}", config.sip.domain); - + let _b2bua = B2BUA::new(); - + println!("RusTalk server started successfully!"); println!("Press Ctrl+C to stop"); - + // Keep running tokio::signal::ctrl_c().await?; println!("\nShutting down..."); - + Ok(()) } async fn check_config(config_path: PathBuf) -> Result<()> { let config = Config::from_file(&config_path).await?; - + println!("✓ Configuration is valid"); println!("\nConfiguration details:"); println!(" Domain: {}", config.sip.domain); - println!(" Bind: {}:{}", config.server.bind_address, config.server.bind_port); + println!( + " Bind: {}:{}", + config.server.bind_address, config.server.bind_port + ); println!(" Workers: {}", config.server.workers); - + if let Some(db) = &config.database { println!(" Database: {}", db.url); } - + if let Some(teams) = &config.teams { - println!(" Teams SBC: {} (enabled: {})", teams.sbc_fqdn, teams.enabled); + println!( + " Teams SBC: {} (enabled: {})", + teams.sbc_fqdn, teams.enabled + ); } - + Ok(()) } async fn generate_config(output_path: PathBuf) -> Result<()> { let config = Config::default(); config.save_to_file(&output_path).await?; - - println!("✓ Generated sample configuration at: {}", output_path.display()); + + println!( + "✓ Generated sample configuration at: {}", + output_path.display() + ); println!("\nEdit the file to customize your settings."); - + Ok(()) } @@ -199,13 +211,13 @@ async fn get_status(_server: &str) -> Result<()> { println!("Version: 0.1.0"); println!("Uptime: N/A"); println!("Active calls: 0"); - + Ok(()) } async fn list_calls(_server: &str) -> Result<()> { println!("Active calls: 0"); println!("\nNo active calls"); - + Ok(()) } diff --git a/rustalk-cloud/src/api/mod.rs b/rustalk-cloud/src/api/mod.rs index bdffa49..1c1b3a2 100644 --- a/rustalk-cloud/src/api/mod.rs +++ b/rustalk-cloud/src/api/mod.rs @@ -1,9 +1,9 @@ //! REST API service implementation use crate::handlers::{self, certificates::AcmeState}; -use crate::models::{Did, Extension, Trunk, RingGroup, Route, SipProfile}; +use crate::models::{Did, Extension, RingGroup, Route, SipProfile, Trunk}; use axum::{ - routing::{get, post, put, delete}, + routing::{delete, get, post, put}, Router, }; use std::net::SocketAddr; @@ -31,7 +31,7 @@ pub struct CloudApi { impl CloudApi { pub fn new(addr: SocketAddr) -> Self { - Self { + Self { addr, webui_path: None, acme_client: None, @@ -83,68 +83,227 @@ impl CloudApi { .route("/api/v1/config", post(handlers::update_config)) .route("/api/v1/stats", get(handlers::get_stats)) // Certificate management endpoints - .route("/api/v1/certificates", get(handlers::certificates::list_certificates)) - .route("/api/v1/certificates/:domain", get(handlers::certificates::get_certificate_status)) - .route("/api/v1/certificates/request", post(handlers::certificates::request_certificate)) - .route("/api/v1/certificates/renew", post(handlers::certificates::renew_certificate)) + .route( + "/api/v1/certificates", + get(handlers::certificates::list_certificates), + ) + .route( + "/api/v1/certificates/:domain", + get(handlers::certificates::get_certificate_status), + ) + .route( + "/api/v1/certificates/request", + post(handlers::certificates::request_certificate), + ) + .route( + "/api/v1/certificates/renew", + post(handlers::certificates::renew_certificate), + ) // Call logs and ratings endpoints - .route("/api/v1/call-logs", get(handlers::call_logs::list_call_logs)) - .route("/api/v1/call-logs/:id", get(handlers::call_logs::get_call_log)) - .route("/api/v1/call-logs/export", post(handlers::call_logs::export_call_logs)) + .route( + "/api/v1/call-logs", + get(handlers::call_logs::list_call_logs), + ) + .route( + "/api/v1/call-logs/:id", + get(handlers::call_logs::get_call_log), + ) + .route( + "/api/v1/call-logs/export", + post(handlers::call_logs::export_call_logs), + ) .route("/api/v1/rates", get(handlers::call_logs::list_rates)) - .route("/api/v1/rates/import", post(handlers::call_logs::import_rates)) + .route( + "/api/v1/rates/import", + post(handlers::call_logs::import_rates), + ) .route("/api/v1/rates", post(handlers::call_logs::save_rate)) - .route("/api/v1/rates/:id", axum::routing::delete(handlers::call_logs::delete_rate)) + .route( + "/api/v1/rates/:id", + axum::routing::delete(handlers::call_logs::delete_rate), + ) .with_state(acme_state) // Codec management endpoints (using separate state) - .route("/api/v1/codecs", get(handlers::codecs::list_codecs).with_state(codec_state.clone())) - .route("/api/v1/codecs/update", put(handlers::codecs::update_codec).with_state(codec_state.clone())) - .route("/api/v1/codecs/add", post(handlers::codecs::add_codec).with_state(codec_state.clone())) - .route("/api/v1/codecs/remove", post(handlers::codecs::remove_codec).with_state(codec_state.clone())) - .route("/api/v1/codecs/reorder", post(handlers::codecs::reorder_codecs).with_state(codec_state)) + .route( + "/api/v1/codecs", + get(handlers::codecs::list_codecs).with_state(codec_state.clone()), + ) + .route( + "/api/v1/codecs/update", + put(handlers::codecs::update_codec).with_state(codec_state.clone()), + ) + .route( + "/api/v1/codecs/add", + post(handlers::codecs::add_codec).with_state(codec_state.clone()), + ) + .route( + "/api/v1/codecs/remove", + post(handlers::codecs::remove_codec).with_state(codec_state.clone()), + ) + .route( + "/api/v1/codecs/reorder", + post(handlers::codecs::reorder_codecs).with_state(codec_state), + ) // DID management endpoints - .route("/api/v1/dids", get(handlers::dids::list_dids).with_state(dids_state.clone())) - .route("/api/v1/dids/:id", get(handlers::dids::get_did).with_state(dids_state.clone())) - .route("/api/v1/dids", post(handlers::dids::create_did).with_state(dids_state.clone())) - .route("/api/v1/dids/:id", put(handlers::dids::update_did).with_state(dids_state.clone())) - .route("/api/v1/dids/:id", delete(handlers::dids::delete_did).with_state(dids_state.clone())) - .route("/api/v1/dids/reorder", post(handlers::dids::reorder_dids).with_state(dids_state)) + .route( + "/api/v1/dids", + get(handlers::dids::list_dids).with_state(dids_state.clone()), + ) + .route( + "/api/v1/dids/:id", + get(handlers::dids::get_did).with_state(dids_state.clone()), + ) + .route( + "/api/v1/dids", + post(handlers::dids::create_did).with_state(dids_state.clone()), + ) + .route( + "/api/v1/dids/:id", + put(handlers::dids::update_did).with_state(dids_state.clone()), + ) + .route( + "/api/v1/dids/:id", + delete(handlers::dids::delete_did).with_state(dids_state.clone()), + ) + .route( + "/api/v1/dids/reorder", + post(handlers::dids::reorder_dids).with_state(dids_state), + ) // Extension management endpoints - .route("/api/v1/extensions", get(handlers::extensions::list_extensions).with_state(extensions_state.clone())) - .route("/api/v1/extensions/:id", get(handlers::extensions::get_extension).with_state(extensions_state.clone())) - .route("/api/v1/extensions", post(handlers::extensions::create_extension).with_state(extensions_state.clone())) - .route("/api/v1/extensions/:id", put(handlers::extensions::update_extension).with_state(extensions_state.clone())) - .route("/api/v1/extensions/:id", delete(handlers::extensions::delete_extension).with_state(extensions_state.clone())) - .route("/api/v1/extensions/reorder", post(handlers::extensions::reorder_extensions).with_state(extensions_state)) + .route( + "/api/v1/extensions", + get(handlers::extensions::list_extensions).with_state(extensions_state.clone()), + ) + .route( + "/api/v1/extensions/:id", + get(handlers::extensions::get_extension).with_state(extensions_state.clone()), + ) + .route( + "/api/v1/extensions", + post(handlers::extensions::create_extension).with_state(extensions_state.clone()), + ) + .route( + "/api/v1/extensions/:id", + put(handlers::extensions::update_extension).with_state(extensions_state.clone()), + ) + .route( + "/api/v1/extensions/:id", + delete(handlers::extensions::delete_extension).with_state(extensions_state.clone()), + ) + .route( + "/api/v1/extensions/reorder", + post(handlers::extensions::reorder_extensions).with_state(extensions_state), + ) // Trunk management endpoints - .route("/api/v1/trunks", get(handlers::trunks::list_trunks).with_state(trunks_state.clone())) - .route("/api/v1/trunks/:id", get(handlers::trunks::get_trunk).with_state(trunks_state.clone())) - .route("/api/v1/trunks", post(handlers::trunks::create_trunk).with_state(trunks_state.clone())) - .route("/api/v1/trunks/:id", put(handlers::trunks::update_trunk).with_state(trunks_state.clone())) - .route("/api/v1/trunks/:id", delete(handlers::trunks::delete_trunk).with_state(trunks_state.clone())) - .route("/api/v1/trunks/reorder", post(handlers::trunks::reorder_trunks).with_state(trunks_state)) + .route( + "/api/v1/trunks", + get(handlers::trunks::list_trunks).with_state(trunks_state.clone()), + ) + .route( + "/api/v1/trunks/:id", + get(handlers::trunks::get_trunk).with_state(trunks_state.clone()), + ) + .route( + "/api/v1/trunks", + post(handlers::trunks::create_trunk).with_state(trunks_state.clone()), + ) + .route( + "/api/v1/trunks/:id", + put(handlers::trunks::update_trunk).with_state(trunks_state.clone()), + ) + .route( + "/api/v1/trunks/:id", + delete(handlers::trunks::delete_trunk).with_state(trunks_state.clone()), + ) + .route( + "/api/v1/trunks/reorder", + post(handlers::trunks::reorder_trunks).with_state(trunks_state), + ) // Ring group management endpoints - .route("/api/v1/ring-groups", get(handlers::ring_groups::list_ring_groups).with_state(ring_groups_state.clone())) - .route("/api/v1/ring-groups/:id", get(handlers::ring_groups::get_ring_group).with_state(ring_groups_state.clone())) - .route("/api/v1/ring-groups", post(handlers::ring_groups::create_ring_group).with_state(ring_groups_state.clone())) - .route("/api/v1/ring-groups/:id", put(handlers::ring_groups::update_ring_group).with_state(ring_groups_state.clone())) - .route("/api/v1/ring-groups/:id", delete(handlers::ring_groups::delete_ring_group).with_state(ring_groups_state.clone())) - .route("/api/v1/ring-groups/reorder", post(handlers::ring_groups::reorder_ring_groups).with_state(ring_groups_state)) + .route( + "/api/v1/ring-groups", + get(handlers::ring_groups::list_ring_groups).with_state(ring_groups_state.clone()), + ) + .route( + "/api/v1/ring-groups/:id", + get(handlers::ring_groups::get_ring_group).with_state(ring_groups_state.clone()), + ) + .route( + "/api/v1/ring-groups", + post(handlers::ring_groups::create_ring_group) + .with_state(ring_groups_state.clone()), + ) + .route( + "/api/v1/ring-groups/:id", + put(handlers::ring_groups::update_ring_group).with_state(ring_groups_state.clone()), + ) + .route( + "/api/v1/ring-groups/:id", + delete(handlers::ring_groups::delete_ring_group) + .with_state(ring_groups_state.clone()), + ) + .route( + "/api/v1/ring-groups/reorder", + post(handlers::ring_groups::reorder_ring_groups).with_state(ring_groups_state), + ) // Route/Dialplan management endpoints - .route("/api/v1/routes", get(handlers::routes::list_routes).with_state(routes_state.clone())) - .route("/api/v1/routes/:id", get(handlers::routes::get_route).with_state(routes_state.clone())) - .route("/api/v1/routes", post(handlers::routes::create_route).with_state(routes_state.clone())) - .route("/api/v1/routes/:id", put(handlers::routes::update_route).with_state(routes_state.clone())) - .route("/api/v1/routes/:id", delete(handlers::routes::delete_route).with_state(routes_state.clone())) - .route("/api/v1/routes/reorder", post(handlers::routes::reorder_routes).with_state(routes_state.clone())) - .route("/api/v1/routes/test", post(handlers::routes::test_route).with_state(routes_state)) + .route( + "/api/v1/routes", + get(handlers::routes::list_routes).with_state(routes_state.clone()), + ) + .route( + "/api/v1/routes/:id", + get(handlers::routes::get_route).with_state(routes_state.clone()), + ) + .route( + "/api/v1/routes", + post(handlers::routes::create_route).with_state(routes_state.clone()), + ) + .route( + "/api/v1/routes/:id", + put(handlers::routes::update_route).with_state(routes_state.clone()), + ) + .route( + "/api/v1/routes/:id", + delete(handlers::routes::delete_route).with_state(routes_state.clone()), + ) + .route( + "/api/v1/routes/reorder", + post(handlers::routes::reorder_routes).with_state(routes_state.clone()), + ) + .route( + "/api/v1/routes/test", + post(handlers::routes::test_route).with_state(routes_state), + ) // SIP Profile management endpoints - .route("/api/v1/sip-profiles", get(handlers::sip_profiles::list_sip_profiles).with_state(sip_profiles_state.clone())) - .route("/api/v1/sip-profiles/:id", get(handlers::sip_profiles::get_sip_profile).with_state(sip_profiles_state.clone())) - .route("/api/v1/sip-profiles", post(handlers::sip_profiles::create_sip_profile).with_state(sip_profiles_state.clone())) - .route("/api/v1/sip-profiles/:id", put(handlers::sip_profiles::update_sip_profile).with_state(sip_profiles_state.clone())) - .route("/api/v1/sip-profiles/:id", delete(handlers::sip_profiles::delete_sip_profile).with_state(sip_profiles_state.clone())) - .route("/api/v1/sip-profiles/reorder", post(handlers::sip_profiles::reorder_sip_profiles).with_state(sip_profiles_state)); + .route( + "/api/v1/sip-profiles", + get(handlers::sip_profiles::list_sip_profiles) + .with_state(sip_profiles_state.clone()), + ) + .route( + "/api/v1/sip-profiles/:id", + get(handlers::sip_profiles::get_sip_profile).with_state(sip_profiles_state.clone()), + ) + .route( + "/api/v1/sip-profiles", + post(handlers::sip_profiles::create_sip_profile) + .with_state(sip_profiles_state.clone()), + ) + .route( + "/api/v1/sip-profiles/:id", + put(handlers::sip_profiles::update_sip_profile) + .with_state(sip_profiles_state.clone()), + ) + .route( + "/api/v1/sip-profiles/:id", + delete(handlers::sip_profiles::delete_sip_profile) + .with_state(sip_profiles_state.clone()), + ) + .route( + "/api/v1/sip-profiles/reorder", + post(handlers::sip_profiles::reorder_sip_profiles).with_state(sip_profiles_state), + ); // If webui_path is provided, serve static files if let Some(path) = webui_path { @@ -165,7 +324,7 @@ impl CloudApi { let ring_groups_state = Arc::new(RwLock::new(self.ring_groups.clone())); let routes_state = Arc::new(RwLock::new(self.routes.clone())); let sip_profiles_state = Arc::new(RwLock::new(self.sip_profiles.clone())); - + let app = Self::router( self.webui_path.clone(), acme_state, diff --git a/rustalk-cloud/src/handlers/acls.rs b/rustalk-cloud/src/handlers/acls.rs index 88a464d..b9aeb27 100644 --- a/rustalk-cloud/src/handlers/acls.rs +++ b/rustalk-cloud/src/handlers/acls.rs @@ -30,7 +30,7 @@ pub async fn get_acl( State(state): State, ) -> (StatusCode, Json) { let manager = state.read().await; - + if let Some(acl) = manager.get_acl(&name) { (StatusCode::OK, Json(json!(acl))) } else { @@ -49,7 +49,7 @@ pub async fn create_acl( Json(payload): Json, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + // Check if ACL already exists if manager.get_acl(&payload.name).is_some() { return ( @@ -60,9 +60,9 @@ pub async fn create_acl( })), ); } - + manager.add_acl(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -80,7 +80,7 @@ pub async fn update_acl( Json(payload): Json, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + if manager.get_acl(&name).is_some() { manager.add_acl(payload); ( @@ -107,7 +107,7 @@ pub async fn delete_acl( State(state): State, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + if manager.remove_acl(&name) { ( StatusCode::OK, @@ -133,7 +133,7 @@ pub async fn check_ip( State(state): State, ) -> (StatusCode, Json) { let manager = state.read().await; - + let ip_addr = match ip.parse() { Ok(addr) => addr, Err(_) => { @@ -145,7 +145,7 @@ pub async fn check_ip( ); } }; - + match manager.is_allowed(&name, ip_addr) { Ok(allowed) => ( StatusCode::OK, @@ -167,14 +167,14 @@ pub async fn check_ip( #[cfg(test)] mod tests { use super::*; - use rustalk_core::acl::{AclAction, AclRule, create_default_acls}; + use rustalk_core::acl::{create_default_acls, AclAction, AclRule}; #[tokio::test] async fn test_list_acls() { let state = Arc::new(RwLock::new(create_default_acls())); let (status, response) = list_acls(State(state)).await; assert_eq!(status, StatusCode::OK); - + let value = response.0; assert!(value["acls"].is_array()); assert!(value["total"].as_u64().unwrap() > 0); @@ -185,7 +185,7 @@ mod tests { let state = Arc::new(RwLock::new(create_default_acls())); let (status, response) = get_acl(Path("localhost".to_string()), State(state)).await; assert_eq!(status, StatusCode::OK); - + let value = response.0; assert_eq!(value["name"], "localhost"); } @@ -193,7 +193,7 @@ mod tests { #[tokio::test] async fn test_create_acl() { let state = Arc::new(RwLock::new(AclManager::new())); - + let mut acl = Acl::new("test"); acl.add_rule(AclRule { name: "allow_local".to_string(), @@ -201,11 +201,11 @@ mod tests { action: AclAction::Allow, priority: 10, }); - + let (status, response) = create_acl(State(state.clone()), Json(acl)).await; assert_eq!(status, StatusCode::CREATED); assert!(response.0["success"].as_bool().unwrap()); - + // Verify it was added let manager = state.read().await; assert!(manager.get_acl("test").is_some()); @@ -214,12 +214,13 @@ mod tests { #[tokio::test] async fn test_check_ip() { let state = Arc::new(RwLock::new(create_default_acls())); - + let (status, response) = check_ip( Path(("localhost".to_string(), "127.0.0.1".to_string())), State(state), - ).await; - + ) + .await; + assert_eq!(status, StatusCode::OK); assert!(response.0["allowed"].as_bool().unwrap()); } diff --git a/rustalk-cloud/src/handlers/call_logs.rs b/rustalk-cloud/src/handlers/call_logs.rs index 6d9fe45..e5a2763 100644 --- a/rustalk-cloud/src/handlers/call_logs.rs +++ b/rustalk-cloud/src/handlers/call_logs.rs @@ -172,9 +172,7 @@ pub async fn list_rates() -> (StatusCode, Json) { } /// Import rates from JSON or CSV -pub async fn import_rates( - Json(request): Json, -) -> (StatusCode, Json) { +pub async fn import_rates(Json(request): Json) -> (StatusCode, Json) { // Placeholder - would parse and save to database let response = RateImportResponse { success: true, diff --git a/rustalk-cloud/src/handlers/certificates.rs b/rustalk-cloud/src/handlers/certificates.rs index ac8ae4a..b69412a 100644 --- a/rustalk-cloud/src/handlers/certificates.rs +++ b/rustalk-cloud/src/handlers/certificates.rs @@ -37,7 +37,7 @@ pub async fn get_certificate_status( Path(domain): Path, ) -> (StatusCode, Json) { let acme_lock = acme_state.read().await; - + let Some(client) = acme_lock.as_ref() else { return ( StatusCode::SERVICE_UNAVAILABLE, @@ -96,11 +96,9 @@ pub async fn get_certificate_status( } /// List all certificates -pub async fn list_certificates( - State(acme_state): State, -) -> (StatusCode, Json) { +pub async fn list_certificates(State(acme_state): State) -> (StatusCode, Json) { let acme_lock = acme_state.read().await; - + let Some(client) = acme_lock.as_ref() else { return ( StatusCode::SERVICE_UNAVAILABLE, @@ -113,7 +111,7 @@ pub async fn list_certificates( match client.storage().list_certificates().await { Ok(domains) => { let mut certificates = Vec::new(); - + for domain in domains { if let Ok(cert_info) = client.storage().get_certificate_info(&domain).await { let status = if cert_info.days_until_expiry > 30 { @@ -161,10 +159,13 @@ pub async fn request_certificate( State(acme_state): State, Json(payload): Json, ) -> (StatusCode, Json) { - info!("Certificate request: domains={:?}, email={}", payload.domains, payload.email); + info!( + "Certificate request: domains={:?}, email={}", + payload.domains, payload.email + ); let acme_lock = acme_state.read().await; - + let Some(client) = acme_lock.as_ref() else { return ( StatusCode::SERVICE_UNAVAILABLE, @@ -196,9 +197,13 @@ pub async fn request_certificate( { Ok(_) => { info!("Certificate issued for domains: {:?}", payload.domains); - + // Get certificate info - match client.storage().get_certificate_info(&payload.domains[0]).await { + match client + .storage() + .get_certificate_info(&payload.domains[0]) + .await + { Ok(cert_info) => ( StatusCode::CREATED, Json(json!({ @@ -244,7 +249,7 @@ pub async fn renew_certificate( info!("Certificate renewal request: domain={}", payload.domain); let acme_lock = acme_state.read().await; - + let Some(client) = acme_lock.as_ref() else { return ( StatusCode::SERVICE_UNAVAILABLE, @@ -287,7 +292,7 @@ pub async fn renew_certificate( match client.renew_certificate(&payload.domain).await { Ok(_) => { info!("Certificate renewed for domain: {}", payload.domain); - + // Get updated certificate info match client.storage().get_certificate_info(&payload.domain).await { Ok(cert_info) => ( diff --git a/rustalk-cloud/src/handlers/codecs.rs b/rustalk-cloud/src/handlers/codecs.rs index 7b631ed..579aa23 100644 --- a/rustalk-cloud/src/handlers/codecs.rs +++ b/rustalk-cloud/src/handlers/codecs.rs @@ -72,7 +72,10 @@ pub async fn update_codec( }; if success { - info!("Codec '{}' set to enabled={}", request.name, request.enabled); + info!( + "Codec '{}' set to enabled={}", + request.name, request.enabled + ); ( StatusCode::OK, Json(json!({ @@ -118,15 +121,13 @@ pub async fn add_codec( })), ) } - Err(err) => { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "success": false, - "error": err - })), - ) - } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": err + })), + ), } } @@ -148,15 +149,13 @@ pub async fn remove_codec( })), ) } - Err(err) => { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "success": false, - "error": err - })), - ) - } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": err + })), + ), } } @@ -169,7 +168,10 @@ pub async fn reorder_codecs( match config.reorder_codec(request.from_index, request.to_index) { Ok(_) => { - info!("Codecs reordered: {} -> {}", request.from_index, request.to_index); + info!( + "Codecs reordered: {} -> {}", + request.from_index, request.to_index + ); ( StatusCode::OK, Json(json!({ @@ -179,15 +181,13 @@ pub async fn reorder_codecs( })), ) } - Err(err) => { - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "success": false, - "error": err - })), - ) - } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": err + })), + ), } } @@ -200,7 +200,7 @@ mod tests { let config = Arc::new(RwLock::new(CodecConfig::default())); let (status, response) = list_codecs(State(config)).await; assert_eq!(status, StatusCode::OK); - + let value = response.0; assert!(value["codecs"].is_array()); assert!(value["total"].as_u64().unwrap() > 0); @@ -209,16 +209,16 @@ mod tests { #[tokio::test] async fn test_update_codec() { let config = Arc::new(RwLock::new(CodecConfig::default())); - + let request = CodecUpdateRequest { name: "PCMU".to_string(), enabled: false, }; - + let (status, response) = update_codec(State(config.clone()), Json(request)).await; assert_eq!(status, StatusCode::OK); assert_eq!(response.0["success"], true); - + // Verify it's disabled let cfg = config.read().await; let codec = cfg.get_by_name("PCMU").unwrap(); @@ -228,7 +228,7 @@ mod tests { #[tokio::test] async fn test_add_custom_codec() { let config = Arc::new(RwLock::new(CodecConfig::default())); - + let request = CodecAddRequest { name: "TestCodec".to_string(), payload_type: 120, @@ -236,11 +236,11 @@ mod tests { channels: 1, description: "Test codec".to_string(), }; - + let (status, response) = add_codec(State(config.clone()), Json(request)).await; assert_eq!(status, StatusCode::CREATED); assert_eq!(response.0["success"], true); - + // Verify it's added let cfg = config.read().await; let codec = cfg.get_by_name("TestCodec"); @@ -251,7 +251,7 @@ mod tests { #[tokio::test] async fn test_remove_codec() { let config = Arc::new(RwLock::new(CodecConfig::default())); - + // Add a custom codec first let add_request = CodecAddRequest { name: "TestCodec".to_string(), @@ -261,16 +261,16 @@ mod tests { description: "Test codec".to_string(), }; add_codec(State(config.clone()), Json(add_request)).await; - + // Now remove it let remove_request = CodecRemoveRequest { name: "TestCodec".to_string(), }; - + let (status, response) = remove_codec(State(config.clone()), Json(remove_request)).await; assert_eq!(status, StatusCode::OK); assert_eq!(response.0["success"], true); - + // Verify it's removed let cfg = config.read().await; assert!(cfg.get_by_name("TestCodec").is_none()); @@ -279,11 +279,11 @@ mod tests { #[tokio::test] async fn test_remove_standard_codec_fails() { let config = Arc::new(RwLock::new(CodecConfig::default())); - + let request = CodecRemoveRequest { name: "PCMU".to_string(), }; - + let (status, response) = remove_codec(State(config), Json(request)).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(response.0["success"], false); @@ -292,22 +292,22 @@ mod tests { #[tokio::test] async fn test_reorder_codecs() { let config = Arc::new(RwLock::new(CodecConfig::default())); - + // Get initial first codec let initial_first = { let cfg = config.read().await; cfg.codecs[0].name.clone() }; - + let request = CodecReorderRequest { from_index: 2, to_index: 0, }; - + let (status, response) = reorder_codecs(State(config.clone()), Json(request)).await; assert_eq!(status, StatusCode::OK); assert_eq!(response.0["success"], true); - + // Verify order changed let cfg = config.read().await; assert_ne!(cfg.codecs[0].name, initial_first); @@ -317,17 +317,17 @@ mod tests { #[tokio::test] async fn test_reorder_codecs_invalid_index() { let config = Arc::new(RwLock::new(CodecConfig::default())); - + let len = { let cfg = config.read().await; cfg.codecs.len() }; - + let request = CodecReorderRequest { from_index: len + 1, to_index: 0, }; - + let (status, response) = reorder_codecs(State(config), Json(request)).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(response.0["success"], false); diff --git a/rustalk-cloud/src/handlers/dids.rs b/rustalk-cloud/src/handlers/dids.rs index cc1ec3a..da1f2ea 100644 --- a/rustalk-cloud/src/handlers/dids.rs +++ b/rustalk-cloud/src/handlers/dids.rs @@ -31,7 +31,7 @@ pub async fn get_did( State(state): State, ) -> (StatusCode, Json) { let dids = state.read().await; - + if let Some(did) = dids.iter().find(|d| d.id == id) { (StatusCode::OK, Json(json!(did))) } else { @@ -50,9 +50,12 @@ pub async fn create_did( Json(payload): Json, ) -> (StatusCode, Json) { let mut dids = state.write().await; - + // Check if DID already exists - if dids.iter().any(|d| d.id == payload.id || d.number == payload.number) { + if dids + .iter() + .any(|d| d.id == payload.id || d.number == payload.number) + { return ( StatusCode::CONFLICT, Json(json!({ @@ -61,9 +64,9 @@ pub async fn create_did( })), ); } - + dids.push(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -81,7 +84,7 @@ pub async fn update_did( Json(payload): Json, ) -> (StatusCode, Json) { let mut dids = state.write().await; - + if let Some(did) = dids.iter_mut().find(|d| d.id == id) { *did = payload; ( @@ -108,7 +111,7 @@ pub async fn delete_did( State(state): State, ) -> (StatusCode, Json) { let mut dids = state.write().await; - + if let Some(pos) = dids.iter().position(|d| d.id == id) { dids.remove(pos); ( @@ -135,10 +138,10 @@ pub async fn reorder_dids( Json(payload): Json, ) -> (StatusCode, Json) { let mut dids = state.write().await; - + let from_index = payload["from_index"].as_u64().unwrap_or(0) as usize; let to_index = payload["to_index"].as_u64().unwrap_or(0) as usize; - + if from_index >= dids.len() || to_index >= dids.len() { return ( StatusCode::BAD_REQUEST, @@ -148,15 +151,15 @@ pub async fn reorder_dids( })), ); } - + let item = dids.remove(from_index); dids.insert(to_index, item); - + // Update priorities based on position for (index, did) in dids.iter_mut().enumerate() { did.priority = index as u32; } - + ( StatusCode::OK, Json(json!({ diff --git a/rustalk-cloud/src/handlers/extensions.rs b/rustalk-cloud/src/handlers/extensions.rs index 5980e75..ed4df51 100644 --- a/rustalk-cloud/src/handlers/extensions.rs +++ b/rustalk-cloud/src/handlers/extensions.rs @@ -31,7 +31,7 @@ pub async fn get_extension( State(state): State, ) -> (StatusCode, Json) { let extensions = state.read().await; - + if let Some(ext) = extensions.iter().find(|e| e.id == id) { (StatusCode::OK, Json(json!(ext))) } else { @@ -50,9 +50,12 @@ pub async fn create_extension( Json(payload): Json, ) -> (StatusCode, Json) { let mut extensions = state.write().await; - + // Check if extension already exists - if extensions.iter().any(|e| e.id == payload.id || e.extension == payload.extension) { + if extensions + .iter() + .any(|e| e.id == payload.id || e.extension == payload.extension) + { return ( StatusCode::CONFLICT, Json(json!({ @@ -61,9 +64,9 @@ pub async fn create_extension( })), ); } - + extensions.push(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -81,7 +84,7 @@ pub async fn update_extension( Json(payload): Json, ) -> (StatusCode, Json) { let mut extensions = state.write().await; - + if let Some(ext) = extensions.iter_mut().find(|e| e.id == id) { *ext = payload; ( @@ -108,7 +111,7 @@ pub async fn delete_extension( State(state): State, ) -> (StatusCode, Json) { let mut extensions = state.write().await; - + if let Some(pos) = extensions.iter().position(|e| e.id == id) { extensions.remove(pos); ( @@ -135,10 +138,10 @@ pub async fn reorder_extensions( Json(payload): Json, ) -> (StatusCode, Json) { let mut extensions = state.write().await; - + let from_index = payload["from_index"].as_u64().unwrap_or(0) as usize; let to_index = payload["to_index"].as_u64().unwrap_or(0) as usize; - + if from_index >= extensions.len() || to_index >= extensions.len() { return ( StatusCode::BAD_REQUEST, @@ -148,15 +151,15 @@ pub async fn reorder_extensions( })), ); } - + let item = extensions.remove(from_index); extensions.insert(to_index, item); - + // Update priorities based on position for (index, ext) in extensions.iter_mut().enumerate() { ext.priority = index as u32; } - + ( StatusCode::OK, Json(json!({ diff --git a/rustalk-cloud/src/handlers/mod.rs b/rustalk-cloud/src/handlers/mod.rs index 88da07c..d1842f7 100644 --- a/rustalk-cloud/src/handlers/mod.rs +++ b/rustalk-cloud/src/handlers/mod.rs @@ -1,23 +1,18 @@ //! API request handlers -use axum::{ - extract::Path, - http::StatusCode, - Json, -}; +use axum::{extract::Path, http::StatusCode, Json}; use serde_json::{json, Value}; - pub mod acls; -pub mod certificates; pub mod call_logs; +pub mod certificates; pub mod codecs; pub mod dids; pub mod extensions; -pub mod trunks; pub mod ring_groups; pub mod routes; pub mod sip_profiles; +pub mod trunks; pub mod voicemail; /// Health check endpoint diff --git a/rustalk-cloud/src/handlers/ring_groups.rs b/rustalk-cloud/src/handlers/ring_groups.rs index d5d912d..d88f9f3 100644 --- a/rustalk-cloud/src/handlers/ring_groups.rs +++ b/rustalk-cloud/src/handlers/ring_groups.rs @@ -31,7 +31,7 @@ pub async fn get_ring_group( State(state): State, ) -> (StatusCode, Json) { let ring_groups = state.read().await; - + if let Some(group) = ring_groups.iter().find(|g| g.id == id) { (StatusCode::OK, Json(json!(group))) } else { @@ -50,9 +50,12 @@ pub async fn create_ring_group( Json(payload): Json, ) -> (StatusCode, Json) { let mut ring_groups = state.write().await; - + // Check if ring group already exists - if ring_groups.iter().any(|g| g.id == payload.id || g.name == payload.name) { + if ring_groups + .iter() + .any(|g| g.id == payload.id || g.name == payload.name) + { return ( StatusCode::CONFLICT, Json(json!({ @@ -61,9 +64,9 @@ pub async fn create_ring_group( })), ); } - + ring_groups.push(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -81,7 +84,7 @@ pub async fn update_ring_group( Json(payload): Json, ) -> (StatusCode, Json) { let mut ring_groups = state.write().await; - + if let Some(group) = ring_groups.iter_mut().find(|g| g.id == id) { *group = payload; ( @@ -108,7 +111,7 @@ pub async fn delete_ring_group( State(state): State, ) -> (StatusCode, Json) { let mut ring_groups = state.write().await; - + if let Some(pos) = ring_groups.iter().position(|g| g.id == id) { ring_groups.remove(pos); ( @@ -135,10 +138,10 @@ pub async fn reorder_ring_groups( Json(payload): Json, ) -> (StatusCode, Json) { let mut ring_groups = state.write().await; - + let from_index = payload["from_index"].as_u64().unwrap_or(0) as usize; let to_index = payload["to_index"].as_u64().unwrap_or(0) as usize; - + if from_index >= ring_groups.len() || to_index >= ring_groups.len() { return ( StatusCode::BAD_REQUEST, @@ -148,15 +151,15 @@ pub async fn reorder_ring_groups( })), ); } - + let item = ring_groups.remove(from_index); ring_groups.insert(to_index, item); - + // Update priorities based on position for (index, group) in ring_groups.iter_mut().enumerate() { group.priority = index as u32; } - + ( StatusCode::OK, Json(json!({ diff --git a/rustalk-cloud/src/handlers/routes.rs b/rustalk-cloud/src/handlers/routes.rs index 6e35337..e9e2c67 100644 --- a/rustalk-cloud/src/handlers/routes.rs +++ b/rustalk-cloud/src/handlers/routes.rs @@ -31,7 +31,7 @@ pub async fn get_route( State(state): State, ) -> (StatusCode, Json) { let routes = state.read().await; - + if let Some(route) = routes.iter().find(|r| r.id == id) { (StatusCode::OK, Json(json!(route))) } else { @@ -50,9 +50,12 @@ pub async fn create_route( Json(payload): Json, ) -> (StatusCode, Json) { let mut routes = state.write().await; - + // Check if route already exists - if routes.iter().any(|r| r.id == payload.id || r.name == payload.name) { + if routes + .iter() + .any(|r| r.id == payload.id || r.name == payload.name) + { return ( StatusCode::CONFLICT, Json(json!({ @@ -61,9 +64,9 @@ pub async fn create_route( })), ); } - + routes.push(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -81,7 +84,7 @@ pub async fn update_route( Json(payload): Json, ) -> (StatusCode, Json) { let mut routes = state.write().await; - + if let Some(route) = routes.iter_mut().find(|r| r.id == id) { *route = payload; ( @@ -108,7 +111,7 @@ pub async fn delete_route( State(state): State, ) -> (StatusCode, Json) { let mut routes = state.write().await; - + if let Some(pos) = routes.iter().position(|r| r.id == id) { routes.remove(pos); ( @@ -135,10 +138,10 @@ pub async fn reorder_routes( Json(payload): Json, ) -> (StatusCode, Json) { let mut routes = state.write().await; - + let from_index = payload["from_index"].as_u64().unwrap_or(0) as usize; let to_index = payload["to_index"].as_u64().unwrap_or(0) as usize; - + if from_index >= routes.len() || to_index >= routes.len() { return ( StatusCode::BAD_REQUEST, @@ -148,15 +151,15 @@ pub async fn reorder_routes( })), ); } - + let item = routes.remove(from_index); routes.insert(to_index, item); - + // Update priorities based on position for (index, route) in routes.iter_mut().enumerate() { route.priority = index as u32; } - + ( StatusCode::OK, Json(json!({ @@ -172,14 +175,14 @@ pub async fn test_route( State(state): State, Json(payload): Json, ) -> (StatusCode, Json) { - use rustalk_core::routing::{RouteEvaluator, CallContext, RoutingConfig, RouteRule}; - + use rustalk_core::routing::{CallContext, RouteEvaluator, RouteRule, RoutingConfig}; + let routes = state.read().await; - + // Extract test parameters let caller_id = payload["caller_id"].as_str().unwrap_or("unknown"); let destination = payload["destination"].as_str().unwrap_or("unknown"); - + // Convert API routes to routing engine format let mut routing_config = RoutingConfig::new(); for route in routes.iter() { @@ -189,14 +192,14 @@ pub async fn test_route( routing_config.add_route(route_rule); } } - + // Create evaluator and test let evaluator = RouteEvaluator::new(routing_config); let context = CallContext { caller_id: caller_id.to_string(), destination: destination.to_string(), }; - + match evaluator.evaluate(&context) { Some(route_match) => ( StatusCode::OK, diff --git a/rustalk-cloud/src/handlers/sip_profiles.rs b/rustalk-cloud/src/handlers/sip_profiles.rs index 4cced45..e4120eb 100644 --- a/rustalk-cloud/src/handlers/sip_profiles.rs +++ b/rustalk-cloud/src/handlers/sip_profiles.rs @@ -31,7 +31,7 @@ pub async fn get_sip_profile( State(state): State, ) -> (StatusCode, Json) { let profiles = state.read().await; - + if let Some(profile) = profiles.iter().find(|p| p.id == id) { (StatusCode::OK, Json(json!(profile))) } else { @@ -50,9 +50,12 @@ pub async fn create_sip_profile( Json(payload): Json, ) -> (StatusCode, Json) { let mut profiles = state.write().await; - + // Check if SIP profile already exists - if profiles.iter().any(|p| p.id == payload.id || p.name == payload.name) { + if profiles + .iter() + .any(|p| p.id == payload.id || p.name == payload.name) + { return ( StatusCode::CONFLICT, Json(json!({ @@ -61,9 +64,9 @@ pub async fn create_sip_profile( })), ); } - + profiles.push(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -81,7 +84,7 @@ pub async fn update_sip_profile( Json(payload): Json, ) -> (StatusCode, Json) { let mut profiles = state.write().await; - + if let Some(profile) = profiles.iter_mut().find(|p| p.id == id) { *profile = payload; ( @@ -108,7 +111,7 @@ pub async fn delete_sip_profile( State(state): State, ) -> (StatusCode, Json) { let mut profiles = state.write().await; - + if let Some(pos) = profiles.iter().position(|p| p.id == id) { profiles.remove(pos); ( @@ -135,10 +138,10 @@ pub async fn reorder_sip_profiles( Json(payload): Json, ) -> (StatusCode, Json) { let mut profiles = state.write().await; - + let from_index = payload["from_index"].as_u64().unwrap_or(0) as usize; let to_index = payload["to_index"].as_u64().unwrap_or(0) as usize; - + if from_index >= profiles.len() || to_index >= profiles.len() { return ( StatusCode::BAD_REQUEST, @@ -148,15 +151,15 @@ pub async fn reorder_sip_profiles( })), ); } - + let item = profiles.remove(from_index); profiles.insert(to_index, item); - + // Update priorities based on position for (index, profile) in profiles.iter_mut().enumerate() { profile.priority = index as u32; } - + ( StatusCode::OK, Json(json!({ diff --git a/rustalk-cloud/src/handlers/trunks.rs b/rustalk-cloud/src/handlers/trunks.rs index 110683c..4e48183 100644 --- a/rustalk-cloud/src/handlers/trunks.rs +++ b/rustalk-cloud/src/handlers/trunks.rs @@ -31,7 +31,7 @@ pub async fn get_trunk( State(state): State, ) -> (StatusCode, Json) { let trunks = state.read().await; - + if let Some(trunk) = trunks.iter().find(|t| t.id == id) { (StatusCode::OK, Json(json!(trunk))) } else { @@ -50,9 +50,12 @@ pub async fn create_trunk( Json(payload): Json, ) -> (StatusCode, Json) { let mut trunks = state.write().await; - + // Check if trunk already exists - if trunks.iter().any(|t| t.id == payload.id || t.name == payload.name) { + if trunks + .iter() + .any(|t| t.id == payload.id || t.name == payload.name) + { return ( StatusCode::CONFLICT, Json(json!({ @@ -61,9 +64,9 @@ pub async fn create_trunk( })), ); } - + trunks.push(payload.clone()); - + ( StatusCode::CREATED, Json(json!({ @@ -81,7 +84,7 @@ pub async fn update_trunk( Json(payload): Json, ) -> (StatusCode, Json) { let mut trunks = state.write().await; - + if let Some(trunk) = trunks.iter_mut().find(|t| t.id == id) { *trunk = payload; ( @@ -108,7 +111,7 @@ pub async fn delete_trunk( State(state): State, ) -> (StatusCode, Json) { let mut trunks = state.write().await; - + if let Some(pos) = trunks.iter().position(|t| t.id == id) { trunks.remove(pos); ( @@ -135,10 +138,10 @@ pub async fn reorder_trunks( Json(payload): Json, ) -> (StatusCode, Json) { let mut trunks = state.write().await; - + let from_index = payload["from_index"].as_u64().unwrap_or(0) as usize; let to_index = payload["to_index"].as_u64().unwrap_or(0) as usize; - + if from_index >= trunks.len() || to_index >= trunks.len() { return ( StatusCode::BAD_REQUEST, @@ -148,15 +151,15 @@ pub async fn reorder_trunks( })), ); } - + let item = trunks.remove(from_index); trunks.insert(to_index, item); - + // Update priorities based on position for (index, trunk) in trunks.iter_mut().enumerate() { trunk.priority = index as u32; } - + ( StatusCode::OK, Json(json!({ diff --git a/rustalk-cloud/src/handlers/voicemail.rs b/rustalk-cloud/src/handlers/voicemail.rs index e57473f..adb2014 100644 --- a/rustalk-cloud/src/handlers/voicemail.rs +++ b/rustalk-cloud/src/handlers/voicemail.rs @@ -16,7 +16,7 @@ pub type VoicemailState = Arc>; pub async fn list_mailboxes(State(state): State) -> (StatusCode, Json) { let manager = state.read().await; let mailboxes = manager.list_mailboxes(); - + ( StatusCode::OK, Json(json!({ @@ -32,7 +32,7 @@ pub async fn get_mailbox( State(state): State, ) -> (StatusCode, Json) { let manager = state.read().await; - + if let Some(mailbox) = manager.get_mailbox(&mailbox_id) { (StatusCode::OK, Json(json!(mailbox))) } else { @@ -51,7 +51,7 @@ pub async fn create_mailbox( Json(payload): Json, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + match manager.add_mailbox(payload.clone()) { Ok(_) => ( StatusCode::CREATED, @@ -77,7 +77,7 @@ pub async fn delete_mailbox( State(state): State, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + match manager.remove_mailbox(&mailbox_id) { Ok(true) => ( StatusCode::OK, @@ -109,9 +109,9 @@ pub async fn get_messages( State(state): State, ) -> (StatusCode, Json) { let manager = state.read().await; - + let messages = manager.get_messages(&mailbox_id, true); - + ( StatusCode::OK, Json(json!({ @@ -129,7 +129,7 @@ pub async fn get_mwi_status( ) -> (StatusCode, Json) { let manager = state.read().await; let status = manager.get_mwi_status(&mailbox_id); - + (StatusCode::OK, Json(json!(status))) } @@ -139,7 +139,7 @@ pub async fn mark_message_read( State(state): State, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + match manager.mark_message_read(&message_id) { Ok(_) => ( StatusCode::OK, @@ -164,7 +164,7 @@ pub async fn delete_message( State(state): State, ) -> (StatusCode, Json) { let mut manager = state.write().await; - + match manager.delete_message(&message_id) { Ok(_) => ( StatusCode::OK, @@ -192,10 +192,10 @@ mod tests { async fn test_list_mailboxes() { let manager = VoicemailManager::new("/tmp/voicemail_test"); let state = Arc::new(RwLock::new(manager)); - + let (status, response) = list_mailboxes(State(state)).await; assert_eq!(status, StatusCode::OK); - + let value = response.0; assert!(value["mailboxes"].is_array()); } @@ -204,7 +204,7 @@ mod tests { async fn test_create_mailbox() { let manager = VoicemailManager::new("/tmp/voicemail_test"); let state = Arc::new(RwLock::new(manager)); - + let mailbox = VoicemailBox { id: "test_mailbox".to_string(), extension: "1001".to_string(), @@ -212,7 +212,7 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + let (status, response) = create_mailbox(State(state.clone()), Json(mailbox)).await; assert_eq!(status, StatusCode::CREATED); assert!(response.0["success"].as_bool().unwrap()); @@ -221,7 +221,7 @@ mod tests { #[tokio::test] async fn test_get_mwi_status() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -229,14 +229,14 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + let state = Arc::new(RwLock::new(manager)); - + let (status, response) = get_mwi_status(Path("1001".to_string()), State(state)).await; assert_eq!(status, StatusCode::OK); - + let value = response.0; assert_eq!(value["new_messages"], 0); assert_eq!(value["old_messages"], 0); diff --git a/rustalk-cloud/src/lib.rs b/rustalk-cloud/src/lib.rs index ef12334..67fe666 100644 --- a/rustalk-cloud/src/lib.rs +++ b/rustalk-cloud/src/lib.rs @@ -20,6 +20,6 @@ pub async fn init() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - + Ok(()) } diff --git a/rustalk-cloud/src/models/mod.rs b/rustalk-cloud/src/models/mod.rs index ada9061..a47c54a 100644 --- a/rustalk-cloud/src/models/mod.rs +++ b/rustalk-cloud/src/models/mod.rs @@ -212,12 +212,12 @@ pub struct Route { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "value")] pub enum RouteDestination { - Extension(String), // Route to specific extension - Trunk(String), // Route to trunk - RingGroup(String), // Route to ring group - Voicemail(String), // Send to voicemail - Hangup, // Hangup the call - Custom(String), // Custom destination string + Extension(String), // Route to specific extension + Trunk(String), // Route to trunk + RingGroup(String), // Route to ring group + Voicemail(String), // Send to voicemail + Hangup, // Hangup the call + Custom(String), // Custom destination string } /// Action to perform when route matches diff --git a/rustalk-cloud/src/ratings.rs b/rustalk-cloud/src/ratings.rs index 312f5d7..81406bc 100644 --- a/rustalk-cloud/src/ratings.rs +++ b/rustalk-cloud/src/ratings.rs @@ -82,11 +82,10 @@ impl RatingEngine { } // Check prefix match - if destination.starts_with(&rate.prefix) - && rate.prefix.len() > best_match_length { - best_match = Some(rate); - best_match_length = rate.prefix.len(); - } + if destination.starts_with(&rate.prefix) && rate.prefix.len() > best_match_length { + best_match = Some(rate); + best_match_length = rate.prefix.len(); + } } best_match.context("No matching rate card found for destination") @@ -118,22 +117,13 @@ mod tests { #[test] fn test_calculate_billable_duration() { // Test minimum charge - assert_eq!( - RatingEngine::calculate_billable_duration(5, 30, 6), - 30 - ); + assert_eq!(RatingEngine::calculate_billable_duration(5, 30, 6), 30); // Test billing increment rounding - assert_eq!( - RatingEngine::calculate_billable_duration(61, 0, 6), - 66 - ); + assert_eq!(RatingEngine::calculate_billable_duration(61, 0, 6), 66); // Test exact increment - assert_eq!( - RatingEngine::calculate_billable_duration(60, 0, 6), - 60 - ); + assert_eq!(RatingEngine::calculate_billable_duration(60, 0, 6), 60); } #[test] @@ -227,7 +217,7 @@ mod tests { }]; let (charges, total) = RatingEngine::calculate_charges(&call_log, &rate_cards).unwrap(); - + assert_eq!(charges.len(), 2); // Connection fee + duration assert!(total > 0.0); // 0.05 connection fee + (120/60) * 0.15 = 0.05 + 0.30 = 0.35 diff --git a/rustalk-core/src/acl/mod.rs b/rustalk-core/src/acl/mod.rs index abb15ca..16500ee 100644 --- a/rustalk-core/src/acl/mod.rs +++ b/rustalk-core/src/acl/mod.rs @@ -134,8 +134,7 @@ impl AclManager { fn matches_cidr(ip: IpAddr, cidr: &str) -> Result { // Handle single IP addresses if !cidr.contains('/') { - let target_ip = IpAddr::from_str(cidr) - .context(format!("Invalid IP address: {}", cidr))?; + let target_ip = IpAddr::from_str(cidr).context(format!("Invalid IP address: {}", cidr))?; return Ok(ip == target_ip); } @@ -145,8 +144,8 @@ fn matches_cidr(ip: IpAddr, cidr: &str) -> Result { anyhow::bail!("Invalid CIDR format: {}", cidr); } - let network = IpAddr::from_str(parts[0]) - .context(format!("Invalid network address: {}", parts[0]))?; + let network = + IpAddr::from_str(parts[0]).context(format!("Invalid network address: {}", parts[0]))?; let prefix_len: u8 = parts[1] .parse() .context(format!("Invalid prefix length: {}", parts[1]))?; @@ -316,7 +315,7 @@ mod tests { fn test_acl_priority() { let mut acl = Acl::new("test"); acl.default_policy = AclAction::Deny; - + // Higher priority rule (lower number) should win acl.add_rule(AclRule { name: "specific_deny".to_string(), @@ -357,14 +356,14 @@ mod tests { #[test] fn test_default_acls() { let manager = create_default_acls(); - + // Test RFC 1918 let ip = IpAddr::from_str("192.168.1.1").unwrap(); assert!(manager.is_allowed("rfc1918", ip).unwrap()); - + let ip2 = IpAddr::from_str("8.8.8.8").unwrap(); assert!(!manager.is_allowed("rfc1918", ip2).unwrap()); - + // Test localhost let ip3 = IpAddr::from_str("127.0.0.1").unwrap(); assert!(manager.is_allowed("localhost", ip3).unwrap()); diff --git a/rustalk-core/src/acme/client.rs b/rustalk-core/src/acme/client.rs index 9efca6b..94209ed 100644 --- a/rustalk-core/src/acme/client.rs +++ b/rustalk-core/src/acme/client.rs @@ -68,10 +68,8 @@ impl AcmeClient { let account = self.get_or_create_account().await?; // Create a new order - let identifiers: Vec = domains - .iter() - .map(|d| Identifier::Dns(d.clone())) - .collect(); + let identifiers: Vec = + domains.iter().map(|d| Identifier::Dns(d.clone())).collect(); let mut order = account .new_order(&NewOrder { @@ -84,7 +82,7 @@ impl AcmeClient { // Process authorizations let authorizations = order.authorizations().await?; - + for authz in authorizations { let domain = match &authz.identifier { Identifier::Dns(domain) => domain.clone(), @@ -107,10 +105,9 @@ impl AcmeClient { let key_authorization = order.key_authorization(challenge).as_str().to_string(); // Set up challenge validation - let validator = ChallengeValidator::new(challenge_type.clone(), self.config.http_challenge_port); - validator - .setup(&domain, &token, &key_authorization) - .await?; + let validator = + ChallengeValidator::new(challenge_type.clone(), self.config.http_challenge_port); + validator.setup(&domain, &token, &key_authorization).await?; // Notify Let's Encrypt that we're ready order.set_challenge_ready(&challenge.url).await?; @@ -124,13 +121,14 @@ impl AcmeClient { attempts += 1; let mut updated_order = account.order(order.url().to_string()).await?; - + // Check if this authorization is valid by checking order authorizations let authzs = updated_order.authorizations().await?; - let authz = authzs.iter().find(|a| { - matches!(&a.identifier, Identifier::Dns(d) if d == &domain) - }).ok_or_else(|| anyhow::anyhow!("Authorization not found for {}", domain))?; - + let authz = authzs + .iter() + .find(|a| matches!(&a.identifier, Identifier::Dns(d) if d == &domain)) + .ok_or_else(|| anyhow::anyhow!("Authorization not found for {}", domain))?; + match authz.status { AuthorizationStatus::Valid => { info!("Authorization valid for {}", domain); @@ -207,10 +205,10 @@ impl AcmeClient { /// Renew an existing certificate pub async fn renew_certificate(&self, domain: &str) -> Result<()> { info!("Renewing certificate for domain: {}", domain); - + // Get existing certificate info to retrieve all domains let cert_info = self.storage.get_certificate_info(domain).await?; - + // Request new certificate with same domains self.request_certificate(cert_info.domains, ChallengeType::Http01) .await @@ -241,7 +239,7 @@ impl AcmeClient { } else { LetsEncrypt::Production.url() }; - + let (account, credentials) = Account::create( &NewAccount { contact: &[&contact_email], diff --git a/rustalk-core/src/acme/storage.rs b/rustalk-core/src/acme/storage.rs index 11684f8..5226fa3 100644 --- a/rustalk-core/src/acme/storage.rs +++ b/rustalk-core/src/acme/storage.rs @@ -119,7 +119,7 @@ impl CertificateStorage { // Parse the first certificate let cert = &cert_ders[0]; - + // Use x509-parser for detailed certificate parsing use x509_parser::prelude::*; let (_, parsed_cert) = X509Certificate::from_der(cert.as_ref()) @@ -147,7 +147,9 @@ impl CertificateStorage { // Get expiry date let not_after = parsed_cert.validity().not_after; - let expires_at = not_after.to_rfc2822().unwrap_or_else(|_| "Unknown".to_string()); + let expires_at = not_after + .to_rfc2822() + .unwrap_or_else(|_| "Unknown".to_string()); // Calculate days until expiry let now = std::time::SystemTime::now() @@ -190,7 +192,10 @@ impl CertificateStorage { let path = entry.path(); if let Some(name) = path.file_name() { if let Some(name_str) = name.to_str() { - if name_str.ends_with(".pem") && !name_str.ends_with("-key.pem") && !name_str.ends_with(".backup") { + if name_str.ends_with(".pem") + && !name_str.ends_with("-key.pem") + && !name_str.ends_with(".backup") + { if let Some(domain) = name_str.strip_suffix(".pem") { certificates.push(domain.to_string()); } @@ -228,10 +233,10 @@ mod tests { #[tokio::test] async fn test_storage_paths() { let storage = CertificateStorage::new(PathBuf::from("/tmp/test_certs")).unwrap(); - + let cert_path = storage.cert_path("example.com"); assert!(cert_path.to_str().unwrap().contains("example.com.pem")); - + let key_path = storage.key_path("example.com"); assert!(key_path.to_str().unwrap().contains("example.com-key.pem")); } @@ -240,11 +245,11 @@ mod tests { async fn test_certificate_exists() { let temp_dir = PathBuf::from("/tmp/test_cert_exists"); let _ = fs::remove_dir_all(&temp_dir).await; - + let storage = CertificateStorage::new(temp_dir.clone()).unwrap(); - + assert!(!storage.certificate_exists("test.com").await); - + // Clean up let _ = fs::remove_dir_all(&temp_dir).await; } diff --git a/rustalk-core/src/acme/validation.rs b/rustalk-core/src/acme/validation.rs index 4e37cc0..318d03b 100644 --- a/rustalk-core/src/acme/validation.rs +++ b/rustalk-core/src/acme/validation.rs @@ -40,12 +40,7 @@ impl ChallengeValidator { } /// Set up challenge validation - pub async fn setup( - &self, - domain: &str, - token: &str, - key_authorization: &str, - ) -> Result<()> { + pub async fn setup(&self, domain: &str, token: &str, key_authorization: &str) -> Result<()> { match self.challenge_type { ChallengeType::Http01 => { self.setup_http_challenge(domain, token, key_authorization) @@ -86,7 +81,7 @@ impl ChallengeValidator { // In a real implementation, this would start a temporary HTTP server // on port 80 to serve the challenge response at: // http:///.well-known/acme-challenge/ - + info!( "HTTP-01 challenge ready at http://{}/.well-known/acme-challenge/{}", domain, token @@ -132,7 +127,7 @@ impl ChallengeValidator { // In a real implementation, this would automatically update DNS records // using a DNS provider API (e.g., CloudFlare, Route53, etc.) - + println!("\nPlease create the following DNS TXT record:"); println!(" Name: _acme-challenge.{}", domain); println!(" Value: {}", txt_value); @@ -140,7 +135,7 @@ impl ChallengeValidator { // Wait for user confirmation (in production, would poll DNS) // This is a simplified implementation - + Ok(()) } @@ -182,7 +177,10 @@ mod tests { .await .unwrap(); - validator.cleanup("example.com", "test_token").await.unwrap(); + validator + .cleanup("example.com", "test_token") + .await + .unwrap(); let key_auth = validator.get_key_authorization("test_token").await; assert_eq!(key_auth, None); diff --git a/rustalk-core/src/auth/mod.rs b/rustalk-core/src/auth/mod.rs index f2e825e..2f89a46 100644 --- a/rustalk-core/src/auth/mod.rs +++ b/rustalk-core/src/auth/mod.rs @@ -57,7 +57,7 @@ impl AuthManager { /// Generate a new digest challenge pub fn generate_challenge(&mut self) -> DigestChallenge { let nonce = self.generate_nonce(); - + DigestChallenge { realm: self.realm.clone(), nonce, @@ -72,16 +72,19 @@ impl AuthManager { .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - + // Create a unique nonce using timestamp and random component let nonce = format!("{:x}{:x}", timestamp, rand::random::()); - + // Store nonce info for validation - self.nonces.insert(nonce.clone(), NonceInfo { - timestamp, - used: false, - }); - + self.nonces.insert( + nonce.clone(), + NonceInfo { + timestamp, + used: false, + }, + ); + nonce } @@ -93,25 +96,27 @@ impl AuthManager { method: &str, ) -> Result { // Check if nonce is valid and not expired - let nonce_info = self.nonces.get_mut(&response.nonce) + let nonce_info = self + .nonces + .get_mut(&response.nonce) .context("Invalid nonce")?; - + // Check nonce age (valid for 5 minutes) let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - + if now - nonce_info.timestamp > 300 { anyhow::bail!("Nonce expired"); } - + // Mark nonce as used (prevent replay) if nonce_info.used { anyhow::bail!("Nonce already used"); } nonce_info.used = true; - + // Calculate expected response let expected = self.calculate_response( &response.username, @@ -123,7 +128,7 @@ impl AuthManager { response.cnonce.as_deref(), response.qop.as_deref(), )?; - + Ok(expected == response.response) } @@ -142,11 +147,11 @@ impl AuthManager { // HA1 = MD5(username:realm:password) let ha1_input = format!("{}:{}:{}", username, self.realm, password); let ha1 = format!("{:x}", md5::compute(ha1_input.as_bytes())); - + // HA2 = MD5(method:uri) let ha2_input = format!("{}:{}", method, uri); let ha2 = format!("{:x}", md5::compute(ha2_input.as_bytes())); - + // Response calculation depends on qop let response = if let Some("auth") = qop { // Response = MD5(HA1:nonce:nc:cnonce:qop:HA2) @@ -159,7 +164,7 @@ impl AuthManager { let response_input = format!("{}:{}:{}", ha1, nonce, ha2); format!("{:x}", md5::compute(response_input.as_bytes())) }; - + Ok(response) } @@ -183,24 +188,20 @@ impl AuthManager { if !header.starts_with("Digest ") { anyhow::bail!("Not a Digest authorization header"); } - + let params_str = &header[7..]; // Skip "Digest " let params = parse_auth_params(params_str)?; - + Ok(DigestResponse { - username: params.get("username") + username: params + .get("username") .context("Missing username")? .to_string(), - realm: params.get("realm") - .context("Missing realm")? - .to_string(), - nonce: params.get("nonce") - .context("Missing nonce")? - .to_string(), - uri: params.get("uri") - .context("Missing uri")? - .to_string(), - response: params.get("response") + realm: params.get("realm").context("Missing realm")?.to_string(), + nonce: params.get("nonce").context("Missing nonce")?.to_string(), + uri: params.get("uri").context("Missing uri")?.to_string(), + response: params + .get("response") .context("Missing response")? .to_string(), algorithm: params.get("algorithm").map(|s| s.to_string()), @@ -216,7 +217,7 @@ impl AuthManager { .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - + self.nonces.retain(|_, info| now - info.timestamp <= 300); } } @@ -224,25 +225,27 @@ impl AuthManager { /// Parse authentication parameters from header value fn parse_auth_params(params_str: &str) -> Result> { let mut params = HashMap::new(); - + for part in params_str.split(',') { let part = part.trim(); - + if let Some(eq_pos) = part.find('=') { let key = part[..eq_pos].trim().to_string(); let value_part = part[eq_pos + 1..].trim(); - + // Handle quoted values - let value = if value_part.starts_with('"') && value_part.ends_with('"') && value_part.len() > 1 { - value_part[1..value_part.len() - 1].to_string() - } else { - value_part.to_string() - }; - + let value = + if value_part.starts_with('"') && value_part.ends_with('"') && value_part.len() > 1 + { + value_part[1..value_part.len() - 1].to_string() + } else { + value_part.to_string() + }; + params.insert(key, value); } } - + Ok(params) } @@ -254,7 +257,7 @@ mod tests { fn test_generate_challenge() { let mut auth = AuthManager::new("rustalk.local"); let challenge = auth.generate_challenge(); - + assert_eq!(challenge.realm, "rustalk.local"); assert!(!challenge.nonce.is_empty()); assert_eq!(challenge.algorithm, "MD5"); @@ -269,7 +272,7 @@ mod tests { algorithm: "MD5".to_string(), qop: Some("auth".to_string()), }; - + let formatted = AuthManager::format_challenge(&challenge); assert!(formatted.contains("realm=\"test.com\"")); assert!(formatted.contains("nonce=\"abc123\"")); @@ -279,7 +282,7 @@ mod tests { #[test] fn test_parse_authorization() { let header = r#"Digest username="alice", realm="test.com", nonce="abc123", uri="sip:test.com", response="6629fae49393a05397450978507c4ef1""#; - + let response = AuthManager::parse_authorization(header).unwrap(); assert_eq!(response.username, "alice"); assert_eq!(response.realm, "test.com"); @@ -291,24 +294,26 @@ mod tests { fn test_validate_response() { let mut auth = AuthManager::new("rustalk.local"); let challenge = auth.generate_challenge(); - + // Calculate response for test let password = "secret123"; let username = "alice"; let method = "REGISTER"; let uri = "sip:rustalk.local"; - - let response = auth.calculate_response( - username, - password, - method, - uri, - &challenge.nonce, - Some("00000001"), - Some("xyz789"), - Some("auth"), - ).unwrap(); - + + let response = auth + .calculate_response( + username, + password, + method, + uri, + &challenge.nonce, + Some("00000001"), + Some("xyz789"), + Some("auth"), + ) + .unwrap(); + let digest_response = DigestResponse { username: username.to_string(), realm: challenge.realm.clone(), @@ -320,31 +325,35 @@ mod tests { nc: Some("00000001".to_string()), cnonce: Some("xyz789".to_string()), }; - - assert!(auth.validate_response(&digest_response, password, method).unwrap()); + + assert!(auth + .validate_response(&digest_response, password, method) + .unwrap()); } #[test] fn test_nonce_reuse_prevention() { let mut auth = AuthManager::new("rustalk.local"); let challenge = auth.generate_challenge(); - + let password = "secret123"; let username = "alice"; let method = "REGISTER"; let uri = "sip:rustalk.local"; - - let response = auth.calculate_response( - username, - password, - method, - uri, - &challenge.nonce, - Some("00000001"), - Some("xyz789"), - Some("auth"), - ).unwrap(); - + + let response = auth + .calculate_response( + username, + password, + method, + uri, + &challenge.nonce, + Some("00000001"), + Some("xyz789"), + Some("auth"), + ) + .unwrap(); + let digest_response = DigestResponse { username: username.to_string(), realm: challenge.realm.clone(), @@ -356,24 +365,28 @@ mod tests { nc: Some("00000001".to_string()), cnonce: Some("xyz789".to_string()), }; - + // First use should succeed - assert!(auth.validate_response(&digest_response, password, method).is_ok()); - + assert!(auth + .validate_response(&digest_response, password, method) + .is_ok()); + // Second use with same nonce should fail (replay attack prevention) - assert!(auth.validate_response(&digest_response, password, method).is_err()); + assert!(auth + .validate_response(&digest_response, password, method) + .is_err()); } #[test] fn test_cleanup_nonces() { let mut auth = AuthManager::new("rustalk.local"); - + // Generate some challenges auth.generate_challenge(); auth.generate_challenge(); - + assert_eq!(auth.nonces.len(), 2); - + // Cleanup should not remove recent nonces auth.cleanup_nonces(); assert_eq!(auth.nonces.len(), 2); diff --git a/rustalk-core/src/b2bua/mod.rs b/rustalk-core/src/b2bua/mod.rs index 061ca71..34e6e7a 100644 --- a/rustalk-core/src/b2bua/mod.rs +++ b/rustalk-core/src/b2bua/mod.rs @@ -7,14 +7,14 @@ use std::sync::Arc; use tokio::sync::RwLock; use tracing::{debug, error, info}; -pub mod session; pub mod call_leg; +pub mod session; -pub use session::{Session, SessionId}; pub use call_leg::CallLeg; +pub use session::{Session, SessionId}; /// B2BUA core engine -/// +/// /// The B2BUA acts as both a UAC (User Agent Client) and UAS (User Agent Server), /// maintaining call state between two legs of a call. #[derive(Clone)] @@ -49,9 +49,9 @@ impl B2BUA { Method::Cancel => self.handle_cancel(request).await, _ => { debug!("Method {} not implemented", request.method); - Ok(Some(Message::Response( - Response::new(StatusCode::NOT_IMPLEMENTED) - ))) + Ok(Some(Message::Response(Response::new( + StatusCode::NOT_IMPLEMENTED, + )))) } } } @@ -59,11 +59,12 @@ impl B2BUA { /// Handle SIP response async fn handle_response(&self, response: Response) -> Result> { info!("Handling response: {}", response.status_code); - + // Find the session this response belongs to - let call_id = response.get_header_value("Call-ID") + let call_id = response + .get_header_value("Call-ID") .ok_or_else(|| anyhow::anyhow!("No Call-ID in response"))?; - + let sessions = self.sessions.read().await; if let Some(session) = sessions.values().find(|s| s.call_id() == call_id) { // Forward response to the other leg @@ -78,7 +79,8 @@ impl B2BUA { /// Handle INVITE request - establish new session async fn handle_invite(&self, request: Request) -> Result> { - let call_id = request.get_header_value("Call-ID") + let call_id = request + .get_header_value("Call-ID") .ok_or_else(|| anyhow::anyhow!("No Call-ID in INVITE"))? .to_string(); @@ -86,27 +88,28 @@ impl B2BUA { // Create new session let session = Session::new(call_id.clone()); - + let mut sessions = self.sessions.write().await; sessions.insert(session.id().clone(), session); // Send 100 Trying - let response = Response::new(StatusCode::TRYING) - .with_header("Call-ID", call_id.as_str()); + let response = Response::new(StatusCode::TRYING).with_header("Call-ID", call_id.as_str()); Ok(Some(Message::Response(response))) } /// Handle BYE request - terminate session async fn handle_bye(&self, request: Request) -> Result> { - let call_id = request.get_header_value("Call-ID") + let call_id = request + .get_header_value("Call-ID") .ok_or_else(|| anyhow::anyhow!("No Call-ID in BYE"))?; info!("Terminating session for Call-ID: {}", call_id); // Find and remove session let mut sessions = self.sessions.write().await; - let session_id = sessions.values() + let session_id = sessions + .values() .find(|s| s.call_id() == call_id) .map(|s| s.id().clone()); @@ -116,8 +119,7 @@ impl B2BUA { } // Send 200 OK - let response = Response::new(StatusCode::OK) - .with_header("Call-ID", call_id); + let response = Response::new(StatusCode::OK).with_header("Call-ID", call_id); Ok(Some(Message::Response(response))) } @@ -126,7 +128,8 @@ impl B2BUA { async fn handle_options(&self, request: Request) -> Result> { info!("Handling OPTIONS request"); - let call_id = request.get_header_value("Call-ID") + let call_id = request + .get_header_value("Call-ID") .unwrap_or("none") .to_string(); @@ -149,13 +152,13 @@ impl B2BUA { /// Handle CANCEL request async fn handle_cancel(&self, request: Request) -> Result> { - let call_id = request.get_header_value("Call-ID") + let call_id = request + .get_header_value("Call-ID") .ok_or_else(|| anyhow::anyhow!("No Call-ID in CANCEL"))?; info!("Canceling session for Call-ID: {}", call_id); - let response = Response::new(StatusCode::OK) - .with_header("Call-ID", call_id); + let response = Response::new(StatusCode::OK).with_header("Call-ID", call_id); Ok(Some(Message::Response(response))) } @@ -180,19 +183,19 @@ mod tests { #[tokio::test] async fn test_b2bua_options() { let b2bua = B2BUA::new(); - + let request = Request::new( Method::Options, - Uri::new("sip".to_string(), "example.com".to_string()) + Uri::new("sip".to_string(), "example.com".to_string()), ) .with_header("Call-ID", "test123"); let result = b2bua.handle_message(Message::Request(request)).await; assert!(result.is_ok()); - + let response = result.unwrap(); assert!(response.is_some()); - + if let Some(Message::Response(res)) = response { assert_eq!(res.status_code, StatusCode::OK); } @@ -201,11 +204,11 @@ mod tests { #[tokio::test] async fn test_b2bua_invite_bye() { let b2bua = B2BUA::new(); - + // Send INVITE let invite = Request::new( Method::Invite, - Uri::new("sip".to_string(), "bob@example.com".to_string()) + Uri::new("sip".to_string(), "bob@example.com".to_string()), ) .with_header("Call-ID", "call123"); @@ -216,7 +219,7 @@ mod tests { // Send BYE let bye = Request::new( Method::Bye, - Uri::new("sip".to_string(), "bob@example.com".to_string()) + Uri::new("sip".to_string(), "bob@example.com".to_string()), ) .with_header("Call-ID", "call123"); diff --git a/rustalk-core/src/config/mod.rs b/rustalk-core/src/config/mod.rs index 3c8d59b..89a7795 100644 --- a/rustalk-core/src/config/mod.rs +++ b/rustalk-core/src/config/mod.rs @@ -116,12 +116,12 @@ impl Config { /// First loads from JSON, then overlays values from database pub async fn from_file_with_db_overlay(path: impl AsRef) -> Result { let mut config = Self::from_file(path).await?; - + // If database is configured, load overlay if let Some(db_config) = config.database.clone() { config = Self::apply_db_overlay(config, &db_config).await?; } - + Ok(config) } @@ -130,12 +130,12 @@ impl Config { // This would connect to the database and overlay configuration values // For now, this is a placeholder tracing::info!("Database overlay from: {}", db_config.url); - + // In a real implementation, we would: // 1. Connect to the database using sqlx // 2. Query configuration overrides // 3. Merge them into the config structure - + Ok(config) } @@ -201,7 +201,7 @@ mod tests { let config = Config::default(); let json = serde_json::to_string_pretty(&config).unwrap(); assert!(json.contains("rustalk.local")); - + let parsed: Config = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.sip.domain, config.sip.domain); } diff --git a/rustalk-core/src/lib.rs b/rustalk-core/src/lib.rs index 979274e..7416919 100644 --- a/rustalk-core/src/lib.rs +++ b/rustalk-core/src/lib.rs @@ -8,27 +8,27 @@ //! - ACME/Let's Encrypt certificate management pub mod acl; +pub mod acme; pub mod auth; -pub mod sip; pub mod b2bua; -pub mod transport; pub mod config; pub mod media; -pub mod acme; pub mod routing; +pub mod sip; +pub mod transport; pub mod voicemail; pub use config::Config; /// Re-export commonly used types pub mod prelude { - pub use crate::acl::{Acl, AclManager, AclAction, AclRule, create_default_acls}; + pub use crate::acl::{create_default_acls, Acl, AclAction, AclManager, AclRule}; + pub use crate::acme::{AcmeClient, AcmeConfig, CertificateStatus}; pub use crate::auth::{AuthManager, DigestChallenge, DigestResponse}; - pub use crate::sip::{Message, Method, Request, Response, StatusCode}; pub use crate::b2bua::B2BUA; - pub use crate::transport::{Transport, TransportConfig}; pub use crate::config::Config; - pub use crate::acme::{AcmeClient, AcmeConfig, CertificateStatus}; - pub use crate::routing::{RouteEvaluator, CallContext, RouteMatch, RoutingConfig}; - pub use crate::voicemail::{VoicemailManager, VoicemailBox, VoicemailMessage, MwiStatus}; + pub use crate::routing::{CallContext, RouteEvaluator, RouteMatch, RoutingConfig}; + pub use crate::sip::{Message, Method, Request, Response, StatusCode}; + pub use crate::transport::{Transport, TransportConfig}; + pub use crate::voicemail::{MwiStatus, VoicemailBox, VoicemailManager, VoicemailMessage}; } diff --git a/rustalk-core/src/media/codec.rs b/rustalk-core/src/media/codec.rs index 8a25b43..2388151 100644 --- a/rustalk-core/src/media/codec.rs +++ b/rustalk-core/src/media/codec.rs @@ -88,10 +88,31 @@ pub fn standard_codecs() -> Vec { Codec::new("GSM", 3, 8000, 1, "GSM Full Rate (13 kbps)", true), // Dynamic payload types (96-127) Codec::new("G729", 18, 8000, 1, "G.729 (8 kbps)", true), - Codec::new("iLBC", 97, 8000, 1, "Internet Low Bitrate Codec (13.33/15.2 kbps)", true), + Codec::new( + "iLBC", + 97, + 8000, + 1, + "Internet Low Bitrate Codec (13.33/15.2 kbps)", + true, + ), Codec::new("opus", 111, 48000, 2, "Opus codec (6-510 kbps)", true), - Codec::new("AMR", 96, 8000, 1, "Adaptive Multi-Rate narrowband (4.75-12.2 kbps)", true), - Codec::new("AMR-WB", 98, 16000, 1, "Adaptive Multi-Rate wideband (6.6-23.85 kbps)", true), + Codec::new( + "AMR", + 96, + 8000, + 1, + "Adaptive Multi-Rate narrowband (4.75-12.2 kbps)", + true, + ), + Codec::new( + "AMR-WB", + 98, + 16000, + 1, + "Adaptive Multi-Rate wideband (6.6-23.85 kbps)", + true, + ), Codec::new("SILK", 99, 16000, 1, "Skype SILK codec (6-40 kbps)", true), ] } @@ -130,7 +151,11 @@ impl CodecConfig { /// Enable a codec by name pub fn enable_codec(&mut self, name: &str) -> bool { - if let Some(codec) = self.codecs.iter_mut().find(|c| c.name.eq_ignore_ascii_case(name)) { + if let Some(codec) = self + .codecs + .iter_mut() + .find(|c| c.name.eq_ignore_ascii_case(name)) + { codec.enabled = true; true } else { @@ -140,7 +165,11 @@ impl CodecConfig { /// Disable a codec by name pub fn disable_codec(&mut self, name: &str) -> bool { - if let Some(codec) = self.codecs.iter_mut().find(|c| c.name.eq_ignore_ascii_case(name)) { + if let Some(codec) = self + .codecs + .iter_mut() + .find(|c| c.name.eq_ignore_ascii_case(name)) + { codec.enabled = false; true } else { @@ -151,8 +180,15 @@ impl CodecConfig { /// Add a custom codec (non-standard) pub fn add_codec(&mut self, mut codec: Codec) -> Result<(), String> { // Check if codec with same payload type already exists - if self.codecs.iter().any(|c| c.payload_type == codec.payload_type) { - return Err(format!("Codec with payload type {} already exists", codec.payload_type)); + if self + .codecs + .iter() + .any(|c| c.payload_type == codec.payload_type) + { + return Err(format!( + "Codec with payload type {} already exists", + codec.payload_type + )); } // Custom codecs are not standard @@ -163,7 +199,11 @@ impl CodecConfig { /// Remove a codec (only non-standard codecs can be removed) pub fn remove_codec(&mut self, name: &str) -> Result<(), String> { - if let Some(pos) = self.codecs.iter().position(|c| c.name.eq_ignore_ascii_case(name)) { + if let Some(pos) = self + .codecs + .iter() + .position(|c| c.name.eq_ignore_ascii_case(name)) + { if self.codecs[pos].is_standard { return Err("Cannot remove standard codecs".to_string()); } @@ -199,15 +239,15 @@ impl CodecConfig { if to_index >= self.codecs.len() { return Err(format!("Invalid to_index: {}", to_index)); } - + if from_index != to_index { let codec = self.codecs.remove(from_index); self.codecs.insert(to_index, codec); - + // Update priorities to reflect new order self.update_priorities(); } - + Ok(()) } @@ -221,26 +261,22 @@ impl CodecConfig { /// Sort codecs by priority (lower number = higher priority) /// Falls back to list order if priority is not set pub fn sort_by_priority(&mut self) { - self.codecs.sort_by(|a, b| { - match (a.priority, b.priority) { - (Some(pa), Some(pb)) => pa.cmp(&pb), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } + self.codecs.sort_by(|a, b| match (a.priority, b.priority) { + (Some(pa), Some(pb)) => pa.cmp(&pb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, }); } /// Get codecs in priority order (enabled codecs first, sorted by priority) pub fn get_priority_ordered(&self) -> Vec<&Codec> { let mut enabled: Vec<&Codec> = self.enabled_codecs(); - enabled.sort_by(|a, b| { - match (a.priority, b.priority) { - (Some(pa), Some(pb)) => pa.cmp(&pb), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } + enabled.sort_by(|a, b| match (a.priority, b.priority) { + (Some(pa), Some(pb)) => pa.cmp(&pb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, }); enabled } @@ -284,15 +320,15 @@ mod tests { #[test] fn test_enable_disable_codec() { let mut config = CodecConfig::new(); - + // Disable a codec assert!(config.disable_codec("PCMU")); assert!(!config.get_by_name("PCMU").unwrap().enabled); - + // Enable it back assert!(config.enable_codec("PCMU")); assert!(config.get_by_name("PCMU").unwrap().enabled); - + // Try non-existent codec assert!(!config.enable_codec("NonExistent")); } @@ -311,7 +347,7 @@ mod tests { let codec = config.get_by_name("PCMA"); assert!(codec.is_some()); assert_eq!(codec.unwrap().payload_type, 8); - + // Case insensitive let codec = config.get_by_name("pcma"); assert!(codec.is_some()); @@ -321,10 +357,10 @@ mod tests { fn test_add_custom_codec() { let mut config = CodecConfig::new(); let custom = Codec::new("CustomCodec", 120, 16000, 1, "Custom test codec", false); - + assert!(config.add_codec(custom.clone()).is_ok()); assert!(config.get_by_name("CustomCodec").is_some()); - + // Try to add codec with duplicate payload type let duplicate = Codec::new("DuplicatePT", 120, 8000, 1, "Duplicate PT", false); assert!(config.add_codec(duplicate).is_err()); @@ -333,10 +369,10 @@ mod tests { #[test] fn test_remove_codec() { let mut config = CodecConfig::new(); - + // Cannot remove standard codec assert!(config.remove_codec("PCMU").is_err()); - + // Add and remove custom codec let custom = Codec::new("CustomCodec", 120, 16000, 1, "Custom test codec", false); config.add_codec(custom).unwrap(); @@ -347,14 +383,14 @@ mod tests { #[test] fn test_sdp_formats() { let mut config = CodecConfig::new(); - + // Disable all but PCMU and PCMA for codec in &config.codecs.clone() { if codec.name != "PCMU" && codec.name != "PCMA" { config.disable_codec(&codec.name); } } - + let formats = config.sdp_formats(); assert!(formats.contains("0")); // PCMU assert!(formats.contains("8")); // PCMA @@ -363,14 +399,14 @@ mod tests { #[test] fn test_sdp_rtpmaps() { let mut config = CodecConfig::new(); - + // Keep only PCMU enabled for codec in &config.codecs.clone() { if codec.name != "PCMU" { config.disable_codec(&codec.name); } } - + let rtpmaps = config.sdp_rtpmaps(); assert_eq!(rtpmaps.len(), 1); assert!(rtpmaps[0].contains("a=rtpmap:0 PCMU/8000")); @@ -381,12 +417,12 @@ mod tests { let mut config = CodecConfig::new(); let initial_first = config.codecs[0].name.clone(); let initial_third = config.codecs[2].name.clone(); - + // Move codec from index 2 to index 0 assert!(config.reorder_codec(2, 0).is_ok()); assert_eq!(config.codecs[0].name, initial_third); assert_eq!(config.codecs[1].name, initial_first); - + // Verify priorities are updated assert_eq!(config.codecs[0].priority, Some(0)); assert_eq!(config.codecs[1].priority, Some(1)); @@ -396,10 +432,10 @@ mod tests { fn test_reorder_codec_invalid_index() { let mut config = CodecConfig::new(); let len = config.codecs.len(); - + // Invalid from_index assert!(config.reorder_codec(len + 1, 0).is_err()); - + // Invalid to_index assert!(config.reorder_codec(0, len + 1).is_err()); } @@ -407,14 +443,14 @@ mod tests { #[test] fn test_priority_ordering() { let mut config = CodecConfig::new(); - + // Set custom priorities config.codecs[0].priority = Some(5); config.codecs[1].priority = Some(2); config.codecs[2].priority = Some(1); - + let ordered = config.get_priority_ordered(); - + // Should be sorted by priority (lower number first) assert_eq!(ordered[0].priority, Some(1)); assert_eq!(ordered[1].priority, Some(2)); @@ -424,15 +460,15 @@ mod tests { #[test] fn test_sort_by_priority() { let mut config = CodecConfig::new(); - + // Set reverse priorities let len = config.codecs.len(); for (i, codec) in config.codecs.iter_mut().enumerate() { codec.priority = Some((len - i) as u32); } - + config.sort_by_priority(); - + // After sorting, priorities should be in ascending order for i in 0..config.codecs.len() - 1 { assert!(config.codecs[i].priority.unwrap() < config.codecs[i + 1].priority.unwrap()); diff --git a/rustalk-core/src/media/mod.rs b/rustalk-core/src/media/mod.rs index 36c7a76..80919d2 100644 --- a/rustalk-core/src/media/mod.rs +++ b/rustalk-core/src/media/mod.rs @@ -64,7 +64,7 @@ impl MediaSession { self.rtp_port = Some(port); } } - + // Check for SRTP if line.contains("RTP/SAVP") || line.contains("RTP/SAVPF") { self.srtp_enabled = true; @@ -100,7 +100,7 @@ mod tests { c=IN IP4 192.168.1.100\r\n\ t=0 0\r\n\ m=audio 49170 RTP/AVP 0\r\n"; - + session.parse_sdp(sdp).unwrap(); assert_eq!(session.rtp_address, Some("192.168.1.100".to_string())); assert_eq!(session.rtp_port, Some(49170)); diff --git a/rustalk-core/src/media/sdp.rs b/rustalk-core/src/media/sdp.rs index cd52e76..1eff49d 100644 --- a/rustalk-core/src/media/sdp.rs +++ b/rustalk-core/src/media/sdp.rs @@ -34,7 +34,7 @@ impl SdpSession { /// Parse SDP from string pub fn parse(sdp: &str) -> Result { let mut session = Self::new(); - + for line in sdp.lines() { let line = line.trim(); if line.is_empty() { @@ -56,10 +56,7 @@ impl SdpSession { media_type: parts[0].to_string(), port: parts[1].parse()?, protocol: parts[2].to_string(), - formats: parts[3..] - .iter() - .filter_map(|f| f.parse().ok()) - .collect(), + formats: parts[3..].iter().filter_map(|f| f.parse().ok()).collect(), }; session.media.push(media); } @@ -72,19 +69,20 @@ impl SdpSession { /// Serialize to SDP string pub fn to_string(&self) -> String { let mut sdp = String::new(); - + sdp.push_str(&format!("v={}\r\n", self.version)); sdp.push_str(&format!("o={}\r\n", self.origin)); sdp.push_str(&format!("s={}\r\n", self.session_name)); - + if let Some(conn) = &self.connection { sdp.push_str(&format!("c={}\r\n", conn)); } - + sdp.push_str("t=0 0\r\n"); - + for media in &self.media { - let formats = media.formats + let formats = media + .formats .iter() .map(|f| f.to_string()) .collect::>() diff --git a/rustalk-core/src/media/srtp.rs b/rustalk-core/src/media/srtp.rs index f158f16..e451ea8 100644 --- a/rustalk-core/src/media/srtp.rs +++ b/rustalk-core/src/media/srtp.rs @@ -102,7 +102,7 @@ mod tests { let config = SrtpConfig::new() .enable() .with_crypto_suite("AES_CM_128_HMAC_SHA1_80".to_string()); - + let attr = config.to_crypto_attribute(1); assert!(attr.contains("AES_CM_128_HMAC_SHA1_80")); } diff --git a/rustalk-core/src/routing/evaluator.rs b/rustalk-core/src/routing/evaluator.rs index 7c0d040..67240d9 100644 --- a/rustalk-core/src/routing/evaluator.rs +++ b/rustalk-core/src/routing/evaluator.rs @@ -1,7 +1,7 @@ //! Route evaluation engine for processing routing rules -use super::{RouteRule, RouteAction, RouteDestination, RoutingConfig}; use super::matcher::ConditionMatcher; +use super::{RouteAction, RouteDestination, RouteRule, RoutingConfig}; use regex::Regex; use std::sync::Arc; @@ -80,7 +80,10 @@ impl RouteEvaluator { // Then check if all conditions match if let Some(conditions) = &route.conditions { - if !self.matcher.matches(conditions, &context.caller_id, &context.destination) { + if !self + .matcher + .matches(conditions, &context.caller_id, &context.destination) + { return false; } } @@ -110,8 +113,8 @@ impl RouteEvaluator { #[cfg(test)] mod tests { use super::*; - use crate::routing::{RouteCondition, TimeCondition, DayOfWeekCondition, CallerIdCondition}; - use crate::routing::matcher::{TimeProvider, ConditionMatcher}; + use crate::routing::matcher::{ConditionMatcher, TimeProvider}; + use crate::routing::{CallerIdCondition, DayOfWeekCondition, RouteCondition, TimeCondition}; use chrono::{DateTime, TimeZone, Utc}; use std::sync::Arc; @@ -144,14 +147,14 @@ mod tests { fn test_simple_pattern_match() { let mut config = RoutingConfig::new(); config.add_route(create_test_route("1", 10, r"^2\d{3}$")); // 2xxx extensions - + let evaluator = RouteEvaluator::new(config); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "2345".to_string(), }; - + let result = evaluator.evaluate(&context); assert!(result.is_some()); assert_eq!(result.unwrap().route_id, "1"); @@ -161,14 +164,14 @@ mod tests { fn test_no_match() { let mut config = RoutingConfig::new(); config.add_route(create_test_route("1", 10, r"^2\d{3}$")); - + let evaluator = RouteEvaluator::new(config); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "5000".to_string(), }; - + let result = evaluator.evaluate(&context); assert!(result.is_none()); } @@ -178,14 +181,14 @@ mod tests { let mut config = RoutingConfig::new(); config.add_route(create_test_route("low", 20, r"^\d+$")); config.add_route(create_test_route("high", 5, r"^2\d{3}$")); - + let evaluator = RouteEvaluator::new(config); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "2345".to_string(), }; - + // Should match the high priority route first let result = evaluator.evaluate(&context); assert!(result.is_some()); @@ -195,28 +198,28 @@ mod tests { #[test] fn test_route_with_time_condition() { let mut config = RoutingConfig::new(); - + let mut route = create_test_route("1", 10, r"^\d+$"); - route.conditions = Some(vec![ - RouteCondition::Time(TimeCondition { - start_time: "09:00".to_string(), - end_time: "17:00".to_string(), - }) - ]); + route.conditions = Some(vec![RouteCondition::Time(TimeCondition { + start_time: "09:00".to_string(), + end_time: "17:00".to_string(), + })]); config.add_route(route); - + // Test at 10:30 AM on Monday let mock_time = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = Arc::new(ConditionMatcher::with_time_provider(provider)); - + let evaluator = RouteEvaluator::with_matcher(config, matcher); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "2345".to_string(), }; - + let result = evaluator.evaluate(&context); assert!(result.is_some()); } @@ -224,25 +227,23 @@ mod tests { #[test] fn test_route_with_caller_id_condition() { let mut config = RoutingConfig::new(); - + let mut route = create_test_route("1", 10, r"^\d+$"); - route.conditions = Some(vec![ - RouteCondition::CallerId(CallerIdCondition { - pattern: r"^\+1".to_string(), - negate: false, - }) - ]); + route.conditions = Some(vec![RouteCondition::CallerId(CallerIdCondition { + pattern: r"^\+1".to_string(), + negate: false, + })]); config.add_route(route); - + let evaluator = RouteEvaluator::new(config); - + // Should match US number let context1 = CallContext { caller_id: "+12125551234".to_string(), destination: "2345".to_string(), }; assert!(evaluator.evaluate(&context1).is_some()); - + // Should not match UK number let context2 = CallContext { caller_id: "+442071234567".to_string(), @@ -254,25 +255,25 @@ mod tests { #[test] fn test_continue_on_match() { let mut config = RoutingConfig::new(); - + let mut route1 = create_test_route("1", 10, r"^\d+$"); route1.continue_on_match = true; route1.action = RouteAction::Continue; route1.destination = RouteDestination::Extension("1000".to_string()); - + let mut route2 = create_test_route("2", 20, r"^\d+$"); route2.destination = RouteDestination::Extension("2000".to_string()); - + config.add_route(route1); config.add_route(route2); - + let evaluator = RouteEvaluator::new(config); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "5000".to_string(), }; - + // Should skip first route and match second let result = evaluator.evaluate(&context); assert!(result.is_some()); @@ -288,18 +289,18 @@ mod tests { #[test] fn test_disabled_route_not_matched() { let mut config = RoutingConfig::new(); - + let mut route = create_test_route("1", 10, r"^\d+$"); route.enabled = false; config.add_route(route); - + let evaluator = RouteEvaluator::new(config); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "2345".to_string(), }; - + let result = evaluator.evaluate(&context); assert!(result.is_none()); } @@ -307,19 +308,19 @@ mod tests { #[test] fn test_reject_action() { let mut config = RoutingConfig::new(); - + let mut route = create_test_route("1", 10, r"^900\d+$"); // Block premium numbers route.action = RouteAction::Reject; route.destination = RouteDestination::Hangup; config.add_route(route); - + let evaluator = RouteEvaluator::new(config); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "9001234567".to_string(), }; - + let result = evaluator.evaluate(&context); assert!(result.is_some()); let matched = result.unwrap(); @@ -330,7 +331,7 @@ mod tests { #[test] fn test_multiple_conditions() { let mut config = RoutingConfig::new(); - + let mut route = create_test_route("1", 10, r"^\d{4}$"); route.conditions = Some(vec![ RouteCondition::Time(TimeCondition { @@ -342,19 +343,21 @@ mod tests { }), ]); config.add_route(route); - + // Test at 10:30 AM on Monday let mock_time = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = Arc::new(ConditionMatcher::with_time_provider(provider)); - + let evaluator = RouteEvaluator::with_matcher(config, matcher); - + let context = CallContext { caller_id: "+12125551234".to_string(), destination: "2345".to_string(), }; - + let result = evaluator.evaluate(&context); assert!(result.is_some()); } diff --git a/rustalk-core/src/routing/matcher.rs b/rustalk-core/src/routing/matcher.rs index 1c0ba91..2051313 100644 --- a/rustalk-core/src/routing/matcher.rs +++ b/rustalk-core/src/routing/matcher.rs @@ -1,8 +1,8 @@ //! Condition matching for routing rules use super::{ - RouteCondition, TimeCondition, DayOfWeekCondition, DateRangeCondition, - CallerIdCondition, DestinationCondition, + CallerIdCondition, DateRangeCondition, DayOfWeekCondition, DestinationCondition, + RouteCondition, TimeCondition, }; use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Timelike, Utc}; use regex::Regex; @@ -42,12 +42,24 @@ impl ConditionMatcher { } /// Check if all conditions match - pub fn matches(&self, conditions: &[RouteCondition], caller_id: &str, destination: &str) -> bool { - conditions.iter().all(|condition| self.match_condition(condition, caller_id, destination)) + pub fn matches( + &self, + conditions: &[RouteCondition], + caller_id: &str, + destination: &str, + ) -> bool { + conditions + .iter() + .all(|condition| self.match_condition(condition, caller_id, destination)) } /// Check if a single condition matches - fn match_condition(&self, condition: &RouteCondition, caller_id: &str, destination: &str) -> bool { + fn match_condition( + &self, + condition: &RouteCondition, + caller_id: &str, + destination: &str, + ) -> bool { match condition { RouteCondition::Time(tc) => self.match_time_condition(tc), RouteCondition::DayOfWeek(dow) => self.match_day_of_week(dow), @@ -60,11 +72,8 @@ impl ConditionMatcher { /// Check if current time is within the specified time range fn match_time_condition(&self, condition: &TimeCondition) -> bool { let now = self.time_provider.now(); - let current_time = NaiveTime::from_hms_opt( - now.hour(), - now.minute(), - now.second(), - ).unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap()); + let current_time = NaiveTime::from_hms_opt(now.hour(), now.minute(), now.second()) + .unwrap_or_else(|| NaiveTime::from_hms_opt(0, 0, 0).unwrap()); let start_time = match parse_time(&condition.start_time) { Ok(t) => t, @@ -155,8 +164,7 @@ fn parse_time(time_str: &str) -> Result { let hour: u32 = parts[0].parse().map_err(|_| "Invalid hour")?; let minute: u32 = parts[1].parse().map_err(|_| "Invalid minute")?; - NaiveTime::from_hms_opt(hour, minute, 0) - .ok_or_else(|| "Invalid time values".to_string()) + NaiveTime::from_hms_opt(hour, minute, 0).ok_or_else(|| "Invalid time values".to_string()) } #[cfg(test)] @@ -189,7 +197,9 @@ mod tests { fn test_match_time_condition() { // Test at 10:30 AM let mock_time = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = ConditionMatcher::with_time_provider(provider); let condition = TimeCondition { @@ -211,7 +221,9 @@ mod tests { fn test_match_time_condition_crosses_midnight() { // Test at 23:00 (11 PM) let mock_time = Utc.with_ymd_and_hms(2024, 1, 15, 23, 0, 0).unwrap(); - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = ConditionMatcher::with_time_provider(provider); let condition = TimeCondition { @@ -226,7 +238,9 @@ mod tests { fn test_match_day_of_week() { // Test on Monday (2024-01-15 is a Monday) let mock_time = Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(); - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = ConditionMatcher::with_time_provider(provider); let weekday_condition = DayOfWeekCondition { @@ -245,7 +259,9 @@ mod tests { #[test] fn test_match_date_range() { let mock_time = Utc.with_ymd_and_hms(2024, 6, 15, 10, 0, 0).unwrap(); - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = ConditionMatcher::with_time_provider(provider); let condition = DateRangeCondition { @@ -301,7 +317,9 @@ mod tests { #[test] fn test_matches_all_conditions() { let mock_time = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(); // Monday 10:30 - let provider = Arc::new(MockTimeProvider { fixed_time: mock_time }); + let provider = Arc::new(MockTimeProvider { + fixed_time: mock_time, + }); let matcher = ConditionMatcher::with_time_provider(provider); let conditions = vec![ diff --git a/rustalk-core/src/routing/mod.rs b/rustalk-core/src/routing/mod.rs index da9f0e5..9aa5d22 100644 --- a/rustalk-core/src/routing/mod.rs +++ b/rustalk-core/src/routing/mod.rs @@ -10,7 +10,7 @@ pub mod evaluator; pub mod matcher; -pub use evaluator::{RouteEvaluator, RouteMatch, CallContext}; +pub use evaluator::{CallContext, RouteEvaluator, RouteMatch}; pub use matcher::{ConditionMatcher, TimeProvider}; use serde::{Deserialize, Serialize}; @@ -101,9 +101,7 @@ pub struct DestinationCondition { impl RoutingConfig { /// Create a new empty routing configuration pub fn new() -> Self { - Self { - routes: Vec::new(), - } + Self { routes: Vec::new() } } /// Add a route to the configuration @@ -132,7 +130,7 @@ mod tests { #[test] fn test_add_route_sorts_by_priority() { let mut config = RoutingConfig::new(); - + let route1 = RouteRule { id: "1".to_string(), name: "Route 1".to_string(), @@ -145,7 +143,7 @@ mod tests { action: RouteAction::Accept, continue_on_match: false, }; - + let route2 = RouteRule { id: "2".to_string(), name: "Route 2".to_string(), @@ -158,10 +156,10 @@ mod tests { action: RouteAction::Accept, continue_on_match: false, }; - + config.add_route(route1); config.add_route(route2); - + assert_eq!(config.routes[0].priority, 5); assert_eq!(config.routes[1].priority, 10); } diff --git a/rustalk-core/src/sip/message.rs b/rustalk-core/src/sip/message.rs index a52033d..60b6ae3 100644 --- a/rustalk-core/src/sip/message.rs +++ b/rustalk-core/src/sip/message.rs @@ -70,7 +70,11 @@ impl Request { } } - pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + pub fn with_header( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { self.headers.push(Header::new(name, value)); self } @@ -81,7 +85,9 @@ impl Request { } pub fn get_header(&self, name: &str) -> Option<&Header> { - self.headers.iter().find(|h| h.name.as_str().eq_ignore_ascii_case(name)) + self.headers + .iter() + .find(|h| h.name.as_str().eq_ignore_ascii_case(name)) } pub fn get_header_value(&self, name: &str) -> Option<&str> { @@ -91,21 +97,23 @@ impl Request { /// Serialize to bytes pub fn to_bytes(&self) -> Vec { let mut result = Vec::new(); - + // Request line - result.extend_from_slice(format!("{} {} {}\r\n", self.method, self.uri, self.version).as_bytes()); - + result.extend_from_slice( + format!("{} {} {}\r\n", self.method, self.uri, self.version).as_bytes(), + ); + // Headers for header in &self.headers { result.extend_from_slice(format!("{}: {}\r\n", header.name, header.value).as_bytes()); } - + // Empty line result.extend_from_slice(b"\r\n"); - + // Body result.extend_from_slice(&self.body); - + result } } @@ -132,7 +140,11 @@ impl Response { } } - pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + pub fn with_header( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { self.headers.push(Header::new(name, value)); self } @@ -143,7 +155,9 @@ impl Response { } pub fn get_header(&self, name: &str) -> Option<&Header> { - self.headers.iter().find(|h| h.name.as_str().eq_ignore_ascii_case(name)) + self.headers + .iter() + .find(|h| h.name.as_str().eq_ignore_ascii_case(name)) } pub fn get_header_value(&self, name: &str) -> Option<&str> { @@ -153,21 +167,27 @@ impl Response { /// Serialize to bytes pub fn to_bytes(&self) -> Vec { let mut result = Vec::new(); - + // Status line - result.extend_from_slice(format!("{} {} {}\r\n", self.version, self.status_code, self.reason_phrase).as_bytes()); - + result.extend_from_slice( + format!( + "{} {} {}\r\n", + self.version, self.status_code, self.reason_phrase + ) + .as_bytes(), + ); + // Headers for header in &self.headers { result.extend_from_slice(format!("{}: {}\r\n", header.name, header.value).as_bytes()); } - + // Empty line result.extend_from_slice(b"\r\n"); - + // Body result.extend_from_slice(&self.body); - + result } } diff --git a/rustalk-core/src/sip/mod.rs b/rustalk-core/src/sip/mod.rs index 946ea54..3b6c103 100644 --- a/rustalk-core/src/sip/mod.rs +++ b/rustalk-core/src/sip/mod.rs @@ -1,15 +1,15 @@ //! SIP Protocol implementation +pub mod header; pub mod message; -pub mod parser; pub mod method; -pub mod header; +pub mod parser; pub mod response; +pub use header::{Header, HeaderName, HeaderValue}; pub use message::{Message, Request, Response}; pub use method::Method; pub use response::StatusCode; -pub use header::{Header, HeaderName, HeaderValue}; use std::net::SocketAddr; diff --git a/rustalk-core/src/sip/parser.rs b/rustalk-core/src/sip/parser.rs index a1711f7..50cc0e8 100644 --- a/rustalk-core/src/sip/parser.rs +++ b/rustalk-core/src/sip/parser.rs @@ -15,17 +15,17 @@ use nom::{ /// Parse a complete SIP message pub fn parse_message(input: &[u8]) -> Result { let input_str = std::str::from_utf8(input).map_err(|e| e.to_string())?; - + // Try to parse as request first if let Ok((_, request)) = parse_request(input_str) { return Ok(Message::Request(request)); } - + // Try to parse as response if let Ok((_, response)) = parse_response(input_str) { return Ok(Message::Response(response)); } - + Err("Invalid SIP message".to_string()) } @@ -41,7 +41,7 @@ fn parse_request(input: &str) -> IResult<&str, Request> { let (input, headers) = many0(parse_header)(input)?; let (input, _) = line_ending(input)?; - + let body = Bytes::from(input.as_bytes().to_vec()); Ok(( @@ -68,7 +68,7 @@ fn parse_response(input: &str) -> IResult<&str, Response> { let (input, headers) = many0(parse_header)(input)?; let (input, _) = line_ending(input)?; - + let body = Bytes::from(input.as_bytes().to_vec()); Ok(( @@ -104,16 +104,16 @@ fn parse_method(input: &str) -> IResult<&str, Method> { fn parse_uri(input: &str) -> IResult<&str, Uri> { let (input, scheme) = alt((tag("sip"), tag("sips")))(input)?; let (input, _) = char(':')(input)?; - + // Try to parse user part let (input, user) = opt(terminated( take_while1(|c: char| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'), char('@'), ))(input)?; - + // Parse host let (input, host) = take_while1(|c: char| c.is_alphanumeric() || c == '.' || c == '-')(input)?; - + // Parse optional port let (input, port) = opt(preceded( char(':'), @@ -165,10 +165,10 @@ mod tests { let result = parse_message(msg); assert!(result.is_ok()); - + let message = result.unwrap(); assert!(message.is_request()); - + let request = message.as_request().unwrap(); assert_eq!(request.method, Method::Options); assert_eq!(request.uri.host, "example.com"); @@ -187,10 +187,10 @@ mod tests { let result = parse_message(msg); assert!(result.is_ok()); - + let message = result.unwrap(); assert!(message.is_response()); - + let response = message.as_response().unwrap(); assert_eq!(response.status_code, StatusCode::OK); } diff --git a/rustalk-core/src/transport/mod.rs b/rustalk-core/src/transport/mod.rs index 8629d96..b1ab7b5 100644 --- a/rustalk-core/src/transport/mod.rs +++ b/rustalk-core/src/transport/mod.rs @@ -39,10 +39,10 @@ impl Default for TransportConfig { pub trait Transport: Send + Sync { /// Send a message async fn send(&self, message: &Message, dest: SocketAddr) -> Result<()>; - + /// Receive messages (blocking) async fn receive(&self) -> Result<(Message, SocketAddr)>; - + /// Get local address fn local_addr(&self) -> SocketAddr; } diff --git a/rustalk-core/src/transport/tls.rs b/rustalk-core/src/transport/tls.rs index ad9cb92..abf1c97 100644 --- a/rustalk-core/src/transport/tls.rs +++ b/rustalk-core/src/transport/tls.rs @@ -1,7 +1,7 @@ //! TLS/mTLS Transport implementation for secure SIP (SIPS) use super::{Transport, TransportConfig}; -use crate::sip::{Message, parser::parse_message}; +use crate::sip::{parser::parse_message, Message}; use anyhow::Result; use rustls::{ClientConfig, ServerConfig}; use rustls_pemfile::{certs, pkcs8_private_keys}; @@ -20,8 +20,14 @@ pub struct TlsTransport { impl TlsTransport { pub async fn new(config: &TransportConfig) -> Result { let server_config = Self::load_server_config( - config.cert_path.as_ref().ok_or_else(|| anyhow::anyhow!("Missing cert_path"))?, - config.key_path.as_ref().ok_or_else(|| anyhow::anyhow!("Missing key_path"))?, + config + .cert_path + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing cert_path"))?, + config + .key_path + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Missing key_path"))?, )?; info!("TLS transport configured for {}", config.bind_addr); @@ -36,16 +42,16 @@ impl TlsTransport { // Load certificate chain let cert_file = File::open(cert_path)?; let mut cert_reader = BufReader::new(cert_file); - let cert_chain: Vec<_> = certs(&mut cert_reader) - .collect::>()?; + let cert_chain: Vec<_> = certs(&mut cert_reader).collect::>()?; // Load private key let key_file = File::open(key_path)?; let mut key_reader = BufReader::new(key_file); - let keys: Vec<_> = pkcs8_private_keys(&mut key_reader) - .collect::>()?; - - let private_key = keys.into_iter().next() + let keys: Vec<_> = pkcs8_private_keys(&mut key_reader).collect::>()?; + + let private_key = keys + .into_iter() + .next() .ok_or_else(|| anyhow::anyhow!("No private key found"))?; let config = ServerConfig::builder() @@ -80,7 +86,7 @@ impl Transport for TlsTransport { // TLS receiving implementation // This is simplified - actual implementation would accept TLS connections debug!("TLS receive (simplified)"); - + // Placeholder for now Err(anyhow::anyhow!("TLS receive not fully implemented")) } diff --git a/rustalk-core/src/transport/udp.rs b/rustalk-core/src/transport/udp.rs index 6991a90..49273b2 100644 --- a/rustalk-core/src/transport/udp.rs +++ b/rustalk-core/src/transport/udp.rs @@ -1,7 +1,7 @@ //! UDP Transport implementation use super::{Transport, TransportConfig}; -use crate::sip::{Message, parser::parse_message}; +use crate::sip::{parser::parse_message, Message}; use anyhow::Result; use std::net::SocketAddr; use std::sync::Arc; @@ -18,9 +18,9 @@ impl UdpTransport { pub async fn new(config: &TransportConfig) -> Result { let socket = UdpSocket::bind(config.bind_addr).await?; let local_addr = socket.local_addr()?; - + debug!("UDP transport listening on {}", local_addr); - + Ok(Self { socket: Arc::new(Mutex::new(socket)), local_addr, @@ -38,25 +38,25 @@ impl Transport for UdpTransport { let socket = self.socket.lock().await; socket.send_to(&bytes, dest).await?; - + debug!("Sent {} bytes to {}", bytes.len(), dest); Ok(()) } async fn receive(&self) -> Result<(Message, SocketAddr)> { let mut buf = vec![0u8; 65535]; - + let socket = self.socket.lock().await; let (len, addr) = socket.recv_from(&mut buf).await?; drop(socket); - + buf.truncate(len); - + debug!("Received {} bytes from {}", len, addr); - + let message = parse_message(&buf) .map_err(|e| anyhow::anyhow!("Failed to parse SIP message: {}", e))?; - + Ok((message, addr)) } diff --git a/rustalk-core/src/voicemail/mod.rs b/rustalk-core/src/voicemail/mod.rs index 5890d33..d48c046 100644 --- a/rustalk-core/src/voicemail/mod.rs +++ b/rustalk-core/src/voicemail/mod.rs @@ -4,9 +4,9 @@ //! similar to FreeSWITCH voicemail functionality. use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use chrono::{DateTime, Utc}; /// Voicemail box configuration #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,12 +110,11 @@ impl VoicemailManager { if self.mailboxes.iter().any(|m| m.id == mailbox.id) { anyhow::bail!("Mailbox already exists: {}", mailbox.id); } - + // Create mailbox directory let mailbox_dir = self.mailbox_dir(&mailbox.id); - std::fs::create_dir_all(&mailbox_dir) - .context("Failed to create mailbox directory")?; - + std::fs::create_dir_all(&mailbox_dir).context("Failed to create mailbox directory")?; + self.mailboxes.push(mailbox); Ok(()) } @@ -128,20 +127,20 @@ impl VoicemailManager { /// Remove a mailbox pub fn remove_mailbox(&mut self, mailbox_id: &str) -> Result { let pos = self.mailboxes.iter().position(|m| m.id == mailbox_id); - + if let Some(pos) = pos { self.mailboxes.remove(pos); - + // Remove all messages for this mailbox self.messages.retain(|m| m.mailbox_id != mailbox_id); - + // Remove mailbox directory let mailbox_dir = self.mailbox_dir(mailbox_id); if mailbox_dir.exists() { std::fs::remove_dir_all(&mailbox_dir) .context("Failed to remove mailbox directory")?; } - + Ok(true) } else { Ok(false) @@ -157,34 +156,36 @@ impl VoicemailManager { audio_data: &[u8], duration: u32, ) -> Result { - let mailbox = self.get_mailbox(mailbox_id) + let mailbox = self + .get_mailbox(mailbox_id) .context(format!("Mailbox not found: {}", mailbox_id))?; - + if !mailbox.enabled { anyhow::bail!("Mailbox is disabled"); } - + // Check message limits - let message_count = self.messages.iter() + let message_count = self + .messages + .iter() .filter(|m| m.mailbox_id == mailbox_id) .count(); - + if message_count >= mailbox.max_messages { anyhow::bail!("Mailbox is full"); } - + if duration > mailbox.max_message_length { anyhow::bail!("Message too long"); } - + // Generate message ID let message_id = uuid::Uuid::new_v4().to_string(); - + // Save audio file let file_path = self.message_file_path(mailbox_id, &message_id); - std::fs::write(&file_path, audio_data) - .context("Failed to write audio file")?; - + std::fs::write(&file_path, audio_data).context("Failed to write audio file")?; + // Create message record let message = VoicemailMessage { id: message_id.clone(), @@ -197,59 +198,63 @@ impl VoicemailManager { read: false, urgent: false, }; - + self.messages.push(message); - + Ok(message_id) } /// Get messages for a mailbox pub fn get_messages(&self, mailbox_id: &str, include_read: bool) -> Vec<&VoicemailMessage> { - self.messages.iter() - .filter(|m| { - m.mailbox_id == mailbox_id && (include_read || !m.read) - }) + self.messages + .iter() + .filter(|m| m.mailbox_id == mailbox_id && (include_read || !m.read)) .collect() } /// Mark a message as read pub fn mark_message_read(&mut self, message_id: &str) -> Result<()> { - let message = self.messages.iter_mut() + let message = self + .messages + .iter_mut() .find(|m| m.id == message_id) .context("Message not found")?; - + message.read = true; Ok(()) } /// Delete a message pub fn delete_message(&mut self, message_id: &str) -> Result<()> { - let pos = self.messages.iter() + let pos = self + .messages + .iter() .position(|m| m.id == message_id) .context("Message not found")?; - + let message = &self.messages[pos]; - + // Delete audio file let file_path = Path::new(&message.file_path); if file_path.exists() { - std::fs::remove_file(file_path) - .context("Failed to delete audio file")?; + std::fs::remove_file(file_path).context("Failed to delete audio file")?; } - + self.messages.remove(pos); Ok(()) } /// Get MWI status for a mailbox pub fn get_mwi_status(&self, mailbox_id: &str) -> MwiStatus { - let messages: Vec<_> = self.messages.iter() + let messages: Vec<_> = self + .messages + .iter() .filter(|m| m.mailbox_id == mailbox_id) .collect(); - + let new_messages = messages.iter().filter(|m| !m.read).count(); let old_messages = messages.iter().filter(|m| m.read).count(); - + MwiStatus { mailbox_id: mailbox_id.to_string(), new_messages, @@ -274,7 +279,8 @@ impl VoicemailManager { /// Get message file path fn message_file_path(&self, mailbox_id: &str, message_id: &str) -> PathBuf { - self.mailbox_dir(mailbox_id).join(format!("{}.wav", message_id)) + self.mailbox_dir(mailbox_id) + .join(format!("{}.wav", message_id)) } /// List all mailboxes @@ -307,7 +313,7 @@ mod tests { #[test] fn test_create_mailbox() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -315,7 +321,7 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + assert!(manager.add_mailbox(mailbox.clone()).is_ok()); assert!(manager.get_mailbox("1001").is_some()); } @@ -323,7 +329,7 @@ mod tests { #[test] fn test_leave_message() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -331,14 +337,15 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + let audio_data = b"fake audio data"; - let result = manager.leave_message("1001", "5551234", Some("Bob".to_string()), audio_data, 10); - + let result = + manager.leave_message("1001", "5551234", Some("Bob".to_string()), audio_data, 10); + assert!(result.is_ok()); - + let messages = manager.get_messages("1001", false); assert_eq!(messages.len(), 1); assert_eq!(messages[0].from_number, "5551234"); @@ -347,7 +354,7 @@ mod tests { #[test] fn test_mwi_status() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -355,14 +362,18 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + // Leave two messages let audio_data = b"fake audio data"; - manager.leave_message("1001", "5551234", None, audio_data, 10).unwrap(); - manager.leave_message("1001", "5555678", None, audio_data, 15).unwrap(); - + manager + .leave_message("1001", "5551234", None, audio_data, 10) + .unwrap(); + manager + .leave_message("1001", "5555678", None, audio_data, 15) + .unwrap(); + let status = manager.get_mwi_status("1001"); assert_eq!(status.new_messages, 2); assert_eq!(status.old_messages, 0); @@ -372,7 +383,7 @@ mod tests { #[test] fn test_mark_message_read() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -380,14 +391,16 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + let audio_data = b"fake audio data"; - let message_id = manager.leave_message("1001", "5551234", None, audio_data, 10).unwrap(); - + let message_id = manager + .leave_message("1001", "5551234", None, audio_data, 10) + .unwrap(); + manager.mark_message_read(&message_id).unwrap(); - + let status = manager.get_mwi_status("1001"); assert_eq!(status.new_messages, 0); assert_eq!(status.old_messages, 1); @@ -396,7 +409,7 @@ mod tests { #[test] fn test_delete_message() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -404,14 +417,16 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + let audio_data = b"fake audio data"; - let message_id = manager.leave_message("1001", "5551234", None, audio_data, 10).unwrap(); - + let message_id = manager + .leave_message("1001", "5551234", None, audio_data, 10) + .unwrap(); + manager.delete_message(&message_id).unwrap(); - + let messages = manager.get_messages("1001", true); assert_eq!(messages.len(), 0); } @@ -419,7 +434,7 @@ mod tests { #[test] fn test_verify_pin() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -427,9 +442,9 @@ mod tests { pin: "1234".to_string(), ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + assert!(manager.verify_pin("1001", "1234")); assert!(!manager.verify_pin("1001", "0000")); } @@ -437,7 +452,7 @@ mod tests { #[test] fn test_mailbox_full() { let mut manager = VoicemailManager::new("/tmp/voicemail_test"); - + let mailbox = VoicemailBox { id: "1001".to_string(), extension: "1001".to_string(), @@ -446,13 +461,17 @@ mod tests { max_messages: 2, ..Default::default() }; - + manager.add_mailbox(mailbox).unwrap(); - + let audio_data = b"fake audio data"; - manager.leave_message("1001", "5551234", None, audio_data, 10).unwrap(); - manager.leave_message("1001", "5555678", None, audio_data, 10).unwrap(); - + manager + .leave_message("1001", "5551234", None, audio_data, 10) + .unwrap(); + manager + .leave_message("1001", "5555678", None, audio_data, 10) + .unwrap(); + // Third message should fail let result = manager.leave_message("1001", "5559999", None, audio_data, 10); assert!(result.is_err()); diff --git a/rustalk-edge/src/gateway/mod.rs b/rustalk-edge/src/gateway/mod.rs index 81d4f86..58df47f 100644 --- a/rustalk-edge/src/gateway/mod.rs +++ b/rustalk-edge/src/gateway/mod.rs @@ -1,11 +1,11 @@ //! Teams Gateway implementation use crate::teams::TeamsConfig; -use rustalk_core::prelude::{B2BUA, Config as CoreConfig}; use anyhow::Result; +use rustalk_core::prelude::{Config as CoreConfig, B2BUA}; use std::sync::Arc; use tokio::time::{interval, Duration}; -use tracing::{info, error}; +use tracing::{error, info}; /// Teams Gateway - SBC for Microsoft Teams Direct Routing pub struct TeamsGateway { @@ -46,12 +46,15 @@ impl TeamsGateway { /// OPTIONS ping loop for Teams health checks async fn options_ping_loop(config: TeamsConfig) { let mut ticker = interval(Duration::from_secs(config.options_ping_interval)); - - info!("Starting OPTIONS ping every {} seconds", config.options_ping_interval); + + info!( + "Starting OPTIONS ping every {} seconds", + config.options_ping_interval + ); loop { ticker.tick().await; - + for proxy in &config.sip_proxies { match Self::send_options_ping(proxy).await { Ok(_) => { diff --git a/rustalk-edge/src/health/mod.rs b/rustalk-edge/src/health/mod.rs index 5f02452..c58ad85 100644 --- a/rustalk-edge/src/health/mod.rs +++ b/rustalk-edge/src/health/mod.rs @@ -47,7 +47,7 @@ impl HealthChecker { pub async fn check(&self) -> Result { let uptime = self.start_time.elapsed().as_secs(); - + Ok(HealthStatus { healthy: true, teams_connection: true, diff --git a/rustalk-edge/src/lib.rs b/rustalk-edge/src/lib.rs index 03bbef5..b37690f 100644 --- a/rustalk-edge/src/lib.rs +++ b/rustalk-edge/src/lib.rs @@ -7,9 +7,9 @@ //! - Media handling and SRTP support //! - OPTIONS ping for health checks -pub mod teams; pub mod gateway; pub mod health; +pub mod teams; pub use gateway::TeamsGateway; pub use teams::TeamsConfig; @@ -21,6 +21,6 @@ pub async fn init() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - + Ok(()) }