```
docs(readme): 重构 README 文档结构与内容以提升可读性 - 更新项目标题图标并优化描述语句 - 重新组织特性列表为表格形式,增加 ECS 剥离、黑名单过滤等功能说明 - 补充快速开始章节,细化源码构建与 Docker 使用方式 - 调整参数说明表,新增黑名单相关配置项及缓存条目限制 - 增加缓存机制详解、黑名单功能使用示例与架构图 - 更新开发依赖信息与推荐编译参数 - 修正作者信息展示格式并添加仓库链接 feat(cache): 改进缓存键生成逻辑与 EDNS 元数据处理 - 使用 dns.CanonicalName 规范化域名避免重复缓存键 - 缓存条目中保存 EDNS 扩展信息(version, rcode, EDE) - 修复缓存读取函数返回值,传递完整缓存元数据 - 调整 TTL 计算优先级,仅在必要时检查 Extra 区域 - 黑名单匹配提前拦截请求,跳过上游查询 - 启动日志中显示黑名单规则数量与返回码设置 ```
This commit is contained in:
185
README.md
185
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)
|
| 🔒 **加密传输** | 完全支持 DNS-over-TLS(RFC 7858),支持 TLS 1.2/1.3 |
|
||||||
- 🧹 **自动清理** — 定期清除过期缓存
|
| ⚡ **多上游并发解析** | “快乐眼球”机制并行查询多个上游 DNS,取最快响应 |
|
||||||
- 🧩 **隐私保护** — 默认剥离 ECS (EDNS Client Subnet)
|
| 🧠 **智能缓存系统** | 支持正向与负面缓存(RFC 2308),动态 TTL 调整 |
|
||||||
- 🪶 **轻量高效** — 单文件可执行,零外部依赖
|
| 🧹 **自动清理机制** | 定期清理过期缓存项(默认每 5 分钟) |
|
||||||
|
| 🧩 **隐私保护** | 默认剥离 ECS (EDNS Client Subnet),防止地理泄露 |
|
||||||
|
| 🧱 **黑名单过滤** | 支持域名黑名单(后缀匹配、文件或命令行加载) |
|
||||||
|
| 🪶 **轻量高效** | 单一可执行文件,无外部依赖,易于容器化部署 |
|
||||||
|
|
||||||
|
## 🏗️ 快速开始
|
||||||
|
|
||||||
## 📦 安装
|
### 从源码构建
|
||||||
|
|
||||||
### 🧰 源码构建
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.aixiao.me/aixiao/dot.git
|
git clone https://git.aixiao.me/aixiao/dot.git
|
||||||
@@ -24,98 +25,138 @@ cd dot
|
|||||||
go build -o dot main.go
|
go build -o dot main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🐳 Docker 构建
|
### 使用 Docker 构建与运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#构建、启动
|
# 构建镜像
|
||||||
bash build.sh build
|
bash build.sh build
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
bash build.sh run
|
bash build.sh run
|
||||||
|
|
||||||
#清理
|
# 查看日志
|
||||||
|
bash build.sh logs
|
||||||
|
|
||||||
|
# 停止与清理
|
||||||
bash build.sh stop
|
bash build.sh stop
|
||||||
bash build.sh clean
|
bash build.sh clean
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 🧩 默认镜像名为 `dot:latest`,监听 `853` 端口,可通过 `PORT` 环境变量修改。
|
||||||
|
|
||||||
## 🚀 启动服务
|
## ⚙️ 启动示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dot \
|
./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
|
||||||
-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
|
🚀 starting DNS-over-TLS on :853
|
||||||
[req] A www.example.com. (id=40192 cd=false do=true from=127.0.0.1:58877)
|
[req] A www.example.com. (id=40192 cd=false do=true from=127.0.0.1:58877)
|
||||||
[cache] MISS A www.example.com.
|
[cache] MISS A www.example.com.
|
||||||
[answer] www.example.com. 300 IN A 93.184.216.34
|
[answer] www.example.com. 300 IN A 93.184.216.34
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ⚙️ 参数说明
|
||||||
|
|
||||||
## 🧩 配置参数
|
| 参数 | 默认值 | 描述 |
|
||||||
|
|
||||||
| 参数 | 默认值 | 说明 |
|
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| `--addr` | `:853` | 监听地址 |
|
| `--addr` | `:853` | 监听地址(支持 IPv4/IPv6) |
|
||||||
| `--cert` | `server.crt` | TLS 证书路径 |
|
| `--cert` | `server.crt` | TLS 证书路径 |
|
||||||
| `--key` | `server.key` | TLS 私钥路径 |
|
| `--key` | `server.key` | TLS 私钥路径 |
|
||||||
| `--upstream` | `8.8.8.8:53,1.1.1.1:53` | 上游 DNS 服务器 |
|
| `--upstream` | `8.8.8.8:53,1.1.1.1:53` | 上游 DNS 服务器列表 |
|
||||||
| `--cache-ttl` | `60s` | 最大缓存 TTL |
|
| `--cache-ttl` | `60s` | 缓存最大 TTL(正向/负面均适用) |
|
||||||
| `--timeout` | `3s` | 上游查询超时 |
|
| `--timeout` | `3s` | 上游查询超时 |
|
||||||
| `--max-parallel` | `3` | 并发上游查询数 |
|
| `--max-parallel` | `3` | 最大并发上游查询数 |
|
||||||
| `--strip-ecs` | `true` | 是否剥离 ECS 信息 |
|
| `--strip-ecs` | `true` | 是否剥离 ECS 信息 |
|
||||||
| `--tcp-fallback` | `true` | 是否启用 TCP 回退 |
|
| `--tcp-fallback` | `true` | UDP 截断时是否自动 TCP 回退 |
|
||||||
| `--v` | `false` | 详细日志模式 |
|
| `--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
|
```bash
|
||||||
kdig @127.0.0.1 +tls-ca +tls-host=dot.local www.example.com
|
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
|
**License:** MIT
|
||||||
**Language:** Go 1.22+
|
**Repository:** [git.aixiao.me/aixiao/dot](https://git.aixiao.me/aixiao/dot)
|
||||||
**Dependency:** [github.com/miekg/dns](https://github.com/miekg/dns)
|
|
||||||
|
|||||||
188
blacklist.go
Normal file
188
blacklist.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
1
blacklist.txt
Normal file
1
blacklist.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.baidu.com
|
||||||
120
main.go
120
main.go
@@ -37,6 +37,11 @@ func initLogger(verbose bool) {
|
|||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
msg *dns.Msg
|
msg *dns.Msg
|
||||||
expireAt time.Time
|
expireAt time.Time
|
||||||
|
// EDNS metadata (to reproduce Extended RCODE / EDE on cache hits)
|
||||||
|
ednsPresent bool
|
||||||
|
ednsVersion uint8
|
||||||
|
ednsExtRcode uint16
|
||||||
|
ednsEDE []*dns.EDNS0_EDE
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -92,7 +97,8 @@ func startCacheCleaner(ctx context.Context) {
|
|||||||
func cacheKeyFromMsg(q dns.Question, do, cd bool) string {
|
func cacheKeyFromMsg(q dns.Question, do, cd bool) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.Grow(len(q.Name) + 32)
|
b.Grow(len(q.Name) + 32)
|
||||||
b.WriteString(strings.ToLower(q.Name))
|
// 采用规范化域名,避免尾随点/IDNA/大小写造成的重复键
|
||||||
|
b.WriteString(dns.CanonicalName(q.Name))
|
||||||
b.WriteString("|T=")
|
b.WriteString("|T=")
|
||||||
b.WriteString(dns.TypeToString[q.Qtype])
|
b.WriteString(dns.TypeToString[q.Qtype])
|
||||||
b.WriteString("|C=")
|
b.WriteString("|C=")
|
||||||
@@ -115,20 +121,43 @@ func isPseudo(rr dns.RR) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取缓存(修复:Get 在写锁下;在锁外调整 TTL)
|
// clone and extract EDNS metadata (present, version, ext-rcode, all EDEs)
|
||||||
func tryCacheRead(key string) (*dns.Msg, bool) {
|
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()
|
now := time.Now()
|
||||||
|
|
||||||
cacheMutex.Lock()
|
cacheMutex.Lock()
|
||||||
e, ok := cache.Get(key) // Get 会更新 LRU,必须在写锁下
|
e, ok := cache.Get(key) // Get 会更新 LRU,必须在写锁下
|
||||||
if !ok {
|
if !ok {
|
||||||
cacheMutex.Unlock()
|
cacheMutex.Unlock()
|
||||||
return nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
if now.After(e.expireAt) {
|
if now.After(e.expireAt) {
|
||||||
cache.Remove(key)
|
cache.Remove(key)
|
||||||
cacheMutex.Unlock()
|
cacheMutex.Unlock()
|
||||||
return nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
// 拷贝副本,在锁外改 TTL,减少临界区时间
|
// 拷贝副本,在锁外改 TTL,减少临界区时间
|
||||||
out := e.msg.Copy()
|
out := e.msg.Copy()
|
||||||
@@ -140,7 +169,7 @@ func tryCacheRead(key string) (*dns.Msg, bool) {
|
|||||||
cacheMutex.Lock()
|
cacheMutex.Lock()
|
||||||
cache.Remove(key)
|
cache.Remove(key)
|
||||||
cacheMutex.Unlock()
|
cacheMutex.Unlock()
|
||||||
return nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sec := range [][]dns.RR{out.Answer, out.Ns, out.Extra} {
|
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
|
// 计算负面 TTL
|
||||||
@@ -197,7 +226,6 @@ func negativeTTL(m *dns.Msg, maxTTL time.Duration) (uint32, bool) {
|
|||||||
}
|
}
|
||||||
if soa == nil {
|
if soa == nil {
|
||||||
// 建议:无 SOA 时不做负面缓存(返回 0,false)
|
// 建议:无 SOA 时不做负面缓存(返回 0,false)
|
||||||
// 如你更希望兜底,可改成:return uint32(maxTTL.Seconds()), true
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +244,8 @@ func negativeTTL(m *dns.Msg, maxTTL time.Duration) (uint32, bool) {
|
|||||||
func minRRsetTTL(m *dns.Msg) (uint32, bool) {
|
func minRRsetTTL(m *dns.Msg) (uint32, bool) {
|
||||||
minTTL := uint32(0)
|
minTTL := uint32(0)
|
||||||
hasTTL := false
|
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 {
|
for _, rr := range sec {
|
||||||
if isPseudo(rr) {
|
if isPseudo(rr) {
|
||||||
continue
|
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
|
return minTTL, hasTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +286,7 @@ func stripPseudoExtras(m *dns.Msg) {
|
|||||||
m.Extra = out
|
m.Extra = out
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写缓存
|
// 写缓存(保存 EDNS 元数据,命中时可重建扩展 RCODE/EDE)
|
||||||
func cacheWrite(key string, in *dns.Msg, maxTTL time.Duration) {
|
func cacheWrite(key string, in *dns.Msg, maxTTL time.Duration) {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return
|
return
|
||||||
@@ -272,9 +313,18 @@ func cacheWrite(key string, in *dns.Msg, maxTTL time.Duration) {
|
|||||||
}
|
}
|
||||||
expire := time.Now().Add(time.Duration(ttl) * time.Second)
|
expire := time.Now().Add(time.Duration(ttl) * time.Second)
|
||||||
cp := in.Copy()
|
cp := in.Copy()
|
||||||
|
// 提取 EDNS 元数据后再剥离伪记录
|
||||||
|
present, ver, ext, ede := extractEDNSMeta(cp)
|
||||||
stripPseudoExtras(cp)
|
stripPseudoExtras(cp)
|
||||||
cacheMutex.Lock()
|
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()
|
cacheMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +451,7 @@ func getDOFlag(m *dns.Msg) bool {
|
|||||||
return false
|
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 {
|
if upstream == nil {
|
||||||
dns.HandleFailed(w, req)
|
dns.HandleFailed(w, req)
|
||||||
return
|
return
|
||||||
@@ -439,6 +489,13 @@ func writeReply(w dns.ResponseWriter, req, upstream *dns.Msg) {
|
|||||||
o.Option = append(o.Option, ede)
|
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)
|
extras = append(extras, o)
|
||||||
}
|
}
|
||||||
@@ -459,6 +516,8 @@ func handleDNS(
|
|||||||
maxParallel int,
|
maxParallel int,
|
||||||
stripECSBeforeForward bool,
|
stripECSBeforeForward bool,
|
||||||
allowTCPFallback bool,
|
allowTCPFallback bool,
|
||||||
|
bl *suffixMatcher,
|
||||||
|
blRcode int,
|
||||||
) dns.HandlerFunc {
|
) dns.HandlerFunc {
|
||||||
return func(w dns.ResponseWriter, r *dns.Msg) {
|
return func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
if len(r.Question) == 0 {
|
if len(r.Question) == 0 {
|
||||||
@@ -472,11 +531,21 @@ func handleDNS(
|
|||||||
if stripECSBeforeForward {
|
if stripECSBeforeForward {
|
||||||
stripECS(r)
|
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)
|
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)
|
log.Printf("[cache] HIT %s %s", dns.TypeToString[q.Qtype], q.Name)
|
||||||
writeReply(w, r, cached)
|
writeReply(w, r, cachedMsg, cachedMeta)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[cache] MISS %s %s", dns.TypeToString[q.Qtype], q.Name)
|
log.Printf("[cache] MISS %s %s", dns.TypeToString[q.Qtype], q.Name)
|
||||||
@@ -492,7 +561,7 @@ func handleDNS(
|
|||||||
for _, ans := range resp.Answer {
|
for _, ans := range resp.Answer {
|
||||||
log.Printf("[answer] %s", ans.String())
|
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, "并发上游数量")
|
maxParallel := flag.Int("max-parallel", 3, "并发上游数量")
|
||||||
stripECSFlag := flag.Bool("strip-ecs", true, "去除 ECS")
|
stripECSFlag := flag.Bool("strip-ecs", true, "去除 ECS")
|
||||||
allowTCPFallback := flag.Bool("tcp-fallback", true, "UDP 截断时 TCP 回退")
|
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 日志")
|
verbose := flag.Bool("v", false, "verbose 日志")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -548,8 +620,20 @@ func main() {
|
|||||||
|
|
||||||
startCacheCleaner(ctx)
|
startCacheCleaner(ctx)
|
||||||
|
|
||||||
|
// 加载黑名单规则
|
||||||
|
bl, blRcode := initBlacklist(ctx, *blacklistStr, *blacklistFile, *blacklistRcodeFlag)
|
||||||
|
|
||||||
mux := dns.NewServeMux()
|
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{
|
srv := &dns.Server{
|
||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
@@ -570,8 +654,8 @@ func main() {
|
|||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("🚀 starting DNS-over-TLS on %s", *addr)
|
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",
|
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)
|
upstreams, cacheTTLFlag.String(), *cacheSizeFlag, timeoutFlag.String(), *maxParallel, *stripECSFlag, *allowTCPFallback, len(bl.rules), strings.ToUpper(*blacklistRcodeFlag))
|
||||||
errCh <- srv.ListenAndServe()
|
errCh <- srv.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user