@opencode-cloud/core 1.0.8 → 3.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@
3
3
  //! Defines the structure and defaults for the config.json file.
4
4
 
5
5
  use serde::{Deserialize, Serialize};
6
+ use std::net::{IpAddr, Ipv4Addr};
6
7
 
7
8
  /// Main configuration structure for opencode-cloud
8
9
  ///
@@ -40,6 +41,78 @@ pub struct Config {
40
41
  /// Seconds between restart attempts (default: 5)
41
42
  #[serde(default = "default_restart_delay")]
42
43
  pub restart_delay: u32,
44
+
45
+ /// Username for opencode basic auth (default: None, triggers wizard)
46
+ #[serde(default)]
47
+ pub auth_username: Option<String>,
48
+
49
+ /// Password for opencode basic auth (default: None, triggers wizard)
50
+ #[serde(default)]
51
+ pub auth_password: Option<String>,
52
+
53
+ /// Environment variables passed to container (default: empty)
54
+ /// Format: ["KEY=value", "KEY2=value2"]
55
+ #[serde(default)]
56
+ pub container_env: Vec<String>,
57
+
58
+ /// Bind address for opencode web UI (default: "127.0.0.1")
59
+ /// Use "0.0.0.0" or "::" for network exposure (requires explicit opt-in)
60
+ #[serde(default = "default_bind_address")]
61
+ pub bind_address: String,
62
+
63
+ /// Trust proxy headers (X-Forwarded-For, etc.) for load balancer deployments
64
+ #[serde(default)]
65
+ pub trust_proxy: bool,
66
+
67
+ /// Allow unauthenticated access when network exposed
68
+ /// Requires double confirmation on first start
69
+ #[serde(default)]
70
+ pub allow_unauthenticated_network: bool,
71
+
72
+ /// Maximum auth attempts before rate limiting
73
+ #[serde(default = "default_rate_limit_attempts")]
74
+ pub rate_limit_attempts: u32,
75
+
76
+ /// Rate limit window in seconds
77
+ #[serde(default = "default_rate_limit_window")]
78
+ pub rate_limit_window_seconds: u32,
79
+
80
+ /// List of usernames configured in container (for persistence tracking)
81
+ /// Passwords are NOT stored here - only in container's /etc/shadow
82
+ #[serde(default)]
83
+ pub users: Vec<String>,
84
+
85
+ /// Cockpit web console port (default: 9090)
86
+ /// Only used when cockpit_enabled is true
87
+ #[serde(default = "default_cockpit_port")]
88
+ pub cockpit_port: u16,
89
+
90
+ /// Enable Cockpit web console (default: false)
91
+ ///
92
+ /// When enabled:
93
+ /// - Container uses systemd as init (required for Cockpit)
94
+ /// - Requires Linux host with native Docker (does NOT work on macOS Docker Desktop)
95
+ /// - Cockpit web UI accessible at cockpit_port
96
+ ///
97
+ /// When disabled (default):
98
+ /// - Container uses tini as init (lightweight, works everywhere)
99
+ /// - Works on macOS, Linux, and Windows
100
+ /// - No Cockpit web UI
101
+ #[serde(default = "default_cockpit_enabled")]
102
+ pub cockpit_enabled: bool,
103
+
104
+ /// Source of Docker image: 'prebuilt' (pull from registry) or 'build' (compile locally)
105
+ #[serde(default = "default_image_source")]
106
+ pub image_source: String,
107
+
108
+ /// When to check for updates: 'always' (every start), 'once' (once per version), 'never'
109
+ #[serde(default = "default_update_check")]
110
+ pub update_check: String,
111
+
112
+ /// Bind mounts to apply when starting the container
113
+ /// Format: ["/host/path:/container/path", "/host:/mnt:ro"]
114
+ #[serde(default)]
115
+ pub mounts: Vec<String>,
43
116
  }
44
117
 
45
118
  fn default_opencode_web_port() -> u16 {
@@ -66,6 +139,63 @@ fn default_restart_delay() -> u32 {
66
139
  5
67
140
  }
68
141
 
142
+ fn default_bind_address() -> String {
143
+ "127.0.0.1".to_string()
144
+ }
145
+
146
+ fn default_rate_limit_attempts() -> u32 {
147
+ 5
148
+ }
149
+
150
+ fn default_rate_limit_window() -> u32 {
151
+ 60
152
+ }
153
+
154
+ fn default_cockpit_port() -> u16 {
155
+ 9090
156
+ }
157
+
158
+ fn default_cockpit_enabled() -> bool {
159
+ false
160
+ }
161
+
162
+ fn default_image_source() -> String {
163
+ "prebuilt".to_string()
164
+ }
165
+
166
+ fn default_update_check() -> String {
167
+ "always".to_string()
168
+ }
169
+
170
+ /// Validate and parse a bind address string
171
+ ///
172
+ /// Accepts:
173
+ /// - IPv4 addresses: "127.0.0.1", "0.0.0.0"
174
+ /// - IPv6 addresses: "::1", "::"
175
+ /// - Bracketed IPv6: "[::1]"
176
+ /// - "localhost" (resolves to 127.0.0.1)
177
+ ///
178
+ /// Returns the parsed IpAddr or an error message.
179
+ pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
180
+ let trimmed = addr.trim();
181
+
182
+ // Handle "localhost" as special case
183
+ if trimmed.eq_ignore_ascii_case("localhost") {
184
+ return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
185
+ }
186
+
187
+ // Strip brackets from IPv6 addresses like "[::1]"
188
+ 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
+
69
199
  impl Default for Config {
70
200
  fn default() -> Self {
71
201
  Self {
@@ -76,6 +206,20 @@ impl Default for Config {
76
206
  boot_mode: default_boot_mode(),
77
207
  restart_retries: default_restart_retries(),
78
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(),
79
223
  }
80
224
  }
81
225
  }
@@ -85,6 +229,51 @@ impl Config {
85
229
  pub fn new() -> Self {
86
230
  Self::default()
87
231
  }
232
+
233
+ /// Check if required auth credentials are configured
234
+ ///
235
+ /// Returns true if:
236
+ /// - Both auth_username and auth_password are Some and non-empty (legacy), OR
237
+ /// - The users array is non-empty (PAM-based auth)
238
+ ///
239
+ /// This is used to determine if the setup wizard needs to run.
240
+ pub fn has_required_auth(&self) -> bool {
241
+ // New PAM-based auth: users array
242
+ if !self.users.is_empty() {
243
+ return true;
244
+ }
245
+
246
+ // Legacy basic auth: username/password
247
+ match (&self.auth_username, &self.auth_password) {
248
+ (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
249
+ _ => false,
250
+ }
251
+ }
252
+
253
+ /// Check if the bind address exposes the service to the network
254
+ ///
255
+ /// Returns true if bind_address is "0.0.0.0" (IPv4 all interfaces) or
256
+ /// "::" (IPv6 all interfaces).
257
+ 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, // Invalid addresses are not considered exposed
262
+ }
263
+ }
264
+
265
+ /// Check if the bind address is localhost-only
266
+ ///
267
+ /// Returns true if bind_address is "127.0.0.1", "::1", or "localhost".
268
+ pub fn is_localhost(&self) -> bool {
269
+ match validate_bind_address(&self.bind_address) {
270
+ Ok(ip) => ip.is_loopback(),
271
+ Err(_) => {
272
+ // Also check for "localhost" string directly
273
+ self.bind_address.eq_ignore_ascii_case("localhost")
274
+ }
275
+ }
276
+ }
88
277
  }
89
278
 
90
279
  #[cfg(test)]
@@ -101,6 +290,17 @@ mod tests {
101
290
  assert_eq!(config.boot_mode, "user");
102
291
  assert_eq!(config.restart_retries, 3);
103
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
+ // Security fields
297
+ 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());
104
304
  }
105
305
 
106
306
  #[test]
@@ -122,6 +322,16 @@ mod tests {
122
322
  assert_eq!(config.boot_mode, "user");
123
323
  assert_eq!(config.restart_retries, 3);
124
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
+ // Security fields should have defaults
329
+ 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());
125
335
  }
126
336
 
127
337
  #[test]
@@ -134,6 +344,20 @@ mod tests {
134
344
  boot_mode: "system".to_string(),
135
345
  restart_retries: 5,
136
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(),
137
361
  };
138
362
  let json = serde_json::to_string(&config).unwrap();
139
363
  let parsed: Config = serde_json::from_str(&json).unwrap();
@@ -141,6 +365,10 @@ mod tests {
141
365
  assert_eq!(parsed.boot_mode, "system");
142
366
  assert_eq!(parsed.restart_retries, 5);
143
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"]);
144
372
  }
145
373
 
146
374
  #[test]
@@ -149,4 +377,337 @@ mod tests {
149
377
  let result: Result<Config, _> = serde_json::from_str(json);
150
378
  assert!(result.is_err());
151
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
+ // Tests for validate_bind_address
454
+
455
+ #[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
+ // Tests for is_network_exposed
522
+
523
+ #[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
+ // Tests for is_localhost
557
+
558
+ #[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
+ // Tests for security fields serialization
595
+
596
+ #[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
+ // Tests for Cockpit fields
619
+
620
+ #[test]
621
+ fn test_default_config_cockpit_fields() {
622
+ let config = Config::default();
623
+ assert_eq!(config.cockpit_port, 9090);
624
+ // cockpit_enabled defaults to false (requires Linux host)
625
+ 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
+ // Old configs without cockpit fields should get defaults
644
+ let json = r#"{"version": 1}"#;
645
+ let config: Config = serde_json::from_str(json).unwrap();
646
+ assert_eq!(config.cockpit_port, 9090);
647
+ // cockpit_enabled defaults to false (requires Linux host)
648
+ assert!(!config.cockpit_enabled);
649
+ }
650
+
651
+ // Tests for image_source and update_check fields
652
+
653
+ #[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
+ // Old configs without image fields should get defaults
676
+ 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
+ // Tests for mounts field
683
+
684
+ #[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
+ // Old configs without mounts field should get empty vec
709
+ let json = r#"{"version": 1}"#;
710
+ let config: Config = serde_json::from_str(json).unwrap();
711
+ assert!(config.mounts.is_empty());
712
+ }
152
713
  }