diff --git a/docs/ndb_probes.rst b/docs/ndb_probes.rst new file mode 100644 index 000000000..8c7240dc0 --- /dev/null +++ b/docs/ndb_probes.rst @@ -0,0 +1,6 @@ +.. ndbprobes: + +Network probes +============== + +.. automodule:: pyroute2.ndb.objects.probe diff --git a/docs/ndb_toc.rst b/docs/ndb_toc.rst index 812ba7ddd..6c64044cc 100644 --- a/docs/ndb_toc.rst +++ b/docs/ndb_toc.rst @@ -11,6 +11,7 @@ ndb_interfaces ndb_addresses ndb_routes + ndb_probes ndb_schema ndb_sources ndb_debug diff --git a/pyroute2/iproute/linux.py b/pyroute2/iproute/linux.py index a1f7e698d..4e550a52a 100644 --- a/pyroute2/iproute/linux.py +++ b/pyroute2/iproute/linux.py @@ -56,6 +56,7 @@ RTM_NEWNEIGH, RTM_NEWNETNS, RTM_NEWNSID, + RTM_NEWPROBE, RTM_NEWQDISC, RTM_NEWROUTE, RTM_NEWRULE, @@ -89,6 +90,7 @@ from pyroute2.netlink.rtnl.ndtmsg import ndtmsg from pyroute2.netlink.rtnl.nsidmsg import nsidmsg from pyroute2.netlink.rtnl.nsinfmsg import nsinfmsg +from pyroute2.netlink.rtnl.probe_msg import probe_msg from pyroute2.netlink.rtnl.riprsocket import RawIPRSocket from pyroute2.netlink.rtnl.rtmsg import rtmsg from pyroute2.netlink.rtnl.tcmsg import plugins as tc_plugins @@ -378,6 +380,63 @@ def poll(self, method, command, timeout=10, interval=0.2, **spec): pass raise TimeoutError() + # 8<--------------------------------------------------------------- + # + # Diagnostics + # + def probe(self, command, **kwarg): + ''' + Run a network probe. + + The API will trigger a network probe from the environment it + works in. For NetNS it will be the network namespace, for + remote IPRoute instances it will be the host it runs on. + + Running probes via API allows to test network connectivity + between the environments in a simple uniform way. + + Supported arguments: + + * kind -- probe type, for now only ping is supported + * dst -- target to run the probe against + * num -- number of probes to run + * timeout -- timeout for the whole request + + Examples:: + + ipr.probe("add", kind="ping", dst="10.0.0.1") + + By default ping probe will send one ICMP request towards + the target. To change this, use num argument:: + + ipr.probe( + "add", + kind="ping", + dst="10.0.0.1", + num=4, + timeout=10 + ) + + Timeout for the ping probe by default is 1 second, which + may not be enough to run multiple requests. + + In the next release more probe types are planned, like TCP + port probe. + ''' + msg = probe_msg() + # + msg['family'] = AF_INET + msg['proto'] = 1 + msg['port'] = 0 + msg['dst_len'] = 32 + # + msg['attrs'].append(['PROBE_KIND', kwarg.get('kind', 'ping')]) + msg['attrs'].append(['PROBE_DST', kwarg.get('dst', '0.0.0.0')]) + msg['attrs'].append(['PROBE_NUM', kwarg.get('num', 1)]) + msg['attrs'].append(['PROBE_TIMEOUT', kwarg.get('timeout', 1)]) + # iterate the results immediately, don't defer the probe run + return tuple(self.nlm_request(msg, msg_type=RTM_NEWPROBE, msg_flags=1)) + # 8<--------------------------------------------------------------- # # Listing methods diff --git a/pyroute2/ndb/main.py b/pyroute2/ndb/main.py index 2ab039509..81b65238f 100644 --- a/pyroute2/ndb/main.py +++ b/pyroute2/ndb/main.py @@ -321,6 +321,7 @@ def add_mock_netns(self, netns): ('af_bridge_fdb', 'fdb'), ('rules', 'rules'), ('netns', 'netns'), + ('probes', 'probes'), ('af_bridge_vlans', 'vlans'), ) diff --git a/pyroute2/ndb/objects/probe.py b/pyroute2/ndb/objects/probe.py new file mode 100644 index 000000000..724171c43 --- /dev/null +++ b/pyroute2/ndb/objects/probe.py @@ -0,0 +1,82 @@ +''' +Run a network probe +=================== + +A successful network probe neither creates real network objects +like interfaces or addresses, nor database records. The only +important thing it does -- it raises no exception. + +On the contrary, an unsuccessful network probe raises a +`NetlinkError` exception, cancelling the whole transaction. + +A network probe is always run from the corresponding netlink +target: a local system, a remote system, a network namespace, +a container. + +An example scenario: + + * target alpha, set up eth0 10.0.0.2/24 + * target beta, set up eth0 10.0.0.4/24 + * ping 10.0.0.4 (beta) from target alpha + * ping 10.0.0.2 (alpha) from target beta + +The code below sets up the addresses and checks ICMP responses. If +any step fails, the whole transaction will be rolled back automatically:: + + with NDB(log='debug') as ndb: + ndb.sources.add(kind='remote', hostname='alpha', username='root') + ndb.sources.add(kind='remote', hostname='beta', username='root') + + with ndb.begin() as trx: + trx.push( + (ndb + .interfaces[{'target': 'alpha', 'ifname': 'eth0'}] + .set(state='up') + .add_ip(address='10.0.0.2', prefixlen=24) + ), + (ndb + .interfaces[{'target': 'beta', 'ifname': 'eth0'}] + .set(state='up') + .add_ip(address='10.0.0.4', prefixlen=24) + ), + (ndb + .probes + .create(target='alpha', kind='ping', dst='10.0.0.4') + ), + (ndb + .probes + .create(target='beta', kind='ping', dst='10.0.0.2') + ), + ) +''' + +from pyroute2.netlink.rtnl.probe_msg import probe_msg + +from ..objects import RTNL_Object + + +def load_probe_msg(schema, target, event): + pass + + +schema = probe_msg.sql_schema().unique_index() +init = { + 'specs': [['probes', schema]], + 'classes': [['probes', probe_msg]], + 'event_map': {probe_msg: [load_probe_msg]}, +} + + +class Probe(RTNL_Object): + + table = 'probes' + msg_class = probe_msg + api = 'probe' + + def __init__(self, *argv, **kwarg): + kwarg['iclass'] = probe_msg + self.event_map = {probe_msg: 'load_probe_msg'} + super().__init__(*argv, **kwarg) + + def check(self): + return True diff --git a/pyroute2/ndb/schema.py b/pyroute2/ndb/schema.py index 97bf83ec6..42a4514cc 100644 --- a/pyroute2/ndb/schema.py +++ b/pyroute2/ndb/schema.py @@ -131,7 +131,7 @@ from pyroute2.common import basestring, uuid32 # -from .objects import address, interface, neighbour, netns, route, rule +from .objects import address, interface, neighbour, netns, probe, route, rule try: import psycopg2 @@ -141,7 +141,7 @@ # # the order is important # -plugins = [interface, address, neighbour, route, netns, rule] +plugins = [interface, address, neighbour, route, netns, rule, probe] MAX_ATTEMPTS = 5 diff --git a/pyroute2/ndb/view.py b/pyroute2/ndb/view.py index 09da32ffb..311b62b96 100644 --- a/pyroute2/ndb/view.py +++ b/pyroute2/ndb/view.py @@ -66,6 +66,7 @@ from .objects.interface import Interface, Vlan from .objects.neighbour import FDBRecord, Neighbour from .objects.netns import NetNS +from .objects.probe import Probe from .objects.route import Route from .objects.rule import Rule from .report import Record, RecordSet @@ -120,6 +121,7 @@ def __init__(self, ndb, table, chain=None, auth_managers=None): self.classes['routes'] = Route self.classes['rules'] = Rule self.classes['netns'] = NetNS + self.classes['probes'] = Probe self.classes['af_bridge_vlans'] = Vlan def __enter__(self): diff --git a/pyroute2/netlink/rtnl/__init__.py b/pyroute2/netlink/rtnl/__init__.py index 0859046d8..df52c98f7 100644 --- a/pyroute2/netlink/rtnl/__init__.py +++ b/pyroute2/netlink/rtnl/__init__.py @@ -155,6 +155,9 @@ RTM_NEWNETNS = 500 RTM_DELNETNS = 501 RTM_GETNETNS = 502 +RTM_NEWPROBE = 504 +RTM_DELPROBE = 505 +RTM_GETPROBE = 506 (RTM_NAMES, RTM_VALUES) = map_namespace('RTM_', globals()) TC_H_INGRESS = 0xFFFFFFF1 diff --git a/pyroute2/netlink/rtnl/iprsocket.py b/pyroute2/netlink/rtnl/iprsocket.py index 5dc3b4877..a6f671e03 100644 --- a/pyroute2/netlink/rtnl/iprsocket.py +++ b/pyroute2/netlink/rtnl/iprsocket.py @@ -17,6 +17,7 @@ proxy_newlink, proxy_setlink, ) + from pyroute2.netlink.rtnl.probe_msg import proxy_newprobe class IPRSocketBase(object): @@ -37,6 +38,7 @@ def __init__(self, *argv, **kwarg): self._sproxy.pmap = { rtnl.RTM_NEWLINK: proxy_newlink, rtnl.RTM_SETLINK: proxy_setlink, + rtnl.RTM_NEWPROBE: proxy_newprobe, } def bind(self, groups=None, **kwarg): diff --git a/pyroute2/netlink/rtnl/marshal.py b/pyroute2/netlink/rtnl/marshal.py index 448b8bfea..40431dc54 100644 --- a/pyroute2/netlink/rtnl/marshal.py +++ b/pyroute2/netlink/rtnl/marshal.py @@ -7,6 +7,7 @@ from pyroute2.netlink.rtnl.ndmsg import ndmsg from pyroute2.netlink.rtnl.ndtmsg import ndtmsg from pyroute2.netlink.rtnl.nsidmsg import nsidmsg +from pyroute2.netlink.rtnl.probe_msg import probe_msg from pyroute2.netlink.rtnl.rtmsg import rtmsg from pyroute2.netlink.rtnl.tcmsg import tcmsg @@ -48,6 +49,7 @@ class MarshalRtnl(Marshal): rtnl.RTM_GETSTATS: ifstatsmsg, rtnl.RTM_NEWLINKPROP: ifinfmsg, rtnl.RTM_DELLINKPROP: ifinfmsg, + rtnl.RTM_NEWPROBE: probe_msg, } def fix_message(self, msg): diff --git a/pyroute2/netlink/rtnl/probe_msg.py b/pyroute2/netlink/rtnl/probe_msg.py new file mode 100644 index 000000000..eb5c2db21 --- /dev/null +++ b/pyroute2/netlink/rtnl/probe_msg.py @@ -0,0 +1,74 @@ +import errno +import shutil +import subprocess + +from pyroute2.netlink import nlmsg +from pyroute2.netlink.exceptions import NetlinkError + + +class probe_msg(nlmsg): + ''' + Fake message type to represent network probe info. + + This is a prototype, the NLA layout is subject to change without + notification. + ''' + + __slots__ = () + prefix = 'PROBE_' + + fields = (('family', 'B'), ('proto', 'B'), ('port', 'H'), ('dst_len', 'I')) + + nla_map = ( + ('PROBE_UNSPEC', 'none'), + ('PROBE_KIND', 'asciiz'), + ('PROBE_STDOUT', 'asciiz'), + ('PROBE_STDERR', 'asciiz'), + ('PROBE_SRC', 'target'), + ('PROBE_DST', 'target'), + ('PROBE_NUM', 'uint8'), + ('PROBE_TIMEOUT', 'uint8'), + ) + + +def proxy_newprobe(msg, nl): + num = msg.get('num') + timeout = msg.get('timeout') + dst = msg.get('dst') + kind = msg.get('kind') + + if kind.endswith('ping'): + args = [ + shutil.which(kind), + '-c', + f'{num}', + '-W', + f'{timeout}', + f'{dst}', + ] + if args[0] is None: + raise NetlinkError(errno.ENOENT, 'probe not found') + + process = subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + try: + out, err = process.communicate(timeout=timeout) + if out: + msg['attrs'].append(['PROBE_STDOUT', out]) + if err: + msg['attrs'].append(['PROBE_STDERR', err]) + except subprocess.TimeoutExpired: + process.terminate() + raise NetlinkError(errno.ETIMEDOUT, 'timeout expired') + finally: + process.stdout.close() + process.stderr.close() + return_code = process.wait() + if return_code != 0: + raise NetlinkError(errno.EHOSTUNREACH, 'probe failed') + else: + raise NetlinkError(errno.ENOTSUP, 'probe type not supported') + msg.reset() + msg.encode() + return {'verdict': 'return', 'data': msg.data} diff --git a/tests/test_linux/test_ipr/test_probe.py b/tests/test_linux/test_ipr/test_probe.py new file mode 100644 index 000000000..df8b7a7cf --- /dev/null +++ b/tests/test_linux/test_ipr/test_probe.py @@ -0,0 +1,77 @@ +import errno +from socket import AF_INET + +import pytest +from pr2test.context_manager import make_test_matrix, skip_if_not_supported +from pr2test.marks import require_root + +from pyroute2 import NetlinkError + +pytestmark = [require_root()] +test_matrix = make_test_matrix(targets=['local', 'netns']) + + +@pytest.mark.parametrize('context', test_matrix, indirect=True) +@skip_if_not_supported +def test_ping_ok(context): + index, ifname = context.default_interface + ipaddr = context.new_ipaddr + + context.ipr.addr('add', index=index, address=ipaddr, prefixlen=24) + context.ipr.link('set', index=index, state='up') + context.ipr.link( + 'set', index=context.ipr.link_lookup(ifname='lo'), state='up' + ) + + context.ndb.interfaces.wait(ifname=ifname, state='up') + context.ndb.interfaces.wait(ifname='lo', state='up') + + probes = [x for x in context.ipr.probe('add', kind='ping', dst=ipaddr)] + probe = probes[0] + + assert len(probes) == 1 + assert probe['family'] == AF_INET + assert probe['proto'] == 1 + assert probe['port'] == 0 + assert probe['dst_len'] == 32 + assert probe.get('dst') == ipaddr + assert probe.get('kind') == 'ping' + + +@pytest.mark.parametrize( + 'context', make_test_matrix(targets=['netns']), indirect=True +) +@skip_if_not_supported +def test_ping_fail_ehostunreach(context): + context.ipr.link( + 'set', index=context.ipr.link_lookup(ifname='lo'), state='down' + ) + with pytest.raises(NetlinkError) as e: + context.ipr.probe('add', kind='ping', dst='127.0.0.1') + assert e.value.code == errno.EHOSTUNREACH + + +@pytest.mark.parametrize( + 'context', make_test_matrix(targets=['netns']), indirect=True +) +@skip_if_not_supported +def test_ping_fail_etimedout(context): + index, ifname = context.default_interface + ipaddr = context.new_ipaddr + target = context.new_ipaddr + + context.ipr.addr('add', index=index, address=ipaddr, prefixlen=24) + context.ipr.link('set', index=index, state='up') + context.ipr.link( + 'set', index=context.ipr.link_lookup(ifname='lo'), state='up' + ) + + context.ndb.interfaces.wait(ifname=ifname, state='up') + context.ndb.interfaces.wait(ifname='lo', state='up') + + context.ipr.link( + 'set', index=context.ipr.link_lookup(ifname='lo'), state='up' + ) + with pytest.raises(NetlinkError) as e: + context.ipr.probe('add', kind='ping', dst=target) + assert e.value.code == errno.ETIMEDOUT diff --git a/tests/test_linux/test_ndb/test_probe.py b/tests/test_linux/test_ndb/test_probe.py new file mode 100644 index 000000000..72669f9f4 --- /dev/null +++ b/tests/test_linux/test_ndb/test_probe.py @@ -0,0 +1,57 @@ +import errno + +import pytest +from pr2test.context_manager import make_test_matrix, skip_if_not_supported +from pr2test.marks import require_root + +from pyroute2 import NetlinkError + +pytestmark = [require_root()] +test_matrix = make_test_matrix(targets=['local', 'netns']) + + +@pytest.mark.parametrize('context', test_matrix, indirect=True) +@skip_if_not_supported +def test_ping_ok(context): + index, ifname = context.default_interface + ipaddr = context.new_ipaddr + + with context.ndb.interfaces[ifname] as i: + i.add_ip(address=ipaddr, prefixlen=24) + i.set(state='up') + + with context.ndb.interfaces['lo'] as i: + i.set(state='up') + + context.ndb.probes.create(kind='ping', dst=ipaddr).commit() + + +@pytest.mark.parametrize( + 'context', make_test_matrix(targets=['netns']), indirect=True +) +@skip_if_not_supported +def test_ping_fail_ehostunreach(context): + with context.ndb.interfaces['lo'] as i: + i.set(state='down') + with pytest.raises(NetlinkError) as e: + context.ndb.probes.create(kind='ping', dst='127.0.0.1').commit() + assert e.value.code == errno.EHOSTUNREACH + + +@pytest.mark.parametrize( + 'context', make_test_matrix(targets=['netns']), indirect=True +) +@skip_if_not_supported +def test_ping_fail_etimedout(context): + index, ifname = context.default_interface + ipaddr = context.new_ipaddr + target = context.new_ipaddr + + with context.ndb.interfaces[ifname] as i: + i.add_ip(address=ipaddr, prefixlen=24) + i.set(state='up') + with context.ndb.interfaces['lo'] as i: + i.set(state='up') + with pytest.raises(NetlinkError) as e: + context.ndb.probes.create(kind='ping', dst=target).commit() + assert e.value.code == errno.ETIMEDOUT