Merge lp:~mpontillo/maas/interpret-dhcp-packets-correctly--bug-1628645 into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 5438
Proposed branch: lp:~mpontillo/maas/interpret-dhcp-packets-correctly--bug-1628645
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 2486 lines (+1657/-422)
16 files modified
Makefile (+1/-0)
scripts/maas-dhcp-monitor (+20/-0)
src/maastesting/factory.py (+67/-0)
src/provisioningserver/__main__.py (+2/-0)
src/provisioningserver/dhcp/detect.py (+421/-145)
src/provisioningserver/dhcp/tests/test_detect.py (+353/-227)
src/provisioningserver/rackdservices/dhcp_probe_service.py (+37/-18)
src/provisioningserver/rackdservices/tests/test_dhcp_probe_service.py (+13/-30)
src/provisioningserver/rackdservices/tests/test_networks_monitoring_service.py (+1/-1)
src/provisioningserver/utils/dhcp.py (+305/-0)
src/provisioningserver/utils/ethernet.py (+12/-0)
src/provisioningserver/utils/network.py (+15/-1)
src/provisioningserver/utils/tcpip.py (+162/-0)
src/provisioningserver/utils/tests/test_dhcp.py (+67/-0)
src/provisioningserver/utils/tests/test_tcpip.py (+165/-0)
utilities/install-dhcp-observer (+16/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/interpret-dhcp-packets-correctly--bug-1628645
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Review via email: mp+307216@code.launchpad.net

Commit message

Fix external DHCP detection code.

 * Parse DHCP options to reliably get DHCP server address.
 * Change DHCP flags field to indicate preference for
   unicast replies from the server.
 * Fix race condition that caused DHCP offers to be missed.
 * When sending the DHCP discovery packet, always send retries,
   to increase the changes we see all servers.
 * Change client unique identifier option to make it more
   unique, and make it obvious that MAAS is sending it.
 * Add code to interpret IPv4, UDP, and DHCP packets.
   (Used for support/debugging only; a debug command uses it.)
 * Added a `maas-rack observe-dhcp` command.
   (For support/debugging only; not used at runtime.)

Description of the change

There are a several major issues with the current external DHCP detection code:

(1) We are not parsing the DHCP options; we use a hard-coded index into the packet to get the DHCP server identifier (IP address). (Fixed in this branch.)

(2) The way we bind to the sending socket doesn't work for interfaces with no address. So we cannot discover external DHCP servers on interfaces with no IP address configured. (NOT fixed in this branch.***)

(3) The way we bind to the receive socket causes a race condition that makes it likely the DHCP offer will come in before we are listening to the receive socket. (Fixed in this branch.)

(4) Since we set the 'broadcast' flag in the DHCP discover packet, we don't reliably receive replies from all servers. (It ignores packets from the DD-WRT router I tested it with, for example.) This is because the DHCP server assumes it cannot talk to the client on a unicast IP/MAC and broadcasts the response from a bogus IP address instead, which the Linux TCP/IP stack never sends up to the socket. One way to fix this is to listen to DHCP replies via something like 'tcpdump' or libpcap instead of a UDP socket. Another way to fix this is to unset the broadcast flag. (This branch fixes that by unsetting the broadcast flag.)

(5) UDP is unreliable, and we only send a single DHCP discover packet out before assuming no servers responded. (Fixed in this branch.)

(6) The deferToThread() covers too much code. It should
only cover code that could block. (Fixed in this branch.)

(7) It is difficult to debug MAAS's interpretation of DHCP offer packets. (Fixed in this branch by adding a 'maas-rack observe-dhcp' command.)

***:
Fixing (2) requires additional privileges. To be specific, we need a way of sending a DHCP probe out directly on the interface, and a way of listening for the replies without relying on a socket bound to a unicast IP address. The listening portion is partially done in this branch (via `maas-rack observe-dhcp`) to ease debugging and support, but changing the design to use it in MAAS is a drastic design change that should not happen in this branch.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

I saw this branch linked from the channel and thought to start reviewing it... but eventually realised that it was Work In Progress. So, no vote, but a few comments. I didn't get to the end but none of my comments are blockers so it's looking good so far.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Thanks for taking the time to look at this monster WIP branch, Gavin. Some replies to your comments below.

Revision history for this message
Gavin Panella (allenap) :
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Per your comment inline, I think it would be interesting to see how twisted behaves with a monotonic clock. It's worth trying, but I suppose if any code anywhere assumes it's a UNIX timestamp, it's game over.

This branch is still a work in progress, but today I heavily refactored the code in `detect.py`. In particular, would you give me your thoughts on the `DHCPRequestMonitor` class?

One thing I tried to do was use @inlineCallbacks to run a generator inside a thread using deferToThread(), but that failed horribly, so I changed the method back to just returning a `set`. Not sure if that's even possible.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Wow this is really large branch. I really just started scrolling quickly at the end because it was so large and I got bored. The code lots good and well tested. The packet creation and parsing is very complicated. It sucks to see all this code being placed in MAAS that we need to maintain when there is already python libraries that do this kind of thing.

I noticed you typed a lot of your methods, but every single one was missing the @typed decorator. You should add that to all the typed methods. I commented on some, but just gave up as I went as it was so large.

Since you have tested this a lot with your router, it can only be an improvement over what it does today. I trusty you!

review: Approve
Revision history for this message
Gavin Panella (allenap) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2016-09-22 02:53:33 +0000
3+++ Makefile 2016-10-05 01:31:45 +0000
4@@ -99,6 +99,7 @@
5 sudoers:
6 utilities/grant-nmap-permissions
7 utilities/install-arp-observer
8+ utilities/install-dhcp-observer
9
10 bin/buildout: bootstrap-buildout.py
11 @utilities/configure-buildout --quiet
12
13=== added file 'scripts/maas-dhcp-monitor'
14--- scripts/maas-dhcp-monitor 1970-01-01 00:00:00 +0000
15+++ scripts/maas-dhcp-monitor 2016-10-05 01:31:45 +0000
16@@ -0,0 +1,20 @@
17+#!/bin/sh -euf
18+# Copyright 2016 Canonical Ltd. This software is licensed under the
19+# GNU Affero General Public License version 3 (see the file LICENSE).
20+
21+# Utility script to wrap `tcpdump`, so that this script can be called with
22+# `sudo` without allowing MAAS access to read arbitrary network traffic.
23+# This script is designed to be as minimal as possible, to prevent arbitrary
24+# code execution.
25+
26+if [ $# -ne 1 ]; then
27+ echo "Write DHCP traffic to stdout using tcpdump's binary PCAP format." >&2
28+ echo "" >&2
29+ echo "Usage:" >&2
30+ echo " $0 <interface>" >&2
31+ exit 32
32+fi
33+
34+exec /usr/sbin/tcpdump --interface "$1" --no-promiscuous-mode \
35+ --packet-buffered --immediate-mode --snapshot-length=1500 -n -w - \
36+ "(port 67 or port 68) or (vlan and (port 67 or port 68))"
37
38=== modified file 'src/maastesting/factory.py'
39--- src/maastesting/factory.py 2016-09-23 01:54:50 +0000
40+++ src/maastesting/factory.py 2016-10-05 01:31:45 +0000
41@@ -654,5 +654,72 @@
42 strings += [release['series'], version_str]
43 return random.choice(strings)
44
45+ def make_dhcp_packet(
46+ self, transaction_id: bytes = None,
47+ truncated: bool=False, truncated_option_value: bool=False,
48+ bad_cookie: bool=False, truncated_option_length: bool=False,
49+ include_server_identifier: bool=False, server_ip: str="127.1.1.1",
50+ include_end_option: bool=True) -> bytes:
51+ """Returns a [possibly invalid] DHCP packet."""
52+ if transaction_id is None:
53+ transaction_id = self.make_bytes(size=4)
54+ options = b''
55+ if include_server_identifier:
56+ # 0x36 == 54 (Server Identifier option)
57+ ip_bytes = int(IPAddress(server_ip).value).to_bytes(4, "big")
58+ options += b"\x36\x04" + ip_bytes
59+ if truncated_option_value:
60+ options += b"\x36\x04\x7f\x01"
61+ include_end_option = False
62+ if truncated_option_length:
63+ options += b"\x36"
64+ include_end_option = False
65+ # Currently, we only validation the transaction ID, and the fact that
66+ # the reply packet has a "Server Identifier" option. This might be
67+ # considered a bug, but in practice it works out.
68+ packet = (
69+ # Message type: 0x02 (BOOTP operation: reply).
70+ b'\x02'
71+ # Hardware type: Ethernet
72+ b'\x01'
73+ # Hardware address length: 6
74+ b'\x06'
75+ # Hops: 0
76+ b'\x00' +
77+ # Transaction ID
78+ transaction_id +
79+ # Seconds
80+ b'\x00\x00'
81+ # Flags
82+ b'\x00\x00'
83+ # Client IP address: 0.0.0.0
84+ b'\x00\x00\x00\x00'
85+ # Your (client) IP address: 0.0.0.0
86+ b'\x00\x00\x00\x00'
87+ # Next server IP address: 0.0.0.0
88+ b'\x00\x00\x00\x00'
89+ # Relay agent IP address: 0.0.0.0
90+ b'\x00\x00\x00\x00' +
91+ # Client hardware address
92+ b'\x01\x02\x03\x04\x05\x06'
93+ # Hardware address padding
94+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +
95+ # Server host name
96+ (b'\x00' * 67) +
97+ # Boot filename
98+ (b'\x00' * 125) +
99+ # Cookie
100+ (b'\x63\x82\x53\x63' if not bad_cookie else b'xxxx') +
101+ # "DHCP Offer" option
102+ b'\x35\x01\x02' +
103+ options +
104+ # End options.
105+ (b'\xff' if include_end_option else b'')
106+ )
107+ if truncated:
108+ packet = packet[:200]
109+ return packet
110+
111+
112 # Create factory singleton.
113 factory = Factory()
114
115=== modified file 'src/provisioningserver/__main__.py'
116--- src/provisioningserver/__main__.py 2016-09-17 00:41:01 +0000
117+++ src/provisioningserver/__main__.py 2016-10-05 01:31:45 +0000
118@@ -12,6 +12,7 @@
119 import provisioningserver.upgrade_cluster
120 import provisioningserver.utils.arp
121 import provisioningserver.utils.avahi
122+import provisioningserver.utils.dhcp
123 import provisioningserver.utils.scan_network
124 from provisioningserver.utils.script import (
125 AtomicDeleteScript,
126@@ -29,6 +30,7 @@
127 'install-uefi-config': provisioningserver.boot.install_grub,
128 'observe-arp': provisioningserver.utils.arp,
129 'observe-mdns': provisioningserver.utils.avahi,
130+ 'observe-dhcp': provisioningserver.utils.dhcp,
131 'scan-network': provisioningserver.utils.scan_network,
132 'register': provisioningserver.register_command,
133 'support-dump': provisioningserver.support_dump,
134
135=== modified file 'src/provisioningserver/dhcp/detect.py'
136--- src/provisioningserver/dhcp/detect.py 2016-04-11 16:23:26 +0000
137+++ src/provisioningserver/dhcp/detect.py 2016-10-05 01:31:45 +0000
138@@ -1,28 +1,58 @@
139-
140 # Copyright 2013-2016 Canonical Ltd. This software is licensed under the
141 # GNU Affero General Public License version 3 (see the file LICENSE).
142
143 """Utilities and helpers to help discover DHCP servers on your network."""
144
145 __all__ = [
146- 'probe_dhcp',
147- ]
148+ 'probe_interface',
149+]
150
151 from contextlib import contextmanager
152 import errno
153 import fcntl
154+from os import strerror
155 from random import randint
156 import socket
157 import struct
158+import time
159+from typing import (
160+ List,
161+ Optional,
162+)
163
164+import attr
165+from netaddr import IPAddress
166 from provisioningserver.logger import get_maas_logger
167-
168-
169-maaslog = get_maas_logger("dhcp.detect")
170-
171-
172-def make_transaction_ID():
173- """Generate a random DHCP transaction identifier."""
174+from provisioningserver.utils.dhcp import DHCP
175+from twisted.internet import reactor
176+from twisted.internet.defer import (
177+ CancelledError,
178+ Deferred,
179+ DeferredList,
180+ FirstError,
181+ inlineCallbacks,
182+)
183+from twisted.internet.interfaces import IReactorThreads
184+from twisted.internet.task import deferLater
185+from twisted.internet.threads import (
186+ blockingCallFromThread,
187+ deferToThread,
188+)
189+from twisted.python import log
190+from twisted.python.failure import Failure
191+
192+
193+_LOG_SYSTEM = "dhcp.detect"
194+maaslog = get_maas_logger(_LOG_SYSTEM)
195+
196+
197+def _log_msg(*args, **kwargs):
198+ """Log to the Twisted logger using a unique `system` name."""
199+ log.msg(*args, system=_LOG_SYSTEM, **kwargs)
200+
201+
202+def make_dhcp_transaction_id() -> bytes:
203+ """Generate and return a random DHCP transaction identifier."""
204 transaction_id = b''
205 for _ in range(4):
206 transaction_id += struct.pack(b'!B', randint(0, 255))
207@@ -32,71 +62,117 @@
208 class DHCPDiscoverPacket:
209 """A representation of a DHCP_DISCOVER packet.
210
211- :param my_mac: The MAC address to which the dhcp server should respond.
212+ :param mac: The MAC address to which the dhcp server should respond.
213 Normally this is the MAC of the interface you're using to send the
214 request.
215 """
216
217- def __init__(self, my_mac):
218- self.transaction_ID = make_transaction_ID()
219- self.packed_mac = self.string_mac_to_packed(my_mac)
220- self._build()
221-
222- @classmethod
223- def string_mac_to_packed(cls, mac):
224+ def __init__(
225+ self, mac: str=None, transaction_id: bytes=None, seconds: int=0):
226+ super().__init__()
227+ self.mac_bytes = None
228+ self.mac_str = None
229+ self.seconds = seconds
230+ if transaction_id is None:
231+ self.transaction_id = make_dhcp_transaction_id()
232+ else:
233+ self.transaction_id = transaction_id
234+ if mac is not None:
235+ self.set_mac(mac)
236+
237+ def __hash__(self):
238+ # Needed for unit tests.
239+ return hash((self.mac_bytes, self.seconds, self.transaction_id))
240+
241+ def __eq__(self, other):
242+ # Needed for unit tests.
243+ return (
244+ (self.mac_bytes, self.seconds, self.transaction_id) ==
245+ (other.mac_bytes, other.seconds, other.transaction_id)
246+ )
247+
248+ @staticmethod
249+ def mac_string_to_bytes(mac: str) -> bytes:
250 """Convert a string MAC address to 6 hex octets.
251
252 :param mac: A MAC address in the format AA:BB:CC:DD:EE:FF
253 :return: a byte string of length 6
254 """
255- packed = b''
256+ mac_bytes = b''
257 for pair in mac.split(':'):
258 hex_octet = int(pair, 16)
259- packed += struct.pack(b'!B', hex_octet)
260- return packed
261-
262- def _build(self):
263- self.packet = b''
264- self.packet += b'\x01' # Message type: Boot Request (1)
265- self.packet += b'\x01' # Hardware type: Ethernet
266- self.packet += b'\x06' # Hardware address length: 6
267- self.packet += b'\x00' # Hops: 0
268- self.packet += self.transaction_ID
269- self.packet += b'\x00\x00' # Seconds elapsed: 0
270-
271- # Bootp flags: 0x8000 (Broadcast) + reserved flags
272- self.packet += b'\x80\x00'
273-
274- self.packet += b'\x00\x00\x00\x00' # Client IP address: 0.0.0.0
275- self.packet += b'\x00\x00\x00\x00' # Your (client) IP address: 0.0.0.0
276- self.packet += b'\x00\x00\x00\x00' # Next server IP address: 0.0.0.0
277- self.packet += b'\x00\x00\x00\x00' # Relay agent IP address: 0.0.0.0
278- self.packet += self.packed_mac
279-
280- # Client hardware address padding: 00000000000000000000
281- self.packet += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
282-
283- self.packet += b'\x00' * 67 # Server host name not given
284- self.packet += b'\x00' * 125 # Boot file name not given
285- self.packet += b'\x63\x82\x53\x63' # Magic cookie: DHCP
286-
287- # Option: (t=53,l=1) DHCP Message Type = DHCP Discover
288- self.packet += b'\x35\x01\x01'
289-
290- self.packet += b'\x3d\x06' + self.packed_mac
291-
292- # Option: (t=55,l=3) Parameter Request List
293- self.packet += b'\x37\x03\x03\x01\x06'
294-
295- self.packet += b'\xff' # End Option
296-
297-
298-class DHCPOfferPacket:
299- """A representation of a DHCP_OFFER packet."""
300-
301- def __init__(self, data):
302- self.transaction_ID = data[4:8]
303- self.dhcp_server_ID = socket.inet_ntoa(data[245:249])
304+ mac_bytes += struct.pack(b'!B', hex_octet)
305+ return mac_bytes
306+
307+ def set_mac(self, mac: str) -> None:
308+ """Sets the MAC address used for the client hardware address, and
309+ client unique identifier option.
310+ """
311+ self.mac_bytes = self.mac_string_to_bytes(mac)
312+ self.mac_str = mac
313+
314+ @property
315+ def client_uid_option(self) -> bytes:
316+ """Returns a `bytes` object representing the client UID.
317+
318+ The `set_mac()` method must have been called prior to using this.
319+ """
320+ # Option: (6=61,l=~23) Client Unique Identifier
321+ # Make our unique identifier a little more unique by adding "MAAS-",
322+ # so it will look like "\x00MAAS-00:00:00:00:00:00\x00"
323+ # See https://tools.ietf.org/html/rfc2132#section-9.14 for details.
324+ client_id = b"\x00MAAS-" + self.mac_str.encode("ascii")
325+ client_id_len = len(client_id).to_bytes(1, "big")
326+ return b'\x3d' + client_id_len + client_id
327+
328+ @property
329+ def packet(self) -> bytes:
330+ """Builds and returns the packet based on specified MAC and seconds."""
331+ return (
332+ # Message type: Boot Request (1)
333+ b'\x01'
334+ # Hardware type: Ethernet
335+ b'\x01'
336+ # Hardware address length: 6
337+ b'\x06'
338+ # Hops: 0
339+ b'\x00' +
340+ self.transaction_id +
341+ self.seconds.to_bytes(2, "big") +
342+ # Flags: the most significant bit is the broadcast bit.
343+ # 0x8000 means "force the server to use broadcast".
344+ # 0x0000 means "it's okay to unicast replies".
345+ # We will miss packets from some DHCP servers if we don't prefer
346+ # unicast. (For example, a DD-WRT router was observed sending to
347+ # the broadcast address from a link-local IPv4 address, which was
348+ # rejected by the IP stack before the socket could recv() it.)
349+ b'\x00\x00'
350+ # Client IP address: 0.0.0.0
351+ b'\x00\x00\x00\x00'
352+ # Your (client) IP address: 0.0.0.0
353+ b'\x00\x00\x00\x00'
354+ # Next server IP address: 0.0.0.0
355+ b'\x00\x00\x00\x00'
356+ # Relay agent IP address: 0.0.0.0
357+ b'\x00\x00\x00\x00' +
358+ # Client hardware address
359+ self.mac_bytes +
360+ # Client hardware address padding: 00000000000000000000
361+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +
362+ # Server host name not given
363+ (b'\x00' * 67) +
364+ # Boot file name not given
365+ (b'\x00' * 125) +
366+ # Magic cookie: DHCP
367+ b'\x63\x82\x53\x63'
368+ # Option: (t=53,l=1) DHCP Message Type = DHCP Discover
369+ b'\x35\x01\x01' +
370+ self.client_uid_option +
371+ # Option: (t=55,l=3) Parameter Request List
372+ b'\x37\x03\x03\x01\x06' +
373+ # End Option
374+ b'\xff'
375+ )
376
377
378 # UDP ports for the BOOTP protocol. Used for discovery requests.
379@@ -110,22 +186,55 @@
380 SIOCGIFHWADDR = 0x8927
381
382
383-def get_interface_MAC(sock, interface):
384+def get_interface_mac(sock: socket.socket, ifname: str) -> str:
385 """Obtain a network interface's MAC address, as a string."""
386- ifreq = struct.pack(b'256s', interface.encode('ascii')[:15])
387- info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, ifreq)
388- mac = ''.join('%02x:' % char for char in info[18:24])[:-1]
389+ ifreq = struct.pack(b'256s', ifname.encode('utf-8')[:15])
390+ try:
391+ info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, ifreq)
392+ except OSError as e:
393+ if e.errno is not None and e.errno == errno.ENODEV:
394+ raise InterfaceNotFound(
395+ "Interface not found: '%s'." % ifname)
396+ else:
397+ raise MACAddressNotAvailable(
398+ "Failed to get MAC address for '%s': %s." % (
399+ ifname, strerror(e.errno)))
400+ else:
401+ # Of course we're sure these are the correct indexes into the `ifreq`.
402+ # Also, your lack of faith is disturbing.
403+ mac = ''.join('%02x:' % char for char in info[18:24])[:-1]
404 return mac
405
406
407-def get_interface_IP(sock, interface):
408+def get_interface_ip(sock: socket.socket, ifname: str) -> str:
409 """Obtain an IP address for a network interface, as a string."""
410- ifreq = struct.pack(
411- b'16sH14s', interface.encode('ascii')[:15],
412- socket.AF_INET, b'\x00' * 14)
413- info = fcntl.ioctl(sock, SIOCGIFADDR, ifreq)
414- ip = struct.unpack(b'16sH2x4s8x', info)[2]
415- return socket.inet_ntoa(ip)
416+ ifreq_tuple = (ifname.encode('utf-8')[:15], socket.AF_INET, b'\x00' * 14)
417+ ifreq = struct.pack(b'16sH14s', *ifreq_tuple)
418+ try:
419+ info = fcntl.ioctl(sock, SIOCGIFADDR, ifreq)
420+ except OSError as e:
421+ if e.errno == errno.ENODEV:
422+ raise InterfaceNotFound(
423+ "Interface not found: '%s'." % ifname)
424+ elif e.errno == errno.EADDRNOTAVAIL:
425+ raise IPAddressNotAvailable(
426+ "No IP address found on interface '%s'." % ifname)
427+ else:
428+ raise IPAddressNotAvailable(
429+ "Failed to get IP address for '%s': %s." % (
430+ ifname, strerror(e.errno)))
431+ else:
432+ # Parse the `struct ifreq` that comes back from the ioctl() call.
433+ # 16x --> char ifr_name[IFNAMSIZ];
434+ # ... next is a union of structures; we're interested in the
435+ # `sockaddr_in` that is returned from this particular ioctl().
436+ # 2x --> short sin_family;
437+ # 2x --> unsigned short sin_port;
438+ # 4s --> struct in_addr sin_addr;
439+ # 8x --> char sin_zero[8];
440+ addr, = struct.unpack(b'16x2x2x4s8x', info)
441+ ip = socket.inet_ntoa(addr)
442+ return ip
443
444
445 @contextmanager
446@@ -142,37 +251,237 @@
447 sock.close()
448
449
450-def request_dhcp(interface):
451- """Broadcast a DHCP discovery request. Return DHCP transaction ID."""
452+class DHCPProbeException(Exception):
453+ """Class of known-possible exceptions during DHCP probing.
454+
455+ These exceptions are logged without including the traceback.
456+ """
457+
458+
459+class IPAddressNotAvailable(DHCPProbeException):
460+ """Raised when an interface's IP address could not be determined."""
461+
462+
463+class MACAddressNotAvailable(DHCPProbeException):
464+ """Raised when an interface's MAC address could not be determined."""
465+
466+
467+class InterfaceNotFound(DHCPProbeException):
468+ """Raised when an interface could not be found."""
469+
470+
471+def send_dhcp_request_packet(
472+ request: DHCPDiscoverPacket, ifname: str) -> None:
473+ """Sends out the specified DHCP discover packet to the given interface.
474+
475+ Optionally takes a `retry_call` to cancel if a fatal error occurs before
476+ the first packet can be sent, such as inability to get a source IP
477+ address.
478+ """
479 with udp_socket() as sock:
480 sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
481- mac = get_interface_MAC(sock, interface)
482- bind_address = get_interface_IP(sock, interface)
483- discover = DHCPDiscoverPacket(mac)
484+ mac = get_interface_mac(sock, ifname)
485+ request.set_mac(mac)
486+ bind_address = get_interface_ip(sock, ifname)
487 sock.bind((bind_address, BOOTP_CLIENT_PORT))
488- sock.sendto(discover.packet, ('<broadcast>', BOOTP_SERVER_PORT))
489- return discover.transaction_ID
490-
491-
492-def receive_offers(transaction_id):
493- """Receive DHCP offers. Return set of offering servers."""
494- servers = set()
495- with udp_socket() as sock:
496- # The socket we use for receiving DHCP offers must be bound to IF_ANY.
497- sock.bind(('', BOOTP_CLIENT_PORT))
498- try:
499- while True:
500- sock.settimeout(3)
501- data = sock.recv(1024)
502- offer = DHCPOfferPacket(data)
503- if offer.transaction_ID == transaction_id:
504- servers.add(offer.dhcp_server_ID)
505- except socket.timeout:
506- # No more offers. Done.
507- return servers
508-
509-
510-def probe_dhcp(interface):
511+ sock.sendto(request.packet, ('<broadcast>', BOOTP_SERVER_PORT))
512+
513+
514+# Packets will be sent at the following intervals (in seconds).
515+# The length of `DHCP_REQUEST_TIMING` indicates the number of packets
516+# that will be sent. The values should get progressively larger, to mimic
517+# the exponential back-off retry behavior of a real DHCP client.
518+DHCP_REQUEST_TIMING = (0, 2, 4, 8)
519+
520+# Wait `REPLY_TIMEOUT` seconds to receive responses.
521+# This value should be a little larger than the largest value in the
522+# `DHCP_REQUEST_TIMING` tuple, to account for network and server delays.
523+REPLY_TIMEOUT = 10
524+
525+# How long we should wait each iteration before waking up and checking if the
526+# timeout has elapsed.
527+SOCKET_TIMEOUT = 0.5
528+
529+
530+class DHCPRequestMonitor:
531+
532+ def __init__(self, ifname: str, clock: IReactorThreads=None):
533+ if clock is None:
534+ clock = reactor
535+ self.clock = clock # type: IReactorThreads
536+ self.ifname = ifname # type: str
537+ self.servers = None # type: set
538+ self.dhcpRequestsDeferredList = None # type: DeferredList
539+ self.deferredDHCPRequests = [] # type: List[Deferred]
540+ self.transaction_id = make_dhcp_transaction_id() # type: bytes
541+
542+ def send_requests_and_await_replies(self):
543+ """Sends out DHCP requests and waits for their replies.
544+
545+ This method is intended to run under `deferToThread()`.
546+
547+ Calls the reactor using `blockingCallFromThread` to queue the request
548+ packets.
549+
550+ Blocks for ~10 seconds while checking for DHCP offers.
551+
552+ :returns: `set` of `DHCPServer` objects.
553+ """
554+ # Since deferToThread() might be delayed until the next thread is
555+ # available, it's better to kick off the DHCP requests from the
556+ # spawned thread rather than hoping the thread starts running after
557+ # we kick off the requests.
558+ blockingCallFromThread(self.clock, self.deferDHCPRequests)
559+ servers = set()
560+ # Convert the transaction_id to an integer so we can test it against
561+ # what the parsed DHCP packet will return.
562+ xid = int.from_bytes(self.transaction_id, byteorder='big')
563+ with udp_socket() as sock:
564+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
565+ # Note: the empty string is the equivalent of INADDR_ANY.
566+ sock.bind(('', BOOTP_CLIENT_PORT))
567+ # The timeout has to be relatively small, since we wake up every
568+ # timeout interval to check the elapsed time.
569+ sock.settimeout(0.5)
570+ runtime = 0
571+ # Use a monotonic clock to ensure leaping backward in time won't
572+ # cause an infinite loop.
573+ start_time = time.monotonic()
574+ while runtime < REPLY_TIMEOUT:
575+ try:
576+ # Use recvfrom() to check the source IP for the request.
577+ # It could be interesting in cases where DHCP relay is in
578+ # use.
579+ data, (address, port) = sock.recvfrom(2048)
580+ except socket.timeout:
581+ continue
582+ else:
583+ offer = DHCP(data)
584+ if not offer.is_valid():
585+ _log_msg(
586+ "Invalid DHCP response received from %s on '%s': "
587+ "%s" % (
588+ address, self.ifname, offer.invalid_reason))
589+ elif offer.packet.xid == xid:
590+ # Offer matches our transaction ID, so check if it has
591+ # a Server Identifier option.
592+ server = offer.server_identifier
593+ if server is not None:
594+ servers.add(DHCPServer(server, address))
595+ finally:
596+ runtime = time.monotonic() - start_time
597+ return servers
598+
599+ @staticmethod
600+ def cancelAll(deferreds: List[Deferred]):
601+ for deferred in deferreds:
602+ deferred.cancel()
603+
604+ def deferredDHCPRequestErrback(
605+ self, failure: Failure) -> Optional[Failure]:
606+ if failure.check(FirstError):
607+ # If an error occurred, cancel any other pending requests.
608+ # (The error is likely to occur for those requests, too.)
609+ # Unfortunately we can't cancel using the DeferredList, since
610+ # the DeferredList considers itself "called" the moment the first
611+ # errback is invoked.
612+ self.cancelAll(self.deferredDHCPRequests)
613+ # Suppress further error handling. The original Deferred's errback
614+ # has already been called.
615+ return None
616+ elif failure.check(DHCPProbeException):
617+ _log_msg("DHCP probe failed. %s" % failure.getErrorMessage())
618+ elif failure.check(CancelledError):
619+ # Intentionally cancelled; no need to spam the log.
620+ pass
621+ else:
622+ log.err(
623+ failure, "DHCP probe on '%s' failed with an unknown error." % (
624+ self.ifname))
625+ # Make sure the error is propagated to the DeferredList.
626+ # We need this so that the DeferredList knows to call us with
627+ # FirstError, which is our indicator to cancel the remaining calls.
628+ # (It's set to consumeErrors, so it won't spam the log.)
629+ return failure
630+
631+ def deferDHCPRequests(self) -> None:
632+ """Queues some DHCP requests to fire off later.
633+
634+ Delays calls slightly so we have a chance to open a listen socket.
635+
636+ Uses the `clock`, `ifname`, and `transaction_id` ivars.
637+
638+ Stores a `DeferredList` of periodic DHCP requests calls in the
639+ `dhcpRequestsDeferredList` ivar, and the list of `Deferred` objects in
640+ the `deferredDHCPRequests` ivar.
641+ """
642+ self.deferredDHCPRequests = []
643+ for seconds in DHCP_REQUEST_TIMING:
644+ packet = DHCPDiscoverPacket(
645+ transaction_id=self.transaction_id, seconds=seconds)
646+ # Wait 0.1 seconds before sending the request, so we have a chance
647+ # to open a listen socket.
648+ seconds += 0.1
649+ deferred = deferLater(
650+ self.clock, seconds, send_dhcp_request_packet, packet,
651+ self.ifname)
652+ deferred.addErrback(self.deferredDHCPRequestErrback)
653+ self.deferredDHCPRequests.append(deferred)
654+ # Use fireOnOneErrback so that we know to cancel the remaining attempts
655+ # to send requests if one of them fails.
656+ self.dhcpRequestsDeferredList = DeferredList(
657+ self.deferredDHCPRequests, fireOnOneErrback=True,
658+ consumeErrors=True)
659+ self.dhcpRequestsDeferredList.addErrback(
660+ self.deferredDHCPRequestErrback)
661+
662+ @inlineCallbacks
663+ def run(self) -> Deferred:
664+ """Queues DHCP requests to be sent, then waits (in a separate thread)
665+ for replies.
666+
667+ Requests will be sent using an exponential back-off algorithm, to mimic
668+ a real DHCP client. But we'll just pretend we didn't see any of the
669+ replies, and hope for more servers to respond.
670+
671+ The set of `DHCPServer`s that responded to the request(s) is stored
672+ in the `servers` ivar, which in turn makes the `dhcp_servers` and
673+ `dhcp_addresses` properties useful.
674+ """
675+ servers = yield deferToThread(self.send_requests_and_await_replies)
676+ if len(servers) > 0:
677+ _log_msg(
678+ "External DHCP server(s) discovered on interface '%s': %s"
679+ % (self.ifname, ', '.join(str(server) for server in servers)))
680+ self.servers = servers
681+
682+ @property
683+ def dhcp_servers(self):
684+ return set(str(server.server) for server in self.servers)
685+
686+ @property
687+ def dhcp_addresses(self):
688+ return set(str(server.address) for server in self.servers)
689+
690+
691+@attr.s
692+class DHCPServer:
693+ server = attr.ib(convert=IPAddress)
694+ address = attr.ib(convert=IPAddress)
695+
696+ def __str__(self):
697+ """Returns eitehr a longer format string (if the address we received
698+ the packet from is different from the DHCP server address specified in
699+ the packet) or a single IP address (if the address we received the
700+ packet from and the server address are the same).
701+ """
702+ if self.server == self.address:
703+ return str(self.server)
704+ return "%s (via %s)" % (self.server, self.address)
705+
706+
707+@inlineCallbacks
708+def probe_interface(interface):
709 """Look for a DHCP server on the network.
710
711 This must be run with provileges to broadcast from the BOOTP port, which
712@@ -182,45 +491,12 @@
713 :param interface: Network interface name, e.g. "eth0", attached to the
714 network you wish to probe.
715 :return: Set of discovered DHCP servers.
716-
717- :exception IOError: If the interface does not have an IP address.
718- """
719- # There is a small race window here, after we close the first socket and
720- # before we bind the second one. Hopefully executing a few lines of code
721- # will be faster than communication over the network.
722- # UDP is not reliable at any rate. If detection is important, we should
723- # send out repeated requests.
724- transaction_id = request_dhcp(interface)
725- return receive_offers(transaction_id)
726-
727-
728-def probe_interface(interface):
729- """Probe the given interface for DHCP servers.
730-
731- :param interface: interface name
732- :return: A set of IP addresses of detected servers.
733-
734- :note: Any servers running on the IP address of the local host are
735- filtered out as they will be the MAAS DHCP server.
736- """
737- try:
738- servers = probe_dhcp(interface)
739- except IOError as e:
740- servers = set()
741- if e.errno == errno.EADDRNOTAVAIL:
742- # Errno EADDRNOTAVAIL is "Cannot assign requested address"
743- # which we need to ignore; it means the interface has no IP
744- # and there's no need to scan this interface as it's not in
745- # use.
746- maaslog.debug(
747- "Ignoring DHCP scan for %s, it has no IP address", interface)
748- elif e.errno == errno.ENODEV:
749- # Errno ENODEV is "no such device". This seems an odd situation
750- # since we're scanning detected devices, so this is probably
751- # a bug.
752- maaslog.error(
753- "Ignoring DHCP scan for %s, it no longer exists. Check "
754- "your cluster interfaces configuration.", interface)
755- else:
756- raise
757- return servers
758+ """
759+ dhcp_request_monitor = DHCPRequestMonitor(interface)
760+ yield dhcp_request_monitor.run()
761+ # The caller expects a set of addresses in unicode format.
762+ # XXX We might want to consider using the address we got from
763+ # recvfrom(), since that is likely the address relaying to this
764+ # interface. (Those are stored in the `dchp_addresses` ivar.)
765+ # Further investigation required.
766+ return dhcp_request_monitor.dhcp_servers
767
768=== modified file 'src/provisioningserver/dhcp/tests/test_detect.py'
769--- src/provisioningserver/dhcp/tests/test_detect.py 2016-05-12 19:07:37 +0000
770+++ src/provisioningserver/dhcp/tests/test_detect.py 2016-10-05 01:31:45 +0000
771@@ -6,121 +6,180 @@
772 __all__ = []
773
774 import errno
775-import fcntl
776+import random
777 import socket
778 from unittest import mock
779+from unittest.mock import call
780
781 from maastesting.factory import factory
782+from maastesting.matchers import (
783+ DocTestMatches,
784+ Equals,
785+ HasLength,
786+ MockCallsMatch,
787+)
788+from maastesting.runtest import MAASTwistedRunTest
789 from maastesting.testcase import MAASTestCase
790+from maastesting.twisted import TwistedLoggerFixture
791 from provisioningserver.dhcp.detect import (
792 BOOTP_CLIENT_PORT,
793 BOOTP_SERVER_PORT,
794+ DHCP_REQUEST_TIMING,
795 DHCPDiscoverPacket,
796- DHCPOfferPacket,
797- get_interface_IP,
798- get_interface_MAC,
799- make_transaction_ID,
800+ DHCPProbeException,
801+ DHCPRequestMonitor,
802+ DHCPServer,
803+ get_interface_ip,
804+ get_interface_mac,
805+ InterfaceNotFound,
806+ IPAddressNotAvailable,
807+ MACAddressNotAvailable,
808+ make_dhcp_transaction_id,
809 probe_interface,
810- receive_offers,
811- request_dhcp,
812+ send_dhcp_request_packet,
813 udp_socket,
814 )
815 import provisioningserver.dhcp.detect as detect_module
816-from provisioningserver.testing.testcase import PservTestCase
817-
818-
819-class MakeTransactionID(MAASTestCase):
820- """Tests for `make_transaction_ID`."""
821-
822- def test_produces_well_formed_ID(self):
823- # The dhcp transaction should be 4 bytes long.
824- transaction_id = make_transaction_ID()
825+from testtools import ExpectedException
826+from testtools.matchers import Contains
827+from twisted.internet import reactor
828+from twisted.internet.defer import (
829+ CancelledError,
830+ DeferredList,
831+ inlineCallbacks,
832+)
833+from twisted.internet.task import (
834+ Clock,
835+ deferLater,
836+)
837+from twisted.python.failure import Failure
838+
839+
840+class MakeDHCPTransactionID(MAASTestCase):
841+ """Tests for `make_dhcp_transaction_id`."""
842+
843+ def test_produces_well_formed_id(self):
844+ # The DHCP transaction ID should be 4 bytes long.
845+ transaction_id = make_dhcp_transaction_id()
846 self.assertIsInstance(transaction_id, bytes)
847 self.assertEqual(4, len(transaction_id))
848
849 def test_randomises(self):
850 self.assertNotEqual(
851- make_transaction_ID(),
852- make_transaction_ID())
853+ make_dhcp_transaction_id(),
854+ make_dhcp_transaction_id())
855
856
857 class TestDHCPDiscoverPacket(MAASTestCase):
858
859- def test_init_sets_transaction_ID(self):
860- transaction_id = make_transaction_ID()
861- self.patch(detect_module, 'make_transaction_ID').return_value = (
862+ def test_init_sets_transaction_id(self):
863+ transaction_id = make_dhcp_transaction_id()
864+ self.patch(detect_module, 'make_dhcp_transaction_id').return_value = (
865 transaction_id)
866-
867 discover = DHCPDiscoverPacket(factory.make_mac_address())
868-
869- self.assertEqual(transaction_id, discover.transaction_ID)
870-
871- def test_init_sets_packed_mac(self):
872+ self.assertEqual(transaction_id, discover.transaction_id)
873+
874+ def test_init_sets_mac_bytes(self):
875 mac = factory.make_mac_address()
876 discover = DHCPDiscoverPacket(mac)
877 self.assertEqual(
878- discover.string_mac_to_packed(mac),
879- discover.packed_mac)
880+ discover.mac_string_to_bytes(mac),
881+ discover.mac_bytes)
882
883- def test_init_sets_packet(self):
884+ def test__packet_property_after_init_with_mac_and_no_transaction_id(self):
885 discover = DHCPDiscoverPacket(factory.make_mac_address())
886 self.assertIsNotNone(discover.packet)
887
888- def test_string_mac_to_packed(self):
889+ def test__converts_byte_string_to_bytes(self):
890 discover = DHCPDiscoverPacket
891 expected = b"\x01\x22\x33\x99\xaa\xff"
892 input = "01:22:33:99:aa:ff"
893- self.assertEqual(expected, discover.string_mac_to_packed(input))
894+ self.assertEqual(expected, discover.mac_string_to_bytes(input))
895
896- def test__build(self):
897+ def test__builds_packet(self):
898 mac = factory.make_mac_address()
899- discover = DHCPDiscoverPacket(mac)
900- discover._build()
901-
902+ seconds = random.randint(0, 1024)
903+ xid = factory.make_bytes(4)
904+ discover = DHCPDiscoverPacket(
905+ transaction_id=xid, mac=mac, seconds=seconds)
906+ seconds_bytes = seconds.to_bytes(2, "big")
907 expected = (
908- b'\x01\x01\x06\x00' + discover.transaction_ID +
909- b'\x00\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00' +
910- b'\x00\x00\x00\x00\x00\x00\x00\x00' +
911- discover.packed_mac +
912+ b'\x01\x01\x06\x00' + xid + seconds_bytes +
913+ b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +
914+ b'\x00\x00\x00\x00\x00\x00' +
915+ discover.mac_bytes +
916 b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +
917 b'\x00' * 67 +
918 b'\x00' * 125 +
919- b'\x63\x82\x53\x63\x35\x01\x01\x3d\x06' + discover.packed_mac +
920+ b'\x63\x82\x53\x63\x35\x01\x01' +
921+ discover.client_uid_option +
922 b'\x37\x03\x03\x01\x06\xff')
923-
924 self.assertEqual(expected, discover.packet)
925
926
927-class TestDHCPOfferPacket(MAASTestCase):
928-
929- def test_decodes_dhcp_server(self):
930- buffer = b'\x00' * 245 + b'\x10\x00\x00\xaa'
931- offer = DHCPOfferPacket(buffer)
932- self.assertEqual('16.0.0.170', offer.dhcp_server_ID)
933-
934-
935 class TestGetInterfaceMAC(MAASTestCase):
936- """Tests for `get_interface_MAC`."""
937+ """Tests for `get_interface_mac`."""
938
939- def test_loopback_has_zero_MAC(self):
940+ def test__loopback_has_zero_mac(self):
941 # It's a lame test, but what other network interfaces can we reliably
942 # test this on?
943 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
944- self.assertEqual('00:00:00:00:00:00', get_interface_MAC(sock, 'lo'))
945+ self.assertEqual('00:00:00:00:00:00', get_interface_mac(sock, 'lo'))
946+
947+ def test__invalid_interface_raises_interfacenotfound(self):
948+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
949+ with ExpectedException(InterfaceNotFound):
950+ get_interface_mac(sock, factory.make_unicode_string(size=15))
951+
952+ def test__no_mac_raises_macaddressnotavailable(self):
953+ mock_ioerror = IOError()
954+ mock_ioerror.errno = errno.EOPNOTSUPP
955+ self.patch(detect_module.fcntl, 'ioctl').side_effect = mock_ioerror
956+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
957+ # Drive-by test for the strerror() call.
958+ with ExpectedException(
959+ MACAddressNotAvailable, '.*not supported.*'):
960+ get_interface_mac(sock, 'lo')
961
962
963 class TestGetInterfaceIP(MAASTestCase):
964- """Tests for `get_interface_IP`."""
965+ """Tests for `get_interface_ip`."""
966
967 def test_loopback_has_localhost_address(self):
968 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
969- self.assertEqual('127.0.0.1', get_interface_IP(sock, 'lo'))
970+ self.assertEqual('127.0.0.1', get_interface_ip(sock, 'lo'))
971+
972+ def test__invalid_interface_raises_interfacenotfound(self):
973+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
974+ with ExpectedException(detect_module.InterfaceNotFound):
975+ get_interface_ip(sock, factory.make_unicode_string(size=15))
976+
977+ def test__no_ip_raises_ipaddressnotavailable(self):
978+ mock_ioerror = IOError()
979+ mock_ioerror.errno = errno.EADDRNOTAVAIL
980+ self.patch(detect_module.fcntl, 'ioctl').side_effect = mock_ioerror
981+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
982+ with ExpectedException(
983+ IPAddressNotAvailable, '.*No IP address.*'):
984+ get_interface_ip(sock, 'lo')
985+
986+ def test__unknown_errno_ip_raises_ipaddressnotavailable(self):
987+ mock_ioerror = IOError()
988+ mock_ioerror.errno = errno.EACCES
989+ self.patch(detect_module.fcntl, 'ioctl').side_effect = mock_ioerror
990+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
991+ # Drive-by test for the strerror() call.
992+ with ExpectedException(
993+ IPAddressNotAvailable,
994+ 'Failed.*Permission denied.'):
995+ get_interface_ip(sock, 'lo')
996
997
998 def patch_socket(testcase):
999 """Patch `socket.socket` to return a mock."""
1000 sock = mock.MagicMock()
1001- testcase.patch(socket, 'socket', mock.MagicMock(return_value=sock))
1002+ testcase.patch(
1003+ detect_module.socket, 'socket', mock.MagicMock(return_value=sock))
1004 return sock
1005
1006
1007@@ -152,175 +211,242 @@
1008 sock.setsockopt.mock_calls)
1009
1010
1011-class TestRequestDHCP(MAASTestCase):
1012- """Tests for `request_dhcp`."""
1013-
1014- def patch_interface_MAC(self):
1015- """Patch `get_interface_MAC` to return a fixed value."""
1016- mac = factory.make_mac_address()
1017- self.patch(detect_module, 'get_interface_MAC').return_value = mac
1018- return mac
1019-
1020- def patch_interface_IP(self):
1021- """Patch `get_interface_IP` to return a fixed value."""
1022- ip = factory.make_ipv4_address()
1023- self.patch(detect_module, 'get_interface_IP').return_value = ip
1024- return ip
1025-
1026- def patch_transaction_ID(self):
1027- """Patch `make_transaction_ID` to return a fixed value."""
1028- transaction_id = make_transaction_ID()
1029- self.patch(
1030- detect_module, 'make_transaction_ID').return_value = transaction_id
1031- return transaction_id
1032-
1033- def test_sends_discover_packet(self):
1034- sock = patch_socket(self)
1035- self.patch_interface_MAC()
1036- self.patch_interface_IP()
1037- interface = factory.make_name('interface')
1038-
1039- request_dhcp(interface)
1040-
1041- [call] = sock.sendto.mock_calls
1042- _, args, _ = call
1043- self.assertEqual(
1044- ('<broadcast>', BOOTP_SERVER_PORT),
1045- args[1])
1046-
1047- def test_returns_transaction_id(self):
1048- patch_socket(self)
1049- self.patch_interface_MAC()
1050- self.patch_interface_IP()
1051- transaction_id = self.patch_transaction_ID()
1052- interface = factory.make_name('interface')
1053-
1054- self.assertEqual(transaction_id, request_dhcp(interface))
1055-
1056-
1057-class FakePacketReceiver:
1058- """Fake callable to substitute for a socket's `recv`.
1059-
1060- Returns the given packets on successive calls. When it runs out,
1061- raises a timeout.
1062- """
1063-
1064- def __init__(self, packets=None):
1065- if packets is None:
1066- packets = []
1067- self.calls = []
1068- self.packets = list(packets)
1069-
1070- def __call__(self, recv_size):
1071- self.calls.append(recv_size)
1072- if len(self.packets) == 0:
1073- raise socket.timeout()
1074- else:
1075- return self.packets.pop(0)
1076-
1077-
1078-class TestReceiveOffers(MAASTestCase):
1079- """Tests for `receive_offers`."""
1080-
1081- def patch_recv(self, sock, num_packets=0):
1082- """Patch up socket's `recv` to return `num_packets` arbitrary packets.
1083-
1084- After that, further calls to `recv` will raise a timeout.
1085- """
1086- packets = [factory.make_bytes() for _ in range(num_packets)]
1087- receiver = FakePacketReceiver(packets)
1088- self.patch(sock, 'recv', receiver)
1089- return receiver
1090-
1091- def patch_offer_packet(self):
1092- """Patch a mock `DHCPOfferPacket`."""
1093- transaction_id = factory.make_bytes(4)
1094- packet = mock.MagicMock()
1095- packet.transaction_ID = transaction_id
1096- packet.dhcp_server_ID = factory.make_ipv4_address()
1097- self.patch(detect_module, 'DHCPOfferPacket').return_value = packet
1098- return packet
1099-
1100- def test_receives_from_socket(self):
1101- sock = patch_socket(self)
1102- receiver = self.patch_recv(sock)
1103- transaction_id = self.patch_offer_packet().transaction_ID
1104-
1105- receive_offers(transaction_id)
1106-
1107- self.assertEqual(
1108- [mock.call(socket.AF_INET, socket.SOCK_DGRAM)],
1109- socket.socket.mock_calls)
1110- self.assertEqual(
1111- [mock.call(('', BOOTP_CLIENT_PORT))],
1112- sock.bind.mock_calls)
1113- self.assertEqual([1024], receiver.calls)
1114-
1115- def test_returns_empty_if_nothing_received(self):
1116- sock = patch_socket(self)
1117- self.patch_recv(sock)
1118- transaction_id = self.patch_offer_packet().transaction_ID
1119-
1120- self.assertEqual(set(), receive_offers(transaction_id))
1121-
1122- def test_processes_offer(self):
1123- sock = patch_socket(self)
1124- self.patch_recv(sock, 1)
1125- packet = self.patch_offer_packet()
1126-
1127- self.assertEqual(
1128- {packet.dhcp_server_ID},
1129- receive_offers(packet.transaction_ID))
1130-
1131- def test_ignores_other_transactions(self):
1132- sock = patch_socket(self)
1133- self.patch_recv(sock, 1)
1134- self.patch_offer_packet()
1135- other_transaction_id = factory.make_bytes(4)
1136-
1137- self.assertEqual(set(), receive_offers(other_transaction_id))
1138-
1139- def test_propagates_errors_other_than_timeout(self):
1140- class InducedError(Exception):
1141- """Deliberately induced error for testing."""
1142-
1143- sock = patch_socket(self)
1144- sock.recv = mock.MagicMock(side_effect=InducedError)
1145-
1146- self.assertRaises(
1147- InducedError,
1148- receive_offers, factory.make_bytes(4))
1149-
1150-
1151-class TestPeriodicTask(PservTestCase):
1152-
1153- def test_probe_interface_returns_empty_set_when_nothing_detected(self):
1154- self.patch(detect_module, 'probe_dhcp').return_value = set()
1155- results = probe_interface("eth0")
1156- self.assertEqual(set(), results)
1157-
1158- def test_probe_interface_returns_empty_set_when_IP_missing(self):
1159- # If the interface being probed has no IP address, the
1160- # request_dhcr() method will raise IOError with errno 99. Make
1161- # sure this is caught and ignored.
1162- ioerror = IOError(
1163- errno.EADDRNOTAVAIL, "Cannot assign requested address")
1164- self.patch(fcntl, 'ioctl').side_effect = ioerror
1165- results = probe_interface("eth0")
1166- self.assertEqual(set(), results)
1167-
1168- def test_probe_interface_returns_empty_set_when_device_missing(self):
1169- # If the interface being probed does not exist, the
1170- # request_dhcp() method will raise IOError with errno 19. Make
1171- # sure this is caught and ignored.
1172- ioerror = IOError(errno.ENODEV, "No such device")
1173- self.patch(fcntl, 'ioctl').side_effect = ioerror
1174- results = probe_interface("eth0")
1175- self.assertEqual(set(), results)
1176-
1177- def test_probe_interface_returns_populated_set(self):
1178- # Test that the detected DHCP server is returned.
1179- self.patch(
1180- detect_module, 'probe_dhcp').return_value = {'10.2.2.2'}
1181- results = probe_interface("eth0")
1182- self.assertEqual({'10.2.2.2'}, results)
1183+class TestSendDHCPRequestPacket(MAASTestCase):
1184+
1185+ def test__sends_expected_packet(self):
1186+ mock_socket = patch_socket(self)
1187+ mock_socket.bind = mock.MagicMock()
1188+ mock_socket.sendto = mock.MagicMock()
1189+ self.patch(detect_module.get_interface_ip).return_value = '127.0.0.1'
1190+ self.patch(
1191+ detect_module.get_interface_mac).return_value = '00:00:00:00:00:00'
1192+ request = DHCPDiscoverPacket()
1193+ send_dhcp_request_packet(request, 'lo')
1194+ self.assertThat(
1195+ mock_socket.bind, MockCallsMatch(
1196+ call(('127.0.0.1', BOOTP_CLIENT_PORT))))
1197+ self.assertThat(
1198+ mock_socket.sendto, MockCallsMatch(
1199+ call(request.packet, ('<broadcast>', BOOTP_SERVER_PORT))))
1200+
1201+
1202+def make_Failure(exception_type, *args, **kwargs):
1203+ try:
1204+ raise exception_type(*args, **kwargs)
1205+ except:
1206+ return Failure()
1207+
1208+
1209+class TestDHCPRequestMonitor(MAASTestCase):
1210+
1211+ run_tests_with = MAASTwistedRunTest.make_factory(debug=False, timeout=5)
1212+
1213+ def test__send_requests_and_await_replies(self):
1214+ # This test is a bit large because it covers the entire functionality
1215+ # of the `send_requests_and_await_replies()` method. (It could be
1216+ # split apart into multiple tests, but the large amount of setup work
1217+ # and interdependencies makes that a maintenance burden.)
1218+ mock_socket = patch_socket(self)
1219+ mock_socket.bind = mock.MagicMock()
1220+ mock_socket.recvfrom = mock.MagicMock()
1221+ mock_socket.setsockopt = mock.MagicMock()
1222+ mock_socket.settimeout = mock.MagicMock()
1223+ # Pretend we were successful at deferring the DHCP requests.
1224+ self.patch_autospec(detect_module, 'blockingCallFromThread')
1225+ # This method normally blocks for ~10 seconds, so take control of the
1226+ # monotonic clock and make sure the last call to `recvfrom()` happens
1227+ # just as we hit the reply timeout.
1228+ mock_time_monotonic = self.patch(detect_module.time.monotonic)
1229+ mock_time_monotonic.side_effect = (
1230+ # Start time (before loop starts).
1231+ 10,
1232+ # First reply (truncated packet).
1233+ 11,
1234+ # Second reply (not a match to our transaction).
1235+ 12,
1236+ # Third reply (Matching reply with server identifier option).
1237+ 13,
1238+ # First socket timeout (need to make sure the loop continues).
1239+ 14,
1240+ # Second socket timeout (hey, we're done!).
1241+ 10 + detect_module.REPLY_TIMEOUT
1242+ )
1243+ mock_xid = factory.make_bytes(4)
1244+ valid_dhcp_reply = factory.make_dhcp_packet(
1245+ transaction_id=mock_xid,
1246+ include_server_identifier=True, server_ip='127.1.1.1')
1247+ mock_get_xid = self.patch(detect_module.make_dhcp_transaction_id)
1248+ mock_get_xid.return_value = mock_xid
1249+ # Valid DHCP packet, but not a match because it doesn't have a
1250+ # Server Identifier option.
1251+ valid_non_match = DHCPDiscoverPacket(
1252+ mac="01:02:03:04:05:06", transaction_id=mock_xid).packet
1253+ mock_socket.recvfrom.side_effect = (
1254+ # Truncated packet, to test logging.
1255+ (b'', ('127.0.0.1', BOOTP_SERVER_PORT)),
1256+ (valid_non_match, ('127.0.0.2', BOOTP_SERVER_PORT)),
1257+ (valid_dhcp_reply, ('127.0.0.3', BOOTP_SERVER_PORT)),
1258+ socket.timeout,
1259+ socket.timeout,
1260+ )
1261+ logger = self.useFixture(TwistedLoggerFixture())
1262+ monitor = DHCPRequestMonitor('lo', Clock())
1263+ result = monitor.send_requests_and_await_replies()
1264+ self.assertThat(
1265+ mock_socket.setsockopt, MockCallsMatch(
1266+ call(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1),
1267+ call(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)))
1268+ self.assertThat(
1269+ mock_socket.bind, MockCallsMatch(call(('', 68))))
1270+ self.assertThat(
1271+ mock_socket.settimeout, MockCallsMatch(
1272+ call(detect_module.SOCKET_TIMEOUT)))
1273+ self.assertThat(
1274+ mock_socket.recvfrom, MockCallsMatch(
1275+ call(2048), call(2048), call(2048), call(2048), call(2048)))
1276+ # One of the response packets was truncated.
1277+ self.assertThat(logger.output, DocTestMatches(
1278+ "Invalid DHCP response...Truncated..."))
1279+ self.assertThat(result, HasLength(1))
1280+ # Ensure we record the fact that the reply packet came from a different
1281+ # IP address than the server claimed to be.
1282+ self.assertThat(result, Contains(DHCPServer('127.1.1.1', '127.0.0.3')))
1283+
1284+ @inlineCallbacks
1285+ def test__cancelAll(self):
1286+ self.errbacks_called = 0
1287+
1288+ def mock_errback(result: Failure):
1289+ self.assertTrue(result.check(CancelledError))
1290+ self.errbacks_called += 1
1291+ a = deferLater(reactor, 6, lambda: 'a')
1292+ b = deferLater(reactor, 6, lambda: 'b')
1293+ a.addBoth(mock_errback)
1294+ b.addBoth(mock_errback)
1295+ deferreds = [a, b]
1296+ DHCPRequestMonitor.cancelAll(deferreds)
1297+ deferredList = DeferredList(deferreds)
1298+ yield deferredList
1299+ self.assertThat(self.errbacks_called, Equals(2))
1300+
1301+ @inlineCallbacks
1302+ def test__deferredDHCPRequestErrback_cancels_all_on_FirstError(self):
1303+ mock_cancelAll = self.patch(DHCPRequestMonitor, 'cancelAll')
1304+
1305+ def raise_ioerror():
1306+ raise IOError()
1307+ a = deferLater(reactor, 0.0, raise_ioerror)
1308+ b = deferLater(reactor, 6, lambda: 'b')
1309+ monitor = DHCPRequestMonitor('lo')
1310+ monitor.deferredDHCPRequests = [a, b]
1311+ deferredList = DeferredList(
1312+ monitor.deferredDHCPRequests,
1313+ consumeErrors=True, fireOnOneErrback=True)
1314+ deferredList.addErrback(monitor.deferredDHCPRequestErrback)
1315+ yield deferredList
1316+ # Still have one call left in the reactor, since we mocked cancelAll().
1317+ b.cancel()
1318+ self.assertThat(mock_cancelAll, MockCallsMatch(call([a, b])))
1319+
1320+ def test__deferredDHCPRequestErrback_logs_known_exceptions(self):
1321+ logger = self.useFixture(TwistedLoggerFixture())
1322+ monitor = DHCPRequestMonitor('lo')
1323+ error = factory.make_string()
1324+ monitor.deferredDHCPRequestErrback(
1325+ make_Failure(DHCPProbeException, error))
1326+ self.assertThat(logger.output, DocTestMatches(
1327+ "DHCP probe failed. %s" % error))
1328+
1329+ def test__deferredDHCPRequestErrback_logs_unknown_exceptions(self):
1330+ logger = self.useFixture(TwistedLoggerFixture())
1331+ monitor = DHCPRequestMonitor('lo')
1332+ error = factory.make_string()
1333+ monitor.deferredDHCPRequestErrback(
1334+ make_Failure(IOError, error))
1335+ self.assertThat(logger.output, DocTestMatches(
1336+ "...unknown error...Traceback...%s" % error))
1337+
1338+ def test__deferredDHCPRequestErrback_ignores_cancelled(self):
1339+ logger = self.useFixture(TwistedLoggerFixture())
1340+ monitor = DHCPRequestMonitor('lo')
1341+ error = factory.make_string()
1342+ monitor.deferredDHCPRequestErrback(
1343+ make_Failure(CancelledError, error))
1344+ self.assertThat(logger.output, DocTestMatches(""))
1345+
1346+ def test__deferDHCPRequests(self):
1347+ clock = Clock()
1348+ monitor = DHCPRequestMonitor('lo', clock)
1349+ mock_addErrback = mock.MagicMock()
1350+ mock_deferredListResult = mock.MagicMock()
1351+ mock_deferLater = self.patch(detect_module, 'deferLater')
1352+ mock_deferLater.return_value = mock.MagicMock()
1353+ mock_deferLater.return_value.addErrback = mock_addErrback
1354+ mock_DeferredList = self.patch(detect_module, 'DeferredList')
1355+ mock_DeferredList.return_value = mock_deferredListResult
1356+ mock_deferredListResult.addErrback = mock_addErrback
1357+ expected_calls = [
1358+ call(clock, seconds + 0.1, send_dhcp_request_packet,
1359+ DHCPDiscoverPacket(
1360+ transaction_id=monitor.transaction_id, seconds=seconds),
1361+ 'lo')
1362+ for seconds in DHCP_REQUEST_TIMING
1363+ ]
1364+ monitor.deferDHCPRequests()
1365+ self.assertThat(
1366+ mock_deferLater, MockCallsMatch(*expected_calls))
1367+ self.assertThat(mock_DeferredList, MockCallsMatch(
1368+ call(
1369+ monitor.deferredDHCPRequests, fireOnOneErrback=True,
1370+ consumeErrors=True)))
1371+ # Expect addErrback to be called both on each individual Deferred, plus
1372+ # one more time on the DeferredList.
1373+ expected_errback_calls = [
1374+ call(monitor.deferredDHCPRequestErrback)
1375+ for _ in range(len(DHCP_REQUEST_TIMING) + 1)
1376+ ]
1377+ self.assertThat(
1378+ mock_addErrback, MockCallsMatch(*expected_errback_calls))
1379+
1380+ @inlineCallbacks
1381+ def test__run_logs_result_and_makes_properties_available(self):
1382+ logger = self.useFixture(TwistedLoggerFixture())
1383+ monitor = DHCPRequestMonitor('lo')
1384+ mock_send_and_await = self.patch(
1385+ monitor, 'send_requests_and_await_replies')
1386+ mock_send_and_await.return_value = {
1387+ DHCPServer('127.0.0.1', '127.0.0.1'),
1388+ DHCPServer('127.1.1.1', '127.2.2.2'),
1389+ }
1390+ yield monitor.run()
1391+ self.assertThat(mock_send_and_await, MockCallsMatch(call()))
1392+ self.assertThat(logger.output, DocTestMatches(
1393+ "External DHCP server(s) discovered on interface 'lo': 127.0.0.1, "
1394+ "127.1.1.1 (via 127.2.2.2)"))
1395+ self.assertThat(monitor.dhcp_servers, Equals(
1396+ {"127.0.0.1", "127.1.1.1"}))
1397+ self.assertThat(monitor.dhcp_addresses, Equals(
1398+ {"127.0.0.1", "127.2.2.2"}))
1399+
1400+ @inlineCallbacks
1401+ def test__run_skips_logging_if_no_servers_found(self):
1402+ logger = self.useFixture(TwistedLoggerFixture())
1403+ monitor = DHCPRequestMonitor('lo')
1404+ mock_send_and_await = self.patch(
1405+ monitor, 'send_requests_and_await_replies')
1406+ mock_send_and_await.return_value = {}
1407+ yield monitor.run()
1408+ self.assertThat(mock_send_and_await, MockCallsMatch(call()))
1409+ self.assertThat(logger.output, DocTestMatches(""))
1410+
1411+ @inlineCallbacks
1412+ def test__run_via_probe_interface_returns_servers(self):
1413+ mock_send_and_await = self.patch(
1414+ DHCPRequestMonitor, 'send_requests_and_await_replies')
1415+ mock_send_and_await.return_value = {
1416+ DHCPServer('127.0.0.1', '127.0.0.1'),
1417+ DHCPServer('127.1.1.1', '127.2.2.2'),
1418+ }
1419+ result = yield probe_interface('lo')
1420+ self.assertThat(mock_send_and_await, MockCallsMatch(call()))
1421+ self.assertThat(result, Equals({"127.0.0.1", "127.1.1.1"}))
1422
1423=== modified file 'src/provisioningserver/rackdservices/dhcp_probe_service.py'
1424--- src/provisioningserver/rackdservices/dhcp_probe_service.py 2016-04-11 16:23:26 +0000
1425+++ src/provisioningserver/rackdservices/dhcp_probe_service.py 2016-10-05 01:31:45 +0000
1426@@ -7,10 +7,10 @@
1427 "DHCPProbeService",
1428 ]
1429
1430-
1431 from datetime import timedelta
1432 import socket
1433
1434+from provisioningserver.config import is_dev_environment
1435 from provisioningserver.dhcp.detect import probe_interface
1436 from provisioningserver.logger.log import get_maas_logger
1437 from provisioningserver.rpc.exceptions import NoConnectionsAvailable
1438@@ -21,15 +21,19 @@
1439 retries,
1440 )
1441 from twisted.application.internet import TimerService
1442-from twisted.internet.defer import inlineCallbacks
1443+from twisted.internet.defer import (
1444+ inlineCallbacks,
1445+ maybeDeferred,
1446+)
1447 from twisted.internet.threads import deferToThread
1448 from twisted.protocols.amp import UnhandledCommand
1449+from twisted.python import log
1450
1451
1452 maaslog = get_maas_logger("dhcp.probe")
1453
1454
1455-class DHCPProbeService(TimerService, object):
1456+class DHCPProbeService(TimerService):
1457 """Service to probe for DHCP servers on the rack controller interface's.
1458
1459 Built on top of Twisted's `TimerService`.
1460@@ -46,6 +50,9 @@
1461 self.clock = reactor
1462 self.client_service = client_service
1463
1464+ def log(self, string):
1465+ log.msg(string, system=type(self).__name__)
1466+
1467 def _get_interfaces(self):
1468 """Return the interfaces for this rack controller."""
1469 d = deferToThread(get_all_interfaces_definition)
1470@@ -81,10 +88,7 @@
1471 return d
1472
1473 @inlineCallbacks
1474- def probe_dhcp(self):
1475- """Find all the interfaces on this rack controller and probe for
1476- DHCP servers.
1477- """
1478+ def _tryGetClient(self):
1479 client = None
1480 for elapsed, remaining, wait in retries(15, 5, self.clock):
1481 try:
1482@@ -92,31 +96,46 @@
1483 break
1484 except NoConnectionsAvailable:
1485 yield pause(wait, self.clock)
1486- else:
1487+ return client
1488+
1489+ @inlineCallbacks
1490+ def probe_dhcp(self):
1491+ """Find all the interfaces on this rack controller and probe for
1492+ DHCP servers.
1493+ """
1494+ client = yield self._tryGetClient()
1495+ if client is None:
1496 maaslog.error(
1497- "Can't initiate DHCP probe, no RPC connection to region.")
1498+ "Can't initiate DHCP probe; no RPC connection to region.")
1499 return
1500
1501 # Iterate over interfaces and probe each one.
1502 interfaces = yield self._get_interfaces()
1503+ self.log(
1504+ "Probe for external DHCP servers started on interfaces: %s." % (
1505+ ', '.join(interfaces)))
1506 for interface in interfaces:
1507 try:
1508- servers = yield deferToThread(probe_interface, interface)
1509- except socket.error:
1510- maaslog.error(
1511- "Failed to probe sockets; did you configure authbind as "
1512- "per HACKING.txt?")
1513- break
1514+ servers = yield maybeDeferred(probe_interface, interface)
1515+ except socket.error as e:
1516+ error = (
1517+ "Failed to probe for external DHCP servers on interface "
1518+ "'%s'." % interface)
1519+ if is_dev_environment():
1520+ error += " (Did you configure authbind per HACKING.txt?)"
1521+ log.err(e, error, system=type(self).__name__)
1522+ continue
1523 else:
1524 if len(servers) > 0:
1525- # Only send one, if it gets cleared out then the
1526- # next detection pass will send a different one, if it
1527- # still exists.
1528+ # XXX For now, only send the region one server, since
1529+ # it can only track one per VLAN (this could be considered
1530+ # a bug).
1531 yield self._inform_region_of_dhcp(
1532 client, interface, servers.pop())
1533 else:
1534 yield self._inform_region_of_dhcp(
1535 client, interface, None)
1536+ self.log("External DHCP probe complete.")
1537
1538 @inlineCallbacks
1539 def try_probe_dhcp(self):
1540
1541=== modified file 'src/provisioningserver/rackdservices/tests/test_dhcp_probe_service.py'
1542--- src/provisioningserver/rackdservices/tests/test_dhcp_probe_service.py 2016-07-30 01:17:54 +0000
1543+++ src/provisioningserver/rackdservices/tests/test_dhcp_probe_service.py 2016-10-05 01:31:45 +0000
1544@@ -7,7 +7,6 @@
1545
1546
1547 from unittest.mock import (
1548- call,
1549 Mock,
1550 sentinel,
1551 )
1552@@ -17,7 +16,6 @@
1553 get_mock_calls,
1554 HasLength,
1555 MockCalledOnceWith,
1556- MockCallsMatch,
1557 MockNotCalled,
1558 )
1559 from maastesting.testcase import MAASTwistedRunTest
1560@@ -32,6 +30,7 @@
1561 from provisioningserver.rpc.testing import MockLiveClusterToRegionRPCFixture
1562 from provisioningserver.testing.testcase import PservTestCase
1563 from twisted.internet import defer
1564+from twisted.internet.defer import inlineCallbacks
1565 from twisted.internet.task import Clock
1566
1567
1568@@ -73,28 +72,7 @@
1569 # Now there were two calls.
1570 self.assertThat(get_mock_calls(probe_dhcp), HasLength(2))
1571
1572- def test_probe_is_initiated_in_new_thread(self):
1573- clock = Clock()
1574- interface_name = factory.make_name("eth")
1575- interfaces = {
1576- interface_name: {
1577- "enabled": True,
1578- }
1579- }
1580-
1581- deferToThread = self.patch(dhcp_probe_service, 'deferToThread')
1582- deferToThread.side_effect = [
1583- defer.succeed(interfaces),
1584- defer.succeed(None),
1585- ]
1586- service = DHCPProbeService(Mock(), clock)
1587- service.startService()
1588- self.assertThat(
1589- deferToThread, MockCallsMatch(
1590- call(dhcp_probe_service.get_all_interfaces_definition),
1591- call(dhcp_probe_service.probe_interface, interface_name)))
1592-
1593- @defer.inlineCallbacks
1594+ @inlineCallbacks
1595 def test_exits_gracefully_if_cant_report_foreign_dhcp_server(self):
1596 clock = Clock()
1597 interface_name = factory.make_name("eth")
1598@@ -109,8 +87,10 @@
1599 dhcp_probe_service, 'deferToThread')
1600 deferToThread.side_effect = [
1601 defer.succeed(interfaces),
1602- defer.succeed(['192.168.0.100']),
1603 ]
1604+ probe_interface = self.patch(
1605+ dhcp_probe_service, 'probe_interface')
1606+ probe_interface.return_value = ['192.168.0.100']
1607 protocol, connecting = self.patch_rpc_methods()
1608 self.addCleanup((yield connecting))
1609
1610@@ -154,7 +134,7 @@
1611 "Unable to probe for DHCP servers: %s",
1612 error_message))
1613
1614- @defer.inlineCallbacks
1615+ @inlineCallbacks
1616 def test_reports_foreign_dhcp_servers_to_region(self):
1617 clock = Clock()
1618 interface_name = factory.make_name("eth")
1619@@ -172,9 +152,10 @@
1620 foreign_dhcp_ip = factory.make_ipv4_address()
1621 deferToThread.side_effect = [
1622 defer.succeed(interfaces),
1623- defer.succeed([foreign_dhcp_ip]),
1624 ]
1625-
1626+ probe_interface = self.patch(
1627+ dhcp_probe_service, 'probe_interface')
1628+ probe_interface.return_value = [foreign_dhcp_ip]
1629 client = getRegionClient()
1630 rpc_service = Mock()
1631 rpc_service.getClient.return_value = client
1632@@ -192,7 +173,7 @@
1633 interface_name=interface_name,
1634 dhcp_ip=foreign_dhcp_ip))
1635
1636- @defer.inlineCallbacks
1637+ @inlineCallbacks
1638 def test_reports_lack_of_foreign_dhcp_servers_to_region(self):
1639 clock = Clock()
1640 interface_name = factory.make_name("eth")
1641@@ -209,8 +190,10 @@
1642 dhcp_probe_service, 'deferToThread')
1643 deferToThread.side_effect = [
1644 defer.succeed(interfaces),
1645- defer.succeed([]),
1646 ]
1647+ probe_interface = self.patch(
1648+ dhcp_probe_service, 'probe_interface')
1649+ probe_interface.return_value = []
1650
1651 client = getRegionClient()
1652 rpc_service = Mock()
1653
1654=== modified file 'src/provisioningserver/rackdservices/tests/test_networks_monitoring_service.py'
1655--- src/provisioningserver/rackdservices/tests/test_networks_monitoring_service.py 2016-09-14 17:19:51 +0000
1656+++ src/provisioningserver/rackdservices/tests/test_networks_monitoring_service.py 2016-10-05 01:31:45 +0000
1657@@ -27,7 +27,7 @@
1658
1659 class TestRackNetworksMonitoringService(MAASTestCase):
1660
1661- run_tests_with = MAASTwistedRunTest.make_factory(debug=True, timeout=5)
1662+ run_tests_with = MAASTwistedRunTest.make_factory(debug=False, timeout=5)
1663
1664 @inlineCallbacks
1665 def test_runs_refresh_first_time(self):
1666
1667=== added file 'src/provisioningserver/utils/dhcp.py'
1668--- src/provisioningserver/utils/dhcp.py 1970-01-01 00:00:00 +0000
1669+++ src/provisioningserver/utils/dhcp.py 2016-10-05 01:31:45 +0000
1670@@ -0,0 +1,305 @@
1671+# Copyright 2016 Canonical Ltd. This software is licensed under the
1672+# GNU Affero General Public License version 3 (see the file LICENSE).
1673+
1674+"""Utilities for working with DHCP packets."""
1675+
1676+__all__ = [
1677+ "DHCP",
1678+ "add_arguments",
1679+ "run"
1680+]
1681+
1682+from collections import namedtuple
1683+from datetime import datetime
1684+from io import BytesIO
1685+import os
1686+from pprint import pformat
1687+import stat
1688+import struct
1689+import subprocess
1690+import sys
1691+from textwrap import dedent
1692+
1693+from netaddr import IPAddress
1694+from provisioningserver.utils.ethernet import (
1695+ Ethernet,
1696+ ETHERTYPE,
1697+)
1698+from provisioningserver.utils.network import (
1699+ bytes_to_ipaddress,
1700+ format_eui,
1701+)
1702+from provisioningserver.utils.pcap import (
1703+ PCAP,
1704+ PCAPError,
1705+)
1706+from provisioningserver.utils.script import ActionScriptError
1707+from provisioningserver.utils.tcpip import (
1708+ IPv4,
1709+ PROTOCOL,
1710+ UDP,
1711+)
1712+
1713+# The SEEN_AGAIN_THRESHOLD is a time (in seconds) that determines how often
1714+# to report (IP, MAC) bindings that have been seen again (or "REFRESHED").
1715+# While it is important for MAAS to know about "NEW" and "MOVED" bindings
1716+# immediately, "REFRESHED" bindings occur too often to be useful, and
1717+# are thus throttled by this value.
1718+SEEN_AGAIN_THRESHOLD = 600
1719+
1720+# Definitions for DHCP packet used with `struct`.
1721+# See https://tools.ietf.org/html/rfc2131#section-2 for packet format.
1722+DHCP_PACKET = '!BBBBLHHLLLL16s64s128sBBBB'
1723+DHCPPacket = namedtuple('DHCPPacket', (
1724+ 'op',
1725+ 'htype',
1726+ 'len',
1727+ 'hops',
1728+ 'xid',
1729+ 'secs',
1730+ 'flags',
1731+ 'ciaddr',
1732+ 'yiaddr',
1733+ 'siaddr',
1734+ 'giaddr',
1735+ 'chaddr',
1736+ 'sname',
1737+ 'file',
1738+ 'cookie1',
1739+ 'cookie2',
1740+ 'cookie3',
1741+ 'cookie4',
1742+))
1743+
1744+# This is the size of the struct; DHCP options are not included here.
1745+SIZEOF_DHCP_PACKET = 240
1746+
1747+
1748+class InvalidDHCPPacket(Exception):
1749+ """Raised internally when a DHCP packet is not valid."""
1750+
1751+
1752+class DHCP:
1753+ """Representation of a DHCP packet."""
1754+
1755+ def __init__(self, pkt_bytes: bytes):
1756+ """
1757+ Create a DHCP packet, given the specified upper-layer packet bytes.
1758+
1759+ :param pkt_bytes: The input bytes of the DHCP packet.
1760+ :type pkt_bytes: bytes
1761+ """
1762+ super().__init__()
1763+ self.valid = None
1764+ self.invalid_reason = None
1765+ self.options = None
1766+ if len(pkt_bytes) < SIZEOF_DHCP_PACKET:
1767+ self.valid = False
1768+ self.invalid_reason = "Truncated DHCP packet."
1769+ return
1770+ packet = DHCPPacket._make(
1771+ struct.unpack(DHCP_PACKET, pkt_bytes[0:SIZEOF_DHCP_PACKET]))
1772+ # https://tools.ietf.org/html/rfc2131#section-3
1773+ expected_cookie = (99, 130, 83, 99)
1774+ actual_cookie = (
1775+ packet.cookie1, packet.cookie2, packet.cookie3, packet.cookie4)
1776+ if expected_cookie != actual_cookie:
1777+ self.valid = False
1778+ self.invalid_reason = "Invalid DHCP cookie."
1779+ return
1780+ self.packet = packet
1781+ option_bytes = pkt_bytes[SIZEOF_DHCP_PACKET:]
1782+ try:
1783+ self.options = {
1784+ option: value
1785+ for option, value in self._parse_options(option_bytes)
1786+ }
1787+ except InvalidDHCPPacket as exception:
1788+ self.valid = False
1789+ self.invalid_reason = str(exception)
1790+ if self.valid is None:
1791+ self.valid = True
1792+
1793+ def _parse_options(self, option_bytes: bytes):
1794+ """Yields tuples of DHCP options found in the given `bytes`.
1795+
1796+ :returns: Iterator of (option_code: int, option: bytes).
1797+ :raises InvalidDHCPPacket: If the options are invalid.
1798+ """
1799+ stream = BytesIO(option_bytes)
1800+ while True:
1801+ option_bytes = stream.read(1)
1802+ if len(option_bytes) != 1:
1803+ break
1804+ option_code = option_bytes[0]
1805+ # RFC 1533 (https://tools.ietf.org/html/rfc1533#section-3) defines
1806+ # 255 as the "end option" and 0 as the "pad option"; both are one
1807+ # byte in length.
1808+ if option_code == 255:
1809+ break
1810+ if option_code == 0:
1811+ continue
1812+ # Each option field is a one-byte quantity indicating how many
1813+ # bytes are expected to follow.
1814+ length_bytes = stream.read(1)
1815+ if len(length_bytes) != 1:
1816+ raise InvalidDHCPPacket(
1817+ "Truncated length field in DHCP option.")
1818+ option_length = length_bytes[0]
1819+ option_value = stream.read(option_length)
1820+ if len(option_value) != option_length:
1821+ raise InvalidDHCPPacket(
1822+ "Truncated DHCP option value.")
1823+ yield option_code, option_value
1824+
1825+ def is_valid(self):
1826+ return self.valid
1827+
1828+ @property
1829+ def server_identifier(self) -> IPAddress:
1830+ """Returns the DHCP server identifier option.
1831+
1832+ This returns the IP address of the DHCP server.
1833+
1834+ :return: netaddr.IPAddress
1835+ """
1836+ server_identifier_bytes = self.options.get(54, None)
1837+ if server_identifier_bytes is not None:
1838+ return bytes_to_ipaddress(server_identifier_bytes)
1839+ return None
1840+
1841+ def write(self, out=sys.stdout):
1842+ """Output text-based details about this DHCP packet to the specified
1843+ file or stream.
1844+
1845+ :param out: An object with `write(str)` and `flush()` methods.
1846+ """
1847+ packet = pformat(self.packet)
1848+ out.write(packet)
1849+ out.write('\n')
1850+ options = pformat(self.options)
1851+ out.write(options)
1852+ out.write("\nServer identifier: %s\n\n" % self.server_identifier)
1853+ out.flush()
1854+
1855+
1856+def observe_dhcp_packets(input=sys.stdin.buffer, out=sys.stdout):
1857+ """Read stdin and look for tcpdump binary DHCP output.
1858+
1859+ :param input: Stream to read PCAP data from.
1860+ :type input: a file or stream supporting `read(int)`
1861+ :param out: Stream to write to.
1862+ :type input: a file or stream supporting `write(str)` and `flush()`.
1863+ """
1864+ try:
1865+ pcap = PCAP(input)
1866+ if pcap.global_header.data_link_type != 1:
1867+ # Not an Ethernet interface. Need to exit here, because our
1868+ # assumptions about the link layer header won't be correct.
1869+ return 4
1870+ for header, packet in pcap:
1871+ # Interpret Layer 2
1872+ ethernet = Ethernet(packet, time=header.timestamp_seconds)
1873+ out.write(str(datetime.now()))
1874+ out.write("\n")
1875+ if not ethernet.is_valid():
1876+ out.write("Invalid Ethernet packet.\n\n")
1877+ out.flush()
1878+ # Ignore packets with a truncated Ethernet header.
1879+ continue
1880+ if ethernet.ethertype != ETHERTYPE.IPV4:
1881+ out.write("Invalid ethertype; expected %04x, got %04x.\n\n" % (
1882+ ethernet.ethertype, ETHERTYPE.IPV4))
1883+ out.flush()
1884+ # Ignore non-IPv4 packets.
1885+ continue
1886+ # Interpret Layer 3
1887+ ipv4 = IPv4(ethernet.payload)
1888+ if not ipv4.is_valid():
1889+ out.write(ipv4.invalid_reason)
1890+ out.write("\n\n")
1891+ out.flush()
1892+ continue
1893+ if ipv4.packet.protocol != PROTOCOL.UDP:
1894+ # Ignore non-IPv4 packets.
1895+ out.write("Invalid IPv4 protocol; expected %d, got %d.\n\n" % (
1896+ ipv4.packet.protocol, PROTOCOL.UDP))
1897+ out.flush()
1898+ continue
1899+ # Interpret Layer 4
1900+ udp = UDP(ipv4.payload)
1901+ if not udp.is_valid():
1902+ out.write(udp.invalid_reason)
1903+ out.write("\n\n")
1904+ out.flush()
1905+ continue
1906+ dhcp = DHCP(udp.payload)
1907+ if not dhcp.is_valid():
1908+ out.write(dhcp.invalid_reason)
1909+ out.write(
1910+ " Source MAC address: %s\n" % format_eui(ethernet.src_eui))
1911+ out.write(
1912+ "Destination MAC address: %s\n" % format_eui(ethernet.dst_eui))
1913+ if ethernet.vid is not None:
1914+ out.write(" Seen on 802.1Q VID: %s\n" % ethernet.vid)
1915+ out.write(" Source IP address: %s\n" % ipv4.src_ip)
1916+ out.write(" Destination IP address: %s\n" % ipv4.dst_ip)
1917+ dhcp.write(out=out)
1918+ out.flush()
1919+ except EOFError:
1920+ # Capture aborted before it could even begin. Note that this does not
1921+ # occur if the end-of-stream occurs normally. (In that case, the
1922+ # program will just exit.)
1923+ return 3
1924+ except PCAPError:
1925+ # Capture aborted due to an I/O error.
1926+ return 2
1927+ return None
1928+
1929+
1930+def add_arguments(parser):
1931+ """Add this command's options to the `ArgumentParser`.
1932+
1933+ Specified by the `ActionScript` interface.
1934+ """
1935+ parser.description = dedent("""\
1936+ Observes DHCP traffic specified interface.
1937+ """)
1938+ parser.add_argument(
1939+ 'interface', type=str, nargs='?',
1940+ help="Ethernet interface from which to capture traffic. Optional if "
1941+ "an input file is specified.")
1942+ parser.add_argument(
1943+ '-i', '--input-file', type=str, required=False,
1944+ help="File to read PCAP output from. Use - for stdin. Default is to "
1945+ "call `sudo /usr/lib/maas/maas-dhcp-monitor` to get input.")
1946+
1947+
1948+def run(args, output=sys.stdout, stdin=sys.stdin,
1949+ stdin_buffer=sys.stdin.buffer):
1950+ """Observe an Ethernet interface and print DHCP packets."""
1951+ network_monitor = None
1952+ if args.input_file is None:
1953+ if args.interface is None:
1954+ raise ActionScriptError("Required argument: interface")
1955+ network_monitor = subprocess.Popen(
1956+ ["sudo", "--non-interactive", "/usr/lib/maas/maas-dhcp-monitor",
1957+ args.interface], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
1958+ stderr=subprocess.DEVNULL
1959+ )
1960+ infile = network_monitor.stdout
1961+ else:
1962+ if args.input_file == '-':
1963+ mode = os.fstat(stdin.fileno()).st_mode
1964+ if not stat.S_ISFIFO(mode):
1965+ raise ActionScriptError("Expected stdin to be a pipe.")
1966+ infile = stdin_buffer
1967+ else:
1968+ infile = open(args.input_file, "rb")
1969+ return_code = observe_dhcp_packets(input=infile, out=output)
1970+ if return_code is not None:
1971+ raise SystemExit(return_code)
1972+ if network_monitor is not None:
1973+ return_code = network_monitor.poll()
1974+ if return_code is not None:
1975+ raise SystemExit(return_code)
1976
1977=== modified file 'src/provisioningserver/utils/ethernet.py'
1978--- src/provisioningserver/utils/ethernet.py 2016-07-17 22:17:00 +0000
1979+++ src/provisioningserver/utils/ethernet.py 2016-10-05 01:31:45 +0000
1980@@ -10,6 +10,7 @@
1981 from collections import namedtuple
1982 import struct
1983
1984+from netaddr import EUI
1985 from provisioningserver.utils.network import (
1986 bytes_to_int,
1987 hex_str_to_bytes,
1988@@ -33,6 +34,7 @@
1989
1990 class ETHERTYPE:
1991 """Enumeration to represent ethertypes that MAAS needs to understand."""
1992+ IPV4 = hex_str_to_bytes('0800')
1993 ARP = hex_str_to_bytes('0806')
1994 VLAN = hex_str_to_bytes('8100')
1995
1996@@ -93,6 +95,16 @@
1997 self.dst_mac = packet.dst_mac
1998 self.time = time
1999
2000+ @property
2001+ def src_eui(self):
2002+ """Returns a netaddr.EUI representing the source MAC address."""
2003+ return EUI(bytes_to_int(self.src_mac))
2004+
2005+ @property
2006+ def dst_eui(self):
2007+ """Returns a netaddr.EUI representing the destination MAC address."""
2008+ return EUI(bytes_to_int(self.dst_mac))
2009+
2010 def is_valid(self):
2011 """Returns True if this is a valid Ethernet packet, False otherwise."""
2012 return self.valid
2013
2014=== modified file 'src/provisioningserver/utils/network.py'
2015--- src/provisioningserver/utils/network.py 2016-10-04 04:36:39 +0000
2016+++ src/provisioningserver/utils/network.py 2016-10-05 01:31:45 +0000
2017@@ -32,6 +32,7 @@
2018 getaddrinfo,
2019 IPPROTO_TCP,
2020 )
2021+import struct
2022
2023 from netaddr import (
2024 EUI,
2025@@ -726,7 +727,7 @@
2026
2027
2028 def inet_ntop(value):
2029- """Convert IPv4 and IPv6 addresses from binary to text form.
2030+ """Convert IPv4 and IPv6 addresses from integer to text form.
2031 (See also inet_ntop(3), the C function with the same name and function.)"""
2032 return str(IPAddress(value))
2033
2034@@ -784,9 +785,22 @@
2035
2036
2037 def ipv4_to_bytes(ipv4_address):
2038+ """Converts the specified IPv4 address (in text or integer form) to bytes.
2039+ """
2040 return bytes.fromhex("%08x" % IPAddress(ipv4_address).value)
2041
2042
2043+def bytes_to_ipaddress(ip_address_bytes):
2044+ if len(ip_address_bytes) == 4:
2045+ return IPAddress(struct.unpack('!L', ip_address_bytes)[0])
2046+ if len(ip_address_bytes) == 16:
2047+ most_significant, least_significant = struct.unpack(
2048+ "!QQ", ip_address_bytes)
2049+ return IPAddress((most_significant << 64) | least_significant)
2050+ else:
2051+ raise ValueError("Invalid IP address size: expected 4 or 16 bytes.")
2052+
2053+
2054 def format_eui(eui):
2055 """Returns the specified netaddr.EUI object formatted in the MAAS style."""
2056 return str(eui).replace('-', ':').lower()
2057
2058=== added file 'src/provisioningserver/utils/tcpip.py'
2059--- src/provisioningserver/utils/tcpip.py 1970-01-01 00:00:00 +0000
2060+++ src/provisioningserver/utils/tcpip.py 2016-10-05 01:31:45 +0000
2061@@ -0,0 +1,162 @@
2062+# Copyright 2016 Canonical Ltd. This software is licensed under the
2063+# GNU Affero General Public License version 3 (see the file LICENSE).
2064+
2065+"""Utilities for working with TCP/IP packets. (That is, layers 3 and 4.)"""
2066+
2067+__all__ = [
2068+ "IPv4",
2069+ "UDP",
2070+]
2071+
2072+from collections import namedtuple
2073+import struct
2074+
2075+from netaddr import IPAddress
2076+
2077+# Definitions for IPv4 packets used with `struct`.
2078+# See https://tools.ietf.org/html/rfc791#section-3.1 for more details.
2079+IPV4_PACKET = '!BBHHHBBHLL'
2080+IPv4Packet = namedtuple('IPv4Packet', (
2081+ 'version__ihl',
2082+ 'tos',
2083+ 'total_length',
2084+ 'fragment_id',
2085+ 'flags__fragment_offset',
2086+ 'ttl',
2087+ 'protocol',
2088+ 'header_checksum',
2089+ 'src_ip',
2090+ 'dst_ip',
2091+))
2092+IPV4_HEADER_MIN_LENGTH = 20
2093+
2094+
2095+class PROTOCOL:
2096+ """Enumeration to represent IP protocols that MAAS needs to understand."""
2097+ UDP = 0x11
2098+
2099+
2100+class IPv4:
2101+ """Representation of an IPv4 packet."""
2102+
2103+ def __init__(self, pkt_bytes: bytes):
2104+ """Decodes the specified IPv4 packet.
2105+
2106+ The IP payload will be placed in the `payload` ivar if the packet
2107+ is valid. If the packet is valid, the `valid` ivar will be set to True.
2108+ If the packet is not valid, the `valid` ivar will be set to False, and
2109+ the `invalid_reason` will contain a description of why the packet is
2110+ not valid.
2111+
2112+ This class does not validate the header checksum, and as such, should
2113+ only be used for testing.
2114+
2115+ :param pkt_bytes: The input bytes of the IPv4 packet.
2116+ """
2117+ self.valid = True
2118+ self.invalid_reason = None
2119+ if len(pkt_bytes) < IPV4_HEADER_MIN_LENGTH:
2120+ self.valid = False
2121+ self.invalid_reason = (
2122+ "Truncated IPv4 header; need at least %d bytes." % (
2123+ IPV4_HEADER_MIN_LENGTH))
2124+ return
2125+ packet = IPv4Packet._make(
2126+ struct.unpack(
2127+ IPV4_PACKET, pkt_bytes[0:IPV4_HEADER_MIN_LENGTH]))
2128+ self.packet = packet
2129+ # Mask out the version_ihl field to get the IP version and IHL
2130+ # (Internet Header Length) separately.
2131+ self.version = (packet.version__ihl & 0xF0) >> 4
2132+ # The IHL is a count of 4-bit words that comprise the header.
2133+ self.ihl = (packet.version__ihl & 0xF) * 4
2134+ if self.version != 4:
2135+ self.valid = False
2136+ self.invalid_reason = (
2137+ "Invalid version field; expected IPv4, got IPv%d." % (
2138+ self.version))
2139+ return
2140+ if self.ihl < 20:
2141+ self.invalid_reason = (
2142+ "Invalid IPv4 IHL field; expected at least 20 bytes; got %d "
2143+ "bytes." % self.ihl)
2144+ self.valid = False
2145+ return
2146+ if len(pkt_bytes) < self.ihl:
2147+ self.valid = False
2148+ self.invalid_reason = (
2149+ "Truncated IPv4 header; IHL indicates to read %d bytes; got "
2150+ "%d bytes." % (self.ihl, len(pkt_bytes)))
2151+ return
2152+ # Everything beyond the IHL is the upper-layer payload. (No need to
2153+ # understand IP options at this time.)
2154+ self.payload = pkt_bytes[self.ihl:]
2155+
2156+ @property
2157+ def src_ip(self):
2158+ return IPAddress(self.packet.src_ip)
2159+
2160+ @property
2161+ def dst_ip(self):
2162+ return IPAddress(self.packet.dst_ip)
2163+
2164+ def is_valid(self):
2165+ return self.valid
2166+
2167+
2168+# Definitions for UDP packets used with `struct`.
2169+# https://tools.ietf.org/html/rfc768
2170+UDP_PACKET = '!HHHH'
2171+UDPPacket = namedtuple('IPPacket', (
2172+ 'src_port',
2173+ 'dst_port',
2174+ 'length',
2175+ 'checksum',
2176+))
2177+UDP_HEADER_LENGTH = 8
2178+
2179+
2180+class UDP:
2181+ """Representation of a UDP packet."""
2182+
2183+ def __init__(self, pkt_bytes: bytes):
2184+ """Decodes the specified UDP packet.
2185+
2186+ The UDP payload will be placed in the `payload` ivar if the packet
2187+ is valid. If the packet is valid, the `valid` ivar will be set to True.
2188+ If the packet is not valid, the `valid` ivar will be set to False, and
2189+ the `invalid_reason` will contain a description of why the packet is
2190+ not valid.
2191+
2192+ This class does not validate the UDP checksum, and as such, should only
2193+ be used for testing.
2194+ """
2195+ self.valid = True
2196+ self.invalid_reason = None
2197+ if len(pkt_bytes) < UDP_HEADER_LENGTH:
2198+ self.valid = False
2199+ self.invalid_reason = (
2200+ "Truncated UDP header; need at least %d bytes." % (
2201+ UDP_HEADER_LENGTH))
2202+ return
2203+ packet = UDPPacket._make(
2204+ struct.unpack(
2205+ UDP_PACKET, pkt_bytes[0:UDP_HEADER_LENGTH]))
2206+ self.packet = packet
2207+ if packet.length < UDP_HEADER_LENGTH:
2208+ self.valid = False
2209+ self.invalid_reason = (
2210+ "Invalid UDP packet; got length of %d bytes; expected at "
2211+ "least %d bytes. " % (packet.length, UDP_HEADER_LENGTH))
2212+ return
2213+ # UDP length includes UDP header, so subtract it to get payload length.
2214+ payload_length = packet.length - UDP_HEADER_LENGTH
2215+ self.payload = pkt_bytes[UDP_HEADER_LENGTH:]
2216+ if len(self.payload) != payload_length:
2217+ self.valid = False
2218+ self.invalid_reason = (
2219+ "UDP packet truncated; expected %d bytes; got %d bytes." % (
2220+ payload_length, len(self.payload)))
2221+
2222+ def is_valid(self):
2223+ return self.valid
2224
2225=== added file 'src/provisioningserver/utils/tests/test_dhcp.py'
2226--- src/provisioningserver/utils/tests/test_dhcp.py 1970-01-01 00:00:00 +0000
2227+++ src/provisioningserver/utils/tests/test_dhcp.py 2016-10-05 01:31:45 +0000
2228@@ -0,0 +1,67 @@
2229+# Copyright 2016 Canonical Ltd. This software is licensed under the
2230+# GNU Affero General Public License version 3 (see the file LICENSE).
2231+
2232+"""Tests for ``provisioningserver.utils.dhcp``."""
2233+
2234+__all__ = []
2235+
2236+from maastesting.factory import factory
2237+from maastesting.matchers import DocTestMatches
2238+from maastesting.testcase import MAASTestCase
2239+from netaddr import IPAddress
2240+from provisioningserver.utils.dhcp import DHCP
2241+from testtools.matchers import (
2242+ Equals,
2243+ Is,
2244+)
2245+
2246+
2247+class TestDHCP(MAASTestCase):
2248+
2249+ def test__is_valid_returns_false_for_truncated_packet(self):
2250+ packet = factory.make_dhcp_packet(truncated=True)
2251+ dhcp = DHCP(packet)
2252+ self.assertThat(dhcp.is_valid(), Equals(False))
2253+ self.assertThat(dhcp.invalid_reason, DocTestMatches(
2254+ "Truncated DHCP packet."))
2255+
2256+ def test__is_valid_returns_false_for_invalid_cookie(self):
2257+ packet = factory.make_dhcp_packet(bad_cookie=True)
2258+ dhcp = DHCP(packet)
2259+ self.assertThat(dhcp.is_valid(), Equals(False))
2260+ self.assertThat(dhcp.invalid_reason, DocTestMatches(
2261+ "Invalid DHCP cookie."))
2262+
2263+ def test__is_valid_returns_false_for_truncated_option_length(self):
2264+ packet = factory.make_dhcp_packet(truncated_option_length=True)
2265+ dhcp = DHCP(packet)
2266+ self.assertThat(dhcp.is_valid(), Equals(False))
2267+ self.assertThat(dhcp.invalid_reason, DocTestMatches(
2268+ "Truncated length field in DHCP option."))
2269+
2270+ def test__is_valid_returns_false_for_truncated_option_value(self):
2271+ packet = factory.make_dhcp_packet(truncated_option_value=True)
2272+ dhcp = DHCP(packet)
2273+ self.assertThat(dhcp.is_valid(), Equals(False))
2274+ self.assertThat(dhcp.invalid_reason, DocTestMatches(
2275+ "Truncated DHCP option value."))
2276+
2277+ def test__is_valid_return_true_for_valid_packet(self):
2278+ packet = factory.make_dhcp_packet()
2279+ dhcp = DHCP(packet)
2280+ self.assertThat(dhcp.is_valid(), Equals(True))
2281+
2282+ def test__returns_server_identifier_if_included(self):
2283+ server_ip = factory.make_ip_address(ipv6=False)
2284+ packet = factory.make_dhcp_packet(
2285+ include_server_identifier=True, server_ip=server_ip)
2286+ dhcp = DHCP(packet)
2287+ self.assertThat(dhcp.is_valid(), Equals(True))
2288+ self.assertThat(dhcp.server_identifier, Equals(IPAddress(server_ip)))
2289+
2290+ def test__server_identifier_none_if_not_included(self):
2291+ packet = factory.make_dhcp_packet(
2292+ include_server_identifier=False)
2293+ dhcp = DHCP(packet)
2294+ self.assertThat(dhcp.is_valid(), Equals(True))
2295+ self.assertThat(dhcp.server_identifier, Is(None))
2296
2297=== added file 'src/provisioningserver/utils/tests/test_tcpip.py'
2298--- src/provisioningserver/utils/tests/test_tcpip.py 1970-01-01 00:00:00 +0000
2299+++ src/provisioningserver/utils/tests/test_tcpip.py 2016-10-05 01:31:45 +0000
2300@@ -0,0 +1,165 @@
2301+# Copyright 2016 Canonical Ltd. This software is licensed under the
2302+# GNU Affero General Public License version 3 (see the file LICENSE).
2303+
2304+"""Tests for ``provisioningserver.utils.tcpip``."""
2305+
2306+__all__ = []
2307+
2308+from maastesting.factory import factory
2309+from maastesting.matchers import DocTestMatches
2310+from maastesting.testcase import MAASTestCase
2311+from provisioningserver.utils.network import hex_str_to_bytes
2312+from provisioningserver.utils.tcpip import (
2313+ IPv4,
2314+ UDP,
2315+)
2316+from testtools.matchers import Equals
2317+
2318+
2319+def make_ipv4_packet(
2320+ total_length=None, version=None, ihl=None, payload=None,
2321+ truncated=False):
2322+ """Construct an IPv4 packet using the specified parameters.
2323+
2324+ If the specified `vid` is not None, it is interpreted as an integer VID,
2325+ and the appropriate Ethertype fields are adjusted.
2326+ """
2327+ if payload is None:
2328+ payload = b''
2329+ if total_length is None:
2330+ total_length = 20 + len(payload)
2331+ if version is None:
2332+ version = 4
2333+ if ihl is None:
2334+ ihl = 5
2335+ version__ihl = ((version << 4) | ihl).to_bytes(1, "big")
2336+
2337+ ipv4_packet = (
2338+ # Version, IHL
2339+ version__ihl +
2340+ # TOS
2341+ hex_str_to_bytes('00') +
2342+ # Total length in bytes
2343+ total_length.to_bytes(2, "big") +
2344+ # Identification
2345+ hex_str_to_bytes('0000') +
2346+ # Flags, fragment offset
2347+ hex_str_to_bytes('0000') +
2348+ # TTL
2349+ hex_str_to_bytes('00') +
2350+ # Protocol (just make it UDP for now)
2351+ hex_str_to_bytes('11') +
2352+ # Header checksum
2353+ hex_str_to_bytes('0000') +
2354+ # Source address
2355+ hex_str_to_bytes('00000000') +
2356+ # Destination address
2357+ hex_str_to_bytes('00000000')
2358+ # No options.
2359+ )
2360+ assert len(ipv4_packet) == 20, "Length was %d" % len(ipv4_packet)
2361+ if truncated:
2362+ return ipv4_packet[:19]
2363+ ipv4_packet = ipv4_packet + payload
2364+ return ipv4_packet
2365+
2366+
2367+class TestIPv4(MAASTestCase):
2368+
2369+ def test__parses_ipv4_packet(self):
2370+ payload = factory.make_bytes(48)
2371+ packet = make_ipv4_packet(payload=payload)
2372+ ipv4 = IPv4(packet)
2373+ self.assertThat(ipv4.is_valid(), Equals(True))
2374+ self.assertThat(ipv4.version, Equals(4))
2375+ self.assertThat(ipv4.ihl, Equals(20))
2376+ self.assertThat(ipv4.payload, Equals(payload))
2377+
2378+ def test__fails_for_non_ipv4_packet(self):
2379+ payload = factory.make_bytes(48)
2380+ packet = make_ipv4_packet(payload=payload, version=5)
2381+ ipv4 = IPv4(packet)
2382+ self.assertThat(ipv4.is_valid(), Equals(False))
2383+ self.assertThat(
2384+ ipv4.invalid_reason, DocTestMatches("Invalid version..."))
2385+
2386+ def test__fails_for_bad_ihl(self):
2387+ payload = factory.make_bytes(48)
2388+ packet = make_ipv4_packet(payload=payload, ihl=0)
2389+ ipv4 = IPv4(packet)
2390+ self.assertThat(ipv4.is_valid(), Equals(False))
2391+ self.assertThat(
2392+ ipv4.invalid_reason, DocTestMatches("Invalid IPv4 IHL..."))
2393+
2394+ def test__fails_for_truncated_packet(self):
2395+ packet = make_ipv4_packet(truncated=True)
2396+ ipv4 = IPv4(packet)
2397+ self.assertThat(ipv4.is_valid(), Equals(False))
2398+ self.assertThat(
2399+ ipv4.invalid_reason, DocTestMatches("Truncated..."))
2400+
2401+
2402+def make_udp_packet(
2403+ total_length=None, payload=None, truncated_header=False,
2404+ truncated_payload=False):
2405+ """Construct an IPv4 packet using the specified parameters.
2406+
2407+ If the specified `vid` is not None, it is interpreted as an integer VID,
2408+ and the appropriate Ethertype fields are adjusted.
2409+ """
2410+ if payload is None:
2411+ payload = b''
2412+ if total_length is None:
2413+ total_length = 8 + len(payload)
2414+
2415+ udp_packet = (
2416+ # Source port
2417+ hex_str_to_bytes('0000') +
2418+ # Destination port
2419+ hex_str_to_bytes('0000') +
2420+ # UDP header length + payload length
2421+ total_length.to_bytes(2, "big") +
2422+ # Checksum
2423+ hex_str_to_bytes('0000')
2424+ )
2425+ assert len(udp_packet) == 8, "Length was %d" % len(udp_packet)
2426+ if truncated_header:
2427+ return udp_packet[:7]
2428+ udp_packet = udp_packet + payload
2429+ if truncated_payload:
2430+ return udp_packet[:-1]
2431+ return udp_packet
2432+
2433+
2434+class TestUDP(MAASTestCase):
2435+
2436+ def test__parses_udp_packet(self):
2437+ payload = factory.make_bytes(48)
2438+ packet = make_udp_packet(payload=payload)
2439+ udp = UDP(packet)
2440+ self.assertThat(udp.is_valid(), Equals(True))
2441+ self.assertThat(udp.payload, Equals(payload))
2442+
2443+ def test__fails_for_truncated_udp_header(self):
2444+ packet = make_udp_packet(truncated_header=True)
2445+ udp = UDP(packet)
2446+ self.assertThat(udp.is_valid(), Equals(False))
2447+ self.assertThat(
2448+ udp.invalid_reason, DocTestMatches("Truncated UDP header..."))
2449+
2450+ def test__fails_for_bad_length(self):
2451+ payload = factory.make_bytes(48)
2452+ packet = make_udp_packet(total_length=0, payload=payload)
2453+ udp = UDP(packet)
2454+ self.assertThat(udp.is_valid(), Equals(False))
2455+ self.assertThat(
2456+ udp.invalid_reason, DocTestMatches(
2457+ "Invalid UDP packet; got length..."))
2458+
2459+ def test__fails_for_truncated_payload(self):
2460+ payload = factory.make_bytes(48)
2461+ packet = make_udp_packet(truncated_payload=True, payload=payload)
2462+ udp = UDP(packet)
2463+ self.assertThat(udp.is_valid(), Equals(False))
2464+ self.assertThat(
2465+ udp.invalid_reason, DocTestMatches("UDP packet truncated..."))
2466
2467=== added file 'utilities/install-dhcp-observer'
2468--- utilities/install-dhcp-observer 1970-01-01 00:00:00 +0000
2469+++ utilities/install-dhcp-observer 2016-10-05 01:31:45 +0000
2470@@ -0,0 +1,16 @@
2471+#!/bin/bash
2472+
2473+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2474+
2475+echo "Copying /usr/lib/maas/maas-dhcp-monitor..."
2476+sudo mkdir -p /usr/lib/maas
2477+sudo cp "$SCRIPT_DIR/../scripts/maas-dhcp-monitor" /usr/lib/maas/maas-dhcp-monitor
2478+SUDOERS_LINE="$USER ALL= NOPASSWD: /usr/lib/maas/maas-dhcp-monitor"
2479+SUDOERS_FILE=/etc/sudoers.d/99-maas-dev-$USER
2480+echo "Installing sudoers file: $SUDOERS_FILE"
2481+echo "$SUDOERS_LINE" | sudo tee $SUDOERS_FILE
2482+sudo chmod 440 $SUDOERS_FILE
2483+echo "Done. You should now be able to do the following to get raw 'tcpdump' output:"
2484+echo " sudo /usr/lib/maas/maas-dhcp-monitor <interface>"
2485+echo "In addition, the following command should now work in the dev env:"
2486+echo " bin/maas-rack observe-dhcp <interface>"