diff options
Diffstat (limited to '')
| -rw-r--r-- | src/bin/client/connection.rs | 176 |
1 files changed, 28 insertions, 148 deletions
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 @@ | |||
| 1 | use crate::tls::TlsConnector; | 1 | //! Terminal adapter around the shared client transport. |
| 2 | //! | ||
| 3 | //! All protocol work (TLS, TOFU, HELLO, auto-login) lives in | ||
| 4 | //! `zagan_bet::client::transport`; this file only decides how to ask a | ||
| 5 | //! terminal player about a new certificate and how to print the result. | ||
| 6 | |||
| 2 | use parking_lot::Mutex; | 7 | use parking_lot::Mutex; |
| 3 | use rusqlite::Connection; | 8 | use std::error::Error; |
| 4 | use std::io::{Read, Write}; | 9 | use zagan_bet::client::transport::{self, TrustPrompt}; |
| 5 | use std::net::TcpStream; | ||
| 6 | use std::sync::mpsc; | ||
| 7 | use std::thread; | ||
| 8 | use std::time::{Duration, Instant}; | ||
| 9 | use zagan_bet::client::database; | ||
| 10 | use zagan_bet::client::ui::first_contact_lines; | 10 | use zagan_bet::client::ui::first_contact_lines; |
| 11 | use zagan_bet::version::{is_newer, parse_hello, VERSION}; | 11 | use zagan_bet::version::{is_newer, VERSION}; |
| 12 | |||
| 13 | pub use zagan_bet::client::transport::TlsStream; | ||
| 12 | 14 | ||
| 13 | static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); | 15 | static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); |
| 14 | 16 | ||
| @@ -16,162 +18,40 @@ pub fn set_unsafe_mode(unsafe_mode: bool) { | |||
| 16 | *UNSAFE_MODE.lock() = unsafe_mode; | 18 | *UNSAFE_MODE.lock() = unsafe_mode; |
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | type ConnectResult = Result<(TlsStream, Option<String>), Box<dyn std::error::Error + Send + Sync>>; | ||
| 20 | |||
| 21 | pub fn connect( | 21 | pub fn connect( |
| 22 | addr: &str, | 22 | addr: &str, |
| 23 | conn: &Connection, | 23 | conn: &rusqlite::Connection, |
| 24 | ) -> Result<(TlsStream, Option<String>), Box<dyn std::error::Error>> { | 24 | ) -> Result<(TlsStream, Option<String>), Box<dyn Error>> { |
| 25 | let stored_fingerprint = database::get_fingerprint(conn, addr); | ||
| 26 | let unsafe_mode = *UNSAFE_MODE.lock(); | 25 | let unsafe_mode = *UNSAFE_MODE.lock(); |
| 27 | 26 | ||
| 28 | let (tx, rx) = mpsc::channel::<ConnectResult>(); | 27 | // The terminal has no dialog: print the first-contact notice and trust. |
| 29 | let addr_str = addr.to_string(); | 28 | let mut trust = |prompt: &TrustPrompt| { |
| 30 | let server_name = addr_str | 29 | for line in first_contact_lines(&prompt.addr, &prompt.fingerprint) { |
| 31 | .split(':') | 30 | println!("{line}"); |
| 32 | .next() | ||
| 33 | .unwrap_or("127.0.0.1") | ||
| 34 | .to_string(); | ||
| 35 | |||
| 36 | thread::spawn(move || { | ||
| 37 | let outcome = (|| -> ConnectResult { | ||
| 38 | let tcp = TcpStream::connect(&addr_str)?; | ||
| 39 | let connector = | ||
| 40 | TlsConnector::new(&server_name, stored_fingerprint.clone(), unsafe_mode)?; | ||
| 41 | let tls_stream = connector.connect(tcp)?; | ||
| 42 | let fp = connector.observed_fingerprint(); | ||
| 43 | Ok((TlsStream { inner: tls_stream }, fp)) | ||
| 44 | })(); | ||
| 45 | let _ = tx.send(outcome); | ||
| 46 | }); | ||
| 47 | |||
| 48 | // Channel carries (stream, observed fingerprint). | ||
| 49 | let (tls, observed) = match rx.recv_timeout(Duration::from_secs(10)) { | ||
| 50 | Ok(Ok((tls, fp))) => (tls, fp), | ||
| 51 | Ok(Err(e)) => return Err(Box::new(TrustError(format!("{e}")))), | ||
| 52 | Err(mpsc::RecvTimeoutError::Timeout) => { | ||
| 53 | return Err("Connection timed out after 10 seconds".into()); | ||
| 54 | } | 31 | } |
| 55 | Err(_) => return Err("Connection attempt failed".into()), | 32 | true |
| 56 | }; | 33 | }; |
| 57 | 34 | ||
| 58 | // TOFU: persist the fingerprint on first contact. | 35 | let connected = transport::connect(addr, conn, unsafe_mode, &mut trust) |
| 59 | if !unsafe_mode { | 36 | .map_err(|error| Box::new(error) as Box<dyn Error>)?; |
| 60 | if let Some(fp) = observed.as_deref() { | ||
| 61 | if database::get_fingerprint(conn, addr).is_none() { | ||
| 62 | database::save_fingerprint(conn, addr, fp)?; | ||
| 63 | for line in first_contact_lines(addr, fp) { | ||
| 64 | println!("{}", line); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | let mut tls = tls; | ||
| 71 | tls.set_read_timeout(Some(Duration::from_secs(10)))?; | ||
| 72 | |||
| 73 | // Version lockstep: the first line after TLS is the handshake, and no | ||
| 74 | // credentials are sent until the server accepts it. | ||
| 75 | let start = Instant::now(); | ||
| 76 | tls.write_line(&format!("HELLO {VERSION}"))?; | ||
| 77 | let mut response = String::new(); | ||
| 78 | if tls.read_line(&mut response)? == 0 { | ||
| 79 | return Err("server closed the connection during HELLO".into()); | ||
| 80 | } | ||
| 81 | let elapsed = start.elapsed(); | ||
| 82 | let response = response.trim(); | ||
| 83 | |||
| 84 | if let Some(message) = response.strip_prefix("ERROR") { | ||
| 85 | return Err(message.trim().to_string().into()); | ||
| 86 | } | ||
| 87 | let Some(hello) = parse_hello(response) else { | ||
| 88 | return Err(format!("unexpected HELLO reply: {response}").into()); | ||
| 89 | }; | ||
| 90 | 37 | ||
| 91 | println!( | 38 | println!( |
| 92 | "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", | 39 | "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", |
| 93 | addr, | 40 | addr, |
| 94 | elapsed.as_secs_f64() * 1000.0 | 41 | connected.elapsed.as_secs_f64() * 1000.0 |
| 95 | ); | 42 | ); |
| 96 | if is_newer(&hello.latest, VERSION) { | 43 | if is_newer(&connected.latest, VERSION) { |
| 97 | println!( | 44 | println!( |
| 98 | "A newer version is available: {} ({})", | 45 | "A newer version is available: {} ({})", |
| 99 | hello.latest, hello.url | 46 | connected.latest, connected.url |
| 100 | ); | 47 | ); |
| 101 | } | 48 | } |
| 102 | 49 | if let Some(user) = &connected.user { | |
| 103 | // Credentials are stored per server identity, so any address presenting | 50 | println!("Authenticated as {user}"); |
| 104 | // this certificate key can log in with the same stored account. | ||
| 105 | let identity = observed | ||
| 106 | .clone() | ||
| 107 | .or_else(|| database::get_fingerprint(conn, addr)); | ||
| 108 | if let Some(identity) = identity { | ||
| 109 | if let Some((username, password)) = database::get_credentials(conn, &identity) { | ||
| 110 | let auth_msg = format!("AUTH {} {}", username, password); | ||
| 111 | tls.write_line(&auth_msg)?; | ||
| 112 | let mut auth_response = String::new(); | ||
| 113 | tls.read_line(&mut auth_response)?; | ||
| 114 | if auth_response.trim().starts_with("OK") { | ||
| 115 | println!("Authenticated as {}", username); | ||
| 116 | return Ok((tls, Some(username))); | ||
| 117 | } | ||
| 118 | println!("Authentication failed: {}", auth_response.trim()); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | Ok((tls, None)) | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Wrapper so TLS handshake failures surface as errors with their message | ||
| 125 | /// intact (rustls errors already carry the mismatch details). | ||
| 126 | struct TrustError(String); | ||
| 127 | |||
| 128 | impl std::fmt::Display for TrustError { | ||
| 129 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 130 | write!(f, "{}", self.0) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | impl std::fmt::Debug for TrustError { | ||
| 134 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 135 | write!(f, "{self}") | ||
| 136 | } | ||
| 137 | } | ||
| 138 | impl std::error::Error for TrustError {} | ||
| 139 | |||
| 140 | pub struct TlsStream { | ||
| 141 | inner: crate::tls::TlsStream, | ||
| 142 | } | ||
| 143 | |||
| 144 | impl TlsStream { | ||
| 145 | pub fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> { | ||
| 146 | self.inner.read_line(buf) | ||
| 147 | } | ||
| 148 | |||
| 149 | pub fn write_line(&mut self, msg: &str) -> std::io::Result<()> { | ||
| 150 | self.inner.write_line(msg) | ||
| 151 | } | 51 | } |
| 152 | 52 | if let Some(error) = &connected.auth_error { | |
| 153 | /// One line if the socket already has one, without blocking. | 53 | println!("Authentication failed: {error}"); |
| 154 | pub fn read_available_line(&mut self) -> std::io::Result<Option<String>> { | ||
| 155 | self.inner.read_available_line() | ||
| 156 | } | 54 | } |
| 157 | 55 | ||
| 158 | pub fn set_read_timeout(&self, duration: Option<std::time::Duration>) -> std::io::Result<()> { | 56 | Ok((connected.stream, connected.user)) |
| 159 | self.inner.set_read_timeout(duration) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | impl Read for TlsStream { | ||
| 164 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { | ||
| 165 | self.inner.read(buf) | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | impl Write for TlsStream { | ||
| 170 | fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { | ||
| 171 | self.inner.write(buf) | ||
| 172 | } | ||
| 173 | |||
| 174 | fn flush(&mut self) -> std::io::Result<()> { | ||
| 175 | self.inner.flush() | ||
| 176 | } | ||
| 177 | } | 57 | } |
