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