Skip to main content

seyd_nat/
portmap.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Router port mapping — PCP (RFC 6887), NAT-PMP (RFC 6886), UPnP-IGD.
4//!
5//! An installed mapping beats an inferred one: it also covers port-restricted
6//! and many symmetric NATs, and it does not expire the way a hole-punched
7//! mapping does. Carrier-grade NAT refuses all three quickly and definitively,
8//! which is itself useful — it tells the pilot to stop hoping.
9//!
10//! All three are implemented directly (no miniupnpc, no reqwest): the UPnP
11//! side needs only SSDP, one HTTP GET and two SOAP POSTs on the LAN.
12
13use crate::addr::is_global;
14use rand::RngCore;
15use serde::Serialize;
16use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV4};
17use std::time::Duration;
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::{TcpStream, UdpSocket};
20
21const PCP_PORT: u16 = 5351;
22const SSDP_ADDR: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(239, 255, 255, 250), 1900);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum MapProtocol {
27    Pcp,
28    NatPmp,
29    Upnp,
30}
31
32/// An external `ip:port` the router agreed to forward to us.
33#[derive(Debug, Clone)]
34pub struct PortMapping {
35    pub protocol: MapProtocol,
36    pub external_ip: IpAddr,
37    pub external_port: u16,
38    pub internal_port: u16,
39    /// Seconds; 0 means the router calls it permanent.
40    pub lifetime_s: u32,
41    /// Whether `external_ip` is reachable from the public internet. A router
42    /// under double NAT or CGNAT installs the mapping and reports an address
43    /// nobody can route to; the mapping is still useful (it removes the inner
44    /// NAT from the path) but must not be advertised.
45    pub routable: bool,
46    gateway: Option<Ipv4Addr>,
47    upnp: Option<UpnpService>,
48}
49
50#[derive(Debug, Clone)]
51struct UpnpService {
52    control_url: String,
53    service_type: String,
54    internal_ip: Ipv4Addr,
55}
56
57impl PortMapping {
58    fn new(
59        protocol: MapProtocol,
60        external_ip: IpAddr,
61        external_port: u16,
62        internal_port: u16,
63        lifetime_s: u32,
64    ) -> Self {
65        PortMapping {
66            protocol,
67            external_ip,
68            external_port,
69            internal_port,
70            lifetime_s,
71            routable: is_global(external_ip),
72            gateway: None,
73            upnp: None,
74        }
75    }
76}
77
78// ── gateway discovery ──────────────────────────────────────────────────────
79
80/// Best-effort IPv4 default gateway, for the UDP-to-gateway protocols.
81pub fn default_gateway() -> Option<Ipv4Addr> {
82    // Linux: the kernel routing table.
83    if let Ok(table) = std::fs::read_to_string("/proc/net/route") {
84        for line in table.lines().skip(1) {
85            let parts: Vec<&str> = line.split_whitespace().collect();
86            if parts.len() > 2 && parts[1] == "00000000" {
87                if let Ok(raw) = u32::from_str_radix(parts[2], 16) {
88                    return Some(Ipv4Addr::from(raw.swap_bytes()));
89                }
90            }
91        }
92    }
93    // macOS / BSD: `route -n get default` prints "gateway: 192.168.1.1".
94    if let Ok(out) = std::process::Command::new("route")
95        .args(["-n", "get", "default"])
96        .output()
97    {
98        let text = String::from_utf8_lossy(&out.stdout);
99        for line in text.lines() {
100            if let Some(rest) = line.trim().strip_prefix("gateway:") {
101                if let Ok(ip) = rest.trim().parse() {
102                    return Some(ip);
103                }
104            }
105        }
106    }
107    None
108}
109
110/// Which of our addresses the kernel would use to reach `host`.
111fn local_ip_towards(host: Ipv4Addr) -> Option<Ipv4Addr> {
112    let s = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
113    s.connect((host, 9)).ok()?;
114    match s.local_addr().ok()?.ip() {
115        IpAddr::V4(v4) => Some(v4),
116        _ => None,
117    }
118}
119
120async fn udp_request(gateway: Ipv4Addr, payload: &[u8], expect_len: usize) -> Option<Vec<u8>> {
121    let sock = UdpSocket::bind("0.0.0.0:0").await.ok()?;
122    let mut buf = [0u8; 1024];
123    for _ in 0..3 {
124        sock.send_to(payload, (gateway, PCP_PORT)).await.ok()?;
125        match tokio::time::timeout(Duration::from_secs(1), sock.recv_from(&mut buf)).await {
126            Ok(Ok((n, _))) if n >= expect_len => return Some(buf[..n].to_vec()),
127            Ok(Ok(_)) => continue,
128            Ok(Err(_)) => return None,
129            Err(_) => continue,
130        }
131    }
132    None
133}
134
135// ── PCP (RFC 6887) ─────────────────────────────────────────────────────────
136
137/// PCP MAP request: version 2, opcode MAP, lifetime, client address as
138/// IPv4-mapped IPv6, 12-byte nonce, protocol UDP, internal + suggested port,
139/// any external address.
140pub fn encode_pcp_map(
141    client_ip: Ipv4Addr,
142    nonce: &[u8; 12],
143    internal_port: u16,
144    lifetime_s: u32,
145) -> [u8; 60] {
146    let mut r = [0u8; 60];
147    r[0] = 2;
148    r[1] = 1;
149    r[4..8].copy_from_slice(&lifetime_s.to_be_bytes());
150    r[8..24].copy_from_slice(&client_ip.to_ipv6_mapped().octets());
151    r[24..36].copy_from_slice(nonce);
152    r[36] = 17;
153    r[40..42].copy_from_slice(&internal_port.to_be_bytes());
154    r[42..44].copy_from_slice(&internal_port.to_be_bytes());
155    r
156}
157
158/// Parse a PCP MAP response: `(lifetime, external_port, external_ip)`.
159pub fn parse_pcp_map(reply: &[u8]) -> Option<(u32, u16, IpAddr)> {
160    if reply.len() < 60 || reply[0] != 2 || reply[1] != 0x81 {
161        return None;
162    }
163    if reply[3] != 0 {
164        tracing::debug!("PCP refused: result={}", reply[3]);
165        return None;
166    }
167    let lifetime = u32::from_be_bytes([reply[4], reply[5], reply[6], reply[7]]);
168    let ext_port = u16::from_be_bytes([reply[42], reply[43]]);
169    let mut raw = [0u8; 16];
170    raw.copy_from_slice(&reply[44..60]);
171    let v6 = Ipv6Addr::from(raw);
172    let ip = match v6.to_ipv4_mapped() {
173        Some(v4) => IpAddr::V4(v4),
174        None => IpAddr::V6(v6),
175    };
176    Some((lifetime, ext_port, ip))
177}
178
179async fn try_pcp(gateway: Ipv4Addr, internal_port: u16, lifetime_s: u32) -> Option<PortMapping> {
180    let client_ip = local_ip_towards(gateway)?;
181    let mut nonce = [0u8; 12];
182    rand::thread_rng().fill_bytes(&mut nonce);
183    let req = encode_pcp_map(client_ip, &nonce, internal_port, lifetime_s);
184    let reply = udp_request(gateway, &req, 60).await?;
185    let (granted, ext_port, ext_ip) = parse_pcp_map(&reply)?;
186    let mut m = PortMapping::new(MapProtocol::Pcp, ext_ip, ext_port, internal_port, granted);
187    m.gateway = Some(gateway);
188    Some(m)
189}
190
191// ── NAT-PMP (RFC 6886) ─────────────────────────────────────────────────────
192
193/// NAT-PMP map-UDP request (opcode 1): version, opcode, reserved, internal,
194/// suggested external, lifetime.
195pub fn encode_natpmp_map(internal_port: u16, lifetime_s: u32) -> [u8; 12] {
196    let mut r = [0u8; 12];
197    r[1] = 1;
198    r[4..6].copy_from_slice(&internal_port.to_be_bytes());
199    r[6..8].copy_from_slice(&internal_port.to_be_bytes());
200    r[8..12].copy_from_slice(&lifetime_s.to_be_bytes());
201    r
202}
203
204/// Parse a NAT-PMP map response: `(external_port, lifetime)`.
205pub fn parse_natpmp_map(reply: &[u8]) -> Option<(u16, u32)> {
206    if reply.len() < 16 || reply[0] != 0 || reply[1] != 129 {
207        return None;
208    }
209    let result = u16::from_be_bytes([reply[2], reply[3]]);
210    if result != 0 {
211        tracing::debug!("NAT-PMP refused: result={result}");
212        return None;
213    }
214    let ext_port = u16::from_be_bytes([reply[10], reply[11]]);
215    let lifetime = u32::from_be_bytes([reply[12], reply[13], reply[14], reply[15]]);
216    Some((ext_port, lifetime))
217}
218
219/// Parse a NAT-PMP external-address response (opcode 128).
220pub fn parse_natpmp_address(reply: &[u8]) -> Option<Ipv4Addr> {
221    if reply.len() < 12 || reply[0] != 0 || reply[1] != 128 || reply[2] != 0 || reply[3] != 0 {
222        return None;
223    }
224    Some(Ipv4Addr::new(reply[8], reply[9], reply[10], reply[11]))
225}
226
227async fn try_natpmp(gateway: Ipv4Addr, internal_port: u16, lifetime_s: u32) -> Option<PortMapping> {
228    let reply = udp_request(gateway, &encode_natpmp_map(internal_port, lifetime_s), 16).await?;
229    let (ext_port, granted) = parse_natpmp_map(&reply)?;
230    let addr_reply = udp_request(gateway, &[0, 0], 12).await?;
231    let ext_ip = parse_natpmp_address(&addr_reply)?;
232    let mut m = PortMapping::new(
233        MapProtocol::NatPmp,
234        IpAddr::V4(ext_ip),
235        ext_port,
236        internal_port,
237        granted,
238    );
239    m.gateway = Some(gateway);
240    Some(m)
241}
242
243// ── UPnP-IGD (SSDP discovery + SOAP control) ───────────────────────────────
244
245const SSDP_SEARCH: &str = "M-SEARCH * HTTP/1.1\r\n\
246HOST: 239.255.255.250:1900\r\n\
247MAN: \"ssdp:discover\"\r\n\
248MX: 2\r\n\
249ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\
250\r\n";
251
252const WAN_SERVICES: [&str; 2] = [
253    "urn:schemas-upnp-org:service:WANIPConnection:1",
254    "urn:schemas-upnp-org:service:WANPPPConnection:1",
255];
256
257async fn ssdp_discover(timeout: Duration) -> Option<String> {
258    let sock = UdpSocket::bind("0.0.0.0:0").await.ok()?;
259    sock.send_to(SSDP_SEARCH.as_bytes(), SSDP_ADDR).await.ok()?;
260    let mut buf = [0u8; 2048];
261    let deadline = tokio::time::Instant::now() + timeout;
262    loop {
263        let now = tokio::time::Instant::now();
264        if now >= deadline {
265            return None;
266        }
267        match tokio::time::timeout(deadline - now, sock.recv_from(&mut buf)).await {
268            Ok(Ok((n, _))) => {
269                let text = String::from_utf8_lossy(&buf[..n]);
270                for line in text.lines() {
271                    if let Some((k, v)) = line.split_once(':') {
272                        if k.trim().eq_ignore_ascii_case("location") {
273                            return Some(v.trim().to_string());
274                        }
275                    }
276                }
277            }
278            _ => return None,
279        }
280    }
281}
282
283/// Split `http://host[:port]/path` into `(host, port, path)`.
284fn parse_http_url(url: &str) -> Option<(String, u16, String)> {
285    let rest = url.strip_prefix("http://")?;
286    let (authority, path) = match rest.find('/') {
287        Some(i) => (&rest[..i], &rest[i..]),
288        None => (rest, "/"),
289    };
290    let (host, port) = match authority.rsplit_once(':') {
291        Some((h, p)) if !h.contains(']') || h.ends_with(']') => (h, p.parse().ok()?),
292        _ => (authority, 80),
293    };
294    Some((
295        host.trim_matches(|c| c == '[' || c == ']').to_string(),
296        port,
297        path.to_string(),
298    ))
299}
300
301/// Minimal HTTP/1.1 client for the LAN gateway: one request, whole body back.
302async fn http(url: &str, body: Option<&[u8]>, extra_headers: &[(&str, String)]) -> Option<Vec<u8>> {
303    let (host, port, path) = parse_http_url(url)?;
304    let mut stream = tokio::time::timeout(
305        Duration::from_secs(3),
306        TcpStream::connect((host.as_str(), port)),
307    )
308    .await
309    .ok()?
310    .ok()?;
311    let mut req = format!(
312        "{} {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\nUser-Agent: seyd\r\n",
313        if body.is_some() { "POST" } else { "GET" },
314        path,
315        host,
316        port
317    );
318    for (k, v) in extra_headers {
319        req.push_str(&format!("{k}: {v}\r\n"));
320    }
321    if let Some(b) = body {
322        req.push_str(&format!("Content-Length: {}\r\n", b.len()));
323    }
324    req.push_str("\r\n");
325    let mut bytes = req.into_bytes();
326    if let Some(b) = body {
327        bytes.extend_from_slice(b);
328    }
329    stream.write_all(&bytes).await.ok()?;
330    let mut resp = Vec::new();
331    tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut resp))
332        .await
333        .ok()?
334        .ok()?;
335    let split = find(&resp, b"\r\n\r\n")?;
336    let head = String::from_utf8_lossy(&resp[..split]).to_ascii_lowercase();
337    let status: u16 = head.split_whitespace().nth(1)?.parse().ok()?;
338    let mut body = resp[split + 4..].to_vec();
339    if head.contains("transfer-encoding: chunked") {
340        body = dechunk(&body);
341    }
342    if status != 200 {
343        tracing::debug!(
344            "UPnP HTTP {url} → {status}: {}",
345            String::from_utf8_lossy(&body)
346                .chars()
347                .take(200)
348                .collect::<String>()
349        );
350        return None;
351    }
352    Some(body)
353}
354
355fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
356    hay.windows(needle.len()).position(|w| w == needle)
357}
358
359fn dechunk(body: &[u8]) -> Vec<u8> {
360    let mut out = Vec::new();
361    let mut pos = 0;
362    while let Some(nl) = find(&body[pos..], b"\r\n") {
363        let size_str = String::from_utf8_lossy(&body[pos..pos + nl]);
364        let size = usize::from_str_radix(size_str.trim().split(';').next().unwrap_or("0"), 16)
365            .unwrap_or(0);
366        pos += nl + 2;
367        if size == 0 || pos + size > body.len() {
368            break;
369        }
370        out.extend_from_slice(&body[pos..pos + size]);
371        pos += size + 2;
372    }
373    out
374}
375
376fn text_between<'a>(s: &'a str, open: &str, close: &str) -> Option<&'a str> {
377    let start = s.find(open)? + open.len();
378    let end = s[start..].find(close)? + start;
379    Some(&s[start..end])
380}
381
382/// Locate a WAN connection service in the device description: `(controlURL, serviceType)`.
383pub fn find_control_url(desc: &str, base_url: &str) -> Option<(String, String)> {
384    let mut rest = desc;
385    while let Some(i) = rest.find("<service>") {
386        let block_start = i + "<service>".len();
387        let block_end = rest[block_start..].find("</service>")? + block_start;
388        let block = &rest[block_start..block_end];
389        let stype = text_between(block, "<serviceType>", "</serviceType>")
390            .map(str::trim)
391            .unwrap_or("");
392        let ctl = text_between(block, "<controlURL>", "</controlURL>")
393            .map(str::trim)
394            .unwrap_or("");
395        if WAN_SERVICES.contains(&stype) && !ctl.is_empty() {
396            return Some((join_url(base_url, ctl), stype.to_string()));
397        }
398        rest = &rest[block_end..];
399    }
400    None
401}
402
403fn join_url(base: &str, rel: &str) -> String {
404    if rel.starts_with("http://") {
405        return rel.to_string();
406    }
407    let Some((host, port, _)) = parse_http_url(base) else {
408        return rel.to_string();
409    };
410    format!(
411        "http://{host}:{port}{}{rel}",
412        if rel.starts_with('/') { "" } else { "/" }
413    )
414}
415
416async fn soap(svc: &UpnpService, action: &str, body: &str) -> Option<Vec<u8>> {
417    let envelope = format!(
418        "<?xml version=\"1.0\"?><s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" \
419s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body><u:{action} xmlns:u=\"{}\">{body}</u:{action}></s:Body></s:Envelope>",
420        svc.service_type
421    );
422    http(
423        &svc.control_url,
424        Some(envelope.as_bytes()),
425        &[
426            ("Content-Type", "text/xml; charset=\"utf-8\"".to_string()),
427            ("SOAPAction", format!("\"{}#{action}\"", svc.service_type)),
428        ],
429    )
430    .await
431}
432
433async fn upnp_add(svc: &UpnpService, internal_port: u16, lease: u32) -> bool {
434    let body = format!(
435        "<NewRemoteHost></NewRemoteHost><NewExternalPort>{internal_port}</NewExternalPort><NewProtocol>UDP</NewProtocol>\
436<NewInternalPort>{internal_port}</NewInternalPort><NewInternalClient>{}</NewInternalClient><NewEnabled>1</NewEnabled>\
437<NewPortMappingDescription>Seyd agent</NewPortMappingDescription><NewLeaseDuration>{lease}</NewLeaseDuration>",
438        svc.internal_ip
439    );
440    soap(svc, "AddPortMapping", &body).await.is_some()
441}
442
443async fn upnp_external_ip(svc: &UpnpService) -> Option<Ipv4Addr> {
444    let ext = soap(svc, "GetExternalIPAddress", "").await?;
445    let text = String::from_utf8_lossy(&ext);
446    text_between(&text, "<NewExternalIPAddress>", "</NewExternalIPAddress>")?
447        .trim()
448        .parse()
449        .ok()
450}
451
452async fn try_upnp(internal_port: u16, lifetime_s: u32) -> Option<PortMapping> {
453    let location = ssdp_discover(Duration::from_secs(3)).await?;
454    let desc = http(&location, None, &[]).await?;
455    let (control_url, service_type) = find_control_url(&String::from_utf8_lossy(&desc), &location)?;
456    let (host, _, _) = parse_http_url(&location)?;
457    let internal_ip = local_ip_towards(host.parse().ok()?)?;
458    let svc = UpnpService {
459        control_url,
460        service_type,
461        internal_ip,
462    };
463
464    let mut granted = lifetime_s;
465    if !upnp_add(&svc, internal_port, lifetime_s).await {
466        // Error 725 OnlyPermanentLeasesSupported is common; retry with 0.
467        if !upnp_add(&svc, internal_port, 0).await {
468            return None;
469        }
470        granted = 0;
471    }
472    let ext_ip = upnp_external_ip(&svc).await?;
473    let mut m = PortMapping::new(
474        MapProtocol::Upnp,
475        IpAddr::V4(ext_ip),
476        internal_port,
477        internal_port,
478        granted,
479    );
480    m.upnp = Some(svc);
481    Some(m)
482}
483
484// ── public entry points ────────────────────────────────────────────────────
485
486/// Ask the router to forward `internal_port/UDP` to us, trying PCP, NAT-PMP
487/// and UPnP in that order. `None` when nothing succeeds (no gateway, CGNAT,
488/// UPnP disabled, double NAT). Never panics.
489pub async fn map_port(internal_port: u16, lifetime_s: u32) -> Option<PortMapping> {
490    let gateway = default_gateway();
491    if gateway.is_none() {
492        tracing::debug!("no default gateway found — skipping PCP/NAT-PMP");
493    }
494    if let Some(gw) = gateway {
495        if let Some(m) = try_pcp(gw, internal_port, lifetime_s).await {
496            return Some(report(m));
497        }
498        if let Some(m) = try_natpmp(gw, internal_port, lifetime_s).await {
499            return Some(report(m));
500        }
501    }
502    if let Some(m) = try_upnp(internal_port, lifetime_s).await {
503        return Some(report(m));
504    }
505    tracing::info!("no router port mapping available (PCP, NAT-PMP and UPnP all declined)");
506    None
507}
508
509/// Re-request the same mapping before its lease expires. Returns the refreshed
510/// mapping, or `None` if the router declined this time (the caller should then
511/// re-run discovery and re-announce).
512pub async fn renew(mapping: &PortMapping) -> Option<PortMapping> {
513    let lifetime = if mapping.lifetime_s == 0 {
514        3600
515    } else {
516        mapping.lifetime_s
517    };
518    match mapping.protocol {
519        MapProtocol::Pcp => try_pcp(mapping.gateway?, mapping.internal_port, lifetime).await,
520        MapProtocol::NatPmp => try_natpmp(mapping.gateway?, mapping.internal_port, lifetime).await,
521        MapProtocol::Upnp => {
522            let svc = mapping.upnp.as_ref()?;
523            let granted = if upnp_add(svc, mapping.internal_port, lifetime).await {
524                lifetime
525            } else if upnp_add(svc, mapping.internal_port, 0).await {
526                0
527            } else {
528                return None;
529            };
530            let ext_ip = upnp_external_ip(svc).await?;
531            let mut m = PortMapping::new(
532                MapProtocol::Upnp,
533                IpAddr::V4(ext_ip),
534                mapping.internal_port,
535                mapping.internal_port,
536                granted,
537            );
538            m.upnp = Some(svc.clone());
539            Some(m)
540        }
541    }
542}
543
544fn report(m: PortMapping) -> PortMapping {
545    if m.routable {
546        tracing::info!(
547            "{:?} mapped {}:{} → us (lifetime {}s)",
548            m.protocol,
549            m.external_ip,
550            m.external_port,
551            m.lifetime_s
552        );
553    } else {
554        tracing::info!(
555            "{:?} mapped port {}, but the router's external address ({}) is not globally reachable — double NAT or CGNAT above it",
556            m.protocol,
557            m.external_port,
558            m.external_ip
559        );
560    }
561    m
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn pcp_request_layout() {
570        let nonce = [9u8; 12];
571        let r = encode_pcp_map(Ipv4Addr::new(192, 168, 1, 20), &nonce, 4433, 3600);
572        assert_eq!(r[0], 2, "version");
573        assert_eq!(r[1], 1, "MAP opcode");
574        assert_eq!(&r[4..8], &3600u32.to_be_bytes());
575        assert_eq!(&r[8..20], &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff]);
576        assert_eq!(&r[20..24], &[192, 168, 1, 20]);
577        assert_eq!(&r[24..36], &nonce);
578        assert_eq!(r[36], 17, "UDP");
579        assert_eq!(&r[40..42], &4433u16.to_be_bytes());
580        assert_eq!(&r[42..44], &4433u16.to_be_bytes());
581        assert_eq!(&r[44..60], &[0u8; 16]);
582    }
583
584    #[test]
585    fn pcp_response_parse() {
586        let mut reply = [0u8; 60];
587        reply[0] = 2;
588        reply[1] = 0x81;
589        reply[4..8].copy_from_slice(&7200u32.to_be_bytes());
590        reply[42..44].copy_from_slice(&4433u16.to_be_bytes());
591        reply[44..60].copy_from_slice(&Ipv4Addr::new(100, 87, 8, 146).to_ipv6_mapped().octets());
592        let (life, port, ip) = parse_pcp_map(&reply).unwrap();
593        assert_eq!(
594            (life, port, ip),
595            (7200, 4433, "100.87.8.146".parse().unwrap())
596        );
597        assert!(!is_global(ip), "CGNAT external must be non-routable");
598        reply[3] = 2;
599        assert!(parse_pcp_map(&reply).is_none(), "result code != 0");
600    }
601
602    #[test]
603    fn natpmp_layouts() {
604        let r = encode_natpmp_map(5000, 3600);
605        assert_eq!(r, [0, 1, 0, 0, 0x13, 0x88, 0x13, 0x88, 0, 0, 0x0e, 0x10]);
606        let mut reply = [0u8; 16];
607        reply[1] = 129;
608        reply[8..10].copy_from_slice(&5000u16.to_be_bytes());
609        reply[10..12].copy_from_slice(&5001u16.to_be_bytes());
610        reply[12..16].copy_from_slice(&1800u32.to_be_bytes());
611        assert_eq!(parse_natpmp_map(&reply), Some((5001, 1800)));
612        let mut addr = [0u8; 12];
613        addr[1] = 128;
614        addr[8..12].copy_from_slice(&[8, 8, 4, 4]);
615        assert_eq!(parse_natpmp_address(&addr), Some(Ipv4Addr::new(8, 8, 4, 4)));
616    }
617
618    #[test]
619    fn upnp_description_parsing() {
620        let desc = r#"<root><device><serviceList>
621<service><serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType><controlURL>/l3f</controlURL></service>
622<service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
623<controlURL>/ctl/IPConn</controlURL></service></serviceList></device></root>"#;
624        let (url, stype) = find_control_url(desc, "http://192.168.1.1:5000/rootDesc.xml").unwrap();
625        assert_eq!(url, "http://192.168.1.1:5000/ctl/IPConn");
626        assert_eq!(stype, "urn:schemas-upnp-org:service:WANIPConnection:1");
627        assert_eq!(
628            parse_http_url("http://10.0.0.1/desc.xml"),
629            Some(("10.0.0.1".into(), 80, "/desc.xml".into()))
630        );
631    }
632
633    #[test]
634    fn dechunk_works() {
635        assert_eq!(dechunk(b"3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"), b"abcde");
636    }
637}