1use std::path::PathBuf;
4
5use super::common::{
6 connect_remote_exec, next_value, parse_duration, parse_exec_target,
7 psexec_service_binary_from_env, run_interactive_exec, AuthArgAccumulator, AuthOptions,
8 ExecTarget,
9};
10use crate::prelude::{ExecMode, ExecRequest};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RemoteExecTool {
15 SmbExec,
17 PsExec,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22struct ParsedRemoteExecArgs {
23 auth: AuthOptions,
24 target: ExecTarget,
25 request: ExecRequest,
26 service_binary: Option<PathBuf>,
27 interactive: bool,
28}
29
30impl RemoteExecTool {
31 fn usage(self, program: &str) -> String {
32 match self {
33 Self::SmbExec => format!(
34 "Usage:\n {program} smb://host[:port] --command COMMAND [--workdir PATH] [--timeout 30s] [--username USER] [--password PASS] [--domain DOMAIN] [--workstation NAME] [--kerberos] [--target-host HOST] [--principal SPN] [--realm REALM] [--kdc-url URL]"
35 ),
36 Self::PsExec => format!(
37 "Usage:\n {program} smb://host[:port] --command COMMAND [--service-binary PATH] [--workdir PATH] [--timeout 30s] [--username USER] [--password PASS] [--domain DOMAIN] [--workstation NAME] [--kerberos] [--target-host HOST] [--principal SPN] [--realm REALM] [--kdc-url URL]\n {program} smb://host[:port] --interactive [--command COMMAND] [--service-binary PATH] [--workdir PATH] [--timeout 30s] [--username USER] [--password PASS] [--domain DOMAIN] [--workstation NAME] [--kerberos] [--target-host HOST] [--principal SPN] [--realm REALM] [--kdc-url URL]"
38 ),
39 }
40 }
41
42 fn expected_program(self) -> &'static str {
43 match self {
44 Self::SmbExec => "smbexec",
45 Self::PsExec => "psexec",
46 }
47 }
48
49 fn mode(self) -> ExecMode {
50 match self {
51 Self::SmbExec => ExecMode::SmbExec,
52 Self::PsExec => ExecMode::PsExec,
53 }
54 }
55
56 fn label(self) -> &'static str {
57 match self {
58 Self::SmbExec => "smbexec",
59 Self::PsExec => "psexec",
60 }
61 }
62}
63
64pub async fn run_remote_exec_tool(
66 tool: RemoteExecTool,
67 args: Vec<String>,
68) -> Result<i32, String> {
69 let parsed = parse_args(tool, args)?;
70 let exec = connect_remote_exec(
71 &parsed.auth,
72 &parsed.target,
73 tool.mode(),
74 parsed.service_binary.as_deref(),
75 )
76 .await?;
77
78 if matches!(tool, RemoteExecTool::PsExec) && parsed.interactive {
79 return run_interactive_exec(&exec, parsed.request).await;
80 }
81
82 let result = exec.run(parsed.request).await.map_err(|error| error.to_string())?;
83 print!("{}", String::from_utf8_lossy(&result.stdout));
84 if !result.stderr.is_empty() {
85 eprint!("{}", String::from_utf8_lossy(&result.stderr));
86 }
87
88 Ok(result.exit_code)
89}
90
91fn parse_args(tool: RemoteExecTool, args: Vec<String>) -> Result<ParsedRemoteExecArgs, String> {
92 let program = args
93 .first()
94 .cloned()
95 .unwrap_or_else(|| tool.expected_program().to_string());
96 let usage = tool.usage(&program);
97 if args.len() < 2 {
98 return Err(usage);
99 }
100
101 let mut auth = AuthArgAccumulator::default();
102 let mut positionals = Vec::new();
103 let mut command_text = None;
104 let mut workdir = None;
105 let mut timeout = None;
106 let mut service_binary = None;
107 let mut interactive = false;
108
109 let mut index = 1;
110 while index < args.len() {
111 let token = &args[index];
112 if token == "-h" || token == "--help" {
113 return Err(tool.usage(&program));
114 }
115 if auth.parse_flag(&args, &mut index, token)? {
116 index += 1;
117 continue;
118 }
119 if let Some(value) = token.strip_prefix("--command=") {
120 command_text = Some(value.to_string());
121 index += 1;
122 continue;
123 }
124 if let Some(value) = token.strip_prefix("--workdir=") {
125 workdir = Some(value.to_string());
126 index += 1;
127 continue;
128 }
129 if let Some(value) = token.strip_prefix("--timeout=") {
130 timeout = Some(parse_duration(value)?);
131 index += 1;
132 continue;
133 }
134 if let Some(value) = token.strip_prefix("--service-binary=") {
135 service_binary = Some(PathBuf::from(value));
136 index += 1;
137 continue;
138 }
139
140 match token.as_str() {
141 "--command" => {
142 command_text = Some(next_value(&args, &mut index, "--command")?);
143 }
144 "--workdir" => {
145 workdir = Some(next_value(&args, &mut index, "--workdir")?);
146 }
147 "--timeout" => {
148 let value = next_value(&args, &mut index, "--timeout")?;
149 timeout = Some(parse_duration(&value)?);
150 }
151 "--service-binary" => {
152 service_binary = Some(PathBuf::from(next_value(
153 &args,
154 &mut index,
155 "--service-binary",
156 )?));
157 }
158 "--interactive" => {
159 interactive = true;
160 }
161 _ if token.starts_with("--") => {
162 return Err(format!("unknown option: {token}\n\n{}", tool.usage(&program)));
163 }
164 _ => {
165 positionals.push(token.as_str());
166 }
167 }
168 index += 1;
169 }
170
171 if positionals.len() != 1 {
172 return Err(format!(
173 "`{}` expects exactly 1 target SMB URL\n\n{}",
174 tool.label(),
175 tool.usage(&program)
176 ));
177 }
178
179 let auth = auth.resolve(&usage)?;
180 let target = parse_exec_target(positionals[0])?;
181 let request = match (tool, interactive, command_text) {
182 (RemoteExecTool::SmbExec, _, Some(command_text))
183 | (RemoteExecTool::PsExec, false, Some(command_text))
184 | (RemoteExecTool::PsExec, true, Some(command_text)) => {
185 let mut request = ExecRequest::command(command_text);
186 if let Some(workdir) = workdir {
187 request = request.with_working_directory(workdir);
188 }
189 if let Some(timeout) = timeout {
190 request = request.with_timeout(timeout);
191 }
192 request
193 }
194 (RemoteExecTool::PsExec, true, None) => {
195 let mut request = ExecRequest::command(String::new());
196 if let Some(workdir) = workdir {
197 request = request.with_working_directory(workdir);
198 }
199 if let Some(timeout) = timeout {
200 request = request.with_timeout(timeout);
201 }
202 request
203 }
204 _ => {
205 return Err(format!(
206 "missing --command for `{}`\n\n{}",
207 tool.label(),
208 tool.usage(&program)
209 ))
210 }
211 };
212
213 Ok(ParsedRemoteExecArgs {
214 auth,
215 target,
216 request,
217 service_binary: if matches!(tool, RemoteExecTool::PsExec) {
218 service_binary.or_else(psexec_service_binary_from_env)
219 } else {
220 None
221 },
222 interactive,
223 })
224}
225
226#[cfg(test)]
227mod tests {
228 use std::path::PathBuf;
229 use std::time::Duration;
230
231 use super::{parse_args, RemoteExecTool};
232 use crate::cli::common::ExecTarget;
233 use crate::prelude::ExecRequest;
234
235 #[test]
236 fn parse_smbexec_command_with_target_only_url() {
237 let options = parse_args(
238 RemoteExecTool::SmbExec,
239 vec![
240 "smbexec".to_string(),
241 "smb://server:1445".to_string(),
242 "--command=whoami".to_string(),
243 "--timeout=30s".to_string(),
244 "--username=user".to_string(),
245 "--password=pass".to_string(),
246 ],
247 )
248 .expect("parser should accept smbexec arguments");
249
250 assert_eq!(options.auth.username, "user");
251 assert_eq!(options.auth.password, "pass");
252 assert_eq!(
253 options.target,
254 ExecTarget {
255 host: "server".to_string(),
256 port: 1445,
257 }
258 );
259 assert_eq!(
260 options.request,
261 ExecRequest::command("whoami").with_timeout(Duration::from_secs(30))
262 );
263 assert_eq!(options.service_binary, None);
264 assert!(!options.interactive);
265 }
266
267 #[test]
268 fn parse_psexec_command_accepts_workdir() {
269 let options = parse_args(
270 RemoteExecTool::PsExec,
271 vec![
272 "psexec".to_string(),
273 "smb://server".to_string(),
274 "--command".to_string(),
275 "dir".to_string(),
276 "--workdir".to_string(),
277 "C:\\Temp".to_string(),
278 "--username=user".to_string(),
279 "--password=pass".to_string(),
280 ],
281 )
282 .expect("parser should accept psexec arguments");
283
284 assert_eq!(options.auth.username, "user");
285 assert_eq!(options.auth.password, "pass");
286 assert_eq!(
287 options.target,
288 ExecTarget {
289 host: "server".to_string(),
290 port: 445,
291 }
292 );
293 assert_eq!(
294 options.request,
295 ExecRequest::command("dir").with_working_directory("C:\\Temp")
296 );
297 assert_eq!(options.service_binary, None);
298 assert!(!options.interactive);
299 }
300
301 #[test]
302 fn parse_psexec_command_accepts_service_binary() {
303 let options = parse_args(
304 RemoteExecTool::PsExec,
305 vec![
306 "psexec".to_string(),
307 "smb://server".to_string(),
308 "--command=dir".to_string(),
309 "--service-binary".to_string(),
310 "target/aarch64-pc-windows-gnullvm/release/smolder-psexecsvc.exe".to_string(),
311 "--username=user".to_string(),
312 "--password=pass".to_string(),
313 ],
314 )
315 .expect("parser should accept psexec service-binary arguments");
316
317 assert_eq!(
318 options.service_binary,
319 Some(PathBuf::from(
320 "target/aarch64-pc-windows-gnullvm/release/smolder-psexecsvc.exe"
321 ))
322 );
323 assert!(!options.interactive);
324 }
325
326 #[test]
327 fn parse_interactive_psexec_allows_missing_command() {
328 let options = parse_args(
329 RemoteExecTool::PsExec,
330 vec![
331 "psexec".to_string(),
332 "smb://server".to_string(),
333 "--interactive".to_string(),
334 "--username=user".to_string(),
335 "--password=pass".to_string(),
336 ],
337 )
338 .expect("parser should accept interactive psexec arguments");
339
340 assert!(options.interactive);
341 assert_eq!(options.request, ExecRequest::command(String::new()));
342 }
343
344 #[cfg(feature = "kerberos")]
345 #[test]
346 fn parse_smbexec_command_accepts_kerberos_flags() {
347 let options = parse_args(
348 RemoteExecTool::SmbExec,
349 vec![
350 "smbexec".to_string(),
351 "smb://127.0.0.1".to_string(),
352 "--command".to_string(),
353 "whoami".to_string(),
354 "--kerberos".to_string(),
355 "--username".to_string(),
356 "smolder@LAB.EXAMPLE".to_string(),
357 "--password".to_string(),
358 "Passw0rd!".to_string(),
359 "--target-host".to_string(),
360 "DESKTOP-PTNJUS5.lab.example".to_string(),
361 "--realm".to_string(),
362 "LAB.EXAMPLE".to_string(),
363 "--kdc-url".to_string(),
364 "tcp://dc1.lab.example:1088".to_string(),
365 ],
366 )
367 .expect("parser should accept kerberos smbexec arguments");
368
369 assert!(matches!(options.auth.mode, crate::cli::common::AuthMode::Kerberos));
370 assert_eq!(
371 options.auth.kerberos.target_host.as_deref(),
372 Some("DESKTOP-PTNJUS5.lab.example")
373 );
374 assert_eq!(options.auth.kerberos.realm.as_deref(), Some("LAB.EXAMPLE"));
375 assert_eq!(
376 options.auth.kerberos.kdc_url.as_deref(),
377 Some("tcp://dc1.lab.example:1088")
378 );
379 }
380}