1use std::collections::BTreeMap;
2use std::process::{Command, Stdio};
3
4use crate::error::Result;
5use crate::models::{
6 MSFHostCheckResult, MSFHostInfo, MSFHostsResult, MSFLoginResult, MSFWorkspaceInfo,
7 MSFWorkspacesResult,
8};
9use crate::msgpack::{decode, encode, MsgValue};
10
11#[derive(Debug, Clone)]
12pub struct MsfRpcClient {
13 host: String,
14 port: u16,
15 ssl: bool,
16 username: String,
17 password: String,
18 token: Option<String>,
19}
20
21impl MsfRpcClient {
22 pub fn new(host: &str, port: u16, username: &str, password: &str, ssl: bool) -> Self {
23 Self {
24 host: host.to_string(),
25 port,
26 ssl,
27 username: username.to_string(),
28 password: password.to_string(),
29 token: None,
30 }
31 }
32
33 pub fn login(&mut self) -> Result<MSFLoginResult> {
34 let url = build_url(&self.host, self.port, self.ssl);
35 let payload = encode(&msg_array(vec![
37 msg_str("auth.login"),
38 msg_str(&self.username),
39 msg_str(&self.password),
40 ]));
41
42 match send_request(&url, &payload, 10) {
43 Ok(val) => {
44 let success = val.get("result").and_then(MsgValue::as_str) == Some("success");
45 let token = val
46 .get("token")
47 .and_then(MsgValue::as_str)
48 .unwrap_or("")
49 .to_string();
50
51 if success && !token.is_empty() {
52 self.token = Some(token.clone());
53 Ok(MSFLoginResult {
54 success: true,
55 token,
56 error: String::new(),
57 })
58 } else {
59 let error_msg = val
60 .get("error_message")
61 .or_else(|| val.get("error_string"))
62 .and_then(MsgValue::as_str)
63 .unwrap_or("Authentication failed");
64 Ok(MSFLoginResult {
65 success: false,
66 token: String::new(),
67 error: error_msg.to_string(),
68 })
69 }
70 }
71 Err(error) => Ok(MSFLoginResult {
72 success: false,
73 token: String::new(),
74 error,
75 }),
76 }
77 }
78
79 pub fn logout(&mut self) {
80 if let Some(token) = self.token.clone() {
81 let url = build_url(&self.host, self.port, self.ssl);
82 let payload = encode(&msg_array(vec![msg_str("auth.logout"), msg_str(&token)]));
84 let _ = send_request(&url, &payload, 5);
85 }
86 self.token = None;
87 }
88
89 pub fn is_authenticated(&self) -> bool {
90 self.token.is_some()
91 }
92
93 pub fn get_workspaces(&self) -> Result<MSFWorkspacesResult> {
94 let token = match self.token.as_ref() {
95 Some(token) => token.clone(),
96 None => {
97 return Ok(MSFWorkspacesResult {
98 workspaces: vec![],
99 error: "Not authenticated".to_string(),
100 });
101 }
102 };
103
104 let url = build_url(&self.host, self.port, self.ssl);
105 let payload = encode(&msg_array(vec![
107 msg_str("db.workspaces"),
108 msg_str(&token),
109 ]));
110
111 match send_request(&url, &payload, 15) {
112 Ok(val) => Ok(MSFWorkspacesResult {
113 workspaces: extract_workspaces(&val),
114 error: String::new(),
115 }),
116 Err(error) => Ok(MSFWorkspacesResult {
117 workspaces: vec![],
118 error,
119 }),
120 }
121 }
122
123 pub fn get_hosts(&self, workspace: Option<&str>) -> Result<MSFHostsResult> {
124 let token = match self.token.as_ref() {
125 Some(token) => token.clone(),
126 None => {
127 return Ok(MSFHostsResult {
128 hosts: vec![],
129 error: "Not authenticated".to_string(),
130 });
131 }
132 };
133
134 let url = build_url(&self.host, self.port, self.ssl);
135 let mut params = vec![msg_str("db.hosts"), msg_str(&token)];
139 if let Some(workspace) = workspace {
140 params.push(MsgValue::Map(BTreeMap::from([(
141 "workspace".to_string(),
142 msg_str(workspace),
143 )])));
144 } else {
145 params.push(MsgValue::Map(BTreeMap::new()));
146 }
147 let payload = encode(&msg_array(params));
148
149 match send_request(&url, &payload, 15) {
150 Ok(val) => Ok(MSFHostsResult {
151 hosts: extract_hosts(&val),
152 error: String::new(),
153 }),
154 Err(error) => Ok(MSFHostsResult {
155 hosts: vec![],
156 error,
157 }),
158 }
159 }
160
161 pub fn check_host_exists(
162 &self,
163 address: &str,
164 workspace: Option<&str>,
165 ) -> Result<MSFHostCheckResult> {
166 let hosts = self.get_hosts(workspace)?;
167 Ok(MSFHostCheckResult {
168 exists: hosts.hosts.iter().any(|host| host.address == address),
169 error: hosts.error,
170 })
171 }
172}
173
174fn build_url(host: &str, port: u16, ssl: bool) -> String {
175 let scheme = if ssl { "https" } else { "http" };
176 format!("{}://{}:{}/api/", scheme, host, port)
177}
178
179fn send_request(
180 url: &str,
181 payload: &[u8],
182 timeout_secs: u64,
183) -> std::result::Result<MsgValue, String> {
184 let mut child = Command::new("curl")
185 .arg("-sS")
186 .arg("--max-time")
187 .arg(timeout_secs.to_string())
188 .arg("-H")
189 .arg("Content-Type: binary/message-pack")
190 .arg("-H")
191 .arg("Accept: binary/message-pack")
192 .arg("-i")
193 .arg("--data-binary")
194 .arg("@-")
195 .arg(url)
196 .stdin(Stdio::piped())
197 .stdout(Stdio::piped())
198 .stderr(Stdio::piped())
199 .spawn()
200 .map_err(|e| format!("failed to spawn curl: {e}"))?;
201
202 if let Some(stdin) = child.stdin.as_mut() {
203 use std::io::Write;
204 stdin.write_all(payload).map_err(|e| format!("failed to write request body: {e}"))?;
205 }
206
207 let output = child.wait_with_output().map_err(|e| format!("curl wait failed: {e}"))?;
208
209 let stdout = &output.stdout;
210 if stdout.is_empty() {
211 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
212 if !stderr.is_empty() {
213 return Err(stderr);
214 }
215 return Err("empty response from server".to_string());
216 }
217
218 let body = if let Some(pos) = find_header_body_boundary(stdout) {
220 let header_part = String::from_utf8_lossy(&stdout[..pos]);
221 let status_line = header_part.lines().next().unwrap_or("unknown");
222 eprintln!("[MSF-RPC] HTTP {status_line}");
223 &stdout[pos..]
224 } else {
225 &stdout[..]
226 };
227
228 if body.is_empty() {
229 return Err("empty response body from server".to_string());
230 }
231
232 decode(body).map_err(|e| {
233 let dump_len = body.len().min(512);
234 let hex_dump: Vec<String> = body[..dump_len]
235 .iter()
236 .map(|b| format!("{:02x}", b))
237 .collect();
238 let utf8_dump = String::from_utf8_lossy(&body[..dump_len]);
239 format!(
240 "{}\n response body hex ({} bytes): {}\n response body utf8: {}",
241 e,
242 dump_len,
243 hex_dump.join(" "),
244 utf8_dump
245 )
246 })
247}
248
249fn find_header_body_boundary(data: &[u8]) -> Option<usize> {
250 data.windows(4)
251 .position(|w| w == b"\r\n\r\n")
252 .map(|p| p + 4)
253}
254
255fn msg_str(value: &str) -> MsgValue {
256 MsgValue::String(value.to_string())
257}
258
259fn msg_array(values: Vec<MsgValue>) -> MsgValue {
260 MsgValue::Array(values)
261}
262
263fn extract_workspaces(val: &MsgValue) -> Vec<MSFWorkspaceInfo> {
264 let mut workspaces = Vec::new();
265 if let Some(map) = val.as_map() {
266 if let Some(MsgValue::Array(ws_arr)) = map.get("workspaces") {
267 for ws in ws_arr {
268 if let Some(ws_map) = ws.as_map() {
269 let name = ws_map
270 .get("name")
271 .and_then(MsgValue::as_str)
272 .unwrap_or("")
273 .to_string();
274 let scope = ws_map
275 .get("scope")
276 .and_then(MsgValue::as_str)
277 .unwrap_or("")
278 .to_string();
279 let host_count = ws_map
280 .get("hosts_count")
281 .or_else(|| ws_map.get("host_count"))
282 .and_then(MsgValue::as_u64)
283 .unwrap_or(0);
284
285 workspaces.push(MSFWorkspaceInfo {
286 name,
287 scope,
288 host_count,
289 });
290 }
291 }
292 }
293 }
294 workspaces
295}
296
297fn extract_hosts(val: &MsgValue) -> Vec<MSFHostInfo> {
298 let mut hosts = Vec::new();
299 if let Some(map) = val.as_map() {
300 if let Some(MsgValue::Array(host_arr)) = map.get("hosts") {
301 for host in host_arr {
302 if let Some(host_map) = host.as_map() {
303 let address = host_map
304 .get("address")
305 .and_then(MsgValue::as_str)
306 .unwrap_or("")
307 .to_string();
308 let os_name = host_map
309 .get("os_name")
310 .and_then(MsgValue::as_str)
311 .unwrap_or("")
312 .to_string();
313 let os_flavor = host_map
314 .get("os_flavor")
315 .and_then(MsgValue::as_str)
316 .unwrap_or("")
317 .to_string();
318 let state = host_map
319 .get("state")
320 .and_then(MsgValue::as_str)
321 .unwrap_or("")
322 .to_string();
323 let notes_count = host_map
324 .get("notes_count")
325 .and_then(MsgValue::as_u64)
326 .unwrap_or(0);
327
328 hosts.push(MSFHostInfo {
329 address,
330 os_name,
331 os_flavor,
332 state,
333 notes_count,
334 });
335 }
336 }
337 }
338 }
339 hosts
340}