warpgate/src/services/samba.rs
grabbit 5efef83a90 Add multi_thread_streams/cutoff support and Samba performance tuning
- Add multi_thread_streams (default 4) and multi_thread_cutoff (default "50M")
  fields to ReadConfig, wired into rclone mount args
- Expose both fields in Web UI config editor under Read Tuning section
- Add Samba performance options: TCP_NODELAY, large readwrite, max xmit
- Update config.toml.default with new fields and sftp_connections guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:15:23 +08:00

365 lines
10 KiB
Rust

//! 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<String> {
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 <username>`)
/// 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"));
}
}