isoloom_render/network/
ipv6.rs1use std::net::{Ipv4Addr, Ipv6Addr};
6
7use isoloom_plan::Environment;
8use isoloom_plan::ipv6::{Route6, block};
9use isoloom_spec::model::Spec;
10use isoloom_spec::shell::quote;
11use isoloom_spec::validate::IPV6_PREFIX as PREFIX;
12
13#[must_use]
17pub fn linux(addresses: &[(Ipv4Addr, Ipv6Addr)], routes: &[Route6]) -> String {
18 let on_interfaces = addresses.iter().map(|(v4, v6)| {
19 let find = quote(&format!(" inet {v4}/"));
20 format!(
21 "dev=$(ip -o -4 addr show | grep -F {find} | awk '{{print $2; exit}}')\nsysctl -q -w \"net.ipv6.conf.$dev.disable_ipv6=0\"\nip -6 addr replace {v6}/{PREFIX} dev \"$dev\" nodad\n"
22 )
23 });
24 let through_router = routes.iter().map(|r| format!("ip -6 route replace {} via {}\n", r.cidr, r.via));
25 on_interfaces.chain(through_router).collect()
26}
27
28#[must_use]
31pub fn windows(addresses: &[(Ipv4Addr, Ipv6Addr)], routes: &[Route6]) -> String {
32 let mut lines: Vec<String> = addresses
33 .iter()
34 .map(|(v4, v6)| {
35 format!(
36 "if (-not (Get-NetIPAddress -IPAddress '{v6}' -ErrorAction SilentlyContinue)) {{ New-NetIPAddress -InterfaceIndex (Get-NetIPAddress -IPAddress '{v4}').InterfaceIndex -IPAddress '{v6}' -PrefixLength {PREFIX} | Out-Null }}"
37 )
38 })
39 .collect();
40 lines.extend(routes.iter().map(|r| {
41 format!(
42 "if (-not (Get-NetRoute -DestinationPrefix '{c}' -ErrorAction SilentlyContinue)) {{ New-NetRoute -DestinationPrefix '{c}' -InterfaceIndex (Find-NetRoute -RemoteIPAddress '{via}' | Select-Object -First 1).InterfaceIndex -NextHop '{via}' | Out-Null }}",
43 c = r.cidr,
44 via = r.via
45 )
46 }));
47 lines.join("\n")
48}
49
50#[must_use]
53pub fn router_linux(addresses: &[(Ipv4Addr, Ipv6Addr)]) -> Option<String> {
54 (!addresses.is_empty()).then(|| format!("sysctl -q -w net.ipv6.conf.all.forwarding=1\n{}", linux(addresses, &[])))
55}
56
57#[must_use]
60pub fn egress_rule(env: &Environment) -> String {
61 let blocks: Vec<&str> = env.networks.values().filter_map(|n| n.ipv6.as_ref()).map(|v| v.cidr.as_str()).collect();
62 if blocks.is_empty() {
63 return String::new();
64 }
65 format!(" ' ip6 daddr != {{ {} }} ct state new drop'", blocks.join(", "))
66}
67
68#[must_use]
71pub fn forward_rules(spec: &Spec, from: &str, to: &str, ports: &[u16]) -> Vec<String> {
72 let (Some(from), Some(to)) = (block(spec, from), block(spec, to)) else {
73 return Vec::new();
74 };
75 let (from, to) = (from.to_string(), to.to_string());
76 if ports.is_empty() {
77 return vec![format!("ip6 saddr {from} ip6 daddr {to} accept")];
78 }
79 let ports = ports.iter().map(u16::to_string).collect::<Vec<_>>().join(", ");
80 vec![
81 format!("ip6 saddr {from} ip6 daddr {to} meta l4proto {{ tcp, udp }} th dport {{ {ports} }} accept"),
82 format!("ip6 saddr {from} ip6 daddr {to} icmpv6 type echo-request accept"),
83 ]
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use crate::testing;
90
91 fn addresses() -> [(Ipv4Addr, Ipv6Addr); 1] {
93 [("10.1.0.10".parse().unwrap(), "fd00:1::10".parse().unwrap())]
94 }
95
96 fn routes() -> [Route6; 1] {
98 [Route6 {
99 cidr: "fd00:2::/64".into(),
100 via: "fd00:1::254".parse().unwrap(),
101 }]
102 }
103
104 #[test]
105 fn the_address_lands_on_the_interface_of_the_ipv4_one() {
106 let sh = linux(&addresses(), &routes());
107 assert!(sh.contains("grep -F ' inet 10.1.0.10/'"), "{sh}");
108 assert!(sh.contains("ip -6 addr replace fd00:1::10/64 dev \"$dev\" nodad"), "{sh}");
109 assert!(sh.ends_with("ip -6 route replace fd00:2::/64 via fd00:1::254\n"), "{sh}");
110 let ps = windows(&addresses(), &routes());
111 assert_eq!(ps.lines().count(), 2, "{ps}");
112 assert!(ps.contains("-IPAddress 'fd00:1::10' -PrefixLength 64"), "{ps}");
113 assert!(
114 ps.contains("New-NetRoute -DestinationPrefix 'fd00:2::/64'") && ps.contains("-NextHop 'fd00:1::254'"),
115 "{ps}"
116 );
117 }
118
119 #[test]
120 fn the_router_forwards_ipv6_only_where_it_routes_an_ipv6_network() {
121 assert_eq!(router_linux(&[]), None);
122 let sh = router_linux(&[("10.1.0.254".parse().unwrap(), "fd00:1::254".parse().unwrap())]).expect("an IPv6 network");
123 assert!(sh.starts_with("sysctl -q -w net.ipv6.conf.all.forwarding=1\ndev=$(ip"), "{sh}");
124 }
125
126 #[test]
127 fn offline_drops_new_connections_outside_the_ipv6_blocks() {
128 const SPEC: &str = "version: 1\nname: e\nnetworks:\n a: { cidr: 10.1.0.0/24, ipv6: { cidr: \"fd00:1::/64\" } }\n b: { cidr: 10.2.0.0/24 }\nmachines:\n x: { networks: { a: 10, b: 10 }, os: debian@12 }\n";
129 let env = testing::env(SPEC, isoloom_spec::model::Target::Vagrant);
130 assert_eq!(egress_rule(&env), " ' ip6 daddr != { fd00:1::/64 } ct state new drop'");
131 let v4 = testing::env(
132 "version: 1\nname: e\nnetworks: { b: { cidr: 10.2.0.0/24 } }\nmachines:\n x: { networks: { b: 10 }, os: debian@12 }\n",
133 isoloom_spec::model::Target::Vagrant,
134 );
135 assert_eq!(egress_rule(&v4), "");
136 }
137
138 #[test]
139 fn rules_only_between_two_ipv6_networks() {
140 let spec = testing::spec(
141 "version: 1\nname: r\nnetworks:\n a: { cidr: 10.1.0.0/24, ipv6: { cidr: \"fd00:1::/64\" } }\n b: { cidr: 10.2.0.0/24, ipv6: { cidr: \"fd00:2::/64\" } }\n c: { cidr: 10.3.0.0/24 }\nmachines:\n x: { networks: { a: 10 }, os: debian@12 }\n",
142 );
143 assert_eq!(forward_rules(&spec, "a", "b", &[]), vec!["ip6 saddr fd00:1::/64 ip6 daddr fd00:2::/64 accept"]);
144 assert_eq!(forward_rules(&spec, "a", "b", &[80]).len(), 2);
145 assert_eq!(forward_rules(&spec, "a", "c", &[]), Vec::<String>::new());
146 }
147}