feat(blacklist): 支持国际化域名与 hosts 文件风格黑名单
- 引入 `golang.org/x/net/idna` 实现 Unicode 域名转 ASCII(Punycode) - 黑名单加载支持通配符格式如 `*.example.com` - 支持解析 hosts 风格的文件(每行首列为 IP 地址时,其余列为域名) - 扩展 Scanner 缓冲区至 2MB 以适应大型 hosts 文件 - 注释处理优化,兼容 `#` 和 `;` 分隔符 - 加载后对规则排序并去重,提升匹配效率与一致性 fix(cache): 调整负面响应缓存逻辑与上游查询并发控制 - 明确区分 NXDOMAIN 与 NODATA 并正确处理 SOA 缺失情况 - 查询上游时引入更可靠的并发限制与超时机制 - UDP 截断时自动回退 TCP 查询 - 过滤无效 RCODE(如 SERVFAIL、REFUSED 等),防止污染结果 - 区分“全部失败”与“部分完成但无有效响应”,增强调试日志信息
This commit is contained in:
37
blacklist.go
37
blacklist.go
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
"golang.org/x/net/idna"
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------- Blacklist helpers (独立文件) --------
|
// -------- Blacklist helpers (独立文件) --------
|
||||||
@@ -22,7 +23,14 @@ func canonicalFQDN(s string) string {
|
|||||||
if s == "" {
|
if s == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
// 允许黑名单写 "*.example.com";内部匹配用裸后缀
|
||||||
s = strings.TrimPrefix(s, "*.")
|
s = strings.TrimPrefix(s, "*.")
|
||||||
|
|
||||||
|
// 先把可能的中文/Unicode 域名转成 ASCII(punycode),再规范化
|
||||||
|
if a, err := idna.Lookup.ToASCII(s); err == nil {
|
||||||
|
s = a
|
||||||
|
}
|
||||||
|
// CanonicalName 会做小写化与尾点规范化
|
||||||
return dns.CanonicalName(s)
|
return dns.CanonicalName(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,38 +51,38 @@ func uniqueStrings(in []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写
|
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写
|
||||||
|
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写;支持 hosts 风格(首列为 IP)
|
||||||
func loadBlacklistFile(path string) ([]string, error) {
|
func loadBlacklistFile(path string) ([]string, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
sc := bufio.NewScanner(f)
|
sc := bufio.NewScanner(f)
|
||||||
|
// 默认 64KB 容量不够稳妥,这里放大到 2MB,兼容一些合并的大 hosts 列表
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
||||||
|
|
||||||
var rules []string
|
var rules []string
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
line := strings.TrimSpace(sc.Text())
|
line := strings.TrimSpace(sc.Text())
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 行首注释
|
// 去掉注释(# 或 ; 之后的内容)
|
||||||
if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 行内注释:先 // 再 # ;
|
|
||||||
if i := strings.Index(line, "//"); i >= 0 {
|
|
||||||
line = strings.TrimSpace(line[:i])
|
|
||||||
}
|
|
||||||
if i := strings.IndexAny(line, "#;"); i >= 0 {
|
if i := strings.IndexAny(line, "#;"); i >= 0 {
|
||||||
line = strings.TrimSpace(line[:i])
|
line = strings.TrimSpace(line[:i])
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// hosts 风格:第一个字段是 IP,则其余每个字段视为域名
|
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hosts 风格:第一个字段是 IP,则其余每个字段视为域名
|
||||||
start := 0
|
start := 0
|
||||||
if net.ParseIP(fields[0]) != nil {
|
if net.ParseIP(fields[0]) != nil {
|
||||||
start = 1
|
start = 1
|
||||||
@@ -88,7 +96,10 @@ func loadBlacklistFile(path string) ([]string, error) {
|
|||||||
if err := sc.Err(); err != nil {
|
if err := sc.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return uniqueStrings(rules), nil
|
|
||||||
|
sort.Strings(rules)
|
||||||
|
rules = uniqueStrings(rules)
|
||||||
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动重载黑名单
|
// 自动重载黑名单
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -5,13 +5,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.68
|
||||||
|
golang.org/x/net v0.46.0
|
||||||
golang.org/x/sync v0.17.0
|
golang.org/x/sync v0.17.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.29.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -12,5 +12,7 @@ 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/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/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
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=
|
||||||
|
|||||||
69
main.go
69
main.go
@@ -343,7 +343,18 @@ func cacheWrite(key string, in *dns.Msg, maxTTL time.Duration) {
|
|||||||
}
|
}
|
||||||
var ttl uint32
|
var ttl uint32
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|
||||||
|
// 判断负面响应(NXDOMAIN 或 NODATA)
|
||||||
|
neg, isNodata := in.Rcode == dns.RcodeNameError, false
|
||||||
|
if in.Rcode == dns.RcodeSuccess && len(in.Question) > 0 && !hasAnswerForType(in, in.Question[0]) {
|
||||||
|
isNodata = true
|
||||||
|
}
|
||||||
|
|
||||||
if ttl, ok = negativeTTL(in, maxTTL); !ok {
|
if ttl, ok = negativeTTL(in, maxTTL); !ok {
|
||||||
|
if neg || isNodata {
|
||||||
|
return // 负面但无 SOA → 不缓存
|
||||||
|
}
|
||||||
|
|
||||||
minTTL, has := minRRsetTTL(in)
|
minTTL, has := minRRsetTTL(in)
|
||||||
if has {
|
if has {
|
||||||
cfgTTL := uint32(maxTTL.Seconds())
|
cfgTTL := uint32(maxTTL.Seconds())
|
||||||
@@ -419,61 +430,85 @@ func queryUpstreamsLimited(
|
|||||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
type result struct{ msg *dns.Msg }
|
type result struct {
|
||||||
|
msg *dns.Msg
|
||||||
|
}
|
||||||
ch := make(chan result, len(servers))
|
ch := make(chan result, len(servers))
|
||||||
|
done := make(chan struct{}, len(servers))
|
||||||
sem := make(chan struct{}, maxParallel)
|
sem := make(chan struct{}, maxParallel)
|
||||||
|
|
||||||
|
// 单个上游执行
|
||||||
execOne := func(svr string) {
|
execOne := func(svr string) {
|
||||||
upReq := clampEDNSForUpstream(req, 1232) // 采用 1232 降低分片风险
|
// 并发限流(可被超时取消)
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-cctx.Done():
|
||||||
|
// 超时/取消,直接放弃
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { done <- struct{}{} }()
|
||||||
|
|
||||||
|
// 为 UDP 上游把 EDNS UDP size 夹到 1232,降低分片风险
|
||||||
|
upReq := clampEDNSForUpstream(req, 1232)
|
||||||
|
|
||||||
|
// 先走 UDP
|
||||||
resp, _, err := udpClient.ExchangeContext(cctx, upReq, svr)
|
resp, _, err := udpClient.ExchangeContext(cctx, upReq, svr)
|
||||||
|
// 截断且允许回退则走 TCP
|
||||||
if err == nil && resp != nil && resp.Truncated && allowTCPFallback {
|
if err == nil && resp != nil && resp.Truncated && allowTCPFallback {
|
||||||
log.Printf("[upstream] UDP truncated, retry TCP: %s", svr)
|
log.Printf("[upstream] UDP truncated, retry TCP: %s", svr)
|
||||||
tcpClient := *udpClient
|
tcpClient := *udpClient
|
||||||
tcpClient.Net = "tcp"
|
tcpClient.Net = "tcp"
|
||||||
|
|
||||||
resp, _, err = tcpClient.ExchangeContext(cctx, req.Copy(), svr)
|
resp, _, err = tcpClient.ExchangeContext(cctx, req.Copy(), svr)
|
||||||
}
|
}
|
||||||
|
// 失败直接返回(但不写入 ch);只在未超时情况下打印错误
|
||||||
if err != nil || resp == nil {
|
if err != nil || resp == nil {
|
||||||
if err != nil && cctx.Err() == nil {
|
if err != nil && cctx.Err() == nil {
|
||||||
log.Printf("[upstream] %s error: %v", svr, err)
|
log.Printf("[upstream] %s: %v", svr, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 丢弃对客户端无意义/不可靠的错误
|
// 过滤不可用的错误 RCODE(避免造成“假性超时”的错觉)
|
||||||
if resp.Rcode == dns.RcodeServerFailure || resp.Rcode == dns.RcodeRefused || resp.Rcode == dns.RcodeFormatError {
|
if resp.Rcode == dns.RcodeServerFailure ||
|
||||||
|
resp.Rcode == dns.RcodeRefused ||
|
||||||
|
resp.Rcode == dns.RcodeFormatError {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 投递可用结果(若已经超时则丢弃)
|
||||||
select {
|
select {
|
||||||
case ch <- result{msg: resp}:
|
case ch <- result{msg: resp}:
|
||||||
case <-cctx.Done():
|
case <-cctx.Done():
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 并发发起
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
s := s
|
s := s
|
||||||
go func() {
|
go execOne(s)
|
||||||
select {
|
|
||||||
case sem <- struct{}{}:
|
|
||||||
defer func() { <-sem }()
|
|
||||||
case <-cctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
execOne(s)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < len(servers); i++ {
|
finished := 0
|
||||||
|
total := len(servers)
|
||||||
|
|
||||||
|
// 聚合:首个可用响应直接返回;区分“真超时”与“无可用结果”
|
||||||
|
for finished < total {
|
||||||
select {
|
select {
|
||||||
case r := <-ch:
|
case r := <-ch:
|
||||||
if r.msg != nil {
|
if r.msg != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return r.msg
|
return r.msg
|
||||||
}
|
}
|
||||||
|
case <-done:
|
||||||
|
finished++
|
||||||
case <-cctx.Done():
|
case <-cctx.Done():
|
||||||
log.Printf("[upstream] timeout after %v", timeout)
|
log.Printf("[upstream] timeout after %v (finished=%d/%d)", timeout, finished, total)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 所有上游都结束,但没有一个可用
|
||||||
|
log.Printf("[upstream] no acceptable upstream response (finished=%d/%d)", finished, total)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user