Skip to main content

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    /// Source of Docker image: 'prebuilt' (pull from registry) or 'build' (compile locally)
105    #[serde(default = "default_image_source")]
106    pub image_source: String,
107
108    /// When to check for updates: 'always' (every start), 'once' (once per version), 'never'
109    #[serde(default = "default_update_check")]
110    pub update_check: String,
111
112    /// Bind mounts to apply when starting the container
113    /// Format: ["/host/path:/container/path", "/host:/mnt:ro"]
114    #[serde(default)]
115    pub mounts: Vec<String>,
116}
117
118fn default_opencode_web_port() -> u16 {
119    3000
120}
121
122fn default_bind() -> String {
123    "localhost".to_string()
124}
125
126fn default_auto_restart() -> bool {
127    true
128}
129
130fn default_boot_mode() -> String {
131    "user".to_string()
132}
133
134fn default_restart_retries() -> u32 {
135    3
136}
137
138fn default_restart_delay() -> u32 {
139    5
140}
141
142fn default_bind_address() -> String {
143    "127.0.0.1".to_string()
144}
145
146fn default_rate_limit_attempts() -> u32 {
147    5
148}
149
150fn default_rate_limit_window() -> u32 {
151    60
152}
153
154fn default_cockpit_port() -> u16 {
155    9090
156}
157
158fn default_cockpit_enabled() -> bool {
159    false
160}
161
162fn default_image_source() -> String {
163    "prebuilt".to_string()
164}
165
166fn default_update_check() -> String {
167    "always".to_string()
168}
169
170/// Validate and parse a bind address string
171///
172/// Accepts:
173/// - IPv4 addresses: "127.0.0.1", "0.0.0.0"
174/// - IPv6 addresses: "::1", "::"
175/// - Bracketed IPv6: "[::1]"
176/// - "localhost" (resolves to 127.0.0.1)
177///
178/// Returns the parsed IpAddr or an error message.
179pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
180    let trimmed = addr.trim();
181
182    // Handle "localhost" as special case
183    if trimmed.eq_ignore_ascii_case("localhost") {
184        return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
185    }
186
187    // Strip brackets from IPv6 addresses like "[::1]"
188    let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
189        &trimmed[1..trimmed.len() - 1]
190    } else {
191        trimmed
192    };
193
194    stripped.parse::<IpAddr>().map_err(|_| {
195        format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
196    })
197}
198
199impl Default for Config {
200    fn default() -> Self {
201        Self {
202            version: 1,
203            opencode_web_port: default_opencode_web_port(),
204            bind: default_bind(),
205            auto_restart: default_auto_restart(),
206            boot_mode: default_boot_mode(),
207            restart_retries: default_restart_retries(),
208            restart_delay: default_restart_delay(),
209            auth_username: None,
210            auth_password: None,
211            container_env: Vec::new(),
212            bind_address: default_bind_address(),
213            trust_proxy: false,
214            allow_unauthenticated_network: false,
215            rate_limit_attempts: default_rate_limit_attempts(),
216            rate_limit_window_seconds: default_rate_limit_window(),
217            users: Vec::new(),
218            cockpit_port: default_cockpit_port(),
219            cockpit_enabled: default_cockpit_enabled(),
220            image_source: default_image_source(),
221            update_check: default_update_check(),
222            mounts: Vec::new(),
223        }
224    }
225}
226
227impl Config {
228    /// Create a new Config with default values
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Check if required auth credentials are configured
234    ///
235    /// Returns true if:
236    /// - Both auth_username and auth_password are Some and non-empty (legacy), OR
237    /// - The users array is non-empty (PAM-based auth)
238    ///
239    /// This is used to determine if the setup wizard needs to run.
240    pub fn has_required_auth(&self) -> bool {
241        // New PAM-based auth: users array
242        if !self.users.is_empty() {
243            return true;
244        }
245
246        // Legacy basic auth: username/password
247        match (&self.auth_username, &self.auth_password) {
248            (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
249            _ => false,
250        }
251    }
252
253    /// Check if the bind address exposes the service to the network
254    ///
255    /// Returns true if bind_address is "0.0.0.0" (IPv4 all interfaces) or
256    /// "::" (IPv6 all interfaces).
257    pub fn is_network_exposed(&self) -> bool {
258        match validate_bind_address(&self.bind_address) {
259            Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
260            Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
261            Err(_) => false, // Invalid addresses are not considered exposed
262        }
263    }
264
265    /// Check if the bind address is localhost-only
266    ///
267    /// Returns true if bind_address is "127.0.0.1", "::1", or "localhost".
268    pub fn is_localhost(&self) -> bool {
269        match validate_bind_address(&self.bind_address) {
270            Ok(ip) => ip.is_loopback(),
271            Err(_) => {
272                // Also check for "localhost" string directly
273                self.bind_address.eq_ignore_ascii_case("localhost")
274            }
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_default_config() {
285        let config = Config::default();
286        assert_eq!(config.version, 1);
287        assert_eq!(config.opencode_web_port, 3000);
288        assert_eq!(config.bind, "localhost");
289        assert!(config.auto_restart);
290        assert_eq!(config.boot_mode, "user");
291        assert_eq!(config.restart_retries, 3);
292        assert_eq!(config.restart_delay, 5);
293        assert!(config.auth_username.is_none());
294        assert!(config.auth_password.is_none());
295        assert!(config.container_env.is_empty());
296        // Security fields
297        assert_eq!(config.bind_address, "127.0.0.1");
298        assert!(!config.trust_proxy);
299        assert!(!config.allow_unauthenticated_network);
300        assert_eq!(config.rate_limit_attempts, 5);
301        assert_eq!(config.rate_limit_window_seconds, 60);
302        assert!(config.users.is_empty());
303        assert!(config.mounts.is_empty());
304    }
305
306    #[test]
307    fn test_serialize_deserialize_roundtrip() {
308        let config = Config::default();
309        let json = serde_json::to_string(&config).unwrap();
310        let parsed: Config = serde_json::from_str(&json).unwrap();
311        assert_eq!(config, parsed);
312    }
313
314    #[test]
315    fn test_deserialize_with_missing_optional_fields() {
316        let json = r#"{"version": 1}"#;
317        let config: Config = serde_json::from_str(json).unwrap();
318        assert_eq!(config.version, 1);
319        assert_eq!(config.opencode_web_port, 3000);
320        assert_eq!(config.bind, "localhost");
321        assert!(config.auto_restart);
322        assert_eq!(config.boot_mode, "user");
323        assert_eq!(config.restart_retries, 3);
324        assert_eq!(config.restart_delay, 5);
325        assert!(config.auth_username.is_none());
326        assert!(config.auth_password.is_none());
327        assert!(config.container_env.is_empty());
328        // Security fields should have defaults
329        assert_eq!(config.bind_address, "127.0.0.1");
330        assert!(!config.trust_proxy);
331        assert!(!config.allow_unauthenticated_network);
332        assert_eq!(config.rate_limit_attempts, 5);
333        assert_eq!(config.rate_limit_window_seconds, 60);
334        assert!(config.users.is_empty());
335    }
336
337    #[test]
338    fn test_serialize_deserialize_roundtrip_with_service_fields() {
339        let config = Config {
340            version: 1,
341            opencode_web_port: 9000,
342            bind: "0.0.0.0".to_string(),
343            auto_restart: false,
344            boot_mode: "system".to_string(),
345            restart_retries: 5,
346            restart_delay: 10,
347            auth_username: None,
348            auth_password: None,
349            container_env: Vec::new(),
350            bind_address: "0.0.0.0".to_string(),
351            trust_proxy: true,
352            allow_unauthenticated_network: false,
353            rate_limit_attempts: 10,
354            rate_limit_window_seconds: 120,
355            users: vec!["admin".to_string()],
356            cockpit_port: 9090,
357            cockpit_enabled: true,
358            image_source: default_image_source(),
359            update_check: default_update_check(),
360            mounts: Vec::new(),
361        };
362        let json = serde_json::to_string(&config).unwrap();
363        let parsed: Config = serde_json::from_str(&json).unwrap();
364        assert_eq!(config, parsed);
365        assert_eq!(parsed.boot_mode, "system");
366        assert_eq!(parsed.restart_retries, 5);
367        assert_eq!(parsed.restart_delay, 10);
368        assert_eq!(parsed.bind_address, "0.0.0.0");
369        assert!(parsed.trust_proxy);
370        assert_eq!(parsed.rate_limit_attempts, 10);
371        assert_eq!(parsed.users, vec!["admin"]);
372    }
373
374    #[test]
375    fn test_reject_unknown_fields() {
376        let json = r#"{"version": 1, "unknown_field": "value"}"#;
377        let result: Result<Config, _> = serde_json::from_str(json);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn test_serialize_deserialize_roundtrip_with_auth_fields() {
383        let config = Config {
384            auth_username: Some("admin".to_string()),
385            auth_password: Some("secret123".to_string()),
386            container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
387            ..Config::default()
388        };
389        let json = serde_json::to_string(&config).unwrap();
390        let parsed: Config = serde_json::from_str(&json).unwrap();
391        assert_eq!(config, parsed);
392        assert_eq!(parsed.auth_username, Some("admin".to_string()));
393        assert_eq!(parsed.auth_password, Some("secret123".to_string()));
394        assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
395    }
396
397    #[test]
398    fn test_has_required_auth_returns_false_when_both_none() {
399        let config = Config::default();
400        assert!(!config.has_required_auth());
401    }
402
403    #[test]
404    fn test_has_required_auth_returns_false_when_username_none() {
405        let config = Config {
406            auth_username: None,
407            auth_password: Some("secret".to_string()),
408            ..Config::default()
409        };
410        assert!(!config.has_required_auth());
411    }
412
413    #[test]
414    fn test_has_required_auth_returns_false_when_password_none() {
415        let config = Config {
416            auth_username: Some("admin".to_string()),
417            auth_password: None,
418            ..Config::default()
419        };
420        assert!(!config.has_required_auth());
421    }
422
423    #[test]
424    fn test_has_required_auth_returns_false_when_username_empty() {
425        let config = Config {
426            auth_username: Some(String::new()),
427            auth_password: Some("secret".to_string()),
428            ..Config::default()
429        };
430        assert!(!config.has_required_auth());
431    }
432
433    #[test]
434    fn test_has_required_auth_returns_false_when_password_empty() {
435        let config = Config {
436            auth_username: Some("admin".to_string()),
437            auth_password: Some(String::new()),
438            ..Config::default()
439        };
440        assert!(!config.has_required_auth());
441    }
442
443    #[test]
444    fn test_has_required_auth_returns_true_when_both_set() {
445        let config = Config {
446            auth_username: Some("admin".to_string()),
447            auth_password: Some("secret123".to_string()),
448            ..Config::default()
449        };
450        assert!(config.has_required_auth());
451    }
452
453    // Tests for validate_bind_address
454
455    #[test]
456    fn test_validate_bind_address_ipv4_localhost() {
457        let result = validate_bind_address("127.0.0.1");
458        assert!(result.is_ok());
459        let ip = result.unwrap();
460        assert!(ip.is_loopback());
461    }
462
463    #[test]
464    fn test_validate_bind_address_ipv4_all_interfaces() {
465        let result = validate_bind_address("0.0.0.0");
466        assert!(result.is_ok());
467        let ip = result.unwrap();
468        assert!(ip.is_unspecified());
469    }
470
471    #[test]
472    fn test_validate_bind_address_ipv6_localhost() {
473        let result = validate_bind_address("::1");
474        assert!(result.is_ok());
475        let ip = result.unwrap();
476        assert!(ip.is_loopback());
477    }
478
479    #[test]
480    fn test_validate_bind_address_ipv6_all_interfaces() {
481        let result = validate_bind_address("::");
482        assert!(result.is_ok());
483        let ip = result.unwrap();
484        assert!(ip.is_unspecified());
485    }
486
487    #[test]
488    fn test_validate_bind_address_localhost_string() {
489        let result = validate_bind_address("localhost");
490        assert!(result.is_ok());
491        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
492    }
493
494    #[test]
495    fn test_validate_bind_address_localhost_case_insensitive() {
496        let result = validate_bind_address("LOCALHOST");
497        assert!(result.is_ok());
498        assert_eq!(result.unwrap().to_string(), "127.0.0.1");
499    }
500
501    #[test]
502    fn test_validate_bind_address_bracketed_ipv6() {
503        let result = validate_bind_address("[::1]");
504        assert!(result.is_ok());
505        assert!(result.unwrap().is_loopback());
506    }
507
508    #[test]
509    fn test_validate_bind_address_invalid() {
510        let result = validate_bind_address("not-an-ip");
511        assert!(result.is_err());
512        assert!(result.unwrap_err().contains("Invalid IP address"));
513    }
514
515    #[test]
516    fn test_validate_bind_address_whitespace() {
517        let result = validate_bind_address("  127.0.0.1  ");
518        assert!(result.is_ok());
519    }
520
521    // Tests for is_network_exposed
522
523    #[test]
524    fn test_is_network_exposed_ipv4_all() {
525        let config = Config {
526            bind_address: "0.0.0.0".to_string(),
527            ..Config::default()
528        };
529        assert!(config.is_network_exposed());
530    }
531
532    #[test]
533    fn test_is_network_exposed_ipv6_all() {
534        let config = Config {
535            bind_address: "::".to_string(),
536            ..Config::default()
537        };
538        assert!(config.is_network_exposed());
539    }
540
541    #[test]
542    fn test_is_network_exposed_localhost_false() {
543        let config = Config::default();
544        assert!(!config.is_network_exposed());
545    }
546
547    #[test]
548    fn test_is_network_exposed_ipv6_localhost_false() {
549        let config = Config {
550            bind_address: "::1".to_string(),
551            ..Config::default()
552        };
553        assert!(!config.is_network_exposed());
554    }
555
556    // Tests for is_localhost
557
558    #[test]
559    fn test_is_localhost_ipv4() {
560        let config = Config {
561            bind_address: "127.0.0.1".to_string(),
562            ..Config::default()
563        };
564        assert!(config.is_localhost());
565    }
566
567    #[test]
568    fn test_is_localhost_ipv6() {
569        let config = Config {
570            bind_address: "::1".to_string(),
571            ..Config::default()
572        };
573        assert!(config.is_localhost());
574    }
575
576    #[test]
577    fn test_is_localhost_string() {
578        let config = Config {
579            bind_address: "localhost".to_string(),
580            ..Config::default()
581        };
582        assert!(config.is_localhost());
583    }
584
585    #[test]
586    fn test_is_localhost_all_interfaces_false() {
587        let config = Config {
588            bind_address: "0.0.0.0".to_string(),
589            ..Config::default()
590        };
591        assert!(!config.is_localhost());
592    }
593
594    // Tests for security fields serialization
595
596    #[test]
597    fn test_serialize_deserialize_with_security_fields() {
598        let config = Config {
599            bind_address: "0.0.0.0".to_string(),
600            trust_proxy: true,
601            allow_unauthenticated_network: true,
602            rate_limit_attempts: 10,
603            rate_limit_window_seconds: 120,
604            users: vec!["admin".to_string(), "developer".to_string()],
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!(config, parsed);
610        assert_eq!(parsed.bind_address, "0.0.0.0");
611        assert!(parsed.trust_proxy);
612        assert!(parsed.allow_unauthenticated_network);
613        assert_eq!(parsed.rate_limit_attempts, 10);
614        assert_eq!(parsed.rate_limit_window_seconds, 120);
615        assert_eq!(parsed.users, vec!["admin", "developer"]);
616    }
617
618    // Tests for Cockpit fields
619
620    #[test]
621    fn test_default_config_cockpit_fields() {
622        let config = Config::default();
623        assert_eq!(config.cockpit_port, 9090);
624        // cockpit_enabled defaults to false (requires Linux host)
625        assert!(!config.cockpit_enabled);
626    }
627
628    #[test]
629    fn test_serialize_deserialize_with_cockpit_fields() {
630        let config = Config {
631            cockpit_port: 9091,
632            cockpit_enabled: false,
633            ..Config::default()
634        };
635        let json = serde_json::to_string(&config).unwrap();
636        let parsed: Config = serde_json::from_str(&json).unwrap();
637        assert_eq!(parsed.cockpit_port, 9091);
638        assert!(!parsed.cockpit_enabled);
639    }
640
641    #[test]
642    fn test_cockpit_fields_default_on_missing() {
643        // Old configs without cockpit fields should get defaults
644        let json = r#"{"version": 1}"#;
645        let config: Config = serde_json::from_str(json).unwrap();
646        assert_eq!(config.cockpit_port, 9090);
647        // cockpit_enabled defaults to false (requires Linux host)
648        assert!(!config.cockpit_enabled);
649    }
650
651    // Tests for image_source and update_check fields
652
653    #[test]
654    fn test_default_config_image_fields() {
655        let config = Config::default();
656        assert_eq!(config.image_source, "prebuilt");
657        assert_eq!(config.update_check, "always");
658    }
659
660    #[test]
661    fn test_serialize_deserialize_with_image_fields() {
662        let config = Config {
663            image_source: "build".to_string(),
664            update_check: "never".to_string(),
665            ..Config::default()
666        };
667        let json = serde_json::to_string(&config).unwrap();
668        let parsed: Config = serde_json::from_str(&json).unwrap();
669        assert_eq!(parsed.image_source, "build");
670        assert_eq!(parsed.update_check, "never");
671    }
672
673    #[test]
674    fn test_image_fields_default_on_missing() {
675        // Old configs without image fields should get defaults
676        let json = r#"{"version": 1}"#;
677        let config: Config = serde_json::from_str(json).unwrap();
678        assert_eq!(config.image_source, "prebuilt");
679        assert_eq!(config.update_check, "always");
680    }
681
682    // Tests for mounts field
683
684    #[test]
685    fn test_default_config_mounts_field() {
686        let config = Config::default();
687        assert!(config.mounts.is_empty());
688    }
689
690    #[test]
691    fn test_serialize_deserialize_with_mounts() {
692        let config = Config {
693            mounts: vec![
694                "/home/user/data:/workspace/data".to_string(),
695                "/home/user/config:/etc/app:ro".to_string(),
696            ],
697            ..Config::default()
698        };
699        let json = serde_json::to_string(&config).unwrap();
700        let parsed: Config = serde_json::from_str(&json).unwrap();
701        assert_eq!(parsed.mounts.len(), 2);
702        assert_eq!(parsed.mounts[0], "/home/user/data:/workspace/data");
703        assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
704    }
705
706    #[test]
707    fn test_mounts_field_default_on_missing() {
708        // Old configs without mounts field should get empty vec
709        let json = r#"{"version": 1}"#;
710        let config: Config = serde_json::from_str(json).unwrap();
711        assert!(config.mounts.is_empty());
712    }
713}