1use crate::docker::volume::{
6 MOUNT_CACHE, MOUNT_CONFIG, MOUNT_PROJECTS, MOUNT_SESSION, MOUNT_STATE,
7};
8use directories::BaseDirs;
9use serde::{Deserialize, Serialize};
10use std::net::{IpAddr, Ipv4Addr};
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(deny_unknown_fields)]
16pub struct Config {
17 pub version: u32,
19
20 #[serde(default = "default_opencode_web_port")]
22 pub opencode_web_port: u16,
23
24 #[serde(default = "default_bind")]
28 pub bind: String,
29
30 #[serde(default = "default_auto_restart")]
32 pub auto_restart: bool,
33
34 #[serde(default = "default_boot_mode")]
38 pub boot_mode: String,
39
40 #[serde(default = "default_restart_retries")]
42 pub restart_retries: u32,
43
44 #[serde(default = "default_restart_delay")]
46 pub restart_delay: u32,
47
48 #[serde(default)]
54 pub auth_username: Option<String>,
55
56 #[serde(default)]
61 pub auth_password: Option<String>,
62
63 #[serde(default)]
66 pub container_env: Vec<String>,
67
68 #[serde(default = "default_bind_address")]
71 pub bind_address: String,
72
73 #[serde(default)]
75 pub trust_proxy: bool,
76
77 #[serde(default)]
80 pub allow_unauthenticated_network: bool,
81
82 #[serde(default = "default_rate_limit_attempts")]
84 pub rate_limit_attempts: u32,
85
86 #[serde(default = "default_rate_limit_window")]
88 pub rate_limit_window_seconds: u32,
89
90 #[serde(default)]
93 pub users: Vec<String>,
94
95 #[serde(default = "default_cockpit_port")]
98 pub cockpit_port: u16,
99
100 #[serde(default = "default_cockpit_enabled")]
112 pub cockpit_enabled: bool,
113
114 #[serde(default = "default_image_source")]
116 pub image_source: String,
117
118 #[serde(default = "default_update_check")]
120 pub update_check: String,
121
122 #[serde(default = "default_mounts")]
125 pub mounts: Vec<String>,
126}
127
128fn default_opencode_web_port() -> u16 {
129 3000
130}
131
132fn default_bind() -> String {
133 "localhost".to_string()
134}
135
136fn default_auto_restart() -> bool {
137 true
138}
139
140fn default_boot_mode() -> String {
141 "user".to_string()
142}
143
144fn default_restart_retries() -> u32 {
145 3
146}
147
148fn default_restart_delay() -> u32 {
149 5
150}
151
152fn default_bind_address() -> String {
153 "127.0.0.1".to_string()
154}
155
156fn default_rate_limit_attempts() -> u32 {
157 5
158}
159
160fn default_rate_limit_window() -> u32 {
161 60
162}
163
164fn default_cockpit_port() -> u16 {
165 9090
166}
167
168fn default_cockpit_enabled() -> bool {
169 false
170}
171
172fn default_image_source() -> String {
173 "prebuilt".to_string()
174}
175
176fn default_update_check() -> String {
177 "always".to_string()
178}
179
180pub fn default_mounts() -> Vec<String> {
181 let Some(base_dirs) = BaseDirs::new() else {
182 return Vec::new();
183 };
184 let home_dir = base_dirs.home_dir();
185
186 let data_dir = home_dir.join(".local").join("share").join("opencode");
187 let state_dir = home_dir.join(".local").join("state").join("opencode");
188 let cache_dir = home_dir.join(".cache").join("opencode");
189 let config_dir = home_dir.join(".config").join("opencode");
190 let workspace_dir = data_dir.join("workspace");
191
192 vec![
193 format!("{}:{MOUNT_SESSION}", data_dir.display()),
194 format!("{}:{MOUNT_STATE}", state_dir.display()),
195 format!("{}:{MOUNT_CACHE}", cache_dir.display()),
196 format!("{}:{MOUNT_PROJECTS}", workspace_dir.display()),
197 format!("{}:{MOUNT_CONFIG}", config_dir.display()),
198 ]
199}
200
201pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
211 let trimmed = addr.trim();
212
213 if trimmed.eq_ignore_ascii_case("localhost") {
215 return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
216 }
217
218 let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
220 &trimmed[1..trimmed.len() - 1]
221 } else {
222 trimmed
223 };
224
225 stripped.parse::<IpAddr>().map_err(|_| {
226 format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
227 })
228}
229
230impl Default for Config {
231 fn default() -> Self {
232 Self {
233 version: 1,
234 opencode_web_port: default_opencode_web_port(),
235 bind: default_bind(),
236 auto_restart: default_auto_restart(),
237 boot_mode: default_boot_mode(),
238 restart_retries: default_restart_retries(),
239 restart_delay: default_restart_delay(),
240 auth_username: None,
241 auth_password: None,
242 container_env: Vec::new(),
243 bind_address: default_bind_address(),
244 trust_proxy: false,
245 allow_unauthenticated_network: false,
246 rate_limit_attempts: default_rate_limit_attempts(),
247 rate_limit_window_seconds: default_rate_limit_window(),
248 users: Vec::new(),
249 cockpit_port: default_cockpit_port(),
250 cockpit_enabled: default_cockpit_enabled(),
251 image_source: default_image_source(),
252 update_check: default_update_check(),
253 mounts: default_mounts(),
254 }
255 }
256}
257
258impl Config {
259 pub fn new() -> Self {
261 Self::default()
262 }
263
264 pub fn has_required_auth(&self) -> bool {
275 if !self.users.is_empty() {
277 return true;
278 }
279
280 match (&self.auth_username, &self.auth_password) {
282 (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
283 _ => false,
284 }
285 }
286
287 pub fn is_network_exposed(&self) -> bool {
292 match validate_bind_address(&self.bind_address) {
293 Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
294 Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
295 Err(_) => false, }
297 }
298
299 pub fn is_localhost(&self) -> bool {
303 match validate_bind_address(&self.bind_address) {
304 Ok(ip) => ip.is_loopback(),
305 Err(_) => {
306 self.bind_address.eq_ignore_ascii_case("localhost")
308 }
309 }
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_default_config() {
319 let config = Config::default();
320 assert_eq!(config.version, 1);
321 assert_eq!(config.opencode_web_port, 3000);
322 assert_eq!(config.bind, "localhost");
323 assert!(config.auto_restart);
324 assert_eq!(config.boot_mode, "user");
325 assert_eq!(config.restart_retries, 3);
326 assert_eq!(config.restart_delay, 5);
327 assert!(config.auth_username.is_none());
328 assert!(config.auth_password.is_none());
329 assert!(config.container_env.is_empty());
330 assert_eq!(config.bind_address, "127.0.0.1");
332 assert!(!config.trust_proxy);
333 assert!(!config.allow_unauthenticated_network);
334 assert_eq!(config.rate_limit_attempts, 5);
335 assert_eq!(config.rate_limit_window_seconds, 60);
336 assert!(config.users.is_empty());
337 assert_eq!(config.mounts, default_mounts());
338 }
339
340 #[test]
341 fn test_serialize_deserialize_roundtrip() {
342 let config = Config::default();
343 let json = serde_json::to_string(&config).unwrap();
344 let parsed: Config = serde_json::from_str(&json).unwrap();
345 assert_eq!(config, parsed);
346 }
347
348 #[test]
349 fn test_deserialize_with_missing_optional_fields() {
350 let json = r#"{"version": 1}"#;
351 let config: Config = serde_json::from_str(json).unwrap();
352 assert_eq!(config.version, 1);
353 assert_eq!(config.opencode_web_port, 3000);
354 assert_eq!(config.bind, "localhost");
355 assert!(config.auto_restart);
356 assert_eq!(config.boot_mode, "user");
357 assert_eq!(config.restart_retries, 3);
358 assert_eq!(config.restart_delay, 5);
359 assert!(config.auth_username.is_none());
360 assert!(config.auth_password.is_none());
361 assert!(config.container_env.is_empty());
362 assert_eq!(config.bind_address, "127.0.0.1");
364 assert!(!config.trust_proxy);
365 assert!(!config.allow_unauthenticated_network);
366 assert_eq!(config.rate_limit_attempts, 5);
367 assert_eq!(config.rate_limit_window_seconds, 60);
368 assert!(config.users.is_empty());
369 }
370
371 #[test]
372 fn test_serialize_deserialize_roundtrip_with_service_fields() {
373 let config = Config {
374 version: 1,
375 opencode_web_port: 9000,
376 bind: "0.0.0.0".to_string(),
377 auto_restart: false,
378 boot_mode: "system".to_string(),
379 restart_retries: 5,
380 restart_delay: 10,
381 auth_username: None,
382 auth_password: None,
383 container_env: Vec::new(),
384 bind_address: "0.0.0.0".to_string(),
385 trust_proxy: true,
386 allow_unauthenticated_network: false,
387 rate_limit_attempts: 10,
388 rate_limit_window_seconds: 120,
389 users: vec!["admin".to_string()],
390 cockpit_port: 9090,
391 cockpit_enabled: true,
392 image_source: default_image_source(),
393 update_check: default_update_check(),
394 mounts: Vec::new(),
395 };
396 let json = serde_json::to_string(&config).unwrap();
397 let parsed: Config = serde_json::from_str(&json).unwrap();
398 assert_eq!(config, parsed);
399 assert_eq!(parsed.boot_mode, "system");
400 assert_eq!(parsed.restart_retries, 5);
401 assert_eq!(parsed.restart_delay, 10);
402 assert_eq!(parsed.bind_address, "0.0.0.0");
403 assert!(parsed.trust_proxy);
404 assert_eq!(parsed.rate_limit_attempts, 10);
405 assert_eq!(parsed.users, vec!["admin"]);
406 }
407
408 #[test]
409 fn test_reject_unknown_fields() {
410 let json = r#"{"version": 1, "unknown_field": "value"}"#;
411 let result: Result<Config, _> = serde_json::from_str(json);
412 assert!(result.is_err());
413 }
414
415 #[test]
416 fn test_serialize_deserialize_roundtrip_with_auth_fields() {
417 let config = Config {
418 auth_username: Some("admin".to_string()),
419 auth_password: Some("secret123".to_string()),
420 container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
421 ..Config::default()
422 };
423 let json = serde_json::to_string(&config).unwrap();
424 let parsed: Config = serde_json::from_str(&json).unwrap();
425 assert_eq!(config, parsed);
426 assert_eq!(parsed.auth_username, Some("admin".to_string()));
427 assert_eq!(parsed.auth_password, Some("secret123".to_string()));
428 assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
429 }
430
431 #[test]
432 fn test_has_required_auth_returns_false_when_both_none() {
433 let config = Config::default();
434 assert!(!config.has_required_auth());
435 }
436
437 #[test]
438 fn test_has_required_auth_returns_false_when_username_none() {
439 let config = Config {
440 auth_username: None,
441 auth_password: Some("secret".to_string()),
442 ..Config::default()
443 };
444 assert!(!config.has_required_auth());
445 }
446
447 #[test]
448 fn test_has_required_auth_returns_false_when_password_none() {
449 let config = Config {
450 auth_username: Some("admin".to_string()),
451 auth_password: None,
452 ..Config::default()
453 };
454 assert!(!config.has_required_auth());
455 }
456
457 #[test]
458 fn test_has_required_auth_returns_false_when_username_empty() {
459 let config = Config {
460 auth_username: Some(String::new()),
461 auth_password: Some("secret".to_string()),
462 ..Config::default()
463 };
464 assert!(!config.has_required_auth());
465 }
466
467 #[test]
468 fn test_has_required_auth_returns_false_when_password_empty() {
469 let config = Config {
470 auth_username: Some("admin".to_string()),
471 auth_password: Some(String::new()),
472 ..Config::default()
473 };
474 assert!(!config.has_required_auth());
475 }
476
477 #[test]
478 fn test_has_required_auth_returns_true_when_both_set() {
479 let config = Config {
480 auth_username: Some("admin".to_string()),
481 auth_password: Some("secret123".to_string()),
482 ..Config::default()
483 };
484 assert!(config.has_required_auth());
485 }
486
487 #[test]
490 fn test_validate_bind_address_ipv4_localhost() {
491 let result = validate_bind_address("127.0.0.1");
492 assert!(result.is_ok());
493 let ip = result.unwrap();
494 assert!(ip.is_loopback());
495 }
496
497 #[test]
498 fn test_validate_bind_address_ipv4_all_interfaces() {
499 let result = validate_bind_address("0.0.0.0");
500 assert!(result.is_ok());
501 let ip = result.unwrap();
502 assert!(ip.is_unspecified());
503 }
504
505 #[test]
506 fn test_validate_bind_address_ipv6_localhost() {
507 let result = validate_bind_address("::1");
508 assert!(result.is_ok());
509 let ip = result.unwrap();
510 assert!(ip.is_loopback());
511 }
512
513 #[test]
514 fn test_validate_bind_address_ipv6_all_interfaces() {
515 let result = validate_bind_address("::");
516 assert!(result.is_ok());
517 let ip = result.unwrap();
518 assert!(ip.is_unspecified());
519 }
520
521 #[test]
522 fn test_validate_bind_address_localhost_string() {
523 let result = validate_bind_address("localhost");
524 assert!(result.is_ok());
525 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
526 }
527
528 #[test]
529 fn test_validate_bind_address_localhost_case_insensitive() {
530 let result = validate_bind_address("LOCALHOST");
531 assert!(result.is_ok());
532 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
533 }
534
535 #[test]
536 fn test_validate_bind_address_bracketed_ipv6() {
537 let result = validate_bind_address("[::1]");
538 assert!(result.is_ok());
539 assert!(result.unwrap().is_loopback());
540 }
541
542 #[test]
543 fn test_validate_bind_address_invalid() {
544 let result = validate_bind_address("not-an-ip");
545 assert!(result.is_err());
546 assert!(result.unwrap_err().contains("Invalid IP address"));
547 }
548
549 #[test]
550 fn test_validate_bind_address_whitespace() {
551 let result = validate_bind_address(" 127.0.0.1 ");
552 assert!(result.is_ok());
553 }
554
555 #[test]
558 fn test_is_network_exposed_ipv4_all() {
559 let config = Config {
560 bind_address: "0.0.0.0".to_string(),
561 ..Config::default()
562 };
563 assert!(config.is_network_exposed());
564 }
565
566 #[test]
567 fn test_is_network_exposed_ipv6_all() {
568 let config = Config {
569 bind_address: "::".to_string(),
570 ..Config::default()
571 };
572 assert!(config.is_network_exposed());
573 }
574
575 #[test]
576 fn test_is_network_exposed_localhost_false() {
577 let config = Config::default();
578 assert!(!config.is_network_exposed());
579 }
580
581 #[test]
582 fn test_is_network_exposed_ipv6_localhost_false() {
583 let config = Config {
584 bind_address: "::1".to_string(),
585 ..Config::default()
586 };
587 assert!(!config.is_network_exposed());
588 }
589
590 #[test]
593 fn test_is_localhost_ipv4() {
594 let config = Config {
595 bind_address: "127.0.0.1".to_string(),
596 ..Config::default()
597 };
598 assert!(config.is_localhost());
599 }
600
601 #[test]
602 fn test_is_localhost_ipv6() {
603 let config = Config {
604 bind_address: "::1".to_string(),
605 ..Config::default()
606 };
607 assert!(config.is_localhost());
608 }
609
610 #[test]
611 fn test_is_localhost_string() {
612 let config = Config {
613 bind_address: "localhost".to_string(),
614 ..Config::default()
615 };
616 assert!(config.is_localhost());
617 }
618
619 #[test]
620 fn test_is_localhost_all_interfaces_false() {
621 let config = Config {
622 bind_address: "0.0.0.0".to_string(),
623 ..Config::default()
624 };
625 assert!(!config.is_localhost());
626 }
627
628 #[test]
631 fn test_serialize_deserialize_with_security_fields() {
632 let config = Config {
633 bind_address: "0.0.0.0".to_string(),
634 trust_proxy: true,
635 allow_unauthenticated_network: true,
636 rate_limit_attempts: 10,
637 rate_limit_window_seconds: 120,
638 users: vec!["admin".to_string(), "developer".to_string()],
639 ..Config::default()
640 };
641 let json = serde_json::to_string(&config).unwrap();
642 let parsed: Config = serde_json::from_str(&json).unwrap();
643 assert_eq!(config, parsed);
644 assert_eq!(parsed.bind_address, "0.0.0.0");
645 assert!(parsed.trust_proxy);
646 assert!(parsed.allow_unauthenticated_network);
647 assert_eq!(parsed.rate_limit_attempts, 10);
648 assert_eq!(parsed.rate_limit_window_seconds, 120);
649 assert_eq!(parsed.users, vec!["admin", "developer"]);
650 }
651
652 #[test]
655 fn test_default_config_cockpit_fields() {
656 let config = Config::default();
657 assert_eq!(config.cockpit_port, 9090);
658 assert!(!config.cockpit_enabled);
660 }
661
662 #[test]
663 fn test_serialize_deserialize_with_cockpit_fields() {
664 let config = Config {
665 cockpit_port: 9091,
666 cockpit_enabled: false,
667 ..Config::default()
668 };
669 let json = serde_json::to_string(&config).unwrap();
670 let parsed: Config = serde_json::from_str(&json).unwrap();
671 assert_eq!(parsed.cockpit_port, 9091);
672 assert!(!parsed.cockpit_enabled);
673 }
674
675 #[test]
676 fn test_cockpit_fields_default_on_missing() {
677 let json = r#"{"version": 1}"#;
679 let config: Config = serde_json::from_str(json).unwrap();
680 assert_eq!(config.cockpit_port, 9090);
681 assert!(!config.cockpit_enabled);
683 }
684
685 #[test]
688 fn test_default_config_image_fields() {
689 let config = Config::default();
690 assert_eq!(config.image_source, "prebuilt");
691 assert_eq!(config.update_check, "always");
692 }
693
694 #[test]
695 fn test_serialize_deserialize_with_image_fields() {
696 let config = Config {
697 image_source: "build".to_string(),
698 update_check: "never".to_string(),
699 ..Config::default()
700 };
701 let json = serde_json::to_string(&config).unwrap();
702 let parsed: Config = serde_json::from_str(&json).unwrap();
703 assert_eq!(parsed.image_source, "build");
704 assert_eq!(parsed.update_check, "never");
705 }
706
707 #[test]
708 fn test_image_fields_default_on_missing() {
709 let json = r#"{"version": 1}"#;
711 let config: Config = serde_json::from_str(json).unwrap();
712 assert_eq!(config.image_source, "prebuilt");
713 assert_eq!(config.update_check, "always");
714 }
715
716 #[test]
719 fn test_default_config_mounts_field() {
720 let config = Config::default();
721 assert_eq!(config.mounts, default_mounts());
722 }
723
724 #[test]
725 fn test_serialize_deserialize_with_mounts() {
726 let config = Config {
727 mounts: vec![
728 "/home/user/data:/home/opencode/workspace/data".to_string(),
729 "/home/user/config:/etc/app:ro".to_string(),
730 ],
731 ..Config::default()
732 };
733 let json = serde_json::to_string(&config).unwrap();
734 let parsed: Config = serde_json::from_str(&json).unwrap();
735 assert_eq!(parsed.mounts.len(), 2);
736 assert_eq!(
737 parsed.mounts[0],
738 "/home/user/data:/home/opencode/workspace/data"
739 );
740 assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
741 }
742
743 #[test]
744 fn test_mounts_field_default_on_missing() {
745 let json = r#"{"version": 1}"#;
747 let config: Config = serde_json::from_str(json).unwrap();
748 assert_eq!(config.mounts, default_mounts());
749 }
750}