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

Merge branch 'python3'

This commit is contained in:
Emmanuel Blot 2019-09-06 18:56:37 +02:00
commit bbf0b15edb
12 changed files with 712 additions and 457 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
*.egg-info
*.pyc
*.pyo
MANIFEST
**/*-local.ini
dist/
build/

View File

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

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright (c) 2010-2016 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2011 Neotion
#
# This library is free software; you can redistribute it and/or

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2010-2016 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# 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

View File

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

View File

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

99
pybootd/httpd.py Executable file
View File

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

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2010-2016 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# 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')

View File

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

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2010-2016 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# 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<name>[\w\.\-]+)\]')
self.genfilecre = recompile(r'\[(?P<name>[\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)

View File

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2010-2016 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# 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)

View File

@ -1,7 +1,6 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#!/usr/bin/env python3
#
# Copyright (c) 2010-2016 Emmanuel Blot <emmanuel.blot@free.fr>
# Copyright (c) 2010-2019 Emmanuel Blot <emmanuel.blot@free.fr>
# 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',