xref: /freebsd/tests/sys/netpfil/common/pft_ping.py (revision 4b9d6057)
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    chksum = ip.chksum
153    ip.chksum = None
154    new_chksum = sp.IP(sp.raw(ip)).chksum
155    if chksum != new_chksum:
156        LOGGER.debug(f'Expected IP checksum {new_chksum} but found {chksum}')
157        return False
158    if flags and ip.flags != flags:
159        LOGGER.debug(f'Wrong IP flags value {ip.flags}, expected {flags}')
160        return False
161    if tos and ip.tos != tos:
162        LOGGER.debug(f'Wrong ToS value {ip.tos}, expected {tos}')
163        return False
164    if ttl and ip.ttl != ttl:
165        LOGGER.debug(f'Wrong TTL value {ip.ttl}, expected {ttl}')
166        return False
167    return True
168
169
170def check_ipv6(expect_params, packet):
171    src_address = expect_params.get('src_address')
172    dst_address = expect_params.get('dst_address')
173    flags = expect_params.get('flags')
174    hlim = expect_params.get('hlim')
175    tc = expect_params.get('tc')
176    ip6 = packet.getlayer(sp.IPv6)
177    if not ip6:
178        LOGGER.debug('Packet is not IPv6!')
179        return False
180    if src_address and ip6.src != src_address:
181        LOGGER.debug('Source IPv6 address does not match!')
182        return False
183    if dst_address and ip6.dst != dst_address:
184        LOGGER.debug('Destination IPv6 address does not match!')
185        return False
186    # IPv6 has no IP-level checksum.
187    if flags:
188        raise Exception("There's no fragmentation flags in IPv6")
189    if hlim and ip6.hlim != hlim:
190        LOGGER.debug(f'Wrong Hop Limit value {ip6.hlim}, expected {hlim}')
191        return False
192    if tc and ip6.tc != tc:
193        LOGGER.debug(f'Wrong TC value {ip6.tc}, expected {tc}')
194        return False
195    return True
196
197
198def check_ping_4(expect_params, packet):
199    expect_length = expect_params['length']
200    if not check_ipv4(expect_params, packet):
201        return False
202    icmp = packet.getlayer(sp.ICMP)
203    if not icmp:
204        LOGGER.debug('Packet is not IPv4 ICMP!')
205        return False
206    raw = packet.getlayer(sp.Raw)
207    if not raw:
208        LOGGER.debug('Packet contains no payload!')
209        return False
210    if raw.load != build_payload(expect_length):
211        LOGGER.debug('Payload magic does not match!')
212        return False
213    return True
214
215
216def check_ping_request_4(expect_params, packet):
217    if not check_ping_4(expect_params, packet):
218        return False
219    icmp = packet.getlayer(sp.ICMP)
220    if sp.icmptypes[icmp.type] != 'echo-request':
221        LOGGER.debug('Packet is not IPv4 ICMP Echo Request!')
222        return False
223    return True
224
225
226def check_ping_reply_4(expect_params, packet):
227    if not check_ping_4(expect_params, packet):
228        return False
229    icmp = packet.getlayer(sp.ICMP)
230    if sp.icmptypes[icmp.type] != 'echo-reply':
231        LOGGER.debug('Packet is not IPv4 ICMP Echo Reply!')
232        return False
233    return True
234
235
236def check_ping_request_6(expect_params, packet):
237    expect_length = expect_params['length']
238    if not check_ipv6(expect_params, packet):
239        return False
240    icmp = packet.getlayer(sp.ICMPv6EchoRequest)
241    if not icmp:
242        LOGGER.debug('Packet is not IPv6 ICMP Echo Request!')
243        return False
244    if icmp.data != build_payload(expect_length):
245        LOGGER.debug('Payload magic does not match!')
246        return False
247    return True
248
249
250def check_ping_reply_6(expect_params, packet):
251    expect_length = expect_params['length']
252    if not check_ipv6(expect_params, packet):
253        return False
254    icmp = packet.getlayer(sp.ICMPv6EchoReply)
255    if not icmp:
256        LOGGER.debug('Packet is not IPv6 ICMP Echo Reply!')
257        return False
258    if icmp.data != build_payload(expect_length):
259        LOGGER.debug('Payload magic does not match!')
260        return False
261    return True
262
263
264def check_ping_request(expect_params, packet):
265    src_address = expect_params.get('src_address')
266    dst_address = expect_params.get('dst_address')
267    if not (src_address or dst_address):
268        raise Exception('Source or destination address must be given to match the ping request!')
269    if (
270        (src_address and ':' in src_address) or
271        (dst_address and ':' in dst_address)
272    ):
273        return check_ping_request_6(expect_params, packet)
274    else:
275        return check_ping_request_4(expect_params, packet)
276
277
278def check_ping_reply(expect_params, packet):
279    src_address = expect_params.get('src_address')
280    dst_address = expect_params.get('dst_address')
281    if not (src_address or dst_address):
282        raise Exception('Source or destination address must be given to match the ping reply!')
283    if (
284        (src_address and ':' in src_address) or
285        (dst_address and ':' in dst_address)
286    ):
287        return check_ping_reply_6(expect_params, packet)
288    else:
289        return check_ping_reply_4(expect_params, packet)
290
291
292def check_tcp(expect_params, packet):
293    tcp_flags = expect_params.get('tcp_flags')
294    mss = expect_params.get('mss')
295    seq = expect_params.get('seq')
296    tcp = packet.getlayer(sp.TCP)
297    if not tcp:
298        LOGGER.debug('Packet is not TCP!')
299        return False
300    chksum = tcp.chksum
301    tcp.chksum = None
302    newpacket = sp.Ether(sp.raw(packet[sp.Ether]))
303    new_chksum = newpacket[sp.TCP].chksum
304    if chksum != new_chksum:
305        LOGGER.debug(f'Wrong TCP checksum {chksum}, expected {new_chksum}!')
306        return False
307    if tcp_flags and tcp.flags != tcp_flags:
308        LOGGER.debug(f'Wrong TCP flags {tcp.flags}, expected {tcp_flags}!')
309        return False
310    if seq:
311        if tcp_flags == 'S':
312            tcp_seq = tcp.seq
313        elif tcp_flags == 'SA':
314            tcp_seq = tcp.ack - 1
315        if seq != tcp_seq:
316            LOGGER.debug(f'Wrong TCP Sequence Number {tcp_seq}, expected {seq}')
317            return False
318    if mss:
319        for option in tcp.options:
320            if option[0] == 'MSS':
321                if option[1] != mss:
322                    LOGGER.debug(f'Wrong TCP MSS {option[1]}, expected {mss}')
323                    return False
324    return True
325
326
327def check_tcp_syn_request_4(expect_params, packet):
328    if not check_ipv4(expect_params, packet):
329        return False
330    if not check_tcp(expect_params | {'tcp_flags': 'S'}, packet):
331        return False
332    return True
333
334
335def check_tcp_syn_reply_4(expect_params, packet):
336    if not check_ipv4(expect_params, packet):
337        return False
338    if not check_tcp(expect_params | {'tcp_flags': 'SA'}, packet):
339        return False
340    return True
341
342
343def check_tcp_syn_request_6(expect_params, packet):
344    if not check_ipv6(expect_params, packet):
345        return False
346    if not check_tcp(expect_params | {'tcp_flags': 'S'}, packet):
347        return False
348    return True
349
350
351def check_tcp_syn_reply_6(expect_params, packet):
352    if not check_ipv6(expect_params, packet):
353        return False
354    if not check_tcp(expect_params | {'tcp_flags': 'SA'}, packet):
355        return False
356    return True
357
358
359def check_tcp_syn_request(expect_params, packet):
360    src_address = expect_params.get('src_address')
361    dst_address = expect_params.get('dst_address')
362    if not (src_address or dst_address):
363        raise Exception('Source or destination address must be given to match the tcp syn request!')
364    if (
365        (src_address and ':' in src_address) or
366        (dst_address and ':' in dst_address)
367    ):
368        return check_tcp_syn_request_6(expect_params, packet)
369    else:
370        return check_tcp_syn_request_4(expect_params, packet)
371
372
373def check_tcp_syn_reply(expect_params, packet):
374    src_address = expect_params.get('src_address')
375    dst_address = expect_params.get('dst_address')
376    if not (src_address or dst_address):
377        raise Exception('Source or destination address must be given to match the tcp syn reply!')
378    if (
379        (src_address and ':' in src_address) or
380        (dst_address and ':' in dst_address)
381    ):
382        return check_tcp_syn_reply_6(expect_params, packet)
383    else:
384        return check_tcp_syn_reply_4(expect_params, packet)
385
386
387def setup_sniffer(recvif, ping_type, sniff_type, expect_params, defrag):
388    if ping_type == 'icmp' and sniff_type == 'request':
389        checkfn = check_ping_request
390    elif ping_type == 'icmp' and sniff_type == 'reply':
391        checkfn = check_ping_reply
392    elif ping_type == 'tcpsyn' and sniff_type == 'request':
393        checkfn = check_tcp_syn_request
394    elif ping_type == 'tcpsyn' and sniff_type == 'reply':
395        checkfn = check_tcp_syn_reply
396    else:
397        raise Exception('Unspported ping or sniff type')
398
399    return Sniffer(expect_params, checkfn, recvif, defrag=defrag)
400
401
402def parse_args():
403    parser = argparse.ArgumentParser("pft_ping.py",
404        description="Ping test tool")
405
406    # Parameters of sent ping request
407    parser.add_argument('--sendif', nargs=1,
408        required=True,
409        help='The interface through which the packet(s) will be sent')
410    parser.add_argument('--to', nargs=1,
411        required=True,
412        help='The destination IP address for the ping request')
413    parser.add_argument('--ping-type',
414        choices=('icmp', 'tcpsyn'),
415        help='Type of ping: ICMP (default) or TCP SYN',
416        default='icmp')
417    parser.add_argument('--fromaddr', nargs=1,
418        help='The source IP address for the ping request')
419
420    # Where to look for packets to analyze.
421    # The '+' format is ugly as it mixes positional with optional syntax.
422    # But we have no positional parameters so I guess it's fine to use it.
423    parser.add_argument('--recvif', nargs='+',
424        help='The interfaces on which to expect the ping request')
425    parser.add_argument('--replyif', nargs='+',
426        help='The interfaces which to expect the ping response')
427
428    # Packet settings
429    parser_send = parser.add_argument_group('Values set in transmitted packets')
430    parser_send.add_argument('--send-flags', nargs=1, type=str,
431        help='IPv4 fragmentation flags')
432    parser_send.add_argument('--send-frag-length', nargs=1, type=int,
433         help='Force IP fragmentation with given fragment length')
434    parser_send.add_argument('--send-hlim', nargs=1, type=int,
435        help='IPv6 Hop Limit or IPv4 Time To Live')
436    parser_send.add_argument('--send-mss', nargs=1, type=int,
437        help='TCP Maximum Segment Size')
438    parser_send.add_argument('--send-seq', nargs=1, type=int,
439        help='TCP sequence number')
440    parser_send.add_argument('--send-length', nargs=1, type=int,
441        default=[len(PAYLOAD_MAGIC)], help='ICMP Echo Request payload size')
442    parser_send.add_argument('--send-tc', nargs=1, type=int,
443        help='IPv6 Traffic Class or IPv4 DiffServ / ToS')
444    parser_send.add_argument('--send-tcpopt-unaligned', action='store_true',
445         help='Include unaligned TCP options')
446
447    # Expectations
448    parser_expect = parser.add_argument_group('Values expected in sniffed packets')
449    parser_expect.add_argument('--expect-flags', nargs=1, type=str,
450        help='IPv4 fragmentation flags')
451    parser_expect.add_argument('--expect-hlim', nargs=1, type=int,
452        help='IPv6 Hop Limit or IPv4 Time To Live')
453    parser_expect.add_argument('--expect-mss', nargs=1, type=int,
454        help='TCP Maximum Segment Size')
455    parser_send.add_argument('--expect-seq', nargs=1, type=int,
456        help='TCP sequence number')
457    parser_expect.add_argument('--expect-tc', nargs=1, type=int,
458        help='IPv6 Traffic Class or IPv4 DiffServ / ToS')
459
460    parser.add_argument('-v', '--verbose', action='store_true',
461        help=('Enable verbose logging. Apart of potentially useful information '
462            'you might see warnings from parsing packets like NDP or other '
463            'packets not related to the test being run. Use only when '
464            'developing because real tests expect empty stderr and stdout.'))
465
466    return parser.parse_args()
467
468
469def main():
470    args = parse_args()
471
472    if args.verbose:
473        LOGGER.setLevel(logging.DEBUG)
474
475    # Dig out real values of program arguments
476    send_if = args.sendif[0]
477    reply_ifs = args.replyif
478    recv_ifs = args.recvif
479    dst_address = args.to[0]
480
481    # Standardize parameters which have nargs=1.
482    send_params = {}
483    expect_params = {}
484    for param_name in ('flags', 'hlim', 'length', 'mss', 'seq', 'tc', 'frag_length'):
485        param_arg = vars(args).get(f'send_{param_name}')
486        send_params[param_name] = param_arg[0] if param_arg else None
487        param_arg = vars(args).get(f'expect_{param_name}')
488        expect_params[param_name] = param_arg[0] if param_arg else None
489
490    expect_params['length'] = send_params['length']
491    send_params['tcpopt_unaligned'] = args.send_tcpopt_unaligned
492    send_params['src_address'] = args.fromaddr[0] if args.fromaddr else None
493
494    # We may not have a default route. Tell scapy where to start looking for routes
495    sp.conf.iface6 = send_if
496
497    # Configuration sanity checking.
498    if not (reply_ifs or recv_ifs):
499        raise Exception('With no reply or recv interface specified no traffic '
500            'can be sniffed and verified!'
501        )
502
503    sniffers = []
504
505    if send_params['frag_length']:
506        defrag = True
507    else:
508        defrag = False
509
510    if recv_ifs:
511        sniffer_params = copy(expect_params)
512        sniffer_params['src_address'] = None
513        sniffer_params['dst_address'] = dst_address
514        for iface in recv_ifs:
515            LOGGER.debug(f'Installing receive sniffer on {iface}')
516            sniffers.append(
517                setup_sniffer(iface, args.ping_type, 'request',
518                              sniffer_params, defrag,
519            ))
520
521    if reply_ifs:
522        sniffer_params = copy(expect_params)
523        sniffer_params['src_address'] = dst_address
524        sniffer_params['dst_address'] = None
525        for iface in reply_ifs:
526            LOGGER.debug(f'Installing reply sniffer on {iface}')
527            sniffers.append(
528                setup_sniffer(iface, args.ping_type, 'reply',
529                              sniffer_params, defrag,
530            ))
531
532    LOGGER.debug(f'Installed {len(sniffers)} sniffers')
533
534    send_ping(dst_address, send_if, args.ping_type, send_params)
535
536    err = 0
537    sniffer_num = 0
538    for sniffer in sniffers:
539        sniffer.join()
540        if sniffer.correctPackets == 1:
541            LOGGER.debug(f'Expected ping has been sniffed on {sniffer._recvif}.')
542        else:
543            # Set a bit in err for each failed sniffer.
544            err |= 1<<sniffer_num
545            if sniffer.correctPackets > 1:
546                LOGGER.debug(f'Duplicated ping has been sniffed on {sniffer._recvif}!')
547            else:
548                LOGGER.debug(f'Expected ping has not been sniffed on {sniffer._recvif}!')
549        sniffer_num += 1
550
551    return err
552
553
554if __name__ == '__main__':
555    sys.exit(main())
556