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