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:
1 parent
304e3b3a2d
commit
ed348d88e2
69 files changed
+6297
-2816
No files matched your search
Generated
+5
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
Generated
+211
-7
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+45
-24
@@ -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")
|
||||
})
|
||||
}
|
||||
+8
-9
@@ -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");
|
||||
+56
-21
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
Generated
+210
-8
@@ -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",
|
||||
]
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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 +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::*;
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user