1use serde::{Deserialize, Serialize};
6use std::net::{IpAddr, Ipv4Addr};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(deny_unknown_fields)]
13pub struct Config {
14 pub version: u32,
16
17 #[serde(default = "default_opencode_web_port")]
19 pub opencode_web_port: u16,
20
21 #[serde(default = "default_bind")]
25 pub bind: String,
26
27 #[serde(default = "default_auto_restart")]
29 pub auto_restart: bool,
30
31 #[serde(default = "default_boot_mode")]
35 pub boot_mode: String,
36
37 #[serde(default = "default_restart_retries")]
39 pub restart_retries: u32,
40
41 #[serde(default = "default_restart_delay")]
43 pub restart_delay: u32,
44
45 #[serde(default)]
51 pub auth_username: Option<String>,
52
53 #[serde(default)]
58 pub auth_password: Option<String>,
59
60 #[serde(default)]
63 pub container_env: Vec<String>,
64
65 #[serde(default = "default_bind_address")]
68 pub bind_address: String,
69
70 #[serde(default)]
72 pub trust_proxy: bool,
73
74 #[serde(default)]
77 pub allow_unauthenticated_network: bool,
78
79 #[serde(default = "default_rate_limit_attempts")]
81 pub rate_limit_attempts: u32,
82
83 #[serde(default = "default_rate_limit_window")]
85 pub rate_limit_window_seconds: u32,
86
87 #[serde(default)]
90 pub users: Vec<String>,
91
92 #[serde(default = "default_cockpit_port")]
95 pub cockpit_port: u16,
96
97 #[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)]
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 validate_bind_address(addr: &str) -> Result<IpAddr, String> {
187 let trimmed = addr.trim();
188
189 if trimmed.eq_ignore_ascii_case("localhost") {
191 return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
192 }
193
194 let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
196 &trimmed[1..trimmed.len() - 1]
197 } else {
198 trimmed
199 };
200
201 stripped.parse::<IpAddr>().map_err(|_| {
202 format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
203 })
204}
205
206impl Default for Config {
207 fn default() -> Self {
208 Self {
209 version: 1,
210 opencode_web_port: default_opencode_web_port(),
211 bind: default_bind(),
212 auto_restart: default_auto_restart(),
213 boot_mode: default_boot_mode(),
214 restart_retries: default_restart_retries(),
215 restart_delay: default_restart_delay(),
216 auth_username: None,
217 auth_password: None,
218 container_env: Vec::new(),
219 bind_address: default_bind_address(),
220 trust_proxy: false,
221 allow_unauthenticated_network: false,
222 rate_limit_attempts: default_rate_limit_attempts(),
223 rate_limit_window_seconds: default_rate_limit_window(),
224 users: Vec::new(),
225 cockpit_port: default_cockpit_port(),
226 cockpit_enabled: default_cockpit_enabled(),
227 image_source: default_image_source(),
228 update_check: default_update_check(),
229 mounts: Vec::new(),
230 }
231 }
232}
233
234impl Config {
235 pub fn new() -> Self {
237 Self::default()
238 }
239
240 pub fn has_required_auth(&self) -> bool {
251 if !self.users.is_empty() {
253 return true;
254 }
255
256 match (&self.auth_username, &self.auth_password) {
258 (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
259 _ => false,
260 }
261 }
262
263 pub fn is_network_exposed(&self) -> bool {
268 match validate_bind_address(&self.bind_address) {
269 Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
270 Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
271 Err(_) => false, }
273 }
274
275 pub fn is_localhost(&self) -> bool {
279 match validate_bind_address(&self.bind_address) {
280 Ok(ip) => ip.is_loopback(),
281 Err(_) => {
282 self.bind_address.eq_ignore_ascii_case("localhost")
284 }
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_default_config() {
295 let config = Config::default();
296 assert_eq!(config.version, 1);
297 assert_eq!(config.opencode_web_port, 3000);
298 assert_eq!(config.bind, "localhost");
299 assert!(config.auto_restart);
300 assert_eq!(config.boot_mode, "user");
301 assert_eq!(config.restart_retries, 3);
302 assert_eq!(config.restart_delay, 5);
303 assert!(config.auth_username.is_none());
304 assert!(config.auth_password.is_none());
305 assert!(config.container_env.is_empty());
306 assert_eq!(config.bind_address, "127.0.0.1");
308 assert!(!config.trust_proxy);
309 assert!(!config.allow_unauthenticated_network);
310 assert_eq!(config.rate_limit_attempts, 5);
311 assert_eq!(config.rate_limit_window_seconds, 60);
312 assert!(config.users.is_empty());
313 assert!(config.mounts.is_empty());
314 }
315
316 #[test]
317 fn test_serialize_deserialize_roundtrip() {
318 let config = Config::default();
319 let json = serde_json::to_string(&config).unwrap();
320 let parsed: Config = serde_json::from_str(&json).unwrap();
321 assert_eq!(config, parsed);
322 }
323
324 #[test]
325 fn test_deserialize_with_missing_optional_fields() {
326 let json = r#"{"version": 1}"#;
327 let config: Config = serde_json::from_str(json).unwrap();
328 assert_eq!(config.version, 1);
329 assert_eq!(config.opencode_web_port, 3000);
330 assert_eq!(config.bind, "localhost");
331 assert!(config.auto_restart);
332 assert_eq!(config.boot_mode, "user");
333 assert_eq!(config.restart_retries, 3);
334 assert_eq!(config.restart_delay, 5);
335 assert!(config.auth_username.is_none());
336 assert!(config.auth_password.is_none());
337 assert!(config.container_env.is_empty());
338 assert_eq!(config.bind_address, "127.0.0.1");
340 assert!(!config.trust_proxy);
341 assert!(!config.allow_unauthenticated_network);
342 assert_eq!(config.rate_limit_attempts, 5);
343 assert_eq!(config.rate_limit_window_seconds, 60);
344 assert!(config.users.is_empty());
345 }
346
347 #[test]
348 fn test_serialize_deserialize_roundtrip_with_service_fields() {
349 let config = Config {
350 version: 1,
351 opencode_web_port: 9000,
352 bind: "0.0.0.0".to_string(),
353 auto_restart: false,
354 boot_mode: "system".to_string(),
355 restart_retries: 5,
356 restart_delay: 10,
357 auth_username: None,
358 auth_password: None,
359 container_env: Vec::new(),
360 bind_address: "0.0.0.0".to_string(),
361 trust_proxy: true,
362 allow_unauthenticated_network: false,
363 rate_limit_attempts: 10,
364 rate_limit_window_seconds: 120,
365 users: vec!["admin".to_string()],
366 cockpit_port: 9090,
367 cockpit_enabled: true,
368 image_source: default_image_source(),
369 update_check: default_update_check(),
370 mounts: Vec::new(),
371 };
372 let json = serde_json::to_string(&config).unwrap();
373 let parsed: Config = serde_json::from_str(&json).unwrap();
374 assert_eq!(config, parsed);
375 assert_eq!(parsed.boot_mode, "system");
376 assert_eq!(parsed.restart_retries, 5);
377 assert_eq!(parsed.restart_delay, 10);
378 assert_eq!(parsed.bind_address, "0.0.0.0");
379 assert!(parsed.trust_proxy);
380 assert_eq!(parsed.rate_limit_attempts, 10);
381 assert_eq!(parsed.users, vec!["admin"]);
382 }
383
384 #[test]
385 fn test_reject_unknown_fields() {
386 let json = r#"{"version": 1, "unknown_field": "value"}"#;
387 let result: Result<Config, _> = serde_json::from_str(json);
388 assert!(result.is_err());
389 }
390
391 #[test]
392 fn test_serialize_deserialize_roundtrip_with_auth_fields() {
393 let config = Config {
394 auth_username: Some("admin".to_string()),
395 auth_password: Some("secret123".to_string()),
396 container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
397 ..Config::default()
398 };
399 let json = serde_json::to_string(&config).unwrap();
400 let parsed: Config = serde_json::from_str(&json).unwrap();
401 assert_eq!(config, parsed);
402 assert_eq!(parsed.auth_username, Some("admin".to_string()));
403 assert_eq!(parsed.auth_password, Some("secret123".to_string()));
404 assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
405 }
406
407 #[test]
408 fn test_has_required_auth_returns_false_when_both_none() {
409 let config = Config::default();
410 assert!(!config.has_required_auth());
411 }
412
413 #[test]
414 fn test_has_required_auth_returns_false_when_username_none() {
415 let config = Config {
416 auth_username: None,
417 auth_password: Some("secret".to_string()),
418 ..Config::default()
419 };
420 assert!(!config.has_required_auth());
421 }
422
423 #[test]
424 fn test_has_required_auth_returns_false_when_password_none() {
425 let config = Config {
426 auth_username: Some("admin".to_string()),
427 auth_password: None,
428 ..Config::default()
429 };
430 assert!(!config.has_required_auth());
431 }
432
433 #[test]
434 fn test_has_required_auth_returns_false_when_username_empty() {
435 let config = Config {
436 auth_username: Some(String::new()),
437 auth_password: Some("secret".to_string()),
438 ..Config::default()
439 };
440 assert!(!config.has_required_auth());
441 }
442
443 #[test]
444 fn test_has_required_auth_returns_false_when_password_empty() {
445 let config = Config {
446 auth_username: Some("admin".to_string()),
447 auth_password: Some(String::new()),
448 ..Config::default()
449 };
450 assert!(!config.has_required_auth());
451 }
452
453 #[test]
454 fn test_has_required_auth_returns_true_when_both_set() {
455 let config = Config {
456 auth_username: Some("admin".to_string()),
457 auth_password: Some("secret123".to_string()),
458 ..Config::default()
459 };
460 assert!(config.has_required_auth());
461 }
462
463 #[test]
466 fn test_validate_bind_address_ipv4_localhost() {
467 let result = validate_bind_address("127.0.0.1");
468 assert!(result.is_ok());
469 let ip = result.unwrap();
470 assert!(ip.is_loopback());
471 }
472
473 #[test]
474 fn test_validate_bind_address_ipv4_all_interfaces() {
475 let result = validate_bind_address("0.0.0.0");
476 assert!(result.is_ok());
477 let ip = result.unwrap();
478 assert!(ip.is_unspecified());
479 }
480
481 #[test]
482 fn test_validate_bind_address_ipv6_localhost() {
483 let result = validate_bind_address("::1");
484 assert!(result.is_ok());
485 let ip = result.unwrap();
486 assert!(ip.is_loopback());
487 }
488
489 #[test]
490 fn test_validate_bind_address_ipv6_all_interfaces() {
491 let result = validate_bind_address("::");
492 assert!(result.is_ok());
493 let ip = result.unwrap();
494 assert!(ip.is_unspecified());
495 }
496
497 #[test]
498 fn test_validate_bind_address_localhost_string() {
499 let result = validate_bind_address("localhost");
500 assert!(result.is_ok());
501 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
502 }
503
504 #[test]
505 fn test_validate_bind_address_localhost_case_insensitive() {
506 let result = validate_bind_address("LOCALHOST");
507 assert!(result.is_ok());
508 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
509 }
510
511 #[test]
512 fn test_validate_bind_address_bracketed_ipv6() {
513 let result = validate_bind_address("[::1]");
514 assert!(result.is_ok());
515 assert!(result.unwrap().is_loopback());
516 }
517
518 #[test]
519 fn test_validate_bind_address_invalid() {
520 let result = validate_bind_address("not-an-ip");
521 assert!(result.is_err());
522 assert!(result.unwrap_err().contains("Invalid IP address"));
523 }
524
525 #[test]
526 fn test_validate_bind_address_whitespace() {
527 let result = validate_bind_address(" 127.0.0.1 ");
528 assert!(result.is_ok());
529 }
530
531 #[test]
534 fn test_is_network_exposed_ipv4_all() {
535 let config = Config {
536 bind_address: "0.0.0.0".to_string(),
537 ..Config::default()
538 };
539 assert!(config.is_network_exposed());
540 }
541
542 #[test]
543 fn test_is_network_exposed_ipv6_all() {
544 let config = Config {
545 bind_address: "::".to_string(),
546 ..Config::default()
547 };
548 assert!(config.is_network_exposed());
549 }
550
551 #[test]
552 fn test_is_network_exposed_localhost_false() {
553 let config = Config::default();
554 assert!(!config.is_network_exposed());
555 }
556
557 #[test]
558 fn test_is_network_exposed_ipv6_localhost_false() {
559 let config = Config {
560 bind_address: "::1".to_string(),
561 ..Config::default()
562 };
563 assert!(!config.is_network_exposed());
564 }
565
566 #[test]
569 fn test_is_localhost_ipv4() {
570 let config = Config {
571 bind_address: "127.0.0.1".to_string(),
572 ..Config::default()
573 };
574 assert!(config.is_localhost());
575 }
576
577 #[test]
578 fn test_is_localhost_ipv6() {
579 let config = Config {
580 bind_address: "::1".to_string(),
581 ..Config::default()
582 };
583 assert!(config.is_localhost());
584 }
585
586 #[test]
587 fn test_is_localhost_string() {
588 let config = Config {
589 bind_address: "localhost".to_string(),
590 ..Config::default()
591 };
592 assert!(config.is_localhost());
593 }
594
595 #[test]
596 fn test_is_localhost_all_interfaces_false() {
597 let config = Config {
598 bind_address: "0.0.0.0".to_string(),
599 ..Config::default()
600 };
601 assert!(!config.is_localhost());
602 }
603
604 #[test]
607 fn test_serialize_deserialize_with_security_fields() {
608 let config = Config {
609 bind_address: "0.0.0.0".to_string(),
610 trust_proxy: true,
611 allow_unauthenticated_network: true,
612 rate_limit_attempts: 10,
613 rate_limit_window_seconds: 120,
614 users: vec!["admin".to_string(), "developer".to_string()],
615 ..Config::default()
616 };
617 let json = serde_json::to_string(&config).unwrap();
618 let parsed: Config = serde_json::from_str(&json).unwrap();
619 assert_eq!(config, parsed);
620 assert_eq!(parsed.bind_address, "0.0.0.0");
621 assert!(parsed.trust_proxy);
622 assert!(parsed.allow_unauthenticated_network);
623 assert_eq!(parsed.rate_limit_attempts, 10);
624 assert_eq!(parsed.rate_limit_window_seconds, 120);
625 assert_eq!(parsed.users, vec!["admin", "developer"]);
626 }
627
628 #[test]
631 fn test_default_config_cockpit_fields() {
632 let config = Config::default();
633 assert_eq!(config.cockpit_port, 9090);
634 assert!(!config.cockpit_enabled);
636 }
637
638 #[test]
639 fn test_serialize_deserialize_with_cockpit_fields() {
640 let config = Config {
641 cockpit_port: 9091,
642 cockpit_enabled: false,
643 ..Config::default()
644 };
645 let json = serde_json::to_string(&config).unwrap();
646 let parsed: Config = serde_json::from_str(&json).unwrap();
647 assert_eq!(parsed.cockpit_port, 9091);
648 assert!(!parsed.cockpit_enabled);
649 }
650
651 #[test]
652 fn test_cockpit_fields_default_on_missing() {
653 let json = r#"{"version": 1}"#;
655 let config: Config = serde_json::from_str(json).unwrap();
656 assert_eq!(config.cockpit_port, 9090);
657 assert!(!config.cockpit_enabled);
659 }
660
661 #[test]
664 fn test_default_config_image_fields() {
665 let config = Config::default();
666 assert_eq!(config.image_source, "prebuilt");
667 assert_eq!(config.update_check, "always");
668 }
669
670 #[test]
671 fn test_serialize_deserialize_with_image_fields() {
672 let config = Config {
673 image_source: "build".to_string(),
674 update_check: "never".to_string(),
675 ..Config::default()
676 };
677 let json = serde_json::to_string(&config).unwrap();
678 let parsed: Config = serde_json::from_str(&json).unwrap();
679 assert_eq!(parsed.image_source, "build");
680 assert_eq!(parsed.update_check, "never");
681 }
682
683 #[test]
684 fn test_image_fields_default_on_missing() {
685 let json = r#"{"version": 1}"#;
687 let config: Config = serde_json::from_str(json).unwrap();
688 assert_eq!(config.image_source, "prebuilt");
689 assert_eq!(config.update_check, "always");
690 }
691
692 #[test]
695 fn test_default_config_mounts_field() {
696 let config = Config::default();
697 assert!(config.mounts.is_empty());
698 }
699
700 #[test]
701 fn test_serialize_deserialize_with_mounts() {
702 let config = Config {
703 mounts: vec![
704 "/home/user/data:/workspace/data".to_string(),
705 "/home/user/config:/etc/app:ro".to_string(),
706 ],
707 ..Config::default()
708 };
709 let json = serde_json::to_string(&config).unwrap();
710 let parsed: Config = serde_json::from_str(&json).unwrap();
711 assert_eq!(parsed.mounts.len(), 2);
712 assert_eq!(parsed.mounts[0], "/home/user/data:/workspace/data");
713 assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
714 }
715
716 #[test]
717 fn test_mounts_field_default_on_missing() {
718 let json = r#"{"version": 1}"#;
720 let config: Config = serde_json::from_str(json).unwrap();
721 assert!(config.mounts.is_empty());
722 }
723}