@opencode-cloud/core 1.0.7 → 1.0.10

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,65 @@ 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,
43
103
  }
44
104
 
45
105
  fn default_opencode_web_port() -> u16 {
@@ -66,6 +126,55 @@ fn default_restart_delay() -> u32 {
66
126
  5
67
127
  }
68
128
 
129
+ fn default_bind_address() -> String {
130
+ "127.0.0.1".to_string()
131
+ }
132
+
133
+ fn default_rate_limit_attempts() -> u32 {
134
+ 5
135
+ }
136
+
137
+ fn default_rate_limit_window() -> u32 {
138
+ 60
139
+ }
140
+
141
+ fn default_cockpit_port() -> u16 {
142
+ 9090
143
+ }
144
+
145
+ fn default_cockpit_enabled() -> bool {
146
+ false
147
+ }
148
+
149
+ /// Validate and parse a bind address string
150
+ ///
151
+ /// Accepts:
152
+ /// - IPv4 addresses: "127.0.0.1", "0.0.0.0"
153
+ /// - IPv6 addresses: "::1", "::"
154
+ /// - Bracketed IPv6: "[::1]"
155
+ /// - "localhost" (resolves to 127.0.0.1)
156
+ ///
157
+ /// Returns the parsed IpAddr or an error message.
158
+ pub fn validate_bind_address(addr: &str) -> Result<IpAddr, String> {
159
+ let trimmed = addr.trim();
160
+
161
+ // Handle "localhost" as special case
162
+ if trimmed.eq_ignore_ascii_case("localhost") {
163
+ return Ok(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
164
+ }
165
+
166
+ // Strip brackets from IPv6 addresses like "[::1]"
167
+ 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
+
69
178
  impl Default for Config {
70
179
  fn default() -> Self {
71
180
  Self {
@@ -76,6 +185,17 @@ impl Default for Config {
76
185
  boot_mode: default_boot_mode(),
77
186
  restart_retries: default_restart_retries(),
78
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(),
79
199
  }
80
200
  }
81
201
  }
@@ -85,6 +205,51 @@ impl Config {
85
205
  pub fn new() -> Self {
86
206
  Self::default()
87
207
  }
208
+
209
+ /// Check if required auth credentials are configured
210
+ ///
211
+ /// Returns true if:
212
+ /// - Both auth_username and auth_password are Some and non-empty (legacy), OR
213
+ /// - The users array is non-empty (PAM-based auth)
214
+ ///
215
+ /// This is used to determine if the setup wizard needs to run.
216
+ pub fn has_required_auth(&self) -> bool {
217
+ // New PAM-based auth: users array
218
+ if !self.users.is_empty() {
219
+ return true;
220
+ }
221
+
222
+ // Legacy basic auth: username/password
223
+ match (&self.auth_username, &self.auth_password) {
224
+ (Some(username), Some(password)) => !username.is_empty() && !password.is_empty(),
225
+ _ => false,
226
+ }
227
+ }
228
+
229
+ /// Check if the bind address exposes the service to the network
230
+ ///
231
+ /// Returns true if bind_address is "0.0.0.0" (IPv4 all interfaces) or
232
+ /// "::" (IPv6 all interfaces).
233
+ 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, // Invalid addresses are not considered exposed
238
+ }
239
+ }
240
+
241
+ /// Check if the bind address is localhost-only
242
+ ///
243
+ /// Returns true if bind_address is "127.0.0.1", "::1", or "localhost".
244
+ pub fn is_localhost(&self) -> bool {
245
+ match validate_bind_address(&self.bind_address) {
246
+ Ok(ip) => ip.is_loopback(),
247
+ Err(_) => {
248
+ // Also check for "localhost" string directly
249
+ self.bind_address.eq_ignore_ascii_case("localhost")
250
+ }
251
+ }
252
+ }
88
253
  }
89
254
 
90
255
  #[cfg(test)]
@@ -101,6 +266,16 @@ mod tests {
101
266
  assert_eq!(config.boot_mode, "user");
102
267
  assert_eq!(config.restart_retries, 3);
103
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
+ // Security fields
273
+ 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());
104
279
  }
105
280
 
106
281
  #[test]
@@ -122,6 +297,16 @@ mod tests {
122
297
  assert_eq!(config.boot_mode, "user");
123
298
  assert_eq!(config.restart_retries, 3);
124
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
+ // Security fields should have defaults
304
+ 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());
125
310
  }
126
311
 
127
312
  #[test]
@@ -134,6 +319,17 @@ mod tests {
134
319
  boot_mode: "system".to_string(),
135
320
  restart_retries: 5,
136
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,
137
333
  };
138
334
  let json = serde_json::to_string(&config).unwrap();
139
335
  let parsed: Config = serde_json::from_str(&json).unwrap();
@@ -141,6 +337,10 @@ mod tests {
141
337
  assert_eq!(parsed.boot_mode, "system");
142
338
  assert_eq!(parsed.restart_retries, 5);
143
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"]);
144
344
  }
145
345
 
146
346
  #[test]
@@ -149,4 +349,274 @@ mod tests {
149
349
  let result: Result<Config, _> = serde_json::from_str(json);
150
350
  assert!(result.is_err());
151
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
+ // Tests for validate_bind_address
426
+
427
+ #[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
+ // Tests for is_network_exposed
494
+
495
+ #[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
+ // Tests for is_localhost
529
+
530
+ #[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
+ // Tests for security fields serialization
567
+
568
+ #[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
+ // Tests for Cockpit fields
591
+
592
+ #[test]
593
+ fn test_default_config_cockpit_fields() {
594
+ let config = Config::default();
595
+ assert_eq!(config.cockpit_port, 9090);
596
+ // cockpit_enabled defaults to false (requires Linux host)
597
+ 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
+ // Old configs without cockpit fields should get defaults
616
+ let json = r#"{"version": 1}"#;
617
+ let config: Config = serde_json::from_str(json).unwrap();
618
+ assert_eq!(config.cockpit_port, 9090);
619
+ // cockpit_enabled defaults to false (requires Linux host)
620
+ assert!(!config.cockpit_enabled);
621
+ }
152
622
  }