Introduction
Quite often I find that I need to serve some files for viewing in a web browser. Most recently, I needed to do this with an in progress OpenAPI document as rendered by ReDoc.
All I needed was something that can serve static files. I really didn’t want to take the time to setup and configure something like Apache or Nginx. These are overkill for static files on a developer machine. All I needed was a simple development web server that can be started and stopped quickly and easily. Oh and something without configuration.
Simple Python http server
The first thing I went to is Python’s built in mini web server. It’s really nice and can set the port, the interface to listen on, and allows you to specify which directory to serve.
python -m http.server 8000 -b 127.0.0.1 -d files/
99% of the time this will get the job done and this command is super useful.
Over kill Python http(s) server
While directly invoking the Python http server works, it doesn’t support TLS connections. It would be really nice to have TLS support. This is my main motivation to writing something else and not using the build in one.
Considerations
First of all I wanted to keep similar functionality as the built in call.
- Port configuration
- Listen local or global
- Specify a directory to serve
Additionally, I want to allow specifying a TLS cert and key to use for TLS connections. That said, I wanted to make it super easy to enable TLS. So, another feature I must have.
- Generate and use a self signed cert
The built in one supports CGI request handling but I really don’t need that. I’m going to leave CGI out for now. I’ll leave adding it as an exercise for any anyone interested.
The code
import argparse
import functools
import http.server
import os
import random
import socket
import ssl
import sys
from tempfile import gettempdir
from self_sign_cert import gen_self_signed_cert
def parse_args():
parser = argparse.ArgumentParser(description='Simple static content HTTP(S) server')
parser.add_argument('--listen_global', '-g', action='store_true', help='Listen globally')
parser.add_argument('--port', '-p', type=int, help='Port to listen on. Default 8080 or 8443 (TLS)')
parser.add_argument('--directory', '-d', help='Directory to serve')
parser.add_argument('--tls', '-t', action='store_true', help='Enable TLS. Will genreate and use self signed cert if cert and key are not specified')
parser.add_argument('--tls_crt', '-c', help='TLS cert to use. Corresponding key must be specified. Implies --tls')
parser.add_argument('--tls_key', '-k', help='TLS cert key to use. Corresponding cert must be specified. Implies --tls')
return parser.parse_args()
def setup_tls(args):
args.self_cert = False
if not args.tls and not args.tls_crt and not args.tls_key:
return True
args.tls = True
if args.tls_crt and args.tls_key:
return True
args.self_cert = True
tls_crt, tls_key = gen_self_signed_cert()
if not tls_crt:
print('Failed to generate self signed certificate')
return False
args.tls_crt = os.path.join(gettempdir(), 'serv_self_cert_%d.crt' % random.randrange(10000))
args.tls_key = os.path.join(gettempdir(), 'serv_self_cert_%d.key' % random.randrange(10000))
with open(args.tls_crt, 'wb') as f:
f.write(tls_crt)
with open(args.tls_key, 'wb') as f:
f.write(tls_key)
return True
def setup_port(args):
if args.port:
return
args.port = 8080
if args.tls:
args.port = 8443
def serv(httpd):
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
except Exception as e:
print(e)
def main():
args = parse_args()
if not setup_tls(args):
print('Failed to setup TLS')
return 2
setup_port(args)
listen = 'localhost'
if args.listen_global:
listen = ''
print('Listening {how} on port {port} (TLS {have_tls})'.format(
how='globally' if args.listen_global else 'locally',
port=args.port,
have_tls='enabled' if args.tls else 'disabled'))
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=args.directory)
with http.server.ThreadingHTTPServer((listen, args.port), handler) as httpd:
if args.tls:
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile=args.tls_crt, keyfile=args.tls_key)
if args.self_cert:
os.remove(args.tls_crt)
os.remove(args.tls_key)
with context.wrap_socket(httpd.socket, server_side=True) as sock:
httpd.socket = sock
serv(httpd)
else:
serv(httpd)
return 0
if __name__ == '__main__':
sys.exit(main())
The code explained
I’m going skip talking about the parse_args
function because it should
be self explanatory. The same goes for setup_port
which determines
which default port to use if one wasn’t specified.
setup_tls
will kick off generating a self signed
cert if necessary. It uses the Self signed cert python module
for the heavy lifting.
args.tls_crt = os.path.join(gettempdir(), 'serv_self_cert_%d.crt' % random.randrange(10000))
args.tls_key = os.path.join(gettempdir(), 'serv_self_cert_%d.key' % random.randrange(10000))
with open(args.tls_crt, 'wb') as f:
f.write(tls_crt)
with open(args.tls_key, 'wb') as f:
f.write(tls_key)
Once the module has generated the cert, we need to write the cert and key to
disk. We’ll store the filenames for these in the args
object. We’ll ensure
args.self_cert
is set appropriately because we’ll need it later. We’re using
a random file name in the user’s temp directory instead of something static
like ‘self.cert/.key’ so we don’t accidentally overwrite anything that might
have been legitimately created.
I’ll explain more about why we’re writing the cert and key to disk when when we get to the place they’re being used.
Starting the server
Now let’s skip the setup code in main
and go right to the good stuff.
handler = partial(http.server.SimpleHTTPRequestHandler, directory=args.directory)
We’re using the default request handler because we don’t need anything fancy,
just simple file serving. The class takes a directory
argument which
allows us to set the directory where files will be severed from. However, when we
pass off the handler we pass the class not an initialized object.
We need to use functools.partial
to wrap the class with the argument so when
it’s invoked directory
will be set to what was passed into the script. If a
directory wasn’t specified args.directory
will be None
which is fine.
That’s the default value for SimpleHTTPRequestHandler
if the argument wasn’t
passed in.
with http.server.ThreadingHTTPServer((listen, args.port), handler) as httpd:
One thing to note is the ThreadingHTTPServer
class is being used. This will
prevent the server from blocking on a single request. Useful if the files being
served are massive and multiple people are trying to access them.
I’ve seen people use socketserver.TCPServer
in place of [Threading]HTTPServer
.
If you’re going to use socketserver
I high recommend using ThreadingTCPServer
.
You could do something like this instead of the ThreadingHTTPServer
line above:
with socketserver.ThreadingTCPServer((listen, args.port), handler) as httpd:
It ultimately doesn’t matter which one is used. HTTPServer
is a
sub classes of TCPServer
. Inspecting the code for HTTPServer
it sets
allow_reuse_address
and a stores a few bits of info that can be
pulled back out later. Otherwise HTTPServer
doesn’t do anything that
makes it absolutely required.
That said, it’s still better to use the right class for what we’re doing. Even
though we’ll never access the cached data but allow_reuse_address
is good to
have set.
if args.tls:
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile=args.tls_crt, keyfile=args.tls_key)
if args.self_cert:
os.remove(args.tls_crt)
os.remove(args.tls_key)
with context.wrap_socket(httpd.socket, server_side=True) as sock:
httpd.socket = sock
serve(httpd)
else:
serv(httpd)
If we’re using TLS we need to create a context and load the cert and key files. There is no way to load a cert into the context from memory. Using a temporary file is unfortunately necessary. Which is why we wrote the self signed certs (if requested) to disk earlier.
If we’re using self signed certs we can delete them once they’ve been loaded. Once loaded they’re been read into memory and the files on disk won’t be accessed again.
Once loaded, we wrap and replace the httpd
’s socket with one that is a server
side TLS connection. Everything will be routed through this socket which will
encrypt and decrypt the data.
Finally, whether we’re using TLS or not, we start serving files by calling
the serv
function.
def serv(httpd):
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
except Exception as e:
print(e)
This gives is just a wrapper so we don’t have to duplicate the try..except
block.
The httpd.serve_forever()
call will not return. Instead we wait for
a KeyboardInterrupt
exception, Ctrl+c
, and capture it. This will
breaks out of the serve_forever()
function. We’re treating this event
as a graceful shutdown. This way doesn’t show a nasty message in the console.
We’re capturing all other exceptions and printing them out. We want to do this so the app won’t exit due to an uncaught exception.
Capturing exceptions here allows all the cleanup from the with
statements to
run. We want the network sockets to be cleanly closed so if we start again the
ports will be accessible for binding.
Conclusion
I’m very surprised that in memory TLS contexts aren’t built
into Python. I’m also surprised that the http.server
module doesn’t
natively support TLS. While it’s pretty easy to add TLS by wrapping
the socket, it would be nice to have the python -m http.server ...
call support it too.
Overall this slightly over engineered development web server was an interesting little project.