From 385cafdcbb10e90c04462bc8e17fcf89e483382a Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 17 Oct 2026 21:52:30 +0200 Subject: nat: T9475: keep translation port when translating to a prefix A translation prefix combined with a port range lost the port range when the rule had no matching source or destination address - the rule only said "snat prefix to " and gave no warning. nft has no syntax for a prefix plus port range, so such rules now use the same map form as rules with an address, keyed on 0.0.0.0/0. This keeps the 1:1 prefix mapping and adds the port range. --- python/vyos/nat.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'python') diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 0a252bbd8..1fdbc2037 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -92,6 +92,10 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): else: translation_output.append(f'{ip_prefix} prefix to {ip_prefix} {translation_prefix}addr map {{ {map_addr} : {addr} }}') ignore_type_addr = True + elif port: + # nft has no "prefix to :" syntax, a catch-all + # map key keeps the 1:1 prefix mapping plus the port range + translation_output.append(f'{ip_prefix} prefix to {ip_prefix} {translation_prefix}addr map {{ 0.0.0.0/0 : {addr} . {port} }}') else: translation_output.append(f'prefix to {addr}') else: -- cgit v1.2.3 From 938140bfe512e8464a5880ae5e7f5c93700765ec Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 17 Oct 2026 21:52:37 +0200 Subject: nat: T9475: load-balance weights are relative to their sum The hash for load-balanced NAT rules was always taken modulo 100, while backend weights may add up to any value. With a sum below 100 some hash values had no backend in the map; commit only warned about it. The modulus is now the sum of all weights, so every hash value maps to a backend and weights act as relative shares. Rules whose weights add up to 100 render exactly as before, and the warning is dropped. --- python/vyos/nat.py | 7 +++++-- src/conf_mode/nat.py | 7 ------- 2 files changed, 5 insertions(+), 9 deletions(-) (limited to 'python') diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 1fdbc2037..e70c6b846 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -141,9 +141,12 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): current_prob = current_prob + item_prob elements = ' , '.join(nat_map) + # weights are relative - a modulus other than their sum leaves hash + # values without a backend + modulus = current_prob if 'hash' in rule_conf['load_balance'] and 'random' in rule_conf['load_balance']['hash']: - translation_str += ' numgen random mod 100 map ' + '{ ' + f'{elements}' + ' }' + translation_str += f' numgen random mod {modulus} map ' + '{ ' + f'{elements}' + ' }' else: for input_param in rule_conf['load_balance']['hash']: if input_param == 'source-address': @@ -158,7 +161,7 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): param = f'{prot} dport' hash_input_items.append(param) hash_input = ' . '.join(hash_input_items) - translation_str += f' jhash ' + f'{hash_input}' + ' mod 100 map ' + '{ ' + f'{elements}' + ' }' + translation_str += f' jhash ' + f'{hash_input}' + f' mod {modulus} map ' + '{ ' + f'{elements}' + ' }' for target in ['source', 'destination']: if target not in rule_conf: diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index fe3359446..bd50c26d9 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -133,13 +133,6 @@ def verify_rule(config, err_msg, groups_dict): for item in ['source-port', 'destination-port']: if item in config['load_balance']['hash'] and config['protocol'] not in ['tcp', 'udp']: raise ConfigError('Protocol must be tcp or udp when specifying hash ports') - count = 0 - if 'backend' in config['load_balance']: - for member in config['load_balance']['backend']: - weight = config['load_balance']['backend'][member]['weight'] - count = count + int(weight) - if count != 100: - Warning(f'Sum of weight for nat load balance rule is not 100. You may get unexpected behaviour') def verify(nat): if not nat or 'deleted' in nat: -- cgit v1.2.3 From a0ea6ec9012350d6bda47d86f9f6f7b928acd0d0 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 17 Oct 2026 21:53:37 +0200 Subject: nat: T9475: load-balance rules with a translation port failed to commit With load-balance backends, the translation port and any translation options were placed between "to" and the hash expression, e.g. "dnat to:80 numgen random ...". nft rejects that and the commit failed. The port is now part of every backend in the map and the options follow the map, so load-balanced port forwarding works. --- python/vyos/nat.py | 12 +++++++++--- smoketest/scripts/cli/test_nat.py | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/nat.py b/python/vyos/nat.py index e70c6b846..9eeb62db6 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -124,19 +124,23 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): else: translation_str = " ".join(translation_output) - if options: - translation_str += f' {",".join(options)}' + options_str = f' {",".join(options)}' if options else '' if not ipv6 and 'backend' in rule_conf['load_balance']: hash_input_items = [] current_prob = 0 nat_map = [] + # the port has to be part of every map value, nft rejects a port + # or options between "to" and the hash expression + translation_str = f'{nat_type[:1]}nat {ip_prefix} to' if port else f'{nat_type[:1]}nat to' + backend_suffix = f' . {port}' if port else '' + for trans_addr, addr in rule_conf['load_balance']['backend'].items(): item_prob = int(addr['weight']) upper_limit = current_prob + item_prob - 1 hash_val = str(current_prob) + '-' + str(upper_limit) - element = hash_val + " : " + trans_addr + element = hash_val + " : " + trans_addr + backend_suffix nat_map.append(element) current_prob = current_prob + item_prob @@ -163,6 +167,8 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): hash_input = ' . '.join(hash_input_items) translation_str += f' jhash ' + f'{hash_input}' + f' mod {modulus} map ' + '{ ' + f'{elements}' + ' }' + translation_str += options_str + for target in ['source', 'destination']: if target not in rule_conf: continue diff --git a/smoketest/scripts/cli/test_nat.py b/smoketest/scripts/cli/test_nat.py index d6a8778a1..eb9d46de7 100755 --- a/smoketest/scripts/cli/test_nat.py +++ b/smoketest/scripts/cli/test_nat.py @@ -321,6 +321,16 @@ class TestNAT(VyOSUnitTestSHIM.TestCase): self.cli_set(src_path + ['rule', '2', 'load-balance', 'backend', '203.0.113.1', 'weight', '1']) self.cli_set(src_path + ['rule', '2', 'load-balance', 'backend', '203.0.113.2', 'weight', '1']) + # translation port and options must not end up in front of the map + self.cli_set(dst_path + ['rule', '3', 'inbound-interface', 'name', ifname]) + self.cli_set(dst_path + ['rule', '3', 'protocol', 'tcp']) + self.cli_set(dst_path + ['rule', '3', 'destination', 'port', '8443']) + self.cli_set(dst_path + ['rule', '3', 'translation', 'port', '443']) + self.cli_set(dst_path + ['rule', '3', 'translation', 'options', 'address-mapping', 'persistent']) + self.cli_set(dst_path + ['rule', '3', 'load-balance', 'hash', 'random']) + self.cli_set(dst_path + ['rule', '3', 'load-balance', 'backend', '192.0.2.61', 'weight', '1']) + self.cli_set(dst_path + ['rule', '3', 'load-balance', 'backend', '192.0.2.62', 'weight', '3']) + self.cli_commit() nftables_search = [ @@ -328,6 +338,7 @@ class TestNAT(VyOSUnitTestSHIM.TestCase): [f'oifname "{ifname}"', f'snat to numgen random mod 100 map', f'0-34 : {member_3}, 35-99 : {member_4}'], ['udp dport 53', 'dnat to jhash ip saddr mod 90 map { 0-49 : 192.0.2.51, 50-89 : 192.0.2.52 }', 'comment "DST-NAT-2"'], ['meta l4proto tcp', 'snat to jhash tcp dport mod 2 map { 0 : 203.0.113.1, 1 : 203.0.113.2 }', 'comment "SRC-NAT-2"'], + ['tcp dport 8443', 'dnat ip to numgen random mod 4 map { 0 : 192.0.2.61 . 443, 1-3 : 192.0.2.62 . 443 } persistent', 'comment "DST-NAT-3"'], ] self.verify_nftables(nftables_search, 'ip vyos_nat') -- cgit v1.2.3