Skip to main content

shuttle/
extension.rs

1use crate::config::ShuttleConfig;
2use crate::pool::ConnectionPool;
3use crate::tools;
4use crate::tunnel::TunnelManager;
5use serde_json::{json, Value};
6use std::collections::HashMap;
7use std::sync::Arc;
8use styrene_identity::signer::{IdentitySigner, RootSecret};
9use tokio::sync::RwLock;
10use zeroize::Zeroize;
11
12pub struct ShuttleExtension {
13    config: Arc<RwLock<ShuttleConfig>>,
14    pool: ConnectionPool,
15    tunnels: TunnelManager,
16    /// Holds the signer so re-derivation works after TTL expiry without
17    /// needing the env var again.
18    signer: Arc<RwLock<Option<Box<dyn IdentitySigner>>>>,
19    root_cache: Arc<RwLock<CachedRoot>>,
20}
21
22const ROOT_SECRET_TTL: std::time::Duration = std::time::Duration::from_secs(300);
23
24struct CachedRoot {
25    secret: Option<RootSecret>,
26    cached_at: Option<std::time::Instant>,
27}
28
29impl CachedRoot {
30    fn empty() -> Self {
31        Self {
32            secret: None,
33            cached_at: None,
34        }
35    }
36
37    fn get(&self) -> Option<&RootSecret> {
38        let cached_at = self.cached_at?;
39        if cached_at.elapsed() > ROOT_SECRET_TTL {
40            return None;
41        }
42        self.secret.as_ref()
43    }
44
45    fn set(&mut self, root: &RootSecret) {
46        self.secret = Some(RootSecret::new(*root.as_bytes()));
47        self.cached_at = Some(std::time::Instant::now());
48    }
49}
50
51impl ShuttleExtension {
52    pub fn new() -> Self {
53        let config = ShuttleConfig::default();
54        Self {
55            config: Arc::new(RwLock::new(config)),
56            pool: ConnectionPool::new(),
57            tunnels: TunnelManager::new(),
58            signer: Arc::new(RwLock::new(None)),
59            root_cache: Arc::new(RwLock::new(CachedRoot::empty())),
60        }
61    }
62
63    /// Ensure the signer is initialized (first call only).
64    async fn ensure_signer(&self) -> omegon_extension::Result<()> {
65        {
66            let s = self.signer.read().await;
67            if s.is_some() {
68                return Ok(());
69            }
70        }
71
72        let discovered = styrene_identity::discover::discover().ok_or_else(|| {
73            omegon_extension::Error::internal_error(
74                "no styrene identity found — run `styrene identity init` first",
75            )
76        })?;
77
78        // Read passphrase from env once, then remove it so child processes
79        // and /proc/pid/environ don't inherit it.
80        let mut passphrase = std::env::var("STYRENE_PASSPHRASE")
81            .map(|s| s.into_bytes())
82            .map_err(|_| {
83                omegon_extension::Error::internal_error(
84                    "set STYRENE_PASSPHRASE to unlock the identity file",
85                )
86            })?;
87
88        #[allow(unused_unsafe)]
89        unsafe {
90            std::env::remove_var("STYRENE_PASSPHRASE");
91        }
92
93        let provider =
94            styrene_identity::file_signer::StaticPassphraseProvider::new(&passphrase);
95        passphrase.zeroize();
96
97        let signer =
98            styrene_identity::file_signer::FileSigner::new(&discovered.path, Box::new(provider));
99
100        let mut s = self.signer.write().await;
101        *s = Some(Box::new(signer));
102        Ok(())
103    }
104
105    async fn root_secret(&self) -> omegon_extension::Result<RootSecret> {
106        // Fast path: TTL cache hit
107        {
108            let cache = self.root_cache.read().await;
109            if let Some(root) = cache.get() {
110                return Ok(RootSecret::new(*root.as_bytes()));
111            }
112        }
113
114        // Slow path: derive from signer (works after TTL expiry too)
115        self.ensure_signer().await?;
116        let signer_guard = self.signer.read().await;
117        let signer = signer_guard.as_ref().unwrap();
118        let root = signer
119            .root_secret()
120            .await
121            .map_err(|e| omegon_extension::Error::internal_error(format!("unlock identity: {e}")))?;
122
123        let mut cache = self.root_cache.write().await;
124        cache.set(&root);
125
126        Ok(root)
127    }
128
129    async fn execute_tool(&self, name: &str, params: Value) -> omegon_extension::Result<Value> {
130        match name {
131            "ssh_exec" => self.tool_ssh_exec(params).await,
132            "ssh_script" => self.tool_ssh_script(params).await,
133            "scp_push" => self.tool_scp_push(params).await,
134            "scp_pull" => self.tool_scp_pull(params).await,
135            "sftp_ls" => self.tool_sftp_ls(params).await,
136            "sftp_read" => self.tool_sftp_read(params).await,
137            "ssh_tunnel_open" => self.tool_tunnel_open(params).await,
138            "ssh_tunnel_close" => self.tool_tunnel_close(params).await,
139            "ssh_tunnel_list" => self.tool_tunnel_list(params).await,
140            "ssh_hosts" => self.tool_ssh_hosts(params).await,
141            "ssh_ping" => self.tool_ssh_ping(params).await,
142            "ssh_migrate_analyze" => self.tool_migrate_analyze(params).await,
143            _ => Err(omegon_extension::Error::method_not_found(&format!("tool '{name}'"))),
144        }
145    }
146
147    fn extract_host(params: &Value) -> omegon_extension::Result<&str> {
148        params
149            .get("host")
150            .and_then(|v| v.as_str())
151            .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'host'"))
152    }
153
154    async fn acquire_client(
155        &self,
156        host_name: &str,
157    ) -> omegon_extension::Result<Arc<tokio::sync::Mutex<crate::client::SshClient>>> {
158        let config = self.config.read().await;
159        let root = self.root_secret().await?;
160        self.pool
161            .acquire(host_name, &config, &root)
162            .await
163            .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))
164    }
165
166    // ── Tool implementations ─────────────────────────────────────────────
167
168    async fn tool_ssh_exec(&self, params: Value) -> omegon_extension::Result<Value> {
169        let host = Self::extract_host(&params)?;
170        let command = params.get("command").and_then(|v| v.as_str()).unwrap_or("");
171        tracing::info!(
172            tool = "ssh_exec",
173            host,
174            command,
175            "executing remote command"
176        );
177        let client = self.acquire_client(host).await?;
178        let config = self.config.read().await;
179        let result = crate::exec::ssh_exec(&client, &config, &params).await;
180        if let Ok(ref v) = result {
181            tracing::info!(
182                tool = "ssh_exec",
183                host,
184                exit_code = v.get("exit_code").and_then(|v| v.as_u64()).unwrap_or(255),
185                truncated = v.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),
186                "command completed"
187            );
188        }
189        result
190    }
191
192    async fn tool_ssh_script(&self, params: Value) -> omegon_extension::Result<Value> {
193        let host = Self::extract_host(&params)?;
194        let script = params.get("script").and_then(|v| v.as_str()).unwrap_or("");
195        let interpreter = params
196            .get("interpreter")
197            .and_then(|v| v.as_str())
198            .unwrap_or("/bin/bash");
199        let script_hash = format!("{:016x}", fxhash(script.as_bytes()));
200        tracing::info!(
201            tool = "ssh_script",
202            host,
203            interpreter,
204            script_hash,
205            script_len = script.len(),
206            "executing remote script"
207        );
208        let client = self.acquire_client(host).await?;
209        let config = self.config.read().await;
210        let result = crate::exec::ssh_script(&client, &config, &params).await;
211        if let Ok(ref v) = result {
212            tracing::info!(
213                tool = "ssh_script",
214                host,
215                exit_code = v.get("exit_code").and_then(|v| v.as_u64()).unwrap_or(255),
216                "script completed"
217            );
218        }
219        result
220    }
221
222    async fn tool_scp_push(&self, params: Value) -> omegon_extension::Result<Value> {
223        let host = Self::extract_host(&params)?;
224        let local = params.get("local_path").and_then(|v| v.as_str()).unwrap_or("");
225        let remote = params.get("remote_path").and_then(|v| v.as_str()).unwrap_or("");
226        tracing::info!(tool = "scp_push", host, local, remote, "uploading file");
227        let client = self.acquire_client(host).await?;
228        let config = self.config.read().await;
229        let result = crate::transfer::scp_push(&client, &config, &params).await;
230        if let Ok(ref v) = result {
231            tracing::info!(
232                tool = "scp_push",
233                host,
234                bytes = v.get("bytes_written").and_then(|v| v.as_u64()).unwrap_or(0),
235                "upload complete"
236            );
237        }
238        result
239    }
240
241    async fn tool_scp_pull(&self, params: Value) -> omegon_extension::Result<Value> {
242        let host = Self::extract_host(&params)?;
243        let remote = params.get("remote_path").and_then(|v| v.as_str()).unwrap_or("");
244        let local = params.get("local_path").and_then(|v| v.as_str()).unwrap_or("");
245        tracing::info!(tool = "scp_pull", host, remote, local, "downloading file");
246        let client = self.acquire_client(host).await?;
247        let config = self.config.read().await;
248        crate::transfer::scp_pull(&client, &config, &params).await
249    }
250
251    async fn tool_sftp_ls(&self, params: Value) -> omegon_extension::Result<Value> {
252        let host = Self::extract_host(&params)?;
253        let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
254        tracing::info!(tool = "sftp_ls", host, path, "listing remote directory");
255        let client = self.acquire_client(host).await?;
256        let config = self.config.read().await;
257        crate::transfer::sftp_ls(&client, &config, &params).await
258    }
259
260    async fn tool_sftp_read(&self, params: Value) -> omegon_extension::Result<Value> {
261        let host = Self::extract_host(&params)?;
262        let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
263        tracing::info!(tool = "sftp_read", host, path, "reading remote file");
264        let client = self.acquire_client(host).await?;
265        let config = self.config.read().await;
266        let result = crate::transfer::sftp_read(&client, &config, &params).await;
267        if let Ok(ref v) = result {
268            tracing::info!(
269                tool = "sftp_read",
270                host,
271                size = v.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
272                truncated = v.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false),
273                "read complete"
274            );
275        }
276        result
277    }
278
279    async fn tool_tunnel_open(&self, params: Value) -> omegon_extension::Result<Value> {
280        let host = Self::extract_host(&params)?;
281        let local_port_raw = params
282            .get("local_port")
283            .and_then(|v| v.as_u64())
284            .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'local_port'"))?;
285        if local_port_raw > 65535 {
286            return Err(omegon_extension::Error::invalid_params(
287                format!("local_port must be 0-65535 (got {local_port_raw})")
288            ));
289        }
290        let local_port = local_port_raw as u16;
291        let remote_host = params
292            .get("remote_host")
293            .and_then(|v| v.as_str())
294            .unwrap_or("127.0.0.1");
295        let remote_port_raw = params
296            .get("remote_port")
297            .and_then(|v| v.as_u64())
298            .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'remote_port'"))?;
299        if remote_port_raw > 65535 {
300            return Err(omegon_extension::Error::invalid_params(
301                format!("remote_port must be 0-65535 (got {remote_port_raw})")
302            ));
303        }
304        let remote_port = remote_port_raw as u16;
305
306        tracing::info!(
307            tool = "ssh_tunnel_open",
308            host,
309            local_port,
310            remote_host,
311            remote_port,
312            "opening tunnel"
313        );
314        let client = self.acquire_client(host).await?;
315        let config = self.config.read().await;
316        self.tunnels
317            .open(
318                host,
319                local_port,
320                remote_host,
321                remote_port,
322                &config.allowed_tunnel_destinations,
323                client,
324            )
325            .await
326    }
327
328    async fn tool_tunnel_close(&self, params: Value) -> omegon_extension::Result<Value> {
329        let tunnel_id = params
330            .get("tunnel_id")
331            .and_then(|v| v.as_str())
332            .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'tunnel_id'"))?;
333        tracing::info!(tool = "ssh_tunnel_close", tunnel_id, "closing tunnel");
334        self.tunnels.close(tunnel_id).await
335    }
336
337    async fn tool_tunnel_list(&self, _params: Value) -> omegon_extension::Result<Value> {
338        self.tunnels.list().await
339    }
340
341    async fn tool_migrate_analyze(&self, params: Value) -> omegon_extension::Result<Value> {
342        tracing::info!(tool = "ssh_migrate_analyze", "scanning ~/.ssh for migration");
343        crate::migrate::ssh_migrate_analyze(&params).await
344    }
345
346    async fn tool_ssh_hosts(&self, _params: Value) -> omegon_extension::Result<Value> {
347        let config = self.config.read().await;
348        let hosts: Vec<Value> = config
349            .host_names()
350            .into_iter()
351            .filter_map(|name| {
352                config.resolve_host(name).ok().map(|entry| {
353                    json!({
354                        "name": name,
355                        "address": entry.address,
356                        "user": entry.user,
357                        "port": entry.port,
358                    })
359                })
360            })
361            .collect();
362        Ok(json!({ "hosts": hosts }))
363    }
364
365    async fn tool_ssh_ping(&self, params: Value) -> omegon_extension::Result<Value> {
366        let host = Self::extract_host(&params)?;
367        tracing::info!(tool = "ssh_ping", host, "testing connectivity");
368        let start = std::time::Instant::now();
369        match self.acquire_client(host).await {
370            Ok(_client) => {
371                let elapsed = start.elapsed();
372                tracing::info!(tool = "ssh_ping", host, latency_ms = elapsed.as_millis(), "reachable");
373                Ok(json!({
374                    "host": host,
375                    "reachable": true,
376                    "latency_ms": elapsed.as_millis(),
377                }))
378            }
379            Err(e) => {
380                tracing::warn!(tool = "ssh_ping", host, error = %e, "unreachable");
381                Ok(json!({
382                    "host": host,
383                    "reachable": false,
384                    "error": e.to_string(),
385                }))
386            }
387        }
388    }
389}
390
391/// Fast non-cryptographic hash for audit log script fingerprinting.
392fn fxhash(data: &[u8]) -> u64 {
393    let mut hash: u64 = 0xcbf29ce484222325;
394    for &b in data {
395        hash = hash.wrapping_mul(0x100000001b3);
396        hash ^= b as u64;
397    }
398    hash
399}
400
401#[async_trait::async_trait]
402impl omegon_extension::Extension for ShuttleExtension {
403    fn name(&self) -> &str {
404        "shuttle"
405    }
406
407    fn version(&self) -> &str {
408        env!("CARGO_PKG_VERSION")
409    }
410
411    async fn handle_rpc(
412        &self,
413        method: &str,
414        params: Value,
415    ) -> omegon_extension::Result<Value> {
416        match method {
417            "initialize" => {
418                let tools = tools::tool_definitions();
419                Ok(json!({
420                    "protocol_version": 2,
421                    "extension_info": {
422                        "name": self.name(),
423                        "version": self.version(),
424                        "sdk_version": "0.19"
425                    },
426                    "capabilities": {
427                        "tools": true,
428                    },
429                    "tools": tools,
430                }))
431            }
432
433            "get_tools" | "tools/list" => Ok(json!(tools::tool_definitions())),
434
435            "bootstrap_config" => {
436                let map: HashMap<String, Value> =
437                    serde_json::from_value(params).unwrap_or_default();
438                self.on_config(map).await;
439                Ok(json!({ "acknowledged": true }))
440            }
441
442            "execute_tool" | "tools/call" => {
443                let name = params
444                    .get("name")
445                    .and_then(|v| v.as_str())
446                    .unwrap_or("");
447                let args = params.get("arguments").cloned().unwrap_or(json!({}));
448                self.execute_tool(name, args).await
449            }
450
451            _ => Err(omegon_extension::Error::method_not_found(method)),
452        }
453    }
454
455    async fn on_config(&self, config: HashMap<String, Value>) {
456        let mut cfg = self.config.write().await;
457        cfg.apply_rpc_config(&config);
458
459        if let Err(e) = cfg.load_hosts() {
460            tracing::warn!("failed to load hosts: {e}");
461        } else {
462            let host_count = cfg.host_names().len();
463            tracing::info!(
464                hosts = host_count,
465                "hosts loaded from {}",
466                cfg.hosts_file.display()
467            );
468
469            if cfg.allowed_hosts.is_none() {
470                tracing::warn!(
471                    "allowed_hosts is not configured — all {} hosts in hosts.toml are \
472                     accessible. Set allowed_hosts to restrict agent access.",
473                    host_count
474                );
475            }
476        }
477    }
478}