1//! Where the service (`whiskersd`) is: a host and a port, typed by a grown-up and kept on the device. 2//! 3//! A [`ServiceAddress`] can only be built by [`ServiceAddress::parse`], so a shell never builds a URL 4//! by gluing strings: it asks the address for its [`url`](ServiceAddress::url). The service is meant to 5//! be reached over a private network (a VPN) that does the encrypting, so the scheme is always plain 6//! `http`; an `https://` address is refused with an explanation rather than quietly downgraded. 7//! 8//! Errors say what is wrong and never quote what was typed. 9 10use std::fmt; 11use std::net::{Ipv4Addr, Ipv6Addr}; 12 13/// The port `whiskersd` listens on unless told otherwise. 14pub const DEFAULT_PORT: u16 = 47900; 15 16/// A reachable-in-principle place: a plausible host and a port. Equal addresses are the same place, however 17/// they were typed (case and a default port do not matter). 18#[derive(Clone, Debug, PartialEq, Eq, Hash)] 19pub struct ServiceAddress { 20 host: Host, 21 port: u16, 22} 23 24#[derive(Clone, Debug, PartialEq, Eq, Hash)] 25enum Host { 26 /// A lower-case DNS-style name (a MagicDNS name or a one-word machine name included). 27 Name(String), 28 V4(Ipv4Addr), 29 V6(Ipv6Addr), 30} 31 32/// Why an address was refused. 33#[derive(Clone, Copy, Debug, PartialEq, Eq)] 34pub enum AddressError { 35 Empty, 36 /// Spaces or other whitespace inside it. 37 Whitespace, 38 /// `https://`: the service speaks plain http, over a private network that encrypts. 39 Https, 40 /// Any other scheme than `http://`. 41 OtherScheme, 42 /// A path, query or fragment: only the machine is wanted. 43 NotJustAMachine, 44 /// `name@host`. 45 Credentials, 46 NotAHost, 47 BadPort, 48} 49 50impl fmt::Display for AddressError { 51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 52 f.write_str(match self { 53 AddressError::Empty => "Type the address of the machine that runs the Whiskers service.", 54 AddressError::Whitespace => "An address has no spaces in it.", 55 AddressError::Https => { 56 "Whiskers talks to its service over plain http, on a private network such as a VPN that does the encrypting. Leave off the https:// (or type http://)." 57 } 58 AddressError::OtherScheme => "Only http:// addresses are used. Leave the beginning off, or type http://.", 59 AddressError::NotJustAMachine => "Only the machine is needed: no path, no question mark and no extras after it. For example 192.0.2.10 or 192.0.2.10:47900.", 60 AddressError::Credentials => "An address has no name or password in it.", 61 AddressError::NotAHost => "That does not look like a machine name or number.", 62 AddressError::BadPort => "The port must be a number from 1 to 65535.", 63 }) 64 } 65} 66 67impl std::error::Error for AddressError {} 68 69impl ServiceAddress { 70 /// Reads what a grown-up typed: a bare host, `host:port`, or either with a leading `http://`; surrounding 71 /// whitespace is ignored and one trailing `/` is allowed. The port defaults to [`DEFAULT_PORT`]. 72 /// IPv6 hosts are written in brackets, as in `[fd7a::1]:47900`. 73 pub fn parse(typed: &str) -> Result<ServiceAddress, AddressError> { 74 let result = Self::parse_inner(typed); 75 match &result { 76 Ok(a) => ::log::debug!("service address accepted (port {})", a.port), 77 Err(e) => ::log::debug!("service address refused: {e:?}"), 78 } 79 result 80 } 81 82 fn parse_inner(typed: &str) -> Result<ServiceAddress, AddressError> { 83 let t = typed.trim(); 84 if t.is_empty() { 85 return Err(AddressError::Empty); 86 } 87 if t.chars().any(char::is_whitespace) { 88 return Err(AddressError::Whitespace); 89 } 90 let rest = match t.split_once("://") { 91 Some((scheme, rest)) if scheme.eq_ignore_ascii_case("http") => rest, 92 Some((scheme, _)) if scheme.eq_ignore_ascii_case("https") => return Err(AddressError::Https), 93 Some(_) => return Err(AddressError::OtherScheme), 94 None => t, 95 }; 96 let rest = rest.strip_suffix('/').unwrap_or(rest); 97 if rest.contains('@') { 98 return Err(AddressError::Credentials); 99 } 100 if rest.contains(['/', '?', '#', '\\']) { 101 return Err(AddressError::NotJustAMachine); 102 } 103 let (host_text, port_text) = if let Some(inner) = rest.strip_prefix('[') { 104 let (v6, after) = inner.split_once(']').ok_or(AddressError::NotAHost)?; 105 match after { 106 "" => (format!("[{v6}]"), None), 107 _ => (format!("[{v6}]"), Some(after.strip_prefix(':').ok_or(AddressError::NotAHost)?)), 108 } 109 } else { 110 match rest.split_once(':') { 111 Some((h, p)) => (h.to_owned(), Some(p)), 112 None => (rest.to_owned(), None), 113 } 114 }; 115 let port = match port_text { 116 None => DEFAULT_PORT, 117 Some(p) => { 118 if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) { 119 return Err(AddressError::BadPort); 120 } 121 match p.parse::<u16>() { 122 Ok(n) if n != 0 => n, 123 _ => return Err(AddressError::BadPort), 124 } 125 } 126 }; 127 Ok(ServiceAddress { host: parse_host(&host_text)?, port }) 128 } 129 130 pub fn port(&self) -> u16 { 131 self.port 132 } 133 134 /// What to show and keep: the host, and the port only when it is not the default. It parses back to an 135 /// equal address. 136 pub fn text(&self) -> String { 137 if self.port == DEFAULT_PORT { self.host.to_string() } else { format!("{}:{}", self.host, self.port) } 138 } 139 140 /// The base URL every request is made under, without a trailing slash. 141 pub fn url(&self) -> String { 142 format!("http://{}:{}", self.host, self.port) 143 } 144} 145 146impl fmt::Display for Host { 147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 148 match self { 149 Host::Name(n) => f.write_str(n), 150 Host::V4(a) => write!(f, "{a}"), 151 Host::V6(a) => write!(f, "[{a}]"), 152 } 153 } 154} 155 156fn parse_host(h: &str) -> Result<Host, AddressError> { 157 if let Some(inner) = h.strip_prefix('[') { 158 return inner.strip_suffix(']').and_then(|v| v.parse::<Ipv6Addr>().ok()).map(Host::V6).ok_or(AddressError::NotAHost); 159 } 160 // A trailing dot makes a name fully qualified; it names the same machine. 161 let h = h.strip_suffix('.').unwrap_or(h); 162 if h.is_empty() || h.len() > 253 { 163 return Err(AddressError::NotAHost); 164 } 165 if let Ok(v4) = h.parse::<Ipv4Addr>() { 166 return Ok(Host::V4(v4)); 167 } 168 // Digits and dots only, yet not an address (`999.1.1.1`, `1.2.3`): a typo, not a name. 169 if h.bytes().all(|b| b.is_ascii_digit() || b == b'.') { 170 return Err(AddressError::NotAHost); 171 } 172 let name = h.to_ascii_lowercase(); 173 let label_ok = |l: &str| { 174 !l.is_empty() 175 && l.len() <= 63 176 && !l.starts_with('-') 177 && !l.ends_with('-') 178 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') 179 }; 180 if name.split('.').all(label_ok) { Ok(Host::Name(name)) } else { Err(AddressError::NotAHost) } 181} 182 183#[cfg(test)] 184mod tests { 185 use super::*; 186 187 fn ok(s: &str) -> ServiceAddress { 188 ServiceAddress::parse(s).unwrap_or_else(|e| panic!("{s:?} should parse: {e:?}")) 189 } 190 191 #[test] 192 fn a_bare_host_gets_the_default_port() { 193 let a = ok("192.0.2.10"); 194 assert_eq!(a.url(), "http://192.0.2.10:47900"); 195 assert_eq!(a.text(), "192.0.2.10"); 196 assert_eq!(a.port(), DEFAULT_PORT); 197 } 198 199 #[test] 200 fn every_way_of_typing_the_same_place_is_one_address() { 201 let a = ok("192.0.2.10"); 202 for s in ["192.0.2.10:47900", "http://192.0.2.10", "http://192.0.2.10:47900/", " 192.0.2.10\n", "HTTP://192.0.2.10"] { 203 assert_eq!(ok(s), a, "{s:?}"); 204 } 205 assert_eq!(ok("Box.Example.TS.net"), ok("box.example.ts.net.")); 206 } 207 208 #[test] 209 fn a_port_other_than_the_default_is_kept() { 210 let a = ok("http://box.example:8080"); 211 assert_eq!(a.url(), "http://box.example:8080"); 212 assert_eq!(a.text(), "box.example:8080"); 213 } 214 215 #[test] 216 fn text_parses_back_to_the_same_address() { 217 for s in ["192.0.2.10", "host:1", "one-word", "a.b.c:65535", "[fd7a::1]", "[fd7a::1]:8080"] { 218 let a = ok(s); 219 assert_eq!(ok(&a.text()), a, "{s:?}"); 220 } 221 } 222 223 #[test] 224 fn a_one_word_machine_name_is_a_host() { 225 assert_eq!(ok("Henhouse").url(), "http://henhouse:47900"); 226 } 227 228 #[test] 229 fn ipv6_is_written_in_brackets() { 230 assert_eq!(ok("[fd7a::1]:8080").url(), "http://[fd7a::1]:8080"); 231 assert_eq!(ok("[fd7a::1]").url(), "http://[fd7a::1]:47900"); 232 assert!(ServiceAddress::parse("fd7a::1").is_err()); 233 assert_eq!(ServiceAddress::parse("[fd7a::1"), Err(AddressError::NotAHost)); 234 assert_eq!(ServiceAddress::parse("[nothex]"), Err(AddressError::NotAHost)); 235 assert_eq!(ServiceAddress::parse("[::1]x"), Err(AddressError::NotAHost)); 236 } 237 238 #[test] 239 fn empty_and_blank_are_refused() { 240 assert_eq!(ServiceAddress::parse(""), Err(AddressError::Empty)); 241 assert_eq!(ServiceAddress::parse(" \t"), Err(AddressError::Empty)); 242 assert_eq!(ServiceAddress::parse("http://"), Err(AddressError::NotAHost)); 243 } 244 245 #[test] 246 fn whitespace_inside_is_refused() { 247 assert_eq!(ServiceAddress::parse("192.0.2. 10"), Err(AddressError::Whitespace)); 248 assert_eq!(ServiceAddress::parse("my host"), Err(AddressError::Whitespace)); 249 } 250 251 #[test] 252 fn https_is_refused_with_its_own_explanation() { 253 let e = ServiceAddress::parse("https://box.example").unwrap_err(); 254 assert_eq!(e, AddressError::Https); 255 assert!(e.to_string().contains("plain http")); 256 assert_eq!(ServiceAddress::parse("HTTPS://box.example"), Err(AddressError::Https)); 257 assert_eq!(ServiceAddress::parse("ftp://box.example"), Err(AddressError::OtherScheme)); 258 } 259 260 #[test] 261 fn paths_queries_and_fragments_are_refused() { 262 for s in ["box.example/usage", "http://box.example/a/b", "box.example?x=1", "box.example:47900#top", "box.example\\x", "http://box.example//"] { 263 assert_eq!(ServiceAddress::parse(s), Err(AddressError::NotJustAMachine), "{s:?}"); 264 } 265 } 266 267 #[test] 268 fn credentials_are_refused() { 269 assert_eq!(ServiceAddress::parse("user@box.example"), Err(AddressError::Credentials)); 270 assert_eq!(ServiceAddress::parse("http://user:pw@box.example:47900"), Err(AddressError::Credentials)); 271 } 272 273 #[test] 274 fn ports_are_checked() { 275 for s in ["box:0", "box:65536", "box:", "box:abc", "box:-1", "box:+80", "box:80:80"] { 276 assert!(ServiceAddress::parse(s).is_err(), "{s:?}"); 277 } 278 assert_eq!(ServiceAddress::parse("box:0"), Err(AddressError::BadPort)); 279 assert_eq!(ServiceAddress::parse("box:65536"), Err(AddressError::BadPort)); 280 assert_eq!(ok("box:65535").port(), 65535); 281 } 282 283 #[test] 284 fn implausible_hosts_are_refused() { 285 for s in ["999.1.1.1", "1.2.3", "-box", "box-", "a..b", ".box", "bo_x", "bo$x", "ex ample", "é.example", ":47900", "a".repeat(64).as_str()] { 286 assert!(ServiceAddress::parse(s).is_err(), "{s:?}"); 287 } 288 assert!(ServiceAddress::parse(&format!("{}.example", "a".repeat(63))).is_ok()); 289 assert!(ServiceAddress::parse(&format!("{}.example", "a".repeat(64))).is_err()); 290 } 291 292 #[test] 293 fn an_error_never_quotes_what_was_typed() { 294 let secret = "hunter2-should-not-appear"; 295 for typed in [format!("{secret}@box"), format!("https://{secret}"), format!("{secret}/x"), format!("{secret}:99999"), format!("{secret} x"), format!("{secret}_")] { 296 let e = ServiceAddress::parse(&typed).unwrap_err(); 297 assert!(!e.to_string().contains(secret)); 298 assert!(!format!("{e:?}").contains(secret)); 299 } 300 } 301}