Files

165 lines
4.5 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type dnsCacheEntry struct {
addresses []string
expires time.Time
}
type safeDNSDialer struct {
client *http.Client
dialer net.Dialer
mu sync.Mutex
cache map[string]dnsCacheEntry
inFlight map[string]chan struct{}
endpoints []string
}
func newSafeDNSDialer(transport *http.Transport) *safeDNSDialer {
lookupTransport := transport.Clone()
lookupTransport.TLSClientConfig = nil
return &safeDNSDialer{
client: &http.Client{Transport: lookupTransport, Timeout: 4 * time.Second},
dialer: net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second},
cache: map[string]dnsCacheEntry{}, inFlight: map[string]chan struct{}{},
endpoints: []string{"https://dns.alidns.com/resolve", "https://dns.google/resolve"},
}
}
func protectedCDNHost(host string) bool {
host = strings.ToLower(host)
return isHuangguoImageCDNHost(host) || strings.HasSuffix(host, ".lkkwip.cn")
}
func (resolver *safeDNSDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil || !protectedCDNHost(host) {
return resolver.dialer.DialContext(ctx, network, address)
}
addresses, err := resolver.lookup(ctx, host)
if err != nil {
return nil, fmt.Errorf("%s 安全 DNS 解析失败,可在网络设置中启用可用代理: %w", host, err)
}
var lastErr error
for _, resolved := range addresses {
connection, err := resolver.dialer.DialContext(ctx, network, net.JoinHostPort(resolved, port))
if err == nil {
return connection, nil
}
lastErr = err
}
return nil, fmt.Errorf("%s 的 CDN 无法直连,请检查代理配置: %w", host, lastErr)
}
func (resolver *safeDNSDialer) lookup(ctx context.Context, host string) ([]string, error) {
entry, err := resolver.lookupEntry(ctx, host, "")
return entry.addresses, err
}
func (resolver *safeDNSDialer) lookupEntry(ctx context.Context, host, subnet string) (dnsCacheEntry, error) {
cacheKey := host + "|" + subnet
for {
resolver.mu.Lock()
if entry, found := resolver.cache[cacheKey]; found && time.Now().Before(entry.expires) {
resolver.mu.Unlock()
return entry, nil
}
if pending := resolver.inFlight[cacheKey]; pending != nil {
resolver.mu.Unlock()
select {
case <-pending:
continue
case <-ctx.Done():
return dnsCacheEntry{}, ctx.Err()
}
}
resolver.inFlight[cacheKey] = make(chan struct{})
resolver.mu.Unlock()
break
}
entry, err := resolver.query(ctx, host, subnet)
resolver.mu.Lock()
if err == nil {
resolver.cache[cacheKey] = entry
}
close(resolver.inFlight[cacheKey])
delete(resolver.inFlight, cacheKey)
resolver.mu.Unlock()
return entry, err
}
func (resolver *safeDNSDialer) query(ctx context.Context, host, subnet string) (dnsCacheEntry, error) {
var lastErr error
for _, endpoint := range resolver.endpoints {
lookupURL, err := url.Parse(endpoint)
if err != nil {
return dnsCacheEntry{}, err
}
query := lookupURL.Query()
query.Set("name", host)
query.Set("type", "A")
if subnet != "" {
query.Set("edns_client_subnet", subnet)
}
lookupURL.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL.String(), nil)
if err != nil {
return dnsCacheEntry{}, err
}
request.Header.Set("Accept", "application/dns-json")
response, err := resolver.client.Do(request)
if err != nil {
lastErr = publicError(err)
continue
}
var payload struct {
Status int `json:"Status"`
Answer []struct {
Type int `json:"type"`
TTL int `json:"TTL"`
Data string `json:"data"`
} `json:"Answer"`
}
err = json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&payload)
response.Body.Close()
if response.StatusCode != http.StatusOK || err != nil || payload.Status != 0 {
lastErr = errors.New("DoH 服务没有返回有效 DNS 记录")
continue
}
entry := dnsCacheEntry{}
ttl := 300
for _, answer := range payload.Answer {
address := net.ParseIP(answer.Data)
if answer.Type != 1 || address == nil || address.To4() == nil || !address.IsGlobalUnicast() || address.IsPrivate() {
continue
}
entry.addresses = append(entry.addresses, address.String())
if answer.TTL < ttl {
ttl = answer.TTL
}
}
if len(entry.addresses) > 0 {
if ttl < 1 {
ttl = 1
}
entry.expires = time.Now().Add(time.Duration(ttl) * time.Second)
return entry, nil
}
lastErr = errors.New("DoH 未返回公网 IPv4 地址")
}
return dnsCacheEntry{}, lastErr
}