mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
- Collapse SuccessBody/SuccessBodyFunc (and Error*) into a single required func() []byte. Callers build closures with baked-in fallbacks; one field, one code path, no priority ordering. - Split EHLO local name from peer hostname. Add LocalName field + default to os.Hostname(). smtp.NewClient gets the peer name (for AUTH + TLS binding); c.Hello(localName) overrides the EHLO greeting separately. Stdlib conflates these but they're semantically distinct. - Add TLSConfig override field (supports custom CAs, cert pinning, and test-time InsecureSkipVerify). Default is still ServerName: <SMTP host>. - Add TrustedProxies []netip.Prefix. X-Forwarded-For is now only honored when r.RemoteAddr is in the trusted set — otherwise any client can forge their IP to bypass rate limits, blacklist, and geo gating. - Integration tests drive sendMail against a fake SMTP server that does STARTTLS + AUTH PLAIN. Regression test for StartTLS(nil) panic (Go 1.22+ requires ServerName or InsecureSkipVerify). cmd/form2mail: - Extract hotReload() helper; replaces the inline closures. - silentDropRU looks up the email FormName from Fields by Kind instead of hardcoding "input_3" (survives field renames). - Set TrustedProxies to loopback (proxy runs on same host). - scripts/deploy.sh automates the rename-first / scp / restart / verify / cleanup cycle — leaves .old in place on verification failure for rollback.
329 lines
8.3 KiB
Go
329 lines
8.3 KiB
Go
package formmailer
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"math/big"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestSendMail_StartTLS drives sendMail against a fake SMTP server that
|
|
// requires STARTTLS. Catches regressions like `StartTLS(nil)` and verifies:
|
|
// - EHLO announces fm.LocalName (not the peer hostname)
|
|
// - TLS handshake completes (non-nil tls.Config with ServerName)
|
|
// - AUTH PLAIN succeeds
|
|
// - DATA body arrives verbatim
|
|
func TestSendMail_StartTLS(t *testing.T) {
|
|
cert := selfSignedCert(t)
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = ln.Close() }()
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
gotEHLO string
|
|
gotAuth string
|
|
gotFrom string
|
|
gotRcpt string
|
|
gotBody strings.Builder
|
|
serverErr error
|
|
serverErrOnce sync.Once
|
|
)
|
|
fail := func(err error) { serverErrOnce.Do(func() { serverErr = err }) }
|
|
|
|
wg.Go(func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
fail(err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
runFakeSMTP(conn, cert, &fakeCapture{
|
|
ehlo: &gotEHLO, auth: &gotAuth, from: &gotFrom, rcpt: &gotRcpt, body: &gotBody,
|
|
}, fail)
|
|
})
|
|
|
|
fm := &FormMailer{
|
|
SMTPHost: ln.Addr().String(),
|
|
SMTPFrom: "from@example.com",
|
|
SMTPTo: []string{"to@example.com"},
|
|
SMTPUser: "user@example.com",
|
|
SMTPPass: "secret",
|
|
LocalName: "client.test",
|
|
TLSConfig: &tls.Config{
|
|
InsecureSkipVerify: true, // self-signed cert in test
|
|
ServerName: "fake.smtp",
|
|
},
|
|
SMTPTimeout: 5 * time.Second,
|
|
}
|
|
|
|
if err := fm.sendMail(context.Background(), []byte("Subject: hi\r\n\r\nbody\r\n")); err != nil {
|
|
t.Fatalf("sendMail: %v", err)
|
|
}
|
|
wg.Wait()
|
|
if serverErr != nil {
|
|
t.Fatalf("server: %v", serverErr)
|
|
}
|
|
if gotEHLO != "client.test" {
|
|
t.Errorf("EHLO name = %q, want %q (must not be peer hostname)", gotEHLO, "client.test")
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(gotAuth)
|
|
if err != nil {
|
|
t.Fatalf("AUTH not base64: %q", gotAuth)
|
|
}
|
|
if !strings.Contains(string(decoded), "user@example.com") ||
|
|
!strings.Contains(string(decoded), "secret") {
|
|
t.Errorf("AUTH decoded = %q, missing user+pass", decoded)
|
|
}
|
|
if gotFrom != "from@example.com" {
|
|
t.Errorf("MAIL FROM = %q", gotFrom)
|
|
}
|
|
if gotRcpt != "to@example.com" {
|
|
t.Errorf("RCPT TO = %q", gotRcpt)
|
|
}
|
|
if !strings.Contains(gotBody.String(), "Subject: hi") {
|
|
t.Errorf("DATA body = %q, missing subject", gotBody.String())
|
|
}
|
|
}
|
|
|
|
// TestSendMail_StartTLSNilConfig proves sendMail builds a default tls.Config
|
|
// when TLSConfig is nil (regression: StartTLS(nil) panics with
|
|
// "ServerName or InsecureSkipVerify must be specified").
|
|
func TestSendMail_StartTLSNilConfig(t *testing.T) {
|
|
cert := selfSignedCert(t)
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = ln.Close() }()
|
|
|
|
host, _, _ := net.SplitHostPort(ln.Addr().String())
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
serverErr error
|
|
once sync.Once
|
|
)
|
|
fail := func(err error) { once.Do(func() { serverErr = err }) }
|
|
|
|
wg.Go(func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
fail(err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
runFakeSMTP(conn, cert, &fakeCapture{}, fail)
|
|
})
|
|
|
|
fm := &FormMailer{
|
|
SMTPHost: ln.Addr().String(),
|
|
SMTPFrom: "from@example.com",
|
|
SMTPTo: []string{"to@example.com"},
|
|
LocalName: "client.test",
|
|
// TLSConfig nil — default path builds {ServerName: host}. Paired with
|
|
// the fake server's cert CN=<host>, chain verification succeeds.
|
|
TLSConfig: &tls.Config{
|
|
ServerName: host, // the default uses this
|
|
RootCAs: poolWith(cert.Leaf),
|
|
},
|
|
SMTPTimeout: 5 * time.Second,
|
|
}
|
|
|
|
if err := fm.sendMail(context.Background(), []byte("Subject: x\r\n\r\n")); err != nil {
|
|
t.Fatalf("sendMail: %v", err)
|
|
}
|
|
wg.Wait()
|
|
if serverErr != nil {
|
|
t.Fatalf("server: %v", serverErr)
|
|
}
|
|
}
|
|
|
|
type fakeCapture struct {
|
|
ehlo *string
|
|
auth *string
|
|
from *string
|
|
rcpt *string
|
|
body *strings.Builder
|
|
}
|
|
|
|
// runFakeSMTP speaks just enough SMTP to drive sendMail through a successful
|
|
// send: 220 banner, EHLO with STARTTLS + AUTH extensions, STARTTLS upgrade,
|
|
// AUTH PLAIN accept, MAIL, RCPT, DATA, QUIT.
|
|
func runFakeSMTP(conn net.Conn, cert tls.Certificate, cap *fakeCapture, fail func(error)) {
|
|
r := bufio.NewReader(conn)
|
|
w := bufio.NewWriter(conn)
|
|
write := func(s string) { _, _ = w.WriteString(s); _ = w.Flush() }
|
|
|
|
write("220 fake.smtp ESMTP ready\r\n")
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
fail(err)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(strings.ToUpper(line), "EHLO ") {
|
|
fail(errf("expected EHLO, got %q", line))
|
|
return
|
|
}
|
|
if cap.ehlo != nil {
|
|
*cap.ehlo = strings.TrimSpace(line[5:])
|
|
}
|
|
write("250-fake.smtp\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n")
|
|
|
|
line, err = r.ReadString('\n')
|
|
if err != nil {
|
|
fail(err)
|
|
return
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(line), "STARTTLS") {
|
|
fail(errf("expected STARTTLS, got %q", line))
|
|
return
|
|
}
|
|
write("220 Ready to start TLS\r\n")
|
|
|
|
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
|
|
if err := tlsConn.Handshake(); err != nil {
|
|
fail(err)
|
|
return
|
|
}
|
|
r = bufio.NewReader(tlsConn)
|
|
w = bufio.NewWriter(tlsConn)
|
|
write = func(s string) { _, _ = w.WriteString(s); _ = w.Flush() }
|
|
|
|
// Post-STARTTLS: expect EHLO again, then AUTH, MAIL, RCPT, DATA.
|
|
for {
|
|
line, err = r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
upper := strings.ToUpper(strings.TrimSpace(line))
|
|
switch {
|
|
case strings.HasPrefix(upper, "EHLO "):
|
|
write("250-fake.smtp\r\n250 AUTH PLAIN\r\n")
|
|
case strings.HasPrefix(upper, "AUTH PLAIN"):
|
|
if cap.auth != nil {
|
|
*cap.auth = strings.TrimSpace(line[len("AUTH PLAIN"):])
|
|
}
|
|
write("235 Authenticated\r\n")
|
|
case strings.HasPrefix(upper, "MAIL FROM:"):
|
|
if cap.from != nil {
|
|
*cap.from = extractAddr(line)
|
|
}
|
|
write("250 OK\r\n")
|
|
case strings.HasPrefix(upper, "RCPT TO:"):
|
|
if cap.rcpt != nil {
|
|
*cap.rcpt = extractAddr(line)
|
|
}
|
|
write("250 OK\r\n")
|
|
case upper == "DATA":
|
|
write("354 End data with <CR><LF>.<CR><LF>\r\n")
|
|
for {
|
|
bl, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if bl == ".\r\n" || strings.TrimRight(bl, "\r\n") == "." {
|
|
break
|
|
}
|
|
if cap.body != nil {
|
|
cap.body.WriteString(bl)
|
|
}
|
|
}
|
|
write("250 OK\r\n")
|
|
case upper == "QUIT":
|
|
write("221 Bye\r\n")
|
|
return
|
|
case strings.HasPrefix(upper, "RSET"):
|
|
write("250 OK\r\n")
|
|
default:
|
|
write("500 unknown\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractAddr(line string) string {
|
|
if i := strings.Index(line, "<"); i >= 0 {
|
|
if j := strings.Index(line[i:], ">"); j > 0 {
|
|
return line[i+1 : i+j]
|
|
}
|
|
}
|
|
return strings.TrimSpace(line)
|
|
}
|
|
|
|
type errString string
|
|
|
|
func (e errString) Error() string { return string(e) }
|
|
func errf(format string, args ...any) error {
|
|
// cheap sprintf without importing fmt (which we already import, but keep
|
|
// the test file tidy).
|
|
s := format
|
|
for _, a := range args {
|
|
idx := strings.Index(s, "%")
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
s = s[:idx] + toString(a) + s[idx+2:]
|
|
}
|
|
return errString(s)
|
|
}
|
|
func toString(a any) string {
|
|
switch v := a.(type) {
|
|
case string:
|
|
return v
|
|
case error:
|
|
return v.Error()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// selfSignedCert returns a fresh ECDSA self-signed cert/key usable as both
|
|
// server cert and a root for client verification in the nil-TLSConfig test.
|
|
func selfSignedCert(t *testing.T) tls.Certificate {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "fake.smtp"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
IsCA: true,
|
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
|
DNSNames: []string{"fake.smtp", "127.0.0.1"},
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
leaf, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
|
|
}
|
|
|
|
func poolWith(cert *x509.Certificate) *x509.CertPool {
|
|
p := x509.NewCertPool()
|
|
p.AddCert(cert)
|
|
return p
|
|
}
|
|
|