mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-10-01 19:43:49 +00:00
Registration has been dead since 2026-09-13. `ssh join@bbs.profullstack.com` creates the account, then fails at the confirmation step with "couldn't email the code" and disconnects, so nobody can finish signing up. Cause: Caddy owns ACME for mail.profullstack.com and renewed on 2026-08-14 (valid to Nov 12), but Mailu went on serving the certificate it loaded at container start (Jun 15 -> Sep 13). When that lapsed, the STARTTLS handshake from internal/mail started failing verification and every transactional send died with it -- confirmation codes, signup notifications, credential mail. Reproduced against production; 25/465/993 all still present the expired cert while :443 serves the renewed one. Three things let a single stale certificate take registration down: - setup.sh installed the refresher and enabled its *timer*, but never ran it. `systemctl enable --now <timer>` starts the timer, not the service, so a redeploy left a stale cert in place (and did nothing at all if the timer was never scheduled). The news and IRC sections already run theirs at provision time; the Mailu section now does too, which is what repairs the live host. - refresh-certs.sh only compared files, so a copy whose reload silently failed left a fresh cert on disk and an expiring one on the wire -- invisible. It now reads back what the relay actually serves, forces a reload when that disagrees with /certs, refuses to copy a source cert that is itself expired, and no longer swallows the `docker compose restart` failure. It restarts `front` alone, the only container that mounts ./certs. - internal/mail verified the relay's certificate even on loopback, where there is nothing to intercept. It now skips verification for a loopback relay (the reasoning docs/mail.md already applies to the plaintext Dovecot hand-off) and gains AGENTBBS_SMTP_SERVERNAME, mirroring AGENTBBS_MAIL_SMTP_SERVERNAME, so the documented 127.0.0.1:25 config can verify against the mail host instead of an IP literal. A non-loopback relay is still verified. Errors are wrapped with the address and the failing stage so the next failure is one journal line to diagnose rather than nine days of silence. Tests cover the envelope, the unreachable-relay message, and both halves of the TLS decision: a loopback relay with an expired cert delivers, a non-loopback one with the same cert is refused. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
9.2 KiB
Go
329 lines
9.2 KiB
Go
package mail
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"math/big"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestConfigured(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
c Config
|
|
want bool
|
|
}{
|
|
{"host+from", Config{Host: "mail.example.com", From: "bbs@example.com"}, true},
|
|
{"no host", Config{From: "bbs@example.com"}, false},
|
|
{"no from", Config{Host: "mail.example.com"}, false},
|
|
{"empty", Config{}, false},
|
|
} {
|
|
if got := tc.c.Configured(); got != tc.want {
|
|
t.Errorf("%s: Configured() = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPortDefaultsToSubmission(t *testing.T) {
|
|
if got := (Config{}).port(); got != "587" {
|
|
t.Errorf("port() = %q, want 587", got)
|
|
}
|
|
if got := (Config{Port: "25"}).port(); got != "25" {
|
|
t.Errorf("port() = %q, want 25", got)
|
|
}
|
|
}
|
|
|
|
// A loopback relay presents a certificate for its public mail host, so the
|
|
// name STARTTLS is verified against has to be overridable.
|
|
func TestTLSServerNameOverride(t *testing.T) {
|
|
if got := (Config{Host: "127.0.0.1"}).tlsServerName(); got != "127.0.0.1" {
|
|
t.Errorf("tlsServerName() = %q, want the host", got)
|
|
}
|
|
c := Config{Host: "127.0.0.1", ServerName: "mail.example.com"}
|
|
if got := c.tlsServerName(); got != "mail.example.com" {
|
|
t.Errorf("tlsServerName() = %q, want the override", got)
|
|
}
|
|
}
|
|
|
|
func TestIsLoopback(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
host string
|
|
want bool
|
|
}{
|
|
{"127.0.0.1", true},
|
|
{"127.1.2.3", true},
|
|
{"::1", true},
|
|
{"[::1]", true},
|
|
{"localhost", true},
|
|
{"LocalHost", true},
|
|
{"mail.example.com", false},
|
|
{"10.0.0.5", false},
|
|
{"", false},
|
|
} {
|
|
if got := isLoopback(tc.host); got != tc.want {
|
|
t.Errorf("isLoopback(%q) = %v, want %v", tc.host, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSendUnconfigured(t *testing.T) {
|
|
if err := (Config{}).Send("a@example.com", "s", "b"); err == nil {
|
|
t.Fatal("Send() on an unconfigured relay should fail")
|
|
}
|
|
}
|
|
|
|
// fakeSMTP is a minimal ESMTP server that does not advertise STARTTLS. It
|
|
// records the dialogue so a test can assert on the envelope and body.
|
|
func fakeSMTP(t *testing.T) (addr string, got func() string) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
|
|
done := make(chan string, 1)
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
done <- ""
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
var log strings.Builder
|
|
w := bufio.NewWriter(conn)
|
|
r := bufio.NewReader(conn)
|
|
say := func(s string) {
|
|
_, _ = w.WriteString(s + "\r\n")
|
|
_ = w.Flush()
|
|
}
|
|
say("220 fake ESMTP ready")
|
|
inData := false
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
break
|
|
}
|
|
log.WriteString(line)
|
|
line = strings.TrimRight(line, "\r\n")
|
|
switch {
|
|
case inData:
|
|
if line == "." {
|
|
inData = false
|
|
say("250 2.0.0 queued")
|
|
}
|
|
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
|
|
say("250-fake")
|
|
say("250 8BITMIME")
|
|
case strings.HasPrefix(line, "MAIL FROM"), strings.HasPrefix(line, "RCPT TO"):
|
|
say("250 2.0.0 ok")
|
|
case line == "DATA":
|
|
inData = true
|
|
say("354 go ahead")
|
|
case line == "QUIT":
|
|
say("221 2.0.0 bye")
|
|
done <- log.String()
|
|
return
|
|
default:
|
|
say("250 2.0.0 ok")
|
|
}
|
|
}
|
|
done <- log.String()
|
|
}()
|
|
return ln.Addr().String(), func() string { return <-done }
|
|
}
|
|
|
|
func TestSendDeliversMessage(t *testing.T) {
|
|
addr, got := fakeSMTP(t)
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
t.Fatalf("split: %v", err)
|
|
}
|
|
c := Config{Host: host, Port: port, From: "bbs@example.com"}
|
|
if err := c.Send("member@example.net", "Your AgentBBS confirmation code", "code: 123456\nthanks"); err != nil {
|
|
t.Fatalf("Send() = %v, want nil", err)
|
|
}
|
|
dialogue := got()
|
|
for _, want := range []string{
|
|
"MAIL FROM:<bbs@example.com>",
|
|
"RCPT TO:<member@example.net>",
|
|
"Subject: Your AgentBBS confirmation code",
|
|
"code: 123456",
|
|
} {
|
|
if !strings.Contains(dialogue, want) {
|
|
t.Errorf("dialogue missing %q:\n%s", want, dialogue)
|
|
}
|
|
}
|
|
// The body must be CRLF-terminated on the wire, not bare LF.
|
|
if strings.Contains(dialogue, "code: 123456\nthanks") {
|
|
t.Error("body lines were not CRLF-normalised")
|
|
}
|
|
}
|
|
|
|
// A relay that cannot be reached must surface a wrapped error naming the
|
|
// address, so the operator can tell "nothing is listening" from "TLS refused".
|
|
func TestSendUnreachableRelayNamesAddress(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
_ = ln.Close() // nothing is listening there now
|
|
|
|
host, port, _ := net.SplitHostPort(addr)
|
|
err = Config{Host: host, Port: port, From: "bbs@example.com"}.Send("a@example.com", "s", "b")
|
|
if err == nil {
|
|
t.Fatal("Send() to a dead relay should fail")
|
|
}
|
|
if !strings.Contains(err.Error(), addr) {
|
|
t.Errorf("error %q should name the relay address %q", err, addr)
|
|
}
|
|
}
|
|
|
|
// selfSignedFor mints a certificate for name that is already expired — the
|
|
// exact condition that took registration down: an on-box relay whose cert
|
|
// lapsed.
|
|
func selfSignedFor(t *testing.T, name string) tls.Certificate {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("key: %v", err)
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: name},
|
|
DNSNames: []string{name},
|
|
NotBefore: time.Now().Add(-90 * 24 * time.Hour),
|
|
NotAfter: time.Now().Add(-24 * time.Hour), // expired yesterday
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("cert: %v", err)
|
|
}
|
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
|
}
|
|
|
|
// starttlsSMTP is a fake relay that advertises STARTTLS and serves an expired
|
|
// certificate for a name that is not the address being dialled.
|
|
func starttlsSMTP(t *testing.T, certName string) (host, port string, delivered <-chan bool) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
cert := selfSignedFor(t, certName)
|
|
|
|
done := make(chan bool, 1)
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
done <- false
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
serve := func(c net.Conn, tlsUp bool) bool {
|
|
r := bufio.NewReader(c)
|
|
w := bufio.NewWriter(c)
|
|
say := func(s string) {
|
|
_, _ = w.WriteString(s + "\r\n")
|
|
_ = w.Flush()
|
|
}
|
|
if !tlsUp {
|
|
say("220 fake ESMTP ready")
|
|
}
|
|
inData := false
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return false
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
switch {
|
|
case inData:
|
|
if line == "." {
|
|
inData = false
|
|
say("250 2.0.0 queued")
|
|
}
|
|
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
|
|
say("250-fake")
|
|
if !tlsUp {
|
|
say("250 STARTTLS")
|
|
} else {
|
|
say("250 8BITMIME")
|
|
}
|
|
case line == "STARTTLS":
|
|
return true // caller upgrades and re-serves
|
|
case strings.HasPrefix(line, "MAIL FROM"), strings.HasPrefix(line, "RCPT TO"):
|
|
say("250 2.0.0 ok")
|
|
case line == "DATA":
|
|
inData = true
|
|
say("354 go ahead")
|
|
case line == "QUIT":
|
|
say("221 2.0.0 bye")
|
|
return false
|
|
default:
|
|
say("250 2.0.0 ok")
|
|
}
|
|
}
|
|
}
|
|
|
|
if !serve(conn, false) {
|
|
done <- false
|
|
return
|
|
}
|
|
// Acknowledge STARTTLS, then hand the connection to TLS.
|
|
_, _ = conn.Write([]byte("220 2.0.0 ready to start TLS\r\n"))
|
|
tc := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
|
|
if err := tc.Handshake(); err != nil {
|
|
done <- false
|
|
return
|
|
}
|
|
serve(tc, true)
|
|
done <- true
|
|
}()
|
|
|
|
h, p, err := net.SplitHostPort(ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("split: %v", err)
|
|
}
|
|
return h, p, done
|
|
}
|
|
|
|
// The regression this guards: an expired/mismatched certificate on a co-located
|
|
// relay must NOT be able to block join@ registration. Loopback traffic cannot be
|
|
// intercepted, so the sender proceeds instead of failing the signup.
|
|
func TestSendLoopbackRelayToleratesBadCert(t *testing.T) {
|
|
host, port, delivered := starttlsSMTP(t, "mail.example.com")
|
|
c := Config{Host: host, Port: port, From: "bbs@example.com", ServerName: "mail.example.com"}
|
|
if err := c.Send("member@example.net", "Your AgentBBS confirmation code", "code: 123456"); err != nil {
|
|
t.Fatalf("Send() over a loopback relay with an expired cert = %v, want nil", err)
|
|
}
|
|
if !<-delivered {
|
|
t.Error("relay did not complete the TLS session")
|
|
}
|
|
}
|
|
|
|
// The same expired cert on a relay that is NOT loopback must still be rejected:
|
|
// skipping verification is a loopback-only concession, not a blanket opt-out.
|
|
func TestSendRemoteRelayRejectsBadCert(t *testing.T) {
|
|
_, port, _ := starttlsSMTP(t, "mail.example.com")
|
|
// "localhost." (trailing dot) resolves to 127.0.0.1 but is not recognised as
|
|
// a loopback literal, so the verified path is exercised against a real dial.
|
|
c := Config{Host: "localhost.", Port: port, From: "bbs@example.com"}
|
|
err := c.Send("member@example.net", "s", "b")
|
|
if err == nil {
|
|
t.Fatal("Send() to a non-loopback relay with an expired cert should fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "starttls") {
|
|
t.Errorf("error %q should identify the STARTTLS stage", err)
|
|
}
|
|
}
|