=> e88c8439b919809fafcbbbc1fd51198abea220c0
[1mdiff --git a/gmcapsule/__init__.py b/gmcapsule/__init__.py[m [1mindex a16c800..27e3a2f 100644[m [1m--- a/gmcapsule/__init__.py[m [1m+++ b/gmcapsule/__init__.py[m [36m@@ -502,13 +502,13 @@[m [mimport shlex[m import subprocess[m from pathlib import Path[m [m [31m-from .gemini import Server, Cache, Context, Identity[m [32m+[m[32mfrom .gemini import Server, Cache, Context, Identity, GeminiError[m from .markdown import to_gemtext as markdown_to_gemtext[m [m [m [31m-__version__ = '0.8.0'[m [32m+[m[32m__version__ = '0.9.0'[m __all__ = [[m [31m- 'Config', 'Cache', 'Context', 'Identity',[m [32m+[m[32m 'Config', 'Cache', 'Context', 'GeminiError', 'Identity',[m 'get_mime_type', 'markdown_to_gemtext'[m ][m [m [1mdiff --git a/gmcapsule/gemini.py b/gmcapsule/gemini.py[m [1mindex 329c641..c5cea90 100644[m [1m--- a/gmcapsule/gemini.py[m [1m+++ b/gmcapsule/gemini.py[m [36m@@ -376,14 +376,10 @@[m [mdef handle_gemini_or_titan_request(request_data):[m if expected_size > max_upload_size and max_upload_size > 0:[m report_error(stream, 59, "Maximum content length exceeded")[m return[m [31m- while len(data) < expected_size:[m [31m- incoming = safe_recv(stream, 65536)[m [31m- if len(incoming) == 0:[m [31m- break[m [31m- data += incoming[m [31m- if len(data) != expected_size:[m [32m+[m[32m if not request_data.receive_data(expected_size):[m report_error(stream, 59, "Invalid content length")[m return[m [32m+[m[32m data = request_data.buffered_data[m else:[m # Edit requests do not contain data.[m if len(data):[m [36m@@ -720,6 +716,16 @@[m [mclass RequestData:[m self.identity = identity[m self.request = request[m [m [32m+[m[32m def receive_data(self, expected_size):[m [32m+[m[32m while len(self.buffered_data) < expected_size:[m [32m+[m[32m incoming = safe_recv(self.stream, 65536)[m [32m+[m[32m if len(incoming) == 0:[m [32m+[m[32m break[m [32m+[m[32m self.buffered_data += incoming[m [32m+[m[32m if len(self.buffered_data) != expected_size:[m [32m+[m[32m return False[m [32m+[m[32m return True[m [32m+[m [m class RequestParser(threading.Thread):[m """Thread that parses incoming requests from clients."""[m [36m@@ -763,11 +769,15 @@[m [mclass RequestParser(threading.Thread):[m [m def process_request(self, stream, from_addr):[m data = bytes()[m [31m- MAX_LEN = 1024 # TODO: Gemini/Titan limitation only.[m [32m+[m[32m MAX_LEN = 1024 # applies only to Gemini and Titan[m MAX_RECV = MAX_LEN + 2 # includes terminator "\r\n"[m [32m+[m[32m MAX_RECV_ANY_PROTOCOL = 65536[m request = None[m incoming = safe_recv(stream, MAX_RECV)[m [m [32m+[m[32m def is_gemini_based(data):[m [32m+[m[32m return data.startswith(b'gemini:') or data.startswith(b'titan:')[m [32m+[m try:[m while len(data) < MAX_RECV:[m data += incoming[m [36m@@ -776,12 +786,11 @@[m [mclass RequestParser(threading.Thread):[m request = data[:crlf_pos].decode('utf-8')[m data = data[crlf_pos + 2:][m break[m [31m- elif len(data) > MAX_LEN:[m [32m+[m[32m elif len(data) > MAX_LEN and is_gemini_based(data):[m # At this point we should have received the line terminator.[m self.log(from_addr, 'sent a malformed request')[m report_error(stream, 59, "Request exceeds maximum length")[m return[m [31m-[m incoming = safe_recv(stream, MAX_RECV - len(data))[m if len(incoming) <= 0:[m break[m [36m@@ -789,7 +798,8 @@[m [mclass RequestParser(threading.Thread):[m report_error(stream, 59, "Request contains malformed UTF-8")[m return[m [m [31m- if not request or len(data) > MAX_RECV:[m [32m+[m[32m if not request or (len(data) > MAX_RECV and is_gemini_based(data)) or \[m [32m+[m[32m len(data) > MAX_RECV_ANY_PROTOCOL:[m report_error(stream, 59, "Bad request")[m return[m [m [1mdiff --git a/gmcapsule/modules/80_misfin.py b/gmcapsule/modules/80_misfin.py[m [1mnew file mode 100644[m [1mindex 0000000..626981e[m [1m--- /dev/null[m [1m+++ b/gmcapsule/modules/80_misfin.py[m [36m@@ -0,0 +1,215 @@[m [32m+[m[32m# Copyright (c) 2024 Jaakko Keränen[m [32m+[m[32m# License: BSD-2-Clause[m [32m+[m [32m+[m[32m"""Misfin Email Bridge"""[m [32m+[m [32m+[m[32mimport gmcapsule[m [32m+[m[32mimport hashlib[m [32m+[m[32mimport re[m [32m+[m[32mimport subprocess[m [32m+[m[32mfrom pathlib import Path[m [32m+[m[32mfrom OpenSSL import SSL, crypto[m [32m+[m [32m+[m [32m+[m[32mdef get_fingerprint(x509_cert):[m [32m+[m[32m h = hashlib.sha256()[m [32m+[m[32m h.update(crypto.dump_certificate(crypto.FILETYPE_ASN1, x509_cert))[m [32m+[m[32m return h.hexdigest()[m [32m+[m [32m+[m [32m+[m[32mdef parse_identity(cert) -> tuple:[m [32m+[m[32m """Returns: (mailbox, host, blurb)"""[m [32m+[m[32m host = None[m [32m+[m [32m+[m[32m comps = {}[m [32m+[m[32m for (comp, value) in cert.get_subject().get_components():[m [32m+[m[32m comps[comp.decode('utf-8')] = value.decode('utf-8')[m [32m+[m[32m i = 0[m [32m+[m[32m while i < cert.get_extension_count():[m [32m+[m[32m ext = cert.get_extension(i)[m [32m+[m[32m if ext.get_short_name() == b'subjectAltName':[m [32m+[m[32m host = str(ext)[m [32m+[m[32m if not host.startswith('DNS:'):[m [32m+[m[32m raise Exception(f"{cert}: subject alternative name must specify a DNS hostname")[m [32m+[m[32m host = host[4:][m [32m+[m[32m break[m [32m+[m[32m i += 1[m [32m+[m[32m if not host:[m [32m+[m[32m raise Exception(f"{cert}: subject alternative name not specified")[m [32m+[m [32m+[m[32m return (comps['UID'], host, comps['CN'])[m [32m+[m [32m+[m [32m+[m [32m+[m[32mclass MisfinError (Exception):[m [32m+[m[32m def __init__(self, status, meta):[m [32m+[m[32m self.status = status[m [32m+[m[32m self.meta = meta[m [32m+[m [32m+[m[32m def __str__(self):[m [32m+[m[32m return f'{self.status} {self.meta}'[m [32m+[m [32m+[m [32m+[m[32mclass Recipient:[m [32m+[m[32m # Note: Generating a recipient certificate:[m [32m+[m[32m # openssl req -x509 -key misfin.key -outform PEM -out misfin.pem -sha256 -days 100000 -addext 'subjectAltName=DNS:example.com' -subj '/CN=blurb/UID=mailbox'[m [32m+[m [32m+[m[32m def __init__(self, name, cert, key, email):[m [32m+[m[32m self.name = name[m [32m+[m[32m self.email = email[m [32m+[m [32m+[m[32m if not cert:[m [32m+[m[32m raise Exception(self.name + ': recipient certificate not specified')[m [32m+[m[32m if not key:[m [32m+[m[32m raise Exception(self.name + ': recipient private key not specified')[m [32m+[m [32m+[m[32m self.cert = crypto.load_certificate(crypto.FILETYPE_PEM, open(cert, 'rb').read())[m [32m+[m[32m self.key = crypto.load_privatekey(crypto.FILETYPE_PEM, open(key, 'rb').read())[m [32m+[m [32m+[m[32m if not self.cert:[m [32m+[m[32m raise Exception(cert + ": invalid certificate")[m [32m+[m[32m if not self.key:[m [32m+[m[32m raise Exception(key + ": invalid private key")[m [32m+[m [32m+[m[32m self.fingerprint = get_fingerprint(self.cert)[m [32m+[m [32m+[m[32m crt_uid, crt_host, crt_blurb = parse_identity(self.cert)[m [32m+[m[32m if crt_uid != self.name:[m [32m+[m[32m raise Exception(f"{cert}: certificate user ID must match mailbox name ({name})")[m [32m+[m[32m self.host = crt_host[m [32m+[m[32m self.blurb = crt_blurb[m [32m+[m [32m+[m [32m+[m[32mclass MisfinHandler:[m [32m+[m[32m def __init__(self, context):[m [32m+[m[32m cfg = context.config().section('misfin')[m [32m+[m [32m+[m[32m self.context = context[m [32m+[m[32m self.email_cmd = cfg.get('email.cmd', fallback='/usr/sbin/sendmail')[m [32m+[m[32m self.email_from = cfg.get('email.from', fallback=None)[m [32m+[m[32m self.trust_file = Path.home() / '.misfin-known-senders'[m [32m+[m[32m self.reject = cfg.get('reject', fallback='').split()[m [32m+[m[32m self.always_trust = set()[m [32m+[m[32m self.recipients = {}[m [32m+[m [32m+[m[32m # Configure.[m [32m+[m[32m cfg = context.config()[m [32m+[m[32m for section in cfg.prefixed_sections('misfin.').values():[m [32m+[m[32m name = section.name[7:][m [32m+[m[32m cert = section.get('cert', fallback=None)[m [32m+[m[32m key = section.get('key', fallback=None)[m [32m+[m[32m email = section.get('email', fallback=None)[m [32m+[m[32m if not email:[m [32m+[m[32m raise Exception("Misfin recipients must have an email forwarding address")[m [32m+[m[32m recp = Recipient(name, cert, key, email)[m [32m+[m[32m self.always_trust.add(recp.fingerprint)[m [32m+[m[32m self.recipients[name] = recp[m [32m+[m [32m+[m[32m def check_sender(self, identity):[m [32m+[m[32m if not identity:[m [32m+[m[32m raise MisfinError(60, 'Certificate required')[m [32m+[m [32m+[m[32m fp = identity.fp_cert[m [32m+[m[32m uid, host, blurb = parse_identity(crypto.load_certificate(crypto.FILETYPE_ASN1,[m [32m+[m[32m identity.cert))[m [32m+[m[32m addr = uid + '@' + host[m [32m+[m [32m+[m[32m if fp in self.always_trust:[m [32m+[m[32m return True[m [32m+[m[32m if fp in self.reject:[m [32m+[m[32m return False[m [32m+[m [32m+[m[32m if re.search(r'\s', uid):[m [32m+[m[32m raise MisfinError(62, 'Invalid sender mailbox')[m [32m+[m[32m if re.search(r'\s', host):[m [32m+[m[32m raise MisfinError(62, 'Invalid sender hostname')[m [32m+[m [32m+[m[32m try:[m [32m+[m[32m for line in open(self.trust_file, 'rt').readlines():[m [32m+[m[32m m = re.match(r'([0-9a-f]{64}) ([^\s]+) .*', line)[m [32m+[m[32m if m:[m [32m+[m[32m if m[1] == fp:[m [32m+[m[32m return True[m [32m+[m[32m if m[2] == addr:[m [32m+[m[32m if m[1] != fp:[m [32m+[m[32m raise MisfinError(63, 'Certificate does not match known identity')[m [32m+[m[32m return True[m [32m+[m[32m except FileNotFoundError:[m [32m+[m[32m pass[m [32m+[m [32m+[m[32m # Never seen this before, TOFU it.[m [32m+[m[32m print(f"{fp} {addr} {blurb}", file=open(self.trust_file, 'at'))[m [32m+[m [32m+[m[32m return True[m [32m+[m [32m+[m[32m def __call__(self, request_data):[m [32m+[m[32m resp_status = 20[m [32m+[m[32m resp_meta = ''[m [32m+[m [32m+[m[32m sender = request_data.identity[m [32m+[m[32m request = request_data.request[m [32m+[m [32m+[m[32m try:[m [32m+[m[32m # If we've seen this before, check that the fingerprint is the same.[m [32m+[m[32m if not self.check_sender(sender):[m [32m+[m[32m raise MisfinError(61, 'Unauthorized sender')[m [32m+[m [32m+[m[32m # Parse the request.[m [32m+[m[32m version = 'B'[m [32m+[m[32m m = re.match(r'misfin://([^@]+)@([^@\s]+) (.*)', request)[m [32m+[m[32m if m:[m [32m+[m[32m message = m[3][m [32m+[m[32m else:[m [32m+[m[32m m = re.match(r'misfin://([^@]+)@([^@\s]+)\t(\d+)', request)[m [32m+[m[32m if not m:[m [32m+[m[32m raise MisfinError(59, "Bad request")[m [32m+[m[32m if not request_data.receive_data(int(m[3])):[m [32m+[m[32m raise MisfinError(59, 'Invalid content length')[m [32m+[m[32m version = 'C'[m [32m+[m[32m message = request_data.buffered_data.decode('utf-8')[m [32m+[m [32m+[m[32m mailbox = m[1][m [32m+[m[32m host = m[2][m [32m+[m [32m+[m[32m if mailbox not in self.recipients:[m [32m+[m[32m raise MisfinError(51, 'Mailbox not found')[m [32m+[m [32m+[m[32m recp = self.recipients[mailbox][m [32m+[m [32m+[m[32m if host != recp.host:[m [32m+[m[32m raise MisfinError(53, 'Domain not serviced')[m [32m+[m [32m+[m[32m resp_meta = recp.fingerprint[m [32m+[m [32m+[m[32m # Forward as email.[m [32m+[m[32m try:[m [32m+[m[32m uid, host, blurb = parse_identity(crypto.load_certificate(crypto.FILETYPE_ASN1,[m [32m+[m[32m sender.cert))[m [32m+[m [32m+[m[32m subject = f"[misfin] Message from {uid}@{host}"[m [32m+[m [32m+[m[32m msg = f'From: {self.email_from}\n' + \[m [32m+[m[32m f'To: {recp.email}\n' + \[m [32m+[m[32m f'Subject: {subject}\n\n' + \[m [32m+[m[32m message.rstrip() + "\n\n" + \[m [32m+[m[32m f"=> misfin://{uid}@{host} {blurb}\n"[m [32m+[m [32m+[m[32m args = [self.email_cmd, '-i', recp.email][m [32m+[m[32m if self.email_cmd == 'stdout':[m [32m+[m[32m print(args, msg)[m [32m+[m[32m else:[m [32m+[m[32m subprocess.check_output(args, input=msg, encoding='utf-8')[m [32m+[m[32m except Exception as x:[m [32m+[m[32m print('Error sending email:', x)[m [32m+[m[32m raise MisfinError(42, 'Internal error')[m [32m+[m [32m+[m[32m except MisfinError as er:[m [32m+[m[32m resp_status = er.status[m [32m+[m[32m resp_meta = er.meta[m [32m+[m [32m+[m[32m return f'{resp_status} {resp_meta}\r\n'.encode('utf-8')[m [32m+[m [32m+[m [32m+[m[32mdef init(context):[m [32m+[m[32m if context.config().prefixed_sections('misfin.').values():[m [32m+[m[32m context.add_protocol('misfin', MisfinHandler(context))[m
text/gemini; charset=utf-8
This content has been proxied by September (ba2dc).