1
1
mirror of https://github.com/eblot/pybootd.git synced 2024-08-17 18:00:33 +03:00

Merge branch 'pxe_rework', with support for legacy/UEFI first image selection

This commit is contained in:
Emmanuel Blot 2020-04-28 11:56:47 +02:00
commit c904b04644
10 changed files with 582 additions and 142 deletions

27
.github/workflows/pythonpackage.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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
;;

View File

@ -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):

View File

@ -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

View File

@ -1,4 +1,4 @@
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2020 Emmanuel Blot <emmanuel.blot@free.fr>
# 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'

View File

@ -1,4 +1,4 @@
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2020 Emmanuel Blot <emmanuel.blot@free.fr>
# 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)

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
netifaces>=0.10

4
setup.cfg Normal file
View File

@ -0,0 +1,4 @@
[bdist_wheel]
[metadata]
license_file = LICENSE

172
setup.py
View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2020 Emmanuel Blot <emmanuel.blot@free.fr>
# 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)