1use std::collections::BTreeMap;
9use std::sync::Arc;
10use std::sync::atomic::AtomicBool;
11use std::time::Duration;
12
13use anyhow::{Context, Result, bail};
14use oxdock_core::{
15 FuncKind, FuncMeta, FuncParam, HostModule, HostRegistration, NativeFn, OxDockFn, OxDockType,
16 StepCtx, Value,
17};
18use oxdock_func_macro::oxdock_func;
19use oxdock_net_plugin::{AcquiredListener, EndpointRegistry, acquire_listener};
20use oxdock_process::ProcessManager;
21use russh::keys::{Algorithm, PrivateKey};
22
23use crate::bridge::{pump_pipe_to_pipe, pump_session};
24use crate::keys::load_or_create_host_key;
25use crate::runtime::{connect_runtime, connect_session};
26use crate::state::{
27 CLOSE_JOIN_TIMEOUT, Dequeue, PendingSession, ServerState, SessionQueue, ShutdownSignal,
28};
29use crate::types::{SshServerTag, SshSessionTag};
30use crate::validate::parse_serve_endpoint;
31
32static SERVER_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
34
35const DEQUEUE_TICK: Duration = Duration::from_millis(10);
37
38fn server_state(value: &Value, func: &str) -> Result<Arc<ServerState>> {
40 let Some(tag) = value.read_heap::<SshServerTag>(SshServerTag::descriptor()) else {
41 bail!(
42 "{func} expects an SSH_SERVER value, got {}",
43 value.type_name()
44 );
45 };
46 Ok(Arc::clone(tag.state()))
47}
48
49fn session_tag(value: &Value, func: &str) -> Result<SshSessionTag> {
51 let Some(tag) = value.read_heap::<SshSessionTag>(SshSessionTag::descriptor()) else {
52 bail!(
53 "{func} expects an SSH_SESSION value, got {}",
54 value.type_name()
55 );
56 };
57 Ok(tag.clone())
58}
59
60fn dequeue_session<P: ProcessManager>(
63 cx: &StepCtx<P>,
64 queue: &Arc<SessionQueue>,
65 func: &str,
66) -> Result<PendingSession> {
67 loop {
68 match queue.try_pop() {
69 Dequeue::Session(session) => return Ok(session),
70 Dequeue::Shutdown => bail!("{func}: server is closed"),
71 Dequeue::Empty => {}
72 }
73 match queue.wait_for_session(DEQUEUE_TICK) {
74 Dequeue::Session(session) => return Ok(session),
75 Dequeue::Shutdown => bail!("{func}: server is closed"),
76 Dequeue::Empty => {}
77 }
78 if cx.is_cancelled() {
79 bail!("{func}: task cancelled");
80 }
81 }
82}
83
84#[oxdock_func(
135 returns = "MAP",
136 summary = "Dequeue one SSH session with its metadata."
137)]
138fn ssh_dequeue<P: ProcessManager>(cx: &mut StepCtx<P>, server: Value) -> Result<Value> {
139 if !cx.is_async_task() {
140 bail!(
141 "SSH_DEQUEUE requires ASYNC: wrap it as LET $t: HANDLE = ASYNC {{ SSH_DEQUEUE($server.server) }}"
142 );
143 }
144 let state = server_state(&server, "SSH_DEQUEUE")?;
145 let session = dequeue_session(cx, state.queue(), "SSH_DEQUEUE")?;
146 let command = session.exec_command.clone().unwrap_or_default();
147 let username = session.username.clone().unwrap_or_default();
148 let addr = session
149 .peer_addr
150 .map(|addr| addr.to_string())
151 .unwrap_or_default();
152 let tag = SshSessionTag::new(
153 session.exec_command,
154 session.username,
155 session.peer_addr,
156 session.pty_size,
157 session.up_rx,
158 session.down_tx,
159 );
160 let mut map = BTreeMap::new();
161 map.insert(
162 "session".to_string(),
163 Value::mint_heap(SshSessionTag::descriptor(), tag),
164 );
165 map.insert("command".to_string(), Value::string(command));
166 map.insert("username".to_string(), Value::string(username));
167 map.insert("addr".to_string(), Value::string(addr));
168 Ok(Value::map(map))
169}
170
171fn argv_list(value: &Value, func: &str) -> Result<Vec<String>> {
173 let Some(items) = value.as_list() else {
174 bail!("{func} argv must be a LIST of strings");
175 };
176 if items.is_empty() {
177 bail!("{func} argv must not be empty");
178 }
179 items
180 .iter()
181 .map(|item| {
182 item.as_str().map(str::to_string).ok_or_else(|| {
183 anyhow::anyhow!("{func} argv must be strings, got {}", item.type_name())
184 })
185 })
186 .collect()
187}
188
189fn serve_options(options: &Value) -> Result<&BTreeMap<String, Value>> {
192 options.as_map().ok_or_else(|| {
193 anyhow::anyhow!(
194 "SSH_SERVE options must be a MAP, got {}",
195 options.type_name()
196 )
197 })
198}
199
200fn required_string(map: &BTreeMap<String, Value>, func: &str, key: &str) -> Result<String> {
204 let Some(value) = map.get(key) else {
205 bail!("{func} option '{key}' is required");
206 };
207 let Some(s) = value.as_str() else {
208 bail!(
209 "{func} option '{key}' must be a STRING, got {}",
210 value.type_name()
211 );
212 };
213 if s.trim().is_empty() {
214 bail!("{func} option '{key}' must not be empty");
215 }
216 Ok(s.to_string())
217}
218
219fn optional_string(map: &BTreeMap<String, Value>, func: &str, key: &str) -> Result<Option<String>> {
222 let Some(value) = map.get(key) else {
223 return Ok(None);
224 };
225 let Some(s) = value.as_str() else {
226 bail!(
227 "{func} option '{key}' must be a STRING, got {}",
228 value.type_name()
229 );
230 };
231 let trimmed = s.trim();
232 if trimmed.is_empty() {
233 return Ok(None);
234 }
235 Ok(Some(s.to_string()))
236}
237
238fn ssh_serve<P: ProcessManager>(
253 cx: &mut StepCtx<P>,
254 registry: &Arc<EndpointRegistry>,
255 bind: String,
256 options: Value,
257) -> Result<Value> {
258 let map = serve_options(&options)?;
259 for key in map.keys() {
260 if key != "username" && key != "password" && key != "key_path" {
261 bail!("SSH_SERVE() unknown option '{key}' (expected: username, password, key_path)");
262 }
263 }
264 let username = required_string(map, "SSH_SERVE", "username")?;
265 let password = required_string(map, "SSH_SERVE", "password")?;
266 let key_path = optional_string(map, "SSH_SERVE", "key_path")?;
267 let endpoint = parse_serve_endpoint(&bind)?;
268 let (acquired, registry) = acquire_listener(registry, &endpoint, "SSH_SERVE")?;
269 let host_key = match load_or_create_host_key(cx, "SSH_SERVE", key_path)? {
270 Some(key) => key,
271 None => PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)
272 .context("generate ephemeral Ed25519 host key")?,
273 };
274 let id = SERVER_IDS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
275 let id = format!("ssh-{pid}-{id}", pid = std::process::id());
276 let queue = Arc::new(SessionQueue::new());
277 let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<ShutdownSignal>();
278 let (local_addr, addr_text, thread) = match acquired {
279 AcquiredListener::Tcp { listener, addr } => {
280 let owned = listener
283 .try_clone()
284 .context("SSH_SERVE cannot clone its listener")?;
285 let thread_queue = Arc::clone(&queue);
286 let thread_user = username.clone();
287 let thread_pass = password.clone();
288 let thread = std::thread::Builder::new()
289 .name(id.clone())
290 .spawn(move || {
291 crate::runtime::serve(
292 owned,
293 host_key,
294 thread_user,
295 thread_pass,
296 thread_queue,
297 shutdown_rx,
298 )
299 })
300 .context("SSH_SERVE cannot spawn the server thread")?;
301 (addr, addr.to_string(), Some(thread))
302 }
303 AcquiredListener::Memory => {
304 drop(shutdown_rx);
305 bail!(
306 "SSH_SERVE: '{endpoint}' is a memory service (SSH needs a TCP socket; map it with -p/--listen)"
307 )
308 }
309 AcquiredListener::Offline => {
310 drop(shutdown_rx);
315 (
316 std::net::SocketAddr::from(([0, 0, 0, 0], 0)),
317 endpoint.to_string(),
318 None,
319 )
320 }
321 };
322 let state = Arc::new(ServerState::new(crate::state::ServerConfig {
323 id,
324 local_addr,
325 addr_text: addr_text.clone(),
326 queue,
327 shutdown_tx,
328 thread,
329 registry: Arc::clone(®istry),
330 endpoint: endpoint.clone(),
331 }));
332 let mut map = BTreeMap::new();
333 map.insert(
334 "server".to_string(),
335 Value::mint_heap(SshServerTag::descriptor(), SshServerTag::new(state)),
336 );
337 map.insert("addr".to_string(), Value::string(addr_text));
338 map.insert("username".to_string(), Value::string(username));
339 map.insert("password".to_string(), Value::string(password));
340 map.insert("virtual".to_string(), Value::string(endpoint.to_string()));
341 Ok(Value::map(map))
342}
343
344#[oxdock_func(returns = "MAP", summary = "Accept one SSH session into pipes.")]
388fn ssh_accept<P: ProcessManager>(
389 cx: &mut StepCtx<P>,
390 server: Value,
391 in_pipe: Value,
392 out_pipe: Value,
393) -> Result<Value> {
394 if !cx.is_async_task() {
395 bail!(
396 "SSH_ACCEPT requires ASYNC: wrap it as LET $t: HANDLE = ASYNC {{ SSH_ACCEPT($server, $in, $out) }}"
397 );
398 }
399 let state = server_state(&server, "SSH_ACCEPT")?;
400 let cancel = AtomicBool::new(false);
401 let session = dequeue_session(cx, state.queue(), "SSH_ACCEPT")?;
402 pump_session(
403 cx,
404 &in_pipe,
405 &out_pipe,
406 session.up_rx,
407 session.down_tx,
408 &cancel,
409 )?;
410 let mut map = BTreeMap::new();
411 map.insert("closed".to_string(), Value::bool(true));
412 map.insert(
413 "command".to_string(),
414 Value::string(session.exec_command.unwrap_or_default()),
415 );
416 Ok(Value::map(map))
417}
418
419#[oxdock_func(
424 returns = "MAP",
425 summary = "Pump a dequeued SSH session through pipes."
426)]
427fn ssh_pump_channel<P: ProcessManager>(
428 cx: &mut StepCtx<P>,
429 session: Value,
430 in_pipe: Value,
431 out_pipe: Value,
432) -> Result<Value> {
433 if !cx.is_async_task() {
434 bail!("SSH_PUMP_CHANNEL requires ASYNC: pump it in its own task after SSH_DEQUEUE");
435 }
436 let tag = session_tag(&session, "SSH_PUMP_CHANNEL")?;
437 let (up_rx, down_tx) = tag.take_pump_ends()?;
438 let cancel = AtomicBool::new(false);
439 pump_session(cx, &in_pipe, &out_pipe, up_rx, down_tx, &cancel)?;
440 let mut map = BTreeMap::new();
441 map.insert("closed".to_string(), Value::bool(true));
442 Ok(Value::map(map))
443}
444
445#[oxdock_func(returns = "BOOL", summary = "Shut down an SSH server.")]
448fn ssh_close<P: ProcessManager>(cx: &mut StepCtx<P>, server: Value) -> Result<Value> {
449 let _ = cx;
450 let state = server_state(&server, "SSH_CLOSE")?;
451 state.request_shutdown();
452 Ok(Value::bool(state.join_thread(CLOSE_JOIN_TIMEOUT)))
453}
454
455fn ssh_connect<P: ProcessManager>(
464 cx: &mut StepCtx<P>,
465 registry: &Arc<EndpointRegistry>,
466 target: String,
467 username: String,
468 password: String,
469 in_pipe: Value,
470 out_pipe: Value,
471) -> Result<Value> {
472 if !cx.is_async_task() {
473 bail!("SSH_CONNECT requires ASYNC: run it in its own task beside the SSH_PUMP tasks");
474 }
475 if registry.is_offline() {
477 bail!("SSH_CONNECT failed: engine running in --offline mode");
478 }
479 if username.is_empty() {
480 bail!("SSH_CONNECT username must not be empty");
481 }
482 let addr = crate::validate::resolve_connect_addr(registry, &target)?;
483 let runtime = connect_runtime()?;
484 let session = runtime
485 .block_on(connect_session(&addr, &username, &password))
486 .context("SSH_CONNECT failed")?;
487 let crate::runtime::OutboundSession {
488 up_rx,
489 down_tx,
490 handle,
491 ..
492 } = session;
493 let cancel = AtomicBool::new(false);
494 let pump = pump_session(cx, &in_pipe, &out_pipe, up_rx, down_tx, &cancel);
495 let _ = runtime.block_on(handle.disconnect(russh::Disconnect::ByApplication, "", ""));
496 pump?;
497 let mut map = BTreeMap::new();
498 map.insert("closed".to_string(), Value::bool(true));
499 Ok(Value::map(map))
500}
501
502#[oxdock_func(returns = "INT", summary = "Copy one pipe into another until EOF.")]
506fn ssh_pump<P: ProcessManager>(
507 cx: &mut StepCtx<P>,
508 from_pipe: Value,
509 to_pipe: Value,
510) -> Result<Value> {
511 let cancel = AtomicBool::new(false);
512 let total = pump_pipe_to_pipe(cx, &from_pipe, &to_pipe, &cancel)?;
513 Ok(Value::int(total))
514}
515
516#[oxdock_func(
528 returns = "INT",
529 summary = "Run a command under a sized local terminal into pipes."
530)]
531fn ssh_pty_run<P: ProcessManager>(
532 cx: &mut StepCtx<P>,
533 session: Value,
534 argv: Value,
535 rows: i64,
536 cols: i64,
537 in_pipe: Value,
538 out_pipe: Value,
539) -> Result<Value> {
540 if !cx.is_async_task() {
541 bail!("SSH_PTY_RUN requires ASYNC: run it in its own task beside the session pump task");
542 }
543 let tag = session_tag(&session, "SSH_PTY_RUN")?;
544 let argv = argv_list(&argv, "SSH_PTY_RUN")?;
545 let initial = if rows > 0 && cols > 0 {
546 crate::state::PtySize::new(rows as u32, cols as u32)
547 } else {
548 tag.pty_size()
549 };
550 let cancel = AtomicBool::new(false);
551 let code = crate::pty::pump_pty_session(
552 cx,
553 &argv,
554 initial,
555 &tag.pty_size_handle(),
556 &in_pipe,
557 &out_pipe,
558 &cancel,
559 )?;
560 Ok(Value::int(code))
561}
562
563pub fn module_with<P: ProcessManager>() -> HostModule<P> {
566 module_with_endpoints(Arc::new(EndpointRegistry::new(false)))
567}
568
569pub fn module_with_endpoints<P: ProcessManager>(registry: Arc<EndpointRegistry>) -> HostModule<P> {
574 HostModule {
575 name: "SSH".to_string(),
576 funcs: vec![
577 ssh_serve_registration(Arc::clone(®istry)),
578 SshAccept::registration(),
579 SshDequeue::registration(),
580 SshPumpChannel::registration(),
581 SshClose::registration(),
582 ssh_connect_registration(registry),
583 SshPump::registration(),
584 SshPtyRun::registration(),
585 ],
586 types: vec![SshServerTag::descriptor(), SshSessionTag::descriptor()],
587 }
588}
589
590fn ssh_serve_registration<P: ProcessManager>(
594 registry: Arc<EndpointRegistry>,
595) -> HostRegistration<P> {
596 let func: NativeFn<P> = Arc::new(move |cx, values| {
597 if values.len() != 2 {
598 bail!("SSH_SERVE() expects 2 argument(s), got {}", values.len());
599 }
600 let mut values = values.into_iter();
601 let bind = match values.next().expect("arity checked above").as_str() {
602 Some(s) => s.to_string(),
603 None => bail!("SSH_SERVE() argument `$bind` must be a STRING"),
604 };
605 let options = values.next().expect("arity checked above");
606 ssh_serve(cx, ®istry, bind, options)
607 });
608 HostRegistration::Stateful {
609 name: "SSH_SERVE".to_string(),
610 meta: FuncMeta {
611 name: "SSH_SERVE".to_string(),
612 module: String::new(),
614 kind: FuncKind::HostCtx,
615 params: Some(vec![
616 FuncParam {
617 name: "bind".to_string(),
618 param_type: Some("STRING".to_string()),
619 },
620 FuncParam {
621 name: "options".to_string(),
622 param_type: None,
623 },
624 ]),
625 returns: Some("MAP".to_string()),
626 rpn: false,
627 summary: "Serve SSH on a virtual service endpoint.",
628 docs: "Serve SSH on a virtual service endpoint.",
629 },
630 func,
631 }
632}
633
634fn ssh_connect_registration<P: ProcessManager>(
637 registry: Arc<EndpointRegistry>,
638) -> HostRegistration<P> {
639 let func: NativeFn<P> = Arc::new(move |cx, values| {
640 if values.len() != 5 {
641 bail!("SSH_CONNECT() expects 5 argument(s), got {}", values.len());
642 }
643 let mut values = values.into_iter();
644 let target = match values.next().expect("arity checked above").as_str() {
645 Some(s) => s.to_string(),
646 None => bail!("SSH_CONNECT() argument `$target` must be a STRING"),
647 };
648 let username = match values.next().expect("arity checked above").as_str() {
649 Some(s) => s.to_string(),
650 None => bail!("SSH_CONNECT() argument `$username` must be a STRING"),
651 };
652 let password = match values.next().expect("arity checked above").as_str() {
653 Some(s) => s.to_string(),
654 None => bail!("SSH_CONNECT() argument `$password` must be a STRING"),
655 };
656 let in_pipe = values.next().expect("arity checked above");
657 let out_pipe = values.next().expect("arity checked above");
658 ssh_connect(cx, ®istry, target, username, password, in_pipe, out_pipe)
659 });
660 HostRegistration::Stateful {
661 name: "SSH_CONNECT".to_string(),
662 meta: FuncMeta {
663 name: "SSH_CONNECT".to_string(),
664 module: String::new(),
666 kind: FuncKind::HostCtx,
667 params: Some(vec![
668 FuncParam {
669 name: "target".to_string(),
670 param_type: Some("STRING".to_string()),
671 },
672 FuncParam {
673 name: "username".to_string(),
674 param_type: Some("STRING".to_string()),
675 },
676 FuncParam {
677 name: "password".to_string(),
678 param_type: Some("STRING".to_string()),
679 },
680 FuncParam {
681 name: "in_pipe".to_string(),
682 param_type: None,
683 },
684 FuncParam {
685 name: "out_pipe".to_string(),
686 param_type: None,
687 },
688 ]),
689 returns: Some("MAP".to_string()),
690 rpn: false,
691 summary: "Open an SSH client session into pipes.",
692 docs: "Open an SSH client session into pipes. Target shapes: a logical port (CLI-mapped address or loopback default), a service name (CLI-mapped address only), a served address, or a host:port dial.",
693 },
694 func,
695 }
696}