opencode_cloud_core/host/
tunnel.rs1use std::net::TcpListener;
6use std::process::{Child, Command, Stdio};
7use std::time::Duration;
8
9use super::error::HostError;
10use super::schema::HostConfig;
11
12pub struct SshTunnel {
17 child: Child,
18 local_port: u16,
19 host_name: String,
20}
21
22impl SshTunnel {
23 pub fn new(host: &HostConfig, host_name: &str) -> Result<Self, HostError> {
30 let local_port = find_available_port()?;
32
33 let mut cmd = Command::new("ssh");
35
36 cmd.arg("-L")
38 .arg(format!("{local_port}:/var/run/docker.sock"));
39
40 cmd.arg("-N");
42
43 cmd.arg("-o").arg("BatchMode=yes");
45
46 cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
48
49 cmd.arg("-o").arg("ConnectTimeout=10");
51
52 cmd.arg("-o").arg("RequestTTY=no");
54
55 if let Some(jump) = &host.jump_host {
57 cmd.arg("-J").arg(jump);
58 }
59
60 if let Some(key) = &host.identity_file {
62 cmd.arg("-i").arg(key);
63 }
64
65 if let Some(port) = host.port {
67 cmd.arg("-p").arg(port.to_string());
68 }
69
70 cmd.arg(format!("{}@{}", host.user, host.hostname));
72
73 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 pub fn local_port(&self) -> u16 {
102 self.local_port
103 }
104
105 pub fn docker_url(&self) -> String {
107 format!("tcp://127.0.0.1:{}", self.local_port)
108 }
109
110 pub fn host_name(&self) -> &str {
112 &self.host_name
113 }
114
115 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 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 pub fn is_alive(&mut self) -> bool {
149 matches!(self.child.try_wait(), Ok(None))
150 }
151}
152
153impl 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 tracing::debug!("SSH tunnel kill result: {}", e);
163 }
164 let _ = self.child.wait();
166 }
167}
168
169fn find_available_port() -> Result<u16, HostError> {
171 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);
182
183 Ok(port)
184}
185
186pub async fn test_connection(host: &HostConfig) -> Result<String, HostError> {
192 let mut cmd = Command::new("ssh");
193
194 cmd.arg("-o")
196 .arg("BatchMode=yes")
197 .arg("-o")
198 .arg("ConnectTimeout=10")
199 .arg("-o")
200 .arg("StrictHostKeyChecking=accept-new");
201
202 cmd.args(host.ssh_args());
204
205 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 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 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)]
249mod 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 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 let url = format!("tcp://127.0.0.1:{}", 12345);
266 assert_eq!(url, "tcp://127.0.0.1:12345");
267 }
268}