1use std::collections::BTreeMap;
2use std::process::{Command, Stdio};
3
4use crate::error::{AppError, Result};
5use crate::models::{
6 MSFHostCheckResult, MSFHostInfo, MSFHostsResult, MSFLoginResult, MSFModule, 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![msg_str("db.workspaces"), msg_str(&token)]));
107
108 match send_request(&url, &payload, 15) {
109 Ok(val) => Ok(MSFWorkspacesResult {
110 workspaces: extract_workspaces(&val),
111 error: String::new(),
112 }),
113 Err(error) => Ok(MSFWorkspacesResult {
114 workspaces: vec![],
115 error,
116 }),
117 }
118 }
119
120 pub fn get_hosts(&self, workspace: Option<&str>) -> Result<MSFHostsResult> {
121 let token = match self.token.as_ref() {
122 Some(token) => token.clone(),
123 None => {
124 return Ok(MSFHostsResult {
125 hosts: vec![],
126 error: "Not authenticated".to_string(),
127 });
128 }
129 };
130
131 let url = build_url(&self.host, self.port, self.ssl);
132 let mut params = vec![msg_str("db.hosts"), msg_str(&token)];
136 if let Some(workspace) = workspace {
137 params.push(MsgValue::Map(BTreeMap::from([(
138 "workspace".to_string(),
139 msg_str(workspace),
140 )])));
141 } else {
142 params.push(MsgValue::Map(BTreeMap::new()));
143 }
144 let payload = encode(&msg_array(params));
145
146 match send_request(&url, &payload, 15) {
147 Ok(val) => Ok(MSFHostsResult {
148 hosts: extract_hosts(&val),
149 error: String::new(),
150 }),
151 Err(error) => Ok(MSFHostsResult {
152 hosts: vec![],
153 error,
154 }),
155 }
156 }
157
158 pub fn check_host_exists(
159 &self,
160 address: &str,
161 workspace: Option<&str>,
162 ) -> Result<MSFHostCheckResult> {
163 let hosts = self.get_hosts(workspace)?;
164 Ok(MSFHostCheckResult {
165 exists: hosts.hosts.iter().any(|host| host.address == address),
166 error: hosts.error,
167 })
168 }
169
170 pub fn rpc_call(
176 &self,
177 method: &str,
178 params: &[MsgValue],
179 timeout_secs: u64,
180 ) -> Result<MsgValue> {
181 let token = self
182 .token
183 .clone()
184 .ok_or_else(|| crate::error::AppError::Message("not authenticated".to_string()))?;
185 let url = build_url(&self.host, self.port, self.ssl);
186 let mut args = vec![msg_str(method), msg_str(&token)];
187 args.extend_from_slice(params);
188 let payload = encode(&msg_array(args));
189 send_request(&url, &payload, timeout_secs).map_err(crate::error::AppError::Network)
190 }
191
192 pub fn module_exists(&self, module_type: &str, name: &str) -> Result<bool> {
198 match self.module_info(module_type, name) {
199 Ok(_) => Ok(true),
200 Err(AppError::RpcModuleNotFound(_)) => Ok(false),
201 Err(error) => Err(error),
202 }
203 }
204
205 pub fn module_info(&self, module_type: &str, name: &str) -> Result<MsgValue> {
212 let leaf = strip_type_prefix(module_type, name);
213 let modern = self.rpc_call("module.info", &[msg_str(module_type), msg_str(&leaf)], 15)?;
214 if let Some(message) = response_error(&modern) {
215 let legacy = self.rpc_call(
218 "module.info",
219 &[msg_str(&full_name(module_type, name))],
220 15,
221 )?;
222 if let Some(legacy_message) = response_error(&legacy) {
223 return Err(classify_lookup(&legacy_message));
224 }
225 let _ = message;
226 return Ok(legacy);
227 }
228 Ok(modern)
229 }
230
231 pub fn module_options(&self, module_type: &str, name: &str) -> Result<MsgValue> {
235 let leaf = strip_type_prefix(module_type, name);
236 let modern = self.rpc_call("module.options", &[msg_str(module_type), msg_str(&leaf)], 15)?;
237 if response_error(&modern).is_none() {
238 return Ok(modern);
239 }
240 let legacy = self.rpc_call(
241 "module.options",
242 &[msg_str(&full_name(module_type, name))],
243 15,
244 )?;
245 if let Some(message) = response_error(&legacy) {
246 return Err(classify_lookup(&message));
247 }
248 Ok(legacy)
249 }
250
251 pub fn list_modules_for_type(&self, module_type: &str) -> Result<Vec<MSFModule>> {
259 let method = match module_type {
260 "exploit" => "module.exploits",
261 "auxiliary" => "module.auxiliary",
262 "post" => "module.post",
263 other => {
264 return Err(AppError::Message(format!(
265 "cannot list modules of unknown type '{other}'"
266 )));
267 }
268 };
269 let val = self.rpc_call(method, &[], 15)?;
270 if let Some(message) = response_error(&val) {
271 return Err(classify_lookup(&message));
272 }
273 Ok(extract_module_list(&val, module_type))
274 }
275
276 pub fn list_modules(&self) -> Result<Vec<MSFModule>> {
280 let mut modules = Vec::new();
281 let mut errors = Vec::new();
282 for module_type in ["exploit", "auxiliary", "post"] {
283 match self.list_modules_for_type(module_type) {
284 Ok(mut found) => modules.append(&mut found),
285 Err(error) => errors.push(error),
286 }
287 }
288 if modules.is_empty() && !errors.is_empty() {
289 return Err(errors.remove(0));
290 }
291 modules.sort_by(|a, b| a.name.cmp(&b.name));
292 Ok(modules)
293 }
294
295 pub fn module_execute(
298 &self,
299 module_type: &str,
300 name: &str,
301 options: &BTreeMap<String, String>,
302 payload: Option<&str>,
303 ) -> Result<MsgValue> {
304 let mut opts = BTreeMap::new();
305 for (key, value) in options {
306 opts.insert(key.clone(), msg_str(value));
307 }
308 if let Some(payload) = payload {
309 opts.insert("PAYLOAD".to_string(), msg_str(payload));
310 }
311 self.rpc_call(
312 "module.execute",
313 &[
314 msg_str(module_type),
315 msg_str(&strip_type_prefix(module_type, name)),
316 MsgValue::Map(opts),
317 ],
318 30,
319 )
320 }
321
322 pub fn job_list(&self) -> Result<MsgValue> {
324 self.rpc_call("job.list", &[], 15)
325 }
326
327 pub fn job_info(&self, job_id: &str) -> Result<MsgValue> {
329 self.rpc_call("job.info", &[msg_str(job_id)], 15)
330 }
331
332 pub fn job_stop(&self, job_id: &str) -> Result<MsgValue> {
334 self.rpc_call("job.stop", &[msg_str(job_id)], 15)
335 }
336
337 pub fn session_list(&self) -> Result<MsgValue> {
339 self.rpc_call("session.list", &[], 15)
340 }
341
342 pub fn db_loots(&self, workspace: &str) -> Result<MsgValue> {
344 let mut opts = BTreeMap::new();
345 if !workspace.is_empty() {
346 opts.insert("workspace".to_string(), msg_str(workspace));
347 }
348 self.rpc_call("db.loots", &[MsgValue::Map(opts)], 15)
349 }
350}
351
352fn msg_str(value: &str) -> MsgValue {
354 MsgValue::String(value.to_string())
355}
356
357fn msg_array(values: Vec<MsgValue>) -> MsgValue {
358 MsgValue::Array(values)
359}
360
361fn build_url(host: &str, port: u16, ssl: bool) -> String {
362 let scheme = if ssl { "https" } else { "http" };
363 format!("{}://{}:{}/api/", scheme, host, port)
364}
365
366fn strip_type_prefix(module_type: &str, name: &str) -> String {
370 for prefix in [module_type, "exploit", "auxiliary", "post"] {
371 if let Some(rest) = name.strip_prefix(&format!("{prefix}/")) {
372 return rest.to_string();
373 }
374 }
375 name.to_string()
376}
377
378fn full_name(module_type: &str, name: &str) -> String {
380 if ["exploit", "auxiliary", "post"]
381 .iter()
382 .any(|p| name.starts_with(&format!("{}/", p)))
383 {
384 return name.to_string();
385 }
386 match module_type {
387 "exploit" | "auxiliary" | "post" => format!("{module_type}/{name}"),
388 _ => name.to_string(),
389 }
390}
391
392fn response_error(val: &MsgValue) -> Option<String> {
398 match val.get("error") {
399 None => None,
400 Some(MsgValue::Bool(false)) => None,
401 Some(MsgValue::String(value)) if value == "success" => None,
402 Some(MsgValue::String(value)) if value == "true" => Some(error_message(val)),
403 Some(MsgValue::UInt(0)) | Some(MsgValue::Int(0)) => None,
404 Some(_) => Some(error_message(val)),
405 }
406}
407
408fn error_message(val: &MsgValue) -> String {
409 val.get("error_message")
410 .or_else(|| val.get("error_string"))
411 .and_then(MsgValue::as_str)
412 .unwrap_or("MSF-RPC command failed")
413 .to_string()
414}
415
416fn classify_lookup(message: &str) -> AppError {
419 let lower = message.to_lowercase();
420 if lower.contains("not found")
421 || lower.contains("does not exist")
422 || lower.contains("missing")
423 || lower.contains("invalid")
424 {
425 AppError::RpcModuleNotFound(message.to_string())
426 } else {
427 AppError::Rpc(message.to_string())
428 }
429}
430
431fn extract_module_list(val: &MsgValue, module_type: &str) -> Vec<MSFModule> {
436 let mut names: Vec<String> = Vec::new();
437 match val.get("modules") {
438 Some(MsgValue::Array(items)) => {
439 for item in items {
440 if let Some(name) = item.as_str() {
441 names.push(name.to_string());
442 }
443 }
444 }
445 Some(MsgValue::Map(items)) => {
446 names.extend(items.keys().cloned());
447 }
448 _ => {
449 if let MsgValue::Map(items) = val {
451 names.extend(items.keys().cloned());
452 }
453 }
454 }
455 names.sort();
456 names.dedup();
457 names
458 .into_iter()
459 .map(|name| MSFModule {
460 name: full_name(module_type, &name),
461 cve: None,
462 rank: String::new(),
463 disclosure_date: "unknown".to_string(),
464 platforms: Vec::new(),
465 required_options: Vec::new(),
466 })
467 .collect()
468}
469
470fn send_request(
471 url: &str,
472 payload: &[u8],
473 timeout_secs: u64,
474) -> std::result::Result<MsgValue, String> {
475 let mut child = Command::new("curl")
476 .arg("-sS")
477 .arg("--max-time")
478 .arg(timeout_secs.to_string())
479 .arg("-H")
480 .arg("Content-Type: binary/message-pack")
481 .arg("-H")
482 .arg("Accept: binary/message-pack")
483 .arg("-i")
484 .arg("--data-binary")
485 .arg("@-")
486 .arg(url)
487 .stdin(Stdio::piped())
488 .stdout(Stdio::piped())
489 .stderr(Stdio::piped())
490 .spawn()
491 .map_err(|e| format!("failed to spawn curl: {e}"))?;
492
493 if let Some(stdin) = child.stdin.as_mut() {
494 use std::io::Write;
495 stdin
496 .write_all(payload)
497 .map_err(|e| format!("failed to write request body: {e}"))?;
498 }
499
500 let output = child
501 .wait_with_output()
502 .map_err(|e| format!("curl wait failed: {e}"))?;
503
504 let stdout = &output.stdout;
505 if stdout.is_empty() {
506 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
507 if !stderr.is_empty() {
508 return Err(stderr);
509 }
510 return Err("empty response from server".to_string());
511 }
512
513 let body = if let Some(pos) = find_header_body_boundary(stdout) {
515 &stdout[pos..]
516 } else {
517 &stdout[..]
518 };
519
520 if body.is_empty() {
521 return Err("empty response body from server".to_string());
522 }
523
524 decode(body).map_err(|e| {
525 let dump_len = body.len().min(512);
526 let hex_dump: Vec<String> = body[..dump_len]
527 .iter()
528 .map(|b| format!("{:02x}", b))
529 .collect();
530 let utf8_dump = String::from_utf8_lossy(&body[..dump_len]);
531 format!(
532 "{}\n response body hex ({} bytes): {}\n response body utf8: {}",
533 e,
534 dump_len,
535 hex_dump.join(" "),
536 utf8_dump
537 )
538 })
539}
540
541fn find_header_body_boundary(data: &[u8]) -> Option<usize> {
542 data.windows(4)
543 .position(|w| w == b"\r\n\r\n")
544 .map(|p| p + 4)
545}
546
547fn extract_workspaces(val: &MsgValue) -> Vec<MSFWorkspaceInfo> {
548 let mut workspaces = Vec::new();
549 if let Some(map) = val.as_map() {
550 if let Some(MsgValue::Array(ws_arr)) = map.get("workspaces") {
551 for ws in ws_arr {
552 if let Some(ws_map) = ws.as_map() {
553 let name = ws_map
554 .get("name")
555 .and_then(MsgValue::as_str)
556 .unwrap_or("")
557 .to_string();
558 let scope = ws_map
559 .get("scope")
560 .and_then(MsgValue::as_str)
561 .unwrap_or("")
562 .to_string();
563 let host_count = ws_map
564 .get("hosts_count")
565 .or_else(|| ws_map.get("host_count"))
566 .and_then(MsgValue::as_u64)
567 .unwrap_or(0);
568
569 workspaces.push(MSFWorkspaceInfo {
570 name,
571 scope,
572 host_count,
573 });
574 }
575 }
576 }
577 }
578 workspaces
579}
580
581fn extract_hosts(val: &MsgValue) -> Vec<MSFHostInfo> {
582 let mut hosts = Vec::new();
583 if let Some(map) = val.as_map() {
584 if let Some(MsgValue::Array(host_arr)) = map.get("hosts") {
585 for host in host_arr {
586 if let Some(host_map) = host.as_map() {
587 let address = host_map
588 .get("address")
589 .and_then(MsgValue::as_str)
590 .unwrap_or("")
591 .to_string();
592 let os_name = host_map
593 .get("os_name")
594 .and_then(MsgValue::as_str)
595 .unwrap_or("")
596 .to_string();
597 let os_flavor = host_map
598 .get("os_flavor")
599 .and_then(MsgValue::as_str)
600 .unwrap_or("")
601 .to_string();
602 let state = host_map
603 .get("state")
604 .and_then(MsgValue::as_str)
605 .unwrap_or("")
606 .to_string();
607 let notes_count = host_map
608 .get("notes_count")
609 .and_then(MsgValue::as_u64)
610 .unwrap_or(0);
611
612 hosts.push(MSFHostInfo {
613 address,
614 os_name,
615 os_flavor,
616 state,
617 notes_count,
618 });
619 }
620 }
621 }
622 }
623 hosts
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 fn map_of(pairs: &[(&str, MsgValue)]) -> MsgValue {
631 MsgValue::Map(
632 pairs
633 .iter()
634 .map(|(k, v)| (k.to_string(), v.clone()))
635 .collect(),
636 )
637 }
638
639 #[test]
640 fn strips_type_prefix_from_various_forms() {
641 assert_eq!(
642 strip_type_prefix("auxiliary", "auxiliary/scanner/http/robots_txt"),
643 "scanner/http/robots_txt"
644 );
645 assert_eq!(
646 strip_type_prefix("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
647 "windows/smb/ms17_010_eternalblue"
648 );
649 assert_eq!(strip_type_prefix("auxiliary", "scanner/http/robots_txt"), "scanner/http/robots_txt");
651 assert_eq!(
653 strip_type_prefix("post", "auxiliary/scanner/http/robots_txt"),
654 "scanner/http/robots_txt"
655 );
656 }
657
658 #[test]
659 fn rebuilds_full_name() {
660 assert_eq!(
661 full_name("auxiliary", "scanner/http/robots_txt"),
662 "auxiliary/scanner/http/robots_txt"
663 );
664 assert_eq!(
666 full_name("exploit", "exploit/windows/smb/ms17_010_eternalblue"),
667 "exploit/windows/smb/ms17_010_eternalblue"
668 );
669 assert_eq!(full_name("payload", "linux/x64/shell"), "linux/x64/shell");
671 }
672
673 #[test]
674 fn extracts_module_list_from_array_form() {
675 let val = map_of(&[(
676 "modules",
677 MsgValue::Array(vec![
678 MsgValue::String("scanner/http/robots_txt".to_string()),
679 MsgValue::String("scanner/http/apache_version".to_string()),
680 ]),
681 )]);
682 let modules = extract_module_list(&val, "auxiliary");
683 let names: Vec<&str> = modules.iter().map(|m| m.name.as_str()).collect();
684 assert_eq!(
685 names,
686 vec![
687 "auxiliary/scanner/http/apache_version",
688 "auxiliary/scanner/http/robots_txt"
689 ]
690 );
691 }
692
693 #[test]
694 fn extracts_module_list_from_map_form() {
695 let val = map_of(&[(
696 "modules",
697 MsgValue::Map(BTreeMap::from([
698 ("scanner/http/robots_txt".to_string(), msg_str("desc")),
699 ("scanner/ssh/ssh_version".to_string(), msg_str("desc")),
700 ])),
701 )]);
702 let modules = extract_module_list(&val, "auxiliary");
703 assert!(modules.iter().any(|m| m.name == "auxiliary/scanner/http/robots_txt"));
704 assert!(modules.iter().any(|m| m.name == "auxiliary/scanner/ssh/ssh_version"));
705 }
706
707 #[test]
708 fn extracts_module_names_without_modules_key() {
709 let val = MsgValue::Map(BTreeMap::from([(
711 "scanner/http/robots_txt".to_string(),
712 msg_str("description"),
713 )]));
714 let modules = extract_module_list(&val, "auxiliary");
715 assert_eq!(modules.len(), 1);
716 assert_eq!(modules[0].name, "auxiliary/scanner/http/robots_txt");
717 }
718
719 #[test]
720 fn response_error_detects_success_and_failure() {
721 assert!(response_error(&map_of(&[("result", msg_str("success"))])).is_none());
723 assert!(response_error(&map_of(&[("error", MsgValue::Bool(false))])).is_none());
725 assert!(response_error(&map_of(&[("error", msg_str("success"))])).is_none());
727 let err = map_of(&[
729 ("error", MsgValue::Bool(true)),
730 ("error_message", msg_str("Module not found")),
731 ]);
732 assert_eq!(response_error(&err).as_deref(), Some("Module not found"));
733 assert!(response_error(&map_of(&[("error", MsgValue::Int(0))])).is_none());
735 assert!(response_error(&map_of(&[("error", MsgValue::Int(1))])).is_some());
736 }
737
738 #[test]
739 fn classifies_missing_vs_other_rpc_failures() {
740 assert!(matches!(
741 classify_lookup("Module not found"),
742 AppError::RpcModuleNotFound(_)
743 ));
744 assert!(matches!(
745 classify_lookup("The referenced module does not exist"),
746 AppError::RpcModuleNotFound(_)
747 ));
748 assert!(matches!(
749 classify_lookup("Missing module name"),
750 AppError::RpcModuleNotFound(_)
751 ));
752 assert!(matches!(
753 classify_lookup("Permission denied"),
754 AppError::Rpc(_)
755 ));
756 }
757
758 #[test]
759 fn module_existence_decision_uses_classification() {
760 let decision = |msg: Option<&str>| match msg {
763 None => Ok(true),
764 Some(m) if matches!(classify_lookup(m), AppError::RpcModuleNotFound(_)) => Ok(false),
765 Some(_) => Err(AppError::Rpc(String::new())),
766 };
767 assert_eq!(decision(None).unwrap(), true);
768 assert_eq!(decision(Some("Module not found")).unwrap(), false);
769 assert!(decision(Some("Permission denied")).is_err());
770 }
771}