diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-24 17:34:45 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-24 17:34:45 +0200 |
| commit | e9fbbe3373eb66a345f5e3829e2563b94dc92051 (patch) | |
| tree | 950d75fda88574afb46b4aeab36e96f3c089a3bb /internal/smtpclient | |
| parent | fb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff) | |
| download | n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.gz n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.xz n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.zip | |
Diffstat (limited to 'internal/smtpclient')
| -rw-r--r-- | internal/smtpclient/client.go | 141 | ||||
| -rw-r--r-- | internal/smtpclient/client_test.go | 99 |
2 files changed, 208 insertions, 32 deletions
diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go index d4ee686..a0abc06 100644 --- a/internal/smtpclient/client.go +++ b/internal/smtpclient/client.go @@ -4,11 +4,13 @@ import ( "bufio" "context" "crypto/tls" + "encoding/base64" "fmt" "io" "net" "net/textproto" "strings" + "sync" "time" ) @@ -19,6 +21,8 @@ type Config struct { Port int Recipient string EnvelopeFrom string + Username string + Password string HELO string TLSServerName string RequireTLS bool @@ -30,6 +34,7 @@ type Config struct { type Client struct { cfg Config dial DialContextFunc + mu sync.Mutex } type Message struct { @@ -45,22 +50,90 @@ func (c *Client) Send(ctx context.Context, msg Message) error { if c.cfg.DryRun { return nil } - if c.dial == nil { - c.dial = (&net.Dialer{}).DialContext + c.mu.Lock() + defer c.mu.Unlock() + + conn, err := c.connect(ctx) + if err != nil { + return err + } + defer conn.close() + session := conn.session + + envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom) + if envelopeFrom == "" { + envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom) + } + if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil { + return fmt.Errorf("mail from rejected: %w", err) + } + if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil { + return fmt.Errorf("rcpt to rejected: %w", err) + } + if _, _, err := session.cmd(354, "DATA\r\n"); err != nil { + return fmt.Errorf("data rejected: %w", err) + } + if err := writeSMTPData(session.w, msg.Raw); err != nil { + return err + } + if _, _, err := session.read(250); err != nil { + return fmt.Errorf("message rejected: %w", err) + } + _, _, _ = session.cmd(221, "QUIT\r\n") + return nil +} + +// Check verifies the complete SOCKS/SMTP/TLS/AUTH path without issuing a mail +// transaction or sending message data. +func (c *Client) Check(ctx context.Context) error { + if c.cfg.DryRun { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + + conn, err := c.connect(ctx) + if err != nil { + return err + } + defer conn.close() + _, _, _ = conn.session.cmd(221, "QUIT\r\n") + return nil +} + +type smtpConnection struct { + conn net.Conn + session *session + cancel context.CancelFunc +} + +func (c *smtpConnection) close() { + c.cancel() + _ = c.conn.Close() +} + +func (c *Client) connect(ctx context.Context) (*smtpConnection, error) { + dial := c.dial + if dial == nil { + dial = (&net.Dialer{}).DialContext } timeout := c.cfg.Timeout if timeout <= 0 { timeout = 90 * time.Second } ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() addr := net.JoinHostPort(c.cfg.Host, fmt.Sprintf("%d", c.cfg.Port)) - conn, err := c.dial(ctx, "tcp", addr) + conn, err := dial(ctx, "tcp", addr) if err != nil { - return fmt.Errorf("dial smtp: %w", err) + cancel() + return nil, fmt.Errorf("dial smtp: %w", err) + } + fail := func(err error) (*smtpConnection, error) { + cancel() + _ = conn.Close() + return nil, err } - defer conn.Close() if deadline, ok := ctx.Deadline(); ok { _ = conn.SetDeadline(deadline) } @@ -70,58 +143,51 @@ func (c *Client) Send(ctx context.Context, msg Message) error { MinVersion: tls.VersionTLS12, }) if err := tlsConn.HandshakeContext(ctx); err != nil { - return fmt.Errorf("implicit tls handshake: %w", err) + return fail(fmt.Errorf("implicit tls handshake: %w", err)) } conn = tlsConn } session := newSession(conn) if _, _, err := session.read(220); err != nil { - return fmt.Errorf("smtp greeting: %w", err) + return fail(fmt.Errorf("smtp greeting: %w", err)) } if err := session.ehlo(c.cfg.HELO); err != nil { - return err + return fail(err) } if c.cfg.RequireTLS && !c.cfg.ImplicitTLS { if _, _, err := session.cmd(220, "STARTTLS\r\n"); err != nil { - return fmt.Errorf("starttls: %w", err) + return fail(fmt.Errorf("starttls: %w", err)) } tlsConn := tls.Client(conn, &tls.Config{ ServerName: c.cfg.TLSServerName, MinVersion: tls.VersionTLS12, }) if err := tlsConn.HandshakeContext(ctx); err != nil { - return fmt.Errorf("tls handshake: %w", err) + return fail(fmt.Errorf("tls handshake: %w", err)) } + conn = tlsConn session = newSession(tlsConn) if err := session.ehlo(c.cfg.HELO); err != nil { - return err + return fail(err) } } - envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom) - if envelopeFrom == "" { - envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom) - } - if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil { - return fmt.Errorf("mail from rejected: %w", err) - } - if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil { - return fmt.Errorf("rcpt to rejected: %w", err) - } - if _, _, err := session.cmd(354, "DATA\r\n"); err != nil { - return fmt.Errorf("data rejected: %w", err) - } - if err := writeSMTPData(session.w, msg.Raw); err != nil { - return err - } - if _, _, err := session.read(250); err != nil { - return fmt.Errorf("message rejected: %w", err) + if c.cfg.Username != "" || c.cfg.Password != "" { + if c.cfg.Username == "" || c.cfg.Password == "" { + return fail(fmt.Errorf("smtp authentication credentials are incomplete")) + } + if !c.cfg.RequireTLS && !c.cfg.ImplicitTLS { + return fail(fmt.Errorf("smtp authentication requires TLS")) + } + if err := session.authPlain(c.cfg.Username, c.cfg.Password); err != nil { + return fail(err) + } } - _, _, _ = session.cmd(221, "QUIT\r\n") - return nil + + return &smtpConnection{conn: conn, session: session, cancel: cancel}, nil } type session struct { @@ -151,6 +217,17 @@ func (s *session) ehlo(helo string) error { return nil } +func (s *session) authPlain(username, password string) error { + if strings.ContainsRune(username, '\x00') || strings.ContainsRune(password, '\x00') { + return fmt.Errorf("smtp authentication credentials contain invalid data") + } + payload := base64.StdEncoding.EncodeToString([]byte("\x00" + username + "\x00" + password)) + if _, _, err := s.cmd(235, "AUTH PLAIN %s\r\n", payload); err != nil { + return fmt.Errorf("smtp authentication rejected: %w", err) + } + return nil +} + func (s *session) cmd(expect int, format string, args ...any) (int, string, error) { if _, err := fmt.Fprintf(s.w, format, args...); err != nil { return 0, "", err diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go index d9eec6d..e91d128 100644 --- a/internal/smtpclient/client_test.go +++ b/internal/smtpclient/client_test.go @@ -3,6 +3,7 @@ package smtpclient import ( "bufio" "context" + "encoding/base64" "net" "strings" "testing" @@ -91,3 +92,101 @@ func TestSendUsesConfiguredEnvelopeFrom(t *testing.T) { t.Fatal("configured envelope sender was not used") } } + +func TestSessionAuthPlain(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + command := make(chan string, 1) + go func() { + defer server.Close() + r := bufio.NewReader(server) + line, err := r.ReadString('\n') + if err != nil { + command <- "" + return + } + command <- line + _, _ = server.Write([]byte("235 2.7.0 authentication successful\r\n")) + }() + + session := newSession(client) + if err := session.authPlain("n2usenet@virebent.art", "test-password"); err != nil { + t.Fatalf("authPlain returned error: %v", err) + } + + line := <-command + const prefix = "AUTH PLAIN " + if !strings.HasPrefix(line, prefix) { + t.Fatalf("unexpected AUTH command: %q", line) + } + token := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + decoded, err := base64.StdEncoding.DecodeString(token) + if err != nil { + t.Fatalf("decode AUTH payload: %v", err) + } + if got, want := string(decoded), "\x00n2usenet@virebent.art\x00test-password"; got != want { + t.Fatalf("unexpected AUTH payload: got %q want %q", got, want) + } +} + +func TestSessionAuthPlainRejectsNUL(t *testing.T) { + session := &session{} + if err := session.authPlain("n2usenet\x00admin", "test-password"); err == nil { + t.Fatal("authPlain accepted a username containing NUL") + } + if err := session.authPlain("n2usenet", "test\x00password"); err == nil { + t.Fatal("authPlain accepted a password containing NUL") + } +} + +func TestCheckDoesNotStartMailTransaction(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + commands := make(chan string, 8) + go func() { + defer close(commands) + defer server.Close() + r := bufio.NewReader(server) + w := bufio.NewWriter(server) + _, _ = w.WriteString("220 test\r\n") + _ = w.Flush() + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + commands <- line + switch { + case strings.HasPrefix(line, "EHLO "): + _, _ = w.WriteString("250 test\r\n") + case strings.HasPrefix(line, "QUIT"): + _, _ = w.WriteString("221 bye\r\n") + _ = w.Flush() + return + default: + _, _ = w.WriteString("500 unexpected\r\n") + } + _ = w.Flush() + } + }() + + mailer := New(Config{ + Host: "mail.virebent.art", + Port: 25, + HELO: "n2usenet.virebent.art", + RequireTLS: false, + Timeout: 5 * time.Second, + }, func(context.Context, string, string) (net.Conn, error) { + return client, nil + }) + if err := mailer.Check(context.Background()); err != nil { + t.Fatalf("Check returned error: %v", err) + } + for command := range commands { + if strings.HasPrefix(command, "MAIL ") || strings.HasPrefix(command, "RCPT ") || strings.HasPrefix(command, "DATA") { + t.Fatalf("health check started a mail transaction: %q", command) + } + } +} |
