//! Generate Samba (SMB) configuration. use std::fmt::Write as _; use std::fs; use std::path::Path; use std::process::{Command, Stdio}; use anyhow::{Context, Result}; use crate::config::Config; /// Default output path for generated smb.conf. pub const SMB_CONF_PATH: &str = "/etc/samba/smb.conf"; /// Generate smb.conf content that shares rclone FUSE mount directories. /// /// Each share points directly at its own mount_point (independent rclone mount). /// When `smb_auth` is enabled, uses `security = user` with a dedicated /// valid user. Otherwise falls back to guest access (`map to guest = Bad User`). pub fn generate(config: &Config) -> Result { let mut conf = String::new(); // [global] section writeln!(conf, "# Generated by Warpgate — do not edit manually.")?; writeln!(conf, "[global]")?; writeln!(conf, " workgroup = WORKGROUP")?; writeln!(conf, " server string = Warpgate NAS Cache")?; writeln!(conf, " server role = standalone server")?; writeln!(conf)?; writeln!(conf, " # Require SMB2+ (disable insecure SMB1)")?; writeln!(conf, " server min protocol = SMB2_02")?; writeln!(conf)?; if config.smb_auth.enabled { let username = config.smb_username().unwrap_or("warpgate"); writeln!(conf, " # User authentication")?; writeln!(conf, " security = user")?; writeln!(conf, " map to guest = Never")?; writeln!(conf, " valid users = {username}")?; } else { writeln!(conf, " # Guest / map-to-guest for simple setups")?; writeln!(conf, " map to guest = Bad User")?; } writeln!(conf)?; writeln!(conf, " # Logging")?; writeln!(conf, " log file = /var/log/samba/log.%m")?; writeln!(conf, " max log size = 1000")?; writeln!(conf)?; writeln!(conf, " # Disable printer sharing")?; writeln!(conf, " load printers = no")?; writeln!(conf, " printing = bsd")?; writeln!(conf, " printcap name = /dev/null")?; writeln!(conf, " disable spoolss = yes")?; writeln!(conf)?; writeln!(conf, " # Performance tuning")?; writeln!(conf, " socket options = TCP_NODELAY IPTOS_THROUGHPUT SO_RCVBUF=131072 SO_SNDBUF=131072")?; writeln!(conf, " read raw = yes")?; writeln!(conf, " write raw = yes")?; writeln!(conf, " large readwrite = yes")?; writeln!(conf, " max xmit = 65535")?; writeln!(conf)?; // Share sections — each share points at its own mount_point for share in &config.shares { writeln!(conf, "[{}]", share.name)?; writeln!(conf, " comment = Warpgate cached NAS share")?; writeln!(conf, " path = {}", share.mount_point.display())?; writeln!(conf, " browseable = yes")?; writeln!( conf, " read only = {}", if share.read_only { "yes" } else { "no" } )?; if config.smb_auth.enabled { writeln!(conf, " guest ok = no")?; } else { writeln!(conf, " guest ok = yes")?; } writeln!(conf, " force user = root")?; writeln!(conf, " create mask = 0644")?; writeln!(conf, " directory mask = 0755")?; writeln!(conf)?; } Ok(conf) } /// Write smb.conf to disk. pub fn write_config(config: &Config) -> Result<()> { let content = generate(config)?; // Ensure parent directory exists if let Some(parent) = Path::new(SMB_CONF_PATH).parent() { fs::create_dir_all(parent) .with_context(|| format!("Failed to create directory: {}", parent.display()))?; } fs::write(SMB_CONF_PATH, content) .with_context(|| format!("Failed to write {SMB_CONF_PATH}"))?; Ok(()) } /// Create the system user and set the Samba password. /// /// 1. Check if the user exists (`id `) /// 2. Create if missing (`useradd --system --no-create-home --shell /usr/sbin/nologin`) /// 3. Set Samba password (`smbpasswd -a -s` via stdin) pub fn setup_user(config: &Config) -> Result<()> { if !config.smb_auth.enabled { return Ok(()); } let username = config.smb_username() .context("SMB auth enabled but username not set")?; let password = config .smb_password()? .context("SMB auth enabled but no password resolved")?; // Check if system user exists let exists = Command::new("id") .arg(username) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .context("Failed to run 'id' command")? .success(); if !exists { println!(" Creating system user '{username}'..."); let status = Command::new("useradd") .args([ "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", username, ]) .status() .context("Failed to run useradd")?; if !status.success() { anyhow::bail!("useradd failed for user '{username}': {status}"); } } // Set Samba password via stdin println!(" Setting Samba password for '{username}'..."); let mut child = Command::new("smbpasswd") .args(["-a", "-s", username]) .stdin(Stdio::piped()) .spawn() .context("Failed to spawn smbpasswd")?; if let Some(ref mut stdin) = child.stdin { use std::io::Write; // smbpasswd -s expects password twice on stdin write!(stdin, "{password}\n{password}\n")?; } let status = child.wait().context("Failed to wait for smbpasswd")?; if !status.success() { anyhow::bail!("smbpasswd failed for user '{username}': {status}"); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn test_config() -> Config { toml::from_str( r#" [[connections]] name = "nas" nas_host = "10.0.0.1" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [[shares]] name = "photos" connection = "nas" remote_path = "/photos" mount_point = "/mnt/photos" "#, ) .unwrap() } fn test_config_with_shares() -> Config { toml::from_str( r#" [[connections]] name = "nas" nas_host = "10.0.0.1" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [[shares]] name = "photos" connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" [[shares]] name = "projects" connection = "nas" remote_path = "/volume1/projects" mount_point = "/mnt/projects" [[shares]] name = "backups" connection = "nas" remote_path = "/volume1/backups" mount_point = "/mnt/backups" read_only = true "#, ) .unwrap() } fn test_config_with_auth() -> Config { toml::from_str( r#" [[connections]] name = "nas" nas_host = "10.0.0.1" nas_user = "admin" [cache] dir = "/tmp/cache" [read] [bandwidth] [writeback] [directory_cache] [protocols] [smb_auth] enabled = true username = "photographer" smb_pass = "my-password" [[shares]] name = "photos" connection = "nas" remote_path = "/volume1/photos" mount_point = "/mnt/photos" "#, ) .unwrap() } #[test] fn test_generate_smb_conf_global_section() { let config = test_config(); let content = generate(&config).unwrap(); assert!(content.contains("[global]")); assert!(content.contains("server role = standalone server")); assert!(content.contains("server min protocol = SMB2_02")); assert!(content.contains("map to guest = Bad User")); assert!(content.contains("load printers = no")); assert!(content.contains("socket options = TCP_NODELAY")); assert!(content.contains("large readwrite = yes")); assert!(content.contains("max xmit = 65535")); } #[test] fn test_generate_smb_conf_share_section() { let config = test_config(); let content = generate(&config).unwrap(); assert!(content.contains("[photos]")); assert!(content.contains("path = /mnt/photos")); assert!(content.contains("browseable = yes")); assert!(content.contains("read only = no")); assert!(content.contains("guest ok = yes")); assert!(content.contains("force user = root")); } #[test] fn test_smb_conf_path_constant() { assert_eq!(SMB_CONF_PATH, "/etc/samba/smb.conf"); } #[test] fn test_generate_multi_share() { let config = test_config_with_shares(); let content = generate(&config).unwrap(); assert!(content.contains("[photos]")); assert!(content.contains("path = /mnt/photos")); assert!(content.contains("[projects]")); assert!(content.contains("path = /mnt/projects")); assert!(content.contains("[backups]")); assert!(content.contains("path = /mnt/backups")); } #[test] fn test_generate_read_only_share() { let config = test_config_with_shares(); let content = generate(&config).unwrap(); // backups is read_only let backups_section = content.split("[backups]").nth(1).unwrap(); assert!(backups_section.contains("read only = yes")); // photos is read-write let photos_section = content .split("[photos]") .nth(1) .unwrap() .split('[') .next() .unwrap(); assert!(photos_section.contains("read only = no")); } #[test] fn test_generate_auth_mode() { let config = test_config_with_auth(); let content = generate(&config).unwrap(); // Global auth settings assert!(content.contains("security = user")); assert!(content.contains("map to guest = Never")); assert!(content.contains("valid users = photographer")); // Share-level auth assert!(content.contains("guest ok = no")); assert!(!content.contains("guest ok = yes")); } #[test] fn test_generate_guest_mode() { let config = test_config(); let content = generate(&config).unwrap(); assert!(content.contains("map to guest = Bad User")); assert!(content.contains("guest ok = yes")); assert!(!content.contains("security = user")); } }