summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorViacheslav Hletenko <v.gletenko@vyos.io>2023-12-07 09:29:47 +0200
committerGitHub <noreply@github.com>2023-12-07 09:29:47 +0200
commit18ee2422cf8a24a6a63d07e565fb677d05f881db (patch)
tree128415b148c092e39f7961a6ef5a2fab9f96ee83
parent99c674cdf6147a69fd57b4151886ff42917caeec (diff)
parent336bb5a071b59264679be4f4f9bedbdecdbe2834 (diff)
downloadvyos-1x-18ee2422cf8a24a6a63d07e565fb677d05f881db.tar.gz
vyos-1x-18ee2422cf8a24a6a63d07e565fb677d05f881db.zip
Merge pull request #2578 from sever-sever/nat64
T160: add NAT64
-rw-r--r--debian/control3
-rw-r--r--interface-definitions/include/nat64/protocol.xml.i27
-rw-r--r--interface-definitions/nat64.xml.in97
-rwxr-xr-xsmoketest/scripts/cli/test_nat64.py102
-rwxr-xr-xsrc/conf_mode/nat64.py209
5 files changed, 438 insertions, 0 deletions
diff --git a/debian/control b/debian/control
index b2b0c6ed0..f20268444 100644
--- a/debian/control
+++ b/debian/control
@@ -249,6 +249,9 @@ Depends:
libstrongswan-standard-plugins (>=5.9),
python3-vici (>= 5.7.2),
# End "vpn ipsec"
+# For "nat64"
+ jool,
+# End "nat64"
# For nat66
ndppd,
# End nat66
diff --git a/interface-definitions/include/nat64/protocol.xml.i b/interface-definitions/include/nat64/protocol.xml.i
new file mode 100644
index 000000000..a640873b5
--- /dev/null
+++ b/interface-definitions/include/nat64/protocol.xml.i
@@ -0,0 +1,27 @@
+<!-- include start from nat64/protocol.xml.i -->
+<node name="protocol">
+ <properties>
+ <help>Apply translation address to a specfic protocol</help>
+ </properties>
+ <children>
+ <leafNode name="tcp">
+ <properties>
+ <help>Transmission Control Protocol</help>
+ <valueless/>
+ </properties>
+ </leafNode>
+ <leafNode name="udp">
+ <properties>
+ <help>User Datagram Protocol</help>
+ <valueless/>
+ </properties>
+ </leafNode>
+ <leafNode name="icmp">
+ <properties>
+ <help>Internet Control Message Protocol</help>
+ <valueless/>
+ </properties>
+ </leafNode>
+ </children>
+</node>
+<!-- include end -->
diff --git a/interface-definitions/nat64.xml.in b/interface-definitions/nat64.xml.in
new file mode 100644
index 000000000..baf13e6cb
--- /dev/null
+++ b/interface-definitions/nat64.xml.in
@@ -0,0 +1,97 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+ <node name="nat64" owner="${vyos_conf_scripts_dir}/nat64.py">
+ <properties>
+ <help>IPv6-to-IPv4 Network Address Translation (NAT64) Settings</help>
+ <priority>501</priority>
+ </properties>
+ <children>
+ <node name="source">
+ <properties>
+ <help>IPv6 source to IPv4 destination address translation</help>
+ </properties>
+ <children>
+ <tagNode name="rule">
+ <properties>
+ <help>Source NAT64 rule number</help>
+ <valueHelp>
+ <format>u32:1-999999</format>
+ <description>Number for this rule</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-999999"/>
+ </constraint>
+ <constraintErrorMessage>NAT64 rule number must be between 1 and 999999</constraintErrorMessage>
+ </properties>
+ <children>
+ #include <include/generic-description.xml.i>
+ #include <include/generic-disable-node.xml.i>
+ <node name="source">
+ <properties>
+ <help>IPv6 source prefix options</help>
+ </properties>
+ <children>
+ <leafNode name="prefix">
+ <properties>
+ <help>IPv6 prefix to be translated</help>
+ <valueHelp>
+ <format>ipv6net</format>
+ <description>IPv6 prefix</description>
+ </valueHelp>
+ <constraint>
+ <validator name="ipv6-prefix"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ </children>
+ </node>
+ <node name="translation">
+ <properties>
+ <help>Translated IPv4 address options</help>
+ </properties>
+ <children>
+ <tagNode name="pool">
+ <properties>
+ <help>Translation IPv4 pool number</help>
+ <valueHelp>
+ <format>u32:1-999999</format>
+ <description>Number for this rule</description>
+ </valueHelp>
+ <constraint>
+ <validator name="numeric" argument="--range 1-999999"/>
+ </constraint>
+ <constraintErrorMessage>NAT64 pool number must be between 1 and 999999</constraintErrorMessage>
+ </properties>
+ <children>
+ #include <include/generic-description.xml.i>
+ #include <include/generic-disable-node.xml.i>
+ #include <include/nat-translation-port.xml.i>
+ #include <include/nat64/protocol.xml.i>
+ <leafNode name="address">
+ <properties>
+ <help>IPv4 address or prefix to translate to</help>
+ <valueHelp>
+ <format>ipv4</format>
+ <description>IPv4 address</description>
+ </valueHelp>
+ <valueHelp>
+ <format>ipv4net</format>
+ <description>IPv4 prefix</description>
+ </valueHelp>
+ <constraint>
+ <validator name="ipv4-address"/>
+ <validator name="ipv4-prefix"/>
+ </constraint>
+ </properties>
+ </leafNode>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/smoketest/scripts/cli/test_nat64.py b/smoketest/scripts/cli/test_nat64.py
new file mode 100755
index 000000000..b5723ac7e
--- /dev/null
+++ b/smoketest/scripts/cli/test_nat64.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import json
+import os
+import unittest
+
+from base_vyostest_shim import VyOSUnitTestSHIM
+from vyos.configsession import ConfigSessionError
+from vyos.utils.process import cmd
+from vyos.utils.dict import dict_search
+
+base_path = ['nat64']
+src_path = base_path + ['source']
+
+jool_nat64_config = '/run/jool/instance-100.json'
+
+
+class TestNAT64(VyOSUnitTestSHIM.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ super(TestNAT64, cls).setUpClass()
+
+ # ensure we can also run this test on a live system - so lets clean
+ # out the current configuration :)
+ cls.cli_delete(cls, base_path)
+
+ def tearDown(self):
+ self.cli_delete(base_path)
+ self.cli_commit()
+ self.assertFalse(os.path.exists(jool_nat64_config))
+
+ def test_snat64(self):
+ rule = '100'
+ translation_rule = '10'
+ prefix_v6 = '64:ff9b::/96'
+ pool = '192.0.2.10'
+ pool_port = '1-65535'
+
+ self.cli_set(src_path + ['rule', rule, 'source', 'prefix', prefix_v6])
+ self.cli_set(
+ src_path
+ + ['rule', rule, 'translation', 'pool', translation_rule, 'address', pool]
+ )
+ self.cli_set(
+ src_path
+ + ['rule', rule, 'translation', 'pool', translation_rule, 'port', pool_port]
+ )
+ self.cli_commit()
+
+ # Load the JSON file
+ with open(f'/run/jool/instance-{rule}.json', 'r') as json_file:
+ config_data = json.load(json_file)
+
+ # Assertions based on the content of the JSON file
+ self.assertEqual(config_data['instance'], f'instance-{rule}')
+ self.assertEqual(config_data['framework'], 'netfilter')
+ self.assertEqual(config_data['global']['pool6'], prefix_v6)
+ self.assertTrue(config_data['global']['manually-enabled'])
+
+ # Check the pool4 entries
+ pool4_entries = config_data.get('pool4', [])
+ self.assertIsInstance(pool4_entries, list)
+ self.assertGreater(len(pool4_entries), 0)
+
+ for entry in pool4_entries:
+ self.assertIn('protocol', entry)
+ self.assertIn('prefix', entry)
+ self.assertIn('port range', entry)
+
+ protocol = entry['protocol']
+ prefix = entry['prefix']
+ port_range = entry['port range']
+
+ if protocol == 'ICMP':
+ self.assertEqual(prefix, pool)
+ self.assertEqual(port_range, pool_port)
+ elif protocol == 'UDP':
+ self.assertEqual(prefix, pool)
+ self.assertEqual(port_range, pool_port)
+ elif protocol == 'TCP':
+ self.assertEqual(prefix, pool)
+ self.assertEqual(port_range, pool_port)
+ else:
+ self.fail(f'Unexpected protocol: {protocol}')
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)
diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py
new file mode 100755
index 000000000..a8b90fb11
--- /dev/null
+++ b/src/conf_mode/nat64.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+# pylint: disable=empty-docstring,missing-module-docstring
+
+import csv
+import os
+import re
+
+from ipaddress import IPv6Network
+from json import dumps as json_write
+
+from vyos import ConfigError
+from vyos import airbag
+from vyos.config import Config
+from vyos.configdict import dict_merge
+from vyos.configdict import is_node_changed
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.kernel import check_kmod
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+
+airbag.enable()
+
+INSTANCE_REGEX = re.compile(r"instance-(\d+)")
+JOOL_CONFIG_DIR = "/run/jool"
+
+
+def get_config(config: Config | None = None) -> None:
+ if config is None:
+ config = Config()
+
+ base = ["nat64"]
+ nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True)
+
+ base_src = base + ["source", "rule"]
+
+ # Load in existing instances so we can destroy any unknown
+ lines = cmd("jool instance display --csv").splitlines()
+ for _, instance, _ in csv.reader(lines):
+ match = INSTANCE_REGEX.fullmatch(instance)
+ if not match:
+ # FIXME: Instances that don't match should be ignored but WARN'ed to the user
+ continue
+ num = match.group(1)
+
+ rules = nat64.setdefault("source", {}).setdefault("rule", {})
+ # Mark it for deletion
+ if num not in rules:
+ rules[num] = {"deleted": True}
+ continue
+
+ # If the user changes the mode, recreate the instance else Jool fails with:
+ # Jool error: Sorry; you can't change an instance's framework for now.
+ if is_node_changed(config, base_src + [f"instance-{num}", "mode"]):
+ rules[num]["recreate"] = True
+
+ # If the user changes the pool6, recreate the instance else Jool fails with:
+ # Jool error: Sorry; you can't change a NAT64 instance's pool6 for now.
+ if dict_search("source.prefix", rules[num]) and is_node_changed(
+ config,
+ base_src + [num, "source", "prefix"],
+ ):
+ rules[num]["recreate"] = True
+
+ return nat64
+
+
+def verify(nat64) -> None:
+ if not nat64:
+ # no need to verify the CLI as nat64 is going to be deactivated
+ return
+
+ if dict_search("source.rule", nat64):
+ # Ensure only 1 netfilter instance per namespace
+ nf_rules = filter(
+ lambda i: "deleted" not in i and i.get('mode') == "netfilter",
+ nat64["source"]["rule"].values(),
+ )
+ next(nf_rules, None) # Discard the first element
+ if next(nf_rules, None) is not None:
+ raise ConfigError(
+ "Jool permits only 1 NAT64 netfilter instance (per network namespace)"
+ )
+
+ for rule, instance in nat64["source"]["rule"].items():
+ if "deleted" in instance:
+ continue
+
+ # Verify that source.prefix is set and is a /96
+ if not dict_search("source.prefix", instance):
+ raise ConfigError(f"Source NAT64 rule {rule} missing source prefix")
+ if IPv6Network(instance["source"]["prefix"]).prefixlen != 96:
+ raise ConfigError(f"Source NAT64 rule {rule} source prefix must be /96")
+
+ pools = dict_search("translation.pool", instance)
+ if pools:
+ for num, pool in pools.items():
+ if "address" not in pool:
+ raise ConfigError(
+ f"Source NAT64 rule {rule} translation pool "
+ f"{num} missing address/prefix"
+ )
+ if "port" not in pool:
+ raise ConfigError(
+ f"Source NAT64 rule {rule} translation pool "
+ f"{num} missing port(-range)"
+ )
+
+
+def generate(nat64) -> None:
+ os.makedirs(JOOL_CONFIG_DIR, exist_ok=True)
+
+ if dict_search("source.rule", nat64):
+ for rule, instance in nat64["source"]["rule"].items():
+ if "deleted" in instance:
+ # Delete the unused instance file
+ os.unlink(os.path.join(JOOL_CONFIG_DIR, f"instance-{rule}.json"))
+ continue
+
+ name = f"instance-{rule}"
+ config = {
+ "instance": name,
+ "framework": "netfilter",
+ "global": {
+ "pool6": instance["source"]["prefix"],
+ "manually-enabled": "disable" not in instance,
+ },
+ # "bib": [],
+ }
+
+ if "description" in instance:
+ config["comment"] = instance["description"]
+
+ if dict_search("translation.pool", instance):
+ pool4 = []
+ for pool in instance["translation"]["pool"].values():
+ if "disable" in pool:
+ continue
+
+ protos = pool.get("protocol", {}).keys() or ("tcp", "udp", "icmp")
+ for proto in protos:
+ obj = {
+ "protocol": proto.upper(),
+ "prefix": pool["address"],
+ "port range": pool["port"],
+ }
+ if "description" in pool:
+ obj["comment"] = pool["description"]
+
+ pool4.append(obj)
+
+ if pool4:
+ config["pool4"] = pool4
+
+ write_file(f'{JOOL_CONFIG_DIR}/{name}.json', json_write(config, indent=2))
+
+
+def apply(nat64) -> None:
+ if not nat64:
+ return
+
+ if dict_search("source.rule", nat64):
+ # Deletions first to avoid conflicts
+ for rule, instance in nat64["source"]["rule"].items():
+ if not any(k in instance for k in ("deleted", "recreate")):
+ continue
+
+ ret = run(f"jool instance remove instance-{rule}")
+ if ret != 0:
+ raise ConfigError(
+ f"Failed to remove nat64 source rule {rule} (jool instance instance-{rule})"
+ )
+
+ # Now creations
+ for rule, instance in nat64["source"]["rule"].items():
+ if "deleted" in instance:
+ continue
+
+ name = f"instance-{rule}"
+ ret = run(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json")
+ if ret != 0:
+ raise ConfigError(f"Failed to set jool instance {name}")
+
+
+if __name__ == "__main__":
+ try:
+ check_kmod(["jool"])
+ c = get_config()
+ verify(c)
+ generate(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ exit(1)