Skip to main content

opencode_cloud_core/host/
tunnel.rs

1//! SSH tunnel management
2//!
3//! Creates and manages SSH tunnels to remote Docker daemons.
4
5use std::net::TcpListener;
6use std::process::{Child, Command, Stdio};
7use std::time::Duration;
8
9use super::error::HostError;
10use 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.
16pub struct SshTunnel {
17    child: Child,
18    local_port: u16,
19    host_name: String,
20}
21
22impl 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
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            // 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
170fn 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
191pub 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)]
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        // 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}