parent
f5382ffaf0
commit
13f296d33f
10 changed files with 997 additions and 79 deletions
@ -1,14 +1,20 @@ |
|||||||
[workspace] |
[workspace] |
||||||
resolver = "2" |
resolver = "2" |
||||||
members = [ |
members = ["client", "common", "server"] |
||||||
"client", |
|
||||||
"common", |
# Enable a small amount of optimization in the dev profile. |
||||||
"server", |
[profile.dev] |
||||||
] |
opt-level = 1 |
||||||
|
|
||||||
|
# Enable a large amount of optimization in the dev profile for dependencies. |
||||||
|
[profile.dev.package."*"] |
||||||
|
opt-level = 3 |
||||||
|
|
||||||
[workspace.dependencies] |
[workspace.dependencies] |
||||||
# TODO: Disable default features and enable what's needed in specific crates. |
# TODO: Disable default features and enable what's needed in specific crates. |
||||||
bevy = "0.15.*" |
bevy = "0.15.*" |
||||||
|
bevy_replicon = "0.29.*" |
||||||
|
|
||||||
aeronet = "0.11.*" |
aeronet = "0.11.*" |
||||||
aeronet_webtransport = "0.11.*" |
aeronet_webtransport = "0.11.*" |
||||||
|
aeronet_replicon = "0.11.*" |
||||||
|
@ -0,0 +1,144 @@ |
|||||||
|
use { |
||||||
|
crate::server_address::ServerAddress, |
||||||
|
aeronet_replicon::client::AeronetRepliconClient, |
||||||
|
aeronet_webtransport::{ |
||||||
|
cert::{self, CertificateHash}, |
||||||
|
client::{ClientConfig, WebTransportClient}, |
||||||
|
}, |
||||||
|
bevy::prelude::*, |
||||||
|
bevy_egui::{egui, EguiContexts}, |
||||||
|
bevy_replicon::prelude::*, |
||||||
|
}; |
||||||
|
|
||||||
|
pub struct ConnectionUiPlugin; |
||||||
|
|
||||||
|
impl Plugin for ConnectionUiPlugin { |
||||||
|
fn build(&self, app: &mut App) { |
||||||
|
app.init_resource::<ConnectionUiState>().add_systems( |
||||||
|
Update, |
||||||
|
display_connection_ui.run_if(not(client_connected.or(client_connecting))), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Default, Resource)] |
||||||
|
struct ConnectionUiState { |
||||||
|
reveal: bool, |
||||||
|
address: String, |
||||||
|
cert_hash: String, |
||||||
|
} |
||||||
|
|
||||||
|
fn display_connection_ui( |
||||||
|
commands: Commands, |
||||||
|
mut contexts: EguiContexts, |
||||||
|
mut ui_state: ResMut<ConnectionUiState>, |
||||||
|
) { |
||||||
|
let default_address_str = format!("[::1]:{}", common::WEB_TRANSPORT_PORT); |
||||||
|
let default_address = default_address_str.parse().unwrap(); |
||||||
|
|
||||||
|
egui::Window::new("Connect to Server") |
||||||
|
.anchor(egui::Align2::CENTER_CENTER, [0., 0.]) |
||||||
|
.collapsible(false) |
||||||
|
.resizable(false) |
||||||
|
.show(contexts.ctx_mut(), |ui| { |
||||||
|
let password = !ui_state.reveal; |
||||||
|
egui::Grid::new("connection_info_grid") |
||||||
|
.num_columns(2) |
||||||
|
.show(ui, |ui| { |
||||||
|
ui.label("Server Address:"); |
||||||
|
egui::TextEdit::singleline(&mut ui_state.address) |
||||||
|
.hint_text(&default_address_str) |
||||||
|
.password(password) |
||||||
|
.show(ui); |
||||||
|
ui.end_row(); |
||||||
|
|
||||||
|
ui.label("Certificate Hash:"); |
||||||
|
egui::TextEdit::singleline(&mut ui_state.cert_hash) |
||||||
|
.password(password) |
||||||
|
.show(ui); |
||||||
|
ui.end_row(); |
||||||
|
}); |
||||||
|
|
||||||
|
ui.horizontal(|ui| { |
||||||
|
ui.checkbox(&mut ui_state.reveal, "Reveal Address / Hash"); |
||||||
|
|
||||||
|
let address = if ui_state.address.is_empty() { |
||||||
|
Some(default_address) |
||||||
|
} else if let Ok(address) = ui_state.address.parse::<ServerAddress>() { |
||||||
|
Some(address.with_default_port(common::WEB_TRANSPORT_PORT)) |
||||||
|
} else { |
||||||
|
None |
||||||
|
}; |
||||||
|
|
||||||
|
let (cert_hash, cert_valid) = if ui_state.cert_hash.is_empty() { |
||||||
|
(None, true) |
||||||
|
} else if let Ok(cert_hash) = cert::hash_from_b64(&ui_state.cert_hash) { |
||||||
|
(Some(cert_hash), true) |
||||||
|
} else { |
||||||
|
(None, false) |
||||||
|
}; |
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { |
||||||
|
ui.horizontal(|ui| { |
||||||
|
ui.add_enabled_ui(address.is_some() && cert_valid, |ui| { |
||||||
|
if ui.button(" Connect ").clicked() { |
||||||
|
connect_to_server(commands, &address.unwrap(), cert_hash); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
fn connect_to_server( |
||||||
|
mut commands: Commands, |
||||||
|
address: &ServerAddress, |
||||||
|
cert_hash: Option<CertificateHash>, |
||||||
|
) { |
||||||
|
// When using a self-signed cert from the server, its hash needs to be provided to the
|
||||||
|
// client for it to accept it. Works on native and WASM. On WASM we have the option to
|
||||||
|
// just accept the HTTPS server's certificate. On native we could theoretically disable
|
||||||
|
// cert checking entirely, but that kind of defeats the point.
|
||||||
|
|
||||||
|
// TODO: Add support for SRV records to `ServerAddress`.
|
||||||
|
// TODO: (WASM) Auto-connect to address of the server hosting the webpage.
|
||||||
|
// FIXME: (WASM) `serverCertificateHashes` not accepted in Firefox. https://github.com/BiagioFesta/wtransport/issues/241
|
||||||
|
let config = web_transport_config(cert_hash); |
||||||
|
let target = format!("https://{address}"); |
||||||
|
commands |
||||||
|
.spawn((Name::new(address.to_string()), AeronetRepliconClient)) |
||||||
|
.queue(WebTransportClient::connect(config, target)); |
||||||
|
} |
||||||
|
|
||||||
|
#[cfg(not(target_family = "wasm"))] |
||||||
|
fn web_transport_config(cert_hash: Option<CertificateHash>) -> ClientConfig { |
||||||
|
use {aeronet_webtransport::wtransport::tls::Sha256Digest, core::time::Duration}; |
||||||
|
let server_certificate_hashes = cert_hash |
||||||
|
.map(Sha256Digest::new) |
||||||
|
.into_iter() |
||||||
|
.collect::<Vec<_>>(); |
||||||
|
ClientConfig::builder() |
||||||
|
.with_bind_default() |
||||||
|
.with_server_certificate_hashes(server_certificate_hashes) |
||||||
|
.keep_alive_interval(Some(Duration::from_secs(1))) |
||||||
|
.max_idle_timeout(Some(Duration::from_secs(5))) |
||||||
|
.unwrap() |
||||||
|
.build() |
||||||
|
} |
||||||
|
|
||||||
|
#[cfg(target_family = "wasm")] |
||||||
|
fn web_transport_config(cert_hash: Option<CertificateHash>) -> ClientConfig { |
||||||
|
use aeronet_webtransport::xwt_web_sys::{CertificateHash, HashAlgorithm}; |
||||||
|
let server_certificate_hashes = cert_hash |
||||||
|
.map(|hash| CertificateHash { |
||||||
|
algorithm: HashAlgorithm::Sha256, |
||||||
|
value: Vec::from(hash), |
||||||
|
}) |
||||||
|
.into_iter() |
||||||
|
.collect::<Vec<_>>(); |
||||||
|
ClientConfig { |
||||||
|
server_certificate_hashes, |
||||||
|
..Default::default() |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,236 @@ |
|||||||
|
use std::net::{Ipv4Addr, Ipv6Addr}; |
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)] |
||||||
|
pub struct ServerAddress { |
||||||
|
host: ServerHost, |
||||||
|
port: Option<u16>, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)] |
||||||
|
pub enum ServerHost { |
||||||
|
Hostname(String), |
||||||
|
Ipv4Addr(Ipv4Addr), |
||||||
|
Ipv6Addr(Ipv6Addr), |
||||||
|
} |
||||||
|
|
||||||
|
impl ServerAddress { |
||||||
|
pub fn host(&self) -> &ServerHost { |
||||||
|
&self.host |
||||||
|
} |
||||||
|
|
||||||
|
pub fn port(&self) -> Option<u16> { |
||||||
|
self.port |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns a new `ServerAddress` with the specified port if
|
||||||
|
/// this instance did not already have an explicit port set.
|
||||||
|
pub fn with_default_port(self, default_port: u16) -> Self { |
||||||
|
Self { |
||||||
|
port: self.port.or(Some(default_port)), |
||||||
|
..self |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)] |
||||||
|
pub enum ParseError { |
||||||
|
/// The given string (or its host or port) was empty.
|
||||||
|
Empty, |
||||||
|
/// The given string was not a valid hostname.
|
||||||
|
Hostname, |
||||||
|
/// The given string looked like an IPv4 address (`#.#.#.#`) but turned out to be invalid.
|
||||||
|
Ipv4, |
||||||
|
/// The given string started with a '[' but did not contain a valid IPv6 address.
|
||||||
|
Ipv6, |
||||||
|
/// The given port was invalid or out of range.
|
||||||
|
Port, |
||||||
|
} |
||||||
|
|
||||||
|
impl std::str::FromStr for ServerAddress { |
||||||
|
type Err = ParseError; |
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> { |
||||||
|
let colon = match (s.rfind(':'), s.rfind(']')) { |
||||||
|
// This doesn't match colons within IPv6 brackets (`[::1]:80`).
|
||||||
|
(Some(colon), Some(bracket)) if colon > bracket => Some(colon), |
||||||
|
(Some(colon), None) => Some(colon), |
||||||
|
_ => None, |
||||||
|
}; |
||||||
|
|
||||||
|
let host_slice = colon.map(|i| &s[..i]).unwrap_or(s); |
||||||
|
let host = host_slice.parse()?; |
||||||
|
|
||||||
|
if colon == Some(s.len() - 1) { |
||||||
|
return Err(ParseError::Empty); |
||||||
|
} |
||||||
|
|
||||||
|
let port_slice = colon.map(|i| &s[(i + 1)..]); |
||||||
|
let port_result = port_slice.map(str::parse).transpose(); |
||||||
|
let port = port_result.map_err(|_| ParseError::Port)?; |
||||||
|
|
||||||
|
Ok(Self { host, port }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl std::str::FromStr for ServerHost { |
||||||
|
type Err = ParseError; |
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> { |
||||||
|
if s.is_empty() { |
||||||
|
Err(ParseError::Empty) |
||||||
|
} else if s.chars().nth(0).unwrap() == '[' { |
||||||
|
if s.chars().last().unwrap() == ']' { |
||||||
|
let result = s[1..(s.len() - 1)].parse(); |
||||||
|
result.map(Self::Ipv6Addr).map_err(|_| ParseError::Ipv6) |
||||||
|
} else { |
||||||
|
Err(ParseError::Ipv6) |
||||||
|
} |
||||||
|
} else if looks_like_ipv4(s) { |
||||||
|
s.parse().map(Self::Ipv4Addr).map_err(|_| ParseError::Ipv4) |
||||||
|
} else if hostname_validator::is_valid(s) { |
||||||
|
Ok(Self::Hostname(s.to_string())) |
||||||
|
} else { |
||||||
|
Err(ParseError::Hostname) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl std::fmt::Display for ServerAddress { |
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
||||||
|
if let Some(port) = self.port { |
||||||
|
write!(f, "{}:{}", self.host, port) |
||||||
|
} else { |
||||||
|
write!(f, "{}", self.host) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl std::fmt::Display for ServerHost { |
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
||||||
|
match self { |
||||||
|
ServerHost::Hostname(host) => write!(f, "{}", host), |
||||||
|
ServerHost::Ipv4Addr(ipv4) => write!(f, "{}", ipv4), |
||||||
|
ServerHost::Ipv6Addr(ipv6) => write!(f, "[{}]", ipv6), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// Returns whether the supplied string is in the dotted-decimal (`#.#.#.#`)
|
||||||
|
/// form that IPv4 addresses are (has 4 digit-only sections seperated by dots).
|
||||||
|
/// Does not verify the individual values are within valid ranges (0-255).
|
||||||
|
fn looks_like_ipv4(s: &str) -> bool { |
||||||
|
let mut num_dots = 0; |
||||||
|
let mut previous_char = '.'; |
||||||
|
for c in s.chars() { |
||||||
|
match c { |
||||||
|
// Dot at beginning or after another dot is invalid.
|
||||||
|
'.' if previous_char == '.' => return false, |
||||||
|
// More than 3 dots aren't valid, let's stop iterating.
|
||||||
|
'.' if num_dots == 3 => return false, |
||||||
|
// More dots!
|
||||||
|
'.' => num_dots += 1, |
||||||
|
// Digits are valid.
|
||||||
|
'0'..'9' => {} |
||||||
|
// Anything else isn't.
|
||||||
|
_ => return false, |
||||||
|
} |
||||||
|
previous_char = c; |
||||||
|
} |
||||||
|
// Must contain 3 dots and not end with one.
|
||||||
|
(num_dots == 3) && (previous_char != '.') |
||||||
|
} |
||||||
|
|
||||||
|
#[cfg(test)] |
||||||
|
mod tests { |
||||||
|
use super::*; |
||||||
|
|
||||||
|
#[test] |
||||||
|
fn valid_addresses() { |
||||||
|
// No port.
|
||||||
|
assert!(parse("localhost").is_ok()); |
||||||
|
assert!(parse("0.1-example.net2").is_ok()); |
||||||
|
assert!(parse("127.0.0.1").is_ok()); |
||||||
|
assert!(parse("255.255.255.255").is_ok()); |
||||||
|
assert!(parse("[::]").is_ok()); |
||||||
|
assert!(parse("[2001:db8:85a3::8a2e:370:7334]").is_ok()); |
||||||
|
assert!(parse("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]").is_ok()); |
||||||
|
|
||||||
|
// Yes port.
|
||||||
|
assert!(parse("localhost:0").is_ok()); |
||||||
|
assert!(parse("0.1-example.net2:80").is_ok()); |
||||||
|
assert!(parse("127.0.0.1:420").is_ok()); |
||||||
|
assert!(parse("[::]:8080").is_ok()); |
||||||
|
assert!(parse("[2001:db8:85a3::8a2e:370:7334]:25565").is_ok()); |
||||||
|
assert!(parse("[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:65535").is_ok()); |
||||||
|
|
||||||
|
assert_host_and_port("localhost:80", "localhost", 80); |
||||||
|
assert_host_and_port("0.0.0.0:69", "0.0.0.0", 69); |
||||||
|
assert_host_and_port("[::1]:1337", "[::1]", 1337); |
||||||
|
|
||||||
|
// Sussy but technically valid hostnames??
|
||||||
|
assert_is_hostname("12.34.56"); |
||||||
|
assert_is_hostname("12.34.56.78.90:123"); |
||||||
|
} |
||||||
|
|
||||||
|
#[test] |
||||||
|
fn invalid_addresses() { |
||||||
|
assert_eq!(parse(""), Err(ParseError::Empty)); |
||||||
|
assert_eq!(parse(":"), Err(ParseError::Empty)); |
||||||
|
assert_eq!(parse(":80"), Err(ParseError::Empty)); |
||||||
|
assert_eq!(parse("abc:"), Err(ParseError::Empty)); |
||||||
|
|
||||||
|
assert_eq!(parse("[::]:a"), Err(ParseError::Port)); |
||||||
|
assert_eq!(parse("[::]:20a"), Err(ParseError::Port)); |
||||||
|
assert_eq!(parse("[::]:65536"), Err(ParseError::Port)); |
||||||
|
assert_eq!(parse("example.net:42.0"), Err(ParseError::Port)); |
||||||
|
|
||||||
|
// Not necessary to verify many of these, as we don't parse them ourselves.
|
||||||
|
assert_eq!(parse("["), Err(ParseError::Ipv6)); |
||||||
|
assert_eq!(parse("[]"), Err(ParseError::Ipv6)); |
||||||
|
assert_eq!(parse("[localhost]"), Err(ParseError::Ipv6)); |
||||||
|
|
||||||
|
// Bare IPv6 values are not supported, they must be wrapped in brackets.
|
||||||
|
assert_eq!(parse("::"), Err(ParseError::Hostname)); |
||||||
|
assert_eq!(parse("::1:1337"), Err(ParseError::Hostname)); |
||||||
|
assert_eq!(parse("2001:4860:4860::8888"), Err(ParseError::Hostname)); |
||||||
|
|
||||||
|
// Anything that "looks like an IPv4 address" will attempt to parse as such.
|
||||||
|
assert_eq!(parse("253.254.255.256"), Err(ParseError::Ipv4)); |
||||||
|
assert_eq!(parse("1.22.333.4444"), Err(ParseError::Ipv4)); |
||||||
|
assert_eq!(parse("01.02.03.04"), Err(ParseError::Ipv4)); |
||||||
|
|
||||||
|
assert_eq!(parse("..."), Err(ParseError::Hostname)); |
||||||
|
assert_eq!(parse("a..b"), Err(ParseError::Hostname)); |
||||||
|
assert_eq!(parse("example.net]"), Err(ParseError::Hostname)); |
||||||
|
assert_eq!(parse("foo.-bar.example"), Err(ParseError::Hostname)); |
||||||
|
assert_eq!(parse("admin@example.net"), Err(ParseError::Hostname)); |
||||||
|
} |
||||||
|
|
||||||
|
#[test] |
||||||
|
fn address_with_default_port() { |
||||||
|
assert_default_port("localhost", 12345, 12345); |
||||||
|
assert_default_port("localhost:80", 12345, 80); |
||||||
|
assert_default_port("[::1]", 12345, 12345); |
||||||
|
assert_default_port("[::1]:80", 12345, 80); |
||||||
|
assert_default_port("1.2.3.4", 12345, 12345); |
||||||
|
assert_default_port("1.2.3.4:80", 12345, 80); |
||||||
|
} |
||||||
|
|
||||||
|
fn parse(s: &str) -> Result<ServerAddress, ParseError> { |
||||||
|
s.parse::<ServerAddress>() |
||||||
|
} |
||||||
|
|
||||||
|
fn assert_host_and_port(s: &str, host: &str, port: u16) { |
||||||
|
let address = parse(s).unwrap(); |
||||||
|
assert_eq!(address.host().to_string(), host); |
||||||
|
assert_eq!(address.port(), Some(port)); |
||||||
|
} |
||||||
|
|
||||||
|
fn assert_is_hostname(s: &str) { |
||||||
|
let host = parse(s).unwrap().host().clone(); |
||||||
|
assert!(matches!(host, ServerHost::Hostname(_))); |
||||||
|
} |
||||||
|
|
||||||
|
fn assert_default_port(s: &str, default_port: u16, expected_port: u16) { |
||||||
|
let address = parse(s).unwrap().with_default_port(default_port); |
||||||
|
assert_eq!(address.port(), Some(expected_port)); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue