From 55032d2de36b08692d39cc94c4f2da317b3e2eb7 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 18:14:16 +0100 Subject: v4.0.4: move the client transport and TOFU into the library --- src/bin/client/connection.rs | 176 +++++++------------------------------------ 1 file changed, 28 insertions(+), 148 deletions(-) (limited to 'src/bin/client/connection.rs') diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index 6bc9b09..f88f62b 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs @@ -1,14 +1,16 @@ -use crate::tls::TlsConnector; +//! Terminal adapter around the shared client transport. +//! +//! All protocol work (TLS, TOFU, HELLO, auto-login) lives in +//! `zagan_bet::client::transport`; this file only decides how to ask a +//! terminal player about a new certificate and how to print the result. + use parking_lot::Mutex; -use rusqlite::Connection; -use std::io::{Read, Write}; -use std::net::TcpStream; -use std::sync::mpsc; -use std::thread; -use std::time::{Duration, Instant}; -use zagan_bet::client::database; +use std::error::Error; +use zagan_bet::client::transport::{self, TrustPrompt}; use zagan_bet::client::ui::first_contact_lines; -use zagan_bet::version::{is_newer, parse_hello, VERSION}; +use zagan_bet::version::{is_newer, VERSION}; + +pub use zagan_bet::client::transport::TlsStream; static UNSAFE_MODE: Mutex = Mutex::new(false); @@ -16,162 +18,40 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { *UNSAFE_MODE.lock() = unsafe_mode; } -type ConnectResult = Result<(TlsStream, Option), Box>; - pub fn connect( addr: &str, - conn: &Connection, -) -> Result<(TlsStream, Option), Box> { - let stored_fingerprint = database::get_fingerprint(conn, addr); + conn: &rusqlite::Connection, +) -> Result<(TlsStream, Option), Box> { let unsafe_mode = *UNSAFE_MODE.lock(); - let (tx, rx) = mpsc::channel::(); - let addr_str = addr.to_string(); - let server_name = addr_str - .split(':') - .next() - .unwrap_or("127.0.0.1") - .to_string(); - - thread::spawn(move || { - let outcome = (|| -> ConnectResult { - let tcp = TcpStream::connect(&addr_str)?; - let connector = - TlsConnector::new(&server_name, stored_fingerprint.clone(), unsafe_mode)?; - let tls_stream = connector.connect(tcp)?; - let fp = connector.observed_fingerprint(); - Ok((TlsStream { inner: tls_stream }, fp)) - })(); - let _ = tx.send(outcome); - }); - - // Channel carries (stream, observed fingerprint). - let (tls, observed) = match rx.recv_timeout(Duration::from_secs(10)) { - Ok(Ok((tls, fp))) => (tls, fp), - Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), - Err(mpsc::RecvTimeoutError::Timeout) => { - return Err("Connection timed out after 10 seconds".into()); + // The terminal has no dialog: print the first-contact notice and trust. + let mut trust = |prompt: &TrustPrompt| { + for line in first_contact_lines(&prompt.addr, &prompt.fingerprint) { + println!("{line}"); } - Err(_) => return Err("Connection attempt failed".into()), + true }; - // TOFU: persist the fingerprint on first contact. - if !unsafe_mode { - if let Some(fp) = observed.as_deref() { - if database::get_fingerprint(conn, addr).is_none() { - database::save_fingerprint(conn, addr, fp)?; - for line in first_contact_lines(addr, fp) { - println!("{}", line); - } - } - } - } - - let mut tls = tls; - tls.set_read_timeout(Some(Duration::from_secs(10)))?; - - // Version lockstep: the first line after TLS is the handshake, and no - // credentials are sent until the server accepts it. - let start = Instant::now(); - tls.write_line(&format!("HELLO {VERSION}"))?; - let mut response = String::new(); - if tls.read_line(&mut response)? == 0 { - return Err("server closed the connection during HELLO".into()); - } - let elapsed = start.elapsed(); - let response = response.trim(); - - if let Some(message) = response.strip_prefix("ERROR") { - return Err(message.trim().to_string().into()); - } - let Some(hello) = parse_hello(response) else { - return Err(format!("unexpected HELLO reply: {response}").into()); - }; + let connected = transport::connect(addr, conn, unsafe_mode, &mut trust) + .map_err(|error| Box::new(error) as Box)?; println!( "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", addr, - elapsed.as_secs_f64() * 1000.0 + connected.elapsed.as_secs_f64() * 1000.0 ); - if is_newer(&hello.latest, VERSION) { + if is_newer(&connected.latest, VERSION) { println!( "A newer version is available: {} ({})", - hello.latest, hello.url + connected.latest, connected.url ); } - - // Credentials are stored per server identity, so any address presenting - // this certificate key can log in with the same stored account. - let identity = observed - .clone() - .or_else(|| database::get_fingerprint(conn, addr)); - if let Some(identity) = identity { - if let Some((username, password)) = database::get_credentials(conn, &identity) { - let auth_msg = format!("AUTH {} {}", username, password); - tls.write_line(&auth_msg)?; - let mut auth_response = String::new(); - tls.read_line(&mut auth_response)?; - if auth_response.trim().starts_with("OK") { - println!("Authenticated as {}", username); - return Ok((tls, Some(username))); - } - println!("Authentication failed: {}", auth_response.trim()); - } - } - Ok((tls, None)) -} - -/// Wrapper so TLS handshake failures surface as errors with their message -/// intact (rustls errors already carry the mismatch details). -struct TrustError(String); - -impl std::fmt::Display for TrustError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} -impl std::fmt::Debug for TrustError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self}") - } -} -impl std::error::Error for TrustError {} - -pub struct TlsStream { - inner: crate::tls::TlsStream, -} - -impl TlsStream { - pub fn read_line(&mut self, buf: &mut String) -> std::io::Result { - self.inner.read_line(buf) - } - - pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { - self.inner.write_line(msg) + if let Some(user) = &connected.user { + println!("Authenticated as {user}"); } - - /// One line if the socket already has one, without blocking. - pub fn read_available_line(&mut self) -> std::io::Result> { - self.inner.read_available_line() + if let Some(error) = &connected.auth_error { + println!("Authentication failed: {error}"); } - pub fn set_read_timeout(&self, duration: Option) -> std::io::Result<()> { - self.inner.set_read_timeout(duration) - } -} - -impl Read for TlsStream { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - self.inner.read(buf) - } -} - -impl Write for TlsStream { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.inner.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.inner.flush() - } + Ok((connected.stream, connected.user)) } -- cgit v1.2.3