xref: /freebsd-14-stable/tests/sys/netpfil/common/pft_ping.py (revision f3586d26936cbcc5bc13c2c74c9224532e896d13)
1#!/usr/bin/env python3
2#
3# SPDX-License-Identifier: BSD-2-Clause
4#
5# Copyright (c) 2017 Kristof Provost <kp@FreeBSD.org>
6# Copyright (c) 2023 Kajetan Staszkiewicz <vegeta@tuxpowered.net>
7#
8# Redistribution and use in source and binary forms, with or without
9# modification, are permitted provided that the following conditions
10# are met:
11# 1. Redistributions of source code must retain the above copyright
12#    notice, this list of conditions and the following disclaimer.
13# 2. Redistributions in binary form must reproduce the above copyright
14#    notice, this list of conditions and the following disclaimer in the
15#    documentation and/or other materials provided with the distribution.
16#
17# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
18# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
21# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
23# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
24# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
26# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
27# SUCH DAMAGE.
28#
29
30import argparse
31import logging
32logging.getLogger("scapy").setLevel(logging.CRITICAL)
33import math
34import scapy.all as sp
35import sys
36
37from copy import copy
38from sniffer import Sniffer
39
40logging.basicConfig(format='%(message)s')
41LOGGER = logging.getLogger(__name__)
42
43PAYLOAD_MAGIC = bytes.fromhex('42c0ffee')
44
45def build_payload(l):
46    pl = len(PAYLOAD_MAGIC)
47    ret = PAYLOAD_MAGIC * math.floor(l/pl)
48    ret += PAYLOAD_MAGIC[0:(l % pl)]
49    return ret
50
51
52def prepare_ipv6(dst_address, send_params):
53    src_address = send_params.get('src_address')
54    hlim = send_params.get('hlim')
55    tc = send_params.get('tc')
56    ip6 = sp.IPv6(dst=dst_address)
57    if src_address:
58        ip6.src = src_address
59    if hlim:
60        ip6.hlim = hlim
61    if tc:
62        ip6.tc = tc
63    return ip6
64
65
66def prepare_ipv4(dst_address, send_params):
67    src_address = send_params.get('src_address')
68    flags = send_params.get('flags')
69    tos = send_params.get('tc')
70    ttl = send_params.get('hlim')
71    ip = sp.IP(dst=dst_address)
72    if src_address:
73        ip.src = src_address
74    if flags:
75        ip.flags = flags
76    if tos:
77        ip.tos = tos
78    if ttl:
79        ip.ttl = ttl
80    return ip
81
82
83def send_icmp_ping(dst_address, sendif, send_params):
84    send_length = send_params['length']
85    send_frag_length = send_params['frag_length']
86    packets = []
87    ether = sp.Ether()
88    if ':' in dst_address:
89        ip6 = prepare_ipv6(dst_address, send_params)
90        icmp = sp.ICMPv6EchoRequest(data=sp.raw(build_payload(send_length)))
91        if send_frag_length:
92            for packet in sp.fragment(ip6 / icmp, fragsize=send_frag_length):
93                packets.append(ether / packet)
94        else:
95            packets.append(ether / ip6 / icmp)
96
97    else:
98        ip = prepare_ipv4(dst_address, send_params)
99        icmp = sp.ICMP(type='echo-request')
100        raw = sp.raw(build_payload(send_length))
101        if send_frag_length:
102            for packet in sp.fragment(ip / icmp / raw, fragsize=send_frag_length):
103                packets.append(ether / packet)
104        else:
105            packets.append(ether / ip / icmp / raw)
106    for packet in packets:
107        sp.sendp(packet, sendif, verbose=False)
108
109
110def send_tcp_syn(dst_address, sendif, send_params):
111    tcpopt_unaligned = send_params.get('tcpopt_unaligned')
112    seq = send_params.get('seq')
113    mss = send_params.get('mss')
114    ether = sp.Ether()
115    opts=[('Timestamp', (1, 1)), ('MSS', mss if mss else 1280)]
116    if tcpopt_unaligned:
117        opts = [('NOP', 0 )] + opts
118    if ':' in dst_address:
119        ip = prepare_ipv6(dst_address, send_params)
120    else:
121        ip = prepare_ipv4(dst_address, send_params)
122    tcp = sp.TCP(dport=666, flags='S', options=opts, seq=seq)
123    req = ether / ip / tcp
124    sp.sendp(req, iface=sendif, verbose=False)
125
126
127def send_ping(dst_address, sendif, ping_type, send_params):
128    if ping_type == 'icmp':
129        send_icmp_ping(dst_address, sendif, send_params)
130    elif ping_type == 'tcpsyn':
131        send_tcp_syn(dst_address, sendif, send_params)
132    else:
133        raise Exception('Unspported ping type')
134
135
136def check_ipv4(expect_params, packet):
137    src_address = expect_params.get('src_address')
138    dst_address = expect_params.get('dst_address')
139    flags = expect_params.get('flags')
140    tos = expect_params.get('tc')
141    ttl = expect_params.get('hlim')
142    ip = packet.getlayer(sp.IP)
143    if not ip:
144        LOGGER.debug('Packet is not IPv4!')
145        return False
146    if src_address and ip.src != src_address:
147        LOGGER.debug('Source IPv4 address does not match!')
148        return False
149    if dst_address and ip.dst != dst_address:
150        LOGGER.debug('Destination IPv4 address does not match!')
151        return False
152    if flags and ip.flags != flags:
153        LOGGER.debug(f'Wrong IP flags value {ip.flags}, expected {flags}')
154        return False
155    if tos and ip.tos != tos:
156        LOGGER.debug(f'Wrong ToS value {ip.tos}, expected {tos}')
157        return False
158    if ttl and ip.ttl != ttl:
159        LOGGER.debug(f'Wrong TTL value {ip.ttl}, expected {ttl}')
160        return False
161    return True
162
163
164def check_ipv6(expect_params, packet):
165    src_address = expect_params.get('src_address')
166    dst_address = expect_params.get('dst_address')
167    flags = expect_params.get('flags')
168    hlim = expect_params.get('hlim')
169    tc = expect_params.get('tc')
170    ip6 = packet.getlayer(sp.IPv6)
171    if not ip6:
172        LOGGER.debug('Packet is not IPv6!')
173        return False
174    if src_address and ip6.src != src_address:
175        LOGGER.debug('Source IPv6 address does not match!')
176        return False
177    if dst_address and ip6.dst != dst_address:
178        LOGGER.debug('Destination IPv6 address does not match!')
179        return False
180    # IPv6 has no IP-level checksum.
181    if flags:
182        raise Exception("There's no fragmentation flags in IPv6")
183    if hlim and ip6.hlim != hlim:
184        LOGGER.debug(f'Wrong Hop Limit value {ip6.hlim}, expected {hlim}')
185        return False
186    if tc and ip6.tc != tc:
187        LOGGER.debug(f'Wrong TC value {ip6.tc}, expected {tc}')
188        return False
189    return True
190
191
192def check_ping_4(expect_params, packet):
193    expect_length = expect_params['length']
194    if not check_ipv4(expect_params, packet):
195        return False
196    icmp = packet.getlayer(sp.ICMP)
197    if not icmp:
198        LOGGER.debug('Packet is not IPv4 ICMP!')
199        return False
200    raw = packet.getlayer(sp.Raw)
201    if not raw:
202        LOGGER.debug('Packet contains no payload!')
203        return False
204    if raw.load != build_payload(expect_length):
205        LOGGER.debug('Payload magic does not match!')
206        return False
207    return True
208
209
210def check_ping_request_4(expect_params, packet):
211    if not check_ping_4(expect_params, packet):
212        return False
213    icmp = packet.getlayer(sp.ICMP)
214    if sp.icmptypes[icmp.type] != 'echo-request':
215        LOGGER.debug('Packet is not IPv4 ICMP Echo Request!')
216        return False
217    return True
218
219
220def check_ping_reply_4(expect_params, packet):
221    if not check_ping_4(expect_params, packet):
222        return False
223    icmp = packet.getlayer(sp.ICMP)
224    if sp.icmptypes[icmp.type] != 'echo-reply':
225        LOGGER.debug('Packet is not IPv4 ICMP Echo Reply!')
226        return False
227    return True
228
229
230def check_ping_request_6(expect_params, packet):
231    expect_length = expect_params['length']
232    if not check_ipv6(expect_params, packet):
233        return False
234    icmp = packet.getlayer(sp.ICMPv6EchoRequest)
235    if not icmp:
236        LOGGER.debug('Packet is not IPv6 ICMP Echo Request!')
237        return False
238    if icmp.data != build_payload(expect_length):
239        LOGGER.debug('Payload magic does not match!')
240        return False
241    return True
242
243
244def check_ping_reply_6(expect_params, packet):
245    expect_length = expect_params['length']
246    if not check_ipv6(expect_params, packet):
247        return False
248    icmp = packet.getlayer(sp.ICMPv6EchoReply)
249    if not icmp:
250        LOGGER.debug('Packet is not IPv6 ICMP Echo Reply!')
251        return False
252    if icmp.data != build_payload(expect_length):
253        LOGGER.debug('Payload magic does not match!')
254        return False
255    return True
256
257
258def check_ping_request(expect_params, packet):
259    src_address = expect_params.get('src_address')
260    dst_address = expect_params.get('dst_address')
261    if not (src_address or dst_address):
262        raise Exception('Source or destination address must be given to match the ping request!')
263    if (
264        (src_address and ':' in src_address) or
265        (dst_address and ':' in dst_address)
266    ):
267        return check_ping_request_6(expect_params, packet)
268    else:
269        return check_ping_request_4(expect_params, packet)
270
271
272def check_ping_reply(expect_params, packet):
273    src_address = expect_params.get('src_address')
274    dst_address = expect_params.get('dst_address')
275    if not (src_address or dst_address):
276        raise Exception('Source or destination address must be given to match the ping reply!')
277    if (
278        (src_address and ':' in src_address) or
279        (dst_address and ':' in dst_address)
280    ):
281        return check_ping_reply_6(expect_params, packet)
282    else:
283        return check_ping_reply_4(expect_params, packet)
284
285
286def check_tcp(expect_params, packet):
287    tcp_flags = expect_params.get('tcp_flags')
288    mss = expect_params.get('mss')
289    seq = expect_params.get('seq')
290    tcp = packet.getlayer(sp.TCP)
291    if not tcp:
292        LOGGER.debug('Packet is not TCP!')
293        return False
294    chksum = tcp.chksum
295    tcp.chksum = None
296    newpacket = sp.Ether(sp.raw(packet[sp.Ether]))
297    new_chksum = newpacket[sp.TCP].chksum
298    if chksum != new_chksum:
299        LOGGER.debug(f'Wrong TCP checksum {chksum}, expected {new_chksum}!')
300        return False
301    if tcp_flags and tcp.flags != tcp_flags:
302        LOGGER.debug(f'Wrong TCP flags {tcp.flags}, expected {tcp_flags}!')
303        return False
304    if seq:
305        if tcp_flags == 'S':
306            tcp_seq = tcp.seq
307        elif tcp_flags == 'SA':
308            tcp_seq = tcp.ack - 1
309        if seq != tcp_seq:
310            LOGGER.debug(f'Wrong TCP Sequence Number {tcp_seq}, expected {seq}')
311            return False
312    if mss:
313        for option in tcp.options:
314            if option[0] == 'MSS':
315                if option[1] != mss:
316                    LOGGER.debug(f'Wrong TCP MSS {option[1]}, expected {mss}')
317                    return False
318    return True
319
320
321def check_tcp_syn_request_4(expect_params, packet):
322    if not check_ipv4(expect_params, packet):
323        return False
324    if not check_tcp(expect_params | {'tcp_flags': 'S'}, packet):
325        return False
326    return True
327
328
329def check_tcp_syn_reply_4(expect_params, packet):
330    if not check_ipv4(expect_params, packet):
331        return False
332    if not check_tcp(expect_params | {'tcp_flags': 'SA'}, packet):
333        return False
334    return True
335
336
337def check_tcp_syn_request_6(expect_params, packet):
338    if not check_ipv6(expect_params, packet):
339        return False
340    if not check_tcp(expect_params | {'tcp_flags': 'S'}, packet):
341        return False
342    return True
343
344
345def check_tcp_syn_reply_6(expect_params, packet):
346    if not check_ipv6(expect_params, packet):
347        return False
348    if not check_tcp(expect_params | {'tcp_flags': 'SA'}, packet):
349        return False
350    return True
351
352
353def check_tcp_syn_request(expect_params, packet):
354    src_address = expect_params.get('src_address')
355    dst_address = expect_params.get('dst_address')
356    if not (src_address or dst_address):
357        raise Exception('Source or destination address must be given to match the tcp syn request!')
358    if (
359        (src_address and ':' in src_address) or
360        (dst_address and ':' in dst_address)
361    ):
362        return check_tcp_syn_request_6(expect_params, packet)
363    else:
364        return check_tcp_syn_request_4(expect_params, packet)
365
366
367def check_tcp_syn_reply(expect_params, packet):
368    src_address = expect_params.get('src_address')
369    dst_address = expect_params.get('dst_address')
370    if not (src_address or dst_address):
371        raise Exception('Source or destination address must be given to match the tcp syn reply!')
372    if (
373        (src_address and ':' in src_address) or
374        (dst_address and ':' in dst_address)
375    ):
376        return check_tcp_syn_reply_6(expect_params, packet)
377    else:
378        return check_tcp_syn_reply_4(expect_params, packet)
379
380
381def setup_sniffer(recvif, ping_type, sniff_type, expect_params, defrag):
382    if ping_type == 'icmp' and sniff_type == 'request':
383        checkfn = check_ping_request
384    elif ping_type == 'icmp' and sniff_type == 'reply':
385        checkfn = check_ping_reply
386    elif ping_type == 'tcpsyn' and sniff_type == 'request':
387        checkfn = check_tcp_syn_request
388    elif ping_type == 'tcpsyn' and sniff_type == 'reply':
389        checkfn = check_tcp_syn_reply
390    else:
391        raise Exception('Unspported ping or sniff type')
392
393    return Sniffer(expect_params, checkfn, recvif, defrag=defrag)
394
395
396def parse_args():
397    parser = argparse.ArgumentParser("pft_ping.py",
398        description="Ping test tool")
399
400    # Parameters of sent ping request
401    parser.add_argument('--sendif', nargs=1,
402        required=True,
403        help='The interface through which the packet(s) will be sent')
404    parser.add_argument('--to', nargs=1,
405        required=True,
406        help='The destination IP address for the ping request')
407    parser.add_argument('--ping-type',
408        choices=('icmp', 'tcpsyn'),
409        help='Type of ping: ICMP (default) or TCP SYN',
410        default='icmp')
411    parser.add_argument('--fromaddr', nargs=1,
412        help='The source IP address for the ping request')
413
414    # Where to look for packets to analyze.
415    # The '+' format is ugly as it mixes positional with optional syntax.
416    # But we have no positional parameters so I guess it's fine to use it.
417    parser.add_argument('--recvif', nargs='+',
418        help='The interfaces on which to expect the ping request')
419    parser.add_argument('--replyif', nargs='+',
420        help='The interfaces which to expect the ping response')
421
422    # Packet settings
423    parser_send = parser.add_argument_group('Values set in transmitted packets')
424    parser_send.add_argument('--send-flags', nargs=1, type=str,
425        help='IPv4 fragmentation flags')
426    parser_send.add_argument('--send-frag-length', nargs=1, type=int,
427         help='Force IP fragmentation with given fragment length')
428    parser_send.add_argument('--send-hlim', nargs=1, type=int,
429        help='IPv6 Hop Limit or IPv4 Time To Live')
430    parser_send.add_argument('--send-mss', nargs=1, type=int,
431        help='TCP Maximum Segment Size')
432    parser_send.add_argument('--send-seq', nargs=1, type=int,
433        help='TCP sequence number')
434    parser_send.add_argument('--send-length', nargs=1, type=int,
435        default=[len(PAYLOAD_MAGIC)], help='ICMP Echo Request payload size')
436    parser_send.add_argument('--send-tc', nargs=1, type=int,
437        help='IPv6 Traffic Class or IPv4 DiffServ / ToS')
438    parser_send.add_argument('--send-tcpopt-unaligned', action='store_true',
439         help='Include unaligned TCP options')
440
441    # Expectations
442    parser_expect = parser.add_argument_group('Values expected in sniffed packets')
443    parser_expect.add_argument('--expect-flags', nargs=1, type=str,
444        help='IPv4 fragmentation flags')
445    parser_expect.add_argument('--expect-hlim', nargs=1, type=int,
446        help='IPv6 Hop Limit or IPv4 Time To Live')
447    parser_expect.add_argument('--expect-mss', nargs=1, type=int,
448        help='TCP Maximum Segment Size')
449    parser_send.add_argument('--expect-seq', nargs=1, type=int,
450        help='TCP sequence number')
451    parser_expect.add_argument('--expect-tc', nargs=1, type=int,
452        help='IPv6 Traffic Class or IPv4 DiffServ / ToS')
453
454    parser.add_argument('-v', '--verbose', action='store_true',
455        help=('Enable verbose logging. Apart of potentially useful information '
456            'you might see warnings from parsing packets like NDP or other '
457            'packets not related to the test being run. Use only when '
458            'developing because real tests expect empty stderr and stdout.'))
459
460    return parser.parse_args()
461
462
463def main():
464    args = parse_args()
465
466    if args.verbose:
467        LOGGER.setLevel(logging.DEBUG)
468
469    # Dig out real values of program arguments
470    send_if = args.sendif[0]
471    reply_ifs = args.replyif
472    recv_ifs = args.recvif
473    dst_address = args.to[0]
474
475    # Standardize parameters which have nargs=1.
476    send_params = {}
477    expect_params = {}
478    for param_name in ('flags', 'hlim', 'length', 'mss', 'seq', 'tc', 'frag_length'):
479        param_arg = vars(args).get(f'send_{param_name}')
480        send_params[param_name] = param_arg[0] if param_arg else None
481        param_arg = vars(args).get(f'expect_{param_name}')
482        expect_params[param_name] = param_arg[0] if param_arg else None
483
484    expect_params['length'] = send_params['length']
485    send_params['tcpopt_unaligned'] = args.send_tcpopt_unaligned
486    send_params['src_address'] = args.fromaddr[0] if args.fromaddr else None
487
488    # We may not have a default route. Tell scapy where to start looking for routes
489    sp.conf.iface6 = send_if
490
491    # Configuration sanity checking.
492    if not (reply_ifs or recv_ifs):
493        raise Exception('With no reply or recv interface specified no traffic '
494            'can be sniffed and verified!'
495        )
496
497    sniffers = []
498
499    if send_params['frag_length']:
500        defrag = True
501    else:
502        defrag = False
503
504    if recv_ifs:
505        sniffer_params = copy(expect_params)
506        sniffer_params['src_address'] = None
507        sniffer_params['dst_address'] = dst_address
508        for iface in recv_ifs:
509            LOGGER.debug(f'Installing receive sniffer on {iface}')
510            sniffers.append(
511                setup_sniffer(iface, args.ping_type, 'request',
512                              sniffer_params, defrag,
513            ))
514
515    if reply_ifs:
516        sniffer_params = copy(expect_params)
517        sniffer_params['src_address'] = dst_address
518        sniffer_params['dst_address'] = None
519        for iface in reply_ifs:
520            LOGGER.debug(f'Installing reply sniffer on {iface}')
521            sniffers.append(
522                setup_sniffer(iface, args.ping_type, 'reply',
523                              sniffer_params, defrag,
524            ))
525
526    LOGGER.debug(f'Installed {len(sniffers)} sniffers')
527
528    send_ping(dst_address, send_if, args.ping_type, send_params)
529
530    err = 0
531    sniffer_num = 0
532    for sniffer in sniffers:
533        sniffer.join()
534        if sniffer.correctPackets == 1:
535            LOGGER.debug(f'Expected ping has been sniffed on {sniffer._recvif}.')
536        else:
537            # Set a bit in err for each failed sniffer.
538            err |= 1<<sniffer_num
539            if sniffer.correctPackets > 1:
540                LOGGER.debug(f'Duplicated ping has been sniffed on {sniffer._recvif}!')
541            else:
542                LOGGER.debug(f'Expected ping has not been sniffed on {sniffer._recvif}!')
543        sniffer_num += 1
544
545    return err
546
547
548if __name__ == '__main__':
549    sys.exit(main())
550