diff --git a/README.md b/README.md index 7d1419a..1a66777 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ -# 🧠 DNS-over-TLS Cache Proxy +# 🧩 DNS-over-TLS Cache Proxy -一个基于 Go 的高性能 **DNS-over-TLS (DoT)** 缓存代理服务器。 -支持多上游并发解析、智能缓存、隐私保护与优雅关闭。 -轻量、无依赖、可直接部署。 +一个用 **Go** 编写的高性能 **DNS-over-TLS (DoT)** 缓存代理服务, +专为隐私保护与性能优化而设计。支持多上游并发解析、智能缓存、ECS 剥离和优雅关闭。 -## ✨ 特性 +## 🚀 特性概览 -- 🔒 **加密传输** — 完全支持 DNS-over-TLS (RFC 7858) -- ⚡ **多上游并发查询** — 类似“快乐眼球”机制,提升解析速度 -- 🧠 **TTL 智能缓存** — 支持正向与负面缓存(RFC 2308) -- 🧹 **自动清理** — 定期清除过期缓存 -- 🧩 **隐私保护** — 默认剥离 ECS (EDNS Client Subnet) -- 🪶 **轻量高效** — 单文件可执行,零外部依赖 +| 功能 | 描述 | +|------|------| +| 🔒 **加密传输** | 完全支持 DNS-over-TLS(RFC 7858),支持 TLS 1.2/1.3 | +| ⚡ **多上游并发解析** | “快乐眼球”机制并行查询多个上游 DNS,取最快响应 | +| 🧠 **智能缓存系统** | 支持正向与负面缓存(RFC 2308),动态 TTL 调整 | +| 🧹 **自动清理机制** | 定期清理过期缓存项(默认每 5 分钟) | +| 🧩 **隐私保护** | 默认剥离 ECS (EDNS Client Subnet),防止地理泄露 | +| 🧱 **黑名单过滤** | 支持域名黑名单(后缀匹配、文件或命令行加载) | +| 🪶 **轻量高效** | 单一可执行文件,无外部依赖,易于容器化部署 | +## 🏗️ 快速开始 -## 📦 安装 - -### 🧰 源码构建 +### 从源码构建 ```bash git clone https://git.aixiao.me/aixiao/dot.git @@ -24,98 +25,138 @@ cd dot go build -o dot main.go ``` -### 🐳 Docker 构建 +### 使用 Docker 构建与运行 ```bash -#构建、启动 +# 构建镜像 bash build.sh build + +# 运行容器 bash build.sh run -#清理 +# 查看日志 +bash build.sh logs + +# 停止与清理 bash build.sh stop bash build.sh clean ``` +> 🧩 默认镜像名为 `dot:latest`,监听 `853` 端口,可通过 `PORT` 环境变量修改。 -## 🚀 启动服务 +## ⚙️ 启动示例 ```bash -./dot \ - -cert=server.crt \ - -key=server.key \ - -addr=":853" \ - -upstream="8.8.8.8:53,1.1.1.1:53" \ - -cache-ttl=120s \ - -timeout=3s \ - -max-parallel=2 \ - -strip-ecs=true \ - -tcp-fallback=true \ - -v - +./dot -cert=server.crt -key=server.key -addr=":853" -upstream="8.8.8.8:53,1.1.1.1:53" -cache-ttl=120s -timeout=3s -max-parallel=2 -strip-ecs=true -tcp-fallback=true -v ``` -输出示例: -``` +启动日志示例: + +```bash 🚀 starting DNS-over-TLS on :853 [req] A www.example.com. (id=40192 cd=false do=true from=127.0.0.1:58877) [cache] MISS A www.example.com. [answer] www.example.com. 300 IN A 93.184.216.34 ``` +## ⚙️ 参数说明 -## 🧩 配置参数 - -| 参数 | 默认值 | 说明 | +| 参数 | 默认值 | 描述 | |------|---------|------| -| `--addr` | `:853` | 监听地址 | +| `--addr` | `:853` | 监听地址(支持 IPv4/IPv6) | | `--cert` | `server.crt` | TLS 证书路径 | | `--key` | `server.key` | TLS 私钥路径 | -| `--upstream` | `8.8.8.8:53,1.1.1.1:53` | 上游 DNS 服务器 | -| `--cache-ttl` | `60s` | 最大缓存 TTL | +| `--upstream` | `8.8.8.8:53,1.1.1.1:53` | 上游 DNS 服务器列表 | +| `--cache-ttl` | `60s` | 缓存最大 TTL(正向/负面均适用) | | `--timeout` | `3s` | 上游查询超时 | -| `--max-parallel` | `3` | 并发上游查询数 | +| `--max-parallel` | `3` | 最大并发上游查询数 | | `--strip-ecs` | `true` | 是否剥离 ECS 信息 | -| `--tcp-fallback` | `true` | 是否启用 TCP 回退 | -| `--v` | `false` | 详细日志模式 | +| `--tcp-fallback` | `true` | UDP 截断时是否自动 TCP 回退 | +| `--blacklist` | 空 | 逗号分隔的黑名单域名或通配后缀 | +| `--blacklist-file` | 空 | 黑名单文件路径(每行一个规则) | +| `--blacklist-rcode` | `REFUSED` | 黑名单命中返回码:`REFUSED` / `NXDOMAIN` / `SERVFAIL` | +| `--cache-size` | `10000` | LRU 缓存最大条目数 | +| `--v` | `false` | 启用详细日志模式 | +## 🔍 缓存机制详解 -## 🧪 测试解析 +**缓存键格式:** -使用 `kdig` 或 `dig` 进行测试: +```sh +domain|type|class|DO|CD +``` + +**缓存策略:** + +- ✅ **正向缓存**:取最小 TTL 与配置上限的较小值 +- 🚫 **负面缓存**:遵循 RFC 2308,从 SOA.MINIMUM 计算 TTL +- 🧭 **动态 TTL 调整**:返回时按剩余时间递减 TTL +- 🧹 **自动清理**:每 5 分钟扫描并删除过期条目 +- 🔒 **隔离逻辑**:DO/CD 不同查询独立缓存空间 + +## 🧱 黑名单功能 + +支持两种配置方式: + +1. 命令行参数: + + ```bash + ./dot -blacklist="*.ads.com,*.tracking.net" + ``` + +2. 文件加载(每行一个域名或后缀): + + ```sh + # blacklist.txt + *.ads.com + *.malware.net + ``` + + 启动命令: + + ```bash + ./dot -blacklist-file=blacklist.txt -blacklist-rcode=NXDOMAIN + ``` + +黑名单命中后不再上游查询,直接返回指定 RCODE。 + +## 🧩 架构与运行原理 + +```mermaid +flowchart TD + A[Client (DoT Request)] --> B[DNS-over-TLS Server] + B --> C[Cache Lookup] + C -- HIT --> D[Return Cached Response] + C -- MISS --> E[Upstream Resolver Pool] + E -->|Fastest Response| F[DNS Response] + F --> G[Cache Write] + G --> H[Return to Client] +``` + +## 🧰 开发与维护 + +- 语言:**Go 1.22+** +- 依赖: + - [`github.com/miekg/dns`](https://github.com/miekg/dns) + - [`github.com/hashicorp/golang-lru/v2`](https://pkg.go.dev/github.com/hashicorp/golang-lru/v2) +- 推荐编译参数: + + ```bash + go build -ldflags="-s -w" -o dot main.go + ``` + +## 🧪 测试方法 + +使用 `kdig` 或 `dig` 测试解析: ```bash kdig @127.0.0.1 +tls-ca +tls-host=dot.local www.example.com ``` - -## 📊 缓存机制 - -- **缓存键**:`domain|type|class|DO|CD` -- **正向缓存**:取最小 TTL 与配置上限的较小值 -- **负面缓存**:依据 SOA.MINIMUM(RFC 2308) -- **动态 TTL 续算**:返回时根据剩余时间更新 TTL -- **清理周期**:每 5 分钟清除过期项 - - -## 🔐 安全特性 - -- 默认支持 **TLS 1.2 / 1.3** -- 剥离 **EDNS Client Subnet** -- 不缓存 OPT/TSIG 伪记录 -- 独立缓存空间隔离 DO/CD 查询 - - -## 🧭 路线图 - -- [ ] 支持 DoH (DNS-over-HTTPS) -- [ ] LRU 缓存上限控制 -- [ ] 增加配置文件支持 (YAML/JSON) -- [ ] 集成 Docker Compose & CI/CD - - ## 👨‍💻 作者信息 -**Email:** aixiao@aixiao.me +**Author:** niuyuling +**Email:** [aixiao@aixiao.me](mailto:aixiao@aixiao.me) **License:** MIT -**Language:** Go 1.22+ -**Dependency:** [github.com/miekg/dns](https://github.com/miekg/dns) +**Repository:** [git.aixiao.me/aixiao/dot](https://git.aixiao.me/aixiao/dot) + diff --git a/blacklist.go b/blacklist.go new file mode 100644 index 0000000..7abd64a --- /dev/null +++ b/blacklist.go @@ -0,0 +1,188 @@ +package main + +import ( + "bufio" + "context" + "log" + "os" + "sort" + "strings" + "time" + + "github.com/miekg/dns" +) + +// -------- Blacklist helpers (独立文件) -------- + +// 将输入域名规范化为 canonical FQDN(去空格、去 *. 前缀、统一大小写/尾点) +func canonicalFQDN(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.TrimPrefix(s, "*.") + return dns.CanonicalName(s) +} + +func uniqueStrings(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写 +func loadBlacklistFile(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + sc := bufio.NewScanner(f) + var rules []string + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + if i := strings.IndexAny(line, "#;"); i >= 0 { + line = strings.TrimSpace(line[:i]) + } + if r := canonicalFQDN(line); r != "" { + rules = append(rules, r) + } + } + if err := sc.Err(); err != nil { + return nil, err + } + return uniqueStrings(rules), nil +} + +// 自动重载黑名单 +func startBlacklistReloader(ctx context.Context, path string, interval time.Duration, current **suffixMatcher) { + if path == "" { + return + } + go func() { + var lastMod time.Time + for { + select { + case <-ctx.Done(): + return + case <-time.After(interval): + fi, err := os.Stat(path) + if err != nil { + log.Printf("[blacklist] reload check failed: %v", err) + continue + } + modTime := fi.ModTime() + if modTime.After(lastMod) { + rules, err := loadBlacklistFile(path) + if err != nil { + log.Printf("[blacklist] reload failed: %v", err) + continue + } + *current = newSuffixMatcher(rules) + lastMod = modTime + log.Printf("[blacklist] reloaded %d rules (modified %s)", len(rules), modTime.Format(time.RFC3339)) + } + } + } + }() +} + +// 后缀匹配器:rules 已 canonical,按长度降序排列(更精确的规则优先) +type suffixMatcher struct { + rules []string +} + +func newSuffixMatcher(rules []string) *suffixMatcher { + rs := uniqueStrings(rules) + sort.Slice(rs, func(i, j int) bool { return len(rs[i]) > len(rs[j]) }) + return &suffixMatcher{rules: rs} +} + +// 命中则返回匹配的规则 +func (m *suffixMatcher) match(name string) (string, bool) { + if m == nil || len(m.rules) == 0 { + return "", false + } + name = dns.CanonicalName(name) + for _, r := range m.rules { + if name == r || strings.HasSuffix(name, "."+r) { + return r, true + } + } + return "", false +} + +// 解析 RCODE 文本到常量 +func parseRcode(s string) int { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "REFUSED", "": + return dns.RcodeRefused + case "NXDOMAIN": + return dns.RcodeNameError + case "SERVFAIL": + return dns.RcodeServerFailure + default: + log.Printf("[blacklist] unknown rcode %q, fallback REFUSED", s) + return dns.RcodeRefused + } +} + +// 构造“伪上游”阻断响应;带 EDE=Blocked(15) 说明命中规则 +func makeBlockedUpstream(rcode int, rule string) *dns.Msg { + m := new(dns.Msg) + m.RecursionAvailable = true + m.AuthenticatedData = false + m.Rcode = rcode + opt := &dns.OPT{} + opt.Hdr.Name = "." + opt.Hdr.Rrtype = dns.TypeOPT + ede := &dns.EDNS0_EDE{InfoCode: 15, ExtraText: "blocked by policy: " + rule} + opt.Option = append(opt.Option, ede) + m.Extra = append(m.Extra, opt) + return m +} + +func initBlacklist(ctx context.Context, listStr, filePath, rcodeStr string) (*suffixMatcher, int) { + var rules []string + + if v := strings.TrimSpace(listStr); v != "" { + for _, s := range strings.Split(v, ",") { + if r := canonicalFQDN(s); r != "" { + rules = append(rules, r) + } + } + } + + if file := strings.TrimSpace(filePath); file != "" { + fileRules, err := loadBlacklistFile(file) + if err != nil { + log.Fatalf("[fatal] failed to load blacklist-file %q: %v", file, err) + } + rules = append(rules, fileRules...) + } + + bl := newSuffixMatcher(rules) + blRcode := parseRcode(rcodeStr) + + if filePath != "" { + startBlacklistReloader(ctx, filePath, 30*time.Second, &bl) + } + + log.Printf("[blacklist] loaded %d rules (file=%v, rcode=%s)", + len(bl.rules), filePath != "", strings.ToUpper(rcodeStr)) + + return bl, blRcode +} diff --git a/blacklist.txt b/blacklist.txt new file mode 100644 index 0000000..e34a910 --- /dev/null +++ b/blacklist.txt @@ -0,0 +1 @@ +*.baidu.com diff --git a/dot b/dot index c6bc347..1c261b4 100644 Binary files a/dot and b/dot differ diff --git a/main.go b/main.go index 5ffb9a2..d07e4c9 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,11 @@ func initLogger(verbose bool) { type cacheEntry struct { msg *dns.Msg expireAt time.Time + // EDNS metadata (to reproduce Extended RCODE / EDE on cache hits) + ednsPresent bool + ednsVersion uint8 + ednsExtRcode uint16 + ednsEDE []*dns.EDNS0_EDE } var ( @@ -92,7 +97,8 @@ func startCacheCleaner(ctx context.Context) { func cacheKeyFromMsg(q dns.Question, do, cd bool) string { var b strings.Builder b.Grow(len(q.Name) + 32) - b.WriteString(strings.ToLower(q.Name)) + // 采用规范化域名,避免尾随点/IDNA/大小写造成的重复键 + b.WriteString(dns.CanonicalName(q.Name)) b.WriteString("|T=") b.WriteString(dns.TypeToString[q.Qtype]) b.WriteString("|C=") @@ -115,20 +121,43 @@ func isPseudo(rr dns.RR) bool { } } -// 读取缓存(修复:Get 在写锁下;在锁外调整 TTL) -func tryCacheRead(key string) (*dns.Msg, bool) { +// clone and extract EDNS metadata (present, version, ext-rcode, all EDEs) +func cloneEDE(in *dns.EDNS0_EDE) *dns.EDNS0_EDE { + if in == nil { + return nil + } + cp := *in + return &cp +} + +func extractEDNSMeta(m *dns.Msg) (present bool, version uint8, ext uint16, ede []*dns.EDNS0_EDE) { + if o := m.IsEdns0(); o != nil { + present = true + version = o.Version() + ext = uint16(o.ExtendedRcode()) + for _, opt := range o.Option { + if e, ok := opt.(*dns.EDNS0_EDE); ok { + ede = append(ede, cloneEDE(e)) + } + } + } + return +} + +// 读取缓存(修复:Get 在写锁下;在锁外调整 TTL;返回 EDNS 元数据) +func tryCacheRead(key string) (*dns.Msg, *cacheEntry, bool) { now := time.Now() cacheMutex.Lock() e, ok := cache.Get(key) // Get 会更新 LRU,必须在写锁下 if !ok { cacheMutex.Unlock() - return nil, false + return nil, nil, false } if now.After(e.expireAt) { cache.Remove(key) cacheMutex.Unlock() - return nil, false + return nil, nil, false } // 拷贝副本,在锁外改 TTL,减少临界区时间 out := e.msg.Copy() @@ -140,7 +169,7 @@ func tryCacheRead(key string) (*dns.Msg, bool) { cacheMutex.Lock() cache.Remove(key) cacheMutex.Unlock() - return nil, false + return nil, nil, false } for _, sec := range [][]dns.RR{out.Answer, out.Ns, out.Extra} { @@ -153,7 +182,7 @@ func tryCacheRead(key string) (*dns.Msg, bool) { } } } - return out, true + return out, e, true } // 计算负面 TTL @@ -197,7 +226,6 @@ func negativeTTL(m *dns.Msg, maxTTL time.Duration) (uint32, bool) { } if soa == nil { // 建议:无 SOA 时不做负面缓存(返回 0,false) - // 如你更希望兜底,可改成:return uint32(maxTTL.Seconds()), true return 0, false } @@ -216,7 +244,8 @@ func negativeTTL(m *dns.Msg, maxTTL time.Duration) (uint32, bool) { func minRRsetTTL(m *dns.Msg) (uint32, bool) { minTTL := uint32(0) hasTTL := false - for _, sec := range [][]dns.RR{m.Answer, m.Ns, m.Extra} { + // 优先 Answer -> Ns;若都为空,再考虑 Extra(排除伪记录) + for _, sec := range [][]dns.RR{m.Answer, m.Ns} { for _, rr := range sec { if isPseudo(rr) { continue @@ -228,6 +257,18 @@ func minRRsetTTL(m *dns.Msg) (uint32, bool) { } } } + if !hasTTL { + for _, rr := range m.Extra { + if isPseudo(rr) { + continue + } + ttl := rr.Header().Ttl + if !hasTTL || ttl < minTTL { + minTTL = ttl + hasTTL = true + } + } + } return minTTL, hasTTL } @@ -245,7 +286,7 @@ func stripPseudoExtras(m *dns.Msg) { m.Extra = out } -// 写缓存 +// 写缓存(保存 EDNS 元数据,命中时可重建扩展 RCODE/EDE) func cacheWrite(key string, in *dns.Msg, maxTTL time.Duration) { if in == nil { return @@ -272,9 +313,18 @@ func cacheWrite(key string, in *dns.Msg, maxTTL time.Duration) { } expire := time.Now().Add(time.Duration(ttl) * time.Second) cp := in.Copy() + // 提取 EDNS 元数据后再剥离伪记录 + present, ver, ext, ede := extractEDNSMeta(cp) stripPseudoExtras(cp) cacheMutex.Lock() - cache.Add(key, &cacheEntry{msg: cp, expireAt: expire}) + cache.Add(key, &cacheEntry{ + msg: cp, + expireAt: expire, + ednsPresent: present, + ednsVersion: ver, + ednsExtRcode: ext, + ednsEDE: ede, + }) cacheMutex.Unlock() } @@ -401,7 +451,7 @@ func getDOFlag(m *dns.Msg) bool { return false } -func writeReply(w dns.ResponseWriter, req, upstream *dns.Msg) { +func writeReply(w dns.ResponseWriter, req *dns.Msg, upstream *dns.Msg, meta *cacheEntry) { if upstream == nil { dns.HandleFailed(w, req) return @@ -439,6 +489,13 @@ func writeReply(w dns.ResponseWriter, req, upstream *dns.Msg) { o.Option = append(o.Option, ede) } } + } else if meta != nil && meta.ednsPresent { + // Upstream/cached msg has no OPT(例如缓存时被剥离),用缓存元数据重建 + o.SetExtendedRcode(meta.ednsExtRcode) + o.SetVersion(meta.ednsVersion) + for _, e := range meta.ednsEDE { + o.Option = append(o.Option, e) + } } extras = append(extras, o) } @@ -459,6 +516,8 @@ func handleDNS( maxParallel int, stripECSBeforeForward bool, allowTCPFallback bool, + bl *suffixMatcher, + blRcode int, ) dns.HandlerFunc { return func(w dns.ResponseWriter, r *dns.Msg) { if len(r.Question) == 0 { @@ -472,11 +531,21 @@ func handleDNS( if stripECSBeforeForward { stripECS(r) } + + // 黑名单拦截:命中则不查上游,直接返回 + if rule, ok := bl.match(q.Name); ok { + nameCanon := dns.CanonicalName(q.Name) + log.Printf("[blacklist] HIT %s rule=%s (no upstream query)", nameCanon, rule) + up := makeBlockedUpstream(blRcode, rule) + writeReply(w, r, up, nil) + return + } + key := cacheKeyFromMsg(q, getDOFlag(r), r.CheckingDisabled) - if cached, ok := tryCacheRead(key); ok { + if cachedMsg, cachedMeta, ok := tryCacheRead(key); ok { log.Printf("[cache] HIT %s %s", dns.TypeToString[q.Qtype], q.Name) - writeReply(w, r, cached) + writeReply(w, r, cachedMsg, cachedMeta) return } log.Printf("[cache] MISS %s %s", dns.TypeToString[q.Qtype], q.Name) @@ -492,7 +561,7 @@ func handleDNS( for _, ans := range resp.Answer { log.Printf("[answer] %s", ans.String()) } - writeReply(w, r, resp) + writeReply(w, r, resp, nil) } } @@ -512,6 +581,9 @@ func main() { maxParallel := flag.Int("max-parallel", 3, "并发上游数量") stripECSFlag := flag.Bool("strip-ecs", true, "去除 ECS") allowTCPFallback := flag.Bool("tcp-fallback", true, "UDP 截断时 TCP 回退") + blacklistStr := flag.String("blacklist", "", "逗号分隔的黑名单域名(后缀匹配;支持如 *.example.com)") + blacklistFile := flag.String("blacklist-file", "", "黑名单文件路径(每行一个域名;支持 # 或 ; 注释;后缀匹配)") + blacklistRcodeFlag := flag.String("blacklist-rcode", "REFUSED", "命中黑名单返回的 RCODE:REFUSED|NXDOMAIN|SERVFAIL") verbose := flag.Bool("v", false, "verbose 日志") flag.Parse() @@ -548,8 +620,20 @@ func main() { startCacheCleaner(ctx) + // 加载黑名单规则 + bl, blRcode := initBlacklist(ctx, *blacklistStr, *blacklistFile, *blacklistRcodeFlag) + mux := dns.NewServeMux() - mux.HandleFunc(".", handleDNS(upstreams, *cacheTTLFlag, *timeoutFlag, *maxParallel, *stripECSFlag, *allowTCPFallback)) + mux.HandleFunc(".", handleDNS( + upstreams, + *cacheTTLFlag, + *timeoutFlag, + *maxParallel, + *stripECSFlag, + *allowTCPFallback, + bl, + blRcode, + )) srv := &dns.Server{ Addr: *addr, @@ -570,8 +654,8 @@ func main() { errCh := make(chan error, 1) go func() { log.Printf("🚀 starting DNS-over-TLS on %s", *addr) - log.Printf(" upstreams=%v | cache_max_ttl=%s | cache_size=%d | timeout=%s | max_parallel=%d | strip_ecs=%v | tcp_fallback=%v", - upstreams, cacheTTLFlag.String(), *cacheSizeFlag, timeoutFlag.String(), *maxParallel, *stripECSFlag, *allowTCPFallback) + log.Printf(" upstreams=%v | cache_max_ttl=%s | cache_size=%d | timeout=%s | max_parallel=%d | strip_ecs=%v | tcp_fallback=%v | blacklist_rules=%d | blacklist_rcode=%s", + upstreams, cacheTTLFlag.String(), *cacheSizeFlag, timeoutFlag.String(), *maxParallel, *stripECSFlag, *allowTCPFallback, len(bl.rules), strings.ToUpper(*blacklistRcodeFlag)) errCh <- srv.ListenAndServe() }()