diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml new file mode 100644 index 0000000..263b31d --- /dev/null +++ b/.github/workflows/pythonpackage.yml @@ -0,0 +1,27 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.5, 3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install setuptools wheel + - name: Build package + run: | + python setup.py bdist + python setup.py sdist bdist_wheel diff --git a/README.rst b/README.rst index ea1a0c5..09cc6e5 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,9 @@ PyBootd +++++++ +.. image:: https://github.com/eblot/pybootd/workflows/Python%20package/badge.svg + :alt: Python package build status + Overview ~~~~~~~~ @@ -26,14 +29,10 @@ Python ------ - Python_ 3.5+ or above is required. Python_ 2.x is not longer supported. -- Netifaces_ Python module is required on OS X; on Linux only, iproute2_ can be - used as an alternative -- Optional: python_pkg_resources_ Python module +- Netifaces_ Python module is required -.. _Python: http://python.org/ -.. _Netifaces: http://alastairs-place.net/netifaces/ -.. _iproute2: http://www.linuxfoundation.org/collaborate/workgroups/networking/iproute2 -.. _python_pkg_resources: http://pythonhosted.org/distribute/pkg_resources.html +.. _Python: https://python.org/ +.. _Netifaces: https://github.com/al45tair/netifaces Permissions ----------- @@ -43,7 +42,7 @@ Permissions - HTTP optional daemon may be run on any port. As these ports are within the server's range (<1024), the superuser privileges -are required on Unix hosts (Linux, Mac OS X, ...) to start up these daemons. +are required on Unix hosts (Linux, macOS, ...) to start up these daemons. Status @@ -57,16 +56,16 @@ Supported features - Access control: - 1. None (any remote host can be served) - 2. MAC address ACL - 3. UUID based ACL - requires PXE protocol - 4. HTTP forwarding - authorization is delegated to a remote server using - simple HTTP GET requests +1. None (any remote host can be served) +2. MAC address ACL +3. UUID based ACL - requires PXE protocol +4. HTTP forwarding - authorization is delegated to a remote server using + simple HTTP GET requests - Local or remote file serving: - - For example, it is possible to boot up a full Debian system directly from - the Internet, without storing any file on the pybootd host machine +- For example, it is possible to boot up a full Debian system directly from + the Internet, without storing any file on the pybootd host machine - Network notification of client requests through UDP messages @@ -112,6 +111,14 @@ Common errors Try listening on all IPv4 interfaces with ``address = 0.0.0.0`` and use ACL to discard requests from network you do not want to serve. +DHCP client keeps requesting an address but seems to receive none + Some stupid clients - such as the ones implemented in BIOS/UEFI from Intel + silently ignore proper network broadcast packets. They only consider global + broadcast packets. The Ethernet MAC of such clients should be added to + the ``[buggy_clients]`` section, so that global broadcast packets are + generated for these clients. + + Configuration ------------- @@ -201,10 +208,6 @@ client requests at least an IP address twice: BOOTP or DHCP requests, this option should be enabled. This option accepts a boolean value. -``boot_file`` - Boot filename to send back to the BOOTP client, which usually requests such - a file over TFTP to boot up after it has been assigned a network address. - ``domain`` Domain part of the client FQDN, that is the network's domain name. @@ -262,6 +265,12 @@ block. The value for each entry is a boolean, *i.e.*:: Note that due to a limitation of the configuration parser, ':' byte separator in MAC addresses is not allowed, please use '-' separator. +It is possible to use a mask syntax to specify a range of MACs addresses, for +example: + + AA-BB-CC/24 = enable + +Accepts all Ethernet addresses whose OUI is AA:BB:CC. ``[static_dhcp]`` section ......................... @@ -308,6 +317,44 @@ the boot phase: either a BIOS initialization or an OS boot sequence. When such differentiation is useless, both options may refer to the same path. +``[bootfile]`` section +...................... + +This section contains one entry for each supported architecture. +It defines the name of the initial boot file the client should request, +indexed on the architecture it reports, if any. + +It should contain at least one entry, ``default``, which map to the bootfile +for clients that do no expose their architecture. + +The bootfile is usually requested over TFTP to boot up after the client has +been assigned a network address. + +Each entry is the architecture string, with a filename value. + + +``[buggy_clients]`` section +........................... + +When a BOOTP client requests a network address, the BOOTP/DHCP server should +broadcast on the client's LAN the DHCP offerring. Using the client's network is +recommended, as it avoid broadcasting BOOTP/DHCP packets to other networks. + +Some clients, notably the clients based on Intel firmwares, are stupid enough +to ignore DHCP offering which is broadcasted to the network broadcast address. +They do require the DHCP server to broadcast to the global ``255.255.255.255`` +address. + +This section lists the MAC of the clients that are so stupid they need this +global broadcast address to work. If you use Intel BIOS/UEFI, this option is +likely needed. + +Each entry is a MAC address, using the ``-`` byte separator, with a boolean +value. + +This section also accepts mask syntax, see ``[mac]`` section for details. + + ``[tftpd]`` section ................... @@ -436,13 +483,6 @@ path to be set to ``/boot`` and the ``dhcp`` path to ``/linux``. Sample configurations ~~~~~~~~~~~~~~~~~~~~~ -Installing a Debian 6.0 machine from the official archive ---------------------------------------------------------- -As pybootd's *tftpd* server is able to retrieve remote files using the HTTP -protocol, there is no need to manually download any file from a Debian mirror. -The daemon will forward all file requests to the mirror on behalf of the client -being installed. - The ``pybootd.ini`` would contain:: [logger] @@ -458,12 +498,15 @@ The ``pybootd.ini`` would contain:: pool_start = 192.168.1.100 ; Google DNS dns = 8.8.8.8 - ; boot-up executable the client should request through TFTP - boot_file = pxelinux.0 + + [bootfile] + ; boot-up executable the client should request through TFTP (BIOS) + default = pxelinux.0 + ; boot-up executable the client should request through TFTP (UEFI x86-64) + 00007 = shimx64.efi [tftpd] - ; URL to install a Debian 6.0 Intel/AMD 64-bit network installation - root = http://http.us.debian.org/debian/dists/squeeze/main/installer-amd64/current/images/netboot + root = http://example.com/installer-amd64/images/netboot [filters] ; serve a simple configuration file to the linux PXE helper diff --git a/forward.sh b/forward.sh index d4a5539..1ef5925 100755 --- a/forward.sh +++ b/forward.sh @@ -70,13 +70,18 @@ while [ $# -ge 0 ]; do done if [ -z "${WAN_IF}" ]; then - echo "Unknown WAN interface" >&2 + echo "Undefined WAN interface" >&2 + exit 1 +fi + +if [ -z "${LAN_IF}" ]; then + echo "Undefined LAN interface" >&2 exit 1 fi UID=`id -u` if [ ${UID} -ne 0 ]; then - echo "Superuser privileges are required" >&2 + echo "Superuser privileges are required (use sudo)" >&2 exit 1 fi @@ -85,12 +90,15 @@ case "${OSTYPE}" in if [ ${ENABLE} -eq 1 ]; then echo "Enabling IP forwarding through interface ${WAN_IF}" sysctl -w net.inet.ip.forwarding=1 - natd -interface ${WAN_IF} - ipfw add divert natd ip from any to any via ${WAN_IF} + pfctl -F all -f /etc/pf.conf + conf=`mktemp` + echo "nat on ${WAN_IF} from ${LAN_IF}:network to any -> (${WAN_IF})" > \ + "${conf}" + pfctl -e -f "${conf}" + rm "${conf}" else echo "Disabling IP forwarding" - ipfw delete `sudo ipfw show | grep divert | cut -d' ' -f1` - killall natd + pfctl -F all -f /etc/pf.conf sysctl -w net.inet.ip.forwarding=0 fi ;; diff --git a/pybootd/__init__.py b/pybootd/__init__.py index 6379705..253ba4b 100644 --- a/pybootd/__init__.py +++ b/pybootd/__init__.py @@ -15,9 +15,22 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -import os -__version__ = '1.7.2' +#pylint: disable-msg=missing-docstring + +__version__ = '1.8.0' +__title__ = 'PyBootd' +__description__ = 'Simplified BOOTP/DHCP/PXE and TFTP server' +__uri__ = 'http://github.com/eblot/pybootd' +__doc__ = __description__ + ' <' + __uri__ + '>' +__author__ = 'Emmanuel Blot' +# For all support requests, please open a new issue on GitHub +__email__ = 'emmanuel.blot@free.fr' +__license__ = 'LGPL v2' +__copyright__ = 'Copyright (c) 2011-2020 Emmanuel Blot' + + +import os def pybootd_path(path): diff --git a/pybootd/etc/pybootd.ini b/pybootd/etc/pybootd.ini index 0167aae..f57fdcc 100644 --- a/pybootd/etc/pybootd.ini +++ b/pybootd/etc/pybootd.ini @@ -9,7 +9,6 @@ pool_start = 192.168.25.100 pool_count = 5 domain = localdomain server_name = debug -boot_file = pxelinux.0 lease_time = 86400 access = mac allow_simple_dhcp = enable @@ -18,9 +17,27 @@ set_gateway = true ; use "nc -l -u 127.0.0.1 -p 12345" to debug ; notify = 192.168.26.201:12345;192.168.26.200:12345 +[bootfile] +; BIOS boot file +default = pxelinux.0 +00000 = pxelinux.0 +; EFI Intel x86_32 +00002 = shim.efi +00006 = shim.efi +; EFI Intel x86_64 +00007 = shimx64.efi +00008 = shimx64.efi +00009 = shimx64.efi + +[buggy_clients] +; list of stupid clients (mostly Intel's) that ignores valid subnet broadcast +; and require global DHCP offer broadcast +1C-69-7A/24 = yes + [mac] ; see doc: byte separator should be defined with dash, not column 00-AA-55-12-34-56 = enable +1C-69-7A/24 = enable [uuid] 12345678-abcd-ef00-1111-abcdefabcdef = enable diff --git a/pybootd/httpd.py b/pybootd/httpd.py index d5fdbed..2005837 100755 --- a/pybootd/httpd.py +++ b/pybootd/httpd.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010-2019 Emmanuel Blot +# Copyright (c) 2010-2020 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -19,7 +19,7 @@ from argparse import ArgumentParser, FileType from functools import partial -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from http.server import SimpleHTTPRequestHandler, HTTPServer from os import getcwd from os.path import isfile, join as joinpath, realpath from sys import exit as sysexit, modules, stderr @@ -73,7 +73,7 @@ class HttpRequestHandler(SimpleHTTPRequestHandler): return False return True -class HttpServer(ThreadingHTTPServer): +class HttpServer(HTTPServer): HTTP_SECTION = 'httpd' diff --git a/pybootd/pxed.py b/pybootd/pxed.py index 670c01e..2e2afc2 100644 --- a/pybootd/pxed.py +++ b/pybootd/pxed.py @@ -1,4 +1,4 @@ -# Copyright (c) 2010-2019 Emmanuel Blot +# Copyright (c) 2010-2020 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -15,20 +15,6 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -from binascii import hexlify -from re import compile as recompile -from select import select -from socket import (inet_aton, inet_ntoa, socket, - AF_INET, SOCK_DGRAM, IPPROTO_UDP, SOL_SOCKET, - SO_BROADCAST, SO_REUSEADDR) -from struct import calcsize as scalc, pack as spack, unpack as sunpack -from time import sleep -from traceback import format_exc -from urllib.error import HTTPError, URLError -from urllib.parse import urlencode, urlunsplit -from urllib.request import urlopen -from .util import hexline, to_bool, iptoint, inttoip, get_iface_config - #pylint: disable-msg=broad-except #pylint: disable-msg=invalid-name #pylint: disable-msg=missing-docstring @@ -37,6 +23,32 @@ from .util import hexline, to_bool, iptoint, inttoip, get_iface_config #pylint: disable-msg=too-many-locals #pylint: disable-msg=too-many-statements #pylint: disable-msg=too-many-nested-blocks +#pylint: disable-msg=too-many-instance-attributes +#pylint: disable-msg=no-name-in-module +#pylint: disable-msg=no-self-use + + +from binascii import hexlify +from collections import OrderedDict +from os import stat +from os.path import realpath, join as joinpath +from re import compile as recompile, sub as resub +from select import select +from socket import (if_nametoindex, inet_aton, inet_ntoa, socket, + AF_INET, SOCK_DGRAM, IPPROTO_UDP, IPPROTO_IP, SOL_SOCKET, + SO_BROADCAST, SO_REUSEADDR) +from struct import calcsize as scalc, pack as spack, unpack as sunpack +from sys import platform +from time import sleep +from traceback import format_exc +from typing import Optional, Tuple, Union +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urlsplit, urlunsplit +from urllib.request import urlopen +from uuid import UUID +from netifaces import ifaddresses, interfaces +from .tftpd import TftpServer +from .util import hexline, to_bool, iptoint, inttoip, get_iface_config BOOTP_PORT_REQUEST = 67 @@ -107,7 +119,7 @@ DHCP_OPTIONS = {0: 'Byte padding', 40: 'Network Information Service domain', 41: 'Network Information servers', 42: 'Network Time Protocol servers', - 43: 'Vendor specific', + 43: 'Vendor specific', # Used by some PXE clients... 44: 'NetBIOS over TCP/IP name server', 45: 'NetBIOS over TCP/IP datagram server', 46: 'NetBIOS over TCP/IP node type', @@ -124,7 +136,7 @@ DHCP_OPTIONS = {0: 'Byte padding', 57: 'Message length', 58: 'Renewal time', 59: 'Rebinding time', - 60: 'Class ID', + 60: 'Vendor class identifier', 61: 'GUID', 64: 'Network Information Service+ domain', 65: 'Network Information Service+ servers', @@ -141,6 +153,20 @@ DHCP_OPTIONS = {0: 'Byte padding', 93: 'System architecture', 94: 'Network type', 97: 'UUID', + 119: 'Domain search', + 121: 'Classless static route', + 128: 'DOCSIS full security server', + # --- PXE vendor-specific (and other crap) --- + 129: 'PXE vendor-specific', + 130: 'PXE vendor-specific', + 131: 'PXE vendor-specific', + 132: 'PXE vendor-specific', + 133: 'PXE vendor-specific', + 134: 'PXE vendor-specific', + 135: 'PXE vendor-specific', + # --- + 249: 'Private/Classless static route', + 252: 'Private/Proxy autodiscovery', 255: 'End of DHCP options'} DHCP_DISCOVER = 1 @@ -169,19 +195,22 @@ PXE_MENU_PROMPT = 10 class BootpError(Exception): - """Bootp error""" - pass + """Bootp error + """ class BootpServer: """BOOTP Server - Implements bootstrap protocol""" + Implements bootstrap protocol. + """ - ACCESS_LOCAL = ['uuid', 'mac'] # Access modes, defined locally + ACCESS_LOCAL = {'uuid': 128, 'mac': 48} # Access modes, defined locally ACCESS_REMOTE = ['http'] # Access modes, remotely retrieved (ST_IDLE, ST_PXE, ST_DHCP) = range(3) # Current state BOOTP_SECTION = 'bootpd' + BOOT_FILE_SECTION = 'bootfile' + BUGGY_CLIENT_SECTION = 'buggy_clients' def __init__(self, logger, config): self.sock = [] @@ -223,23 +252,43 @@ class BootpServer: self.acl = None else: access = access.lower() - if access not in self.ACCESS_LOCAL + self.ACCESS_REMOTE: + if access not in list(self.ACCESS_LOCAL) + self.ACCESS_REMOTE: raise BootpError('Invalid access mode: %s' % access) if not self.config.has_section(access): raise BootpError("Missing access section '%s'" % access) - self.acl = {} + self.acl = OrderedDict() if access in self.ACCESS_LOCAL: for entry in self.config.options(access): - self.acl[entry.upper().replace('-', ':')] = \ - to_bool(self.config.get(access, entry)) + acl_builder = getattr(self, 'build_%s_acl' % access) + kent = acl_builder(entry) + self.acl[kent] = to_bool(self.config.get(access, entry)) + self.buggy_clients = OrderedDict() + if self.config.has_section(self.BUGGY_CLIENT_SECTION): + for entry in self.config.options(self.BUGGY_CLIENT_SECTION): + item = self.build_mac_acl(entry) + self.buggy_clients[item] = \ + to_bool(self.config.get(self.BUGGY_CLIENT_SECTION, entry)) + self.boot_files = dict() + if not self.config.options(self.BOOT_FILE_SECTION): + raise BootpError("Mising '%s' section" % self.BOOT_FILE_SECTION) + for entry in self.config.options(self.BOOT_FILE_SECTION): + self.boot_files[entry] = self.config.get(self.BOOT_FILE_SECTION, + entry) + if 'default' not in self.boot_files: + raise BootpError("'%s' section should contain at least the default" + "boot file") # pre-fill ippool if specified if self.config.has_section('static_dhcp'): for mac_str, ip_str in config.items('static_dhcp'): mac_key = mac_str.upper().replace('-', ':') self.ippool[mac_key] = ip_str - if access == 'mac' and mac_str not in self.acl: - self.acl[mac_key] = True + mac = int(resub('[-:]', '', mac_str), 16) + mask = (1 << self.ACCESS_LOCAL['mac']) - 1 + access_key = (mac, mask) + if access == 'mac' and access_key not in self.acl: + self.acl[access_key] = True self.access = access + self._resume = False # Private def _notify(self, notice, uuid_str, mac_str, ip): @@ -253,6 +302,95 @@ class BootpServer: notify_sock.sendto(msg, n) # Public + + @staticmethod + def find_interface(address: str) -> Optional[str]: + iaddress = sunpack('!I', inet_aton(address))[0] + for iface in interfaces(): + for confs in ifaddresses(iface).values(): + for conf in confs: + if all([x in conf for x in ('addr', 'netmask')]): + address = conf['addr'] + if ':' in address: + # IPv6 + continue + netmask = conf['netmask'] + iaddr = sunpack('!I', inet_aton(address))[0] + inet = sunpack('!I', inet_aton(netmask))[0] + inic = iaddr & inet + ires = iaddress & inet + if inic == ires: + return iface + return None + + @staticmethod + def is_url(path): + return bool(urlsplit(path).scheme) + + @classmethod + def build_mac_acl(cls, entry: str) -> Tuple[int, int]: + parts = entry.split('/', 1) + values = [] + bitcount = cls.ACCESS_LOCAL['mac'] + maxval = (1 << bitcount) - 1 + for mask, part in enumerate(parts): + try: + if mask: + value = maxval & ~((1 << int(part)) - 1) + else: + part = resub('[-:]', '', part) + value = int(part, 16) + value <<= bitcount - len(part)*4 + if not 0 <= value <= maxval: + raise ValueError() + values.append(value) + except Exception: + raise ValueError('Invalid ACL value: %s' % entry) + if len(values) < 2: + values.append(maxval) + return tuple(values) + + @classmethod + def build_uuid_acl(cls, entry: str) -> Tuple[int, int]: + parts = entry.split('/', 1) + values = [] + bitcount = cls.ACCESS_LOCAL['uuid'] + maxval = (1 << bitcount) - 1 + for part in parts: + try: + value = UUID('{%s}' % part).int + if not 0 <= value <= maxval: + raise ValueError() + values.append(value) + except Exception: + raise ValueError('Invalid ACL value: %s' % entry) + if len(values) < 2: + values.append(maxval) + return tuple(values) + + @classmethod + def check_acl(cls, acl: dict, access: str, value: Union[bytes, UUID]) \ + -> Union[bool, None]: + width = cls.ACCESS_LOCAL[access] + if access == 'mac': + ival = int(hexlify(value), 16) + else: + ival = value.int + access_key = (ival, (1 << width) - 1) + if access_key in acl: + # try direct match + result = acl[access_key] + else: + # find matching filter + result = None + for val, mask in acl: + if ival & mask == val & mask: + result = acl[(val, mask)] + break + if result: + return True + return result + def get_netconfig(self): return self.netconfig @@ -266,6 +404,20 @@ class BootpServer: sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + if self.buggy_clients: + iface = self.find_interface(self.config.get(self.BOOTP_SECTION, + 'pool_start')) + if not iface: + raise BootpError('Unable to retrieve binding interface') + if platform == 'linux': + from socket import SO_BINDTODEVICE + sock.setsockopt(SOL_SOCKET, SO_BINDTODEVICE, iface.encode()) + elif platform == 'darwin': + IP_BOUND_IF = 25 # unfortunately not mapped to Python + sock.setsockopt(IPPROTO_IP, IP_BOUND_IF, if_nametoindex(iface)) + else: + raise BootpError('Bind to interface not supported on %s' % + platform) self.sock.append(sock) self.log.info('Listening to %s:%s' % (host, port)) sock.bind((host, int(port))) @@ -314,16 +466,55 @@ class BootpServer: continue dhcp_tags[tag] = value - def build_pxe_options(self, options, server): + def build_pxe_options(self, options, server, bootp_buf): + try: + client_params = options[55] + except IndexError: + client_params = b'' buf = b'' try: - uuid = options[97] - buf += spack('!BB%ds' % len(uuid), - 97, len(uuid), uuid) - clientclass = options[60] - clientclass = clientclass[:clientclass.find(b':')] - buf += spack('!BB%ds' % len(clientclass), - 60, len(clientclass), clientclass) + if 97 in client_params: + uuid = options[97] + buf += spack('!BB%ds' % len(uuid), + 97, len(uuid), uuid) + if 13 in client_params: + bootfile_size = 0 + path = self.config.get(TftpServer.TFTP_SECTION, 'root', '') + bootfile_name = bootp_buf[BOOTP_FILE].decode() + if not self.is_url(path): + pathname = realpath(joinpath(path,bootfile_name)) + try: + bootfile_size = stat(pathname).st_size + except OSError as exc: + self.log.error('Cannot get size of %s: %s', + pathname, exc) + else: + url = joinpath(path, bootp_buf[BOOTP_FILE].decode()) + try: + resource = urlopen(url) + bootfile_size = int(resource.info()['Content-Length']) + except Exception as exc: + self.log.error('Cannot retrieve size of %s: %s', + url, exc) + if bootfile_size: + self.log.debug('Bootfile %s is %d byte long', + bootfile_name, bootfile_size) + bootfile_block = (bootfile_size+511)//512 + buf += spack('!BBH', 13, scalc('!H'), bootfile_block) + if 60 in client_params: + clientclass = options[60] + clientclass = clientclass[:clientclass.find(b':')] + buf += spack('!BB%ds' % len(clientclass), + 60, len(clientclass), clientclass) + if 66 in client_params: + tftp_server = bootp_buf[BOOTP_SNAME] + buf += spack('!BB%ds' % len(tftp_server), 66, + len(tftp_server), tftp_server) + if 67 in client_params: + boot_file = bootp_buf[BOOTP_FILE] + buf += spack('!BB%ds' % len(boot_file), 67, + len(boot_file), boot_file) + # Vendor specific (PXE extension) vendor = b'' vendor += spack('!BBB', PXE_DISCOVERY_CONTROL, 1, 0x0A) vendor += spack('!BBHB4s', PXE_BOOT_SERVERS, 2+1+4, @@ -349,7 +540,9 @@ class BootpServer: 12, len(clientname), clientname) def handle(self, sock, addr, data): - self.log.info('Sender: %s on socket %s' % (addr, sock.getsockname())) + sockname = sock.getsockname() + self.log.debug('Sender %s:%d on socket %s:%d', + addr[0], addr[1], sockname[0], sockname[1]) if len(data) < DHCPFORMATSIZE: self.log.error('Cannot be a DHCP or BOOTP request - too small!') tail = data[DHCPFORMATSIZE:] @@ -370,22 +563,23 @@ class BootpServer: server_addr = self.netconfig['server'] mac_addr = buf[BOOTP_CHADDR][:6] + identifiers = {'mac': mac_addr} mac_str = ':'.join(['%02X' % x for x in mac_addr]) # is the UUID received (PXE mode) if 97 in options and len(options[97]) == 17: - uuid = options[97][1:] + uuid = UUID(bytes=options[97][1:]) + identifiers['uuid'] = uuid pxe = True - self.log.info('PXE UUID has been received') + self.log.debug('PXE UUID has been received') # or retrieved from the cache (DHCP mode) else: uuid = self.uuidpool.get(mac_addr, None) + identifiers['uuid'] = uuid pxe = False - self.log.info('PXE UUID not present in request') - uuid_str = uuid and ('%s-%s-%s-%s-%s' % tuple([hexlify(x) - for x in (uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]) - ])).upper() + self.log.debug('PXE UUID not present in request') + uuid_str = str(uuid) if uuid else None if uuid_str: - self.log.info('UUID is %s for MAC %s' % (uuid_str, mac_str)) + self.log.info('UUID is %s for MAC %s', uuid_str, mac_str) hostname = '' filename = '' @@ -434,7 +628,7 @@ class BootpServer: parameters['uuid'] = uuid_str if not pxe and mac_str in self.ippool: parameters['ip'] = self.ippool[mac_str] - item = uuid_str or mac_str + item_str = uuid_str or mac_str # only bother the authentication host when a state change is # required. checkhost = currentstate != newstate @@ -466,26 +660,60 @@ class BootpServer: self.log.critical('Internal error: %s' % exc) self.states[mac_str] = self.ST_IDLE return - # local access is only validated if mac address is not yet known elif mac_str not in self.ippool: - item = locals()['%s_str' % self.access] + # local access is only validated if mac addr is not yet known + item = identifiers.get(self.access, None) if not item: self.log.info('Missing %s identifier, ' 'ignoring %s request' % (self.access, mac_str)) return - if item not in self.acl: - self.log.info('%s is not in ACL list, ' - 'ignoring %s request' % (item, mac_str)) - return - if not self.acl[item]: - self.log.info('%s access is disabled, ' - 'ignoring %s request' % (item, mac_str)) + result = self.check_acl(self.acl, self.access, item) + if uuid: + item_str = '/'.join((uuid_str, mac_str)) + else: + item_str = mac_str + if not result: + if result is not None: + self.log.info('%s access in ACL is disabled', item_str) + else: + self.log.info('%s is not in ACL list', item_str) return else: - item = locals()['%s_str' % self.access] + # mac is registered, that is already authorized + item_str = mac_str self.log.info('%s access is authorized, ' - 'request will be satisfied' % item) + 'request will be satisfied' % item_str) + + if 55 in options: + for opt in options[55]: + try: + parameter = DHCP_OPTIONS[opt] + self.log.debug('Client request: %s', parameter) + except KeyError: + self.log.warning('Unknown requested option: %d', opt) + + boot_file = self.boot_files['default'] + if 60 in options: + clientclass = options[60] + classids = clientclass.split(b':') + if len(classids) >= 3 and \ + classids[0].lower() == b'pxeclient' and \ + classids[1].lower() == b'arch': + try: + architecture = classids[2].decode() + except UnicodeDecodeError: + self.log.error('Unable to decode architecture') + return + try: + boot_file = self.boot_files[architecture] + self.log.info("Selecting bootfile '%s' for architecture " + "%s", boot_file, architecture) + except KeyError: + self.log.error('No boot file defined for architecture %s', + architecture) + return + # construct reply buf[BOOTP_HOPS] = 0 buf[BOOTP_OP] = BOOTREPLY @@ -535,8 +763,7 @@ class BootpServer: self.config.get(self.BOOTP_SECTION, 'domain', 'localdomain')]).encode() # file - buf[BOOTP_FILE] = self.config.get(self.BOOTP_SECTION, - 'boot_file', '\x00').encode() + buf[BOOTP_FILE] = boot_file.encode() if not dhcp_msg_type: self.log.warn('No DHCP message type found, discarding request') @@ -608,14 +835,13 @@ class BootpServer: dns_ip = inet_aton(dns_str) pkt += spack('!BB4s', DHCP_IP_DNS, 4, dns_ip) pkt += spack('!BBI', DHCP_LEASE_TIME, 4, - int(self.config.get(self.BOOTP_SECTION, - 'lease_time', - str(24*3600)))) + int(self.config.get(self.BOOTP_SECTION, 'lease_time', + str(24*3600)))) # do not attempt to produce a PXE-augmented response for # regular DHCP requests if pxe: - extra_buf = self.build_pxe_options(options, server) + extra_buf = self.build_pxe_options(options, server, buf) if not extra_buf: return else: @@ -628,6 +854,13 @@ class BootpServer: if pxe: self.uuidpool[mac_addr] = uuid + if self.check_acl(self.buggy_clients, 'mac', mac_addr): + self.log.info('Force global broadcast for buggy client %s', + mac_str) + addr = ('255.255.255.255', addr[1]) + else: + self.log.debug('Not buggy') + # send the response sock.sendto(pkt, addr) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06bbac7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +netifaces>=0.10 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a0e7b1e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[bdist_wheel] + +[metadata] +license_file = LICENSE diff --git a/setup.py b/setup.py index 10855cd..522d57b 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (c) 2010-2019 Emmanuel Blot +# Copyright (c) 2010-2020 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -17,49 +17,143 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -from distutils.core import setup -from os.path import dirname, join as joinpath -from sys import platform +#pylint: disable-msg=broad-except +#pylint: disable-msg=no-self-use +#pylint: disable-msg=missing-docstring + +from os import close, unlink +from os.path import abspath, dirname, join as joinpath +from py_compile import compile as pycompile, PyCompileError +from re import search as research +from sys import stderr +from tempfile import mkstemp +from setuptools import find_packages, setup +from setuptools.command.build_py import build_py from pybootd import __version__ def _read(fname): return open(joinpath(dirname(__file__), fname)).read() -requirements = [] -if platform == 'darwin': - requirements.append('netifaces (>= 0.5)') +PACKAGES = find_packages(where='.') +KEYWORDS = 'bootp ftdp dhcp pxe netboot bios uefi' +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Environment :: No Input/Output (Daemon)', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: GNU Library or ' + 'Lesser General Public License (LGPL)', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Topic :: Internet', + 'Topic :: System :: Installation/Setup', + 'Topic :: System :: Networking', + 'Topic :: Utilities' +] +INSTALL_REQUIRES = ['netifaces >= 0.10'] + +HERE = abspath(dirname(__file__)) -setup( - name='pybootd', - version=__version__, - description='Simplified BOOTP/DHCP/PXE and TFTP server', - author='Emmanuel Blot', - author_email='emmanuel.blot@free.fr', - license='LGPL v2', - keywords='bootp ftdp dhcp pxe netboot', - url='http://github.com/eblot/pybootd', - download_url='https://github.com/eblot/pybootd/tarball/master', - packages=['pybootd'], - requires=requirements, - classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: No Input/Output (Daemon)', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: GNU Library or ' - 'Lesser General Public License (LGPL)', - 'Operating System :: MacOS :: MacOS X', - 'Operating System :: POSIX', - 'Programming Language :: Python :: 3.5', - 'Topic :: Internet', - 'Topic :: System :: Installation/Setup', - 'Topic :: System :: Networking', - 'Topic :: Utilities' - ], - package_data={ - '': ['etc/*.ini', 'etc/*.cfg'], - }, - long_description=_read('README.rst'), -) +def read(*parts): + """ + Build an absolute path from *parts* and and return the contents of the + resulting file. Assume UTF-8 encoding. + """ + with open(joinpath(HERE, *parts), 'rt') as dfp: + return dfp.read() + +META_FILE = read(joinpath('pybootd', '__init__.py')) + +def find_meta(meta): + """ + Extract __*meta*__ from META_FILE. + """ + meta_match = research( + r"(?m)^__{meta}__ = ['\"]([^'\"]*)['\"]".format(meta=meta), + META_FILE + ) + if meta_match: + return meta_match.group(1) + raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) + + +class BuildPy(build_py): + """Override byte-compile sequence to catch any syntax error issue. + + For some reason, distutils' byte-compile when it forks a sub-process + to byte-compile a .py file into a .pyc does NOT check the success of + the compilation. Therefore, any syntax error is explictly ignored, + and no output file is generated. This ends up generating an incomplete + package w/ a nevertheless successfull setup.py execution. + + Here, each Python file is build before invoking distutils, so that any + syntax error is catched, raised and setup.py actually fails should this + event arise. + + This step is critical to check that an unsupported syntax (for ex. 3.6 + syntax w/ a 3.5 interpreter) does not end into a 'valid' package from + setuptools perspective... + """ + + def byte_compile(self, files): + for file in files: + if not file.endswith('.py'): + continue + pfd, pyc = mkstemp('.pyc') + close(pfd) + try: + pycompile(file, pyc, doraise=True) + self._check_line_width(file) + continue + except PyCompileError as exc: + # avoid chaining exceptions + print(str(exc), file=stderr) + raise SyntaxError("Cannot byte-compile '%s'" % file) + finally: + unlink(pyc) + super().byte_compile(files) + + def _check_line_width(self, file): + with open(file, 'rt') as pfp: + for lpos, line in enumerate(pfp, start=1): + if len(line) > 80: + print('\n %d: %s' % (lpos, line.rstrip())) + raise RuntimeError("Invalid line width '%s'" % file) + + +def main(): + setup( + cmdclass={'build_py': BuildPy}, + name=find_meta('title').lower(), + description=find_meta('description'), + license=find_meta('license'), + url=find_meta('uri'), + version=find_meta('version'), + author=find_meta('author'), + author_email=find_meta('email'), + maintainer=find_meta('author'), + maintainer_email=find_meta('email'), + keywords=KEYWORDS, + long_description=_read('README.rst'), + packages=PACKAGES, + download_url='/'.join((find_meta('uri'), 'tarball/master')), + install_requires=INSTALL_REQUIRES, + classifiers=CLASSIFIERS, + package_data={ + '': ['etc/*.ini', 'etc/*.cfg'], + }, + ) + + +if __name__ == '__main__': + try: + main() + except Exception as exc: + print(exc, file=stderr) + exit(1)