@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
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
//! SSH config file parsing and writing
|
|
2
|
+
//!
|
|
3
|
+
//! Parses ~/.ssh/config to auto-fill host settings and can write new entries.
|
|
4
|
+
|
|
5
|
+
use std::fs::{self, File, OpenOptions};
|
|
6
|
+
use std::io::{BufReader, Write};
|
|
7
|
+
use std::path::PathBuf;
|
|
8
|
+
|
|
9
|
+
use ssh2_config_rs::{ParseRule, SshConfig};
|
|
10
|
+
|
|
11
|
+
use super::error::HostError;
|
|
12
|
+
|
|
13
|
+
/// Settings found in user's SSH config for a host
|
|
14
|
+
#[derive(Debug, Clone, Default)]
|
|
15
|
+
pub struct SshConfigMatch {
|
|
16
|
+
/// User from SSH config
|
|
17
|
+
pub user: Option<String>,
|
|
18
|
+
/// Port from SSH config
|
|
19
|
+
pub port: Option<u16>,
|
|
20
|
+
/// Identity file path from SSH config
|
|
21
|
+
pub identity_file: Option<String>,
|
|
22
|
+
/// ProxyJump (jump host) from SSH config
|
|
23
|
+
pub proxy_jump: Option<String>,
|
|
24
|
+
/// Whether any match was found
|
|
25
|
+
pub matched: bool,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl SshConfigMatch {
|
|
29
|
+
/// Check if any useful settings were found
|
|
30
|
+
pub fn has_settings(&self) -> bool {
|
|
31
|
+
self.user.is_some()
|
|
32
|
+
|| self.port.is_some()
|
|
33
|
+
|| self.identity_file.is_some()
|
|
34
|
+
|| self.proxy_jump.is_some()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Format found settings for display
|
|
38
|
+
pub fn display_settings(&self) -> String {
|
|
39
|
+
let mut parts = Vec::new();
|
|
40
|
+
|
|
41
|
+
if let Some(user) = &self.user {
|
|
42
|
+
parts.push(format!("User={user}"));
|
|
43
|
+
}
|
|
44
|
+
if let Some(port) = self.port {
|
|
45
|
+
parts.push(format!("Port={port}"));
|
|
46
|
+
}
|
|
47
|
+
if let Some(key) = &self.identity_file {
|
|
48
|
+
parts.push(format!("IdentityFile={key}"));
|
|
49
|
+
}
|
|
50
|
+
if let Some(jump) = &self.proxy_jump {
|
|
51
|
+
parts.push(format!("ProxyJump={jump}"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
parts.join(", ")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Get the path to the user's SSH config file
|
|
59
|
+
pub fn get_ssh_config_path() -> Option<PathBuf> {
|
|
60
|
+
dirs::home_dir().map(|home| home.join(".ssh").join("config"))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Parse user's SSH config and query for a hostname
|
|
64
|
+
///
|
|
65
|
+
/// Returns settings found for the given hostname, applying SSH config
|
|
66
|
+
/// precedence rules (first match wins).
|
|
67
|
+
pub fn query_ssh_config(hostname: &str) -> Result<SshConfigMatch, HostError> {
|
|
68
|
+
let config_path = match get_ssh_config_path() {
|
|
69
|
+
Some(path) if path.exists() => path,
|
|
70
|
+
_ => {
|
|
71
|
+
tracing::debug!("No SSH config file found");
|
|
72
|
+
return Ok(SshConfigMatch::default());
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let file = File::open(&config_path).map_err(|e| {
|
|
77
|
+
HostError::SshConfigRead(format!("Failed to open {}: {}", config_path.display(), e))
|
|
78
|
+
})?;
|
|
79
|
+
|
|
80
|
+
let mut reader = BufReader::new(file);
|
|
81
|
+
|
|
82
|
+
// Use ALLOW_UNKNOWN_FIELDS to be lenient with SSH config options we don't support
|
|
83
|
+
let config = SshConfig::default()
|
|
84
|
+
.parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
|
|
85
|
+
.map_err(|e| HostError::SshConfigRead(format!("Failed to parse SSH config: {e}")))?;
|
|
86
|
+
|
|
87
|
+
// Query for the hostname
|
|
88
|
+
let params = config.query(hostname);
|
|
89
|
+
|
|
90
|
+
let mut result = SshConfigMatch {
|
|
91
|
+
matched: true,
|
|
92
|
+
..Default::default()
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Extract relevant fields
|
|
96
|
+
if let Some(user) = params.user {
|
|
97
|
+
result.user = Some(user);
|
|
98
|
+
}
|
|
99
|
+
if let Some(port) = params.port {
|
|
100
|
+
result.port = Some(port);
|
|
101
|
+
}
|
|
102
|
+
if let Some(files) = params.identity_file {
|
|
103
|
+
// SSH config can have multiple identity files; take the first
|
|
104
|
+
if let Some(first) = files.first() {
|
|
105
|
+
result.identity_file = Some(first.to_string_lossy().to_string());
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if let Some(jump) = params.proxy_jump {
|
|
109
|
+
// SSH config can have multiple jump hosts chained; join them
|
|
110
|
+
if !jump.is_empty() {
|
|
111
|
+
result.proxy_jump = Some(jump.join(","));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if we actually found anything useful
|
|
116
|
+
if !result.has_settings() {
|
|
117
|
+
result.matched = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
Ok(result)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Write a new host entry to the user's SSH config file
|
|
124
|
+
///
|
|
125
|
+
/// Appends a Host block to ~/.ssh/config with the provided settings.
|
|
126
|
+
/// Creates the file and directory if they don't exist.
|
|
127
|
+
pub fn write_ssh_config_entry(
|
|
128
|
+
alias: &str,
|
|
129
|
+
hostname: &str,
|
|
130
|
+
user: Option<&str>,
|
|
131
|
+
port: Option<u16>,
|
|
132
|
+
identity_file: Option<&str>,
|
|
133
|
+
jump_host: Option<&str>,
|
|
134
|
+
) -> Result<PathBuf, HostError> {
|
|
135
|
+
let config_path = get_ssh_config_path().ok_or_else(|| {
|
|
136
|
+
HostError::SshConfigWrite("Could not determine home directory".to_string())
|
|
137
|
+
})?;
|
|
138
|
+
|
|
139
|
+
// Ensure .ssh directory exists with proper permissions
|
|
140
|
+
if let Some(ssh_dir) = config_path.parent() {
|
|
141
|
+
if !ssh_dir.exists() {
|
|
142
|
+
fs::create_dir_all(ssh_dir).map_err(|e| {
|
|
143
|
+
HostError::SshConfigWrite(format!("Failed to create .ssh directory: {e}"))
|
|
144
|
+
})?;
|
|
145
|
+
|
|
146
|
+
// Set directory permissions to 700 on Unix
|
|
147
|
+
#[cfg(unix)]
|
|
148
|
+
{
|
|
149
|
+
use std::os::unix::fs::PermissionsExt;
|
|
150
|
+
let perms = fs::Permissions::from_mode(0o700);
|
|
151
|
+
fs::set_permissions(ssh_dir, perms).map_err(|e| {
|
|
152
|
+
HostError::SshConfigWrite(format!("Failed to set .ssh permissions: {e}"))
|
|
153
|
+
})?;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build the config entry
|
|
159
|
+
let mut entry = String::new();
|
|
160
|
+
entry.push_str(&format!("\n# Added by opencode-cloud for host '{alias}'\n"));
|
|
161
|
+
entry.push_str(&format!("Host {alias}\n"));
|
|
162
|
+
entry.push_str(&format!(" HostName {hostname}\n"));
|
|
163
|
+
|
|
164
|
+
if let Some(u) = user {
|
|
165
|
+
entry.push_str(&format!(" User {u}\n"));
|
|
166
|
+
}
|
|
167
|
+
if let Some(p) = port {
|
|
168
|
+
if p != 22 {
|
|
169
|
+
entry.push_str(&format!(" Port {p}\n"));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if let Some(key) = identity_file {
|
|
173
|
+
entry.push_str(&format!(" IdentityFile {key}\n"));
|
|
174
|
+
}
|
|
175
|
+
if let Some(jump) = jump_host {
|
|
176
|
+
entry.push_str(&format!(" ProxyJump {jump}\n"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Append to config file (create if doesn't exist)
|
|
180
|
+
let mut file = OpenOptions::new()
|
|
181
|
+
.create(true)
|
|
182
|
+
.append(true)
|
|
183
|
+
.open(&config_path)
|
|
184
|
+
.map_err(|e| {
|
|
185
|
+
HostError::SshConfigWrite(format!("Failed to open {}: {}", config_path.display(), e))
|
|
186
|
+
})?;
|
|
187
|
+
|
|
188
|
+
// Set file permissions to 600 on Unix if we just created it
|
|
189
|
+
#[cfg(unix)]
|
|
190
|
+
{
|
|
191
|
+
use std::os::unix::fs::PermissionsExt;
|
|
192
|
+
let metadata = file
|
|
193
|
+
.metadata()
|
|
194
|
+
.map_err(|e| HostError::SshConfigWrite(format!("Failed to get file metadata: {e}")))?;
|
|
195
|
+
if metadata.len() == 0 {
|
|
196
|
+
let perms = fs::Permissions::from_mode(0o600);
|
|
197
|
+
fs::set_permissions(&config_path, perms).map_err(|e| {
|
|
198
|
+
HostError::SshConfigWrite(format!("Failed to set config permissions: {e}"))
|
|
199
|
+
})?;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
file.write_all(entry.as_bytes()).map_err(|e| {
|
|
204
|
+
HostError::SshConfigWrite(format!(
|
|
205
|
+
"Failed to write to {}: {}",
|
|
206
|
+
config_path.display(),
|
|
207
|
+
e
|
|
208
|
+
))
|
|
209
|
+
})?;
|
|
210
|
+
|
|
211
|
+
tracing::info!(
|
|
212
|
+
"Added host '{}' to SSH config at {}",
|
|
213
|
+
alias,
|
|
214
|
+
config_path.display()
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
Ok(config_path)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Check if a host alias already exists in SSH config
|
|
221
|
+
pub fn host_exists_in_ssh_config(alias: &str) -> bool {
|
|
222
|
+
let config_path = match get_ssh_config_path() {
|
|
223
|
+
Some(path) if path.exists() => path,
|
|
224
|
+
_ => return false,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
let Ok(file) = File::open(&config_path) else {
|
|
228
|
+
return false;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
let mut reader = BufReader::new(file);
|
|
232
|
+
|
|
233
|
+
let Ok(config) = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
|
|
234
|
+
else {
|
|
235
|
+
return false;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Query returns default params if not found, so we check if hostname is set
|
|
239
|
+
let params = config.query(alias);
|
|
240
|
+
params.host_name.is_some()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[cfg(test)]
|
|
244
|
+
mod tests {
|
|
245
|
+
use super::*;
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn test_ssh_config_match_display() {
|
|
249
|
+
let m = SshConfigMatch {
|
|
250
|
+
user: Some("ubuntu".to_string()),
|
|
251
|
+
port: Some(2222),
|
|
252
|
+
identity_file: Some("~/.ssh/mykey.pem".to_string()),
|
|
253
|
+
proxy_jump: None,
|
|
254
|
+
matched: true,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
let display = m.display_settings();
|
|
258
|
+
assert!(display.contains("User=ubuntu"));
|
|
259
|
+
assert!(display.contains("Port=2222"));
|
|
260
|
+
assert!(display.contains("IdentityFile=~/.ssh/mykey.pem"));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[test]
|
|
264
|
+
fn test_ssh_config_match_has_settings() {
|
|
265
|
+
let empty = SshConfigMatch::default();
|
|
266
|
+
assert!(!empty.has_settings());
|
|
267
|
+
|
|
268
|
+
let with_user = SshConfigMatch {
|
|
269
|
+
user: Some("test".to_string()),
|
|
270
|
+
..Default::default()
|
|
271
|
+
};
|
|
272
|
+
assert!(with_user.has_settings());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#[test]
|
|
276
|
+
fn test_get_ssh_config_path() {
|
|
277
|
+
let path = get_ssh_config_path();
|
|
278
|
+
assert!(path.is_some());
|
|
279
|
+
let path = path.unwrap();
|
|
280
|
+
assert!(path.ends_with(".ssh/config"));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//! Host configuration storage
|
|
2
|
+
//!
|
|
3
|
+
//! Load and save hosts.json file.
|
|
4
|
+
|
|
5
|
+
use std::fs::{self, File};
|
|
6
|
+
use std::io::{Read, Write};
|
|
7
|
+
|
|
8
|
+
use super::error::HostError;
|
|
9
|
+
use super::schema::HostsFile;
|
|
10
|
+
use crate::config::paths::get_hosts_path;
|
|
11
|
+
|
|
12
|
+
/// Load hosts configuration from hosts.json
|
|
13
|
+
///
|
|
14
|
+
/// Returns empty HostsFile if file doesn't exist.
|
|
15
|
+
pub fn load_hosts() -> Result<HostsFile, HostError> {
|
|
16
|
+
let hosts_path = get_hosts_path()
|
|
17
|
+
.ok_or_else(|| HostError::LoadFailed("Could not determine hosts file path".to_string()))?;
|
|
18
|
+
|
|
19
|
+
if !hosts_path.exists() {
|
|
20
|
+
tracing::debug!(
|
|
21
|
+
"Hosts file not found, returning empty: {}",
|
|
22
|
+
hosts_path.display()
|
|
23
|
+
);
|
|
24
|
+
return Ok(HostsFile::new());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let mut file = File::open(&hosts_path).map_err(|e| {
|
|
28
|
+
HostError::LoadFailed(format!("Failed to open {}: {}", hosts_path.display(), e))
|
|
29
|
+
})?;
|
|
30
|
+
|
|
31
|
+
let mut contents = String::new();
|
|
32
|
+
file.read_to_string(&mut contents).map_err(|e| {
|
|
33
|
+
HostError::LoadFailed(format!("Failed to read {}: {}", hosts_path.display(), e))
|
|
34
|
+
})?;
|
|
35
|
+
|
|
36
|
+
let hosts: HostsFile = serde_json::from_str(&contents).map_err(|e| {
|
|
37
|
+
HostError::LoadFailed(format!("Invalid JSON in {}: {}", hosts_path.display(), e))
|
|
38
|
+
})?;
|
|
39
|
+
|
|
40
|
+
tracing::debug!(
|
|
41
|
+
"Loaded {} hosts from {}",
|
|
42
|
+
hosts.hosts.len(),
|
|
43
|
+
hosts_path.display()
|
|
44
|
+
);
|
|
45
|
+
Ok(hosts)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Save hosts configuration to hosts.json
|
|
49
|
+
///
|
|
50
|
+
/// Creates the config directory if it doesn't exist.
|
|
51
|
+
/// Creates a backup (.bak) if file already exists.
|
|
52
|
+
pub fn save_hosts(hosts: &HostsFile) -> Result<(), HostError> {
|
|
53
|
+
let hosts_path = get_hosts_path()
|
|
54
|
+
.ok_or_else(|| HostError::SaveFailed("Could not determine hosts file path".to_string()))?;
|
|
55
|
+
|
|
56
|
+
// Ensure config directory exists
|
|
57
|
+
if let Some(parent) = hosts_path.parent() {
|
|
58
|
+
if !parent.exists() {
|
|
59
|
+
fs::create_dir_all(parent)
|
|
60
|
+
.map_err(|e| HostError::SaveFailed(format!("Failed to create directory: {e}")))?;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create backup if file exists
|
|
65
|
+
if hosts_path.exists() {
|
|
66
|
+
let backup_path = hosts_path.with_extension("json.bak");
|
|
67
|
+
fs::copy(&hosts_path, &backup_path)
|
|
68
|
+
.map_err(|e| HostError::SaveFailed(format!("Failed to create backup: {e}")))?;
|
|
69
|
+
tracing::debug!("Created hosts backup: {}", backup_path.display());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Serialize with pretty formatting
|
|
73
|
+
let json = serde_json::to_string_pretty(hosts)
|
|
74
|
+
.map_err(|e| HostError::SaveFailed(format!("Failed to serialize: {e}")))?;
|
|
75
|
+
|
|
76
|
+
// Write to file
|
|
77
|
+
let mut file = File::create(&hosts_path).map_err(|e| {
|
|
78
|
+
HostError::SaveFailed(format!("Failed to create {}: {}", hosts_path.display(), e))
|
|
79
|
+
})?;
|
|
80
|
+
|
|
81
|
+
file.write_all(json.as_bytes()).map_err(|e| {
|
|
82
|
+
HostError::SaveFailed(format!("Failed to write {}: {}", hosts_path.display(), e))
|
|
83
|
+
})?;
|
|
84
|
+
|
|
85
|
+
tracing::debug!(
|
|
86
|
+
"Saved {} hosts to {}",
|
|
87
|
+
hosts.hosts.len(),
|
|
88
|
+
hosts_path.display()
|
|
89
|
+
);
|
|
90
|
+
Ok(())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[cfg(test)]
|
|
94
|
+
mod tests {
|
|
95
|
+
use super::*;
|
|
96
|
+
use crate::host::schema::HostConfig;
|
|
97
|
+
|
|
98
|
+
#[test]
|
|
99
|
+
fn test_load_nonexistent_returns_empty() {
|
|
100
|
+
// This test relies on hosts.json not existing in a fresh environment
|
|
101
|
+
// In CI/testing, we'd mock the path, but for basic test:
|
|
102
|
+
let result = load_hosts();
|
|
103
|
+
// Should succeed with empty or existing hosts
|
|
104
|
+
assert!(result.is_ok());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#[test]
|
|
108
|
+
fn test_serialize_format() {
|
|
109
|
+
let mut hosts = HostsFile::new();
|
|
110
|
+
hosts.add_host("test", HostConfig::new("test.example.com"));
|
|
111
|
+
|
|
112
|
+
let json = serde_json::to_string_pretty(&hosts).unwrap();
|
|
113
|
+
|
|
114
|
+
// Verify it's valid JSON that can be read back
|
|
115
|
+
let parsed: HostsFile = serde_json::from_str(&json).unwrap();
|
|
116
|
+
assert!(parsed.has_host("test"));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
//! SSH tunnel management
|
|
2
|
+
//!
|
|
3
|
+
//! Creates and manages SSH tunnels to remote Docker daemons.
|
|
4
|
+
|
|
5
|
+
use std::net::TcpListener;
|
|
6
|
+
use std::process::{Child, Command, Stdio};
|
|
7
|
+
use std::time::Duration;
|
|
8
|
+
|
|
9
|
+
use super::error::HostError;
|
|
10
|
+
use super::schema::HostConfig;
|
|
11
|
+
|
|
12
|
+
/// SSH tunnel to a remote Docker daemon
|
|
13
|
+
///
|
|
14
|
+
/// The tunnel forwards a local port to the remote Docker socket.
|
|
15
|
+
/// Implements Drop to ensure the SSH process is killed on cleanup.
|
|
16
|
+
pub struct SshTunnel {
|
|
17
|
+
child: Child,
|
|
18
|
+
local_port: u16,
|
|
19
|
+
host_name: String,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl SshTunnel {
|
|
23
|
+
/// Create SSH tunnel to remote Docker socket
|
|
24
|
+
///
|
|
25
|
+
/// Spawns an SSH process with local port forwarding:
|
|
26
|
+
/// `ssh -L local_port:/var/run/docker.sock -N host`
|
|
27
|
+
///
|
|
28
|
+
/// Uses BatchMode=yes to fail fast if key not in agent.
|
|
29
|
+
pub fn new(host: &HostConfig, host_name: &str) -> Result<Self, HostError> {
|
|
30
|
+
// Find available local port
|
|
31
|
+
let local_port = find_available_port()?;
|
|
32
|
+
|
|
33
|
+
// Build SSH command
|
|
34
|
+
let mut cmd = Command::new("ssh");
|
|
35
|
+
|
|
36
|
+
// Local port forward: local_port -> remote docker.sock
|
|
37
|
+
cmd.arg("-L")
|
|
38
|
+
.arg(format!("{local_port}:/var/run/docker.sock"));
|
|
39
|
+
|
|
40
|
+
// No command, just forward
|
|
41
|
+
cmd.arg("-N");
|
|
42
|
+
|
|
43
|
+
// Suppress prompts, fail fast on auth issues
|
|
44
|
+
cmd.arg("-o").arg("BatchMode=yes");
|
|
45
|
+
|
|
46
|
+
// Accept new host keys automatically (first connection)
|
|
47
|
+
cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
|
|
48
|
+
|
|
49
|
+
// Connection timeout
|
|
50
|
+
cmd.arg("-o").arg("ConnectTimeout=10");
|
|
51
|
+
|
|
52
|
+
// Prevent SSH from reading stdin (fixes issues with background operation)
|
|
53
|
+
cmd.arg("-o").arg("RequestTTY=no");
|
|
54
|
+
|
|
55
|
+
// Jump host support
|
|
56
|
+
if let Some(jump) = &host.jump_host {
|
|
57
|
+
cmd.arg("-J").arg(jump);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Identity file
|
|
61
|
+
if let Some(key) = &host.identity_file {
|
|
62
|
+
cmd.arg("-i").arg(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Custom port
|
|
66
|
+
if let Some(port) = host.port {
|
|
67
|
+
cmd.arg("-p").arg(port.to_string());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Target: user@hostname
|
|
71
|
+
cmd.arg(format!("{}@{}", host.user, host.hostname));
|
|
72
|
+
|
|
73
|
+
// Configure stdio
|
|
74
|
+
cmd.stdin(Stdio::null())
|
|
75
|
+
.stdout(Stdio::null())
|
|
76
|
+
.stderr(Stdio::piped());
|
|
77
|
+
|
|
78
|
+
tracing::debug!(
|
|
79
|
+
"Spawning SSH tunnel: ssh -L {}:/var/run/docker.sock {}@{}",
|
|
80
|
+
local_port,
|
|
81
|
+
host.user,
|
|
82
|
+
host.hostname
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
let child = cmd.spawn().map_err(|e| {
|
|
86
|
+
if e.kind() == std::io::ErrorKind::NotFound {
|
|
87
|
+
HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
|
|
88
|
+
} else {
|
|
89
|
+
HostError::SshSpawn(e.to_string())
|
|
90
|
+
}
|
|
91
|
+
})?;
|
|
92
|
+
|
|
93
|
+
Ok(Self {
|
|
94
|
+
child,
|
|
95
|
+
local_port,
|
|
96
|
+
host_name: host_name.to_string(),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Get the local port for Docker connection
|
|
101
|
+
pub fn local_port(&self) -> u16 {
|
|
102
|
+
self.local_port
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Get the Docker connection URL
|
|
106
|
+
pub fn docker_url(&self) -> String {
|
|
107
|
+
format!("tcp://127.0.0.1:{}", self.local_port)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Get the host name this tunnel connects to
|
|
111
|
+
pub fn host_name(&self) -> &str {
|
|
112
|
+
&self.host_name
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Wait for tunnel to be ready (port accepting connections)
|
|
116
|
+
///
|
|
117
|
+
/// Retries with exponential backoff: 100ms, 200ms, 400ms (3 attempts)
|
|
118
|
+
pub async fn wait_ready(&self) -> Result<(), HostError> {
|
|
119
|
+
let max_attempts = 3;
|
|
120
|
+
let initial_delay_ms = 100;
|
|
121
|
+
|
|
122
|
+
for attempt in 0..max_attempts {
|
|
123
|
+
if attempt > 0 {
|
|
124
|
+
let delay = Duration::from_millis(initial_delay_ms * 2u64.pow(attempt));
|
|
125
|
+
tracing::debug!("Tunnel wait attempt {} after {:?}", attempt + 1, delay);
|
|
126
|
+
tokio::time::sleep(delay).await;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Try to connect to the local port
|
|
130
|
+
match std::net::TcpStream::connect_timeout(
|
|
131
|
+
&format!("127.0.0.1:{}", self.local_port).parse().unwrap(),
|
|
132
|
+
Duration::from_secs(1),
|
|
133
|
+
) {
|
|
134
|
+
Ok(_) => {
|
|
135
|
+
tracing::debug!("SSH tunnel ready on port {}", self.local_port);
|
|
136
|
+
return Ok(());
|
|
137
|
+
}
|
|
138
|
+
Err(e) => {
|
|
139
|
+
tracing::debug!("Tunnel not ready: {}", e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
Err(HostError::TunnelTimeout(max_attempts))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Check if the SSH process is still running
|
|
148
|
+
pub fn is_alive(&mut self) -> bool {
|
|
149
|
+
matches!(self.child.try_wait(), Ok(None))
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
impl Drop for SshTunnel {
|
|
154
|
+
fn drop(&mut self) {
|
|
155
|
+
tracing::debug!(
|
|
156
|
+
"Cleaning up SSH tunnel to {} (port {})",
|
|
157
|
+
self.host_name,
|
|
158
|
+
self.local_port
|
|
159
|
+
);
|
|
160
|
+
if let Err(e) = self.child.kill() {
|
|
161
|
+
// Process may have already exited
|
|
162
|
+
tracing::debug!("SSH tunnel kill result: {}", e);
|
|
163
|
+
}
|
|
164
|
+
// Wait to reap the zombie process
|
|
165
|
+
let _ = self.child.wait();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Find an available local port for the tunnel
|
|
170
|
+
fn find_available_port() -> Result<u16, HostError> {
|
|
171
|
+
// Bind to port 0 to get OS-assigned port
|
|
172
|
+
let listener =
|
|
173
|
+
TcpListener::bind("127.0.0.1:0").map_err(|e| HostError::PortAllocation(e.to_string()))?;
|
|
174
|
+
|
|
175
|
+
let port = listener
|
|
176
|
+
.local_addr()
|
|
177
|
+
.map_err(|e| HostError::PortAllocation(e.to_string()))?
|
|
178
|
+
.port();
|
|
179
|
+
|
|
180
|
+
// Drop listener to free the port
|
|
181
|
+
drop(listener);
|
|
182
|
+
|
|
183
|
+
Ok(port)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Test SSH connection to a host
|
|
187
|
+
///
|
|
188
|
+
/// Runs `ssh user@host docker version` to verify:
|
|
189
|
+
/// 1. SSH connection works
|
|
190
|
+
/// 2. Docker is available on remote
|
|
191
|
+
pub async fn test_connection(host: &HostConfig) -> Result<String, HostError> {
|
|
192
|
+
let mut cmd = Command::new("ssh");
|
|
193
|
+
|
|
194
|
+
// Standard options
|
|
195
|
+
cmd.arg("-o")
|
|
196
|
+
.arg("BatchMode=yes")
|
|
197
|
+
.arg("-o")
|
|
198
|
+
.arg("ConnectTimeout=10")
|
|
199
|
+
.arg("-o")
|
|
200
|
+
.arg("StrictHostKeyChecking=accept-new");
|
|
201
|
+
|
|
202
|
+
// Host-specific options (port, identity, jump, user@host)
|
|
203
|
+
cmd.args(host.ssh_args());
|
|
204
|
+
|
|
205
|
+
// Docker version command
|
|
206
|
+
cmd.arg("docker")
|
|
207
|
+
.arg("version")
|
|
208
|
+
.arg("--format")
|
|
209
|
+
.arg("{{.Server.Version}}");
|
|
210
|
+
|
|
211
|
+
cmd.stdin(Stdio::null())
|
|
212
|
+
.stdout(Stdio::piped())
|
|
213
|
+
.stderr(Stdio::piped());
|
|
214
|
+
|
|
215
|
+
let output = cmd.output().map_err(|e| {
|
|
216
|
+
if e.kind() == std::io::ErrorKind::NotFound {
|
|
217
|
+
HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
|
|
218
|
+
} else {
|
|
219
|
+
HostError::SshSpawn(e.to_string())
|
|
220
|
+
}
|
|
221
|
+
})?;
|
|
222
|
+
|
|
223
|
+
if output.status.success() {
|
|
224
|
+
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
225
|
+
tracing::info!("Docker version on remote: {}", version);
|
|
226
|
+
Ok(version)
|
|
227
|
+
} else {
|
|
228
|
+
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
229
|
+
|
|
230
|
+
// Detect authentication failures
|
|
231
|
+
if stderr.contains("Permission denied") || stderr.contains("Host key verification failed") {
|
|
232
|
+
return Err(HostError::AuthFailed {
|
|
233
|
+
key_hint: host.identity_file.clone(),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Detect Docker not available
|
|
238
|
+
if stderr.contains("command not found") || stderr.contains("not found") {
|
|
239
|
+
return Err(HostError::RemoteDockerUnavailable(
|
|
240
|
+
"Docker is not installed on remote host".to_string(),
|
|
241
|
+
));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
Err(HostError::ConnectionFailed(stderr.to_string()))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[cfg(test)]
|
|
249
|
+
mod tests {
|
|
250
|
+
use super::*;
|
|
251
|
+
|
|
252
|
+
#[test]
|
|
253
|
+
fn test_find_available_port() {
|
|
254
|
+
let port = find_available_port().unwrap();
|
|
255
|
+
assert!(port > 0);
|
|
256
|
+
|
|
257
|
+
// Port should be available (we can bind to it)
|
|
258
|
+
let listener = TcpListener::bind(format!("127.0.0.1:{port}"));
|
|
259
|
+
assert!(listener.is_ok());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#[test]
|
|
263
|
+
fn test_docker_url_format() {
|
|
264
|
+
// We can't easily test tunnel creation without SSH, but we can test the URL format
|
|
265
|
+
let url = format!("tcp://127.0.0.1:{}", 12345);
|
|
266
|
+
assert_eq!(url, "tcp://127.0.0.1:12345");
|
|
267
|
+
}
|
|
268
|
+
}
|
package/src/lib.rs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
pub mod config;
|
|
7
7
|
pub mod docker;
|
|
8
|
+
pub mod host;
|
|
8
9
|
pub mod platform;
|
|
9
10
|
pub mod singleton;
|
|
10
11
|
pub mod version;
|
|
@@ -13,7 +14,7 @@ pub mod version;
|
|
|
13
14
|
pub use version::{get_version, get_version_long};
|
|
14
15
|
|
|
15
16
|
// Re-export config types and functions
|
|
16
|
-
pub use config::{Config, load_config, save_config};
|
|
17
|
+
pub use config::{Config, get_hosts_path, load_config, save_config};
|
|
17
18
|
|
|
18
19
|
// Re-export singleton types
|
|
19
20
|
pub use singleton::{InstanceLock, SingletonError};
|
|
@@ -27,6 +28,14 @@ pub use platform::{
|
|
|
27
28
|
is_service_registration_supported,
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// Re-export host types
|
|
32
|
+
pub use host::{
|
|
33
|
+
DistroFamily, DistroInfo, HostConfig, HostError, HostsFile, SshConfigMatch, SshTunnel,
|
|
34
|
+
detect_distro, get_docker_install_commands, get_ssh_config_path, host_exists_in_ssh_config,
|
|
35
|
+
install_docker, load_hosts, query_ssh_config, save_hosts, test_connection,
|
|
36
|
+
verify_docker_installed, write_ssh_config_entry,
|
|
37
|
+
};
|
|
38
|
+
|
|
30
39
|
// Re-export bollard to ensure all crates use the same version
|
|
31
40
|
pub use bollard;
|
|
32
41
|
|
package/src/platform/launchd.rs
CHANGED