From f272a1775ec75ce162b6d25db3355f128624a914 Mon Sep 17 00:00:00 2001 From: "sijie.sun" Date: Thu, 21 Aug 2025 00:21:47 +0800 Subject: [PATCH] tmp --- Cargo.lock | 10 + easytier/Cargo.toml | 3 + easytier/src/instance/mod.rs | 2 + .../src/instance/upnp_igd/common/messages.rs | 157 ++++ easytier/src/instance/upnp_igd/common/mod.rs | 11 + .../src/instance/upnp_igd/common/options.rs | 45 ++ .../src/instance/upnp_igd/common/parsing.rs | 756 ++++++++++++++++++ easytier/src/instance/upnp_igd/gateway.rs | 323 ++++++++ easytier/src/instance/upnp_igd/mod.rs | 64 ++ easytier/src/instance/upnp_igd/search.rs | 250 ++++++ 10 files changed, 1621 insertions(+) create mode 100644 easytier/src/instance/upnp_igd/common/messages.rs create mode 100644 easytier/src/instance/upnp_igd/common/mod.rs create mode 100644 easytier/src/instance/upnp_igd/common/options.rs create mode 100644 easytier/src/instance/upnp_igd/common/parsing.rs create mode 100644 easytier/src/instance/upnp_igd/gateway.rs create mode 100644 easytier/src/instance/upnp_igd/mod.rs create mode 100644 easytier/src/instance/upnp_igd/search.rs diff --git a/Cargo.lock b/Cargo.lock index 546ec740..7afd7c13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2226,6 +2226,7 @@ dependencies = [ "windows-service", "windows-sys 0.52.0", "winreg 0.52.0", + "xmltree", "zerocopy", "zip", "zstd", @@ -11141,6 +11142,15 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af4e2e2f7cba5a093896c1e150fbfe177d1883e7448200efb81d40b9d339ef26" +[[package]] +name = "xmltree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b619f8c85654798007fb10afa5125590b43b088c225a25fc2fec100a9fad0fc6" +dependencies = [ + "xml-rs", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 9c938d83..0b67cb09 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -217,6 +217,9 @@ version-compare = "0.2.0" hmac = "0.12.1" sha2 = "0.10.8" +# upnp igd +xmltree = "0.11.0" + [target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd"))'.dependencies] machine-uid = "0.5.3" diff --git a/easytier/src/instance/mod.rs b/easytier/src/instance/mod.rs index 8575b73b..c3c01bf4 100644 --- a/easytier/src/instance/mod.rs +++ b/easytier/src/instance/mod.rs @@ -8,3 +8,5 @@ pub mod listeners; pub mod virtual_nic; pub mod logger_rpc_service; + +pub mod upnp_igd; diff --git a/easytier/src/instance/upnp_igd/common/messages.rs b/easytier/src/instance/upnp_igd/common/messages.rs new file mode 100644 index 00000000..514cf301 --- /dev/null +++ b/easytier/src/instance/upnp_igd/common/messages.rs @@ -0,0 +1,157 @@ +use super::super::PortMappingProtocol; +use std::net::SocketAddr; + +// Content of the request. +pub const SEARCH_REQUEST: &str = "M-SEARCH * HTTP/1.1\r +Host:239.255.255.250:1900\r +ST:urn:schemas-upnp-org:device:InternetGatewayDevice:1\r +Man:\"ssdp:discover\"\r +MX:3\r\n\r\n"; + +pub const GET_EXTERNAL_IP_HEADER: &str = + r#""urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress""#; + +pub const ADD_ANY_PORT_MAPPING_HEADER: &str = + r#""urn:schemas-upnp-org:service:WANIPConnection:1#AddAnyPortMapping""#; + +pub const ADD_PORT_MAPPING_HEADER: &str = + r#""urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping""#; + +pub const DELETE_PORT_MAPPING_HEADER: &str = + r#""urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping""#; + +pub const GET_GENERIC_PORT_MAPPING_ENTRY: &str = + r#""urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry""#; + +const MESSAGE_HEAD: &str = r#" + +"#; + +const MESSAGE_TAIL: &str = r#" +"#; + +fn format_message(body: String) -> String { + format!("{MESSAGE_HEAD}{body}{MESSAGE_TAIL}") +} + +pub fn format_get_external_ip_message() -> String { + r#" + + + + + +"# + .into() +} + +pub fn format_add_any_port_mapping_message( + schema: &[String], + protocol: PortMappingProtocol, + external_port: u16, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, +) -> String { + let args = schema + .iter() + .filter_map(|argument| { + let value = match argument.as_str() { + "NewEnabled" => 1.to_string(), + "NewExternalPort" => external_port.to_string(), + "NewInternalClient" => local_addr.ip().to_string(), + "NewInternalPort" => local_addr.port().to_string(), + "NewLeaseDuration" => lease_duration.to_string(), + "NewPortMappingDescription" => description.to_string(), + "NewProtocol" => protocol.to_string(), + "NewRemoteHost" => "".to_string(), + unknown => { + tracing::warn!("Unknown argument: {}", unknown); + return None; + } + }; + Some(format!("<{argument}>{value}")) + }) + .collect::>() + .join("\n"); + + format_message(format!( + r#" + {args} + "#, + )) +} + +pub fn format_add_port_mapping_message( + schema: &[String], + protocol: PortMappingProtocol, + external_port: u16, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, +) -> String { + let args = schema + .iter() + .filter_map(|argument| { + let value = match argument.as_str() { + "NewEnabled" => 1.to_string(), + "NewExternalPort" => external_port.to_string(), + "NewInternalClient" => local_addr.ip().to_string(), + "NewInternalPort" => local_addr.port().to_string(), + "NewLeaseDuration" => lease_duration.to_string(), + "NewPortMappingDescription" => description.to_string(), + "NewProtocol" => protocol.to_string(), + "NewRemoteHost" => "".to_string(), + unknown => { + tracing::warn!("Unknown argument: {}", unknown); + return None; + } + }; + Some(format!("<{argument}>{value}",)) + }) + .collect::>() + .join("\n"); + + format_message(format!( + r#" + {args} + "# + )) +} + +pub fn format_delete_port_message( + schema: &[String], + protocol: PortMappingProtocol, + external_port: u16, +) -> String { + let args = schema + .iter() + .filter_map(|argument| { + let value = match argument.as_str() { + "NewExternalPort" => external_port.to_string(), + "NewProtocol" => protocol.to_string(), + "NewRemoteHost" => "".to_string(), + unknown => { + tracing::warn!("Unknown argument: {}", unknown); + return None; + } + }; + Some(format!("<{argument}>{value}",)) + }) + .collect::>() + .join("\n"); + + format_message(format!( + r#" + {args} + "# + )) +} + +pub fn formate_get_generic_port_mapping_entry_message(port_mapping_index: u32) -> String { + format_message(format!( + r#" + {port_mapping_index} + "# + )) +} diff --git a/easytier/src/instance/upnp_igd/common/mod.rs b/easytier/src/instance/upnp_igd/common/mod.rs new file mode 100644 index 00000000..a2735f0a --- /dev/null +++ b/easytier/src/instance/upnp_igd/common/mod.rs @@ -0,0 +1,11 @@ +pub mod messages; +pub mod options; +pub mod parsing; + +use rand::Rng; + +pub use self::options::SearchOptions; + +pub fn random_port() -> u16 { + rand::thread_rng().gen_range(32_768_u16..65_535_u16) +} diff --git a/easytier/src/instance/upnp_igd/common/options.rs b/easytier/src/instance/upnp_igd/common/options.rs new file mode 100644 index 00000000..03211756 --- /dev/null +++ b/easytier/src/instance/upnp_igd/common/options.rs @@ -0,0 +1,45 @@ +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +/// Default timeout for a gateway search. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +/// Timeout for each broadcast response during a gateway search. +#[allow(dead_code)] +pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Gateway search configuration +/// +/// SearchOptions::default() should suffice for most situations. +/// +/// # Example +/// To customize only a few options you can use `Default::default()` or `SearchOptions::default()` and the +/// [struct update syntax](https://doc.rust-lang.org/book/ch05-01-defining-structs.html#creating-instances-from-other-instances-with-struct-update-syntax). +/// ``` +/// # use std::time::Duration; +/// # use igd_next::SearchOptions; +/// let opts = SearchOptions { +/// timeout: Some(Duration::from_secs(60)), +/// ..Default::default() +/// }; +/// ``` +pub struct SearchOptions { + /// Bind address for UDP socket (defaults to all `0.0.0.0`) + pub bind_addr: SocketAddr, + /// Broadcast address for discovery packets (defaults to `239.255.255.250:1900`) + pub broadcast_address: SocketAddr, + /// Timeout for a search iteration (defaults to 10s) + pub timeout: Option, + /// Timeout for a single search response (defaults to 5s) + pub single_search_timeout: Option, +} + +impl Default for SearchOptions { + fn default() -> Self { + Self { + bind_addr: (IpAddr::from([0, 0, 0, 0]), 0).into(), + broadcast_address: "239.255.255.250:1900".parse().unwrap(), + timeout: Some(DEFAULT_TIMEOUT), + single_search_timeout: Some(RESPONSE_TIMEOUT), + } + } +} diff --git a/easytier/src/instance/upnp_igd/common/parsing.rs b/easytier/src/instance/upnp_igd/common/parsing.rs new file mode 100644 index 00000000..4eb99bc3 --- /dev/null +++ b/easytier/src/instance/upnp_igd/common/parsing.rs @@ -0,0 +1,756 @@ +use std::collections::HashMap; +use std::io; +use std::net::{IpAddr, SocketAddr}; + +use anyhow::Context; +use url::Url; +use xmltree::{self, Element}; + +use super::super::PortMappingProtocol; + +// Parse the result. +pub fn parse_search_result(text: &str) -> anyhow::Result<(SocketAddr, String)> { + for line in text.lines() { + let line = line.trim(); + if line.to_ascii_lowercase().starts_with("location:") { + if let Some(colon) = line.find(':') { + let url_text = &line[colon + 1..].trim(); + let url = Url::parse(url_text).map_err(|_| anyhow::anyhow!("Invalid response"))?; + let addr: IpAddr = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("Invalid response")) + .and_then(|s| s.parse().map_err(|_| anyhow::anyhow!("Invalid response")))?; + let port: u16 = url + .port_or_known_default() + .ok_or_else(|| anyhow::anyhow!("Invalid response"))?; + + return Ok((SocketAddr::new(addr, port), url.path().to_string())); + } + } + } + Err(anyhow::anyhow!("Invalid response")) +} + +pub fn parse_control_urls(resp: R) -> anyhow::Result<(String, String)> +where + R: io::Read, +{ + let root = Element::parse(resp)?; + + let mut urls = root.children.iter().filter_map(|child| { + let child = child.as_element()?; + if child.name == "device" { + Some(parse_device(child)?) + } else { + None + } + }); + + urls.next() + .ok_or_else(|| anyhow::anyhow!("Invalid response")) +} + +fn parse_device(device: &Element) -> Option<(String, String)> { + let services = device.get_child("serviceList").and_then(|service_list| { + service_list + .children + .iter() + .filter_map(|child| { + let child = child.as_element()?; + if child.name == "service" { + parse_service(child) + } else { + None + } + }) + .next() + }); + let devices = device.get_child("deviceList").and_then(parse_device_list); + services.or(devices) +} + +fn parse_device_list(device_list: &Element) -> Option<(String, String)> { + device_list + .children + .iter() + .filter_map(|child| { + let child = child.as_element()?; + if child.name == "device" { + parse_device(child) + } else { + None + } + }) + .next() +} + +fn parse_service(service: &Element) -> Option<(String, String)> { + let service_type = service.get_child("serviceType")?; + let service_type = service_type + .get_text() + .map(|s| s.into_owned()) + .unwrap_or_else(|| "".into()); + if [ + "urn:schemas-upnp-org:service:WANPPPConnection:1", + "urn:schemas-upnp-org:service:WANIPConnection:1", + "urn:schemas-upnp-org:service:WANIPConnection:2", + ] + .contains(&service_type.as_str()) + { + let scpd_url = service.get_child("SCPDURL"); + let control_url = service.get_child("controlURL"); + if let (Some(scpd_url), Some(control_url)) = (scpd_url, control_url) { + Some(( + scpd_url + .get_text() + .map(|s| s.into_owned()) + .unwrap_or_else(|| "".into()), + control_url + .get_text() + .map(|s| s.into_owned()) + .unwrap_or_else(|| "".into()), + )) + } else { + None + } + } else { + None + } +} + +pub fn parse_schemas(resp: R) -> anyhow::Result>> +where + R: io::Read, +{ + let root = Element::parse(resp)?; + + let mut schema = root.children.iter().filter_map(|child| { + let child = child.as_element()?; + if child.name == "actionList" { + parse_action_list(child) + } else { + None + } + }); + + schema + .next() + .ok_or_else(|| anyhow::anyhow!("Invalid response")) +} + +fn parse_action_list(action_list: &Element) -> Option>> { + Some( + action_list + .children + .iter() + .filter_map(|child| { + let child = child.as_element()?; + if child.name == "action" { + parse_action(child) + } else { + None + } + }) + .collect(), + ) +} + +fn parse_action(action: &Element) -> Option<(String, Vec)> { + Some(( + action.get_child("name")?.get_text()?.into_owned(), + parse_argument_list(action.get_child("argumentList")?)?, + )) +} + +fn parse_argument_list(argument_list: &Element) -> Option> { + Some( + argument_list + .children + .iter() + .filter_map(|child| { + let child = child.as_element()?; + if child.name == "argument" { + parse_argument(child) + } else { + None + } + }) + .collect(), + ) +} + +fn parse_argument(action: &Element) -> Option { + if action + .get_child("direction")? + .get_text()? + .into_owned() + .as_str() + == "in" + { + Some(action.get_child("name")?.get_text()?.into_owned()) + } else { + None + } +} + +pub struct RequestReponse { + text: String, + xml: xmltree::Element, +} + +pub type RequestResult = anyhow::Result; + +pub fn parse_response(text: String, ok: &str) -> RequestResult { + let mut xml = match xmltree::Element::parse(text.as_bytes()) { + Ok(xml) => xml, + Err(..) => return Err(anyhow::anyhow!("Invalid response: {}", text)), + }; + let body = match xml.get_mut_child("Body") { + Some(body) => body, + None => return Err(anyhow::anyhow!("Invalid response: {}", text)), + }; + if let Some(ok) = body.take_child(ok) { + return Ok(RequestReponse { text, xml: ok }); + } + let upnp_error = match body + .get_child("Fault") + .and_then(|e| e.get_child("detail")) + .and_then(|e| e.get_child("UPnPError")) + { + Some(upnp_error) => upnp_error, + None => return Err(anyhow::anyhow!("Invalid response: {}", text)), + }; + + match ( + upnp_error.get_child("errorCode"), + upnp_error.get_child("errorDescription"), + ) { + (Some(e), Some(d)) => match (e.get_text().as_ref(), d.get_text().as_ref()) { + (Some(et), Some(dt)) => match et.parse::() { + Ok(en) => Err(anyhow::anyhow!("Error code {}: {}", en, dt)), + Err(..) => Err(anyhow::anyhow!("Invalid response: {}", text)), + }, + _ => Err(anyhow::anyhow!("Invalid response: {}", text)), + }, + _ => Err(anyhow::anyhow!("Invalid response: {}", text)), + } +} + +pub fn parse_get_external_ip_response(result: RequestResult) -> anyhow::Result> { + if let Ok(resp) = &result { + let child = resp.xml.get_child("NewExternalIPAddress"); + if let Some(child) = child { + let text = child.get_text(); + println!("text {:?}", text); + } + + let child_empty = resp.xml.get_child("NewExternalIPAddressFuck"); + println!("child_empty {:?}", child_empty); + } + match result { + Ok(resp) => { + let child = resp.xml.get_child("NewExternalIPAddress"); + if let Some(child) = child { + match child.get_text() { + Some(text) => { + Ok(Some(text.parse::().with_context(|| { + format!("Invalid IP address: {}", text) + })?)) + } + None => Ok(None), + } + } else { + anyhow::bail!("Invalid response: {}", resp.text); + } + } + Err(e) => { + let error_msg = e.to_string(); + if error_msg.contains("Error code 606") { + Err(anyhow::anyhow!("Action not authorized")) + } else { + Err(e) + } + } + } +} + +pub fn parse_add_any_port_mapping_response(result: RequestResult) -> anyhow::Result { + match result { + Ok(resp) => { + match resp + .xml + .get_child("NewReservedPort") + .and_then(|e| e.get_text()) + .and_then(|t| t.parse::().ok()) + { + Some(port) => Ok(port), + None => Err(anyhow::anyhow!("Invalid response: {}", resp.text)), + } + } + Err(err) => { + let error_msg = err.to_string(); + if error_msg.contains("Error code 605") { + Err(anyhow::anyhow!("Description too long")) + } else if error_msg.contains("Error code 606") { + Err(anyhow::anyhow!("Action not authorized")) + } else if error_msg.contains("Error code 728") { + Err(anyhow::anyhow!("No ports available")) + } else { + Err(err) + } + } + } +} + +pub fn convert_add_random_port_mapping_error(error: anyhow::Error) -> Option { + let error_msg = error.to_string(); + if error_msg.contains("Error code 724") { + None + } else if error_msg.contains("Error code 605") { + Some(anyhow::anyhow!("Description too long")) + } else if error_msg.contains("Error code 606") { + Some(anyhow::anyhow!("Action not authorized")) + } else if error_msg.contains("Error code 718") { + Some(anyhow::anyhow!("No ports available")) + } else if error_msg.contains("Error code 725") { + Some(anyhow::anyhow!("Only permanent leases supported")) + } else { + Some(error) + } +} + +pub fn convert_add_same_port_mapping_error(error: anyhow::Error) -> anyhow::Error { + let error_msg = error.to_string(); + if error_msg.contains("Error code 606") { + anyhow::anyhow!("Action not authorized") + } else if error_msg.contains("Error code 718") { + anyhow::anyhow!("External port in use") + } else if error_msg.contains("Error code 725") { + anyhow::anyhow!("Only permanent leases supported") + } else { + error + } +} + +pub fn convert_add_port_error(err: anyhow::Error) -> anyhow::Error { + let error_msg = err.to_string(); + if error_msg.contains("Error code 605") { + anyhow::anyhow!("Description too long") + } else if error_msg.contains("Error code 606") { + anyhow::anyhow!("Action not authorized") + } else if error_msg.contains("Error code 718") { + anyhow::anyhow!("Port in use") + } else if error_msg.contains("Error code 724") { + anyhow::anyhow!("Same port values required") + } else if error_msg.contains("Error code 725") { + anyhow::anyhow!("Only permanent leases supported") + } else { + err + } +} + +pub fn parse_delete_port_mapping_response(result: RequestResult) -> anyhow::Result<()> { + match result { + Ok(_) => Ok(()), + Err(err) => { + let error_msg = err.to_string(); + if error_msg.contains("Error code 606") { + Err(anyhow::anyhow!("Action not authorized")) + } else if error_msg.contains("Error code 714") { + Err(anyhow::anyhow!("No such port mapping")) + } else { + Err(err) + } + } + } +} + +/// One port mapping entry as returned by GetGenericPortMappingEntry +pub struct PortMappingEntry { + /// The remote host for which the mapping is valid + /// Can be an IP address or a host name + pub remote_host: String, + /// The external port of the mapping + pub external_port: u16, + /// The protocol of the mapping + pub protocol: PortMappingProtocol, + /// The internal (local) port + pub internal_port: u16, + /// The internal client of the port mapping + /// Can be an IP address or a host name + pub internal_client: String, + /// A flag whether this port mapping is enabled + pub enabled: bool, + /// A description for this port mapping + pub port_mapping_description: String, + /// The lease duration of this port mapping in seconds + pub lease_duration: u32, +} + +pub fn parse_get_generic_port_mapping_entry( + result: RequestResult, +) -> anyhow::Result { + let response = result?; + let xml = response.xml; + let make_err = |msg: String| move || anyhow::anyhow!("Invalid response: {}", msg); + let extract_field = |field: &str| { + xml.get_child(field) + .ok_or_else(make_err(format!("{field} is missing"))) + }; + let remote_host = extract_field("NewRemoteHost")? + .get_text() + .map(|c| c.into_owned()) + .unwrap_or_else(|| "".into()); + let external_port = extract_field("NewExternalPort")? + .get_text() + .and_then(|t| t.parse::().ok()) + .ok_or_else(make_err("Field NewExternalPort is invalid".into()))?; + let protocol = match extract_field("NewProtocol")?.get_text() { + Some(std::borrow::Cow::Borrowed("UDP")) => PortMappingProtocol::Udp, + Some(std::borrow::Cow::Borrowed("TCP")) => PortMappingProtocol::Tcp, + _ => { + return Err(anyhow::anyhow!( + "Invalid response: Field NewProtocol is invalid" + )) + } + }; + let internal_port = extract_field("NewInternalPort")? + .get_text() + .and_then(|t| t.parse::().ok()) + .ok_or_else(make_err("Field NewInternalPort is invalid".into()))?; + let internal_client = extract_field("NewInternalClient")? + .get_text() + .map(|c| c.into_owned()) + .ok_or_else(make_err("Field NewInternalClient is empty".into()))?; + let enabled = match extract_field("NewEnabled")? + .get_text() + .and_then(|t| t.parse::().ok()) + .ok_or_else(make_err("Field Enabled is invalid".into()))? + { + 0 => false, + 1 => true, + _ => { + return Err(anyhow::anyhow!( + "Invalid response: Field NewEnabled is invalid" + )) + } + }; + let port_mapping_description = extract_field("NewPortMappingDescription")? + .get_text() + .map(|c| c.into_owned()) + .unwrap_or_else(|| "".into()); + let lease_duration = extract_field("NewLeaseDuration")? + .get_text() + .and_then(|t| t.parse::().ok()) + .ok_or_else(make_err("Field NewLeaseDuration is invalid".into()))?; + Ok(PortMappingEntry { + remote_host, + external_port, + protocol, + internal_port, + internal_client, + enabled, + port_mapping_description, + lease_duration, + }) +} + +#[test] +fn test_parse_search_result_case_insensitivity() { + assert!(parse_search_result("location:http://0.0.0.0:0/control_url").is_ok()); + assert!(parse_search_result("LOCATION:http://0.0.0.0:0/control_url").is_ok()); +} + +#[test] +fn test_parse_search_result_ok() { + let result = parse_search_result("location:http://0.0.0.0:0/control_url").unwrap(); + assert_eq!( + result.0.ip(), + IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0)) + ); + assert_eq!(result.0.port(), 0); + assert_eq!(&result.1[..], "/control_url"); +} + +#[test] +fn test_parse_search_result_fail() { + assert!(parse_search_result("content-type:http://0.0.0.0:0/control_url").is_err()); +} + +#[test] +fn test_parse_device1() { + let text = r#" + + + 1 + 0 + + + urn:schemas-upnp-org:device:InternetGatewayDevice:1 + + + + + + 1 + 00000000 + + + + urn:schemas-upnp-org:service:Layer3Forwarding:1 + urn:upnp-org:serviceId:Layer3Forwarding1 + /ctl/L3F + /evt/L3F + /L3F.xml + + + + + urn:schemas-upnp-org:device:WANDevice:1 + WANDevice + MiniUPnP + http://miniupnp.free.fr/ + WAN Device + WAN Device + 20180615 + http://miniupnp.free.fr/ + 00000000 + uuid:804e2e56-7bfe-4733-bae0-04bf6d569692 + MINIUPNPD + + + urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 + urn:upnp-org:serviceId:WANCommonIFC1 + /ctl/CmnIfCfg + /evt/CmnIfCfg + /WANCfg.xml + + + + + urn:schemas-upnp-org:device:WANConnectionDevice:1 + WANConnectionDevice + MiniUPnP + http://miniupnp.free.fr/ + MiniUPnP daemon + MiniUPnPd + 20180615 + http://miniupnp.free.fr/ + 00000000 + uuid:804e2e56-7bfe-4733-bae0-04bf6d569692 + MINIUPNPD + + + urn:schemas-upnp-org:service:WANIPConnection:1 + urn:upnp-org:serviceId:WANIPConn1 + /ctl/IPConn + /evt/IPConn + /WANIPCn.xml + + + + + + + http://192.168.0.1/ + +"#; + + let (control_schema_url, control_url) = parse_control_urls(text.as_bytes()).unwrap(); + assert_eq!(control_url, "/ctl/IPConn"); + assert_eq!(control_schema_url, "/WANIPCn.xml"); +} + +#[test] +fn test_parse_device2() { + let text = r#" + + + 1 + 0 + + + urn:schemas-upnp-org:device:InternetGatewayDevice:1 + FRITZ!Box 7430 + AVM Berlin + http://www.avm.de + FRITZ!Box 7430 + FRITZ!Box 7430 + avm + http://www.avm.de + uuid:00000000-0000-0000-0000-000000000000 + + + image/gif + 118 + 119 + 8 + /ligd.gif + + + + + urn:schemas-any-com:service:Any:1 + urn:any-com:serviceId:any1 + /igdupnp/control/any + /igdupnp/control/any + /any.xml + + + + + urn:schemas-upnp-org:device:WANDevice:1 + WANDevice - FRITZ!Box 7430 + AVM Berlin + www.avm.de + WANDevice - FRITZ!Box 7430 + WANDevice - FRITZ!Box 7430 + avm + www.avm.de + uuid:00000000-0000-0000-0000-000000000000 + AVM IGD + + + urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 + urn:upnp-org:serviceId:WANCommonIFC1 + /igdupnp/control/WANCommonIFC1 + /igdupnp/control/WANCommonIFC1 + /igdicfgSCPD.xml + + + + + urn:schemas-upnp-org:device:WANConnectionDevice:1 + WANConnectionDevice - FRITZ!Box 7430 + AVM Berlin + www.avm.de + WANConnectionDevice - FRITZ!Box 7430 + WANConnectionDevice - FRITZ!Box 7430 + avm + www.avm.de + uuid:00000000-0000-0000-0000-000000000000 + AVM IGD + + + urn:schemas-upnp-org:service:WANDSLLinkConfig:1 + urn:upnp-org:serviceId:WANDSLLinkC1 + /igdupnp/control/WANDSLLinkC1 + /igdupnp/control/WANDSLLinkC1 + /igddslSCPD.xml + + + urn:schemas-upnp-org:service:WANIPConnection:1 + urn:upnp-org:serviceId:WANIPConn1 + /igdupnp/control/WANIPConn1 + /igdupnp/control/WANIPConn1 + /igdconnSCPD.xml + + + urn:schemas-upnp-org:service:WANIPv6FirewallControl:1 + urn:upnp-org:serviceId:WANIPv6Firewall1 + /igd2upnp/control/WANIPv6Firewall1 + /igd2upnp/control/WANIPv6Firewall1 + /igd2ipv6fwcSCPD.xml + + + + + + + http://fritz.box + + + "#; + let result = parse_control_urls(text.as_bytes()); + assert!(result.is_ok()); + let (control_schema_url, control_url) = result.unwrap(); + assert_eq!(control_url, "/igdupnp/control/WANIPConn1"); + assert_eq!(control_schema_url, "/igdconnSCPD.xml"); +} + +#[test] +fn test_parse_device3() { + let text = r#" + + + 1 + 0 + + + urn:schemas-upnp-org:device:InternetGatewayDevice:1 + + + + + + + + http://192.168.1.1 + uuid:00000000-0000-0000-0000-000000000000 + 999999999001 + + + image/png + 16 + 16 + 8 + /ligd.png + + + + + urn:schemas-upnp-org:device:WANDevice:1 + + + + + + + + + http://192.168.1.254 + uuid:00000000-0000-0000-0000-000000000000 + 999999999001 + + + urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1 + urn:upnp-org:serviceId:WANCommonIFC1 + /upnp/control/WANCommonIFC1 + /upnp/control/WANCommonIFC1 + /332b484d/wancomicfgSCPD.xml + + + + + urn:schemas-upnp-org:device:WANConnectionDevice:1 + + + + + + + + + http://192.168.1.254 + uuid:00000000-0000-0000-0000-000000000000 + 999999999001 + + + urn:schemas-upnp-org:service:WANIPConnection:1 + urn:upnp-org:serviceId:WANIPConn1 + /upnp/control/WANIPConn1 + /upnp/control/WANIPConn1 + /332b484d/wanipconnSCPD.xml + + + + + + + +"#; + + let (control_schema_url, control_url) = parse_control_urls(text.as_bytes()).unwrap(); + assert_eq!(control_url, "/upnp/control/WANIPConn1"); + assert_eq!(control_schema_url, "/332b484d/wanipconnSCPD.xml"); +} diff --git a/easytier/src/instance/upnp_igd/gateway.rs b/easytier/src/instance/upnp_igd/gateway.rs new file mode 100644 index 00000000..e6f68be2 --- /dev/null +++ b/easytier/src/instance/upnp_igd/gateway.rs @@ -0,0 +1,323 @@ +use std::collections::HashMap; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::net::{IpAddr, SocketAddr}; + +use super::Provider; + +use super::common::{self, messages, parsing, parsing::RequestReponse}; +use super::PortMappingProtocol; + +/// This structure represents a gateway found by the search functions. +#[derive(Clone, Debug)] +pub struct Gateway

{ + /// Socket address of the gateway + pub addr: SocketAddr, + /// Root url of the device + pub root_url: String, + /// Control url of the device + pub control_url: String, + /// Url to get schema data from + pub control_schema_url: String, + /// Control schema for all actions + pub control_schema: HashMap>, + /// Executor provider + pub provider: P, +} + +impl Gateway

{ + async fn perform_request( + &self, + header: &str, + body: &str, + ok: &str, + ) -> anyhow::Result { + let url = format!("{self}"); + let text = P::send_async(&url, header, body).await?; + parsing::parse_response(text, ok) + } + + /// Get the external IP address of the gateway in a tokio compatible way + pub async fn get_external_ip(&self) -> anyhow::Result> { + let result = self + .perform_request( + messages::GET_EXTERNAL_IP_HEADER, + &messages::format_get_external_ip_message(), + "GetExternalIPAddressResponse", + ) + .await; + parsing::parse_get_external_ip_response(result) + } + + /// Get an external socket address with our external ip and any port. This is a convenience + /// function that calls `get_external_ip` followed by `add_any_port` + /// + /// The local_addr is the address where the traffic is sent to. + /// The lease_duration parameter is in seconds. A value of 0 is infinite. + /// + /// # Returns + /// + /// The external address that was mapped on success. Otherwise an error. + pub async fn get_any_address( + &self, + protocol: PortMappingProtocol, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result { + let description = description.to_owned(); + let ip = self + .get_external_ip() + .await? + .ok_or_else(|| anyhow::anyhow!("Router does not have an external IP address"))?; + let port = self + .add_any_port(protocol, local_addr, lease_duration, &description) + .await?; + Ok(SocketAddr::new(ip, port)) + } + + /// Add a port mapping.with any external port. + /// + /// The local_addr is the address where the traffic is sent to. + /// The lease_duration parameter is in seconds. A value of 0 is infinite. + /// + /// # Returns + /// + /// The external port that was mapped on success. Otherwise an error. + pub async fn add_any_port( + &self, + protocol: PortMappingProtocol, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result { + // This function first attempts to call AddAnyPortMapping on the IGD with a random port + // number. If that fails due to the method being unknown it attempts to call AddPortMapping + // instead with a random port number. If that fails due to ConflictInMappingEntry it retrys + // with another port up to a maximum of 20 times. If it fails due to SamePortValuesRequired + // it retrys once with the same port values. + + if local_addr.port() == 0 { + return Err(anyhow::anyhow!("Internal port zero is invalid")); + } + + let schema = self.control_schema.get("AddAnyPortMapping"); + if let Some(schema) = schema { + let external_port = common::random_port(); + + let description = description.to_owned(); + + let resp = self + .perform_request( + messages::ADD_ANY_PORT_MAPPING_HEADER, + &messages::format_add_any_port_mapping_message( + schema, + protocol, + external_port, + local_addr, + lease_duration, + &description, + ), + "AddAnyPortMappingResponse", + ) + .await; + parsing::parse_add_any_port_mapping_response(resp) + } else { + // The router does not have the AddAnyPortMapping method. + // Fall back to using AddPortMapping with a random port. + self.retry_add_random_port_mapping(protocol, local_addr, lease_duration, description) + .await + } + } + + async fn retry_add_random_port_mapping( + &self, + protocol: PortMappingProtocol, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result { + for _ in 0u8..20u8 { + match self + .add_random_port_mapping(protocol, local_addr, lease_duration, description) + .await + { + Ok(port) => return Ok(port), + Err(_) => continue, + } + } + Err(anyhow::anyhow!("No ports available")) + } + + async fn add_random_port_mapping( + &self, + protocol: PortMappingProtocol, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result { + let description = description.to_owned(); + let external_port = common::random_port(); + let res = self + .add_port_mapping( + protocol, + external_port, + local_addr, + lease_duration, + &description, + ) + .await; + + match res { + Ok(_) => Ok(external_port), + Err(_) => { + self.add_same_port_mapping(protocol, local_addr, lease_duration, &description) + .await + } + } + } + + async fn add_same_port_mapping( + &self, + protocol: PortMappingProtocol, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result { + let res = self + .add_port_mapping( + protocol, + local_addr.port(), + local_addr, + lease_duration, + description, + ) + .await; + match res { + Ok(_) => Ok(local_addr.port()), + Err(err) => Err(anyhow::anyhow!("Add same port mapping failed: {}", err)), + } + } + + async fn add_port_mapping( + &self, + protocol: PortMappingProtocol, + external_port: u16, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result<()> { + self.perform_request( + messages::ADD_PORT_MAPPING_HEADER, + &messages::format_add_port_mapping_message( + self.control_schema + .get("AddPortMapping") + .ok_or_else(|| anyhow::anyhow!("Unsupported action: AddPortMapping"))?, + protocol, + external_port, + local_addr, + lease_duration, + description, + ), + "AddPortMappingResponse", + ) + .await?; + Ok(()) + } + + /// Add a port mapping. + /// + /// The local_addr is the address where the traffic is sent to. + /// The lease_duration parameter is in seconds. A value of 0 is infinite. + pub async fn add_port( + &self, + protocol: PortMappingProtocol, + external_port: u16, + local_addr: SocketAddr, + lease_duration: u32, + description: &str, + ) -> anyhow::Result<()> { + if external_port == 0 { + return Err(anyhow::anyhow!("External port zero is invalid")); + } + if local_addr.port() == 0 { + return Err(anyhow::anyhow!("Internal port zero is invalid")); + } + + let res = self + .add_port_mapping( + protocol, + external_port, + local_addr, + lease_duration, + description, + ) + .await; + if let Err(err) = res { + return Err(anyhow::anyhow!("Add port mapping failed: {}", err)); + }; + Ok(()) + } + + /// Remove a port mapping. + pub async fn remove_port( + &self, + protocol: PortMappingProtocol, + external_port: u16, + ) -> anyhow::Result<()> { + let res = self + .perform_request( + messages::DELETE_PORT_MAPPING_HEADER, + &messages::format_delete_port_message( + self.control_schema + .get("DeletePortMapping") + .ok_or_else(|| anyhow::anyhow!("Unsupported action: DeletePortMapping"))?, + protocol, + external_port, + ), + "DeletePortMappingResponse", + ) + .await; + parsing::parse_delete_port_mapping_response(res) + } + + /// Get one port mapping entry + /// + /// Gets one port mapping entry by its index. + /// Not all existing port mappings might be visible to this client. + /// If the index is out of bound, GetGenericPortMappingEntryError::SpecifiedArrayIndexInvalid will be returned + pub async fn get_generic_port_mapping_entry( + &self, + index: u32, + ) -> anyhow::Result { + let result = self + .perform_request( + messages::GET_GENERIC_PORT_MAPPING_ENTRY, + &messages::formate_get_generic_port_mapping_entry_message(index), + "GetGenericPortMappingEntryResponse", + ) + .await; + parsing::parse_get_generic_port_mapping_entry(result) + } +} + +impl

fmt::Display for Gateway

{ + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "http://{}{}", self.addr, self.control_url) + } +} + +impl

PartialEq for Gateway

{ + fn eq(&self, other: &Gateway

) -> bool { + self.addr == other.addr && self.control_url == other.control_url + } +} + +impl

Eq for Gateway

{} + +impl

Hash for Gateway

{ + fn hash(&self, state: &mut H) { + self.addr.hash(state); + self.control_url.hash(state); + } +} diff --git a/easytier/src/instance/upnp_igd/mod.rs b/easytier/src/instance/upnp_igd/mod.rs new file mode 100644 index 00000000..9f06b439 --- /dev/null +++ b/easytier/src/instance/upnp_igd/mod.rs @@ -0,0 +1,64 @@ +mod common; +mod gateway; +mod search; + +use std::fmt; + +pub(crate) const MAX_RESPONSE_SIZE: usize = 1500; +pub(crate) const HEADER_NAME: &str = "SOAPAction"; + +/// Trait to allow abstracting over `tokio`. +#[async_trait::async_trait] +pub trait Provider { + /// Send an async request over the executor. + async fn send_async(url: &str, action: &str, body: &str) -> anyhow::Result; +} + +/// Represents the protocols available for port mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PortMappingProtocol { + /// TCP protocol + Tcp, + /// UDP protocol + Udp, +} + +impl fmt::Display for PortMappingProtocol { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match *self { + PortMappingProtocol::Tcp => "TCP", + PortMappingProtocol::Udp => "UDP", + } + ) + } +} + +#[cfg(test)] +mod tests { + use crate::instance::upnp_igd::{ + common::SearchOptions, search::search_gateway, PortMappingProtocol, + }; + + #[tokio::test] + async fn test_search_device() { + let ret = search_gateway(SearchOptions::default()).await.unwrap(); + println!("{:?}", ret); + let external_ip = ret.get_external_ip().await.unwrap(); + println!("{:?}", external_ip); + + let add_port_ret = ret + .add_port( + PortMappingProtocol::Tcp, + 51010, + "10.147.223.128:11010".parse().unwrap(), + 1000, + "test", + ) + .await; + + println!("{:?}", add_port_ret); + } +} diff --git a/easytier/src/instance/upnp_igd/search.rs b/easytier/src/instance/upnp_igd/search.rs new file mode 100644 index 00000000..9d64ad1c --- /dev/null +++ b/easytier/src/instance/upnp_igd/search.rs @@ -0,0 +1,250 @@ +//! Tokio abstraction for the aio [`Gateway`]. + +use std::collections::HashMap; +use std::net::SocketAddr; + +use http_req::response::Headers; +use tokio::{net::UdpSocket, time::timeout}; + +use super::common::options::{DEFAULT_TIMEOUT, RESPONSE_TIMEOUT}; +use super::common::{messages, parsing, SearchOptions}; +use super::gateway::Gateway; +use super::{Provider, HEADER_NAME, MAX_RESPONSE_SIZE}; +use tracing::debug; + +/// Tokio provider for the [`Gateway`]. +#[derive(Debug, Clone)] +pub struct Tokio; + +#[async_trait::async_trait] +impl Provider for Tokio { + async fn send_async(url: &str, action: &str, body: &str) -> anyhow::Result { + use http_req::request; + + // Run the blocking HTTP request in a separate thread to avoid blocking the async runtime + let url_owned = url.to_string(); + let body_clone = body.to_string(); + let action_clone = action.to_string(); + + let (response, response_body) = tokio::task::spawn_blocking(move || { + let uri = http_req::uri::Uri::try_from(url_owned.as_str()) + .map_err(|e| anyhow::anyhow!("URI parse error: {}", e))?; + + println!("body: {body_clone}, action: {action_clone}"); + + let mut response_body = Vec::new(); + let response = request::Request::new(&uri) + .method(request::Method::POST) + .header(HEADER_NAME, &action_clone) + .header("Content-Type", "text/xml; charset=\"utf-8\"") + .body(body_clone.as_bytes()) + .send(&mut response_body); + + if response.is_err() { + if response_body.is_empty() { + anyhow::bail!("HTTP request error: {}", response.unwrap_err()); + } else { + anyhow::bail!( + "HTTP request error: {} with response body: {}", + response.unwrap_err(), + String::from_utf8_lossy(&response_body) + ); + } + } + + let response = response.unwrap(); + Ok::<_, anyhow::Error>((response, response_body)) + }) + .await + .map_err(|e| anyhow::anyhow!("Task join error: {}", e))??; + + if !response.status_code().is_success() { + if response_body.is_empty() { + return Err(anyhow::anyhow!( + "HTTP error with empty body: {}", + response.status_code() + )); + } + } + + let string = String::from_utf8(response_body) + .map_err(|e| anyhow::anyhow!("UTF-8 conversion error: {}", e))?; + Ok(string) + } +} + +/// Search for a gateway with the provided options. +pub async fn search_gateway(options: SearchOptions) -> anyhow::Result> { + let search_timeout = options.timeout.unwrap_or(DEFAULT_TIMEOUT); + match timeout(search_timeout, search_gateway_inner(options)).await { + Ok(Ok(gateway)) => Ok(gateway), + Ok(Err(err)) => Err(err), + Err(_err) => { + // Timeout + Err(anyhow::anyhow!("No response within timeout")) + } + } +} + +async fn search_gateway_inner(options: SearchOptions) -> anyhow::Result> { + // Create socket for future calls + let mut socket = UdpSocket::bind(&options.bind_addr) + .await + .map_err(|e| anyhow::anyhow!("Failed to bind socket: {}", e))?; + + send_search_request(&mut socket, options.broadcast_address).await?; + let response_timeout = options.single_search_timeout.unwrap_or(RESPONSE_TIMEOUT); + + loop { + let search_response = receive_search_response(&mut socket); + + // Receive search response + let (response_body, from) = match timeout(response_timeout, search_response).await { + Ok(Ok(v)) => v, + Ok(Err(err)) => { + debug!("error while receiving broadcast response: {err}"); + continue; + } + Err(_) => { + debug!("timeout while receiving broadcast response"); + continue; + } + }; + + let (addr, root_url) = match handle_broadcast_resp(&from, &response_body) { + Ok(v) => v, + Err(e) => { + debug!("error handling broadcast response: {}", e); + continue; + } + }; + + let (control_schema_url, control_url) = match get_control_urls(&addr, &root_url).await { + Ok(v) => v, + Err(e) => { + debug!("error getting control URLs: {}", e); + continue; + } + }; + + let control_schema = match get_control_schemas(&addr, &control_schema_url).await { + Ok(v) => v, + Err(e) => { + debug!("error getting control schemas: {}", e); + continue; + } + }; + + return Ok(Gateway { + addr, + root_url, + control_url, + control_schema_url, + control_schema, + provider: Tokio, + }); + } +} + +// Create a new search. +async fn send_search_request(socket: &mut UdpSocket, addr: SocketAddr) -> anyhow::Result<()> { + debug!( + "sending broadcast request to: {} on interface: {:?}", + addr, + socket.local_addr() + ); + socket + .send_to(messages::SEARCH_REQUEST.as_bytes(), &addr) + .await + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("Failed to send search request: {}", e)) +} + +async fn receive_search_response(socket: &mut UdpSocket) -> anyhow::Result<(Vec, SocketAddr)> { + let mut buff = [0u8; MAX_RESPONSE_SIZE]; + let (n, from) = socket + .recv_from(&mut buff) + .await + .map_err(|e| anyhow::anyhow!("Failed to receive response: {}", e))?; + debug!("received broadcast response from: {}", from); + Ok((buff[..n].to_vec(), from)) +} + +// Handle a UDP response message. +fn handle_broadcast_resp(from: &SocketAddr, data: &[u8]) -> anyhow::Result<(SocketAddr, String)> { + debug!("handling broadcast response from: {}", from); + + // Convert response to text. + let text = + std::str::from_utf8(data).map_err(|e| anyhow::anyhow!("UTF-8 conversion error: {}", e))?; + + // Parse socket address and path. + let (addr, root_url) = parsing::parse_search_result(text)?; + + Ok((addr, root_url)) +} + +async fn get_control_urls(addr: &SocketAddr, path: &str) -> anyhow::Result<(String, String)> { + use http_req::request; + + let url = format!("http://{addr}{path}"); + debug!("requesting control url from: {}", url); + + // Run the blocking HTTP request in a separate thread to avoid blocking the async runtime + let (response, response_body) = tokio::task::spawn_blocking(move || { + let uri = http_req::uri::Uri::try_from(url.as_str()) + .map_err(|e| anyhow::anyhow!("URI parse error: {}", e))?; + + let mut response_body = Vec::new(); + let response = request::Request::new(&uri) + .method(request::Method::GET) + .send(&mut response_body) + .map_err(|e| anyhow::anyhow!("HTTP GET error: {}", e))?; + + Ok::<_, anyhow::Error>((response, response_body)) + }) + .await + .map_err(|e| anyhow::anyhow!("Task join error: {}", e))??; + + if !response.status_code().is_success() { + return Err(anyhow::anyhow!("HTTP error: {}", response.status_code())); + } + + debug!("handling control response from: {addr}"); + let c = std::io::Cursor::new(&response_body); + parsing::parse_control_urls(c) +} + +async fn get_control_schemas( + addr: &SocketAddr, + control_schema_url: &str, +) -> anyhow::Result>> { + use http_req::request; + + let url = format!("http://{addr}{control_schema_url}"); + debug!("requesting control schema from: {}", url); + + // Run the blocking HTTP request in a separate thread to avoid blocking the async runtime + let (response, response_body) = tokio::task::spawn_blocking(move || { + let uri = http_req::uri::Uri::try_from(url.as_str()) + .map_err(|e| anyhow::anyhow!("URI parse error: {}", e))?; + + let mut response_body = Vec::new(); + let response = request::Request::new(&uri) + .method(request::Method::GET) + .send(&mut response_body) + .map_err(|e| anyhow::anyhow!("HTTP GET error: {}", e))?; + + Ok::<_, anyhow::Error>((response, response_body)) + }) + .await + .map_err(|e| anyhow::anyhow!("Task join error: {}", e))??; + + if !response.status_code().is_success() { + return Err(anyhow::anyhow!("HTTP error: {}", response.status_code())); + } + + debug!("handling schema response from: {addr}"); + let c = std::io::Cursor::new(&response_body); + parsing::parse_schemas(c) +}