From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 8627E1FF141 for ; Fri, 13 Feb 2026 11:11:24 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E827734CCA; Fri, 13 Feb 2026 11:12:03 +0100 (CET) Message-ID: <54891c3f-b5ef-401e-b322-07a609ff2cb6@proxmox.com> Date: Fri, 13 Feb 2026 11:11:57 +0100 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird From: Stefan Hanreich Subject: Re: [pve-devel] [PATCH proxmox 03/11] network-types: add ServiceEndpoint type as host/port tuple abstraction To: Proxmox VE development discussion , Christoph Heiss References: <20260116153317.1146323-1-c.heiss@proxmox.com> <20260116153317.1146323-4-c.heiss@proxmox.com> Content-Language: en-US In-Reply-To: <20260116153317.1146323-4-c.heiss@proxmox.com> Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.721 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record WEIRD_PORT 0.001 Uses non-standard port number for HTTP Message-ID-Hash: U76XV4XUSL3KCUXEUCXQCP3PRZTYZTMV X-Message-ID-Hash: U76XV4XUSL3KCUXEUCXQCP3PRZTYZTMV X-MailFrom: s.hanreich@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: On 1/16/26 4:33 PM, Christoph Heiss wrote: > Basically a composite data type to provide bit of an better abstraction > to a tuple (host, port). > > Signed-off-by: Christoph Heiss > --- > proxmox-network-types/src/endpoint.rs | 154 ++++++++++++++++++++++++++ > proxmox-network-types/src/lib.rs | 3 +- > 2 files changed, 156 insertions(+), 1 deletion(-) > create mode 100644 proxmox-network-types/src/endpoint.rs > > diff --git a/proxmox-network-types/src/endpoint.rs b/proxmox-network-types/src/endpoint.rs > new file mode 100644 > index 00000000..24e33c7f > --- /dev/null > +++ b/proxmox-network-types/src/endpoint.rs > @@ -0,0 +1,154 @@ > +//! Implements a wrapper around a (host, port) tuple, where host can either > +//! be a plain IP address or a resolvable hostname. > + > +use std::{ > + fmt::{self, Display}, > + net::IpAddr, > + str::FromStr, > +}; > + > +use serde_with::{DeserializeFromStr, SerializeDisplay}; > + > +/// Represents either a resolvable hostname or an IPv4/IPv6 address. > +/// IPv6 address are correctly bracketed on [`Display`], and parsing > +/// automatically tries parsing it as an IP address first, falling back to a > +/// plain hostname in the other case. > +#[derive(Clone, Debug, PartialEq)] > +pub enum HostnameOrIpAddr { > + Hostname(String), > + IpAddr(IpAddr), > +} > + > +impl Display for HostnameOrIpAddr { > + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { > + match self { > + HostnameOrIpAddr::Hostname(s) => write!(f, "{s}"), > + HostnameOrIpAddr::IpAddr(addr) => match addr { > + IpAddr::V4(v4) => write!(f, "{v4}"), > + IpAddr::V6(v6) => write!(f, "[{v6}]"), > + }, could just be forwarded to the respective display implementations of String / IpAddr? at least for the Hostname / IPv4 parts - IPv6 should work as well but with writing a '[' / ']' first / after respectively. > + } > + } > +} > + > +impl> From for HostnameOrIpAddr { > + fn from(value: S) -> Self { > + let s = value.into(); > + if let Ok(ip_addr) = IpAddr::from_str(&s) { > + Self::IpAddr(ip_addr) > + } else { > + Self::Hostname(s) > + } > + } > +}> +/// Represents a (host, port) tuple, where the host can either be a resolvable > +/// hostname or an IPv4/IPv6 address. > +#[derive(Clone, Debug, PartialEq, SerializeDisplay, DeserializeFromStr)] > +pub struct ServiceEndpoint { > + host: HostnameOrIpAddr, > + port: u16, > +} > + > +impl ServiceEndpoint { > + pub fn new>(host: S, port: u16) -> Self { > + let s = host.into(); > + Self { > + host: s.into(), > + port, > + } > + } > +} > + > +impl Display for ServiceEndpoint { > + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { > + write!(f, "{}:{}", self.host, self.port) > + } > +} > + > +#[derive(thiserror::Error, Debug)] > +pub enum ParseError { > + #[error("host and port must be separated by a colon")] > + MissingSeparator, > + #[error("host part missing")] > + MissingHost, > + #[error("invalid port: {0}")] > + InvalidPort(String), > +} > + > +impl FromStr for ServiceEndpoint { > + type Err = ParseError; > + > + fn from_str(s: &str) -> Result { > + let (mut host, port) = s.rsplit_once(':').ok_or(Self::Err::MissingSeparator)?; > + > + if host.is_empty() { > + return Err(Self::Err::MissingHost); > + } > + > + // [ and ] are not valid characters in a hostname, so strip them in case it > + // is a IPv6 address. > + host = host.trim_matches(['[', ']']); > + > + Ok(ServiceEndpoint { > + host: host.into(), > + port: port > + .parse() > + .map_err(|err: std::num::ParseIntError| Self::Err::InvalidPort(err.to_string()))?, > + }) > + } > +} > + > +#[cfg(test)] > +mod tests { > + use crate::endpoint::HostnameOrIpAddr; > + > + use super::ServiceEndpoint; > + > + #[test] > + fn display_works() { > + let s = ServiceEndpoint::new("127.0.0.1", 123); > + assert_eq!(s.to_string(), "127.0.0.1:123"); > + > + let s = ServiceEndpoint::new("fc00:f00d::4321", 123); > + assert_eq!(s.to_string(), "[fc00:f00d::4321]:123"); > + > + let s = ServiceEndpoint::new("::", 123); > + assert_eq!(s.to_string(), "[::]:123"); > + > + let s = ServiceEndpoint::new("fc00::", 123); > + assert_eq!(s.to_string(), "[fc00::]:123"); > + > + let s = ServiceEndpoint::new("example.com", 123); > + assert_eq!(s.to_string(), "example.com:123"); > + } > + > + #[test] > + fn fromstr_works() { > + assert_eq!( > + "127.0.0.1:123".parse::().unwrap(), > + ServiceEndpoint { > + host: HostnameOrIpAddr::IpAddr([127, 0, 0, 1].into()), > + port: 123 > + } > + ); > + > + assert_eq!( > + "[::1]:123".parse::().unwrap(), > + ServiceEndpoint { > + host: HostnameOrIpAddr::IpAddr( > + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1].into() > + ), > + port: 123 > + } > + ); > + > + assert_eq!( > + "example.com:123".parse::().unwrap(), > + ServiceEndpoint { > + host: HostnameOrIpAddr::Hostname("example.com".to_owned()), > + port: 123 > + } > + ); > + } > +} > diff --git a/proxmox-network-types/src/lib.rs b/proxmox-network-types/src/lib.rs > index ee26b1c1..1bacbaf3 100644 > --- a/proxmox-network-types/src/lib.rs > +++ b/proxmox-network-types/src/lib.rs > @@ -1,5 +1,6 @@ > #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] > -#![deny(unsafe_op_in_unsafe_fn)] > +#![deny(unsafe_code, unsafe_op_in_unsafe_fn)] > > +pub mod endpoint; > pub mod ip_address; > pub mod mac_address;