[harness] add functionality to discover and control simulations (#7901)

It can run all the Thread 1.1 test cases with simulations as a role of
Router now except those requiring RF enclosure.
This commit is contained in:
Jiachen Dong
2022-07-21 21:49:34 -07:00
committed by GitHub
parent 19ca3a0a3c
commit 12933d3f86
11 changed files with 1027 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Test Harness on Simulation Environment Setup
THCI (Thread Host Controller Interface) is an implementation of the Python abstract class template `IThci`, which is used by the Thread Test Harness Software to control OpenThread-based reference devices according to each test scenario.
SI (Sniffer Interface) is an implementation of the sniffer abstract class template `ISniffer`, which is used by the Thread Test Harness Software to sniff all packets sent by devices.
Both OpenThread simulation and sniffer simulation are required to run on a POSIX environment. However, Harness has to be run on Windows, which is a non-POSIX environment. So both two systems are needed, and their setup procedures in detail are listed in the following sections. Either two machines or one machine running two (sub)systems (for example, VM, WSL) is feasible.
Platform developers should modify the THCI implementation and/or the SI implementation directly to match their platform (for example, the path of the OpenThread repository).
## POSIX Environment Setup
1. Build OpenThread to generate standalone OpenThread simulation `ot-cli-ftd`. For example, run the following command in the top directory of OpenThread.
```bash
$ script/cmake-build simulation
```
Then `ot-cli-ftd` is built in the directory `build/simulation/examples/apps/cli/`.
## Test Harness Environment Setup
1. Double click the file `harness\install.bat` on the machine which installed Harness.
2. Check the configuration file `C:\GRL\Thread1.2\Thread_Harness\simulation\config.py`
- Edit the value of `REMOTE_USERNAME` to the username expected to connect to on the remote POSIX environment.
- Edit the value of `REMOTE_PASSWORD` to the password corresponding to the username above.
- Edit the value of `REMOTE_OT_PATH` to the absolute path where the top directory of the OpenThread repository is located.
3. Add the additional simulation device information in `harness\Web\data\deviceInputFields.xml` to `C:\GRL\Thread1.2\Web\data\deviceInputFields.xml`.
## Run Test Harness on Simulation
1. On POSIX machine, change directory to the top of OpenThread repository, and run the following commands.
```bash
$ cd tools/harness-simulation/posix
$ python harness_dev_discovery.py \
--interface=eth0 \
--ot1.1=24 \
--sniffer=2
```
It starts 24 OT FTD simulations and 2 sniffer simulations and can be discovered on eth0.
The arguments can be adjusted according to the requirement of test cases.
2. Run Test Harness. The information field of the device is encoded as `<node_id>@<ip_addr>`. Choose the proper device as the DUT accordingly.
3. Select one or more test cases to start the test.
@@ -0,0 +1,225 @@
#!/usr/bin/env python
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
import ipaddress
import netifaces
import os
import paramiko
import select
import socket
import struct
import subprocess
import time
import winreg as wr
from ISniffer import ISniffer
from THCI.OpenThread import watched
from simulation.config import (
REMOTE_PORT,
REMOTE_USERNAME,
REMOTE_PASSWORD,
REMOTE_OT_PATH,
REMOTE_SNIFFER_OUTPUT_PREFIX,
EDITCAP_PATH,
)
DISCOVERY_ADDR = ('ff02::114', 12345)
IFNAME = 'WLAN'
SCAN_TIME = 3
# `socket.IPPROTO_IPV6` only exists in Python 3, so the constant is manually defined.
IPPROTO_IPV6 = 41
# The subkey represents the class of network adapter devices supported by the system,
# which is used to filter physical network cards and avoid virtual network adapters.
WINREG_KEY = r'SYSTEM\CurrentControlSet\Control\Network\{4d36e972-e325-11ce-bfc1-08002be10318}'
class SimSniffer(ISniffer):
@watched
def __init__(self, **kwargs):
self.channel = kwargs.get('channel')
self.ipaddr = kwargs.get('addressofDevice')
self.is_active = False
self._local_pcapng_location = None
self._ssh = None
self._remote_pcap_location = None
self._remote_pid = None
def __repr__(self):
return '%r' % self.__dict__
def _get_connection_name_from_guid(self, iface_guids):
iface_names = ['(unknown)' for i in range(len(iface_guids))]
reg = wr.ConnectRegistry(None, wr.HKEY_LOCAL_MACHINE)
reg_key = wr.OpenKey(reg, WINREG_KEY)
for i in range(len(iface_guids)):
try:
reg_subkey = wr.OpenKey(reg_key, iface_guids[i] + r'\Connection')
iface_names[i] = wr.QueryValueEx(reg_subkey, 'Name')[0]
except Exception, e:
pass
return iface_names
def _find_index(self, iface_name):
ifaces_guid = netifaces.interfaces()
absolute_iface_name = self._get_connection_name_from_guid(ifaces_guid)
try:
_required_iface_index = absolute_iface_name.index(iface_name)
_required_guid = ifaces_guid[_required_iface_index]
ip = netifaces.ifaddresses(_required_guid)[netifaces.AF_INET6][-1]['addr']
self.log('Local IP: %s', ip)
return int(ip.split('%')[1])
except Exception, e:
self.log('%r', e)
self.log('Interface %s not found', iface_name)
return None
def _encode_address_port(self, addr, port):
port = str(port)
if isinstance(ipaddress.ip_address(addr), ipaddress.IPv6Address):
return '[' + addr + ']:' + port
return addr + ':' + port
@watched
def discoverSniffer(self):
sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
# Define the output interface of the socket
ifn = self._find_index(IFNAME)
if ifn is None:
self.log('%s interface has not enabled IPv6.', IFNAME)
return []
ifn = struct.pack('I', ifn)
sock.setsockopt(IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, ifn)
# Send the request
sock.sendto(('Sniffer').encode(), DISCOVERY_ADDR)
# Scan for responses
devs = set()
start = time.time()
while time.time() - start < SCAN_TIME:
if select.select([sock], [], [], 1)[0]:
addr, _ = sock.recvfrom(1024)
devs.add(addr)
else:
# Re-send the request, due to unreliability of UDP especially on WLAN
sock.sendto(('Sniffer').encode(), DISCOVERY_ADDR)
devs = [SimSniffer(addressofDevice=addr, channel=None) for addr in devs]
self.log('List of SimSniffers: %r', devs)
return devs
@watched
def startSniffer(self, channelToCapture, captureFileLocation, includeEthernet=False):
self.channel = channelToCapture
self._local_pcapng_location = captureFileLocation
self._remote_pcap_location = os.path.join(REMOTE_SNIFFER_OUTPUT_PREFIX, self.ipaddr.split('@')[0] + '.pcap')
self._ssh = paramiko.SSHClient()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
remote_ip = self.ipaddr.split('@')[1]
self._ssh.connect(remote_ip, port=REMOTE_PORT, username=REMOTE_USERNAME, password=REMOTE_PASSWORD)
_, stdout, _ = self._ssh.exec_command(
'echo $$ && exec python3 %s -o %s -c %d' %
(os.path.join(REMOTE_OT_PATH, 'tools/harness-simulation/posix/sniffer_sim/sniffer.py'),
self._remote_pcap_location, self.channel))
self._remote_pid = int(stdout.readline())
self.log('local pcapng location = %s', self._local_pcapng_location)
self.log('remote pcap location = %s', self._remote_pcap_location)
self.log('remote pid = %d', self._remote_pid)
self.is_active = True
@watched
def stopSniffer(self):
if not self.is_active:
return
self.is_active = False
assert self._ssh is not None
self._ssh.exec_command('kill -s TERM %d' % self._remote_pid)
# Wait to make sure the file is closed
time.sleep(3)
# Truncate suffix from .pcapng to .pcap
local_pcap_location = self._local_pcapng_location[:-2]
with self._ssh.open_sftp() as sftp:
sftp.get(self._remote_pcap_location, local_pcap_location)
self._ssh.close()
cmd = [EDITCAP_PATH, '-F', 'pcapng', local_pcap_location, self._local_pcapng_location]
self.log('running editcap: %r', cmd)
subprocess.Popen(cmd).wait()
self.log('editcap done')
self._local_pcapng_location = None
self._ssh = None
self._remote_pcap_location = None
self._remote_pid = None
@watched
def setChannel(self, channelToCapture):
self.channel = channelToCapture
@watched
def getChannel(self):
return self.channel
@watched
def validateFirmwareVersion(self, device):
return True
@watched
def isSnifferCapturing(self):
return self.is_active
@watched
def getSnifferAddress(self):
return self.ipaddr
@watched
def globalReset(self):
pass
def log(self, fmt, *args):
try:
msg = fmt % args
print('%s - %s - %s' % ('SimSniffer', time.strftime('%b %d %H:%M:%S'), msg))
except Exception:
pass
@@ -0,0 +1,144 @@
#!/usr/bin/env python
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
"""
>> Thread Host Controller Interface
>> Device : OpenThread_Sim THCI
>> Class : OpenThread_Sim
"""
import ipaddress
import os
import paramiko
import socket
import time
from IThci import IThci
from OpenThread import OpenThreadTHCI, watched
from simulation.config import REMOTE_OT_PATH
class SSHHandle(object):
def __init__(self, ip, port, username, password, device, node_id):
ipaddress.ip_address(ip)
self.ip = ip
self.port = int(port)
self.username = username
self.password = password
self.__handle = None
self.__stdin = None
self.__stdout = None
self.__connect(device, node_id)
@watched
def __connect(self, device, node_id):
self.close()
self.__handle = paramiko.SSHClient()
self.__handle.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
self.log('Connecting to %s:%s with username=%s', self.ip, self.port, self.username)
self.__handle.connect(self.ip, port=self.port, username=self.username, password=self.password)
except paramiko.AuthenticationException:
if not self.password:
self.__handle.get_transport().auth_none(self.username)
else:
raise Exception('Password error')
self.__stdin, self.__stdout, _ = self.__handle.exec_command(device + ' ' + str(node_id), get_pty=True)
self.__stdout.channel.setblocking(0)
# Wait some time for initiation
time.sleep(0.1)
@watched
def close(self):
if self.__handle is None:
return
self.__stdin.write('exit\n')
# Wait some time for termination
time.sleep(0.1)
self.__handle.close()
self.__stdin = None
self.__stdout = None
self.__handle = None
def send(self, cmd):
self.__stdin.write(cmd)
def recv(self):
try:
return self.__stdout.readline().rstrip()
except socket.timeout:
return ''
def log(self, fmt, *args):
try:
msg = fmt % args
print('%s - %s - %s' % (self.port, time.strftime('%b %d %H:%M:%S'), msg))
except Exception:
pass
class OpenThread_Sim(OpenThreadTHCI, IThci):
DEFAULT_COMMAND_TIMEOUT = 20
__handle = None
device = os.path.join(REMOTE_OT_PATH, 'build/simulation/examples/apps/cli/ot-cli-ftd')
@watched
def _connect(self):
# Only actually connect once.
if self.__handle is None:
assert self.connectType == 'ip'
assert '@' in self.telnetIp
self.log('SSH connecting ...')
node_id, ssh_ip = self.telnetIp.split('@')
self.__handle = SSHHandle(ssh_ip, self.telnetPort, self.telnetUsername, self.telnetPassword, self.device,
node_id)
self.log('connected to %s successfully', self.telnetIp)
@watched
def _disconnect(self):
pass
def _cliReadLine(self):
tail = self.__handle.recv()
return tail if tail else None
def _cliWriteLine(self, line):
self.__handle.send(line + '\n')
def _onCommissionStart(self):
pass
def _onCommissionStop(self):
pass
@@ -0,0 +1,38 @@
#!/usr/bin/env python
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
REMOTE_PORT = 22
REMOTE_USERNAME = 'pi'
REMOTE_PASSWORD = 'raspberry'
REMOTE_SNIFFER_OUTPUT_PREFIX = '/tmp/'
REMOTE_OT_PATH = '/home/pi/work/src/openthread-pr/'
EDITCAP_PATH = r'C:\Program Files (x86)\Wireshark_Thread\editcap.exe'
@@ -0,0 +1,10 @@
<DEVICE_FIELDS>
<DEVICE name="OpenThread_Sim" thumbnail="OpenThread.png" description = "OpenThread Simulation" THCI="OpenThread_Sim">
<ITEM label="Serial Line"
type="text"
forParam="SerialPort"
validation="COM"
hint="eg: COM1">COM1
</ITEM>
</DEVICE>
</DEVICE_FIELDS>
@@ -0,0 +1,2 @@
xcopy /E /Y Thread_Harness %systemdrive%\GRL\Thread1.2\Thread_Harness
copy /Y ..\..\harness-thci\OpenThread.py %systemdrive%\GRL\Thread1.2\Thread_Harness\THCI
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
import argparse
import ctypes
import ctypes.util
import json
import logging
import os
import socket
import struct
GROUP = 'ff02::114'
PORT = 12345
MAX_OT11_NUM = 33
MAX_SNIFFER_NUM = 4
def if_nametoindex(ifname: str) -> int:
libc = ctypes.CDLL(ctypes.util.find_library('c'))
ret = libc.if_nametoindex(ifname.encode('ascii'))
if not ret:
raise RuntimeError('Invalid interface name')
return ret
def get_ipaddr(ifname: str) -> str:
for line in os.popen(f'ip addr list dev {ifname} | grep inet | grep global'):
addr = line.strip().split()[1]
return addr.split('/')[0]
raise RuntimeError(f'No IP address on dev {ifname}')
def init_socket(ifname: str, group: str, port: int) -> socket.socket:
# Look up multicast group address in name server and find out IP version
addrinfo = socket.getaddrinfo(group, None)[0]
assert addrinfo[0] == socket.AF_INET6
# Create a socket
s = socket.socket(addrinfo[0], socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, (ifname + '\0').encode('ascii'))
# Bind it to the port
s.bind((group, port))
group_bin = socket.inet_pton(addrinfo[0], addrinfo[4][0])
# Join group
interface_index = if_nametoindex(ifname)
mreq = group_bin + struct.pack('@I', interface_index)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq)
return s
def advertise_ftd(s: socket.socket, dst, ven: str, ver: str, add: str, por: int, number: int):
# Node ID of ot-cli-ftd is 1-indexed
for i in range(1, number + 1):
info = {
'ven': ven,
'mod': f'{ven}_{i}',
'ver': ver,
'add': f'{i}@{add}',
'por': por,
}
logging.info('Advertise: %r', info)
s.sendto(json.dumps(info).encode('utf-8'), dst)
def advertise_sniffer(s: socket.socket, dst, add: str, number: int):
for i in range(number):
info = 'Sniffer_%d@%s' % (i, add)
logging.info('Advertise: %r', info)
s.sendto(info.encode('utf-8'), dst)
def main():
logging.basicConfig(level=logging.INFO)
# Parse arguments
parser = argparse.ArgumentParser()
# Determine the interface
parser.add_argument('-i',
'--interface',
dest='ifname',
type=str,
required=True,
help='the interface used for discovery')
# Determine the number of OpenThread 1.1 FTD simulations to be "detected" and then initiated
parser.add_argument('--ot1.1',
dest='ot11_num',
type=int,
required=False,
default=0,
help=f'the number of OpenThread FTD simulations, no more than {MAX_OT11_NUM}')
# Determine the number of sniffer simulations to be initiated and then detected
parser.add_argument('-s',
'--sniffer',
dest='sniffer_num',
type=int,
required=False,
default=0,
help=f'the number of sniffer simulations, no more than {MAX_SNIFFER_NUM}')
args = parser.parse_args()
# Check validation of arguments
if not 0 <= args.ot11_num <= MAX_OT11_NUM:
raise ValueError(f'The number of FTDs should be between 0 and {MAX_OT11_NUM}')
if not 0 <= args.sniffer_num <= MAX_SNIFFER_NUM:
raise ValueError(f'The number of FTDs should be between 0 and {MAX_SNIFFER_NUM}')
if args.ot11_num == args.sniffer_num == 0:
raise ValueError('At least one device is required')
# Get the local IP address on the specified interface
addr = get_ipaddr(args.ifname)
s = init_socket(args.ifname, GROUP, PORT)
logging.info('Advertising on interface %s group %s ...', args.ifname, GROUP)
# Loop, printing any data we receive
while True:
data, src = s.recvfrom(64)
if data == b'BBR':
logging.info('Received OpenThread simulation query, advertising')
advertise_ftd(s, src, ven='OpenThread_Sim', ver='4', add=addr, por=22, number=args.ot11_num)
elif data == b'Sniffer':
logging.info('Received sniffer simulation query, advertising')
advertise_sniffer(s, src, add=addr, number=args.sniffer_num)
else:
logging.warning('Received %r, but ignored', data)
if __name__ == '__main__':
main()
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
""" Module to provide codec utilities for .pcap formatters. """
import struct
import time
# https://www.tcpdump.org/linktypes.html
DLT_IEEE802_15_4_WITHFCS = 195
PCAP_MAGIC_NUMBER = 0xA1B2C3D4
PCAP_VERSION_MAJOR = 2
PCAP_VERSION_MINOR = 4
class PcapCodec(object):
""" Utility class for .pcap formatters. """
def __init__(self, filename, channel):
self._dlt = DLT_IEEE802_15_4_WITHFCS
if not filename.endswith('.pcap'):
raise ValueError('Filename should end with .pcap')
self._pcap_file = open(filename, 'wb')
self._pcap_file.write(self.encode_header())
self._channel = channel
def encode_header(self):
""" Return a pcap file header. """
return struct.pack(
'<LHHLLLL',
PCAP_MAGIC_NUMBER,
PCAP_VERSION_MAJOR,
PCAP_VERSION_MINOR,
0,
0,
256,
self._dlt,
)
def encode_frame(self, frame, sec, usec):
""" Return a pcap encapsulation of the given frame. """
# Ignore the first byte storing channel.
frame = frame[1:]
length = len(frame)
pcap_frame = struct.pack('<LLLL', sec, usec, length, length)
pcap_frame += frame
return pcap_frame
def _get_timestamp(self):
""" Return the internal timestamp. """
timestamp = time.time()
timestamp_sec = int(timestamp)
timestamp_usec = int((timestamp - timestamp_sec) * 1000000)
return timestamp_sec, timestamp_usec
def append(self, frame):
""" Append a frame. """
# Filter channel.
if frame[0] != self._channel:
return
timestamp = self._get_timestamp()
pkt = self.encode_frame(frame, *timestamp)
self._pcap_file.write(pkt)
self._pcap_file.flush()
def close(self):
""" Close the pcap file. """
self._pcap_file.close()
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
import argparse
import logging
import signal
import time
import pcap_codec
import sys
import threading
import sniffer_transport
class Sniffer:
""" Class representing the Sniffing node, whose main task is listening.
"""
logger = logging.getLogger('sniffer.Sniffer')
RECV_BUFFER_SIZE = 4096
def __init__(self, filename, channel):
self._pcap = pcap_codec.PcapCodec(filename, channel)
# Create transport
transport_factory = sniffer_transport.SnifferTransportFactory()
self._transport = transport_factory.create_transport()
self._thread = None
self._thread_alive = threading.Event()
self._thread_alive.clear()
def _sniffer_main_loop(self):
""" Sniffer main loop. """
self.logger.debug('Sniffer started.')
while self._thread_alive.is_set():
data, nodeid = self._transport.recv(self.RECV_BUFFER_SIZE)
self._pcap.append(data)
self.logger.debug('Sniffer stopped.')
def start(self):
""" Start sniffing. """
self._thread = threading.Thread(target=self._sniffer_main_loop)
self._thread.daemon = True
self._transport.open()
self._thread_alive.set()
self._thread.start()
def stop(self):
""" Stop sniffing. """
self._thread_alive.clear()
self._transport.close()
self._thread.join(timeout=1)
self._thread = None
def close(self):
""" Close the pcap file. """
self._pcap.close()
def run_sniffer():
parser = argparse.ArgumentParser()
parser.add_argument('-o',
'--output',
dest='output',
type=str,
required=True,
help='the path of the output .pcap file')
parser.add_argument('-c',
'--channel',
dest='channel',
type=int,
required=True,
help='the channel which is sniffered')
args = parser.parse_args()
sniffer = Sniffer(args.output, args.channel)
sniffer.start()
def atexit(signum, frame):
sniffer.stop()
sniffer.close()
sys.exit(0)
signal.signal(signal.SIGTERM, atexit)
while sniffer._thread_alive.is_set():
time.sleep(0.5)
sniffer.stop()
sniffer.close()
if __name__ == '__main__':
run_sniffer()
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
#
# Copyright (c) 2022, The OpenThread Authors.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# 3. Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
import os
import socket
class SnifferTransport(object):
""" Interface for transport that allows eavesdrop other nodes. """
def open(self):
""" Open transport.
Raises:
RuntimeError: when transport is already opened or when transport opening failed.
"""
raise NotImplementedError
def close(self):
""" Close transport.
Raises:
RuntimeError: when transport is already closed.
"""
raise NotImplementedError
@property
def is_opened(self):
""" Check if transport is opened.
Returns:
bool: True if the transport is opened, False in otherwise
"""
raise NotImplementedError
def send(self, data, nodeid):
""" Send data to the node with nodeid.
Args:
data (bytearray): outcoming data.
nodeid (int): node id
Returns:
int: number of sent bytes
"""
raise NotImplementedError
def recv(self, bufsize):
""" Receive data sent by other node.
Args:
bufsize (int): size of buffer for incoming data.
Returns:
A tuple contains data and node id.
For example:
(bytearray([0x00, 0x01...], 1)
"""
raise NotImplementedError
class SnifferSocketTransport(SnifferTransport):
""" Socket based implementation of sniffer transport. """
BASE_PORT = 9000
MAX_NETWORK_SIZE = int(os.getenv('MAX_NETWORK_SIZE', '33'))
PORT_OFFSET = int(os.getenv('PORT_OFFSET', '0'))
RADIO_GROUP = '224.0.0.116'
def __init__(self):
self._socket = None
def __del__(self):
if not self.is_opened:
return
self.close()
def _nodeid_to_port(self, nodeid: int):
return self.BASE_PORT + (self.PORT_OFFSET * (self.MAX_NETWORK_SIZE + 1)) + nodeid
def _port_to_nodeid(self, port):
return (port - self.BASE_PORT - (self.PORT_OFFSET * (self.MAX_NETWORK_SIZE + 1)))
def open(self):
if self.is_opened:
raise RuntimeError('Transport is already opened.')
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if not self.is_opened:
raise RuntimeError('Transport opening failed.')
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024)
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2 * 1024 * 1024)
self._socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(self.RADIO_GROUP) + socket.inet_aton('127.0.0.1'))
self._socket.bind((self.RADIO_GROUP, self._nodeid_to_port(0)))
def close(self):
if not self.is_opened:
raise RuntimeError('Transport is closed.')
self._socket.close()
self._socket = None
@property
def is_opened(self):
return bool(self._socket is not None)
def send(self, data, nodeid):
address = ('127.0.0.1', self._nodeid_to_port(nodeid))
return self._socket.sendto(data, address)
def recv(self, bufsize):
data, address = self._socket.recvfrom(bufsize)
nodeid = self._port_to_nodeid(address[1])
return bytearray(data), nodeid
class SnifferTransportFactory(object):
def create_transport(self):
return SnifferSocketTransport()