├── .gitignore ├── config.go ├── resolver.go ├── .goreleaser.yml ├── install.sh ├── cmd └── dns-heaven │ └── main.go ├── LICENSE ├── README.md ├── standardresolver.go ├── server.go └── osx ├── scutil.go └── osx.go /.gitignore: -------------------------------------------------------------------------------- 1 | dns-heaven 2 | dist 3 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package dnsheaven 2 | 3 | type Config struct { 4 | Address string 5 | Timeout int 6 | Interval int 7 | } 8 | -------------------------------------------------------------------------------- /resolver.go: -------------------------------------------------------------------------------- 1 | package dnsheaven 2 | 3 | import ( 4 | "github.com/miekg/dns" 5 | ) 6 | 7 | type Resolver interface { 8 | Resolve(net string, req *dns.Msg) (*dns.Msg, error) 9 | } 10 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - main: cmd/dns-heaven/main.go 3 | binary: dns-heaven 4 | goos: 5 | - darwin 6 | goarch: 7 | - amd64 8 | 9 | archive: 10 | format: binary 11 | 12 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | TARGET=/usr/local/bin/dns-heaven 6 | PLIST=/Library/LaunchDaemons/com.greenboxal.dnsheaven.plist 7 | 8 | curl -L -o $TARGET https://github.com/greenboxal/dns-heaven/releases/download/v1.0.0/dns-heaven_1.0.0_darwin_amd64 9 | chmod +x $TARGET 10 | 11 | cat > $PLIST < 13 | 14 | 15 | 16 | Label 17 | com.greenboxal.dnsheaven 18 | ProgramArguments 19 | 20 | $TARGET 21 | 22 | KeepAlive 23 | 24 | RunAtLoad 25 | 26 | 27 | 28 | EOF 29 | 30 | chmod 644 $PLIST 31 | launchctl load -w $PLIST 32 | 33 | -------------------------------------------------------------------------------- /cmd/dns-heaven/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "os/signal" 7 | 8 | "github.com/greenboxal/dns-heaven" 9 | "github.com/greenboxal/dns-heaven/osx" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var config = &dnsheaven.Config{} 14 | 15 | func init() { 16 | flag.StringVar(&config.Address, "address", "127.0.0.1:53", "address to listen") 17 | flag.IntVar(&config.Timeout, "timeout", 2000, "request timeout") 18 | flag.IntVar(&config.Interval, "interval", 1000, "interval between requests") 19 | } 20 | 21 | func main() { 22 | flag.Parse() 23 | 24 | resolver, err := osx.New(config) 25 | 26 | if err != nil { 27 | logrus.WithError(err).Error("error starting server") 28 | os.Exit(1) 29 | } 30 | 31 | server := dnsheaven.NewServer(config, resolver) 32 | 33 | stopping := false 34 | go func() { 35 | err := server.Start() 36 | 37 | if !stopping && err != nil { 38 | logrus.WithError(err).Error("error starting server") 39 | os.Exit(1) 40 | } 41 | }() 42 | 43 | sig := make(chan os.Signal) 44 | signal.Notify(sig, os.Interrupt) 45 | 46 | _ = <-sig 47 | 48 | server.Shutdown() 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jonathan Lima 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dns-heaven 2 | 3 | dns-heaven fixes macOS DNS stack by enabling the usage of the native DNS stack through /etc/resolv.conf. 4 | 5 | # Overview 6 | 7 | Some programs like dig, nslookup and anything compiled with Go doesn't use macOS native name resolution stack. This makes some features like split DNS to not work with those programs. 8 | 9 | This occurs because macOS native name resolution uses a set of rules that aren't compatible with resolv.conf. This includes: 10 | 11 | * Per interface DNS settings (scoped) 12 | * Per domain settings 13 | 14 | In order to support programs that uses resolv.conf, macOS writes a file with only the primary name server and search domains that were configured either through DHCP or manually. 15 | 16 | # Installation 17 | 18 | Just run: 19 | 20 | curl -L https://git.io/fix-my-dns-plz | sudo bash 21 | 22 | This script downloads the latest version and installs a LaunchAgent making sure that dns-heaven is always running. 23 | 24 | If you want to do this manually, just download the latest release or compile dns-heaven yourself, and make sure it's always running. 25 | 26 | # How it works 27 | 28 | dns-heaven exposes a DNS server that acts as a proxy mimicking native macOS behaviour. This is accomplished by periodically reading the output of `scutil --dns` and updating upstream rules and nameservers. 29 | 30 | It also keeps /etc/resolv.conf pointing to 127.0.0.1 as the system will rewrite this file whenever your network settings changes (e.g.: changing wifi network). 31 | 32 | # Alternatives 33 | 34 | ## dnsmasq 35 | This is one of the best options but it has some drawbacks. In order to use dnsmasq you need to manually specify it on network settings and manually configure the upstream forwarders. This is bad because sometimes you want to use the servers announced on DHCP instead of something static like 8.8.8.8 and 8.8.4.4. 36 | 37 | # License 38 | 39 | [MIT](LICENSE). 40 | -------------------------------------------------------------------------------- /standardresolver.go: -------------------------------------------------------------------------------- 1 | package dnsheaven 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/miekg/dns" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // ResolvError type 14 | type ResolvError struct { 15 | qname, net string 16 | nameservers []string 17 | } 18 | 19 | // Error formats a ResolvError 20 | func (e ResolvError) Error() string { 21 | errmsg := fmt.Sprintf("%s resolv failed on %s (%s)", e.qname, strings.Join(e.nameservers, "; "), e.net) 22 | return errmsg 23 | } 24 | 25 | // Resolver type 26 | type StandardResolver struct { 27 | Nameservers []string 28 | Timeout time.Duration 29 | Interval time.Duration 30 | } 31 | 32 | // Lookup will ask each nameserver in top-to-bottom fashion, starting a new request 33 | // in every second, and return as early as possbile (have an answer). 34 | // It returns an error if no request has succeeded. 35 | func (r *StandardResolver) Lookup(net string, req *dns.Msg) (message *dns.Msg, err error) { 36 | var wg sync.WaitGroup 37 | 38 | c := &dns.Client{ 39 | Net: net, 40 | ReadTimeout: r.Timeout, 41 | WriteTimeout: r.Timeout, 42 | } 43 | 44 | qname := req.Question[0].Name 45 | res := make(chan *dns.Msg, 1) 46 | 47 | L := func(nameserver string) { 48 | defer wg.Done() 49 | 50 | r, _, err := c.Exchange(req, nameserver) 51 | 52 | if err != nil { 53 | logrus.WithError(err).WithField("qname", qname).WithField("ns", nameserver).Error("error resolving query") 54 | return 55 | } 56 | 57 | if r != nil && r.Rcode != dns.RcodeSuccess { 58 | if r.Rcode == dns.RcodeServerFailure { 59 | return 60 | } 61 | } 62 | 63 | select { 64 | case res <- r: 65 | default: 66 | } 67 | } 68 | 69 | ticker := time.NewTicker(r.Interval) 70 | defer ticker.Stop() 71 | 72 | // Start lookup on each nameserver top-down, in every second 73 | for _, nameserver := range r.Nameservers { 74 | wg.Add(1) 75 | go L(nameserver) 76 | // but exit early, if we have an answer 77 | select { 78 | case r := <-res: 79 | return r, nil 80 | case <-ticker.C: 81 | continue 82 | } 83 | } 84 | 85 | // wait for all the namservers to finish 86 | wg.Wait() 87 | select { 88 | case r := <-res: 89 | return r, nil 90 | default: 91 | return nil, ResolvError{qname, net, r.Nameservers} 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package dnsheaven 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/miekg/dns" 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type Server struct { 11 | config *Config 12 | tcp *dns.Server 13 | udp *dns.Server 14 | } 15 | 16 | func NewServer(config *Config, resolver Resolver) *Server { 17 | resolve := func(net string) dns.HandlerFunc { 18 | return func(r dns.ResponseWriter, msg *dns.Msg) { 19 | result, err := resolver.Resolve(net, msg) 20 | 21 | if err != nil { 22 | logrus.WithError(err).WithField("req", msg).Error("error resolving request") 23 | 24 | r.WriteMsg(&dns.Msg{ 25 | MsgHdr: dns.MsgHdr{ 26 | Id: msg.Id, 27 | Response: true, 28 | Opcode: msg.Opcode, 29 | Authoritative: false, 30 | Truncated: false, 31 | RecursionDesired: false, 32 | RecursionAvailable: false, 33 | Zero: false, 34 | AuthenticatedData: false, 35 | CheckingDisabled: false, 36 | Rcode: dns.RcodeServerFailure, 37 | }, 38 | }) 39 | 40 | return 41 | } 42 | 43 | r.WriteMsg(result) 44 | } 45 | } 46 | 47 | tcp := &dns.Server{ 48 | Addr: config.Address, 49 | Net: "tcp", 50 | Handler: resolve("tcp"), 51 | } 52 | 53 | udp := &dns.Server{ 54 | Addr: config.Address, 55 | Net: "udp", 56 | Handler: resolve("udp"), 57 | } 58 | 59 | return &Server{ 60 | config: config, 61 | tcp: tcp, 62 | udp: udp, 63 | } 64 | } 65 | 66 | func (s *Server) Start() error { 67 | wg := &sync.WaitGroup{} 68 | 69 | errch := make(chan error) 70 | 71 | wg.Add(2) 72 | go s.runServer(wg, errch, s.tcp) 73 | go s.runServer(wg, errch, s.udp) 74 | 75 | go func() { 76 | wg.Wait() 77 | close(errch) 78 | }() 79 | 80 | for err := range errch { 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (s *Server) Shutdown() error { 90 | err1 := s.tcp.Shutdown() 91 | err2 := s.udp.Shutdown() 92 | 93 | if err1 != nil { 94 | return err1 95 | } 96 | 97 | if err2 != nil { 98 | return err2 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func (s *Server) runServer(wg *sync.WaitGroup, err chan<- error, server *dns.Server) { 105 | err <- server.ListenAndServe() 106 | wg.Done() 107 | } 108 | -------------------------------------------------------------------------------- /osx/scutil.go: -------------------------------------------------------------------------------- 1 | package osx 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type DnsInfo struct { 10 | Config *DnsConfig 11 | Scoped *DnsConfig 12 | } 13 | 14 | type DnsConfig struct { 15 | Resolvers []*ResolverInfo 16 | } 17 | 18 | type ResolverInfo struct { 19 | SearchDomains []string 20 | Nameservers []string 21 | Domain string 22 | Reachable bool 23 | Timeout int 24 | IsMdns bool 25 | } 26 | 27 | // FIXME: This parser is pretty lame and probably will break if anything changes 28 | func ParseScutilDns(data string) (*DnsInfo, error) { 29 | var currentConfig *DnsConfig 30 | var currentResolver *ResolverInfo 31 | 32 | lines := strings.Split(data, "\n") 33 | 34 | info := &DnsInfo{ 35 | Config: &DnsConfig{}, 36 | Scoped: &DnsConfig{}, 37 | } 38 | 39 | for _, l := range lines { 40 | if l == "DNS configuration" { 41 | currentConfig = info.Config 42 | continue 43 | } else if l == "DNS configuration (for scoped queries)" { 44 | currentConfig = info.Scoped 45 | continue 46 | } else if strings.HasPrefix(l, "resolver #") { 47 | if currentConfig == nil { 48 | continue 49 | } 50 | 51 | currentResolver = &ResolverInfo{} 52 | currentConfig.Resolvers = append(currentConfig.Resolvers, currentResolver) 53 | } else if strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t") { 54 | if currentResolver == nil { 55 | continue 56 | } 57 | 58 | parts := strings.SplitN(l, ":", 2) 59 | 60 | if len(parts) != 2 { 61 | continue 62 | } 63 | 64 | name := strings.TrimSpace(parts[0]) 65 | value := strings.TrimSpace(parts[1]) 66 | 67 | if strings.HasPrefix(name, "search domain") { 68 | currentResolver.SearchDomains = append(currentResolver.SearchDomains, value) 69 | } else if strings.HasPrefix(name, "nameserver") { 70 | currentResolver.Nameservers = append(currentResolver.Nameservers, fmt.Sprintf("[%s]:53", value)) 71 | } else if name == "reach" { 72 | currentResolver.Reachable = !strings.Contains(value, "Not Reachable") 73 | } else if name == "domain" { 74 | currentResolver.Domain = value 75 | } else if name == "timeout" { 76 | timeout, err := strconv.Atoi(value) 77 | 78 | if err != nil { 79 | continue 80 | } 81 | 82 | currentResolver.Timeout = timeout 83 | } else if name == "options" { 84 | currentResolver.IsMdns = strings.Contains(value, "mdns") 85 | } 86 | } 87 | } 88 | 89 | return info, nil 90 | } 91 | -------------------------------------------------------------------------------- /osx/osx.go: -------------------------------------------------------------------------------- 1 | package osx 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net" 7 | "os/exec" 8 | "strings" 9 | "time" 10 | 11 | "github.com/greenboxal/dns-heaven" 12 | "github.com/miekg/dns" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type Resolver struct { 17 | udp *dns.Client 18 | tcp *dns.Client 19 | 20 | config *dnsheaven.Config 21 | dns *DnsConfig 22 | domains map[string]*dnsheaven.StandardResolver 23 | standard *dnsheaven.StandardResolver 24 | } 25 | 26 | func New(config *dnsheaven.Config) (*Resolver, error) { 27 | udp := &dns.Client{ 28 | Net: "udp", 29 | } 30 | 31 | tcp := &dns.Client{ 32 | Net: "tcp", 33 | } 34 | 35 | r := &Resolver{ 36 | udp: udp, 37 | tcp: tcp, 38 | 39 | config: config, 40 | domains: make(map[string]*dnsheaven.StandardResolver), 41 | } 42 | 43 | err := r.refresh() 44 | 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | go r.run() 50 | 51 | return r, nil 52 | } 53 | 54 | func (r *Resolver) Resolve(net string, msg *dns.Msg) (*dns.Msg, error) { 55 | resolver := r.resolverForRequest(msg) 56 | 57 | return resolver.Lookup(net, msg) 58 | } 59 | 60 | func (r *Resolver) run() { 61 | timer := time.Tick(1 * time.Second) 62 | 63 | for _ = range timer { 64 | err := r.refresh() 65 | 66 | if err != nil { 67 | logrus.WithError(err).Error("error refreshing dns config") 68 | continue 69 | } 70 | 71 | err = r.hijack() 72 | 73 | if err != nil { 74 | logrus.WithError(err).Error("error hijacking dns config") 75 | continue 76 | } 77 | } 78 | } 79 | 80 | func (r *Resolver) refresh() error { 81 | cmd := exec.Command("/usr/sbin/scutil", "--dns") 82 | 83 | output, err := cmd.CombinedOutput() 84 | 85 | if err != nil { 86 | return err 87 | } 88 | 89 | parsed, err := ParseScutilDns(string(output)) 90 | 91 | if err != nil { 92 | return err 93 | } 94 | 95 | return r.update(parsed) 96 | } 97 | 98 | func (r *Resolver) update(d *DnsInfo) error { 99 | var standard *ResolverInfo 100 | var domains []*ResolverInfo 101 | 102 | for _, re := range d.Config.Resolvers { 103 | if !re.Reachable { 104 | continue 105 | } 106 | 107 | if re.IsMdns { 108 | continue 109 | } 110 | 111 | if standard == nil && len(re.Domain) == 0 { 112 | standard = re 113 | } else if len(re.Domain) > 0 { 114 | domains = append(domains, re) 115 | } 116 | } 117 | 118 | if standard == nil { 119 | standard = &ResolverInfo{ 120 | Nameservers: []string{ 121 | "8.8.8.8:53", 122 | "8.8.4.4:53", 123 | }, 124 | } 125 | } 126 | 127 | perDomain := map[string]*dnsheaven.StandardResolver{} 128 | for _, d := range domains { 129 | perDomain[d.Domain] = r.buildResolver(d) 130 | } 131 | 132 | r.standard = r.buildResolver(standard) 133 | r.domains = perDomain 134 | 135 | return nil 136 | } 137 | 138 | func (r *Resolver) buildResolver(re *ResolverInfo) *dnsheaven.StandardResolver { 139 | var timeout time.Duration 140 | 141 | if re.Timeout != 0 { 142 | timeout = time.Duration(re.Timeout) * time.Second 143 | } else { 144 | timeout = time.Duration(r.config.Timeout) * time.Millisecond 145 | } 146 | 147 | return &dnsheaven.StandardResolver{ 148 | Nameservers: re.Nameservers, 149 | Interval: time.Duration(r.config.Interval) * time.Millisecond, 150 | Timeout: timeout, 151 | } 152 | } 153 | 154 | func (r *Resolver) hijack() error { 155 | host, _, err := net.SplitHostPort(r.config.Address) 156 | 157 | if err != nil { 158 | return err 159 | } 160 | 161 | // FIXME: This assumes that we're listening on port 53 162 | content := fmt.Sprintf("nameserver %s", host) 163 | 164 | err = ioutil.WriteFile("/etc/resolv.conf", []byte(content), 0644) 165 | 166 | if err != nil { 167 | return err 168 | } 169 | 170 | return nil 171 | } 172 | 173 | func (r *Resolver) resolverForRequest(msg *dns.Msg) *dnsheaven.StandardResolver { 174 | if msg.Opcode != dns.OpcodeQuery && msg.Opcode != dns.OpcodeIQuery { 175 | return r.standard 176 | } 177 | 178 | qname := msg.Question[0].Name 179 | 180 | // Try to find a domain match 181 | // FIXME: Maybe this should be the longest match 182 | for k, v := range r.domains { 183 | if strings.HasSuffix(strings.ToLower(qname), strings.ToLower(k)+".") { 184 | return v 185 | } 186 | } 187 | 188 | return r.standard 189 | } 190 | --------------------------------------------------------------------------------