summaryrefslogtreecommitdiffstats
path: root/src/bin/client/connection.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 18:14:16 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 18:14:16 +0100
commit55032d2de36b08692d39cc94c4f2da317b3e2eb7 (patch)
tree2ace8ef541e1ec211ff703c5050027c13ba56759 /src/bin/client/connection.rs
parentv4.0.3: lobby and history screens behind commands (diff)
downloadzagan-bet-55032d2de36b08692d39cc94c4f2da317b3e2eb7.tar.gz
zagan-bet-55032d2de36b08692d39cc94c4f2da317b3e2eb7.zip
v4.0.4: move the client transport and TOFU into the library
Diffstat (limited to 'src/bin/client/connection.rs')
-rw-r--r--src/bin/client/connection.rs176
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 @@
1use 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
2use parking_lot::Mutex; 7use parking_lot::Mutex;
3use rusqlite::Connection; 8use std::error::Error;
4use std::io::{Read, Write}; 9use zagan_bet::client::transport::{self, TrustPrompt};
5use std::net::TcpStream;
6use std::sync::mpsc;
7use std::thread;
8use std::time::{Duration, Instant};
9use zagan_bet::client::database;
10use zagan_bet::client::ui::first_contact_lines; 10use zagan_bet::client::ui::first_contact_lines;
11use zagan_bet::version::{is_newer, parse_hello, VERSION}; 11use zagan_bet::version::{is_newer, VERSION};
12
13pub use zagan_bet::client::transport::TlsStream;
12 14
13static UNSAFE_MODE: Mutex<bool> = Mutex::new(false); 15static 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
19type ConnectResult = Result<(TlsStream, Option<String>), Box<dyn std::error::Error + Send + Sync>>;
20
21pub fn connect( 21pub 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).
126struct TrustError(String);
127
128impl std::fmt::Display for TrustError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 write!(f, "{}", self.0)
131 }
132}
133impl std::fmt::Debug for TrustError {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 write!(f, "{self}")
136 }
137}
138impl std::error::Error for TrustError {}
139
140pub struct TlsStream {
141 inner: crate::tls::TlsStream,
142}
143
144impl 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
163impl Read for TlsStream {
164 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
165 self.inner.read(buf)
166 }
167}
168
169impl 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}