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