[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 jaakko.keranen@iki.fi[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/plain
This content has been proxied by September (ba2dc).