1use 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#[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 pub lifetime_s: u32,
41 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
78pub fn default_gateway() -> Option<Ipv4Addr> {
82 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 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
110fn 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
135pub 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
158pub 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
191pub 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
204pub 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
219pub 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
243const 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
283fn 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
301async 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
382pub 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 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
484pub 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
509pub 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}