//! Telnet client (port of Go `internal/telnetclient`): RFC 854/855 option negotiation (ECHO, SGA, //! TTYPE, NAWS; everything else refused), IAC escaping, line-ending conversion, ISO 8859-1 output //! encoding and BRK. Go read but never acted on `lineMode`, and neither do we. use std::sync::{Arc, Mutex}; use super::{Callbacks, DataFn, DebugFn, TcpConn}; use crate::models::{LineEnding, Profile, TelnetConfig}; pub const IAC: u8 = 255; pub const DONT: u8 = 254; pub const DO: u8 = 253; pub const WONT: u8 = 252; pub const WILL: u8 = 251; pub const SB: u8 = 250; pub const SE: u8 = 240; pub const BRK: u8 = 243; pub const OPT_ECHO: u8 = 1; pub const OPT_SGA: u8 = 3; pub const OPT_TERM_TYPE: u8 = 24; pub const OPT_NAWS: u8 = 31; const SUB_IS: u8 = 0; const SUB_SEND: u8 = 1; /// An unterminated IAC sequence longer than this is dropped rather than buffered forever. const MAX_PENDING: usize = 8192; pub struct TelnetClient { inner: Arc, } struct Inner { conn: Arc, host: String, port: i64, connect_timeout: i64, cfg: TelnetConfig, encoding: String, on_data: DataFn, on_debug: Option, state: Mutex, } struct State { local_echo: bool, cols: u16, rows: u16, } impl TelnetClient { /// `on_debug` receives negotiation messages ("received DO 31", "sending WILL 31", …), always, /// as in Go; the profile's `negotiationDebug` is for the caller to honour. pub fn new(profile: &Profile, cb: Callbacks, on_debug: Option) -> TelnetClient { let cfg = profile.telnet.clone().unwrap_or_default(); let encoding = if cfg.encoding.is_empty() { "utf-8".to_string() } else { cfg.encoding.clone() }; let state = State { local_echo: cfg.local_echo, cols: 80, rows: 24 }; TelnetClient { inner: Arc::new(Inner { conn: Arc::new(TcpConn::new(cb.on_error, cb.on_close)), host: profile.host.clone(), port: if profile.port == 0 { 23 } else { profile.port }, connect_timeout: profile.connect_timeout, cfg, encoding, on_data: cb.on_data, on_debug, state: Mutex::new(state), }), } } pub fn connect(&self) -> crate::Result<()> { let inner = &self.inner; if inner.conn.is_connected() { return Err("already connected".into()); } let (addr, res) = super::dial(&inner.host, inner.port, inner.connect_timeout); let stream = res.map_err(|e| format!("Telnet connection to {addr} failed: {e}"))?; let me = inner.clone(); let mut pending = Vec::new(); inner.conn.connect(stream, move |chunk| me.process(&mut pending, chunk)) } /// Sends user input: line endings converted, encoded, literal 0xFF doubled. pub fn write(&self, data: &[u8]) -> crate::Result<()> { let converted = convert_line_ending(data, self.inner.cfg.line_ending); let encoded = encode_outgoing(&converted, &self.inner.encoding); self.inner.conn.write(&escape_iac(&encoded)) } /// Records the size and sends a NAWS update (sent even if the server never asked for NAWS, as in Go). pub fn resize(&self, cols: u16, rows: u16) -> crate::Result<()> { if !self.inner.conn.is_connected() { return Err("not connected".into()); } { let mut st = self.inner.state.lock().unwrap(); st.cols = cols; st.rows = rows; } self.inner.send_naws(cols, rows) } /// Sends IAC BRK. pub fn send_break(&self) -> crate::Result<()> { self.inner.conn.write(&[IAC, BRK]) } pub fn close(&self) { self.inner.conn.close(); } pub fn is_connected(&self) -> bool { self.inner.conn.is_connected() } /// The negotiated local-echo state: starts at the profile's `localEcho`, cleared by the /// server's WILL ECHO and set by WONT ECHO. Like Go, the client itself never echoes. pub fn local_echo(&self) -> bool { self.inner.state.lock().unwrap().local_echo } } impl Inner { /// Splits a received chunk into data and IAC sequences. Unlike Go, a sequence cut off at the /// end of a read is kept for the next read instead of dropped. fn process(&self, pending: &mut Vec, chunk: &[u8]) { pending.extend_from_slice(chunk); let mut data = Vec::new(); let mut i = 0; while i < pending.len() { if pending[i] != IAC { data.push(pending[i]); i += 1; continue; } if !data.is_empty() { (self.on_data)(&data); data.clear(); } let consumed = self.handle_iac(&pending[i..]); if consumed == 0 { break; } i += consumed; } if !data.is_empty() { (self.on_data)(&data); } pending.drain(..i); if pending.len() > MAX_PENDING { pending.clear(); } } /// Handles the IAC sequence at `data[0]`; returns bytes consumed, 0 if incomplete. fn handle_iac(&self, data: &[u8]) -> usize { if data.len() < 2 { return 0; } match data[1] { IAC => { (self.on_data)(&[IAC]); 2 } DO | DONT | WILL | WONT => { let Some(&opt) = data.get(2) else { return 0 }; match data[1] { DO => self.handle_do(opt), DONT => self.handle_dont(opt), WILL => self.handle_will(opt), _ => self.handle_wont(opt), } 3 } SB => match (2..data.len().saturating_sub(1)).find(|&i| data[i] == IAC && data[i + 1] == SE) { Some(end) => { self.process_subnegotiation(&data[2..end]); end + 2 } None => 0, }, cmd => { self.debug(format!("received IAC command: {cmd}")); 2 } } } fn handle_do(&self, opt: u8) { self.debug(format!("received DO {opt}")); match opt { OPT_TERM_TYPE | OPT_SGA => self.negotiate(WILL, opt), OPT_NAWS => { self.negotiate(WILL, opt); let (cols, rows) = { let st = self.state.lock().unwrap(); (st.cols, st.rows) }; let _ = self.send_naws(cols, rows); } _ => self.negotiate(WONT, opt), } } fn handle_dont(&self, opt: u8) { self.debug(format!("received DONT {opt}")); self.negotiate(WONT, opt); } fn handle_will(&self, opt: u8) { self.debug(format!("received WILL {opt}")); match opt { OPT_ECHO => { self.negotiate(DO, opt); self.state.lock().unwrap().local_echo = false; } OPT_SGA => self.negotiate(DO, opt), _ => self.negotiate(DONT, opt), } } fn handle_wont(&self, opt: u8) { self.debug(format!("received WONT {opt}")); if opt == OPT_ECHO { self.state.lock().unwrap().local_echo = true; } self.negotiate(DONT, opt); } fn process_subnegotiation(&self, payload: &[u8]) { let Some(&opt) = payload.first() else { return }; self.debug(format!("sub-negotiation for option {opt}")); if opt == OPT_TERM_TYPE && payload.get(1) == Some(&SUB_SEND) { let tt = if self.cfg.terminal_type.is_empty() { "xterm-256color" } else { &self.cfg.terminal_type }; self.debug(format!("sending terminal type: {tt}")); let mut resp = vec![IAC, SB, OPT_TERM_TYPE, SUB_IS]; resp.extend_from_slice(tt.to_uppercase().as_bytes()); resp.extend_from_slice(&[IAC, SE]); let _ = self.conn.write(&resp); } } fn negotiate(&self, cmd: u8, opt: u8) { self.debug(format!("sending {} {opt}", command_name(cmd))); let _ = self.conn.write(&[IAC, cmd, opt]); } fn send_naws(&self, cols: u16, rows: u16) -> crate::Result<()> { self.debug(format!("sending NAWS: {cols}x{rows}")); let [c0, c1] = cols.to_be_bytes(); let [r0, r1] = rows.to_be_bytes(); self.conn.write(&[IAC, SB, OPT_NAWS, c0, c1, r0, r1, IAC, SE]) } fn debug(&self, msg: String) { if let Some(f) = &self.on_debug { f(msg); } } } fn command_name(cmd: u8) -> String { match cmd { WILL => "WILL".into(), WONT => "WONT".into(), DO => "DO".into(), DONT => "DONT".into(), _ => format!("CMD({cmd})"), } } /// Normalises CRLF/CR/LF to the configured ending (`None` leaves data untouched). fn convert_line_ending(data: &[u8], le: LineEnding) -> Vec { if le == LineEnding::None { return data.to_vec(); } let mut out = Vec::with_capacity(data.len() + 8); let mut i = 0; while i < data.len() { match data[i] { b'\r' => { if data.get(i + 1) == Some(&b'\n') { i += 1; } out.extend_from_slice(le.bytes()); } b'\n' => out.extend_from_slice(le.bytes()), b => out.push(b), } i += 1; } out } /// Doubles literal 0xFF bytes. fn escape_iac(data: &[u8]) -> Vec { let mut out = Vec::with_capacity(data.len()); for &b in data { out.push(b); if b == IAC { out.push(IAC); } } out } /// UTF-8 → ISO 8859-1 for the Latin-1 encodings; everything else is sent as-is. Unlike Go this /// runs before IAC escaping, so a Latin-1 'ÿ' (0xFF) is escaped correctly. fn encode_outgoing(data: &[u8], encoding: &str) -> Vec { if super::is_latin1(encoding) { super::utf8_to_latin1(data) } else { data.to_vec() } } #[cfg(test)] mod tests { use super::*; use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc; use std::time::Duration; #[test] fn line_endings() { let cases = [ (LineEnding::Crlf, "a\nb\r\nc\rd", "a\r\nb\r\nc\r\nd"), (LineEnding::Lf, "a\r\nb\rc\n", "a\nb\nc\n"), (LineEnding::Cr, "a\r\nb\n", "a\rb\r"), (LineEnding::None, "a\r\nb\n", "a\r\nb\n"), ]; for (le, input, want) in cases { assert_eq!(convert_line_ending(input.as_bytes(), le), want.as_bytes(), "{le:?}"); } } #[test] fn iac_escaping_and_latin1() { assert_eq!(escape_iac(&[1, 0xff, 2]), vec![1, 0xff, 0xff, 2]); assert_eq!(encode_outgoing("é".as_bytes(), "Latin-1"), vec![0xe9]); assert_eq!(encode_outgoing("€".as_bytes(), "latin1"), "€".as_bytes()); // unencodable: unchanged assert_eq!(encode_outgoing("é".as_bytes(), "utf-8"), "é".as_bytes()); } fn read_n(s: &mut std::net::TcpStream, n: usize) -> Vec { let mut buf = vec![0; n]; s.read_exact(&mut buf).unwrap(); buf } #[test] fn negotiates_and_frames_data() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Telnet) }; let (dtx, drx) = mpsc::channel::>(); let (etx, erx) = mpsc::channel::(); let (ctx, crx) = mpsc::channel::<()>(); let debug = Arc::new(Mutex::new(Vec::::new())); let dbg = debug.clone(); let cb = Callbacks { on_data: Arc::new(move |d| dtx.send(d.to_vec()).unwrap()), on_error: Arc::new(move |e| etx.send(e).unwrap()), on_close: Arc::new(move || ctx.send(()).unwrap()), }; let client = TelnetClient::new(&profile, cb, Some(Arc::new(move |m| dbg.lock().unwrap().push(m)))); client.connect().unwrap(); let (mut srv, _) = listener.accept().unwrap(); srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99]) .unwrap(); let mut want = vec![IAC, WILL, OPT_TERM_TYPE, IAC, WILL, OPT_NAWS, IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE]; want.extend_from_slice(&[IAC, DO, OPT_ECHO, IAC, DO, OPT_SGA, IAC, WONT, 99]); assert_eq!(read_n(&mut srv, want.len()), want); assert!(!client.local_echo()); // Data with an escaped 0xFF, then a TTYPE SEND split across two writes. srv.write_all(b"hi").unwrap(); srv.write_all(&[IAC, IAC, IAC, SB, OPT_TERM_TYPE]).unwrap(); std::thread::sleep(Duration::from_millis(50)); srv.write_all(&[SUB_SEND, IAC, SE]).unwrap(); let mut want = vec![IAC, SB, OPT_TERM_TYPE, SUB_IS]; want.extend_from_slice(b"XTERM-256COLOR"); want.extend_from_slice(&[IAC, SE]); assert_eq!(read_n(&mut srv, want.len()), want); let mut got = Vec::new(); while got.len() < 3 { got.extend(drx.recv_timeout(Duration::from_secs(5)).unwrap()); } assert_eq!(got, b"hi\xff"); srv.write_all(&[IAC, WONT, OPT_ECHO]).unwrap(); assert_eq!(read_n(&mut srv, 3), [IAC, DONT, OPT_ECHO]); assert!(client.local_echo()); client.resize(132, 50).unwrap(); assert_eq!(read_n(&mut srv, 9), [IAC, SB, OPT_NAWS, 0, 132, 0, 50, IAC, SE]); client.send_break().unwrap(); assert_eq!(read_n(&mut srv, 2), [IAC, BRK]); client.write(b"ls\n\xff").unwrap(); assert_eq!(read_n(&mut srv, 6), b"ls\r\n\xff\xff"); let log = debug.lock().unwrap().clone(); for m in ["received DO 24", "sending WILL 31", "sending NAWS: 80x24", "sending WONT 99", "sending terminal type: xterm-256color"] { assert!(log.iter().any(|l| l == m), "missing debug {m:?} in {log:?}"); } // Remote close: Go reports "read error: EOF" then closes, exactly once. drop(srv); assert_eq!(erx.recv_timeout(Duration::from_secs(5)).unwrap(), "read error: EOF"); crx.recv_timeout(Duration::from_secs(5)).unwrap(); client.close(); assert!(crx.recv_timeout(Duration::from_millis(100)).is_err()); assert!(client.write(b"x").is_err()); } #[test] fn local_close_fires_once_without_error() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let profile = Profile { host: "127.0.0.1".into(), port: listener.local_addr().unwrap().port() as i64, ..Profile::new(crate::models::Protocol::Telnet) }; let (etx, erx) = mpsc::channel::(); let (ctx, crx) = mpsc::channel::<()>(); let cb = Callbacks { on_error: Arc::new(move |e| etx.send(e).unwrap()), on_close: Arc::new(move || ctx.send(()).unwrap()), ..Callbacks::noop() }; let client = TelnetClient::new(&profile, cb, None); client.connect().unwrap(); assert!(client.connect().is_err()); let _srv = listener.accept().unwrap(); client.close(); client.close(); crx.recv_timeout(Duration::from_secs(1)).unwrap(); assert!(crx.recv_timeout(Duration::from_millis(100)).is_err()); assert!(erx.recv_timeout(Duration::from_millis(100)).is_err()); } }