feat: add provider ssh tcp data pool
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /app/src/components/backend-core
|
||||
ARG CARGO_BUILD_JOBS=1
|
||||
ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS}
|
||||
COPY src/components/backend-core/Cargo.toml ./Cargo.toml
|
||||
COPY src/components/backend-core/Cargo.lock ./Cargo.lock
|
||||
COPY src/components/backend-core/src ./src
|
||||
RUN CARGO_BUILD_JOBS=1 cargo build --release
|
||||
RUN cargo build --release --jobs "${CARGO_BUILD_JOBS}"
|
||||
|
||||
FROM postgres:16-bookworm
|
||||
RUN apt-get update \
|
||||
|
||||
@@ -135,6 +135,7 @@ pub fn read_config() -> anyhow::Result<RuntimeConfig> {
|
||||
Ok(RuntimeConfig {
|
||||
port: number_env("PORT")?,
|
||||
provider_port: number_env("PROVIDER_PORT")?,
|
||||
provider_data_port: optional_number_env("PROVIDER_DATA_PORT", 8082)?,
|
||||
database_url: database_url.clone(),
|
||||
identity: runtime_identity(&database_url),
|
||||
provider_token: required_env("PROVIDER_TOKEN")?,
|
||||
|
||||
@@ -10,6 +10,7 @@ mod overview;
|
||||
mod performance;
|
||||
mod provider_registry;
|
||||
mod scheduler;
|
||||
mod ssh_data_channel;
|
||||
mod ssh_bridge;
|
||||
mod state;
|
||||
mod task_dispatcher;
|
||||
@@ -36,6 +37,7 @@ use crate::performance::record_request_performance;
|
||||
use crate::provider_registry::{mark_stale_providers_offline, provider_ws};
|
||||
use crate::scheduler::{recover_scheduled_runs, run_due_scheduled_tasks};
|
||||
use crate::ssh_bridge::ssh_ws;
|
||||
use crate::ssh_data_channel::spawn_ssh_data_listener;
|
||||
use crate::state::AppState;
|
||||
use crate::task_dispatcher::mark_stale_tasks_failed;
|
||||
|
||||
@@ -71,6 +73,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let api_addr = SocketAddr::from(([0, 0, 0, 0], state.config.port));
|
||||
let provider_addr = SocketAddr::from(([0, 0, 0, 0], state.config.provider_port));
|
||||
let provider_data_addr = SocketAddr::from(([0, 0, 0, 0], state.config.provider_data_port));
|
||||
let api = Router::new()
|
||||
.route("/ws/ssh", get(ssh_ws_handler))
|
||||
.fallback(api_handler)
|
||||
@@ -84,19 +87,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let api_listener = TcpListener::bind(api_addr).await?;
|
||||
let provider_listener = TcpListener::bind(provider_addr).await?;
|
||||
let provider_data_listener = TcpListener::bind(provider_data_addr).await?;
|
||||
state.log(
|
||||
"info",
|
||||
"server_listening",
|
||||
Some(serde_json::json!({
|
||||
"apiUrl": format!("http://0.0.0.0:{}", state.config.port),
|
||||
"providerIngressUrl": format!("ws://0.0.0.0:{}/ws/provider", state.config.provider_port),
|
||||
"providerDataTcpUrl": format!("tcp://0.0.0.0:{}", state.config.provider_data_port),
|
||||
"logFile": state.config.log_file,
|
||||
})),
|
||||
);
|
||||
|
||||
let api_server = axum::serve(api_listener, api);
|
||||
let provider_server = axum::serve(provider_listener, provider);
|
||||
tokio::try_join!(api_server, provider_server)?;
|
||||
let provider_data_server = spawn_ssh_data_listener(state.clone(), provider_data_listener);
|
||||
tokio::try_join!(api_server, provider_server, provider_data_server)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -185,6 +191,7 @@ async fn provider_health_handler(State(state): State<Arc<AppState>>) -> Response
|
||||
"ok": true,
|
||||
"service": "unidesk-provider-ingress",
|
||||
"activeSocketCount": state.active_provider_count().await,
|
||||
"providerDataPort": state.config.provider_data_port,
|
||||
}),
|
||||
200,
|
||||
)
|
||||
|
||||
@@ -72,12 +72,7 @@ export async function handleProviderMessage(ws: ProviderSocket, raw: string | Bu
|
||||
ws.data.providerId = message.providerId;
|
||||
ctx.activeProviders.set(message.providerId, ws);
|
||||
|
||||
if (
|
||||
message.type === "host_ssh_opened" ||
|
||||
message.type === "host_ssh_data" ||
|
||||
message.type === "host_ssh_exit" ||
|
||||
message.type === "host_ssh_error"
|
||||
) {
|
||||
if (message.type === "host_ssh_error") {
|
||||
forwardSshProviderMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -657,7 +657,7 @@ async fn handle_provider_text(
|
||||
.insert(provider_id.clone(), connection.clone());
|
||||
let message_type = message.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
match message_type {
|
||||
"host_ssh_opened" | "host_ssh_data" | "host_ssh_exit" | "host_ssh_error" => {
|
||||
"host_ssh_error" => {
|
||||
forward_ssh_provider_message(state, &message).await;
|
||||
}
|
||||
"http_tunnel_response" => {
|
||||
@@ -820,6 +820,11 @@ async fn handle_provider_text(
|
||||
|
||||
pub async fn mark_provider_offline(state: &Arc<AppState>, provider_id: &str) -> anyhow::Result<()> {
|
||||
state.active_providers.write().await.remove(provider_id);
|
||||
state
|
||||
.active_ssh_data_channels
|
||||
.lock()
|
||||
.await
|
||||
.retain(|_, channel| channel.provider_id != provider_id);
|
||||
if !state.db_ready() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { Server } from "bun";
|
||||
import type {
|
||||
CoreHostSshCloseMessage,
|
||||
CoreHostSshEofMessage,
|
||||
CoreHostSshInputMessage,
|
||||
CoreHostSshOpenMessage,
|
||||
CoreHostSshResizeMessage,
|
||||
ProviderHostSshDataMessage,
|
||||
ProviderHostSshErrorMessage,
|
||||
ProviderHostSshExitMessage,
|
||||
ProviderHostSshOpenedMessage,
|
||||
} from "../../shared/src/index";
|
||||
import { ctx, sql, config, logger } from "./context";
|
||||
import type { ProviderSocket, WsData } from "./types";
|
||||
@@ -33,26 +25,13 @@ export function closeSshClient(sessionId: string, code = 1000, reason = "ssh ses
|
||||
}
|
||||
|
||||
export function forwardSshProviderMessage(
|
||||
message: ProviderHostSshOpenedMessage | ProviderHostSshDataMessage | ProviderHostSshExitMessage | ProviderHostSshErrorMessage,
|
||||
message: ProviderHostSshErrorMessage,
|
||||
): void {
|
||||
const client = sshClientFor(message.sessionId);
|
||||
if (client === null) {
|
||||
logger("warn", "ssh_client_missing", { providerId: message.providerId, sessionId: message.sessionId, type: message.type });
|
||||
return;
|
||||
}
|
||||
if (message.type === "host_ssh_opened") {
|
||||
wsSendJson(client, { type: "ssh.opened", providerId: message.providerId, sessionId: message.sessionId });
|
||||
return;
|
||||
}
|
||||
if (message.type === "host_ssh_data") {
|
||||
wsSendJson(client, { type: "ssh.data", stream: message.stream, data: message.data, encoding: message.encoding });
|
||||
return;
|
||||
}
|
||||
if (message.type === "host_ssh_exit") {
|
||||
wsSendJson(client, { type: "ssh.exit", exitCode: message.exitCode, signal: message.signal });
|
||||
setTimeout(() => closeSshClient(message.sessionId), 50);
|
||||
return;
|
||||
}
|
||||
wsSendJson(client, { type: "ssh.error", message: message.message });
|
||||
setTimeout(() => closeSshClient(message.sessionId, 1011, "ssh session error"), 50);
|
||||
}
|
||||
@@ -95,70 +74,23 @@ export async function handleSshClientMessage(ws: ProviderSocket, raw: string | B
|
||||
wsSendJson(ws, { type: "ssh.error", message: `provider does not declare host.ssh capability: ${providerId}` });
|
||||
return;
|
||||
}
|
||||
const sessionId = safeSessionId();
|
||||
ws.data.channel = "ssh";
|
||||
ws.data.providerId = providerId;
|
||||
ws.data.sessionId = sessionId;
|
||||
ctx.activeSshClients.set(sessionId, ws);
|
||||
const openMessage: CoreHostSshOpenMessage = {
|
||||
type: "host_ssh_open",
|
||||
sessionId,
|
||||
cols: numberFromUnknown(message.cols, 100, 20, 300),
|
||||
rows: numberFromUnknown(message.rows, 30, 8, 120),
|
||||
};
|
||||
if (typeof message.cwd === "string" && message.cwd.length > 0) openMessage.cwd = message.cwd;
|
||||
if (typeof message.command === "string" && message.command.length > 0) openMessage.command = message.command;
|
||||
if (typeof message.tty === "boolean") openMessage.tty = message.tty;
|
||||
provider.send(JSON.stringify(openMessage));
|
||||
wsSendJson(ws, { type: "ssh.dispatched", providerId, sessionId });
|
||||
logger("info", "ssh_session_dispatched", { providerId, sessionId, hasCommand: typeof openMessage.command === "string" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = ws.data.sessionId;
|
||||
const providerId = ws.data.providerId;
|
||||
if (sessionId === undefined || providerId === undefined) {
|
||||
if (message.type === "ssh.eof" || message.type === "ssh.close") return;
|
||||
wsSendJson(ws, { type: "ssh.error", message: "ssh session is not open" });
|
||||
return;
|
||||
}
|
||||
const provider = providerForSsh(providerId);
|
||||
if (provider === null) {
|
||||
wsSendJson(ws, { type: "ssh.error", message: `provider went offline: ${providerId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "ssh.input") {
|
||||
if (typeof message.data !== "string" || message.encoding !== "base64") {
|
||||
wsSendJson(ws, { type: "ssh.error", message: "ssh.input requires base64 data" });
|
||||
if (!(await providerSupports(providerId, "host.ssh.tcp-pool"))) {
|
||||
wsSendJson(ws, {
|
||||
type: "ssh.error",
|
||||
message: `provider gateway must be upgraded before host.ssh can run: ${providerId} does not declare host.ssh.tcp-pool`,
|
||||
failureKind: "provider-gateway-upgrade-required",
|
||||
providerId,
|
||||
requiredCapability: "host.ssh.tcp-pool",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inputMessage: CoreHostSshInputMessage = { type: "host_ssh_input", sessionId, data: message.data, encoding: "base64" };
|
||||
provider.send(JSON.stringify(inputMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "ssh.resize") {
|
||||
const resizeMessage: CoreHostSshResizeMessage = {
|
||||
type: "host_ssh_resize",
|
||||
sessionId,
|
||||
cols: numberFromUnknown(message.cols, 100, 20, 300),
|
||||
rows: numberFromUnknown(message.rows, 30, 8, 120),
|
||||
};
|
||||
provider.send(JSON.stringify(resizeMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "ssh.eof") {
|
||||
const eofMessage: CoreHostSshEofMessage = { type: "host_ssh_eof", sessionId };
|
||||
provider.send(JSON.stringify(eofMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "ssh.close") {
|
||||
const closeMessage: CoreHostSshCloseMessage = { type: "host_ssh_close", sessionId };
|
||||
provider.send(JSON.stringify(closeMessage));
|
||||
closeSshClient(sessionId);
|
||||
wsSendJson(ws, {
|
||||
type: "ssh.error",
|
||||
message: "legacy TypeScript backend-core does not implement host.ssh.tcp-pool data channels; use the Rust backend-core",
|
||||
failureKind: "backend-core-upgrade-required",
|
||||
providerId,
|
||||
requiredCapability: "host.ssh.tcp-pool",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
use axum::response::Response;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rand::Rng;
|
||||
use serde_json::{json, Value};
|
||||
@@ -10,7 +11,11 @@ use tokio::sync::mpsc;
|
||||
|
||||
use crate::db::provider_supports;
|
||||
use crate::http::json_response;
|
||||
use crate::state::{AppState, SshClientConnection};
|
||||
use crate::ssh_data_channel::{
|
||||
active_ssh_data_channel_count, claim_ssh_data_channel, close_ssh_data_session,
|
||||
idle_ssh_data_channel_count, send_ssh_data_frame,
|
||||
};
|
||||
use crate::state::{AppState, SshClientConnection, SshDataFrame};
|
||||
|
||||
pub async fn ssh_ws(
|
||||
state: Arc<AppState>,
|
||||
@@ -65,7 +70,7 @@ async fn ssh_socket_task(state: Arc<AppState>, socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
if let Some(session_id) = session_id {
|
||||
state.active_ssh_clients.write().await.remove(&session_id);
|
||||
close_ssh_client(&state, &session_id, Some("ssh client disconnected")).await;
|
||||
}
|
||||
send_task.abort();
|
||||
}
|
||||
@@ -104,18 +109,53 @@ async fn handle_ssh_client_text(
|
||||
let _ = client_sender.send(Message::Text(json!({ "type": "ssh.error", "message": format!("provider does not declare host.ssh capability: {provider_id}") }).to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
if !provider_supports(state, provider_id, "host.ssh.tcp-pool")
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = client_sender.send(Message::Text(json!({
|
||||
"type": "ssh.error",
|
||||
"message": format!("provider gateway must be upgraded before host.ssh can run: {provider_id} does not declare host.ssh.tcp-pool"),
|
||||
"failureKind": "provider-gateway-upgrade-required",
|
||||
"providerId": provider_id,
|
||||
"requiredCapability": "host.ssh.tcp-pool",
|
||||
}).to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
let new_session_id = safe_session_id();
|
||||
let Some((data_channel_id, _data_writer)) =
|
||||
claim_ssh_data_channel(state, provider_id, &new_session_id).await
|
||||
else {
|
||||
let ready = idle_ssh_data_channel_count(state, provider_id).await;
|
||||
let total = active_ssh_data_channel_count(state, provider_id).await;
|
||||
let _ = client_sender.send(Message::Text(json!({
|
||||
"type": "ssh.error",
|
||||
"message": format!("provider ssh tcp data pool has no idle channel: {provider_id}"),
|
||||
"failureKind": "provider-data-pool-exhausted",
|
||||
"providerId": provider_id,
|
||||
"dataPool": {
|
||||
"ready": ready,
|
||||
"total": total,
|
||||
"required": 1
|
||||
},
|
||||
"requiredCapability": "host.ssh.tcp-pool",
|
||||
}).to_string()));
|
||||
return Ok(());
|
||||
};
|
||||
*session_id = Some(new_session_id.clone());
|
||||
state.active_ssh_clients.write().await.insert(
|
||||
new_session_id.clone(),
|
||||
SshClientConnection {
|
||||
provider_id: provider_id.to_string(),
|
||||
sender: client_sender.clone(),
|
||||
data_channel_id: data_channel_id.clone(),
|
||||
},
|
||||
);
|
||||
let mut open_message = json!({
|
||||
"type": "host_ssh_open",
|
||||
"sessionId": new_session_id,
|
||||
"transport": "tcp-pool",
|
||||
"dataChannelId": data_channel_id,
|
||||
"cols": number_from_unknown(message.get("cols"), 100, 20, 300),
|
||||
"rows": number_from_unknown(message.get("rows"), 30, 8, 120),
|
||||
});
|
||||
@@ -139,8 +179,8 @@ async fn handle_ssh_client_text(
|
||||
let _ = provider
|
||||
.sender
|
||||
.send(Message::Text(open_message.to_string()));
|
||||
let _ = client_sender.send(Message::Text(json!({ "type": "ssh.dispatched", "providerId": provider_id, "sessionId": new_session_id }).to_string()));
|
||||
state.log("info", "ssh_session_dispatched", Some(json!({ "providerId": provider_id, "sessionId": session_id, "hasCommand": open_message.get("command").is_some() })));
|
||||
let _ = client_sender.send(Message::Text(json!({ "type": "ssh.dispatched", "providerId": provider_id, "sessionId": new_session_id, "transport": "tcp-pool", "dataChannelId": open_message.get("dataChannelId").cloned().unwrap_or(Value::Null) }).to_string()));
|
||||
state.log("info", "ssh_session_dispatched", Some(json!({ "providerId": provider_id, "sessionId": new_session_id, "transport": "tcp-pool", "dataChannelId": open_message.get("dataChannelId").cloned().unwrap_or(Value::Null), "hasCommand": open_message.get("command").is_some() })));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -153,28 +193,15 @@ async fn handle_ssh_client_text(
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
let provider_id = {
|
||||
let session = {
|
||||
let clients = state.active_ssh_clients.read().await;
|
||||
clients
|
||||
.get(¤t_session_id)
|
||||
.map(|client| client.provider_id.clone())
|
||||
.map(|client| (client.provider_id.clone(), client.data_channel_id.clone()))
|
||||
};
|
||||
let provider_id = provider_id.unwrap_or_else(|| {
|
||||
message
|
||||
.get("providerId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
});
|
||||
let provider = state
|
||||
.active_providers
|
||||
.read()
|
||||
.await
|
||||
.get(&provider_id)
|
||||
.cloned();
|
||||
let Some(provider) = provider else {
|
||||
let Some((provider_id, data_channel_id)) = session else {
|
||||
let _ = client_sender.send(Message::Text(
|
||||
json!({ "type": "ssh.error", "message": "provider went offline" }).to_string(),
|
||||
json!({ "type": "ssh.error", "message": "ssh session disappeared" }).to_string(),
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
@@ -190,28 +217,60 @@ async fn handle_ssh_client_text(
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
let _ = provider.sender.send(Message::Text(json!({ "type": "host_ssh_input", "sessionId": current_session_id, "data": data, "encoding": "base64" }).to_string()));
|
||||
let bytes = match base64::engine::general_purpose::STANDARD.decode(data) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
let _ = client_sender.send(Message::Text(
|
||||
json!({ "type": "ssh.error", "message": format!("invalid ssh.input base64: {error}") })
|
||||
.to_string(),
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !send_ssh_data_frame(
|
||||
state,
|
||||
&provider_id,
|
||||
&data_channel_id,
|
||||
SshDataFrame {
|
||||
header: json!({ "type": "input", "sessionId": current_session_id }),
|
||||
payload: bytes,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = client_sender.send(Message::Text(json!({ "type": "ssh.error", "message": "ssh tcp data channel is not available", "failureKind": "provider-data-channel-missing", "providerId": provider_id, "dataChannelId": data_channel_id }).to_string()));
|
||||
}
|
||||
}
|
||||
"ssh.resize" => {
|
||||
let _ = provider.sender.send(Message::Text(
|
||||
json!({
|
||||
"type": "host_ssh_resize",
|
||||
let _ = send_ssh_data_frame(
|
||||
state,
|
||||
&provider_id,
|
||||
&data_channel_id,
|
||||
SshDataFrame {
|
||||
header: json!({
|
||||
"type": "resize",
|
||||
"sessionId": current_session_id,
|
||||
"cols": number_from_unknown(message.get("cols"), 100, 20, 300),
|
||||
"rows": number_from_unknown(message.get("rows"), 30, 8, 120),
|
||||
})
|
||||
.to_string(),
|
||||
));
|
||||
}),
|
||||
payload: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"ssh.eof" => {
|
||||
let _ = provider.sender.send(Message::Text(
|
||||
json!({ "type": "host_ssh_eof", "sessionId": current_session_id }).to_string(),
|
||||
));
|
||||
let _ = send_ssh_data_frame(
|
||||
state,
|
||||
&provider_id,
|
||||
&data_channel_id,
|
||||
SshDataFrame {
|
||||
header: json!({ "type": "eof", "sessionId": current_session_id }),
|
||||
payload: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"ssh.close" => {
|
||||
let _ = provider.sender.send(Message::Text(
|
||||
json!({ "type": "host_ssh_close", "sessionId": current_session_id }).to_string(),
|
||||
));
|
||||
close_ssh_client(state, ¤t_session_id, None).await;
|
||||
}
|
||||
_ => {
|
||||
@@ -232,23 +291,15 @@ pub async fn forward_ssh_provider_message(state: &Arc<AppState>, message: &Value
|
||||
return;
|
||||
};
|
||||
let message_type = message.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let outbound = match message_type {
|
||||
"host_ssh_opened" => {
|
||||
json!({ "type": "ssh.opened", "providerId": message.get("providerId").cloned().unwrap_or(Value::Null), "sessionId": session_id })
|
||||
}
|
||||
"host_ssh_data" => {
|
||||
json!({ "type": "ssh.data", "stream": message.get("stream").cloned().unwrap_or(Value::Null), "data": message.get("data").cloned().unwrap_or(Value::Null), "encoding": message.get("encoding").cloned().unwrap_or(Value::Null) })
|
||||
}
|
||||
"host_ssh_exit" => {
|
||||
json!({ "type": "ssh.exit", "exitCode": message.get("exitCode").cloned().unwrap_or(Value::Null), "signal": message.get("signal").cloned().unwrap_or(Value::Null) })
|
||||
}
|
||||
_ => {
|
||||
json!({ "type": "ssh.error", "message": message.get("message").and_then(Value::as_str).unwrap_or("ssh session error") })
|
||||
}
|
||||
};
|
||||
let outbound = json!({
|
||||
"type": "ssh.error",
|
||||
"message": message.get("message").and_then(Value::as_str).unwrap_or("ssh session error"),
|
||||
"transport": "tcp-pool",
|
||||
"controlFallback": true,
|
||||
});
|
||||
let _ = client.sender.send(Message::Text(outbound.to_string()));
|
||||
drop(clients);
|
||||
if message_type == "host_ssh_exit" || message_type == "host_ssh_error" {
|
||||
if message_type == "host_ssh_error" {
|
||||
let state = state.clone();
|
||||
let session_id = session_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
@@ -259,6 +310,7 @@ pub async fn forward_ssh_provider_message(state: &Arc<AppState>, message: &Value
|
||||
}
|
||||
|
||||
async fn close_ssh_client(state: &Arc<AppState>, session_id: &str, message: Option<&str>) {
|
||||
close_ssh_data_session(state, session_id).await;
|
||||
let client = state.active_ssh_clients.write().await.remove(session_id);
|
||||
if let Some(client) = client {
|
||||
let close = Message::Close(Some(axum::extract::ws::CloseFrame {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::Message;
|
||||
use base64::Engine;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{tcp::OwnedReadHalf, tcp::OwnedWriteHalf, TcpListener, TcpStream};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::state::{AppState, SshDataChannel, SshDataFrame};
|
||||
|
||||
const SSH_DATA_PROTOCOL: &str = "unidesk-host-ssh-tcp-pool-v1";
|
||||
const SSH_DATA_MAX_HEADER_BYTES: usize = 64 * 1024;
|
||||
const SSH_DATA_MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn channel_key(provider_id: &str, channel_id: &str) -> String {
|
||||
format!("{provider_id}:{channel_id}")
|
||||
}
|
||||
|
||||
fn header_string(header: &Value, key: &str) -> String {
|
||||
header
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn read_data_frame(reader: &mut OwnedReadHalf) -> std::io::Result<Option<(Value, Vec<u8>)>> {
|
||||
let mut header_len_bytes = [0_u8; 4];
|
||||
if let Err(error) = reader.read_exact(&mut header_len_bytes).await {
|
||||
if error.kind() == ErrorKind::UnexpectedEof {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
let header_len = u32::from_be_bytes(header_len_bytes) as usize;
|
||||
if header_len == 0 || header_len > SSH_DATA_MAX_HEADER_BYTES {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("invalid ssh data header length: {header_len}"),
|
||||
));
|
||||
}
|
||||
let mut header_bytes = vec![0_u8; header_len];
|
||||
reader.read_exact(&mut header_bytes).await?;
|
||||
let header: Value = serde_json::from_slice(&header_bytes)
|
||||
.map_err(|error| Error::new(ErrorKind::InvalidData, error.to_string()))?;
|
||||
let payload_len = header
|
||||
.get("length")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as usize;
|
||||
if payload_len > SSH_DATA_MAX_PAYLOAD_BYTES {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("ssh data payload too large: {payload_len}"),
|
||||
));
|
||||
}
|
||||
let mut payload = vec![0_u8; payload_len];
|
||||
if payload_len > 0 {
|
||||
reader.read_exact(&mut payload).await?;
|
||||
}
|
||||
Ok(Some((header, payload)))
|
||||
}
|
||||
|
||||
async fn write_data_frame(
|
||||
writer: &mut OwnedWriteHalf,
|
||||
frame: SshDataFrame,
|
||||
) -> std::io::Result<()> {
|
||||
let mut header = frame.header;
|
||||
if let Some(object) = header.as_object_mut() {
|
||||
object.insert(
|
||||
"length".to_string(),
|
||||
Value::Number(serde_json::Number::from(frame.payload.len())),
|
||||
);
|
||||
}
|
||||
let header_bytes = serde_json::to_vec(&header)
|
||||
.map_err(|error| Error::new(ErrorKind::InvalidData, error.to_string()))?;
|
||||
if header_bytes.len() > SSH_DATA_MAX_HEADER_BYTES {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("ssh data header too large: {}", header_bytes.len()),
|
||||
));
|
||||
}
|
||||
writer
|
||||
.write_all(&(header_bytes.len() as u32).to_be_bytes())
|
||||
.await?;
|
||||
writer.write_all(&header_bytes).await?;
|
||||
if !frame.payload.is_empty() {
|
||||
writer.write_all(&frame.payload).await?;
|
||||
}
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn claim_ssh_data_channel(
|
||||
state: &Arc<AppState>,
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
) -> Option<(String, mpsc::UnboundedSender<SshDataFrame>)> {
|
||||
let mut channels = state.active_ssh_data_channels.lock().await;
|
||||
let channel = channels
|
||||
.values_mut()
|
||||
.filter(|channel| channel.provider_id == provider_id && channel.active_session_id.is_none())
|
||||
.min_by(|left, right| left.channel_id.cmp(&right.channel_id))?;
|
||||
channel.active_session_id = Some(session_id.to_string());
|
||||
Some((channel.channel_id.clone(), channel.writer.clone()))
|
||||
}
|
||||
|
||||
pub async fn send_ssh_data_frame(
|
||||
state: &Arc<AppState>,
|
||||
provider_id: &str,
|
||||
channel_id: &str,
|
||||
frame: SshDataFrame,
|
||||
) -> bool {
|
||||
let channels = state.active_ssh_data_channels.lock().await;
|
||||
channels
|
||||
.get(&channel_key(provider_id, channel_id))
|
||||
.is_some_and(|channel| channel.writer.send(frame).is_ok())
|
||||
}
|
||||
|
||||
pub async fn release_ssh_data_channel(
|
||||
state: &Arc<AppState>,
|
||||
provider_id: &str,
|
||||
channel_id: &str,
|
||||
) {
|
||||
let mut channels = state.active_ssh_data_channels.lock().await;
|
||||
if let Some(channel) = channels.get_mut(&channel_key(provider_id, channel_id)) {
|
||||
channel.active_session_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_ssh_data_session(state: &Arc<AppState>, session_id: &str) {
|
||||
let client = {
|
||||
let clients = state.active_ssh_clients.read().await;
|
||||
clients.get(session_id).map(|client| {
|
||||
(
|
||||
client.provider_id.clone(),
|
||||
client.data_channel_id.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let Some((provider_id, channel_id)) = client else {
|
||||
return;
|
||||
};
|
||||
let _ = send_ssh_data_frame(
|
||||
state,
|
||||
&provider_id,
|
||||
&channel_id,
|
||||
SshDataFrame {
|
||||
header: json!({ "type": "close", "sessionId": session_id }),
|
||||
payload: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
release_ssh_data_channel(state, &provider_id, &channel_id).await;
|
||||
}
|
||||
|
||||
pub async fn active_ssh_data_channel_count(state: &Arc<AppState>, provider_id: &str) -> usize {
|
||||
let channels = state.active_ssh_data_channels.lock().await;
|
||||
channels
|
||||
.values()
|
||||
.filter(|channel| channel.provider_id == provider_id)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub async fn idle_ssh_data_channel_count(state: &Arc<AppState>, provider_id: &str) -> usize {
|
||||
let channels = state.active_ssh_data_channels.lock().await;
|
||||
channels
|
||||
.values()
|
||||
.filter(|channel| channel.provider_id == provider_id && channel.active_session_id.is_none())
|
||||
.count()
|
||||
}
|
||||
|
||||
async fn forward_provider_frame(
|
||||
state: &Arc<AppState>,
|
||||
provider_id: &str,
|
||||
channel_id: &str,
|
||||
header: Value,
|
||||
payload: Vec<u8>,
|
||||
) {
|
||||
let session_id = header_string(&header, "sessionId");
|
||||
if session_id.is_empty() {
|
||||
state.log(
|
||||
"warn",
|
||||
"ssh_data_frame_missing_session",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id, "header": header })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let clients = state.active_ssh_clients.read().await;
|
||||
let Some(client) = clients.get(&session_id) else {
|
||||
state.log(
|
||||
"warn",
|
||||
"ssh_data_client_missing",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id, "sessionId": session_id, "header": header })),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let frame_type = header_string(&header, "type");
|
||||
let client_sender = client.sender.clone();
|
||||
let outbound = match frame_type.as_str() {
|
||||
"opened" => json!({
|
||||
"type": "ssh.opened",
|
||||
"providerId": provider_id,
|
||||
"sessionId": session_id,
|
||||
"transport": "tcp-pool",
|
||||
"dataChannelId": channel_id,
|
||||
}),
|
||||
"data" => json!({
|
||||
"type": "ssh.data",
|
||||
"stream": header.get("stream").cloned().unwrap_or_else(|| Value::String("stdout".to_string())),
|
||||
"data": base64::engine::general_purpose::STANDARD.encode(payload),
|
||||
"encoding": "base64",
|
||||
"transport": "tcp-pool",
|
||||
"dataChannelId": channel_id,
|
||||
}),
|
||||
"exit" => json!({
|
||||
"type": "ssh.exit",
|
||||
"exitCode": header.get("exitCode").cloned().unwrap_or(Value::Null),
|
||||
"signal": header.get("signal").cloned().unwrap_or(Value::Null),
|
||||
"transport": "tcp-pool",
|
||||
"dataChannelId": channel_id,
|
||||
}),
|
||||
"error" => json!({
|
||||
"type": "ssh.error",
|
||||
"message": header.get("message").and_then(Value::as_str).unwrap_or("ssh data channel error"),
|
||||
"transport": "tcp-pool",
|
||||
"dataChannelId": channel_id,
|
||||
}),
|
||||
_ => json!({
|
||||
"type": "ssh.error",
|
||||
"message": format!("unsupported ssh data frame: {frame_type}"),
|
||||
"transport": "tcp-pool",
|
||||
"dataChannelId": channel_id,
|
||||
}),
|
||||
};
|
||||
let _ = client_sender.send(Message::Text(outbound.to_string()));
|
||||
drop(clients);
|
||||
if frame_type == "exit" || frame_type == "error" {
|
||||
let close = Message::Close(Some(axum::extract::ws::CloseFrame {
|
||||
code: axum::extract::ws::close_code::NORMAL,
|
||||
reason: "ssh session closed".to_string().into(),
|
||||
}));
|
||||
let _ = client_sender.send(close);
|
||||
release_ssh_data_channel(state, provider_id, channel_id).await;
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
state.active_ssh_clients.write().await.remove(&session_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ssh_data_stream(state: Arc<AppState>, stream: TcpStream) {
|
||||
let peer_addr = stream.peer_addr().ok();
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
let hello = match read_data_frame(&mut reader).await {
|
||||
Ok(Some((header, _))) => header,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
state.log("warn", "ssh_data_hello_read_failed", Some(json!({ "peer": peer_addr.map(|addr| addr.to_string()), "error": error.to_string() })));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let provider_id = header_string(&hello, "providerId");
|
||||
let channel_id = header_string(&hello, "channelId");
|
||||
let token = header_string(&hello, "token");
|
||||
let protocol = header_string(&hello, "protocol");
|
||||
if provider_id.is_empty()
|
||||
|| channel_id.is_empty()
|
||||
|| token != state.config.provider_token
|
||||
|| protocol != SSH_DATA_PROTOCOL
|
||||
{
|
||||
state.log(
|
||||
"warn",
|
||||
"ssh_data_hello_rejected",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id, "protocol": protocol, "peer": peer_addr.map(|addr| addr.to_string()) })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !state.active_providers.read().await.contains_key(&provider_id) {
|
||||
state.log(
|
||||
"warn",
|
||||
"ssh_data_provider_not_registered",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id })),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SshDataFrame>();
|
||||
state.active_ssh_data_channels.lock().await.insert(
|
||||
channel_key(&provider_id, &channel_id),
|
||||
SshDataChannel {
|
||||
provider_id: provider_id.clone(),
|
||||
channel_id: channel_id.clone(),
|
||||
writer: tx,
|
||||
active_session_id: None,
|
||||
},
|
||||
);
|
||||
state.log(
|
||||
"info",
|
||||
"ssh_data_channel_ready",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id, "peer": peer_addr.map(|addr| addr.to_string()) })),
|
||||
);
|
||||
|
||||
let writer_state = state.clone();
|
||||
let writer_provider_id = provider_id.clone();
|
||||
let writer_channel_id = channel_id.clone();
|
||||
let writer_task = tokio::spawn(async move {
|
||||
while let Some(frame) = rx.recv().await {
|
||||
if let Err(error) = write_data_frame(&mut writer, frame).await {
|
||||
writer_state.log(
|
||||
"warn",
|
||||
"ssh_data_write_failed",
|
||||
Some(json!({ "providerId": writer_provider_id, "channelId": writer_channel_id, "error": error.to_string() })),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
match read_data_frame(&mut reader).await {
|
||||
Ok(Some((header, payload))) => {
|
||||
forward_provider_frame(&state, &provider_id, &channel_id, header, payload).await;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
state.log(
|
||||
"warn",
|
||||
"ssh_data_read_failed",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id, "error": error.to_string() })),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
.active_ssh_data_channels
|
||||
.lock()
|
||||
.await
|
||||
.remove(&channel_key(&provider_id, &channel_id));
|
||||
writer_task.abort();
|
||||
state.log(
|
||||
"warn",
|
||||
"ssh_data_channel_closed",
|
||||
Some(json!({ "providerId": provider_id, "channelId": channel_id })),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn spawn_ssh_data_listener(
|
||||
state: Arc<AppState>,
|
||||
listener: TcpListener,
|
||||
) -> std::io::Result<()> {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
tokio::spawn(handle_ssh_data_stream(state.clone(), stream));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,19 @@ pub struct ProviderConnection {
|
||||
pub struct SshClientConnection {
|
||||
pub provider_id: String,
|
||||
pub sender: mpsc::UnboundedSender<Message>,
|
||||
pub data_channel_id: String,
|
||||
}
|
||||
|
||||
pub struct SshDataFrame {
|
||||
pub header: JsonValue,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct SshDataChannel {
|
||||
pub provider_id: String,
|
||||
pub channel_id: String,
|
||||
pub writer: mpsc::UnboundedSender<SshDataFrame>,
|
||||
pub active_session_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct HttpTunnelResponse {
|
||||
@@ -43,6 +56,7 @@ pub struct AppState {
|
||||
pub service_started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub active_providers: RwLock<HashMap<String, Arc<ProviderConnection>>>,
|
||||
pub active_ssh_clients: RwLock<HashMap<String, SshClientConnection>>,
|
||||
pub active_ssh_data_channels: Mutex<HashMap<String, SshDataChannel>>,
|
||||
pub http_tunnel_waiters: Mutex<HashMap<String, HttpTunnelWaiter>>,
|
||||
pub task_terminal_waiters: Mutex<HashMap<String, Vec<TaskTerminalWaiter>>>,
|
||||
pub active_egress_tcp: Mutex<HashMap<String, crate::egress_tcp::EgressTcpConnection>>,
|
||||
@@ -77,6 +91,7 @@ impl AppState {
|
||||
service_started_at: chrono::Utc::now(),
|
||||
active_providers: RwLock::new(HashMap::new()),
|
||||
active_ssh_clients: RwLock::new(HashMap::new()),
|
||||
active_ssh_data_channels: Mutex::new(HashMap::new()),
|
||||
http_tunnel_waiters: Mutex::new(HashMap::new()),
|
||||
task_terminal_waiters: Mutex::new(HashMap::new()),
|
||||
active_egress_tcp: Mutex::new(HashMap::new()),
|
||||
|
||||
@@ -9,6 +9,7 @@ pub type JsonValue = Value;
|
||||
pub struct RuntimeConfig {
|
||||
pub port: u16,
|
||||
pub provider_port: u16,
|
||||
pub provider_data_port: u16,
|
||||
pub database_url: String,
|
||||
pub identity: RuntimeIdentity,
|
||||
pub provider_token: String,
|
||||
|
||||
Reference in New Issue
Block a user