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