opencode_cloud_core/config/
schema.rs1use 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
105fn default_opencode_web_port() -> u16 {
106 3000
107}
108
109fn default_bind() -> String {
110 "localhost".to_string()
111}
112
113fn default_auto_restart() -> bool {
114 true
115}
116
117fn default_boot_mode() -> String {
118 "user".to_string()
119}
120
121fn default_restart_retries() -> u32 {
122 3
123}
124
125fn default_restart_delay() -> u32 {
126 5
127}
128
129fn default_bind_address() -> String {
130 "127.0.0.1".to_string()
131}
132
133fn default_rate_limit_attempts() -> u32 {
134 5
135}
136
137fn default_rate_limit_window() -> u32 {
138 60
139}
140
141fn default_cockpit_port() -> u16 {
142 9090
143}
144
145fn default_cockpit_enabled() -> bool {
146 false
147}
148
149pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
159 let trimmed = addr.trim();
160
161 if trimmed.eq_ignore_ascii_case("localhost") {
163 return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
164 }
165
166 let stripped = if trimmed.starts_with('[') && trimmed.ends_with(']') {
168 &trimmed[1..trimmed.len() - 1]
169 } else {
170 trimmed
171 };
172
173 stripped.parse::<IpAddr>().map_err(|_| {
174 format!("Invalid IP address: '{addr}'. Use 127.0.0.1, ::1, 0.0.0.0, ::, or localhost")
175 })
176}
177
178impl Default for Config {
179 fn default() -> Self {
180 Self {
181 version: 1,
182 opencode_web_port: default_opencode_web_port(),
183 bind: default_bind(),
184 auto_restart: default_auto_restart(),
185 boot_mode: default_boot_mode(),
186 restart_retries: default_restart_retries(),
187 restart_delay: default_restart_delay(),
188 auth_username: None,
189 auth_password: None,
190 container_env: Vec::new(),
191 bind_address: default_bind_address(),
192 trust_proxy: false,
193 allow_unauthenticated_network: false,
194 rate_limit_attempts: default_rate_limit_attempts(),
195 rate_limit_window_seconds: default_rate_limit_window(),
196 users: Vec::new(),
197 cockpit_port: default_cockpit_port(),
198 cockpit_enabled: default_cockpit_enabled(),
199 }
200 }
201}
202
203impl Config {
204 pub fn new() -> Self {
206 Self::default()
207 }
208
209 pub fn has_required_auth(&self) -> bool {
217 if !self.users.is_empty() {
219 return true;
220 }
221
222 match (&self.auth_username, &self.auth_password) {
224 (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
225 _ => false,
226 }
227 }
228
229 pub fn is_network_exposed(&self) -> bool {
234 match validate_bind_address(&self.bind_address) {
235 Ok(IpAddr::V4(ip)) => ip.is_unspecified(),
236 Ok(IpAddr::V6(ip)) => ip.is_unspecified(),
237 Err(_) => false, }
239 }
240
241 pub fn is_localhost(&self) -> bool {
245 match validate_bind_address(&self.bind_address) {
246 Ok(ip) => ip.is_loopback(),
247 Err(_) => {
248 self.bind_address.eq_ignore_ascii_case("localhost")
250 }
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_default_config() {
261 let config = Config::default();
262 assert_eq!(config.version, 1);
263 assert_eq!(config.opencode_web_port, 3000);
264 assert_eq!(config.bind, "localhost");
265 assert!(config.auto_restart);
266 assert_eq!(config.boot_mode, "user");
267 assert_eq!(config.restart_retries, 3);
268 assert_eq!(config.restart_delay, 5);
269 assert!(config.auth_username.is_none());
270 assert!(config.auth_password.is_none());
271 assert!(config.container_env.is_empty());
272 assert_eq!(config.bind_address, "127.0.0.1");
274 assert!(!config.trust_proxy);
275 assert!(!config.allow_unauthenticated_network);
276 assert_eq!(config.rate_limit_attempts, 5);
277 assert_eq!(config.rate_limit_window_seconds, 60);
278 assert!(config.users.is_empty());
279 }
280
281 #[test]
282 fn test_serialize_deserialize_roundtrip() {
283 let config = Config::default();
284 let json = serde_json::to_string(&config).unwrap();
285 let parsed: Config = serde_json::from_str(&json).unwrap();
286 assert_eq!(config, parsed);
287 }
288
289 #[test]
290 fn test_deserialize_with_missing_optional_fields() {
291 let json = r#"{"version": 1}"#;
292 let config: Config = serde_json::from_str(json).unwrap();
293 assert_eq!(config.version, 1);
294 assert_eq!(config.opencode_web_port, 3000);
295 assert_eq!(config.bind, "localhost");
296 assert!(config.auto_restart);
297 assert_eq!(config.boot_mode, "user");
298 assert_eq!(config.restart_retries, 3);
299 assert_eq!(config.restart_delay, 5);
300 assert!(config.auth_username.is_none());
301 assert!(config.auth_password.is_none());
302 assert!(config.container_env.is_empty());
303 assert_eq!(config.bind_address, "127.0.0.1");
305 assert!(!config.trust_proxy);
306 assert!(!config.allow_unauthenticated_network);
307 assert_eq!(config.rate_limit_attempts, 5);
308 assert_eq!(config.rate_limit_window_seconds, 60);
309 assert!(config.users.is_empty());
310 }
311
312 #[test]
313 fn test_serialize_deserialize_roundtrip_with_service_fields() {
314 let config = Config {
315 version: 1,
316 opencode_web_port: 9000,
317 bind: "0.0.0.0".to_string(),
318 auto_restart: false,
319 boot_mode: "system".to_string(),
320 restart_retries: 5,
321 restart_delay: 10,
322 auth_username: None,
323 auth_password: None,
324 container_env: Vec::new(),
325 bind_address: "0.0.0.0".to_string(),
326 trust_proxy: true,
327 allow_unauthenticated_network: false,
328 rate_limit_attempts: 10,
329 rate_limit_window_seconds: 120,
330 users: vec!["admin".to_string()],
331 cockpit_port: 9090,
332 cockpit_enabled: true,
333 };
334 let json = serde_json::to_string(&config).unwrap();
335 let parsed: Config = serde_json::from_str(&json).unwrap();
336 assert_eq!(config, parsed);
337 assert_eq!(parsed.boot_mode, "system");
338 assert_eq!(parsed.restart_retries, 5);
339 assert_eq!(parsed.restart_delay, 10);
340 assert_eq!(parsed.bind_address, "0.0.0.0");
341 assert!(parsed.trust_proxy);
342 assert_eq!(parsed.rate_limit_attempts, 10);
343 assert_eq!(parsed.users, vec!["admin"]);
344 }
345
346 #[test]
347 fn test_reject_unknown_fields() {
348 let json = r#"{"version": 1, "unknown_field": "value"}"#;
349 let result: Result<Config, _> = serde_json::from_str(json);
350 assert!(result.is_err());
351 }
352
353 #[test]
354 fn test_serialize_deserialize_roundtrip_with_auth_fields() {
355 let config = Config {
356 auth_username: Some("admin".to_string()),
357 auth_password: Some("secret123".to_string()),
358 container_env: vec!["FOO=bar".to_string(), "BAZ=qux".to_string()],
359 ..Config::default()
360 };
361 let json = serde_json::to_string(&config).unwrap();
362 let parsed: Config = serde_json::from_str(&json).unwrap();
363 assert_eq!(config, parsed);
364 assert_eq!(parsed.auth_username, Some("admin".to_string()));
365 assert_eq!(parsed.auth_password, Some("secret123".to_string()));
366 assert_eq!(parsed.container_env, vec!["FOO=bar", "BAZ=qux"]);
367 }
368
369 #[test]
370 fn test_has_required_auth_returns_false_when_both_none() {
371 let config = Config::default();
372 assert!(!config.has_required_auth());
373 }
374
375 #[test]
376 fn test_has_required_auth_returns_false_when_username_none() {
377 let config = Config {
378 auth_username: None,
379 auth_password: Some("secret".to_string()),
380 ..Config::default()
381 };
382 assert!(!config.has_required_auth());
383 }
384
385 #[test]
386 fn test_has_required_auth_returns_false_when_password_none() {
387 let config = Config {
388 auth_username: Some("admin".to_string()),
389 auth_password: None,
390 ..Config::default()
391 };
392 assert!(!config.has_required_auth());
393 }
394
395 #[test]
396 fn test_has_required_auth_returns_false_when_username_empty() {
397 let config = Config {
398 auth_username: Some(String::new()),
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_empty() {
407 let config = Config {
408 auth_username: Some("admin".to_string()),
409 auth_password: Some(String::new()),
410 ..Config::default()
411 };
412 assert!(!config.has_required_auth());
413 }
414
415 #[test]
416 fn test_has_required_auth_returns_true_when_both_set() {
417 let config = Config {
418 auth_username: Some("admin".to_string()),
419 auth_password: Some("secret123".to_string()),
420 ..Config::default()
421 };
422 assert!(config.has_required_auth());
423 }
424
425 #[test]
428 fn test_validate_bind_address_ipv4_localhost() {
429 let result = validate_bind_address("127.0.0.1");
430 assert!(result.is_ok());
431 let ip = result.unwrap();
432 assert!(ip.is_loopback());
433 }
434
435 #[test]
436 fn test_validate_bind_address_ipv4_all_interfaces() {
437 let result = validate_bind_address("0.0.0.0");
438 assert!(result.is_ok());
439 let ip = result.unwrap();
440 assert!(ip.is_unspecified());
441 }
442
443 #[test]
444 fn test_validate_bind_address_ipv6_localhost() {
445 let result = validate_bind_address("::1");
446 assert!(result.is_ok());
447 let ip = result.unwrap();
448 assert!(ip.is_loopback());
449 }
450
451 #[test]
452 fn test_validate_bind_address_ipv6_all_interfaces() {
453 let result = validate_bind_address("::");
454 assert!(result.is_ok());
455 let ip = result.unwrap();
456 assert!(ip.is_unspecified());
457 }
458
459 #[test]
460 fn test_validate_bind_address_localhost_string() {
461 let result = validate_bind_address("localhost");
462 assert!(result.is_ok());
463 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
464 }
465
466 #[test]
467 fn test_validate_bind_address_localhost_case_insensitive() {
468 let result = validate_bind_address("LOCALHOST");
469 assert!(result.is_ok());
470 assert_eq!(result.unwrap().to_string(), "127.0.0.1");
471 }
472
473 #[test]
474 fn test_validate_bind_address_bracketed_ipv6() {
475 let result = validate_bind_address("[::1]");
476 assert!(result.is_ok());
477 assert!(result.unwrap().is_loopback());
478 }
479
480 #[test]
481 fn test_validate_bind_address_invalid() {
482 let result = validate_bind_address("not-an-ip");
483 assert!(result.is_err());
484 assert!(result.unwrap_err().contains("Invalid IP address"));
485 }
486
487 #[test]
488 fn test_validate_bind_address_whitespace() {
489 let result = validate_bind_address(" 127.0.0.1 ");
490 assert!(result.is_ok());
491 }
492
493 #[test]
496 fn test_is_network_exposed_ipv4_all() {
497 let config = Config {
498 bind_address: "0.0.0.0".to_string(),
499 ..Config::default()
500 };
501 assert!(config.is_network_exposed());
502 }
503
504 #[test]
505 fn test_is_network_exposed_ipv6_all() {
506 let config = Config {
507 bind_address: "::".to_string(),
508 ..Config::default()
509 };
510 assert!(config.is_network_exposed());
511 }
512
513 #[test]
514 fn test_is_network_exposed_localhost_false() {
515 let config = Config::default();
516 assert!(!config.is_network_exposed());
517 }
518
519 #[test]
520 fn test_is_network_exposed_ipv6_localhost_false() {
521 let config = Config {
522 bind_address: "::1".to_string(),
523 ..Config::default()
524 };
525 assert!(!config.is_network_exposed());
526 }
527
528 #[test]
531 fn test_is_localhost_ipv4() {
532 let config = Config {
533 bind_address: "127.0.0.1".to_string(),
534 ..Config::default()
535 };
536 assert!(config.is_localhost());
537 }
538
539 #[test]
540 fn test_is_localhost_ipv6() {
541 let config = Config {
542 bind_address: "::1".to_string(),
543 ..Config::default()
544 };
545 assert!(config.is_localhost());
546 }
547
548 #[test]
549 fn test_is_localhost_string() {
550 let config = Config {
551 bind_address: "localhost".to_string(),
552 ..Config::default()
553 };
554 assert!(config.is_localhost());
555 }
556
557 #[test]
558 fn test_is_localhost_all_interfaces_false() {
559 let config = Config {
560 bind_address: "0.0.0.0".to_string(),
561 ..Config::default()
562 };
563 assert!(!config.is_localhost());
564 }
565
566 #[test]
569 fn test_serialize_deserialize_with_security_fields() {
570 let config = Config {
571 bind_address: "0.0.0.0".to_string(),
572 trust_proxy: true,
573 allow_unauthenticated_network: true,
574 rate_limit_attempts: 10,
575 rate_limit_window_seconds: 120,
576 users: vec!["admin".to_string(), "developer".to_string()],
577 ..Config::default()
578 };
579 let json = serde_json::to_string(&config).unwrap();
580 let parsed: Config = serde_json::from_str(&json).unwrap();
581 assert_eq!(config, parsed);
582 assert_eq!(parsed.bind_address, "0.0.0.0");
583 assert!(parsed.trust_proxy);
584 assert!(parsed.allow_unauthenticated_network);
585 assert_eq!(parsed.rate_limit_attempts, 10);
586 assert_eq!(parsed.rate_limit_window_seconds, 120);
587 assert_eq!(parsed.users, vec!["admin", "developer"]);
588 }
589
590 #[test]
593 fn test_default_config_cockpit_fields() {
594 let config = Config::default();
595 assert_eq!(config.cockpit_port, 9090);
596 assert!(!config.cockpit_enabled);
598 }
599
600 #[test]
601 fn test_serialize_deserialize_with_cockpit_fields() {
602 let config = Config {
603 cockpit_port: 9091,
604 cockpit_enabled: false,
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!(parsed.cockpit_port, 9091);
610 assert!(!parsed.cockpit_enabled);
611 }
612
613 #[test]
614 fn test_cockpit_fields_default_on_missing() {
615 let json = r#"{"version": 1}"#;
617 let config: Config = serde_json::from_str(json).unwrap();
618 assert_eq!(config.cockpit_port, 9090);
619 assert!(!config.cockpit_enabled);
621 }
622}