GmCapsule [main]

Virtual host certificates; bumped version to 0.9.1

=> 3f372e730e881135dfa7d5677499b1c908a36b90

diff --git a/gmcapsule/__init__.py b/gmcapsule/__init__.py
index 27e3a2f..010c8df 100644
--- a/gmcapsule/__init__.py
+++ b/gmcapsule/__init__.py
@@ -85,6 +85,10 @@ server
 host : string [string...]
     One or more hostnames for the server. Defaults to ``localhost``.
 
+    When multiple hostnames are specified, each becomes a virtual host.
+    The certs directory (see below) may contain separate certificate
+    files for each virtual host.
+
 address : string
     IP address of the network interface where the server is listening.
     Defaults to ``0.0.0.0`` (all interfaces).
@@ -93,10 +97,16 @@ port : int
     IP port on which the server is listening.
 
 certs : path
-    Directory where the server certificate is stored. The directory must
+    Directory where server certificates are stored. The directory must
     contain the PEM-formatted files `cert.pem` and `key.pem`. Defaults
     to `.certs`.
 
+    If virtual hosts are in use (multiple hostnames configured), this
+    directory can have subdirectories matching each hostname. These
+    host-specific subdirectories are also expected to contain the
+    PEM-formatted files `cert.pem` and `key.pem`. As a fallback, the
+    common certificate at the top level of the certs directory is used.
+
 modules : path [path...]
     One or more directories to load extension modules from.
 
@@ -506,7 +516,7 @@ from .gemini import Server, Cache, Context, Identity, GeminiError
 from .markdown import to_gemtext as markdown_to_gemtext
 
 
-__version__ = '0.9.0'
+__version__ = '0.9.1'
 __all__ = [
     'Config', 'Cache', 'Context', 'GeminiError', 'Identity',
     'get_mime_type', 'markdown_to_gemtext'
diff --git a/gmcapsule/gemini.py b/gmcapsule/gemini.py
index c5cea90..c70f2f0 100644
--- a/gmcapsule/gemini.py
+++ b/gmcapsule/gemini.py
@@ -867,27 +867,57 @@ class Server:
     def __init__(self, cfg):
         mp.set_start_method('spawn')
 
-        self.cfg = cfg
+        self.cfg     = cfg
         self.address = cfg.address()
-        self.port = cfg.port()
-
-        cert_path = cfg.certs_dir() / 'cert.pem'
-        key_path = cfg.certs_dir() / 'key.pem'
+        self.port    = cfg.port()
 
-        if not os.path.exists(cert_path):
-            raise Exception("certificate file not found: " + str(cert_path))
-        if not os.path.exists(key_path):
-            raise Exception("private key file not found: " + str(key_path))
+        self.main_context = None
+        self.contexts     = {}
+        session_id = f'GmCapsule:{cfg.port()}'.encode('utf-8')
 
-        self.context = SSL.Context(SSL.TLS_SERVER_METHOD)
-        self.context.use_certificate_file(str(cert_path))
-        self.context.use_privatekey_file(str(key_path))
-        self.context.set_verify(SSL.VERIFY_PEER, verify_callback)
+        for host in cfg.hostnames():
+            ctx = SSL.Context(SSL.TLS_SERVER_METHOD)
+            ctx.set_verify(SSL.VERIFY_PEER, verify_callback)
+            ctx.set_session_id(session_id)
+            self.contexts[host] = ctx
+
+            if not self.main_context:
+                self.main_context = ctx
+
+            keys_found = False
+
+            # Try the domain-specific certificates first.
+            cert_path = cfg.certs_dir() / host / 'cert.pem'
+            key_path  = cfg.certs_dir() / host / 'key.pem'
+            if cert_path.exists() and key_path.exists():
+                print(f'Host "{host}": Using certificate {cert_path}')
+                ctx.use_certificate_file(str(cert_path))
+                ctx.use_privatekey_file(str(key_path))
+                keys_found = True
+
+            if not keys_found:
+                cert_path = cfg.certs_dir() / 'cert.pem'
+                key_path  = cfg.certs_dir() / 'key.pem'
+                if os.path.exists(cert_path) and os.path.exists(key_path):
+                    print(f'Host "{host}": Using default certificate {cert_path}')
+                    ctx.use_certificate_file(str(cert_path))
+                    ctx.use_privatekey_file(str(key_path))
+                    keys_found = True
+
+            if not keys_found:
+                raise Exception(f"certificate not found for host '{host}'; check {str(cfg.certs_dir())}")
+
+        def _select_ssl_context(conn):
+            name = conn.get_servername().decode('utf-8')
+            ctx = self.main_context
+            if name in self.contexts:
+                print('selecting context', self.contexts[name])
+                ctx = self.contexts[name]
+            else:
+                print('selecting MAIN context', self.main_context)
+            conn.set_context(ctx)
 
-        session_id = f'GmCapsule:{cfg.port()}'.encode('utf-8')
-        #if type(session_id) != bytes:
-        #    raise Exception("session_id type must be `bytes`")
-        self.context.set_session_id(session_id)
+        self.main_context.set_tlsext_servername_callback(_select_ssl_context)
 
         # Spawn the worker threads.
         self.parser_queue = queue.Queue()
@@ -909,7 +939,7 @@ class Server:
                 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                 self.sock.bind((self.address, self.port))
                 self.sock.listen(5)
-                self.sv_conn = SSL.Connection(self.context, self.sock)
+                self.sv_conn = SSL.Connection(self.main_context, self.sock)
                 self.sv_conn.set_accept_state()
                 break
             except:
Proxy Information
Original URL
gemini://git.skyjake.fi/gmcapsule/main/cdiff/3f372e730e881135dfa7d5677499b1c908a36b90
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
114.044615 milliseconds
Gemini-to-HTML Time
0.285575 milliseconds

This content has been proxied by September (3851b).