← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~troyanov/maas:use-netmon-binary into maas:master

 

Anton Troyanov has proposed merging ~troyanov/maas:use-netmon-binary into maas:master.

Commit message:
feat(netmon)!: replace tcpdump with maas-netmon

New binary `maas-netmon` is introduced for ARP network discovery. 

BREAKING CHANGE: Binary doesn't read PCAP format, thus it is not possible to pass in stdin or file as an argument anymore. It is also not possible to ask for a verbose output.

Requested reviews:
  MAAS Lander (maas-lander)
  MAAS Maintainers (maas-maintainers)

For more details, see:
https://code.launchpad.net/~troyanov/maas/+git/maas/+merge/442457
-- 
Your team MAAS Committers is subscribed to branch maas:master.
diff --git a/Makefile b/Makefile
index 8caf1e9..68e0262 100644
--- a/Makefile
+++ b/Makefile
@@ -202,7 +202,6 @@ lint-go-fix: lint-go
 lint-shell:
 	@shellcheck -x \
 		package-files/usr/lib/maas/beacon-monitor \
-		package-files/usr/lib/maas/network-monitor \
 		package-files/usr/lib/maas/unverified-ssh \
 		snap/hooks/* \
 		snap/local/tree/bin/* \
diff --git a/debian/extras/99-maas-common-sudoers b/debian/extras/99-maas-common-sudoers
index 412c60b..c00627c 100644
--- a/debian/extras/99-maas-common-sudoers
+++ b/debian/extras/99-maas-common-sudoers
@@ -2,7 +2,6 @@ maas ALL= NOPASSWD: /usr/bin/lshw
 maas ALL= NOPASSWD: /sbin/blockdev
 
 # MAAS network monitoring tools.
-maas ALL= NOPASSWD: /usr/lib/maas/network-monitor
 maas ALL= NOPASSWD: /usr/lib/maas/beacon-monitor
 
 # Control of the HTTP server: MAAS needs to reconfigure it after editing
diff --git a/debian/maas-common.install b/debian/maas-common.install
index 7caf604..5d6f0f5 100644
--- a/debian/maas-common.install
+++ b/debian/maas-common.install
@@ -4,7 +4,6 @@ package-files/usr/lib/maas/maas-delete-file usr/lib/maas
 package-files/usr/lib/maas/maas-write-file usr/lib/maas
 
 # Install network monitoring scripts
-package-files/usr/lib/maas/network-monitor usr/lib/maas
 package-files/usr/lib/maas/beacon-monitor usr/lib/maas
 
 # Install unverified-ssh
diff --git a/package-files/usr/lib/maas/network-monitor b/package-files/usr/lib/maas/network-monitor
deleted file mode 100755
index 2b6d362..0000000
--- a/package-files/usr/lib/maas/network-monitor
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/sh -euf
-# Copyright 2016 Canonical Ltd.  This software is licensed under the
-# GNU Affero General Public License version 3 (see the file LICENSE).
-
-# Utility script to wrap `tcpdump`, so that this script can be called with
-# `sudo` without allowing MAAS access to read arbitrary network traffic.
-# This script is designed to be as minimal as possible, to prevent arbitrary
-# code execution.
-
-if [ $# -ne 1 ]; then
-    echo "Writes ARP traffic (and ARP traffic on tagged VLANs) to stdout" >&2
-    echo "using tcpdump's binary PCAP format." >&2
-    echo "" >&2
-    echo "Usage:" >&2
-    echo "    $0 <interface>" >&2
-    exit 32
-fi
-
-exec "${SNAP:-}/usr/bin/tcpdump" -Z root --interface "$1" --no-promiscuous-mode \
-    --packet-buffered --immediate-mode --snapshot-length=64 -n -w - \
-    "arp or (vlan and arp)"
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index 6889cda..77cd0c3 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -248,8 +248,10 @@ parts:
       - OUT_PREFIX=maas-
     build-packages:
       - golang-go
+    organize:
+      bin/maas-netmon: usr/sbin/maas-netmon
     prime:
-      - bin/maas-netmon
+      - usr/sbin/maas-netmon
 
   tree:
     plugin: dump
diff --git a/src/provisioningserver/utils/arp.py b/src/provisioningserver/utils/arp.py
index 86172fb..35e701b 100644
--- a/src/provisioningserver/utils/arp.py
+++ b/src/provisioningserver/utils/arp.py
@@ -4,356 +4,15 @@
 """Utilities for working with ARP packets."""
 
 
-from collections import namedtuple
-from datetime import datetime
-import json
 import os
-import stat
-import struct
 import subprocess
 import sys
 from textwrap import dedent
 
-from netaddr import EUI, IPAddress
-
 from provisioningserver.path import get_path
 from provisioningserver.utils import sudo
-from provisioningserver.utils.ethernet import Ethernet, ETHERTYPE
-from provisioningserver.utils.network import bytes_to_int, format_eui
-from provisioningserver.utils.pcap import PCAP, PCAPError
 from provisioningserver.utils.script import ActionScriptError
 
-# The SEEN_AGAIN_THRESHOLD is a time (in seconds) that determines how often
-# to report (IP, MAC) bindings that have been seen again (or "REFRESHED").
-# While it is important for MAAS to know about "NEW" and "MOVED" bindings
-# immediately, "REFRESHED" bindings occur too often to be useful, and
-# are thus throttled by this value.
-SEEN_AGAIN_THRESHOLD = 600
-
-# Definitions for ARP packet used with `struct`.
-ARP_PACKET = "!hhBBh6sL6sL"
-ARPPacket = namedtuple(
-    "ARPPacket",
-    (
-        "hardware_type",
-        "protocol",
-        "hardware_length",
-        "protocol_length",
-        "operation",
-        "sender_mac",
-        "sender_ip",
-        "target_mac",
-        "target_ip",
-    ),
-)
-
-SIZEOF_ARP_PACKET = 28
-
-
-class ARP_OPERATION:
-    """Enumeration to represent ARP operation types."""
-
-    REQUEST = 1
-    REPLY = 2
-
-    def __init__(self, operation):
-        super().__init__()
-        self.operation = operation
-
-    def __bytes__(self):
-        """Returns the ARP operation in byte format.
-
-        The returned value will be padded to two bytes; suitable for placement
-        in an ARP packet.
-        """
-        return bytes.fromhex("%04x" % self.operation)
-
-    def __str__(self):
-        if self.operation == 1:
-            extra = " (request)"
-        elif self.operation == 2:
-            extra = " (reply)"
-        else:
-            extra = ""
-        return "%d%s" % (self.operation, extra)
-
-    def __radd__(self, other):
-        """Allows concatenating an ARP_OPERATION with `bytes`."""
-        if isinstance(other, bytes):
-            return other + bytes(self)
-        else:
-            raise NotImplementedError(
-                "ARP_OPERATION may only be added to `bytes`."
-            )
-
-
-class ARP:
-    """Representation of an ARP packet."""
-
-    def __init__(
-        self, pkt_bytes, time=None, src_mac=None, dst_mac=None, vid=None
-    ):
-        """
-        :param pkt_bytes: The input bytes of the ARP packet.
-        :type pkt_bytes: bytes
-        :param time: Timestamp packet was seen (seconds since epoch)
-        :type time: str
-        :param src_mac: Source MAC address from Ethernet header.
-        :type src_mac: bytes
-        :param dst_mac: Destination MAC address from Ethernet header.
-        :type dst_mac: bytes
-        :param vid: 802.1q VLAN ID (VID), or None if untagged.
-        :type vid: int
-        :return:
-        """
-        packet = ARPPacket._make(
-            struct.unpack(ARP_PACKET, pkt_bytes[0:SIZEOF_ARP_PACKET])
-        )
-        self.packet = packet
-        self.time = time
-        if src_mac is not None:
-            self.src_mac = EUI(bytes_to_int(src_mac))
-        else:
-            self.src_mac = None
-        if dst_mac is not None:
-            self.dst_mac = EUI(bytes_to_int(dst_mac))
-        else:
-            self.dst_mac = None
-        self.vid = vid
-        self.hardware_type = packet.hardware_type
-        self.protocol_type = packet.protocol
-        self.hardware_length = packet.hardware_length
-        self.protocol_length = packet.protocol_length
-        self.operation = packet.operation
-        self.sender_hardware_bytes = packet.sender_mac
-        self.sender_protocol_bytes = packet.sender_ip
-        self.target_hardware_bytes = packet.target_mac
-        self.target_protocol_bytes = packet.target_ip
-
-    @property
-    def source_eui(self):
-        """Returns a netaddr.EUI representing the source MAC address."""
-        return EUI(bytes_to_int(self.sender_hardware_bytes))
-
-    @property
-    def target_eui(self):
-        """Returns a netaddr.EUI representing the target MAC address."""
-        return EUI(bytes_to_int(self.target_hardware_bytes))
-
-    @property
-    def source_ip(self):
-        """Returns a netaddr.IPAddress representing the source IP address."""
-        return IPAddress(self.sender_protocol_bytes)
-
-    @property
-    def target_ip(self):
-        """Returns a netaddr.IPAddress representing the target IP address."""
-        return IPAddress(self.target_protocol_bytes)
-
-    def is_valid(self):
-        """Only (Ethernet MAC, IPv4) bindings are currently supported. This
-        method ensures this ARP packet specifies those types.
-        """
-        # http://www.iana.org/assignments/arp-parameters/arp-parameters.xhtml
-        # Hardware type 1 == Ethernet
-        if self.hardware_type != 1:
-            return False
-        # Protocol type 0x800 == IPv4 (this should match the Ethertype)
-        if self.protocol_type != 0x800:
-            return False
-        if self.hardware_length != 6:
-            return False
-        if self.protocol_length != 4:
-            return False
-        return True
-
-    def bindings(self):
-        """Yields each (MAC, IP) binding found in this ARP packet."""
-        if not self.is_valid():
-            return
-
-        if self.operation == 1:
-            # This is an ARP request.
-            # We can find a binding in the (source_eui, source_ip)
-            source_ip = self.source_ip
-            source_eui = self.source_eui
-            if int(source_ip) != 0 and int(source_eui) != 0:
-                yield (source_ip, self.source_eui)
-        elif self.operation == 2:
-            # This is an ARP reply.
-            # We can find a binding in both the (source_eui, source_ip) and
-            # the (target_eui, target_ip).
-            source_ip = self.source_ip
-            source_eui = self.source_eui
-            target_ip = self.target_ip
-            target_eui = self.target_eui
-            if int(source_ip) != 0 and int(source_eui) != 0:
-                yield (source_ip, self.source_eui)
-            if int(target_ip) != 0 and int(target_eui) != 0:
-                yield (target_ip, self.target_eui)
-
-    def write(self, out=sys.stdout):
-        """Output text-based details about this ARP packet to the specified
-        file or stream.
-        :param out: An object with a `write(str)` method.
-        """
-        if self.time is not None:
-            out.write(
-                "ARP observed at %s:\n" % (datetime.fromtimestamp(self.time))
-            )
-        if self.vid is not None:
-            out.write(
-                f"   802.1q VLAN ID (VID): {self.vid} (0x{self.vid:03x})\n"
-            )
-        if self.src_mac is not None:
-            out.write(
-                "        Ethernet source: %s\n" % format_eui(self.src_mac)
-            )
-        if self.dst_mac is not None:
-            out.write(
-                "   Ethernet destination: %s\n" % format_eui(self.dst_mac)
-            )
-        out.write("          Hardware type: 0x%04x\n" % self.hardware_type)
-        out.write("          Protocol type: 0x%04x\n" % self.protocol_type)
-        out.write("Hardware address length: %d\n" % self.hardware_length)
-        out.write("Protocol address length: %d\n" % self.protocol_length)
-        out.write(
-            "              Operation: %s\n" % (ARP_OPERATION(self.operation))
-        )
-        out.write(
-            "Sender hardware address: %s\n" % (format_eui(self.source_eui))
-        )
-        out.write("Sender protocol address: %s\n" % self.source_ip)
-        out.write(
-            "Target hardware address: %s\n" % (format_eui(self.target_eui))
-        )
-        out.write("Target protocol address: %s\n" % self.target_ip)
-        out.write("\n")
-
-
-def update_bindings_and_get_event(bindings, vid, ip, mac, time):
-    """Update the specified bindings dictionary and returns a dictionary if the
-    information resulted in an update to the bindings. (otherwise, returns
-    None.)
-
-    If an event is returned, it will be a dictionary with the following fields:
-
-        ip - The IP address of the binding.
-        mac - The MAC address the IP was bound to.
-        previous_mac - (if the IP moved between MACs) The previous MAC that
-            was using the IP address.
-        time - The time (in seconds since the epoch) the binding was observed.
-        event - An event type; either "NEW", "MOVED", or "REFRESHED".
-    """
-    if (vid, ip) in bindings:
-        binding = bindings[(vid, ip)]
-        if binding["mac"] != mac:
-            # Another MAC claimed ownership of this IP address. Update the
-            # MAC and emit a "MOVED" event.
-            previous_mac = binding["mac"]
-            binding["mac"] = mac
-            binding["time"] = time
-            return dict(
-                ip=str(ip),
-                mac=format_eui(mac),
-                time=time,
-                event="MOVED",
-                previous_mac=format_eui(previous_mac),
-                vid=vid,
-            )
-        elif time - binding["time"] >= SEEN_AGAIN_THRESHOLD:
-            binding["time"] = time
-            return dict(
-                ip=str(ip),
-                mac=format_eui(mac),
-                time=time,
-                event="REFRESHED",
-                vid=vid,
-            )
-        else:
-            # The IP was found in the bindings dict, but within the
-            # SEEN_AGAIN_THRESHOLD. Don't update the record; the time field
-            # records the last time we emitted an event for this IP address.
-            return None
-    else:
-        # We haven't seen this IP before, so add a binding for it and
-        # emit a "NEW" event.
-        bindings[(vid, ip)] = {"mac": mac, "time": time}
-        return dict(
-            ip=str(ip), mac=format_eui(mac), time=time, event="NEW", vid=vid
-        )
-
-
-def update_and_print_bindings(bindings, arp, out=sys.stdout):
-    """Update the specified bindings dictionary with the given ARP packet.
-
-    Output a JSON object on the specified stream (defaults to stdout) based on
-    the results of updating the binding.
-    """
-    for ip, mac in arp.bindings():
-        event = update_bindings_and_get_event(
-            bindings, arp.vid, ip, mac, arp.time
-        )
-        if event is not None:
-            out.write("%s\n" % json.dumps(event))
-            out.flush()
-
-
-def observe_arp_packets(
-    verbose=False, bindings=False, input=sys.stdin.buffer, output=sys.stdout
-):
-    """Read stdin and look for tcpdump binary ARP output.
-    :param verbose: Output text-based ARP packet details.
-    :type verbose: bool
-    :param bindings: Track (MAC, IP) bindings, and print new/update bindings.
-    :type bindings: bool
-    :param input: Stream to read PCAP data from.
-    :type input: a file or stream supporting `read(int)`
-    :param output: Stream to write JSON data to.
-    :type input: a file or stream supporting `write(str)` and `flush()`.
-    """
-    if bindings:
-        bindings = dict()
-    else:
-        bindings = None
-    try:
-        pcap = PCAP(input)
-        if pcap.global_header.data_link_type != 1:
-            # Not an Ethernet interface. Need to exit here, because our
-            # assumptions about the link layer header won't be correct.
-            return 4
-        for header, packet in pcap:
-            ethernet = Ethernet(packet, time=header.timestamp_seconds)
-            if not ethernet.is_valid():
-                # Ignore packets with a truncated Ethernet header.
-                continue
-            if len(ethernet.payload) < SIZEOF_ARP_PACKET:
-                # Ignore truncated ARP packets.
-                continue
-            if ethernet.ethertype != ETHERTYPE.ARP:
-                # Ignore non-ARP packets.
-                continue
-            arp = ARP(
-                ethernet.payload,
-                src_mac=ethernet.src_mac,
-                dst_mac=ethernet.dst_mac,
-                vid=ethernet.vid,
-                time=ethernet.time,
-            )
-            if bindings is not None:
-                update_and_print_bindings(bindings, arp, output)
-            if verbose:
-                arp.write()
-    except EOFError:
-        # Capture aborted before it could even begin. Note that this does not
-        # occur if the end-of-stream occurs normally. (In that case, the
-        # program will just exit.)
-        return 3
-    except PCAPError:
-        # Capture aborted due to an I/O error.
-        return 2
-    return None
-
 
 def add_arguments(parser):
     """Add this command's options to the `ArgumentParser`.
@@ -370,32 +29,15 @@ def add_arguments(parser):
         """
     )
     parser.add_argument(
-        "-v",
-        "--verbose",
-        action="store_true",
-        required=False,
-        help="Print verbose packet information.",
-    )
-    parser.add_argument(
         "interface",
         type=str,
         nargs="?",
         help="Ethernet interface from which to capture traffic. Optional if "
         "an input file is specified.",
     )
-    parser.add_argument(
-        "-i",
-        "--input-file",
-        type=str,
-        required=False,
-        help="File to read PCAP output from. Use - for stdin. Default is to "
-        "call `sudo /usr/lib/maas/network-monitor` to get input.",
-    )
 
 
-def run(
-    args, output=sys.stdout, stdin=sys.stdin, stdin_buffer=sys.stdin.buffer
-):
+def run(args, output=sys.stdout):
     """Observe an Ethernet interface and print ARP bindings."""
 
     # First, become a progress group leader, so that signals can be directed
@@ -403,28 +45,12 @@ def run(
     os.setpgrp()
 
     network_monitor = None
-    if args.input_file is None:
-        if args.interface is None:
-            raise ActionScriptError("Required argument: interface")
-        cmd = [get_path("/usr/lib/maas/network-monitor"), args.interface]
-        cmd = sudo(cmd)
-        network_monitor = subprocess.Popen(
-            cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE
-        )
-        infile = network_monitor.stdout
-    else:
-        if args.input_file == "-":
-            mode = os.fstat(stdin.fileno()).st_mode
-            if not stat.S_ISFIFO(mode):
-                raise ActionScriptError("Expected stdin to be a pipe.")
-            infile = stdin_buffer
-        else:
-            infile = open(args.input_file, "rb")
-    return_code = observe_arp_packets(
-        bindings=True, verbose=args.verbose, input=infile, output=output
-    )
-    if return_code is not None:
-        raise SystemExit(return_code)
+    if args.interface is None:
+        raise ActionScriptError("Required argument: interface")
+
+    cmd = [get_path("/usr/sbin/maas-netmon"), args.interface]
+    cmd = sudo(cmd)
+    network_monitor = subprocess.Popen(cmd, stdout=output)
     if network_monitor is not None:
         return_code = network_monitor.poll()
         if return_code is not None:
diff --git a/src/provisioningserver/utils/tests/test_arp.py b/src/provisioningserver/utils/tests/test_arp.py
index cf26e68..574aa0e 100644
--- a/src/provisioningserver/utils/tests/test_arp.py
+++ b/src/provisioningserver/utils/tests/test_arp.py
@@ -5,565 +5,23 @@
 
 
 from argparse import ArgumentParser
-from datetime import datetime
 import io
-import json
-import subprocess
-from tempfile import NamedTemporaryFile
-from textwrap import dedent
-import time
 from unittest.mock import Mock
 
-from netaddr import EUI, IPAddress
-from testtools.matchers import Equals, HasLength
 from testtools.testcase import ExpectedException
 
 from maastesting.factory import factory
 from maastesting.matchers import MockCalledOnceWith
 from maastesting.testcase import MAASTestCase
 from provisioningserver.utils import arp as arp_module
-from provisioningserver.utils.arp import (
-    add_arguments,
-    ARP,
-    ARP_OPERATION,
-    run,
-    SEEN_AGAIN_THRESHOLD,
-    update_and_print_bindings,
-    update_bindings_and_get_event,
-)
-from provisioningserver.utils.network import (
-    format_eui,
-    hex_str_to_bytes,
-    ipv4_to_bytes,
-)
+from provisioningserver.utils.arp import add_arguments, run
 from provisioningserver.utils.script import ActionScriptError
 
 
-def make_arp_packet(
-    sender_ip,
-    sender_mac,
-    target_ip,
-    target_mac="00:00:00:00:00:00",
-    op=ARP_OPERATION.REQUEST,
-    hardware_type="0x0001",
-    protocol="0x0800",
-    hardware_length="0x06",
-    protocol_length="0x04",
-):
-    # Concatenate a byte string with the specified values.
-    # For ARP format, see: https://tools.ietf.org/html/rfc826
-    arp_packet = (
-        hex_str_to_bytes(hardware_type)
-        + hex_str_to_bytes(protocol)
-        + hex_str_to_bytes(hardware_length)
-        + hex_str_to_bytes(protocol_length)
-        + ARP_OPERATION(op)
-        + hex_str_to_bytes(sender_mac)
-        + ipv4_to_bytes(sender_ip)
-        + hex_str_to_bytes(target_mac)
-        + ipv4_to_bytes(target_ip)
-    )
-    return arp_packet
-
-
-class TestARP(MAASTestCase):
-    def test_operation_enum__str(self):
-        self.expectThat(
-            str(ARP_OPERATION(ARP_OPERATION.REQUEST)), Equals("1 (request)")
-        )
-        self.expectThat(
-            str(ARP_OPERATION(ARP_OPERATION.REPLY)), Equals("2 (reply)")
-        )
-        self.expectThat(str(ARP_OPERATION(3)), Equals("3"))
-
-    def test_operation_enum__bytes(self):
-        self.expectThat(
-            bytes(ARP_OPERATION(ARP_OPERATION.REQUEST)), Equals(b"\0\x01")
-        )
-        self.expectThat(
-            bytes(ARP_OPERATION(ARP_OPERATION.REPLY)), Equals(b"\0\x02")
-        )
-
-    def test_operation_enum__radd(self):
-        self.expectThat(
-            b"\xff" + bytes(ARP_OPERATION(ARP_OPERATION.REPLY)) + b"\xff",
-            Equals(b"\xff\0\x02\xff"),
-        )
-
-    def test_write(self):
-        ts = int(time.time())
-        expected_time = datetime.fromtimestamp(ts)
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "00:00:00:00:00:00"
-        eth_src = "02:03:04:05:06:07"
-        eth_dst = "ff:ff:ff:ff:ff:ff"
-        arp_packet = make_arp_packet(
-            pkt_sender_ip, pkt_sender_mac, pkt_target_ip, pkt_target_mac
-        )
-        arp = ARP(
-            arp_packet,
-            time=ts,
-            src_mac=hex_str_to_bytes(eth_src),
-            dst_mac=hex_str_to_bytes(eth_dst),
-            vid=100,
-        )
-        out = io.StringIO()
-        arp.write(out)
-        expected_output = dedent(
-            """\
-        ARP observed at {expected_time}:
-           802.1q VLAN ID (VID): 100 (0x064)
-                Ethernet source: {eth_src}
-           Ethernet destination: {eth_dst}
-                  Hardware type: 0x0001
-                  Protocol type: 0x0800
-        Hardware address length: 6
-        Protocol address length: 4
-                      Operation: 1 (request)
-        Sender hardware address: {pkt_sender_mac}
-        Sender protocol address: {pkt_sender_ip}
-        Target hardware address: {pkt_target_mac}
-        Target protocol address: {pkt_target_ip}
-        """
-        )
-        self.assertEqual(
-            expected_output.format(**locals()).strip(),
-            out.getvalue().strip(),
-        )
-
-    def test_is_valid__succeeds_for_normal_packet(self):
-        arp_packet = make_arp_packet(
-            "192.168.0.1", "01:02:03:04:05:06", "192.168.0.2"
-        )
-        arp = ARP(arp_packet)
-        self.assertTrue(arp.is_valid())
-
-    def test_is_valid__fails_for_invalid_packets(self):
-        arp = ARP(b"\x00" * 28)
-        self.assertFalse(arp.is_valid())
-        arp = ARP(
-            make_arp_packet(
-                "192.168.0.1",
-                "01:02:03:04:05:06",
-                "192.168.0.2",
-                hardware_type="0x0000",
-            )
-        )
-        self.assertFalse(arp.is_valid())
-        arp = ARP(
-            make_arp_packet(
-                "192.168.0.1",
-                "01:02:03:04:05:06",
-                "192.168.0.2",
-                protocol="0x0000",
-            )
-        )
-        self.assertFalse(arp.is_valid())
-        arp = ARP(
-            make_arp_packet(
-                "192.168.0.1",
-                "01:02:03:04:05:06",
-                "192.168.0.2",
-                hardware_length="0x00",
-            )
-        )
-        self.assertFalse(arp.is_valid())
-        arp = ARP(
-            make_arp_packet(
-                "192.168.0.1",
-                "01:02:03:04:05:06",
-                "192.168.0.2",
-                protocol_length="0x00",
-            )
-        )
-        self.assertFalse(arp.is_valid())
-
-    def test_properties(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "00:00:00:00:00:00"
-        eth_src = "02:03:04:05:06:07"
-        eth_dst = "ff:ff:ff:ff:ff:ff"
-        arp_packet = make_arp_packet(
-            pkt_sender_ip, pkt_sender_mac, pkt_target_ip, pkt_target_mac
-        )
-        arp = ARP(
-            arp_packet,
-            src_mac=hex_str_to_bytes(eth_src),
-            dst_mac=hex_str_to_bytes(eth_dst),
-        )
-        self.assertEqual(EUI(pkt_sender_mac), arp.source_eui)
-        self.assertEqual(EUI(pkt_target_mac), arp.target_eui)
-        self.assertEqual(IPAddress(pkt_sender_ip), arp.source_ip)
-        self.assertEqual(IPAddress(pkt_target_ip), arp.target_ip)
-
-    def test_bindings__returns_sender_for_request(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "00:00:00:00:00:00"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REQUEST,
-            )
-        )
-        self.assertCountEqual(
-            arp.bindings(), [(IPAddress(pkt_sender_ip), EUI(pkt_sender_mac))]
-        )
-
-    def test_bindings__returns_sender_and_target_for_reply(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "02:03:04:05:06:07"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REPLY,
-            )
-        )
-        self.assertCountEqual(
-            arp.bindings(),
-            [
-                (IPAddress(pkt_sender_ip), EUI(pkt_sender_mac)),
-                (IPAddress(pkt_target_ip), EUI(pkt_target_mac)),
-            ],
-        )
-
-    def test_bindings__skips_null_source_ip_for_request(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "0.0.0.0"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "00:00:00:00:00:00"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REQUEST,
-            )
-        )
-        self.assertCountEqual(arp.bindings(), [])
-
-    def test_bindings__skips_null_source_ip_in_reply(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "0.0.0.0"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "02:03:04:05:06:07"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REPLY,
-            )
-        )
-        self.assertCountEqual(
-            arp.bindings(), [(IPAddress(pkt_target_ip), EUI(pkt_target_mac))]
-        )
-
-    def test_bindings__skips_null_target_ip_in_reply(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "0.0.0.0"
-        pkt_target_mac = "02:03:04:05:06:07"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REPLY,
-            )
-        )
-        self.assertCountEqual(
-            arp.bindings(), [(IPAddress(pkt_sender_ip), EUI(pkt_sender_mac))]
-        )
-
-    def test_bindings__skips_null_source_eui_for_request(self):
-        pkt_sender_mac = "00:00:00:00:00:00"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "00:00:00:00:00:00"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REQUEST,
-            )
-        )
-        self.assertCountEqual(arp.bindings(), [])
-
-    def test_bindings__skips_null_source_eui_in_reply(self):
-        pkt_sender_mac = "00:00:00:00:00:00"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "02:03:04:05:06:07"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REPLY,
-            )
-        )
-        self.assertCountEqual(
-            arp.bindings(), [(IPAddress(pkt_target_ip), EUI(pkt_target_mac))]
-        )
-
-    def test_bindings__skips_null_target_eui_in_reply(self):
-        pkt_sender_mac = "01:02:03:04:05:06"
-        pkt_sender_ip = "192.168.0.1"
-        pkt_target_ip = "192.168.0.2"
-        pkt_target_mac = "00:00:00:00:00:00"
-        arp = ARP(
-            make_arp_packet(
-                pkt_sender_ip,
-                pkt_sender_mac,
-                pkt_target_ip,
-                pkt_target_mac,
-                op=ARP_OPERATION.REPLY,
-            )
-        )
-        self.assertCountEqual(
-            arp.bindings(), [(IPAddress(pkt_sender_ip), EUI(pkt_sender_mac))]
-        )
-
-
-class TestUpdateBindingsAndGetEvent(MAASTestCase):
-    def test_new_binding(self):
-        bindings = {}
-        ip = IPAddress("192.168.0.1")
-        mac = EUI("00:01:02:03:04:05")
-        vid = None
-        event = update_bindings_and_get_event(bindings, vid, ip, mac, 0)
-        self.assertEqual({(vid, ip): {"mac": mac, "time": 0}}, bindings)
-        self.assertThat(
-            event,
-            Equals(
-                dict(
-                    event="NEW",
-                    ip=str(ip),
-                    mac=format_eui(mac),
-                    time=0,
-                    vid=vid,
-                )
-            ),
-        )
-
-    def test_new_bindings_with_vid(self):
-        bindings = {}
-        ip = IPAddress("192.168.0.1")
-        mac = EUI("00:01:02:03:04:05")
-        vid = None
-        event = update_bindings_and_get_event(bindings, vid, ip, mac, 0)
-        self.assertEqual({(vid, ip): {"mac": mac, "time": 0}}, bindings)
-        self.assertThat(
-            event,
-            Equals(
-                dict(
-                    event="NEW",
-                    ip=str(ip),
-                    mac=format_eui(mac),
-                    time=0,
-                    vid=vid,
-                )
-            ),
-        )
-        vid = 4095
-        event = update_bindings_and_get_event(bindings, vid, ip, mac, 0)
-        self.assertThat(
-            bindings,
-            Equals(
-                {
-                    (None, ip): {"mac": mac, "time": 0},
-                    (4095, ip): {"mac": mac, "time": 0},
-                }
-            ),
-        )
-        self.assertThat(
-            event,
-            Equals(
-                dict(
-                    event="NEW",
-                    ip=str(ip),
-                    mac=format_eui(mac),
-                    time=0,
-                    vid=vid,
-                )
-            ),
-        )
-
-    def test_refreshed_binding(self):
-        bindings = {}
-        ip = IPAddress("192.168.0.1")
-        mac = EUI("00:01:02:03:04:05")
-        vid = None
-        update_bindings_and_get_event(bindings, vid, ip, mac, 0)
-        event = update_bindings_and_get_event(
-            bindings, vid, ip, mac, SEEN_AGAIN_THRESHOLD
-        )
-        self.assertEqual(
-            {(vid, ip): {"mac": mac, "time": SEEN_AGAIN_THRESHOLD}},
-            bindings,
-        )
-        self.assertThat(
-            event,
-            Equals(
-                dict(
-                    event="REFRESHED",
-                    ip=str(ip),
-                    mac=format_eui(mac),
-                    time=SEEN_AGAIN_THRESHOLD,
-                    vid=vid,
-                )
-            ),
-        )
-
-    def test_refreshed_binding_within_threshold_does_not_emit_event(self):
-        bindings = {}
-        ip = IPAddress("192.168.0.1")
-        mac = EUI("00:01:02:03:04:05")
-        vid = None
-        update_bindings_and_get_event(bindings, vid, ip, mac, 0)
-        event = update_bindings_and_get_event(bindings, vid, ip, mac, 1)
-        self.assertEqual({(vid, ip): {"mac": mac, "time": 0}}, bindings)
-        self.assertIsNone(event)
-
-    def test_moved_binding(self):
-        bindings = {}
-        ip = IPAddress("192.168.0.1")
-        mac1 = EUI("00:01:02:03:04:05")
-        mac2 = EUI("02:03:04:05:06:07")
-        vid = None
-        update_bindings_and_get_event(bindings, vid, ip, mac1, 0)
-        event = update_bindings_and_get_event(bindings, vid, ip, mac2, 1)
-        self.assertEqual({(vid, ip): {"mac": mac2, "time": 1}}, bindings)
-        self.assertThat(
-            event,
-            Equals(
-                dict(
-                    event="MOVED",
-                    ip=str(ip),
-                    mac=format_eui(mac2),
-                    time=1,
-                    previous_mac=format_eui(mac1),
-                    vid=vid,
-                )
-            ),
-        )
-
-
-class FakeARP:
-    """Fake ARP packet used for testing the processing of bindings."""
-
-    def __init__(self, mock_bindings, time=0, vid=None):
-        self.mock_bindings = mock_bindings
-        self.time = time
-        self.vid = vid
-
-    def bindings(self):
-        yield from self.mock_bindings
-
-
-class TestUpdateAndPrintBindings(MAASTestCase):
-    def test_prints_bindings_in_json_format(self):
-        bindings = {}
-        ip = IPAddress("192.168.0.1")
-        mac1 = EUI("00:01:02:03:04:05")
-        mac2 = EUI("02:03:04:05:06:07")
-        # Need to test with three bindings so that we ensure we cover JSON
-        # output for NEW, MOVED, and REFRESHED. Two packets is sufficient
-        # to test all three. (Though it would be three packets in real life,
-        # it's better to test it this way, since some packets *do* have two
-        # bindings.)
-        arp1 = FakeARP([(ip, mac1), (ip, mac2)])
-        arp2 = FakeARP([(ip, mac2)], time=SEEN_AGAIN_THRESHOLD)
-        out = io.StringIO()
-        update_and_print_bindings(bindings, arp1, out)
-        update_and_print_bindings(bindings, arp2, out)
-        self.assertEqual(
-            {(None, ip): {"mac": mac2, "time": SEEN_AGAIN_THRESHOLD}},
-            bindings,
-        )
-        output = io.StringIO(out.getvalue())
-        lines = output.readlines()
-        self.assertThat(lines, HasLength(3))
-        line1 = json.loads(lines[0])
-        self.assertThat(
-            line1,
-            Equals(
-                {
-                    "ip": str(ip),
-                    "mac": format_eui(mac1),
-                    "time": 0,
-                    "event": "NEW",
-                    "vid": None,
-                }
-            ),
-        )
-        line2 = json.loads(lines[1])
-        self.assertThat(
-            line2,
-            Equals(
-                {
-                    "ip": str(ip),
-                    "mac": format_eui(mac2),
-                    "previous_mac": format_eui(mac1),
-                    "time": 0,
-                    "event": "MOVED",
-                    "vid": None,
-                }
-            ),
-        )
-        line3 = json.loads(lines[2])
-        self.assertThat(
-            line3,
-            Equals(
-                {
-                    "ip": str(ip),
-                    "mac": format_eui(mac2),
-                    "time": SEEN_AGAIN_THRESHOLD,
-                    "event": "REFRESHED",
-                    "vid": None,
-                }
-            ),
-        )
-
-
-# Test data expected from an input PCAP file.
-test_input = (
-    b"\xd4\xc3\xb2\xa1\x02\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00"
-    b"@\x00\x00\x00\x01\x00\x00\x00*\xdc\xa0W\x9e+\x03\x00<\x00\x00\x00"
-    b"<\x00\x00\x00\x80\xfa[\x0cFN\x00$\xa5\xaf$\x85\x08\x06\x00\x01"
-    b"\x08\x00\x06\x04\x00\x01\x00$\xa5\xaf$\x85\xac\x10*\x01\x00\x00\x00\x00"
-    b"\x00\x00\xac\x10*m\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
-    b"\x00\x00\x00\x00\x00\x00\x00\x00*\xdc\xa0W\xbb+\x03\x00*\x00\x00\x00"
-    b"*\x00\x00\x00\x00$\xa5\xaf$\x85\x80\xfa[\x0cFN\x08\x06\x00\x01"
-    b"\x08\x00\x06\x04\x00\x02\x80\xfa[\x0cFN\xac\x10*m\x00$\xa5\xaf"
-    b"$\x85\xac\x10*\x01"
-)
-
-
 class TestObserveARPCommand(MAASTestCase):
     """Tests for `maas-rack observe-arp`."""
 
-    def test_requires_input_file(self):
+    def test_requires_input_interface(self):
         parser = ArgumentParser()
         add_arguments(parser)
         args = parser.parse_args([])
@@ -579,15 +37,13 @@ class TestObserveARPCommand(MAASTestCase):
         popen = self.patch(arp_module.subprocess, "Popen")
         popen.return_value.poll = Mock()
         popen.return_value.poll.return_value = None
-        popen.return_value.stdout = io.BytesIO(test_input)
+        popen.return_value.stdout = io.StringIO("{}")
         output = io.StringIO()
         run(args, output=output)
         self.assertThat(
             popen,
             MockCalledOnceWith(
-                ["sudo", "-n", "/usr/lib/maas/network-monitor", "eth0"],
-                stdin=subprocess.DEVNULL,
-                stdout=subprocess.PIPE,
+                ["sudo", "-n", "/usr/sbin/maas-netmon", "eth0"], stdout=output
             ),
         )
 
@@ -598,72 +54,16 @@ class TestObserveARPCommand(MAASTestCase):
         popen = self.patch(arp_module.subprocess, "Popen")
         popen.return_value.poll = Mock()
         popen.return_value.poll.return_value = None
-        popen.return_value.stdout = io.BytesIO(test_input)
+        popen.return_value.stdout = io.StringIO("{}")
         output = io.StringIO()
         run(args, output=output)
         self.assertThat(
             popen,
             MockCalledOnceWith(
-                ["sudo", "-n", "/usr/lib/maas/network-monitor", "eth0"],
-                stdin=subprocess.DEVNULL,
-                stdout=subprocess.PIPE,
+                ["sudo", "-n", "/usr/sbin/maas-netmon", "eth0"], stdout=output
             ),
         )
 
-    def test_checks_for_pipe(self):
-        parser = ArgumentParser()
-        add_arguments(parser)
-        args = parser.parse_args(["--input-file", "-"])
-        output = io.StringIO()
-        stdin = self.patch(arp_module.sys, "stdin")
-        stdin.return_value.fileno = Mock()
-        fstat = self.patch(arp_module.os, "fstat")
-        fstat.return_value.st_mode = None
-        stat = self.patch(arp_module.stat, "S_ISFIFO")
-        stat.return_value = False
-        with ExpectedException(
-            ActionScriptError, "Expected stdin to be a pipe"
-        ):
-            run(args, output=output)
-
-    def test_allows_pipe_input(self):
-        parser = ArgumentParser()
-        add_arguments(parser)
-        args = parser.parse_args(["--input-file", "-"])
-        output = io.StringIO()
-        stdin = self.patch(arp_module.sys, "stdin")
-        stdin.return_value.fileno = Mock()
-        fstat = self.patch(arp_module.os, "fstat")
-        fstat.return_value.st_mode = None
-        stat = self.patch(arp_module.stat, "S_ISFIFO")
-        stat.return_value = True
-        stdin_buffer = io.BytesIO(test_input)
-        run(args, output=output, stdin_buffer=stdin_buffer)
-
-    def test_allows_file_input(self):
-        with NamedTemporaryFile("wb") as f:
-            parser = ArgumentParser()
-            add_arguments(parser)
-            f.write(test_input)
-            f.flush()
-            args = parser.parse_args(["--input-file", f.name])
-            output = io.StringIO()
-            run(args, output=output)
-
-    def test_raises_systemexit_observe_arp_return_code(self):
-        parser = ArgumentParser()
-        add_arguments(parser)
-        args = parser.parse_args(["eth0"])
-        popen = self.patch(arp_module.subprocess, "Popen")
-        popen.return_value.poll = Mock()
-        popen.return_value.poll.return_value = None
-        popen.return_value.stdout = io.BytesIO(test_input)
-        output = io.StringIO()
-        observe_arp_packets = self.patch(arp_module, "observe_arp_packets")
-        observe_arp_packets.return_value = 37
-        with ExpectedException(SystemExit, ".*37.*"):
-            run(args, output=output)
-
     def test_raises_systemexit_poll_result(self):
         parser = ArgumentParser()
         add_arguments(parser)
@@ -671,7 +71,7 @@ class TestObserveARPCommand(MAASTestCase):
         popen = self.patch(arp_module.subprocess, "Popen")
         popen.return_value.poll = Mock()
         popen.return_value.poll.return_value = None
-        popen.return_value.stdout = io.BytesIO(test_input)
+        popen.return_value.stdout = io.StringIO("{}")
         output = io.StringIO()
         observe_arp_packets = self.patch(arp_module, "observe_arp_packets")
         observe_arp_packets.return_value = None

References