From 0ffa5cd6ea9ac197269d561b014b054671601fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Wed, 27 May 2026 06:23:24 +0200 Subject: [PATCH] telnet: serial + monitor TCP listeners negotiate options on accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both serial ports (8880/tty2, 8881/tty1) and the monitor (8888) were raw TCP sockets — telnet clients stayed in NVT line-buffered mode with local echo and CR/LF mangling. Add a small RFC 854 state machine that, on accept, offers WILL ECHO + WILL SGA + WILL BINARY + DO BINARY so the client drops into char-at-a-time, host-echo, 8-bit-clean mode for the serial endpoints. Inbound IAC sequences are filtered out; outbound 0xFF on the serial side is escaped as IAC IAC. The monitor uses a passive variant (no initial offers, just declines what the client offers) so the client keeps its local line editor and echo. All monitor write paths route through a CrlfWriter so bare \n renders as CRLF on the wire, as NVT requires. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib.rs | 1 + src/monitor.rs | 18 ++- src/telnet.rs | 378 +++++++++++++++++++++++++++++++++++++++++++++++++ src/z85c30.rs | 71 +++++++--- 4 files changed, 445 insertions(+), 23 deletions(-) create mode 100644 src/telnet.rs diff --git a/src/lib.rs b/src/lib.rs index 9488320..8661504 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod ioc; pub mod physical; pub mod ds1x86; pub mod z85c30; +pub mod telnet; pub mod monitor; pub mod locks; pub mod pit8254; diff --git a/src/monitor.rs b/src/monitor.rs index 85adad2..978b6da 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -41,13 +41,21 @@ impl Monitor { fn handle_client(stream: TcpStream, devices: Arc>>>) { // Register this connection with DevLog so log output is broadcast here. + // Wrap in CrlfWriter so bare \n from log messages renders as CRLF on the + // telnet client side. if let Some(dl) = crate::devlog::DEVLOG.get() { - let w = Arc::new(Mutex::new(BufWriter::new(stream.try_clone().unwrap()))); - dl.add_writer(w); + let w: crate::devlog::DevLogWriter = Arc::new(Mutex::new( + BufWriter::new(crate::telnet::CrlfWriter::new(stream.try_clone().unwrap())) + )); + dl.add_sink(w); } - let mut reader = BufReader::new(stream.try_clone().unwrap()); - let mut writer = BufWriter::new(stream.try_clone().unwrap()); + // Monitor is line-oriented: stay in NVT (passive telnet — strip inbound + // IAC, decline whatever the client offers, but don't initiate). The + // client keeps its local echo and line editor. Outbound goes through + // CrlfWriter so bare \n becomes CRLF on the wire as NVT requires. + let mut reader = BufReader::new(crate::telnet::TelnetReader::new_passive(stream.try_clone().unwrap())); + let mut writer = BufWriter::new(crate::telnet::CrlfWriter::new(stream.try_clone().unwrap())); let mut line = String::new(); { @@ -108,7 +116,7 @@ fn handle_client(stream: TcpStream, devices: Arc>>>) { if !is_help { if let Some(dev) = target_device { - let cmd_writer = Box::new(BufWriter::new(stream.try_clone().unwrap())); + let cmd_writer = Box::new(BufWriter::new(crate::telnet::CrlfWriter::new(stream.try_clone().unwrap()))); match dev.execute_command(cmd, args, cmd_writer) { Ok(_) => { } diff --git a/src/telnet.rs b/src/telnet.rs new file mode 100644 index 0000000..4abaf6e --- /dev/null +++ b/src/telnet.rs @@ -0,0 +1,378 @@ +//! Minimal telnet (RFC 854/855/857/858/1091/856) option negotiation for the +//! emulator's TCP-facing endpoints (serial ports and monitor). +//! +//! The serial-console endpoint wants character-at-a-time input with no client +//! local echo and no CR/LF translation, so on accept the server offers: +//! WILL ECHO, WILL SGA, WILL BINARY, DO BINARY. +//! Clients that recognize these (BSD telnet, PuTTY raw-telnet, etc.) will then +//! suppress local echo, drop line buffering and stop mangling 0x0D/0x0A. +//! +//! We don't implement the full RFC 1143 "Q method" state machine — we just +//! track the last reply we sent for each option and only emit a new reply if +//! the desired state actually changed. That's enough to avoid the WILL/DO +//! ping-pong loops the naive "always reply" approach causes. + +use std::collections::HashMap; +use std::io::{self, Read, Write}; + +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 OPT_BINARY: u8 = 0; +pub const OPT_ECHO: u8 = 1; +pub const OPT_SGA: u8 = 3; + +#[derive(Debug, Clone, Copy)] +enum State { + Data, + Iac, + Will, + Wont, + Do, + Dont, + Sb, + SbIac, +} + +pub struct TelnetFilter { + state: State, + // Last WILL/WONT we sent for each option (true = WILL, false = WONT). + we_will: HashMap, + // Last DO/DONT we sent for each option (true = DO, false = DONT). + we_do: HashMap, +} + +impl TelnetFilter { + /// Character-at-a-time mode: server will echo, suppress go-ahead, and run + /// the link 8-bit clean. Use for raw serial console endpoints. + pub fn new() -> Self { + // Pre-populate with the initial handshake state so the client's + // confirming replies don't cause us to re-send. + let mut we_will = HashMap::new(); + we_will.insert(OPT_ECHO, true); + we_will.insert(OPT_SGA, true); + we_will.insert(OPT_BINARY, true); + let mut we_do = HashMap::new(); + we_do.insert(OPT_BINARY, true); + Self { state: State::Data, we_will, we_do } + } + + /// Passive / NVT mode: don't initiate anything, decline whatever the + /// client offers. Leaves the client in default line-buffered + local-echo + /// behavior — the right fit for a line-oriented endpoint like the + /// monitor. Still strips inbound IAC sequences and replies politely to + /// client-initiated negotiation. + pub fn new_passive() -> Self { + Self { state: State::Data, we_will: HashMap::new(), we_do: HashMap::new() } + } + + /// Bytes the server should write immediately after accepting a connection + /// in character-at-a-time mode. Passive mode sends nothing. + pub const fn initial_handshake() -> [u8; 12] { + [ + IAC, WILL, OPT_ECHO, + IAC, WILL, OPT_SGA, + IAC, WILL, OPT_BINARY, + IAC, DO, OPT_BINARY, + ] + } + + /// Feed one inbound byte. Returns `Some(data)` if a real data byte fell + /// out of the state machine. Any required reply bytes are appended to + /// `out` and must be written back to the client. + pub fn feed(&mut self, byte: u8, out: &mut Vec) -> Option { + match self.state { + State::Data => { + if byte == IAC { + self.state = State::Iac; + None + } else { + Some(byte) + } + } + State::Iac => match byte { + IAC => { self.state = State::Data; Some(IAC) } + WILL => { self.state = State::Will; None } + WONT => { self.state = State::Wont; None } + DO => { self.state = State::Do; None } + DONT => { self.state = State::Dont; None } + SB => { self.state = State::Sb; None } + _ => { self.state = State::Data; None } + }, + State::Will => { + let desired = byte == OPT_BINARY; + self.maybe_reply_do(byte, desired, out); + self.state = State::Data; + None + } + State::Wont => { + self.maybe_reply_do(byte, false, out); + self.state = State::Data; + None + } + State::Do => { + let desired = matches!(byte, OPT_ECHO | OPT_SGA | OPT_BINARY); + self.maybe_reply_will(byte, desired, out); + self.state = State::Data; + None + } + State::Dont => { + self.maybe_reply_will(byte, false, out); + self.state = State::Data; + None + } + State::Sb => { + if byte == IAC { self.state = State::SbIac; } + None + } + State::SbIac => { + if byte == SE { self.state = State::Data; } else { self.state = State::Sb; } + None + } + } + } + + fn maybe_reply_will(&mut self, opt: u8, desired: bool, out: &mut Vec) { + if self.we_will.get(&opt).copied() != Some(desired) { + out.extend_from_slice(&[IAC, if desired { WILL } else { WONT }, opt]); + self.we_will.insert(opt, desired); + } + } + + fn maybe_reply_do(&mut self, opt: u8, desired: bool, out: &mut Vec) { + if self.we_do.get(&opt).copied() != Some(desired) { + out.extend_from_slice(&[IAC, if desired { DO } else { DONT }, opt]); + self.we_do.insert(opt, desired); + } + } +} + +/// Escape one outbound data byte: 0xFF must be doubled so the client doesn't +/// mistake it for the start of a telnet command. +pub fn escape_byte(byte: u8, out: &mut Vec) { + if byte == IAC { out.push(IAC); } + out.push(byte); +} + +/// Write adapter that turns bare `\n` into `\r\n` on output. NVT requires the +/// server to emit CRLF for end-of-line, but most of our text code uses bare +/// `\n` via `writeln!`. Tracks the previous byte so a `\r\n` written by the +/// caller passes through unchanged. +pub struct CrlfWriter { + inner: W, + last_was_cr: bool, +} + +impl CrlfWriter { + pub fn new(inner: W) -> Self { + Self { inner, last_was_cr: false } + } +} + +impl Write for CrlfWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut start = 0; + for (i, &b) in buf.iter().enumerate() { + if b == b'\n' && !self.last_was_cr { + if i > start { self.inner.write_all(&buf[start..i])?; } + self.inner.write_all(b"\r\n")?; + start = i + 1; + self.last_was_cr = false; + } else { + self.last_was_cr = b == b'\r'; + } + } + if start < buf.len() { self.inner.write_all(&buf[start..])?; } + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { self.inner.flush() } +} + +/// Read adapter that owns a duplex byte stream (e.g. a `TcpStream`), strips +/// inbound telnet commands, and writes any negotiation replies back through +/// the same stream. Sends the initial handshake the first time `read` is +/// called. +/// +/// Used by line-oriented endpoints (the monitor) that want to plug a +/// `BufReader` on top without thinking about IAC sequences. +pub struct TelnetReader { + inner: S, + filter: TelnetFilter, + handshake: Option<&'static [u8]>, +} + +impl TelnetReader { + /// Character-at-a-time mode. Sends the full WILL/DO handshake on first + /// read. Use for serial console endpoints. + pub fn new(inner: S) -> Self { + const HS: [u8; 12] = TelnetFilter::initial_handshake(); + Self { inner, filter: TelnetFilter::new(), handshake: Some(&HS) } + } + + /// Passive / NVT mode: no initial offer; just filter inbound IAC. Use for + /// line-oriented endpoints where the client's default local-echo + + /// line-edit behavior is exactly what we want. + pub fn new_passive(inner: S) -> Self { + Self { inner, filter: TelnetFilter::new_passive(), handshake: None } + } +} + +impl Read for TelnetReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if let Some(hs) = self.handshake.take() { + self.inner.write_all(hs)?; + } + if buf.is_empty() { return Ok(0); } + let mut raw = vec![0u8; buf.len()]; + loop { + let n = self.inner.read(&mut raw)?; + if n == 0 { return Ok(0); } + let mut replies = Vec::new(); + let mut out_idx = 0; + for &b in &raw[..n] { + if let Some(d) = self.filter.feed(b, &mut replies) { + buf[out_idx] = d; + out_idx += 1; + } + } + if !replies.is_empty() { + self.inner.write_all(&replies)?; + } + if out_idx > 0 { + return Ok(out_idx); + } + // The chunk was all telnet protocol with no data. Loop and read + // more; on a blocking socket this is what the caller expects. + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_data_passes_through() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + assert_eq!(f.feed(b'a', &mut out), Some(b'a')); + assert_eq!(f.feed(b'\r', &mut out), Some(b'\r')); + assert!(out.is_empty()); + } + + #[test] + fn iac_iac_yields_ff() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + assert_eq!(f.feed(IAC, &mut out), None); + assert_eq!(f.feed(IAC, &mut out), Some(0xFF)); + assert!(out.is_empty()); + } + + #[test] + fn client_confirming_will_echo_does_not_reply() { + // After initial_handshake we're WILL ECHO; client replies DO ECHO. + // We must not send anything in return. + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + for &b in &[IAC, DO, OPT_ECHO] { assert_eq!(f.feed(b, &mut out), None); } + assert!(out.is_empty(), "got unwanted reply: {:?}", out); + } + + #[test] + fn client_confirming_do_binary_does_not_reply() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + for &b in &[IAC, WILL, OPT_BINARY] { assert_eq!(f.feed(b, &mut out), None); } + assert!(out.is_empty(), "got unwanted reply: {:?}", out); + } + + #[test] + fn client_offers_unknown_option_we_decline() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + // Client offers TERMINAL-TYPE (24). We say DONT. + for &b in &[IAC, WILL, 24] { assert_eq!(f.feed(b, &mut out), None); } + assert_eq!(out, vec![IAC, DONT, 24]); + } + + #[test] + fn client_asks_us_to_do_unknown_option_we_decline() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + for &b in &[IAC, DO, 24] { assert_eq!(f.feed(b, &mut out), None); } + assert_eq!(out, vec![IAC, WONT, 24]); + } + + #[test] + fn subnegotiation_swallowed() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + // IAC SB IAC SE + let bytes = [IAC, SB, 24, 1, b'a', b'b', IAC, SE, b'X']; + let mut data = Vec::new(); + for &b in &bytes { + if let Some(d) = f.feed(b, &mut out) { data.push(d); } + } + assert_eq!(data, vec![b'X']); + assert!(out.is_empty()); + } + + #[test] + fn iac_inside_subnegotiation_not_terminator() { + let mut f = TelnetFilter::new(); + let mut out = Vec::new(); + // IAC SB ... IAC IAC ... IAC SE (escaped FF inside SB) + let bytes = [IAC, SB, 24, IAC, IAC, b'x', IAC, SE, b'Y']; + let mut data = Vec::new(); + for &b in &bytes { + if let Some(d) = f.feed(b, &mut out) { data.push(d); } + } + assert_eq!(data, vec![b'Y']); + } + + #[test] + fn crlf_writer_translates_bare_lf() { + let mut out = Vec::new(); + { + let mut w = CrlfWriter::new(&mut out); + w.write_all(b"hello\nworld\n").unwrap(); + } + assert_eq!(out, b"hello\r\nworld\r\n"); + } + + #[test] + fn crlf_writer_passes_through_existing_crlf() { + let mut out = Vec::new(); + { + let mut w = CrlfWriter::new(&mut out); + w.write_all(b"hello\r\nworld\r\n").unwrap(); + } + assert_eq!(out, b"hello\r\nworld\r\n"); + } + + #[test] + fn crlf_writer_handles_split_cr_lf_across_writes() { + let mut out = Vec::new(); + { + let mut w = CrlfWriter::new(&mut out); + w.write_all(b"foo\r").unwrap(); + w.write_all(b"\nbar").unwrap(); + } + assert_eq!(out, b"foo\r\nbar"); + } + + #[test] + fn escape_byte_doubles_ff() { + let mut out = Vec::new(); + escape_byte(0xFE, &mut out); + escape_byte(0xFF, &mut out); + escape_byte(0x00, &mut out); + assert_eq!(out, vec![0xFE, 0xFF, 0xFF, 0x00]); + } +} diff --git a/src/z85c30.rs b/src/z85c30.rs index ee44833..370f217 100644 --- a/src/z85c30.rs +++ b/src/z85c30.rs @@ -399,9 +399,14 @@ impl SerialBackend for UnixSocketBackend { } } +struct TcpConn { + stream: TcpStream, + telnet: crate::telnet::TelnetFilter, +} + struct TcpSocketBackend { listener: TcpListener, - stream: Mutex>, + conn: Mutex>, } impl TcpSocketBackend { @@ -410,31 +415,43 @@ impl TcpSocketBackend { listener.set_nonblocking(true).expect("Failed to set nonblocking"); Self { listener, - stream: Mutex::new(None), + conn: Mutex::new(None), } } } impl SerialBackend for TcpSocketBackend { fn send_byte(&self, byte: u8) { - let mut stream_guard = self.stream.lock(); - if let Some(ref mut stream) = *stream_guard { - if let Err(e) = stream.write_all(&[byte]) { + let mut guard = self.conn.lock(); + if let Some(ref mut c) = *guard { + let mut buf = Vec::with_capacity(2); + crate::telnet::escape_byte(byte, &mut buf); + if let Err(e) = c.stream.write_all(&buf) { if e.kind() != io::ErrorKind::WouldBlock { - *stream_guard = None; + *guard = None; } } } } fn recv_byte(&self) -> io::Result { - let mut guard = self.stream.lock(); - + let mut guard = self.conn.lock(); + if guard.is_none() { match self.listener.accept() { Ok((socket, _)) => { socket.set_nonblocking(true)?; - *guard = Some(socket); + let mut c = TcpConn { + stream: socket, + telnet: crate::telnet::TelnetFilter::new(), + }; + // Send the initial WILL/DO offers. If the write fails, + // drop the connection — the client can reconnect. + let hs = crate::telnet::TelnetFilter::initial_handshake(); + if c.stream.write_all(&hs).is_err() { + return Err(io::Error::new(io::ErrorKind::WouldBlock, "handshake failed")); + } + *guard = Some(c); } Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { return Err(io::Error::new(io::ErrorKind::WouldBlock, "No connection")); @@ -443,22 +460,40 @@ impl SerialBackend for TcpSocketBackend { } } - if let Some(ref mut stream) = *guard { + // Pull bytes one at a time through the telnet filter until either a + // real data byte falls out or the socket has nothing more to give. + loop { + let c = guard.as_mut().unwrap(); let mut buf = [0u8; 1]; - match stream.read(&mut buf) { - Ok(1) => Ok(buf[0]), - Ok(_) => { // EOF + match c.stream.read(&mut buf) { + Ok(1) => { + let mut reply = Vec::new(); + let data = c.telnet.feed(buf[0], &mut reply); + if !reply.is_empty() { + if let Err(e) = c.stream.write_all(&reply) { + if e.kind() != io::ErrorKind::WouldBlock { + *guard = None; + return Err(e); + } + } + } + if let Some(d) = data { + return Ok(d); + } + // Telnet command consumed; keep reading. + } + Ok(_) => { *guard = None; - Err(io::Error::new(io::ErrorKind::NotConnected, "EOF")) + return Err(io::Error::new(io::ErrorKind::NotConnected, "EOF")); + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + return Err(io::Error::new(io::ErrorKind::WouldBlock, "WouldBlock")); } - Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => Err(io::Error::new(io::ErrorKind::WouldBlock, "WouldBlock")), Err(e) => { *guard = None; - Err(e) + return Err(e); } } - } else { - Err(io::Error::new(io::ErrorKind::WouldBlock, "No connection")) } } }