feat(vnc): add VNC support for web, including frame subscription and clipboard management; enhance proxy settings and session handling for Telnet and VNC protocols

This commit is contained in:
Kang committed 2026-10-04 10:38:46 +08:00
1 parent 304e3b3a2d
commit ed348d88e2
69 files changed
+6297 -2816

No files matched your search

+5
View File
@@ -5008,8 +5008,10 @@ name = "nyaterm-core"
version = "0.1.0"
dependencies = [
"aes-gcm 0.10.3",
"async-http-proxy",
"base64 0.22.1",
"dirs",
"encoding_rs",
"font-kit",
"futures-util",
"genai",
@@ -5026,12 +5028,15 @@ dependencies = [
"sha1 0.10.7",
"sha2 0.10.9",
"ssh-key 0.6.7",
"strip-ansi-escapes",
"thiserror 2.0.20",
"time",
"tokio",
"tokio-socks",
"tokio-util",
"tracing",
"uuid",
"vnc-rs",
"zeroize",
]
+211 -7
View File
@@ -122,6 +122,28 @@ dependencies = [
"tokio",
]
[[package]]
name = "async-http-proxy"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592"
dependencies = [
"base64 0.13.1",
"httparse",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async_io_stream"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
dependencies = [
"futures",
"rustc_version",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -146,6 +168,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.22.1"
@@ -252,12 +280,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -344,6 +384,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "cmac"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
dependencies = [
"cipher 0.4.4",
"dbl",
"digest 0.10.7",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -680,6 +731,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbl"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9"
dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "defmt"
version = "1.1.1"
@@ -708,7 +768,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -729,6 +789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468 0.7.0",
"zeroize",
]
@@ -884,6 +945,19 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "eax"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
dependencies = [
"aead 0.5.2",
"cipher 0.4.4",
"cmac",
"ctr 0.9.2",
"subtle",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -960,6 +1034,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@@ -1768,6 +1848,20 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2066,6 +2160,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -2187,8 +2291,10 @@ name = "nyaterm-core"
version = "0.1.0"
dependencies = [
"aes-gcm 0.10.3",
"async-http-proxy",
"base64 0.22.1",
"dirs",
"encoding_rs",
"font-kit",
"futures-util",
"genai",
@@ -2205,13 +2311,16 @@ dependencies = [
"sha1 0.10.7",
"sha2 0.10.9",
"ssh-key 0.6.7",
"strip-ansi-escapes",
"tempfile",
"thiserror",
"thiserror 2.0.21",
"time",
"tokio",
"tokio-socks",
"tokio-util",
"tracing",
"uuid",
"vnc-rs",
"zeroize",
]
@@ -2367,7 +2476,7 @@ dependencies = [
"log",
"rand 0.10.3",
"sha2 0.11.0",
"thiserror",
"thiserror 2.0.21",
"tokio",
"windows",
"windows-strings",
@@ -2655,6 +2764,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quote"
version = "1.0.47"
@@ -2676,6 +2791,7 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
]
@@ -2741,7 +2857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
"libredox",
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -2974,7 +3090,7 @@ dependencies = [
"ssh-encoding 0.3.0",
"ssh-key 0.7.0-rc.11",
"subtle",
"thiserror",
"thiserror 2.0.21",
"tokio",
"typenum",
"universal-hash 0.6.1",
@@ -3004,7 +3120,7 @@ dependencies = [
"log",
"serde",
"serde_bytes",
"thiserror",
"thiserror 2.0.21",
"tokio",
"tokio-util",
"wasm-bindgen-futures",
@@ -3570,6 +3686,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strip-ansi-escapes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3679,13 +3804,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -3791,6 +3936,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-socks"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -3810,6 +3967,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"libc",
@@ -3873,6 +4031,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -4012,6 +4171,36 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vnc-rs"
version = "0.5.3"
dependencies = [
"aes 0.8.4",
"async_io_stream",
"eax",
"flate2",
"futures",
"image",
"rand 0.8.8",
"rsa 0.9.10",
"sha2 0.10.9",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"wasm-bindgen-futures",
]
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4502,3 +4691,18 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+6
View File
@@ -35,6 +35,12 @@ time = { version = "0.3", features = ["formatting", "macros"] }
genai = { version = "0.6.5", default-features = false, features = ["native-tls"] }
reqwest = { version = "0.13", default-features = false, features = ["socks", "json", "form", "query", "native-tls"] }
encoding_rs = "0.8"
strip-ansi-escapes = "0.2"
vnc-rs = { path = "../../vendor/vnc-rs" }
tokio-socks = "0.5.2"
async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"] }
[dev-dependencies]
tempfile = "3"
+6
View File
@@ -7,3 +7,9 @@ pub mod services;
pub mod ssh;
pub mod storage;
pub mod utils;
pub mod network;
pub mod remote_desktop_frame;
pub mod telnet;
pub mod terminal_encoding;
pub mod vnc;
@@ -0,0 +1,178 @@
use crate::config::{self, ConnectionNetwork, ProxySettings};
use crate::error::{AppError, AppResult};
use std::net::SocketAddr;
use tokio::io::{AsyncRead, AsyncWrite};
pub trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite + ?Sized> AsyncReadWrite for T {}
pub type BoxedTransportStream = Box<dyn AsyncReadWrite + Unpin + Send + Sync>;
pub struct OpenedTransport {
pub stream: BoxedTransportStream,
pub local_addr: Option<SocketAddr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportRouteKind {
Direct,
Proxy,
SshJump,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedTransportRoute {
pub kind: TransportRouteKind,
pub proxy_id: Option<String>,
pub proxy_jump_id: Option<String>,
}
pub fn resolve_transport_route(network: Option<&ConnectionNetwork>) -> ResolvedTransportRoute {
let proxy_jump_id = network
.and_then(|network| network.proxy_jump_id.as_deref())
.filter(|id| !id.trim().is_empty())
.map(ToOwned::to_owned);
let proxy_id = network
.and_then(|network| network.proxy_id.as_deref())
.filter(|id| !id.trim().is_empty())
.map(ToOwned::to_owned);
if proxy_jump_id.is_some() {
return ResolvedTransportRoute {
kind: TransportRouteKind::SshJump,
proxy_id,
proxy_jump_id,
};
}
if proxy_id.is_some() {
return ResolvedTransportRoute {
kind: TransportRouteKind::Proxy,
proxy_id,
proxy_jump_id: None,
};
}
ResolvedTransportRoute {
kind: TransportRouteKind::Direct,
proxy_id: None,
proxy_jump_id: None,
}
}
pub async fn open_direct_transport(
target_host: &str,
target_port: u16,
) -> AppResult<OpenedTransport> {
let stream = tokio::net::TcpStream::connect((target_host, target_port))
.await
.map_err(|error| {
AppError::Channel(format!(
"Direct TCP connection to {target_host}:{target_port} failed: {error}"
))
})?;
let local_addr = stream.local_addr().ok();
Ok(OpenedTransport {
stream: Box::new(stream),
local_addr,
})
}
pub async fn open_proxy_transport(
proxy: ProxySettings,
target_host: &str,
target_port: u16,
) -> AppResult<OpenedTransport> {
let proxy_addr = if proxy.host.contains(':') {
format!("[{}]:{}", proxy.host, proxy.port)
} else {
format!("{}:{}", proxy.host, proxy.port)
};
if proxy.username.is_some() != proxy.password.is_some() {
return Err(AppError::Config(
"Proxy authentication requires both username and password".into(),
));
}
match proxy.protocol.as_str() {
"socks5" => {
let stream = match (&proxy.username, &proxy.password) {
(Some(user), Some(pass)) => {
tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host, target_port),
user,
pass,
)
.await
}
_ => {
tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host, target_port),
)
.await
}
}
.map_err(|error| AppError::Auth(format!("SOCKS5 proxy connection failed: {error}")))?
.into_inner();
let local_addr = stream.local_addr().ok();
Ok(OpenedTransport {
stream: Box::new(stream),
local_addr,
})
}
"http" => {
let mut stream =
tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|error| {
AppError::Channel(format!("HTTP proxy connection failed: {error}"))
})?;
match (&proxy.username, &proxy.password) {
(Some(user), Some(pass)) => {
async_http_proxy::http_connect_tokio_with_basic_auth(
&mut stream,
target_host,
target_port,
user,
pass,
)
.await
}
_ => {
async_http_proxy::http_connect_tokio(&mut stream, target_host, target_port)
.await
}
}
.map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {error}")))?;
let local_addr = stream.local_addr().ok();
Ok(OpenedTransport {
stream: Box::new(stream),
local_addr,
})
}
other => Err(AppError::Config(format!(
"Unsupported proxy protocol '{other}'"
))),
}
}
pub fn resolve_proxy(app: &impl Sized, proxy_id: &str) -> AppResult<ProxySettings> {
let proxy_cfg = config::load_proxy_by_id(app, proxy_id)?
.ok_or_else(|| AppError::Config(format!("Proxy '{proxy_id}' not found")))?;
let password = proxy_cfg
.password
.as_ref()
.map(|ciphertext| crate::utils::crypto::decrypt(ciphertext))
.transpose()?;
Ok(ProxySettings {
enabled: true,
protocol: proxy_cfg.protocol,
host: proxy_cfg.host,
port: proxy_cfg.port,
command: proxy_cfg.command,
username: proxy_cfg.username,
password,
})
}
@@ -0,0 +1,153 @@
use crate::error::{AppError, AppResult};
pub const FRAME_HEADER_BYTES: usize = 44;
pub const PIXEL_FORMAT_BGRA8888: u32 = 1;
pub const PIXEL_FORMAT_RGBA8888: u32 = 2;
const BYTES_PER_PIXEL: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum RemoteDesktopPixelFormat {
Bgra8888,
Rgba8888,
}
impl RemoteDesktopPixelFormat {
fn wire_value(self) -> u32 {
match self {
Self::Bgra8888 => PIXEL_FORMAT_BGRA8888,
Self::Rgba8888 => PIXEL_FORMAT_RGBA8888,
}
}
}
pub struct RemoteDesktopFramePatch<'a> {
pub sequence: u64,
pub desktop_width: u32,
pub desktop_height: u32,
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub stride: u32,
pub pixel_format: RemoteDesktopPixelFormat,
pub payload: &'a [u8],
}
pub fn encode_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<Vec<u8>> {
validate_frame_patch(patch)?;
let mut frame = Vec::with_capacity(
FRAME_HEADER_BYTES
.checked_add(patch.payload.len())
.ok_or_else(|| AppError::Config("Remote desktop frame size overflows".to_string()))?,
);
frame.extend_from_slice(&patch.sequence.to_le_bytes());
frame.extend_from_slice(&patch.desktop_width.to_le_bytes());
frame.extend_from_slice(&patch.desktop_height.to_le_bytes());
frame.extend_from_slice(&patch.x.to_le_bytes());
frame.extend_from_slice(&patch.y.to_le_bytes());
frame.extend_from_slice(&patch.width.to_le_bytes());
frame.extend_from_slice(&patch.height.to_le_bytes());
frame.extend_from_slice(&patch.stride.to_le_bytes());
frame.extend_from_slice(&patch.pixel_format.wire_value().to_le_bytes());
let payload_len = u32::try_from(patch.payload.len()).map_err(|_| {
AppError::Config("Remote desktop frame payload exceeds the wire limit".to_string())
})?;
frame.extend_from_slice(&payload_len.to_le_bytes());
frame.extend_from_slice(patch.payload);
Ok(frame)
}
fn validate_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<()> {
if patch.desktop_width == 0 || patch.desktop_height == 0 {
return Err(AppError::Config(
"Remote desktop framebuffer dimensions must be non-zero".to_string(),
));
}
if patch.width == 0 || patch.height == 0 {
return Err(AppError::Config(
"Remote desktop frame rectangle must be non-zero".to_string(),
));
}
let right = patch.x.checked_add(patch.width).ok_or_else(|| {
AppError::Config("Remote desktop frame horizontal bounds overflow".to_string())
})?;
let bottom = patch.y.checked_add(patch.height).ok_or_else(|| {
AppError::Config("Remote desktop frame vertical bounds overflow".to_string())
})?;
if right > patch.desktop_width || bottom > patch.desktop_height {
return Err(AppError::Config(
"Remote desktop frame rectangle exceeds framebuffer bounds".to_string(),
));
}
let row_bytes = usize::try_from(patch.width)
.ok()
.and_then(|width| width.checked_mul(BYTES_PER_PIXEL))
.ok_or_else(|| AppError::Config("Remote desktop frame row size overflows".to_string()))?;
let stride = usize::try_from(patch.stride)
.map_err(|_| AppError::Config("Remote desktop frame stride is invalid".to_string()))?;
if stride < row_bytes {
return Err(AppError::Config(
"Remote desktop frame stride is too small".to_string(),
));
}
let required_payload = usize::try_from(patch.height)
.ok()
.and_then(|height| stride.checked_mul(height))
.ok_or_else(|| {
AppError::Config("Remote desktop frame payload size overflows".to_string())
})?;
if patch.payload.len() < required_payload {
return Err(AppError::Config(
"Remote desktop frame payload is too small".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_the_shared_frame_header() {
let payload = [1, 2, 3, 255];
let frame = encode_frame_patch(&RemoteDesktopFramePatch {
sequence: 7,
desktop_width: 10,
desktop_height: 10,
x: 2,
y: 3,
width: 1,
height: 1,
stride: 4,
pixel_format: RemoteDesktopPixelFormat::Rgba8888,
payload: &payload,
})
.expect("frame should encode");
assert_eq!(frame.len(), FRAME_HEADER_BYTES + payload.len());
assert_eq!(u64::from_le_bytes(frame[0..8].try_into().unwrap()), 7);
assert_eq!(u32::from_le_bytes(frame[36..40].try_into().unwrap()), 2);
assert_eq!(&frame[FRAME_HEADER_BYTES..], &payload);
}
#[test]
fn rejects_rectangles_outside_the_framebuffer() {
let error = encode_frame_patch(&RemoteDesktopFramePatch {
sequence: 1,
desktop_width: 10,
desktop_height: 10,
x: 10,
y: 0,
width: 1,
height: 1,
stride: 4,
pixel_format: RemoteDesktopPixelFormat::Rgba8888,
payload: &[0; 4],
})
.expect_err("out-of-bounds rectangle must fail");
assert!(error.to_string().contains("exceeds framebuffer bounds"));
}
}
@@ -242,11 +242,12 @@ pub fn validate_proxy_jump_config(
if !matches!(
connection.config,
config::ConnectionType::Ssh { .. }
| config::ConnectionType::Telnet { .. }
| config::ConnectionType::Rdp { .. }
| config::ConnectionType::Vnc { .. }
) {
return Err(AppError::Config(
"ProxyJump is only supported for SSH, RDP, and VNC connections".to_string(),
"ProxyJump is only supported for SSH, Telnet, RDP, and VNC connections".to_string(),
));
}
@@ -255,6 +256,9 @@ pub fn validate_proxy_jump_config(
let mut current_jump_id = proxy_jump_id;
loop {
if visited.len() > 8 {
return Err(AppError::Config("SSH jump chain exceeds 8 hops".into()));
}
if !visited.insert(current_jump_id) {
if connection.id == current_jump_id {
return Err(AppError::Config(
@@ -4,6 +4,7 @@ use tokio_util::sync::CancellationToken;
pub enum Command {
Input(Vec<u8>),
RawInput(Vec<u8>),
Resize(u32, u32),
Close,
}
@@ -11,6 +12,7 @@ pub enum Output {
Data(Vec<u8>),
Closed,
Error,
Failure(String),
}
pub const CHUNK_BYTES: usize = 64 * 1024;
pub const OUTPUT_CAPACITY: usize = 32;
@@ -38,7 +40,7 @@ pub async fn run(
loop {
tokio::select! {
command = input.recv() => match command {
Some(Command::Input(data)) => write(&channel, &data).await?,
Some(Command::Input(data) | Command::RawInput(data)) => write(&channel, &data).await?,
Some(Command::Resize(cols, rows)) => resize(&channel, cols, rows).await?,
Some(Command::Close) | None => break,
},
@@ -89,7 +89,9 @@ impl TelnetAutoLogin {
if !config.enabled {
return None;
}
if credentials.username.trim().is_empty() && credentials.password.as_deref().is_none_or(str::is_empty) {
if credentials.username.trim().is_empty()
&& credentials.password.as_deref().is_none_or(str::is_empty)
{
return None;
}
@@ -117,9 +119,8 @@ impl TelnetAutoLogin {
return Vec::new();
}
if now.duration_since(self.started_at) > Duration::from_millis(self.config.timeout_ms) {
self.disabled = true;
return vec![TelnetAutoLoginAction::Disable];
if let Some(action) = self.tick(now) {
return vec![action];
}
self.push_tail(text);
@@ -161,7 +162,11 @@ impl TelnetAutoLogin {
}
if !self.sent_password
&& self.credentials.password.as_deref().is_some_and(|value| !value.is_empty())
&& self
.credentials
.password
.as_deref()
.is_some_and(|value| !value.is_empty())
&& self.matches_password_prompt(&prompts)
{
self.sent_password = true;
@@ -187,6 +192,18 @@ impl TelnetAutoLogin {
Some(TelnetAutoLoginAction::Disable)
}
pub fn tick(&mut self, now: Instant) -> Option<TelnetAutoLoginAction> {
if !self.disabled
&& !self.completed
&& now.duration_since(self.started_at) > Duration::from_millis(self.config.timeout_ms)
{
self.disabled = true;
Some(TelnetAutoLoginAction::Disable)
} else {
None
}
}
fn push_tail(&mut self, text: &str) {
self.tail.push_str(text);
self.tail = last_chars(&self.tail, AUTO_LOGIN_TAIL_CHARS);
@@ -227,13 +244,16 @@ impl TelnetAutoLogin {
fn matches_failure(&self, text: &str, last_line: &str) -> bool {
self.failure_regex.as_ref().map_or_else(
|| default_failure_regex().is_match(text) || default_failure_regex().is_match(last_line),
|| {
default_failure_regex().is_match(text)
|| default_failure_regex().is_match(last_line)
},
|regex| regex.is_match(text) || regex.is_match(last_line),
)
}
}
fn compile_optional_regex(pattern: Option<&str>) -> Option<Regex> {
pub fn compile_optional_regex(pattern: Option<&str>) -> Option<Regex> {
let trimmed = pattern?.trim();
if trimmed.is_empty() {
return None;
@@ -241,13 +261,13 @@ fn compile_optional_regex(pattern: Option<&str>) -> Option<Regex> {
Regex::new(trimmed).ok()
}
fn line_bytes(value: &str, enter_mode: TelnetEnterMode) -> Vec<u8> {
pub fn line_bytes(value: &str, enter_mode: TelnetEnterMode) -> Vec<u8> {
let mut data = value.as_bytes().to_vec();
data.push(b'\r');
normalize_enter_bytes(&data, enter_mode)
}
fn last_chars(value: &str, max_chars: usize) -> String {
pub fn last_chars(value: &str, max_chars: usize) -> String {
let len = value.chars().count();
if len <= max_chars {
return value.to_string();
@@ -255,7 +275,7 @@ fn last_chars(value: &str, max_chars: usize) -> String {
value.chars().skip(len - max_chars).collect()
}
fn last_non_empty_line(value: &str) -> String {
pub fn last_non_empty_line(value: &str) -> String {
value
.lines()
.rev()
@@ -265,7 +285,7 @@ fn last_non_empty_line(value: &str) -> String {
.to_string()
}
fn prompt_candidates(window: &str, current_input: &str) -> Vec<String> {
pub fn prompt_candidates(window: &str, current_input: &str) -> Vec<String> {
let mut prompts = Vec::new();
for source in [window, current_input] {
for line in source.lines() {
@@ -277,13 +297,13 @@ fn prompt_candidates(window: &str, current_input: &str) -> Vec<String> {
prompts
}
fn push_prompt_candidate(prompts: &mut Vec<String>, prompt: &str) {
pub fn push_prompt_candidate(prompts: &mut Vec<String>, prompt: &str) {
if !prompt.is_empty() && !prompts.iter().any(|existing| existing == prompt) {
prompts.push(prompt.to_string());
}
}
fn push_prompt_suffix_candidates(prompts: &mut Vec<String>, prompt: &str) {
pub fn push_prompt_suffix_candidates(prompts: &mut Vec<String>, prompt: &str) {
const KEYWORDS: &[&str] = &[
"user name",
"username",
@@ -321,7 +341,7 @@ fn push_prompt_suffix_candidates(prompts: &mut Vec<String>, prompt: &str) {
}
}
fn default_username_regex() -> &'static Regex {
pub fn default_username_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
@@ -331,14 +351,13 @@ fn default_username_regex() -> &'static Regex {
})
}
fn last_login_regex() -> &'static Regex {
pub fn last_login_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)\b(?:last|previous)\s+login\b").expect("last login regex")
})
REGEX
.get_or_init(|| Regex::new(r"(?i)\b(?:last|previous)\s+login\b").expect("last login regex"))
}
fn default_password_regex() -> &'static Regex {
pub fn default_password_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
@@ -348,7 +367,7 @@ fn default_password_regex() -> &'static Regex {
})
}
fn default_wake_regex() -> &'static Regex {
pub fn default_wake_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)(press\s+(?:return|<enter>|\[enter\]|enter|any\s+key))")
@@ -356,15 +375,17 @@ fn default_wake_regex() -> &'static Regex {
})
}
fn default_success_regex() -> &'static Regex {
pub fn default_success_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"[$#>]\s*$").expect("default success prompt regex"))
}
fn default_failure_regex() -> &'static Regex {
pub fn default_failure_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)(login\s+incorrect|authentication\s+failed|access\s+denied|密码错误|认证失败)")
.expect("default failure prompt regex")
Regex::new(
r"(?i)(login\s+incorrect|authentication\s+failed|access\s+denied|密码错误|认证失败)",
)
.expect("default failure prompt regex")
})
}
@@ -1,4 +1,4 @@
fn local_echo_text(data: &[u8]) -> String {
pub fn local_echo_text(data: &[u8]) -> String {
let mut visible = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
@@ -41,23 +41,22 @@ fn local_echo_text(data: &[u8]) -> String {
}
#[derive(Debug, Default)]
struct TelnetLineEditor {
pub struct TelnetLineEditor {
buffer: String,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct TelnetLineEditResult {
display: String,
writes: Vec<Vec<u8>>,
pub struct TelnetLineEditResult {
pub display: String,
pub writes: Vec<Vec<u8>>,
}
impl TelnetLineEditor {
#[cfg(test)]
fn buffer(&self) -> &str {
pub fn buffer(&self) -> &str {
&self.buffer
}
fn process(&mut self, data: &[u8], enter_mode: TelnetEnterMode) -> TelnetLineEditResult {
pub fn process(&mut self, data: &[u8], enter_mode: TelnetEnterMode) -> TelnetLineEditResult {
let input = String::from_utf8_lossy(data);
let mut result = TelnetLineEditResult::default();
let mut chars = input.char_indices().peekable();
@@ -116,7 +115,7 @@ impl TelnetLineEditor {
}
}
fn consume_escape_sequence_end(
pub fn consume_escape_sequence_end(
start: usize,
chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
) -> usize {
@@ -0,0 +1,17 @@
//! Transport-independent Telnet negotiation, editing and automatic login.
pub const IAC: u8 = 255;
pub const WILL: u8 = 251;
pub const WONT: u8 = 252;
pub const DO: u8 = 253;
pub const DONT: u8 = 254;
pub const SB: u8 = 250;
pub const SE: u8 = 240;
pub const OPT_ECHO: u8 = 1;
pub const OPT_SUPPRESS_GO_AHEAD: u8 = 3;
pub const OPT_NAWS: u8 = 31;
include!("types.rs");
include!("negotiation.rs");
include!("line_editor.rs");
include!("auto_login.rs");
include!("tests.rs");
@@ -1,4 +1,4 @@
fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec<u8> {
pub fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec<u8> {
match command {
WILL => {
if option == OPT_ECHO || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) {
@@ -8,7 +8,7 @@ fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool)
}
}
DO => {
if send_naws && option == OPT_NAWS {
if (send_naws && option == OPT_NAWS) || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) {
vec![IAC, WILL, option]
} else {
vec![IAC, WONT, option]
@@ -21,21 +21,56 @@ fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool)
}
/// Build a NAWS (Negotiate About Window Size) subnegotiation sequence.
fn build_naws(cols: u16, rows: u16) -> Vec<u8> {
vec![
IAC,
SB,
OPT_NAWS,
(cols >> 8) as u8,
(cols & 0xff) as u8,
(rows >> 8) as u8,
(rows & 0xff) as u8,
IAC,
SE,
]
pub fn build_naws(cols: u16, rows: u16) -> Vec<u8> {
let mut result = vec![IAC, SB, OPT_NAWS];
result.extend(escape_telnet_application_data(
&[(cols >> 8) as u8, cols as u8, (rows >> 8) as u8, rows as u8],
false,
));
result.extend([IAC, SE]);
result
}
fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option<Vec<u8>> {
/// Negotiation may span arbitrary TCP reads, including escaped IAC bytes.
#[derive(Default)]
pub struct TelnetDecoder {
state: u8,
command: u8,
}
impl TelnetDecoder {
pub fn decode(&mut self, data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec<u8> {
let mut visible = Vec::with_capacity(data.len());
for &byte in data {
match self.state {
0 if byte == IAC => self.state = 1,
0 => visible.push(byte),
1 => match byte {
IAC => {
visible.push(IAC);
self.state = 0;
}
WILL | WONT | DO | DONT => {
self.command = byte;
self.state = 2;
}
SB => self.state = 3,
_ => self.state = 0,
},
2 => {
on_negotiate(self.command, byte);
self.state = 0;
}
3 if byte == IAC => self.state = 4,
3 => {}
4 => self.state = if byte == SE { 0 } else { 3 },
_ => unreachable!(),
}
}
visible
}
}
pub fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option<Vec<u8>> {
if config.raw_tcp_cli || !config.send_naws {
None
} else {
@@ -43,7 +78,7 @@ fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Optio
}
}
fn unescape_iac_iac(data: &[u8]) -> Vec<u8> {
pub fn unescape_iac_iac(data: &[u8]) -> Vec<u8> {
let mut visible = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
@@ -58,7 +93,7 @@ fn unescape_iac_iac(data: &[u8]) -> Vec<u8> {
visible
}
fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec<u8> {
pub fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec<u8> {
if raw_tcp_cli {
return data.to_vec();
}
@@ -75,7 +110,7 @@ fn escape_telnet_application_data(data: &[u8], raw_tcp_cli: bool) -> Vec<u8> {
/// Strip IAC sequences from raw data, returning only user-visible bytes.
/// Calls `on_negotiate` for each IAC command/option pair encountered.
fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec<u8> {
pub fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) -> Vec<u8> {
let mut visible = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
@@ -117,7 +152,7 @@ fn strip_telnet_commands(data: &[u8], on_negotiate: &mut impl FnMut(u8, u8)) ->
visible
}
fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec<u8> {
pub fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec<u8> {
let replacement: &[u8] = match enter_mode {
TelnetEnterMode::Crlf => b"\r\n",
TelnetEnterMode::Cr => b"\r",
@@ -134,7 +169,7 @@ fn normalize_enter_bytes(data: &[u8], enter_mode: TelnetEnterMode) -> Vec<u8> {
normalized
}
fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] {
pub fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] {
match enter_mode {
TelnetEnterMode::Crlf => b"\r\n",
TelnetEnterMode::Cr => b"\r",
@@ -142,7 +177,7 @@ fn enter_bytes(enter_mode: TelnetEnterMode) -> &'static [u8] {
}
}
fn split_write_chunks(data: &[u8], force_character_at_a_time: bool) -> Vec<Vec<u8>> {
pub fn split_write_chunks(data: &[u8], force_character_at_a_time: bool) -> Vec<Vec<u8>> {
if !force_character_at_a_time {
return vec![data.to_vec()];
}
@@ -0,0 +1,405 @@
#[cfg(test)]
mod tests {
#[test]
fn fragmented_iac_and_subnegotiation_preserve_only_application_bytes() {
use super::{SB, SE, TelnetDecoder};
let wire = [
b'a',
IAC,
WILL,
OPT_SUPPRESS_GO_AHEAD,
IAC,
IAC,
IAC,
SB,
31,
0,
IAC,
IAC,
3,
IAC,
SE,
b'b',
];
for split in 0..=wire.len() {
let mut decoder = TelnetDecoder::default();
let mut negotiations = Vec::new();
let mut callback = |cmd, opt| negotiations.push((cmd, opt));
let mut bytes = decoder.decode(&wire[..split], &mut callback);
bytes.extend(decoder.decode(&wire[split..], &mut callback));
assert_eq!(bytes, [b'a', IAC, b'b'], "split={split}");
assert_eq!(negotiations, [(WILL, OPT_SUPPRESS_GO_AHEAD)]);
}
}
#[test]
fn naws_escapes_255_in_each_dimension() {
assert_eq!(
super::build_naws(255, 511),
[
IAC,
super::SB,
OPT_NAWS,
0,
IAC,
IAC,
1,
IAC,
IAC,
IAC,
super::SE
]
);
}
use super::{
DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetAutoLogin, TelnetAutoLoginAction,
TelnetAutoLoginConfig, TelnetAutoLoginCredentials, TelnetEnterMode, TelnetLineEditor,
TelnetSessionConfig, WILL, escape_telnet_application_data, maybe_build_naws,
negotiate_response, normalize_enter_bytes, split_write_chunks, strip_telnet_commands,
};
use std::time::Instant;
#[test]
fn standard_negotiation_responds_by_default() {
assert_eq!(
negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, true),
vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD]
);
}
#[test]
fn send_sga_false_rejects_sga() {
assert_ne!(
negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, false),
vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD]
);
}
#[test]
fn send_naws_false_rejects_naws_negotiation() {
assert_ne!(
negotiate_response(DO, OPT_NAWS, false, true),
vec![IAC, WILL, OPT_NAWS]
);
}
#[test]
fn raw_mode_can_suppress_negotiation_responses() {
let config = TelnetSessionConfig {
raw_tcp_cli: true,
..Default::default()
};
let mut responses = Vec::new();
if !config.raw_tcp_cli {
let _ = strip_telnet_commands(&[IAC, WILL, OPT_SUPPRESS_GO_AHEAD], &mut |cmd, opt| {
responses.push(negotiate_response(
cmd,
opt,
config.send_naws,
config.send_sga,
));
});
}
assert!(responses.is_empty());
}
#[test]
fn binary_input_escapes_iac_only_for_telnet_transport() {
let mouse_report = b"\x1b[M \xff\x80";
assert_eq!(
escape_telnet_application_data(mouse_report, false),
b"\x1b[M \xff\xff\x80"
);
assert_eq!(
escape_telnet_application_data(mouse_report, true),
mouse_report
);
}
#[test]
fn send_naws_false_prevents_naws_resize_payload() {
let config = TelnetSessionConfig {
send_naws: false,
..Default::default()
};
assert!(maybe_build_naws(80, 24, &config).is_none());
}
#[test]
fn raw_mode_prevents_naws_resize_payload() {
let config = TelnetSessionConfig {
raw_tcp_cli: true,
..Default::default()
};
assert!(maybe_build_naws(80, 24, &config).is_none());
}
#[test]
fn enter_conversion_maps_carriage_return() {
assert_eq!(
normalize_enter_bytes(b"show\r", TelnetEnterMode::Crlf),
b"show\r\n"
);
assert_eq!(
normalize_enter_bytes(b"show\r", TelnetEnterMode::Cr),
b"show\r"
);
assert_eq!(
normalize_enter_bytes(b"show\r", TelnetEnterMode::Lf),
b"show\n"
);
}
#[test]
fn force_character_at_a_time_preserves_utf8_order() {
let chunks = split_write_chunks("a中\r".as_bytes(), true);
assert_eq!(
chunks,
vec![b"a".to_vec(), "中".as_bytes().to_vec(), b"\r".to_vec()]
);
let joined: Vec<u8> = chunks.into_iter().flatten().collect();
assert_eq!(joined, "a中\r".as_bytes());
}
#[test]
fn strip_telnet_commands_emits_naws_response_request() {
let mut seen = Vec::new();
let visible = strip_telnet_commands(b"hi\xff\xfd\x1f", &mut |cmd, opt| {
seen.push((cmd, opt));
});
assert_eq!(visible, b"hi");
assert_eq!(seen, vec![(DO, OPT_NAWS)]);
}
#[test]
fn local_line_editor_backspace_updates_buffer() {
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7f", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "ab");
assert_eq!(result.display, "abc\x08 \x08");
assert!(result.writes.is_empty());
}
#[test]
fn local_line_editor_sends_buffer_on_enter() {
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "");
assert_eq!(result.writes, vec![b"abd\r".to_vec()]);
assert_eq!(result.display, "abc\x08 \x08d\r\n");
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Crlf);
assert_eq!(result.writes, vec![b"abd\r\n".to_vec()]);
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Lf);
assert_eq!(result.writes, vec![b"abd\n".to_vec()]);
}
#[test]
fn local_line_editor_backspace_removes_one_utf8_char() {
let mut editor = TelnetLineEditor::default();
let result = editor.process("中a\u{7f}".as_bytes(), TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "中");
assert_eq!(result.display, "中a\x08 \x08");
let result = editor.process(b"\x7f", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "");
assert_eq!(result.display, "\x08 \x08");
}
#[test]
fn local_line_editor_passes_controls_without_buffering() {
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"a\x03\x04\x1b[A\x1b[3~", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "");
assert_eq!(
result.writes,
vec![vec![0x03], vec![0x04], b"\x1b[A".to_vec()]
);
assert_eq!(result.display, "a\x08 \x08");
}
fn auto_login() -> TelnetAutoLogin {
TelnetAutoLogin::new(
TelnetAutoLoginConfig::default(),
TelnetAutoLoginCredentials {
username: "admin".to_string(),
password: Some("secret".to_string()),
},
TelnetEnterMode::Cr,
Instant::now(),
)
.expect("auto login")
}
fn sent_payloads(actions: Vec<TelnetAutoLoginAction>) -> Vec<Vec<u8>> {
actions
.into_iter()
.filter_map(|action| match action {
TelnetAutoLoginAction::Send(data) => Some(data),
_ => None,
})
.collect()
}
#[test]
fn auto_login_detects_split_username_and_password_prompts() {
let mut login = auto_login();
assert!(login.handle_text("User", Instant::now()).is_empty());
assert_eq!(
sent_payloads(login.handle_text("name:", Instant::now())),
vec![b"admin\r".to_vec()]
);
assert!(login.handle_text("Pass", Instant::now()).is_empty());
assert_eq!(
sent_payloads(login.handle_text("word:", Instant::now())),
vec![b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_detects_chinese_prompts() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("用户名:", Instant::now())),
vec![b"admin\r".to_vec()]
);
assert_eq!(
sent_payloads(login.handle_text("密码:", Instant::now())),
vec![b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_detects_linux_getty_host_login_prompt() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text(
"\r\nLinux 6.8.0-107-generic (kaikai) (pts/3)\r\n\r\nkaikai login: ",
Instant::now()
)),
vec![b"admin\r".to_vec()]
);
}
#[test]
fn auto_login_detects_password_prompt_without_colon() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("Input Password", Instant::now())),
vec![b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_sends_wake_enter_once() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("Press RETURN to get started.", Instant::now())),
vec![b"\r".to_vec()]
);
assert!(
login
.handle_text("Press RETURN to get started.", Instant::now())
.is_empty()
);
}
#[test]
fn auto_login_does_not_treat_last_login_as_username_prompt() {
let mut login = auto_login();
assert!(
login
.handle_text("Last login: Mon Jul 13 12:00:00", Instant::now())
.is_empty()
);
assert!(
login
.handle_text("Previous login: Mon Jul 13 12:00:00", Instant::now())
.is_empty()
);
}
#[test]
fn auto_login_sends_username_before_password_in_same_chunk() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("Username:\nPassword:", Instant::now())),
vec![b"admin\r".to_vec(), b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_completes_on_shell_prompt_after_credentials() {
let mut login = auto_login();
let _ = login.handle_text("Username:", Instant::now());
let _ = login.handle_text("Password:", Instant::now());
assert!(
login
.handle_text("router#", Instant::now())
.contains(&TelnetAutoLoginAction::Complete)
);
}
#[test]
fn auto_login_failure_retries_then_disables() {
let mut login = TelnetAutoLogin::new(
TelnetAutoLoginConfig {
max_retries: 1,
..TelnetAutoLoginConfig::default()
},
TelnetAutoLoginCredentials {
username: String::new(),
password: Some("secret".to_string()),
},
TelnetEnterMode::Cr,
Instant::now(),
)
.expect("auto login");
assert_eq!(
sent_payloads(login.handle_text("Password:", Instant::now())),
vec![b"secret\r".to_vec()]
);
assert!(
login
.handle_text("Authentication failed", Instant::now())
.is_empty()
);
assert_eq!(
sent_payloads(login.handle_text("Password:", Instant::now())),
vec![b"secret\r".to_vec()]
);
assert!(
login
.handle_text("Authentication failed", Instant::now())
.contains(&TelnetAutoLoginAction::Disable)
);
}
#[test]
fn auto_login_user_input_disables_but_automated_input_does_not() {
let mut login = auto_login();
assert_eq!(login.handle_user_input(true), None);
assert_eq!(
login.handle_user_input(false),
Some(TelnetAutoLoginAction::Disable)
);
assert!(login.handle_text("Username:", Instant::now()).is_empty());
}
}
@@ -23,6 +23,7 @@ impl TelnetEnterMode {
#[derive(Debug, Clone)]
pub struct TelnetSessionConfig {
pub network: Option<crate::config::ConnectionNetwork>,
pub host: String,
pub port: u16,
pub name: String,
@@ -43,6 +44,7 @@ pub struct TelnetSessionConfig {
impl Default for TelnetSessionConfig {
fn default() -> Self {
Self {
network: None,
host: String::new(),
port: 23,
name: "Telnet".to_string(),
@@ -67,4 +69,3 @@ pub struct TelnetStartupCommand {
pub command: String,
pub delay_ms: u64,
}
@@ -0,0 +1,185 @@
fn remap_del_to_bs(data: &mut [u8]) {
for byte in data {
if *byte == 0x7f {
*byte = 0x08;
}
}
}
use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8};
pub fn terminal_encoding(label: &str) -> &'static Encoding {
let trimmed = label.trim();
if trimmed.is_empty() {
return UTF_8;
}
Encoding::for_label(trimmed.as_bytes()).unwrap_or(UTF_8)
}
pub struct TerminalOutputDecoder {
decoder: Decoder,
}
impl TerminalOutputDecoder {
pub fn new(encoding: &str) -> Self {
Self {
decoder: terminal_encoding(encoding).new_decoder_without_bom_handling(),
}
}
pub fn decode(&mut self, data: &[u8]) -> String {
let capacity = self
.decoder
.max_utf8_buffer_length(data.len())
.unwrap_or_else(|| data.len().saturating_mul(4));
let mut output = String::with_capacity(capacity);
let mut total_read = 0;
while total_read < data.len() {
let (result, read, _) =
self.decoder
.decode_to_string(&data[total_read..], &mut output, false);
total_read += read;
match result {
CoderResult::InputEmpty => break,
CoderResult::OutputFull => {
output.reserve(
self.decoder
.max_utf8_buffer_length(data.len() - total_read)
.unwrap_or_else(|| (data.len() - total_read).saturating_mul(4))
.max(4),
);
}
}
}
output
}
}
pub fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec<u8> {
let target = terminal_encoding(encoding);
if target == UTF_8 {
return data.to_vec();
}
let mut result = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
if data[i] == 0x1b {
let end = ansi_sequence_end(data, i);
result.extend_from_slice(&data[i..end]);
i = end;
continue;
}
if data[i].is_ascii() {
result.push(data[i]);
i += 1;
continue;
}
let start = i;
while i < data.len() && !data[i].is_ascii() && data[i] != 0x1b {
i += 1;
}
let text = String::from_utf8_lossy(&data[start..i]);
let (encoded, _, _) = target.encode(&text);
result.extend_from_slice(&encoded);
}
result
}
pub fn prepare_terminal_write_input(
mut data: Vec<u8>,
encoding: &str,
raw: bool,
backspace_as_bs: bool,
) -> Vec<u8> {
if raw {
return data;
}
if backspace_as_bs {
remap_del_to_bs(&mut data);
}
encode_terminal_input(&data, encoding)
}
fn ansi_sequence_end(data: &[u8], start: usize) -> usize {
let mut i = start + 1;
if i >= data.len() {
return i;
}
if matches!(data[i], b'[' | b']' | b'(' | b')' | b'#') {
i += 1;
while i < data.len() {
let byte = data[i];
i += 1;
if (0x40..=0x7e).contains(&byte) {
break;
}
}
return i;
}
(i + 1).min(data.len())
}
#[cfg(test)]
mod tests {
use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input};
#[test]
fn write_input_encodes_text_but_preserves_raw_bytes_and_del() {
assert_eq!(
prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true),
vec![0xB2, 0xE2, 0x08]
);
let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f];
assert_eq!(
prepare_terminal_write_input(raw.clone(), "GBK", true, true),
raw
);
}
#[test]
fn gbk_decoder_preserves_split_multibyte_character() {
let mut decoder = TerminalOutputDecoder::new("GBK");
assert_eq!(decoder.decode(&[0xB2]), "");
assert_eq!(decoder.decode(&[0xE2]), "测");
}
#[test]
fn gb18030_decoder_preserves_split_multibyte_character() {
let mut decoder = TerminalOutputDecoder::new("GB18030");
assert_eq!(decoder.decode(&[0xB2]), "");
assert_eq!(decoder.decode(&[0xE2]), "测");
}
#[test]
fn gbk_decoder_handles_utf8_output_expansion() {
let mut decoder = TerminalOutputDecoder::new("GBK");
let mut input = Vec::new();
for _ in 0..1024 {
input.extend_from_slice(&[0xB2, 0xE2]);
}
let output = decoder.decode(&input);
assert_eq!(output.chars().count(), 1024);
assert!(output.chars().all(|ch| ch == '测'));
}
#[test]
fn input_encoder_keeps_controls_and_ansi_sequences_raw() {
let input = b"\x1b[200~\x7f\xe6\xb5\x8b\x1b[201~";
let encoded = encode_terminal_input(input, "GBK");
assert_eq!(
encoded,
vec![
0x1b, b'[', b'2', b'0', b'0', b'~', 0x7f, 0xB2, 0xE2, 0x1b, b'[', b'2', b'0', b'1',
b'~'
]
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
mod manager;
pub mod runtime;
pub use manager::*;
@@ -0,0 +1,46 @@
use crate::{config::ConnectionNetwork, error::AppResult, network::OpenedTransport};
use futures_util::future::BoxFuture;
use serde::Serialize;
use serde_json::Value;
use std::sync::Arc;
type EventSink = dyn Fn(Option<&str>, &str, Value) -> AppResult<()> + Send + Sync;
type Transport = dyn Fn(
String,
u16,
Option<ConnectionNetwork>,
Option<String>,
) -> BoxFuture<'static, AppResult<OpenedTransport>>
+ Send
+ Sync;
/// The protocol engine owns no native application handles or IPC channels.
#[derive(Clone)]
pub struct VncContext {
pub events: Arc<EventSink>,
pub transport: Arc<Transport>,
}
impl VncContext {
pub fn emit<T: Serialize>(&self, event: &str, payload: T) -> AppResult<()> {
(self.events)(None, event, serde_json::to_value(payload)?)
}
pub fn emit_to<T: Serialize>(&self, target: &str, event: &str, payload: T) -> AppResult<()> {
(self.events)(Some(target), event, serde_json::to_value(payload)?)
}
}
#[derive(Clone)]
pub struct FrameChannel(pub Arc<dyn Fn(Vec<u8>) -> AppResult<()> + Send + Sync>);
impl FrameChannel {
pub fn send(&self, frame: Vec<u8>) -> AppResult<()> {
(self.0)(frame)
}
}
pub async fn open_tcp_transport(
context: &VncContext,
host: &str,
port: u16,
network: Option<&ConnectionNetwork>,
owner: Option<String>,
) -> AppResult<OpenedTransport> {
(context.transport)(host.to_owned(), port, network.cloned(), owner).await
}
+210 -8
View File
@@ -122,6 +122,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "async-http-proxy"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592"
dependencies = [
"base64 0.13.1",
"httparse",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@@ -144,6 +156,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "async_io_stream"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
dependencies = [
"futures",
"rustc_version",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -224,6 +246,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.22.1"
@@ -330,12 +358,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -422,6 +462,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "cmac"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
dependencies = [
"cipher 0.4.4",
"dbl",
"digest 0.10.7",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -758,6 +809,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbl"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9"
dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "defmt"
version = "1.1.1"
@@ -786,7 +846,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -807,6 +867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468 0.7.0",
"zeroize",
]
@@ -962,6 +1023,19 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "eax"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
dependencies = [
"aead 0.5.2",
"cipher 0.4.4",
"cmac",
"ctr 0.9.2",
"subtle",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1038,6 +1112,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@@ -1871,6 +1951,20 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2175,6 +2269,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -2313,8 +2417,10 @@ name = "nyaterm-core"
version = "0.1.0"
dependencies = [
"aes-gcm 0.10.3",
"async-http-proxy",
"base64 0.22.1",
"dirs",
"encoding_rs",
"font-kit",
"futures-util",
"genai",
@@ -2331,12 +2437,15 @@ dependencies = [
"sha1 0.10.7",
"sha2 0.10.9",
"ssh-key 0.6.7",
"thiserror",
"strip-ansi-escapes",
"thiserror 2.0.21",
"time",
"tokio",
"tokio-socks",
"tokio-util",
"tracing",
"uuid",
"vnc-rs",
"zeroize",
]
@@ -2521,7 +2630,7 @@ dependencies = [
"log",
"rand 0.10.3",
"sha2 0.11.0",
"thiserror",
"thiserror 2.0.21",
"tokio",
"windows",
"windows-strings",
@@ -2819,6 +2928,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quote"
version = "1.0.47"
@@ -2941,7 +3056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
"libredox",
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -3174,7 +3289,7 @@ dependencies = [
"ssh-encoding 0.3.0",
"ssh-key 0.7.0-rc.11",
"subtle",
"thiserror",
"thiserror 2.0.21",
"tokio",
"typenum",
"universal-hash 0.6.1",
@@ -3204,7 +3319,7 @@ dependencies = [
"log",
"serde",
"serde_bytes",
"thiserror",
"thiserror 2.0.21",
"tokio",
"tokio-util",
"wasm-bindgen-futures",
@@ -3791,6 +3906,15 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strip-ansi-escapes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3900,13 +4024,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.21",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -4014,6 +4158,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-socks"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -4045,6 +4201,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"libc",
@@ -4159,7 +4316,7 @@ dependencies = [
"log",
"rand 0.9.5",
"sha1 0.10.7",
"thiserror",
"thiserror 2.0.21",
]
[[package]]
@@ -4270,6 +4427,36 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vnc-rs"
version = "0.5.3"
dependencies = [
"aes 0.8.4",
"async_io_stream",
"eax",
"flate2",
"futures",
"image",
"rand 0.8.8",
"rsa 0.9.10",
"sha2 0.10.9",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"wasm-bindgen-futures",
]
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4775,3 +4962,18 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+37 -1
View File
@@ -65,7 +65,7 @@ pub async fn route(
"save_connection" => {
let _guard=state.mutation.lock().await;
let connection:config::SavedConnection=argument(&args,"connection")?;
if !matches!(connection.config,config::ConnectionType::Ssh { .. }) { return Err(WebError::unsupported()); }
if !matches!(connection.config,config::ConnectionType::Ssh { .. } | config::ConnectionType::Telnet { .. } | config::ConnectionType::Vnc { .. }) { return Err(WebError::unsupported()); }
let id=services::save_connection(&(),connection)?;
state.broadcast("connections-changed",Value::Null).await;json!(id)
},
@@ -75,6 +75,41 @@ pub async fn route(
state.broadcast("connections-changed",Value::Null).await;Value::Null
},
"get_groups" => json!(config::load_config(&())?.groups),
"get_proxies" => json!(config::load_proxies(&())?.into_iter().map(|mut proxy| { proxy.password=None; proxy }).collect::<Vec<_>>()),
"get_proxy_groups" => json!(config::load_proxy_groups(&())?),
"get_proxy_password" => {
let proxy=config::load_proxy_by_id(&(), text(&args,"proxyId")?)?.ok_or(WebError::bad("Proxy not found"))?;
json!(crypto::decrypt_optional(&proxy.password)?)
},
"save_proxy" => {
let _guard=state.mutation.lock().await;
let mut proxy:config::ProxyConfig=argument(&args,"proxy")?;
if !matches!(proxy.protocol.as_str(), "socks5" | "http") { return Err(WebError::bad("Web supports SOCKS5 and HTTP CONNECT proxies only")); }
crate::network::validate_target(&proxy.host, proxy.port)?;
if proxy.id.is_empty() { proxy.id=uuid::Uuid::new_v4().to_string(); }
let mut proxies=config::load_proxies(&())?;
proxy.password=match proxy.password.as_deref() { Some("")=>None,Some(secret)=>Some(crypto::encrypt(secret)?),None=>proxies.iter().find(|p|p.id==proxy.id).and_then(|p|p.password.clone()) };
let id=proxy.id.clone();proxies.retain(|p|p.id!=id);proxies.push(proxy);config::save_proxies(&(),&proxies)?;
state.broadcast("proxy-saved",Value::Null).await;json!(id)
},
"delete_proxy" => {
let _guard=state.mutation.lock().await;let mut proxies=config::load_proxies(&())?;proxies.retain(|p|Some(p.id.as_str())!=args["proxyId"].as_str());config::save_proxies(&(),&proxies)?;
state.broadcast("proxy-saved",Value::Null).await;Value::Null
},
"set_proxy_group" => {
let _guard=state.mutation.lock().await;let mut proxies=config::load_proxies(&())?;
let proxy=proxies.iter_mut().find(|p|Some(p.id.as_str())==args["proxyId"].as_str()).ok_or(WebError::bad("Proxy not found"))?;
proxy.group_id=args["groupId"].as_str().map(String::from);config::save_proxies(&(),&proxies)?;state.broadcast("proxy-saved",Value::Null).await;Value::Null
},
"save_proxy_group" => {
let _guard=state.mutation.lock().await;let mut group:config::ProxyGroup=argument(&args,"group")?;
if group.id.is_empty() { group.id=uuid::Uuid::new_v4().to_string(); }let id=group.id.clone();let mut groups=config::load_proxy_groups(&())?;groups.retain(|g|g.id!=id);groups.push(group);config::save_proxy_groups(&(),&groups)?;state.broadcast("proxy-saved",Value::Null).await;json!(id)
},
"delete_proxy_group" => {
let _guard=state.mutation.lock().await;let id=text(&args,"groupId")?;
let mut groups=config::load_proxy_groups(&())?;groups.retain(|g|g.id!=id);config::save_proxy_groups(&(),&groups)?;
let mut proxies=config::load_proxies(&())?;proxies.retain(|p|p.group_id.as_deref()!=Some(id));config::save_proxies(&(),&proxies)?;state.broadcast("proxy-saved",Value::Null).await;Value::Null
},
"get_connection_custom_icons" => json!(config::load_config(&())?.custom_icons),
"get_supported_ssh_algorithms" => json!(nyaterm_core::ssh::algorithms::get_supported_ssh_algorithms()),
"save_group" => {
@@ -130,6 +165,7 @@ pub async fn route(
},
"close_session" => { state.session(&owner.0,text(&args,"sessionId")?).await?.cancel.cancel();Value::Null },
"cancel_session_creation" => { let id=text(&args,"createRequestId")?;for session in state.sessions.lock().await.values().filter(|s|s.owner==owner.0&&s.request_id.as_deref()==Some(id)){session.cancel.cancel();}Value::Null },
"vnc_input_batch" | "vnc_set_clipboard_text" | "vnc_reconnect" | "close_vnc_session" | "respond_vnc_server_key" => crate::vnc::command(&state,&owner.0,&command,&args).await?,
"list_sessions" | "get_sessions" => json!(state.sessions.lock().await.values().filter(|s|s.owner==owner.0).map(|s|s.info()).collect::<Vec<_>>()),
"get_session_info" => state.session(&owner.0,text(&args,"sessionId")?).await?.info(),
"try_get_terminal_cwd" | "get_session_cwd" => { state.session(&owner.0,text(&args,"sessionId")?).await?;Value::Null },
+4
View File
@@ -2,10 +2,13 @@ pub mod ai;
pub mod auth;
pub mod commands;
pub mod error;
pub mod network;
pub mod plugins;
pub mod session;
pub mod sftp;
pub mod state;
pub mod telnet;
pub mod vnc;
use crate::{auth::Owner, state::State};
use axum::{
@@ -53,6 +56,7 @@ pub fn router(state: Arc<State>, dist: std::path::PathBuf) -> Router {
.route("/events", get(events))
.route("/sessions", post(session::create_route))
.route("/sessions/{id}/terminal", get(session::ws_route))
.route("/sessions/{id}/vnc", get(vnc::ws_route))
.route("/sessions/{id}/download", get(sftp::download))
.route(
"/sessions/{id}/upload",
+145
View File
@@ -0,0 +1,145 @@
//! Authenticated proxy and recursive SSH jump transports for Web sessions.
use crate::{
error::{Result, WebError},
session,
state::State,
};
use futures_util::future::BoxFuture;
use nyaterm_core::{
config::{self, ConnectionNetwork},
network::{self, OpenedTransport, TransportRouteKind},
};
use std::{
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_util::sync::CancellationToken;
pub fn open(
state: Arc<State>,
owner: String,
cancel: CancellationToken,
host: String,
port: u16,
route: Option<ConnectionNetwork>,
) -> BoxFuture<'static, Result<OpenedTransport>> {
Box::pin(async move {
validate_target(&host, port)?;
tokio::select! {
_ = cancel.cancelled() => Err(WebError::bad("Connection cancelled")),
result = tokio::time::timeout(std::time::Duration::from_secs(120), open_inner(state, owner, cancel.clone(), host, port, route, Vec::new())) => result.unwrap_or(Err(WebError::bad("Network connection timed out"))),
}
})
}
pub fn validate_target(host: &str, port: u16) -> Result<()> {
if host.is_empty()
|| host.len() > 253
|| host.chars().any(|c| c.is_whitespace() || c.is_control())
|| port == 0
{
return Err(WebError::bad("Invalid host or port"));
}
Ok(())
}
fn open_inner(
state: Arc<State>,
owner: String,
cancel: CancellationToken,
host: String,
port: u16,
route: Option<ConnectionNetwork>,
mut visited: Vec<String>,
) -> BoxFuture<'static, Result<OpenedTransport>> {
Box::pin(async move {
validate_target(&host, port)?;
let resolved = network::resolve_transport_route(route.as_ref());
match resolved.kind {
TransportRouteKind::Direct => Ok(network::open_direct_transport(&host, port)
.await
.map_err(|e| WebError::bad(e.to_string()))?),
TransportRouteKind::Proxy => {
let proxy = network::resolve_proxy(&(), resolved.proxy_id.as_deref().unwrap())
.map_err(|e| match e {
nyaterm_core::error::AppError::Crypto(_) => {
WebError::bad("Proxy credentials could not be decrypted")
}
_ => WebError::bad("Proxy configuration is missing or invalid"),
})?;
if !matches!(proxy.protocol.as_str(), "socks5" | "http") {
return Err(WebError::bad(
"Web supports SOCKS5 and HTTP CONNECT proxies only",
));
}
validate_target(&proxy.host, proxy.port)?;
Ok(network::open_proxy_transport(proxy, &host, port)
.await
.map_err(|e| WebError::bad(e.to_string()))?)
}
TransportRouteKind::SshJump => {
let id = resolved.proxy_jump_id.unwrap();
if visited.contains(&id) || visited.len() >= 8 {
return Err(WebError::bad("SSH jump cycle or depth limit exceeded"));
}
visited.push(id.clone());
let connection = config::load_connection_by_id(&(), &id)?;
if !matches!(connection.config, config::ConnectionType::Ssh { .. }) {
return Err(WebError::bad("SSH jump must refer to an SSH connection"));
}
let target = session::jump_target(&id)?;
let transport = open_inner(
state.clone(),
owner.clone(),
cancel.clone(),
target.host.clone(),
target.port,
target.network.clone(),
visited,
)
.await?;
let handle =
session::authenticate(&state, &owner, &cancel, target, transport.stream)
.await?;
let channel = handle
.channel_open_direct_tcpip(&host, u32::from(port), "127.0.0.1", 0)
.await?;
Ok(OpenedTransport {
stream: Box::new(ForwardedStream {
stream: channel.into_stream(),
_handle: handle,
}),
local_addr: None,
})
}
}
})
}
struct ForwardedStream {
stream: russh::ChannelStream<russh::client::Msg>,
_handle: russh::client::Handle<session::Handler>,
}
impl AsyncRead for ForwardedStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.stream).poll_read(cx, buf)
}
}
impl AsyncWrite for ForwardedStream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Pin::new(&mut self.stream).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.stream).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.stream).poll_shutdown(cx)
}
}
+238 -92
View File
@@ -80,6 +80,7 @@ impl client::Handler for Handler {
}
}
pub struct WebSession {
pub protocol: SessionProtocol,
pub id: String,
pub owner: String,
pub name: String,
@@ -88,6 +89,7 @@ pub struct WebSession {
pub username: String,
pub connection_id: Option<String>,
pub request_id: Option<String>,
pub workspace_pane_id: Option<String>,
pub cancel: CancellationToken,
pub handle: Mutex<Option<client::Handle<Handler>>>,
pub input: mpsc::Sender<Command>,
@@ -98,16 +100,106 @@ pub struct WebSession {
pub transfers: Arc<tokio::sync::Semaphore>,
pub sftp: config::SftpSettings,
}
pub enum SessionProtocol {
Ssh,
Telnet,
Vnc(Arc<crate::vnc::WebVnc>),
}
impl WebSession {
pub fn info(&self) -> Value {
json!({"id":self.id,"name":self.name,"session_type":"SSH","host":self.host,"port":self.port,"username":self.username,"connection_id":self.connection_id,"owner_window_label":"main","terminal_available":true,"shell_available":true,"sftp_available":self.sftp.enabled && self.ready.load(Ordering::Acquire),"runtime_mode":"standard","dynamic_title_enabled":false,"dynamic_title_integration_active":false})
let kind = match self.protocol {
SessionProtocol::Ssh => "SSH",
SessionProtocol::Telnet => "Telnet",
SessionProtocol::Vnc(_) => "VNC",
};
let terminal = !matches!(self.protocol, SessionProtocol::Vnc(_));
let sftp = matches!(self.protocol, SessionProtocol::Ssh)
&& self.sftp.enabled
&& self.ready.load(Ordering::Acquire);
let connected = match &self.protocol {
SessionProtocol::Vnc(web) => *web.state.lock().unwrap() == "active",
_ => self.ready.load(Ordering::Acquire),
} && !self.cancel.is_cancelled();
json!({"id":self.id,"name":self.name,"session_type":kind,"host":self.host,"port":self.port,"username":self.username,"connection_id":self.connection_id,"owner_window_label":"main","workspace_pane_id":self.workspace_pane_id,"attached":self.attached.load(Ordering::Acquire),"ready":self.ready.load(Ordering::Acquire),"connected":connected,"terminal_available":terminal,"shell_available":terminal,"sftp_available":sftp,"remote_file_browser_enabled":sftp,"remote_stats_enabled":false,"injection_active":false,"runtime_mode":"standard","ssh_runtime_mode":"standard","dynamic_title_enabled":false,"dynamic_title_integration_active":false})
}
}
struct Target {
host: String,
port: u16,
username: String,
pub(crate) struct SessionMetadata {
pub host: String,
pub port: u16,
pub username: String,
pub name: String,
pub connection_id: Option<String>,
pub sftp: Option<config::SftpSettings>,
}
pub(crate) async fn register(
state: &Arc<State>,
owner: &str,
args: &Value,
protocol: SessionProtocol,
metadata: SessionMetadata,
cancel: CancellationToken,
) -> Result<(
Arc<WebSession>,
mpsc::Receiver<Command>,
mpsc::Sender<Output>,
)> {
state.login(owner).await?;
crate::network::validate_target(&metadata.host, metadata.port)?;
let (input, commands) = mpsc::channel(128);
let (sender, output) = mpsc::channel(terminal::OUTPUT_CAPACITY);
let session = Arc::new(WebSession {
protocol,
id: uuid::Uuid::new_v4().to_string(),
owner: owner.into(),
name: metadata.name,
host: metadata.host,
port: metadata.port,
username: metadata.username,
connection_id: metadata.connection_id,
request_id: args["createRequestId"].as_str().map(String::from),
workspace_pane_id: args["recordingScopeId"].as_str().map(String::from),
cancel,
handle: Mutex::new(None),
input,
output: Arc::new(Mutex::new(output)),
attached: AtomicBool::new(false),
detached_at: StdMutex::new(Instant::now()),
ready: AtomicBool::new(false),
transfers: Arc::new(tokio::sync::Semaphore::new(4)),
sftp: metadata.sftp.unwrap_or(config::SftpSettings {
enabled: false,
..Default::default()
}),
});
let mut registry = state.sessions.lock().await;
if registry.len() >= 128 || registry.values().filter(|s| s.owner == owner).count() >= 16 {
return Err(WebError::bad("Too many sessions"));
}
registry.insert(session.id.clone(), session.clone());
Ok((session, commands, sender))
}
pub(crate) fn connection(args: &Value, kind: &str) -> Result<(config::SavedConnection, bool)> {
if let Some(id) = args["connectionId"].as_str() {
return Ok((config::load_connection_by_id(&(), id)?, true));
}
let mut value = if args["config"].is_object() {
args["config"].clone()
} else {
args.clone()
};
value["type"] = json!(kind);
value["id"] = json!("");
if !value["name"].is_string() {
value["name"] = json!(kind);
}
Ok((serde_json::from_value(value)?, false))
}
pub(crate) struct Target {
pub(crate) host: String,
pub(crate) port: u16,
pub(crate) username: String,
name: String,
mode: String,
secret: Option<zeroize::Zeroizing<String>>,
@@ -115,6 +207,7 @@ struct Target {
passphrase: Option<zeroize::Zeroizing<String>>,
preferences: Option<config::SshAlgorithmPreferences>,
connection_id: Option<String>,
pub(crate) network: Option<config::ConnectionNetwork>,
terminal_type: config::SshTerminalType,
sftp: config::SftpSettings,
}
@@ -140,7 +233,13 @@ fn validate_sftp(settings: &config::SftpSettings) -> Result<()> {
}
Ok(())
}
fn target(args: &Value) -> Result<Target> {
pub(crate) fn target(args: &Value) -> Result<Target> {
target_for(args, false)
}
pub(crate) fn jump_target(id: &str) -> Result<Target> {
target_for(&json!({"connectionId": id}), true)
}
fn target_for(args: &Value, transport_only: bool) -> Result<Target> {
if let Some(id) = args["connectionId"].as_str() {
let conn = config::load_connection_by_id(&(), id)?;
let config::ConnectionType::Ssh {
@@ -155,26 +254,23 @@ fn target(args: &Value) -> Result<Target> {
else {
return Err(WebError::unsupported());
};
if *x11_forwarding || agent_forwarding_config.as_ref().is_some_and(|c| c.enabled) {
if !transport_only
&& (*x11_forwarding || agent_forwarding_config.as_ref().is_some_and(|c| c.enabled))
{
return Err(WebError::unsupported());
}
require_utf8(encoding)?;
validate_sftp(&conn.sftp)?;
if conn.post_login.as_ref().is_some_and(|p| p.enabled)
|| conn.ssh_profile != config::SshProfile::Standard
if !transport_only {
require_utf8(encoding)?;
validate_sftp(&conn.sftp)?;
}
if !transport_only
&& (conn.post_login.as_ref().is_some_and(|p| p.enabled)
|| conn.ssh_profile != config::SshProfile::Standard)
{
return Err(WebError::unsupported());
}
let terminal_type =
config::resolve_ssh_terminal_type(&conn.ssh_profile, conn.terminal_type.as_ref());
// Unsupported network paths must not silently become direct connections.
if conn
.network
.as_ref()
.is_some_and(|n| n.proxy_id.is_some() || n.proxy_jump_id.is_some())
{
return Err(WebError::unsupported());
}
let account = conn
.auth
.as_ref()
@@ -219,6 +315,7 @@ fn target(args: &Value) -> Result<Target> {
key,
passphrase: passphrase.map(zeroize::Zeroizing::new),
preferences: conn.ssh_algorithms,
network: conn.network.clone(),
connection_id: Some(id.into()),
terminal_type,
sftp: conn.sftp,
@@ -285,6 +382,11 @@ fn target(args: &Value) -> Result<Target> {
.filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone()))
.transpose()?,
network: value
.get("network")
.filter(|v| !v.is_null())
.map(|v| serde_json::from_value(v.clone()))
.transpose()?,
connection_id: None,
terminal_type,
sftp,
@@ -292,6 +394,12 @@ fn target(args: &Value) -> Result<Target> {
}
}
pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<String> {
match args["type"].as_str().unwrap_or("ssh") {
"telnet" => return crate::telnet::create(state, owner, args).await,
"vnc" => return crate::vnc::create(state, owner, args).await,
"ssh" => {}
_ => return Err(WebError::unsupported()),
}
if args["startupCommand"].as_object().is_some()
|| args["runtimeMode"]
.as_str()
@@ -318,34 +426,24 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
return Err(WebError::bad("Invalid SSH target"));
}
let login = state.login(owner).await?;
let (input, commands) = mpsc::channel(128);
let (sender, output) = mpsc::channel(terminal::OUTPUT_CAPACITY);
let id = uuid::Uuid::new_v4().to_string();
let session = Arc::new(WebSession {
id: id.clone(),
owner: owner.into(),
name: target.name.clone(),
let metadata = SessionMetadata {
host: target.host.clone(),
port: target.port,
username: target.username.clone(),
name: target.name.clone(),
connection_id: target.connection_id.clone(),
request_id: args["createRequestId"].as_str().map(String::from),
cancel: login.cancel.child_token(),
handle: Mutex::new(None),
input,
output: Arc::new(Mutex::new(output)),
attached: AtomicBool::new(false),
detached_at: StdMutex::new(Instant::now()),
ready: AtomicBool::new(false),
transfers: Arc::new(tokio::sync::Semaphore::new(4)),
sftp: target.sftp.clone(),
});
let mut registry = state.sessions.lock().await;
if registry.len() >= 128 || registry.values().filter(|s| s.owner == owner).count() >= 16 {
return Err(WebError::bad("Too many sessions"));
}
registry.insert(id.clone(), session.clone());
drop(registry);
sftp: Some(target.sftp.clone()),
};
let (session, commands, sender) = register(
&state,
owner,
&args,
SessionProtocol::Ssh,
metadata,
login.cancel.child_token(),
)
.await?;
let id = session.id.clone();
tokio::spawn(async move {
let result = tokio::select! {
_ = session.cancel.cancelled() => Err(WebError::bad("Session creation cancelled")),
@@ -359,47 +457,85 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
.await;
terminal::run(channel, commands, sender, session.cancel.clone()).await;
}
Err(_) => {
Err(error) => {
state
.event(
&session.owner,
&format!("connection-error-{}", session.id),
json!("SSH connection failed or authentication was cancelled"),
json!(error.1),
)
.await;
let _ = sender.try_send(Output::Error);
let _ = sender.try_send(Output::Failure(error.1));
}
}
session.cancel.cancel();
if let Some(handle) = session.handle.lock().await.take() {
let _ = tokio::time::timeout(
Duration::from_secs(2),
handle.disconnect(russh::Disconnect::ByApplication, "", ""),
)
.await;
}
state.sessions.lock().await.remove(&session.id);
// Attached terminals receive ordered closure on the WS after final bytes.
if !session.attached.load(Ordering::Acquire) {
state
.event(
&session.owner,
&format!("session-closed-{}", session.id),
Value::Null,
)
.await;
}
state
.event(&session.owner, "sessions-changed", Value::Null)
.await;
finish(&state, &session).await;
});
Ok(id)
}
pub(crate) async fn finish(state: &State, session: &WebSession) {
session.cancel.cancel();
if let Some(handle) = session.handle.lock().await.take() {
let _ = tokio::time::timeout(
Duration::from_secs(2),
handle.disconnect(russh::Disconnect::ByApplication, "", ""),
)
.await;
}
state.sessions.lock().await.remove(&session.id);
if !session.attached.load(Ordering::Acquire) {
state
.event(
&session.owner,
&format!("session-closed-{}", session.id),
Value::Null,
)
.await;
}
state
.event(&session.owner, "sessions-changed", Value::Null)
.await;
}
async fn connect(
state: &Arc<State>,
session: &Arc<WebSession>,
mut target: Target,
target: Target,
) -> Result<russh::Channel<client::Msg>> {
let stream = crate::network::open(
state.clone(),
session.owner.clone(),
session.cancel.clone(),
target.host.clone(),
target.port,
target.network.clone(),
)
.await?;
let terminal = target.terminal_type.as_str().to_owned();
let connection_id = target.connection_id.clone();
let mut handle = authenticate(
state,
&session.owner,
&session.cancel,
target,
stream.stream,
)
.await?;
let channel = protocol::open_shell_with_terminal(&mut handle, 80, 24, &terminal).await?;
*session.handle.lock().await = Some(handle);
if let Some(id) = connection_id {
storage::mark_connection_used(&id)?;
}
Ok(channel)
}
pub(crate) async fn authenticate(
state: &Arc<State>,
owner: &str,
cancel: &CancellationToken,
mut target: Target,
stream: nyaterm_core::network::BoxedTransportStream,
) -> Result<client::Handle<Handler>> {
if !matches!(target.mode.as_str(), "none" | "password" | "key") {
return Err(WebError::bad("Unsupported SSH authentication mode"));
}
let client_config = client::Config {
preferred: algorithms::resolve_preferred_algorithms(target.preferences.as_ref())?,
keepalive_interval: Some(Duration::from_secs(30)),
@@ -408,16 +544,12 @@ async fn connect(
};
let handler = Handler {
state: state.clone(),
owner: session.owner.clone(),
owner: owner.into(),
host: target.host.clone(),
port: target.port,
cancel: session.cancel.clone(),
cancel: cancel.clone(),
};
let handle =
protocol::connect(Arc::new(client_config), &target.host, target.port, handler).await?;
let mut locked = session.handle.lock().await;
*locked = Some(handle);
let handle = locked.as_mut().unwrap();
let mut handle = client::connect_stream(Arc::new(client_config), stream, handler).await?;
let mut authenticated = false;
if target.mode == "none" {
authenticated = handle.authenticate_none(&target.username).await?.success();
@@ -434,7 +566,7 @@ async fn connect(
target.passphrase.as_deref().map(|p| p.as_str()),
);
if decoded.is_err() && target.passphrase.is_none() {
let response=state.prompt(&session.owner,"ssh-auth-request",json!({"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,"reason":"key_passphrase_required","promptKind":"passphrase","availableMethods":["publickey"],"currentAuthMode":"key","attempt":1,"canSave":false}),&session.cancel).await?;
let response=state.prompt(owner,"ssh-auth-request",json!({"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,"reason":"key_passphrase_required","promptKind":"passphrase","availableMethods":["publickey"],"currentAuthMode":"key","attempt":1,"canSave":false}),cancel).await?;
target.passphrase = response["secret"]
.as_str()
.map(|s| zeroize::Zeroizing::new(s.into()));
@@ -456,10 +588,10 @@ async fn connect(
if !authenticated && target.mode != "key" {
for attempt in 1..=3 {
if target.secret.is_none() {
let response = state.prompt(&session.owner,"ssh-auth-request",json!({
let response = state.prompt(owner,"ssh-auth-request",json!({
"connectionId":target.connection_id,"connectionName":target.name,"host":target.host,"port":target.port,"username":target.username,
"reason":if attempt == 1 {"missing_password"} else {"password_rejected"},"promptKind":"password","availableMethods":["password"],"currentAuthMode":"password","attempt":attempt,"canSave":false,"accountId":null,
}),&session.cancel).await?;
}),cancel).await?;
target.secret = response["secret"]
.as_str()
.map(|s| zeroize::Zeroizing::new(s.into()));
@@ -467,7 +599,7 @@ async fn connect(
let Some(secret) = &target.secret else {
return Err(WebError::bad("SSH authentication cancelled"));
};
let result = protocol::password(handle, &target.username, secret).await?;
let result = protocol::password(&mut handle, &target.username, secret).await?;
if result.success() {
authenticated = true;
break;
@@ -477,7 +609,8 @@ async fn connect(
} = result
{
if remaining_methods.contains(&russh::MethodKind::KeyboardInteractive) {
authenticated = keyboard_auth(state, session, handle, &target).await?;
authenticated =
keyboard_auth(state, owner, cancel, &mut handle, &target).await?;
break;
}
}
@@ -485,21 +618,17 @@ async fn connect(
}
}
if !authenticated && target.mode == "key" {
authenticated = keyboard_auth(state, session, handle, &target).await?;
authenticated = keyboard_auth(state, owner, cancel, &mut handle, &target).await?;
}
if !authenticated {
return Err(WebError::bad("SSH authentication failed"));
}
let channel =
protocol::open_shell_with_terminal(handle, 80, 24, target.terminal_type.as_str()).await?;
if let Some(id) = &target.connection_id {
storage::mark_connection_used(id)?;
}
Ok(channel)
Ok(handle)
}
async fn keyboard_auth(
state: &Arc<State>,
session: &Arc<WebSession>,
owner: &str,
cancel: &CancellationToken,
handle: &mut client::Handle<Handler>,
target: &Target,
) -> Result<bool> {
@@ -516,7 +645,7 @@ async fn keyboard_auth(
if prompts.len() > 16 {
return Err(WebError::bad("Too many authentication prompts"));
}
let response=state.prompt(&session.owner,"otp-request",json!({"connectionName":target.name,"name":name,"instructions":instructions,"round":round,"prompts":prompts.iter().map(|p|json!({"prompt":p.prompt,"echo":p.echo})).collect::<Vec<_>>(),"otpEntryId":null}),&session.cancel).await?;
let response=state.prompt(owner,"otp-request",json!({"connectionName":target.name,"name":name,"instructions":instructions,"round":round,"prompts":prompts.iter().map(|p|json!({"prompt":p.prompt,"echo":p.echo})).collect::<Vec<_>>(),"otpEntryId":null}),cancel).await?;
let responses: Vec<String> = serde_json::from_value(response)?;
if responses.len() != prompts.len() {
return Err(WebError::bad("Invalid authentication response"));
@@ -543,6 +672,9 @@ pub async fn ws_route(
upgrade: WebSocketUpgrade,
) -> Result<Response> {
let session = state.session(&owner.0, &id).await?;
if matches!(session.protocol, SessionProtocol::Vnc(_)) {
return Err(WebError::bad("This session has no terminal"));
}
let receiver = session.output.clone().try_lock_owned().map_err(|_| {
WebError(
axum::http::StatusCode::CONFLICT,
@@ -578,7 +710,8 @@ async fn socket_loop(
let final_frame = !matches!(&next, Some(Output::Data(_)));
let message = match next {
Some(Output::Data(data)) => Message::Binary(data.into()),
Some(Output::Error) => Message::Text(json!({"type":"error","error":"SSH connection failed"}).to_string().into()),
Some(Output::Error) => Message::Text(json!({"type":"error","error":if matches!(session.protocol, SessionProtocol::Telnet) { "Telnet connection failed" } else { "SSH connection failed" }}).to_string().into()),
Some(Output::Failure(error)) => Message::Text(json!({"type":"error","error":error}).to_string().into()),
_ => Message::Text(json!({"type":"closed"}).to_string().into()),
};
if !matches!(tokio::time::timeout(Duration::from_secs(10),socket.send(message)).await,Ok(Ok(()))) { session.cancel.cancel(); break; }
@@ -598,7 +731,7 @@ async fn socket_loop(
let Some(command) = command else { break; };
if session.input.try_send(command).is_err() { break; }
},
Some(Ok(Message::Binary(bytes))) => { last_peer=Instant::now(); if session.input.try_send(Command::Input(bytes.to_vec())).is_err() { break; } },
Some(Ok(Message::Binary(bytes))) => { last_peer=Instant::now(); if session.input.try_send(Command::RawInput(bytes.to_vec())).is_err() { break; } },
Some(Ok(Message::Pong(_))) => last_peer=Instant::now(),
Some(Ok(Message::Ping(data))) => { if !matches!(tokio::time::timeout(Duration::from_secs(2),socket.send(Message::Pong(data))).await,Ok(Ok(()))) { break; } },
_ => break,
@@ -609,4 +742,17 @@ async fn socket_loop(
}
}
}
// Complete the WebSocket close handshake before dropping the TCP stream.
// Dropping with an unread pong/input can reset the connection on Windows,
// discarding the last terminal output even though send() already succeeded.
let _ = tokio::time::timeout(Duration::from_secs(2), async {
if socket.send(Message::Close(None)).await.is_ok() {
while let Some(Ok(message)) = socket.recv().await {
if matches!(message, Message::Close(_)) {
break;
}
}
}
})
.await;
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn supports(command: &str) -> bool {
)
}
pub async fn open(session: &WebSession) -> Result<russh_sftp::client::SftpSession> {
if !session.sftp.enabled {
if !matches!(session.protocol, crate::session::SessionProtocol::Ssh) || !session.sftp.enabled {
return Err(WebError::unsupported());
}
if !session.ready.load(std::sync::atomic::Ordering::Acquire) {
+255
View File
@@ -0,0 +1,255 @@
use crate::{
error::{Result, WebError},
session::{self, SessionMetadata, SessionProtocol, WebSession},
state::State,
};
use nyaterm_core::{
config,
ssh::terminal::{Command, Output},
telnet::*,
terminal_encoding::*,
};
use serde_json::Value;
use std::{
sync::{Arc, atomic::Ordering},
time::Instant,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::mpsc,
};
pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<String> {
let (conn, saved) = session::connection(&args, "telnet")?;
let config::ConnectionType::Telnet {
host,
port,
username,
backspace_mode,
raw_tcp_cli,
enter_mode,
local_echo,
local_line_edit,
force_character_at_a_time,
send_naws,
send_sga,
auto_login,
encoding,
..
} = conn.config
else {
return Err(WebError::bad("Connection is not Telnet"));
};
let account = conn
.auth
.as_ref()
.map(|a| config::load_saved_account(&(), a.account_id.as_deref(), a.password_id.as_deref()))
.transpose()?
.flatten();
let username = config::resolve_account_username(account.as_ref(), &username);
let password = match conn.auth.as_ref() {
Some(a) if a.mode == "password" => {
if saved {
match &a.password {
Some(secret) => Some(nyaterm_core::utils::crypto::decrypt(secret)?),
None if a.password_source.as_deref() != Some("connection") => {
config::decrypt_account_password(account.as_ref())?
}
_ => None,
}
} else {
match &a.password {
Some(password) => Some(password.clone()),
None => config::decrypt_account_password(account.as_ref())?,
}
}
}
_ => None,
};
let encoding = if encoding.is_empty() || encoding.eq_ignore_ascii_case("global") {
config::load_app_settings(&())?.interaction.default_encoding
} else {
encoding
};
let config = TelnetSessionConfig {
network: conn.network.clone(),
host: host.clone(),
port,
name: conn.name.clone(),
username: username.clone(),
password,
backspace_mode,
raw_tcp_cli,
enter_mode: TelnetEnterMode::from_config_value(&enter_mode),
local_echo,
local_line_edit,
force_character_at_a_time,
send_naws,
send_sga,
auto_login: auto_login.into(),
encoding,
};
let cancel = state.login(owner).await?.cancel.child_token();
let (session, commands, sender) = session::register(
&state,
owner,
&args,
SessionProtocol::Telnet,
SessionMetadata {
host,
port,
username,
name: conn.name,
connection_id: saved.then_some(conn.id),
sftp: None,
},
cancel,
)
.await?;
let id = session.id.clone();
let startup = args["startupCommand"].clone();
tokio::spawn(async move {
let result = async {
let transport = crate::network::open(
state.clone(),
session.owner.clone(),
session.cancel.clone(),
session.host.clone(),
session.port,
conn.network,
)
.await?;
session.ready.store(true, Ordering::Release);
if let Some(id) = &session.connection_id {
nyaterm_core::storage::mark_connection_used(id)?;
}
run(
transport.stream,
config,
commands,
sender.clone(),
&session,
startup,
)
.await
}
.await;
let end = if let Err(e) = result {
state
.event(
&session.owner,
&format!("connection-error-{}", session.id),
serde_json::json!({"sessionId":session.id,"error":e.1}),
)
.await;
Output::Failure(e.1)
} else {
Output::Closed
};
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), sender.send(end)).await;
session::finish(&state, &session).await;
});
Ok(id)
}
async fn run(
mut stream: nyaterm_core::network::BoxedTransportStream,
config: TelnetSessionConfig,
mut input: mpsc::Receiver<Command>,
output: mpsc::Sender<Output>,
session: &Arc<WebSession>,
startup: Value,
) -> Result<()> {
let mut decoder = TelnetDecoder::default();
let mut text_decoder = TerminalOutputDecoder::new(&config.encoding);
let mut editor = TelnetLineEditor::default();
let mut auto_login = TelnetAutoLogin::new(
config.auto_login.clone(),
TelnetAutoLoginCredentials {
username: config.username.clone(),
password: config.password.clone(),
},
config.enter_mode,
Instant::now(),
);
let mut buf = [0u8; 16 * 1024];
let mut startup_input = startup["command"]
.as_str()
.filter(|s| !s.trim().is_empty())
.map(|command| normalize_enter_bytes(format!("{command}\r").as_bytes(), config.enter_mode));
let startup_delay = std::time::Duration::from_millis(
startup["delayMs"]
.as_u64()
.or(startup["delay_ms"].as_u64())
.unwrap_or(0)
.min(300_000),
);
let mut startup_deadline = if auto_login.is_none() && startup_input.is_some() {
Some(tokio::time::Instant::now() + startup_delay)
} else {
None
};
let mut login_tick = tokio::time::interval(std::time::Duration::from_millis(250));
let transfer = async {
loop {
tokio::select! {
_ = async { tokio::time::sleep_until(startup_deadline.unwrap()).await }, if startup_deadline.is_some() => {
startup_deadline = None;
if let Some(data) = startup_input.take() {
stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&data, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet startup write failed"))?;
}
},
_ = login_tick.tick() => {
if let Some(login) = &mut auto_login {
if login.tick(Instant::now()).is_some() { startup_input = None; startup_deadline = None; }
}
},
read = stream.read(&mut buf) => {
let n = read.map_err(|_| WebError::bad("Telnet read failed"))?;
if n == 0 { break; }
let mut replies = Vec::new();
let visible = if config.raw_tcp_cli { buf[..n].to_vec() } else { decoder.decode(&buf[..n], &mut |command, option| {
replies.extend(negotiate_response(command, option, config.send_naws, config.send_sga));
}) };
if !replies.is_empty() { stream.write_all(&replies).await.map_err(|_| WebError::bad("Telnet negotiation failed"))?; }
let text = text_decoder.decode(&visible);
if let Some(login) = &mut auto_login {
for action in login.handle_text(&text, Instant::now()) {
match action {
TelnetAutoLoginAction::Send(bytes) => stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&bytes, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet login failed"))?,
TelnetAutoLoginAction::Complete => if startup_input.is_some() { startup_deadline = Some(tokio::time::Instant::now() + startup_delay); },
TelnetAutoLoginAction::Disable => { startup_input = None; startup_deadline = None; },
}
}
}
if !text.is_empty() { output.send(Output::Data(text.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; }
},
command = input.recv() => match command {
Some(Command::Input(mut bytes)) => {
if let Some(login) = &mut auto_login {
if login.handle_user_input(false).is_some() { startup_input = None; startup_deadline = None; }
}
let chunks = if config.raw_tcp_cli && config.local_line_edit {
let edited = editor.process(&bytes, config.enter_mode);
if !edited.display.is_empty() { output.send(Output::Data(edited.display.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; }
edited.writes
} else {
if config.backspace_mode == "ctrl_h" { for byte in &mut bytes { if *byte == 0x7f { *byte = 8; } } }
let data = normalize_enter_bytes(&bytes, config.enter_mode);
if config.local_echo { let text = local_echo_text(&data); if !text.is_empty() { output.send(Output::Data(text.into_bytes())).await.map_err(|_| WebError::bad("Terminal detached"))?; } }
split_write_chunks(&data, config.force_character_at_a_time)
};
for chunk in chunks { stream.write_all(&escape_telnet_application_data(&encode_terminal_input(&chunk, &config.encoding), config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet write failed"))?; }
},
Some(Command::RawInput(bytes)) => {
if let Some(login) = &mut auto_login { if login.handle_user_input(false).is_some() { startup_input = None; startup_deadline = None; } }
stream.write_all(&escape_telnet_application_data(&bytes, config.raw_tcp_cli)).await.map_err(|_| WebError::bad("Telnet write failed"))?;
},
Some(Command::Resize(cols, rows)) => if let Some(bytes) = maybe_build_naws(cols as u16, rows as u16, &config) { stream.write_all(&bytes).await.map_err(|_| WebError::bad("Telnet resize failed"))?; },
Some(Command::Close) | None => break,
}
}
}
Ok(())
};
tokio::select! { _ = session.cancel.cancelled() => Ok(()), result = transfer => result }
}
+324
View File
@@ -0,0 +1,324 @@
use crate::{
auth::Owner,
error::{Result, WebError},
session::{self, SessionMetadata, SessionProtocol},
state::State,
};
use axum::{
Extension,
extract::{
Path, State as ExtractState, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::Response,
};
use nyaterm_core::{
error::AppError,
vnc::{
self, VncInputEvent, VncSessionManager,
runtime::{FrameChannel, VncContext},
},
};
use serde_json::{Value, json};
use std::{
collections::HashSet,
sync::{
Arc, Mutex as StdMutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tokio::sync::{broadcast, mpsc};
pub struct WebVnc {
pub manager: Arc<VncSessionManager>,
pub context: VncContext,
pub pending_keys: Arc<StdMutex<HashSet<String>>>,
pub state: Arc<StdMutex<String>>,
events: broadcast::Sender<(String, Value)>,
}
pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<String> {
let (mut conn, saved) = session::connection(&args, "vnc")?;
nyaterm_core::services::validate_vnc_config(&conn)?;
let login = state.login(owner).await?;
let cancel = login.cancel.child_token();
// Only persisted secrets need decryption. Encrypt transient input through the same path.
if !saved {
if let Some(auth) = &mut conn.auth {
if let Some(password) = &auth.password {
auth.password = Some(nyaterm_core::utils::crypto::encrypt(password)?);
}
}
}
let mut config = vnc::config_from_connection(&(), conn, "main".into())?;
let pending = Arc::new(StdMutex::new(HashSet::new()));
let latest_state = Arc::new(StdMutex::new("connecting".into()));
let (events, _) = broadcast::channel(64);
let event_state = state.clone();
let event_owner = owner.to_owned();
let event_pending = pending.clone();
let event_sender = events.clone();
let event_latest_state = latest_state.clone();
let transport_state = state.clone();
let transport_owner = owner.to_owned();
let transport_cancel = cancel.clone();
let context = VncContext {
events: Arc::new(move |_, event, payload| {
if event.starts_with("vnc-state-") {
if let Some(state) = payload["state"].as_str() {
*event_latest_state.lock().unwrap() = state.into();
}
}
if let Some(id) = payload["requestId"].as_str() {
let mut pending = event_pending.lock().unwrap();
if event == "vnc-server-key-verify" {
pending.insert(id.into());
} else if event == "vnc-server-key-verify-resolved" {
pending.remove(id);
}
}
if event.starts_with("vnc-state-") || event.starts_with("vnc-clipboard-") {
let _ = event_sender.send((event.to_owned(), payload));
return Ok(());
}
let state = event_state.clone();
let owner = event_owner.clone();
let event = event.to_owned();
tokio::spawn(async move {
state.event(&owner, &event, payload).await;
});
Ok(())
}),
transport: Arc::new(move |host, port, network, _| {
let state = transport_state.clone();
let owner = transport_owner.clone();
let cancel = transport_cancel.clone();
Box::pin(async move {
crate::network::open(state, owner, cancel, host, port, network)
.await
.map_err(|e| AppError::Channel(e.1))
})
}),
};
let web = Arc::new(WebVnc {
manager: Arc::new(VncSessionManager::new()),
context,
pending_keys: pending,
state: latest_state,
events,
});
let metadata = SessionMetadata {
host: config.host.clone(),
port: config.port,
username: config.username.clone(),
name: config.name.clone(),
connection_id: saved.then_some(config.connection_id.clone()),
sftp: None,
};
let (session, _, _) = session::register(
&state,
owner,
&args,
SessionProtocol::Vnc(web.clone()),
metadata,
cancel,
)
.await?;
config.session_id = session.id.clone();
if let Err(e) = web
.manager
.create_session(web.context.clone(), config)
.await
{
state.sessions.lock().await.remove(&session.id);
return Err(e.into());
}
session.ready.store(true, Ordering::Release);
if let Some(id) = &session.connection_id {
nyaterm_core::storage::mark_connection_used(id)?;
}
let id = session.id.clone();
tokio::spawn(async move {
session.cancel.cancelled().await;
let _ = web.manager.close(&web.context, &session.id).await;
session::finish(&state, &session).await;
});
Ok(id)
}
pub async fn command(
state: &Arc<State>,
owner: &str,
command: &str,
args: &Value,
) -> Result<Value> {
if command == "respond_vnc_server_key" {
let id = args["requestId"]
.as_str()
.ok_or(WebError::bad("Missing key request id"))?;
let sessions = state
.sessions
.lock()
.await
.values()
.filter(|s| s.owner == owner)
.cloned()
.collect::<Vec<_>>();
for session in sessions {
if let SessionProtocol::Vnc(web) = &session.protocol {
if web.pending_keys.lock().unwrap().contains(id) {
web.manager
.respond_server_key(
id,
args["accepted"]
.as_bool()
.ok_or(WebError::bad("Missing key response"))?,
)
.await?;
return Ok(Value::Null);
}
}
}
return Err(WebError::forbidden());
}
let session = state
.session(
owner,
args["sessionId"]
.as_str()
.ok_or(WebError::bad("Missing session id"))?,
)
.await?;
let SessionProtocol::Vnc(web) = &session.protocol else {
return Err(WebError::bad("Not a VNC session"));
};
match command {
"vnc_input_batch" => {
web.manager
.send_input(&session.id, serde_json::from_value(args["events"].clone())?)
.await?
}
"vnc_set_clipboard_text" => {
web.manager
.set_clipboard_text(
&session.id,
args["text"]
.as_str()
.ok_or(WebError::bad("Missing clipboard text"))?
.into(),
)
.await?
}
"vnc_reconnect" => {
web.manager
.reconnect(web.context.clone(), &session.id)
.await?
}
"close_vnc_session" => session.cancel.cancel(),
_ => return Err(WebError::unsupported()),
}
Ok(Value::Null)
}
pub async fn ws_route(
ExtractState(state): ExtractState<Arc<State>>,
Extension(owner): Extension<Owner>,
Path(id): Path<String>,
upgrade: WebSocketUpgrade,
) -> Result<Response> {
let session = state.session(&owner.0, &id).await?;
if !matches!(session.protocol, SessionProtocol::Vnc(_)) {
return Err(WebError::bad("Not a VNC session"));
}
if session
.attached
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err(WebError(
axum::http::StatusCode::CONFLICT,
"VNC already attached".into(),
));
}
Ok(upgrade
.max_message_size(2 * 1024 * 1024)
.max_frame_size(2 * 1024 * 1024)
.max_write_buffer_size(140 * 1024 * 1024)
.on_failed_upgrade({
let session = session.clone();
move |_| {
session.attached.store(false, Ordering::Release);
}
})
.on_upgrade(move |socket| socket_loop(session, socket)))
}
async fn socket_loop(session: Arc<session::WebSession>, mut socket: WebSocket) {
let SessionProtocol::Vnc(web) = &session.protocol else {
return;
};
let (tx, mut frames) = mpsc::channel(2);
let overflow = Arc::new(AtomicBool::new(false));
let overflow_sink = overflow.clone();
let sink = FrameChannel(Arc::new(move |frame| {
tx.try_send(frame).map_err(|_| {
overflow_sink.store(true, Ordering::Release);
AppError::Channel("VNC consumer needs a full refresh".into())
})
}));
let mut events = web.events.subscribe();
let _ = web
.manager
.attach_frame_channel(&web.context, &session.id, sink.clone())
.await;
let mut heartbeat = tokio::time::interval(Duration::from_secs(15));
let mut refresh = tokio::time::interval(Duration::from_millis(32));
let mut last_peer = Instant::now();
loop {
let message = tokio::select! {
_ = session.cancel.cancelled() => break,
_ = refresh.tick() => {
if overflow.swap(false, Ordering::AcqRel) {
while frames.try_recv().is_ok() {}
let _ = web.manager.attach_frame_channel(&web.context, &session.id, sink.clone()).await;
}
continue;
},
frame = frames.recv() => match frame { Some(frame) => Message::Binary(frame.into()), None => break },
event = events.recv() => match event {
Ok((event, payload)) => Message::Text(json!({"type":"event","event":event,"payload":payload}).to_string().into()),
Err(broadcast::error::RecvError::Lagged(_)) => { let _ = web.manager.attach_frame_channel(&web.context, &session.id, sink.clone()).await; continue; },
Err(_) => break,
},
next = socket.recv() => {
match next {
Some(Ok(Message::Text(text))) => {
last_peer = Instant::now();
let result = async {
let value: Value = serde_json::from_str(&text)?;
match value["type"].as_str() {
Some("input") => web.manager.send_input(&session.id, serde_json::from_value::<Vec<VncInputEvent>>(value["events"].clone())?).await?,
Some("clipboard") => web.manager.set_clipboard_text(&session.id, value["text"].as_str().ok_or(WebError::bad("Missing clipboard"))?.into()).await?,
Some("close") => session.cancel.cancel(),
_ => return Err(WebError::bad("Invalid VNC message")),
}
Ok::<(), WebError>(())
}.await;
if let Err(e) = result { Message::Text(json!({"type":"error","error":e.1}).to_string().into()) } else { continue; }
},
Some(Ok(Message::Ping(data))) => { last_peer = Instant::now(); Message::Pong(data) },
Some(Ok(Message::Pong(_))) => { last_peer = Instant::now(); continue; },
_ => break,
}
},
_ = heartbeat.tick() => { if last_peer.elapsed() > Duration::from_secs(45) { break; } Message::Ping(Vec::new().into()) },
};
if !matches!(
tokio::time::timeout(Duration::from_secs(5), socket.send(message)).await,
Ok(Ok(()))
) {
break;
}
}
let _ = web.manager.detach_frame_channel(&session.id).await;
*session.detached_at.lock().unwrap() = Instant::now();
session.attached.store(false, Ordering::Release);
let _ = socket.send(Message::Close(None)).await;
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -286,6 +286,8 @@ pub async fn create_telnet_session(
create_request_id: Option<String>,
recording_scope_id: Option<String>,
startup_command: Option<StartupCommandPayload>,
network: Option<config::ConnectionNetwork>,
encoding: Option<String>,
) -> AppResult<String> {
let pending_creation = state.begin_session_creation(create_request_id).await;
let (guard, cancel_rx) = match pending_creation {
@@ -330,6 +332,7 @@ pub async fn create_telnet_session(
name: conn.name.clone(),
username: config::resolve_account_username(account.as_ref(), &username),
password: telnet_password,
network: conn.network.clone(),
backspace_mode,
raw_tcp_cli,
enter_mode: core::TelnetEnterMode::from_config_value(&enter_mode),
@@ -352,6 +355,8 @@ pub async fn create_telnet_session(
host: host.ok_or_else(|| AppError::Config("host is required".to_string()))?,
port: port.unwrap_or(23),
name: name.unwrap_or_else(|| "Telnet".to_string()),
network,
encoding: encoding.unwrap_or_else(|| "UTF-8".into()),
..Default::default()
}
};
+8
View File
@@ -42,6 +42,14 @@ pub async fn vnc_attach_frame_channel(
.await
}
#[tauri::command]
pub async fn vnc_detach_frame_channel(
state: tauri::State<'_, Arc<VncSessionManager>>,
session_id: String,
) -> AppResult<()> {
state.detach_frame_channel(&session_id).await
}
#[tauri::command]
pub async fn vnc_input_batch(
state: tauri::State<'_, Arc<VncSessionManager>>,
+13 -143
View File
@@ -1,65 +1,10 @@
use crate::config::{self, ConnectionNetwork, ProxySettings};
use crate::config::{ConnectionNetwork, ProxySettings};
use crate::error::{AppError, AppResult};
use std::net::SocketAddr;
use tauri::AppHandle;
use tokio::io::{AsyncRead, AsyncWrite};
pub trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite + ?Sized> AsyncReadWrite for T {}
pub use nyaterm_core::network::{BoxedTransportStream, OpenedTransport};
pub type BoxedTransportStream = Box<dyn AsyncReadWrite + Unpin + Send + Sync>;
pub struct OpenedTransport {
pub stream: BoxedTransportStream,
pub local_addr: Option<SocketAddr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportRouteKind {
Direct,
Proxy,
SshJump,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedTransportRoute {
pub kind: TransportRouteKind,
pub proxy_id: Option<String>,
pub proxy_jump_id: Option<String>,
}
pub fn resolve_transport_route(network: Option<&ConnectionNetwork>) -> ResolvedTransportRoute {
let proxy_jump_id = network
.and_then(|network| network.proxy_jump_id.as_deref())
.filter(|id| !id.trim().is_empty())
.map(ToOwned::to_owned);
let proxy_id = network
.and_then(|network| network.proxy_id.as_deref())
.filter(|id| !id.trim().is_empty())
.map(ToOwned::to_owned);
if proxy_jump_id.is_some() {
return ResolvedTransportRoute {
kind: TransportRouteKind::SshJump,
proxy_id,
proxy_jump_id,
};
}
if proxy_id.is_some() {
return ResolvedTransportRoute {
kind: TransportRouteKind::Proxy,
proxy_id,
proxy_jump_id: None,
};
}
ResolvedTransportRoute {
kind: TransportRouteKind::Direct,
proxy_id: None,
proxy_jump_id: None,
}
}
pub use nyaterm_core::network::{TransportRouteKind, resolve_proxy, resolve_transport_route};
pub async fn open_tcp_transport(
app: &AppHandle,
@@ -101,18 +46,11 @@ pub async fn open_tcp_transport(
}
}
async fn open_direct_transport(target_host: &str, target_port: u16) -> AppResult<OpenedTransport> {
let stream = tokio::net::TcpStream::connect((target_host, target_port))
.await
.map_err(|error| {
AppError::Channel(format!(
"Direct TCP connection to {target_host}:{target_port} failed: {error}"
))
})?;
let local_addr = stream.local_addr().ok();
async fn open_direct_transport(host: &str, port: u16) -> AppResult<OpenedTransport> {
let transport = nyaterm_core::network::open_direct_transport(host, port).await?;
Ok(OpenedTransport {
stream: Box::new(stream),
local_addr,
stream: transport.stream,
local_addr: transport.local_addr,
})
}
@@ -121,63 +59,14 @@ async fn open_proxy_transport(
target_host: &str,
target_port: u16,
) -> AppResult<OpenedTransport> {
let proxy_addr = format!("{}:{}", proxy.host, proxy.port);
match proxy.protocol.as_str() {
"socks5" => {
let stream = match (&proxy.username, &proxy.password) {
(Some(user), Some(pass)) => {
tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host, target_port),
user,
pass,
)
.await
}
_ => {
tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host, target_port),
)
.await
}
}
.map_err(|error| AppError::Auth(format!("SOCKS5 proxy connection failed: {error}")))?
.into_inner();
let local_addr = stream.local_addr().ok();
"socks5" | "http" => {
let transport =
nyaterm_core::network::open_proxy_transport(proxy, target_host, target_port)
.await?;
Ok(OpenedTransport {
stream: Box::new(stream),
local_addr,
})
}
"http" => {
let mut stream =
tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|error| {
AppError::Channel(format!("HTTP proxy connection failed: {error}"))
})?;
match (&proxy.username, &proxy.password) {
(Some(user), Some(pass)) => {
async_http_proxy::http_connect_tokio_with_basic_auth(
&mut stream,
target_host,
target_port,
user,
pass,
)
.await
}
_ => {
async_http_proxy::http_connect_tokio(&mut stream, target_host, target_port)
.await
}
}
.map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {error}")))?;
let local_addr = stream.local_addr().ok();
Ok(OpenedTransport {
stream: Box::new(stream),
local_addr,
stream: transport.stream,
local_addr: transport.local_addr,
})
}
"proxycommand" => {
@@ -200,25 +89,6 @@ async fn open_proxy_transport(
}
}
fn resolve_proxy(app: &AppHandle, proxy_id: &str) -> AppResult<ProxySettings> {
let proxy_cfg = config::load_proxy_by_id(app, proxy_id)?
.ok_or_else(|| AppError::Config(format!("Proxy '{proxy_id}' not found")))?;
let password = proxy_cfg
.password
.as_ref()
.and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok());
Ok(ProxySettings {
enabled: true,
protocol: proxy_cfg.protocol,
host: proxy_cfg.host,
port: proxy_cfg.port,
command: proxy_cfg.command,
username: proxy_cfg.username,
password,
})
}
#[cfg(test)]
mod tests {
use super::*;
+1 -153
View File
@@ -1,153 +1 @@
use crate::error::{AppError, AppResult};
pub const FRAME_HEADER_BYTES: usize = 44;
pub const PIXEL_FORMAT_BGRA8888: u32 = 1;
pub const PIXEL_FORMAT_RGBA8888: u32 = 2;
const BYTES_PER_PIXEL: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum RemoteDesktopPixelFormat {
Bgra8888,
Rgba8888,
}
impl RemoteDesktopPixelFormat {
fn wire_value(self) -> u32 {
match self {
Self::Bgra8888 => PIXEL_FORMAT_BGRA8888,
Self::Rgba8888 => PIXEL_FORMAT_RGBA8888,
}
}
}
pub struct RemoteDesktopFramePatch<'a> {
pub sequence: u64,
pub desktop_width: u32,
pub desktop_height: u32,
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub stride: u32,
pub pixel_format: RemoteDesktopPixelFormat,
pub payload: &'a [u8],
}
pub fn encode_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<Vec<u8>> {
validate_frame_patch(patch)?;
let mut frame = Vec::with_capacity(
FRAME_HEADER_BYTES
.checked_add(patch.payload.len())
.ok_or_else(|| AppError::Config("Remote desktop frame size overflows".to_string()))?,
);
frame.extend_from_slice(&patch.sequence.to_le_bytes());
frame.extend_from_slice(&patch.desktop_width.to_le_bytes());
frame.extend_from_slice(&patch.desktop_height.to_le_bytes());
frame.extend_from_slice(&patch.x.to_le_bytes());
frame.extend_from_slice(&patch.y.to_le_bytes());
frame.extend_from_slice(&patch.width.to_le_bytes());
frame.extend_from_slice(&patch.height.to_le_bytes());
frame.extend_from_slice(&patch.stride.to_le_bytes());
frame.extend_from_slice(&patch.pixel_format.wire_value().to_le_bytes());
let payload_len = u32::try_from(patch.payload.len()).map_err(|_| {
AppError::Config("Remote desktop frame payload exceeds the wire limit".to_string())
})?;
frame.extend_from_slice(&payload_len.to_le_bytes());
frame.extend_from_slice(patch.payload);
Ok(frame)
}
fn validate_frame_patch(patch: &RemoteDesktopFramePatch<'_>) -> AppResult<()> {
if patch.desktop_width == 0 || patch.desktop_height == 0 {
return Err(AppError::Config(
"Remote desktop framebuffer dimensions must be non-zero".to_string(),
));
}
if patch.width == 0 || patch.height == 0 {
return Err(AppError::Config(
"Remote desktop frame rectangle must be non-zero".to_string(),
));
}
let right = patch.x.checked_add(patch.width).ok_or_else(|| {
AppError::Config("Remote desktop frame horizontal bounds overflow".to_string())
})?;
let bottom = patch.y.checked_add(patch.height).ok_or_else(|| {
AppError::Config("Remote desktop frame vertical bounds overflow".to_string())
})?;
if right > patch.desktop_width || bottom > patch.desktop_height {
return Err(AppError::Config(
"Remote desktop frame rectangle exceeds framebuffer bounds".to_string(),
));
}
let row_bytes = usize::try_from(patch.width)
.ok()
.and_then(|width| width.checked_mul(BYTES_PER_PIXEL))
.ok_or_else(|| AppError::Config("Remote desktop frame row size overflows".to_string()))?;
let stride = usize::try_from(patch.stride)
.map_err(|_| AppError::Config("Remote desktop frame stride is invalid".to_string()))?;
if stride < row_bytes {
return Err(AppError::Config(
"Remote desktop frame stride is too small".to_string(),
));
}
let required_payload = usize::try_from(patch.height)
.ok()
.and_then(|height| stride.checked_mul(height))
.ok_or_else(|| {
AppError::Config("Remote desktop frame payload size overflows".to_string())
})?;
if patch.payload.len() < required_payload {
return Err(AppError::Config(
"Remote desktop frame payload is too small".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_the_shared_frame_header() {
let payload = [1, 2, 3, 255];
let frame = encode_frame_patch(&RemoteDesktopFramePatch {
sequence: 7,
desktop_width: 10,
desktop_height: 10,
x: 2,
y: 3,
width: 1,
height: 1,
stride: 4,
pixel_format: RemoteDesktopPixelFormat::Rgba8888,
payload: &payload,
})
.expect("frame should encode");
assert_eq!(frame.len(), FRAME_HEADER_BYTES + payload.len());
assert_eq!(u64::from_le_bytes(frame[0..8].try_into().unwrap()), 7);
assert_eq!(u32::from_le_bytes(frame[36..40].try_into().unwrap()), 2);
assert_eq!(&frame[FRAME_HEADER_BYTES..], &payload);
}
#[test]
fn rejects_rectangles_outside_the_framebuffer() {
let error = encode_frame_patch(&RemoteDesktopFramePatch {
sequence: 1,
desktop_width: 10,
desktop_height: 10,
x: 10,
y: 0,
width: 1,
height: 1,
stride: 4,
pixel_format: RemoteDesktopPixelFormat::Rgba8888,
payload: &[0; 4],
})
.expect_err("out-of-bounds rectangle must fail");
assert!(error.to_string().contains("exceeds framebuffer bounds"));
}
}
pub use nyaterm_core::remote_desktop_frame::*;
+1 -16
View File
@@ -686,22 +686,7 @@ fn resolve_proxy(
return Ok(None);
};
let proxy_cfg = crate::config::load_proxy_by_id(app, proxy_id)?
.ok_or_else(|| AppError::Config(format!("Proxy '{}' not found", proxy_id)))?;
let password = proxy_cfg
.password
.as_ref()
.and_then(|ciphertext| crate::utils::crypto::decrypt(ciphertext).ok());
Ok(Some(crate::config::ProxySettings {
enabled: true,
protocol: proxy_cfg.protocol,
host: proxy_cfg.host,
port: proxy_cfg.port,
command: proxy_cfg.command,
username: proxy_cfg.username,
password,
}))
Ok(Some(nyaterm_core::network::resolve_proxy(app, proxy_id)?))
}
pub(super) async fn authenticate_handle(
+9 -48
View File
@@ -1117,53 +1117,12 @@ pub(super) async fn connect_with_proxy(
"Opening SSH transport via proxy"
);
let proxy_addr = format!("{}:{}", proxy.host, proxy.port);
match proxy.protocol.as_str() {
"socks5" => {
let stream = match (&proxy.username, &proxy.password) {
(Some(user), Some(pass)) => {
tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
target,
user,
pass,
)
.await
}
_ => tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target).await,
}
.map_err(|error| {
AppError::Auth(format!("SOCKS5 proxy connection failed: {}", error))
})?;
client::connect_stream(ssh_config, stream.into_inner(), handler).await
}
"http" => {
let mut stream =
tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|error| {
AppError::Auth(format!("HTTP proxy connection failed: {}", error))
})?;
match (&proxy.username, &proxy.password) {
(Some(user), Some(pass)) => {
async_http_proxy::http_connect_tokio_with_basic_auth(
&mut stream,
&config.host,
config.port,
user,
pass,
)
.await
}
_ => {
async_http_proxy::http_connect_tokio(&mut stream, &config.host, config.port)
.await
}
}
.map_err(|error| AppError::Auth(format!("HTTP proxy tunnel failed: {}", error)))?;
client::connect_stream(ssh_config, stream, handler).await
"socks5" | "http" => {
let transport =
nyaterm_core::network::open_proxy_transport(proxy, &config.host, config.port)
.await?;
client::connect_stream(ssh_config, transport.stream, handler).await
}
"proxycommand" => {
let stream = open_proxy_command_stream(
@@ -1175,8 +1134,10 @@ pub(super) async fn connect_with_proxy(
.await?;
client::connect_stream(ssh_config, stream, handler).await
}
_ => {
nyaterm_core::ssh::protocol::connect(ssh_config, target.0, target.1, handler).await
other => {
return Err(AppError::Config(format!(
"Unsupported proxy protocol '{other}'"
)));
}
}
} else {
+3 -179
View File
@@ -4,182 +4,6 @@ pub(crate) mod local;
pub(crate) mod serial;
pub(crate) mod telnet;
use crate::core::input::remap_del_to_bs;
use encoding_rs::{CoderResult, Decoder, Encoding, UTF_8};
pub(crate) fn terminal_encoding(label: &str) -> &'static Encoding {
let trimmed = label.trim();
if trimmed.is_empty() {
return UTF_8;
}
Encoding::for_label(trimmed.as_bytes()).unwrap_or(UTF_8)
}
pub(crate) struct TerminalOutputDecoder {
decoder: Decoder,
}
impl TerminalOutputDecoder {
pub(crate) fn new(encoding: &str) -> Self {
Self {
decoder: terminal_encoding(encoding).new_decoder_without_bom_handling(),
}
}
pub(crate) fn decode(&mut self, data: &[u8]) -> String {
let capacity = self
.decoder
.max_utf8_buffer_length(data.len())
.unwrap_or_else(|| data.len().saturating_mul(4));
let mut output = String::with_capacity(capacity);
let mut total_read = 0;
while total_read < data.len() {
let (result, read, _) =
self.decoder
.decode_to_string(&data[total_read..], &mut output, false);
total_read += read;
match result {
CoderResult::InputEmpty => break,
CoderResult::OutputFull => {
output.reserve(
self.decoder
.max_utf8_buffer_length(data.len() - total_read)
.unwrap_or_else(|| (data.len() - total_read).saturating_mul(4))
.max(4),
);
}
}
}
output
}
}
pub(crate) fn encode_terminal_input(data: &[u8], encoding: &str) -> Vec<u8> {
let target = terminal_encoding(encoding);
if target == UTF_8 {
return data.to_vec();
}
let mut result = Vec::with_capacity(data.len());
let mut i = 0;
while i < data.len() {
if data[i] == 0x1b {
let end = ansi_sequence_end(data, i);
result.extend_from_slice(&data[i..end]);
i = end;
continue;
}
if data[i].is_ascii() {
result.push(data[i]);
i += 1;
continue;
}
let start = i;
while i < data.len() && !data[i].is_ascii() && data[i] != 0x1b {
i += 1;
}
let text = String::from_utf8_lossy(&data[start..i]);
let (encoded, _, _) = target.encode(&text);
result.extend_from_slice(&encoded);
}
result
}
pub(crate) fn prepare_terminal_write_input(
mut data: Vec<u8>,
encoding: &str,
raw: bool,
backspace_as_bs: bool,
) -> Vec<u8> {
if raw {
return data;
}
if backspace_as_bs {
remap_del_to_bs(&mut data);
}
encode_terminal_input(&data, encoding)
}
fn ansi_sequence_end(data: &[u8], start: usize) -> usize {
let mut i = start + 1;
if i >= data.len() {
return i;
}
if matches!(data[i], b'[' | b']' | b'(' | b')' | b'#') {
i += 1;
while i < data.len() {
let byte = data[i];
i += 1;
if (0x40..=0x7e).contains(&byte) {
break;
}
}
return i;
}
(i + 1).min(data.len())
}
#[cfg(test)]
mod tests {
use super::{TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input};
#[test]
fn write_input_encodes_text_but_preserves_raw_bytes_and_del() {
assert_eq!(
prepare_terminal_write_input("测\u{7f}".as_bytes().to_vec(), "GBK", false, true),
vec![0xB2, 0xE2, 0x08]
);
let raw = vec![0x1b, b'[', b'M', b' ', 0x80, 0xff, 0x7f];
assert_eq!(
prepare_terminal_write_input(raw.clone(), "GBK", true, true),
raw
);
}
#[test]
fn gbk_decoder_preserves_split_multibyte_character() {
let mut decoder = TerminalOutputDecoder::new("GBK");
assert_eq!(decoder.decode(&[0xB2]), "");
assert_eq!(decoder.decode(&[0xE2]), "测");
}
#[test]
fn gb18030_decoder_preserves_split_multibyte_character() {
let mut decoder = TerminalOutputDecoder::new("GB18030");
assert_eq!(decoder.decode(&[0xB2]), "");
assert_eq!(decoder.decode(&[0xE2]), "测");
}
#[test]
fn gbk_decoder_handles_utf8_output_expansion() {
let mut decoder = TerminalOutputDecoder::new("GBK");
let mut input = Vec::new();
for _ in 0..1024 {
input.extend_from_slice(&[0xB2, 0xE2]);
}
let output = decoder.decode(&input);
assert_eq!(output.chars().count(), 1024);
assert!(output.chars().all(|ch| ch == '测'));
}
#[test]
fn input_encoder_keeps_controls_and_ansi_sequences_raw() {
let input = b"\x1b[200~\x7f\xe6\xb5\x8b\x1b[201~";
let encoded = encode_terminal_input(input, "GBK");
assert_eq!(
encoded,
vec![
0x1b, b'[', b'2', b'0', b'0', b'~', 0x7f, 0xB2, 0xE2, 0x1b, b'[', b'2', b'0', b'1',
b'~'
]
);
}
}
pub(crate) use nyaterm_core::terminal_encoding::{
TerminalOutputDecoder, encode_terminal_input, prepare_terminal_write_input,
};
@@ -27,8 +27,23 @@ pub async fn create_telnet_session(
client_timestamp: None,
});
let session_id = uuid::Uuid::new_v4().to_string();
let addr = format!("{}:{}", host, port);
let stream = match await_telnet_connection(TcpStream::connect(&addr), cancel_rx).await {
let stream = match await_telnet_connection(
async {
crate::core::network::open_tcp_transport(
&app,
&host,
port,
config.network.as_ref(),
owner_window_label.clone(),
)
.await
.map(|opened| opened.stream)
.map_err(std::io::Error::other)
},
cancel_rx,
)
.await
{
Ok(stream) => stream,
Err(AppError::Cancelled(message)) => {
log_event(StructuredLog {
@@ -21,25 +21,10 @@ use crate::observability::{StructuredLog, StructuredLogLevel, log_event, log_rat
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{Mutex as TokioMutex, mpsc, oneshot};
const IAC: u8 = 255;
const WILL: u8 = 251;
const WONT: u8 = 252;
const DO: u8 = 253;
const DONT: u8 = 254;
const SB: u8 = 250;
const SE: u8 = 240;
pub use nyaterm_core::telnet::*;
const OPT_ECHO: u8 = 1;
const OPT_SUPPRESS_GO_AHEAD: u8 = 3;
const OPT_NAWS: u8 = 31;
include!("types.rs");
include!("negotiation.rs");
include!("line_editor.rs");
include!("auto_login.rs");
include!("tests.rs");
include!("manager.rs");
include!("session.rs");
@@ -38,14 +38,14 @@ async fn telnet_session_task(
manager: Arc<SessionManager>,
mut cmd_rx: SessionCommandReceiver,
output_control_tx: SessionCommandSender,
stream: TcpStream,
stream: crate::core::network::BoxedTransportStream,
config: TelnetSessionConfig,
connection_id: Option<String>,
encoding: String,
startup_command: Option<TelnetStartupCommand>,
) {
let backspace_as_bs = config.backspace_mode == "ctrl_h";
let (mut reader, mut writer) = stream.into_split();
let (mut reader, mut writer) = tokio::io::split(stream);
let output_event = format!("terminal-output-{}", session_id);
let closed_event = format!("session-closed-{}", session_id);
let recording_mgr: Option<Arc<RecordingManager>> = app
@@ -95,6 +95,7 @@ async fn telnet_session_task(
let mut buf = [0u8; 4096];
let mut zmodem_detector = ZmodemDetector::new();
let mut output_decoder = TerminalOutputDecoder::new(&encoding_reader);
let mut telnet_decoder = TelnetDecoder::default();
'reader: loop {
while *pause_rx.borrow() {
if pause_rx.changed().await.is_err() {
@@ -105,10 +106,10 @@ async fn telnet_session_task(
Ok(0) => break,
Ok(n) => {
let visible = if reader_config.raw_tcp_cli {
unescape_iac_iac(&buf[..n])
buf[..n].to_vec()
} else {
let neg_tx = negotiate_tx.clone();
strip_telnet_commands(&buf[..n], &mut |cmd, opt| {
telnet_decoder.decode(&buf[..n], &mut |cmd, opt| {
let resp = negotiate_response(
cmd,
opt,
@@ -171,9 +172,7 @@ async fn telnet_session_task(
let visible = if zmodem_download_oo_drain_reader.lock().await.is_active() {
let mut drain = zmodem_download_oo_drain_reader.lock().await;
drain
.filter(&visible, std::time::Instant::now())
.to_vec()
drain.filter(&visible, std::time::Instant::now()).to_vec()
} else {
visible
};
@@ -243,9 +242,7 @@ async fn telnet_session_task(
{
let mut auto = auto_login_for_reader.lock().await;
if let Some(auto) = auto.as_mut() {
for action in
auto.handle_text(&text, std::time::Instant::now())
{
for action in auto.handle_text(&text, std::time::Instant::now()) {
let _ = auto_login_tx.send(action);
}
}
@@ -1,24 +1,8 @@
#[cfg(test)]
mod tests {
use super::{
DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetEnterMode, TelnetLineEditor,
TelnetAutoLogin, TelnetAutoLoginAction, TelnetAutoLoginConfig,
TelnetAutoLoginCredentials, TelnetSessionConfig, WILL, maybe_build_naws,
await_telnet_connection, escape_telnet_application_data, negotiate_response, normalize_enter_bytes, split_write_chunks,
strip_telnet_commands,
};
use super::await_telnet_connection;
use crate::error::AppError;
use std::time::Instant;
use tokio::sync::oneshot;
#[test]
fn standard_negotiation_responds_by_default() {
assert_eq!(
negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, true),
vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD]
);
}
#[tokio::test]
async fn connection_wait_can_be_cancelled_before_connect_finishes() {
let (cancel_tx, cancel_rx) = oneshot::channel();
@@ -38,337 +22,11 @@ mod tests {
#[tokio::test]
async fn connection_wait_returns_connect_result_without_cancellation() {
let result = await_telnet_connection(
std::future::ready(Ok::<_, std::io::Error>(42_u8)),
None,
)
.await
.expect("connect result");
let result =
await_telnet_connection(std::future::ready(Ok::<_, std::io::Error>(42_u8)), None)
.await
.expect("connect result");
assert_eq!(result, 42);
}
#[test]
fn send_sga_false_rejects_sga() {
assert_ne!(
negotiate_response(WILL, OPT_SUPPRESS_GO_AHEAD, true, false),
vec![IAC, DO, OPT_SUPPRESS_GO_AHEAD]
);
}
#[test]
fn send_naws_false_rejects_naws_negotiation() {
assert_ne!(
negotiate_response(DO, OPT_NAWS, false, true),
vec![IAC, WILL, OPT_NAWS]
);
}
#[test]
fn raw_mode_can_suppress_negotiation_responses() {
let config = TelnetSessionConfig {
raw_tcp_cli: true,
..Default::default()
};
let mut responses = Vec::new();
if !config.raw_tcp_cli {
let _ = strip_telnet_commands(&[IAC, WILL, OPT_SUPPRESS_GO_AHEAD], &mut |cmd, opt| {
responses.push(negotiate_response(
cmd,
opt,
config.send_naws,
config.send_sga,
));
});
}
assert!(responses.is_empty());
}
#[test]
fn binary_input_escapes_iac_only_for_telnet_transport() {
let mouse_report = b"\x1b[M \xff\x80";
assert_eq!(
escape_telnet_application_data(mouse_report, false),
b"\x1b[M \xff\xff\x80"
);
assert_eq!(
escape_telnet_application_data(mouse_report, true),
mouse_report
);
}
#[test]
fn send_naws_false_prevents_naws_resize_payload() {
let config = TelnetSessionConfig {
send_naws: false,
..Default::default()
};
assert!(maybe_build_naws(80, 24, &config).is_none());
}
#[test]
fn raw_mode_prevents_naws_resize_payload() {
let config = TelnetSessionConfig {
raw_tcp_cli: true,
..Default::default()
};
assert!(maybe_build_naws(80, 24, &config).is_none());
}
#[test]
fn enter_conversion_maps_carriage_return() {
assert_eq!(
normalize_enter_bytes(b"show\r", TelnetEnterMode::Crlf),
b"show\r\n"
);
assert_eq!(
normalize_enter_bytes(b"show\r", TelnetEnterMode::Cr),
b"show\r"
);
assert_eq!(
normalize_enter_bytes(b"show\r", TelnetEnterMode::Lf),
b"show\n"
);
}
#[test]
fn force_character_at_a_time_preserves_utf8_order() {
let chunks = split_write_chunks("a中\r".as_bytes(), true);
assert_eq!(
chunks,
vec![b"a".to_vec(), "中".as_bytes().to_vec(), b"\r".to_vec()]
);
let joined: Vec<u8> = chunks.into_iter().flatten().collect();
assert_eq!(joined, "a中\r".as_bytes());
}
#[test]
fn strip_telnet_commands_emits_naws_response_request() {
let mut seen = Vec::new();
let visible = strip_telnet_commands(b"hi\xff\xfd\x1f", &mut |cmd, opt| {
seen.push((cmd, opt));
});
assert_eq!(visible, b"hi");
assert_eq!(seen, vec![(DO, OPT_NAWS)]);
}
#[test]
fn local_line_editor_backspace_updates_buffer() {
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7f", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "ab");
assert_eq!(result.display, "abc\x08 \x08");
assert!(result.writes.is_empty());
}
#[test]
fn local_line_editor_sends_buffer_on_enter() {
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "");
assert_eq!(result.writes, vec![b"abd\r".to_vec()]);
assert_eq!(result.display, "abc\x08 \x08d\r\n");
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Crlf);
assert_eq!(result.writes, vec![b"abd\r\n".to_vec()]);
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"abc\x7fd\r", TelnetEnterMode::Lf);
assert_eq!(result.writes, vec![b"abd\n".to_vec()]);
}
#[test]
fn local_line_editor_backspace_removes_one_utf8_char() {
let mut editor = TelnetLineEditor::default();
let result = editor.process("中a\u{7f}".as_bytes(), TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "中");
assert_eq!(result.display, "中a\x08 \x08");
let result = editor.process(b"\x7f", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "");
assert_eq!(result.display, "\x08 \x08");
}
#[test]
fn local_line_editor_passes_controls_without_buffering() {
let mut editor = TelnetLineEditor::default();
let result = editor.process(b"a\x03\x04\x1b[A\x1b[3~", TelnetEnterMode::Cr);
assert_eq!(editor.buffer(), "");
assert_eq!(
result.writes,
vec![vec![0x03], vec![0x04], b"\x1b[A".to_vec()]
);
assert_eq!(result.display, "a\x08 \x08");
}
fn auto_login() -> TelnetAutoLogin {
TelnetAutoLogin::new(
TelnetAutoLoginConfig::default(),
TelnetAutoLoginCredentials {
username: "admin".to_string(),
password: Some("secret".to_string()),
},
TelnetEnterMode::Cr,
Instant::now(),
)
.expect("auto login")
}
fn sent_payloads(actions: Vec<TelnetAutoLoginAction>) -> Vec<Vec<u8>> {
actions
.into_iter()
.filter_map(|action| match action {
TelnetAutoLoginAction::Send(data) => Some(data),
_ => None,
})
.collect()
}
#[test]
fn auto_login_detects_split_username_and_password_prompts() {
let mut login = auto_login();
assert!(login.handle_text("User", Instant::now()).is_empty());
assert_eq!(
sent_payloads(login.handle_text("name:", Instant::now())),
vec![b"admin\r".to_vec()]
);
assert!(login.handle_text("Pass", Instant::now()).is_empty());
assert_eq!(
sent_payloads(login.handle_text("word:", Instant::now())),
vec![b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_detects_chinese_prompts() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("用户名:", Instant::now())),
vec![b"admin\r".to_vec()]
);
assert_eq!(
sent_payloads(login.handle_text("密码:", Instant::now())),
vec![b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_detects_linux_getty_host_login_prompt() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text(
"\r\nLinux 6.8.0-107-generic (kaikai) (pts/3)\r\n\r\nkaikai login: ",
Instant::now()
)),
vec![b"admin\r".to_vec()]
);
}
#[test]
fn auto_login_detects_password_prompt_without_colon() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("Input Password", Instant::now())),
vec![b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_sends_wake_enter_once() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("Press RETURN to get started.", Instant::now())),
vec![b"\r".to_vec()]
);
assert!(login
.handle_text("Press RETURN to get started.", Instant::now())
.is_empty());
}
#[test]
fn auto_login_does_not_treat_last_login_as_username_prompt() {
let mut login = auto_login();
assert!(login
.handle_text("Last login: Mon Jul 13 12:00:00", Instant::now())
.is_empty());
assert!(login
.handle_text("Previous login: Mon Jul 13 12:00:00", Instant::now())
.is_empty());
}
#[test]
fn auto_login_sends_username_before_password_in_same_chunk() {
let mut login = auto_login();
assert_eq!(
sent_payloads(login.handle_text("Username:\nPassword:", Instant::now())),
vec![b"admin\r".to_vec(), b"secret\r".to_vec()]
);
}
#[test]
fn auto_login_completes_on_shell_prompt_after_credentials() {
let mut login = auto_login();
let _ = login.handle_text("Username:", Instant::now());
let _ = login.handle_text("Password:", Instant::now());
assert!(login
.handle_text("router#", Instant::now())
.contains(&TelnetAutoLoginAction::Complete));
}
#[test]
fn auto_login_failure_retries_then_disables() {
let mut login = TelnetAutoLogin::new(
TelnetAutoLoginConfig {
max_retries: 1,
..TelnetAutoLoginConfig::default()
},
TelnetAutoLoginCredentials {
username: String::new(),
password: Some("secret".to_string()),
},
TelnetEnterMode::Cr,
Instant::now(),
)
.expect("auto login");
assert_eq!(
sent_payloads(login.handle_text("Password:", Instant::now())),
vec![b"secret\r".to_vec()]
);
assert!(login
.handle_text("Authentication failed", Instant::now())
.is_empty());
assert_eq!(
sent_payloads(login.handle_text("Password:", Instant::now())),
vec![b"secret\r".to_vec()]
);
assert!(login
.handle_text("Authentication failed", Instant::now())
.contains(&TelnetAutoLoginAction::Disable));
}
#[test]
fn auto_login_user_input_disables_but_automated_input_does_not() {
let mut login = auto_login();
assert_eq!(login.handle_user_input(true), None);
assert_eq!(
login.handle_user_input(false),
Some(TelnetAutoLoginAction::Disable)
);
assert!(login.handle_text("Username:", Instant::now()).is_empty());
}
}
+64 -1666
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -271,6 +271,7 @@ pub fn run() {
cmd::rdp::respond_rdp_certificate,
cmd::vnc::create_vnc_session,
cmd::vnc::vnc_attach_frame_channel,
cmd::vnc::vnc_detach_frame_channel,
cmd::vnc::vnc_input_batch,
cmd::vnc::vnc_set_clipboard_text,
cmd::vnc::vnc_reconnect,