@opencode-cloud/core 1.0.8 → 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.
- package/Cargo.toml +9 -2
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/config/mod.rs +8 -3
- package/src/config/paths.rs +14 -0
- package/src/config/schema.rs +470 -0
- package/src/config/validation.rs +271 -0
- package/src/docker/Dockerfile +278 -153
- package/src/docker/client.rs +132 -3
- package/src/docker/container.rs +90 -33
- 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 +47 -4
- package/src/docker/progress.rs +4 -4
- 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/Cargo.toml
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "opencode-cloud-core"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.10"
|
|
4
4
|
edition = "2024"
|
|
5
|
-
rust-version = "1.
|
|
5
|
+
rust-version = "1.88"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
repository = "https://github.com/pRizz/opencode-cloud"
|
|
8
8
|
homepage = "https://github.com/pRizz/opencode-cloud"
|
|
@@ -32,6 +32,7 @@ thiserror = "2"
|
|
|
32
32
|
anyhow = "1"
|
|
33
33
|
tracing = "0.1"
|
|
34
34
|
console = "0.16"
|
|
35
|
+
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
|
|
35
36
|
|
|
36
37
|
# NAPI dependencies (optional - only for Node bindings)
|
|
37
38
|
napi = { version = "2", features = ["tokio_rt", "napi9"], optional = true }
|
|
@@ -46,10 +47,16 @@ tokio-retry = "0.3"
|
|
|
46
47
|
indicatif = { version = "0.17", features = ["tokio", "futures"] }
|
|
47
48
|
http-body-util = "0.1"
|
|
48
49
|
bytes = "1.9"
|
|
50
|
+
reqwest = { version = "0.12", features = ["json"] }
|
|
49
51
|
|
|
50
52
|
# Platform service management (macOS)
|
|
51
53
|
plist = "1.8"
|
|
52
54
|
|
|
55
|
+
# Host management
|
|
56
|
+
whoami = "1.5"
|
|
57
|
+
ssh2-config = "0.6"
|
|
58
|
+
dirs = "6"
|
|
59
|
+
|
|
53
60
|
[build-dependencies]
|
|
54
61
|
napi-build = "2"
|
|
55
62
|
|
package/README.md
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
A production-ready toolkit for deploying [opencode](https://github.com/anomalyco/opencode) as a persistent cloud service.
|
|
11
11
|
|
|
12
|
+
## Quick install (cargo)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cargo install opencode-cloud
|
|
16
|
+
opencode-cloud --version
|
|
17
|
+
```
|
|
18
|
+
|
|
12
19
|
## Features
|
|
13
20
|
|
|
14
21
|
- Cross-platform CLI (`opencode-cloud` / `occ`)
|
package/package.json
CHANGED
package/src/config/mod.rs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
pub mod paths;
|
|
7
7
|
pub mod schema;
|
|
8
|
+
pub mod validation;
|
|
8
9
|
|
|
9
10
|
use std::fs::{self, File};
|
|
10
11
|
use std::io::{Read, Write};
|
|
@@ -13,8 +14,12 @@ use std::path::PathBuf;
|
|
|
13
14
|
use anyhow::{Context, Result};
|
|
14
15
|
use jsonc_parser::parse_to_serde_value;
|
|
15
16
|
|
|
16
|
-
pub use paths::{get_config_dir, get_config_path, get_data_dir, get_pid_path};
|
|
17
|
-
pub use schema::Config;
|
|
17
|
+
pub use paths::{get_config_dir, get_config_path, get_data_dir, get_hosts_path, get_pid_path};
|
|
18
|
+
pub use schema::{Config, validate_bind_address};
|
|
19
|
+
pub use validation::{
|
|
20
|
+
ValidationError, ValidationWarning, display_validation_error, display_validation_warning,
|
|
21
|
+
validate_config,
|
|
22
|
+
};
|
|
18
23
|
|
|
19
24
|
/// Ensure the config directory exists
|
|
20
25
|
///
|
|
@@ -84,7 +89,7 @@ pub fn load_config() -> Result<Config> {
|
|
|
84
89
|
|
|
85
90
|
// Parse JSONC (JSON with comments)
|
|
86
91
|
let parsed_value = parse_to_serde_value(&contents, &Default::default())
|
|
87
|
-
.map_err(|e| anyhow::anyhow!("Invalid JSONC in config file: {}"
|
|
92
|
+
.map_err(|e| anyhow::anyhow!("Invalid JSONC in config file: {e}"))?
|
|
88
93
|
.ok_or_else(|| anyhow::anyhow!("Config file is empty"))?;
|
|
89
94
|
|
|
90
95
|
// Deserialize into Config struct (deny_unknown_fields will reject unknown keys)
|
package/src/config/paths.rs
CHANGED
|
@@ -72,6 +72,13 @@ pub fn get_pid_path() -> Option<PathBuf> {
|
|
|
72
72
|
get_data_dir().map(|d| d.join("opencode-cloud.pid"))
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/// Get the full path to the hosts configuration file
|
|
76
|
+
///
|
|
77
|
+
/// Returns: `{config_dir}/hosts.json`
|
|
78
|
+
pub fn get_hosts_path() -> Option<PathBuf> {
|
|
79
|
+
get_config_dir().map(|d| d.join("hosts.json"))
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
#[cfg(test)]
|
|
76
83
|
mod tests {
|
|
77
84
|
use super::*;
|
|
@@ -105,4 +112,11 @@ mod tests {
|
|
|
105
112
|
assert!(path.is_some());
|
|
106
113
|
assert!(path.unwrap().ends_with("opencode-cloud.pid"));
|
|
107
114
|
}
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn test_hosts_path_ends_with_hosts_json() {
|
|
118
|
+
let path = get_hosts_path();
|
|
119
|
+
assert!(path.is_some());
|
|
120
|
+
assert!(path.unwrap().ends_with("hosts.json"));
|
|
121
|
+
}
|
|
108
122
|
}
|
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,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
|
}
|