Merge lp:~mpontillo/maas/interpret-dhcp-packets-correctly--bug-1628645 into lp:~maas-committers/maas/trunk
- interpret-dhcp-packets-correctly--bug-1628645
- Merge into trunk
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 | ||||
Related bugs: |
|
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.
Gavin Panella (allenap) wrote : | # |
Mike Pontillo (mpontillo) wrote : | # |
Thanks for taking the time to look at this monster WIP branch, Gavin. Some replies to your comments below.
Gavin Panella (allenap) : | # |
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 `DHCPRequestMon
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.
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!
Gavin Panella (allenap) : | # |
Preview Diff
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>" |
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.