feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs
This commit is contained in:
parent
a0950fab52
commit
5dda6567af
18 changed files with 1764 additions and 44 deletions
|
|
@ -1,12 +1,19 @@
|
|||
"""pneumatube: a gateway bridging one smolmail account and an SMTP mailbox.
|
||||
|
||||
smolmail -> email: a message the gateway receives with an Email-To
|
||||
frontmatter field is relayed to that address over SMTP.
|
||||
frontmatter field is relayed to that address over SMTP. By default this only
|
||||
happens for senders the gateway account has accepted (SPEC.md S5.8;
|
||||
PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0 to relay first-contact senders too).
|
||||
|
||||
email -> smolmail: mail sent to <username>@PNEUMATUBE_EMAIL_DOMAIN is
|
||||
relayed as a smolmail message to <username>@PNEUMATUBE_SMOLMAIL_DOMAIN, if
|
||||
that username is registered there, with the original sender recorded in an
|
||||
Email-From frontmatter field.
|
||||
that username is registered there and (by default) has accepted the gateway
|
||||
(SPEC.md S5.8; PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 for the softer,
|
||||
smolmail-native requests-tier demotion instead of an outright reject -- see
|
||||
email_to_smol.py's module docstring). The relayed message carries no claim
|
||||
about the original email sender -- an email's MAIL FROM is unauthenticated,
|
||||
so forwarding it into the message would let any sender forge an
|
||||
apparently-trustworthy "from" inside a signed smolmail message.
|
||||
|
||||
See config.py for the full list of environment variables, including
|
||||
PNEUMATUBE_LISTEN_PROTOCOL (lmtp, behind a real MTA, or smtp for local-dev
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import os
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Frontmatter is case-insensitive and flat (smolmail SPEC.md S5.5); these two
|
||||
# custom keys are the only contract between the two directions of the bridge.
|
||||
# Frontmatter is case-insensitive and flat (smolmail SPEC.md S5.5); this is
|
||||
# the only contract the smolmail -> email direction relies on. The reverse
|
||||
# direction deliberately carries no analogous custom field -- see
|
||||
# email_to_smol.py's module docstring for why.
|
||||
FRONTMATTER_EMAIL_KEY = "Email-To" # smolmail -> email: where to deliver
|
||||
FRONTMATTER_SENDER_KEY = "Email-From" # email -> smolmail: who originally sent it
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
|
|
@ -54,6 +55,17 @@ class Config:
|
|||
|
||||
gateway_from_address: str # From: header used on mail forwarded to SMTP
|
||||
|
||||
# If true (the default), only relay smolmail messages from senders the
|
||||
# gateway account has accepted (SPEC.md S5.8) -- a first-contact sender
|
||||
# with no accept token lands in the requests tier and is left alone.
|
||||
# Set to false to run an intentionally open bridge instead.
|
||||
require_accepted_sender: bool
|
||||
|
||||
# If true (the default), only relay inbound email to a smolmail user who
|
||||
# has accepted the gateway (SPEC.md S5.8) -- an all-or-nothing RCPT-time
|
||||
# reject, not a demotion. See email_to_smol.py's module docstring.
|
||||
require_accepted_recipient: bool
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
listen_protocol = os.environ.get("PNEUMATUBE_LISTEN_PROTOCOL", "lmtp")
|
||||
|
|
@ -80,4 +92,7 @@ def load() -> Config:
|
|||
smtp_relay_password=os.environ.get("PNEUMATUBE_SMTP_RELAY_PASSWORD"),
|
||||
smtp_relay_starttls=os.environ.get("PNEUMATUBE_SMTP_RELAY_STARTTLS", "1") != "0",
|
||||
gateway_from_address=_env("PNEUMATUBE_GATEWAY_FROM_ADDRESS"),
|
||||
require_accepted_sender=os.environ.get("PNEUMATUBE_REQUIRE_ACCEPTED_SENDER", "1") != "0",
|
||||
require_accepted_recipient=os.environ.get(
|
||||
"PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT", "1") != "0",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,37 @@
|
|||
"""email -> smolmail direction.
|
||||
|
||||
Confirms a username is registered on the configured smolmail server and, if
|
||||
so, relays an inbound email to it as a smolmail message, carrying the
|
||||
original sender's email address in the Email-From frontmatter field
|
||||
(SPEC.md S5.5).
|
||||
Confirms a username is registered on the configured smolmail server, and
|
||||
that they have accepted the gateway's own smolmail identity (SPEC.md S5.8),
|
||||
before relaying an inbound email to them as a smolmail message. This is an
|
||||
all-or-nothing gate, not a demotion: without it, the gateway's SEND would
|
||||
still go through and land in the recipient's own requests tier (still
|
||||
delivered, just quota-limited); with it, pneumatube refuses the RCPT
|
||||
entirely, so an unaccepted recipient gets nothing at all. Set
|
||||
PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 to fall back to that softer,
|
||||
smolmail-native tiering instead.
|
||||
|
||||
Acceptance is checked locally, with no network round trip: cmd_send already
|
||||
needs to know whether it holds a token for the recipient (SELECT token FROM
|
||||
tokens WHERE address = ?) to decide whether to attach a valid accept MAC,
|
||||
and that same row's presence is exactly "has this recipient accepted the
|
||||
gateway". The recipient must do two things before this check passes, not
|
||||
one: accept the gateway's address on their own smolmail account (which only
|
||||
affects their own server's tiering), and then actually send the gateway
|
||||
something -- the token only reaches pneumatube's local `tokens` table via a
|
||||
message's signed Accept frontmatter field (learn_token(), run at FETCH time).
|
||||
Accepting alone does not make pneumatube aware of it.
|
||||
|
||||
The relayed message deliberately carries no claim about the original email
|
||||
sender: smolmail's own signature already tells the recipient this message
|
||||
is authentically from the gateway's registered identity, which is the only
|
||||
thing anyone here can vouch for. An email's MAIL FROM is never authenticated
|
||||
(SMTP has no mechanism for that without SPF/DKIM enforcement upstream, which
|
||||
this project does not configure), so forwarding it into a frontmatter field
|
||||
that looks like gateway-attested metadata would let any email sender forge
|
||||
an apparently-trustworthy "from" inside a signed smolmail message -- a
|
||||
sender-spoofing vector, not a feature. If the recipient needs to know who
|
||||
sent the original email, that has to come from the body text itself, which
|
||||
is clearly just quoted content rather than something the signature backs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -12,7 +40,7 @@ import logging
|
|||
from email.message import Message
|
||||
from types import ModuleType
|
||||
|
||||
from .config import Config, FRONTMATTER_SENDER_KEY
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -30,28 +58,51 @@ class EmailToSmolBridge:
|
|||
def user_exists(self, username: str) -> bool | None:
|
||||
"""True/False for a definite answer; None when resolving itself
|
||||
failed (pinning/network), which is not the same as "no such user"
|
||||
and should not be treated as a permanent bounce."""
|
||||
and should not be treated as a permanent bounce.
|
||||
|
||||
Caches the resolved identity as a contact (trust-on-first-use, same
|
||||
as smolmail.py's own trust_key) so forward()'s cmd_send -- called a
|
||||
moment later for the same username -- hits that cache instead of
|
||||
paying for a second full connect-and-RESOLVE round trip. Each one is
|
||||
a fresh TCP + Noise handshake (smolmail.py has no connection
|
||||
pooling), so skipping the duplicate roughly halves the network cost
|
||||
of this direction's hot path.
|
||||
"""
|
||||
try:
|
||||
addr = self.smol.Address.parse(f"{username}@{self.config.smolmail_domain}")
|
||||
with self.smol.connect(self.store, addr, require_pin=True) as conn:
|
||||
self.smol.do_resolve(conn, username)
|
||||
identity, _chain = self.smol.do_resolve(conn, username)
|
||||
except self.smol.SmolError as exc:
|
||||
if "unknown user" in str(exc):
|
||||
return False
|
||||
logger.warning("resolve for %s failed: %s", username, exc)
|
||||
return None
|
||||
self.store.save_contact(addr.short, identity, verified=False)
|
||||
return True
|
||||
|
||||
def forward(self, username: str, sender: str, subject: str, body: str) -> None:
|
||||
# Subject and sender end up as frontmatter values (SPEC.md S5.5: one
|
||||
# `Key: value` per line); a bare CR/LF smuggled through email headers
|
||||
# would otherwise let a sender inject extra frontmatter fields.
|
||||
def recipient_accepted_gateway(self, username: str) -> bool:
|
||||
"""Whether pneumatube holds an accept token for this recipient --
|
||||
see this module's docstring for exactly what that does and doesn't
|
||||
mean. A pure local lookup against the gateway's own db, no network.
|
||||
|
||||
Addresses are stored (by learn_token()) in Address.short's
|
||||
normalized form, which drops an explicit default port -- build the
|
||||
lookup key the same way rather than concatenating smolmail_domain
|
||||
directly, or a configured "host:1961" would never match.
|
||||
"""
|
||||
addr = self.smol.Address.parse(f"{username}@{self.config.smolmail_domain}")
|
||||
return self.store.one("SELECT token FROM tokens WHERE address = ?", addr.short) is not None
|
||||
|
||||
def forward(self, username: str, subject: str, body: str) -> None:
|
||||
# Subject ends up as a frontmatter value (SPEC.md S5.5: one
|
||||
# `Key: value` per line); a bare CR/LF smuggled through the email
|
||||
# Subject header would otherwise let a sender inject extra
|
||||
# frontmatter fields.
|
||||
send_args = argparse.Namespace(
|
||||
key=str(self.config.smolmail_key), db=str(self.config.smolmail_db),
|
||||
address=f"{username}@{self.config.smolmail_domain}",
|
||||
body=body, subject=_single_line(subject), reply_to=None,
|
||||
header=[f"{FRONTMATTER_SENDER_KEY}: {_single_line(sender)}"],
|
||||
anonymous=False, no_pad=False)
|
||||
header=[], anonymous=False, no_pad=False)
|
||||
self.smol.cmd_send(send_args, self.store)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""Postfix tcp_table(5) lookup service, backing check_recipient_access.
|
||||
|
||||
Without this, Postfix's smtpd accepts a recipient into its queue before ever
|
||||
asking pneumatube whether the username exists — the live RESOLVE check only
|
||||
runs later, when the queue manager delivers over LMTP, so an unknown user
|
||||
gets a backscatter-prone bounce instead of a live SMTP-time reject. Wiring
|
||||
this service into smtpd_recipient_restrictions moves that same RESOLVE check
|
||||
asking pneumatube whether the username exists and has accepted the gateway
|
||||
(SPEC.md S5.8) — those checks only run later, when the queue manager
|
||||
delivers over LMTP, so a reject becomes a backscatter-prone bounce instead
|
||||
of a live SMTP-time reject. Wiring this service into
|
||||
smtpd_recipient_restrictions moves the same checks handle_RCPT already does
|
||||
to RCPT TO time, before Postfix commits to accepting the message.
|
||||
|
||||
Protocol (tcp_table(5)): one request per line, `get <key>`, where key and
|
||||
|
|
@ -33,12 +34,19 @@ async def _lookup(bridge: EmailToSmolBridge, worker: SmolWorker, config: Config,
|
|||
# to a bare-domain lookup after a miss) — no opinion.
|
||||
return "500 not found"
|
||||
|
||||
exists = await worker.run(bridge.user_exists, username.lower())
|
||||
username = username.lower()
|
||||
exists = await worker.run(bridge.user_exists, username)
|
||||
if exists is None:
|
||||
return "400 temporary failure resolving recipient"
|
||||
if exists:
|
||||
return "200 " + quote("OK")
|
||||
return "200 " + quote("550 5.1.1 no such user")
|
||||
if not exists:
|
||||
return "200 " + quote("550 5.1.1 no such user")
|
||||
|
||||
if config.require_accepted_recipient:
|
||||
accepted = await worker.run(bridge.recipient_accepted_gateway, username)
|
||||
if not accepted:
|
||||
return "200 " + quote("550 5.7.1 recipient has not accepted mail from this gateway")
|
||||
|
||||
return "200 " + quote("OK")
|
||||
|
||||
|
||||
async def _handle_connection(bridge, worker, config, reader, writer) -> None:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,27 @@ the Email-To frontmatter field (SPEC.md S5.5) to a real mailbox over SMTP.
|
|||
A message without that field is left in the local inbox rather than dropped,
|
||||
so it stays visible to `smolmail.py list`/`read` against the gateway's own
|
||||
db for manual follow-up.
|
||||
|
||||
By default (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=1, config.require_accepted_
|
||||
sender), only the main tier is relayed: a first-contact sender with no
|
||||
accept token (SPEC.md S5.8) lands in the requests tier and is left alone,
|
||||
so the gateway can't be used as an open relay by anyone who merely learns
|
||||
its smolmail address. Setting the flag to false restores the earlier "relay
|
||||
anyone" behavior for a deliberately open/public bridge.
|
||||
|
||||
Unblocking a sender takes two steps, not one -- confirmed live, since it is
|
||||
easy to assume `accept` alone is enough:
|
||||
1. `smolmail.py --key gateway.key --db gateway.db accept <address-or-smol://-uri>`
|
||||
records the acceptance locally and uploads the token to the server, but
|
||||
does NOT retroactively reclassify a message already sitting in this
|
||||
gateway's local inbox (its tier was decided at fetch time) or do
|
||||
anything the sender can see.
|
||||
2. The sender only learns the token when it arrives in a reply's Accept
|
||||
frontmatter field (SPEC.md S5.8) -- which pneumatube itself never sends,
|
||||
since this direction only relays outward to SMTP. The operator must
|
||||
send at least one message back from the gateway's own identity (e.g.
|
||||
`echo hi | smolmail.py --key gateway.key --db gateway.db send <address>`)
|
||||
before the sender's *next* message will actually carry a valid token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -29,10 +50,20 @@ class SmolToEmailBridge:
|
|||
|
||||
def poll_once(self) -> None:
|
||||
self.smol.cmd_fetch(self.fetch_args, self.store)
|
||||
# "all" rather than "inbox": a gateway must relay unsolicited senders
|
||||
# too, so requests-tier mail (SPEC.md S5.8) is not silently skipped.
|
||||
for mid, envelope, _received_at, _recipient in self.store.mail("all"):
|
||||
self._relay(mid, envelope)
|
||||
# "inbox" (main tier only) when gating on acceptance; "all" includes
|
||||
# the requests tier too, for a deliberately open bridge.
|
||||
box = "inbox" if self.config.require_accepted_sender else "all"
|
||||
for mid, envelope, _received_at, _recipient in self.store.mail(box):
|
||||
try:
|
||||
self._relay(mid, envelope)
|
||||
except Exception:
|
||||
# One message's failure must not block every message behind
|
||||
# it: _relay() only deletes on success, so a message that
|
||||
# keeps raising here (e.g. smtp_client.send() rejecting a
|
||||
# value _relay()'s own except clause doesn't cover) would
|
||||
# otherwise jam this entire direction forever -- it always
|
||||
# sorts first by received_at on the next poll.
|
||||
logger.exception("failed to relay %s; left for manual review", mid.hex()[:16])
|
||||
|
||||
def _relay(self, mid: bytes, envelope: bytes) -> None:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ def validate(address: str) -> str:
|
|||
def send(config: Config, *, to_addr: str, subject: str, body: str, info_header: str) -> None:
|
||||
to_addr = validate(to_addr)
|
||||
message = EmailMessage()
|
||||
# Fixed, not derived from the smolmail sender: that sender's address
|
||||
# isn't a deliverable email address and isn't authorized (SPF/DKIM) to
|
||||
# send as anything but this one, operator-configured address. A
|
||||
# per-sender plus-address under this same domain (e.g.
|
||||
# "gateway+alice@example.com") would stay within that authorization
|
||||
# while still distinguishing senders and could route a reply back to
|
||||
# the originating smolmail identity -- a real option if useful later,
|
||||
# just not implemented.
|
||||
message["From"] = config.gateway_from_address
|
||||
message["To"] = to_addr
|
||||
message["Subject"] = subject
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ the internet it has no TLS, rate limiting or rDNS checks.
|
|||
|
||||
handle_RCPT does a live smolmail RESOLVE per recipient, so an unknown
|
||||
username is rejected inline during the conversation rather than bounced
|
||||
later. handle_DATA replies once per accepted recipient (joined by CRLF)
|
||||
instead of a single pass/fail for the whole message, which is what makes
|
||||
LMTP's per-recipient status semantics real rather than cosmetic.
|
||||
later. By default it also requires the recipient to have accepted the
|
||||
gateway (PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT; see email_to_smol.py's
|
||||
module docstring) -- an all-or-nothing reject, not a demotion. handle_DATA
|
||||
replies once per accepted recipient (joined by CRLF) instead of a single
|
||||
pass/fail for the whole message, which is what makes LMTP's per-recipient
|
||||
status semantics real rather than cosmetic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -42,12 +45,16 @@ class GatewayHandler:
|
|||
if not exists:
|
||||
return "550 no such user"
|
||||
|
||||
if self.config.require_accepted_recipient:
|
||||
accepted = await self.worker.run(self.bridge.recipient_accepted_gateway, username)
|
||||
if not accepted:
|
||||
return "550 5.7.1 recipient has not accepted mail from this gateway"
|
||||
|
||||
envelope.rcpt_tos.append(f"{username}@{domain}")
|
||||
return "250 OK"
|
||||
|
||||
async def handle_DATA(self, server, session, envelope):
|
||||
message = email_module.message_from_bytes(envelope.content)
|
||||
sender = envelope.mail_from
|
||||
subject = str(message.get("Subject", "(no subject)"))
|
||||
body = self.bridge.extract_text(message)
|
||||
|
||||
|
|
@ -55,7 +62,7 @@ class GatewayHandler:
|
|||
for address in envelope.rcpt_tos:
|
||||
username = address.split("@", 1)[0]
|
||||
try:
|
||||
await self.worker.run(self.bridge.forward, username, sender, subject, body)
|
||||
await self.worker.run(self.bridge.forward, username, subject, body)
|
||||
replies.append("250 2.1.5 delivered")
|
||||
except Exception:
|
||||
logger.exception("failed to forward mail for %s", username)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue