Files
Abtin Keshavarzian 24d633d99d [srp-client] add auto host address mode (#7655)
This commit adds a "auto host address mode" in SRP client. When
enabled, host IPv6 addresses are automatically set by SRP client
using all the unicast addresses on Thread netif excluding the
link-local and mesh-local addresses. If there is no valid address,
then Mesh Local EID address is added. The SRP client will
automatically re-register when/if addresses on Thread netif are
updated (e.g., new addresses are added or existing addresses are
removed).

This commit updates CLI to add support for new SRP client APIs related
to the new feature. It also adds `test_srp_auto_host_address` test to
cover the behavior of this feature.
2022-06-02 15:32:08 -07:00

635 lines
23 KiB
Python

#!/usr/bin/env python3
#
# Copyright (c) 2021, 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 sys
import os
import time
import re
import random
import string
import subprocess
import pexpect
import pexpect.popen_spawn
import signal
import inspect
import weakref
# ----------------------------------------------------------------------------------------------------------------------
# Constants
JOIN_TYPE_ROUTER = 'router'
JOIN_TYPE_END_DEVICE = 'ed'
JOIN_TYPE_SLEEPY_END_DEVICE = 'sed'
# ----------------------------------------------------------------------------------------------------------------------
def _log(text, new_line=True, flush=True):
sys.stdout.write(text)
if new_line:
sys.stdout.write('\n')
if flush:
sys.stdout.flush()
# ----------------------------------------------------------------------------------------------------------------------
# CliError class
class CliError(Exception):
def __init__(self, error_code, message):
self._error_code = error_code
self._message = message
@property
def error_code(self):
return self._error_code
@property
def message(self):
return self._message
# ----------------------------------------------------------------------------------------------------------------------
# Node class
class Node(object):
""" An OT CLI instance """
# defines the default verbosity setting (can be changed per `Node`)
_VERBOSE = os.getenv('TORANJ_VERBOSE', 'no').lower() in ['true', '1', 't', 'y', 'yes', 'on']
_SPEED_UP_FACTOR = 1 # defines the default time speed up factor
# Determine whether to save logs in a file.
_SAVE_LOGS = True
# name of log file (if _SAVE_LOGS is `True`)
_LOG_FNAME = 'ot-logs'
_OT_BUILDDIR = os.getenv('top_builddir', '../../..')
_OT_CLI_FTD = '%s/examples/apps/cli/ot-cli-ftd' % _OT_BUILDDIR
_WAIT_TIME = 10
_START_INDEX = 1
_cur_index = _START_INDEX
_all_nodes = weakref.WeakSet()
def __init__(self, verbose=_VERBOSE):
"""Creates a new `Node` instance"""
index = Node._cur_index
Node._cur_index += 1
self._index = index
self._verbose = verbose
if Node._SAVE_LOGS:
self._log_file = open(self._LOG_FNAME + str(index) + '.log', 'wb')
else:
self._log_file = None
cmd = f'{self._OT_CLI_FTD} --time-speed={self._SPEED_UP_FACTOR} {self._index}'
if self._verbose:
_log(f'$ Node{index}.__init__() cmd: `{cmd}`')
self._cli_process = pexpect.popen_spawn.PopenSpawn(cmd, logfile=self._log_file)
Node._all_nodes.add(self)
def __del__(self):
self._finalize()
def __repr__(self):
return f'Node(index={self._index})'
@property
def index(self):
return self._index
# ------------------------------------------------------------------------------------------------------------------
# Executing a `cli` command
def cli(self, *args):
""" Issues a CLI command on the given node and returns the resulting output.
The returned result is a list of strings (with `\r\n` removed) as outputted by the CLI.
If executing the command fails, `CliError` is raised with error code and error message.
"""
cmd = ' '.join([f'{arg}' for arg in args if arg is not None]).strip()
if self._verbose:
_log(f'$ Node{self._index}.cli(\'{cmd}\')', new_line=False)
self._cli_process.send(cmd + '\n')
index = self._cli_process.expect(['(.*)Done\r\n', '.*Error (\d+):(.*)\r\n'])
if index == 0:
result = [
line for line in self._cli_process.match.group(1).decode().splitlines()
if not self._is_ot_logg_line(line) if not line.strip().endswith(cmd)
]
if self._verbose:
if len(result) > 1:
_log(':')
for line in result:
_log(' ' + line)
elif len(result) == 1:
_log(f' -> {result[0]}')
else:
_log('')
return result
else:
match = self._cli_process.match
e = CliError(int(match.group(1).decode()), match.group(2).decode().strip())
if self._verbose:
_log(f': Error {e.message} ({e.error_code})')
raise e
def _is_ot_logg_line(self, line):
return any(level in line for level in [' [D] ', ' [I] ', ' [N] ', ' [W] ', ' [C] ', ' [-] '])
def _cli_no_output(self, cmd, *args):
outputs = self.cli(cmd, *args)
verify(len(outputs) == 0)
def _cli_single_output(self, cmd, expected_outputs=None):
outputs = self.cli(cmd)
verify(len(outputs) == 1)
verify((expected_outputs is None) or (outputs[0] in expected_outputs))
return outputs[0]
def _finalize(self):
if self._cli_process.proc.poll() is None:
if self._verbose:
_log(f'$ Node{self.index} terminating')
self._cli_process.send('exit\n')
self._cli_process.wait()
# ------------------------------------------------------------------------------------------------------------------
# cli commands
def get_state(self):
return self._cli_single_output('state', ['detached', 'child', 'router', 'leader', 'disabled'])
def get_channel(self):
return self._cli_single_output('channel')
def set_channel(self, channel):
self._cli_no_output('channel', channel)
def get_ext_addr(self):
return self._cli_single_output('extaddr')
def set_ext_addr(self, ext_addr):
self._cli_no_output('extaddr', ext_addr)
def get_ext_panid(self):
return self._cli_single_output('extpanid')
def set_ext_panid(self, ext_panid):
self._cli_no_output('extpanid', ext_panid)
def get_mode(self):
return self._cli_single_output('mode')
def set_mode(self, mode):
self._cli_no_output('mode', mode)
def get_network_key(self):
return self._cli_single_output('networkkey')
def set_network_key(self, networkkey):
self._cli_no_output('networkkey', networkkey)
def get_network_name(self):
return self._cli_single_output('networkname')
def set_network_name(self, network_name):
self._cli_no_output('networkname', network_name)
def get_panid(self):
return self._cli_single_output('panid')
def set_panid(self, panid):
self._cli_no_output('panid', panid)
def get_router_upgrade_threshold(self):
return self._cli_single_output('routerupgradethreshold')
def set_router_upgrade_threshold(self, threshold):
self._cli_no_output('routerupgradethreshold', threshold)
def get_router_selection_jitter(self):
return self._cli_single_output('routerselectionjitter')
def set_router_selection_jitter(self, jitter):
self._cli_no_output('routerselectionjitter', jitter)
def interface_up(self):
self._cli_no_output('ifconfig up')
def interface_down(self):
self._cli_no_output('ifconfig down')
def get_interface_state(self):
return self._cli_single_output('ifconfig')
def thread_start(self):
self._cli_no_output('thread start')
def thread_stop(self):
self._cli_no_output('thread stop')
def get_ip_addrs(self):
return self.cli('ipaddr')
def get_mleid_ip_addr(self):
return self._cli_single_output('ipaddr mleid')
def get_linklocal_ip_addr(self):
return self._cli_single_output('ipaddr linklocal')
def get_rloc_ip_addr(self):
return self._cli_single_output('ipaddr rloc')
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# SRP client
def srp_client_start(self, server_address, server_port):
self._cli_no_output('srp client start', server_address, server_port)
def srp_client_stop(self):
self._cli_no_output('srp client stop')
def srp_client_get_state(self):
return self._cli_single_output('srp client state', ['Enabled', 'Disabled'])
def srp_client_get_auto_start_mode(self):
return self._cli_single_output('srp client autostart', ['Enabled', 'Disabled'])
def srp_client_enable_auto_start_mode(self):
self._cli_no_output('srp client autostart enable')
def srp_client_disable_auto_start_mode(self):
self._cli_no_output('srp client autostart disable')
def srp_client_get_server_address(self):
return self._cli_single_output('srp client server address')
def srp_client_get_server_port(self):
return self._cli_single_output('srp client server port')
def srp_client_get_host_state(self):
return self._cli_single_output('srp client host state')
def srp_client_set_host_name(self, name):
self._cli_no_output('srp client host name', name)
def srp_client_get_host_name(self):
return self._cli_single_output('srp client host name')
def srp_client_remove_host(self, remove_key=False, send_unreg_to_server=False):
self._cli_no_output('srp client host remove', int(remove_key), int(send_unreg_to_server))
def srp_client_clear_host(self):
self._cli_no_output('srp client host clear')
def srp_client_enable_auto_host_address(self):
self._cli_no_output('srp client host address auto')
def srp_client_set_host_address(self, *addrs):
self._cli_no_output('srp client host address', *addrs)
def srp_client_get_host_address(self):
return self.cli('srp client host address')
def srp_client_add_service(self, instance_name, service_name, port, priority=0, weight=0, txt_entries=[]):
txt_record = "".join(self._encode_txt_entry(entry) for entry in txt_entries)
self._cli_no_output('srp client service add', instance_name, service_name, port, priority, weight, txt_record)
def srp_client_remove_service(self, instance_name, service_name):
self._cli_no_output('srp client service remove', instance_name, service_name)
def srp_client_clear_service(self, instance_name, service_name):
self._cli_no_output('srp client service clear', instance_name, service_name)
def srp_client_get_services(self):
outputs = self.cli('srp client service')
return [self._parse_srp_client_service(line) for line in outputs]
def _encode_txt_entry(self, entry):
"""Encodes the TXT entry to the DNS-SD TXT record format as a HEX string.
Example usage:
self._encode_txt_entries(['abc']) -> '03616263'
self._encode_txt_entries(['def=']) -> '046465663d'
self._encode_txt_entries(['xyz=XYZ']) -> '0778797a3d58595a'
"""
return '{:02x}'.format(len(entry)) + "".join("{:02x}".format(ord(c)) for c in entry)
def _parse_srp_client_service(self, line):
"""Parse one line of srp service list into a dictionary which
maps string keys to string values.
Example output for input
'instance:\"%s\", name:\"%s\", state:%s, port:%d, priority:%d, weight:%d"'
{
'instance': 'my-service',
'name': '_ipps._udp',
'state': 'ToAdd',
'port': '12345',
'priority': '0',
'weight': '0'
}
Note that value of 'port', 'priority' and 'weight' are represented
as strings but not integers.
"""
key_values = [word.strip().split(':') for word in line.split(', ')]
return {key_value[0].strip(): key_value[1].strip('"') for key_value in key_values}
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# SRP server
def srp_server_get_state(self):
return self._cli_single_output('srp server state', ['disabled', 'running', 'stopped'])
def srp_server_get_addr_mode(self):
return self._cli_single_output('srp server addrmode', ['unicast', 'anycast'])
def srp_server_set_addr_mode(self, mode):
self._cli_no_output('srp server addrmode', mode)
def srp_server_get_anycast_seq_num(self):
return self._cli_single_output('srp server seqnum')
def srp_server_set_anycast_seq_num(self, seqnum):
self._cli_no_output('srp server seqnum', seqnum)
def srp_server_enable(self):
self._cli_no_output('srp server enable')
def srp_server_disable(self):
self._cli_no_output('srp server disable')
def srp_server_set_lease(self, min_lease, max_lease, min_key_lease, max_key_lease):
self._cli_no_output('srp server lease', min_lease, max_lease, min_key_lease, max_key_lease)
def srp_server_get_hosts(self):
"""Returns the host list on the SRP server as a list of property
dictionary.
Example output:
[{
'fullname': 'my-host.default.service.arpa.',
'name': 'my-host',
'deleted': 'false',
'addresses': ['2001::1', '2001::2']
}]
"""
outputs = self.cli('srp server host')
host_list = []
while outputs:
host = {}
host['fullname'] = outputs.pop(0).strip()
host['name'] = host['fullname'].split('.')[0]
host['deleted'] = outputs.pop(0).strip().split(':')[1].strip()
if host['deleted'] == 'true':
host_list.append(host)
continue
addresses = outputs.pop(0).strip().split('[')[1].strip(' ]').split(',')
map(str.strip, addresses)
host['addresses'] = [addr for addr in addresses if addr]
host_list.append(host)
return host_list
def srp_server_get_host(self, host_name):
"""Returns host on the SRP server that matches given host name.
Example usage:
self.srp_server_get_host("my-host")
"""
for host in self.srp_server_get_hosts():
if host_name == host['name']:
return host
def srp_server_get_services(self):
"""Returns the service list on the SRP server as a list of property
dictionary.
Example output:
[{
'fullname': 'my-service._ipps._tcp.default.service.arpa.',
'instance': 'my-service',
'name': '_ipps._tcp',
'deleted': 'false',
'port': '12345',
'priority': '0',
'weight': '0',
'ttl': '7200',
'TXT': ['abc=010203'],
'host_fullname': 'my-host.default.service.arpa.',
'host': 'my-host',
'addresses': ['2001::1', '2001::2']
}]
Note that the TXT data is output as a HEX string.
"""
outputs = self.cli('srp server service')
service_list = []
while outputs:
service = {}
service['fullname'] = outputs.pop(0).strip()
name_labels = service['fullname'].split('.')
service['instance'] = name_labels[0]
service['name'] = '.'.join(name_labels[1:3])
service['deleted'] = outputs.pop(0).strip().split(':')[1].strip()
if service['deleted'] == 'true':
service_list.append(service)
continue
# 'subtypes', port', 'priority', 'weight', 'ttl'
for i in range(0, 5):
key_value = outputs.pop(0).strip().split(':')
service[key_value[0].strip()] = key_value[1].strip()
txt_entries = outputs.pop(0).strip().split('[')[1].strip(' ]').split(',')
txt_entries = map(str.strip, txt_entries)
service['TXT'] = [txt for txt in txt_entries if txt]
service['host_fullname'] = outputs.pop(0).strip().split(':')[1].strip()
service['host'] = service['host_fullname'].split('.')[0]
addresses = outputs.pop(0).strip().split('[')[1].strip(' ]').split(',')
addresses = map(str.strip, addresses)
service['addresses'] = [addr for addr in addresses if addr]
service_list.append(service)
return service_list
def srp_server_get_service(self, instance_name, service_name):
"""Returns service on the SRP server that matches given instance
name and service name.
Example usage:
self.srp_server_get_service("my-service", "_ipps._tcp")
"""
for service in self.srp_server_get_services():
if (instance_name == service['instance'] and service_name == service['name']):
return service
# ------------------------------------------------------------------------------------------------------------------
# Helper methods
def form(self, network_name=None, network_key=None, channel=None, panid=0x1234, xpanid=None):
if network_name is not None:
self.set_network_name(network_name)
if network_key is not None:
self.set_network_key(network_key)
if channel is not None:
self.set_channel(channel)
if xpanid is not None:
self.set_ext_panid(xpanid)
self.set_panid(panid)
self.interface_up()
self.thread_start()
verify_within(_check_node_is_leader, self._WAIT_TIME, arg=self)
def join(self, node, type=JOIN_TYPE_ROUTER):
self.set_network_name(node.get_network_name())
self.set_network_key(node.get_network_key())
self.set_channel(node.get_channel())
self.set_panid(node.get_panid())
if type == JOIN_TYPE_END_DEVICE:
self.set_mode('rn')
elif type == JOIN_TYPE_SLEEPY_END_DEVICE:
self.set_mode('-')
else:
self.set_mode('rdn')
self.set_router_selection_jitter(1)
self.interface_up()
self.thread_start()
if type == JOIN_TYPE_ROUTER:
verify_within(_check_node_is_router, self._WAIT_TIME, arg=self)
else:
verify_within(_check_node_is_child, self._WAIT_TIME, arg=self)
def allowlist_node(self, node):
"""Adds a given node to the allowlist of `self` and enables allowlisting on `self`"""
self._cli_no_output('macfilter addr add', node.get_ext_addr())
self._cli_no_output('macfilter addr allowlist')
def un_allowlist_node(self, node):
"""Removes a given node (of node `Node) from the allowlist"""
self._cli_no_output('macfilter addr remove', node.get_ext_addr())
# ------------------------------------------------------------------------------------------------------------------
# class methods
@classmethod
def finalize_all_nodes(cls):
"""Finalizes all previously created `Node` instances (stops the CLI process)"""
for node in Node._all_nodes:
node._finalize()
@classmethod
def set_time_speedup_factor(cls, factor):
"""Sets up the time speed up factor - should be set before creating any `Node` objects"""
if len(Node._all_nodes) != 0:
raise Node._NodeError('set_time_speedup_factor() cannot be called after creating a `Node`')
Node._SPEED_UP_FACTOR = factor
def _check_node_is_leader(node):
verify(node.get_state() == 'leader')
def _check_node_is_router(node):
verify(node.get_state() == 'router')
def _check_node_is_child(node):
verify(node.get_state() == 'child')
# ----------------------------------------------------------------------------------------------------------------------
class VerifyError(Exception):
pass
_is_in_verify_within = False
def verify(condition):
"""Verifies that a `condition` is true, otherwise raises a VerifyError"""
global _is_in_verify_within
if not condition:
calling_frame = inspect.currentframe().f_back
error_message = 'verify() failed at line {} in "{}"'.format(calling_frame.f_lineno,
calling_frame.f_code.co_filename)
if not _is_in_verify_within:
print(error_message)
raise VerifyError(error_message)
def verify_within(condition_checker_func, wait_time, arg=None, delay_time=0.1):
"""Verifies that a given function `condition_checker_func` passes successfully within a given wait timeout.
`wait_time` is maximum time waiting for condition_checker to pass (in seconds).
`arg` is optional parameter and if it s not None, will be passed to `condition_checker_func()`
`delay_time` specifies a delay interval added between failed attempts (in seconds).
"""
global _is_in_verify_within
start_time = time.time()
old_is_in_verify_within = _is_in_verify_within
_is_in_verify_within = True
while True:
try:
if arg is None:
condition_checker_func()
else:
condition_checker_func(arg)
except VerifyError as e:
if time.time() - start_time > wait_time:
print('Took too long to pass the condition ({}>{} sec)'.format(time.time() - start_time, wait_time))
print(e.message)
raise e
except BaseException:
raise
else:
break
if delay_time != 0:
time.sleep(delay_time)
_is_in_verify_within = old_is_in_verify_within