diff --git a/README.rst b/README.rst index 3d9c238..ea1a0c5 100644 --- a/README.rst +++ b/README.rst @@ -5,16 +5,20 @@ 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 ~~~~~~~~~~~~ @@ -36,17 +40,21 @@ 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) @@ -70,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 ~~~ @@ -104,8 +126,9 @@ Options: -h, --help show this help message and exit -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 @@ -315,6 +338,28 @@ differentiation is useless, both options may refer to the same path. - 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 ..................... diff --git a/pybootd/daemons.py b/pybootd/daemons.py index c6a7d64..ea28d46 100755 --- a/pybootd/daemons.py +++ b/pybootd/daemons.py @@ -20,12 +20,14 @@ """Tiny BOOTP/DHCP/TFTP/PXE server""" 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 @@ -35,49 +37,72 @@ from .util import logger_factory, EasyConfigParser #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: argparser = ArgumentParser(description=modules[__name__].__doc__) - argparser.add_argument('-c', '--config', dest='config', + 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() @@ -100,18 +125,33 @@ def main(): 'info')) 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 + 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: diff --git a/pybootd/etc/pybootd.ini b/pybootd/etc/pybootd.ini index 1fbbe77..0167aae 100644 --- a/pybootd/etc/pybootd.ini +++ b/pybootd/etc/pybootd.ini @@ -37,6 +37,12 @@ always_check = disable 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 egg.info = [{filename}] 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 7ae5c03..b41c7a9 100644 --- a/pybootd/pxed.py +++ b/pybootd/pxed.py @@ -255,7 +255,10 @@ class BootpServer: def get_netconfig(self): return self.netconfig - def bind(self): + 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)) @@ -265,11 +268,12 @@ class BootpServer: 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 = select(self.sock, [], self.sock)[0] + 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) @@ -277,6 +281,9 @@ class BootpServer: 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 = {} diff --git a/pybootd/tests/httpd.py b/pybootd/tests/httptestd.py similarity index 100% rename from pybootd/tests/httpd.py rename to pybootd/tests/httptestd.py diff --git a/pybootd/tftpd.py b/pybootd/tftpd.py index 03990ac..39ef502 100644 --- a/pybootd/tftpd.py +++ b/pybootd/tftpd.py @@ -416,8 +416,9 @@ class TftpServer: self.root = self.config.get(self.TFTP_SECTION, 'root', os.getcwd()) self.fcre, self.filepatterns = self.get_file_filters() 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(self.TFTP_SECTION, 'address', netconfig and netconfig['server']) @@ -427,20 +428,23 @@ class TftpServer: 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 = select(self.sock, [], self.sock)[0] + 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) tc = TftpConnection(self) - thread = Thread(target=tc.connect, args=(addr, data)) + 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 # conversion string from the file convertion sequence