背景

传说中的下一代 iptablesnftables 已经出来了好长时间了。现在主流发行版的内核也都已经更新到了对 nftables 支持足够好的版本。 在2年多前我也初步体验过了 nftables ,当时写了个 《nftables初体验》 。并且开始使用 nftables 来实现对家里软路由的管理。 而去年的时候,我也尝试用 nftables 实现了双拨(详见: 《折腾一下nftables下的双拨》)并且可以搭配TPROXY透明代理使用。

但是由于 nftables 的生态建设仍然时落后于 iptables ,导致我一致没能完全把家里的软路由全部迁移到 nftables 上来。 剩下的部分主要有两处,一处是桥接的重路由,另一个是我原来的策略路由会根据域名解析动态地写 ipset ,然后根据 ipset 来决定是否走tproxy。 前者之前和别人讨论过说是我之前测试时候用地地址不对,理论上是有解的(我还没测试)。 而后者由于 nftables 有自己的 set 结构,且并没有指令可以访问 ipset,并且我之前用的无论是 dnsmasq 还是 smartdns 都不支持直接写 nftables,所以就一直没有完全替换。

其实最新版本的 dnsmasq 在今年9约发布的版本(2.8.7)里已经支持写入 nftables 了,而 smartdns 去年就有PR支持 nftables,但是知道今天都还没合入。 然而由于之前无论是 dnsmasqsmartdns,我在使用上都碰到了一些问题。所以现在是全面替换成了 coredns 作为软路由的DNS服务,然后仅仅把 dnsmasq 用作 ipv4 的DHCP服务。

dnsmasq 是碰到过一些ipv6的兼容性问题,这个问题存在了很久都没有解决。而 2.8.7 版本又迟迟不发布。另一个问题是 dnsmasq 内部很多操作都是链表实现,如果我们有个巨大的匹配规则,会导致CPU居高不下。后来有Patch加了缓存来减少这个开销,但是本质上还是 O(n) 复杂度,感觉很不优雅。 而 smartdns 仅支持 A 和 AAAA 记录。如果把它当作DNS服务的入口,对PTR、ANY等记录解析是由问题的,导致有些app网络不正常。所以官方主页也是建议把它放在已有的DNS服务后面当加速DNS使用,而不是替代现有DNS服务。

CoreDNS和插件

corednsCNCF 基金会下的项目,也是现在 Kubernetes 中默认的DNS服务。基于 Caddy , 使用 golang 编写。所以它其实消耗内存相对较高。 但是我家里软路由是 x86_64 的,不在乎那点内存。受益于 Kubernetes 社区和 golang 的灵活性,它的可靠性和可扩展性都特别好。编写插件的上手难度相当地低,所以我就打算自己写插件满足我的需求。

我的需求其实也比较简单:

  • 根据域名解析结果写入 nftables 的set。
  • 如果域名能同时解析出 ipv4 地址和 ipv6 地址,则只返回ipv4地址。

    我的用于tproxy的机器只有ipv4地址,所以有个特殊的需要是尽可能用ipv4,实在没有的话才用ipv6。

  • 实现 dnsmasqsmartdns 里都有的 bogus-nxdomain 功能。

针对这两个需求,我拆分成了2个插件,一个仅用于操作 nftablescoredns-nftables ;另一个相当于是对返回数据的过滤,所以叫 coredns-filter

coredns 的插件行为编写只需要实现一个Handle,实现这两个接口即可:

Handler interface {
  ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error)
  Name() string
}

然后初始化的时候需要实现

func init() {
  plugin.Register("插件名", setup)
}

func setup(c *caddy.Controller) error {
  handle := NewHandler()    // 创建插件实例
  err := parse(c, &handle)  // 解析配置
  if err != nil {
    return plugin.Error("插件名", err)
  }

  // 设置串联插件的回调(设置下一个插件)
  dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler {
    handle.Next = next
    return &handle
  })

  log.Debug("Add 插件名 plugin to dnsserver")

  return nil
}

coredns 的插件是静态的,就是增减插件都需要把插件名字和地址写进 plugin.cfg 里然后重新编译。而 coredns 的插件触发顺序是按 plugin.cfg 里的顺序,和用户写配置的顺序无关。 实际上 coredns 会对每个Zone建立一个插件的链表,然后从尾部开始触发插件的 setup 接口。

比如,如果 plugin.cfg 里插件 A , B 的配置顺序是

A
B

那么会先调用 Bfunc(next plugin.Handler) plugin.Handler ,然后在调用 Afunc(next plugin.Handler) plugin.Handler 时候 next 传入 B 的返回值。

而执行的时候,几乎所有的插件都是使用 plugin.NextOrFailure(m.Name(), m.Next, ctx, w, r) 来先触发下一跳,通过协程的等待机制等下一跳完成在执行自己的逻辑。 如果需要修改下一跳的返回结果,可以通过 coredns 提供的 nonwriter 模块对插件链的解析结果进行劫持再重新写入。

这些操作最终使得整体像是一个链表按配置的顺序执行,实际上它是一个栈。

当然也可以不用这种标准方式执行,比如说我们也可以像 smartdns 一样并发发起多个请求,返回最快的那个。(说不定哪天有时间了我可以再写个插件完成这个功能)

其中 ServeDNS 接口的返回值如果无错误的话要返回 DNS解析的 RCODE 。

所以实现插件只要把我们要加的功能插入到合适的位置。

coredns-nftables

coredns-nftables 用于操作 nftables。先预留了也许以后还可以由其他操作,所以插件名字就叫 nftables 。只是当前版本还是仅支持添加 set 。

golang 其实没有成熟的能够直接操作 nftables 的库,所幸 Google 有一个没有Release的库 https://github.com/google/nftables ,可以直接用。

然后由于这个库是对解析出的最终结果写入set,对于返回 CNAME 的域名,其实需要进一步递归解析,这时候可以使用 coredns 的外部插件 finalize

那么由于我们要对 finalize 的最终结果做处理,所以我们的这个插件要挂在 finalize 的后面。

我测试的时候发现操作 nftables 的时候延迟还是比较大的,能有上百毫秒。所以为了不拖慢域名解析的速度,我把这个插件写成了先写出和返回解析结果,然后后台起了个 goroutine 去执行 nftables 的写入。同时也为了降低不必要的开销,建议是把这个插件放在 cache 插件后,并且超时时间大于 cache 的超时时间。

sed -i.bak -r '/finalize:.*/d' plugin.cfg
sed -i.bak '/cache:.*/a finalize:github.com/tmeckel/coredns-finalizer' plugin.cfg
go get github.com/tmeckel/coredns-finalizer

sed -i.bak -r '/nftables:.*/d' plugin.cfg
sed -i.bak '/cache:.*/i nftables:github.com/owent/coredns-nftables' plugin.cfg
go get github.com/owent/coredns-nftables

go generate

最终的配置结构如下:

nftables [ip/ip6]... {
  set add element <TABLE_NAME> <SET_NAME> [ip/ip6/auto] [interval] [timeout]
  [connection timeout <timeout>]
}

nftables [inet/bridge/arp/netdev]... {
  set add element <TABLE_NAME> <SET_NAME> <ip/ip6> [interval] [timeout]
  [connection timeout <timeout>]
}

这里还利用了 nftables 的 set 自带的超时机制去设置定时淘汰。 对于 ipip6 的 family 类型,可以设置 set 类型为 auto,这样就只会写入符合条件对的ip。 而对于其他的 family 类型,就必须自己设置建立 set 时要使用哪种类型了。当然如果set本身就已存在会用已存在的set的类型。

coredns-filter

coredns-filter 插件就更简单一些了。当然也是建议放在 cachecoredns-nftables 插件后,但是应该在 finalize 前。这样能减少 coredns-nftables 不必要的开销。

整个配置的语法大概是:

filter [command options...] {
  [command options...]
}

然后这里的指令可以是:

  • prefer <none/ipv4/ipv6> : 不过滤/优先使用ipv4/优先使用ipv6
  • bogus-nxdomain [ip address/ip prefix...] : 反污染的ip地址或前缀

比如:

example.org {
    whoami
    forward . 8.8.8.8
    filter prefer ipv4 {
      bogus-nxdomain 127.0.0.1/30 123.125.81.12
    }
}

当然ipv6也是可以的

最后

以上两个插件我开源在了 https://github.com/owent/coredns-nftableshttps://github.com/owent/coredns-filter

另外我还做了个 coredns 的docker镜像放在了 docker.io/owt5008137/coredns 。 每周三拉取最新版本的 coredns 代码并在 cache 插件后面依次插入 coredns-nftablescoredns-filterfinalize ,并在 forward 插件前插入了 alternate 插件。alternate 相当于反向的 bogus-nxdomain 功能。

最终大概这个样子:

cache
nftables
filter
finalizer
# ...
alternate
forward

有需要的小伙伴可以通过 podman/docker pull owt5008137/coredns 或者 podman/docker pull docker.io/owt5008137/coredns 自取。

也欢迎有兴趣的小伙伴们互相交流。