mirror of
https://github.com/eblot/pybootd.git
synced 2024-10-27 00:51:54 +03:00
Merge branch 'pxe_rework', with support for legacy/UEFI first image selection
This commit is contained in:
commit
c904b04644
27
.github/workflows/pythonpackage.yml
vendored
Normal file
27
.github/workflows/pythonpackage.yml
vendored
Normal 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
|
99
README.rst
99
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,15 +56,15 @@ 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
|
||||
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
|
||||
- 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
|
||||
|
20
forward.sh
20
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
|
||||
;;
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
||||
|
339
pybootd/pxed.py
339
pybootd/pxed.py
@ -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:
|
||||
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',
|
||||
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
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
netifaces>=0.10
|
146
setup.py
146
setup.py
@ -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,33 +17,27 @@
|
||||
# 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)')
|
||||
|
||||
|
||||
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=[
|
||||
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',
|
||||
@ -51,15 +45,115 @@ setup(
|
||||
'License :: OSI Approved :: GNU Library or '
|
||||
'Lesser General Public License (LGPL)',
|
||||
'Operating System :: MacOS :: MacOS X',
|
||||
'Operating System :: POSIX',
|
||||
'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__))
|
||||
|
||||
|
||||
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'],
|
||||
},
|
||||
long_description=_read('README.rst'),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as exc:
|
||||
print(exc, file=stderr)
|
||||
exit(1)
|
||||
|
Loading…
Reference in New Issue
Block a user