I used to be a big fan of coredns: I use it on my laptop, in our team's internal infrastructure and maintain the package for archlinuxcn. Until one day, I want to solve the DNS Pollution problem by coredns. But...

CoreDNS is not for DNS Pollution

It's obvious that I'm not the only fan of coredns, there are mainly two solutions via coredns:

Why not generate corefile?

First of all, it's ugly and not suitable for updates automatically. We have to generate the whole Corefile every time domain list updated and make it hard to apply custom updates. For example, I need to forward some internal domains to a DNS server in a VPN. I need to add them in corefile build scripts or build another system to build corefile.

Then, coredns forward except is not designed for large domains input. forward will read all zone in a slice, and check them one by one:

// Read all domains
func parseBlock(c *caddy.Controller, f *Forward) error {
   switch c.Val() {
   case "except":
      ignore := c.RemainingArgs()
      if len(ignore) == 0 {
         return c.ArgErr()
      }
      for i := 0; i < len(ignore); i++ {
         ignore[i] = plugin.Host(ignore[i]).Normalize()
      }
      f.ignored = ignore
    ...
}

// Check one by one
func (f *Forward) isAllowedDomain(name string) bool {
   if dns.Name(name) == dns.Name(f.from) {
      return true
   }

   for _, ignore := range f.ignored {
      if plugin.Name(ignore).Matches(name) {
         return false
      }
   }
   return true
}

We can use the builtin zone support for better performance:

. {
    log
    cache
    forward . 8.8.8.8
}

abc.com def.com {
    log
    cache
    forward . 114.114.114.114
}

But we still need to maintain a huge corefile.

Why not write a plugin?

So, why not write a plugin to read domains from a file and forward it to different upstreams? Yes, I tried, and I found coredns's plugin system is a mess.

coredns is chain based, all dns query will be split via zones, so we can't filter and forward them by domains. To handle all domains, we have to design a plugin under root:

. {
    filter xxxxx
}

Then, we need to design a config that matches something and forward to somewhere, maybe like the following:

. {
    filter {
        condition domain_in_file xxxxxxxxxx
        action forward xxxxxxx
    }
    filter {
        condition domain_not_in_file xxxxxxxxxx
        action forward xxxxxx
    }
}

Looks perfect for now, let's implement it. Ooooops, we need forward here, why not use the builtin forward? Sorry, we can't. Plugin in coredns doesn't been designed for run standalone or embedded, we can't call other plugins or pass the query to other plugins conveniently. As I described in RFC embeddable plugin, many plugins have to implement the same feature.

So, don't dig deeper, let's jump out of the chaos.

So, what am I need?

After talking so much, what am I need?

  • Forward DNS query to upstreams depends on conditions
  • Human-readable config
  • Simple deployment, no extra build needed
  • No so bad performance

Although there are other good DNS servers like overture and smartdns, let's build a DNS server for ourselves.

Say Hi to AtomDNS!

With dns package support, write a DNS server by go is so simple that we can build one in a half-day: atomdns.

atomdns is built by three-part: upstream, match and rules. upstream means a set of DNS servers that dns been forwarded to. match is the match policy, and we support in_domain_list type for now. rules will specify when match policy matched, this query should be forwarded to which upstream.

atomdns's config is powered by hcl2:

listen = "127.0.0.1:53"

upstream "oversea" {
  type = "dot"
  addr = "185.222.222.222:853"
  tls_server_name = "public-dns-a.dns.sb"
}

upstream "mainland" {
  type = "udp"
  addr = "114.114.114.114:53"
}

match "to_mainland" {
  type = "in_domain_list"
  # get this file from https://github.com/felixonmars/dnsmasq-china-list
  path = "/etc/atomdns/accelerated-domains.china.raw.txt"
}

rules = {
  to_mainland: "mainland",
  default: "oversea"
}

We have two upstream here, and one names oversea, the other names mainland. When to_mainland matched which is in the domain list we specify here, we will forward it to mainland.

atomdns[164688]: 2020/04/06 22:09:13 [{wx1.qq.com. 1 1}]
atomdns[164688]: 2020/04/06 22:09:13 rule to_mainland matched, served via mainland
atomdns[164688]: 2020/04/06 22:12:18 [{github.githubassets.com. 1 1}]
atomdns[164688]: 2020/04/06 22:12:18 no rules matched, served via oversea

Simple, but works.