Use python -m http.server in SSL

2024-09-02

python -m http.server is a very convenient command line tool to start an ad-hoc static web server. This tool is very useful when you want to develop small web applications or serve static files.

But it does not support SSL. A lot of modern web applications as well as browser features require a secured connection. So I want to wrap http.server in SSL and try making it simple to use.

Use http.server with SSL

I modified several functions from the module http.server and write a script ssl_server.py. I’ll show you how to use the script and mkcert to serve a static sites with a self-signed SSL certificate.

The first step is to copy the ssl_server.py script to ~/.local/bin and make it executable.

cp ssl_server.py ~/.local/bin
chmod +x ~/.local/bin/ssl_server.py

Then install the mkcert tool. You can find the installation instructions on the mkcert GitHub page.

Generate a self-signed certificate and a private key using mkcert(for a domain like example.local).

mkcert example.local

The command will generate two files example.local.pem and example.local-key.pem. You can use these files to start the SSL server.

~/.local/bin/ssl_server.py --cert example.local.pem --key example.local-key.pem --port 8443

Now that you have a static web server running on https://example.local:8443.

To visit the sites in your browser, you need to do 2 more things:

  1. add the domain example.local to your /etc/hosts file.
echo "127.0.0.1 example.local" | sudo tee -a /etc/hosts
  1. Trust the certificate in your browser.
# Install using mkcert
mkcert -install

# Or install manually from mkcert root CA directory
mkcert -CAROOT
# copy the root CA file 

The ssl_server.py script

The script is as follows:

#! /usr/bin/env python
'''
A wrapper around the standard library's http.server module that adds SSL support.
'''
import sys
import os
import socket
import ssl
from http.server import (
    SimpleHTTPRequestHandler,
    CGIHTTPRequestHandler,
    ThreadingHTTPServer,
    BaseHTTPRequestHandler,
    _get_best_family
)


def test(HandlerClass=BaseHTTPRequestHandler,
         ServerClass=ThreadingHTTPServer,
         protocol="HTTP/1.0", port=8000, bind=None,
         cert=None, key=None
         ):
    """Test the HTTP request handler class.

    This runs an HTTP server on port 8000 (or the port argument).

    """
    ServerClass.address_family, addr = _get_best_family(bind, port)
    HandlerClass.protocol_version = protocol
    with ServerClass(addr, HandlerClass) as httpd:
        if cert and key:
            ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
            ssl_context.load_cert_chain(cert, key)
            httpd.socket = ssl_context.wrap_socket(httpd.socket, server_side=True)
        host, port = httpd.socket.getsockname()[:2]
        url_host = f'[{host}]' if ':' in host else host
        print(
            f"Serving HTTP on {host} port {port} "
            f"(http://{url_host}:{port}/) ..."
        )
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            print("\nKeyboard interrupt received, exiting.")
            sys.exit(0)

if __name__ == '__main__':
    import argparse
    import contextlib

    parser = argparse.ArgumentParser()
    parser.add_argument('--cgi', action='store_true',
                        help='run as CGI server')
    parser.add_argument('--bind', '-b', metavar='ADDRESS',
                        help='specify alternate bind address '
                             '(default: all interfaces)')
    parser.add_argument('--directory', '-d', default=os.getcwd(),
                        help='specify alternate directory '
                             '(default: current directory)')
    parser.add_argument('--port', action='store', default=8000, type=int,
                        nargs='?',
                        help='specify alternate port (default: 8000)')
    parser.add_argument('--cert', help='specify a certificate file')
    parser.add_argument('--key', help='specify a private key file')
    args = parser.parse_args()
    if args.cgi:
        handler_class = CGIHTTPRequestHandler
    else:
        handler_class = SimpleHTTPRequestHandler

    # ensure dual-stack is not disabled; ref #38907
    class DualStackServer(ThreadingHTTPServer):

        def server_bind(self):
            # suppress exception when protocol is IPv4
            with contextlib.suppress(Exception):
                self.socket.setsockopt(
                    socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
            return super().server_bind()

        def finish_request(self, request, client_address):
            self.RequestHandlerClass(request, client_address, self,
                                     directory=args.directory)

    test(
        HandlerClass=handler_class,
        ServerClass=DualStackServer,
        port=args.port,
        bind=args.bind,
        cert=args.cert,
        key=args.key
    )

The script wraps the httpd.socket with an SSL socket if the cert and key options are provided.

The script keeps the same interface as http.server, but it adds two more options --cert and --key which are used to specify the certificate and the private key files.

Generally, you can use the script like this:

python ssl_server.py --cert cert.pem --key key.pem

For convenience, you may put cert and key files in a directory and use a bash script to start the server.

#!/bin/bash
cert_dir=~/certs  # change to your cert directory
cert=$cert_dir/cert.pem
key=$cert_dir/key.pem
python ssl_server.py --cert $cert --key $key $@