diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go new file mode 100644 index 000000000..197682513 --- /dev/null +++ b/dns/transport/local/local_darwin.go @@ -0,0 +1,277 @@ +//go:build darwin + +package local + +import ( + "cmp" + "context" + "encoding/binary" + "errors" + "io" + "net" + "os" + + "github.com/sagernet/sing-box/dns" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + question := message.Question[0] + response, err := darwinLookupSystemDNS(ctx, question.Name, question.Qtype, question.Qclass) + if err != nil { + var rcodeError dns.RcodeError + if errors.As(err, &rcodeError) { + return dns.FixedResponseStatus(message, int(rcodeError)), nil + } + return nil, err + } + response.Id = message.Id + response.Response = true + response.RecursionAvailable = true + return response, nil +} + +// The mDNSResponder daemon speaks an undocumented binary protocol over a +// AF_UNIX SOCK_STREAM socket. The framing below is taken from the client +// stub of Apple's open-source mDNSResponder (mDNSShared/dnssd_ipc.h and +// dnssd_clientstub.c). All multi-byte fields are big-endian; for a one-shot +// query on a fresh, non-shared connection the request and every reply travel +// over the single connected stream (no SCM_RIGHTS, no return socket). +const ( + mdnsResponderSocketPath = "/var/run/mDNSResponder" + mdnsResponderSocketEnv = "DNSSD_UDS_PATH" + mdnsResponderVersion = 1 + mdnsResponderHeaderLength = 28 + mdnsResponderQueryRequest = 8 // query_request + mdnsResponderQueryReply = 68 // query_reply_op + + mdnsResponderFlagMoreComing = 0x1 + mdnsResponderFlagAdd = 0x2 + mdnsResponderFlagReturnIntermediates = 0x1000 + + mdnsResponderErrNoError = 0 + mdnsResponderErrNoSuchName = -65538 + mdnsResponderErrNoSuchRecord = -65554 +) + +func darwinLookupSystemDNS(ctx context.Context, name string, qtype, qclass uint16) (*mDNS.Msg, error) { + socketPath := cmp.Or(os.Getenv(mdnsResponderSocketEnv), mdnsResponderSocketPath) + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err != nil { + return nil, E.Cause(err, "connect mDNSResponder") + } + defer conn.Close() + stopCancel := context.AfterFunc(ctx, func() { + conn.Close() + }) + defer stopCancel() + + _, err = conn.Write(buildQueryRequest(name, qtype, qclass)) + if err != nil { + return nil, contextError(ctx, E.Cause(err, "write mDNSResponder query")) + } + + var status [4]byte + _, err = io.ReadFull(conn, status[:]) + if err != nil { + return nil, contextError(ctx, E.Cause(err, "read mDNSResponder status")) + } + statusCode := int32(binary.BigEndian.Uint32(status[:])) + if statusCode != mdnsResponderErrNoError { + return nil, darwinResolverError(name, statusCode) + } + + var answers []mDNS.RR + for { + reply, replyErr := readReply(conn) + if replyErr != nil { + return nil, contextError(ctx, E.Cause(replyErr, "read mDNSResponder reply")) + } + if reply.errorCode != mdnsResponderErrNoError { + if len(answers) > 0 { + break + } + return nil, darwinResolverError(name, reply.errorCode) + } + if reply.flags&mdnsResponderFlagAdd != 0 && len(reply.rdata) > 0 { + record, buildErr := buildResourceRecord(reply) + if buildErr == nil { + answers = append(answers, record) + } + } + if reply.flags&mdnsResponderFlagMoreComing == 0 { + break + } + } + + response := new(mDNS.Msg) + response.Question = []mDNS.Question{{Name: mDNS.Fqdn(name), Qtype: qtype, Qclass: qclass}} + response.Answer = answers + return response, nil +} + +func buildQueryRequest(name string, qtype, qclass uint16) []byte { + payload := make([]byte, 0, 8+len(name)+1+4) + payload = binary.BigEndian.AppendUint32(payload, mdnsResponderFlagReturnIntermediates) + payload = binary.BigEndian.AppendUint32(payload, 0) // interfaceIndex + payload = append(payload, name...) + payload = append(payload, 0) // C string terminator + payload = binary.BigEndian.AppendUint16(payload, qtype) + payload = binary.BigEndian.AppendUint16(payload, qclass) + + message := make([]byte, mdnsResponderHeaderLength, mdnsResponderHeaderLength+len(payload)) + binary.BigEndian.PutUint32(message[0:], mdnsResponderVersion) + binary.BigEndian.PutUint32(message[4:], uint32(len(payload))) + binary.BigEndian.PutUint32(message[8:], 0) // ipc_flags + binary.BigEndian.PutUint32(message[12:], mdnsResponderQueryRequest) + // message[16:24] client_context and message[24:28] reg_index stay zero. + return append(message, payload...) +} + +type mdnsResponderReply struct { + flags uint32 + errorCode int32 + name string + rrtype uint16 + rrclass uint16 + ttl uint32 + rdata []byte +} + +func readReply(conn net.Conn) (mdnsResponderReply, error) { + var reply mdnsResponderReply + var header [mdnsResponderHeaderLength]byte + _, err := io.ReadFull(conn, header[:]) + if err != nil { + return reply, err + } + dataLength := binary.BigEndian.Uint32(header[4:8]) + operation := binary.BigEndian.Uint32(header[12:16]) + if operation != mdnsResponderQueryReply { + return reply, E.New("unexpected mDNSResponder reply op ", operation) + } + data := make([]byte, dataLength) + _, err = io.ReadFull(conn, data) + if err != nil { + return reply, err + } + + reader := replyReader{data: data} + reply.flags = reader.uint32() + reader.uint32() // interfaceIndex + reply.errorCode = int32(reader.uint32()) + reply.name = reader.cString() + reply.rrtype = reader.uint16() + reply.rrclass = reader.uint16() + rdlen := reader.uint16() + reply.rdata = reader.bytes(int(rdlen)) + reply.ttl = reader.uint32() + if reader.err != nil { + return reply, reader.err + } + return reply, nil +} + +func buildResourceRecord(reply mdnsResponderReply) (mDNS.RR, error) { + name := mDNS.Fqdn(reply.name) + nameBuffer := make([]byte, 256) + offset, err := mDNS.PackDomainName(name, nameBuffer, 0, nil, false) + if err != nil { + return nil, err + } + record := make([]byte, 0, offset+10+len(reply.rdata)) + record = append(record, nameBuffer[:offset]...) + record = binary.BigEndian.AppendUint16(record, reply.rrtype) + record = binary.BigEndian.AppendUint16(record, reply.rrclass) + record = binary.BigEndian.AppendUint32(record, reply.ttl) + record = binary.BigEndian.AppendUint16(record, uint16(len(reply.rdata))) + record = append(record, reply.rdata...) + resourceRecord, _, err := mDNS.UnpackRR(record, 0) + if err != nil { + return nil, err + } + return resourceRecord, nil +} + +// The daemon's NoSuchRecord conflates NXDOMAIN and NODATA, so it is reported as +// an empty NOERROR to avoid a false NXDOMAIN. +func darwinResolverError(name string, code int32) error { + switch code { + case mdnsResponderErrNoSuchRecord: + return dns.RcodeSuccess + case mdnsResponderErrNoSuchName: + return dns.RcodeNameError + default: + return E.New("mDNSResponder query failed for ", name, ": error ", code) + } +} + +func contextError(ctx context.Context, err error) error { + ctxErr := ctx.Err() + if ctxErr != nil { + return ctxErr + } + return err +} + +type replyReader struct { + data []byte + offset int + err error +} + +func (r *replyReader) uint32() uint32 { + if r.err != nil || r.offset+4 > len(r.data) { + r.fail() + return 0 + } + value := binary.BigEndian.Uint32(r.data[r.offset:]) + r.offset += 4 + return value +} + +func (r *replyReader) uint16() uint16 { + if r.err != nil || r.offset+2 > len(r.data) { + r.fail() + return 0 + } + value := binary.BigEndian.Uint16(r.data[r.offset:]) + r.offset += 2 + return value +} + +func (r *replyReader) cString() string { + if r.err != nil { + return "" + } + end := r.offset + for end < len(r.data) && r.data[end] != 0 { + end++ + } + if end >= len(r.data) { + r.fail() + return "" + } + value := string(r.data[r.offset:end]) + r.offset = end + 1 + return value +} + +func (r *replyReader) bytes(length int) []byte { + if r.err != nil || length < 0 || r.offset+length > len(r.data) { + r.fail() + return nil + } + value := r.data[r.offset : r.offset+length] + r.offset += length + return value +} + +func (r *replyReader) fail() { + if r.err == nil { + r.err = E.New("truncated mDNSResponder reply") + } +} diff --git a/dns/transport/local/local_darwin_cgo.go b/dns/transport/local/local_darwin_cgo.go deleted file mode 100644 index de2ec56a4..000000000 --- a/dns/transport/local/local_darwin_cgo.go +++ /dev/null @@ -1,118 +0,0 @@ -//go:build darwin - -package local - -/* -#include -#include -#include - -static int cgo_dns_search(const char *name, int class, int type, - unsigned char *answer, int anslen, int *out_h_errno) { - dns_handle_t handle = (dns_handle_t)dns_open(NULL); - if (handle == NULL) { - *out_h_errno = NO_RECOVERY; - return -1; - } - struct sockaddr_storage from; - uint32_t fromlen = sizeof(from); - h_errno = 0; - int n = dns_search(handle, name, class, type, (char *)answer, anslen, - (struct sockaddr *)&from, &fromlen); - *out_h_errno = h_errno; - dns_free(handle); - return n; -} -*/ -import "C" - -import ( - "context" - "errors" - "unsafe" - - "github.com/sagernet/sing-box/dns" - E "github.com/sagernet/sing/common/exceptions" - - mDNS "github.com/miekg/dns" -) - -const ( - darwinResolverHostNotFound = 1 - darwinResolverTryAgain = 2 - darwinResolverNoRecovery = 3 - darwinResolverNoData = 4 -) - -func darwinLookupSystemDNS(name string, class, qtype int) (*mDNS.Msg, error) { - cName := C.CString(name) - defer C.free(unsafe.Pointer(cName)) - - answer := make([]byte, 4096) - var hErrno C.int - n := C.cgo_dns_search(cName, C.int(class), C.int(qtype), - (*C.uchar)(unsafe.Pointer(&answer[0])), C.int(len(answer)), - &hErrno) - if n <= 0 { - return nil, darwinResolverHErrno(name, int(hErrno)) - } - var response mDNS.Msg - err := response.Unpack(answer[:int(n)]) - if err != nil { - return nil, E.Cause(err, "unpack dns_search response") - } - return &response, nil -} - -func darwinResolverHErrno(name string, hErrno int) error { - switch hErrno { - case darwinResolverHostNotFound: - return dns.RcodeNameError - case darwinResolverNoData: - return dns.RcodeSuccess - case darwinResolverTryAgain, darwinResolverNoRecovery: - return dns.RcodeServerFailure - default: - return E.New("dns_search: unknown h_errno ", hErrno, " for ", name) - } -} - -func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - question := message.Question[0] - type resolvResult struct { - response *mDNS.Msg - err error - } - resultCh := make(chan resolvResult, 1) - go func() { - response, err := darwinLookupSystemDNS(question.Name, int(question.Qclass), int(question.Qtype)) - resultCh <- resolvResult{response, err} - }() - var result resolvResult - select { - case <-ctx.Done(): - return nil, ctx.Err() - case result = <-resultCh: - } - if result.err != nil { - var rcodeError dns.RcodeError - if errors.As(result.err, &rcodeError) { - return dns.FixedResponseStatus(message, int(rcodeError)), nil - } - return nil, result.err - } - result.response.Id = message.Id - // Workaround for a bug in Apple libresolv: res_query_mDNSResponder - // (libresolv/res_query.c), used when the resolver has - // DNS_FLAG_FORWARD_TO_MDNSRESPONDER set (typical inside a Network - // Extension), writes: - // - // ans->qr = 1; - // ans->qr = htons(ans->qr); - // - // HEADER.qr is a 1-bit bitfield (), so - // htons(1) == 0x0100 gets truncated back to 0, clearing the QR bit. - // Force it on so downstream clients see a valid response. - result.response.Response = true - return result.response, nil -} diff --git a/dns/transport/local/local_darwin_stun.go b/dns/transport/local/local_darwin_stun.go deleted file mode 100644 index b99833c26..000000000 --- a/dns/transport/local/local_darwin_stun.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build darwin && !cgo - -package local - -import ( - "context" - - E "github.com/sagernet/sing/common/exceptions" - - mDNS "github.com/miekg/dns" -) - -func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - return nil, E.New(`local DNS server requires CGO on darwin, rebuild with CGO_ENABLED=1`) -} diff --git a/dns/transport/local/local_darwin_test.go b/dns/transport/local/local_darwin_test.go new file mode 100644 index 000000000..43aa59e32 --- /dev/null +++ b/dns/transport/local/local_darwin_test.go @@ -0,0 +1,103 @@ +//go:build darwin + +package local + +import ( + "cmp" + "context" + "net" + "os" + "testing" + "time" + + mDNS "github.com/miekg/dns" +) + +// "localhost" is answered by the mDNSResponder daemon itself, so these tests need +// no external network. + +func requireMDNSResponder(t *testing.T) { + t.Helper() + socketPath := cmp.Or(os.Getenv(mdnsResponderSocketEnv), mdnsResponderSocketPath) + conn, err := net.DialTimeout("unix", socketPath, time.Second) + if err != nil { + t.Skipf("mDNSResponder not reachable at %s: %v", socketPath, err) + } + conn.Close() +} + +func TestSystemExchangeLoopback(t *testing.T) { + requireMDNSResponder(t) + transport := &Transport{} + for _, testCase := range []struct { + qtype uint16 + expected net.IP + }{ + {mDNS.TypeA, net.IPv4(127, 0, 0, 1)}, + {mDNS.TypeAAAA, net.IPv6loopback}, + } { + message := new(mDNS.Msg) + message.SetQuestion("localhost.", testCase.qtype) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + response, err := transport.systemExchange(ctx, message) + cancel() + if err != nil { + t.Fatalf("%s localhost: %v", mDNS.TypeToString[testCase.qtype], err) + } + if response.Id != message.Id { + t.Fatalf("%s response id %d != request id %d", mDNS.TypeToString[testCase.qtype], response.Id, message.Id) + } + if !response.Response { + t.Fatalf("%s response flag not set", mDNS.TypeToString[testCase.qtype]) + } + var found bool + for _, answer := range response.Answer { + switch record := answer.(type) { + case *mDNS.A: + found = found || record.A.Equal(testCase.expected) + case *mDNS.AAAA: + found = found || record.AAAA.Equal(testCase.expected) + } + } + if !found { + t.Fatalf("%s localhost: expected %s in answer, got %v", mDNS.TypeToString[testCase.qtype], testCase.expected, response.Answer) + } + } +} + +func TestSystemExchangeNoData(t *testing.T) { + requireMDNSResponder(t) + message := new(mDNS.Msg) + // localhost has no MX record, so the daemon reports NoSuchRecord, which must + // surface as an empty NOERROR response rather than an error. + message.SetQuestion("localhost.", mDNS.TypeMX) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + response, err := (&Transport{}).systemExchange(ctx, message) + if err != nil { + t.Fatalf("MX localhost: %v", err) + } + if response.Rcode != mDNS.RcodeSuccess { + t.Fatalf("MX localhost: rcode %s, want NOERROR", mDNS.RcodeToString[response.Rcode]) + } + if len(response.Answer) != 0 { + t.Fatalf("MX localhost: expected no answers, got %v", response.Answer) + } +} + +func TestSystemExchangeCancel(t *testing.T) { + requireMDNSResponder(t) + message := new(mDNS.Msg) + message.SetQuestion("localhost.", mDNS.TypeA) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + _, err := (&Transport{}).systemExchange(ctx, message) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected error for cancelled context") + } + if elapsed > time.Second { + t.Fatalf("cancellation too slow: %s", elapsed) + } +}