From 8d40cc543f723f89102772b0ad99c482795e1d77 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 17 Oct 2026 21:43:01 +0200 Subject: firewall: T9474: match time windows crossing midnight in UTC Rules with a start and stop time were rendered as two separate hour comparisons. nft converts both to UTC when loading the ruleset, so a window crossing midnight in UTC never matched - e.g. 22:00-06:00 anywhere, or 01:00-02:00 on a system running in CEST. A start or stop time on its own had the same problem. Hours are now rendered as a single range, which nft turns into its complement when it wraps around midnight. A missing start or stop defaults to the beginning or end of the day. --- python/vyos/firewall.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) (limited to 'python') diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 08f978f39..1e33462f4 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -722,15 +722,18 @@ def parse_time(time): if 'T' not in start and 'starttime' in time: start += f' {time["starttime"]}' out.append(f'time >= "{start}"') - if 'starttime' in time and 'startdate' not in time: - out.append(f'hour >= "{time["starttime"]}"') if 'stopdate' in time: stop = time['stopdate'] if 'T' not in stop and 'stoptime' in time: stop += f' {time["stoptime"]}' out.append(f'time < "{stop}"') - if 'stoptime' in time and 'stopdate' not in time: - out.append(f'hour < "{time["stoptime"]}"') + # nft converts hours to UTC when loading the ruleset. Separate >= and < + # comparisons never match once the window crosses midnight in UTC, a range + # is turned into its complement by nft instead. + hour_start = time['starttime'] if 'starttime' in time and 'startdate' not in time else None + hour_stop = time['stoptime'] if 'stoptime' in time and 'stopdate' not in time else None + if hour_start or hour_stop: + out.append(f'hour "{hour_start or "00:00"}"-"{hour_stop or "23:59:59"}"') if 'weekdays' in time: days = [day.strip() for day in time['weekdays'].split(",") if day] out_days = [f'"{expand_weekday(day).title()}"' for day in days if day[0] != '!'] -- cgit v1.2.3 From dbde15e2dc1a0cfacdc7f8893434a3257a75a7e1 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 17 Oct 2026 21:43:07 +0200 Subject: firewall: T9474: keep fragment matching working alongside packet marks Fragments were detected by overwriting the whole packet mark with a fixed value before reassembly. Any later mark - a raw "set mark" or a policy route "set table" - replaced it, so "fragment match-frag" no longer matched, and marks already present on fragments were lost. Fragments now only get bit 31 of the mark, which no configurable firewall or routing policy mark uses: - fragment matching tests the bit, mark matching ignores it - firewall and policy route mark writers keep it - policy route and local-route ip rules mask it while fragments are routed - it is cleared after the forward and input filters, so later consumers such as QoS or IPVS see the original mark WAN load-balancing still overwrites the mark and keeps the previous behaviour for fragments. --- data/templates/firewall/nftables.j2 | 28 +++++++++++++++-- python/vyos/defaults.py | 6 ++++ python/vyos/firewall.py | 14 +++++---- smoketest/scripts/cli/test_firewall.py | 23 +++++++------- smoketest/scripts/cli/test_policy.py | 48 +++++++++++++++--------------- smoketest/scripts/cli/test_policy_route.py | 43 ++++++++++++++------------ src/conf_mode/policy_local-route.py | 6 ++-- src/conf_mode/policy_route.py | 7 ++++- 8 files changed, 111 insertions(+), 64 deletions(-) (limited to 'python') diff --git a/data/templates/firewall/nftables.j2 b/data/templates/firewall/nftables.j2 index 4772a6487..6b9358406 100755 --- a/data/templates/firewall/nftables.j2 +++ b/data/templates/firewall/nftables.j2 @@ -119,9 +119,22 @@ table ip vyos_filter { {% endfor %} {% endif %} + # Fragments are reassembled at priority -400, mark them before. Bit 31 is + # never used by any other mark, set/match it only and clear it again once + # the filter hooks are done so routing, QoS and IPVS see the original mark. chain VYOS_FRAG_MARK { type filter hook prerouting priority -450; policy accept; - ip frag-off & 0x3fff != 0 meta mark set 0xffff1 return + ip frag-off & 0x3fff != 0 meta mark set meta mark | 0x80000000 return + } + + chain VYOS_FRAG_UNMARK_FWD { + type filter hook forward priority filter + 10; policy accept; + meta mark & 0x80000000 == 0x80000000 meta mark set meta mark & 0x7fffffff + } + + chain VYOS_FRAG_UNMARK_INP { + type filter hook input priority filter + 10; policy accept; + meta mark & 0x80000000 == 0x80000000 meta mark set meta mark & 0x7fffffff } {% if ipv4.name is vyos_defined %} @@ -305,9 +318,20 @@ table ip6 vyos_filter { } {% endfor %} {% endif %} + # see VYOS_FRAG_MARK chain VYOS_FRAG6_MARK { type filter hook prerouting priority -450; policy accept; - exthdr frag exists meta mark set 0xffff1 return + exthdr frag exists meta mark set meta mark | 0x80000000 return + } + + chain VYOS_FRAG6_UNMARK_FWD { + type filter hook forward priority filter + 10; policy accept; + meta mark & 0x80000000 == 0x80000000 meta mark set meta mark & 0x7fffffff + } + + chain VYOS_FRAG6_UNMARK_INP { + type filter hook input priority filter + 10; policy accept; + meta mark & 0x80000000 == 0x80000000 meta mark set meta mark & 0x7fffffff } {% if ipv6.name is vyos_defined %} diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 65e702927..17f8aed51 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -90,6 +90,12 @@ rt_symbolic_names = { rt_global_vrf = rt_symbolic_names['main'] rt_global_table = rt_symbolic_names['main'] +# Firewall marks fragments with bit 31 of the packet mark until the filter +# hooks are done. Firewall and routing policy marks stop at 2147483647, so the +# bit is free - anything matching a mark while the bit can be set must mask it. +fragment_mark_bit = '0x80000000' +user_mark_mask = '0x7fffffff' + vyconfd_conf = '/etc/vyos/vyconfd.conf' DEFAULT_COMMIT_CONFIRM_MINUTES = 10 diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 1e33462f4..847e74531 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -23,8 +23,10 @@ from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.process import cmdl from vyos.utils.network import get_vrf_tableid +from vyos.defaults import fragment_mark_bit from vyos.defaults import rt_global_table from vyos.defaults import rt_global_vrf +from vyos.defaults import user_mark_mask # Conntrack def conntrack_required(conf): @@ -462,11 +464,11 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if 'fragment' in rule_conf: # Checking for fragmentation after priority -400 is not possible, - # so we use a priority -450 hook to set a mark + # a priority -450 hook sets bit 31 of the mark on fragments if 'match_frag' in rule_conf['fragment']: - output.append('meta mark 0xffff1') + output.append(f'meta mark & {fragment_mark_bit} == {fragment_mark_bit}') if 'match_non_frag' in rule_conf['fragment']: - output.append('meta mark != 0xffff1') + output.append(f'meta mark & {fragment_mark_bit} != {fragment_mark_bit}') if 'limit' in rule_conf: if 'rate' in rule_conf['limit']: @@ -536,7 +538,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if mark[0] == '!': operator = '!=' mark = mark[1:] - output.append(f'meta mark {operator} {{{mark}}}') + output.append(f'meta mark & {user_mark_mask} {operator} {{{mark}}}') if 'vlan' in rule_conf: if 'id' in rule_conf['vlan']: @@ -608,7 +610,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): output.append(f'ip{def_suffix} dscp set {dscp}') if 'mark' in rule_conf['set']: mark = rule_conf['set']['mark'] - output.append(f'meta mark set {mark}') + output.append(f'meta mark set meta mark & {fragment_mark_bit} | {mark}') if 'vrf' in rule_conf['set']: set_table = True vrf_name = rule_conf['set']['vrf'] @@ -624,7 +626,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): table = rt_global_table if set_table: mark = 0x7FFFFFFF - int(table) - output.append(f'meta mark set {mark}') + output.append(f'meta mark set meta mark & {fragment_mark_bit} | {mark}') if 'tcp_mss' in rule_conf['set']: mss = rule_conf['set']['tcp_mss'] output.append(f'tcp option maxseg size set {mss}') diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 995fe2e19..9658efaeb 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -808,7 +808,7 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['OUT-raw default-action drop', 'drop'], ['chain VYOS_PREROUTING_raw'], ['type filter hook prerouting priority raw; policy accept;'], - ['tcp dport 23', 'meta mark set 0x00000037', 'notrack'], + ['tcp dport 23', 'meta mark set meta mark & 0x80000037 | 0x00000037', 'notrack'], ['PRE-raw default-action accept', 'accept'], ['chain NAME_smoketest'], ['saddr 172.16.20.10', 'daddr 172.16.10.10', 'log prefix "[ipv4-NAM-smoketest-1-A]" log level debug', 'ip ttl 15', 'accept'], @@ -822,9 +822,11 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['elements = { 00:00:5e:00:53:01,'], ['icmp type echo-request', 'accept', 'ipv4-NAM-smoketest-10'], ['icmp type destination-unreachable', 'icmp code host-unreachable', 'accept', 'ipv4-NAM-smoketest-11'], - ['ip frag-off & 0x3fff != 0x0', 'meta mark set 0x000ffff1', 'return'], - ['meta mark 0x000ffff1', 'drop', 'ipv4-NAM-smoketest-12'], - ['ip saddr 198.51.100.13', 'meta mark != 0x000ffff1', 'accept', 'ipv4-NAM-smoketest-13'], + ['ip frag-off & 0x3fff != 0x0', 'meta mark set meta mark | 0x80000000', 'return'], + ['chain VYOS_FRAG_UNMARK_FWD'], + ['meta mark 0x80000000/1', 'meta mark set meta mark & 0x7fffffff'], + ['meta mark 0x80000000/1', 'drop', 'ipv4-NAM-smoketest-12'], + ['ip saddr 198.51.100.13', 'meta mark != 0x80000000/1', 'accept', 'ipv4-NAM-smoketest-13'], ['ip ttl < 5', 'accept', 'ipv4-NAM-smoketest-14'], ['tcp sport 1024-2048', 'accept', 'ipv4-NAM-smoketest-15'], ['limit rate 10/second burst 20 packets', 'accept', 'ipv4-NAM-smoketest-16'], @@ -924,11 +926,11 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): nftables_search = [ ['chain VYOS_FORWARD_filter'], ['type filter hook forward priority filter; policy accept;'], - ['ip saddr 198.51.100.1-198.51.100.50', 'meta mark 0x000003f2', 'ip dscp set cs4', f'jump NAME_{name}'], + ['ip saddr 198.51.100.1-198.51.100.50', 'meta mark & 0x7fffffff == 0x000003f2', 'ip dscp set cs4', f'jump NAME_{name}'], ['FWD-filter default-action drop', 'drop'], ['chain VYOS_INPUT_filter'], ['type filter hook input priority filter; policy accept;'], - ['meta mark != 0x000181cd', 'meta l4proto tcp','queue to 3'], + ['meta mark & 0x7fffffff != 0x000181cd', 'meta l4proto tcp','queue to 3'], ['meta l4proto udp','queue flags bypass,fanout to 0-15'], ['INP-filter default-action accept', 'accept'], [f'chain NAME_{name}'], @@ -1199,7 +1201,8 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['type filter hook prerouting priority raw; policy accept;'], ['tcp dport 23', 'ip6 hoplimit set 79', 'drop'], ['PRE-raw default-action accept', 'accept'], - ['exthdr frag exists', 'meta mark set 0x000ffff1', 'return'], + ['exthdr frag exists', 'meta mark set meta mark | 0x80000000', 'return'], + ['chain VYOS_FRAG6_UNMARK_FWD'], ['tcp dport 8080', 'ct state invalid,untracked', 'synproxy mss 1460 wscale 7'], [f'chain NAME6_{name}'], ['saddr 2002::1-2002::10', 'daddr 2002::1:1', 'log prefix "[ipv6-NAM-v6-smoketest-1-A]" log level crit', 'accept'], @@ -1222,7 +1225,7 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['ct helper "ftp"', 'accept', f'ipv6-NAM-{name}-21'], ['tcp flags & syn == syn', 'tcp option maxseg size 501-1460', 'accept', f'ipv6-NAM-{name}-22'], ['meta pkttype multicast', 'accept', f'ipv6-NAM-{name}-23'], - ['meta mark 0x000ffff1', 'accept', f'ipv6-NAM-{name}-24'], + ['meta mark 0x80000000/1', 'accept', f'ipv6-NAM-{name}-24'], ['jump VYOS_STATE_POLICY6'], ['chain VYOS_STATE_POLICY6'], ['ct state established', 'accept'], @@ -1292,7 +1295,7 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['ip6 length 1-1999', 'ip6 length != 60000-65535', 'ip6 dscp 0x04-0x0e', 'ip6 dscp != 0x1f-0x23', 'accept'], ['chain VYOS_IPV6_INPUT_filter'], ['type filter hook input priority filter; policy accept;'], - ['ip6 saddr 2001:db8::/64', 'meta mark != 0x000019ff-0x00001e56', f'jump NAME6_{name}'], + ['ip6 saddr 2001:db8::/64', 'meta mark & 0x7fffffff != 0x000019ff-0x00001e56', f'jump NAME6_{name}'], [f'chain NAME6_{name}'], ['ip6 length { 65, 513, 1025 }', 'ip6 dscp { af21, 0x35 }', 'accept'], [f'log prefix "[ipv6-NAM-{name}-default-D]"', 'drop'] @@ -1580,7 +1583,7 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['icmpv6 type nd-neighbor-solicit', 'accept', 'bri-FWD-filter-10'], ['ip6 hoplimit 255', 'accept', 'bri-FWD-filter-11'], ['ip ttl 1', 'accept', 'bri-FWD-filter-12'], - ['meta mark 0x000004d2', 'accept', 'bri-FWD-filter-13'], + ['meta mark & 0x7fffffff == 0x000004d2', 'accept', 'bri-FWD-filter-13'], ['ip dscp ef', 'accept', 'bri-FWD-filter-14'], ['ct mark 0x0000022b', 'accept', 'bri-FWD-filter-15'], ['meta l4proto udp', 'ip6 hoplimit set 64', 'accept', 'bri-FWD-filter-16'], diff --git a/smoketest/scripts/cli/test_policy.py b/smoketest/scripts/cli/test_policy.py index 40bb01a8b..afa3bee02 100755 --- a/smoketest/scripts/cli/test_policy.py +++ b/smoketest/scripts/cli/test_policy.py @@ -1645,7 +1645,7 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 101: from all fwmark 0x18 lookup 154 + 101: from all fwmark 0x18/0x7fffffff lookup 154 """ tmp = cmdl(['ip', 'rule', 'show', 'prio', '101']) @@ -1720,8 +1720,8 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 23: from 203.0.113.1 to 203.0.113.5 fwmark 0x1e240 ipproto udp sport 5555 dport 8888 lookup 123 - 23: from 203.0.113.2 to 203.0.113.5 fwmark 0x1e240 ipproto udp sport 5555 dport 8888 lookup 123 + 23: from 203.0.113.1 to 203.0.113.5 fwmark 0x1e240/0x7fffffff ipproto udp sport 5555 dport 8888 lookup 123 + 23: from 203.0.113.2 to 203.0.113.5 fwmark 0x1e240/0x7fffffff ipproto udp sport 5555 dport 8888 lookup 123 """ tmp = cmdl(['ip', 'rule', 'show', 'prio', rule]) @@ -1759,8 +1759,8 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 100: from 203.0.113.11 fwmark 0x17 lookup 150 - 100: from 203.0.113.12 fwmark 0x17 lookup 150 + 100: from 203.0.113.11 fwmark 0x17/0x7fffffff lookup 150 + 100: from 203.0.113.12 fwmark 0x17/0x7fffffff lookup 150 """ tmp = cmdl(['ip', 'rule', 'show', 'prio', '100']) @@ -1811,10 +1811,10 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 103: from 203.0.113.11 to 203.0.113.13 fwmark 0x17 lookup 150 - 103: from 203.0.113.11 to 203.0.113.15 fwmark 0x17 lookup 150 - 103: from 203.0.113.12 to 203.0.113.13 fwmark 0x17 lookup 150 - 103: from 203.0.113.12 to 203.0.113.15 fwmark 0x17 lookup 150 + 103: from 203.0.113.11 to 203.0.113.13 fwmark 0x17/0x7fffffff lookup 150 + 103: from 203.0.113.11 to 203.0.113.15 fwmark 0x17/0x7fffffff lookup 150 + 103: from 203.0.113.12 to 203.0.113.13 fwmark 0x17/0x7fffffff lookup 150 + 103: from 203.0.113.12 to 203.0.113.15 fwmark 0x17/0x7fffffff lookup 150 """ tmp = cmdl(['ip', 'rule', 'show', 'prio', '103']) @@ -1855,7 +1855,7 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 100: from all fwmark 0x18 lookup 154 + 100: from all fwmark 0x18/0x7fffffff lookup 154 """ tmp = cmdl(['ip', '-6', 'rule', 'show', 'prio', '100']) @@ -1897,8 +1897,8 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 102: from 2001:db8:1338::/126 fwmark 0x17 lookup 150 - 102: from 2001:db8:1339::/126 fwmark 0x17 lookup 150 + 102: from 2001:db8:1338::/126 fwmark 0x17/0x7fffffff lookup 150 + 102: from 2001:db8:1339::/126 fwmark 0x17/0x7fffffff lookup 150 """ tmp = cmdl(['ip', '-6', 'rule', 'show', 'prio', '102']) @@ -1948,10 +1948,10 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 103: from 2001:db8:1338::/126 to 2001:db8:13::/48 fwmark 0x17 lookup 150 - 103: from 2001:db8:1338::/126 to 2001:db8:16::/48 fwmark 0x17 lookup 150 - 103: from 2001:db8:1339::/56 to 2001:db8:13::/48 fwmark 0x17 lookup 150 - 103: from 2001:db8:1339::/56 to 2001:db8:16::/48 fwmark 0x17 lookup 150 + 103: from 2001:db8:1338::/126 to 2001:db8:13::/48 fwmark 0x17/0x7fffffff lookup 150 + 103: from 2001:db8:1338::/126 to 2001:db8:16::/48 fwmark 0x17/0x7fffffff lookup 150 + 103: from 2001:db8:1339::/56 to 2001:db8:13::/48 fwmark 0x17/0x7fffffff lookup 150 + 103: from 2001:db8:1339::/56 to 2001:db8:16::/48 fwmark 0x17/0x7fffffff lookup 150 """ tmp = cmdl(['ip', '-6', 'rule', 'show', 'prio', '103']) @@ -1986,16 +1986,16 @@ class TestPolicy(VyOSUnitTestSHIM.TestCase): self.cli_commit() original = """ - 103: from 203.0.113.0/24 to 203.0.116.5 fwmark 0x17 lookup 150 - 103: from 203.0.114.5 to 203.0.112.0/24 fwmark 0x17 lookup 150 - 103: from 203.0.114.5 to 203.0.116.5 fwmark 0x17 lookup 150 - 103: from 203.0.113.0/24 to 203.0.112.0/24 fwmark 0x17 lookup 150 + 103: from 203.0.113.0/24 to 203.0.116.5 fwmark 0x17/0x7fffffff lookup 150 + 103: from 203.0.114.5 to 203.0.112.0/24 fwmark 0x17/0x7fffffff lookup 150 + 103: from 203.0.114.5 to 203.0.116.5 fwmark 0x17/0x7fffffff lookup 150 + 103: from 203.0.113.0/24 to 203.0.112.0/24 fwmark 0x17/0x7fffffff lookup 150 """ original_v6 = """ - 103: from 2001:db8:1338::/126 to 2001:db8:16::/48 fwmark 0x17 lookup 150 - 103: from 2001:db8:1339::/56 to 2001:db8:13::/48 fwmark 0x17 lookup 150 - 103: from 2001:db8:1339::/56 to 2001:db8:16::/48 fwmark 0x17 lookup 150 - 103: from 2001:db8:1338::/126 to 2001:db8:13::/48 fwmark 0x17 lookup 150 + 103: from 2001:db8:1338::/126 to 2001:db8:16::/48 fwmark 0x17/0x7fffffff lookup 150 + 103: from 2001:db8:1339::/56 to 2001:db8:13::/48 fwmark 0x17/0x7fffffff lookup 150 + 103: from 2001:db8:1339::/56 to 2001:db8:16::/48 fwmark 0x17/0x7fffffff lookup 150 + 103: from 2001:db8:1338::/126 to 2001:db8:13::/48 fwmark 0x17/0x7fffffff lookup 150 """ tmp = cmdl(['ip', 'rule', 'show', 'prio', '103']) tmp_v6 = cmdl(['ip', '-6', 'rule', 'show', 'prio', '103']) diff --git a/smoketest/scripts/cli/test_policy_route.py b/smoketest/scripts/cli/test_policy_route.py index a113cd9a4..45f132197 100755 --- a/smoketest/scripts/cli/test_policy_route.py +++ b/smoketest/scripts/cli/test_policy_route.py @@ -25,6 +25,11 @@ mark = '100' conn_mark = '555' conn_mark_set = '111' table_mark_offset = 0x7fffffff + +def mark_set(mark_hex): + # bit 31 is kept for fragments, nft folds it into the listed mask + mark = int(mark_hex, 16) + return f'meta mark set meta mark & {0x80000000 | mark:#010x} | {mark:#010x}' table_id = '101' vrf = 'PBRVRF' vrf_table_id = '102' @@ -72,7 +77,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): # Verify ip rule cleanup ip_rule_search = [ - ['fwmark ' + hex(table_mark_offset - int(table_id)), 'lookup ' + table_id] + ['fwmark ' + hex(table_mark_offset - int(table_id)) + '/0x7fffffff', 'lookup ' + table_id] ] self.verify_rules(ip_rule_search, inverse=True) @@ -152,7 +157,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): nftables_search = [ [f'iifname "{interface}"','jump VYOS_PBR_UD_smoketest'], - ['ip daddr 172.16.10.10', 'ip saddr 172.16.20.10', 'meta mark set ' + mark_hex], + ['ip daddr 172.16.10.10', 'ip saddr 172.16.20.10', mark_set(mark_hex)], ] self.verify_nftables(nftables_search, 'ip vyos_mangle') @@ -197,7 +202,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): nftables_search = [ [f'iifname "{interface}"', 'jump VYOS_PBR_UD_smoketest'], - ['tcp flags syn / syn,ack', 'tcp dport 8888', 'meta mark set ' + mark_hex] + ['tcp flags syn / syn,ack', 'tcp dport 8888', mark_set(mark_hex)] ] self.verify_nftables(nftables_search, 'ip vyos_mangle') @@ -206,7 +211,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): nftables6_search = [ [f'iifname "{interface}"', 'jump VYOS_PBR6_UD_smoketest'], - ['meta l4proto { tcp, udp }', 'th dport 8888', 'meta mark set ' + mark_hex] + ['meta l4proto { tcp, udp }', 'th dport 8888', mark_set(mark_hex)] ] self.verify_nftables(nftables6_search, 'ip6 vyos_mangle') @@ -214,7 +219,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): # IP rule fwmark -> table ip_rule_search = [ - ['fwmark ' + hex(table_mark_offset - int(table_id)), 'lookup ' + table_id] + ['fwmark ' + hex(table_mark_offset - int(table_id)) + '/0x7fffffff', 'lookup ' + table_id] ] self.verify_rules(ip_rule_search) @@ -241,7 +246,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): nftables_search = [ [f'iifname "{interface}"', 'jump VYOS_PBR_UD_smoketest'], - ['tcp flags syn / syn,ack', 'tcp dport 8888', 'meta mark set ' + mark_hex] + ['tcp flags syn / syn,ack', 'tcp dport 8888', mark_set(mark_hex)] ] self.verify_nftables(nftables_search, 'ip vyos_mangle') @@ -250,7 +255,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): nftables6_search = [ [f'iifname "{interface}"', 'jump VYOS_PBR6_UD_smoketest'], - ['meta l4proto { tcp, udp }', 'th dport 8888', 'meta mark set ' + mark_hex] + ['meta l4proto { tcp, udp }', 'th dport 8888', mark_set(mark_hex)] ] self.verify_nftables(nftables6_search, 'ip6 vyos_mangle') @@ -258,7 +263,7 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): # IP rule fwmark -> table ip_rule_search = [ - ['fwmark ' + hex(table_mark_offset - int(vrf_table_id)), 'lookup ' + vrf] + ['fwmark ' + hex(table_mark_offset - int(vrf_table_id)) + '/0x7fffffff', 'lookup ' + vrf] ] self.verify_rules(ip_rule_search) @@ -329,11 +334,11 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): # IPv4 nftables_search = [ ['iifname { "' + interface + '", "' + interface_wc + '" }', 'jump VYOS_PBR_UD_smoketest'], - ['meta l4proto udp', 'meta mark 0x000007e4', 'drop'], - ['tcp flags syn / syn,ack', 'meta mark 0x00000002-0x00000bb8', 'meta mark set ' + mark_hex], - ['ct state new', 'tcp dport 22', 'ip saddr 198.51.100.0/24', 'ip ttl > 2', 'meta mark != 0x000001c8', 'meta mark set ' + mark_hex], - ['log prefix "[ipv4-route-smoketest-4-A]"', 'icmp type echo-request', 'ip length { 128, 1024-2048 }', 'meta pkttype other', 'meta mark set ' + mark_hex], - ['ip dscp { 0x29, 0x39-0x3b }', 'meta mark != 0x000001c8-0x000001f4', 'meta mark set ' + mark_hex], + ['meta l4proto udp', 'meta mark & 0x7fffffff == 0x000007e4', 'drop'], + ['tcp flags syn / syn,ack', 'meta mark & 0x7fffffff 0x00000002-0x00000bb8', mark_set(mark_hex)], + ['ct state new', 'tcp dport 22', 'ip saddr 198.51.100.0/24', 'ip ttl > 2', 'meta mark & 0x7fffffff != 0x000001c8', mark_set(mark_hex)], + ['log prefix "[ipv4-route-smoketest-4-A]"', 'icmp type echo-request', 'ip length { 128, 1024-2048 }', 'meta pkttype other', mark_set(mark_hex)], + ['ip dscp { 0x29, 0x39-0x3b }', 'meta mark & 0x7fffffff != 0x000001c8-0x000001f4', mark_set(mark_hex)], ['log prefix "[ipv4-smoketest-default]"'] ] @@ -343,10 +348,10 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): nftables6_search = [ [f'iifname "{interface_wc}"', 'jump VYOS_PBR6_UD_smoketest'], ['meta l4proto udp', 'drop'], - ['tcp flags syn / syn,ack', 'meta mark set ' + mark_hex], - ['ct state new', 'tcp dport 22', 'ip6 saddr 2001:db8::/64', 'ip6 hoplimit > 2', 'meta mark set ' + mark_hex], - ['log prefix "[ipv6-route6-smoketest6-4-A]"', 'icmpv6 type echo-request', 'ip6 length != { 128, 1024-2048 }', 'meta pkttype multicast', 'meta mark set ' + mark_hex], - ['ip6 dscp != { 0x0e-0x13, 0x3d }', 'meta mark set ' + mark_hex], + ['tcp flags syn / syn,ack', mark_set(mark_hex)], + ['ct state new', 'tcp dport 22', 'ip6 saddr 2001:db8::/64', 'ip6 hoplimit > 2', mark_set(mark_hex)], + ['log prefix "[ipv6-route6-smoketest6-4-A]"', 'icmpv6 type echo-request', 'ip6 length != { 128, 1024-2048 }', 'meta pkttype multicast', mark_set(mark_hex)], + ['ip6 dscp != { 0x0e-0x13, 0x3d }', mark_set(mark_hex)], ['log prefix "[ipv6-smoketest6-default]"'] ] @@ -416,8 +421,8 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): self.cli_commit() nftables_search = [ - ['ip6 saddr 2001:db8::1', f'meta mark set {mark_hex}'], - ['ip6 saddr 2001:db8::2', f'meta mark set {mark_hex}'], + ['ip6 saddr 2001:db8::1', mark_set(mark_hex)], + ['ip6 saddr 2001:db8::2', mark_set(mark_hex)], ] self.verify_nftables(nftables_search, 'ip6 vyos_mangle') diff --git a/src/conf_mode/policy_local-route.py b/src/conf_mode/policy_local-route.py index 23aadfade..e58cce041 100755 --- a/src/conf_mode/policy_local-route.py +++ b/src/conf_mode/policy_local-route.py @@ -22,6 +22,7 @@ from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists +from vyos.defaults import user_mark_mask from vyos.utils.process import call from vyos import ConfigError from vyos import airbag @@ -282,7 +283,7 @@ def apply(pbr): f_src_port = '' if src_port == '' else f' sport {src_port} ' f_dst = '' if dst == '' else f' to {dst} ' f_dst_port = '' if dst_port == '' else f' dport {dst_port} ' - f_fwmk = '' if fwmk == '' else f' fwmark {fwmk} ' + f_fwmk = '' if fwmk == '' else f' fwmark {fwmk}/{user_mark_mask} ' f_iif = '' if iif == '' else f' iif {iif} ' f_proto = '' if proto == '' else f' ipproto {proto} ' f_table = '' if table_or_vrf == '' else f' lookup {table_or_vrf} ' @@ -320,7 +321,8 @@ def apply(pbr): f_dst = f' to {dst} ' if dst else '' f_src_port = f' sport {source_port} ' if source_port else '' f_dst_port = f' dport {destination_port} ' if destination_port else '' - f_fwmk = f' fwmark {fwmark} ' if fwmark else '' + # fragments carry bit 31 of the mark while being routed + f_fwmk = f' fwmark {fwmark}/{user_mark_mask} ' if fwmark else '' f_iif = f' iif {inbound_interface} ' if inbound_interface else '' f_proto = f' ipproto {protocol} ' if protocol else '' diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index 64bb7b323..116ae90f8 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -34,6 +34,7 @@ from vyos.utils.network import get_vrf_tableid from vyos.utils.network import interface_exists from vyos.defaults import rt_global_table from vyos.defaults import rt_global_vrf +from vyos.defaults import user_mark_mask from vyos.geoip import geoip_refresh, geoip_update from vyos import ConfigError from vyos import airbag @@ -265,8 +266,9 @@ def apply_table_marks(policy): continue tables.append(vrf_table_id) table_mark = mark_offset - vrf_table_id + # fragments carry bit 31 of the mark while being routed cmdl(cmd_list + ['rule', 'add', 'pref', str(vrf_table_id), - 'fwmark', str(table_mark), 'table', str(vrf_table_id)]) + 'fwmark', f'{table_mark}/{user_mark_mask}', 'table', str(vrf_table_id)]) def cleanup_table_marks(): for cmd_list in [['ip'], ['ip', '-6']]: @@ -280,6 +282,9 @@ def cleanup_table_marks(): if fwmark[:2] == '0x': fwmark = int(fwmark, 16) if (int(fwmark) == (mark_offset - table)): + # a rule with a mask is only found again with the same mask + if 'fwmask' in rule: + fwmark = f'{fwmark}/{rule["fwmask"]}' cmdl(cmd_list + ['rule', 'del', 'fwmark', str(fwmark), 'table', str(table)]) def apply(policy): -- cgit v1.2.3 From c10229f44ed96b07a41014a7c8552c285d44a379 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 17 Oct 2026 21:43:27 +0200 Subject: firewall: T9474: keep time windows correct across UTC offset changes nft converts hour matches to UTC when the ruleset is loaded, using the offset of that moment. After a daylight saving change, or a change of the system time-zone, every hour window was off until the next firewall commit. - A new helper loads only the rules with an hour match again, in place, so sets such as dynamic, domain and GeoIP groups and all counters are kept. It is scheduled as a systemd timer for the next UTC offset change and re-arms itself; firewall and policy route commits arm or remove it. - Changing the time-zone re-runs firewall and policy route. The smoketest moves the clock to just before a change in Europe/Berlin and checks that the window is refreshed and a dynamic group entry survives. It only runs in the smoketest harness, as it changes the system clock. --- data/config-mode-dependencies/vyos-1x.json | 4 + python/vyos/defaults.py | 3 + smoketest/scripts/cli/test_firewall.py | 78 ++++++++++++++ src/conf_mode/firewall.py | 4 + src/conf_mode/policy_route.py | 4 + src/conf_mode/system_timezone.py | 10 ++ src/helpers/vyos-nft-time-refresh.py | 165 +++++++++++++++++++++++++++++ 7 files changed, 268 insertions(+) create mode 100755 src/helpers/vyos-nft-time-refresh.py (limited to 'python') diff --git a/data/config-mode-dependencies/vyos-1x.json b/data/config-mode-dependencies/vyos-1x.json index dd3d28d8f..c07267b7c 100644 --- a/data/config-mode-dependencies/vyos-1x.json +++ b/data/config-mode-dependencies/vyos-1x.json @@ -141,6 +141,10 @@ "firewall": ["firewall"], "sysctl": ["system_sysctl"] }, + "system_timezone": { + "firewall": ["firewall"], + "policy_route": ["policy_route"] + }, "system_logs": { "syslog": ["system_syslog"] }, diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 17f8aed51..d45661a43 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -96,6 +96,9 @@ rt_global_table = rt_symbolic_names['main'] fragment_mark_bit = '0x80000000' user_mark_mask = '0x7fffffff' +# Loads nft rules with an hour match again whenever the UTC offset changes +nft_time_refresh = f'{base_dir}/vyos-nft-time-refresh.py' + vyconfd_conf = '/etc/vyos/vyconfd.conf' DEFAULT_COMMIT_CONFIRM_MINUTES = 10 diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 9658efaeb..cd40d6d89 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -14,12 +14,21 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import re import unittest +from datetime import datetime +from datetime import timedelta +from datetime import timezone from glob import glob +from time import monotonic +from time import sleep +from time import time +from zoneinfo import ZoneInfo from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.configquery import ConfigTreeQuery from vyos.configsession import ConfigSessionError from vyos.utils.process import cmdl from vyos.utils.process import run @@ -2476,6 +2485,75 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['ip6 saddr 2001:db8::2', 'drop', 'comment "ipv6-NAM-smoketest-2"'], ] self.verify_nftables(nftables_search, 'ip6 vyos_filter') + def test_hour_window_follows_utc_offset_change(self): + # nft converts hour matches to UTC when the ruleset is loaded. Once the + # UTC offset changes the rules must be loaded again, without touching + # anything else in the table. + if not self.running_in_smoketest_harness(): + # jumping the clock days ahead fires every timer due until then + self.skipTest('moves the system clock, only run in the smoketest harness') + tz_name = 'Europe/Berlin' + name = 'smoketest-dst' + group = 'smoketest_dst' + element = '192.0.2.99' + rule = ['firewall', 'ipv4', 'name', name, 'rule', '10'] + + self.cli_set(['system', 'time-zone', tz_name]) + self.cli_set(['firewall', 'group', 'dynamic-group', 'address-group', group]) + self.cli_set(rule + ['action', 'drop']) + self.cli_set(rule + ['protocol', 'tcp']) + self.cli_set(rule + ['destination', 'port', '25']) + self.cli_set(rule + ['time', 'starttime', '08:00']) + self.cli_set(rule + ['time', 'stoptime', '17:00']) + self.cli_set(['firewall', 'ipv4', 'name', name, 'rule', '20', 'action', 'drop']) + self.cli_set(['firewall', 'ipv4', 'name', name, 'rule', '20', 'source', 'group', 'dynamic-address-group', group]) + self.cli_commit() + + def hour_window(): + rules = cmdl(['nft', 'list', 'chain', 'ip', 'vyos_filter', f'NAME_{name}'], sudo=True) + for line in rules.splitlines(): + if f'"ipv4-NAM-{name}-10"' in line: + return re.search(r'meta hour (\S+)', line).group(1) + return None + + self.assertEqual(hour_window(), '"08:00"-"17:00"') + timers = cmdl(['systemctl', 'list-timers', '--all', '--no-legend', 'vyos-nft-time-refresh-*']) + self.assertIn('vyos-nft-time-refresh-', timers) + + # learned at runtime - must survive the refresh + cmdl(['nft', 'add', 'element', 'ip', 'vyos_filter', f'DA_{group}', f'{{ {element} timeout 1h }}'], sudo=True) + + # Europe/Berlin changes its offset on a full UTC hour + tz = ZoneInfo(tz_name) + now = datetime.now(timezone.utc) + old_offset = now.astimezone(tz).utcoffset() + change = now.replace(minute=0, second=0, microsecond=0) + while change.astimezone(tz).utcoffset() == old_offset: + change += timedelta(hours=1) + shift = change.astimezone(tz).utcoffset() - old_offset + stale = '"{}"-"{}"'.format(*[(datetime(2000, 1, 1, h) + shift).strftime('%H:%M') for h in (8, 17)]) + + # time synchronisation would undo the clock change - tearDownClass + # loads the saved configuration, which brings NTP back + if ConfigTreeQuery().exists(['service', 'ntp']): + self.cli_delete(['service', 'ntp']) + self.cli_commit() + + wall, mono = time(), monotonic() + try: + cmdl(['date', '-s', f'@{int(change.timestamp()) - 10}'], sudo=True) + self.assertEqual(hour_window(), '"08:00"-"17:00"') + + # past the change, the refresh fires 5 seconds after it + sleep(max(0, change.timestamp() + 1 - time())) + self.assertEqual(hour_window(), stale) + + refreshed, last = self.wait_for_result(hour_window, '"08:00"-"17:00"', pause=0.5, timeout=20) + self.assertTrue(refreshed, msg=f'hour window not refreshed: {last}') + self.assertEqual(0, run(f'sudo nft get element ip vyos_filter DA_{group} {{ {element} }}')) + finally: + cmdl(['date', '-s', f'@{int(wall + monotonic() - mono)}'], sudo=True) + self.cli_delete(['system', 'time-zone']) if __name__ == '__main__': unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on()) diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 5ca508640..2f0529965 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -26,6 +26,7 @@ from vyos.configdict import is_node_changed from vyos.configdiff import Diff, get_config_diff from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_interface_exists +from vyos.defaults import nft_time_refresh from vyos.ethtool import Ethtool from vyos.firewall import fqdn_config_parse from vyos.geoip import geoip_refresh, geoip_update @@ -783,6 +784,9 @@ def apply(firewall): # Apply firewall global-options sysctl settings cmdl(['sysctl', '-f', sysctl_file]) + # hour matches were converted to UTC with the current offset + call(f'{nft_time_refresh} --arm') + call_dependents() ## DOMAIN RESOLVER diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index 116ae90f8..4ca9a5643 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -32,6 +32,7 @@ from vyos.utils.process import cmdl from vyos.utils.process import run from vyos.utils.network import get_vrf_tableid from vyos.utils.network import interface_exists +from vyos.defaults import nft_time_refresh from vyos.defaults import rt_global_table from vyos.defaults import rt_global_vrf from vyos.defaults import user_mark_mask @@ -297,6 +298,9 @@ def apply(policy): apply_table_marks(policy) + # hour matches were converted to UTC with the current offset + call(f'{nft_time_refresh} --arm') + update_domain_resolver(policy) if policy['geoip_sets']['name'] or policy['geoip_sets']['ipv6_name']: diff --git a/src/conf_mode/system_timezone.py b/src/conf_mode/system_timezone.py index 54ffb88ee..2f1830900 100755 --- a/src/conf_mode/system_timezone.py +++ b/src/conf_mode/system_timezone.py @@ -18,6 +18,8 @@ import sys from copy import deepcopy from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos import ConfigError from vyos.utils.process import call @@ -37,6 +39,12 @@ def get_config(config=None): if conf.exists('system time-zone'): tz['name'] = conf.return_value('system time-zone') + # nft converts hour matches with the offset of the time-zone in use + if conf.exists('firewall'): + set_dependents('firewall', conf) + if conf.exists('policy route') or conf.exists('policy route6'): + set_dependents('policy_route', conf) + return tz def verify(tz): @@ -49,6 +57,8 @@ def apply(tz): call('/usr/bin/timedatectl set-timezone {}'.format(tz['name'])) call('systemctl restart rsyslog') + call_dependents() + if __name__ == '__main__': try: c = get_config() diff --git a/src/helpers/vyos-nft-time-refresh.py b/src/helpers/vyos-nft-time-refresh.py new file mode 100755 index 000000000..c52567840 --- /dev/null +++ b/src/helpers/vyos-nft-time-refresh.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# +# Copyright 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 . + +# nft converts "meta hour" to UTC when a ruleset is loaded, using the UTC +# offset of that moment. After a daylight saving change every hour window is +# off by the difference until the rules are loaded again. This helper loads +# only the affected rules again, in place, so sets and counters are kept, and +# arms itself for the next change of the UTC offset. + +import argparse +import json +import re +import sys + +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from zoneinfo import ZoneInfo + +from vyos.defaults import nft_time_refresh +from vyos.utils.commit import acquire_commit_lock_file +from vyos.utils.commit import release_commit_lock_file +from vyos.utils.process import cmdl +from vyos.utils.process import rc_cmd + +# rendered by conf_mode/firewall.py and conf_mode/policy_route.py +rule_files = ['/run/nftables.conf', '/run/nftables_policy.conf'] +unit_prefix = 'vyos-nft-time-refresh' + +table_re = re.compile(r'^\s*table\s+(\S+)\s+(\S+)\s*\{') +chain_re = re.compile(r'^\s*chain\s+(\S+)\s*\{') +comment_re = re.compile(r'comment "([^"]+)"') + +def hour_rules(path: str) -> dict: + """Return {(family, table, chain, comment): rule} for rules with an hour match""" + rules = {} + family = table = chain = None + try: + with open(path) as f: + lines = f.read().splitlines() + except FileNotFoundError: + return rules + + for line in lines: + if match := table_re.match(line): + family, table = match.groups() + chain = None + elif match := chain_re.match(line): + chain = match.group(1) + elif chain and 'hour "' in line and (match := comment_re.search(line)): + rules[(family, table, chain, match.group(1))] = line.strip() + return rules + +def all_hour_rules() -> dict: + rules = {} + for path in rule_files: + rules.update(hour_rules(path)) + return rules + +def rule_handles(family: str, table: str) -> dict: + """Return {(family, table, chain, comment): handle} of a loaded table""" + handles = {} + rc, out = rc_cmd(f'nft -j -a list table {family} {table}') + if rc != 0: + return handles + for item in json.loads(out)['nftables']: + rule = item.get('rule') + if rule and 'comment' in rule: + key = (rule['family'], rule['table'], rule['chain'], rule['comment']) + handles[key] = rule['handle'] + return handles + +def replace_batch(rules: dict) -> list: + """nft commands replacing every rendered hour rule found in the ruleset""" + batch = [] + handles = {} + for family, table in {(k[0], k[1]) for k in rules}: + handles.update(rule_handles(family, table)) + for key, rule in sorted(rules.items()): + if key in handles: + family, table, chain, _ = key + batch.append(f'replace rule {family} {table} {chain} handle {handles[key]} {rule}') + return batch + +def next_offset_change(now: datetime, tz: ZoneInfo, horizon_days=400): + """First moment after now with a different UTC offset, or None""" + offset = now.astimezone(tz).utcoffset() + lo = now + for _ in range(horizon_days): + hi = lo + timedelta(days=1) + if hi.astimezone(tz).utcoffset() != offset: + # narrow down to the second + while hi - lo > timedelta(seconds=1): + mid = lo + (hi - lo) / 2 + if mid.astimezone(tz).utcoffset() == offset: + lo = mid + else: + hi = mid + return hi.replace(microsecond=0) + lo = hi + return None + +def arm(): + """Schedule a refresh at the next UTC offset change if any rule needs it""" + # stopping only the timers keeps a refresh which is running right now + rc_cmd(f"systemctl stop '{unit_prefix}-*.timer'") + if not all_hour_rules(): + return + + with open('/etc/localtime', 'rb') as f: + tz = ZoneInfo.from_file(f) + change = next_offset_change(datetime.now(timezone.utc), tz) + if not change: + return + + when = change + timedelta(seconds=5) + # unique unit name - the unit of a running refresh still exists while it + # arms the next one + unit = f'{unit_prefix}-{int(when.timestamp())}' + # timers default to AccuracySec=1min, which leaves a window up to a minute long + cmdl(['systemd-run', f'--unit={unit}', '--timer-property=RemainAfterElapse=no', + '--timer-property=AccuracySec=1s', + f'--on-calendar={when:%Y-%m-%d %H:%M:%S} UTC', nft_time_refresh, '--refresh']) + +def refresh(): + batch = replace_batch(all_hour_rules()) + if not batch: + return + rc, out = rc_cmd('nft -f -', input='\n'.join(batch) + '\n') + if rc != 0: + print(f'Failed to refresh time based rules: {out}', file=sys.stderr) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--arm', action='store_true', help='Schedule a refresh for the next UTC offset change') + group.add_argument('--refresh', action='store_true', help='Load rules with an hour match again') + args = parser.parse_args() + + if args.arm: + arm() + sys.exit(0) + + # a commit loads the rules itself and arms the next refresh + lock, _ = acquire_commit_lock_file() + if lock is None: + sys.exit(0) + try: + refresh() + arm() + finally: + release_commit_lock_file(lock) -- cgit v1.2.3