opencode_cloud_core/config/
schema.rs

1//! Configuration schema for opencode-cloud
2//!
3//! Defines the structure and defaults for the config.json file.
4
5use serde::{Deserialize, Serialize};
6use std::net::{IpAddr, Ipv4Addr};
7
8/// Main configuration structure for opencode-cloud
9///
10/// Serialized to/from `~/.config/opencode-cloud/config.json`
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(deny_unknown_fields)]
13pub struct Config {
14    /// Config file version for migrations
15    pub version: u32,
16
17    /// Port for the opencode web UI (default: 3000)
18    #[serde(default = "default_opencode_web_port")]
19    pub opencode_web_port: u16,
20
21    /// Bind address (default: "localhost")
22    /// Use "localhost" for local-only access (secure default)
23    /// Use "0.0.0.0" for network access (requires explicit opt-in)
24    #[serde(default = "default_bind")]
25    pub bind: String,
26
27    /// Auto-restart service on crash (default: true)
28    #[serde(default = "default_auto_restart")]
29    pub auto_restart: bool,
30
31    /// Boot mode for service registration (default: "user")
32    /// "user" - Service starts on user login (no root required)
33    /// "system" - Service starts on boot (requires root)
34    #[serde(default = "default_boot_mode")]
35    pub boot_mode: String,
36
37    /// Number of restart attempts on crash (default: 3)
38    #[serde(default = "default_restart_retries")]
39    pub restart_retries: u32,
40
41    /// Seconds between restart attempts (default: 5)
42    #[serde(default = "default_restart_delay")]
43    pub restart_delay: u32,
44
45    /// Username for opencode basic auth (default: None, triggers wizard)
46    #[serde(default)]
47    pub auth_username: Option<String>,
48
49    /// Password for opencode basic auth (default: None, triggers wizard)
50    #[serde(default)]
51    pub auth_password: Option<String>,
52
53    /// Environment variables passed to container (default: empty)
54    /// Format: ["KEY=value", "KEY2=value2"]
55    #[serde(default)]
56    pub container_env: Vec<String>,
57
58    /// Bind address for opencode web UI (default: "127.0.0.1")
59    /// Use "0.0.0.0" or "::" for network exposure (requires explicit opt-in)
60    #[serde(default = "default_bind_address")]
61    pub bind_address: String,
62
63    /// Trust proxy headers (X-Forwarded-For, etc.) for load balancer deployments
64    #[serde(default)]
65    pub trust_proxy: bool,
66
67    /// Allow unauthenticated access when network exposed
68    /// Requires double confirmation on first start
69    #[serde(default)]
70    pub allow_unauthenticated_network: bool,
71
72    /// Maximum auth attempts before rate limiting
73    #[serde(default = "default_rate_limit_attempts")]
74    pub rate_limit_attempts: u32,
75
76    /// Rate limit window in seconds
77    #[serde(default = "default_rate_limit_window")]
78    pub rate_limit_window_seconds: u32,
79
80    /// List of usernames configured in container (for persistence tracking)
81    /// Passwords are NOT stored here - only in container's /etc/shadow
82    #[serde(default)]
83    pub users: Vec<String>,
84
85    /// Cockpit web console port (default: 9090)
86    /// Only used when cockpit_enabled is true
87    #[serde(default = "default_cockpit_port")]
88    pub cockpit_port: u16,
89
90    /// Enable Cockpit web console (default: false)
91    ///
92    /// When enabled:
93    /// - Container uses systemd as init (required for Cockpit)
94    /// - Requires Linux host with native Docker (does NOT work on macOS Docker Desktop)
95    /// - Cockpit web UI accessible at cockpit_port
96    ///
97    /// When disabled (default):
98    /// - Container uses tini as init (lightweight, works everywhere)
99    /// - Works on macOS, Linux, and Windows
100    /// - No Cockpit web UI
101    #[serde(default = "default_cockpit_enabled")]
102    pub cockpit_enabled: bool,
103}
104
105fn default_opencode_web_port() -> u16 {
106    3000
107}
108
109fn default_bind() -> String {
110    "localhost".to_string()
111}
112
113fn default_auto_restart() -> bool {
114    true
115}
116
117fn default_boot_mode() -> String {
118    "user".to_string()
119}
120
121fn default_restart_retries() -> u32 {
122    3
123}
124
125fn default_restart_delay() -> u32 {
126    5
127}
128
129fn default_bind_address() -> String {
130    "127.0.0.1".to_string()
131}
132
133fn default_rate_limit_attempts() -> u32 {
134    5
135}
136
137fn default_rate_limit_window() -> u32 {
138    60
139}
140
141fn default_cockpit_port() -> u16 {
142    9090
143}
144
145fn default_cockpit_enabled() -> bool {
146    false
147}
148
149/// Validate and parse a bind address string
150///
151/// Accepts:
152/// - IPv4 addresses: "127.0.0.1", "0.0.0.0"
153/// - IPv6 addresses: "::1", "::"
154/// - Bracketed IPv6: "[::1]"
155/// - "localhost" (resolves to 127.0.0.1)
156///
157/// Returns the parsed IpAddr or an error message.
158pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
159    let trimmed = addr.trim();
160
161    // Handle "localhost" as special case
162    if trimmed.eq_ignore_ascii_case("localhost") {
163        return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
164    }
165
166    // Strip brackets from IPv6 addresses like "[::1]"
167    let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
168        &trimmed[1..trimmed.len() - 1]
169    } else {
170        trimmed
171    };
172
173    stripped.parse::<IpAddr>().map_err(|_| {
174        format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
175    })
176}
177
178impl Default for Config {
179    fn default() -> Self {
180        Self {
181            version: 1,
182            opencode_web_port: default_opencode_web_port(),
183            bind: default_bind(),
184            auto_restart: default_auto_restart(),
185            boot_mode: default_boot_mode(),
186            restart_retries: default_restart_retries(),
187            restart_delay: default_restart_delay(),
188            auth_username: None,
189            auth_password: None,
190            container_env: Vec::new(),
191            bind_address: default_bind_address(),
192            trust_proxy: false,
193            allow_unauthenticated_network: false,
194            rate_limit_attempts: default_rate_limit_attempts(),
195            rate_limit_window_seconds: default_rate_limit_window(),
196            users: Vec::new(),
197            cockpit_port: default_cockpit_port(),
198            cockpit_enabled: default_cockpit_enabled(),
199        }
200    }
201}
202
203impl Config {
204    /// Create a new Config with default values
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    /// Check if required auth credentials are configured
210    ///
211    /// Returns true if:
212    /// - Both auth_username and auth_password are Some and non-empty (legacy), OR
213    /// - The users array is non-empty (PAM-based auth)
214    ///
215    /// This is used to determine if the setup wizard needs to run.
216    pub fn has_required_auth(&self) -> bool {
217        // New PAM-based auth: users array
218        if !self.users.is_empty() {
219            return true;
220        }
221
222        // Legacy basic auth: username/password
223        match (&self.auth_username, &self.auth_password) {
224            (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
225            _ => false,
226        }
227    }
228
229    /// Check if the bind address exposes the service to the network
230    ///
231    /// Returns true if bind_address is "0.0.0.0" (IPv4 all interfaces) or
232    /// "::" (IPv6 all interfaces).
233    pub fn is_network_exposed(&self) -> bool {
234        match validate_bind_address(&self.bind_address) {
235            Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
236            Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
237            Err(_) => false, // Invalid addresses are not considered exposed
238        }
239    }
240
241    /// Check if the bind address is localhost-only
242    ///
243    /// Returns true if bind_address is "127.0.0.1", "::1", or "localhost".
244    pub fn is_localhost(&self) -> bool {
245        match validate_bind_address(&self.bind_address) {
246            Ok(ip) => ip.is_loopback(),
247            Err(_) => {
248                // Also check for "localhost" string directly
249                self.bind_address.eq_ignore_ascii_case("localhost")
250            }
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_default_config() {
261        let config = Config::default();
262        assert_eq!(config.version, 1);
263        assert_eq!(config.opencode_web_port, 3000);
264        assert_eq!(config.bind, "localhost");
265        assert!(config.auto_restart);
266        assert_eq!(config.boot_mode, "user");
267        assert_eq!(config.restart_retries, 3);
268        assert_eq!(config.restart_delay, 5);
269        assert!(config.auth_username.is_none());
270        assert!(config.auth_password.is_none());
271        assert!(config.container_env.is_empty());
272        // Security fields
273        assert_eq!(config.bind_address, "127.0.0.1");
274        assert!(!config.trust_proxy);
275        assert!(!config.allow_unauthenticated_network);
276        assert_eq!(config.rate_limit_attempts, 5);
277        assert_eq!(config.rate_limit_window_seconds, 60);
278        assert!(config.users.is_empty());
279    }
280
281    #[test]
282    fn test_serialize_deserialize_roundtrip() {
283        let config = Config::default();
284        let json = serde_json::to_string(&config).unwrap();
285        let parsed: Config = serde_json::from_str(&json).unwrap();
286        assert_eq!(config, parsed);
287    }
288
289    #[test]
290    fn test_deserialize_with_missing_optional_fields() {
291        let json = r#"{"version": 1}"#;
292        let config: Config = serde_json::from_str(json).unwrap();
293        assert_eq!(config.version, 1);
294        assert_eq!(config.opencode_web_port, 3000);
295        assert_eq!(config.bind, "localhost");
296        assert!(config.auto_restart);
297        assert_eq!(config.boot_mode, "user");
298        assert_eq!(config.restart_retries, 3);
299        assert_eq!(config.restart_delay, 5);
300        assert!(config.auth_username.is_none());
301        assert!(config.auth_password.is_none());
302        assert!(config.container_env.is_empty());
303        // Security fields should have defaults
304        assert_eq!(config.bind_address, "127.0.0.1");
305        assert!(!config.trust_proxy);
306        assert!(!config.allow_unauthenticated_network);
307        assert_eq!(config.rate_limit_attempts, 5);
308        assert_eq!(config.rate_limit_window_seconds, 60);
309        assert!(config.users.is_empty());
310    }
311
312    #[test]
313    fn test_serialize_deserialize_roundtrip_with_service_fields() {
314        let config = Config {
315            version: 1,
316            opencode_web_port: 9000,
317            bind: "0.0.0.0".to_string(),
318            auto_restart: false,
319            boot_mode: "system".to_string(),
320            restart_retries: 5,
321            restart_delay: 10,
322            auth_username: None,
323            auth_password: None,
324            container_env: Vec::new(),
325            bind_address: "0.0.0.0".to_string(),
326            trust_proxy: true,
327            allow_unauthenticated_network: false,
328            rate_limit_attempts: 10,
329            rate_limit_window_seconds: 120,
330            users: vec!["admin".to_string()],
331            cockpit_port: 9090,
332            cockpit_enabled: true,
333        };
334        let json = serde_json::to_string(&config).unwrap();
335        let parsed: Config = serde_json::from_str(&json).unwrap();
336        assert_eq!(config, parsed);
337        assert_eq!(parsed.boot_mode, "system");
338        assert_eq!(parsed.restart_retries, 5);
339        assert_eq!(parsed.restart_delay, 10);
340        assert_eq!(parsed.bind_address, "0.0.0.0");
341        assert!(parsed.trust_proxy);
342        assert_eq!(parsed.rate_limit_attempts, 10);
343        assert_eq!(parsed.users, vec!["admin"]);
344    }
345
346    #[test]
347    fn test_reject_unknown_fields() {
348        let json = r#"{"version": 1, "unknown_field": "value"}"#;
349        let result: Result<Config, _> = serde_json::from_str(json);
350        assert!(result.is_err());
351    }
352
353    #[test]
354    fn test_serialize_deserialize_roundtrip_with_auth_fields() {
355        let config = Config {
356            auth_username: Some("admin".to_string()),
357            auth_password: Some("secret123".to_string()),
358            container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
359            ..Config::default()
360        };
361        let json = serde_json::to_string(&config).unwrap();
362        let parsed: Config = serde_json::from_str(&json).unwrap();
363        assert_eq!(config, parsed);
364        assert_eq!(parsed.auth_username, Some("admin".to_string()));
365        assert_eq!(parsed.auth_password, Some("secret123".to_string()));
366        assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
367    }
368
369    #[test]
370    fn test_has_required_auth_returns_false_when_both_none() {
371        let config = Config::default();
372        assert!(!config.has_required_auth());
373    }
374
375    #[test]
376    fn test_has_required_auth_returns_false_when_username_none() {
377        let config = Config {
378            auth_username: None,
379            auth_password: Some("secret".to_string()),
380            ..Config::default()
381        };
382        assert!(!config.has_required_auth());
383    }
384
385    #[test]
386    fn test_has_required_auth_returns_false_when_password_none() {
387        let config = Config {
388            auth_username: Some("admin".to_string()),
389            auth_password: None,
390            ..Config::default()
391        };
392        assert!(!config.has_required_auth());
393    }
394
395    #[test]
396    fn test_has_required_auth_returns_false_when_username_empty() {
397        let config = Config {
398            auth_username: Some(String::new()),
399            auth_password: Some("secret".to_string()),
400            ..Config::default()
401        };
402        assert!(!config.has_required_auth());
403    }
404
405    #[test]
406    fn test_has_required_auth_returns_false_when_password_empty() {
407        let config = Config {
408            auth_username: Some("admin".to_string()),
409            auth_password: Some(String::new()),
410            ..Config::default()
411        };
412        assert!(!config.has_required_auth());
413    }
414
415    #[test]
416    fn test_has_required_auth_returns_true_when_both_set() {
417        let config = Config {
418            auth_username: Some("admin".to_string()),
419            auth_password: Some("secret123".to_string()),
420            ..Config::default()
421        };
422        assert!(config.has_required_auth());
423    }
424
425    // Tests for validate_bind_address
426
427    #[test]
428    fn test_validate_bind_address_ipv4_localhost() {
429        let result = validate_bind_address("127.0.0.1");
430        assert!(result.is_ok());
431        let ip = result.unwrap();
432        assert!(ip.is_loopback());
433    }
434
435    #[test]
436    fn test_validate_bind_address_ipv4_all_interfaces() {
437        let result = validate_bind_address("0.0.0.0");
438        assert!(result.is_ok());
439        let ip = result.unwrap();
440        assert!(ip.is_unspecified());
441    }
442
443    #[test]
444    fn test_validate_bind_address_ipv6_localhost() {
445        let result = validate_bind_address("::1");
446        assert!(result.is_ok());
447        let ip = result.unwrap();
448        assert!(ip.is_loopback());
449    }
450
451    #[test]
452    fn test_validate_bind_address_ipv6_all_interfaces() {
453        let result = validate_bind_address("::");
454        assert!(result.is_ok());
455        let ip = result.unwrap();
456        assert!(ip.is_unspecified());
457    }
458
459    #[test]
460    fn test_validate_bind_address_localhost_string() {
461        let result = validate_bind_address("localhost");
462        assert!(result.is_ok());
463        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
464    }
465
466    #[test]
467    fn test_validate_bind_address_localhost_case_insensitive() {
468        let result = validate_bind_address("LOCALHOST");
469        assert!(result.is_ok());
470        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
471    }
472
473    #[test]
474    fn test_validate_bind_address_bracketed_ipv6() {
475        let result = validate_bind_address("[::1]");
476        assert!(result.is_ok());
477        assert!(result.unwrap().is_loopback());
478    }
479
480    #[test]
481    fn test_validate_bind_address_invalid() {
482        let result = validate_bind_address("not-an-ip");
483        assert!(result.is_err());
484        assert!(result.unwrap_err().contains("Invalid IP address"));
485    }
486
487    #[test]
488    fn test_validate_bind_address_whitespace() {
489        let result = validate_bind_address("  127.0.0.1  ");
490        assert!(result.is_ok());
491    }
492
493    // Tests for is_network_exposed
494
495    #[test]
496    fn test_is_network_exposed_ipv4_all() {
497        let config = Config {
498            bind_address: "0.0.0.0".to_string(),
499            ..Config::default()
500        };
501        assert!(config.is_network_exposed());
502    }
503
504    #[test]
505    fn test_is_network_exposed_ipv6_all() {
506        let config = Config {
507            bind_address: "::".to_string(),
508            ..Config::default()
509        };
510        assert!(config.is_network_exposed());
511    }
512
513    #[test]
514    fn test_is_network_exposed_localhost_false() {
515        let config = Config::default();
516        assert!(!config.is_network_exposed());
517    }
518
519    #[test]
520    fn test_is_network_exposed_ipv6_localhost_false() {
521        let config = Config {
522            bind_address: "::1".to_string(),
523            ..Config::default()
524        };
525        assert!(!config.is_network_exposed());
526    }
527
528    // Tests for is_localhost
529
530    #[test]
531    fn test_is_localhost_ipv4() {
532        let config = Config {
533            bind_address: "127.0.0.1".to_string(),
534            ..Config::default()
535        };
536        assert!(config.is_localhost());
537    }
538
539    #[test]
540    fn test_is_localhost_ipv6() {
541        let config = Config {
542            bind_address: "::1".to_string(),
543            ..Config::default()
544        };
545        assert!(config.is_localhost());
546    }
547
548    #[test]
549    fn test_is_localhost_string() {
550        let config = Config {
551            bind_address: "localhost".to_string(),
552            ..Config::default()
553        };
554        assert!(config.is_localhost());
555    }
556
557    #[test]
558    fn test_is_localhost_all_interfaces_false() {
559        let config = Config {
560            bind_address: "0.0.0.0".to_string(),
561            ..Config::default()
562        };
563        assert!(!config.is_localhost());
564    }
565
566    // Tests for security fields serialization
567
568    #[test]
569    fn test_serialize_deserialize_with_security_fields() {
570        let config = Config {
571            bind_address: "0.0.0.0".to_string(),
572            trust_proxy: true,
573            allow_unauthenticated_network: true,
574            rate_limit_attempts: 10,
575            rate_limit_window_seconds: 120,
576            users: vec!["admin".to_string(), "developer".to_string()],
577            ..Config::default()
578        };
579        let json = serde_json::to_string(&config).unwrap();
580        let parsed: Config = serde_json::from_str(&json).unwrap();
581        assert_eq!(config, parsed);
582        assert_eq!(parsed.bind_address, "0.0.0.0");
583        assert!(parsed.trust_proxy);
584        assert!(parsed.allow_unauthenticated_network);
585        assert_eq!(parsed.rate_limit_attempts, 10);
586        assert_eq!(parsed.rate_limit_window_seconds, 120);
587        assert_eq!(parsed.users, vec!["admin", "developer"]);
588    }
589
590    // Tests for Cockpit fields
591
592    #[test]
593    fn test_default_config_cockpit_fields() {
594        let config = Config::default();
595        assert_eq!(config.cockpit_port, 9090);
596        // cockpit_enabled defaults to false (requires Linux host)
597        assert!(!config.cockpit_enabled);
598    }
599
600    #[test]
601    fn test_serialize_deserialize_with_cockpit_fields() {
602        let config = Config {
603            cockpit_port: 9091,
604            cockpit_enabled: false,
605            ..Config::default()
606        };
607        let json = serde_json::to_string(&config).unwrap();
608        let parsed: Config = serde_json::from_str(&json).unwrap();
609        assert_eq!(parsed.cockpit_port, 9091);
610        assert!(!parsed.cockpit_enabled);
611    }
612
613    #[test]
614    fn test_cockpit_fields_default_on_missing() {
615        // Old configs without cockpit fields should get defaults
616        let json = r#"{"version": 1}"#;
617        let config: Config = serde_json::from_str(json).unwrap();
618        assert_eq!(config.cockpit_port, 9090);
619        // cockpit_enabled defaults to false (requires Linux host)
620        assert!(!config.cockpit_enabled);
621    }
622}