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