Files
2026-09-30 10:20:58 +08:00

151 lines
3.8 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sync"
"time"
)
const dnsAlternateSubnet = "8.8.8.0/24"
type dnsCacheEntry struct {
addresses []string
expires time.Time
}
type dnsResolver struct {
client *http.Client
mu sync.Mutex
cache map[string]dnsCacheEntry
inFlight map[string]chan struct{}
endpoints []string
}
func newDNSResolver(transport *http.Transport) *dnsResolver {
lookupTransport := transport.Clone()
lookupTransport.TLSClientConfig = nil
return &dnsResolver{
client: &http.Client{Transport: lookupTransport, Timeout: 4 * time.Second},
cache: map[string]dnsCacheEntry{}, inFlight: map[string]chan struct{}{},
endpoints: []string{"https://dns.alidns.com/resolve", "https://dns.google/resolve"},
}
}
func (resolver *dnsResolver) lookup(ctx context.Context, host string) ([]string, error) {
entry, err := resolver.lookupEntry(ctx, host, "")
return entry.addresses, err
}
func (resolver *dnsResolver) 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 *dnsResolver) query(ctx context.Context, host, subnet string) (dnsCacheEntry, error) {
lastErr := errors.New("DoH 未返回公网 IPv4 地址")
for _, endpoint := range resolver.endpoints {
if err := ctx.Err(); err != nil {
return dnsCacheEntry{}, err
}
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
var addressErr error
for _, answer := range payload.Answer {
if answer.Type != 1 {
continue
}
address := net.ParseIP(answer.Data)
if address == nil || address.To4() == nil {
addressErr = errors.New("DoH 返回无效 IPv4 地址")
break
}
entry.addresses = append(entry.addresses, address.String())
if answer.TTL < ttl {
ttl = answer.TTL
}
}
if addressErr == nil {
addressErr = validateImageAddresses(entry.addresses)
}
if addressErr != nil {
lastErr = fmt.Errorf("DoH 未返回可用公网 IPv4 地址:%w", addressErr)
continue
}
if ttl < 1 {
ttl = 1
}
entry.expires = time.Now().Add(time.Duration(ttl) * time.Second)
return entry, nil
}
return dnsCacheEntry{}, lastErr
}