Skip to content

Commit 93b6542

Browse files
TheLinuxGuyCapirca Team
authored andcommitted
Nftables implement support for source-interface and destination-interface. Add tests to counter and logging tokens.
PiperOrigin-RevId: 505237024
1 parent 72b542e commit 93b6542

File tree

3 files changed

+155
-31
lines changed

3 files changed

+155
-31
lines changed

capirca/lib/nftables.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2022 Google Inc. All Rights Reserved.
1+
# Copyright 2023 Google Inc. All Rights Reserved.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -350,14 +350,17 @@ def _OptionsHandler(self, term):
350350
else:
351351
return ''
352352

353-
def GroupExpressions(self, address_expr, pp_expr, options, verdict, comment):
353+
def GroupExpressions(
354+
self, int_expr, address_expr, pp_expr, options, verdict, comment
355+
):
354356
"""Combines all expressions with a verdict (decision).
355357
356358
The inputs are already pre-sanitized by RulesetGenerator. NFTables processes
357359
rules from left-to-right - ending in a verdict. We form our ruleset then
358360
towards the end append any term.options from _OptionsHandler.
359361
360362
Args:
363+
int_expr: RulesetGenerator source or destination interface str.
361364
address_expr: pre-processed list of nftable statements of network
362365
addresses.
363366
pp_expr: pre-processed list of nftables protocols and ports.
@@ -369,7 +372,6 @@ def GroupExpressions(self, address_expr, pp_expr, options, verdict, comment):
369372
list of strings representing valid nftables statements.
370373
"""
371374
statement = []
372-
statement_with_comment = []
373375
if address_expr:
374376
for addr in address_expr:
375377
if pp_expr:
@@ -392,12 +394,14 @@ def GroupExpressions(self, address_expr, pp_expr, options, verdict, comment):
392394
else:
393395
# If no addresses or ports & protocol. Verdict only statement.
394396
statement.append((Add(options) + Add(verdict)))
397+
# source/destination interface handling always to be done at the end.
398+
if int_expr:
399+
# 'statement' is a list because join to another list in RulesetGenerator.
400+
statement[0] = int_expr + Add(statement[0])
395401
# Handling of comments should always be done after verdict statement.
396-
if comment != 'comment ':
397-
statement_with_comment.append(statement[0] + Add(comment))
398-
return statement_with_comment
399-
else:
400-
return statement
402+
if comment:
403+
statement[0] = statement[0] + Add(comment)
404+
return statement
401405

402406
def _AddrStatement(self, address_family, src_addr, dst_addr):
403407
"""Builds an NFTables address statement.
@@ -464,12 +468,20 @@ def RulesetGenerator(self, term):
464468
"""
465469
term_ruleset = []
466470
unique_term_ruleset = []
467-
comment = 'comment '
471+
comment = ''
468472

469473
# COMMENT handling.
470474
if self.verbose:
471-
comment += aclgenerator.TruncateWords(
475+
comment = 'comment ' + aclgenerator.TruncateWords(
472476
self.term.comment, Nftables.COMMENT_CHAR_LIMIT)
477+
478+
# INTERFACE (source/destination) handling.
479+
if term.source_interface:
480+
interface = 'iifname' + Add(term.source_interface)
481+
elif term.destination_interface:
482+
interface = 'oifname' + Add(term.destination_interface)
483+
else:
484+
interface = ''
473485
# OPTIONS / LOGGING / COUNTERS
474486
opt = self._OptionsHandler(term)
475487
# STATEMENT VERDICT / ACTION.
@@ -482,22 +494,28 @@ def RulesetGenerator(self, term):
482494
address_list = self._AddrStatement(address_family,
483495
self.term.source_address,
484496
self.term.destination_address)
497+
# Check if we're dealing with a term of a different IP family that needs
498+
# to be skipped.
499+
if not address_list and (
500+
self.term.source_address or self.term.destination_address):
501+
continue
485502

486503
# PORTS and PROTOCOLS handling.
487504
proto_and_ports = self.PortsAndProtocols(address_family,
488505
self.term.protocol,
489506
self.term.source_port,
490507
self.term.destination_port,
491508
self.term.icmp_type)
492-
493509
# Do not render ICMP types if IP family mismatch.
494510
if ((address_family == 'ip6' and 'icmp' in self.term.protocol) or
495511
(address_family == 'ip' and ('icmpv6' in self.term.protocol)
496512
or 'icmp6' in self.term.protocol)):
497513
continue
514+
498515
# TODO: If verdict is not supported, drop nftable_rule for it.
499-
nftable_rule = self.GroupExpressions(address_list, proto_and_ports, opt,
500-
verdict, comment)
516+
nftable_rule = self.GroupExpressions(
517+
interface, address_list, proto_and_ports, opt, verdict, comment
518+
)
501519
term_ruleset.extend(nftable_rule)
502520
# Ensure that chain statements contain no duplicates rules.
503521
unique_term_ruleset = [
@@ -610,9 +628,11 @@ def _BuildTokens(self):
610628
'protocol',
611629
'platform',
612630
'platform_exclude',
631+
'source_interface', # NFT iifname
613632
'source_address',
614633
'source_address_exclude',
615634
'source_port',
635+
'destination_interface', # NFT oifname
616636
'translated', # obj attribute, not token
617637
'stateless_reply',
618638
}
@@ -666,11 +686,16 @@ def _TranslatePolicy(self, pol, exp_info):
666686
child_chains = collections.defaultdict(dict)
667687
term_names = set()
668688
new_terms = []
669-
# TODO: Add checks for ICMP and address families.
670689
for term in terms:
671690
if term.name in term_names:
672691
raise TermError('Duplicate term name')
673692
term_names.add(term.name)
693+
if term.source_interface and term.destination_interface:
694+
raise TermError(
695+
'Incorrect interface on term. Must be either be a source or'
696+
' destination, not both.'
697+
)
698+
continue
674699
if term.stateless_reply:
675700
logging.warning(
676701
'WARNING: Term %s is a stateless reply '

doc/generators/nftables.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@ When reporting bugs about this generator ensure to include:
3737
- _destination-port::_ One or more service definition tokens.
3838
- _expiration::_ stop rendering this term after specified date. [YYYY](YYYY.md)-[MM](MM.md)-[DD](DD.md)
3939
- _icmp-type::_ Specify icmp-type code to match.
40+
- _source-interface::_ input direction interface name (renders as: [iifname](https://wiki.nftables.org/wiki-nftables/index.php/Matching_packet_metainformation))
4041
- _source-address::_ One or more source address tokens.
4142
- _source-port::_ One or more service definition tokens.
43+
- _destination-interface::_ output direction interface name (renders as: [oifname](https://wiki.nftables.org/wiki-nftables/index.php/Matching_packet_metainformation))
4244
- _protocol::_ The network protocol(s) this term will match.
4345
- _logging::_ NFTables system logging (host-based).
4446
- _counter::_ NFTables counter for specific term.
4547

48+
Note: combining source-interface and destination-interface tokens within a term is not supported.
49+
4650
## Sub-tokens
4751

4852
### Actions

tests/lib/nftables_test.py

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ def __init__(self, in_dict: dict):
5555
'protocol',
5656
'platform',
5757
'platform_exclude',
58+
'source_interface', #input interface
5859
'source_address',
5960
'source_address_exclude',
6061
'source_port',
62+
'destination_interface', #ouput interface
6163
'translated', # obj attribute, not token
6264
'stateless_reply',
6365
})
@@ -110,6 +112,10 @@ def __init__(self, in_dict: dict):
110112
'version-2-multicast-listener-report',
111113
},
112114
}
115+
# IP address data, to be loaded onto policy and test rendering.
116+
TEST_IPV4_ONLY = [nacaddr.IP('10.2.3.4/32')]
117+
TEST_IPV6_ONLY = [nacaddr.IP('2001:4860:8000::5/128')]
118+
TEST_IPS = [nacaddr.IP('10.2.3.4/32'), nacaddr.IP('2001:4860:8000::5/128')]
113119

114120
HEADER_TEMPLATE = """
115121
header {
@@ -179,6 +185,33 @@ def __init__(self, in_dict: dict):
179185
}
180186
"""
181187

188+
# Input interface name test term.
189+
SOURCE_INTERFACE_TERM = """
190+
term src-interface-term {
191+
source-interface:: eth123
192+
protocol:: tcp
193+
action:: accept
194+
}
195+
"""
196+
197+
# Output interface name test term.
198+
DESTINATION_INTERFACE_TERM = """
199+
term dst-interface-term {
200+
destination-interface:: eth123
201+
protocol:: tcp
202+
action:: accept
203+
}
204+
"""
205+
206+
BAD_INTERFACE_TERM = """
207+
term dst-interface-term {
208+
source-interface:: eth123
209+
destination-interface:: eth123
210+
protocol:: tcp
211+
action:: accept
212+
}
213+
"""
214+
182215
ESTABLISHED_OPTION_TERM = """
183216
term established-term {
184217
protocol:: udp
@@ -233,6 +266,28 @@ def __init__(self, in_dict: dict):
233266
}
234267
"""
235268

269+
LOGGING_TERM = """
270+
term log-packets {
271+
logging:: true
272+
action:: accept
273+
}
274+
"""
275+
276+
COUNTER_TERM = """
277+
term count-packets {
278+
counter:: thisnameisignored
279+
action:: accept
280+
}
281+
"""
282+
283+
COUNT_AND_LOG_TERM = """
284+
term count-and-log-packets {
285+
logging:: true
286+
counter:: thisnameisignored
287+
action:: accept
288+
}
289+
"""
290+
236291
GOOD_TERM_1 = """
237292
term good-term-1 {
238293
action:: accept
@@ -248,8 +303,31 @@ def __init__(self, in_dict: dict):
248303
}
249304
"""
250305

251-
IPV6_TERM_2 = """
252-
term inet6-icmp {
306+
IPV6_ONLY_TERM = """
307+
term ip6-only {
308+
destination-address:: TEST_IPV6_ONLY
309+
action:: accept
310+
}
311+
"""
312+
313+
IPV6_SRCIP = """
314+
term ip6-src-addr {
315+
source-address:: TEST_IPV6_ONLY
316+
action:: deny
317+
}
318+
"""
319+
320+
IPV4_SRCIP = """
321+
term ip4-src-addr {
322+
source-address:: TEST_IPV4_ONLY
323+
action:: deny
324+
}
325+
"""
326+
327+
ALL_SRCIP = """
328+
term all-src-addr {
329+
comment:: "All IP address families. v4/v6"
330+
source-address:: TEST_IPS
253331
action:: deny
254332
}
255333
"""
@@ -260,10 +338,6 @@ def __init__(self, in_dict: dict):
260338
# This is normally passed from command line.
261339
EXP_INFO = 2
262340

263-
TEST_IPV4 = nacaddr.IP('10.2.3.4/32')
264-
TEST_IPV6 = nacaddr.IP('2001:4860:8000::5/128')
265-
TEST_IPS = [TEST_IPV4, TEST_IPV6]
266-
267341
def IPhelper(addresses):
268342
"""Helper for string to nacaddr.IP conversion for parametized tests."""
269343
normalized = []
@@ -334,18 +408,24 @@ def testCreateAnonymousSet(self, input_data, expected):
334408
self.assertEqual(result, expected)
335409

336410
@parameterized.parameters(
337-
(['ip6 saddr 2606:4700:4700::1111/128 ip6 daddr { 2001:4860:4860::8844/128, 2001:4860:4860::8888/128 }'], ['tcp sport 80 tcp dport 80'],'ct state { ESTABLISHED, RELATED } log prefix "combo_cnt_log_established" counter',
338-
'accept', 'comment ', ['ip6 saddr 2606:4700:4700::1111/128 ip6 daddr { 2001:4860:4860::8844/128, 2001:4860:4860::8888/128 } tcp sport 80 tcp dport 80 ct state { ESTABLISHED, RELATED } log prefix "combo_cnt_log_established" counter accept'
411+
('',['ip6 saddr 2606:4700:4700::1111/128 ip6 daddr { 2001:4860:4860::8844/128, 2001:4860:4860::8888/128 }'], ['tcp sport 80 tcp dport 80'],'ct state { ESTABLISHED, RELATED } log prefix "combo_cnt_log_established" counter',
412+
'accept', '', ['ip6 saddr 2606:4700:4700::1111/128 ip6 daddr { 2001:4860:4860::8844/128, 2001:4860:4860::8888/128 } tcp sport 80 tcp dport 80 ct state { ESTABLISHED, RELATED } log prefix "combo_cnt_log_established" counter accept'
339413
]),
340-
(['ip daddr 8.8.8.8/32'], ['tcp sport 53 tcp dport 53'],'ct state new','accept', 'comment "this is a term with a comment"', ['ip daddr 8.8.8.8/32 tcp sport 53 tcp dport 53 ct state new accept comment "this is a term with a comment"'])
414+
('',['ip daddr 8.8.8.8/32'], ['tcp sport 53 tcp dport 53'],'ct state new','accept', 'comment "this is a term with a comment"', ['ip daddr 8.8.8.8/32 tcp sport 53 tcp dport 53 ct state new accept comment "this is a term with a comment"'])
341415
)
342-
def testGroupExpressions(self, address_expr, porst_proto_expr, opt,
416+
def testGroupExpressions(self, int_str, address_expr, porst_proto_expr, opt,
343417
verdict, comment, expected_output):
344-
result = self.dummyterm.GroupExpressions(address_expr, porst_proto_expr,
418+
result = self.dummyterm.GroupExpressions(int_str, address_expr, porst_proto_expr,
345419
opt, verdict, comment)
346-
347420
self.assertEqual(result, expected_output)
348421

422+
def testBadInterfaceTerm(self):
423+
pol = policy.ParsePolicy(GOOD_HEADER_1 + GOOD_TERM_1 + BAD_INTERFACE_TERM,
424+
self.naming)
425+
with self.assertRaises(nftables.TermError):
426+
nftables.Nftables.__init__(
427+
nftables.Nftables.__new__(nftables.Nftables), pol, EXP_INFO)
428+
349429
def testDuplicateTerm(self):
350430
pol = policy.ParsePolicy(GOOD_HEADER_1 + GOOD_TERM_1 + GOOD_TERM_1,
351431
self.naming)
@@ -409,7 +489,7 @@ def testGoodHeader(self):
409489
nft = str(
410490
nftables.Nftables(
411491
policy.ParsePolicy(
412-
GOOD_HEADER_1 + GOOD_TERM_1 + GOOD_HEADER_2 + IPV6_TERM_2,
492+
GOOD_HEADER_1 + GOOD_TERM_1 + GOOD_HEADER_2 + IPV6_SRCIP,
413493
self.naming), EXP_INFO))
414494
self.assertIn('type filter hook input', nft)
415495

@@ -419,7 +499,7 @@ def testStatefulFirewall(self):
419499
nft = str(
420500
nftables.Nftables(
421501
policy.ParsePolicy(
422-
GOOD_HEADER_1 + GOOD_TERM_1 + GOOD_HEADER_2 + IPV6_TERM_2,
502+
GOOD_HEADER_1 + GOOD_TERM_1 + GOOD_HEADER_2 + IPV6_SRCIP,
423503
self.naming), EXP_INFO))
424504
self.assertIn('ct state established,related accept', nft)
425505

@@ -578,7 +658,7 @@ def testRulesetGeneratorICMPmismatch(self, pol_data, doesnotcontain):
578658
nft = str(
579659
nftables.Nftables(
580660
policy.ParsePolicy(pol_data, self.naming), EXP_INFO))
581-
self.assertNotRegex(nft, doesnotcontain)
661+
self.assertNotIn(doesnotcontain, nft)
582662

583663
def testRulesetGeneratorUniqueChain(self):
584664
# This test is intended to verify that on mixed address family rulesets
@@ -621,9 +701,9 @@ def testRulesetGeneratorAF(self, policy_data: str, expected_inet: str):
621701
self.assertNotEmpty(ruleset_list)
622702
for ruleset in ruleset_list:
623703
if expected_inet == 'inet':
624-
self.assertNotIn(str(TEST_IPV6), ruleset)
704+
self.assertNotIn(str(TEST_IPV6_ONLY), ruleset)
625705
elif expected_inet == 'inet6':
626-
self.assertNotIn(str(TEST_IPV4), ruleset)
706+
self.assertNotIn(str(TEST_IPV4_ONLY), ruleset)
627707

628708
for rule in ruleset.split('\n'):
629709
if rule.startswith('ip '):
@@ -633,6 +713,21 @@ def testRulesetGeneratorAF(self, policy_data: str, expected_inet: str):
633713
self.assertNotIn('ip protocol', rule)
634714
self.assertNotIn('icmp', rule)
635715

716+
@parameterized.parameters(
717+
(GOOD_HEADER_1 + SOURCE_INTERFACE_TERM, TEST_IPS, ' iifname eth123 meta l4proto'),
718+
(GOOD_HEADER_1 + DESTINATION_INTERFACE_TERM, TEST_IPS, ' oifname eth123 meta l4proto'),
719+
(GOOD_HEADER_1 + LOGGING_TERM, TEST_IPS, 'log prefix "log-packets"'),
720+
(GOOD_HEADER_1 + COUNTER_TERM, TEST_IPS, 'counter'),
721+
(GOOD_HEADER_1 + COUNT_AND_LOG_TERM, TEST_IPS, 'log prefix "count-and-log-packets" counter'),
722+
(HEADER_MIXED_AF + IPV6_ONLY_TERM, TEST_IPS, 'ip6 daddr 2001:4860:8000::5/128 ct state new accept'),
723+
(HEADER_MIXED_AF + ALL_SRCIP, TEST_IPS, 'ip saddr 10.2.3.4/32 drop comment "All IP address families. v4/v6"'),
724+
)
725+
def testRulesetGenerator(self, policy_data: str, IPs, contains: str):
726+
self.naming.GetNetAddr.return_value = IPs
727+
nft = str(
728+
nftables.Nftables(
729+
policy.ParsePolicy(policy_data, self.naming), EXP_INFO))
730+
self.assertIn(contains, nft)
636731

637732
if __name__ == '__main__':
638733
absltest.main()

0 commit comments

Comments
 (0)