diff --git a/Cargo.lock b/Cargo.lock index b4e1c9362..455db1f19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,6 +889,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deflate64" version = "0.1.9" @@ -992,6 +998,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.102", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1480,6 +1498,51 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.5", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.5", + "resolv-conf", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1813,6 +1876,18 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2076,6 +2151,15 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lyon" version = "1.0.1" @@ -2830,6 +2914,7 @@ dependencies = [ "fluent-syntax", "futures-util", "hex", + "hickory-resolver", "image", "logos", "lru", @@ -2880,8 +2965,10 @@ dependencies = [ "anyhow", "chrono", "futures-util", + "hickory-resolver", "log", "macroquad", + "once_cell", "phira-mp-client", "phira-mp-common", "pretty_env_logger", @@ -3246,7 +3333,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3508,6 +3595,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfd" version = "0.15.3" @@ -4939,6 +5032,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -5096,6 +5195,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5114,6 +5222,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5155,6 +5278,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5173,6 +5302,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5191,6 +5326,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5221,6 +5362,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5239,6 +5386,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5251,6 +5404,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5269,6 +5428,12 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5299,6 +5464,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/phira-monitor/Cargo.toml b/phira-monitor/Cargo.toml index b7d122b3a..7573d67cc 100644 --- a/phira-monitor/Cargo.toml +++ b/phira-monitor/Cargo.toml @@ -9,11 +9,13 @@ chrono = "0.4.41" futures-util = "0.3.31" log = "0.4.27" macroquad = { git = "https://github.com/Mivik/prpr-macroquad", default-features = false } +once_cell = "1.21.3" pretty_env_logger = "0.5.0" reqwest = { version = "0.12.19",default-features = false, features = ["json", "stream", "gzip", "charset", "http2", "system-proxy", "rustls-tls"] } serde = { version = "*", features = ["derive"] } serde_yaml = "*" tokio = { workspace = true } +hickory-resolver = "0.24" uuid = { version = "1.17.0", features = ["v4"] } phira-mp-client = { git = "https://github.com/TeamFlos/phira-mp" } diff --git a/phira-monitor/src/main.rs b/phira-monitor/src/main.rs index 809a7c457..adfd96481 100644 --- a/phira-monitor/src/main.rs +++ b/phira-monitor/src/main.rs @@ -1,6 +1,7 @@ mod cloud; mod launch; mod scene; +mod srv_resolver; use anyhow::{Context, Result}; use macroquad::prelude::*; diff --git a/phira-monitor/src/scene.rs b/phira-monitor/src/scene.rs index e1ae3f988..b5728ff6b 100644 --- a/phira-monitor/src/scene.rs +++ b/phira-monitor/src/scene.rs @@ -292,7 +292,8 @@ fn create_init_task(config: Config, token: Option) -> Task = Lazy::new(|| { + TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()) +}); + +/// Resolves a server address, attempting SRV resolution if no port is specified. +/// +/// Detection logic: +/// - IPv6 addresses in brackets with ports (e.g., `[::1]:8080`) are returned as-is +/// - IPv4 addresses with ports (e.g., `192.168.1.1:8080`) are returned as-is +/// - Domain names with ports (e.g., `example.com:12345`) are returned as-is +/// - Bare IPv6 addresses without brackets (e.g., `::1`) trigger SRV resolution +/// - Domain names without ports (e.g., `example.com`) trigger SRV resolution +/// +/// If SRV resolution succeeds, returns the target host and port from the SRV record. +/// If SRV resolution fails, returns an error. +pub async fn resolve_server_address(address: &str) -> Result { + // If address contains a port (simple heuristic: last colon followed by digits), + // or is an IPv6 address in brackets, return as-is + if has_port(address) { + return Ok(address.to_string()); + } + + // Attempt SRV resolution + match resolve_srv(address).await { + Ok(resolved) => Ok(resolved), + Err(e) => { + // SRV resolution failed, return error + Err(anyhow::anyhow!( + "Failed to resolve SRV record for '{}': {}. Please specify host:port explicitly.", + address, + e + )) + } + } +} + +/// Checks if an address appears to have a port specified. +/// Handles both IPv4:port and [IPv6]:port formats. +fn has_port(address: &str) -> bool { + // Check for IPv6 with port: [::1]:8080 + if address.starts_with('[') { + return address.contains("]:"); + } + + // For non-bracketed addresses, check if there's a colon followed by digits + // IPv6 addresses without ports will have multiple colons or non-digit characters after colons + if let Some(colon_pos) = address.rfind(':') { + // Check if everything after the last colon is digits (port) + let after_colon = &address[colon_pos + 1..]; + if after_colon.is_empty() { + return false; + } + // If it's all digits and we only have one colon (or the part before has no colons), + // it's likely host:port format + if after_colon.chars().all(|c| c.is_ascii_digit()) { + // Check if there's another colon before this one (would indicate IPv6) + let before_colon = &address[..colon_pos]; + return !before_colon.contains(':'); + } + } + + false +} + +/// Performs SRV DNS lookup for the given domain. +/// SRV records are automatically returned by the DNS resolver in priority order. +async fn resolve_srv(domain: &str) -> Result { + let srv_name = format!("{}{}", SRV_PREFIX, domain); + + let lookup = RESOLVER + .srv_lookup(&srv_name) + .await + .map_err(|e| anyhow::anyhow!("SRV lookup failed: {}", e))?; + + // Get the first SRV record - the DNS resolver returns records in priority order + // (lowest priority value first), so we can simply take the first one + let srv = lookup + .iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No SRV records found"))?; + + let target = srv.target().to_string(); + let port = srv.port(); + + // Remove trailing dot from target if present + let target = target.trim_end_matches('.'); + + Ok(format!("{}:{}", target, port)) +} diff --git a/phira/Cargo.toml b/phira/Cargo.toml index 10e42d9f9..548168914 100644 --- a/phira/Cargo.toml +++ b/phira/Cargo.toml @@ -62,6 +62,7 @@ tap = "1.0.1" tempfile = "3.20.0" tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } tracing = "0.1.41" +hickory-resolver = "0.24" walkdir = "2.5.0" zip = "4.0.0" zstd = "0.13" diff --git a/phira/src/mp.rs b/phira/src/mp.rs index 59f6914cc..fa327514b 100644 --- a/phira/src/mp.rs +++ b/phira/src/mp.rs @@ -1,4 +1,7 @@ prpr_l10n::tl_file!("multiplayer" mtl); mod panel; +mod srv_resolver; + pub use panel::MPPanel; +pub use srv_resolver::resolve_server_address; diff --git a/phira/src/mp/panel.rs b/phira/src/mp/panel.rs index aa87fcbb3..b97d8862b 100644 --- a/phira/src/mp/panel.rs +++ b/phira/src/mp/panel.rs @@ -187,7 +187,8 @@ impl MPPanel { }; let addr = get_data().config.mp_address.clone(); self.connect_task = Some(Task::new(async move { - let client = Client::new(TcpStream::connect(addr).await?).await?; + let resolved_addr = super::resolve_server_address(&addr).await?; + let client = Client::new(TcpStream::connect(resolved_addr).await?).await?; client .authenticate(token) .await diff --git a/phira/src/mp/srv_resolver.rs b/phira/src/mp/srv_resolver.rs new file mode 100644 index 000000000..d26d87d4b --- /dev/null +++ b/phira/src/mp/srv_resolver.rs @@ -0,0 +1,146 @@ +use anyhow::Result; +use hickory_resolver::{ + config::{ResolverConfig, ResolverOpts}, + TokioAsyncResolver, +}; +use once_cell::sync::Lazy; + +const SRV_PREFIX: &str = "_phira._tcp."; + +/// Global DNS resolver that's reused across all lookups for efficiency +static RESOLVER: Lazy = Lazy::new(|| { + TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()) +}); + +/// Resolves a server address, attempting SRV resolution if no port is specified. +/// +/// Detection logic: +/// - IPv6 addresses in brackets with ports (e.g., `[::1]:8080`) are returned as-is +/// - IPv4 addresses with ports (e.g., `192.168.1.1:8080`) are returned as-is +/// - Domain names with ports (e.g., `example.com:12345`) are returned as-is +/// - Bare IPv6 addresses without brackets (e.g., `::1`) trigger SRV resolution +/// - Domain names without ports (e.g., `example.com`) trigger SRV resolution +/// +/// If SRV resolution succeeds, returns the target host and port from the SRV record. +/// If SRV resolution fails, returns an error. +pub async fn resolve_server_address(address: &str) -> Result { + // If address contains a port (simple heuristic: last colon followed by digits), + // or is an IPv6 address in brackets, return as-is + if has_port(address) { + return Ok(address.to_string()); + } + + // Attempt SRV resolution + match resolve_srv(address).await { + Ok(resolved) => Ok(resolved), + Err(e) => { + // SRV resolution failed, return error + Err(anyhow::anyhow!( + "Failed to resolve SRV record for '{}': {}. Please specify host:port explicitly.", + address, + e + )) + } + } +} + +/// Checks if an address appears to have a port specified. +/// Handles both IPv4:port and [IPv6]:port formats. +fn has_port(address: &str) -> bool { + // Check for IPv6 with port: [::1]:8080 + if address.starts_with('[') { + return address.contains("]:"); + } + + // For non-bracketed addresses, check if there's a colon followed by digits + // IPv6 addresses without ports will have multiple colons or non-digit characters after colons + if let Some(colon_pos) = address.rfind(':') { + // Check if everything after the last colon is digits (port) + let after_colon = &address[colon_pos + 1..]; + if after_colon.is_empty() { + return false; + } + // If it's all digits and we only have one colon (or the part before has no colons), + // it's likely host:port format + if after_colon.chars().all(|c| c.is_ascii_digit()) { + // Check if there's another colon before this one (would indicate IPv6) + let before_colon = &address[..colon_pos]; + return !before_colon.contains(':'); + } + } + + false +} + +/// Performs SRV DNS lookup for the given domain. +/// SRV records are automatically returned by the DNS resolver in priority order. +async fn resolve_srv(domain: &str) -> Result { + let srv_name = format!("{}{}", SRV_PREFIX, domain); + + let lookup = RESOLVER + .srv_lookup(&srv_name) + .await + .map_err(|e| anyhow::anyhow!("SRV lookup failed: {}", e))?; + + // Get the first SRV record - the DNS resolver returns records in priority order + // (lowest priority value first), so we can simply take the first one + let srv = lookup + .iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No SRV records found"))?; + + let target = srv.target().to_string(); + let port = srv.port(); + + // Remove trailing dot from target if present + let target = target.trim_end_matches('.'); + + Ok(format!("{}:{}", target, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_address_with_port_returns_as_is() { + let address = "example.com:12345"; + let result = resolve_server_address(address).await.unwrap(); + assert_eq!(result, "example.com:12345"); + } + + #[tokio::test] + async fn test_ipv6_with_port_returns_as_is() { + let address = "[::1]:8080"; + let result = resolve_server_address(address).await.unwrap(); + assert_eq!(result, "[::1]:8080"); + } + + #[tokio::test] + async fn test_ipv4_with_port_returns_as_is() { + let address = "192.168.1.1:8080"; + let result = resolve_server_address(address).await.unwrap(); + assert_eq!(result, "192.168.1.1:8080"); + } + + #[tokio::test] + async fn test_address_without_port_requires_srv() { + let address = "nonexistent-domain-for-testing.example"; + let result = resolve_server_address(address).await; + assert!(result.is_err()); + } + + #[test] + fn test_has_port_detection() { + assert!(has_port("example.com:12345")); + assert!(has_port("192.168.1.1:8080")); + assert!(has_port("[::1]:8080")); + assert!(has_port("[2001:db8::1]:9000")); + + assert!(!has_port("example.com")); + assert!(!has_port("192.168.1.1")); + assert!(!has_port("::1")); + assert!(!has_port("2001:db8::1")); + assert!(!has_port("localhost")); + } +} diff --git a/phira/src/page/settings.rs b/phira/src/page/settings.rs index 4da7b22ae..3f1124891 100644 --- a/phira/src/page/settings.rs +++ b/phira/src/page/settings.rs @@ -419,13 +419,16 @@ impl GeneralList { } if let Some((id, text)) = take_input() { if id == "mp_addr" { + // TODO: better error handling? if let Err(err) = text.to_socket_addrs() { - show_error(anyhow::Error::new(err).context(tl!("item-mp-addr-invalid"))); - return Ok(false); - } else { - data.config.mp_address = text; - return Ok(true); + // domain without port, for SRV + if (text.clone(), 80).to_socket_addrs().is_err() || text.is_empty() { + show_error(anyhow::Error::new(err).context(tl!("item-mp-addr-invalid"))); + return Ok(false); + } } + data.config.mp_address = text; + return Ok(true); } else if id == "anys_gateway" { if let Err(err) = Url::parse(&text) { show_error(anyhow::Error::new(err).context(tl!("item-anys-gateway-invalid")));