|
| 1 | +//! nftables rules for the dedicated `ip portmap` table. |
| 2 | +//! |
| 3 | +//! Port mapping DNAT rules live in their own table, separate from the `ip |
| 4 | +//! nat` table that [`crate::nft::apply_nat_for_router`] manages, because |
| 5 | +//! [`crate::Router::set_nat_mode`] flushes the latter wholesale. Using a |
| 6 | +//! separate table means runtime NAT reconfiguration never discards active |
| 7 | +//! mappings. |
| 8 | +//! |
| 9 | +//! The table is re-rendered in full whenever the mapping set changes, so |
| 10 | +//! the server keeps the authoritative mapping list in the registry and |
| 11 | +//! lets nftables state be derived from it. This is simple, atomic when |
| 12 | +//! applied through a single `nft -f` invocation, and avoids the complexity |
| 13 | +//! of rule handles. |
| 14 | +
|
| 15 | +use std::net::Ipv4Addr; |
| 16 | + |
| 17 | +use anyhow::Result; |
| 18 | + |
| 19 | +use super::registry::{MapProto, Mapping}; |
| 20 | +use crate::{netns, nft::run_nft_in}; |
| 21 | + |
| 22 | +/// Generates the full `ip portmap` table body for the given mappings. |
| 23 | +/// |
| 24 | +/// Runs at nft priority `-110`, which sits 10 below the `dstnat` priority |
| 25 | +/// (`-100`) used by [`crate::nft::apply_nat_for_router`]. That placement |
| 26 | +/// guarantees a static port forward DNAT applies before the fullcone map |
| 27 | +/// rule, and that the packet's changed destination no longer matches the |
| 28 | +/// fullcone criteria downstream. |
| 29 | +/// |
| 30 | +/// The rule form is `ip daddr <wan_ip> meta l4proto <p> dport <ext> dnat |
| 31 | +/// to <internal_ip>:<internal_port>`. Matching on `daddr` (rather than |
| 32 | +/// `iif`) lets a LAN host hitting the router's WAN IP through hairpin |
| 33 | +/// follow the same DNAT path as traffic arriving from the uplink. |
| 34 | +pub(crate) fn generate_portmap_rules(wan_ip: Ipv4Addr, mappings: &[Mapping]) -> String { |
| 35 | + let mut rules = String::new(); |
| 36 | + rules.push_str("table ip portmap {\n"); |
| 37 | + rules.push_str(" chain prerouting {\n"); |
| 38 | + rules.push_str(" type nat hook prerouting priority -110; policy accept;\n"); |
| 39 | + for m in mappings { |
| 40 | + let proto = match m.proto { |
| 41 | + MapProto::Udp => "udp", |
| 42 | + MapProto::Tcp => "tcp", |
| 43 | + }; |
| 44 | + rules.push_str(&format!( |
| 45 | + " ip daddr {wan} {proto} dport {ext} dnat to {ip}:{port}\n", |
| 46 | + wan = wan_ip, |
| 47 | + proto = proto, |
| 48 | + ext = m.external_port.get(), |
| 49 | + ip = m.internal_ip, |
| 50 | + port = m.internal_port.get(), |
| 51 | + )); |
| 52 | + } |
| 53 | + rules.push_str(" }\n"); |
| 54 | + rules.push_str("}\n"); |
| 55 | + rules |
| 56 | +} |
| 57 | + |
| 58 | +/// Flushes and repopulates the `ip portmap` table in `ns`. |
| 59 | +/// |
| 60 | +/// The table is deleted if present, then re-declared with the new rule |
| 61 | +/// set. `delete table` fails if the table does not exist; nft treats that |
| 62 | +/// as an error and aborts the containing script, so the delete runs |
| 63 | +/// separately and its error is swallowed before the fresh ruleset is |
| 64 | +/// installed. Safe to call with an empty `mappings` slice, which leaves |
| 65 | +/// the table declared but empty. |
| 66 | +pub(crate) async fn apply_portmap_rules( |
| 67 | + netns: &netns::NetnsManager, |
| 68 | + ns: &str, |
| 69 | + wan_ip: Ipv4Addr, |
| 70 | + mappings: &[Mapping], |
| 71 | +) -> Result<()> { |
| 72 | + run_nft_in(netns, ns, "delete table ip portmap\n") |
| 73 | + .await |
| 74 | + .ok(); |
| 75 | + run_nft_in(netns, ns, &generate_portmap_rules(wan_ip, mappings)).await |
| 76 | +} |
| 77 | + |
| 78 | +/// Removes the `ip portmap` table entirely. Idempotent. |
| 79 | +pub(crate) async fn clear_portmap_rules(netns: &netns::NetnsManager, ns: &str) -> Result<()> { |
| 80 | + run_nft_in(netns, ns, "delete table ip portmap\n") |
| 81 | + .await |
| 82 | + .ok(); |
| 83 | + Ok(()) |
| 84 | +} |
| 85 | + |
| 86 | +#[cfg(test)] |
| 87 | +mod tests { |
| 88 | + use std::{net::Ipv4Addr, num::NonZeroU16, time::Instant}; |
| 89 | + |
| 90 | + use super::*; |
| 91 | + |
| 92 | + fn mapping(proto: MapProto, ext: u16, internal_ip: [u8; 4], internal_port: u16) -> Mapping { |
| 93 | + Mapping { |
| 94 | + proto, |
| 95 | + external_port: NonZeroU16::new(ext).unwrap(), |
| 96 | + internal_ip: Ipv4Addr::from(internal_ip), |
| 97 | + internal_port: NonZeroU16::new(internal_port).unwrap(), |
| 98 | + deadline: Instant::now() + std::time::Duration::from_secs(60), |
| 99 | + pcp_nonce: None, |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + #[test] |
| 104 | + fn empty_rules_render_declares_table_and_chain() { |
| 105 | + let rendered = generate_portmap_rules(Ipv4Addr::new(198, 51, 100, 1), &[]); |
| 106 | + assert!(rendered.contains("table ip portmap")); |
| 107 | + assert!(rendered.contains("chain prerouting")); |
| 108 | + assert!(rendered.contains("priority -110")); |
| 109 | + // No DNAT rules when mappings is empty. |
| 110 | + assert!(!rendered.contains("dnat")); |
| 111 | + } |
| 112 | + |
| 113 | + #[test] |
| 114 | + fn udp_mapping_renders_as_udp_dnat() { |
| 115 | + let rendered = generate_portmap_rules( |
| 116 | + Ipv4Addr::new(198, 51, 100, 1), |
| 117 | + &[mapping(MapProto::Udp, 5000, [10, 0, 0, 5], 1234)], |
| 118 | + ); |
| 119 | + assert!(rendered.contains("ip daddr 198.51.100.1 udp dport 5000 dnat to 10.0.0.5:1234",)); |
| 120 | + } |
| 121 | + |
| 122 | + #[test] |
| 123 | + fn tcp_mapping_renders_as_tcp_dnat() { |
| 124 | + let rendered = generate_portmap_rules( |
| 125 | + Ipv4Addr::new(198, 51, 100, 1), |
| 126 | + &[mapping(MapProto::Tcp, 8080, [10, 0, 0, 5], 80)], |
| 127 | + ); |
| 128 | + assert!(rendered.contains("tcp dport 8080 dnat to 10.0.0.5:80")); |
| 129 | + } |
| 130 | + |
| 131 | + #[test] |
| 132 | + fn multiple_mappings_render_independent_rules() { |
| 133 | + let rendered = generate_portmap_rules( |
| 134 | + Ipv4Addr::new(198, 51, 100, 1), |
| 135 | + &[ |
| 136 | + mapping(MapProto::Udp, 5000, [10, 0, 0, 5], 1234), |
| 137 | + mapping(MapProto::Tcp, 8080, [10, 0, 0, 6], 80), |
| 138 | + ], |
| 139 | + ); |
| 140 | + assert_eq!( |
| 141 | + rendered.matches("dnat to").count(), |
| 142 | + 2, |
| 143 | + "two mappings produce two rules", |
| 144 | + ); |
| 145 | + } |
| 146 | +} |
0 commit comments