230 lines
6 KiB
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,
|
|
}
|
|
}
|