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