add "mox smtp dial" subcommand, for debugging connectivity issues

with various options for the tls connection.
This commit is contained in:
Mechiel Lukkien 2025-05-15 14:12:08 +02:00
parent 70bbfc8f10
commit 4a14abc254
3 changed files with 286 additions and 3 deletions

46
doc.go
View File

@ -114,6 +114,7 @@ any parameters. Followed by the help and usage information for each command.
mox rdap domainage domain mox rdap domainage domain
mox retrain [accountname] mox retrain [accountname]
mox sendmail [-Fname] [ignoredflags] [-t] [<message] mox sendmail [-Fname] [ignoredflags] [-t] [<message]
mox smtp dial host[:port]
mox spf check domain ip mox spf check domain ip
mox spf lookup domain mox spf lookup domain
mox spf parse txtrecord mox spf parse txtrecord
@ -1527,6 +1528,51 @@ binary should be setgid that group:
usage: mox sendmail [-Fname] [ignoredflags] [-t] [<message] usage: mox sendmail [-Fname] [ignoredflags] [-t] [<message]
# mox smtp dial
Dial the address, initialize the SMTP session, including using STARTTLS to enable TLS if the server supports it.
If no port is specified, SMTP port 25 is used.
Data is copied between connection and stdin/stdout until either side closes the
connection.
The flags influence the TLS configuration, useful for debugging interoperability
issues.
No MTA-STS or DANE verification is done.
Hint: Use "mox -loglevel trace smtp dial ..." to see the protocol messages
exchanged during connection set up.
usage: mox smtp dial host[:port]
-ehlohostname string
our hostname to use during the SMTP EHLO command
-forcetls
use TLS, even if remote SMTP server does not announce STARTTLS extension
-notls
do not use TLS
-remotehostname string
remote hostname to use for TLS verification, if enabled; the hostname from the parameter is used by default
-tlscerts string
path to root ca certificates in pem form, for verification
-tlsciphersuites string
ciphersuites to allow, comma-separated, order is ignored, only for TLS 1.2 and earlier, empty value uses TLS stack defaults; values: tls_ecdhe_ecdsa_with_aes_128_cbc_sha, tls_ecdhe_ecdsa_with_aes_128_gcm_sha256, tls_ecdhe_ecdsa_with_aes_256_cbc_sha, tls_ecdhe_ecdsa_with_aes_256_gcm_sha384, tls_ecdhe_ecdsa_with_chacha20_poly1305_sha256, tls_ecdhe_rsa_with_aes_128_cbc_sha, tls_ecdhe_rsa_with_aes_128_gcm_sha256, tls_ecdhe_rsa_with_aes_256_cbc_sha, tls_ecdhe_rsa_with_aes_256_gcm_sha384, tls_ecdhe_rsa_with_chacha20_poly1305_sha256, and insecure: tls_ecdhe_ecdsa_with_aes_128_cbc_sha256, tls_ecdhe_ecdsa_with_rc4_128_sha, tls_ecdhe_rsa_with_3des_ede_cbc_sha, tls_ecdhe_rsa_with_aes_128_cbc_sha256, tls_ecdhe_rsa_with_rc4_128_sha, tls_rsa_with_3des_ede_cbc_sha, tls_rsa_with_aes_128_cbc_sha, tls_rsa_with_aes_128_cbc_sha256, tls_rsa_with_aes_128_gcm_sha256, tls_rsa_with_aes_256_cbc_sha, tls_rsa_with_aes_256_gcm_sha384, tls_rsa_with_rc4_128_sha
-tlscurves string
tls ecc key exchange mechanisms to allow, comma-separated, order is ignored, empty value uses TLS stack defaults; values: curvep256, curvep384, curvep521, x25519, x25519mlkem768
-tlsnodynamicrecordsizing
disable TLS dynamic record sizing
-tlsnosessiontickets
disable TLS session tickets
-tlsrenegotiation string
when to allow renegotiation; only applies to tls1.2 and earlier, not tls1.3; values: never, once, always (default "never")
-tlsverify
verify remote hostname during TLS
-tlsversionmax string
maximum TLS version, empty value uses TLS stack default; values: tls1.2, etc.
-tlsversionmin string
minimum TLS version, empty value uses TLS stack default; values: tls1.2, etc.
# mox spf check # mox spf check
Check the status of IP for the policy published in DNS for the domain. Check the status of IP for the policy published in DNS for the domain.

224
main.go
View File

@ -12,6 +12,7 @@ import (
"crypto/rsa" "crypto/rsa"
"crypto/sha256" "crypto/sha256"
"crypto/sha512" "crypto/sha512"
"crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@ -23,6 +24,7 @@ import (
"io/fs" "io/fs"
"log" "log"
"log/slog" "log/slog"
"maps"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@ -201,6 +203,7 @@ var commands = []struct {
{"rdap domainage", cmdRDAPDomainage}, {"rdap domainage", cmdRDAPDomainage},
{"retrain", cmdRetrain}, {"retrain", cmdRetrain},
{"sendmail", cmdSendmail}, {"sendmail", cmdSendmail},
{"smtp dial", cmdSMTPDial},
{"spf check", cmdSPFCheck}, {"spf check", cmdSPFCheck},
{"spf lookup", cmdSPFLookup}, {"spf lookup", cmdSPFLookup},
{"spf parse", cmdSPFParse}, {"spf parse", cmdSPFParse},
@ -1897,6 +1900,227 @@ with DKIM, by mox.
xcheckf(err, "writing rsa private key") xcheckf(err, "writing rsa private key")
} }
// todo: options for specifying the domain this is the mx host of, and enabling dane and/or mta-sts verification
func cmdSMTPDial(c *cmd) {
c.params = "host[:port]"
var tlsCerts, tlsCiphersuites, tlsCurves, tlsVersionMin, tlsVersionMax, tlsRenegotiation string
var tlsVerify, noTLS, forceTLS, tlsNoSessionTickets, tlsNoDynamicRecordSizing bool
var ehloHostnameStr, remoteHostnameStr string
ciphersuites := map[string]*tls.CipherSuite{}
ciphersuitesInsecure := map[string]*tls.CipherSuite{}
for _, v := range tls.CipherSuites() {
if slices.Contains(v.SupportedVersions, tls.VersionTLS10) || slices.Contains(v.SupportedVersions, tls.VersionTLS11) || slices.Contains(v.SupportedVersions, tls.VersionTLS12) {
ciphersuites[strings.ToLower(v.Name)] = v
}
}
for _, v := range tls.InsecureCipherSuites() {
if slices.Contains(v.SupportedVersions, tls.VersionTLS10) || slices.Contains(v.SupportedVersions, tls.VersionTLS11) || slices.Contains(v.SupportedVersions, tls.VersionTLS12) {
ciphersuitesInsecure[strings.ToLower(v.Name)] = v
}
}
curvesList := []tls.CurveID{
tls.CurveP256,
tls.CurveP384,
tls.CurveP521,
tls.X25519,
tls.X25519MLKEM768,
}
curves := map[string]tls.CurveID{}
for _, a := range curvesList {
curves[strings.ToLower(a.String())] = a
}
c.flag.StringVar(&tlsCiphersuites, "tlsciphersuites", "", "ciphersuites to allow, comma-separated, order is ignored, only for TLS 1.2 and earlier, empty value uses TLS stack defaults; values: "+strings.Join(slices.Sorted(maps.Keys(ciphersuites)), ", ")+", and insecure: "+strings.Join(slices.Sorted(maps.Keys(ciphersuitesInsecure)), ", "))
c.flag.StringVar(&tlsCurves, "tlscurves", "", "tls ecc key exchange mechanisms to allow, comma-separated, order is ignored, empty value uses TLS stack defaults; values: "+strings.Join(slices.Sorted(maps.Keys(curves)), ", "))
c.flag.StringVar(&tlsCerts, "tlscerts", "", "path to root ca certificates in pem form, for verification")
c.flag.StringVar(&tlsVersionMin, "tlsversionmin", "", "minimum TLS version, empty value uses TLS stack default; values: tls1.2, etc.")
c.flag.StringVar(&tlsVersionMax, "tlsversionmax", "", "maximum TLS version, empty value uses TLS stack default; values: tls1.2, etc.")
c.flag.BoolVar(&tlsVerify, "tlsverify", false, "verify remote hostname during TLS")
c.flag.BoolVar(&tlsNoSessionTickets, "tlsnosessiontickets", false, "disable TLS session tickets")
c.flag.BoolVar(&tlsNoDynamicRecordSizing, "tlsnodynamicrecordsizing", false, "disable TLS dynamic record sizing")
c.flag.BoolVar(&noTLS, "notls", false, "do not use TLS")
c.flag.BoolVar(&forceTLS, "forcetls", false, "use TLS, even if remote SMTP server does not announce STARTTLS extension")
c.flag.StringVar(&tlsRenegotiation, "tlsrenegotiation", "never", "when to allow renegotiation; only applies to tls1.2 and earlier, not tls1.3; values: never, once, always")
c.flag.StringVar(&ehloHostnameStr, "ehlohostname", "", "our hostname to use during the SMTP EHLO command")
c.flag.StringVar(&remoteHostnameStr, "remotehostname", "", "remote hostname to use for TLS verification, if enabled; the hostname from the parameter is used by default")
c.help = `Dial the address, initialize the SMTP session, including using STARTTLS to enable TLS if the server supports it.
If no port is specified, SMTP port 25 is used.
Data is copied between connection and stdin/stdout until either side closes the
connection.
The flags influence the TLS configuration, useful for debugging interoperability
issues.
No MTA-STS or DANE verification is done.
Hint: Use "mox -loglevel trace smtp dial ..." to see the protocol messages
exchanged during connection set up.
`
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
if noTLS && forceTLS {
log.Fatalf("cannot have both -notls and -forcetls")
}
parseTLSVersion := func(s string) uint16 {
switch s {
case "tls1.0":
return tls.VersionTLS10
case "tls1.1":
return tls.VersionTLS11
case "tls1.2":
return tls.VersionTLS12
case "tls1.3":
return tls.VersionTLS13
case "":
return 0
default:
log.Fatalf("invalid tls version %q", s)
panic("not reached")
}
}
tlsConfig := tls.Config{
MinVersion: parseTLSVersion(tlsVersionMin),
MaxVersion: parseTLSVersion(tlsVersionMax),
InsecureSkipVerify: !tlsVerify,
SessionTicketsDisabled: tlsNoSessionTickets,
DynamicRecordSizingDisabled: tlsNoDynamicRecordSizing,
}
switch tlsRenegotiation {
case "never":
tlsConfig.Renegotiation = tls.RenegotiateNever
case "once":
tlsConfig.Renegotiation = tls.RenegotiateOnceAsClient
case "always":
tlsConfig.Renegotiation = tls.RenegotiateFreelyAsClient
default:
log.Fatalf("invalid value %q for -tlsrenegotation", tlsRenegotiation)
}
if tlsCerts != "" {
pool := x509.NewCertPool()
pembuf, err := os.ReadFile(tlsCerts)
xcheckf(err, "reading tls certificates")
ok := pool.AppendCertsFromPEM(pembuf)
if !ok {
c.log.Warn("no tls certificates found", slog.String("path", tlsCerts))
}
tlsConfig.RootCAs = pool
}
if tlsCiphersuites != "" {
for s := range strings.SplitSeq(tlsCiphersuites, ",") {
s = strings.TrimSpace(s)
c, ok := ciphersuites[s]
if !ok {
c, ok = ciphersuitesInsecure[s]
}
if !ok {
log.Fatalf("unknown ciphersuite %q", s)
}
tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, c.ID)
}
}
if tlsCurves != "" {
for s := range strings.SplitSeq(tlsCurves, ",") {
s = strings.TrimSpace(s)
if c, ok := curves[s]; !ok {
log.Fatalf("unknown ecc key exchange algorithm %q", s)
} else {
tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, c)
}
}
}
var host, portStr string
var err error
host, portStr, err = net.SplitHostPort(args[0])
if err != nil {
host = args[0]
portStr = "25"
}
port, err := strconv.ParseInt(portStr, 10, 64)
xcheckf(err, "parsing port %q", portStr)
if remoteHostnameStr == "" {
remoteHostnameStr = host
}
remoteHostname, err := dns.ParseDomain(remoteHostnameStr)
xcheckf(err, "parsing remote host")
tlsConfig.ServerName = remoteHostname.Name()
resolver := dns.StrictResolver{Pkg: "smtpdial"}
_, _, _, ips, _, err := smtpclient.GatherIPs(context.Background(), c.log.Logger, resolver, "ip", dns.IPDomain{Domain: remoteHostname}, nil)
xcheckf(err, "resolve host")
c.log.Info("resolved remote address", slog.Any("ips", ips))
dialer := &net.Dialer{Timeout: 5 * time.Second}
dialedIPs := map[string][]net.IP{}
conn, ip, err := smtpclient.Dial(context.Background(), c.log.Logger, dialer, dns.IPDomain{Domain: remoteHostname}, ips, int(port), dialedIPs, nil)
xcheckf(err, "dial")
c.log.Info("connected to remote host", slog.Any("ip", ip))
tlsMode := smtpclient.TLSOpportunistic
if forceTLS {
tlsMode = smtpclient.TLSRequiredStartTLS
} else if noTLS {
tlsMode = smtpclient.TLSSkip
}
var ehloHostname dns.Domain
if ehloHostnameStr == "" {
name, err := os.Hostname()
xcheckf(err, "get hostname")
ehloHostnameStr = name
}
ehloHostname, err = dns.ParseDomain(ehloHostnameStr)
xcheckf(err, "parse hostname")
opts := smtpclient.Opts{
TLSConfig: &tlsConfig,
}
client, err := smtpclient.New(context.Background(), c.log.Logger, conn, tlsMode, false, ehloHostname, dns.Domain{}, opts)
xcheckf(err, "new smtp client")
cs := client.TLSConnectionState()
if cs == nil {
c.log.Info("smtp initialized without tls")
} else {
c.log.Info("smtp initialized with tls",
slog.String("version", tls.VersionName(cs.Version)),
slog.String("ciphersuite", strings.ToLower(tls.CipherSuiteName(cs.CipherSuite))),
slog.String("sni", cs.ServerName),
)
for _, chain := range cs.VerifiedChains {
var l []string
for _, cert := range chain {
s := fmt.Sprintf("dns names %q, common name %q, %s - %s, issuer %q)", strings.Join(cert.DNSNames, ","), cert.Subject.CommonName, cert.NotBefore.Format("2006-01-02T15:04:05"), cert.NotAfter.Format("2006-01-02T15:04:05"), cert.Issuer.CommonName)
l = append(l, s)
}
c.log.Info("tls certificate verification chain", slog.String("chain", strings.Join(l, "; ")))
}
}
conn, err = client.Conn()
xcheckf(err, "get smtp session connection")
go func() {
_, err := io.Copy(os.Stdout, conn)
xcheckf(err, "copy from connection to stdout")
err = conn.Close()
c.log.Check(err, "closing connection")
}()
_, err = io.Copy(conn, os.Stdin)
xcheckf(err, "copy from stdin to connection")
}
func cmdDANEDial(c *cmd) { func cmdDANEDial(c *cmd) {
c.params = "host:port" c.params = "host:port"
var usages string var usages string

View File

@ -114,6 +114,7 @@ type Client struct {
daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA. daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA.
daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any. daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any.
clientCert *tls.Certificate // If non-nil, tls client authentication is done. clientCert *tls.Certificate // If non-nil, tls client authentication is done.
tlsConfigOpts *tls.Config // If non-nil, tls config to use.
// TLS connection success/failure are added. These are always non-nil, regardless // TLS connection success/failure are added. These are always non-nil, regardless
// of what was passed in opts. It lets us unconditionally dereference them. // of what was passed in opts. It lets us unconditionally dereference them.
@ -235,6 +236,12 @@ type Opts struct {
// tracked. // tracked.
RecipientDomainResult *tlsrpt.Result // MTA-STS or no policy. RecipientDomainResult *tlsrpt.Result // MTA-STS or no policy.
HostResult *tlsrpt.Result // DANE or no policy. HostResult *tlsrpt.Result // DANE or no policy.
// If not nil, the TLS config to use instead of the default. Useful for custom
// certificate verification or TLS parameters. The other DANE/TLS/certificate
// fields in [Opts], and the tlsVerifyPKIX and remoteHostname parameters to [New]
// have no effect when TLSConfig is set.
TLSConfig *tls.Config
} }
// New initializes an SMTP session on the given connection, returning a client that // New initializes an SMTP session on the given connection, returning a client that
@ -290,6 +297,7 @@ func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode,
cmds: []string{"(none)"}, cmds: []string{"(none)"},
recipientDomainResult: ensureResult(opts.RecipientDomainResult), recipientDomainResult: ensureResult(opts.RecipientDomainResult),
hostResult: ensureResult(opts.HostResult), hostResult: ensureResult(opts.HostResult),
tlsConfigOpts: opts.TLSConfig,
} }
c.log = mlog.New("smtpclient", elog).WithFunc(func() []slog.Attr { c.log = mlog.New("smtpclient", elog).WithFunc(func() []slog.Attr {
now := time.Now() now := time.Now()
@ -354,6 +362,10 @@ func (c *Client) tlsConfig() *tls.Config {
// failures. And we may have to verify both PKIX and DANE, record errors for // failures. And we may have to verify both PKIX and DANE, record errors for
// each, and possibly ignore the errors. // each, and possibly ignore the errors.
if c.tlsConfigOpts != nil {
return c.tlsConfigOpts
}
verifyConnection := func(cs tls.ConnectionState) error { verifyConnection := func(cs tls.ConnectionState) error {
// Collect verification errors. If there are none at the end, TLS validation // Collect verification errors. If there are none at the end, TLS validation
// succeeded. We may find validation problems below, record them for a TLS report // succeeded. We may find validation problems below, record them for a TLS report
@ -1453,9 +1465,10 @@ func (c *Client) Close() (rerr error) {
return return
} }
// Conn returns the connection with initialized SMTP session. Once the caller uses // Conn returns the connection with the initialized SMTP session, possibly wrapping
// this connection it is in control, and responsible for closing the connection, // a TLS connection, and handling protocol trace logging. Once the caller uses this
// and other functions on the client must not be called anymore. // connection it is in control, and responsible for closing the connection, and
// other functions on the client must not be called anymore.
func (c *Client) Conn() (net.Conn, error) { func (c *Client) Conn() (net.Conn, error) {
if err := c.conn.SetDeadline(time.Time{}); err != nil { if err := c.conn.SetDeadline(time.Time{}); err != nil {
return nil, fmt.Errorf("clearing io deadlines: %w", err) return nil, fmt.Errorf("clearing io deadlines: %w", err)