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)]
47 pub auth_username: Option<String>,
48
49 #[serde(default)]
51 pub auth_password: Option<String>,
52
53 #[serde(default)]
56 pub container_env: Vec<String>,
57
58 #[serde(default = "default_bind_address")]
61 pub bind_address: String,
62
63 #[serde(default)]
65 pub trust_proxy: bool,
66
67 #[serde(default)]
70 pub allow_unauthenticated_network: bool,
71
72 #[serde(default = "default_rate_limit_attempts")]
74 pub rate_limit_attempts: u32,
75
76 #[serde(default = "default_rate_limit_window")]
78 pub rate_limit_window_seconds: u32,
79
80 #[serde(default)]
83 pub users: Vec<String>,
84
85 #[serde(default = "default_cockpit_port")]
88 pub cockpit_port: u16,
89
90 #[serde(default = "default_cockpit_enabled")]
102 pub cockpit_enabled: bool,
103
104 #[serde(default = "default_image_source")]
106 pub image_source: String,
107
108 #[serde(default = "default_update_check")]
110 pub update_check: String,
111
112 #[serde(default)]
115 pub mounts: Vec<String>,
116}
117
118fn default_opencode_web_port() -> u16 {
119 3000
120}
121
122fn default_bind() -> String {
123 "localhost".to_string()
124}
125
126fn default_auto_restart() -> bool {
127 true
128}
129
130fn default_boot_mode() -> String {
131 "user".to_string()
132}
133
134fn default_restart_retries() -> u32 {
135 3
136}
137
138fn default_restart_delay() -> u32 {
139 5
140}
141
142fn default_bind_address() -> String {
143 "127.0.0.1".to_string()
144}
145
146fn default_rate_limit_attempts() -> u32 {
147 5
148}
149
150fn default_rate_limit_window() -> u32 {
151 60
152}
153
154fn default_cockpit_port() -> u16 {
155 9090
156}
157
158fn default_cockpit_enabled() -> bool {
159 false
160}
161
162fn default_image_source() -> String {
163 "prebuilt".to_string()
164}
165
166fn default_update_check() -> String {
167 "always".to_string()
168}
169
170pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
180 let trimmed = addr.trim();
181
182 if trimmed.eq_ignore_ascii_case("localhost") {
184 return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
185 }
186
187 let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
189 &trimmed[1..trimmed.len() - 1]
190 } else {
191 trimmed
192 };
193
194 stripped.parse::<IpAddr>().map_err(|_| {
195 format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
196 })
197}
198
199impl Default for Config {
200 fn default() -> Self {
201 Self {
202 version: 1,
203 opencode_web_port: default_opencode_web_port(),
204 bind: default_bind(),
205 auto_restart: default_auto_restart(),
206 boot_mode: default_boot_mode(),
207 restart_retries: default_restart_retries(),
208 restart_delay: default_restart_delay(),
209 auth_username: None,
210 auth_password: None,
211 container_env: Vec::new(),
212 bind_address: default_bind_address(),
213 trust_proxy: false,
214 allow_unauthenticated_network: false,
215 rate_limit_attempts: default_rate_limit_attempts(),
216 rate_limit_window_seconds: default_rate_limit_window(),
217 users: Vec::new(),
218 cockpit_port: default_cockpit_port(),
219 cockpit_enabled: default_cockpit_enabled(),
220 image_source: default_image_source(),
221 update_check: default_update_check(),
222 mounts: Vec::new(),
223 }
224 }
225}
226
227impl Config {
228 pub fn new() -> Self {
230 Self::default()
231 }
232
233 pub fn has_required_auth(&self) -> bool {
241 if !self.users.is_empty() {
243 return true;
244 }
245
246 match (&self.auth_username, &self.auth_password) {
248 (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
249 _ => false,
250 }
251 }
252
253 pub fn is_network_exposed(&self) -> bool {
258 match validate_bind_address(&self.bind_address) {
259 Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
260 Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
261 Err(_) => false, }
263 }
264
265 pub fn is_localhost(&self) -> bool {
269 match validate_bind_address(&self.bind_address) {
270 Ok(ip) => ip.is_loopback(),
271 Err(_) => {
272 self.bind_address.eq_ignore_ascii_case("localhost")
274 }
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn test_default_config() {
285 let config = Config::default();
286 assert_eq!(config.version, 1);
287 assert_eq!(config.opencode_web_port, 3000);
288 assert_eq!(config.bind, "localhost");
289 assert!(config.auto_restart);
290 assert_eq!(config.boot_mode, "user");
291 assert_eq!(config.restart_retries, 3);
292 assert_eq!(config.restart_delay, 5);
293 assert!(config.auth_username.is_none());
294 assert!(config.auth_password.is_none());
295 assert!(config.container_env.is_empty());
296 assert_eq!(config.bind_address, "127.0.0.1");
298 assert!(!config.trust_proxy);
299 assert!(!config.allow_unauthenticated_network);
300 assert_eq!(config.rate_limit_attempts, 5);
301 assert_eq!(config.rate_limit_window_seconds, 60);
302 assert!(config.users.is_empty());
303 assert!(config.mounts.is_empty());
304 }
305
306 #[test]
307 fn test_serialize_deserialize_roundtrip() {
308 let config = Config::default();
309 let json = serde_json::to_string(&config).unwrap();
310 let parsed: Config = serde_json::from_str(&json).unwrap();
311 assert_eq!(config, parsed);
312 }
313
314 #[test]
315 fn test_deserialize_with_missing_optional_fields() {
316 let json = r#"{"version": 1}"#;
317 let config: Config = serde_json::from_str(json).unwrap();
318 assert_eq!(config.version, 1);
319 assert_eq!(config.opencode_web_port, 3000);
320 assert_eq!(config.bind, "localhost");
321 assert!(config.auto_restart);
322 assert_eq!(config.boot_mode, "user");
323 assert_eq!(config.restart_retries, 3);
324 assert_eq!(config.restart_delay, 5);
325 assert!(config.auth_username.is_none());
326 assert!(config.auth_password.is_none());
327 assert!(config.container_env.is_empty());
328 assert_eq!(config.bind_address, "127.0.0.1");
330 assert!(!config.trust_proxy);
331 assert!(!config.allow_unauthenticated_network);
332 assert_eq!(config.rate_limit_attempts, 5);
333 assert_eq!(config.rate_limit_window_seconds, 60);
334 assert!(config.users.is_empty());
335 }
336
337 #[test]
338 fn test_serialize_deserialize_roundtrip_with_service_fields() {
339 let config = Config {
340 version: 1,
341 opencode_web_port: 9000,
342 bind: "0.0.0.0".to_string(),
343 auto_restart: false,
344 boot_mode: "system".to_string(),
345 restart_retries: 5,
346 restart_delay: 10,
347 auth_username: None,
348 auth_password: None,
349 container_env: Vec::new(),
350 bind_address: "0.0.0.0".to_string(),
351 trust_proxy: true,
352 allow_unauthenticated_network: false,
353 rate_limit_attempts: 10,
354 rate_limit_window_seconds: 120,
355 users: vec!["admin".to_string()],
356 cockpit_port: 9090,
357 cockpit_enabled: true,
358 image_source: default_image_source(),
359 update_check: default_update_check(),
360 mounts: Vec::new(),
361 };
362 let json = serde_json::to_string(&config).unwrap();
363 let parsed: Config = serde_json::from_str(&json).unwrap();
364 assert_eq!(config, parsed);
365 assert_eq!(parsed.boot_mode, "system");
366 assert_eq!(parsed.restart_retries, 5);
367 assert_eq!(parsed.restart_delay, 10);
368 assert_eq!(parsed.bind_address, "0.0.0.0");
369 assert!(parsed.trust_proxy);
370 assert_eq!(parsed.rate_limit_attempts, 10);
371 assert_eq!(parsed.users, vec!["admin"]);
372 }
373
374 #[test]
375 fn test_reject_unknown_fields() {
376 let json = r#"{"version": 1, "unknown_field": "value"}"#;
377 let result: Result<Config, _> = serde_json::from_str(json);
378 assert!(result.is_err());
379 }
380
381 #[test]
382 fn test_serialize_deserialize_roundtrip_with_auth_fields() {
383 let config = Config {
384 auth_username: Some("admin".to_string()),
385 auth_password: Some("secret123".to_string()),
386 container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
387 ..Config::default()
388 };
389 let json = serde_json::to_string(&config).unwrap();
390 let parsed: Config = serde_json::from_str(&json).unwrap();
391 assert_eq!(config, parsed);
392 assert_eq!(parsed.auth_username, Some("admin".to_string()));
393 assert_eq!(parsed.auth_password, Some("secret123".to_string()));
394 assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
395 }
396
397 #[test]
398 fn test_has_required_auth_returns_false_when_both_none() {
399 let config = Config::default();
400 assert!(!config.has_required_auth());
401 }
402
403 #[test]
404 fn test_has_required_auth_returns_false_when_username_none() {
405 let config = Config {
406 auth_username: None,
407 auth_password: Some("secret".to_string()),
408 ..Config::default()
409 };
410 assert!(!config.has_required_auth());
411 }
412
413 #[test]
414 fn test_has_required_auth_returns_false_when_password_none() {
415 let config = Config {
416 auth_username: Some("admin".to_string()),
417 auth_password: None,
418 ..Config::default()
419 };
420 assert!(!config.has_required_auth());
421 }
422
423 #[test]
424 fn test_has_required_auth_returns_false_when_username_empty() {
425 let config = Config {
426 auth_username: Some(String::new()),
427 auth_password: Some("secret".to_string()),
428 ..Config::default()
429 };
430 assert!(!config.has_required_auth());
431 }
432
433 #[test]
434 fn test_has_required_auth_returns_false_when_password_empty() {
435 let config = Config {
436 auth_username: Some("admin".to_string()),
437 auth_password: Some(String::new()),
438 ..Config::default()
439 };
440 assert!(!config.has_required_auth());
441 }
442
443 #[test]
444 fn test_has_required_auth_returns_true_when_both_set() {
445 let config = Config {
446 auth_username: Some("admin".to_string()),
447 auth_password: Some("secret123".to_string()),
448 ..Config::default()
449 };
450 assert!(config.has_required_auth());
451 }
452
453 #[test]
456 fn test_validate_bind_address_ipv4_localhost() {
457 let result = validate_bind_address("127.0.0.1");
458 assert!(result.is_ok());
459 let ip = result.unwrap();
460 assert!(ip.is_loopback());
461 }
462
463 #[test]
464 fn test_validate_bind_address_ipv4_all_interfaces() {
465 let result = validate_bind_address("0.0.0.0");
466 assert!(result.is_ok());
467 let ip = result.unwrap();
468 assert!(ip.is_unspecified());
469 }
470
471 #[test]
472 fn test_validate_bind_address_ipv6_localhost() {
473 let result = validate_bind_address("::1");
474 assert!(result.is_ok());
475 let ip = result.unwrap();
476 assert!(ip.is_loopback());
477 }
478
479 #[test]
480 fn test_validate_bind_address_ipv6_all_interfaces() {
481 let result = validate_bind_address("::");
482 assert!(result.is_ok());
483 let ip = result.unwrap();
484 assert!(ip.is_unspecified());
485 }
486
487 #[test]
488 fn test_validate_bind_address_localhost_string() {
489 let result = validate_bind_address("localhost");
490 assert!(result.is_ok());
491 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
492 }
493
494 #[test]
495 fn test_validate_bind_address_localhost_case_insensitive() {
496 let result = validate_bind_address("LOCALHOST");
497 assert!(result.is_ok());
498 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
499 }
500
501 #[test]
502 fn test_validate_bind_address_bracketed_ipv6() {
503 let result = validate_bind_address("[::1]");
504 assert!(result.is_ok());
505 assert!(result.unwrap().is_loopback());
506 }
507
508 #[test]
509 fn test_validate_bind_address_invalid() {
510 let result = validate_bind_address("not-an-ip");
511 assert!(result.is_err());
512 assert!(result.unwrap_err().contains("Invalid IP address"));
513 }
514
515 #[test]
516 fn test_validate_bind_address_whitespace() {
517 let result = validate_bind_address(" 127.0.0.1 ");
518 assert!(result.is_ok());
519 }
520
521 #[test]
524 fn test_is_network_exposed_ipv4_all() {
525 let config = Config {
526 bind_address: "0.0.0.0".to_string(),
527 ..Config::default()
528 };
529 assert!(config.is_network_exposed());
530 }
531
532 #[test]
533 fn test_is_network_exposed_ipv6_all() {
534 let config = Config {
535 bind_address: "::".to_string(),
536 ..Config::default()
537 };
538 assert!(config.is_network_exposed());
539 }
540
541 #[test]
542 fn test_is_network_exposed_localhost_false() {
543 let config = Config::default();
544 assert!(!config.is_network_exposed());
545 }
546
547 #[test]
548 fn test_is_network_exposed_ipv6_localhost_false() {
549 let config = Config {
550 bind_address: "::1".to_string(),
551 ..Config::default()
552 };
553 assert!(!config.is_network_exposed());
554 }
555
556 #[test]
559 fn test_is_localhost_ipv4() {
560 let config = Config {
561 bind_address: "127.0.0.1".to_string(),
562 ..Config::default()
563 };
564 assert!(config.is_localhost());
565 }
566
567 #[test]
568 fn test_is_localhost_ipv6() {
569 let config = Config {
570 bind_address: "::1".to_string(),
571 ..Config::default()
572 };
573 assert!(config.is_localhost());
574 }
575
576 #[test]
577 fn test_is_localhost_string() {
578 let config = Config {
579 bind_address: "localhost".to_string(),
580 ..Config::default()
581 };
582 assert!(config.is_localhost());
583 }
584
585 #[test]
586 fn test_is_localhost_all_interfaces_false() {
587 let config = Config {
588 bind_address: "0.0.0.0".to_string(),
589 ..Config::default()
590 };
591 assert!(!config.is_localhost());
592 }
593
594 #[test]
597 fn test_serialize_deserialize_with_security_fields() {
598 let config = Config {
599 bind_address: "0.0.0.0".to_string(),
600 trust_proxy: true,
601 allow_unauthenticated_network: true,
602 rate_limit_attempts: 10,
603 rate_limit_window_seconds: 120,
604 users: vec!["admin".to_string(), "developer".to_string()],
605 ..Config::default()
606 };
607 let json = serde_json::to_string(&config).unwrap();
608 let parsed: Config = serde_json::from_str(&json).unwrap();
609 assert_eq!(config, parsed);
610 assert_eq!(parsed.bind_address, "0.0.0.0");
611 assert!(parsed.trust_proxy);
612 assert!(parsed.allow_unauthenticated_network);
613 assert_eq!(parsed.rate_limit_attempts, 10);
614 assert_eq!(parsed.rate_limit_window_seconds, 120);
615 assert_eq!(parsed.users, vec!["admin", "developer"]);
616 }
617
618 #[test]
621 fn test_default_config_cockpit_fields() {
622 let config = Config::default();
623 assert_eq!(config.cockpit_port, 9090);
624 assert!(!config.cockpit_enabled);
626 }
627
628 #[test]
629 fn test_serialize_deserialize_with_cockpit_fields() {
630 let config = Config {
631 cockpit_port: 9091,
632 cockpit_enabled: false,
633 ..Config::default()
634 };
635 let json = serde_json::to_string(&config).unwrap();
636 let parsed: Config = serde_json::from_str(&json).unwrap();
637 assert_eq!(parsed.cockpit_port, 9091);
638 assert!(!parsed.cockpit_enabled);
639 }
640
641 #[test]
642 fn test_cockpit_fields_default_on_missing() {
643 let json = r#"{"version": 1}"#;
645 let config: Config = serde_json::from_str(json).unwrap();
646 assert_eq!(config.cockpit_port, 9090);
647 assert!(!config.cockpit_enabled);
649 }
650
651 #[test]
654 fn test_default_config_image_fields() {
655 let config = Config::default();
656 assert_eq!(config.image_source, "prebuilt");
657 assert_eq!(config.update_check, "always");
658 }
659
660 #[test]
661 fn test_serialize_deserialize_with_image_fields() {
662 let config = Config {
663 image_source: "build".to_string(),
664 update_check: "never".to_string(),
665 ..Config::default()
666 };
667 let json = serde_json::to_string(&config).unwrap();
668 let parsed: Config = serde_json::from_str(&json).unwrap();
669 assert_eq!(parsed.image_source, "build");
670 assert_eq!(parsed.update_check, "never");
671 }
672
673 #[test]
674 fn test_image_fields_default_on_missing() {
675 let json = r#"{"version": 1}"#;
677 let config: Config = serde_json::from_str(json).unwrap();
678 assert_eq!(config.image_source, "prebuilt");
679 assert_eq!(config.update_check, "always");
680 }
681
682 #[test]
685 fn test_default_config_mounts_field() {
686 let config = Config::default();
687 assert!(config.mounts.is_empty());
688 }
689
690 #[test]
691 fn test_serialize_deserialize_with_mounts() {
692 let config = Config {
693 mounts: vec![
694 "/home/user/data:/workspace/data".to_string(),
695 "/home/user/config:/etc/app:ro".to_string(),
696 ],
697 ..Config::default()
698 };
699 let json = serde_json::to_string(&config).unwrap();
700 let parsed: Config = serde_json::from_str(&json).unwrap();
701 assert_eq!(parsed.mounts.len(), 2);
702 assert_eq!(parsed.mounts[0], "/home/user/data:/workspace/data");
703 assert_eq!(parsed.mounts[1], "/home/user/config:/etc/app:ro");
704 }
705
706 #[test]
707 fn test_mounts_field_default_on_missing() {
708 let json = r#"{"version": 1}"#;
710 let config: Config = serde_json::from_str(json).unwrap();
711 assert!(config.mounts.is_empty());
712 }
713}