feat(blacklist): 实现基于Trie树的高性能黑名单匹配系统
重构黑名单匹配算法,采用Trie前缀树数据结构替换原有的后缀匹配, 将百万级域名匹配复杂度从O(n)降至O(L),显著提升性能。 同时优化黑名单文件加载机制,支持hosts格式和通配符匹配, 并实现文件修改自动重载功能,提升系统的灵活性和实用性。 refactor: 重构README文档结构和内容展示 更新项目介绍文档,优化整体布局结构,添加项目徽章标识, 精简功能特性描述,改进快速开始指南,提供更清晰的使用说明。 chore(deps): 更新项目依赖库至最新版本 升级github.com/miekg/dns至v1.1.72版本, 更新golang.org/x/net至v0.52.0版本, 升级golang.org/x/sync至v0.20.0版本, 以及其他相关依赖库的版本更新。
This commit is contained in:
191
README.md
191
README.md
@@ -1,50 +1,47 @@
|
|||||||
# DNS-over-TLS Cache Proxy
|
# 🚀 Go-DoT: 高性能 DNS-over-TLS 缓存代理
|
||||||
|
|
||||||
一个用 **Go** 编写的高性能 **DNS-over-TLS (DoT)** 缓存代理服务,
|
[](https://golang.org)
|
||||||
专为隐私保护与性能优化而设计。支持多上游并发解析、智能缓存、ECS 剥离和优雅关闭。
|
[](LICENSE)
|
||||||
|
[](https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_1.3)
|
||||||
|
|
||||||
## 🚀 特性概览
|
**Go-DoT** 是一个用 Go 语言编写的生产级 **DNS-over-TLS (DoT)** 缓存代理服务器,专注于**隐私保护、极致性能与高可用性**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 特性概览
|
||||||
|
|
||||||
| 功能 | 描述 |
|
| 功能 | 描述 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 🔒 **加密传输** | 完全支持 DNS-over-TLS(RFC 7858),支持 TLS 1.2/1.3 |
|
| 🔒 加密传输 | 支持 RFC 7858,默认强制 TLS 1.3 |
|
||||||
| ⚡ **多上游并发解析** | “快乐眼球”机制并行查询多个上游 DNS,取最快响应 |
|
| ⚡ 高效匹配 | 基于 Trie(前缀树)实现百万级黑名单匹配,复杂度 O(L) |
|
||||||
| 🧠 **智能缓存系统** | 支持正向与负面缓存(RFC 2308),动态 TTL 调整 |
|
| 🧠 请求合并 | 使用 Singleflight 防止缓存击穿 |
|
||||||
| 🧹 **自动清理机制** | 定期清理过期缓存项(默认每 5 分钟) |
|
| 🚀 并发查询 | 并行请求上游 DNS,返回最快响应 |
|
||||||
| 🧩 **隐私保护** | 默认剥离 ECS (EDNS Client Subnet),防止地理泄露 |
|
| 📦 智能缓存 | 支持正向缓存 + NXDOMAIN 负缓存(LRU) |
|
||||||
| 🧱 **黑名单过滤** | 支持域名黑名单(后缀匹配、文件或命令行加载) |
|
| 🧹 优雅退出 | 支持 SIGTERM 信号 |
|
||||||
| 🪶 **轻量高效** | 单一可执行文件,无外部依赖,易于容器化部署 |
|
| 🧩 隐私保护 | 默认移除 ECS(EDNS Client Subnet) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🏗️ 快速开始
|
## 🏗️ 快速开始
|
||||||
|
|
||||||
### 从源码构建
|
### 1. 编译构建
|
||||||
|
|
||||||
|
需要 Go 1.22 或更高版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.aixiao.me/aixiao/dot.git
|
git clone https://git.aixiao.me/aixiao/dot.git
|
||||||
cd dot
|
cd dot
|
||||||
bash build.sh bin
|
go mod tidy
|
||||||
|
go build -ldflags="-s -w -X 'main.BuildDate=$(date)'" -o dot
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使用 Docker 构建与运行
|
### 2. 生成 TLS 证书(测试用)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建镜像
|
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes -subj "/CN=dot.local"
|
||||||
bash build.sh build
|
|
||||||
|
|
||||||
# 运行容器
|
|
||||||
bash build.sh run
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
bash build.sh logs
|
|
||||||
|
|
||||||
# 停止与清理
|
|
||||||
bash build.sh stop
|
|
||||||
bash build.sh clean
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 🧩 默认镜像名为 `dot:latest`,监听 `853` 端口,可通过 `PORT` 环境变量修改。
|
### 3. 启动服务
|
||||||
|
|
||||||
## ⚙️ 启动示例
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dot \
|
./dot \
|
||||||
@@ -52,119 +49,79 @@ bash build.sh clean
|
|||||||
-key aixiao.me.key \
|
-key aixiao.me.key \
|
||||||
-addr :853 \
|
-addr :853 \
|
||||||
-upstream 119.29.29.29:53,223.5.5.5:53,114.114.114.114:53 \
|
-upstream 119.29.29.29:53,223.5.5.5:53,114.114.114.114:53 \
|
||||||
-cache-ttl 300s \
|
-blacklist-file blacklist.txt \
|
||||||
-timeout 3s \
|
-v
|
||||||
-max-parallel 3 \
|
|
||||||
-blacklist-file blacklist.txt
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
启动日志示例:
|
---
|
||||||
|
|
||||||
```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` | 监听地址(支持 IPv4/IPv6) |
|
| -addr | :853 | DoT 监听地址 |
|
||||||
| `--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(正向/负面均适用) |
|
| -blacklist-file | 空 | 黑名单文件路径 |
|
||||||
| `--timeout` | `3s` | 上游查询超时 |
|
| -blacklist-rcode | REFUSED | 命中黑名单返回码:REFUSED / NXDOMAIN / SERVFAIL |
|
||||||
| `--max-parallel` | `3` | 最大并发上游查询数 |
|
| -v | false | 启用详细日志模式 |
|
||||||
| `--strip-ecs` | `true` | 是否剥离 ECS 信息 |
|
|
||||||
| `--tcp-fallback` | `true` | UDP 截断时是否自动 TCP 回退 |
|
|
||||||
| `--blacklist` | 空 | 逗号分隔的黑名单域名或通配后缀 |
|
|
||||||
| `--blacklist-file` | 空 | 黑名单文件路径(每行一个规则) |
|
|
||||||
| `--blacklist-rcode` | `REFUSED` | 黑名单命中返回码:`REFUSED` / `NXDOMAIN` / `SERVFAIL` |
|
|
||||||
| `--cache-size` | `10000` | LRU 缓存最大条目数 |
|
|
||||||
| `--v` | `false` | 启用详细日志模式 |
|
|
||||||
|
|
||||||
## 🔍 缓存机制详解
|
---
|
||||||
|
|
||||||
**缓存键格式:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
domain|type|class|DO|CD
|
|
||||||
```
|
|
||||||
|
|
||||||
**缓存策略:**
|
|
||||||
|
|
||||||
- ✅ **正向缓存**:取最小 TTL 与配置上限的较小值
|
|
||||||
- 🚫 **负面缓存**:遵循 RFC 2308,从 SOA.MINIMUM 计算 TTL
|
|
||||||
- 🧭 **动态 TTL 调整**:返回时按剩余时间递减 TTL
|
|
||||||
- 🧹 **自动清理**:每 5 分钟扫描并删除过期条目
|
|
||||||
- 🔒 **隔离逻辑**:DO/CD 不同查询独立缓存空间
|
|
||||||
|
|
||||||
## 🧱 黑名单功能
|
## 🧱 黑名单功能
|
||||||
|
|
||||||
支持两种配置方式:
|
支持 hosts + 通配符格式:
|
||||||
|
|
||||||
1. 命令行参数:
|
```text
|
||||||
|
# 注释
|
||||||
|
ad.doubleclick.net
|
||||||
|
*.tracking.com
|
||||||
|
127.0.0.1 malicious-site.io
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
命中后直接返回指定 RCODE,并附带 EDE 说明。
|
||||||
./dot -blacklist="*.ads.com,*.tracking.net"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 文件加载(每行一个域名或后缀):
|
---
|
||||||
|
|
||||||
```sh
|
## 🔍 缓存机制
|
||||||
# blacklist.txt
|
|
||||||
*.ads.com
|
|
||||||
*.malware.net
|
|
||||||
```
|
|
||||||
|
|
||||||
启动命令:
|
- 缓存键: Type|DO|CD|ECS|Domain 组合键,确保不同请求策略的结果物理隔离。
|
||||||
|
|
||||||
```bash
|
- TTL 策略:
|
||||||
./dot -blacklist-file=blacklist.txt -blacklist-rcode=NXDOMAIN
|
- ✅ 正向缓存: 取记录中最小 TTL 与配置上限的较小值。
|
||||||
```
|
- 🚫 负面缓存: 遵循 RFC 2308,自动计算 SOA 的最小 TTL 进行缓存。
|
||||||
|
|
||||||
黑名单命中后不再上游查询,直接返回指定 RCODE。
|
---
|
||||||
|
|
||||||
## 🧩 架构与运行原理
|
## 🧩 工作流程
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Client (DoT Request)] --> B[DNS-over-TLS Server]
|
A[客户端请求] --> B{黑名单匹配}
|
||||||
B --> C[Cache Lookup]
|
B -- 命中 --> C[返回拦截]
|
||||||
C -- HIT --> D[Return Cached Response]
|
B -- 未命中 --> D{缓存命中}
|
||||||
C -- MISS --> E[Upstream Resolver Pool]
|
D -- 命中 --> E[返回缓存]
|
||||||
E -->|Fastest Response| F[DNS Response]
|
D -- 未命中 --> F[Singleflight]
|
||||||
F --> G[Cache Write]
|
F --> G[并发查询上游]
|
||||||
G --> H[Return to Client]
|
G --> H[写入缓存]
|
||||||
|
H --> I[返回结果]
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧰 开发与维护
|
---
|
||||||
|
|
||||||
- 语言:**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
|
- 提高文件描述符限制:ulimit -n 65535
|
||||||
go build -ldflags="-s -w" -o dot main.go
|
- 使用合法 CA 证书(Android/iOS 必须)
|
||||||
```
|
- 关闭 -v 日志提升性能
|
||||||
|
- 配置 ≥3 个上游 DNS
|
||||||
|
|
||||||
## 🧪 测试方法
|
---
|
||||||
|
|
||||||
使用 `kdig` 或 `dig` 测试解析:
|
## 👨💻 作者
|
||||||
|
|
||||||
```bash
|
- Author: niuyuling
|
||||||
kdig @127.0.0.1 +tls-ca +tls-host=dot.local www.example.com
|
- Email: aixiao@aixiao.me
|
||||||
```
|
- License: MIT
|
||||||
|
- Repository: https://git.aixiao.me/aixiao/dot
|
||||||
## 👨💻 作者信息
|
|
||||||
|
|
||||||
**Author:** niuyuling
|
|
||||||
**Email:** [aixiao@aixiao.me](mailto:aixiao@aixiao.me)
|
|
||||||
**License:** MIT
|
|
||||||
**Repository:** [git.aixiao.me/aixiao/dot](https://git.aixiao.me/aixiao/dot)
|
|
||||||
|
|||||||
272
blacklist.go
272
blacklist.go
@@ -6,214 +6,146 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"golang.org/x/net/idna"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------- Blacklist helpers (独立文件) --------
|
type BlacklistTrie struct {
|
||||||
|
root *trieNode
|
||||||
// 将输入域名规范化为 canonical FQDN(去空格、去 *. 前缀、统一大小写/尾点)
|
|
||||||
func canonicalFQDN(s string) string {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// 允许黑名单写 "*.example.com";内部匹配用裸后缀
|
|
||||||
s = strings.TrimPrefix(s, "*.")
|
|
||||||
|
|
||||||
// 先把可能的中文/Unicode 域名转成 ASCII(punycode),再规范化
|
|
||||||
if a, err := idna.Lookup.ToASCII(s); err == nil {
|
|
||||||
s = a
|
|
||||||
}
|
|
||||||
// CanonicalName 会做小写化与尾点规范化
|
|
||||||
return dns.CanonicalName(s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func uniqueStrings(in []string) []string {
|
type trieNode struct {
|
||||||
seen := make(map[string]struct{}, len(in))
|
children map[string]*trieNode
|
||||||
out := make([]string, 0, len(in))
|
isEnd bool
|
||||||
for _, s := range in {
|
rule string
|
||||||
if s == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[s]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[s] = struct{}{}
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写
|
func newTrie() *BlacklistTrie {
|
||||||
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写;支持 hosts 风格(首列为 IP)
|
return &BlacklistTrie{root: &trieNode{children: make(map[string]*trieNode)}}
|
||||||
func loadBlacklistFile(path string) ([]string, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
sc := bufio.NewScanner(f)
|
|
||||||
// 默认 64KB 容量不够稳妥,这里放大到 2MB,兼容一些合并的大 hosts 列表
|
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
|
||||||
|
|
||||||
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 line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// hosts 风格:第一个字段是 IP,则其余每个字段视为域名
|
|
||||||
start := 0
|
|
||||||
if net.ParseIP(fields[0]) != nil {
|
|
||||||
start = 1
|
|
||||||
}
|
|
||||||
for _, tok := range fields[start:] {
|
|
||||||
if r := canonicalFQDN(tok); r != "" {
|
|
||||||
rules = append(rules, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := sc.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(rules)
|
|
||||||
rules = uniqueStrings(rules)
|
|
||||||
return rules, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动重载黑名单
|
func (t *BlacklistTrie) Add(domain string) {
|
||||||
func startBlacklistReloader(ctx context.Context, path string, interval time.Duration, holder *atomic.Pointer[suffixMatcher]) {
|
name := strings.TrimSuffix(strings.ToLower(domain), ".")
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
curr := t.root
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
p := parts[i]
|
||||||
|
if _, ok := curr.children[p]; !ok {
|
||||||
|
curr.children[p] = &trieNode{children: make(map[string]*trieNode)}
|
||||||
|
}
|
||||||
|
curr = curr.children[p]
|
||||||
|
}
|
||||||
|
curr.isEnd = true
|
||||||
|
curr.rule = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BlacklistTrie) Match(domain string) (string, bool) {
|
||||||
|
name := strings.TrimSuffix(strings.ToLower(domain), ".")
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
curr := t.root
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
p := parts[i]
|
||||||
|
next, ok := curr.children[p]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
curr = next
|
||||||
|
if curr.isEnd {
|
||||||
|
return curr.rule, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func initBlacklist(ctx context.Context, path, rcodeStr string) (*atomic.Pointer[BlacklistTrie], int) {
|
||||||
|
var holder atomic.Pointer[BlacklistTrie]
|
||||||
|
var lastMod time.Time
|
||||||
|
rcode := parseRcode(rcodeStr)
|
||||||
|
|
||||||
|
load := func() {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[blacklist] Stat error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 如果文件没动,不加载
|
||||||
|
if !info.ModTime().After(lastMod) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
trie := newTrie()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
count := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || line[0] == '#' || line[0] == ';' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
domain := fields[0]
|
||||||
|
if net.ParseIP(domain) != nil && len(fields) > 1 {
|
||||||
|
domain = fields[1]
|
||||||
|
}
|
||||||
|
trie.Add(domain)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
holder.Store(trie)
|
||||||
|
lastMod = info.ModTime()
|
||||||
|
log.Printf("[blacklist] Loaded %d rules (updated: %v)", count, lastMod.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
if path != "" {
|
||||||
go func() {
|
go func() {
|
||||||
var lastMod time.Time
|
// 缩短检查间隔,但因为有文件时间校验,所以不费 CPU
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
load()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
|
||||||
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
|
|
||||||
}
|
|
||||||
holder.Store(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 {
|
return &holder, rcode
|
||||||
if name == r || strings.HasSuffix(name, "."+r) {
|
|
||||||
return r, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 RCODE 文本到常量
|
|
||||||
func parseRcode(s string) int {
|
func parseRcode(s string) int {
|
||||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
switch strings.ToUpper(s) {
|
||||||
case "REFUSED", "":
|
|
||||||
return dns.RcodeRefused
|
|
||||||
case "NXDOMAIN":
|
case "NXDOMAIN":
|
||||||
return dns.RcodeNameError
|
return dns.RcodeNameError
|
||||||
case "SERVFAIL":
|
case "SERVFAIL":
|
||||||
return dns.RcodeServerFailure
|
return dns.RcodeServerFailure
|
||||||
default:
|
default:
|
||||||
log.Printf("[blacklist] unknown rcode %q, fallback REFUSED", s)
|
|
||||||
return dns.RcodeRefused
|
return dns.RcodeRefused
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造“伪上游”阻断响应;带 EDE=Blocked(15) 说明命中规则
|
func makeBlockedMsg(rcode int, rule string) *dns.Msg {
|
||||||
func makeBlockedUpstream(rcode int, rule string) *dns.Msg {
|
|
||||||
m := new(dns.Msg)
|
m := new(dns.Msg)
|
||||||
m.RecursionAvailable = true
|
|
||||||
m.AuthenticatedData = false
|
|
||||||
m.Rcode = rcode
|
m.Rcode = rcode
|
||||||
opt := &dns.OPT{}
|
m.RecursionAvailable = true
|
||||||
opt.Hdr.Name = "."
|
o := new(dns.OPT)
|
||||||
opt.Hdr.Rrtype = dns.TypeOPT
|
o.Hdr.Name = "."
|
||||||
ede := &dns.EDNS0_EDE{InfoCode: 15, ExtraText: "blocked by policy: " + rule}
|
o.Hdr.Rrtype = dns.TypeOPT
|
||||||
opt.Option = append(opt.Option, ede)
|
o.Option = append(o.Option, &dns.EDNS0_EDE{InfoCode: 15, ExtraText: "Blocked by policy"})
|
||||||
m.Extra = append(m.Extra, opt)
|
m.Extra = append(m.Extra, o)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func initBlacklist(ctx context.Context, listStr, filePath, rcodeStr string) (*atomic.Pointer[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 filePath != "" {
|
|
||||||
if fs, err := loadBlacklistFile(filePath); err != nil {
|
|
||||||
log.Printf("[blacklist] load file error: %v", err)
|
|
||||||
} else {
|
|
||||||
rules = append(rules, fs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var holder atomic.Pointer[suffixMatcher]
|
|
||||||
holder.Store(newSuffixMatcher(rules))
|
|
||||||
blRcode := parseRcode(rcodeStr)
|
|
||||||
if filePath != "" {
|
|
||||||
startBlacklistReloader(ctx, filePath, 30*time.Second, &holder)
|
|
||||||
}
|
|
||||||
log.Printf("[blacklist] loaded %d rules (file=%v, rcode=%s)", len(holder.Load().rules), filePath != "", strings.ToUpper(rcodeStr))
|
|
||||||
return &holder, blRcode
|
|
||||||
}
|
|
||||||
|
|||||||
13
go.mod
13
go.mod
@@ -4,15 +4,14 @@ go 1.25.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/miekg/dns v1.1.68
|
github.com/miekg/dns v1.1.72
|
||||||
golang.org/x/net v0.46.0
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.17.0
|
golang.org/x/sync v0.20.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -4,15 +4,43 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||||
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
|||||||
Reference in New Issue
Block a user