checker-smtp/checker/smtp.go

230 lines
6 KiB
Go

package checker
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
)
// smtpConn is a minimal ESMTP client focused on *observation*, not on
// transmitting mail: we never reach DATA, and every transaction is torn
// down with RSET + QUIT. Writing our own client (instead of using
// net/smtp) gives us access to the raw banner text, per-line extension
// responses, and the timing of each step.
type smtpConn struct {
raw net.Conn
br *bufio.Reader
wr io.Writer
timeout time.Duration
}
func newSMTPConn(c net.Conn, timeout time.Duration) *smtpConn {
return &smtpConn{
raw: c,
br: bufio.NewReader(c),
wr: c,
timeout: timeout,
}
}
// touch extends the per-turn deadline on the underlying connection.
func (s *smtpConn) touch() {
if s.timeout > 0 {
_ = s.raw.SetDeadline(time.Now().Add(s.timeout))
}
}
// swap replaces the underlying connection (used after STARTTLS).
func (s *smtpConn) swap(c net.Conn) {
s.raw = c
s.br = bufio.NewReader(c)
s.wr = c
s.touch()
}
// writeLine writes a single CRLF-terminated line.
func (s *smtpConn) writeLine(line string) error {
s.touch()
_, err := io.WriteString(s.wr, line+"\r\n")
return err
}
// readResponse reads a multiline SMTP response ("xxx-...\r\nxxx ...\r\n")
// and returns (code, full-joined-text, raw-lines, error).
//
// Any transport error is surfaced immediately; a malformed line (missing
// 3-digit code, missing separator) yields an error with the offending
// text so callers can surface it verbatim.
func (s *smtpConn) readResponse() (code int, text string, lines []string, err error) {
for {
s.touch()
line, rerr := s.br.ReadString('\n')
if rerr != nil && line == "" {
return 0, "", lines, rerr
}
line = strings.TrimRight(line, "\r\n")
if len(line) < 4 {
return 0, line, append(lines, line), fmt.Errorf("short SMTP line %q", line)
}
cStr := line[:3]
sep := line[3]
rest := line[4:]
c, nerr := strconv.Atoi(cStr)
if nerr != nil {
return 0, line, append(lines, line), fmt.Errorf("bad SMTP code in %q", line)
}
if code == 0 {
code = c
}
lines = append(lines, line)
if sep == ' ' {
text += rest
return code, text, lines, nil
}
if sep == '-' {
text += rest + "\n"
continue
}
return 0, line, lines, fmt.Errorf("bad separator %q in %q", sep, line)
}
}
// cmd writes a command and returns the server response.
func (s *smtpConn) cmd(line string) (int, string, []string, error) {
if err := s.writeLine(line); err != nil {
return 0, "", nil, err
}
return s.readResponse()
}
// close attempts a graceful QUIT then closes the socket. Errors are
// swallowed; the caller has already captured everything interesting.
func (s *smtpConn) close() {
_ = s.writeLine("QUIT")
_, _, _, _ = s.readResponse()
_ = s.raw.Close()
}
// parseBanner teases the announced hostname out of the 220 greeting.
// ESMTP convention is "220 <hostname> <greeting-text>", but a number of
// servers deviate, so we return the first whitespace-delimited token that
// looks like a FQDN. Empty string when nothing looks plausible.
func parseBanner(text string) string {
for f := range strings.FieldsSeq(text) {
// Skip things that are obviously not a hostname.
if strings.Contains(f, "@") {
continue
}
if !strings.Contains(f, ".") {
continue
}
// Strip trailing punctuation.
f = strings.TrimRight(f, ",;:.")
if looksLikeHostname(f) {
return f
}
}
return ""
}
func looksLikeHostname(s string) bool {
if s == "" || len(s) > 253 {
return false
}
// A hostname has at least one dot and no invalid characters.
if strings.ContainsAny(s, " \t\r\n<>\"()[]") {
return false
}
// We tolerate '_' even though RFC 1123 forbids it in hostnames: this
// helper only classifies tokens parsed out of an SMTP banner for
// display, never for routing or certificate matching, and a number of
// real-world MTAs announce names with underscores.
for _, r := range s {
if r == '.' || r == '-' || r == '_' ||
(r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
return false
}
return true
}
// parseEHLO splits an EHLO response into its keyword/arg lines. The
// first line is the greeting hostname, subsequent lines are extensions.
func parseEHLO(lines []string) (greeting string, extensions []string) {
for i, l := range lines {
if len(l) < 4 {
continue
}
payload := strings.TrimSpace(l[4:])
if i == 0 {
greeting = payload
continue
}
extensions = append(extensions, payload)
}
return greeting, extensions
}
// extensionLookup indexes parsed EHLO extensions by their (uppercased)
// keyword, preserving the argument portion unchanged.
type extensionLookup map[string]string
func buildExtensions(exts []string) extensionLookup {
m := extensionLookup{}
for _, e := range exts {
kw, arg, _ := strings.Cut(e, " ")
m[strings.ToUpper(strings.TrimSpace(kw))] = strings.TrimSpace(arg)
}
return m
}
func (m extensionLookup) has(kw string) bool {
_, ok := m[kw]
return ok
}
// parseSize extracts the integer argument of the SIZE extension (0 when
// absent or unparseable). SIZE may be advertised without an argument;
// we treat that as "no limit declared".
func (m extensionLookup) parseSize() uint64 {
v, ok := m["SIZE"]
if !ok || v == "" {
return 0
}
n, err := strconv.ParseUint(strings.Fields(v)[0], 10, 64)
if err != nil {
return 0
}
return n
}
// parseAuth returns the SASL mechanisms advertised under the AUTH
// extension, upper-cased for easy comparison. Empty slice when AUTH is
// not advertised.
func (m extensionLookup) parseAuth() []string {
v, ok := m["AUTH"]
if !ok || v == "" {
return nil
}
out := strings.Fields(v)
for i := range out {
out[i] = strings.ToUpper(out[i])
}
return out
}
// tlsProbeConfig mirrors the XMPP checker's stance: certificate
// verification is checker-tls' job, so we skip it here.
func tlsProbeConfig(serverName string) *tls.Config {
return &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec (cert validation is the TLS checker's job)
MinVersion: tls.VersionTLS10,
}
}