use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use oxdock_core::{
FuncKind, FuncMeta, FuncParam, HostModule, HostRegistration, NativeFn, OxDockFn, OxDockType,
StepCtx, Value,
};
use oxdock_func_macro::oxdock_func;
use oxdock_net_plugin::{AcquiredListener, EndpointRegistry, acquire_listener};
use oxdock_process::ProcessManager;
use russh::keys::{Algorithm, PrivateKey};
use crate::bridge::{pump_pipe_to_pipe, pump_session};
use crate::keys::load_or_create_host_key;
use crate::runtime::{connect_runtime, connect_session};
use crate::state::{
CLOSE_JOIN_TIMEOUT, Dequeue, PendingSession, ServerState, SessionQueue, ShutdownSignal,
};
use crate::types::{SshServerTag, SshSessionTag};
use crate::validate::parse_serve_endpoint;
static SERVER_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
const DEQUEUE_TICK: Duration = Duration::from_millis(10);
fn server_state(value: &Value, func: &str) -> Result<Arc<ServerState>> {
let Some(tag) = value.read_heap::<SshServerTag>(SshServerTag::descriptor()) else {
bail!(
"{func} expects an SSH_SERVER value, got {}",
value.type_name()
);
};
Ok(Arc::clone(tag.state()))
}
fn session_tag(value: &Value, func: &str) -> Result<SshSessionTag> {
let Some(tag) = value.read_heap::<SshSessionTag>(SshSessionTag::descriptor()) else {
bail!(
"{func} expects an SSH_SESSION value, got {}",
value.type_name()
);
};
Ok(tag.clone())
}
fn dequeue_session<P: ProcessManager>(
cx: &StepCtx<P>,
queue: &Arc<SessionQueue>,
func: &str,
) -> Result<PendingSession> {
loop {
match queue.try_pop() {
Dequeue::Session(session) => return Ok(session),
Dequeue::Shutdown => bail!("{func}: server is closed"),
Dequeue::Empty => {}
}
match queue.wait_for_session(DEQUEUE_TICK) {
Dequeue::Session(session) => return Ok(session),
Dequeue::Shutdown => bail!("{func}: server is closed"),
Dequeue::Empty => {}
}
if cx.is_cancelled() {
bail!("{func}: task cancelled");
}
}
}
#[oxdock_func(
returns = "MAP",
summary = "Dequeue one SSH session with its metadata."
)]
fn ssh_dequeue<P: ProcessManager>(cx: &mut StepCtx<P>, server: Value) -> Result<Value> {
if !cx.is_async_task() {
bail!(
"SSH_DEQUEUE requires ASYNC: wrap it as LET $t: HANDLE = ASYNC {{ SSH_DEQUEUE($server.server) }}"
);
}
let state = server_state(&server, "SSH_DEQUEUE")?;
let session = dequeue_session(cx, state.queue(), "SSH_DEQUEUE")?;
let command = session.exec_command.clone().unwrap_or_default();
let username = session.username.clone().unwrap_or_default();
let addr = session
.peer_addr
.map(|addr| addr.to_string())
.unwrap_or_default();
let tag = SshSessionTag::new(
session.exec_command,
session.username,
session.peer_addr,
session.pty_size,
session.up_rx,
session.down_tx,
);
let mut map = BTreeMap::new();
map.insert(
"session".to_string(),
Value::mint_heap(SshSessionTag::descriptor(), tag),
);
map.insert("command".to_string(), Value::string(command));
map.insert("username".to_string(), Value::string(username));
map.insert("addr".to_string(), Value::string(addr));
Ok(Value::map(map))
}
fn argv_list(value: &Value, func: &str) -> Result<Vec<String>> {
let Some(items) = value.as_list() else {
bail!("{func} argv must be a LIST of strings");
};
if items.is_empty() {
bail!("{func} argv must not be empty");
}
items
.iter()
.map(|item| {
item.as_str().map(str::to_string).ok_or_else(|| {
anyhow::anyhow!("{func} argv must be strings, got {}", item.type_name())
})
})
.collect()
}
fn serve_options(options: &Value) -> Result<&BTreeMap<String, Value>> {
options.as_map().ok_or_else(|| {
anyhow::anyhow!(
"SSH_SERVE options must be a MAP, got {}",
options.type_name()
)
})
}
fn required_string(map: &BTreeMap<String, Value>, func: &str, key: &str) -> Result<String> {
let Some(value) = map.get(key) else {
bail!("{func} option '{key}' is required");
};
let Some(s) = value.as_str() else {
bail!(
"{func} option '{key}' must be a STRING, got {}",
value.type_name()
);
};
if s.trim().is_empty() {
bail!("{func} option '{key}' must not be empty");
}
Ok(s.to_string())
}
fn optional_string(map: &BTreeMap<String, Value>, func: &str, key: &str) -> Result<Option<String>> {
let Some(value) = map.get(key) else {
return Ok(None);
};
let Some(s) = value.as_str() else {
bail!(
"{func} option '{key}' must be a STRING, got {}",
value.type_name()
);
};
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(s.to_string()))
}
fn ssh_serve<P: ProcessManager>(
cx: &mut StepCtx<P>,
registry: &Arc<EndpointRegistry>,
bind: String,
options: Value,
) -> Result<Value> {
let map = serve_options(&options)?;
for key in map.keys() {
if key != "username" && key != "password" && key != "key_path" {
bail!("SSH_SERVE() unknown option '{key}' (expected: username, password, key_path)");
}
}
let username = required_string(map, "SSH_SERVE", "username")?;
let password = required_string(map, "SSH_SERVE", "password")?;
let key_path = optional_string(map, "SSH_SERVE", "key_path")?;
let endpoint = parse_serve_endpoint(&bind)?;
let (acquired, registry) = acquire_listener(registry, &endpoint, "SSH_SERVE")?;
let host_key = match load_or_create_host_key(cx, "SSH_SERVE", key_path)? {
Some(key) => key,
None => PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)
.context("generate ephemeral Ed25519 host key")?,
};
let id = SERVER_IDS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let id = format!("ssh-{pid}-{id}", pid = std::process::id());
let queue = Arc::new(SessionQueue::new());
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<ShutdownSignal>();
let (local_addr, addr_text, thread) = match acquired {
AcquiredListener::Tcp { listener, addr } => {
let owned = listener
.try_clone()
.context("SSH_SERVE cannot clone its listener")?;
let thread_queue = Arc::clone(&queue);
let thread_user = username.clone();
let thread_pass = password.clone();
let thread = std::thread::Builder::new()
.name(id.clone())
.spawn(move || {
crate::runtime::serve(
owned,
host_key,
thread_user,
thread_pass,
thread_queue,
shutdown_rx,
)
})
.context("SSH_SERVE cannot spawn the server thread")?;
(addr, addr.to_string(), Some(thread))
}
AcquiredListener::Memory => {
drop(shutdown_rx);
bail!(
"SSH_SERVE: '{endpoint}' is a memory service (SSH needs a TCP socket; map it with -p/--listen)"
)
}
AcquiredListener::Offline => {
drop(shutdown_rx);
(
std::net::SocketAddr::from(([0, 0, 0, 0], 0)),
endpoint.to_string(),
None,
)
}
};
let state = Arc::new(ServerState::new(crate::state::ServerConfig {
id,
local_addr,
addr_text: addr_text.clone(),
queue,
shutdown_tx,
thread,
registry: Arc::clone(®istry),
endpoint: endpoint.clone(),
}));
let mut map = BTreeMap::new();
map.insert(
"server".to_string(),
Value::mint_heap(SshServerTag::descriptor(), SshServerTag::new(state)),
);
map.insert("addr".to_string(), Value::string(addr_text));
map.insert("username".to_string(), Value::string(username));
map.insert("password".to_string(), Value::string(password));
map.insert("virtual".to_string(), Value::string(endpoint.to_string()));
Ok(Value::map(map))
}
#[oxdock_func(returns = "MAP", summary = "Accept one SSH session into pipes.")]
fn ssh_accept<P: ProcessManager>(
cx: &mut StepCtx<P>,
server: Value,
in_pipe: Value,
out_pipe: Value,
) -> Result<Value> {
if !cx.is_async_task() {
bail!(
"SSH_ACCEPT requires ASYNC: wrap it as LET $t: HANDLE = ASYNC {{ SSH_ACCEPT($server, $in, $out) }}"
);
}
let state = server_state(&server, "SSH_ACCEPT")?;
let cancel = AtomicBool::new(false);
let session = dequeue_session(cx, state.queue(), "SSH_ACCEPT")?;
pump_session(
cx,
&in_pipe,
&out_pipe,
session.up_rx,
session.down_tx,
&cancel,
)?;
let mut map = BTreeMap::new();
map.insert("closed".to_string(), Value::bool(true));
map.insert(
"command".to_string(),
Value::string(session.exec_command.unwrap_or_default()),
);
Ok(Value::map(map))
}
#[oxdock_func(
returns = "MAP",
summary = "Pump a dequeued SSH session through pipes."
)]
fn ssh_pump_channel<P: ProcessManager>(
cx: &mut StepCtx<P>,
session: Value,
in_pipe: Value,
out_pipe: Value,
) -> Result<Value> {
if !cx.is_async_task() {
bail!("SSH_PUMP_CHANNEL requires ASYNC: pump it in its own task after SSH_DEQUEUE");
}
let tag = session_tag(&session, "SSH_PUMP_CHANNEL")?;
let (up_rx, down_tx) = tag.take_pump_ends()?;
let cancel = AtomicBool::new(false);
pump_session(cx, &in_pipe, &out_pipe, up_rx, down_tx, &cancel)?;
let mut map = BTreeMap::new();
map.insert("closed".to_string(), Value::bool(true));
Ok(Value::map(map))
}
#[oxdock_func(returns = "BOOL", summary = "Shut down an SSH server.")]
fn ssh_close<P: ProcessManager>(cx: &mut StepCtx<P>, server: Value) -> Result<Value> {
let _ = cx;
let state = server_state(&server, "SSH_CLOSE")?;
state.request_shutdown();
Ok(Value::bool(state.join_thread(CLOSE_JOIN_TIMEOUT)))
}
fn ssh_connect<P: ProcessManager>(
cx: &mut StepCtx<P>,
registry: &Arc<EndpointRegistry>,
target: String,
username: String,
password: String,
in_pipe: Value,
out_pipe: Value,
) -> Result<Value> {
if !cx.is_async_task() {
bail!("SSH_CONNECT requires ASYNC: run it in its own task beside the SSH_PUMP tasks");
}
if registry.is_offline() {
bail!("SSH_CONNECT failed: engine running in --offline mode");
}
if username.is_empty() {
bail!("SSH_CONNECT username must not be empty");
}
let addr = crate::validate::resolve_connect_addr(registry, &target)?;
let runtime = connect_runtime()?;
let session = runtime
.block_on(connect_session(&addr, &username, &password))
.context("SSH_CONNECT failed")?;
let crate::runtime::OutboundSession {
up_rx,
down_tx,
handle,
..
} = session;
let cancel = AtomicBool::new(false);
let pump = pump_session(cx, &in_pipe, &out_pipe, up_rx, down_tx, &cancel);
let _ = runtime.block_on(handle.disconnect(russh::Disconnect::ByApplication, "", ""));
pump?;
let mut map = BTreeMap::new();
map.insert("closed".to_string(), Value::bool(true));
Ok(Value::map(map))
}
#[oxdock_func(returns = "INT", summary = "Copy one pipe into another until EOF.")]
fn ssh_pump<P: ProcessManager>(
cx: &mut StepCtx<P>,
from_pipe: Value,
to_pipe: Value,
) -> Result<Value> {
let cancel = AtomicBool::new(false);
let total = pump_pipe_to_pipe(cx, &from_pipe, &to_pipe, &cancel)?;
Ok(Value::int(total))
}
#[oxdock_func(
returns = "INT",
summary = "Run a command under a sized local terminal into pipes."
)]
fn ssh_pty_run<P: ProcessManager>(
cx: &mut StepCtx<P>,
session: Value,
argv: Value,
rows: i64,
cols: i64,
in_pipe: Value,
out_pipe: Value,
) -> Result<Value> {
if !cx.is_async_task() {
bail!("SSH_PTY_RUN requires ASYNC: run it in its own task beside the session pump task");
}
let tag = session_tag(&session, "SSH_PTY_RUN")?;
let argv = argv_list(&argv, "SSH_PTY_RUN")?;
let initial = if rows > 0 && cols > 0 {
crate::state::PtySize::new(rows as u32, cols as u32)
} else {
tag.pty_size()
};
let cancel = AtomicBool::new(false);
let code = crate::pty::pump_pty_session(
cx,
&argv,
initial,
&tag.pty_size_handle(),
&in_pipe,
&out_pipe,
&cancel,
)?;
Ok(Value::int(code))
}
pub fn module_with<P: ProcessManager>() -> HostModule<P> {
module_with_endpoints(Arc::new(EndpointRegistry::new(false)))
}
pub fn module_with_endpoints<P: ProcessManager>(registry: Arc<EndpointRegistry>) -> HostModule<P> {
HostModule {
name: "SSH".to_string(),
funcs: vec![
ssh_serve_registration(Arc::clone(®istry)),
SshAccept::registration(),
SshDequeue::registration(),
SshPumpChannel::registration(),
SshClose::registration(),
ssh_connect_registration(registry),
SshPump::registration(),
SshPtyRun::registration(),
],
types: vec![SshServerTag::descriptor(), SshSessionTag::descriptor()],
}
}
fn ssh_serve_registration<P: ProcessManager>(
registry: Arc<EndpointRegistry>,
) -> HostRegistration<P> {
let func: NativeFn<P> = Arc::new(move |cx, values| {
if values.len() != 2 {
bail!("SSH_SERVE() expects 2 argument(s), got {}", values.len());
}
let mut values = values.into_iter();
let bind = match values.next().expect("arity checked above").as_str() {
Some(s) => s.to_string(),
None => bail!("SSH_SERVE() argument `$bind` must be a STRING"),
};
let options = values.next().expect("arity checked above");
ssh_serve(cx, ®istry, bind, options)
});
HostRegistration::Stateful {
name: "SSH_SERVE".to_string(),
meta: FuncMeta {
name: "SSH_SERVE".to_string(),
module: String::new(),
kind: FuncKind::HostCtx,
params: Some(vec![
FuncParam {
name: "bind".to_string(),
param_type: Some("STRING".to_string()),
},
FuncParam {
name: "options".to_string(),
param_type: None,
},
]),
returns: Some("MAP".to_string()),
rpn: false,
summary: "Serve SSH on a virtual service endpoint.",
docs: "Serve SSH on a virtual service endpoint.",
},
func,
}
}
fn ssh_connect_registration<P: ProcessManager>(
registry: Arc<EndpointRegistry>,
) -> HostRegistration<P> {
let func: NativeFn<P> = Arc::new(move |cx, values| {
if values.len() != 5 {
bail!("SSH_CONNECT() expects 5 argument(s), got {}", values.len());
}
let mut values = values.into_iter();
let target = match values.next().expect("arity checked above").as_str() {
Some(s) => s.to_string(),
None => bail!("SSH_CONNECT() argument `$target` must be a STRING"),
};
let username = match values.next().expect("arity checked above").as_str() {
Some(s) => s.to_string(),
None => bail!("SSH_CONNECT() argument `$username` must be a STRING"),
};
let password = match values.next().expect("arity checked above").as_str() {
Some(s) => s.to_string(),
None => bail!("SSH_CONNECT() argument `$password` must be a STRING"),
};
let in_pipe = values.next().expect("arity checked above");
let out_pipe = values.next().expect("arity checked above");
ssh_connect(cx, ®istry, target, username, password, in_pipe, out_pipe)
});
HostRegistration::Stateful {
name: "SSH_CONNECT".to_string(),
meta: FuncMeta {
name: "SSH_CONNECT".to_string(),
module: String::new(),
kind: FuncKind::HostCtx,
params: Some(vec![
FuncParam {
name: "target".to_string(),
param_type: Some("STRING".to_string()),
},
FuncParam {
name: "username".to_string(),
param_type: Some("STRING".to_string()),
},
FuncParam {
name: "password".to_string(),
param_type: Some("STRING".to_string()),
},
FuncParam {
name: "in_pipe".to_string(),
param_type: None,
},
FuncParam {
name: "out_pipe".to_string(),
param_type: None,
},
]),
returns: Some("MAP".to_string()),
rpn: false,
summary: "Open an SSH client session into pipes.",
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.",
},
func,
}
}