diff options
| -rw-r--r-- | data/templates/firewall/nftables-cgnat.j2 | 47 | ||||
| -rw-r--r-- | interface-definitions/nat_cgnat.xml.in | 197 | ||||
| -rwxr-xr-x | src/conf_mode/nat_cgnat.py | 288 | 
3 files changed, 532 insertions, 0 deletions
| diff --git a/data/templates/firewall/nftables-cgnat.j2 b/data/templates/firewall/nftables-cgnat.j2 new file mode 100644 index 000000000..79a8e3d5a --- /dev/null +++ b/data/templates/firewall/nftables-cgnat.j2 @@ -0,0 +1,47 @@ +#!/usr/sbin/nft -f + +add table ip cgnat +flush table ip cgnat + +add map ip cgnat tcp_nat_map { type ipv4_addr: interval ipv4_addr . inet_service ; flags interval ;} +add map ip cgnat udp_nat_map { type ipv4_addr: interval ipv4_addr . inet_service ; flags interval ;} +add map ip cgnat icmp_nat_map { type ipv4_addr: interval ipv4_addr . inet_service ; flags interval ;} +add map ip cgnat other_nat_map { type ipv4_addr: interval ipv4_addr ; flags interval ;} +flush map ip cgnat tcp_nat_map +flush map ip cgnat udp_nat_map +flush map ip cgnat icmp_nat_map +flush map ip cgnat other_nat_map + +table ip cgnat { +    map tcp_nat_map { +        type ipv4_addr : interval ipv4_addr . inet_service +        flags interval +        elements = { {{ proto_map_elements }} } +    } + +    map udp_nat_map { +        type ipv4_addr : interval ipv4_addr . inet_service +        flags interval +        elements = { {{ proto_map_elements }} } +    } + +    map icmp_nat_map { +        type ipv4_addr : interval ipv4_addr . inet_service +        flags interval +        elements = { {{ proto_map_elements }} } +    } + +    map other_nat_map { +        type ipv4_addr : interval ipv4_addr +        flags interval +        elements = { {{ other_map_elements }} } +    } + +    chain POSTROUTING { +        type nat hook postrouting priority srcnat; policy accept; +        ip protocol tcp counter snat ip to ip saddr map @tcp_nat_map +        ip protocol udp counter snat ip to ip saddr map @udp_nat_map +        ip protocol icmp counter snat ip to ip saddr map @icmp_nat_map +        counter snat ip to ip saddr map @other_nat_map +    } +} diff --git a/interface-definitions/nat_cgnat.xml.in b/interface-definitions/nat_cgnat.xml.in new file mode 100644 index 000000000..caa26b4d9 --- /dev/null +++ b/interface-definitions/nat_cgnat.xml.in @@ -0,0 +1,197 @@ +<?xml version="1.0"?> +<interfaceDefinition> +  <node name="nat"> +    <children> +      <node name="cgnat" owner="${vyos_conf_scripts_dir}/nat_cgnat.py"> +        <properties> +          <help>Carrier-grade NAT (CGNAT) parameters</help> +          <priority>221</priority> +        </properties> +        <children> +          <node name="pool"> +            <properties> +              <help>External and internal pool parameters</help> +            </properties> +            <children> +              <tagNode name="external"> +                <properties> +                  <help>External pool name</help> +                  <valueHelp> +                    <format>txt</format> +                    <description>External pool name</description> +                  </valueHelp> +                  <constraint> +                    #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> +                  </constraint> +                  <constraintErrorMessage>Name of pool can only contain alpha-numeric letters, hyphen and underscores</constraintErrorMessage> +                </properties> +                <children> +                  <leafNode name="external-port-range"> +                    <properties> +                      <help>Port range</help> +                      <valueHelp> +                        <format>range</format> +                        <description>Numbered port range (e.g., 1001-1005)</description> +                      </valueHelp> +                      <constraint> +                        <validator name="port-range"/> +                      </constraint> +                    </properties> +                    <defaultValue>1024-65535</defaultValue> +                  </leafNode> +                  <node name="per-user-limit"> +                    <properties> +                      <help>Per user limits for the pool</help> +                    </properties> +                    <children> +                      <leafNode name="port"> +                        <properties> +                          <help>Ports per user</help> +                          <valueHelp> +                            <format>u32:1-65535</format> +                            <description>Numeric IP port</description> +                          </valueHelp> +                          <constraint> +                            <validator name="numeric" argument="--range 1-65535"/> +                          </constraint> +                        </properties> +                        <defaultValue>2000</defaultValue> +                      </leafNode> +                    </children> +                  </node> +                  <tagNode name="range"> +                    <properties> +                      <help>Range of IP addresses</help> +                      <valueHelp> +                        <format>ipv4net</format> +                        <description>IPv4 prefix</description> +                      </valueHelp> +                      <valueHelp> +                        <format>ipv4range</format> +                        <description>IPv4 address range</description> +                      </valueHelp> +                      <constraint> +                        <validator name="ipv4-prefix"/> +                        <validator name="ipv4-host"/> +                        <validator name="ipv4-range"/> +                      </constraint> +                    </properties> +                    <children> +                      <leafNode name="seq"> +                        <properties> +                          <help>Sequence</help> +                          <valueHelp> +                            <format>u32:1-999999</format> +                            <description>Sequence number</description> +                          </valueHelp> +                          <constraint> +                            <validator name="numeric" argument="--range 1-999999"/> +                          </constraint> +                          <constraintErrorMessage>Sequence number must be between 1 and 999999</constraintErrorMessage> +                        </properties> +                      </leafNode> +                    </children> +                  </tagNode> +                </children> +              </tagNode> +              <tagNode name="internal"> +                <properties> +                  <help>Internal pool name</help> +                  <valueHelp> +                    <format>txt</format> +                    <description>Internal pool name</description> +                  </valueHelp> +                  <constraint> +                    #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> +                  </constraint> +                  <constraintErrorMessage>Name of pool can only contain alpha-numeric letters, hyphen and underscores</constraintErrorMessage> +                </properties> +                <children> +                  <leafNode name="range"> +                    <properties> +                      <help>Range of IP addresses</help> +                      <valueHelp> +                        <format>ipv4net</format> +                        <description>IPv4 prefix</description> +                      </valueHelp> +                      <valueHelp> +                        <format>ipv4range</format> +                        <description>IPv4 address range</description> +                      </valueHelp> +                      <constraint> +                        <validator name="ipv4-prefix"/> +                        <validator name="ipv4-host"/> +                        <validator name="ipv4-range"/> +                      </constraint> +                    </properties> +                  </leafNode> +                </children> +              </tagNode> +            </children> +          </node> +          <tagNode name="rule"> +            <properties> +              <help>Rule</help> +              <valueHelp> +                <format>u32:1-999999</format> +                <description>Number for this CGNAT rule</description> +              </valueHelp> +              <constraint> +                <validator name="numeric" argument="--range 1-999999"/> +              </constraint> +              <constraintErrorMessage>Rule number must be between 1 and 999999</constraintErrorMessage> +            </properties> +            <children> +              <node name="source"> +                <properties> +                  <help>Source parameters</help> +                </properties> +                <children> +                  <leafNode name="pool"> +                    <properties> +                      <help>Source internal pool</help> +                      <completionHelp> +                        <path>nat cgnat pool internal</path> +                      </completionHelp> +                      <valueHelp> +                        <format>txt</format> +                        <description>Source internal pool name</description> +                      </valueHelp> +                      <constraint> +                        #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> +                      </constraint> +                      <constraintErrorMessage>Name of pool can only contain alpha-numeric letters, hyphen and underscores</constraintErrorMessage> +                    </properties> +                  </leafNode> +                </children> +              </node> +              <node name="translation"> +                <properties> +                  <help>Translation parameters</help> +                </properties> +                <children> +                  <leafNode name="pool"> +                    <properties> +                      <help>Translation external pool</help> +                      <completionHelp> +                        <path>nat cgnat pool external</path> +                      </completionHelp> +                      <valueHelp> +                        <format>txt</format> +                        <description>Translation external pool name</description> +                      </valueHelp> +                      <constraint> +                        #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> +                      </constraint> +                      <constraintErrorMessage>Name of pool can only contain alpha-numeric letters, hyphen and underscores</constraintErrorMessage> +                    </properties> +                  </leafNode> +                </children> +              </node> +            </children> +          </tagNode> +        </children> +      </node> +    </children> +  </node> +</interfaceDefinition> diff --git a/src/conf_mode/nat_cgnat.py b/src/conf_mode/nat_cgnat.py new file mode 100755 index 000000000..f41d66c66 --- /dev/null +++ b/src/conf_mode/nat_cgnat.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 ipaddress +import jmespath +import os + +from sys import exit + +from vyos.config import Config +from vyos.template import render +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +nftables_cgnat_config = '/run/nftables-cgnat.nft' + + +class IPOperations: +    def __init__(self, ip_prefix: str): +        self.ip_prefix = ip_prefix +        self.ip_network = ipaddress.ip_network(ip_prefix) if '/' in ip_prefix else None + +    def get_ips_count(self) -> int: +        """Returns the number of IPs in a prefix or range. + +        Example: +        % ip = IPOperations('192.0.2.0/30') +        % ip.get_ips_count() +        4 +        % ip = IPOperations('192.0.2.0-192.0.2.2') +        % ip.get_ips_count() +        3 +        """ +        if '-' in self.ip_prefix: +            start_ip, end_ip = self.ip_prefix.split('-') +            start_ip = ipaddress.ip_address(start_ip) +            end_ip = ipaddress.ip_address(end_ip) +            return int(end_ip) - int(start_ip) + 1 +        elif '/31' in self.ip_prefix: +            return 2 +        elif '/32' in self.ip_prefix: +            return 1 +        else: +            return sum( +                1 +                for _ in [self.ip_network.network_address] +                + list(self.ip_network.hosts()) +                + [self.ip_network.broadcast_address] +            ) + +    def convert_prefix_to_list_ips(self) -> list: +        """Converts a prefix or IP range to a list of IPs including the network and broadcast addresses. + +        Example: +        % ip = IPOperations('192.0.2.0/30') +        % ip.convert_prefix_to_list_ips() +        ['192.0.2.0', '192.0.2.1', '192.0.2.2', '192.0.2.3'] +        % +        % ip = IPOperations('192.0.0.1-192.0.2.5') +        % ip.convert_prefix_to_list_ips() +        ['192.0.2.1', '192.0.2.2', '192.0.2.3', '192.0.2.4', '192.0.2.5'] +        """ +        if '-' in self.ip_prefix: +            start_ip, end_ip = self.ip_prefix.split('-') +            start_ip = ipaddress.ip_address(start_ip) +            end_ip = ipaddress.ip_address(end_ip) +            return [ +                str(ipaddress.ip_address(ip)) +                for ip in range(int(start_ip), int(end_ip) + 1) +            ] +        elif '/31' in self.ip_prefix: +            return [ +                str(ip) +                for ip in [ +                    self.ip_network.network_address, +                    self.ip_network.broadcast_address, +                ] +            ] +        elif '/32' in self.ip_prefix: +            return [str(self.ip_network.network_address)] +        else: +            return [ +                str(ip) +                for ip in [self.ip_network.network_address] +                + list(self.ip_network.hosts()) +                + [self.ip_network.broadcast_address] +            ] + + +def generate_port_rules( +    external_hosts: list, +    internal_hosts: list, +    port_count: int, +    global_port_range: str = '1024-65535', +) -> list: +    """Generates list of nftables rules for the batch file.""" +    rules = [] +    proto_map_elements = [] +    other_map_elements = [] +    start_port, end_port = map(int, global_port_range.split('-')) +    total_possible_ports = (end_port - start_port) + 1 + +    # Calculate the required number of ports per host +    required_ports_per_host = port_count + +    # Check if there are enough external addresses for all internal hosts +    if required_ports_per_host * len(internal_hosts) > total_possible_ports * len( +        external_hosts +    ): +        raise ConfigError("Not enough ports available for the specified parameters!") + +    current_port = start_port +    current_external_index = 0 + +    for internal_host in internal_hosts: +        external_host = external_hosts[current_external_index] +        next_end_port = current_port + required_ports_per_host - 1 + +        # If the port range exceeds the end_port, move to the next external host +        while next_end_port > end_port: +            current_external_index = (current_external_index + 1) % len(external_hosts) +            external_host = external_hosts[current_external_index] +            current_port = start_port +            next_end_port = current_port + required_ports_per_host - 1 + +        # Ensure the same port is not assigned to the same external host +        if any( +            rule.endswith(f'{external_host}:{current_port}-{next_end_port}') +            for rule in rules +        ): +            raise ConfigError("Not enough ports available for the specified parameters") + +        proto_map_elements.append( +            f'{internal_host} : {external_host} . {current_port}-{next_end_port}' +        ) +        other_map_elements.append(f'{internal_host} : {external_host}') + +        current_port = next_end_port + 1 +        if current_port > end_port: +            current_port = start_port +            current_external_index += 1  # Move to the next external host + +    return [proto_map_elements, other_map_elements] + + +def get_config(config=None): +    if config: +        conf = config +    else: +        conf = Config() + +    base = ['nat', 'cgnat'] +    config = conf.get_config_dict( +        base, +        get_first_key=True, +        key_mangling=('-', '_'), +        no_tag_node_value_mangle=True, +        with_recursive_defaults=True, +    ) + +    return config + + +def verify(config): +    # bail out early - looks like removal from running config +    if not config: +        return None + +    if 'pool' not in config: +        raise ConfigError(f'Pool must be defined!') +    if 'rule' not in config: +        raise ConfigError(f'Rule must be defined!') + +    # As PoC allow only one rule for CGNAT translations +    # one internal pool and one external pool +    if len(config['rule']) > 1: +        raise ConfigError(f'Only one rule is allowed for translations!') + +    for pool in ('external', 'internal'): +        if pool not in config['pool']: +            raise ConfigError(f'{pool} pool must be defined!') +        for pool_name, pool_config in config['pool'][pool].items(): +            if 'range' not in pool_config: +                raise ConfigError( +                    f'Range for "{pool} pool {pool_name}" must be defined!' +                ) + +    for rule, rule_config in config['rule'].items(): +        if 'source' not in rule_config: +            raise ConfigError(f'Rule "{rule}" source pool must be defined!') +        if 'pool' not in rule_config['source']: +            raise ConfigError(f'Rule "{rule}" source pool must be defined!') + +        if 'translation' not in rule_config: +            raise ConfigError(f'Rule "{rule}" translation pool must be defined!') + + +def generate(config): +    if not config: +        return None +    # first external pool as we allow only one as PoC +    ext_pool_name = jmespath.search("rule.*.translation | [0]", config).get('pool') +    int_pool_name = jmespath.search("rule.*.source | [0]", config).get('pool') +    ext_query = f"pool.external.{ext_pool_name}.range | keys(@)" +    int_query = f"pool.internal.{int_pool_name}.range" +    external_ranges = jmespath.search(ext_query, config) +    internal_ranges = [jmespath.search(int_query, config)] + +    external_list_count = [] +    external_list_hosts = [] +    internal_list_count = [] +    internal_list_hosts = [] +    for ext_range in external_ranges: +        # External hosts count +        e_count = IPOperations(ext_range).get_ips_count() +        external_list_count.append(e_count) +        # External hosts list +        e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips() +        external_list_hosts.extend(e_hosts) +    for int_range in internal_ranges: +        # Internal hosts count +        i_count = IPOperations(int_range).get_ips_count() +        internal_list_count.append(i_count) +        # Internal hosts list +        i_hosts = IPOperations(int_range).convert_prefix_to_list_ips() +        internal_list_hosts.extend(i_hosts) + +    external_host_count = sum(external_list_count) +    internal_host_count = sum(internal_list_count) +    ports_per_user = int( +        jmespath.search(f'pool.external.{ext_pool_name}.per_user_limit.port', config) +    ) +    external_port_range: str = jmespath.search( +        f'pool.external.{ext_pool_name}.external_port_range', config +    ) + +    proto_maps, other_maps = generate_port_rules( +        external_list_hosts, internal_list_hosts, ports_per_user, external_port_range +    ) + +    config['proto_map_elements'] = ', '.join(proto_maps) +    config['other_map_elements'] = ', '.join(other_maps) + +    render(nftables_cgnat_config, 'firewall/nftables-cgnat.j2', config) + +    # dry-run newly generated configuration +    tmp = run(f'nft --check --file {nftables_cgnat_config}') +    if tmp > 0: +        raise ConfigError('Configuration file errors encountered!') + + +def apply(config): +    if not config: +        # Cleanup cgnat +        cmd('nft delete table ip cgnat') +        if os.path.isfile(nftables_cgnat_config): +            os.unlink(nftables_cgnat_config) +        return None +    cmd(f'nft --file {nftables_cgnat_config}') + + +if __name__ == '__main__': +    try: +        c = get_config() +        verify(c) +        generate(c) +        apply(c) +    except ConfigError as e: +        print(e) +        exit(1) | 
