/usr/lib/python2.7/dist-packages/provisioningserver/dhcp/detect.py is in python-maas-provisioningserver 1.5.4+bzr2294-0ubuntu1.2.
This file is owned by root:root, with mode 0o644.
The actual contents of the file can be viewed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | # Copyright 2013-2014 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Utilities and helpers to help discover DHCP servers on your network."""
from __future__ import (
absolute_import,
print_function,
unicode_literals,
)
str = None
__metaclass__ = type
__all__ = []
from contextlib import contextmanager
import errno
import fcntl
import httplib
import json
from logging import getLogger
from random import randint
import socket
import struct
from urllib2 import (
HTTPError,
URLError,
)
from apiclient.maas_client import (
MAASClient,
MAASDispatcher,
MAASOAuth,
)
from provisioningserver.auth import (
get_recorded_api_credentials,
get_recorded_nodegroup_uuid,
)
from provisioningserver.cluster_config import get_maas_url
logger = getLogger(__name__)
def make_transaction_ID():
"""Generate a random DHCP transaction identifier."""
transaction_id = b''
for _ in range(4):
transaction_id += struct.pack(b'!B', randint(0, 255))
return transaction_id
class DHCPDiscoverPacket:
"""A representation of a DHCP_DISCOVER packet.
:param my_mac: The MAC address to which the dhcp server should respond.
Normally this is the MAC of the interface you're using to send the
request.
"""
def __init__(self, my_mac):
self.transaction_ID = make_transaction_ID()
self.packed_mac = self.string_mac_to_packed(my_mac)
self._build()
@classmethod
def string_mac_to_packed(cls, mac):
"""Convert a string MAC address to 6 hex octets.
:param mac: A MAC address in the format AA:BB:CC:DD:EE:FF
:return: a byte string of length 6
"""
packed = b''
for pair in mac.split(':'):
hex_octet = int(pair, 16)
packed += struct.pack(b'!B', hex_octet)
return packed
def _build(self):
self.packet = b''
self.packet += b'\x01' # Message type: Boot Request (1)
self.packet += b'\x01' # Hardware type: Ethernet
self.packet += b'\x06' # Hardware address length: 6
self.packet += b'\x00' # Hops: 0
self.packet += self.transaction_ID
self.packet += b'\x00\x00' # Seconds elapsed: 0
# Bootp flags: 0x8000 (Broadcast) + reserved flags
self.packet += b'\x80\x00'
self.packet += b'\x00\x00\x00\x00' # Client IP address: 0.0.0.0
self.packet += b'\x00\x00\x00\x00' # Your (client) IP address: 0.0.0.0
self.packet += b'\x00\x00\x00\x00' # Next server IP address: 0.0.0.0
self.packet += b'\x00\x00\x00\x00' # Relay agent IP address: 0.0.0.0
self.packet += self.packed_mac
# Client hardware address padding: 00000000000000000000
self.packet += b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
self.packet += b'\x00' * 67 # Server host name not given
self.packet += b'\x00' * 125 # Boot file name not given
self.packet += b'\x63\x82\x53\x63' # Magic cookie: DHCP
# Option: (t=53,l=1) DHCP Message Type = DHCP Discover
self.packet += b'\x35\x01\x01'
self.packet += b'\x3d\x06' + self.packed_mac
# Option: (t=55,l=3) Parameter Request List
self.packet += b'\x37\x03\x03\x01\x06'
self.packet += b'\xff' # End Option
class DHCPOfferPacket:
"""A representation of a DHCP_OFFER packet."""
def __init__(self, data):
self.transaction_ID = data[4:8]
self.dhcp_server_ID = socket.inet_ntoa(data[245:249])
# UDP ports for the BOOTP protocol. Used for discovery requests.
BOOTP_SERVER_PORT = 67
BOOTP_CLIENT_PORT = 68
# ioctl request for requesting IP address.
SIOCGIFADDR = 0x8915
# ioctl request for requesting hardware (MAC) address.
SIOCGIFHWADDR = 0x8927
def get_interface_MAC(sock, interface):
"""Obtain a network interface's MAC address, as a string."""
ifreq = struct.pack(b'256s', interface.encode('ascii')[:15])
info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, ifreq)
mac = ''.join(['%02x:' % ord(char) for char in info[18:24]])[:-1]
return mac
def get_interface_IP(sock, interface):
"""Obtain an IP address for a network interface, as a string."""
ifreq = struct.pack(
b'16sH14s', interface.encode('ascii')[:15],
socket.AF_INET, b'\x00' * 14)
info = fcntl.ioctl(sock, SIOCGIFADDR, ifreq)
ip = struct.unpack(b'16sH2x4s8x', info)[2]
return socket.inet_ntoa(ip)
@contextmanager
def udp_socket():
"""Open, and later close, a UDP socket."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# We're going to bind to the BOOTP/DHCP client socket, where dhclient may
# also be listening, even if it's operating on a different interface!
# The SO_REUSEADDR option makes this possible.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
yield sock
sock.close()
def request_dhcp(interface):
"""Broadcast a DHCP discovery request. Return DHCP transaction ID."""
with udp_socket() as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
mac = get_interface_MAC(sock, interface)
bind_address = get_interface_IP(sock, interface)
discover = DHCPDiscoverPacket(mac)
sock.bind((bind_address, BOOTP_CLIENT_PORT))
sock.sendto(discover.packet, ('<broadcast>', BOOTP_SERVER_PORT))
return discover.transaction_ID
def receive_offers(transaction_id):
"""Receive DHCP offers. Return set of offering servers."""
servers = set()
with udp_socket() as sock:
# The socket we use for receiving DHCP offers must be bound to IF_ANY.
sock.bind(('', BOOTP_CLIENT_PORT))
try:
while True:
sock.settimeout(3)
data = sock.recv(1024)
offer = DHCPOfferPacket(data)
if offer.transaction_ID == transaction_id:
servers.add(offer.dhcp_server_ID)
except socket.timeout:
# No more offers. Done.
return servers
def probe_dhcp(interface):
"""Look for a DHCP server on the network.
This must be run with provileges to broadcast from the BOOTP port, which
typically requires root. It may fail to bind to that port if a DHCP client
is running on that same interface.
:param interface: Network interface name, e.g. "eth0", attached to the
network you wish to probe.
:return: Set of discovered DHCP servers.
:exception IOError: If the interface does not have an IP address.
"""
# There is a small race window here, after we close the first socket and
# before we bind the second one. Hopefully executing a few lines of code
# will be faster than communication over the network.
# UDP is not reliable at any rate. If detection is important, we should
# send out repeated requests.
transaction_id = request_dhcp(interface)
return receive_offers(transaction_id)
def process_request(client_func, *args, **kwargs):
"""Run a MAASClient query and check for common errors.
:return: None if there is an error, otherwise the decoded response body.
"""
try:
response = client_func(*args, **kwargs)
except (HTTPError, URLError) as e:
logger.error("Failed to contact region controller:\n%s", e)
return None
code = response.getcode()
if code != httplib.OK:
logger.error(
"Failed talking to region controller, it returned:\n%s\n%s",
code, response.read())
return None
try:
raw_data = response.read()
if len(raw_data) > 0:
data = json.loads(raw_data)
else:
return None
except ValueError as e:
logger.error(
"Failed to decode response from region controller:\n%s", e)
return None
return data
def determine_cluster_interfaces(knowledge):
"""Given server knowledge, determine network interfaces on this cluster.
:return: a list of tuples of (interface name, ip) for all interfaces.
:note: this uses an API call and not local probing because the
region controller has the definitive and final say in what does and
doesn't exist.
"""
api_path = (
'api/1.0/nodegroups/%s/interfaces/' % knowledge['nodegroup_uuid'])
oauth = MAASOAuth(*knowledge['api_credentials'])
client = MAASClient(oauth, MAASDispatcher(), knowledge['maas_url'])
interfaces = process_request(client.get, api_path, 'list')
if interfaces is None:
return None
interface_names = sorted(
(interface['interface'], interface['ip'])
for interface in interfaces
if interface['interface'] != '')
return interface_names
def probe_interface(interface, ip):
"""Probe the given interface for DHCP servers.
:param interface: interface as returned from determine_cluster_interfaces
:param ip: ip as returned from determine_cluster_interfaces
:return: A set of IP addresses of detected servers.
:note: Any servers running on the IP address of the local host are
filtered out as they will be the MAAS DHCP server.
"""
try:
servers = probe_dhcp(interface)
except IOError as e:
servers = set()
if e.errno == errno.EADDRNOTAVAIL:
# Errno EADDRNOTAVAIL is "Cannot assign requested address"
# which we need to ignore; it means the interface has no IP
# and there's no need to scan this interface as it's not in
# use.
logger.info(
"Ignoring DHCP scan for %s, it has no IP address", interface)
elif e.errno == errno.ENODEV:
# Errno ENODEV is "no such device". This seems an odd situation
# since we're scanning detected devices, so this is probably
# a bug.
logger.error(
"Ignoring DHCP scan for %s, it no longer exists. Check "
"your cluster interfaces configuration.", interface)
else:
raise
# Using servers.discard(ip) here breaks Mock in the tests, so
# we're creating a copy of the set instead.
results = servers.difference([ip])
return results
def update_region_controller(knowledge, interface, server):
"""Update the region controller with the status of the probe.
:param knowledge: dictionary of server info
:param interface: name of interface, e.g. eth0
:param server: IP address of detected DHCP server, or None
"""
api_path = 'api/1.0/nodegroups/%s/interfaces/%s/' % (
knowledge['nodegroup_uuid'], interface)
oauth = MAASOAuth(*knowledge['api_credentials'])
client = MAASClient(oauth, MAASDispatcher(), knowledge['maas_url'])
if server is None:
server = ''
process_request(
client.post, api_path, 'report_foreign_dhcp', foreign_dhcp_ip=server)
def periodic_probe_task():
"""Probe for DHCP servers and set NodeGroupInterface.foriegn_dhcp.
This should be run periodically so that the database has an up-to-date
view of any rogue DHCP servers on the network.
NOTE: This uses blocking I/O with sequential polling of interfaces, and
hence doesn't scale well. It's a future improvement to make
to throw it in parallel threads or async I/O.
"""
# Items that the server must have sent us before we can do this.
knowledge = {
'maas_url': get_maas_url(),
'api_credentials': get_recorded_api_credentials(),
'nodegroup_uuid': get_recorded_nodegroup_uuid(),
}
if None in knowledge.values():
# The MAAS server hasn't sent us enough information for us to do
# this yet. Leave it for another time.
logger.info(
"Not probing for rogue DHCP servers; not all required knowledge "
"received from server yet. "
"Missing: %s" % ', '.join(sorted(
name for name, value in knowledge.items() if value is None)))
return
# Determine all the active interfaces on this cluster (nodegroup).
interfaces = determine_cluster_interfaces(knowledge)
if interfaces is None:
logger.info("No interfaces on cluster, not probing DHCP.")
return
# Iterate over interfaces and probe each one.
for interface, ip in interfaces:
try:
servers = probe_interface(interface, ip)
except socket.error:
logger.exception(
"Failed to probe sockets; did you configure authbind as per "
"HACKING.txt?")
return
else:
if len(servers) > 0:
# Only send one, if it gets cleared out then the
# next detection pass will send a different one, if it
# still exists.
update_region_controller(knowledge, interface, servers.pop())
else:
update_region_controller(knowledge, interface, None)
|