Launch from Canaveral

Following my adventure Down the gopher hole, I have been hooked on the Gopher (and especially Gemini) protocols. After reading the first sentence of the formal Gemini network protocol's overview, I knew I had to make my own Gemini server:

An overriding goal of Gemini is to provide a simple protocol that is easy to implement (requiring a day or two of effort and only a few hundred lines for a server or a client) while still being useful.

Gemini network protocol specification

Unlike the series of webpages for my Minimum viable email service project, the intentionally small scope of Gemini allowed me to build this project to my satisfaction in just a day. (Also, if you want to view the full code, you can skip to the bottom of this page. Just note that this is a hobby implementation which likely has significant bugs and issues.)

As a bit of foreshadowing, this quote from the Gemini Protocol FAQ page is a good summary of my experience:

On the practical level, Gemini's design is basically a novel way of connecting together some very non-novel technological primitives. … As a result, writing Gemini software can feel a lot like joining together Lego bricks.

Simplicity of implementation

I started with the most important aspect of any project: the name. Keeping with the tradition of references to NASA's Gemini project, I am dubbing my server "Canaveral" after Cape Canaveral, the launch site for all ten crewed Project Gemini missions.

 ██████╗ █████╗ ███╗   ██╗ █████╗ ██╗   ██╗███████╗██████╗  █████╗ ██╗
██╔════╝██╔══██╗████╗  ██║██╔══██╗██║   ██║██╔════╝██╔══██╗██╔══██╗██║
██║     ███████║██╔██╗ ██║███████║██║   ██║█████╗  ██████╔╝███████║██║
██║     ██╔══██║██║╚██╗██║██╔══██║╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══██║██║
╚██████╗██║  ██║██║ ╚████║██║  ██║ ╚████╔╝ ███████╗██║  ██║██║  ██║███████╗
 ╚═════╝╚═╝  ╚═╝╚═╝  ╚═══╝╚═╝  ╚═╝  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝

Gemini server

I began with a list of objectives:

  • Recursively host a static directory via Gemini
  • Concurrency support for multiple clients
  • Basic certificate management
  • Automatic MIME types
  • Argument parser for ease of use
  • Minimal dependencies (standard library & openssl)
  • Small, single file project

The Gemini protocol mandates the use of TLS encryption which makes it a great place to start. openssl is the only external dependency not included in modern Python and it completely abstracts away the challenges of SSL for this project. When the server starts, it checks for the existence of a cert and key file pair (filepaths can be specified as arguments or left as default). If they do not exist, it will give the user a y/n prompt to generate them. If they do exist, it will ensure that they are not expired.

Once the certificate is handled, the server is started on the 1965 port (unless overridden by an argument). Each new connection is handled by a different thread to support concurrency; even though the protocol is stateless, a slow network connection could cause delays when sending data. When the client requests a URL, the server does some basic parsing (such as treating "*/" as "*/index.gmi") and ensures that the requested file exists within the specified SERVER_ROOT directory. If the resource exists, mimetype (with extra entries for the "text/gemini" data type) will guess the proper format of the file. If all of the checks pass, then the server streams the requested file in 8kb chunks to the client.

elpher_canaveral_test.png

Figure 1: Example capsule hosted with Canaveral, on elpher

Once the operator is ready to close the server, a simple C-c will gracefully shutdown the server after granting a 2 second grace period to existing connections.

Finally, the server allows for some basic configuration differences through arguments:

usage: canaveral.py [-h] [-g] [-c CERT] [-k KEY] [-C CN] [-s SAN] [-D DAYS] [-p PORT] [-H HOST] [-d DIR]

options:
  -h, --help           show this help message and exit
  -g, --generate-cert  generate certificate
  -c, --cert CERT      path to certificate file
  -k, --key KEY        path to key file
  -C, --cn CN          common name
  -s, --san SAN        subject alternative names
  -D, --days DAYS      certificate validity period
  -p, --port PORT      server port
  -H, --host HOST      server host
  -d, --dir DIR        server root

The complete file (including comments and whitespace) is 7.0kb and 204 lines long; the entire server is 100 lines smaller than just the TCP/SSL file for my email project.

However, the brevity comes with significant limitations. Notably, it only serves static content from a directory, cannot ask for user input, has no rate limiting, does not support proxying, does not consider the user's certificate, and doubtlessly contains oversights and bugs.

Summary and source code

You may have noticed that I barely even mentioned the underlying protocol. Honestly, it's so natural and minimal that it is essentially implied. For example, when the client connects, it also sends a URL with the desired resource path; when the server responds, it provides a status code, data type, and the bytes of data.

The result is a protocol that feels like you could accidentally support it, and I mean that as a complement. Clearly, the choice of what to include and what not to include were made with intention and the benefit of hindsight, and the result is frictionless.

Canaveral server (collapsable details)
"""

Canaveral, a simple Gemini server

Key features:
- Recursively host a static directory via Gemini
- Concurrency
- Certificate management 
- Automatic MIME types
- Argument parser for ease of use
- Minimal dependencies (standard library & openssl)
- Under 200 lines of Python

"""

import os
import ssl
import sys
import argparse
import mimetypes
import threading
import subprocess
import socket
from pathlib import Path
from urllib.parse import urlparse, unquote

LOGO = r"""
   ██████╗ █████╗ ███╗   ██╗ █████╗ ██╗   ██╗███████╗██████╗  █████╗ ██╗     
  ██╔════╝██╔══██╗████╗  ██║██╔══██╗██║   ██║██╔════╝██╔══██╗██╔══██╗██║     
  ██║     ███████║██╔██╗ ██║███████║██║   ██║█████╗  ██████╔╝███████║██║     
  ██║     ██╔══██║██║╚██╗██║██╔══██║╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══██║██║     
  ╚██████╗██║  ██║██║ ╚████║██║  ██║ ╚████╔╝ ███████╗██║  ██║██║  ██║███████╗
   ╚═════╝╚═╝  ╚═╝╚═╝  ╚═══╝╚═╝  ╚═╝  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝
"""

parser = argparse.ArgumentParser()
parser.add_argument("-g", "--generate-cert", help="generate certificate", action="store_true")
parser.add_argument("-c", "--cert", type=str, help="path to certificate file", default="cert.pem")
parser.add_argument("-k", "--key", type=str, help="path to key file", default="key.pem")
parser.add_argument("-C", "--cn", type=str, help="common name", default="localhost")
parser.add_argument("-s", "--san", type=str, help="subject alternative names", default="localhost,127.0.0.1")
parser.add_argument("-D", "--days", type=int, help="certificate validity period", default=365)
parser.add_argument("-p", "--port", type=int, help="server port", default=1965)
parser.add_argument("-H", "--host", type=str, help="server host", default="0.0.0.0")
parser.add_argument("-d", "--dir", type=str, help="server root", default="~/public_gemini")
args = parser.parse_args()

CERT = Path(args.cert)
KEY = Path(args.key)
SERVER_ROOT = Path(args.dir).expanduser().resolve()

def check_cert_valid(cert_path):
    if not cert_path.exists():
        return False

    try:
        result = subprocess.run(["openssl", "x509", "-checkend", "0", "-noout", "-in", str(cert_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        return result.returncode == 0
    except FileNotFoundError:
        print("OpenSSL not found in PATH.")
        sys.exit(1)

def build_san(san_str):
    entries = []
    for item in san_str.split(","):
        item = item.strip()
        if not item:
            continue
        if item.replace(".", "").isdigit():  # naive IP check
            entries.append(f"IP:{item}")
        else:
            entries.append(f"DNS:{item}")
    return "subjectAltName=" + ",".join(entries)

def generate_cert():
    print("Generating new self-signed certificate...")
    san = build_san(args.san)
    subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-keyout", str(KEY), "-out", str(CERT), "-days", str(args.days), "-nodes", "-subj", f"/CN={args.cn}", "-addext", san], check=True)

def ensure_cert():
    if args.generate_cert:
        generate_cert()
        return

    if (CERT.exists() and KEY.exists() and check_cert_valid(CERT)):
        print("Certificate is valid.")
        return

    confirm = input("No SSL certificate was detected. Generate one? (y/n): ").strip().lower()
    if confirm != "y":
        print("Cannot proceed without certificate.")
        sys.exit(1)

    generate_cert()

def resolve_path(request_bytes):
    url = request_bytes.decode("utf-8", "ignore").strip()
    parsed = urlparse(url)
    raw_path = unquote(parsed.path)

    rel = raw_path.lstrip("/")
    candidate = (SERVER_ROOT / rel).resolve()

    if not candidate.is_relative_to(SERVER_ROOT):
        raise PermissionError("Path escapes server root")

    if candidate.exists() and candidate.is_dir():
        candidate = candidate / "index.gmi"

    return candidate

mimetypes.add_type("text/gemini", ".gmi")
mimetypes.add_type("text/gemini", ".gemini")

def guess_mime(path):
    mime, encoding = mimetypes.guess_type(path)
    if mime is None:
        return "application/octet-stream"
    if mime.startswith("text/"):
        return f"{mime}; charset=utf-8" # specify utf-8 for text data
    return mime

shutdown_event = threading.Event()
threads = []

def handle_client(conn, addr, context):
    try:
        with context.wrap_socket(conn, server_side=True) as tls:
            tls.settimeout(10)

            request = tls.recv(1024)

            try:
                path = resolve_path(request)

                if not path.exists() or not path.is_file():
                    tls.sendall(b"51 Not Found\r\n")
                    return

                send_success(tls, path)

            except PermissionError:
                tls.sendall(b"53 Proxy Request Refused\r\n")
            except ValueError:
                tls.sendall(b"59 Bad Request\r\n")

    except socket.timeout:
        print(f"Connection timed out from {addr}")
    except ssl.SSLError as e:
        print(f"TLS failed from {addr}: {e}")
    finally:
        conn.close()

def exec_server():
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain(CERT, KEY)

    with socket.socket() as sock:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        sock.bind((args.host, args.port))
        sock.listen()
        sock.settimeout(1.0)

        print(f"Preparing to serve {SERVER_ROOT}.")
        print(f"Listening on {args.port}...")

        try:
            while not shutdown_event.is_set():
                try:
                    conn, addr = sock.accept()
                except socket.timeout:
                    continue

                thread = threading.Thread(target=handle_client, args=(conn, addr, context))
                thread.start()
                threads.append(thread)

        except KeyboardInterrupt:
            print("\nInterrupt received.")
            shutdown_event.set()

        finally:
            if len(threads) > 0:
                print("Grace period for active connections to finish...")

            for thread in threads:
                thread.join(timeout=2)

def send_success(conn, path):
    header = f"20 {guess_mime(path)}\r\n".encode("utf-8")
    conn.sendall(header)

    # send chunks
    with path.open("rb") as f:
        while chunk := f.read(8192):
            conn.sendall(chunk)

def main():
    print(LOGO)
    ensure_cert()
    exec_server()

if __name__ == "__main__":
    main()

Last updated April 24, 2026