Files
nexus/rust/core/src/clients/telnet.rs
T

435 lines
15 KiB
Rust

//! 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<Inner>,
}
struct Inner {
conn: Arc<TcpConn>,
host: String,
port: i64,
connect_timeout: i64,
cfg: TelnetConfig,
encoding: String,
on_data: DataFn,
on_debug: Option<DebugFn>,
state: Mutex<State>,
}
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<DebugFn>) -> 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<u8>, 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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::<Vec<u8>>();
let (etx, erx) = mpsc::channel::<String>();
let (ctx, crx) = mpsc::channel::<()>();
let debug = Arc::new(Mutex::new(Vec::<String>::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::<String>();
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());
}
}