mirror of
https://github.com/XTLS/Xray-core.git
synced 2024-09-07 08:49:21 +03:00
* DNS: add clientip for specific nameserver * Refactoring: DNS App * DNS: add DNS over QUIC support * Feat: add disableCache option for DNS * Feat: add queryStrategy option for DNS * Feat: add disableFallback & skipFallback option for DNS * Feat: DNS hosts support multiple addresses * Feat: DNS transport over TCP * DNS: fix typo & refine code * DNS: refine code * Add disableFallbackIfMatch dns option * Feat: routing and freedom outbound ignore Fake DNS Turn off fake DNS for request sent from Routing and Freedom outbound. Fake DNS now only apply to DNS outbound. This is important for Android, where VPN service take over all system DNS traffic and pass it to core. "UseIp" option can be used in Freedom outbound to avoid getting fake IP and fail connection. * Fix test * Fix dns return * Fix local dns return empty * Apply timeout to dns outbound * Update app/dns/config.go Co-authored-by: Loyalsoldier <10487845+loyalsoldier@users.noreply.github.com> Co-authored-by: Ye Zhihao <vigilans@foxmail.com> Co-authored-by: maskedeken <52683904+maskedeken@users.noreply.github.com> Co-authored-by: V2Fly Team <51714622+vcptr@users.noreply.github.com> Co-authored-by: CalmLong <37164399+calmlong@users.noreply.github.com> Co-authored-by: Shelikhoo <xiaokangwang@outlook.com> Co-authored-by: 秋のかえで <autmaple@protonmail.com> Co-authored-by: 朱聖黎 <digglife@gmail.com> Co-authored-by: rurirei <72071920+rurirei@users.noreply.github.com> Co-authored-by: yuhan6665 <1588741+yuhan6665@users.noreply.github.com> Co-authored-by: Arthur Morgan <4637240+badO1a5A90@users.noreply.github.com>
424 lines
10 KiB
Go
424 lines
10 KiB
Go
package dns
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"golang.org/x/net/dns/dnsmessage"
|
|
|
|
"github.com/xtls/xray-core/common"
|
|
"github.com/xtls/xray-core/common/log"
|
|
"github.com/xtls/xray-core/common/net"
|
|
"github.com/xtls/xray-core/common/net/cnc"
|
|
"github.com/xtls/xray-core/common/protocol/dns"
|
|
"github.com/xtls/xray-core/common/session"
|
|
"github.com/xtls/xray-core/common/signal/pubsub"
|
|
"github.com/xtls/xray-core/common/task"
|
|
dns_feature "github.com/xtls/xray-core/features/dns"
|
|
"github.com/xtls/xray-core/features/routing"
|
|
"github.com/xtls/xray-core/transport/internet"
|
|
)
|
|
|
|
// DoHNameServer implemented DNS over HTTPS (RFC8484) Wire Format,
|
|
// which is compatible with traditional dns over udp(RFC1035),
|
|
// thus most of the DOH implementation is copied from udpns.go
|
|
type DoHNameServer struct {
|
|
dispatcher routing.Dispatcher
|
|
sync.RWMutex
|
|
ips map[string]*record
|
|
pub *pubsub.Service
|
|
cleanup *task.Periodic
|
|
reqID uint32
|
|
httpClient *http.Client
|
|
dohURL string
|
|
name string
|
|
}
|
|
|
|
// NewDoHNameServer creates DOH server object for remote resolving.
|
|
func NewDoHNameServer(url *url.URL, dispatcher routing.Dispatcher) (*DoHNameServer, error) {
|
|
newError("DNS: created Remote DOH client for ", url.String()).AtInfo().WriteToLog()
|
|
s := baseDOHNameServer(url, "DOH")
|
|
|
|
s.dispatcher = dispatcher
|
|
tr := &http.Transport{
|
|
MaxIdleConns: 30,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 30 * time.Second,
|
|
ForceAttemptHTTP2: true,
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
dispatcherCtx := context.Background()
|
|
|
|
dest, err := net.ParseDestination(network + ":" + addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dispatcherCtx = session.ContextWithContent(dispatcherCtx, session.ContentFromContext(ctx))
|
|
dispatcherCtx = session.ContextWithInbound(dispatcherCtx, session.InboundFromContext(ctx))
|
|
dispatcherCtx = log.ContextWithAccessMessage(dispatcherCtx, &log.AccessMessage{
|
|
From: "DoH",
|
|
To: s.dohURL,
|
|
Status: log.AccessAccepted,
|
|
Reason: "",
|
|
})
|
|
|
|
link, err := s.dispatcher.Dispatch(dispatcherCtx, dest)
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cc := common.ChainedClosable{}
|
|
if cw, ok := link.Writer.(common.Closable); ok {
|
|
cc = append(cc, cw)
|
|
}
|
|
if cr, ok := link.Reader.(common.Closable); ok {
|
|
cc = append(cc, cr)
|
|
}
|
|
return cnc.NewConnection(
|
|
cnc.ConnectionInputMulti(link.Writer),
|
|
cnc.ConnectionOutputMulti(link.Reader),
|
|
cnc.ConnectionOnClose(cc),
|
|
), nil
|
|
},
|
|
}
|
|
s.httpClient = &http.Client{
|
|
Timeout: time.Second * 180,
|
|
Transport: tr,
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// NewDoHLocalNameServer creates DOH client object for local resolving
|
|
func NewDoHLocalNameServer(url *url.URL) *DoHNameServer {
|
|
url.Scheme = "https"
|
|
s := baseDOHNameServer(url, "DOHL")
|
|
tr := &http.Transport{
|
|
IdleConnTimeout: 90 * time.Second,
|
|
ForceAttemptHTTP2: true,
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
dest, err := net.ParseDestination(network + ":" + addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conn, err := internet.DialSystem(ctx, dest, nil)
|
|
log.Record(&log.AccessMessage{
|
|
From: "DoH",
|
|
To: s.dohURL,
|
|
Status: log.AccessAccepted,
|
|
Detour: "local",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return conn, nil
|
|
},
|
|
}
|
|
s.httpClient = &http.Client{
|
|
Timeout: time.Second * 180,
|
|
Transport: tr,
|
|
}
|
|
newError("DNS: created Local DOH client for ", url.String()).AtInfo().WriteToLog()
|
|
return s
|
|
}
|
|
|
|
func baseDOHNameServer(url *url.URL, prefix string) *DoHNameServer {
|
|
s := &DoHNameServer{
|
|
ips: make(map[string]*record),
|
|
pub: pubsub.NewService(),
|
|
name: prefix + "//" + url.Host,
|
|
dohURL: url.String(),
|
|
}
|
|
s.cleanup = &task.Periodic{
|
|
Interval: time.Minute,
|
|
Execute: s.Cleanup,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Name implements Server.
|
|
func (s *DoHNameServer) Name() string {
|
|
return s.name
|
|
}
|
|
|
|
// Cleanup clears expired items from cache
|
|
func (s *DoHNameServer) Cleanup() error {
|
|
now := time.Now()
|
|
s.Lock()
|
|
defer s.Unlock()
|
|
|
|
if len(s.ips) == 0 {
|
|
return newError("nothing to do. stopping...")
|
|
}
|
|
|
|
for domain, record := range s.ips {
|
|
if record.A != nil && record.A.Expire.Before(now) {
|
|
record.A = nil
|
|
}
|
|
if record.AAAA != nil && record.AAAA.Expire.Before(now) {
|
|
record.AAAA = nil
|
|
}
|
|
|
|
if record.A == nil && record.AAAA == nil {
|
|
newError(s.name, " cleanup ", domain).AtDebug().WriteToLog()
|
|
delete(s.ips, domain)
|
|
} else {
|
|
s.ips[domain] = record
|
|
}
|
|
}
|
|
|
|
if len(s.ips) == 0 {
|
|
s.ips = make(map[string]*record)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *DoHNameServer) updateIP(req *dnsRequest, ipRec *IPRecord) {
|
|
elapsed := time.Since(req.start)
|
|
|
|
s.Lock()
|
|
rec, found := s.ips[req.domain]
|
|
if !found {
|
|
rec = &record{}
|
|
}
|
|
updated := false
|
|
|
|
switch req.reqType {
|
|
case dnsmessage.TypeA:
|
|
if isNewer(rec.A, ipRec) {
|
|
rec.A = ipRec
|
|
updated = true
|
|
}
|
|
case dnsmessage.TypeAAAA:
|
|
addr := make([]net.Address, 0, len(ipRec.IP))
|
|
for _, ip := range ipRec.IP {
|
|
if len(ip.IP()) == net.IPv6len {
|
|
addr = append(addr, ip)
|
|
}
|
|
}
|
|
ipRec.IP = addr
|
|
if isNewer(rec.AAAA, ipRec) {
|
|
rec.AAAA = ipRec
|
|
updated = true
|
|
}
|
|
}
|
|
newError(s.name, " got answer: ", req.domain, " ", req.reqType, " -> ", ipRec.IP, " ", elapsed).AtInfo().WriteToLog()
|
|
|
|
if updated {
|
|
s.ips[req.domain] = rec
|
|
}
|
|
switch req.reqType {
|
|
case dnsmessage.TypeA:
|
|
s.pub.Publish(req.domain+"4", nil)
|
|
case dnsmessage.TypeAAAA:
|
|
s.pub.Publish(req.domain+"6", nil)
|
|
}
|
|
s.Unlock()
|
|
common.Must(s.cleanup.Start())
|
|
}
|
|
|
|
func (s *DoHNameServer) newReqID() uint16 {
|
|
return uint16(atomic.AddUint32(&s.reqID, 1))
|
|
}
|
|
|
|
func (s *DoHNameServer) sendQuery(ctx context.Context, domain string, clientIP net.IP, option dns_feature.IPOption) {
|
|
newError(s.name, " querying: ", domain).AtInfo().WriteToLog(session.ExportIDToError(ctx))
|
|
|
|
if s.name+"." == "DOH//"+domain {
|
|
newError(s.name, " tries to resolve itself! Use IP or set \"hosts\" instead.").AtError().WriteToLog(session.ExportIDToError(ctx))
|
|
return
|
|
}
|
|
|
|
reqs := buildReqMsgs(domain, option, s.newReqID, genEDNS0Options(clientIP))
|
|
|
|
var deadline time.Time
|
|
if d, ok := ctx.Deadline(); ok {
|
|
deadline = d
|
|
} else {
|
|
deadline = time.Now().Add(time.Second * 5)
|
|
}
|
|
|
|
for _, req := range reqs {
|
|
go func(r *dnsRequest) {
|
|
// generate new context for each req, using same context
|
|
// may cause reqs all aborted if any one encounter an error
|
|
dnsCtx := ctx
|
|
|
|
// reserve internal dns server requested Inbound
|
|
if inbound := session.InboundFromContext(ctx); inbound != nil {
|
|
dnsCtx = session.ContextWithInbound(dnsCtx, inbound)
|
|
}
|
|
|
|
dnsCtx = session.ContextWithContent(dnsCtx, &session.Content{
|
|
Protocol: "https",
|
|
SkipDNSResolve: true,
|
|
})
|
|
|
|
// forced to use mux for DOH
|
|
// dnsCtx = session.ContextWithMuxPrefered(dnsCtx, true)
|
|
|
|
var cancel context.CancelFunc
|
|
dnsCtx, cancel = context.WithDeadline(dnsCtx, deadline)
|
|
defer cancel()
|
|
|
|
b, err := dns.PackMessage(r.msg)
|
|
if err != nil {
|
|
newError("failed to pack dns query for ", domain).Base(err).AtError().WriteToLog()
|
|
return
|
|
}
|
|
resp, err := s.dohHTTPSContext(dnsCtx, b.Bytes())
|
|
if err != nil {
|
|
newError("failed to retrieve response for ", domain).Base(err).AtError().WriteToLog()
|
|
return
|
|
}
|
|
rec, err := parseResponse(resp)
|
|
if err != nil {
|
|
newError("failed to handle DOH response for ", domain).Base(err).AtError().WriteToLog()
|
|
return
|
|
}
|
|
s.updateIP(r, rec)
|
|
}(req)
|
|
}
|
|
}
|
|
|
|
func (s *DoHNameServer) dohHTTPSContext(ctx context.Context, b []byte) ([]byte, error) {
|
|
body := bytes.NewBuffer(b)
|
|
req, err := http.NewRequest("POST", s.dohURL, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Header.Add("Accept", "application/dns-message")
|
|
req.Header.Add("Content-Type", "application/dns-message")
|
|
|
|
hc := s.httpClient
|
|
|
|
resp, err := hc.Do(req.WithContext(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
io.Copy(io.Discard, resp.Body) // flush resp.Body so that the conn is reusable
|
|
return nil, fmt.Errorf("DOH server returned code %d", resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func (s *DoHNameServer) findIPsForDomain(domain string, option dns_feature.IPOption) ([]net.IP, error) {
|
|
s.RLock()
|
|
record, found := s.ips[domain]
|
|
s.RUnlock()
|
|
|
|
if !found {
|
|
return nil, errRecordNotFound
|
|
}
|
|
|
|
var err4 error
|
|
var err6 error
|
|
var ips []net.Address
|
|
var ip6 []net.Address
|
|
|
|
if option.IPv4Enable {
|
|
ips, err4 = record.A.getIPs()
|
|
}
|
|
|
|
if option.IPv6Enable {
|
|
ip6, err6 = record.AAAA.getIPs()
|
|
ips = append(ips, ip6...)
|
|
}
|
|
|
|
if len(ips) > 0 {
|
|
return toNetIP(ips)
|
|
}
|
|
|
|
if err4 != nil {
|
|
return nil, err4
|
|
}
|
|
|
|
if err6 != nil {
|
|
return nil, err6
|
|
}
|
|
|
|
if (option.IPv4Enable && record.A != nil) || (option.IPv6Enable && record.AAAA != nil) {
|
|
return nil, dns_feature.ErrEmptyResponse
|
|
}
|
|
|
|
return nil, errRecordNotFound
|
|
}
|
|
|
|
// QueryIP implements Server.
|
|
func (s *DoHNameServer) QueryIP(ctx context.Context, domain string, clientIP net.IP, option dns_feature.IPOption, disableCache bool) ([]net.IP, error) { // nolint: dupl
|
|
fqdn := Fqdn(domain)
|
|
|
|
if disableCache {
|
|
newError("DNS cache is disabled. Querying IP for ", domain, " at ", s.name).AtDebug().WriteToLog()
|
|
} else {
|
|
ips, err := s.findIPsForDomain(fqdn, option)
|
|
if err != errRecordNotFound {
|
|
newError(s.name, " cache HIT ", domain, " -> ", ips).Base(err).AtDebug().WriteToLog()
|
|
log.Record(&log.DNSLog{Server: s.name, Domain: domain, Result: ips, Status: log.DNSCacheHit, Elapsed: 0, Error: err})
|
|
return ips, err
|
|
}
|
|
}
|
|
|
|
// ipv4 and ipv6 belong to different subscription groups
|
|
var sub4, sub6 *pubsub.Subscriber
|
|
if option.IPv4Enable {
|
|
sub4 = s.pub.Subscribe(fqdn + "4")
|
|
defer sub4.Close()
|
|
}
|
|
if option.IPv6Enable {
|
|
sub6 = s.pub.Subscribe(fqdn + "6")
|
|
defer sub6.Close()
|
|
}
|
|
done := make(chan interface{})
|
|
go func() {
|
|
if sub4 != nil {
|
|
select {
|
|
case <-sub4.Wait():
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
if sub6 != nil {
|
|
select {
|
|
case <-sub6.Wait():
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
close(done)
|
|
}()
|
|
s.sendQuery(ctx, fqdn, clientIP, option)
|
|
start := time.Now()
|
|
|
|
for {
|
|
ips, err := s.findIPsForDomain(fqdn, option)
|
|
if err != errRecordNotFound {
|
|
log.Record(&log.DNSLog{Server: s.name, Domain: domain, Result: ips, Status: log.DNSQueried, Elapsed: time.Since(start), Error: err})
|
|
return ips, err
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-done:
|
|
}
|
|
}
|
|
}
|