mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
refactor(net/formmailer): rebase release onto main
This commit is contained in:
parent
be816d8da0
commit
0e6faecf6c
1024
net/formmailer/formmailer.go
Normal file
1024
net/formmailer/formmailer.go
Normal file
File diff suppressed because it is too large
Load Diff
28
net/formmailer/go.mod
Normal file
28
net/formmailer/go.mod
Normal file
@ -0,0 +1,28 @@
|
||||
module github.com/therootcompany/golib/net/formmailer
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/therootcompany/golib/net/geoip v0.6.0
|
||||
github.com/therootcompany/golib/net/ippolicy v0.9.0
|
||||
golang.org/x/time v0.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/miekg/dns v1.1.69 // indirect
|
||||
github.com/oschwald/geoip2-golang v1.13.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||
github.com/therootcompany/golib/https v0.9.0 // indirect
|
||||
github.com/therootcompany/golib/io/transform/gsheet2csv v1.1.0 // indirect
|
||||
github.com/therootcompany/golib/net/dnsresolver v0.6.0 // indirect
|
||||
github.com/therootcompany/golib/net/gitshallow v0.10.0 // indirect
|
||||
github.com/therootcompany/golib/net/httpcache v0.6.0 // indirect
|
||||
github.com/therootcompany/golib/net/ipcohort v0.9.0 // indirect
|
||||
github.com/therootcompany/golib/net/iplist v0.9.0 // indirect
|
||||
github.com/therootcompany/golib/sync/cachable v0.1.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
)
|
||||
48
net/formmailer/go.sum
Normal file
48
net/formmailer/go.sum
Normal file
@ -0,0 +1,48 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
|
||||
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
|
||||
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/therootcompany/golib/https v0.9.0 h1:D9gh0K6ljkvF805JUbylITn8SHPMo08q5PglQGXsK8c=
|
||||
github.com/therootcompany/golib/https v0.9.0/go.mod h1:ilExqR/Uh7oB36oy9mwuNhANcFh+zdCfD3uJiZ11YAY=
|
||||
github.com/therootcompany/golib/io/transform/gsheet2csv v1.1.0 h1:R5zAPun5p3JPlNfcMD9es4LD9sqh6nWDNTflZBeeUJo=
|
||||
github.com/therootcompany/golib/io/transform/gsheet2csv v1.1.0/go.mod h1:CsntOAhhIMdzSAYuwuRZtfkdoiER9+TfSgYOIDWN71o=
|
||||
github.com/therootcompany/golib/net/dnsresolver v0.6.0 h1:sy08rzX/2QowofAKnCw1QtTDoatPL6/FXZcb+Wz4GlM=
|
||||
github.com/therootcompany/golib/net/dnsresolver v0.6.0/go.mod h1:tOV99lX6ghpWPksxSFAt4CDe5XLefFzIGf2qXsZJap4=
|
||||
github.com/therootcompany/golib/net/geoip v0.6.0 h1:+LyOKt47Mbxoi8ssaX9RaN0loW6wyPDA5XvExSiRzB0=
|
||||
github.com/therootcompany/golib/net/geoip v0.6.0/go.mod h1:yrk7CuIet0BntICfRwRSX44fwu/1YAuHHjTQYoWHSOE=
|
||||
github.com/therootcompany/golib/net/gitshallow v0.10.0 h1:cLN9ElTmNWk5HSbaRD3sSFuGdpgy4YXc+LVzjzT0nM4=
|
||||
github.com/therootcompany/golib/net/gitshallow v0.10.0/go.mod h1:S/3OK7wMOqJJjP79U9Qw/l3jjEyrb/zsjJ8HAPAX/Fc=
|
||||
github.com/therootcompany/golib/net/httpcache v0.6.0 h1:qFBnSs6rZrtUQ+fweP4bHF2FQd/Syvs7mRN5nO5YtkY=
|
||||
github.com/therootcompany/golib/net/httpcache v0.6.0/go.mod h1:mKSQJWpGSCDBzWkTCwT7dO1SCVCsFmdkRgKr39fNS1U=
|
||||
github.com/therootcompany/golib/net/ipcohort v0.9.0 h1:j3AaoW2u4XQJz1ya6fixv3pb3KgxIpZV8t0082/kS3E=
|
||||
github.com/therootcompany/golib/net/ipcohort v0.9.0/go.mod h1:Ljcnt2xOryVDGIVV5Kzt5hj2LhW4lBAWo5OeeVYU9ys=
|
||||
github.com/therootcompany/golib/net/iplist v0.9.0 h1:rEDrflVvc5pQWt6sptrZVFWPZZ3M7ATUKjfIuFchKKI=
|
||||
github.com/therootcompany/golib/net/iplist v0.9.0/go.mod h1:8ifRSgp4ptijldClyh688NLom8EZre6E16JZ2AHCEMw=
|
||||
github.com/therootcompany/golib/net/ippolicy v0.9.0 h1:fhNEqtshRLiY1Ycuykf7yj+VkaP+tnnqCvxHXsIhfrE=
|
||||
github.com/therootcompany/golib/net/ippolicy v0.9.0/go.mod h1:VMnaRjH6yTJ6bdnWFpji1xGCboOxEDsao9GDwA6g/ss=
|
||||
github.com/therootcompany/golib/sync/cachable v0.1.0 h1:swCXtXB116jhSmla81HaXi/9cPsqFI+Oq/6OcSfdU5Q=
|
||||
github.com/therootcompany/golib/sync/cachable v0.1.0/go.mod h1:l/NZdGiSbW5CkLlkkRa0yObkqak2sj/jQTXXqXwvCl0=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
128
net/formmailer/run_test.go
Normal file
128
net/formmailer/run_test.go
Normal file
@ -0,0 +1,128 @@
|
||||
package formmailer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// captureStdout runs fn while capturing everything written to os.Stdout,
|
||||
// returning the captured text.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
orig := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
b, _ := io.ReadAll(r)
|
||||
done <- string(b)
|
||||
}()
|
||||
fn()
|
||||
_ = w.Close()
|
||||
os.Stdout = orig
|
||||
return <-done
|
||||
}
|
||||
|
||||
// runOnce starts a FormMailer with a context that expires quickly so Run
|
||||
// returns immediately after selecting and logging its GeoIP mode. It must not
|
||||
// require network (BlocklistRepo is empty) and binds an ephemeral port.
|
||||
func runOnce(t *testing.T, fm *FormMailer) string {
|
||||
t.Helper()
|
||||
fm.ListenAddr = "127.0.0.1:0"
|
||||
fm.RefreshInterval = time.Minute
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
return captureStdout(t, func() {
|
||||
_ = fm.Run(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRunGeoModeSelection sanity-checks the hybrid GeoIP mode selection:
|
||||
// - conf present -> download mode
|
||||
// - conf absent + populated -> poll mode
|
||||
// - conf absent + empty dir -> disabled
|
||||
func TestRunGeoModeSelection(t *testing.T) {
|
||||
t.Run("download", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conf := filepath.Join(dir, "GeoIP.conf")
|
||||
if err := os.WriteFile(conf, []byte(
|
||||
"AccountID 123456\nLicenseKey testkey\nEditionIDs GeoLite2-City GeoLite2-ASN\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 500 server so the owned download loop fails fast; the mode is
|
||||
// selected and logged before serving regardless.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := runOnce(t, &FormMailer{
|
||||
CacheDir: dir,
|
||||
GeoIPConfPath: conf,
|
||||
GeoIPBaseURL: srv.URL,
|
||||
SMTPHost: "localhost:25",
|
||||
SMTPFrom: "a@example.com",
|
||||
SMTPTo: []string{"b@example.com"},
|
||||
})
|
||||
if !strings.Contains(out, "GeoIP: download mode") {
|
||||
t.Fatalf("missing download mode line; stdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("poll", func(t *testing.T) {
|
||||
// Point HOME at a temp dir so the default conf discovery can't find a
|
||||
// real ~/.config/maxmind/GeoIP.conf.
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
archive := filepath.Join(t.TempDir(), "maxmind")
|
||||
if err := os.MkdirAll(archive, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Any entry makes the dir "populated" -> poll mode.
|
||||
if err := os.WriteFile(filepath.Join(archive, "keep"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := runOnce(t, &FormMailer{
|
||||
CacheDir: home,
|
||||
GeoIPDir: archive,
|
||||
SMTPHost: "localhost:25",
|
||||
SMTPFrom: "a@example.com",
|
||||
SMTPTo: []string{"b@example.com"},
|
||||
})
|
||||
if !strings.Contains(out, "GeoIP: poll mode") {
|
||||
t.Fatalf("missing poll mode line; stdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
empty := filepath.Join(home, "maxmind")
|
||||
if err := os.MkdirAll(empty, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out := runOnce(t, &FormMailer{
|
||||
CacheDir: home,
|
||||
GeoIPDir: empty,
|
||||
SMTPHost: "localhost:25",
|
||||
SMTPFrom: "a@example.com",
|
||||
SMTPTo: []string{"b@example.com"},
|
||||
})
|
||||
if strings.Contains(out, "GeoIP:") {
|
||||
t.Fatalf("expected GeoIP disabled; stdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
327
net/formmailer/sendmail_test.go
Normal file
327
net/formmailer/sendmail_test.go
Normal file
@ -0,0 +1,327 @@
|
||||
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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user