Skip to content

Commit 2b10e35

Browse files
committed
nat64: T160: Implement Jool-based NAT64 translator
Signed-off-by: Joe Groocock <me@frebib.net>
1 parent aea8922 commit 2b10e35

File tree

4 files changed

+358
-0
lines changed

4 files changed

+358
-0
lines changed

debian/control

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Depends:
7878
isc-dhcp-relay,
7979
isc-dhcp-server,
8080
iw,
81+
jool,
8182
keepalived (>=2.0.5),
8283
lcdproc,
8384
lcdproc-extra-drivers,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!-- include start from nat64-protocol.xml.i -->
2+
<node name="protocol">
3+
<properties>
4+
<help>Apply translation address to a specfic protocol</help>
5+
</properties>
6+
<children>
7+
<leafNode name="tcp">
8+
<properties>
9+
<help>Transmission Control Protocol</help>
10+
<valueless/>
11+
</properties>
12+
</leafNode>
13+
<leafNode name="udp">
14+
<properties>
15+
<help>User Datagram Protocol</help>
16+
<valueless/>
17+
</properties>
18+
</leafNode>
19+
<leafNode name="icmp">
20+
<properties>
21+
<help>Internet Message Control Protocol</help>
22+
<valueless/>
23+
</properties>
24+
</leafNode>
25+
</children>
26+
</node>
27+
<!-- include end -->

interface-definitions/nat64.xml.in

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?xml version="1.0"?>
2+
<interfaceDefinition>
3+
<node name="nat64" owner="${vyos_conf_scripts_dir}/nat64.py">
4+
<properties>
5+
<help>IPv6-to-IPv4 Network Address Translation (NAT64) Settings</help>
6+
<priority>500</priority>
7+
</properties>
8+
<children>
9+
<node name="source">
10+
<properties>
11+
<help>IPv6 source to IPv4 destination address translation</help>
12+
</properties>
13+
<children>
14+
<tagNode name="rule">
15+
<properties>
16+
<help>Source NAT64 rule number</help>
17+
<valueHelp>
18+
<format>u32:1-999999</format>
19+
<description>Number for this rule</description>
20+
</valueHelp>
21+
<constraint>
22+
<validator name="numeric" argument="--range 1-999999"/>
23+
</constraint>
24+
<constraintErrorMessage>NAT64 rule number must be between 1 and 999999</constraintErrorMessage>
25+
</properties>
26+
<children>
27+
#include <include/generic-description.xml.i>
28+
#include <include/generic-disable-node.xml.i>
29+
<node name="source">
30+
<properties>
31+
<help>IPv6 source prefix options</help>
32+
</properties>
33+
<children>
34+
<leafNode name="prefix">
35+
<properties>
36+
<help>IPv6 prefix to be translated</help>
37+
<valueHelp>
38+
<format>ipv6net</format>
39+
<description>IPv6 prefix</description>
40+
</valueHelp>
41+
<constraint>
42+
<validator name="ipv6-prefix"/>
43+
</constraint>
44+
</properties>
45+
</leafNode>
46+
</children>
47+
</node>
48+
<node name="translation">
49+
<properties>
50+
<help>Translated IPv4 address options</help>
51+
</properties>
52+
<children>
53+
<tagNode name="pool">
54+
<properties>
55+
<help>Translation IPv4 pool number</help>
56+
<valueHelp>
57+
<format>u32:1-999999</format>
58+
<description>Number for this rule</description>
59+
</valueHelp>
60+
<constraint>
61+
<validator name="numeric" argument="--range 1-999999"/>
62+
</constraint>
63+
<constraintErrorMessage>NAT64 pool number must be between 1 and 999999</constraintErrorMessage>
64+
</properties>
65+
<children>
66+
<leafNode name="address">
67+
<properties>
68+
<help>IPv4 address or prefix to translate to</help>
69+
<valueHelp>
70+
<format>ipv4</format>
71+
<description>IPv4 address</description>
72+
</valueHelp>
73+
<valueHelp>
74+
<format>ipv4net</format>
75+
<description>IPv4 prefix</description>
76+
</valueHelp>
77+
<constraint>
78+
<validator name="ipv4-address"/>
79+
<validator name="ipv4-prefix"/>
80+
</constraint>
81+
</properties>
82+
</leafNode>
83+
#include <include/nat-translation-port.xml.i>
84+
#include <include/nat64-protocol.xml.i>
85+
</children>
86+
</tagNode>
87+
</children>
88+
</node>
89+
</children>
90+
</tagNode>
91+
</children>
92+
</node>
93+
</children>
94+
</node>
95+
</interfaceDefinition>

src/conf_mode/nat64.py

+235
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (C) 2023 VyOS maintainers and contributors
4+
#
5+
# This program is free software; you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License version 2 or later as
7+
# published by the Free Software Foundation.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
# pylint: disable=empty-docstring,missing-module-docstring
18+
19+
import csv
20+
import json
21+
import os
22+
import re
23+
from ipaddress import IPv6Network
24+
25+
from vyos import ConfigError, airbag
26+
from vyos.config import Config
27+
from vyos.configdict import dict_merge
28+
from vyos.util import check_kmod, cmd, dict_search, run
29+
from vyos.xml import defaults
30+
31+
airbag.enable()
32+
33+
INSTANCE_REGEX = re.compile(r"instance-(\d+)")
34+
JOOL_CONFIG_DIR = "/run/jool"
35+
36+
37+
def get_config(config: Config | None = None) -> None:
38+
""" """
39+
if config is None:
40+
conf = Config()
41+
42+
base = ["nat64"]
43+
nat64 = conf.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True)
44+
45+
# T2665: we must add the tagNode defaults individually until this is
46+
# moved to the base class
47+
for direction in ["source"]:
48+
if direction in nat64:
49+
default_values = defaults(base + [direction, "rule"])
50+
if "rule" in nat64[direction]:
51+
for rule in nat64[direction]["rule"]:
52+
nat64[direction]["rule"][rule] = dict_merge(
53+
default_values, nat64[direction]["rule"][rule]
54+
)
55+
56+
# Only support netfilter for now
57+
nat64[direction]["rule"][rule]["mode"] = "netfilter"
58+
59+
# Load in existing instances so we can destroy any unknown
60+
lines = cmd("jool instance display --csv").splitlines()
61+
for namespace, instance, mode in csv.reader(lines):
62+
match = INSTANCE_REGEX.fullmatch(instance)
63+
if not match:
64+
# FIXME: Instances that don't match should be ignored but WARN'ed to the user
65+
continue
66+
num = match.group(1)
67+
68+
rules = nat64.setdefault("source", {}).setdefault("rule", {})
69+
# Mark it for deletion
70+
if num not in rules:
71+
print(f"Deleting unknown instance: {num}")
72+
rules[num] = {"deleted": True}
73+
continue
74+
75+
# If the user changes the mode, recreate the instance else Jool fails with:
76+
# Jool error: Sorry; you can't change an instance's framework for now.
77+
if rules[num]["mode"] != mode:
78+
print(
79+
f"Recreating instance {num} due to changed mode: {mode} -> {rules[num]['mode']}"
80+
)
81+
rules[num]["recreate"] = True
82+
83+
lines = cmd(f"jool -i instance-{num} global display --csv --no-header")
84+
existing = dict(csv.reader(lines.splitlines()))
85+
86+
# If the user changes the pool6, recreate the instance else Jool fails with:
87+
# Jool error: Sorry; you can't change a NAT64 instance's pool6 for now.
88+
if (
89+
dict_search("source.prefix", rules[num])
90+
and rules[num]["source"]["prefix"] != existing["pool6"]
91+
):
92+
print(
93+
f"Recreating instance {num} due to changed source prefix: "
94+
f"{existing['pool6']} -> {rules[num]['source']['prefix']}",
95+
)
96+
rules[num]["recreate"] = True
97+
98+
return nat64
99+
100+
101+
def verify(nat64) -> None:
102+
""" """
103+
if not nat64:
104+
# no need to verify the CLI as nat64 is going to be deactivated
105+
return
106+
107+
if dict_search("source.rule", nat64):
108+
for rule, instance in dict_search("source.rule", nat64).items():
109+
if "deleted" in instance:
110+
continue
111+
112+
# Verify that source.prefix is set and is a /96
113+
if not dict_search("source.prefix", instance):
114+
raise ConfigError(f"Source NAT64 rule {rule} missing source prefix")
115+
if IPv6Network(instance["source"]["prefix"]).prefixlen != 96:
116+
raise ConfigError(f"Source NAT64 rule {rule} source prefix must be /96")
117+
118+
for num, pool in dict_search("translation.pool", instance).items():
119+
if "address" not in pool:
120+
raise ConfigError(
121+
f"Source NAT64 rule {rule} translation pool {num} missing address/prefix"
122+
)
123+
if "port" not in pool:
124+
raise ConfigError(
125+
f"Source NAT64 rule {rule} translation pool {num} missing port(-range)"
126+
)
127+
128+
# Ensure only 1 netfilter instance per namespace
129+
130+
return
131+
132+
133+
def generate(nat64) -> None:
134+
""" """
135+
os.makedirs(JOOL_CONFIG_DIR, exist_ok=True)
136+
137+
tokeep = set()
138+
if dict_search("source.rule", nat64):
139+
for rule, instance in dict_search("source.rule", nat64).items():
140+
if "deleted" in instance:
141+
continue
142+
143+
name = f"instance-{rule}"
144+
config = {
145+
"instance": name,
146+
"framework": "netfilter",
147+
"global": {
148+
"pool6": instance["source"]["prefix"],
149+
"manually-enabled": "disable" not in instance,
150+
},
151+
# "pool4": [],
152+
# "bib": [],
153+
}
154+
155+
if "description" in instance:
156+
config["comment"] = instance["description"]
157+
158+
pool4 = []
159+
for pool in dict_search("translation.pool", instance).values():
160+
protos = pool.get("protocol", {}).keys() or ("tcp", "udp", "icmp")
161+
for proto in protos:
162+
obj = {
163+
"protocol": proto.upper(),
164+
"prefix": pool["address"],
165+
"port range": pool["port"],
166+
}
167+
if "description" in pool:
168+
obj["comment"] = pool["description"]
169+
170+
pool4.append(obj)
171+
172+
if pool4:
173+
config["pool4"] = pool4
174+
175+
# pylint: disable=invalid-name
176+
with open(f"{JOOL_CONFIG_DIR}/{name}.json", "w", encoding="utf-8") as f:
177+
json.dump(config, f, indent=2)
178+
179+
tokeep.add(name)
180+
181+
instfiles = {
182+
file
183+
for file in os.listdir(JOOL_CONFIG_DIR)
184+
if INSTANCE_REGEX.fullmatch(file)
185+
and os.path.isfile(os.path.join(JOOL_CONFIG_DIR, file))
186+
}
187+
for file in instfiles - tokeep:
188+
print(f"Removing instance file {file}")
189+
os.unlink(os.path.join(JOOL_CONFIG_DIR, file))
190+
191+
192+
def apply(nat64) -> None:
193+
""" """
194+
if not nat64:
195+
return
196+
197+
if dict_search("source.rule", nat64):
198+
# Deletions first to avoid conflicts
199+
for rule, instance in dict_search("source.rule", nat64).items():
200+
if not any(k in instance for k in ("deleted", "recreate")):
201+
continue
202+
203+
print(f"jool instance remove instance-{rule}")
204+
ret = run(f"jool instance remove instance-{rule}")
205+
if ret != 0:
206+
raise ConfigError(
207+
f"Failed to remove nat64 source rule {rule} (jool instance instance-{rule})"
208+
)
209+
210+
# Now creations
211+
for rule, instance in dict_search("source.rule", nat64).items():
212+
if "deleted" in instance:
213+
continue
214+
215+
name = f"instance-{rule}"
216+
print(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json")
217+
ret = run(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json")
218+
if ret != 0:
219+
raise ConfigError(f"Failed to set jool instance {name}")
220+
221+
return
222+
223+
224+
if __name__ == "__main__":
225+
import sys
226+
227+
try:
228+
check_kmod(["jool"])
229+
c = get_config()
230+
verify(c)
231+
generate(c)
232+
apply(c)
233+
except ConfigError as e:
234+
print(e)
235+
sys.exit(1)

0 commit comments

Comments
 (0)