diff --git a/.gitignore b/.gitignore index badda5a..c0d2f86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.egg-info *.pyc *.pyo +MANIFEST +**/*-local.ini dist/ build/ diff --git a/README.rst b/README.rst index 19d9ebf..ea1a0c5 100644 --- a/README.rst +++ b/README.rst @@ -5,50 +5,56 @@ PyBootd Overview ~~~~~~~~ -PyBootd is a daemon supporting a subset of the BOOTP, DHCP, PXE and TFTP +PyBootd is a daemon supporting a subset of the BOOTP, DHCP, PXE, TFTP and HTTP protocols, with some handy extensions. One of its main goals is to provide a simple solution to boot up any PXE-enabled personal computer, with no other tool required but a standard Python installation. +It is not designed to be feature-complete, but to be used as an easy modifiable +code to develop custom boot solutions + Pybootd can be used for any network boot up, or to install an OS without any physical support such as a USB key or a CD/DVD. + Requirements ~~~~~~~~~~~~ Python ------ -- Python_ 2.7 or above is required. Python_ 3.x is not yet supported. -- Six_ compatibility module +- 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 +- Optional: python_pkg_resources_ Python module .. _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 -.. _Six: http://pythonhosted.org/six +.. _python_pkg_resources: http://pythonhosted.org/distribute/pkg_resources.html Permissions ----------- - DHCP protocol requires the daemon to listen on port 67. - TFTP protocol requires the daemon to listen on port 69. +- 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. + Status ~~~~~~ This project is in beta development stage. + Supported features ~~~~~~~~~~~~~~~~~~ + - Access control: 1. None (any remote host can be served) @@ -72,6 +78,20 @@ Supported features - It is possible to use pybootd with only one of the services, either TFTP or DHCP +- A very basic HTTP server can be optionally enabled to serve files over HTTP + for complex hosts that require additional files (such as a root file system) + after the initial boot sequence. + +Warning +~~~~~~~ + +There is no strong checking of permissions nor robust file path management, so +it is recommended NOT to run this daemon on a host with sensitive content. + +Although only read requests are implemented, there is no enforcement or +strong validation of received data and strings from adversary remote clients. + + FAQ ~~~ @@ -81,7 +101,7 @@ Common errors ``pybootd.pxed.BootpError: Unable to detect network configuration`` This error is often triggered when the ``pool_start`` address is not part of a valid network. Double check the network configuration and - fix up the ``[bootp]`` section so that it matches the actual + fix up the ``[bootpd]`` section so that it matches the actual network. If you don't want to allocate addresses dynamically from the pool (with ``pool_count = 0``), you still need to specify ``pool_start`` to some address in the local network you want to @@ -95,19 +115,21 @@ Common errors Configuration ------------- -``pybootd`` has a few option switches. The server offers two services: bootp -(which supports Dhcp and PXE extensions) and tftp. It is possible to disable +``pybootd`` has a few option switches. The server offers two services: *bootpd* +(which supports DHCP and PXE extensions) and *tftpd*. It is possible to disable either services. -Usage: pybootd.py [options] - PXE boot up server, a tiny BOOTP/DHCP/TFTP server +Usage: pybootd.py [-h] [-c CONFIG] [-p] [-t] [-d] + Tiny BOOTP/DHCP/TFTP/PXE server Options: -h, --help show this help message and exit - -c CONFIG, --config=CONFIG + -c CONFIG, --config CONFIG configuration file - -p, --pxe enable BOOTP/DHCP/PXE server only - -t, --tftp enable TFTP server only + -p, --pxe only enable BOOTP/DHCP/PXE server + -t, --tftp only enable TFTP server + -H, --http enable HTTP server (default: disabled) + -d, --debug enable debug mode ``pybootd`` daemon uses a configuration file, in ``.ini`` format, for all other options. @@ -145,8 +167,8 @@ client requests at least an IP address twice: ``file`` The path to the output log file, if ``type`` is set to ``file``. -``[bootp]`` section -................... +``[bootpd]`` section +.................... ``access`` Type of access control list. If this option is not defined, all BOOTP @@ -228,36 +250,40 @@ client requests at least an IP address twice: ``servername`` Name of the BOOTP server. + ``[mac]`` section ................. - The ``[mac]`` section contains one entry for each MAC address to allow or - block. The value for each entry is a boolean, *i.e.*:: +The ``[mac]`` section contains one entry for each MAC address to allow or +block. The value for each entry is a boolean, *i.e.*:: - AA-BB-CC-DD-EE-FF = enable + AA-BB-CC-DD-EE-FF = enable + +Note that due to a limitation of the configuration parser, ':' byte separator +in MAC addresses is not allowed, please use '-' separator. - Note that due to a limitation of the configuration parser, ':' byte separator - in MAC addresses is not allowed, please use '-' separator. ``[static_dhcp]`` section ......................... - The ``[static_dhcp]`` section contains one entry for each MAC - address to associate with a specific IP address. The IP address can be - any IPv4 address in dotted notation, *i.e.*: +The ``[static_dhcp]`` section contains one entry for each MAC +address to associate with a specific IP address. The IP address can be +any IPv4 address in dotted notation, *i.e.*: - AA-BB-CC-DD-EE-FF = 192.168.1.2 + AA-BB-CC-DD-EE-FF = 192.168.1.2 + +The MAC addresses specified here will automatically be allowed, +unless ``[mac]`` section specifies otherwise. - The MAC addresses specified here will automatically be allowed, - unless ``[mac]`` section specifies otherwise. ``[uuid]`` section .................. - The ``[uuid]`` section contains one entry for each UUID to allow or block. - The value for each entry is a boolean, *i.e.*:: +The ``[uuid]`` section contains one entry for each UUID to allow or block. +The value for each entry is a boolean, *i.e.*:: + + xxxxxxxx-aaaa-bbbb-cccc-yyyyyyyyyyyy = enable - xxxxxxxx-aaaa-bbbb-cccc-yyyyyyyyyyyy = enable ``[http]`` section .................. @@ -281,8 +307,9 @@ The ``pxe``/``dhcp`` option pair enables the remote HTTP server to identify 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. -``[tftp]`` section -.................. + +``[tftpd]`` section +................... ``address`` Address to listen to incoming TFTP requests. When the BOOTP daemon is @@ -310,11 +337,34 @@ differentiation is useless, both options may refer to the same path. - an absolute path, when the ``root`` option starts with ``/``, - a URL prefix, to access remote files. + +``[httpd]`` section +................... + +``address`` + Address to listen to incoming HTTP requests. When the BOOTP daemon is + enabled this option is better omitted, as the address is automatically + received from the BOOTP daemon. + +``port`` + Alternative port for incoming HTTP request, default to 80 + +``root`` + Base directory for the HTTP service. This path is automatically prepended + to the pathname issued from the TFTP client. It can either point to a local + directory for now. + +``check_ip`` + Whether to enforce HTTP client IP or not. When enabled, requests from + clients that have not obtained an IP address from the BOOTP daemon are + rejected. + + ``[filters]`` section ..................... The ``filters`` section allows on-the-fly pathnames transformation. When a TFTP -client requests some specific filenames, the *tftp* server can translate them +client requests some specific filenames, the *tftpd* server can translate them to other ones. This option is useful to serve the very same configuration file (''e.g.'' @@ -332,8 +382,8 @@ braces, such as ``{varname}``. For now, the only supported variable is ``filename``, which is replaced with the actual requested filename. -The *value* part can also contain a special marker, that tells the *tftp* -daemon to read the replacement pattern from a file. This special marker should +The *value* part can also contain a special marker, that tells the *tftpd* +server to read the replacement pattern from a file. This special marker should be written with enclosing brackets, such as ``[file]``. Examples @@ -343,7 +393,7 @@ The following filter:: pxelinux.cfg/* = pybootd/etc/pxe.cfg -tells the *tftp* server that all client requests matching the +tells the *tftpd* server that all client requests matching the ``pxelinux.cfg/*`` pattern should be served the ``pybootd/etc/pxe.cfg`` file instead. This prevents the client to perform the usual time-costing fallback requests using UUID, MAC, and suffix addresses before eventually falling @@ -353,7 +403,7 @@ The following filter:: startup = [dir/{filename}.cfg] -tells the *tftp* server that when the ``startup`` file is requested, it should +tells the *tftpd* server that when the ``startup`` file is requested, it should read out the actual filename from the ``dir/startup.cfg`` file. HTTP-based authentication @@ -382,15 +432,16 @@ this feature. It can be found within the ``tests/`` subdirectory. See the ``config.ini`` file for this test daemon. The test daemon expects the ``pxe`` 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 the *tftp* daemon 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. +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:: @@ -400,7 +451,7 @@ The ``pybootd.ini`` would contain:: ; show informative and error messages only (disable verbose mode) level = info - [bootp] + [bootpd] ; do not force a full PXE boot-up cycle to accept the client allow_simple_dhcp = enable ; First BOOTP/DHCP address to generate @@ -410,7 +461,7 @@ The ``pybootd.ini`` would contain:: ; boot-up executable the client should request through TFTP boot_file = pxelinux.0 - [tftp] + [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 diff --git a/pybootd.py b/pybootd.py index 4e7679f..3b937dd 100755 --- a/pybootd.py +++ b/pybootd.py @@ -1,7 +1,7 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or diff --git a/pybootd/__init__.py b/pybootd/__init__.py index 627c1ea..415bfba 100644 --- a/pybootd/__init__.py +++ b/pybootd/__init__.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -18,33 +16,8 @@ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import os -import sys - -def _get_package_name(default='', version='1.5.0'): - try: - from pkg_resources import WorkingSet - except ImportError: - ws = [] - else: - ws = WorkingSet() - _path, _ = os.path.split(os.path.dirname( - sys.modules['pybootd'].__file__)) - _path = os.path.normpath(_path) - if 'nt' not in os.name: - for dist in ws: - if os.path.samefile(os.path.normpath(dist.location), _path): - return dist.project_name, dist.version - else: # tweak for windows - _path = os.path.abspath(_path).lower() - for dist in ws: - if 'pybootd' in dist.location: - if _path == os.path.abspath(dist.location).lower(): - return dist.project_name, dist.version - return default, version - - -PRODUCT_NAME, __version__ = _get_package_name('pybootd') +__version__ = '1.7.0' def pybootd_path(path): @@ -61,9 +34,9 @@ def pybootd_path(path): except ImportError: raise IOError('pkg_resources module not available') try: - newpath = resource_filename(Requirement.parse(PRODUCT_NAME), path) + newpath = resource_filename(Requirement.parse('pybootd'), path) if not newpath: - localpath = get_distribution(PRODUCT_NAME).location + localpath = get_distribution('pybootd').location newpath = os.path.join(localpath, path) except DistributionNotFound: newpath = path diff --git a/pybootd/daemons.py b/pybootd/daemons.py index 9e97a7d..ea28d46 100755 --- a/pybootd/daemons.py +++ b/pybootd/daemons.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +#!/usr/bin/env python3 # -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -18,69 +17,98 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -"""Boot up server, a tiny BOOTP/DHCP/TFTP/PXE server""" +"""Tiny BOOTP/DHCP/TFTP/PXE server""" - -import os -import sys -from pxed import BootpServer -from pybootd import pybootd_path, PRODUCT_NAME, __version__ as VERSION -from six import print_ -from tftpd import TftpServer -from util import logger_factory, EasyConfigParser +from argparse import ArgumentParser +from collections import OrderedDict +from os.path import isfile from threading import Thread +from sys import exit as sysexit, modules, stderr +from traceback import format_exc + +from . import pybootd_path, __version__ +from .httpd import HttpServer +from .pxed import BootpServer +from .tftpd import TftpServer +from .util import logger_factory, EasyConfigParser + +#pylint: disable-msg=broad-except +#pylint: disable-msg=missing-docstring +#pylint: disable-msg=invalid-name -class BootpDaemon(Thread): +class Daemon(Thread): - def __init__(self, logger, config): - super(BootpDaemon, self).__init__(name="BootpDeamon") - self.daemon = True + def __init__(self, debug): + super(Daemon, self).__init__(name=self.__class__.__name__, daemon=True) + self._server = None + self._debug = debug + + def run(self): + try: + self._server.start() + except KeyboardInterrupt: + raise + except Exception as exc: + print('\nError: %s' % exc, stderr) + if self._debug: + print(format_exc(chain=False), file=stderr) + raise + + def stop(self): + self._server.stop() + + +class BootpDaemon(Daemon): + + def __init__(self, logger, config, debug): + super(BootpDaemon, self).__init__(debug) self._server = BootpServer(logger=logger, config=config) def get_netconfig(self): return self._server.get_netconfig() + def is_managed_ip(self, ip): + return self._server.is_managed_ip(ip) + def get_filename(self, ip): return self._server.get_filename(ip) - def run(self): - self._server.bind() - self._server.forever() +class TftpDaemon(Daemon): -class TftpDaemon(Thread): - - def __init__(self, logger, config, bootpd=None): - super(TftpDaemon, self).__init__(name="TftpDeamon") - self.daemon = True + def __init__(self, logger, config, debug, bootpd=None): + super(TftpDaemon, self).__init__(debug) self._server = TftpServer(logger=logger, config=config, bootpd=bootpd) - def run(self): - self._server.bind() - self._server.forever() + +class HttpDaemon(Daemon): + + def __init__(self, logger, config, debug, bootpd=None): + super(HttpDaemon, self).__init__(debug) + self.daemon = True + self._server = HttpServer(logger=logger, config=config, bootpd=bootpd) def main(): debug = False try: - from argparse import ArgumentParser - argparser = ArgumentParser(description=sys.modules[__name__].__doc__) - argparser.add_argument('-c', '--config', dest='config', + argparser = ArgumentParser(description=modules[__name__].__doc__) + argparser.add_argument('-c', '--config', default='pybootd/etc/pybootd.ini', help='configuration file') - argparser.add_argument('-p', '--pxe', dest='pxe', - action='store_true', - help='enable BOOTP/DHCP/PXE server only') - argparser.add_argument('-t', '--tftp', dest='tftp', - action='store_true', - help='enable TFTP server only') + argparser.add_argument('-p', '--pxe', action='store_true', + help='only enable BOOTP/DHCP/PXE server') + argparser.add_argument('-t', '--tftp', action='store_true', + help='only enable TFTP server') + argparser.add_argument('-H', '--http', action='store_true', + help='enable HTTP server (default: disabled)') argparser.add_argument('-d', '--debug', action='store_true', help='enable debug mode') args = argparser.parse_args() debug = args.debug - if not os.path.isfile(args.config): + if not isfile(args.config): argparser.error('Invalid configuration file') if args.pxe and args.tftp: @@ -88,32 +116,47 @@ def main(): cfgparser = EasyConfigParser() with open(pybootd_path(args.config), 'rt') as config: - cfgparser.readfp(config) + cfgparser.read_file(config) logger = logger_factory(logtype=cfgparser.get('logger', 'type', 'stderr'), logfile=cfgparser.get('logger', 'file'), level=cfgparser.get('logger', 'level', 'info')) - logger.info('-'.join((PRODUCT_NAME, VERSION))) + logger.info('-'.join(('pybootd', __version__))) - daemon = None + daemons = OrderedDict() if not args.tftp: - daemon = BootpDaemon(logger, cfgparser) + daemon = BootpDaemon(logger, cfgparser, debug) daemon.start() + daemons['bootp'] = daemon if not args.pxe: - daemon = TftpDaemon(logger, cfgparser, daemon) + daemon = TftpDaemon(logger, cfgparser, debug, + daemons.get('bootp', None)) daemon.start() - if daemon: - while True: - daemon.join(0.5) + daemons['tftp'] = daemon + if args.http: + daemon = HttpDaemon(logger, cfgparser, debug, daemons.get('bootp')) + daemon.start() + daemons['http'] = daemon + resume = True + while daemons: + zombies = set() + for name, daemon in daemons.items(): + if not resume: + daemon.stop() + daemon.join(0.1) if not daemon.is_alive(): - break - except Exception as e: - print_('\nError: %s' % e, file=sys.stderr) + logger.warn('%s daemon terminated', name) + zombies.add(name) + resume = False + for name in zombies: + del daemons[name] + except Exception as exc: + print('\nError: %s' % exc, file=stderr) if debug: - import traceback - print_(traceback.format_exc(), file=sys.stderr) - sys.exit(1) + print(format_exc(chain=False), file=stderr) + sysexit(1) except KeyboardInterrupt: - print_("Aborting...") + print("\nAborting...", file=stderr) + sysexit(2) diff --git a/pybootd/etc/pybootd.ini b/pybootd/etc/pybootd.ini index 8a41793..0167aae 100644 --- a/pybootd/etc/pybootd.ini +++ b/pybootd/etc/pybootd.ini @@ -2,7 +2,7 @@ type = stderr level = info -[bootp] +[bootpd] address = 0.0.0.0 ; pool_start should be in a valid subnet pool_start = 192.168.25.100 @@ -13,14 +13,14 @@ boot_file = pxelinux.0 lease_time = 86400 access = mac allow_simple_dhcp = enable -dns = 10.130.0.2 -boot_file = pxelinux.0 +dns = 8.8.8.8 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 [mac] -00-1E-4F-C4-95-EE = enable +; see doc: byte separator should be defined with dash, not column +00-AA-55-12-34-56 = enable [uuid] 12345678-abcd-ef00-1111-abcdefabcdef = enable @@ -32,10 +32,16 @@ pxe = boot dhcp = linux always_check = disable -[tftp] +[tftpd] ;address = (use address from bootpd) -;root = ./images -root = http://http.us.debian.org/debian/dists/squeeze/main/installer-amd64/current/images/netboot +root = ./images +;root = http://http.us.debian.org/debian/dists/squeeze/main/installer-amd64/current/images/netboot + +[httpd] +; address = (use address from bootpd) +port = 8000 +check_ip = yes +root = /tmp [filters] ;pxelinux.cfg/* = pybootd/etc/pxe.cfg diff --git a/pybootd/httpd.py b/pybootd/httpd.py new file mode 100755 index 0000000..d5fdbed --- /dev/null +++ b/pybootd/httpd.py @@ -0,0 +1,99 @@ +# Copyright (c) 2010-2019 Emmanuel Blot +# Copyright (c) 2010-2011 Neotion +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +"""HTTPd tiny server to exercise the pybootd daemon""" + +from argparse import ArgumentParser, FileType +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from os import getcwd +from os.path import isfile, join as joinpath, realpath +from sys import exit as sysexit, modules, stderr +from traceback import format_exc +from urllib.parse import parse_qs, urlsplit +from .util import logger_factory, to_bool, to_int, EasyConfigParser + + +#pylint: disable-msg=broad-except +#pylint: disable-msg=missing-docstring +#pylint: disable-msg=invalid-name + + +class HttpRequestHandler(SimpleHTTPRequestHandler): + + def __init__(self, *args, **kwargs): + super(HttpRequestHandler, self).__init__(*args, **kwargs) + + def do_HEAD(self): + if self._validate(): + super(HttpRequestHandler, self).do_HEAD() + + def do_GET(self): + if self._validate(): + super(HttpRequestHandler, self).do_GET() + + def log_request(self, code='-', size='-'): + self.server.log.info('Request: %s %s', code, size) + + def log_error(self, fmt, *args): + self.server.log.error(fmt, *args) + + def _validate(self): + log = self.server.log + bootpd = self.server.bootpd + if bootpd and self.server.check_ip: + ipaddr = self.client_address[0] + if not bootpd.is_managed_ip(ipaddr): + log.info('Unknown IP: %s', ipaddr) + self.send_error(401, 'Not authorized') + return False + log.debug("Request: %s" % self.path) + path = realpath(joinpath(self.directory, self.path.lstrip('/'))) + if not path.startswith(self.directory): + log.info('Malformed path: %s', path) + self.send_error(403, 'Forbidden') + return False + if not isfile(path): + log.info('Invalid path: %s', path) + self.send_error(404, 'Not found') + return False + return True + +class HttpServer(ThreadingHTTPServer): + + HTTP_SECTION = 'httpd' + + def __init__(self, logger, config, bootpd=None): + self.log = logger + self.config = config + self.bootpd = bootpd + netconfig = bootpd and bootpd.get_netconfig() + address = (self.config.get(self.HTTP_SECTION, 'address', + netconfig and netconfig['server']), + int(self.config.get(self.HTTP_SECTION, 'port', '80'))) + root = realpath(self.config.get(self.HTTP_SECTION, 'root', None)) + self.check_ip = to_bool(self.config.get(self.HTTP_SECTION, 'check_ip', + 'yes')) + self.log.info('Listening to %s:%s' % address) + handler = partial(HttpRequestHandler, directory=root) + super(HttpServer, self).__init__(address, handler) + + def start(self): + self.serve_forever() + + def stop(self): + self.shutdown() diff --git a/pybootd/pxed.py b/pybootd/pxed.py index cc3ace3..1c13ac4 100644 --- a/pybootd/pxed.py +++ b/pybootd/pxed.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -17,27 +15,40 @@ # 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 re -import select -import socket -import string -import struct -import sys -import time from binascii import hexlify -from pybootd import PRODUCT_NAME +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 +#pylint: disable-msg=too-many-return-statements +#pylint: disable-msg=too-many-branches +#pylint: disable-msg=too-many-locals +#pylint: disable-msg=too-many-statements +#pylint: disable-msg=too-many-nested-blocks + + BOOTP_PORT_REQUEST = 67 BOOTP_PORT_REPLY = 68 BOOTREQUEST = 1 BOOTREPLY = 2 -BOOTPFormat = '!4bIHH4s4s4s4s16s64s128s64s' -BOOTPFormatSize = struct.calcsize(BOOTPFormat) -DHCPFormat = '!4bIHH4s4s4s4s16s64s128s4s' -DHCPFormatSize = struct.calcsize(DHCPFormat) +BOOTPFORMAT = '!4bIHH4s4s4s4s16s64s128s64s' +BOOTPFORMATSIZE = scalc(BOOTPFORMAT) +DHCPFORMAT = '!4bIHH4s4s4s4s16s64s128s4s' +DHCPFORMATSIZE = scalc(DHCPFORMAT) (BOOTP_OP, BOOTP_HTYPE, BOOTP_HLEN, BOOTP_HOPS, BOOTP_XID, BOOTP_SECS, BOOTP_FLAGS, BOOTP_CIADDR, BOOTP_YIADDR, BOOTP_SIADDR, BOOTP_GIADDR, @@ -46,7 +57,7 @@ DHCPFormatSize = struct.calcsize(DHCPFormat) BOOTP_FLAGS_NONE = 0 BOOTP_FLAGS_BROADCAST = 1<<15 -COOKIE='\0x63\0x82\0x53\0x63' +COOKIE = r'\0x63\0x82\0x53\0x63' DHCP_OPTIONS = {0: 'Byte padding', 1: 'Subnet mask', @@ -169,6 +180,8 @@ class BootpServer: ACCESS_REMOTE = ['http'] # Access modes, remotely retrieved (ST_IDLE, ST_PXE, ST_DHCP) = range(3) # Current state + BOOTP_SECTION = 'bootpd' + def __init__(self, logger, config): self.sock = [] self.log = logger @@ -177,26 +190,24 @@ class BootpServer: self.ippool = {} # key MAC address string, value assigned IP string self.filepool = {} # key IP string, value pathname self.states = {} # key MAC address string, value client state - name_ = PRODUCT_NAME.split('-') - name_[0] = 'bootp' - self.bootp_section = '_'.join(name_) - self.pool_start = self.config.get(self.bootp_section, 'pool_start') + self.pool_start = self.config.get(self.BOOTP_SECTION, 'pool_start') if not self.pool_start: raise BootpError('Missing pool_start definition') - self.pool_count = int(self.config.get(self.bootp_section, + self.pool_count = int(self.config.get(self.BOOTP_SECTION, 'pool_count', '10')) self.netconfig = get_iface_config(self.pool_start) if not self.netconfig: - host = self.config.get(self.bootp_section, 'address', '0.0.0.0') + host = self.config.get(self.BOOTP_SECTION, 'address', '0.0.0.0') self.netconfig = get_iface_config(host) if not self.netconfig: - raise BootpError('Unable to detect network configuration') + # the available networks on the host may not match the config... + raise BootpError('Unable to detect a matching network config') keys = sorted(self.netconfig.keys()) self.log.info('Using %s' % ', '.join(map( ':'.join, zip(keys, [self.netconfig[k] for k in keys])))) - nlist = self.config.get(self.bootp_section, 'notify') + nlist = self.config.get(self.BOOTP_SECTION, 'notify') self.notify = [] if nlist: try: @@ -204,9 +215,9 @@ class BootpServer: for n in nlist: n = n.strip().split(':') self.notify.append((n[0], int(n[1]))) - except Exception, e: - raise BootpError('Invalid notification URL: %s' % str(e)) - access = self.config.get(self.bootp_section, 'access') + except Exception as exc: + raise BootpError('Invalid notification URL: %s' % exc) + access = self.config.get(self.BOOTP_SECTION, 'access') if not access: self.acl = None else: @@ -235,7 +246,7 @@ class BootpServer: msg = ','.join([notice, uuid_str, mac_str, ip]) else: msg = ','.join([notice, mac_str, ip]) - notify_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + notify_sock = socket(AF_INET, SOCK_DGRAM) for n in self.notify: self.log.info('Notifying %s with %s' % (n, msg)) notify_sock.sendto(msg, n) @@ -244,42 +255,52 @@ class BootpServer: def get_netconfig(self): return self.netconfig - def bind(self): - host = self.config.get(self.bootp_section, 'address', '0.0.0.0') - port = self.config.get(self.bootp_section, 'port', + def is_managed_ip(self, address): + return address in self.ippool.values() + + def start(self): + host = self.config.get(self.BOOTP_SECTION, 'address', '0.0.0.0') + port = self.config.get(self.BOOTP_SECTION, 'port', str(BOOTP_PORT_REQUEST)) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, - socket.IPPROTO_UDP) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) + sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1) + sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) self.sock.append(sock) self.log.info('Listening to %s:%s' % (host, port)) sock.bind((host, int(port))) - - def forever(self): - while True: + self._resume = True + while self._resume: try: - r, w, e = select.select(self.sock, [], self.sock) + r = select(self.sock, [], self.sock, 0.25)[0] + if not r: + continue for sock in r: data, addr = sock.recvfrom(556) self.handle(sock, addr, data) - except Exception, e: - import traceback - self.log.critical('%s\n%s' % (str(e), traceback.format_exc())) - time.sleep(1) + except Exception as exc: + self.log.critical('%s\n%s' % (exc, format_exc())) + sleep(1) + + def stop(self): + self._resume = False def parse_options(self, tail): self.log.debug('Parsing DHCP options') dhcp_tags = {} + padding_count = 0 while tail: - tag = ord(tail[0]) + tag = tail[0] # padding if tag == 0: + padding_count += 1 + if padding_count > 255: + raise ValueError('Padding overflow') continue + padding_count = 0 if tag == 0xff: return dhcp_tags - length = ord(tail[1]) - (value, ) = struct.unpack('!%ss' % length, tail[2:2+length]) + length = tail[1] + (value, ) = sunpack('!%ss' % length, tail[2:2+length]) tail = tail[2+length:] try: option = DHCP_OPTIONS[tag] @@ -292,47 +313,45 @@ class BootpServer: dhcp_tags[tag] = value def build_pxe_options(self, options, server): + buf = b'' try: - buf = '' uuid = options[97] - buf += struct.pack('!BB%ds' % len(uuid), - 97, len(uuid), uuid) + buf += spack('!BB%ds' % len(uuid), + 97, len(uuid), uuid) clientclass = options[60] clientclass = clientclass[:clientclass.find(':')] - buf += struct.pack('!BB%ds' % len(clientclass), - 60, len(clientclass), clientclass) + buf += spack('!BB%ds' % len(clientclass), + 60, len(clientclass), clientclass) vendor = '' - vendor += struct.pack('!BBB', PXE_DISCOVERY_CONTROL, 1, 0x0A) - vendor += struct.pack('!BBHB4s', PXE_BOOT_SERVERS, 2+1+4, - 0, 1, server) + vendor += spack('!BBB', PXE_DISCOVERY_CONTROL, 1, 0x0A) + vendor += spack('!BBHB4s', PXE_BOOT_SERVERS, 2+1+4, + 0, 1, server) srvstr = 'Python' - vendor += struct.pack('!BBHB%ds' % len(srvstr), PXE_BOOT_MENU, - 2+1+len(srvstr), 0, len(srvstr), srvstr) + vendor += spack('!BBHB%ds' % len(srvstr), PXE_BOOT_MENU, + 2+1+len(srvstr), 0, len(srvstr), srvstr) prompt = 'Stupid PXE' - vendor += struct.pack('!BBB%ds' % len(prompt), PXE_MENU_PROMPT, - 1+len(prompt), len(prompt), prompt) - buf += struct.pack('!BB%ds' % len(vendor), 43, - len(vendor), vendor) - buf += struct.pack('!BBB', 255, 0, 0) + vendor += spack('!BBB%ds' % len(prompt), PXE_MENU_PROMPT, + 1+len(prompt), len(prompt), prompt) + buf += spack('!BB%ds' % len(vendor), 43, + len(vendor), vendor) + buf += spack('!BBB', 255, 0, 0) return buf - except KeyError, e: - self.log.error('Missing options, cancelling: ' + str(e)) - return None + except KeyError as exc: + self.log.error('Missing options, cancelling: %s' % exc) + return b'' def build_dhcp_options(self, clientname): - buf = '' if not clientname: - return buf - buf += struct.pack('!BB%ds' % len(clientname), - 12, len(clientname), clientname) - return buf + return b'' + return spack('!BB%ds' % len(clientname), + 12, len(clientname), clientname) def handle(self, sock, addr, data): self.log.info('Sender: %s on socket %s' % (addr, sock.getsockname())) - if len(data) < DHCPFormatSize: + if len(data) < DHCPFORMATSIZE: self.log.error('Cannot be a DHCP or BOOTP request - too small!') - tail = data[DHCPFormatSize:] - buf = list(struct.unpack(DHCPFormat, data[:DHCPFormatSize])) + tail = data[DHCPFORMATSIZE:] + buf = list(sunpack(DHCPFORMAT, data[:DHCPFORMATSIZE])) if buf[BOOTP_OP] != BOOTREQUEST: self.log.warn('Not a BOOTREQUEST') return @@ -343,13 +362,13 @@ class BootpServer: # Extras (DHCP options) try: - dhcp_msg_type = ord(options[53][0]) + dhcp_msg_type = options[53][0] except KeyError: dhcp_msg_type = None server_addr = self.netconfig['server'] mac_addr = buf[BOOTP_CHADDR][:6] - mac_str = ':'.join(['%02X' % ord(x) for x in 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:] @@ -360,9 +379,9 @@ class BootpServer: uuid = self.uuidpool.get(mac_addr, None) 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() + 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() if uuid_str: self.log.info('UUID is %s for MAC %s' % (uuid_str, mac_str)) @@ -388,12 +407,12 @@ class BootpServer: # if the state has not evolved from idle, there is nothing to do if newstate == self.ST_IDLE: - self.log.info('Request from %s ignored (idle state)' % mac_str) sdhcp = 'allow_simple_dhcp' simple_dhcp = \ - self.config.has_option(self.bootp_section, sdhcp) and \ - to_bool(self.config.get(self.bootp_section, sdhcp)) + self.config.has_option(self.BOOTP_SECTION, sdhcp) and \ + to_bool(self.config.get(self.BOOTP_SECTION, sdhcp)) if not simple_dhcp: + self.log.info('Request from %s ignored (idle state)' % mac_str) return if not dhcp_msg_type: # Legacy DHCP: assuming discover by default @@ -404,8 +423,6 @@ class BootpServer: # remote access is always validated on each request if self.access in self.ACCESS_REMOTE: # need to query a host to grant or reject access - import urlparse - import urllib netloc = self.config.get(self.access, 'location') path = self.config.get(self.access, pxe and 'pxe' or 'dhcp') timeout = int(self.config.get(self.access, 'timeout', '5')) @@ -422,14 +439,12 @@ class BootpServer: if to_bool(always_check): checkhost = True if checkhost: - query = urllib.urlencode(parameters) + query = urlencode(parameters) urlparts = (self.access, netloc, path, query, '') - url = urlparse.urlunsplit(urlparts) + url = urlunsplit(urlparts) self.log.info('Requesting URL: %s' % url) - import urllib2 - import httplib try: - up = urllib2.urlopen(url, timeout=timeout) + up = urlopen(url, timeout=timeout) for l in up: try: # Look for extra definition within the reply @@ -441,16 +456,12 @@ class BootpServer: filename = v except ValueError: pass - except urllib2.HTTPError, e: - self.log.error('HTTP Error: %s' % str(e)) + except HTTPError as exc: + self.log.error('HTTP Error: %s' % exc) self.states[mac_str] = self.ST_IDLE return - except urllib2.URLError, e: - self.log.critical('Internal error: %s' % str(e)) - self.states[mac_str] = self.ST_IDLE - return - except httplib.HTTPException, e: - self.log.error('Server error: %s' % type(e)) + except URLError as exc: + 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 @@ -476,9 +487,9 @@ class BootpServer: # construct reply buf[BOOTP_HOPS] = 0 buf[BOOTP_OP] = BOOTREPLY - self.log.info('Client IP: %s' % socket.inet_ntoa(buf[7])) - if buf[BOOTP_CIADDR] == '\x00\x00\x00\x00': - self.log.debug('Client needs its address') + ciaddr = buf[BOOTP_CIADDR] + if not sunpack('!I', ciaddr)[0]: + self.log.info('Client needs its address') ipaddr = iptoint(self.pool_start) ip = None if mac_str in self.ippool: @@ -486,7 +497,7 @@ class BootpServer: self.log.info('Lease for MAC %s already defined as IP %s' % (mac_str, ip)) else: - for idx in xrange(self.pool_count): + for idx in range(self.pool_count): ipkey = inttoip(ipaddr+idx) self.log.debug('Check for IP %s' % ipkey) if ipkey not in self.ippool.values(): @@ -497,32 +508,33 @@ class BootpServer: raise BootpError('No more IP available in definined pool') mask = iptoint(self.config.get( - self.bootp_section, 'netmask', self.netconfig['mask'])) + self.BOOTP_SECTION, 'netmask', self.netconfig['mask'])) reply_broadcast = iptoint(ip) & mask reply_broadcast |= (~mask) & ((1 << 32)-1) - buf[BOOTP_YIADDR] = socket.inet_aton(ip) + buf[BOOTP_YIADDR] = inet_aton(ip) buf[BOOTP_SECS] = 0 buf[BOOTP_FLAGS] = BOOTP_FLAGS_BROADCAST relay = buf[BOOTP_GIADDR] - if relay != b'\x00\x00\x00\x00': - addr = (socket.inet_ntoa(relay), addr[1]) + if sunpack('!I', relay)[0]: + addr = (inet_ntoa(relay), addr[1]) else: addr = (inttoip(reply_broadcast), addr[1]) self.log.info('Reply to: %s:%s' % addr) else: - buf[BOOTP_YIADDR] = buf[BOOTP_CIADDR] - ip = socket.inet_ntoa(buf[BOOTP_YIADDR]) - buf[BOOTP_SIADDR] = socket.inet_aton(server_addr) + self.log.info('Client IP: %s' % inet_ntoa(ciaddr)) + buf[BOOTP_YIADDR] = ciaddr + ip = inet_ntoa(buf[BOOTP_YIADDR]) + buf[BOOTP_SIADDR] = inet_aton(server_addr) # sname buf[BOOTP_SNAME] = \ - '.'.join([self.config.get(self.bootp_section, + '.'.join([self.config.get(self.BOOTP_SECTION, 'servername', 'unknown'), - self.config.get(self.bootp_section, - 'domain', 'localdomain')]) + self.config.get(self.BOOTP_SECTION, + 'domain', 'localdomain')]).encode() # file - buf[BOOTP_FILE] = self.config.get(self.bootp_section, - 'boot_file', '\x00') + buf[BOOTP_FILE] = self.config.get(self.BOOTP_SECTION, + 'boot_file', '\x00').encode() if not dhcp_msg_type: self.log.warn('No DHCP message type found, discarding request') @@ -566,38 +578,37 @@ class BootpServer: else: self.log.debug('No filename defined for IP %s' % ip) - pkt = struct.pack(DHCPFormat, *buf) - pkt += struct.pack('!BBB', DHCP_MSG, 1, dhcp_reply) - server = socket.inet_aton(server_addr) - pkt += struct.pack('!BB4s', DHCP_SERVER, 4, server) + pkt = spack(DHCPFORMAT, *buf) + pkt += spack('!BBB', DHCP_MSG, 1, dhcp_reply) + server = inet_aton(server_addr) + pkt += spack('!BB4s', DHCP_SERVER, 4, server) - mask = socket.inet_aton(self.config.get( - self.bootp_section, 'netmask', self.netconfig['mask'])) + mask = inet_aton(self.config.get( + self.BOOTP_SECTION, 'netmask', self.netconfig['mask'])) - pkt += struct.pack('!BB4s', DHCP_IP_MASK, 4, mask) + pkt += spack('!BB4s', DHCP_IP_MASK, 4, mask) - gateway_addr = self.config.get(self.bootp_section, 'gateway', '') + gateway_addr = self.config.get(self.BOOTP_SECTION, 'gateway', '') if gateway_addr: - gateway = socket.inet_aton(gateway_addr) + gateway = inet_aton(gateway_addr) else: gateway = server - pkt += struct.pack('!BB4s', DHCP_IP_GATEWAY, 4, gateway) + pkt += spack('!BB4s', DHCP_IP_GATEWAY, 4, gateway) - dns = self.config.get(self.bootp_section, + dns = self.config.get(self.BOOTP_SECTION, 'dns', None) if dns: if dns.lower() == 'auto': - dns_list = self.get_dns_servers() or [socket.inet_ntoa(server)] + dns_list = self.get_dns_servers() or [inet_ntoa(server)] else: dns_list = dns.split(';') for dns_str in dns_list: - dns_ip = socket.inet_aton(dns_str) - pkt += struct.pack('!BB4s', DHCP_IP_DNS, 4, dns_ip) - pkt += struct.pack('!BBI', DHCP_LEASE_TIME, 4, - int(self.config.get(self.bootp_section, + 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)))) - pkt += struct.pack('!BB', DHCP_END, 0) # do not attempt to produce a PXE-augmented response for # regular DHCP requests @@ -608,12 +619,15 @@ class BootpServer: else: extra_buf = self.build_dhcp_options(hostname) + pkt += extra_buf + pkt += spack('!BB', DHCP_END, 0) + # update the UUID cache if pxe: self.uuidpool[mac_addr] = uuid # send the response - sock.sendto(pkt + extra_buf, addr) + sock.sendto(pkt, addr) # update the current state if currentstate != newstate: @@ -622,7 +636,7 @@ class BootpServer: self.states[mac_str] = newstate def get_dns_servers(self): - nscre = re.compile('nameserver\s+(\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})\s') + nscre = recompile(r'nameserver\s+(\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})\s') result = [] try: with open('/etc/resolv.conf', 'r') as resolv: @@ -632,7 +646,7 @@ class BootpServer: dns = mo.group(1) self.log.info('Found nameserver: %s' % dns) result.append(dns) - except Exception, e: + except Exception: pass if not result: self.log.info('No nameserver found') diff --git a/pybootd/tests/httpd.py b/pybootd/tests/httptestd.py similarity index 62% rename from pybootd/tests/httpd.py rename to pybootd/tests/httptestd.py index fcb67d2..9b8fede 100755 --- a/pybootd/tests/httpd.py +++ b/pybootd/tests/httptestd.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +#!/usr/bin/env python3 # -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -18,14 +17,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 sys -import urlparse -from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler -from optparse import OptionParser -from util import logger_factory, to_bool, to_int, EasyConfigParser +"""HTTPd tiny server to exercise the pybootd daemon""" + +from argparse import ArgumentParser, FileType +from http.server import BaseHTTPRequestHandler, HTTPServer +from sys import exit as sysexit, modules, stderr +from traceback import format_exc +from urllib.parse import parse_qs, urlsplit +from pybootd.util import logger_factory, to_bool, to_int, EasyConfigParser -class HttpdDaemon(HTTPServer): +#pylint: disable-msg=broad-except +#pylint: disable-msg=missing-docstring +#pylint: disable-msg=invalid-name + + +class HttpdTestDaemon(HTTPServer): class ReqHandler(BaseHTTPRequestHandler): @@ -33,8 +40,9 @@ class HttpdDaemon(HTTPServer): log = self.server.log log.debug("GET from %s:%d" % self.client_address) log.debug("Request: %s" % self.path) - urlparts = urlparse.urlsplit(self.path) - query = urlparse.parse_qs(urlparts.query) + urlparts = urlsplit(self.path) + query = parse_qs(urlparts.query) + uuid = '' if urlparts.path in ('/boot', '/linux'): if 'uuid' in query: uuids = query['uuid'] @@ -79,30 +87,39 @@ class HttpdDaemon(HTTPServer): self.serve_forever() -if __name__ == "__main__": - usage = 'Usage: %prog [options]\n' \ - ' HTTPd tiny server to exercise the pybootd daemon' - optparser = OptionParser(usage=usage) - optparser.add_option('-c', '--config', dest='config', - help='configuration file') - (options, args) = optparser.parse_args(sys.argv[1:]) - - if not options.config: - raise AssertionError('Missing configuration file') - - cfgparser = EasyConfigParser() - with open(options.config, 'rt') as config: - cfgparser.readfp(config) - - logger = logger_factory(logtype=cfgparser.get('logger', 'type', 'stderr'), - logfile=cfgparser.get('logger', 'file'), - level=cfgparser.get('logger', 'level', 'info')) - +def main(): + debug = False try: - bt = HttpdDaemon(logger, cfgparser) + argparser = ArgumentParser(description=modules[__name__].__doc__) + argparser.add_argument('-c', '--config', dest='config', required=True, + type=FileType('rt'), + help='configuration file') + argparser.add_argument('-d', '--debug', action='store_true', + help='enable debug mode') + args = argparser.parse_args() + + cfgparser = EasyConfigParser() + cfgparser.read_file(args.config) + + logger = logger_factory(logtype=cfgparser.get('logger', 'type', + 'stderr'), + logfile=cfgparser.get('logger', 'file'), + level=cfgparser.get('logger', 'level', 'info')) + + bt = HttpdTestDaemon(logger, cfgparser) bt.start() while True: import time time.sleep(5) + except Exception as exc: + print('\nError: %s' % exc, file=stderr) + if debug: + print(format_exc(chain=False), file=stderr) + sysexit(1) except KeyboardInterrupt: - print "Aborting..." + print("\nAborting...", file=stderr) + sysexit(2) + + +if __name__ == '__main__': + main() diff --git a/pybootd/tftpd.py b/pybootd/tftpd.py index 6318b3d..39ef502 100644 --- a/pybootd/tftpd.py +++ b/pybootd/tftpd.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -18,29 +16,43 @@ # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import os -import re -import select -import socket -import string -import struct -import sys -import time -import thread -import urllib2 -import urlparse -from ConfigParser import NoSectionError -from cStringIO import StringIO -from pybootd import pybootd_path -from util import hexline +from configparser import NoSectionError +from io import StringIO +from re import compile as recompile, sub as resub +from select import select +from socket import socket, AF_INET, SOCK_DGRAM +from struct import pack as spack, unpack as sunpack +from sys import argv, exc_info +from threading import Thread +from time import time as now +from traceback import format_exc +from urllib.parse import urlparse, urlsplit +from urllib.request import urlopen +from . import pybootd_path +from .util import hexline + +#pylint: disable-msg=broad-except +#pylint: disable-msg=invalid-name +#pylint: disable-msg=missing-docstring -__all__ = ['TftpServer'] TFTP_PORT = 69 -class TftpError(AssertionError): +class TftpError(RuntimeError): + (NOT_DEF, + FILE_NOT_FOUND, + ACCESS_ERROR, + ALLOC_EXCEED, + ILLEGAL, + UNKNOWN_ID, + ALREADY_EXIST, + NO_SUCH_USER) = range(8) + """Any TFTP error""" - pass + def __init__(self, code, msg): + super(TftpError, self).__init__(msg) + self.code = code class TftpConnection(object): @@ -50,6 +62,7 @@ class TftpConnection(object): ACK = 4 ERR = 5 OACK = 6 + HDRSIZE = 4 # number of bytes for OPCODE and BLOCK in header def __init__(self, server, port=0): @@ -70,7 +83,7 @@ class TftpConnection(object): def _bind(self, host='', port=TFTP_PORT): self.log.debug('bind %s:%d' % (host, port)) - self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock = socket(AF_INET, SOCK_DGRAM) if host or port: self.sock.bind((host, port)) @@ -86,7 +99,7 @@ class TftpConnection(object): timeout = self.timeout retry = self.server.retry while retry: - r, w, e = select.select([fno], [], [fno], timeout) + r = select([fno], [], [fno], timeout)[0] if not r: # We timed out -- retransmit retry = retry - 1 @@ -98,7 +111,7 @@ class TftpConnection(object): if addr == client_addr: break else: - raise TftpError(4, 'Transfer timed out') + raise TftpError(TftpError.ALLOC_EXCEED, 'Transfer timed out') # end while return self.parse(data) @@ -121,14 +134,15 @@ class TftpConnection(object): path = self.server.bootpd.get_filename(client_ip) return path - def parse(self, data, unpack=struct.unpack): + def parse(self, data, unpack=sunpack): self.log.debug('parse') - buf = buffer(data) + buf = data pkt = {} opcode = pkt['opcode'] = unpack('!h', buf[:2])[0] if (opcode == self.RRQ) or (opcode == self.WRQ): - resource, mode, options = string.split(data[2:], '\000', 2) - resource = self.server.fcre.sub(self._filter_file, resource) + resource, mode, options = data[2:].split(b'\x00', 2) + resource = self.server.fcre.sub(self._filter_file, + resource.decode()) if self.server.root and self.is_url(self.server.root): resource = '%s/%s' % (self.server.root, resource) else: @@ -138,7 +152,7 @@ class TftpConnection(object): if not self.server.genfilecre.match(resource): if resource.startswith('^%s' % os.sep): resource = os.path.join( - os.path.dirname(sys.argv[0]), + os.path.dirname(argv[0]), resource.lstrip('^%s' % os.sep)) elif self.server.root: if self.server.root.startswith(os.sep): @@ -147,7 +161,7 @@ class TftpConnection(object): resource) else: # Relative root directory, from the daemon path - daemonpath = os.path.dirname(sys.argv[0]) + daemonpath = os.path.dirname(argv[0]) if not daemonpath.startswith(os.sep): daemonpath = os.path.normpath( os.path.join(os.getcwd(), daemonpath)) @@ -158,7 +172,7 @@ class TftpConnection(object): pkt['filename'] = resource pkt['mode'] = mode while options: - key, value, options = options.split('\000', 2) + key, value, options = options.split(b'\x00', 2) if key == 'blksize': self.blocksize = int(value) elif key == 'timeout': @@ -173,7 +187,7 @@ class TftpConnection(object): errnum = pkt['errnum'] = unpack('!h', buf[2:4])[0] errtxt = pkt['errtxt'] = buf[4:-1] else: - raise TftpError(4, 'Unknown packet type') + raise TftpError(TftpError.ILLEGAL, 'Unknown packet type') return pkt def retransmit(self): @@ -194,7 +208,7 @@ class TftpConnection(object): pkt = self.parse(data) opcode = pkt['opcode'] if opcode not in (RRQ, WRQ): - raise TftpError(4, 'Bad request') + raise TftpError(TftpError.ILLEGAL, 'Bad request') # Start lock-step transfer self.active = 1 @@ -215,13 +229,12 @@ class TftpConnection(object): elif opcode == ERR: self.recv_err(pkt) else: - raise TftpError(5, 'Invalid opcode') + raise TftpError(TftpError.ILLEGAL, 'Invalid opcode') self.log.debug('End of active: %s:%s' % addr) - except TftpError, detail: - self.send_error(detail[0], detail[1]) - except: - import traceback - self.log.error(traceback.format_exc()) + except TftpError as exc: + self.send_error(exc.code, str(exc)) + except Exception: + self.log.error(format_exc(chain=False)) self.log.debug('Ending connection %s:%s' % addr) def recv_ack(self, pkt): @@ -245,19 +258,19 @@ class TftpConnection(object): self.handle_err(pkt) self.retransmit() - def send_data(self, data, pack=struct.pack): + def send_data(self, data, pack=spack): self.log.debug('send_data') if not self.time: - self.time = time.time() + self.time = now() blocksize = self.blocksize block = self.blockNumber = self.blockNumber + 1 lendata = len(data) - format = '!hh%ds' % lendata - pkt = pack(format, self.DATA, block, data) + fmt = '!hh%ds' % lendata + pkt = pack(fmt, self.DATA, block, data) self.send(pkt) self.active = (len(data) == blocksize) if not self.active and self.time: - total = time.time()-self.time + total = now()-self.time self.time = 0 try: name = self.file.name @@ -270,31 +283,30 @@ class TftpConnection(object): except AttributeError: # StringIO does not have a 'name' attribute pass - except Exception: - import traceback - traceback.print_exc() - pass + except Exception as exc: + self.log.error('Error: %s' % exc) + self.log.warn('%s', format_exc(chain=False)) - def send_ack(self, pack=struct.pack): + def send_ack(self, pack=spack): self.log.debug('send_ack') block = self.blockNumber self.blockNumber = self.blockNumber + 1 - format = '!hh' - pkt = pack(format, self.ACK, block) + fmt = '!hh' + pkt = pack(fmt, self.ACK, block) self.send(pkt) - def send_error(self, errnum, errtext, pack=struct.pack): + def send_error(self, errnum, errtext, pack=spack): self.log.debug('send_error') - errtext = errtext + '\000' - format = '!hh%ds' % len(errtext) - outdata = pack(format, self.ERR, errnum, errtext) + errtext = errtext.encode() + b'\x00' + fmt = '!hh%ds' % len(errtext) + outdata = pack(fmt, self.ERR, errnum, errtext) self.sock.sendto(outdata, self.client_addr) - def send_oack(self, options, pack=struct.pack): + def send_oack(self, options, pack=spack): self.log.debug('send_oack') pkt = pack('!h', self.OACK) for k, v in options: - pkt += k + '\x00' + v + '\x00' + pkt += k.encode() + b'\x00' + v.encode() + b'\x00' self.send(pkt) # clear out the last packet buffer to prevent from retransmitting it self.lastpkt = '' @@ -310,14 +322,15 @@ class TftpConnection(object): else: try: if self.is_url(resource): - rp = urllib2.urlopen(resource) + rp = urlopen(resource) meta = rp.info() filesize = int(meta.getheaders('Content-Length')[0]) else: filesize = os.stat(resource)[6] except Exception: self.active = False - self.send_error(1, 'Cannot access resource') + self.send_error(TftpError.FILE_NOT_FOUND, + 'Cannot access resource') self.log.warn('Cannot stat resource %s' % resource) return self.log.info('Send size request file %s size: %d' % @@ -332,16 +345,17 @@ class TftpConnection(object): else: try: if self.is_url(resource): + self.file = urlopen(resource) self.log.info("Sending resource '%s'" % resource) - self.file = urllib2.urlopen(resource) else: resource = os.path.realpath(resource) - self.log.info("Sending file '%s'" % resource) self.file = open(resource, 'rb') - except Exception: - self.send_error(1, 'Cannot open resource') + self.log.info("Sending file '%s'" % resource) + except Exception as exc: + self.send_error(TftpError.FILE_NOT_FOUND, + 'Cannot open resource') self.log.warn('Cannot open file for reading %s: %s' % - sys.exc_info()[:2]) + (resource, exc)) return if 'tsize' not in pkt: self.send_data(self.file.read(self.blocksize)) @@ -357,9 +371,9 @@ class TftpConnection(object): self.log.info('Receiving file: %s' % resource) self.file = open(resource, 'wb') except: - self.send_error(1, 'Cannot open file') + self.send_error(TftpError.FILE_NOT_FOUND, 'Cannot open file') self.log.error('Cannot open file for writing %s: %s' % - sys.exc_info()[:2]) + exc_info()[:2]) return self.send_ack() @@ -379,7 +393,7 @@ class TftpConnection(object): @staticmethod def is_url(path): - return bool(urlparse.urlsplit(path).scheme) + return bool(urlsplit(path).scheme) class TftpServer: @@ -388,40 +402,48 @@ class TftpServer: Each request is handled in its own thread """ + TFTP_SECTION = 'tftpd' + def __init__(self, logger, config, bootpd=None): self.log = logger self.config = config self.sock = [] self.bootpd = bootpd - self.blocksize = int(self.config.get('tftp', 'blocksize', '512')) - self.timeout = float(self.config.get('tftp', 'timeout', '2.0')) - self.retry = int(self.config.get('tftp', 'blocksize', '5')) - self.root = self.config.get('tftp', 'root', os.getcwd()) + self.blocksize = int(self.config.get(self.TFTP_SECTION, 'blocksize', + '512')) + self.timeout = float(self.config.get(self.TFTP_SECTION, 'timeout', '2.0')) + self.retry = int(self.config.get(self.TFTP_SECTION, 'blocksize', '5')) + self.root = self.config.get(self.TFTP_SECTION, 'root', os.getcwd()) self.fcre, self.filepatterns = self.get_file_filters() - self.genfilecre = re.compile(r'\[(?P[\w\.\-]+)\]') + self.genfilecre = recompile(r'\[(?P[\w\.\-]+)\]') + self._resume = False - def bind(self): + def start(self): netconfig = self.bootpd and self.bootpd.get_netconfig() - host = self.config.get('tftp', 'address', + host = self.config.get(self.TFTP_SECTION, 'address', netconfig and netconfig['server']) if not host: - raise TftpError('TFTP address no defined') - port = int(self.config.get('tftp', 'port', str(TFTP_PORT))) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + raise TftpError(TftpError.NO_SUCH_USER, 'TFTP address no defined') + port = int(self.config.get(self.TFTP_SECTION, 'port', str(TFTP_PORT))) + sock = socket(AF_INET, SOCK_DGRAM) self.sock.append(sock) sock.bind((host, port)) - - def forever(self): - while True: - if self.bootpd: - if not self.bootpd.is_alive(): - self.log.info('Bootp daemon is dead, exiting') - break - r, w, e = select.select(self.sock, [], self.sock) + self.log.info('Listening to %s:%s' % (host, port)) + self._resume = True + while self._resume: + r = select(self.sock, [], self.sock, 0.25)[0] + if not r: + continue for sock in r: data, addr = sock.recvfrom(516) - t = TftpConnection(self) - thread.start_new_thread(t.connect, (addr, data)) + tc = TftpConnection(self) + thread = Thread(target=tc.connect, args=(addr, data), + daemon=True) + thread.start() + + + def stop(self): + self._resume = False def filter_file(self, connexion, mo): # extract the position of the matching pattern, then extract the @@ -432,8 +454,9 @@ class TftpServer: if not filename: continue filepattern = self.filepatterns[group] - return re.sub(r'\{(\w+)\}', connexion._dynreplace, filepattern) - raise TftpError('Internal error, file matching pattern issue') + return resub(r'\{(\w+)\}', connexion._dynreplace, filepattern) + raise TftpError(TftpError.NOT_DEF, + 'Internal error, file matching pattern issue') def get_file_filters(self): patterns = [] @@ -442,12 +465,12 @@ class TftpServer: for pos, pattern in enumerate(self.config.options('filters'), 1): value = self.config.get('filters', pattern).strip() pattern = pattern.strip('\r\n \t') - pattern = pattern.replace('.', '\.') - pattern = pattern.replace('*', '.*').replace('?', '.') + pattern = pattern.replace(r'.', r'\.') + pattern = pattern.replace(r'*', r'.*').replace(r'?', r'.') pname = 'p%d' % pos replacements[pname] = value - patterns.append('(?P<%s>%s)' % (pname, pattern)) - xre = '^(?:\./)?(?:%s)$' % '|'.join(patterns) + patterns.append(r'(?P<%s>%s)' % (pname, pattern)) + xre = r'^(?:\./)?(?:%s)$' % r'|'.join(patterns) except NoSectionError: - xre = '^$' - return (re.compile(xre), replacements) + xre = r'^$' + return (recompile(xre), replacements) diff --git a/pybootd/util.py b/pybootd/util.py index e1a1c58..9a665d3 100644 --- a/pybootd/util.py +++ b/pybootd/util.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -17,23 +15,24 @@ # 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 array import array -import commands -import logging -import re -import socket -import struct -import sys -from ConfigParser import SafeConfigParser -from six import PY3, integer_types, binary_type +from configparser import ConfigParser, InterpolationSyntaxError +from logging import (DEBUG, INFO, ERROR, CRITICAL, WARNING, + Formatter, FileHandler, StreamHandler, getLogger) +from logging.handlers import (BufferingHandler, NTEventLogHandler, + SysLogHandler) +from re import match +from socket import inet_aton, inet_ntoa +from subprocess import run +from struct import pack as spack, unpack as sunpack +from sys import platform, stderr try: - import netifaces + import netifaces as nif except ImportError: - import os - if os.uname()[0].lower() == 'darwin': + if platform == 'darwin': raise ImportError('netifaces package is not installed') - netifaces = None + nif = None + # String values evaluated as true boolean values TRUE_BOOLEANS = ['on', 'high', 'true', 'enable', 'enabled', 'yes', '1'] @@ -56,9 +55,9 @@ def to_int(value): """ if not value: return 0 - if isinstance(value, integer_types): + if isinstance(value, int): return int(value) - mo = re.match('^\s*(\d+)\s*(?:([KMkm]i?)?B?)?\s*$', value) + mo = match(r'^\s*(\d+)\s*(?:([KMkm]i?)?B?)?\s*$', value) if mo: mult = {'K': (1000), 'KI': (1 << 10), @@ -108,7 +107,7 @@ def hexline(data, sep=' '): of the buffer data """ try: - if isinstance(data, (binary_type, array)): + if isinstance(data, bytes): src = bytearray(data) elif isinstance(data, bytearray): src = data @@ -128,20 +127,19 @@ def hexline(data, sep=' '): def logger_factory(logtype='syslog', logfile=None, level='WARNING', logid='PXEd', format=None): # this code has been copied from Trac (MIT modified license) - logger = logging.getLogger(logid) + logger = getLogger(logid) logtype = logtype.lower() if logtype == 'file': - hdlr = logging.FileHandler(logfile) + hdlr = FileHandler(logfile) elif logtype in ('winlog', 'eventlog', 'nteventlog'): # Requires win32 extensions - hdlr = logging.handlers.NTEventLogHandler(logid, - logtype='Application') + hdlr = NTEventLogHandler(logid, logtype='Application') elif logtype in ('syslog', 'unix'): - hdlr = logging.handlers.SysLogHandler('/dev/log') + hdlr = SysLogHandler('/dev/log') elif logtype in ('stderr'): - hdlr = logging.StreamHandler(sys.stderr) + hdlr = StreamHandler(stderr) else: - hdlr = logging.handlers.BufferingHandler(0) + hdlr = BufferingHandler(0) if not format: format = 'PXEd[%(module)s] %(levelname)s: %(message)s' @@ -152,45 +150,36 @@ def logger_factory(logtype='syslog', logfile=None, level='WARNING', datefmt = '%X' level = level.upper() if level in ('DEBUG', 'ALL'): - logger.setLevel(logging.DEBUG) + logger.setLevel(DEBUG) elif level == 'INFO': - logger.setLevel(logging.INFO) + logger.setLevel(INFO) elif level == 'ERROR': - logger.setLevel(logging.ERROR) + logger.setLevel(ERROR) elif level == 'CRITICAL': - logger.setLevel(logging.CRITICAL) + logger.setLevel(CRITICAL) else: - logger.setLevel(logging.WARNING) - formatter = logging.Formatter(format, datefmt) + logger.setLevel(WARNING) + formatter = Formatter(format, datefmt) hdlr.setFormatter(formatter) logger.addHandler(hdlr) - - def logerror(record): - import traceback - print_(record.msg) - print_(record.args) - traceback.print_exc() - # uncomment the following line to show logger formatting error - #hdlr.handleError = logerror - return logger def iptoint(ipstr): - return struct.unpack('!I', socket.inet_aton(ipstr))[0] + return sunpack('!I', inet_aton(ipstr))[0] def inttoip(ipval): - return socket.inet_ntoa(struct.pack('!I', ipval)) + return inet_ntoa(spack('!I', ipval)) def _netifaces_get_iface_config(address): pool = iptoint(address) - for iface in netifaces.interfaces(): - ifinfo = netifaces.ifaddresses(iface) - if netifaces.AF_INET not in ifinfo: + for iface in nif.interfaces(): + ifinfo = nif.ifaddresses(iface) + if nif.AF_INET not in ifinfo: continue - for inetinfo in netifaces.ifaddresses(iface)[netifaces.AF_INET]: + for inetinfo in nif.ifaddresses(iface)[nif.AF_INET]: addr_s = inetinfo.get('addr') netmask_s = inetinfo.get('netmask') if addr_s is None or netmask_s is None: @@ -213,7 +202,7 @@ def _netifaces_get_iface_config(address): def _iproute_get_iface_config(address): pool = iptoint(address) iplines = (line.strip() - for line in commands.getoutput("ip address show").split('\n')) + for line in run("ip address show").stdout.split('\n')) iface = None for l in iplines: items = l.split() @@ -240,17 +229,55 @@ def _iproute_get_iface_config(address): def get_iface_config(address): if not address: return None - if not netifaces: - return _iproute_get_iface_config(address) - return _netifaces_get_iface_config(address) + nifcfg = _netifaces_get_iface_config if nif else _iproute_get_iface_config + return nifcfg(address) -class EasyConfigParser(SafeConfigParser): - "ConfigParser extension to support default config values" +def is_quoted(str_): + """Tells whether a string is enclosed in simple- or double- quoted + markers""" + str_ = str_.strip() + return (str_.startswith('"') and str_.endswith('"')) or \ + (str_.startswith("'") and str_.endswith("'")) - def get(self, section, option, default=None): + +class EasyConfigParser(ConfigParser): + """ConfigParser extension to support default config values and do not + mess with multi-line option strings""" + + INDENT_SIZE = 8 + + InterpolationSyntaxError = InterpolationSyntaxError + + def get(self, section, option, default=None, raw=True, vars=None, + fallback=None): + """Return the section:option value if it exists, or the default value + if either the section or the option is missing""" if not self.has_section(section): return default if not self.has_option(section, option): return default - return SafeConfigParser.get(self, section, option) + return ConfigParser.get(self, section, option, raw=raw, vars=vars, + fallback=fallback) + + def write(self, filep): + """Write an .ini-format representation of the configuration state, + with automatic line wrapping, using improved multi-line + representation. + """ + for section in self._sections: + filep.write("[%s]\n" % section) + for (key, value) in self._sections[section].items(): + if key != "__name__": + filep.write("%s = %s\n" % + (key, str(value).replace('\n', '\n' + + ' ' * self.INDENT_SIZE))) + filep.write("\n") + + def _interpolate(self, section, option, rawval, vars): + # special overloading of SafeConfigParser._interpolate: + # do not attempt to interpolate if the string is (double-)quoted + if is_quoted(rawval): + return rawval + # cannot use 'super' here as ConfigParser is outdated + return ConfigParser._interpolate(self, section, option, rawval, vars) diff --git a/setup.py b/setup.py index 31cf3b9..10855cd 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +#!/usr/bin/env python3 # -# Copyright (c) 2010-2016 Emmanuel Blot +# Copyright (c) 2010-2019 Emmanuel Blot # Copyright (c) 2010-2011 Neotion # # This library is free software; you can redistribute it and/or @@ -18,22 +17,23 @@ # 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 from distutils.core import setup - +from os.path import dirname, join as joinpath +from sys import platform +from pybootd import __version__ def _read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() + return open(joinpath(dirname(__file__), fname)).read() -requirements = ['six'] -if os.uname()[0].lower() == 'darwin': +requirements = [] +if platform == 'darwin': requirements.append('netifaces (>= 0.5)') setup( name='pybootd', - version='1.6.0', + version=__version__, description='Simplified BOOTP/DHCP/PXE and TFTP server', author='Emmanuel Blot', author_email='emmanuel.blot@free.fr', @@ -52,7 +52,7 @@ setup( 'Lesser General Public License (LGPL)', 'Operating System :: MacOS :: MacOS X', 'Operating System :: POSIX', - 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.5', 'Topic :: Internet', 'Topic :: System :: Installation/Setup', 'Topic :: System :: Networking',