Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions framework/python/src/net_orc/arp_prober.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Cyclically probes the device interface with ARP requests so that a device
under test configured with a static IP address (instead of using DHCP) can be
detected during the 'waiting' phase.

The prober only sends requests. Responses are detected by the existing network
Listener, which fires an ARP_RESPONSE network event. This keeps a single packet
receive path (the Listener) and avoids introducing additional sniffers."""
import threading
from scapy.all import ARP, Ether, sendp
from common import logger

LOGGER = logger.get_logger('arp_prober')

# Mandated static IP address for devices that do not support DHCP. This falls
# within the network range used by Testrun (10.10.10.0/24). Must stay in sync with
# modules/test/conn/python/src/connection_module.py.STATIC_IP_ADDRESS.
STATIC_IP_ADDRESS = '10.10.10.100'

# Source identity for the probes. The probes are sent as the gateway network
# container.
ARP_PROBE_SRC_MAC = '9a:02:57:1e:8f:01'
ARP_PROBE_SRC_IP = '10.10.10.1'

# Interval, in seconds, between successive ARP probes.
ARP_PROBE_INTERVAL = 1


class ArpProber:
"""Periodically sends ARP requests for the mandated static IP address on the
device interface, for the duration of the 'waiting' phase."""

def __init__(self,
device_intf,
target_ip=STATIC_IP_ADDRESS,
src_mac=ARP_PROBE_SRC_MAC,
src_ip=ARP_PROBE_SRC_IP,
interval=ARP_PROBE_INTERVAL):
self._device_intf = device_intf
self._target_ip = target_ip
self._src_mac = src_mac
self._src_ip = src_ip
self._interval = interval

self._stop_event = threading.Event()
self._thread = None

# A traditional broadcast ARP request ('who-has target_ip') sourced from a
# recognised network container.
self._probe = (
Ether(src=self._src_mac, dst='ff:ff:ff:ff:ff:ff') /
ARP(op=1,
hwsrc=self._src_mac,
psrc=self._src_ip,
pdst=self._target_ip))

def is_running(self):
"""Determine whether the prober thread is active."""
return self._thread is not None and self._thread.is_alive()

def start(self):
"""Start cyclically probing for the static IP address."""
if self.is_running():
LOGGER.debug('ARP prober was already running')
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._probe_loop,
name='ARP prober',
daemon=True)
self._thread.start()
LOGGER.debug(
f'Started ARP prober for {self._target_ip} on {self._device_intf}')

def stop(self):
"""Stop probing."""
self._stop_event.set()
thread = self._thread
# Never join from within the prober thread itself.
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=self._interval + 1)
LOGGER.debug('Stopped the ARP prober')
self._thread = None

def _probe_loop(self):
while not self._stop_event.is_set():
try:
sendp(self._probe, iface=self._device_intf, verbose=False)
except Exception as e: # pylint: disable=W0703
LOGGER.error(f'Error sending ARP probe: {e}')
# Interruptible wait so stop() takes effect promptly.
self._stop_event.wait(self._interval)
12 changes: 11 additions & 1 deletion framework/python/src/net_orc/listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
"""Intercepts network traffic between network services and the device
under test."""
import threading
from scapy.all import AsyncSniffer, DHCP, get_if_hwaddr
from scapy.all import AsyncSniffer, ARP, DHCP, get_if_hwaddr
from scapy.error import Scapy_Exception
from net_orc.network_event import NetworkEvent
from net_orc.arp_prober import STATIC_IP_ADDRESS
from common import logger

LOGGER = logger.get_logger('listener')
Expand All @@ -26,6 +27,7 @@
DHCP_OFFER = 2
DHCP_REQUEST = 3
DHCP_ACK = 5
ARP_REPLY = 2
CONTAINER_MAC_PREFIX = '9a:02:57:1e:8f'


Expand Down Expand Up @@ -88,6 +90,14 @@ def _packet_callback(self, packet):
if DHCP in packet and self._get_dhcp_type(packet) == DHCP_ACK:
self.call_callback(NetworkEvent.DHCP_LEASE_ACK, packet)

# ARP probe response callback (static IP device detection). Fires when a
# device replies to the ARP request claiming the mandated static IP address.
# The responder's hardware address is validated against the configured
# target device by the registered callback.
if (ARP in packet and packet[ARP].op == ARP_REPLY
and packet[ARP].psrc == STATIC_IP_ADDRESS):
self.call_callback(NetworkEvent.ARP_RESPONSE, packet[ARP].hwsrc)

# New device discovered callback
if not packet.src is None and packet.src not in self._discovered_devices:
# Ignore packets originating from our containers
Expand Down
1 change: 1 addition & 0 deletions framework/python/src/net_orc/network_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ class NetworkEvent(Enum):
DEVICE_DISCOVERED = 1
DEVICE_STABLE = 2
DHCP_LEASE_ACK = 3
ARP_RESPONSE = 4
53 changes: 53 additions & 0 deletions framework/python/src/net_orc/network_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from common.statuses import TestrunStatus
from net_orc.listener import Listener
from net_orc.network_event import NetworkEvent
from net_orc.arp_prober import ArpProber, STATIC_IP_ADDRESS
from net_orc.network_validator import NetworkValidator
from net_orc.ovs_control import OVSControl
from net_orc.ip_control import IPControl
Expand Down Expand Up @@ -57,6 +58,7 @@ def __init__(self, session):
self._monitor_in_progress = False
self._monitor_packets = []
self._listener = None
self._arp_prober = None
self._net_modules = []

self._path = os.path.dirname(
Expand Down Expand Up @@ -159,6 +161,10 @@ def get_listener(self):
def start_listener(self):
LOGGER.debug('Starting network listener')
self.get_listener().start_listener()
# Begin probing for statically addressed devices in parallel with the
# listener's DHCP-based detection.
if self._arp_prober is not None:
self._arp_prober.start()

def stop(self, kill=False):
"""Stop the network orchestrator."""
Expand Down Expand Up @@ -202,6 +208,13 @@ def _device_discovered(self, mac_addr):
# Ignore device if not registered
return

# Clear any IP address carried over from a previous run. ip_addr persists on
# the device object across runs and is never otherwise reset, so a stale
# value would cause _device_has_ip to short-circuit the waiting phase before
# this run's DHCP lease or ARP probe response is observed. Resetting here
# ensures the transition to monitoring reflects only this-run detection.
device.ip_addr = None

# Cleanup any old test files
test_dir = os.path.join(RUNTIME_DIR, TEST_DIR)
device_tests = os.listdir(test_dir)
Expand All @@ -219,6 +232,12 @@ def _device_discovered(self, mac_addr):
packet_capture = sniff(iface=self._session.get_device_interface(),
timeout=self._session.get_startup_timeout(),
stop_filter=self._device_has_ip)

# The waiting phase has ended for this device (it obtained an IP or timed
# out), so stop probing.
if self._arp_prober is not None:
self._arp_prober.stop()

wrpcap(os.path.join(device_runtime_dir, 'startup.pcap'), packet_capture)

# Copy the device config file to the runtime directory
Expand Down Expand Up @@ -295,6 +314,30 @@ def _dhcp_lease_ack(self, packet):
# TODO: Check if device is None
device.ip_addr = packet[BOOTP].yiaddr

def _arp_response(self, hwsrc):
"""Handle a device that answered our ARP probe for the mandated static IP
address. Mirrors _dhcp_lease_ack: assigns the static IP to the matching
registered device so the waiting phase can complete just as it would for a
device that obtained its address via DHCP."""
device = self._session.get_device(mac_addr=hwsrc)

# Ignore responses from devices that are not registered
if device is None:
return

# Only progress for the device the user configured. If the responder's MAC
# does not match the configured target MAC, do not assign the static IP.
target = self._session.get_target_device()
if target is not None and hwsrc.lower() != target.mac_addr.lower():
return

# Assign the mandated static IP once. The waiting-phase stop filter
# (_device_has_ip) detects this and the flow proceeds to monitoring.
if device.ip_addr is None:
device.ip_addr = STATIC_IP_ADDRESS
LOGGER.info(f'Device with mac addr {device.mac_addr} responded to ARP '
f'probe with static IP address {STATIC_IP_ADDRESS}')

def _start_device_monitor(self, device):
"""Start a timer until the steady state has been reached and
callback the steady state method for this device."""
Expand Down Expand Up @@ -436,6 +479,11 @@ def create_net(self):
[NetworkEvent.DEVICE_DISCOVERED])
self.get_listener().register_callback(self._dhcp_lease_ack,
[NetworkEvent.DHCP_LEASE_ACK])
self.get_listener().register_callback(self._arp_response,
[NetworkEvent.ARP_RESPONSE])

# Prober for detecting devices configured with a static IP address
self._arp_prober = ArpProber(self._session.get_device_interface())

def load_network_modules(self):
"""Load network modules from module_config.json."""
Expand Down Expand Up @@ -669,6 +717,11 @@ def restore_net(self):

LOGGER.info('Clearing baseline network')

# Stop probing if it is still active (e.g. cancelled before a device was
# discovered).
if self._arp_prober is not None:
self._arp_prober.stop()

if self.get_listener() is not None and self.get_listener().is_running():
self.get_listener().stop_listener()

Expand Down
Loading
Loading