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:
2026-03-20 14:39:29 +08:00
parent 77bea1e99e
commit f5bf77927d
6 changed files with 458 additions and 963 deletions

191
README.md
View File

@@ -1,50 +1,47 @@
# DNS-over-TLS Cache Proxy
# 🚀 Go-DoT: 高性能 DNS-over-TLS 缓存代理
一个用 **Go** 编写的高性能 **DNS-over-TLS (DoT)** 缓存代理服务,
专为隐私保护与性能优化而设计。支持多上游并发解析、智能缓存、ECS 剥离和优雅关闭。
[![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)](https://golang.org)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Security](https://img.shields.io/badge/TLS-1.3_Only-green.svg)](https://en.wikipedia.org/wiki/Transport_Layer_Security#TLS_1.3)
## 🚀 特性概览
**Go-DoT** 是一个用 Go 语言编写的生产级 **DNS-over-TLS (DoT)** 缓存代理服务器,专注于**隐私保护、极致性能与高可用性**。
---
## ✨ 特性概览
| 功能 | 描述 |
|------|------|
| 🔒 **加密传输** | 完全支持 DNS-over-TLSRFC 7858),支持 TLS 1.2/1.3 |
| ⚡ **多上游并发解析** | “快乐眼球”机制并行查询多个上游 DNS取最快响应 |
| 🧠 **智能缓存系统** | 支持正向与负面缓存RFC 2308动态 TTL 调整 |
| 🧹 **自动清理机制** | 定期清理过期缓存项(默认每 5 分钟) |
| 🧩 **隐私保护** | 默认剥离 ECS (EDNS Client Subnet),防止地理泄露 |
| 🧱 **黑名单过滤** | 支持域名黑名单(后缀匹配、文件或命令行加载) |
| 🪶 **轻量高效** | 单一可执行文件,无外部依赖,易于容器化部署 |
| 🔒 加密传输 | 支持 RFC 7858,默认强制 TLS 1.3 |
| ⚡ 高效匹配 | 基于 Trie前缀树实现百万级黑名单匹配复杂度 O(L) |
| 🧠 请求合并 | 使用 Singleflight 防止缓存击穿 |
| 🚀 并发查询 | 并行请求上游 DNS返回最快响应 |
| 📦 智能缓存 | 支持正向缓存 + NXDOMAIN 负缓存LRU |
| 🧹 优雅退出 | 支持 SIGTERM 信号 |
| 🧩 隐私保护 | 默认移除 ECSEDNS Client Subnet |
---
## 🏗️ 快速开始
### 从源码构建
### 1. 编译构建
需要 Go 1.22 或更高版本:
```bash
git clone https://git.aixiao.me/aixiao/dot.git
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 build.sh build
# 运行容器
bash build.sh run
# 查看日志
bash build.sh logs
# 停止与清理
bash build.sh stop
bash build.sh clean
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes -subj "/CN=dot.local"
```
> 🧩 默认镜像名为 `dot:latest`,监听 `853` 端口,可通过 `PORT` 环境变量修改。
## ⚙️ 启动示例
### 3. 启动服务
```bash
./dot \
@@ -52,119 +49,79 @@ bash build.sh clean
-key aixiao.me.key \
-addr :853 \
-upstream 119.29.29.29:53,223.5.5.5:53,114.114.114.114:53 \
-cache-ttl 300s \
-timeout 3s \
-max-parallel 3 \
-blacklist-file blacklist.txt
-blacklist-file blacklist.txt \
-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` | 监听地址(支持 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正向/负面均适用) |
| `--timeout` | `3s` | 上游查询超时 |
| `--max-parallel` | `3` | 最大并发上游查询数 |
| `--strip-ecs` | `true` | 是否剥离 ECS 信息 |
| `--tcp-fallback` | `true` | UDP 截断时是否自动 TCP 回退 |
| `--blacklist` | 空 | 逗号分隔的黑名单域名或通配后缀 |
| `--blacklist-file` | 空 | 黑名单文件路径(每行一个规则) |
| `--blacklist-rcode` | `REFUSED` | 黑名单命中返回码:`REFUSED` / `NXDOMAIN` / `SERVFAIL` |
| `--cache-size` | `10000` | LRU 缓存最大条目数 |
| `--v` | `false` | 启用详细日志模式 |
|------|--------|------|
| -addr | :853 | DoT 监听地址 |
| -cert | server.crt | TLS 证书路径 |
| -key | server.key | TLS 私钥路径 |
| -upstream | 8.8.8.8:53,1.1.1.1:53 | 上游 DNS |
| -blacklist-file | 空 | 黑名单文件路径 |
| -blacklist-rcode | REFUSED | 命中黑名单返回码REFUSED / NXDOMAIN / SERVFAIL |
| -v | false | 启用详细日志模式 |
## 🔍 缓存机制详解
**缓存键格式:**
```sh
domain|type|class|DO|CD
```
**缓存策略:**
-**正向缓存**:取最小 TTL 与配置上限的较小值
- 🚫 **负面缓存**:遵循 RFC 2308从 SOA.MINIMUM 计算 TTL
- 🧭 **动态 TTL 调整**:返回时按剩余时间递减 TTL
- 🧹 **自动清理**:每 5 分钟扫描并删除过期条目
- 🔒 **隔离逻辑**DO/CD 不同查询独立缓存空间
---
## 🧱 黑名单功能
支持两种配置方式:
支持 hosts + 通配符格式:
1. 命令行参数:
```bash
./dot -blacklist="*.ads.com,*.tracking.net"
```text
# 注释
ad.doubleclick.net
*.tracking.com
127.0.0.1 malicious-site.io
```
2. 文件加载(每行一个域名或后缀):
命中后直接返回指定 RCODE并附带 EDE 说明。
```sh
# blacklist.txt
*.ads.com
*.malware.net
```
---
启动命令:
## 🔍 缓存机制
```bash
./dot -blacklist-file=blacklist.txt -blacklist-rcode=NXDOMAIN
```
- 缓存键: Type|DO|CD|ECS|Domain 组合键,确保不同请求策略的结果物理隔离。
黑名单命中后不再上游查询,直接返回指定 RCODE。
- TTL 策略:
- ✅ 正向缓存: 取记录中最小 TTL 与配置上限的较小值。
- 🚫 负面缓存: 遵循 RFC 2308自动计算 SOA 的最小 TTL 进行缓存。
## 🧩 架构与运行原理
---
## 🧩 工作流程
```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]
A[客户端请求] --> B{黑名单匹配}
B -- 命中 --> C[返回拦截]
B -- 未命中 --> D{缓存命中}
D -- 命中 --> E[返回缓存]
D -- 未命中 --> F[Singleflight]
F --> G[并发查询上游]
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
go build -ldflags="-s -w" -o dot main.go
```
- 提高文件描述符限制ulimit -n 65535
- 使用合法 CA 证书Android/iOS 必须)
- 关闭 -v 日志提升性能
- 配置 ≥3 个上游 DNS
## 🧪 测试方法
---
使用 `kdig` 或 `dig` 测试解析:
## 👨‍💻 作者
```bash
kdig @127.0.0.1 +tls-ca +tls-host=dot.local www.example.com
```
## 👨‍💻 作者信息
**Author:** niuyuling
**Email:** [aixiao@aixiao.me](mailto:aixiao@aixiao.me)
**License:** MIT
**Repository:** [git.aixiao.me/aixiao/dot](https://git.aixiao.me/aixiao/dot)
- Author: niuyuling
- Email: aixiao@aixiao.me
- License: MIT
- Repository: https://git.aixiao.me/aixiao/dot

View File

@@ -6,214 +6,146 @@ import (
"log"
"net"
"os"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/miekg/dns"
"golang.org/x/net/idna"
)
// -------- Blacklist helpers (独立文件) --------
// 将输入域名规范化为 canonical FQDN去空格、去 *. 前缀、统一大小写/尾点)
func canonicalFQDN(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
// 允许黑名单写 "*.example.com";内部匹配用裸后缀
s = strings.TrimPrefix(s, "*.")
// 先把可能的中文/Unicode 域名转成 ASCIIpunycode再规范化
if a, err := idna.Lookup.ToASCII(s); err == nil {
s = a
}
// CanonicalName 会做小写化与尾点规范化
return dns.CanonicalName(s)
type BlacklistTrie struct {
root *trieNode
}
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
type trieNode struct {
children map[string]*trieNode
isEnd bool
rule string
}
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写
// 支持 # / ; 注释;每行一个域名;支持以 "*.example.com" 书写;支持 hosts 风格(首列为 IP
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
}
func newTrie() *BlacklistTrie {
return &BlacklistTrie{root: &trieNode{children: make(map[string]*trieNode)}}
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
func (t *BlacklistTrie) Add(domain string) {
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
}
// hosts 风格:第一个字段是 IP则其余每个字段视为域名
start := 0
if net.ParseIP(fields[0]) != nil {
start = 1
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
}
for _, tok := range fields[start:] {
if r := canonicalFQDN(tok); r != "" {
rules = append(rules, r)
curr = next
if curr.isEnd {
return curr.rule, true
}
}
}
if err := sc.Err(); err != nil {
return nil, err
return "", false
}
sort.Strings(rules)
rules = uniqueStrings(rules)
return rules, nil
}
func initBlacklist(ctx context.Context, path, rcodeStr string) (*atomic.Pointer[BlacklistTrie], int) {
var holder atomic.Pointer[BlacklistTrie]
var lastMod time.Time
rcode := parseRcode(rcodeStr)
// 自动重载黑名单
func startBlacklistReloader(ctx context.Context, path string, interval time.Duration, holder *atomic.Pointer[suffixMatcher]) {
load := func() {
if path == "" {
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() {
var lastMod time.Time
ticker := time.NewTicker(interval)
defer ticker.Stop()
// 缩短检查间隔,但因为有文件时间校验,所以不费 CPU
ticker := time.NewTicker(5 * time.Minute)
for {
select {
case <-ticker.C:
load()
case <-ctx.Done():
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
return &holder, rcode
}
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
switch strings.ToUpper(s) {
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 {
func makeBlockedMsg(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)
m.RecursionAvailable = true
o := new(dns.OPT)
o.Hdr.Name = "."
o.Hdr.Rrtype = dns.TypeOPT
o.Option = append(o.Option, &dns.EDNS0_EDE{InfoCode: 15, ExtraText: "Blocked by policy"})
m.Extra = append(m.Extra, o)
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
}

BIN
dot

Binary file not shown.

13
go.mod
View File

@@ -4,15 +4,14 @@ go 1.25.3
require (
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/miekg/dns v1.1.68
golang.org/x/net v0.46.0
golang.org/x/sync v0.17.0
github.com/miekg/dns v1.1.72
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0
)
require (
github.com/google/go-cmp v0.7.0 // indirect
golang.org/x/mod v0.29.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/mod v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)

28
go.sum
View File

@@ -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/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.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/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/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/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/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/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/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=

909
main.go

File diff suppressed because it is too large Load Diff