修复代理环境邮件目标解析
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-16 14:27:53 +08:00
parent d8867dfc4a
commit 657f4922b4
2 changed files with 100 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/config"
@ -30,9 +31,11 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"io" "io"
"net" "net"
"net/http"
"net/netip" "net/netip"
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
"net/url"
"strings" "strings"
"time" "time"
) )
@ -47,8 +50,11 @@ const (
outboundSMTPTLSHandshakeTimeout = 2 * time.Second outboundSMTPTLSHandshakeTimeout = 2 * time.Second
outboundSMTPTransactionTimeout = 2 * time.Minute outboundSMTPTransactionTimeout = 2 * time.Minute
outboundSMTPQuitWriteTimeout = 250 * time.Millisecond outboundSMTPQuitWriteTimeout = 250 * time.Millisecond
remoteAddressResponseLimit = 64 * 1024
) )
var benchmarkNetworkPrefix = netip.MustParsePrefix("198.18.0.0/15")
// A Client represents a client connection to an SMTP server. // A Client represents a client connection to an SMTP server.
type Client struct { type Client struct {
// Text is the textproto.Conn used by the Client. It is exported to allow for // Text is the textproto.Conn used by the Client. It is exported to allow for
@ -189,6 +195,78 @@ func resolveRemoteAddresses(ctx context.Context, host string) ([]netip.Addr, err
if len(addresses) == 0 { if len(addresses) == 0 {
return nil, errors.New("smtp: remote address is unavailable") return nil, errors.New("smtp: remote address is unavailable")
} }
if containsOnlyBenchmarkAddresses(addresses) {
addresses, err = resolveRemoteAddressesOverHTTPS(ctx, host)
if err != nil {
return nil, fmt.Errorf("smtp: resolve real remote address: %w", err)
}
}
return addresses, nil
}
func containsOnlyBenchmarkAddresses(addresses []netip.Addr) bool {
if len(addresses) == 0 {
return false
}
for _, address := range addresses {
if !benchmarkNetworkPrefix.Contains(address.Unmap()) {
return false
}
}
return true
}
func resolveRemoteAddressesOverHTTPS(ctx context.Context, host string) ([]netip.Addr, error) {
endpoint := "https://cloudflare-dns.com/dns-query?name=" + url.QueryEscape(host) + "&type=A"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/dns-json")
client := &http.Client{
Timeout: outboundSMTPDialTimeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected DNS response status: %d", response.StatusCode)
}
var payload struct {
Status int `json:"Status"`
Answer []struct {
Type int `json:"type"`
Data string `json:"data"`
} `json:"Answer"`
}
decoder := json.NewDecoder(io.LimitReader(response.Body, remoteAddressResponseLimit))
if err = decoder.Decode(&payload); err != nil {
return nil, err
}
if payload.Status != 0 {
return nil, fmt.Errorf("DNS response code: %d", payload.Status)
}
addresses := make([]netip.Addr, 0, len(payload.Answer))
for _, answer := range payload.Answer {
if answer.Type != 1 {
continue
}
address, parseErr := netip.ParseAddr(strings.TrimSpace(answer.Data))
if parseErr == nil {
addresses = append(addresses, address.Unmap())
}
}
if len(addresses) == 0 {
return nil, errors.New("remote address is unavailable")
}
return addresses, nil return addresses, nil
} }

View File

@ -11,6 +11,7 @@ import (
"io" "io"
"math/big" "math/big"
"net" "net"
"net/netip"
"net/textproto" "net/textproto"
"strings" "strings"
"testing" "testing"
@ -42,6 +43,27 @@ func TestDialRejectsNonPublicRemoteAddress(t *testing.T) {
} }
} }
func TestContainsOnlyBenchmarkAddresses(t *testing.T) {
tests := []struct {
name string
addresses []netip.Addr
want bool
}{
{name: "empty", want: false},
{name: "fake IP", addresses: []netip.Addr{netip.MustParseAddr("198.18.1.77")}, want: true},
{name: "multiple fake IPs", addresses: []netip.Addr{netip.MustParseAddr("198.18.1.77"), netip.MustParseAddr("198.19.1.82")}, want: true},
{name: "public IP", addresses: []netip.Addr{netip.MustParseAddr("47.251.242.178")}, want: false},
{name: "mixed", addresses: []netip.Addr{netip.MustParseAddr("198.18.1.77"), netip.MustParseAddr("127.0.0.1")}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := containsOnlyBenchmarkAddresses(tt.addresses); got != tt.want {
t.Fatalf("containsOnlyBenchmarkAddresses() = %v, want %v", got, tt.want)
}
})
}
}
func TestSendMailUnsafeRejectsNonLoopbackTargets(t *testing.T) { func TestSendMailUnsafeRejectsNonLoopbackTargets(t *testing.T) {
tests := []struct { tests := []struct {
name string name string