diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d0c68e..5ca3ee0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,17 +54,35 @@ jobs: LDFLAGS="-X RealityChecker/internal/version.Version=$VERSION -X RealityChecker/internal/version.Commit=$COMMIT -X RealityChecker/internal/version.BuildTime=$BUILD_TIME -s -w" # Linux AMD64 - 静态编译 - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -tags netgo -installsuffix netgo -o reality-checker . - chmod +x reality-checker + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -tags netgo -installsuffix netgo -o dist/reality-checker-linux-amd64 . + cp dist/reality-checker-linux-amd64 reality-checker zip -j dist/reality-checker-linux-amd64.zip reality-checker rm reality-checker # Linux ARM64 - 静态编译 - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -tags netgo -installsuffix netgo -o reality-checker . - chmod +x reality-checker + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -tags netgo -installsuffix netgo -o dist/reality-checker-linux-arm64 . + cp dist/reality-checker-linux-arm64 reality-checker zip -j dist/reality-checker-linux-arm64.zip reality-checker rm reality-checker + # Windows AMD64 + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o dist/reality-checker-windows-amd64.exe . + cp dist/reality-checker-windows-amd64.exe reality-checker.exe + zip -j dist/reality-checker-windows-amd64.zip reality-checker.exe + rm reality-checker.exe + + # macOS AMD64 + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o dist/reality-checker-darwin-amd64 . + cp dist/reality-checker-darwin-amd64 reality-checker + zip -j dist/reality-checker-darwin-amd64.zip reality-checker + rm reality-checker + + # macOS ARM64 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o dist/reality-checker-darwin-arm64 . + cp dist/reality-checker-darwin-arm64 reality-checker + zip -j dist/reality-checker-darwin-arm64.zip reality-checker + rm reality-checker + # 显示构建结果 ls -la dist/ @@ -82,8 +100,12 @@ jobs: 详细使用方法请参考 [README.md](https://github.com/V2RaySSR/RealityChecker/blob/main/README.md) **下载说明:** - - `reality-checker-linux-amd64.zip` - Linux x86_64 - - `reality-checker-linux-arm64.zip` - Linux ARM64 + - Linux (x86_64): `reality-checker-linux-amd64.zip` / `reality-checker-linux-amd64` + - Linux (ARM64): `reality-checker-linux-arm64.zip` / `reality-checker-linux-arm64` + - Windows (x86_64): `reality-checker-windows-amd64.zip` / `reality-checker-windows-amd64.exe` + - macOS (Intel): `reality-checker-darwin-amd64.zip` / `reality-checker-darwin-amd64` + - macOS (Apple Silicon): `reality-checker-darwin-arm64.zip` / `reality-checker-darwin-arm64` + **基本命令:** ```bash diff --git a/.gitignore b/.gitignore index 1553bfd..31784bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ reality-checker +reality-checker.exe +*.exe PROJECT_STATUS.md # 系统文件 diff --git a/README.md b/README.md index bfa2df6..601b7d9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ -# Reality协议目标网站检测工具 +# Reality Target 寻找和分析工具 -一个专业的Reality协议目标网站检测工具,用于评估网站是否适合作为Reality协议的目标域名。 +一键找到符合最佳实践的SNI -[V2RaySSR综合网](https://v2rayssr.com) +基于原项目,整合Xray官方RealiTLScanner的功能,简化了操作步骤,实现 `输入ip段,返回符合条件的域名`,大大减少了心智负担和操作难度 + +Reality SNI目标域名的最佳实践 + +- 不使用CDN +- 非热门大厂 +- 和入口机ip同ASN +- TLS握手延迟尽量低 ## ✨ 功能特性 @@ -16,14 +23,52 @@ * **批量检测** - 支持多域名并发检测,可与RealiTLScanner配合使用 * **智能报告** - 生成详细的检测分析报告 +### 推荐工作流程 + +1.确认vps ip + +2.手动查找和第一步的ip同ASN同国家的ip段(比如在[ipinfo](https://ipinfo.io)), + +3.把找到的ip range填入一个文件(比如as7203.txt),一行一条 + +4.开始检测(在客户端运行,也就是本地电脑,这样测出来的握手时间才有参考性,保证直连[不会分流请关闭代理]) + +```bash +./reality-checker auto --in ./as7203.txt --limit 10 +``` + +5.手动测试符合条件的域名,观察是否像真实上线的网站(不是demo,不是初始部署欢迎页),最好有真实的功能和业务 + ## 📊 检测结果说明 ### 检测结果示例 -以下是一个批量检测的实际输出示例: +原本设计为 -```bash -./reality-checker csv file.csv +``` +输入vps的ip -> 自动查询ASN以及对应国家的同ASN ip段 -> 并发扫描所有ip -> 返回符合条件的结果 +``` + +但是自动查询ASN下的ip段目前没找到免费的数据库方案。 +所以折中的做法是 + +### 过滤条件 + +二进制同目录下 congfig.yaml + +```yaml +reality_filter: + #是否要求无cdn,默认true(推荐) + require_no_cdn:true + #TLS握手延迟上限,根据具体情况调整,默认800ms + max_hadshake_ms: 800 + #最少证书有效期(如果过低可能是无人维护的死站) + min_cert_days: 20 + +#并发性能 +concurrency: + max_concurrent: 10 + check_timeout: 3s ``` **实际运行效果:** @@ -32,14 +77,6 @@ **只有满足Reality目标域名硬性条件的(TLS1.3、X25519、H2、SNI匹配、证书有效),才会在列表中显示** -### CDN检测等级说明 - -| 等级 | 含义 | 影响 | -|------|------|------| -| **高** | 明确使用CDN | 可以使用,但不推荐 | -| **中** | 疑似使用CDN | 可以使用,但不推荐 | -| **低** | 轻微CDN特征 | 可以使用,但不推荐 | -| **-** | 未检测到CDN | 可以使用,强烈推荐 | ### 热门网站说明 @@ -64,41 +101,8 @@ **方法1:直接下载(推荐)** -从 [Releases](https://github.com/V2RaySSR/RealityChecker/releases) 页面下载对应架构的zip文件: - -```bash -# Linux x86_64 -wget https://github.com/V2RaySSR/RealityChecker/releases/latest/download/reality-checker-linux-amd64.zip - -# Linux ARM64 -wget https://github.com/V2RaySSR/RealityChecker/releases/latest/download/reality-checker-linux-arm64.zip -``` - -解压后直接使用: -```bash -# 解压 -unzip reality-checker-linux-amd64.zip - -# 添加执行权限 -chmod +x reality-checker +从 [Releases](https://github.com/qualvey/RealityChecker/releases) 页面下载对应架构的zip文件: -# 开始检测 -./reality-checker check <域名> -``` - -**方法2:本地编译** - -```bash -# 克隆项目 -git clone https://github.com/V2RaySSR/RealityChecker.git -cd RealityChecker - -# 编译程序 -go build -o reality-checker - -# 开始检测 -./reality-checker check <域名> -``` ## 🔍 使用示例 @@ -123,24 +127,6 @@ go build -o reality-checker ./reality-checker csv file.csv ``` -### 推荐工作流程 - -对于大量域名检测,建议配合使用 [RealiTLScanner](https://github.com/XTLS/RealiTLScanner) 工具( [教程观看](https://www.youtube.com/watch?v=zE8CFQ6muUI) ): - -**1. 使用RealiTLScanner扫描VPS IP:** -```bash -./RealiTLScanner -addr -port 443 -thread 100 -timeout 5 -out file.csv -``` - -**2. 使用本工具检测生成的CSV文件:** -```bash -./reality-checker csv file.csv -``` - -**重要提示:** -- RealiTLScanner 尽量在本地运行,不要在远端 -- 多次运行RealiTLScanner时,请更改输出文件名,如:`file1.csv`、`file2.csv`、`file3.csv` 等 -- 如果使用相同的文件名,可能会导致文件导出失败或覆盖之前的扫描结果 ### 查看帮助 diff --git a/RealiTLScanner-ARM.zip b/RealiTLScanner-ARM.zip deleted file mode 100644 index 81cf52e..0000000 Binary files a/RealiTLScanner-ARM.zip and /dev/null differ diff --git a/RealiTLScanner-Intel.zip b/RealiTLScanner-Intel.zip deleted file mode 100644 index 3965f72..0000000 Binary files a/RealiTLScanner-Intel.zip and /dev/null differ diff --git a/RealityChecker.png b/RealityChecker.png deleted file mode 100644 index b07d9e5..0000000 Binary files a/RealityChecker.png and /dev/null differ diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d1093b3 --- /dev/null +++ b/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# 获取 Git 版本、Commit 哈希及当前 UTC 时间 +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "v3.0.0-embedded") +COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") +BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') + +echo "==========================================" +echo "正在编译 REALITY 单二进制工具..." +echo "版本 (Version): ${VERSION}" +echo "提交 (Commit): ${COMMIT}" +echo "时间 (BuildTime): ${BUILD_TIME}" +echo "==========================================" + +go build -ldflags "-s -w \ + -X RealityChecker/internal/version.Version=${VERSION} \ + -X RealityChecker/internal/version.Commit=${COMMIT} \ + -X RealityChecker/internal/version.BuildTime=${BUILD_TIME}" \ + -o ../reality-checker . + +echo "编译完成!二进制位置: /home/ryu/script/reality/reality-checker" diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..d1cc6f5 --- /dev/null +++ b/config.yaml @@ -0,0 +1,43 @@ +# REALITY 目标网站检测与选型系统 - 配置文件 + +# 日志系统配置 +log: + # 日志级别: debug (每请求一个 IP 即输出详细日志), info, warn, error + level: info + file: "" + +# REALITY 目标选型过滤策略 (遵从 Xray 官方最佳实践) +reality_filter: + # 1. 强制非 CDN 过滤 (true: 剔除 Cloudflare/Akamai/Fastly 等 CDN 节点,防止 VPS 变成公网免费反代) + require_no_cdn: true + + # 2. 握手时间延迟上限 (毫秒): 高于此延迟的节点抛弃 (默认 800ms) + max_handshake_ms: 400 + + # 3. 热门大站过滤 (true: 依据 data/hot_websites.txt 匹配剔除 Google/Apple 等超级大站) + require_no_hot: true + + # 4. 证书剩余有效天数下限: 默认 7 天 (剔除 7 天内到期废弃的死链,避开 30 天造成的 ACME 自动续期误杀) + min_cert_days: 10 + + # 5. 最低推荐星级门槛: (1~5 星) + min_stars: 3 + +# 网络检测配置 +network: + timeout: 3s + retries: 1 + dns_servers: + - "8.8.8.8" + - "1.1.1.1" + +# 并发性能配置 +concurrency: + max_concurrent: 10 + check_timeout: 3s + +# 输出与格式 +output: + color: true + verbose: false + format: "table" diff --git a/doc/version_management.md b/doc/version_management.md new file mode 100644 index 0000000..fea1257 --- /dev/null +++ b/doc/version_management.md @@ -0,0 +1,119 @@ +# REALITY 目标检测与扫描系统 - 版本管理与编译部署规范文档 + +本文档规范了 REALITY 渐进式目标检测系统(`reality-checker`)的代码版本控制 (Git Version Control) 和二进制编译注入 (Go Build Flags Version Injection) 标准流程。 + +--- + +## 一、 代码版本控制规范 (Git Version Control) + +项目代码托管于 Git 仓库中。请遵循以下标准 Git 工作流对新功能与补丁进行版本追踪。 + +### 1. 代码提交与分支管理 + +修改或新增代码后,在项目根目录(`RealityChecker/`)下执行: + +```bash +# 1. 查看暂存状态 +git status + +# 2. 暂存相关代码变更 +git add . + +# 3. 编写符合规范的 Commit 信息 +git commit -m "feat: 深度融合 RealiTLScanner 源码,增加 auto 和 pipe 渐进式流处理模式" +``` + +### 2. 规范化 Tag 标签管理 + +每当完成重大功能更新或发布稳定版本时,需打了带有语义化版本(Semantic Versioning, e.g. `v3.0.0`)的 Git Tag 标签: + +```bash +# 打带注释的正式版本标签 +git tag -a v3.0.0 -m "Release v3.0.0: 内嵌 RealiTLScanner 引擎,实现单文件原生并发渐进式扫描" + +# 推送代码与标签至远程仓库 +git push origin main --tags +``` + +--- + +## 二、 二进制编译与版本注入规范 (Go Dynamic Build Injection) + +为了让编译出的二进制文件能够自报家门(如运行 `./reality-checker version`),我们利用 Go 编译期的 `-ldflags` 参数将 **Git 版本号 (Version)**、**提交哈希 (Commit Hash)** 及 **构建时间 (Build Time)** 动态注入到 `RealityChecker/internal/version` 包的全局变量中。 + +### 1. 自动化编译脚本示例 (`build.sh`) + +项目提供了自动化构建脚本,建议使用该方式编译二进制: + +```bash +#!/usr/bin/env bash + +set -euo pipefail + +# 1. 自动提取 Git 标记、哈希与构建时间 +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "v3.0.0-dev") +COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "custom") +BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') + +echo "==========================================" +echo "正在构建 reality-checker 单二进制文件..." +echo "版本 (Version): ${VERSION}" +echo "提交 (Commit): ${COMMIT}" +echo "时间 (BuildTime): ${BUILD_TIME}" +echo "==========================================" + +# 2. 执行带 ldflags 注入的编译 +go build -ldflags "-s -w \ + -X RealityChecker/internal/version.Version=${VERSION} \ + -X RealityChecker/internal/version.Commit=${COMMIT} \ + -X RealityChecker/internal/version.BuildTime=${BUILD_TIME}" \ + -o ../reality-checker . + +echo "构建成功!产物位置: ../reality-checker" +``` + +### 2. 编译参数解释 + +- `-s -w`:剥离符号表与调试信息,可将二进制文件体积缩减约 20% ~ 30%。 +- `-X package.variable=value`:在编译期修改 Go 变量的值,实现静态注入。 + +--- + +## 三、 版本校验与发布验证 + +编译完成后,可通过以下命令验证版本注入是否成功: + +```bash +./reality-checker version +``` + +**预期标准输出格式**: + +```text +Reality协议目标网站检测工具 +版本: v3.0.0 +提交: 1a76f16 +构建时间: 2026-08-03_13:12:47 +GitHub: https://github.com/V2RaySSR/RealityChecker +``` + +--- + +## 四、 跨平台交叉编译 (Cross-Compilation) + +由于系统已经深度融合了 `RealiTLScanner` 源码,不再依赖外部平台特定二进制文件,因此可以在单台机器上轻松编译出适用于所有目标架构的二进制: + +### 编译 Linux AMD64 (x86_64) +```bash +GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X RealityChecker/internal/version.Version=${VERSION} -X RealityChecker/internal/version.Commit=${COMMIT} -X RealityChecker/internal/version.BuildTime=${BUILD_TIME}" -o reality-checker-linux-amd64 . +``` + +### 编译 Linux ARM64 (aarch64) +```bash +GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X RealityChecker/internal/version.Version=${VERSION} -X RealityChecker/internal/version.Commit=${COMMIT} -X RealityChecker/internal/version.BuildTime=${BUILD_TIME}" -o reality-checker-linux-arm64 . +``` + +### 编译 Windows AMD64 +```bash +GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X RealityChecker/internal/version.Version=${VERSION} -X RealityChecker/internal/version.Commit=${COMMIT} -X RealityChecker/internal/version.BuildTime=${BUILD_TIME}" -o reality-checker-windows-amd64.exe . +``` diff --git a/file.csv b/file.csv deleted file mode 100644 index a1d947f..0000000 --- a/file.csv +++ /dev/null @@ -1,68 +0,0 @@ -IP,ORIGIN,CERT_DOMAIN,CERT_ISSUER,GEO_CODE -103.103.245.124,103.103.245.124,cwangrz.top,"Let's Encrypt",HK -103.103.245.129,103.103.245.129,*.bozai.cc,"ZeroSSL",HK -103.103.245.128,103.103.245.128,*.weiwei132.uk,"Let's Encrypt",HK -103.103.245.109,103.103.245.109,styles.redditmedia.com,"Google Trust Services LLC",HK -103.103.245.94,103.103.245.94,001.90009000.xyz,"Let's Encrypt",HK -103.103.245.172,103.103.245.172,voll.996969.xyz,"Let's Encrypt",HK -103.103.245.175,103.103.245.175,20291010.xyz,"Let's Encrypt",HK -103.103.245.81,103.103.245.81,mp.gihnius.net,"Let's Encrypt",HK -103.103.245.71,103.103.245.71,010.70007000.xyz,"Let's Encrypt",HK -103.103.245.191,103.103.245.191,hk.zpsonic.xyz,"Let's Encrypt",HK -103.103.245.58,103.103.245.58,cloudflare.net,"Let's Encrypt",HK -103.103.245.205,103.103.245.205,voll.hzwel.com,"Let's Encrypt",HK -103.103.245.211,103.103.245.211,CloudFlare Origin Certificate,"CloudFlare, Inc.",HK -103.103.245.223,103.103.245.223,portalhk.bricksmelt.com,"Let's Encrypt",HK -103.103.245.34,103.103.245.34,xindiegame.top,"Let's Encrypt",HK -103.103.245.20,103.103.245.20,780228.xyz,"Let's Encrypt",HK -103.103.245.245,103.103.245.245,eaglemoe.com,"Google Trust Services",HK -103.103.245.9,103.103.245.9,hk.needyou.cc,"Let's Encrypt",HK -103.103.245.250,103.103.245.250,5250.vrxdy8.com,"Let's Encrypt",HK -103.103.245.6,103.103.245.6,cetacloud.irort.top,"ZeroSSL",HK -103.103.245.252,103.103.245.252,vcmihk103.ra9.top,"Let's Encrypt",HK -103.103.245.5,103.103.245.5,cmi2.xyz88.cc,"ZeroSSL",HK -103.103.245.254,103.103.245.254,youcris.site,"Let's Encrypt",HK -103.103.245.2,103.103.245.2,realher.us,"Let's Encrypt",HK -103.103.244.144,103.103.244.144,*.douyincdn.com,"DigiCert Inc",RU -103.103.244.143,103.103.244.143,*.douyincdn.com,"DigiCert Inc",RU -103.103.244.142,103.103.244.142,NONE,"NONE",RU -103.103.244.141,103.103.244.141,NONE,"NONE",RU -103.103.244.140,103.103.244.140,NONE,"NONE",RU -103.103.244.139,103.103.244.139,NONE,"NONE",RU -103.103.244.138,103.103.244.138,NONE,"NONE",RU -103.103.244.137,103.103.244.137,NONE,"NONE",RU -103.103.244.136,103.103.244.136,NONE,"NONE",RU -103.103.244.135,103.103.244.135,NONE,"NONE",RU -103.103.244.134,103.103.244.134,NONE,"NONE",RU -103.103.244.133,103.103.244.133,NONE,"NONE",RU -103.103.244.132,103.103.244.132,NONE,"NONE",RU -103.103.244.131,103.103.244.131,NONE,"NONE",RU -103.103.244.130,103.103.244.130,NONE,"NONE",RU -103.103.244.112,103.103.244.112,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.111,103.103.244.111,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.110,103.103.244.110,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.109,103.103.244.109,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.108,103.103.244.108,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.107,103.103.244.107,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.103,103.103.244.103,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.102,103.103.244.102,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.98,103.103.244.98,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.94,103.103.244.94,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.95,103.103.244.95,minio.sk-robot.com,"Amazon",RU -103.103.244.92,103.103.244.92,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.91,103.103.244.91,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.88,103.103.244.88,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.87,103.103.244.87,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.78,103.103.244.78,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.71,103.103.244.71,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.70,103.103.244.70,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.66,103.103.244.66,*.zga.zenlayer.net,"Let's Encrypt",RU -103.103.244.30,103.103.244.30,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.28,103.103.244.28,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.29,103.103.244.29,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.27,103.103.244.27,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.6,103.103.244.6,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.5,103.103.244.5,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.4,103.103.244.4,*.bilivideo.com,"GlobalSign nv-sa",RU -103.103.244.3,103.103.244.3,*.ecn.zenlayer.net,"Let's Encrypt",RU -103.103.244.2,103.103.244.2,*.ecn.zenlayer.net,"Let's Encrypt",RU diff --git a/internal/batch/manager.go b/internal/batch/manager.go index a920902..aa179b4 100644 --- a/internal/batch/manager.go +++ b/internal/batch/manager.go @@ -42,6 +42,24 @@ func NewManagerWithEngine(engine *core.Engine, config *types.Config) *Manager { } } +// GetConfig 获取当前配置 +func (bm *Manager) GetConfig() *types.Config { + return bm.config +} + +// FormatSuitableTable 格式化适合域名的表格 +func (bm *Manager) FormatSuitableTable(results []*types.DetectionResult) string { + if bm.tableFormatter == nil { + bm.tableFormatter = report.NewTableFormatter(bm.config) + } + return bm.tableFormatter.FormatSuitableTable(results) +} + +// SortByRecommendationStars 按推荐星级排序 +func (bm *Manager) SortByRecommendationStars(results []*types.DetectionResult) { + bm.sortByRecommendationStars(results) +} + // Start 启动批量管理器 func (bm *Manager) Start() error { bm.mu.Lock() diff --git a/internal/cmd/auto.go b/internal/cmd/auto.go new file mode 100644 index 0000000..e1e8ab6 --- /dev/null +++ b/internal/cmd/auto.go @@ -0,0 +1,431 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "sync" + "time" + + "RealityChecker/internal/logger" + "RealityChecker/internal/scanner" + "RealityChecker/internal/types" + "RealityChecker/internal/ui" +) + +// executeAuto 处理 CIDR/IP 列表,内存中调用内置 TLS 扫描器并根据 REALITY 策略输出彩色表格结果 +func (r *RootCmd) executeAuto(cidrs []string, maxTargets int, filter types.RealityFilterConfig) { + if len(cidrs) == 0 { + ui.PrintError("错误:未提供有效的 CIDR 扫描网段") + return + } + + ui.PrintTimestampedMessage("开启内嵌自动化扫描与检测模式 (Native Engine)...") + ui.PrintTimestampedMessage("应用 REALITY 选型策略: [非CDN=%v, 最大握手=%dms, 排除热门=%v, 最小证书天数=%d天, 最低星级=%d星]", + filter.RequireNoCDN, filter.MaxHandshakeMS, filter.RequireNoHot, filter.MinCertDays, filter.MinStars) + + ui.PrintTimestampedMessage("待扫描网段总数: %d 个。排序如下:", len(cidrs)) + for i, c := range cidrs { + if i < 5 { + fmt.Printf(" [%d] %s\n", i+1, c) + } + } + if len(cidrs) > 5 { + fmt.Printf(" ...以及其余 %d 个网段\n", len(cidrs)-5) + } + + // 1. 初始化内嵌 Go 扫描器 + scannerEngine := scanner.NewScanner() + defer scannerEngine.Close() + + // 2. 构造流水线 Channel + scanResultChan := make(chan *scanner.ScanResult, 100) + domainSet := make(map[string]bool) + var mu sync.Mutex + + var suitableResults []*types.DetectionResult + concurrency := 10 + var workerWg sync.WaitGroup + + ctx, cancel := context.WithCancel(r.ctx) + defer cancel() + + // 启动检测 Worker 协程池 + for i := 0; i < concurrency; i++ { + go func() { + for scanRes := range scanResultChan { + func() { + defer workerWg.Done() + if scanRes == nil { + return + } + + // 检查是否已经达到上限,若达到立刻放弃 + mu.Lock() + if maxTargets > 0 && len(suitableResults) >= maxTargets { + mu.Unlock() + return + } + mu.Unlock() + + select { + case <-ctx.Done(): + return + default: + } + + d := scanRes.CertDomain + res, err := r.engine.CheckDomain(ctx, d) + + mu.Lock() + defer mu.Unlock() + + // 再次检查二次确认 + if maxTargets > 0 && len(suitableResults) >= maxTargets { + return + } + + // 检验是否完全符合 Xray REALITY 选型策略 + if isSatisfiedRealityPolicy(res, err, filter) { + suitableResults = append(suitableResults, res) + suitableCount := len(suitableResults) + + var handshakeMs int64 = 0 + if res.TLS != nil { + handshakeMs = res.TLS.HandshakeTime.Milliseconds() + } + var statusCode int = 0 + if res.Network != nil { + statusCode = res.Network.StatusCode + } + + timestamp := time.Now().Format("15:04:05") + fmt.Printf("[%s] ★ [发现优质 REALITY 目标 #%d] %-35s (IP: %s, 握手: %dms, 页面: %d)\n", + timestamp, suitableCount, d, scanRes.IP, handshakeMs, statusCode) + + if maxTargets > 0 && suitableCount >= maxTargets { + ui.PrintTimestampedMessage("已达到设定的目标数量限制 (%d 个),即刻刹车并停止扫描...", maxTargets) + cancel() + } + } + }() + } + }() + } + +CIDRLoop: + for idx, cidr := range cidrs { + select { + case <-ctx.Done(): + break CIDRLoop + default: + } + + mu.Lock() + sc := len(suitableResults) + mu.Unlock() + if maxTargets > 0 && sc >= maxTargets { + break CIDRLoop + } + + ui.PrintTimestampedMessage("[%d/%d] 正在内嵌并发扫描网段: %s ...", idx+1, len(cidrs), cidr) + + // 单个 CIDR 的扫描输出中间通道 + subChan := make(chan *scanner.ScanResult, 50) + + // 异步收纳单个 CIDR 的扫描结果 + var pipeWg sync.WaitGroup + pipeWg.Add(1) + go func() { + defer pipeWg.Done() + for res := range subChan { + if res == nil || res.CertDomain == "" || shouldExcludeDomain(res.CertDomain) { + continue + } + + mu.Lock() + if maxTargets > 0 && len(suitableResults) >= maxTargets { + mu.Unlock() + continue + } + + if !domainSet[res.CertDomain] { + domainSet[res.CertDomain] = true + workerWg.Add(1) + select { + case scanResultChan <- res: + case <-ctx.Done(): + workerWg.Done() + } + } + mu.Unlock() + } + }() + + // 执行并发 TLS 握手扫描 + scannerEngine.ScanCIDRStream(ctx, cidr, 443, 100, 5, false, subChan) + close(subChan) + pipeWg.Wait() + } + + workerWg.Wait() + close(scanResultChan) + + // 严密裁剪结果数量,保证精确等于 maxTargets + if maxTargets > 0 && len(suitableResults) > maxTargets { + suitableResults = suitableResults[:maxTargets] + } + + ui.PrintTimestampedMessage("扫描与检测完成!共找到 %d 个符合策略的优质 REALITY 目标域名。", len(suitableResults)) + + // 渲染经典带颜色 ASCII 表格 + if len(suitableResults) > 0 { + r.batchManager.SortByRecommendationStars(suitableResults) + fmt.Println("\n适合的域名:") + fmt.Println(r.batchManager.FormatSuitableTable(suitableResults)) + } +} + +// isSatisfiedRealityPolicy 检查检测结果是否符合 REALITY 最佳实践筛选策略 +func isSatisfiedRealityPolicy(res *types.DetectionResult, err error, filter types.RealityFilterConfig) bool { + if err != nil || res == nil || !res.Suitable || res.Error != nil { + return false + } + + // 1. 基础条件必须满足 TLS1.3 与 HTTP/2 + if res.TLS == nil || !res.TLS.SupportsTLS13 || !res.TLS.SupportsHTTP2 { + return false + } + + // 2. 强制非 CDN 过滤 + if filter.RequireNoCDN { + if res.CDN != nil && res.CDN.IsCDN { + return false + } + } + + // 3. 握手时间延迟上限过滤 + if filter.MaxHandshakeMS > 0 && res.TLS != nil { + if res.TLS.HandshakeTime.Milliseconds() > filter.MaxHandshakeMS { + return false + } + } + + // 4. 排除热门大站 + if filter.RequireNoHot { + if res.CDN != nil && res.CDN.IsHotWebsite { + return false + } + } + + // 5. 证书剩余有效天数下限过滤 + if filter.MinCertDays > 0 { + if res.Certificate == nil || !res.Certificate.Valid || res.Certificate.DaysUntilExpiry < filter.MinCertDays { + return false + } + } + + // 6. 最低推荐星级门槛过滤 + if filter.MinStars > 0 { + stars := calculateStars(res) + if stars < filter.MinStars { + return false + } + } + + return true +} + +func calculateStars(result *types.DetectionResult) int { + stars := 0 + if result.TLS != nil && result.TLS.SupportsTLS13 && + result.TLS.SupportsX25519 && result.TLS.SupportsHTTP2 && + result.SNI != nil && result.SNI.SNIMatch { + stars++ + } + if result.TLS != nil && result.TLS.HandshakeTime > 0 { + if result.TLS.HandshakeTime.Milliseconds() <= 200 { + stars++ + } + } + if result.CDN == nil || !result.CDN.IsCDN { + stars++ + } + if result.CDN != nil && !result.CDN.IsHotWebsite { + stars++ + } + if result.Certificate != nil && result.Certificate.Valid { + if result.Certificate.DaysUntilExpiry >= 60 { + stars++ + } + } + return stars +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// resolveInputToCIDRs 原生构建 CIDR 扫描队列(无需依赖易失效的第三方 HTTP API) +func resolveInputToCIDRs(targetInput string, inFile string) []string { + var cidrs []string + + // 从文件批量读取 CIDRs + if inFile != "" { + f, err := os.Open(inFile) + if err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + cidrs = appendCIDR(cidrs, line) + } + } + } + + // 处理单个命令行目标输入 (支持 CIDR 或 IP) + if targetInput != "" { + cidrs = appendCIDR(cidrs, targetInput) + } + + return cidrs +} + +func appendCIDR(list []string, input string) []string { + input = strings.TrimSpace(input) + if input == "" { + return list + } + + // 如果输入本身是 CIDR (如 5.45.102.0/24) + if strings.Contains(input, "/") { + if !contains(list, input) { + list = append(list, input) + } + return list + } + + // 如果输入是单个 IP (如 5.45.102.196),原生自动生成该 IP 的近邻 /24 和 /23 网段 + ip := net.ParseIP(input) + if ip != nil { + v4 := ip.To4() + if v4 != nil { + c24 := fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2]) + if !contains(list, c24) { + list = append(list, c24) + } + c23 := fmt.Sprintf("%d.%d.%d.0/23", v4[0], v4[1], v4[2]&0xFE) + if !contains(list, c23) { + list = append(list, c23) + } + } + } + + return list +} + +// parseAndExecuteAuto 解析命令行参数并应用 YAML 策略与 CLI 参数覆盖 +func (r *RootCmd) parseAndExecuteAuto(args []string) { + if len(args) == 0 { + ui.PrintErrorWithDetails( + "错误:缺少目标 IP / CIDR 参数或 --in 文件参数", + "用法:", + " reality-checker auto [选项]", + " reality-checker auto --in [选项]", + "选项:", + " --in FILE 从文件中批量读取 CIDR/IP 列表 (每行一个)", + " --limit N 指定获取合适目标的数量上限 (默认 5)", + " --no-cdn 强制筛选无 CDN 节点 (默认开启)", + " --allow-cdn 允许 CDN 节点", + " --max-handshake MS 设置最大握手延迟 (毫秒, 默认 800)", + " --no-hot 排除热门大站 (默认开启)", + " --allow-hot 允许热门大站", + " --min-cert-days DAYS 证书最低剩余天数 (默认 7 天)", + " --min-stars STARS 最低推荐星级 1-5 (默认 3)", + "示例:", + " reality-checker auto 5.45.102.0/24 --limit 2", + " reality-checker auto 5.45.102.196 --limit 5 --max-handshake 500", + " reality-checker auto --in cidrs.txt --limit 5", + ) + os.Exit(1) + } + + var targetInput string + var inFile string + maxTargets := 5 + filter := r.batchManager.GetConfig().RealityFilter + + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "-") && targetInput == "" { + targetInput = arg + continue + } + + switch arg { + case "--in": + if i+1 < len(args) { + inFile = args[i+1] + i++ + } + case "--limit", "-n": + if i+1 < len(args) { + if n, err := strconv.Atoi(args[i+1]); err == nil { + maxTargets = n + } + i++ + } + case "--no-cdn": + filter.RequireNoCDN = true + case "--allow-cdn": + filter.RequireNoCDN = false + case "--no-hot": + filter.RequireNoHot = true + case "--allow-hot": + filter.RequireNoHot = false + case "--max-handshake": + if i+1 < len(args) { + if ms, err := strconv.ParseInt(args[i+1], 10, 64); err == nil { + filter.MaxHandshakeMS = ms + } + i++ + } + case "--min-cert-days": + if i+1 < len(args) { + if days, err := strconv.Atoi(args[i+1]); err == nil { + filter.MinCertDays = days + } + i++ + } + case "--min-stars": + if i+1 < len(args) { + if s, err := strconv.Atoi(args[i+1]); err == nil { + filter.MinStars = s + } + i++ + } + case "--debug": + logger.Init("debug", "") + case "--log-level": + if i+1 < len(args) { + logger.Init(args[i+1], "") + i++ + } + } + } + + cidrs := resolveInputToCIDRs(targetInput, inFile) + r.executeAuto(cidrs, maxTargets, filter) +} diff --git a/internal/cmd/batch.go b/internal/cmd/batch.go index 23951fc..e125adb 100644 --- a/internal/cmd/batch.go +++ b/internal/cmd/batch.go @@ -73,9 +73,6 @@ func (r *RootCmd) executeBatch(domainsStr string) { } // 详细结果已在batch manager中打印,无需重复 - - // 显示广告 - ui.PrintAdvertisement() } // parseDomains 解析域名列表,返回有效域名、无效域名和重复域名 diff --git a/internal/cmd/check.go b/internal/cmd/check.go index 2f21930..7cb018c 100644 --- a/internal/cmd/check.go +++ b/internal/cmd/check.go @@ -40,9 +40,6 @@ func (r *RootCmd) executeCheck(domain string) { cfg, _ := config.LoadConfig("") formatter := report.NewFormatter(cfg) fmt.Printf("\n%s", formatter.FormatSingleResult(result)) - - // 显示广告 - ui.PrintAdvertisement() } // isValidDomain 验证域名格式是否有效 diff --git a/internal/cmd/csv.go b/internal/cmd/csv.go index 8314000..1a9cd86 100644 --- a/internal/cmd/csv.go +++ b/internal/cmd/csv.go @@ -79,22 +79,39 @@ func (r *RootCmd) executeCSV(csvFile string) { return } - // 显示广告 - ui.PrintAdvertisement() } // extractDomainsFromCSV 从CSV记录中提取域名 func extractDomainsFromCSV(records [][]string) []string { + if len(records) < 2 { + return nil + } + + // 查找 CERT_DOMAIN 列的索引 + domainIdx := -1 + header := records[0] + for idx, col := range header { + if strings.ToUpper(strings.TrimSpace(col)) == "CERT_DOMAIN" { + domainIdx = idx + break + } + } + + // 如果没找到,默认使用索引 2 + if domainIdx == -1 { + domainIdx = 2 + } + var domains []string domainSet := make(map[string]bool) // 用于去重 - // 跳过标题行,从第二行开始处理 + // 从第二行开始处理 for i := 1; i < len(records); i++ { - if len(records[i]) < 3 { + if len(records[i]) <= domainIdx { continue } - certDomain := strings.TrimSpace(records[i][2]) // CERT_DOMAIN列 + certDomain := strings.TrimSpace(records[i][domainIdx]) if certDomain == "" { continue } diff --git a/internal/cmd/csv_test.go b/internal/cmd/csv_test.go new file mode 100644 index 0000000..2527c54 --- /dev/null +++ b/internal/cmd/csv_test.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "reflect" + "testing" +) + +func TestExtractDomainsFromCSV(t *testing.T) { + tests := []struct { + name string + records [][]string + want []string + }{ + { + name: "old format (CERT_DOMAIN at index 2)", + records: [][]string{ + {"IP", "ORIGIN", "CERT_DOMAIN", "CERT_ISSUER", "GEO_CODE"}, + {"103.103.245.124", "103.103.245.124", "cwangrz.top", "Let's Encrypt", "HK"}, + {"103.103.245.129", "103.103.245.129", "*.bozai.cc", "ZeroSSL", "HK"}, // should be excluded due to wildcard + {"103.103.245.58", "103.103.245.58", "cloudflare.net", "Let's Encrypt", "HK"}, + {"103.103.245.124", "103.103.245.124", "cwangrz.top", "Let's Encrypt", "HK"}, // duplicate + }, + want: []string{"cwangrz.top", "cloudflare.net"}, + }, + { + name: "new format (CERT_DOMAIN at index 8)", + records: [][]string{ + {"IP", "ORIGIN", "TLS", "ALPN", "CURVE", "CERT_LENGTH", "CERT_SIGNATURE", "CERT_PUBLICKEY", "CERT_DOMAIN", "CERT_ISSUER", "GEO_CODE"}, + {"5.45.102.238", "5.45.102.238", "TLS 1.3", "h2", "X25519", "2793", "SHA256-RSA", "RSA", "wantedlink.de", "Let's Encrypt", "DE"}, + {"5.45.102.201", "5.45.102.201", "TLS 1.3", "h2", "X25519", "5443", "SHA256-RSA", "RSA", "mail.mpache.com", "Let's Encrypt", "DE"}, + {"5.45.102.202", "5.45.102.202", "TLS 1.3", "h2", "X25519", "4045", "SHA256-RSA", "RSA", "pge.ring0.de", "Let's Encrypt", "DE"}, + }, + want: []string{"wantedlink.de", "mail.mpache.com", "pge.ring0.de"}, + }, + { + name: "no CERT_DOMAIN in header, fallback to index 2", + records: [][]string{ + {"IP", "ORIGIN", "SOMETHING", "CERT_ISSUER", "GEO_CODE"}, + {"1.1.1.1", "1.1.1.1", "fallback.com", "Let's Encrypt", "US"}, + }, + want: []string{"fallback.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractDomainsFromCSV(tt.records) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("extractDomainsFromCSV() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/pipe.go b/internal/cmd/pipe.go new file mode 100644 index 0000000..685d130 --- /dev/null +++ b/internal/cmd/pipe.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + "time" + + "RealityChecker/internal/types" + "RealityChecker/internal/ui" +) + +// executePipe 从标准输入(Stdin)流式读取 CSV 数据或域名,进行实时并行检测并输出彩色表格 +func (r *RootCmd) executePipe() { + ui.PrintTimestampedMessage("开启管道流式检测模式 (Pipe Mode)...") + + scanner := bufio.NewScanner(os.Stdin) + + // 域名管道与去重集合 + domainChan := make(chan string, 100) + domainSet := make(map[string]bool) + var mu sync.Mutex + + var suitableResults []*types.DetectionResult + concurrency := 10 + var wg sync.WaitGroup + + // 启动固定数量的 Worker 协程 + for i := 0; i < concurrency; i++ { + go func() { + for domain := range domainChan { + func() { + defer wg.Done() + + res, err := r.engine.CheckDomain(r.ctx, domain) + + mu.Lock() + defer mu.Unlock() + + if err == nil && res != nil && res.Suitable && res.Error == nil && res.TLS != nil && res.TLS.SupportsTLS13 { + suitableResults = append(suitableResults, res) + suitableCount := len(suitableResults) + + var handshakeMs int64 = 0 + if res.TLS != nil { + handshakeMs = res.TLS.HandshakeTime.Milliseconds() + } + var statusCode int = 0 + if res.Network != nil { + statusCode = res.Network.StatusCode + } + + timestamp := time.Now().Format("15:04:05") + fmt.Printf("[%s] ★ [可用 REALITY 目标 #%d] %-35s (握手: %dms, 页面: %d)\n", + timestamp, suitableCount, domain, handshakeMs, statusCode) + } + }() + } + }() + } + + certDomainIdx := -1 + headerParsed := false + + // 逐行解析 Stdin + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // 检查是否为 CSV 表头 + if !headerParsed && strings.Contains(strings.ToUpper(line), "CERT_DOMAIN") { + parts := strings.Split(line, ",") + for idx, col := range parts { + if strings.ToUpper(strings.TrimSpace(col)) == "CERT_DOMAIN" { + certDomainIdx = idx + break + } + } + headerParsed = true + continue + } + + var candidateDomain string + + // 判断是 CSV 行还是单域名 + if strings.Contains(line, ",") { + parts := strings.Split(line, ",") + targetIdx := certDomainIdx + if targetIdx == -1 || targetIdx >= len(parts) { + // 容错: 查找包含 '.' 的可能域名列 + for _, part := range parts { + p := strings.Trim(strings.TrimSpace(part), "\"") + if strings.Contains(p, ".") && !strings.HasPrefix(p, "TLS") && !shouldExcludeDomain(p) { + candidateDomain = p + break + } + } + } else { + candidateDomain = strings.Trim(strings.TrimSpace(parts[targetIdx]), "\"") + } + } else { + candidateDomain = strings.Trim(strings.TrimSpace(line), "\"") + } + + if candidateDomain == "" || shouldExcludeDomain(candidateDomain) { + continue + } + + // 内存去重并派发给 Worker + mu.Lock() + seen := domainSet[candidateDomain] + if !seen { + domainSet[candidateDomain] = true + wg.Add(1) + domainChan <- candidateDomain + } + mu.Unlock() + } + + wg.Wait() + close(domainChan) + + if err := scanner.Err(); err != nil { + ui.PrintError(fmt.Sprintf("读取管道输入错误: %v", err)) + } + + ui.PrintTimestampedMessage("管道流式检测结束。共找到 %d 个适合的 REALITY 目标域名。", len(suitableResults)) + + // 渲染经典带颜色 ASCII 表格 + if len(suitableResults) > 0 { + r.batchManager.SortByRecommendationStars(suitableResults) + fmt.Println("\n适合的域名:") + fmt.Println(r.batchManager.FormatSuitableTable(suitableResults)) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 9b6bbbc..1d9c7d4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -11,6 +11,7 @@ import ( "RealityChecker/internal/batch" "RealityChecker/internal/config" "RealityChecker/internal/core" + "RealityChecker/internal/logger" "RealityChecker/internal/ui" "RealityChecker/internal/version" ) @@ -31,6 +32,9 @@ func NewRootCmd() (*RootCmd, error) { return nil, fmt.Errorf("加载配置失败: %v", err) } + // 初始化日志系统 + logger.Init(cfg.Log.Level, cfg.Log.File) + // 创建引擎 engine := core.NewEngine(cfg) if err := engine.Start(); err != nil { @@ -71,6 +75,10 @@ func (r *RootCmd) Execute() { } switch os.Args[1] { + case "pipe": + r.executePipe() + case "auto": + r.parseAndExecuteAuto(os.Args[2:]) case "check": if len(os.Args) < 3 { ui.PrintErrorWithDetails( @@ -105,10 +113,12 @@ func (r *RootCmd) Execute() { r.executeCSV(os.Args[2]) case "version", "-v", "--version": r.showVersion() + case "help", "-h", "--help": + ui.PrintUsage() default: ui.PrintErrorWithDetails( fmt.Sprintf("错误:未知命令 '%s'", os.Args[1]), - "可用命令: check, batch, csv, version", + "可用命令: auto, pipe, check, batch, csv, version", ) os.Exit(1) } diff --git a/internal/config/config.go b/internal/config/config.go index d984176..8658cef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,6 +126,27 @@ func mergeConfig(defaultConfig *types.Config, fileConfig *types.Config) { if fileConfig.Batch.Timeout > 0 { defaultConfig.Batch.Timeout = fileConfig.Batch.Timeout } + + // REALITY 选型策略配置 + defaultConfig.RealityFilter.RequireNoCDN = fileConfig.RealityFilter.RequireNoCDN + defaultConfig.RealityFilter.RequireNoHot = fileConfig.RealityFilter.RequireNoHot + if fileConfig.RealityFilter.MaxHandshakeMS > 0 { + defaultConfig.RealityFilter.MaxHandshakeMS = fileConfig.RealityFilter.MaxHandshakeMS + } + if fileConfig.RealityFilter.MinCertDays > 0 { + defaultConfig.RealityFilter.MinCertDays = fileConfig.RealityFilter.MinCertDays + } + if fileConfig.RealityFilter.MinStars > 0 { + defaultConfig.RealityFilter.MinStars = fileConfig.RealityFilter.MinStars + } + + // 日志配置 + if fileConfig.Log.Level != "" { + defaultConfig.Log.Level = fileConfig.Log.Level + } + if fileConfig.Log.File != "" { + defaultConfig.Log.File = fileConfig.Log.File + } } // getDefaultConfig 获取默认配置 @@ -162,6 +183,17 @@ func getDefaultConfig() *types.Config { ReportFormat: "text", Timeout: 30 * time.Second, }, + RealityFilter: types.RealityFilterConfig{ + RequireNoCDN: true, + MaxHandshakeMS: 800, + RequireNoHot: true, + MinCertDays: 7, + MinStars: 3, + }, + Log: types.LogConfig{ + Level: "info", + File: "", + }, } } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..365407e --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,98 @@ +package logger + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" +) + +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn + LevelError +) + +type Logger struct { + mu sync.Mutex + level Level + out io.Writer + fileWriter io.WriteCloser +} + +var globalLogger *Logger = NewLogger("info", "") + +func NewLogger(levelStr string, logFile string) *Logger { + l := &Logger{ + level: parseLevel(levelStr), + out: os.Stdout, + } + if logFile != "" { + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err == nil { + l.fileWriter = f + l.out = io.MultiWriter(os.Stdout, f) + } + } + return l +} + +func Init(levelStr string, logFile string) { + globalLogger = NewLogger(levelStr, logFile) +} + +func parseLevel(l string) Level { + switch strings.ToLower(l) { + case "debug": + return LevelDebug + case "info": + return LevelInfo + case "warn", "warning": + return LevelWarn + case "error": + return LevelError + default: + return LevelInfo + } +} + +func IsDebug() bool { + return globalLogger.level <= LevelDebug +} + +func Debug(format string, args ...interface{}) { + if globalLogger.level <= LevelDebug { + logMsg("DEBUG", format, args...) + } +} + +func Info(format string, args ...interface{}) { + if globalLogger.level <= LevelInfo { + logMsg("INFO", format, args...) + } +} + +func Warn(format string, args ...interface{}) { + if globalLogger.level <= LevelWarn { + logMsg("WARN", format, args...) + } +} + +func Error(format string, args ...interface{}) { + if globalLogger.level <= LevelError { + logMsg("ERROR", format, args...) + } +} + +func logMsg(tag string, format string, args ...interface{}) { + globalLogger.mu.Lock() + defer globalLogger.mu.Unlock() + timestamp := time.Now().Format("15:04:05.000") + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(globalLogger.out, "[%s] [%s] %s\n", timestamp, tag, msg) +} diff --git a/internal/scanner/host.go b/internal/scanner/host.go new file mode 100644 index 0000000..c2364b2 --- /dev/null +++ b/internal/scanner/host.go @@ -0,0 +1,90 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "regexp" +) + +// IterateCIDR 返回迭代 CIDR 或单个 IP/域名的 Host 通道 +func IterateCIDR(ctx context.Context, cidrStr string, enableIPv6 bool) <-chan Host { + hostChan := make(chan Host, 100) + go func() { + defer close(hostChan) + + p, err := netip.ParsePrefix(cidrStr) + if err != nil { + // 如果不是 CIDR,尝试单 IP 或 域名 + ip := net.ParseIP(cidrStr) + if ip != nil { + select { + case hostChan <- Host{IP: ip, Origin: cidrStr, Type: HostTypeIP}: + case <-ctx.Done(): + } + return + } + if ValidateDomainName(cidrStr) { + select { + case hostChan <- Host{IP: nil, Origin: cidrStr, Type: HostTypeDomain}: + case <-ctx.Done(): + } + return + } + return + } + + if !p.Addr().Is4() && !enableIPv6 { + return + } + + p = p.Masked() + addr := p.Addr() + for { + select { + case <-ctx.Done(): + return + default: + } + + if !p.Contains(addr) { + break + } + + ip := net.ParseIP(addr.String()) + if ip != nil { + select { + case hostChan <- Host{IP: ip, Origin: cidrStr, Type: HostTypeCIDR}: + case <-ctx.Done(): + return + } + } + addr = addr.Next() + } + }() + return hostChan +} + +func ValidateDomainName(domain string) bool { + r := regexp.MustCompile(`(?m)^[A-Za-z0-9\-.]+$`) + return r.MatchString(domain) +} + +func LookupIP(addr string, enableIPv6 bool) (net.IP, error) { + ips, err := net.LookupIP(addr) + if err != nil { + return nil, fmt.Errorf("failed to lookup: %w", err) + } + var arr []net.IP + for _, ip := range ips { + if ip.To4() != nil || enableIPv6 { + arr = append(arr, ip) + } + } + if len(arr) == 0 { + return nil, errors.New("no IP found") + } + return arr[0], nil +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..e72c8eb --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,173 @@ +package scanner + +import ( + "context" + "crypto/tls" + "net" + "os" + "strconv" + "strings" + "sync" + "time" + + "RealityChecker/internal/logger" + + "github.com/oschwald/geoip2-golang" +) + +type Scanner struct { + geoReader *geoip2.Reader +} + +func NewScanner() *Scanner { + s := &Scanner{} + paths := []string{"data/Country.mmdb", "Country.mmdb"} + for _, p := range paths { + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + if r, err := geoip2.Open(p); err == nil { + s.geoReader = r + break + } + } + } + return s +} + +func (s *Scanner) Close() { + if s.geoReader != nil { + s.geoReader.Close() + } +} + +func (s *Scanner) getGeo(ip net.IP) string { + if s.geoReader == nil || ip == nil { + return "N/A" + } + country, err := s.geoReader.Country(ip) + if err != nil || country.Country.IsoCode == "" { + return "N/A" + } + return country.Country.IsoCode +} + +func (s *Scanner) ScanTLS(ctx context.Context, host Host, port int, timeout int, enableIPv6 bool) *ScanResult { + select { + case <-ctx.Done(): + return nil + default: + } + + if host.IP == nil { + ip, err := LookupIP(host.Origin, enableIPv6) + if err != nil { + return nil + } + host.IP = ip + } + + hostPort := net.JoinHostPort(host.IP.String(), strconv.Itoa(port)) + dialer := &net.Dialer{ + Timeout: time.Duration(timeout) * time.Second, + } + + conn, err := dialer.DialContext(ctx, "tcp", hostPort) + if err != nil { + return nil + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(time.Duration(timeout) * time.Second)) + + tlsCfg := &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{"h2", "http/1.1"}, + CurvePreferences: []tls.CurveID{tls.X25519, tls.X25519MLKEM768}, + } + if host.Type == HostTypeDomain { + tlsCfg.ServerName = host.Origin + } + + c := tls.Client(conn, tlsCfg) + if err := c.HandshakeContext(ctx); err != nil { + return nil + } + + state := c.ConnectionState() + if len(state.PeerCertificates) == 0 { + return nil + } + + alpn := state.NegotiatedProtocol + domain := state.PeerCertificates[0].Subject.CommonName + issuers := strings.Join(state.PeerCertificates[0].Issuer.Organization, " | ") + length := 0 + leaf := state.PeerCertificates[0] + + for _, cert := range state.PeerCertificates { + length += len(cert.Raw) + if len(cert.DNSNames) != 0 { + leaf = cert + } + } + + // 只要符合 REALITY 基本硬性条件 TLS1.3 + h2 + if state.Version != tls.VersionTLS13 || alpn != "h2" || len(domain) == 0 { + return nil + } + + geoCode := s.getGeo(host.IP) + + return &ScanResult{ + IP: host.IP.String(), + Origin: host.Origin, + TLSVersion: tls.VersionName(state.Version), + ALPN: alpn, + Curve: state.CurveID.String(), + CertLength: strconv.Itoa(length) + "(certs count: " + strconv.Itoa(len(state.PeerCertificates)) + ")", + CertSignature: leaf.SignatureAlgorithm.String(), + CertPublicKey: leaf.PublicKeyAlgorithm.String(), + CertDomain: domain, + CertIssuer: issuers, + GeoCode: geoCode, + } +} + +// ScanCIDRStream 执行并发扫描并把结果通过 Channel 传出 +func (s *Scanner) ScanCIDRStream(ctx context.Context, cidrStr string, port int, threads int, timeout int, enableIPv6 bool, outChan chan<- *ScanResult) { + hostChan := IterateCIDR(ctx, cidrStr, enableIPv6) + + var wg sync.WaitGroup + for i := 0; i < threads; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for host := range hostChan { + select { + case <-ctx.Done(): + return + default: + } + + if logger.IsDebug() { + logger.Debug("正在尝试 TLS1.3/h2 握手 IP: %s:%d", host.IP.String(), port) + } + + res := s.ScanTLS(ctx, host, port, timeout, enableIPv6) + if res != nil { + if logger.IsDebug() { + logger.Debug("IP: %s 握手成功! 提取证书CN: %s, ALPN: %s, 加密套件: %s", host.IP.String(), res.CertDomain, res.ALPN, res.Curve) + } + select { + case outChan <- res: + case <-ctx.Done(): + return + } + } else if logger.IsDebug() { + logger.Debug("IP: %s 握手失败/未响应/非 TLS1.3+h2", host.IP.String()) + } + } + }() + } + + wg.Wait() +} diff --git a/internal/scanner/types.go b/internal/scanner/types.go new file mode 100644 index 0000000..9ab426d --- /dev/null +++ b/internal/scanner/types.go @@ -0,0 +1,33 @@ +package scanner + +import ( + "net" +) + +type HostType int + +const ( + HostTypeIP HostType = iota + 1 + HostTypeCIDR + HostTypeDomain +) + +type Host struct { + IP net.IP + Origin string + Type HostType +} + +type ScanResult struct { + IP string + Origin string + TLSVersion string + ALPN string + Curve string + CertLength string + CertSignature string + CertPublicKey string + CertDomain string + CertIssuer string + GeoCode string +} diff --git a/internal/types/types.go b/internal/types/types.go index cea2407..2ffc877 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -331,14 +331,31 @@ type TLSConfig struct { NextProtos []string } +// RealityFilterConfig REALITY 选型策略配置 +type RealityFilterConfig struct { + RequireNoCDN bool `yaml:"require_no_cdn"` + MaxHandshakeMS int64 `yaml:"max_handshake_ms"` + RequireNoHot bool `yaml:"require_no_hot"` + MinCertDays int `yaml:"min_cert_days"` + MinStars int `yaml:"min_stars"` +} + +// LogConfig 日志配置 +type LogConfig struct { + Level string `yaml:"level"` + File string `yaml:"file"` +} + // Config 配置结构 type Config struct { - Network NetworkConfig `yaml:"network"` - TLS TLSConfig `yaml:"tls"` - Concurrency ConcurrencyConfig `yaml:"concurrency"` - Output OutputConfig `yaml:"output"` - Cache CacheConfig `yaml:"cache"` - Batch BatchConfig `yaml:"batch"` + Network NetworkConfig `yaml:"network"` + TLS TLSConfig `yaml:"tls"` + Concurrency ConcurrencyConfig `yaml:"concurrency"` + Output OutputConfig `yaml:"output"` + Cache CacheConfig `yaml:"cache"` + Batch BatchConfig `yaml:"batch"` + RealityFilter RealityFilterConfig `yaml:"reality_filter"` + Log LogConfig `yaml:"log"` } // NetworkConfig 网络配置 diff --git a/internal/ui/display.go b/internal/ui/display.go index cbf1ade..8e221e4 100644 --- a/internal/ui/display.go +++ b/internal/ui/display.go @@ -20,13 +20,16 @@ type GitHubRelease struct { func PrintUsage() { fmt.Printf("Reality协议目标网站检测器 %s\n\n", version.GetVersion()) fmt.Println("用法:") + fmt.Println(" reality-checker auto [--limit N] 自动查ASN并管道扫描检测 (推荐)") + fmt.Println(" reality-checker pipe 从标准输入(Stdin)管道流式读取检测") fmt.Println(" reality-checker check 检测单个域名") - fmt.Println(" reality-checker batch ... 批量检测域名") + fmt.Println(" reality-checker batch ... 批量检测多个域名") fmt.Println(" reality-checker csv 从CSV文件批量检测域名") fmt.Println("") fmt.Println("示例:") + fmt.Println(" reality-checker auto 85.155.184.100 --limit 5") + fmt.Println(" ./RealiTLScanner -addr 85.155.184.0/23 -out /dev/stdout | ./reality-checker pipe") fmt.Println(" reality-checker check apple.com") - fmt.Println(" reality-checker batch apple.com tesla.com microsoft.com") fmt.Println(" reality-checker csv file.csv") } @@ -97,22 +100,7 @@ func getLatestVersion() string { // getVersionInfo 获取版本信息字符串 func getVersionInfo() string { - currentVersion := version.GetVersion() // 使用动态版本号 - - latestVersion := getLatestVersion() - - if latestVersion == "" { - // 无法获取最新版本,只显示当前版本 - return currentVersion - } - - if latestVersion == currentVersion { - // 版本相同,只显示当前版本 - return currentVersion - } - - // 版本不同,显示当前版本和最新版本 - return fmt.Sprintf("%s (最新: %s)", currentVersion, latestVersion) + return version.GetVersion() } // getDisplayWidth 计算字符串的显示宽度(中文字符占2个位置) @@ -130,21 +118,6 @@ func getDisplayWidth(s string) int { return width } -// PrintAdvertisement 打印广告信息 +// PrintAdvertisement 打印广告信息 (已移除) func PrintAdvertisement() { - // 使用颜色代码 - blue := "\033[36m" // 青色 - yellow := "\033[33m" // 黄色 - white := "\033[37m" // 白色 - reset := "\033[0m" // 重置颜色 - - fmt.Println() - fmt.Printf("%s-----------------------------------------------------%s\n", white, reset) - fmt.Println() - fmt.Printf("%s %s五年老机场%s %s%shttps://goii.cc/mn%s %s(牧牛云)%s\n", - white, - yellow, reset, - blue, white, reset, - yellow, reset) - fmt.Println() } diff --git a/main.go b/main.go index 34f957a..080a021 100644 --- a/main.go +++ b/main.go @@ -10,9 +10,30 @@ import ( ) func main() { - // 首先显示横幅 - ui.PrintBanner() - + // 若无参数或显式请求 help (-h, --help, help),立刻打印帮助信息并退出,避免触发耗时的数据校验 + if len(os.Args) < 2 { + ui.PrintBanner() + ui.PrintUsage() + os.Exit(0) + } + + subcmd := os.Args[1] + if subcmd == "-h" || subcmd == "--help" || subcmd == "help" { + ui.PrintBanner() + ui.PrintUsage() + os.Exit(0) + } + + // 判断是否需要打印横幅(pipe/auto 或有 --quiet 标记时跳过横幅) + shouldPrintBanner := true + if subcmd == "pipe" || subcmd == "auto" || subcmd == "--quiet" || subcmd == "-q" { + shouldPrintBanner = false + } + + if shouldPrintBanner { + ui.PrintBanner() + } + // 检查并下载必要的数据文件 downloader := data.NewDownloader() if err := downloader.EnsureDataFiles(); err != nil { @@ -32,3 +53,4 @@ func main() { } +