```
docs(readme): 重构 README 文档结构与内容以提升可读性 - 更新项目标题图标并优化描述语句 - 重新组织特性列表为表格形式,增加 ECS 剥离、黑名单过滤等功能说明 - 补充快速开始章节,细化源码构建与 Docker 使用方式 - 调整参数说明表,新增黑名单相关配置项及缓存条目限制 - 增加缓存机制详解、黑名单功能使用示例与架构图 - 更新开发依赖信息与推荐编译参数 - 修正作者信息展示格式并添加仓库链接 feat(cache): 改进缓存键生成逻辑与 EDNS 元数据处理 - 使用 dns.CanonicalName 规范化域名避免重复缓存键 - 缓存条目中保存 EDNS 扩展信息(version, rcode, EDE) - 修复缓存读取函数返回值,传递完整缓存元数据 - 调整 TTL 计算优先级,仅在必要时检查 Extra 区域 - 黑名单匹配提前拦截请求,跳过上游查询 - 启动日志中显示黑名单规则数量与返回码设置 ```
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user