@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.
- package/Cargo.toml +9 -2
- package/README.md +56 -30
- package/package.json +1 -1
- package/src/config/mod.rs +8 -3
- package/src/config/paths.rs +14 -0
- package/src/config/schema.rs +561 -0
- package/src/config/validation.rs +271 -0
- package/src/docker/Dockerfile +353 -207
- package/src/docker/README.dockerhub.md +39 -0
- package/src/docker/client.rs +132 -3
- package/src/docker/container.rs +204 -36
- package/src/docker/dockerfile.rs +15 -12
- package/src/docker/exec.rs +278 -0
- package/src/docker/health.rs +165 -0
- package/src/docker/image.rs +2 -4
- package/src/docker/mod.rs +72 -8
- package/src/docker/mount.rs +330 -0
- package/src/docker/progress.rs +4 -4
- package/src/docker/state.rs +120 -0
- package/src/docker/update.rs +156 -0
- package/src/docker/users.rs +357 -0
- package/src/docker/version.rs +95 -0
- package/src/host/error.rs +61 -0
- package/src/host/mod.rs +29 -0
- package/src/host/provision.rs +394 -0
- package/src/host/schema.rs +308 -0
- package/src/host/ssh_config.rs +282 -0
- package/src/host/storage.rs +118 -0
- package/src/host/tunnel.rs +268 -0
- package/src/lib.rs +10 -1
- package/src/platform/launchd.rs +1 -1
- package/src/platform/systemd.rs +6 -6
- package/src/singleton/mod.rs +1 -1
- package/src/version.rs +1 -6
package/src/config/schema.rs
CHANGED
|
@@ -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
|
}
|