This commit is contained in:
2026-01-06 21:16:48 +02:00
commit 116807ca04
6 changed files with 818 additions and 0 deletions

38
src/cli_args.rs Normal file
View File

@@ -0,0 +1,38 @@
use clap::Parser;
#[derive(Debug, Parser)]
pub struct CliArgs {
/// force IPv4
#[arg(short='4', long=None, default_value_t=false, conflicts_with = "force_ipv6")]
pub force_ipv4: bool,
/// force IPv6
#[arg(short='6', long=None, default_value_t=false, conflicts_with = "force_ipv4")]
pub force_ipv6: bool,
/// use 8-bit data path (dont strip high bit)
#[arg(short='8', long=None, default_value_t=false)]
pub use_8bit_data_path: bool,
/// disable escape character (ignored)
#[arg(short='E', long=None, default_value_t=false)]
pub disable_esc_char: bool,
/// set escape character (ignored)
#[arg(short='e', long=None, default_value="^]")]
pub escape_char: String,
/// record session to file (net trace)
#[arg(short='n', long=None)]
pub net_trace_f: Option<camino::Utf8PathBuf>,
/// enable socket debugging (ignored)
#[arg(short='d', long=None, default_value_t=false)]
pub socket_debug: bool,
/// socket timeout
#[arg(short='t', long=None, default_value_t=5)]
pub socket_timeout: u64,
#[clap(default_value = "0.0.0.0")]
pub host: String,
#[clap(default_value_t = 23)]
pub port: u16
}

351
src/main.rs Normal file
View File

@@ -0,0 +1,351 @@
use std::{fs::File, io::{Read, Seek, SeekFrom, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, path::PathBuf, sync::Mutex, time::Duration};
use camino::Utf8PathBuf;
use clap::Parser;
use lazy_static::lazy_static;
use nix::sys::termios::{self, Termios};
mod cli_args;
lazy_static!(
static ref SETTINGS: Mutex<Settings> = Mutex::new(Settings::default());
);
mod cmd {
#![allow(dead_code)]
pub const IAC: u8 = 255;
pub const DO: u8 = 253;
pub const DONT: u8 = 254;
pub const WILL: u8 = 251;
pub const WONT: u8 = 252;
pub const SB: u8 = 250;
pub const SE: u8 = 240;
pub const BINARY: u8 = 0; // Dont strip the 8th bit
pub const ECHO: u8 = 1; // Echo your keystrokes
pub const SUPPRESS_GO_AHEAD: u8 = 3; // SGA; suppress “go ahead” protocol
pub const STATUS: u8 = 5; // Status request
pub const TIMING_MARK: u8 = 6; // Timing mark
pub const TERMINAL_TYPE: u8 = 24; // Terminal type subnegotiation
pub const WINDOW_SIZE: u8 = 31; //NAWS: send terminal width/height
pub const TERMINAL_SPEED: u8 = 32; //Terminal speed
pub const REMOTE_FLOW_CONTROL: u8 = 33; // RFC 1081 flow control
pub const LINEMODE: u8 = 34; // Line mode options
pub const ENVIRONMENT_VARS: u8 = 36; // Environment variable exchange
}
#[derive(Clone, Copy, Debug)]
enum State {
Data,
Iac,
IacCmd(u8),
Subneg,
SubnegIac,
}
#[derive(Debug, Default, Clone)]
struct Settings {
pub echo: bool,
pub strip8b: bool,
}
static NET_TRACE_H: Mutex<Option<File>> = Mutex::new(None);
fn write_net_trace(bytes: &[u8]) -> anyhow::Result<()> {
let h = NET_TRACE_H.lock().unwrap();
if let Some(mut h) = h.as_ref() {
h.write_all(bytes)?;
}
Ok(())
}
fn write_net_trace_backspace() -> anyhow::Result<()> {
let h_guard = NET_TRACE_H.lock().unwrap();
if let Some(mut h) = h_guard.as_ref() {
let len = h.metadata()?.len();
if len == 0 {
return Ok(());
}
h.set_len(len - 1)?;
h.seek(SeekFrom::End(0))?;
}
Ok(())
}
fn main_loop(cli: &cli_args::CliArgs) -> anyhow::Result<()> {
let mut addrs = (cli.host.clone(), cli.port).to_socket_addrs()?.collect::<Vec<std::net::SocketAddr>>();
if let Some(f) = &cli.net_trace_f {
let h = std::fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(f)?;
*NET_TRACE_H.lock().unwrap() = Some(h);
}
{
let mut settings = SETTINGS.lock().unwrap();
settings.strip8b = !cli.use_8bit_data_path;
}
if cli.force_ipv4 {
addrs = addrs
.into_iter()
.filter(|a| matches!(a, std::net::SocketAddr::V4(_)))
.collect::<Vec<std::net::SocketAddr>>();
}
if cli.force_ipv6 {
addrs = addrs
.into_iter()
.filter(|a| matches!(a, std::net::SocketAddr::V6(_)))
.collect::<Vec<std::net::SocketAddr>>();
}
if addrs.is_empty() {
println!("Server lookup failure: {}:telnet, Name or service not known", cli.host);
}
let mut stream = None;
for addr in addrs {
println!("Connecting to {}:{} ...\r", cli.host, cli.port);
match TcpStream::connect(addr) {
Ok(_stream) => {
stream = Some(_stream);
break;
}
Err(e) => {
eprintln!("Error: Failed to connect to {addr}: {e}");
eprintln!("Trying next address");
}
}
}
let Some(mut stream) = stream else {
eprintln!("Error: Failed to connect to host {}, bailing!", cli.host);
return Ok(())
};
stream.set_read_timeout(Some(Duration::from_secs(cli.socket_timeout)))?;
stream.set_write_timeout(Some(Duration::from_secs(cli.socket_timeout)))?;
let mut buffer = [0u8; 1024];
let mut state = State::Data;
let mut write_stream = stream.try_clone()?;
write_stream.write_all(&[cmd::IAC, cmd::WILL, cmd::ECHO])?;
std::thread::spawn(move || {
let should_echo = {SETTINGS.lock().unwrap().echo};
let mut stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut buf = [0u8; 1];
let mut real_buf = Vec::new();
loop {
let n = match stdin.read(&mut buf) {
Ok(n) => n,
Err(_) => {
let _ = write_stream.shutdown(Shutdown::Both);
break;
},
};
if n == 0 { continue; }
match buf[0] {
3 => {
let _ = write_stream.shutdown(Shutdown::Both);
break;
},
8 | 127 => {
if !real_buf.is_empty() {
real_buf.pop();
write_net_trace_backspace().unwrap();
if should_echo {
let _ = stdout.write_all(b"\x08 \x08");
let _ = stdout.flush();
}
}
let _ = write_stream.write_all(&buf);
}
c => {
write_net_trace(&[c]).unwrap();
if should_echo {
let _ = stdout.write(&[c]);
let _ = stdout.flush();
}
real_buf.push(buf[0]);
if write_stream.write_all(&[c]).is_err() {
let _ = write_stream.shutdown(Shutdown::Both);
break;
}
}
}
}
});
loop {
let settings = {
SETTINGS.lock().unwrap().clone()
};
let n = match stream.read(&mut buffer) {
Ok(0) => break,
Ok(n) => n,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
Err(e) => return Err(e.into()),
};
let mut buf = Vec::from(buffer);
buf.resize(n, 0);
buf.reverse();
while let Some(mut byte) = buf.pop() {
// println!("{:?}", state);
// println!("{}", byte);
match state {
State::Data => {
if byte == cmd::IAC {
state = State::Iac;
} else {
if settings.strip8b {
byte = byte & 0x7F;
}
print!("{}", byte as char);
std::io::stdout().flush().unwrap();
write_net_trace(&[byte])?;
}
}
State::Iac => match byte {
cmd::IAC => {
print!("{}", cmd::IAC as char);
std::io::stdout().flush().unwrap();
state = State::Data;
}
cmd::DO | cmd::DONT | cmd::WILL | cmd::WONT => {
state = State::IacCmd(byte);
}
cmd::SB => {
state = State::Subneg;
}
_ => state = State::Data,
},
State::IacCmd(cmd) => {
match cmd {
cmd::DO => {
match byte {
cmd::ECHO => {
stream.write_all(&[cmd::IAC, cmd::WILL, cmd::ECHO])?;
SETTINGS.lock().unwrap().echo = true;
},
cmd::BINARY => {
stream.write_all(&[cmd::IAC, cmd::WILL, cmd::BINARY])?;
if !cli.use_8bit_data_path {
SETTINGS.lock().unwrap().strip8b = false;
}
},
c => stream.write_all(&[cmd::IAC, cmd::WONT, c])?,
}
},
cmd::WILL => {
match byte {
cmd::ECHO => {
stream.write_all(&[cmd::IAC, cmd::DO, cmd::ECHO])?;
SETTINGS.lock().unwrap().echo = false;
}
cmd::BINARY => {
stream.write_all(&[cmd::IAC, cmd::DO, cmd::BINARY])?;
if !cli.use_8bit_data_path {
SETTINGS.lock().unwrap().strip8b = false;
}
},
c => stream.write_all(&[cmd::IAC, cmd::DONT, c])?,
}
},
cmd::WONT => {
match byte {
cmd::ECHO => {
stream.write_all(&[cmd::IAC, cmd::DONT, cmd::ECHO])?;
SETTINGS.lock().unwrap().echo = true;
}
cmd::BINARY => {
stream.write_all(&[cmd::IAC, cmd::DONT, cmd::BINARY])?;
if !cli.use_8bit_data_path {
SETTINGS.lock().unwrap().strip8b = true;
}
},
c => stream.write_all(&[cmd::IAC, cmd::DONT, c])?,
}
},
cmd::DONT => {
match byte {
cmd::ECHO => {
stream.write_all(&[cmd::IAC, cmd::WONT, cmd::ECHO])?;
SETTINGS.lock().unwrap().echo = false;
},
cmd::BINARY => {
stream.write_all(&[cmd::IAC, cmd::WONT, cmd::BINARY])?;
if !cli.use_8bit_data_path {
SETTINGS.lock().unwrap().strip8b = true;
}
},
c => stream.write_all(&[cmd::IAC, cmd::WONT, c])?,
}
},
_ => print!("Unknown cmd: '{byte}'"),
};
state = State::Data;
}
State::Subneg => {
if byte == cmd::IAC {
state = State::SubnegIac;
}
}
State::SubnegIac => {
if byte == cmd::SE {
state = State::Data;
} else if byte == cmd::IAC {
state = State::Subneg;
} else {
state = State::Subneg;
}
}
}
}
}
Ok(())
}
fn enable_raw_mode() -> anyhow::Result<Termios> {
let stdin = std::io::stdin();
let orig_term = termios::tcgetattr(&stdin)
.map_err(|e| anyhow::anyhow!("tcgetattr failed: {}", e))?;
let mut raw_term = orig_term.clone();
termios::cfmakeraw(&mut raw_term);
termios::tcsetattr(&stdin, nix::sys::termios::SetArg::TCSANOW, &raw_term)
.map_err(|e| anyhow::anyhow!("tcsetattr failed: {}", e))?;
Ok(orig_term)
}
fn restore_terminal(orig_term: &Termios) -> anyhow::Result<()> {
let stdin = std::io::stdin();
print!("\r");
nix::sys::termios::tcsetattr(&stdin, nix::sys::termios::SetArg::TCSANOW, &orig_term)
.map_err(|e| anyhow::anyhow!("restore failed: {}", e))?;
Ok(())
}
fn main() -> anyhow::Result<()> {
let cli = cli_args::CliArgs::parse();
let orig_term = enable_raw_mode()?;
if let Err(e) = main_loop(&cli) {
let _ = restore_terminal(&orig_term);
eprintln!("An error happened: {e}");
return Ok(())
}
let _ = restore_terminal(&orig_term);
Ok(())
}