diff --git a/debian/control b/debian/control index 4a2706fc3a8..713888e63fc 100644 --- a/debian/control +++ b/debian/control @@ -78,6 +78,7 @@ Depends: isc-dhcp-relay, isc-dhcp-server, iw, + jool, keepalived (>=2.0.5), lcdproc, lcdproc-extra-drivers, diff --git a/interface-definitions/include/nat64-protocol.xml.i b/interface-definitions/include/nat64-protocol.xml.i new file mode 100644 index 00000000000..b61d77e34ad --- /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 Message Control Protocol + + + + + + diff --git a/interface-definitions/nat64.xml.in b/interface-definitions/nat64.xml.in new file mode 100644 index 00000000000..f2b9ee424e9 --- /dev/null +++ b/interface-definitions/nat64.xml.in @@ -0,0 +1,95 @@ + + + + + IPv6-to-IPv4 Network Address Translation (NAT64) Settings + 500 + + + + + 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 + + + + + IPv4 address or prefix to translate to + + ipv4 + IPv4 address + + + ipv4net + IPv4 prefix + + + + + + + + #include + #include + + + + + + + + + + + diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py new file mode 100755 index 00000000000..78d243efb0b --- /dev/null +++ b/src/conf_mode/nat64.py @@ -0,0 +1,235 @@ +#!/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 json +import os +import re +from ipaddress import IPv6Network + +from vyos import ConfigError, airbag +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.util import check_kmod, cmd, dict_search, run +from vyos.xml import defaults + +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: + conf = Config() + + base = ["nat64"] + nat64 = conf.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" + + # Load in existing instances so we can destroy any unknown + lines = cmd("jool instance display --csv").splitlines() + for namespace, instance, mode 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: + print(f"Deleting unknown instance: {num}") + 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 rules[num]["mode"] != mode: + print( + f"Recreating instance {num} due to changed mode: {mode} -> {rules[num]['mode']}" + ) + rules[num]["recreate"] = True + + lines = cmd(f"jool -i instance-{num} global display --csv --no-header") + existing = dict(csv.reader(lines.splitlines())) + + # 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 rules[num]["source"]["prefix"] != existing["pool6"] + ): + print( + f"Recreating instance {num} due to changed source prefix: " + f"{existing['pool6']} -> {rules[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): + for rule, instance in dict_search("source.rule", nat64).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") + + for num, pool in dict_search("translation.pool", instance).items(): + if "address" not in pool: + raise ConfigError( + f"Source NAT64 rule {rule} translation pool {num} missing address/prefix" + ) + if "port" not in pool: + raise ConfigError( + f"Source NAT64 rule {rule} translation pool {num} missing port(-range)" + ) + + # Ensure only 1 netfilter instance per namespace + + return + + +def generate(nat64) -> None: + """ """ + os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) + + tokeep = set() + if dict_search("source.rule", nat64): + for rule, instance in dict_search("source.rule", nat64).items(): + if "deleted" in instance: + continue + + name = f"instance-{rule}" + config = { + "instance": name, + "framework": "netfilter", + "global": { + "pool6": instance["source"]["prefix"], + "manually-enabled": "disable" not in instance, + }, + # "pool4": [], + # "bib": [], + } + + if "description" in instance: + config["comment"] = instance["description"] + + pool4 = [] + for pool in dict_search("translation.pool", instance).values(): + 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 + + # pylint: disable=invalid-name + with open(f"{JOOL_CONFIG_DIR}/{name}.json", "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + tokeep.add(name) + + instfiles = { + file + for file in os.listdir(JOOL_CONFIG_DIR) + if INSTANCE_REGEX.fullmatch(file) + and os.path.isfile(os.path.join(JOOL_CONFIG_DIR, file)) + } + for file in instfiles - tokeep: + print(f"Removing instance file {file}") + os.unlink(os.path.join(JOOL_CONFIG_DIR, file)) + + +def apply(nat64) -> None: + """ """ + if not nat64: + return + + if dict_search("source.rule", nat64): + # Deletions first to avoid conflicts + for rule, instance in dict_search("source.rule", nat64).items(): + if not any(k in instance for k in ("deleted", "recreate")): + continue + + print(f"jool instance remove instance-{rule}") + 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 dict_search("source.rule", nat64).items(): + if "deleted" in instance: + continue + + name = f"instance-{rule}" + print(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json") + 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}") + + return + + +if __name__ == "__main__": + import sys + + try: + check_kmod(["jool"]) + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1)