- 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>
365 lines
10 KiB
Rust
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"));
|
|
}
|
|
}
|