From dfca06b0584116ac88bcb1585e8750ecfeeb4dd4 Mon Sep 17 00:00:00 2001 From: Joe Groocock Date: Sun, 20 Aug 2023 14:40:38 +0100 Subject: nat64: T160: Implement Jool-based NAT64 translator Signed-off-by: Joe Groocock (cherry picked from commit 7d49f7079f1129c2fadc7f38ceb230804d89e177) # Conflicts: # debian/control --- debian/control | 67 ++++++ interface-definitions/include/nat64-protocol.xml.i | 27 +++ interface-definitions/nat64.xml.in | 97 +++++++++ python/vyos/utils/file.py | 14 ++ src/conf_mode/nat64.py | 226 +++++++++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 interface-definitions/include/nat64-protocol.xml.i create mode 100644 interface-definitions/nat64.xml.in create mode 100755 src/conf_mode/nat64.py diff --git a/debian/control b/debian/control index ae54d6ed6..bd748dd6e 100644 --- a/debian/control +++ b/debian/control @@ -163,6 +163,73 @@ Depends: sstp-client, strongswan (>= 5.9), strongswan-swanctl (>= 5.9), +<<<<<<< HEAD +======= + charon-systemd, + libcharon-extra-plugins (>=5.9), + libcharon-extauth-plugins (>=5.9), + libstrongswan-extra-plugins (>=5.9), + libstrongswan-standard-plugins (>=5.9), + python3-vici (>= 5.7.2), +# End "vpn ipsec" +# For "nat64" + jool, +# End "nat64" +# For nat66 + ndppd, +# End nat66 +# For "system ntp" + chrony, +# End "system ntp" +# For "vpn openconnect" + ocserv, +# End "vpn openconnect" +# For "set system flow-accounting" + pmacct (>= 1.6.0), +# End "set system flow-accounting" +# For container + podman, + netavark, + aardvark-dns, +# iptables is only used for containers now, not the the firewall CLI + iptables, +# End container +## End Configuration mode +## Operational mode +# Used for hypervisor model in "run show version" + hvinfo, +# For "run traceroute" + traceroute, +# For "run monitor traffic" + tcpdump, +# End "run monitor traffic" +# For "run show hardware storage smart" + smartmontools, +# For "run show hardware scsi" + lsscsi, +# For "run show hardware pci" + pciutils, +# For "show hardware usb" + usbutils, +# For "run show hardware storage nvme" + nvme-cli, +# For "run monitor bandwidth-test" + iperf, + iperf3, +# End "run monitor bandwidth-test" +# For "run wake-on-lan" + etherwake, +# For "run force ipv6-nd" + ndisc6, +# For "run monitor bandwidth" + bmon, +# End Operational mode +## Optional utilities + easy-rsa, + tcptraceroute, + mtr-tiny, + telnet, +>>>>>>> 7d49f7079 (nat64: T160: Implement Jool-based NAT64 translator) stunnel4, sudo, systemd, diff --git a/interface-definitions/include/nat64-protocol.xml.i b/interface-definitions/include/nat64-protocol.xml.i new file mode 100644 index 000000000..9432e6a87 --- /dev/null +++ b/interface-definitions/include/nat64-protocol.xml.i @@ -0,0 +1,27 @@ + + + + Apply translation address to a specfic protocol + + + + + Transmission Control Protocol + + + + + + User Datagram Protocol + + + + + + Internet Control Message Protocol + + + + + + diff --git a/interface-definitions/nat64.xml.in b/interface-definitions/nat64.xml.in new file mode 100644 index 000000000..1522395e4 --- /dev/null +++ b/interface-definitions/nat64.xml.in @@ -0,0 +1,97 @@ + + + + + IPv6-to-IPv4 Network Address Translation (NAT64) Settings + 501 + + + + + IPv6 source to IPv4 destination address translation + + + + + Source NAT64 rule number + + u32:1-999999 + Number for this rule + + + + + NAT64 rule number must be between 1 and 999999 + + + #include + #include + + + IPv6 source prefix options + + + + + IPv6 prefix to be translated + + ipv6net + IPv6 prefix + + + + + + + + + + + Translated IPv4 address options + + + + + Translation IPv4 pool number + + u32:1-999999 + Number for this rule + + + + + NAT64 pool number must be between 1 and 999999 + + + #include + #include + #include + #include + + + IPv4 address or prefix to translate to + + ipv4 + IPv4 address + + + ipv4net + IPv4 prefix + + + + + + + + + + + + + + + + + + diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index 667a2464b..e20899fe7 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -83,6 +83,20 @@ def read_json(fname, defaultonfailure=None): return defaultonfailure raise e +def write_json(fname, data, indent=2, defaultonfailure=None): + """ + encode data to json and write to a file + should defaultonfailure be not None, it is returned on failure to write + """ + import json + try: + with open(fname, 'w') as f: + json.dump(data, f, indent=indent) + except Exception as e: + if defaultonfailure is not None: + return defaultonfailure + raise e + def chown(path, user, group): """ change file/directory owner """ from pwd import getpwnam diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py new file mode 100755 index 000000000..d4df479ac --- /dev/null +++ b/src/conf_mode/nat64.py @@ -0,0 +1,226 @@ +#!/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 . + +# pylint: disable=empty-docstring,missing-module-docstring + +import csv +import os +import re + +from ipaddress import IPv6Network + +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_json +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) + + # T2665: we must add the tagNode defaults individually until this is + # moved to the base class + for direction in ["source"]: + if direction in nat64: + default_values = defaults(base + [direction, "rule"]) + if "rule" in nat64[direction]: + for rule in nat64[direction]["rule"]: + nat64[direction]["rule"][rule] = dict_merge( + default_values, nat64[direction]["rule"][rule] + ) + + # Only support netfilter for now + nat64[direction]["rule"][rule]["mode"] = "netfilter" + + 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["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_json(f"{JOOL_CONFIG_DIR}/{name}.json", config) + + +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) -- cgit v1.2.3 From 3aad7e75112d6e065d72d79dbdf61902cf19b63f Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Wed, 6 Dec 2023 07:10:46 +0000 Subject: T160: Rebase and fixes for NAT64 - Update the base (rebase) - Move include/nat64-protocol.xml.i => include/nat64/protocol.xml.i - Delete unwanted `write_json`, use `write_file` instead - Remove unnecessary deleting of default values for tagNodes T2665 - Add smoketest Example: ``` set interfaces ethernet eth0 address '192.168.122.14/24' set interfaces ethernet eth0 address '192.168.122.10/24' set interfaces ethernet eth2 address '2001:db8::1/64' set nat64 source rule 100 source prefix '64:ff9b::/96' set nat64 source rule 100 translation pool 10 address '192.168.122.10' set nat64 source rule 100 translation pool 10 port '1-65535' ``` (cherry picked from commit 336bb5a071b59264679be4f4f9bedbdecdbe2834) --- interface-definitions/include/nat64-protocol.xml.i | 27 ------ interface-definitions/include/nat64/protocol.xml.i | 27 ++++++ interface-definitions/nat64.xml.in | 2 +- python/vyos/utils/file.py | 14 --- smoketest/scripts/cli/test_nat64.py | 102 +++++++++++++++++++++ src/conf_mode/nat64.py | 25 +---- 6 files changed, 134 insertions(+), 63 deletions(-) delete mode 100644 interface-definitions/include/nat64-protocol.xml.i create mode 100644 interface-definitions/include/nat64/protocol.xml.i create mode 100755 smoketest/scripts/cli/test_nat64.py diff --git a/interface-definitions/include/nat64-protocol.xml.i b/interface-definitions/include/nat64-protocol.xml.i deleted file mode 100644 index 9432e6a87..000000000 --- a/interface-definitions/include/nat64-protocol.xml.i +++ /dev/null @@ -1,27 +0,0 @@ - - - - Apply translation address to a specfic protocol - - - - - Transmission Control Protocol - - - - - - User Datagram Protocol - - - - - - Internet Control Message Protocol - - - - - - 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 @@ + + + + Apply translation address to a specfic protocol + + + + + Transmission Control Protocol + + + + + + User Datagram Protocol + + + + + + Internet Control Message Protocol + + + + + + diff --git a/interface-definitions/nat64.xml.in b/interface-definitions/nat64.xml.in index 1522395e4..baf13e6cb 100644 --- a/interface-definitions/nat64.xml.in +++ b/interface-definitions/nat64.xml.in @@ -66,7 +66,7 @@ #include #include #include - #include + #include IPv4 address or prefix to translate to diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index e20899fe7..667a2464b 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -83,20 +83,6 @@ def read_json(fname, defaultonfailure=None): return defaultonfailure raise e -def write_json(fname, data, indent=2, defaultonfailure=None): - """ - encode data to json and write to a file - should defaultonfailure be not None, it is returned on failure to write - """ - import json - try: - with open(fname, 'w') as f: - json.dump(data, f, indent=indent) - except Exception as e: - if defaultonfailure is not None: - return defaultonfailure - raise e - def chown(path, user, group): """ change file/directory owner """ from pwd import getpwnam 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 . + +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 index d4df479ac..a8b90fb11 100755 --- a/src/conf_mode/nat64.py +++ b/src/conf_mode/nat64.py @@ -21,6 +21,7 @@ import os import re from ipaddress import IPv6Network +from json import dumps as json_write from vyos import ConfigError from vyos import airbag @@ -28,7 +29,7 @@ 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_json +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 @@ -40,27 +41,12 @@ 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) - # T2665: we must add the tagNode defaults individually until this is - # moved to the base class - for direction in ["source"]: - if direction in nat64: - default_values = defaults(base + [direction, "rule"]) - if "rule" in nat64[direction]: - for rule in nat64[direction]["rule"]: - nat64[direction]["rule"][rule] = dict_merge( - default_values, nat64[direction]["rule"][rule] - ) - - # Only support netfilter for now - nat64[direction]["rule"][rule]["mode"] = "netfilter" - base_src = base + ["source", "rule"] # Load in existing instances so we can destroy any unknown @@ -95,7 +81,6 @@ def get_config(config: Config | None = None) -> None: def verify(nat64) -> None: - """ """ if not nat64: # no need to verify the CLI as nat64 is going to be deactivated return @@ -103,7 +88,7 @@ def verify(nat64) -> None: if dict_search("source.rule", nat64): # Ensure only 1 netfilter instance per namespace nf_rules = filter( - lambda i: "deleted" not in i and i["mode"] == "netfilter", + lambda i: "deleted" not in i and i.get('mode') == "netfilter", nat64["source"]["rule"].values(), ) next(nf_rules, None) # Discard the first element @@ -138,7 +123,6 @@ def verify(nat64) -> None: def generate(nat64) -> None: - """ """ os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) if dict_search("source.rule", nat64): @@ -183,11 +167,10 @@ def generate(nat64) -> None: if pool4: config["pool4"] = pool4 - write_json(f"{JOOL_CONFIG_DIR}/{name}.json", config) + write_file(f'{JOOL_CONFIG_DIR}/{name}.json', json_write(config, indent=2)) def apply(nat64) -> None: - """ """ if not nat64: return -- cgit v1.2.3 From 2076549112b5b65316123c54a68afa6bb3bf8611 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Thu, 7 Dec 2023 16:39:01 +0200 Subject: T160: Fix Debian control conflicts --- debian/control | 68 +--------------------------------------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/debian/control b/debian/control index bd748dd6e..362f11009 100644 --- a/debian/control +++ b/debian/control @@ -79,6 +79,7 @@ Depends: isc-dhcp-relay, isc-dhcp-server, iw, + jool, keepalived (>=2.0.5), lcdproc, lcdproc-extra-drivers, @@ -163,73 +164,6 @@ Depends: sstp-client, strongswan (>= 5.9), strongswan-swanctl (>= 5.9), -<<<<<<< HEAD -======= - charon-systemd, - libcharon-extra-plugins (>=5.9), - libcharon-extauth-plugins (>=5.9), - libstrongswan-extra-plugins (>=5.9), - libstrongswan-standard-plugins (>=5.9), - python3-vici (>= 5.7.2), -# End "vpn ipsec" -# For "nat64" - jool, -# End "nat64" -# For nat66 - ndppd, -# End nat66 -# For "system ntp" - chrony, -# End "system ntp" -# For "vpn openconnect" - ocserv, -# End "vpn openconnect" -# For "set system flow-accounting" - pmacct (>= 1.6.0), -# End "set system flow-accounting" -# For container - podman, - netavark, - aardvark-dns, -# iptables is only used for containers now, not the the firewall CLI - iptables, -# End container -## End Configuration mode -## Operational mode -# Used for hypervisor model in "run show version" - hvinfo, -# For "run traceroute" - traceroute, -# For "run monitor traffic" - tcpdump, -# End "run monitor traffic" -# For "run show hardware storage smart" - smartmontools, -# For "run show hardware scsi" - lsscsi, -# For "run show hardware pci" - pciutils, -# For "show hardware usb" - usbutils, -# For "run show hardware storage nvme" - nvme-cli, -# For "run monitor bandwidth-test" - iperf, - iperf3, -# End "run monitor bandwidth-test" -# For "run wake-on-lan" - etherwake, -# For "run force ipv6-nd" - ndisc6, -# For "run monitor bandwidth" - bmon, -# End Operational mode -## Optional utilities - easy-rsa, - tcptraceroute, - mtr-tiny, - telnet, ->>>>>>> 7d49f7079 (nat64: T160: Implement Jool-based NAT64 translator) stunnel4, sudo, systemd, -- cgit v1.2.3