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