Skip to main content

mur_core/remote/
relay.rs

1//! mur.run relay-based remote execution via WebSocket tunnel.
2//!
3//! Routes commands through the mur.run relay server, enabling
4//! execution on machines behind NAT/firewalls without direct SSH access.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use super::{RemoteExecutor, RemoteOutput};
10
11/// Configuration for connecting to the mur.run relay.
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct RelayConfig {
14    /// WebSocket URL of the relay server.
15    pub relay_url: String,
16    /// Authentication token for the relay.
17    pub auth_token: String,
18    /// Channel/room ID for this team's machines.
19    pub channel_id: String,
20}
21
22impl RelayConfig {
23    /// Create a config for the default mur.run relay.
24    pub fn default_relay(auth_token: String, channel_id: String) -> Self {
25        Self {
26            relay_url: "wss://relay.mur.run/v1/ws".to_string(),
27            auth_token,
28            channel_id,
29        }
30    }
31}
32
33/// Message sent through the relay to execute a command.
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35pub struct RelayCommand {
36    pub target_machine: String,
37    pub command: String,
38    pub working_dir: Option<String>,
39    pub timeout_secs: u64,
40}
41
42/// Response received from a relay command execution.
43#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
44pub struct RelayResponse {
45    pub machine_id: String,
46    pub stdout: String,
47    pub stderr: String,
48    pub exit_code: i32,
49    pub duration_ms: u64,
50}
51
52impl From<RelayResponse> for RemoteOutput {
53    fn from(resp: RelayResponse) -> Self {
54        RemoteOutput {
55            stdout: resp.stdout,
56            stderr: resp.stderr,
57            exit_code: resp.exit_code,
58            duration_ms: resp.duration_ms,
59            machine_id: resp.machine_id,
60        }
61    }
62}
63
64/// Relay-based remote executor that tunnels through mur.run.
65#[derive(Debug, Clone)]
66pub struct RelayExecutor {
67    config: RelayConfig,
68    /// Default timeout for remote commands in seconds.
69    default_timeout_secs: u64,
70}
71
72impl RelayExecutor {
73    /// Create a new relay executor.
74    pub fn new(config: RelayConfig) -> Self {
75        Self {
76            config,
77            default_timeout_secs: 300,
78        }
79    }
80
81    /// Set the default command timeout.
82    pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
83        self.default_timeout_secs = timeout_secs;
84        self
85    }
86
87    /// Get the relay URL.
88    pub fn relay_url(&self) -> &str {
89        &self.config.relay_url
90    }
91
92    /// Get the channel ID.
93    pub fn channel_id(&self) -> &str {
94        &self.config.channel_id
95    }
96
97    /// Build a relay command message.
98    pub fn build_command(
99        &self,
100        machine_id: &str,
101        command: &str,
102        working_dir: Option<&str>,
103    ) -> RelayCommand {
104        RelayCommand {
105            target_machine: machine_id.to_string(),
106            command: command.to_string(),
107            working_dir: working_dir.map(String::from),
108            timeout_secs: self.default_timeout_secs,
109        }
110    }
111
112    /// Serialize a relay command to JSON for sending over WebSocket.
113    pub fn serialize_command(&self, cmd: &RelayCommand) -> anyhow::Result<String> {
114        serde_json::to_string(cmd).map_err(|e| anyhow::anyhow!("failed to serialize command: {e}"))
115    }
116
117    /// Parse a relay response from JSON received over WebSocket.
118    pub fn parse_response(&self, json: &str) -> anyhow::Result<RelayResponse> {
119        serde_json::from_str(json)
120            .map_err(|e| anyhow::anyhow!("failed to parse relay response: {e}"))
121    }
122}
123
124impl RemoteExecutor for RelayExecutor {
125    fn execute(
126        &self,
127        machine_id: &str,
128        command: &str,
129        working_dir: Option<&str>,
130    ) -> anyhow::Result<RemoteOutput> {
131        let relay_cmd = self.build_command(machine_id, command, working_dir);
132        tracing::info!(
133            machine = machine_id,
134            relay = %self.config.relay_url,
135            "executing command via relay"
136        );
137
138        // In a full implementation, this would:
139        // 1. Open a WebSocket connection to the relay
140        // 2. Send the serialized command
141        // 3. Wait for the response
142        // For now, return an error indicating async runtime is needed
143        let _serialized = self.serialize_command(&relay_cmd)?;
144
145        anyhow::bail!(
146            "relay execution requires async runtime — use async execute method or tokio::block_in_place"
147        )
148    }
149
150    fn ping(&self, machine_id: &str) -> anyhow::Result<bool> {
151        let cmd = self.build_command(machine_id, "echo pong", None);
152        let _serialized = self.serialize_command(&cmd)?;
153
154        // Would send via WebSocket and check response
155        tracing::debug!(machine = machine_id, "relay ping (stub)");
156        Ok(false)
157    }
158
159    fn executor_type(&self) -> &str {
160        "relay"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn make_config() -> RelayConfig {
169        RelayConfig {
170            relay_url: "wss://relay.mur.run/v1/ws".into(),
171            auth_token: "test-token".into(),
172            channel_id: "team-123".into(),
173        }
174    }
175
176    #[test]
177    fn test_relay_config_default() {
178        let config = RelayConfig::default_relay("token".into(), "channel".into());
179        assert_eq!(config.relay_url, "wss://relay.mur.run/v1/ws");
180        assert_eq!(config.auth_token, "token");
181        assert_eq!(config.channel_id, "channel");
182    }
183
184    #[test]
185    fn test_relay_executor_new() {
186        let executor = RelayExecutor::new(make_config());
187        assert_eq!(executor.relay_url(), "wss://relay.mur.run/v1/ws");
188        assert_eq!(executor.channel_id(), "team-123");
189        assert_eq!(executor.default_timeout_secs, 300);
190    }
191
192    #[test]
193    fn test_relay_executor_with_timeout() {
194        let executor = RelayExecutor::new(make_config()).with_timeout(60);
195        assert_eq!(executor.default_timeout_secs, 60);
196    }
197
198    #[test]
199    fn test_build_command() {
200        let executor = RelayExecutor::new(make_config());
201        let cmd = executor.build_command("m1", "cargo build", Some("/app"));
202        assert_eq!(cmd.target_machine, "m1");
203        assert_eq!(cmd.command, "cargo build");
204        assert_eq!(cmd.working_dir.as_deref(), Some("/app"));
205        assert_eq!(cmd.timeout_secs, 300);
206    }
207
208    #[test]
209    fn test_serialize_and_parse_command() {
210        let executor = RelayExecutor::new(make_config());
211        let cmd = executor.build_command("m1", "ls", None);
212        let json = executor.serialize_command(&cmd).unwrap();
213        assert!(json.contains("m1"));
214        assert!(json.contains("ls"));
215    }
216
217    #[test]
218    fn test_parse_response() {
219        let executor = RelayExecutor::new(make_config());
220        let json = r#"{"machine_id":"m1","stdout":"ok\n","stderr":"","exit_code":0,"duration_ms":42}"#;
221        let resp = executor.parse_response(json).unwrap();
222        assert_eq!(resp.machine_id, "m1");
223        assert_eq!(resp.exit_code, 0);
224        assert_eq!(resp.duration_ms, 42);
225    }
226
227    #[test]
228    fn test_relay_response_into_remote_output() {
229        let resp = RelayResponse {
230            machine_id: "m1".into(),
231            stdout: "output".into(),
232            stderr: "warn".into(),
233            exit_code: 0,
234            duration_ms: 100,
235        };
236        let output: RemoteOutput = resp.into();
237        assert_eq!(output.machine_id, "m1");
238        assert!(output.success());
239    }
240
241    #[test]
242    fn test_executor_type() {
243        let executor = RelayExecutor::new(make_config());
244        assert_eq!(executor.executor_type(), "relay");
245    }
246
247    #[test]
248    fn test_relay_command_serialization() {
249        let cmd = RelayCommand {
250            target_machine: "m1".into(),
251            command: "echo hello".into(),
252            working_dir: None,
253            timeout_secs: 30,
254        };
255        let json = serde_json::to_string(&cmd).unwrap();
256        let back: RelayCommand = serde_json::from_str(&json).unwrap();
257        assert_eq!(cmd.target_machine, back.target_machine);
258        assert_eq!(cmd.timeout_secs, back.timeout_secs);
259    }
260
261    #[test]
262    fn test_relay_config_serialization() {
263        let config = make_config();
264        let json = serde_json::to_string(&config).unwrap();
265        let back: RelayConfig = serde_json::from_str(&json).unwrap();
266        assert_eq!(config.relay_url, back.relay_url);
267        assert_eq!(config.channel_id, back.channel_id);
268    }
269}