feat(telnet): enhance Telnet form with advanced configuration options (#22)
- Added new props to the TelnetForm component for advanced Telnet settings including raw TCP CLI, enter mode, local echo, force character-at-a-time, and NAWS/SGA options. - Implemented UI elements for toggling these settings, improving user control over Telnet connections. - Updated NewSessionPage to manage and persist these new settings for Telnet sessions. - Enhanced type definitions to accommodate new configuration options for Telnet connections.
This commit is contained in:
1 parent
6946c3a98b
commit
0ae88ae43d
9 files changed
+628
-46
No files matched your search
@@ -224,6 +224,12 @@ mod tests {
|
||||
port: 23,
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
backspace_mode: "del".to_string(),
|
||||
raw_tcp_cli: false,
|
||||
enter_mode: "cr".to_string(),
|
||||
local_echo: false,
|
||||
force_character_at_a_time: false,
|
||||
send_naws: true,
|
||||
send_sga: true,
|
||||
},
|
||||
group_id: None,
|
||||
description: None,
|
||||
|
||||
@@ -145,15 +145,32 @@ pub async fn create_telnet_session(
|
||||
Some((guard, cancel_rx)) => (Some(guard), Some(cancel_rx)),
|
||||
None => (None, None),
|
||||
};
|
||||
let (h, p, n, bs_mode) = if let Some(ref cid) = connection_id {
|
||||
let cfg = if let Some(ref cid) = connection_id {
|
||||
let conn = config::load_connection_by_id(&app, cid)?;
|
||||
match conn.config {
|
||||
config::ConnectionType::Telnet {
|
||||
host: ref ch,
|
||||
port: cp,
|
||||
backspace_mode,
|
||||
raw_tcp_cli,
|
||||
enter_mode,
|
||||
local_echo,
|
||||
force_character_at_a_time,
|
||||
send_naws,
|
||||
send_sga,
|
||||
..
|
||||
} => (ch.clone(), cp, conn.name.clone(), backspace_mode),
|
||||
} => core::TelnetSessionConfig {
|
||||
host: ch.clone(),
|
||||
port: cp,
|
||||
name: conn.name.clone(),
|
||||
backspace_mode,
|
||||
raw_tcp_cli,
|
||||
enter_mode: core::TelnetEnterMode::from_config_value(&enter_mode),
|
||||
local_echo,
|
||||
force_character_at_a_time,
|
||||
send_naws,
|
||||
send_sga,
|
||||
},
|
||||
_ => {
|
||||
return Err(AppError::Config(
|
||||
"Connection is not a Telnet connection".to_string(),
|
||||
@@ -161,22 +178,19 @@ pub async fn create_telnet_session(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(
|
||||
host.ok_or_else(|| AppError::Config("host is required".to_string()))?,
|
||||
port.unwrap_or(23),
|
||||
name.unwrap_or_else(|| "Telnet".to_string()),
|
||||
"del".to_string(),
|
||||
)
|
||||
core::TelnetSessionConfig {
|
||||
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()),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let marked_connection_id = connection_id.clone();
|
||||
let session_id = core::create_telnet_session(
|
||||
app.clone(),
|
||||
state.inner().clone(),
|
||||
h,
|
||||
p,
|
||||
cfg,
|
||||
connection_id,
|
||||
n,
|
||||
bs_mode,
|
||||
Some(window.label().to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -55,6 +55,21 @@ pub enum ConnectionType {
|
||||
ai_execution_profile: AiExecutionProfile,
|
||||
#[serde(default = "default_backspace_mode_telnet")]
|
||||
backspace_mode: String,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
raw_tcp_cli: bool,
|
||||
#[serde(
|
||||
default = "default_telnet_enter_mode",
|
||||
skip_serializing_if = "is_default_telnet_enter_mode"
|
||||
)]
|
||||
enter_mode: String,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
local_echo: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
force_character_at_a_time: bool,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
send_naws: bool,
|
||||
#[serde(default = "default_true", skip_serializing_if = "is_true")]
|
||||
send_sga: bool,
|
||||
},
|
||||
Serial {
|
||||
port_name: String,
|
||||
@@ -103,9 +118,15 @@ fn default_backspace_mode_serial() -> String {
|
||||
fn default_backspace_mode_telnet() -> String {
|
||||
"del".to_string()
|
||||
}
|
||||
fn default_telnet_enter_mode() -> String {
|
||||
"cr".to_string()
|
||||
}
|
||||
fn is_ai_execution_profile_auto(value: &AiExecutionProfile) -> bool {
|
||||
*value == AiExecutionProfile::Auto
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// ── Auth block ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -136,6 +157,12 @@ fn default_auth_mode() -> String {
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
fn is_true(value: &bool) -> bool {
|
||||
*value
|
||||
}
|
||||
fn is_default_telnet_enter_mode(value: &str) -> bool {
|
||||
value == "cr"
|
||||
}
|
||||
|
||||
// ── Network block ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -418,6 +445,76 @@ mod tests {
|
||||
assert!(!x11_forwarding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telnet_connection_defaults_compatibility_options() {
|
||||
let connection: SavedConnection = serde_json::from_value(serde_json::json!({
|
||||
"id": "conn-1",
|
||||
"name": "Test",
|
||||
"type": "telnet",
|
||||
"host": "example.com",
|
||||
"port": 23
|
||||
}))
|
||||
.expect("connection");
|
||||
|
||||
let ConnectionType::Telnet {
|
||||
raw_tcp_cli,
|
||||
enter_mode,
|
||||
local_echo,
|
||||
force_character_at_a_time,
|
||||
send_naws,
|
||||
send_sga,
|
||||
..
|
||||
} = connection.config
|
||||
else {
|
||||
panic!("expected telnet connection");
|
||||
};
|
||||
|
||||
assert!(!raw_tcp_cli);
|
||||
assert_eq!(enter_mode, "cr");
|
||||
assert!(!local_echo);
|
||||
assert!(!force_character_at_a_time);
|
||||
assert!(send_naws);
|
||||
assert!(send_sga);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telnet_connection_preserves_compatibility_options() {
|
||||
let connection: SavedConnection = serde_json::from_value(serde_json::json!({
|
||||
"id": "conn-1",
|
||||
"name": "Test",
|
||||
"type": "telnet",
|
||||
"host": "example.com",
|
||||
"port": 8080,
|
||||
"raw_tcp_cli": true,
|
||||
"enter_mode": "lf",
|
||||
"local_echo": true,
|
||||
"force_character_at_a_time": true,
|
||||
"send_naws": false,
|
||||
"send_sga": false
|
||||
}))
|
||||
.expect("connection");
|
||||
|
||||
let ConnectionType::Telnet {
|
||||
raw_tcp_cli,
|
||||
enter_mode,
|
||||
local_echo,
|
||||
force_character_at_a_time,
|
||||
send_naws,
|
||||
send_sga,
|
||||
..
|
||||
} = connection.config
|
||||
else {
|
||||
panic!("expected telnet connection");
|
||||
};
|
||||
|
||||
assert!(raw_tcp_cli);
|
||||
assert_eq!(enter_mode, "lf");
|
||||
assert!(local_echo);
|
||||
assert!(force_character_at_a_time);
|
||||
assert!(!send_naws);
|
||||
assert!(!send_sga);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_login_defaults_delay_when_omitted() {
|
||||
let connection: SavedConnection = serde_json::from_value(serde_json::json!({
|
||||
|
||||
@@ -708,6 +708,12 @@ fn prepare_nyaterm_json_session(
|
||||
port,
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
backspace_mode,
|
||||
raw_tcp_cli: false,
|
||||
enter_mode: "cr".to_string(),
|
||||
local_echo: false,
|
||||
force_character_at_a_time: false,
|
||||
send_naws: true,
|
||||
send_sga: true,
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
|
||||
@@ -37,4 +37,4 @@ pub(crate) use session::update_cwd_if_changed;
|
||||
pub use session::{
|
||||
SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType, SharedCwd,
|
||||
};
|
||||
pub use telnet::create_telnet_session;
|
||||
pub use telnet::{TelnetEnterMode, TelnetSessionConfig, create_telnet_session};
|
||||
+305
-33
@@ -31,18 +31,72 @@ const OPT_ECHO: u8 = 1;
|
||||
const OPT_SUPPRESS_GO_AHEAD: u8 = 3;
|
||||
const OPT_NAWS: u8 = 31;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TelnetEnterMode {
|
||||
Crlf,
|
||||
Cr,
|
||||
Lf,
|
||||
}
|
||||
|
||||
impl Default for TelnetEnterMode {
|
||||
fn default() -> Self {
|
||||
Self::Cr
|
||||
}
|
||||
}
|
||||
|
||||
impl TelnetEnterMode {
|
||||
pub fn from_config_value(value: &str) -> Self {
|
||||
match value {
|
||||
"crlf" => Self::Crlf,
|
||||
"lf" => Self::Lf,
|
||||
_ => Self::Cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TelnetSessionConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub name: String,
|
||||
pub backspace_mode: String,
|
||||
pub raw_tcp_cli: bool,
|
||||
pub enter_mode: TelnetEnterMode,
|
||||
pub local_echo: bool,
|
||||
pub force_character_at_a_time: bool,
|
||||
pub send_naws: bool,
|
||||
pub send_sga: bool,
|
||||
}
|
||||
|
||||
impl Default for TelnetSessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
port: 23,
|
||||
name: "Telnet".to_string(),
|
||||
backspace_mode: "del".to_string(),
|
||||
raw_tcp_cli: false,
|
||||
enter_mode: TelnetEnterMode::Cr,
|
||||
local_echo: false,
|
||||
force_character_at_a_time: false,
|
||||
send_naws: true,
|
||||
send_sga: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Respond to a Telnet option negotiation request.
|
||||
fn negotiate_response(command: u8, option: u8) -> Vec<u8> {
|
||||
fn negotiate_response(command: u8, option: u8, send_naws: bool, send_sga: bool) -> Vec<u8> {
|
||||
match command {
|
||||
WILL => {
|
||||
if option == OPT_ECHO || option == OPT_SUPPRESS_GO_AHEAD {
|
||||
if option == OPT_ECHO || (send_sga && option == OPT_SUPPRESS_GO_AHEAD) {
|
||||
vec![IAC, DO, option]
|
||||
} else {
|
||||
vec![IAC, DONT, option]
|
||||
}
|
||||
}
|
||||
DO => {
|
||||
if option == OPT_NAWS {
|
||||
if send_naws && option == OPT_NAWS {
|
||||
vec![IAC, WILL, option]
|
||||
} else {
|
||||
vec![IAC, WONT, option]
|
||||
@@ -69,6 +123,29 @@ fn build_naws(cols: u16, rows: u16) -> Vec<u8> {
|
||||
]
|
||||
}
|
||||
|
||||
fn maybe_build_naws(cols: u16, rows: u16, config: &TelnetSessionConfig) -> Option<Vec<u8>> {
|
||||
if config.raw_tcp_cli || !config.send_naws {
|
||||
None
|
||||
} else {
|
||||
Some(build_naws(cols, rows))
|
||||
}
|
||||
}
|
||||
|
||||
fn unescape_iac_iac(data: &[u8]) -> Vec<u8> {
|
||||
let mut visible = Vec::with_capacity(data.len());
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if data[i] == IAC && i + 1 < data.len() && data[i + 1] == IAC {
|
||||
visible.push(IAC);
|
||||
i += 2;
|
||||
} else {
|
||||
visible.push(data[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -113,16 +190,196 @@ 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> {
|
||||
let replacement: &[u8] = match enter_mode {
|
||||
TelnetEnterMode::Crlf => b"\r\n",
|
||||
TelnetEnterMode::Cr => b"\r",
|
||||
TelnetEnterMode::Lf => b"\n",
|
||||
};
|
||||
let mut normalized = Vec::with_capacity(data.len());
|
||||
for byte in data {
|
||||
if *byte == b'\r' {
|
||||
normalized.extend_from_slice(replacement);
|
||||
} else {
|
||||
normalized.push(*byte);
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
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()];
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(data)
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
let mut buf = [0u8; 4];
|
||||
ch.encode_utf8(&mut buf).as_bytes().to_vec()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn local_echo_text(data: &[u8]) -> String {
|
||||
let mut visible = Vec::with_capacity(data.len());
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
match data[i] {
|
||||
0x1b => {
|
||||
i += 1;
|
||||
if i < data.len() && data[i] == b'[' {
|
||||
i += 1;
|
||||
while i < data.len() && !(0x40..=0x7e).contains(&data[i]) {
|
||||
i += 1;
|
||||
}
|
||||
} else if i < data.len() && data[i] == b']' {
|
||||
i += 1;
|
||||
while i < data.len() {
|
||||
if data[i] == 0x07 {
|
||||
break;
|
||||
}
|
||||
if data[i] == 0x1b && i + 1 < data.len() && data[i + 1] == b'\\' {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
b'\r' => {
|
||||
if i + 1 < data.len() && data[i + 1] == b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
visible.extend_from_slice(b"\r\n");
|
||||
}
|
||||
b'\n' => visible.extend_from_slice(b"\r\n"),
|
||||
0x20..=0x7e | b'\t' => visible.push(data[i]),
|
||||
byte if byte >= 0x80 => visible.push(byte),
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&visible).to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DO, IAC, OPT_NAWS, OPT_SUPPRESS_GO_AHEAD, TelnetEnterMode, TelnetSessionConfig, WILL,
|
||||
maybe_build_naws, negotiate_response, normalize_enter_bytes, split_write_chunks,
|
||||
strip_telnet_commands,
|
||||
};
|
||||
|
||||
#[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 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)]);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_telnet_session(
|
||||
app: AppHandle,
|
||||
manager: Arc<SessionManager>,
|
||||
host: String,
|
||||
port: u16,
|
||||
config: TelnetSessionConfig,
|
||||
connection_id: Option<String>,
|
||||
name: String,
|
||||
backspace_mode: String,
|
||||
owner_window_label: Option<String>,
|
||||
) -> AppResult<String> {
|
||||
let host = config.host.clone();
|
||||
let port = config.port;
|
||||
log_event(StructuredLog {
|
||||
level: StructuredLogLevel::Info,
|
||||
domain: "session.lifecycle".to_string(),
|
||||
@@ -144,7 +401,7 @@ pub async fn create_telnet_session(
|
||||
|
||||
let session_info = SessionInfo {
|
||||
id: session_id.clone(),
|
||||
name,
|
||||
name: config.name.clone(),
|
||||
session_type: SessionType::Telnet,
|
||||
connected: true,
|
||||
owner_window_label,
|
||||
@@ -167,17 +424,7 @@ pub async fn create_telnet_session(
|
||||
let mgr = manager.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
telnet_session_task(
|
||||
app,
|
||||
sid,
|
||||
mgr,
|
||||
cmd_rx,
|
||||
host,
|
||||
port,
|
||||
connection_id,
|
||||
backspace_mode,
|
||||
)
|
||||
.await;
|
||||
telnet_session_task(app, sid, mgr, cmd_rx, config, connection_id).await;
|
||||
});
|
||||
|
||||
Ok(session_id)
|
||||
@@ -188,12 +435,12 @@ async fn telnet_session_task(
|
||||
session_id: String,
|
||||
manager: Arc<SessionManager>,
|
||||
mut cmd_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||
host: String,
|
||||
port: u16,
|
||||
config: TelnetSessionConfig,
|
||||
connection_id: Option<String>,
|
||||
backspace_mode: String,
|
||||
) {
|
||||
let backspace_as_bs = backspace_mode == "ctrl_h";
|
||||
let backspace_as_bs = config.backspace_mode == "ctrl_h";
|
||||
let host = config.host.clone();
|
||||
let port = config.port;
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let stream = match TcpStream::connect(&addr).await {
|
||||
Ok(s) => s,
|
||||
@@ -252,6 +499,7 @@ async fn telnet_session_task(
|
||||
|
||||
let (negotiate_tx, mut negotiate_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
|
||||
let reader_config = config.clone();
|
||||
let reader_handle = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
let mut zmodem_detector = ZmodemDetector::new();
|
||||
@@ -264,13 +512,22 @@ async fn telnet_session_task(
|
||||
match reader.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let neg_tx = negotiate_tx.clone();
|
||||
let visible = strip_telnet_commands(&buf[..n], &mut |cmd, opt| {
|
||||
let resp = negotiate_response(cmd, opt);
|
||||
if !resp.is_empty() {
|
||||
let _ = neg_tx.send(resp);
|
||||
}
|
||||
});
|
||||
let visible = if reader_config.raw_tcp_cli {
|
||||
unescape_iac_iac(&buf[..n])
|
||||
} else {
|
||||
let neg_tx = negotiate_tx.clone();
|
||||
strip_telnet_commands(&buf[..n], &mut |cmd, opt| {
|
||||
let resp = negotiate_response(
|
||||
cmd,
|
||||
opt,
|
||||
reader_config.send_naws,
|
||||
reader_config.send_sga,
|
||||
);
|
||||
if !resp.is_empty() {
|
||||
let _ = neg_tx.send(resp);
|
||||
}
|
||||
})
|
||||
};
|
||||
if visible.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -418,10 +675,24 @@ async fn telnet_session_task(
|
||||
if backspace_as_bs {
|
||||
remap_del_to_bs(&mut data);
|
||||
}
|
||||
let data = normalize_enter_bytes(&data, config.enter_mode);
|
||||
if config.local_echo {
|
||||
let echoed = local_echo_text(&data);
|
||||
if !echoed.is_empty() {
|
||||
output.push_owned(echoed);
|
||||
}
|
||||
}
|
||||
if let Some(ref recorder) = recording_mgr {
|
||||
recorder.write_input(&session_id, &data);
|
||||
}
|
||||
if let Err(e) = writer.write_all(&data).await {
|
||||
let mut write_failed = None;
|
||||
for chunk in split_write_chunks(&data, config.force_character_at_a_time) {
|
||||
if let Err(e) = writer.write_all(&chunk).await {
|
||||
write_failed = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(e) = write_failed {
|
||||
log_rate_limited(StructuredLog {
|
||||
level: StructuredLogLevel::Warn,
|
||||
domain: "session.lifecycle".to_string(),
|
||||
@@ -451,8 +722,9 @@ async fn telnet_session_task(
|
||||
}
|
||||
}
|
||||
Some(SessionCommand::Resize { cols, rows }) => {
|
||||
let naws = build_naws(cols as u16, rows as u16);
|
||||
let _ = writer.write_all(&naws).await;
|
||||
if let Some(naws) = maybe_build_naws(cols as u16, rows as u16, &config) {
|
||||
let _ = writer.write_all(&naws).await;
|
||||
}
|
||||
}
|
||||
Some(SessionCommand::PauseOutput) => {
|
||||
let _ = pause_tx.send(true);
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TelnetEnterMode = "crlf" | "cr" | "lf";
|
||||
|
||||
interface TelnetFormProps {
|
||||
host: string;
|
||||
@@ -17,6 +21,18 @@ interface TelnetFormProps {
|
||||
setPort: (v: number) => void;
|
||||
backspaceMode: string;
|
||||
setBackspaceMode: (v: string) => void;
|
||||
rawTcpCli: boolean;
|
||||
setRawTcpCli: (v: boolean) => void;
|
||||
enterMode: TelnetEnterMode;
|
||||
setEnterMode: (v: TelnetEnterMode) => void;
|
||||
localEcho: boolean;
|
||||
setLocalEcho: (v: boolean) => void;
|
||||
forceCharacterAtATime: boolean;
|
||||
setForceCharacterAtATime: (v: boolean) => void;
|
||||
sendNaws: boolean;
|
||||
setSendNaws: (v: boolean) => void;
|
||||
sendSga: boolean;
|
||||
setSendSga: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
@@ -30,8 +46,44 @@ export function TelnetForm({
|
||||
setPort,
|
||||
backspaceMode,
|
||||
setBackspaceMode,
|
||||
rawTcpCli,
|
||||
setRawTcpCli,
|
||||
enterMode,
|
||||
setEnterMode,
|
||||
localEcho,
|
||||
setLocalEcho,
|
||||
forceCharacterAtATime,
|
||||
setForceCharacterAtATime,
|
||||
sendNaws,
|
||||
setSendNaws,
|
||||
sendSga,
|
||||
setSendSga,
|
||||
}: TelnetFormProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderSwitchRow = (
|
||||
label: string,
|
||||
description: string,
|
||||
checked: boolean,
|
||||
onCheckedChange: (checked: boolean) => void,
|
||||
disabled = false,
|
||||
) => (
|
||||
<div className={cn("rounded-md border bg-background/70 px-3 py-2", disabled && "opacity-55")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="text-xs font-medium">{label}</div>
|
||||
<p className="text-[0.6875rem] leading-relaxed text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
className="mt-0.5"
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
@@ -77,6 +129,93 @@ export function TelnetForm({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-accent/25 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium">{t("dialog.telnetAdvanced", "Telnet Advanced")}</div>
|
||||
<p className="text-[0.6875rem] leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
"dialog.telnetRawTcpCliDesc",
|
||||
"Suitable for non-standard Telnet, embedded debug ports, and device CLIs. When enabled, Telnet negotiation is disabled and input is passed through directly.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
{renderSwitchRow(
|
||||
t("dialog.telnetRawTcpCli", "Embedded debug port / Raw TCP CLI"),
|
||||
t(
|
||||
"dialog.telnetRawTcpCliLongDesc",
|
||||
"Disables Telnet negotiation and passes input directly through for TVs, routers, switches, and embedded debug ports.",
|
||||
),
|
||||
rawTcpCli,
|
||||
(checked) => {
|
||||
setRawTcpCli(checked);
|
||||
if (checked) {
|
||||
setEnterMode("cr");
|
||||
}
|
||||
},
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-foreground/80">
|
||||
{t("dialog.telnetEnterMode", "Enter sends")}
|
||||
</Label>
|
||||
<Select
|
||||
value={enterMode}
|
||||
onValueChange={(value) => setEnterMode(value as TelnetEnterMode)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs font-normal">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="crlf">CRLF (\r\n)</SelectItem>
|
||||
<SelectItem value="cr">CR (\r)</SelectItem>
|
||||
<SelectItem value="lf">LF (\n)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{renderSwitchRow(
|
||||
t("dialog.telnetLocalEcho", "Local Echo"),
|
||||
t(
|
||||
"dialog.telnetLocalEchoDesc",
|
||||
"Show typed input locally when the device does not echo.",
|
||||
),
|
||||
localEcho,
|
||||
setLocalEcho,
|
||||
)}
|
||||
{renderSwitchRow(
|
||||
t("dialog.telnetForceCharAtATime", "Force character-at-a-time"),
|
||||
t(
|
||||
"dialog.telnetForceCharAtATimeDesc",
|
||||
"Write each input character to the TCP stream immediately.",
|
||||
),
|
||||
forceCharacterAtATime,
|
||||
setForceCharacterAtATime,
|
||||
)}
|
||||
{renderSwitchRow(
|
||||
t("dialog.telnetSendNaws", "Send NAWS"),
|
||||
t("dialog.telnetSendNawsDesc", "Send terminal size changes in standard Telnet mode."),
|
||||
sendNaws,
|
||||
setSendNaws,
|
||||
rawTcpCli,
|
||||
)}
|
||||
{renderSwitchRow(
|
||||
t("dialog.telnetSendSga", "Send SGA"),
|
||||
t(
|
||||
"dialog.telnetSendSgaDesc",
|
||||
"Accept Suppress Go Ahead negotiation in standard Telnet mode.",
|
||||
),
|
||||
sendSga,
|
||||
setSendSga,
|
||||
rawTcpCli,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +109,12 @@ export default function NewSessionPage() {
|
||||
const [workingDir, setWorkingDir] = useState("");
|
||||
const [serialBackspaceMode, setSerialBackspaceMode] = useState("ctrl_h");
|
||||
const [telnetBackspaceMode, setTelnetBackspaceMode] = useState("del");
|
||||
const [telnetRawTcpCli, setTelnetRawTcpCli] = useState(false);
|
||||
const [telnetEnterMode, setTelnetEnterMode] = useState<"crlf" | "cr" | "lf">("cr");
|
||||
const [telnetLocalEcho, setTelnetLocalEcho] = useState(false);
|
||||
const [telnetForceCharacterAtATime, setTelnetForceCharacterAtATime] = useState(false);
|
||||
const [telnetSendNaws, setTelnetSendNaws] = useState(true);
|
||||
const [telnetSendSga, setTelnetSendSga] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<Group[]>("get_groups")
|
||||
@@ -168,6 +174,12 @@ export default function NewSessionPage() {
|
||||
setHost(found.host || "");
|
||||
setTelnetPort(found.port || 23);
|
||||
setTelnetBackspaceMode(found.backspace_mode || "del");
|
||||
setTelnetRawTcpCli(found.raw_tcp_cli ?? false);
|
||||
setTelnetEnterMode(found.enter_mode || "cr");
|
||||
setTelnetLocalEcho(found.local_echo ?? false);
|
||||
setTelnetForceCharacterAtATime(found.force_character_at_a_time ?? false);
|
||||
setTelnetSendNaws(found.send_naws ?? true);
|
||||
setTelnetSendSga(found.send_sga ?? true);
|
||||
} else if (found.type === "local_terminal") {
|
||||
setShellPath(found.shell_path || "powershell.exe");
|
||||
setShellArgs(found.shell_args || "");
|
||||
@@ -243,6 +255,12 @@ export default function NewSessionPage() {
|
||||
setWorkingDir("");
|
||||
setSerialBackspaceMode("ctrl_h");
|
||||
setTelnetBackspaceMode("del");
|
||||
setTelnetRawTcpCli(false);
|
||||
setTelnetEnterMode("cr");
|
||||
setTelnetLocalEcho(false);
|
||||
setTelnetForceCharacterAtATime(false);
|
||||
setTelnetSendNaws(true);
|
||||
setTelnetSendSga(true);
|
||||
setShowIconPicker(false);
|
||||
setError("");
|
||||
setConnecting(false);
|
||||
@@ -518,6 +536,12 @@ export default function NewSessionPage() {
|
||||
host: normalizedHost,
|
||||
port: telnetPort,
|
||||
backspace_mode: telnetBackspaceMode,
|
||||
raw_tcp_cli: telnetRawTcpCli,
|
||||
enter_mode: telnetEnterMode,
|
||||
local_echo: telnetLocalEcho,
|
||||
force_character_at_a_time: telnetForceCharacterAtATime,
|
||||
send_naws: telnetSendNaws,
|
||||
send_sga: telnetSendSga,
|
||||
}
|
||||
: {}),
|
||||
...(currentTab === "local"
|
||||
@@ -861,6 +885,18 @@ export default function NewSessionPage() {
|
||||
setPort={setTelnetPort}
|
||||
backspaceMode={telnetBackspaceMode}
|
||||
setBackspaceMode={setTelnetBackspaceMode}
|
||||
rawTcpCli={telnetRawTcpCli}
|
||||
setRawTcpCli={setTelnetRawTcpCli}
|
||||
enterMode={telnetEnterMode}
|
||||
setEnterMode={setTelnetEnterMode}
|
||||
localEcho={telnetLocalEcho}
|
||||
setLocalEcho={setTelnetLocalEcho}
|
||||
forceCharacterAtATime={telnetForceCharacterAtATime}
|
||||
setForceCharacterAtATime={setTelnetForceCharacterAtATime}
|
||||
sendNaws={telnetSendNaws}
|
||||
setSendNaws={setTelnetSendNaws}
|
||||
sendSga={telnetSendSga}
|
||||
setSendSga={setTelnetSendSga}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
Vendored
+12
@@ -211,6 +211,18 @@ export interface SavedConnection {
|
||||
stop_bits?: string;
|
||||
/** Backspace key mode for SSH/Telnet/Serial connections ("ctrl_h" or "del"). */
|
||||
backspace_mode?: string;
|
||||
/** Telnet-only: bypass Telnet option negotiation for embedded/raw TCP CLIs. */
|
||||
raw_tcp_cli?: boolean;
|
||||
/** Telnet-only: Enter send mode ("crlf", "cr", or "lf"). */
|
||||
enter_mode?: "crlf" | "cr" | "lf";
|
||||
/** Telnet-only: locally echo typed input when the remote does not echo. */
|
||||
local_echo?: boolean;
|
||||
/** Telnet-only: write each input character to the socket immediately. */
|
||||
force_character_at_a_time?: boolean;
|
||||
/** Telnet-only: send NAWS resize subnegotiation in standard Telnet mode. */
|
||||
send_naws?: boolean;
|
||||
/** Telnet-only: accept/respond to SGA negotiation in standard Telnet mode. */
|
||||
send_sga?: boolean;
|
||||
/** SSH-only: enables X11 forwarding for remote graphical applications. */
|
||||
x11_forwarding?: boolean;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user