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 ", 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, } }