Skip to main content

pwr_server/
config.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5/// Server-side configuration, typically stored at `/etc/pwr/server.toml`
6/// or `~/.config/pwr/server.toml`.
7///
8/// Controls the TLS listener, project storage backend, authentication,
9/// and operational limits for the pwr-server daemon.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ServerConfig {
12    /// Schema version for forward compatibility.
13    pub version: u32,
14
15    /// IP address to bind the TCP listener to (e.g., "0.0.0.0" for all
16    /// interfaces, or "127.0.0.1" for local-only access).
17    #[serde(default = "default_listen_address")]
18    pub listen_address: String,
19
20    /// TCP port to listen on. Defaults to 9742, an unprivileged port
21    /// not assigned by IANA.
22    #[serde(default = "default_port")]
23    pub listen_port: u16,
24
25    /// Filesystem path where project archives are stored.
26    /// The server creates per-project subdirectories under this root.
27    #[serde(default = "default_storage_path")]
28    pub storage_base_path: PathBuf,
29
30    /// Maximum size in gigabytes allowed for a single project archive.
31    /// Archives exceeding this limit are rejected before transfer begins.
32    #[serde(default = "default_max_size_gb")]
33    pub max_project_size_gb: u64,
34
35    /// Path to the PEM-encoded TLS certificate file.
36    /// Generated during `pwr-server init` if not provided.
37    pub tls_cert_path: PathBuf,
38
39    /// Path to the PEM-encoded TLS private key file.
40    /// Must be readable only by the server process (mode 0o600).
41    pub tls_key_path: PathBuf,
42
43    /// Hex-encoded 256-bit pre-shared key for client authentication.
44    /// Must match the value in the client's config.toml.
45    pub auth_token: String,
46
47    /// Maximum number of concurrent client connections.
48    /// Additional connections are accepted but immediately closed
49    /// with a rate-limit error.
50    #[serde(default = "default_max_connections")]
51    pub max_connections: usize,
52
53    /// Idle timeout in seconds for authenticated connections.
54    /// Connections with no activity for this duration are closed.
55    #[serde(default = "default_idle_timeout")]
56    pub idle_timeout_secs: u64,
57}
58
59fn default_listen_address() -> String {
60    "0.0.0.0".into()
61}
62
63fn default_port() -> u16 {
64    9742
65}
66
67fn default_storage_path() -> PathBuf {
68    PathBuf::from("/srv/pwr/projects")
69}
70
71fn default_max_size_gb() -> u64 {
72    500
73}
74
75fn default_max_connections() -> usize {
76    32
77}
78
79fn default_idle_timeout() -> u64 {
80    300
81}
82
83impl Default for ServerConfig {
84    fn default() -> Self {
85        Self {
86            version: 1,
87            listen_address: default_listen_address(),
88            listen_port: default_port(),
89            storage_base_path: default_storage_path(),
90            max_project_size_gb: default_max_size_gb(),
91            tls_cert_path: PathBuf::from("/etc/pwr/server.crt"),
92            tls_key_path: PathBuf::from("/etc/pwr/server.key"),
93            auth_token: String::new(),
94            max_connections: default_max_connections(),
95            idle_timeout_secs: default_idle_timeout(),
96        }
97    }
98}
99
100impl ServerConfig {
101    /// Validate the configuration for common mistakes.
102    ///
103    /// Checks that required paths exist (or can be created), the port
104    /// is in the valid range, the auth token is non-empty, and size
105    /// limits are reasonable.
106    pub fn validate(&self) -> Result<(), String> {
107        if self.listen_port == 0 {
108            return Err("listen_port must be non-zero".into());
109        }
110        if self.auth_token.is_empty() {
111            return Err("auth_token must not be empty".into());
112        }
113        if self.max_project_size_gb == 0 {
114            return Err("max_project_size_gb must be positive".into());
115        }
116        if self.max_connections == 0 {
117            return Err("max_connections must be positive".into());
118        }
119        Ok(())
120    }
121
122    /// Return the bind address as "address:port".
123    pub fn bind_addr(&self) -> String {
124        format!("{}:{}", self.listen_address, self.listen_port)
125    }
126
127    /// Return the path where a specific project's data is stored.
128    pub fn project_dir(&self, uuid: &uuid::Uuid) -> PathBuf {
129        self.storage_base_path.join(uuid.to_string())
130    }
131
132    /// Return the path to the project registry index file.
133    pub fn registry_path(&self) -> PathBuf {
134        self.storage_base_path.join("registry.json")
135    }
136}
137
138/// Search for the server config file in standard locations.
139///
140/// Checks, in order:
141/// 1. The path specified by the `--config` CLI argument (if provided)
142/// 2. `./server.toml` (current working directory, for development)
143/// 3. `~/.config/pwr/server.toml` (user-local install)
144/// 4. `/etc/pwr/server.toml` (system-wide install)
145pub fn find_config(explicit_path: Option<&Path>) -> Option<PathBuf> {
146    if let Some(p) = explicit_path {
147        if p.exists() {
148            return Some(p.to_path_buf());
149        }
150    }
151
152    let candidates = [
153        PathBuf::from("server.toml"),
154        dirs::config_dir()
155            .unwrap_or_else(|| PathBuf::from("~/.config"))
156            .join("pwr")
157            .join("server.toml"),
158        PathBuf::from("/etc/pwr/server.toml"),
159    ];
160
161    for candidate in &candidates {
162        if candidate.exists() {
163            return Some(candidate.clone());
164        }
165    }
166
167    None
168}
169
170/// Load the server configuration from a specific path.
171pub fn load_config(path: &Path) -> Result<ServerConfig, String> {
172    let contents = fs::read_to_string(path)
173        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
174
175    let config: ServerConfig = toml::from_str(&contents)
176        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
177
178    config.validate()?;
179
180    Ok(config)
181}
182
183/// Save a server configuration to disk, creating parent directories
184/// as needed.
185pub fn save_config(config: &ServerConfig, path: &Path) -> Result<(), String> {
186    if let Some(parent) = path.parent() {
187        fs::create_dir_all(parent)
188            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
189    }
190
191    let contents = toml::to_string_pretty(config)
192        .map_err(|e| format!("Failed to serialize config: {}", e))?;
193
194    fs::write(path, &contents)
195        .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
196
197    Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_default_config_passes_validation() {
206        let mut cfg = ServerConfig::default();
207        cfg.auth_token = "test-token-0123456789abcdef".into();
208        assert!(cfg.validate().is_ok());
209    }
210
211    #[test]
212    fn test_empty_auth_token_fails_validation() {
213        let cfg = ServerConfig::default();
214        assert!(cfg.validate().is_err());
215    }
216
217    #[test]
218    fn test_zero_port_fails_validation() {
219        let mut cfg = ServerConfig::default();
220        cfg.listen_port = 0;
221        cfg.auth_token = "test".into();
222        assert!(cfg.validate().is_err());
223    }
224
225    #[test]
226    fn test_bind_addr_format() {
227        let mut cfg = ServerConfig::default();
228        cfg.listen_address = "192.168.1.100".into();
229        cfg.listen_port = 8443;
230        assert_eq!(cfg.bind_addr(), "192.168.1.100:8443");
231    }
232
233    #[test]
234    fn test_project_dir() {
235        let cfg = ServerConfig::default();
236        let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
237        let dir = cfg.project_dir(&uuid);
238        assert!(dir.to_string_lossy().contains("550e8400"));
239    }
240
241    #[test]
242    fn test_registry_path() {
243        let cfg = ServerConfig::default();
244        assert!(cfg
245            .registry_path()
246            .to_string_lossy()
247            .ends_with("registry.json"));
248    }
249
250    #[test]
251    fn test_save_and_load_round_trip() {
252        let tmp = tempfile::TempDir::new().unwrap();
253        let path = tmp.path().join("server.toml");
254
255        let mut cfg = ServerConfig::default();
256        cfg.auth_token = "roundtrip-test-token".into();
257        cfg.listen_port = 12345;
258
259        save_config(&cfg, &path).unwrap();
260        let loaded = load_config(&path).unwrap();
261
262        assert_eq!(loaded.listen_port, 12345);
263        assert_eq!(loaded.auth_token, "roundtrip-test-token");
264        assert_eq!(loaded.version, 1);
265    }
266}