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")]
109 pub cockpit_enabled: bool,
110
111 #[serde(default = "default_image_source")]
113 pub image_source: String,
114
115 #[serde(default = "default_update_check")]
117 pub update_check: String,
118
119 #[serde(default = "default_mounts")]
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
177pub fn default_mounts() -> Vec<String> {
178 let Some(base_dirs) = BaseDirs::new() else {
179 return Vec::new();
180 };
181 let home_dir = base_dirs.home_dir();
182
183 let data_dir = home_dir.join(".local").join("share").join("opencode");
184 let state_dir = home_dir.join(".local").join("state").join("opencode");
185 let cache_dir = home_dir.join(".cache").join("opencode");
186 let config_dir = home_dir.join(".config").join("opencode");
187 let workspace_dir = data_dir.join("workspace");
188
189 vec![
190 format!("{}:{MOUNT_SESSION}", data_dir.display()),
191 format!("{}:{MOUNT_STATE}", state_dir.display()),
192 format!("{}:{MOUNT_CACHE}", cache_dir.display()),
193 format!("{}:{MOUNT_PROJECTS}", workspace_dir.display()),
194 format!("{}:{MOUNT_CONFIG}", config_dir.display()),
195 ]
196}
197
198pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
208 let trimmed = addr.trim();
209
210 if trimmed.eq_ignore_ascii_case("localhost") {
212 return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
213 }
214
215 let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
217 &trimmed[1..trimmed.len() - 1]
218 } else {
219 trimmed
220 };
221
222 stripped.parse::<IpAddr>().map_err(|_| {
223 format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
224 })
225}
226
227impl Default for Config {
228 fn default() -> Self {
229 Self {
230 version: 1,
231 opencode_web_port: default_opencode_web_port(),
232 bind: default_bind(),
233 auto_restart: default_auto_restart(),
234 boot_mode: default_boot_mode(),
235 restart_retries: default_restart_retries(),
236 restart_delay: default_restart_delay(),
237 auth_username: None,
238 auth_password: None,
239 container_env: Vec::new(),
240 bind_address: default_bind_address(),
241 trust_proxy: false,
242 allow_unauthenticated_network: false,
243 rate_limit_attempts: default_rate_limit_attempts(),
244 rate_limit_window_seconds: default_rate_limit_window(),
245 users: Vec::new(),
246 cockpit_port: default_cockpit_port(),
247 cockpit_enabled: default_cockpit_enabled(),
248 image_source: default_image_source(),
249 update_check: default_update_check(),
250 mounts: default_mounts(),
251 }
252 }
253}
254
255impl Config {
256 pub fn new() -> Self {
258 Self::default()
259 }
260
261 pub fn has_required_auth(&self) -> bool {
272 if !self.users.is_empty() {
274 return true;
275 }
276
277 match (&self.auth_username, &self.auth_password) {
279 (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
280 _ => false,
281 }
282 }
283
284 pub fn is_network_exposed(&self) -> bool {
289 match validate_bind_address(&self.bind_address) {
290 Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
291 Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
292 Err(_) => false, }
294 }
295
296 pub fn is_localhost(&self) -> bool {
300 match validate_bind_address(&self.bind_address) {
301 Ok(ip) => ip.is_loopback(),
302 Err(_) => {
303 self.bind_address.eq_ignore_ascii_case("localhost")
305 }
306 }
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_default_config() {
316 let config = Config::default();
317 assert_eq!(config.version, 1);
318 assert_eq!(config.opencode_web_port, 3000);
319 assert_eq!(config.bind, "localhost");
320 assert!(config.auto_restart);
321 assert_eq!(config.boot_mode, "user");
322 assert_eq!(config.restart_retries, 3);
323 assert_eq!(config.restart_delay, 5);
324 assert!(config.auth_username.is_none());
325 assert!(config.auth_password.is_none());
326 assert!(config.container_env.is_empty());
327 assert_eq!(config.bind_address, "127.0.0.1");
329 assert!(!config.trust_proxy);
330 assert!(!config.allow_unauthenticated_network);
331 assert_eq!(config.rate_limit_attempts, 5);
332 assert_eq!(config.rate_limit_window_seconds, 60);
333 assert!(config.users.is_empty());
334 assert_eq!(config.mounts, default_mounts());
335 }
336
337 #[test]
338 fn test_serialize_deserialize_roundtrip() {
339 let config = Config::default();
340 let json = serde_json::to_string(&config).unwrap();
341 let parsed: Config = serde_json::from_str(&json).unwrap();
342 assert_eq!(config, parsed);
343 }
344
345 #[test]
346 fn test_deserialize_with_missing_optional_fields() {
347 let json = r#"{"version": 1}"#;
348 let config: Config = serde_json::from_str(json).unwrap();
349 assert_eq!(config.version, 1);
350 assert_eq!(config.opencode_web_port, 3000);
351 assert_eq!(config.bind, "localhost");
352 assert!(config.auto_restart);
353 assert_eq!(config.boot_mode, "user");
354 assert_eq!(config.restart_retries, 3);
355 assert_eq!(config.restart_delay, 5);
356 assert!(config.auth_username.is_none());
357 assert!(config.auth_password.is_none());
358 assert!(config.container_env.is_empty());
359 assert_eq!(config.bind_address, "127.0.0.1");
361 assert!(!config.trust_proxy);
362 assert!(!config.allow_unauthenticated_network);
363 assert_eq!(config.rate_limit_attempts, 5);
364 assert_eq!(config.rate_limit_window_seconds, 60);
365 assert!(config.users.is_empty());
366 }
367
368 #[test]
369 fn test_serialize_deserialize_roundtrip_with_service_fields() {
370 let config = Config {
371 version: 1,
372 opencode_web_port: 9000,
373 bind: "0.0.0.0".to_string(),
374 auto_restart: false,
375 boot_mode: "system".to_string(),
376 restart_retries: 5,
377 restart_delay: 10,
378 auth_username: None,
379 auth_password: None,
380 container_env: Vec::new(),
381 bind_address: "0.0.0.0".to_string(),
382 trust_proxy: true,
383 allow_unauthenticated_network: false,
384 rate_limit_attempts: 10,
385 rate_limit_window_seconds: 120,
386 users: vec!["admin".to_string()],
387 cockpit_port: 9090,
388 cockpit_enabled: true,
389 image_source: default_image_source(),
390 update_check: default_update_check(),
391 mounts: Vec::new(),
392 };
393 let json = serde_json::to_string(&config).unwrap();
394 let parsed: Config = serde_json::from_str(&json).unwrap();
395 assert_eq!(config, parsed);
396 assert_eq!(parsed.boot_mode, "system");
397 assert_eq!(parsed.restart_retries, 5);
398 assert_eq!(parsed.restart_delay, 10);
399 assert_eq!(parsed.bind_address, "0.0.0.0");
400 assert!(parsed.trust_proxy);
401 assert_eq!(parsed.rate_limit_attempts, 10);
402 assert_eq!(parsed.users, vec!["admin"]);
403 }
404
405 #[test]
406 fn test_reject_unknown_fields() {
407 let json = r#"{"version": 1, "unknown_field": "value"}"#;
408 let result: Result<Config, _> = serde_json::from_str(json);
409 assert!(result.is_err());
410 }
411
412 #[test]
413 fn test_serialize_deserialize_roundtrip_with_auth_fields() {
414 let config = Config {
415 auth_username: Some("admin".to_string()),
416 auth_password: Some("secret123".to_string()),
417 container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
418 ..Config::default()
419 };
420 let json = serde_json::to_string(&config).unwrap();
421 let parsed: Config = serde_json::from_str(&json).unwrap();
422 assert_eq!(config, parsed);
423 assert_eq!(parsed.auth_username, Some("admin".to_string()));
424 assert_eq!(parsed.auth_password, Some("secret123".to_string()));
425 assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
426 }
427
428 #[test]
429 fn test_has_required_auth_returns_false_when_both_none() {
430 let config = Config::default();
431 assert!(!config.has_required_auth());
432 }
433
434 #[test]
435 fn test_has_required_auth_returns_false_when_username_none() {
436 let config = Config {
437 auth_username: None,
438 auth_password: Some("secret".to_string()),
439 ..Config::default()
440 };
441 assert!(!config.has_required_auth());
442 }
443
444 #[test]
445 fn test_has_required_auth_returns_false_when_password_none() {
446 let config = Config {
447 auth_username: Some("admin".to_string()),
448 auth_password: None,
449 ..Config::default()
450 };
451 assert!(!config.has_required_auth());
452 }
453
454 #[test]
455 fn test_has_required_auth_returns_false_when_username_empty() {
456 let config = Config {
457 auth_username: Some(String::new()),
458 auth_password: Some("secret".to_string()),
459 ..Config::default()
460 };
461 assert!(!config.has_required_auth());
462 }
463
464 #[test]
465 fn test_has_required_auth_returns_false_when_password_empty() {
466 let config = Config {
467 auth_username: Some("admin".to_string()),
468 auth_password: Some(String::new()),
469 ..Config::default()
470 };
471 assert!(!config.has_required_auth());
472 }
473
474 #[test]
475 fn test_has_required_auth_returns_true_when_both_set() {
476 let config = Config {
477 auth_username: Some("admin".to_string()),
478 auth_password: Some("secret123".to_string()),
479 ..Config::default()
480 };
481 assert!(config.has_required_auth());
482 }
483
484 #[test]
487 fn test_validate_bind_address_ipv4_localhost() {
488 let result = validate_bind_address("127.0.0.1");
489 assert!(result.is_ok());
490 let ip = result.unwrap();
491 assert!(ip.is_loopback());
492 }
493
494 #[test]
495 fn test_validate_bind_address_ipv4_all_interfaces() {
496 let result = validate_bind_address("0.0.0.0");
497 assert!(result.is_ok());
498 let ip = result.unwrap();
499 assert!(ip.is_unspecified());
500 }
501
502 #[test]
503 fn test_validate_bind_address_ipv6_localhost() {
504 let result = validate_bind_address("::1");
505 assert!(result.is_ok());
506 let ip = result.unwrap();
507 assert!(ip.is_loopback());
508 }
509
510 #[test]
511 fn test_validate_bind_address_ipv6_all_interfaces() {
512 let result = validate_bind_address("::");
513 assert!(result.is_ok());
514 let ip = result.unwrap();
515 assert!(ip.is_unspecified());
516 }
517
518 #[test]
519 fn test_validate_bind_address_localhost_string() {
520 let result = validate_bind_address("localhost");
521 assert!(result.is_ok());
522 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
523 }
524
525 #[test]
526 fn test_validate_bind_address_localhost_case_insensitive() {
527 let result = validate_bind_address("LOCALHOST");
528 assert!(result.is_ok());
529 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
530 }
531
532 #[test]
533 fn test_validate_bind_address_bracketed_ipv6() {
534 let result = validate_bind_address("[::1]");
535 assert!(result.is_ok());
536 assert!(result.unwrap().is_loopback());
537 }
538
539 #[test]
540 fn test_validate_bind_address_invalid() {
541 let result = validate_bind_address("not-an-ip");
542 assert!(result.is_err());
543 assert!(result.unwrap_err().contains("Invalid IP address"));
544 }
545
546 #[test]
547 fn test_validate_bind_address_whitespace() {
548 let result = validate_bind_address(" 127.0.0.1 ");
549 assert!(result.is_ok());
550 }
551
552 #[test]
555 fn test_is_network_exposed_ipv4_all() {
556 let config = Config {
557 bind_address: "0.0.0.0".to_string(),
558 ..Config::default()
559 };
560 assert!(config.is_network_exposed());
561 }
562
563 #[test]
564 fn test_is_network_exposed_ipv6_all() {
565 let config = Config {
566 bind_address: "::".to_string(),
567 ..Config::default()
568 };
569 assert!(config.is_network_exposed());
570 }
571
572 #[test]
573 fn test_is_network_exposed_localhost_false() {
574 let config = Config::default();
575 assert!(!config.is_network_exposed());
576 }
577
578 #[test]
579 fn test_is_network_exposed_ipv6_localhost_false() {
580 let config = Config {
581 bind_address: "::1".to_string(),
582 ..Config::default()
583 };
584 assert!(!config.is_network_exposed());
585 }
586
587 #[test]
590 fn test_is_localhost_ipv4() {
591 let config = Config {
592 bind_address: "127.0.0.1".to_string(),
593 ..Config::default()
594 };
595 assert!(config.is_localhost());
596 }
597
598 #[test]
599 fn test_is_localhost_ipv6() {
600 let config = Config {
601 bind_address: "::1".to_string(),
602 ..Config::default()
603 };
604 assert!(config.is_localhost());
605 }
606
607 #[test]
608 fn test_is_localhost_string() {
609 let config = Config {
610 bind_address: "localhost".to_string(),
611 ..Config::default()
612 };
613 assert!(config.is_localhost());
614 }
615
616 #[test]
617 fn test_is_localhost_all_interfaces_false() {
618 let config = Config {
619 bind_address: "0.0.0.0".to_string(),
620 ..Config::default()
621 };
622 assert!(!config.is_localhost());
623 }
624
625 #[test]
628 fn test_serialize_deserialize_with_security_fields() {
629 let config = Config {
630 bind_address: "0.0.0.0".to_string(),
631 trust_proxy: true,
632 allow_unauthenticated_network: true,
633 rate_limit_attempts: 10,
634 rate_limit_window_seconds: 120,
635 users: vec!["admin".to_string(), "developer".to_string()],
636 ..Config::default()
637 };
638 let json = serde_json::to_string(&config).unwrap();
639 let parsed: Config = serde_json::from_str(&json).unwrap();
640 assert_eq!(config, parsed);
641 assert_eq!(parsed.bind_address, "0.0.0.0");
642 assert!(parsed.trust_proxy);
643 assert!(parsed.allow_unauthenticated_network);
644 assert_eq!(parsed.rate_limit_attempts, 10);
645 assert_eq!(parsed.rate_limit_window_seconds, 120);
646 assert_eq!(parsed.users, vec!["admin", "developer"]);
647 }
648
649 #[test]
652 fn test_default_config_cockpit_fields() {
653 let config = Config::default();
654 assert_eq!(config.cockpit_port, 9090);
655 assert!(!config.cockpit_enabled);
657 }
658
659 #[test]
660 fn test_serialize_deserialize_with_cockpit_fields() {
661 let config = Config {
662 cockpit_port: 9091,
663 cockpit_enabled: false,
664 ..Config::default()
665 };
666 let json = serde_json::to_string(&config).unwrap();
667 let parsed: Config = serde_json::from_str(&json).unwrap();
668 assert_eq!(parsed.cockpit_port, 9091);
669 assert!(!parsed.cockpit_enabled);
670 }
671
672 #[test]
673 fn test_cockpit_fields_default_on_missing() {
674 let json = r#"{"version": 1}"#;
676 let config: Config = serde_json::from_str(json).unwrap();
677 assert_eq!(config.cockpit_port, 9090);
678 assert!(!config.cockpit_enabled);
680 }
681
682 #[test]
685 fn test_default_config_image_fields() {
686 let config = Config::default();
687 assert_eq!(config.image_source, "prebuilt");
688 assert_eq!(config.update_check, "always");
689 }
690
691 #[test]
692 fn test_serialize_deserialize_with_image_fields() {
693 let config = Config {
694 image_source: "build".to_string(),
695 update_check: "never".to_string(),
696 ..Config::default()
697 };
698 let json = serde_json::to_string(&config).unwrap();
699 let parsed: Config = serde_json::from_str(&json).unwrap();
700 assert_eq!(parsed.image_source, "build");
701 assert_eq!(parsed.update_check, "never");
702 }
703
704 #[test]
705 fn test_image_fields_default_on_missing() {
706 let json = r#"{"version": 1}"#;
708 let config: Config = serde_json::from_str(json).unwrap();
709 assert_eq!(config.image_source, "prebuilt");
710 assert_eq!(config.update_check, "always");
711 }
712
713 #[test]
716 fn test_default_config_mounts_field() {
717 let config = Config::default();
718 assert_eq!(config.mounts, default_mounts());
719 }
720
721 #[test]
722 fn test_serialize_deserialize_with_mounts() {
723 let config = Config {
724 mounts: vec![
725 "/home/user/data:/home/opencode/workspace/data".to_string(),
726 "/home/user/config:/etc/app:ro".to_string(),
727 ],
728 ..Config::default()
729 };
730 let json = serde_json::to_string(&config).unwrap();
731 let parsed: Config = serde_json::from_str(&json).unwrap();
732 assert_eq!(parsed.mounts.len(), 2);
733 assert_eq!(
734 parsed.mounts[0],
735 "/home/user/data:/home/opencode/workspace/data"
736 );
737 assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
738 }
739
740 #[test]
741 fn test_mounts_field_default_on_missing() {
742 let json = r#"{"version": 1}"#;
744 let config: Config = serde_json::from_str(json).unwrap();
745 assert_eq!(config.mounts, default_mounts());
746 }
747}