63 Commits
Author SHA1 Message Date
ZacharyZcR d7dbccab76 merge dev into main for v2.2.0-rc.1 re-release
发布 / release (push) Has been cancelled
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
2026-06-15 12:46:01 +08:00
ZacharyZcR 6eff1d5ccf fix: 外部审查 8 项修复 + 国密 TLS 按需回退
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
- UserAgent 默认值回退 + 注册 -ua flag (#2)
- README 编译命令 main.go → . (#3)
- README 版本号同步 rc.1 (#4)
- Client.go gmtls stdout 劫持删除 (#5)
- ms17010 smb1GetResponse size<32 越界 panic (#6)
- SSH 拨号超时统一 ModuleTimeout (#8)
- AddPorts 死字段删除 (#9)
- 国密 TLS 按需回退:标准 TLS 握手失败时仅在错误为
  cipher/protocol 不兼容时尝试国密,跳过超时/拒绝等连接级错误
2026-06-15 04:46:25 +08:00
ZacharyZcR 2f7d2d49c6 fix: redis exploit 超时改用配置值 & 清理死代码
- redis exploit 硬编码 30s deadline 改为 config.ModuleTimeout(),与同文件其他超时一致
- 删除 BaseScanStrategy.LogPluginInfo 残留死代码(全是空操作)
- .gitignore 补充 fscan_cli/fscan_web/embed-agent 构建产物
2026-06-14 23:58:56 +08:00
ZacharyZcR 35f3cf1960 docs: release notes 移除实测验证章节
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
2026-06-14 23:01:39 +08:00
ZacharyZcR 065ba6fae7 docs: 更新 v2.2.0-rc.1 release notes,覆盖全部 29 个 commit 的变更 2026-06-14 23:00:26 +08:00
ZacharyZcR 6d61b661f4 fix: 修复实机测试发现的可靠性问题 (v2.2.0-rc.1)
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
- UDP 插件在 -p 指定端口时被跳过
- Redis exploit 无超时保护 / readReply 吞没非超时错误
- service_probe 连接丢失后静默成功
- SNMP 探测成功但终端无输出
- SSH 爆破不稳定 (并发过高 + 自适应超时过短 + 限流误判)
- 进度条 isActive 竞态

新增 Config.ModuleTimeout() 协议级超时下限 (≥3s)
新增 ErrorTypeThrottle 限流错误分类
2026-06-14 22:23:52 +08:00
ZacharyZcR 3babff6863 fix: -hash 支持 LM:NT 格式 & -debug 日志文件修复
1. -hash 支持标准的 LM:NT 格式 (如 aad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0)
   之前只接受纯 32 字符 NTLM hash,LM:NT 格式报 invalid hash length

2. -debug 日志文件写入修复(已在上一个 commit 中)
2026-06-14 22:23:51 +08:00
ZacharyZcR d7071b7b8e fix: -debug 日志文件写入失败,applyLogLevel 重建 Logger 时丢失 DebugLogFile 配置 2026-06-14 22:23:51 +08:00
ZacharyZcR 6ae37b8892 fix: -nopoc 禁用POC时不再输出错误日志 2026-06-14 22:23:51 +08:00
ZacharyZcR 2b202aa298 fix: 让 -gt 全局超时参数真正生效
-gt 参数之前是死代码:flag 定义了 GlobalTimeout 但从未被使用。
现在在 RunScan 中用它创建带 deadline 的 context,
超时后所有扫描任务(端口扫描、插件执行)被取消。
2026-06-14 22:23:51 +08:00
ZacharyZcR 6d7e6cd394 fix: 修复3个实测输出问题
1. 静默模式NDJSON banner截断至200字符
   Redis INFO响应~5KB导致JSONL单行过长,截断后加...后缀

2. CSV漏洞Type列补全
   ResultTypeVuln的fillDetail未设type字段,CSV Vulns列为空
   统一设为"vulnerability"

3. ICMP权限不足告警精简
   4行告警(listen失败/连接失败/权限不足/切换ping)合并为1行
2026-06-14 22:23:51 +08:00
ZacharyZcR 626d8f79bb fix: 修复3个实测发现的问题
1. -pwd 支持逗号分隔多个密码
   之前 -pwd "123,456,root" 被当作单个密码,SSH root:123 无法匹配
   现在逗号分隔为独立密码,空格保留(可能是密码的一部分)

2. -nobr 禁用爆破时仍检测 Redis 未授权访问
   未授权访问是服务探测不是爆破,不应被 -nobr 跳过
   将未授权检测移到 DisableBrute 判断之前

3. 指定端口时跳过 UDP 插件调度
   -p 80 只扫 HTTP 时不需要 SNMP/BACnet/DNS 等 UDP 探测
   仅在默认端口扫描时才分发 UDP 插件
   效果: -p 80 从 9 秒降到 3 秒
2026-06-14 22:23:51 +08:00
ZacharyZcR a52e93e84c fix: 非终端输出时禁用进度条,防止ANSI控制码覆盖扫描结果 2026-06-14 22:23:50 +08:00
ZacharyZcR 04cae2e42d fix: 彻底解决UDP插件阻塞导致扫描无法结束的问题
根因分析(通过 goroutine dump 定位):
1. UDP conn.Write 在 WSL2 上可能永久阻塞(SetDeadline 对 Write 不生效)
2. SNMP community 爆破混入通用密码字典(57个),串行 × 10s超时 = 10分钟

修复:
- 提取 udpProbe() 公共函数,用 context timeout + conn.Close 双保险
  超时后强制关闭连接,中断阻塞的 Write/Read
- BACnet/DNS/IPMI/TFTP 统一使用 udpProbe()
- SNMP probe 使用 goroutine + context select 保护
- SNMP community 列表不再混入通用密码字典(8个专用 community 足够)
- SNMP 后续 community 爆破用 3s 短超时 + 连续失败 3 次快速退出

效果: 同样的扫描从无限卡死 → 9秒完成
2026-06-14 22:23:50 +08:00
ZacharyZcR 4169eb6ee0 fix: UDP插件改用 conn.SetDeadline 替代类型断言 2026-06-14 22:23:50 +08:00
ZacharyZcR 63631d6cdf fix: 所有UDP插件添加ReadDeadline防止无限阻塞
SNMP/BACnet/DNS/IPMI/TFTP 的 conn.Read() 在目标不响应时
无限阻塞(goroutine 泄漏),导致整个扫描无法结束。

在 Write 前设置 ReadDeadline 确保超时后返回。
2026-06-14 22:23:50 +08:00
ZacharyZcR 4f6bb28138 fix: 修复6个运行时问题
1. 抑制 gmtls 库的 handshake error stdout 噪声
   gmtls/conn.go:1304 硬编码了 fmt.Println,在调用时临时重定向 os.Stdout

2. MySQL 3306 服务名误识别为 genetec-5400
   nmap 指纹库将 MySQL 握手包的随机 salt 误匹配,通过 banner 特征校正

3. 管道输出时自动禁用 ANSI 控制码
   检测 stdout 是否为终端,非终端时自动启用 NoColor

4. 进度条完成消息措辞精确化
   去掉冗余冒号,保持信息简洁一致

5. URL 模式跳过不必要的 TLS 探测
   用户已通过 -u 显式指定 http:// 协议时直接使用,不再做 TLS 握手

6. 无网络探测数据时降低默认重试次数
   -np 跳过存活探测后,将默认重试从 3 降到 2,加速不可达主机的超时
2026-06-14 22:23:49 +08:00
ZacharyZcR 46a6d812a4 fix: DetectPocFormat 误判含 transport 的 fscan POC 为 xray 格式
有 transport 字段但 rules 是数组的 POC(如 apache-httpd-cve-2021-40438)
属于 fscan 格式,不应被 xray 分支兜底。移除错误的 fallback return,
让这类 POC 正确落入 fscan 格式检测分支。

修复前: 388个POC成功380个,失败8个
修复后: 388个POC成功388个,失败0个
2026-06-14 22:23:49 +08:00
ZacharyZcR d4f4e65dec refactor: 4项架构优化 — CEL缓存/POC隔离/服务缓存/结果统一
1. CEL 表达式编译缓存
   - 新增 CelProgCache,同一 POC 的所有规则/参数组合共享编译后的 Program
   - clusterpoc 热路径上消除重复的 Compile+Program 调用

2. POC 全局状态消除
   - allPocs/pocLoaded 全局变量改为 pocStore 按 PocPath 缓存
   - 不同 PocPath 的扫描独立加载,Web API 并发场景不再互相覆盖

3. serviceCache 下沉到 per-session State
   - 服务识别缓存从包级全局 map 迁移到 State.serviceCache (sync.Map)
   - BaseScanStrategy 通过 SetState 注入 session state
   - 消除多个并发扫描之间的服务识别缓存串台

4. POC 结果输出路径统一
   - 提取 buildVulnDetails/buildVulnLogMsg/saveVulnResult 三个公共函数
   - CheckMultiPoc 和 recordVulnerabilityResult 共用统一的结果构造逻辑
   - 消除 details 字段名不一致和日志格式差异
2026-06-14 22:23:49 +08:00
ZacharyZcR a115499793 fix+perf: 修复10个bug & 10项性能优化
Bug修复:
- clustersend CEL结果判断从字符串比较改为类型断言
- Nuclei DSL matcher安全降级为false避免误报
- clusterpoc发现漏洞后返回true修正语义
- reverseCheck加10s超时防止ceye API阻塞
- doSearch/bmatches正则编译结果缓存到sync.Map
- evalset CEL求值失败时存空字符串而非原始表达式
- CEL wait()函数加nil Reverse指针检查防panic
- MongoDB readMongoMsg应用timeout参数设置读超时
- TXTWriter.Close确保Sync失败后仍调用file.Close

性能优化:
- 指纹regex缓存从RWMutex+map改为sync.Map消除锁竞争
- CaseInsensitive指纹词加载时预小写化避免匹配时分配
- 版本提取FindAllStringSubmatch限制返回数量
- i18n.Tr用strconv.Itoa替代Sprintf减少分配
- POC加载用atomic.Bool+DCLP消除热路径锁
- 结果缓冲map预分配容量减少rehash
- HTTP连接池参数随并发数动态调整
- getRuleHash去除反射+Headers排序保证确定性dedup
- POC并发加载用channel替代Mutex收集结果
2026-06-14 22:23:49 +08:00
ZacharyZcR 8402be98e3 优化自适应扫描系统 & 修复 POC 调度问题
自适应扫描优化:
- target/ceiling 分离,自适应池可向上探索而非锁死在 target
- assessHealth 阈值按网络环境区分(LAN 收紧 / Internet 放宽)
- RTT 漂移时动态压低 target,配合 AIMD 双重降速
- 去掉 semaphore 双层流控,由 ants pool 统一反压
- 探测端口从 3 个扩充到 8 个,减少 RTT 采样偏差
- computeRetries 按环境调整目标概率和上限

Bug 修复:
- AdaptivePool.Wait() 加 10 分钟超时,防止 goroutine 卡死时永久挂起
- CEL 环境初始化失败后允许重试(sync.Once → sync.Mutex + 标志位)
- CAS 自旋加 runtime.Gosched() 退避,减少高并发下 CPU 空转
- -full 模式下 web 插件跳过 IsMarkedWebService 检查 #588
- 不确定服务补做 HTTP 回退探测,覆盖自定义框架漏网场景
- POC sets 纯字面量值跳过 CEL 编译,消除大量误报错误日志
2026-06-14 22:23:48 +08:00
ZacharyZcR c49c23c7f0 Harden scan robustness and tests 2026-06-14 22:23:48 +08:00
ZacharyZcR 5ad914a1bb feat: 统一服务缓存 + 指纹驱动插件匹配
将 webServiceCache 扩展为通用 serviceCache,所有指纹识别结果
统一缓存,插件匹配时端口不命中则回退到服务名称匹配。

删除多余的 service_cache.go,复用已有的 ServiceInfo 体系。
补充 nil 防御、Explicit 标记、大量单元/集成/回归测试。
2026-06-14 22:23:47 +08:00
ZacharyZcR 2ab7c4d9b2 fix: 非标准端口的服务无法匹配对应插件 #588
端口扫描识别到 8881 上运行 SSH,但 SSH 插件只注册了 [22,2222,2200,22222],
端口不匹配导致插件不执行。

新增服务名称缓存:端口扫描阶段记录 host:port → serviceName,
插件匹配时端口不命中则回退到服务名称匹配。
2026-06-14 22:23:46 +08:00
ZacharyZcR 5c251b123d fix: 移除误导性的"无可用插件"日志 #588
扫描开始前的插件预检基于端口列表静态匹配,不代表实际扫描中插件
不会执行。移除"无可用插件"提示,避免用户误以为插件未工作。
2026-06-14 22:23:46 +08:00
ZacharyZcR 800cc30794 Fix credential cleanup and explicit tuning flags 2026-06-14 22:23:46 +08:00
ZacharyZcR 8e3cac303d fix: webtitle HTTP 请求失败时重试,修复批量扫描 POC 缺失 #587
批量扫描(-hf)时并发压力导致 HTTP 请求瞬时失败,getWebTitle
直接返回 error,跳过指纹识别和 POC 触发。

加入指数退避重试(200ms→400ms),最多 3 次,复用 config.MaxRetries。
2026-06-14 22:23:45 +08:00
ZacharyZcR 4b79cb7a18 ci: 升级 CI Go 版本到 1.25 2026-06-14 22:23:45 +08:00
ZacharyZcR 9d38874a03 fix: 降级 modernc.org/sqlite 到 v1.39.0 适配 CI 2026-06-14 22:23:45 +08:00
ZacharyZcR 88c7e4f2be feat: 自适应并发调度 — 网络探测 + AIMD + 参数智能推导
扫描前自动探测网络环境(RTT、丢包率、fd limit),基于探测数据
推导 6 个关键参数,替代硬编码默认值:

- Timeout: median_RTT + 4σ(覆盖 99.9% 正常连接)
- ModuleThreadNum: target_concurrency / 30
- MaxRetries: ceil(log(0.01)/log(loss_rate))(全失败概率 <1%)
- ICMPRate: 环境基准 × fd 系数
- PocNum: 跟随 ModuleThreadNum
- DisablePing: 已有 ICMP 权限降级机制

线程池从单信号(资源耗尽率)升级为 AIMD + 慢启动:
- 慢启动:target/4 起步,500ms 翻倍
- 稳态 AIMD:健康 +5%,拥塞 ×0.5
- 双信号:资源耗尽率 + RTT 趋势(双 EMA)

用户 -t 显式指定时作为 ceiling,探测仍调整其他参数。

测试:单元 + 边界 + 集成 + 真实网络,core 包 580+ 用例全通过。
2026-06-14 22:23:45 +08:00
ZacharyZcR e0468ecd35 feat: Web 版独立入口 + SQLite 持久化存储
- 拆分 main.go 为 main_cli.go 和 main_web.go,Web 版不再包含 CLI 参数解析
- Web 版直接启动 HTTP 服务,通过 -port/-lang 控制,无需 -web flag
- 结果存储从内存 map 替换为 SQLite(modernc.org/sqlite,纯 Go 零 CGO)
- 数据库文件 ~/.fscan/results.db,进程重启后结果不丢失
- 修复结果分布面板跟随 tab 筛选联动的问题
2026-06-14 22:23:44 +08:00
ZacharyZcR d0295dcb92 fix: 修复 SSH 扫描 goroutine 泄漏
ssh.NewClientConn 不接受 context,context 取消后底层 TCP 连接未关闭,
导致 readLoop goroutine 永久阻塞在 conn.Read 上。大规模扫描时泄漏数万
goroutine。

- doSSHAuth 新增 goroutine 监听 context 取消并关闭底层连接
- TestSingleCredential 移除 5 秒超时放弃逻辑,改为持续等待清理
2026-06-14 22:23:44 +08:00
ZacharyZcR ade9cd1bff 修复默认扫描 POC 结果缺失 #586 2026-06-14 22:23:44 +08:00
ZacharyZcR 6d91b544de 显示 Web 服务识别 URL 2026-06-14 22:23:44 +08:00
ZacharyZcR 42092d8664 fix: 修复实机测试发现的可靠性问题 (v2.2.0-rc.1)
- UDP 插件在 -p 指定端口时被跳过
- Redis exploit 无超时保护 / readReply 吞没非超时错误
- service_probe 连接丢失后静默成功
- SNMP 探测成功但终端无输出
- SSH 爆破不稳定 (并发过高 + 自适应超时过短 + 限流误判)
- 进度条 isActive 竞态

新增 Config.ModuleTimeout() 协议级超时下限 (≥3s)
新增 ErrorTypeThrottle 限流错误分类
2026-06-14 22:14:02 +08:00
ZacharyZcR bc46e90d89 fix: -hash 支持 LM:NT 格式 & -debug 日志文件修复
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
1. -hash 支持标准的 LM:NT 格式 (如 aad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0)
   之前只接受纯 32 字符 NTLM hash,LM:NT 格式报 invalid hash length

2. -debug 日志文件写入修复(已在上一个 commit 中)
2026-06-14 09:50:10 +08:00
ZacharyZcR 67f2251da3 fix: -debug 日志文件写入失败,applyLogLevel 重建 Logger 时丢失 DebugLogFile 配置 2026-06-14 09:48:03 +08:00
ZacharyZcR e6c5e5a9a8 fix: -nopoc 禁用POC时不再输出错误日志
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
2026-06-13 23:09:47 +08:00
ZacharyZcR 0356485595 fix: 让 -gt 全局超时参数真正生效
-gt 参数之前是死代码:flag 定义了 GlobalTimeout 但从未被使用。
现在在 RunScan 中用它创建带 deadline 的 context,
超时后所有扫描任务(端口扫描、插件执行)被取消。
2026-06-13 23:04:22 +08:00
ZacharyZcR b085df1878 fix: 修复3个实测输出问题
1. 静默模式NDJSON banner截断至200字符
   Redis INFO响应~5KB导致JSONL单行过长,截断后加...后缀

2. CSV漏洞Type列补全
   ResultTypeVuln的fillDetail未设type字段,CSV Vulns列为空
   统一设为"vulnerability"

3. ICMP权限不足告警精简
   4行告警(listen失败/连接失败/权限不足/切换ping)合并为1行
2026-06-13 22:55:02 +08:00
ZacharyZcR 8fb66e8e2f fix: 修复3个实测发现的问题
1. -pwd 支持逗号分隔多个密码
   之前 -pwd "123,456,root" 被当作单个密码,SSH root:123 无法匹配
   现在逗号分隔为独立密码,空格保留(可能是密码的一部分)

2. -nobr 禁用爆破时仍检测 Redis 未授权访问
   未授权访问是服务探测不是爆破,不应被 -nobr 跳过
   将未授权检测移到 DisableBrute 判断之前

3. 指定端口时跳过 UDP 插件调度
   -p 80 只扫 HTTP 时不需要 SNMP/BACnet/DNS 等 UDP 探测
   仅在默认端口扫描时才分发 UDP 插件
   效果: -p 80 从 9 秒降到 3 秒
2026-06-13 22:35:48 +08:00
ZacharyZcR 272b0e28c8 fix: 非终端输出时禁用进度条,防止ANSI控制码覆盖扫描结果 2026-06-13 22:21:03 +08:00
ZacharyZcR 28686f845d fix: 彻底解决UDP插件阻塞导致扫描无法结束的问题
根因分析(通过 goroutine dump 定位):
1. UDP conn.Write 在 WSL2 上可能永久阻塞(SetDeadline 对 Write 不生效)
2. SNMP community 爆破混入通用密码字典(57个),串行 × 10s超时 = 10分钟

修复:
- 提取 udpProbe() 公共函数,用 context timeout + conn.Close 双保险
  超时后强制关闭连接,中断阻塞的 Write/Read
- BACnet/DNS/IPMI/TFTP 统一使用 udpProbe()
- SNMP probe 使用 goroutine + context select 保护
- SNMP community 列表不再混入通用密码字典(8个专用 community 足够)
- SNMP 后续 community 爆破用 3s 短超时 + 连续失败 3 次快速退出

效果: 同样的扫描从无限卡死 → 9秒完成
2026-06-13 22:07:03 +08:00
ZacharyZcR 06ba595e32 fix: UDP插件改用 conn.SetDeadline 替代类型断言 2026-06-13 21:52:26 +08:00
ZacharyZcR 1a7770530d fix: 所有UDP插件添加ReadDeadline防止无限阻塞
SNMP/BACnet/DNS/IPMI/TFTP 的 conn.Read() 在目标不响应时
无限阻塞(goroutine 泄漏),导致整个扫描无法结束。

在 Write 前设置 ReadDeadline 确保超时后返回。
2026-06-13 21:42:49 +08:00
ZacharyZcR 2a9a3c36e2 fix: 修复6个运行时问题
1. 抑制 gmtls 库的 handshake error stdout 噪声
   gmtls/conn.go:1304 硬编码了 fmt.Println,在调用时临时重定向 os.Stdout

2. MySQL 3306 服务名误识别为 genetec-5400
   nmap 指纹库将 MySQL 握手包的随机 salt 误匹配,通过 banner 特征校正

3. 管道输出时自动禁用 ANSI 控制码
   检测 stdout 是否为终端,非终端时自动启用 NoColor

4. 进度条完成消息措辞精确化
   去掉冗余冒号,保持信息简洁一致

5. URL 模式跳过不必要的 TLS 探测
   用户已通过 -u 显式指定 http:// 协议时直接使用,不再做 TLS 握手

6. 无网络探测数据时降低默认重试次数
   -np 跳过存活探测后,将默认重试从 3 降到 2,加速不可达主机的超时
2026-06-13 19:53:28 +08:00
ZacharyZcR 70cce742e1 fix: DetectPocFormat 误判含 transport 的 fscan POC 为 xray 格式
有 transport 字段但 rules 是数组的 POC(如 apache-httpd-cve-2021-40438)
属于 fscan 格式,不应被 xray 分支兜底。移除错误的 fallback return,
让这类 POC 正确落入 fscan 格式检测分支。

修复前: 388个POC成功380个,失败8个
修复后: 388个POC成功388个,失败0个
2026-06-13 19:31:16 +08:00
ZacharyZcR 02ad8f5334 refactor: 4项架构优化 — CEL缓存/POC隔离/服务缓存/结果统一
1. CEL 表达式编译缓存
   - 新增 CelProgCache,同一 POC 的所有规则/参数组合共享编译后的 Program
   - clusterpoc 热路径上消除重复的 Compile+Program 调用

2. POC 全局状态消除
   - allPocs/pocLoaded 全局变量改为 pocStore 按 PocPath 缓存
   - 不同 PocPath 的扫描独立加载,Web API 并发场景不再互相覆盖

3. serviceCache 下沉到 per-session State
   - 服务识别缓存从包级全局 map 迁移到 State.serviceCache (sync.Map)
   - BaseScanStrategy 通过 SetState 注入 session state
   - 消除多个并发扫描之间的服务识别缓存串台

4. POC 结果输出路径统一
   - 提取 buildVulnDetails/buildVulnLogMsg/saveVulnResult 三个公共函数
   - CheckMultiPoc 和 recordVulnerabilityResult 共用统一的结果构造逻辑
   - 消除 details 字段名不一致和日志格式差异
2026-06-13 19:24:57 +08:00
ZacharyZcR 6a1636112f fix+perf: 修复10个bug & 10项性能优化
Bug修复:
- clustersend CEL结果判断从字符串比较改为类型断言
- Nuclei DSL matcher安全降级为false避免误报
- clusterpoc发现漏洞后返回true修正语义
- reverseCheck加10s超时防止ceye API阻塞
- doSearch/bmatches正则编译结果缓存到sync.Map
- evalset CEL求值失败时存空字符串而非原始表达式
- CEL wait()函数加nil Reverse指针检查防panic
- MongoDB readMongoMsg应用timeout参数设置读超时
- TXTWriter.Close确保Sync失败后仍调用file.Close

性能优化:
- 指纹regex缓存从RWMutex+map改为sync.Map消除锁竞争
- CaseInsensitive指纹词加载时预小写化避免匹配时分配
- 版本提取FindAllStringSubmatch限制返回数量
- i18n.Tr用strconv.Itoa替代Sprintf减少分配
- POC加载用atomic.Bool+DCLP消除热路径锁
- 结果缓冲map预分配容量减少rehash
- HTTP连接池参数随并发数动态调整
- getRuleHash去除反射+Headers排序保证确定性dedup
- POC并发加载用channel替代Mutex收集结果
2026-06-13 18:46:14 +08:00
ZacharyZcR 45ebe7040e 优化自适应扫描系统 & 修复 POC 调度问题
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
自适应扫描优化:
- target/ceiling 分离,自适应池可向上探索而非锁死在 target
- assessHealth 阈值按网络环境区分(LAN 收紧 / Internet 放宽)
- RTT 漂移时动态压低 target,配合 AIMD 双重降速
- 去掉 semaphore 双层流控,由 ants pool 统一反压
- 探测端口从 3 个扩充到 8 个,减少 RTT 采样偏差
- computeRetries 按环境调整目标概率和上限

Bug 修复:
- AdaptivePool.Wait() 加 10 分钟超时,防止 goroutine 卡死时永久挂起
- CEL 环境初始化失败后允许重试(sync.Once → sync.Mutex + 标志位)
- CAS 自旋加 runtime.Gosched() 退避,减少高并发下 CPU 空转
- -full 模式下 web 插件跳过 IsMarkedWebService 检查 #588
- 不确定服务补做 HTTP 回退探测,覆盖自定义框架漏网场景
- POC sets 纯字面量值跳过 CEL 编译,消除大量误报错误日志
2026-06-13 12:39:24 +08:00
ZacharyZcR 15a7670ba2 Harden scan robustness and tests 2026-06-13 07:55:37 +08:00
ZacharyZcR 1595c92aed feat: 统一服务缓存 + 指纹驱动插件匹配
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
将 webServiceCache 扩展为通用 serviceCache,所有指纹识别结果
统一缓存,插件匹配时端口不命中则回退到服务名称匹配。

删除多余的 service_cache.go,复用已有的 ServiceInfo 体系。
补充 nil 防御、Explicit 标记、大量单元/集成/回归测试。
2026-06-12 19:49:07 +08:00
ZacharyZcR 517133f72f fix: 非标准端口的服务无法匹配对应插件 #588
端口扫描识别到 8881 上运行 SSH,但 SSH 插件只注册了 [22,2222,2200,22222],
端口不匹配导致插件不执行。

新增服务名称缓存:端口扫描阶段记录 host:port → serviceName,
插件匹配时端口不命中则回退到服务名称匹配。
2026-06-12 19:31:56 +08:00
ZacharyZcR 0918eb38a6 fix: 移除误导性的"无可用插件"日志 #588
扫描开始前的插件预检基于端口列表静态匹配,不代表实际扫描中插件
不会执行。移除"无可用插件"提示,避免用户误以为插件未工作。
2026-06-12 16:36:27 +08:00
ZacharyZcR 5b7e72e56e Fix credential cleanup and explicit tuning flags 2026-06-12 15:30:44 +08:00
ZacharyZcR 52f872b8d1 fix: webtitle HTTP 请求失败时重试,修复批量扫描 POC 缺失 #587
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
批量扫描(-hf)时并发压力导致 HTTP 请求瞬时失败,getWebTitle
直接返回 error,跳过指纹识别和 POC 触发。

加入指数退避重试(200ms→400ms),最多 3 次,复用 config.MaxRetries。
2026-06-12 12:23:54 +08:00
ZacharyZcR 66b175d623 ci: 升级 CI Go 版本到 1.25 2026-06-12 11:41:49 +08:00
ZacharyZcR 6b5bc191ca fix: 降级 modernc.org/sqlite 到 v1.39.0 适配 CI 2026-06-12 10:11:34 +08:00
ZacharyZcR f883944b2b feat: 自适应并发调度 — 网络探测 + AIMD + 参数智能推导
扫描前自动探测网络环境(RTT、丢包率、fd limit),基于探测数据
推导 6 个关键参数,替代硬编码默认值:

- Timeout: median_RTT + 4σ(覆盖 99.9% 正常连接)
- ModuleThreadNum: target_concurrency / 30
- MaxRetries: ceil(log(0.01)/log(loss_rate))(全失败概率 <1%)
- ICMPRate: 环境基准 × fd 系数
- PocNum: 跟随 ModuleThreadNum
- DisablePing: 已有 ICMP 权限降级机制

线程池从单信号(资源耗尽率)升级为 AIMD + 慢启动:
- 慢启动:target/4 起步,500ms 翻倍
- 稳态 AIMD:健康 +5%,拥塞 ×0.5
- 双信号:资源耗尽率 + RTT 趋势(双 EMA)

用户 -t 显式指定时作为 ceiling,探测仍调整其他参数。

测试:单元 + 边界 + 集成 + 真实网络,core 包 580+ 用例全通过。
2026-06-12 09:46:02 +08:00
ZacharyZcR 683707fcd4 feat: Web 版独立入口 + SQLite 持久化存储
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
- 拆分 main.go 为 main_cli.go 和 main_web.go,Web 版不再包含 CLI 参数解析
- Web 版直接启动 HTTP 服务,通过 -port/-lang 控制,无需 -web flag
- 结果存储从内存 map 替换为 SQLite(modernc.org/sqlite,纯 Go 零 CGO)
- 数据库文件 ~/.fscan/results.db,进程重启后结果不丢失
- 修复结果分布面板跟随 tab 筛选联动的问题
2026-06-12 05:17:26 +08:00
ZacharyZcR b94e8bc4ca fix: 修复 SSH 扫描 goroutine 泄漏
ssh.NewClientConn 不接受 context,context 取消后底层 TCP 连接未关闭,
导致 readLoop goroutine 永久阻塞在 conn.Read 上。大规模扫描时泄漏数万
goroutine。

- doSSHAuth 新增 goroutine 监听 context 取消并关闭底层连接
- TestSingleCredential 移除 5 秒超时放弃逻辑,改为持续等待清理
2026-06-12 03:59:02 +08:00
ZacharyZcR 4198c1abc8 修复默认扫描 POC 结果缺失 #586
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled
2026-06-04 14:41:49 +08:00
ZacharyZcR 68f990d20b 显示 Web 服务识别 URL 2026-06-04 14:26:20 +08:00
188 changed files with 11774 additions and 1744 deletions
+117
View File
@@ -0,0 +1,117 @@
# fscan v2.2.0-rc.1
> ⚠️ **这是预发布版本 (Release Candidate)**,可能存在未发现的问题。
> 如果你在使用中遇到任何异常,请积极通过 [Issue](https://github.com/shadow1ng/fscan/issues/new/choose) 反馈,帮助我们尽快稳定正式版。
> 生产环境建议继续使用 [v2.1.3](https://github.com/shadow1ng/fscan/releases/tag/v2.1.3)。
---
## 与 v2.2.0-rc 的变更
本版本基于大量实机测试反馈,**修复 30+ 个问题,新增自适应扫描系统**。183 个文件变更。
---
### 🚀 新功能
#### 自适应并发调度
扫描前自动探测网络环境(RTT、丢包率、fd limit),基于探测数据推导关键参数,替代硬编码默认值:
- **Timeout**: `median_RTT + 4σ`(覆盖 99.9% 正常连接),下限 1s,上限 10s
- **ModuleThreadNum**: `ThreadNum / 30`,下限 5,上限 50
- **MaxRetries**: 基于丢包率推导,保证全失败概率 <1%
- **ICMPRate / PocNum**: 跟随环境和并发自动调整
线程池升级为 **AIMD + 慢启动**:慢启动阶段 500ms 翻倍,稳态 AIMD(健康 +5%,拥塞 ×0.5),双信号(资源耗尽率 + RTT 趋势)驱动。
#### 协议级超时下限(ModuleTimeout)
新增 `Config.ModuleTimeout()` 方法,保证插件级交互超时不低于 3s。自适应系统将端口扫描超时压到 1s 时,SSH 握手、SNMP 探测、数据库认证等多轮交互协议不再受影响。全部 44 个服务插件已迁移。
#### 限流错误分类(ErrorTypeThrottle)
新增 `ErrorTypeThrottle` 错误类型,区分服务端限流(SSH MaxStartups 等)和真正的网络不可达。限流错误不计入连续失败计数,触发 500ms 退避后继续,避免误判目标不可达而提前放弃。
#### Web 版独立入口
- 拆分 `main.go` 为 `main_cli.go` 和 `main_web.go`
- Web 版结果存储从内存替换为 SQLite 持久化(纯 Go 零 CGO)
---
### 🐛 Bug 修复
#### 插件调度(#586 #587 #588)
- **非标准端口服务无法匹配插件** — SSH 在 8881 端口,端口匹配失败导致插件不执行。新增服务名称缓存 + 指纹驱动回退匹配 (#588)
- **移除误导性的"无可用插件"日志** — 预检基于静态端口匹配,不代表实际不执行 (#588)
- **默认扫描 POC 结果缺失** — `executeRules` 返回空 `vulName` 导致检测结果被丢弃 (#586)
- **批量扫描(-hf)POC 缺失** — 并发压力下 HTTP 请求瞬时失败未重试,跳过指纹识别和 POC 触发。加入指数退避重试 (#587)
- **UDP 插件在 `-p` 指定端口时被跳过** — 现在按用户指定端口过滤并正确调度
- **`-full` 模式下 Web 插件跳过 `IsMarkedWebService` 检查**
- **不确定服务补做 HTTP 回退探测**,覆盖自定义 HTTP 框架漏网场景
#### UDP 插件
- **UDP 插件阻塞导致扫描无法结束** — `conn.Read()` 在目标不响应时无限阻塞。所有 UDP 插件(SNMP/BACnet/DNS/IPMI/TFTP)统一使用 context timeout + conn.Close 双保险
- **SNMP community 爆破混入通用密码字典** — 57 个通用密码串行探测导致 10 分钟阻塞,精简为 8 个专用 community
#### SSH
- **SSH goroutine 泄漏** — `ssh.NewClientConn` 不接受 context,context 取消后底层 TCP 连接未关闭,大规模扫描时泄漏数万 goroutine
- **SSH 握手无 TCP deadline 兜底** — 在 `NewClientConn` 前设置 deadline,握手成功后清除
- **SSH 爆破并发过高** — 从 30 降至 3,避免触发 OpenSSH MaxStartups 限流
#### Redis
- **Redis exploit 无超时保护** — exploit 阶段移除了全部 deadline,改为 30s 超时
- **Redis readReply 吞没非超时错误** — 现在仅对 timeout 类型错误做容忍
#### POC 引擎
- **DetectPocFormat 误判含 transport 的 fscan POC 为 xray 格式** — 修复后 388 个 POC 全部正确加载(之前 8 个失败)
- **CEL clustersend 结果判断错误** — 从字符串比较改为类型断言
- **CEL wait() 函数 nil Reverse 指针 panic**
- **reverseCheck 无超时** — 加 10s 超时防止 ceye API 阻塞
- **正则编译结果未缓存** — `doSearch`/`bmatches` 缓存到 `sync.Map`
#### 参数与输出
- **`-gt` 全局超时参数是死代码** — 现在真正生效,超时后取消所有扫描任务
- **`-nopoc` 禁用 POC 时仍输出错误日志** — 已修复
- **`-debug` 日志文件写入失败** — `applyLogLevel` 重建 Logger 时丢失 `DebugLogFile` 配置
- **`-hash` 不支持 LM:NT 格式** — 现在支持 `aad3b435b51404ee:31d6cfe0d16ae931...` 标准格式
- **`-pwd` 不支持逗号分隔多个密码** — 现在 `-pwd "123,456,root"` 正确拆分
- **`-nobr` 跳过了 Redis 未授权检测** — 未授权是服务探测不是爆破,不受 `-nobr` 影响
- **非终端输出时 ANSI 控制码覆盖扫描结果** — 管道/重定向时自动禁用进度条和颜色
- **静默模式 NDJSON banner 过长** — Redis INFO ~5KB 截断至 200 字符
- **CSV 漏洞 Type 列为空** — 补全 `type` 字段
- **SNMP 探测成功但终端无输出** — 补充 `session.LogVuln` 调用
#### 其他
- **service_probe 连接丢失后静默成功** — `Write`/`Read` 在 `Conn=nil` 时返回明确错误
- **MongoDB readMongoMsg 未设置读超时**
- **TXTWriter.Close Sync 失败后未关闭文件**
- **MySQL 3306 服务名误识别为 genetec-5400** — nmap 指纹库误匹配,通过 banner 特征校正
- **gmtls stdout 竞态** — 移除 `os.Stdout` 非同步重定向
---
### 🏗️ 架构优化
- **统一服务缓存** — `webServiceCache` 扩展为通用 `serviceCache`,下沉到 per-session State,消除多实例缓存串台
- **CEL 表达式编译缓存** — 同一 POC 的所有规则共享编译后的 Program
- **POC 全局状态消除** — `allPocs` 全局变量改为 `pocStore` 按 PocPath 缓存,并发场景不再互相覆盖
- **进度条竞态修复** — `isActive` 改为 `atomic.Bool`
- **Lint 全量修复** — cassandra/ipmi/mongodb/webscan 的 ineffassign、unused、errcheck
---
## 反馈与贡献
- 🐛 发现 Bug → [提交 Bug 报告](https://github.com/shadow1ng/fscan/issues/new?template=bug_report.yml)
- 🎯 结果不准 → [提交误报/漏报](https://github.com/shadow1ng/fscan/issues/new?template=false_positive.yml)
- ✨ 功能建议 → [提交功能请求](https://github.com/shadow1ng/fscan/issues/new?template=feature_request.yml)
- 💬 使用疑问 → [Discussions](https://github.com/shadow1ng/fscan/discussions)
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
uses: ./.github/actions/build-release
with:
mode: ${{ inputs.snapshot && 'snapshot' || 'release' }}
go-version: '1.20'
go-version: '1.25'
retention-days: '90'
release-args: ${{ inputs.draft && '--draft' || '' }}
+3 -3
View File
@@ -54,7 +54,7 @@ jobs:
- name: 设置 Go 环境
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version: '1.25'
cache: true
- name: 运行 golangci-lint
@@ -114,7 +114,7 @@ jobs:
- name: 设置 Go 环境
uses: actions/setup-go@v5
with:
go-version: '1.20'
go-version: '1.25'
cache: true
- name: 下载依赖
@@ -181,7 +181,7 @@ jobs:
- name: 设置 Go 环境
uses: actions/setup-go@v5
with:
go-version: '1.20'
go-version: '1.25'
cache: true
- name: 构建验证
+3
View File
@@ -58,6 +58,9 @@ bin/
*.dll
*.so
*.dylib
/fscan_cli
/fscan_web
/embed-agent
# Web UI build / Web前端构建
web-ui/node_modules/
+1 -1
View File
@@ -64,7 +64,7 @@ build-web: build-ui
@echo "$(BLUE)构建Web版本...$(NC)"
$(GO) build -tags web -ldflags="-s -w" -trimpath -o $(BINARY_NAME)-web .
@echo "$(GREEN)✓ 构建完成: $(BINARY_NAME)-web$(NC)"
@echo "$(BLUE)提示: 运行 ./$(BINARY_NAME)-web -web 启动Web界面$(NC)"
@echo "$(BLUE)提示: 运行 ./$(BINARY_NAME)-web 启动Web界面(默认端口 10240)$(NC)"
## build-ui: 构建前端(需要Node.js和npm)
build-ui:
+3 -3
View File
@@ -4,7 +4,7 @@
内网综合扫描工具,一键自动化漏扫。
**版本**: 2.2.0-rc
**版本**: 2.2.0-rc.1
## 功能特性
@@ -186,10 +186,10 @@
```bash
# 标准编译
go build -ldflags="-s -w" -trimpath -o fscan main.go
go build -ldflags="-s -w" -trimpath -o fscan .
# 带Web管理界面
go build -tags web -ldflags="-s -w" -trimpath -o fscan main.go
go build -tags web -ldflags="-s -w" -trimpath -o fscan-web .
```
## 安装
+3 -3
View File
@@ -4,7 +4,7 @@
Comprehensive intranet scanning tool for automated vulnerability assessment.
**Version**: 2.2.0-rc
**Version**: 2.2.0-rc.1.1
## Features
@@ -185,10 +185,10 @@ Comprehensive intranet scanning tool for automated vulnerability assessment.
```bash
# Standard build
go build -ldflags="-s -w" -trimpath -o fscan main.go
go build -ldflags="-s -w" -trimpath -o fscan .
# With Web UI
go build -tags web -ldflags="-s -w" -trimpath -o fscan main.go
go build -tags web -ldflags="-s -w" -trimpath -o fscan-web .
```
## Install
+49 -5
View File
@@ -4,6 +4,7 @@ import (
"encoding/hex"
"fmt"
"net"
"net/url"
"strconv"
"strings"
@@ -128,9 +129,14 @@ func parseUsernames(fv *FlagVars) ([]string, error) {
func parsePasswords(fv *FlagVars) ([]string, error) {
var passwords []string
// 命令行密码
// 命令行密码(支持逗号分隔多个值,保留空格作为密码的一部分)
if fv.Password != "" {
passwords = append(passwords, fv.Password)
for _, p := range strings.Split(fv.Password, ",") {
p = strings.TrimSpace(p)
if p != "" {
passwords = append(passwords, p)
}
}
}
// 从文件读取
@@ -171,6 +177,7 @@ func parseUserPassPairs(fv *FlagVars) ([]config.CredentialPair, error) {
// 如果命令行同时指定了单个用户名和单个密码(不是逗号分隔的多个)
if fv.Username != "" && fv.Password != "" &&
!strings.Contains(fv.Username, ",") && !strings.Contains(fv.Password, ",") &&
fv.AddUsers == "" && fv.AddPasswords == "" &&
fv.UsersFile == "" && fv.PasswordsFile == "" && fv.UserPassFile == "" {
pairs = append(pairs, config.CredentialPair{
Username: strings.TrimSpace(fv.Username),
@@ -195,11 +202,15 @@ func parseHashes(fv *FlagVars) ([]string, [][]byte, error) {
var hashValues []string
var hashBytes [][]byte
// 命令行哈希
// 命令行哈希(支持纯 NTLM 32字符 或 LM:NT 格式)
if fv.HashValue != "" {
hash := strings.TrimSpace(fv.HashValue)
// LM:NT 格式取 NT hash 部分
if parts := strings.SplitN(hash, ":", 2); len(parts) == 2 && len(parts[1]) == 32 {
hash = parts[1]
}
if len(hash) != 32 {
return nil, nil, fmt.Errorf("invalid hash length: %s", hash)
return nil, nil, fmt.Errorf("invalid hash length: %s", fv.HashValue)
}
hashByte, err := hex.DecodeString(hash)
if err != nil {
@@ -294,10 +305,43 @@ func normalizeURL(rawURL string) string {
}
lowerURL := strings.ToLower(rawURL)
if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") {
return "http://" + rawURL
return "http://" + normalizeSchemelessURLTarget(rawURL)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return rawURL
}
normalizedHost := normalizeURLHost(parsed.Host)
if normalizedHost == parsed.Host {
return rawURL
}
parsed.Host = normalizedHost
normalized := parsed.String()
if schemeEnd := strings.Index(rawURL, "://"); schemeEnd >= 0 {
return rawURL[:schemeEnd] + normalized[len(parsed.Scheme):]
}
return normalized
}
func normalizeSchemelessURLTarget(rawURL string) string {
authority := rawURL
suffix := ""
if idx := strings.IndexAny(rawURL, "/?#"); idx >= 0 {
authority = rawURL[:idx]
suffix = rawURL[idx:]
}
return normalizeURLHost(authority) + suffix
}
func normalizeURLHost(host string) string {
if strings.HasPrefix(host, "[") {
return host
}
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
// =============================================================================
// 日志级别应用
+121 -2
View File
@@ -3,11 +3,13 @@ package common
import (
"reflect"
"testing"
fscanconfig "github.com/shadow1ng/fscan/common/config"
)
func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) {
fv := &FlagVars{
Password: "root admin",
Password: "root admin,pass0",
AddPasswords: "pass1 pass2,pass3\tpass4",
}
@@ -15,7 +17,8 @@ func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) {
if err != nil {
t.Fatalf("parsePasswords error = %v", err)
}
want := []string{"root admin", "pass1", "pass2", "pass3", "pass4"}
// -pwd 逗号分隔,空格保留;-pwda 逗号/空格/tab 分隔
want := []string{"root admin", "pass0", "pass1", "pass2", "pass3", "pass4"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parsePasswords() = %#v, want %#v", got, want)
}
@@ -49,6 +52,100 @@ func TestBuildConfigRejectsInvalidHashValue(t *testing.T) {
}
}
func TestBuildConfigDefaultsAreIndependentCopies(t *testing.T) {
cfg, _, err := BuildConfig(&FlagVars{Username: "custom-user"}, &HostInfo{})
if err != nil {
t.Fatalf("BuildConfig error = %v", err)
}
defaultSSHUsers := fscanconfig.DefaultUserDict["ssh"]
if len(defaultSSHUsers) == 1 && defaultSSHUsers[0] == "custom-user" {
t.Fatal("BuildConfig mutated DefaultUserDict")
}
cfg.Credentials.Userdict["ssh"][0] = "mutated-user"
if fscanconfig.DefaultUserDict["ssh"][0] == "mutated-user" {
t.Fatal("Config userdict shares backing storage with DefaultUserDict")
}
cfg.Credentials.Passwords[0] = "mutated-password"
if fscanconfig.DefaultPasswords[0] == "mutated-password" {
t.Fatal("Config passwords share backing storage with DefaultPasswords")
}
port := 80
cfg.PortMap[port][0] = "mutated-probe"
if fscanconfig.DefaultPortMap[port][0] == "mutated-probe" {
t.Fatal("Config port map shares backing storage with DefaultPortMap")
}
cfg.DefaultMap[0] = "mutated-default-probe"
if fscanconfig.DefaultProbeMap[0] == "mutated-default-probe" {
t.Fatal("Config default map shares backing storage with DefaultProbeMap")
}
}
func TestParseUserPassPairsKeepsAdditionalCredentialFlags(t *testing.T) {
tests := []struct {
name string
fv *FlagVars
}{
{
name: "additional passwords",
fv: &FlagVars{
Username: "root",
Password: "primary",
AddPasswords: "extra",
},
},
{
name: "additional users",
fv: &FlagVars{
Username: "root",
Password: "primary",
AddUsers: "admin",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pairs, err := parseUserPassPairs(tt.fv)
if err != nil {
t.Fatalf("parseUserPassPairs error = %v", err)
}
if len(pairs) != 0 {
t.Fatalf("parseUserPassPairs returned exact pairs %#v; additional credential flags would be ignored", pairs)
}
})
}
}
func TestNewConfigDefaultsAreIndependentCopies(t *testing.T) {
cfg := NewConfig()
cfg.Credentials.Userdict["ssh"][0] = "mutated-user"
if fscanconfig.DefaultUserDict["ssh"][0] == "mutated-user" {
t.Fatal("NewConfig userdict shares backing storage with DefaultUserDict")
}
cfg.Credentials.Passwords[0] = "mutated-password"
if fscanconfig.DefaultPasswords[0] == "mutated-password" {
t.Fatal("NewConfig passwords share backing storage with DefaultPasswords")
}
port := 80
cfg.PortMap[port][0] = "mutated-probe"
if fscanconfig.DefaultPortMap[port][0] == "mutated-probe" {
t.Fatal("NewConfig port map shares backing storage with DefaultPortMap")
}
cfg.DefaultMap[0] = "mutated-default-probe"
if fscanconfig.DefaultProbeMap[0] == "mutated-default-probe" {
t.Fatal("NewConfig default map shares backing storage with DefaultProbeMap")
}
}
func TestParseTargetsHostPortDoesNotLeaveSyntheticHost(t *testing.T) {
fv := &FlagVars{Ports: "22"}
info := &HostInfo{Host: "127.0.0.1:8080"}
@@ -73,3 +170,25 @@ func TestNormalizeURLKeepsUppercaseScheme(t *testing.T) {
t.Fatalf("normalizeURL() = %q", got)
}
}
func TestNormalizeURLBracketsIPv6Literals(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "bare ipv6 without scheme", in: "2001:db8::1", want: "http://[2001:db8::1]"},
{name: "bracketed ipv6 without scheme", in: "[2001:db8::1]", want: "http://[2001:db8::1]"},
{name: "bare ipv6 with scheme", in: "http://2001:db8::1", want: "http://[2001:db8::1]"},
{name: "bare ipv6 path without scheme", in: "2001:db8::1/admin", want: "http://[2001:db8::1]/admin"},
{name: "bare ipv6 query without scheme", in: "2001:db8::1?debug=1", want: "http://[2001:db8::1]?debug=1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeURL(tt.in); got != tt.want {
t.Fatalf("normalizeURL(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+56 -4
View File
@@ -23,8 +23,12 @@ config_struct.go - 配置结构体定义
type Config struct {
// 高频访问字段 - 平铺到顶层
Timeout time.Duration // 通用超时
TimeoutExplicit bool // 用户显式指定了 -time
ThreadNum int // 主线程数
ThreadCeiling int // 线程数上限(自适应池允许的最大值)
ThreadNumExplicit bool // 用户显式指定了 -t
ModuleThreadNum int // 模块线程数
ModuleThreadNumExplicit bool // 用户显式指定了 -mt
DisableBrute bool // 禁用暴力破解
DisablePing bool // 禁用Ping检测
DisableTcpProbe bool // 禁用TCP补充探测
@@ -35,6 +39,8 @@ type Config struct {
LocalPlugin string // 本地插件名
AliveOnly bool // 仅存活检测
MaxRetries int // 最大重试次数
MaxRetriesExplicit bool // 用户显式指定了 -retry
DetectedNetworkEnv int // 探测到的网络环境(来自 core.NetworkEnv)
// 高级功能(从AdvancedConfig合并)
Shellcode string // Shellcode
@@ -55,6 +61,9 @@ type Config struct {
LocalExploit LocalExploitConfig
Target TargetConfig // 扫描目标配置
// 全局超时
GlobalTimeout time.Duration
// SOCKS5代理端口配置
Socks5ProxyPort int // SOCKS5代理端口
}
@@ -88,6 +97,7 @@ type NetworkConfig struct {
PacketRateLimit int64
MaxPacketCount int64
ICMPRate float64
ICMPRateExplicit bool
}
// OutputConfig 输出相关配置
@@ -110,6 +120,7 @@ type POCConfig struct {
PocName string // 指定POC名称
Full bool // 完整POC扫描
Num int // POC并发数
NumExplicit bool // 用户显式指定了 -num
Disabled bool // 禁用POC扫描
}
@@ -139,12 +150,53 @@ type LocalExploitConfig struct {
DownloadSavePath string // 下载保存路径
}
func cloneStringSlice(values []string) []string {
if values == nil {
return nil
}
return append([]string(nil), values...)
}
func cloneStringSliceMap(values map[string][]string) map[string][]string {
if values == nil {
return nil
}
cloned := make(map[string][]string, len(values))
for key, value := range values {
cloned[key] = cloneStringSlice(value)
}
return cloned
}
func clonePortMap(values map[int][]string) map[int][]string {
if values == nil {
return nil
}
cloned := make(map[int][]string, len(values))
for key, value := range values {
cloned[key] = cloneStringSlice(value)
}
return cloned
}
const minModuleTimeout = 3 * time.Second
// ModuleTimeout 返回插件级超时(用于弱口令测试、服务交互等多轮协议)
// 保证下限 3s,避免自适应把端口扫描超时压低后影响 SSH/SNMP 等交互型协议
func (c *Config) ModuleTimeout() time.Duration {
if c.Timeout >= minModuleTimeout {
return c.Timeout
}
return minModuleTimeout
}
// NewConfig 创建带默认值的Config(后备用,正常流程使用BuildConfigFromFlags)
func NewConfig() *Config {
return &Config{
// 高频字段 - 使用默认常量
Timeout: time.Duration(DefaultTimeout) * time.Second,
ThreadNum: DefaultThreadNum,
ThreadCeiling: DefaultThreadNum,
ModuleThreadNum: 10,
DisableBrute: false,
DisablePing: false,
@@ -157,13 +209,13 @@ func NewConfig() *Config {
MaxRetries: 3,
// 高级功能 - 使用默认配置
PortMap: config.DefaultPortMap,
DefaultMap: config.DefaultProbeMap,
PortMap: clonePortMap(config.DefaultPortMap),
DefaultMap: cloneStringSlice(config.DefaultProbeMap),
// 分组配置 - 使用默认字典
Credentials: CredentialConfig{
Userdict: config.DefaultUserDict,
Passwords: config.DefaultPasswords,
Userdict: cloneStringSliceMap(config.DefaultUserDict),
Passwords: cloneStringSlice(config.DefaultPasswords),
UserPassPairs: nil,
},
Network: NetworkConfig{
+11
View File
@@ -0,0 +1,11 @@
//go:build !debug
// +build !debug
package debug
import "testing"
func TestStubStartStop(t *testing.T) {
Start()
Stop()
}
+23
View File
@@ -0,0 +1,23 @@
package common
import "testing"
func TestDNSCacheResolveIPAndCacheHit(t *testing.T) {
cache := &dnsCache{}
first, err := cache.ResolveIP("127.0.0.1")
if err != nil {
t.Fatalf("ResolveIP loopback error = %v", err)
}
second, err := cache.ResolveIP("127.0.0.1")
if err != nil {
t.Fatalf("ResolveIP cached loopback error = %v", err)
}
if first != second {
t.Fatal("ResolveIP should return cached address on second lookup")
}
if _, err := cache.ResolveIP("bad host with spaces"); err == nil {
t.Fatal("ResolveIP should reject an invalid host")
}
}
+19
View File
@@ -137,6 +137,7 @@ func Flag(Info *HostInfo) error {
flag.StringVar(&fv.TargetURL, "u", "", i18n.GetText("flag_target_url"))
flag.StringVar(&fv.URLsFile, "uf", "", i18n.GetText("flag_urls_file"))
flag.StringVar(&fv.Cookie, "cookie", "", i18n.GetText("flag_cookie"))
flag.StringVar(&fv.UserAgent, "ua", "", i18n.GetText("flag_user_agent"))
flag.Int64Var(&fv.WebTimeout, "wt", 5, i18n.GetText("flag_web_timeout"))
flag.IntVar(&fv.MaxRedirects, "max-redirect", 10, i18n.GetText("flag_max_redirects"))
flag.StringVar(&fv.HTTPProxy, "proxy", "", i18n.GetText("flag_http_proxy"))
@@ -213,6 +214,24 @@ func Flag(Info *HostInfo) error {
return err
}
// 检测用户是否显式指定了 -t
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "t":
fv.ThreadNumExplicit = true
case "time":
fv.TimeoutExplicit = true
case "mt":
fv.ModuleThreadNumExplicit = true
case "retry":
fv.MaxRetriesExplicit = true
case "icmp-rate":
fv.ICMPRateExplicit = true
case "num":
fv.PocNumExplicit = true
}
})
// 设置语言
i18n.SetLanguage(fv.Language)
+35 -7
View File
@@ -1,9 +1,11 @@
package common
import (
"os"
"time"
"github.com/shadow1ng/fscan/common/config"
"golang.org/x/term"
)
/*
@@ -25,15 +27,17 @@ type FlagVars struct {
ExcludeHostsFile string
Ports string
ExcludePorts string
AddPorts string
HostsFile string
PortsFile string
// 扫描控制
ScanMode string
ThreadNum int
ThreadNumExplicit bool // 用户显式指定了 -t
ModuleThreadNum int
ModuleThreadNumExplicit bool
TimeoutSec int64 // 秒,需转换为 time.Duration
TimeoutExplicit bool
GlobalTimeout int64
DisablePing bool
DisableTcpProbe bool
@@ -41,6 +45,7 @@ type FlagVars struct {
AliveOnly bool
DisableBrute bool
MaxRetries int
MaxRetriesExplicit bool
// 认证凭据
Username string
@@ -73,6 +78,7 @@ type FlagVars struct {
PocFull bool
DNSLog bool
PocNum int
PocNumExplicit bool
DisablePocScan bool
// Redis利用
@@ -87,6 +93,7 @@ type FlagVars struct {
PacketRateLimit int64
MaxPacketCount int64
ICMPRate float64
ICMPRateExplicit bool
// 输出控制
Outputfile string
@@ -135,8 +142,11 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
return &Config{
// 高频字段
Timeout: time.Duration(fv.TimeoutSec) * time.Second,
TimeoutExplicit: fv.TimeoutExplicit,
ThreadNum: fv.ThreadNum,
ThreadNumExplicit: fv.ThreadNumExplicit,
ModuleThreadNum: fv.ModuleThreadNum,
ModuleThreadNumExplicit: fv.ModuleThreadNumExplicit,
DisableBrute: fv.DisableBrute,
DisablePing: fv.DisablePing,
DisableTcpProbe: fv.DisableTcpProbe,
@@ -147,6 +157,7 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
LocalPlugin: fv.LocalPlugin,
AliveOnly: fv.AliveOnly,
MaxRetries: fv.MaxRetries,
MaxRetriesExplicit: fv.MaxRetriesExplicit,
// 高级功能
Shellcode: fv.Shellcode,
@@ -154,8 +165,11 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
DNSLog: fv.DNSLog,
PersistenceTargetFile: fv.PersistenceTargetFile,
WinPEFile: fv.WinPEFile,
PortMap: config.DefaultPortMap,
DefaultMap: config.DefaultProbeMap,
PortMap: clonePortMap(config.DefaultPortMap),
DefaultMap: cloneStringSlice(config.DefaultProbeMap),
// 全局超时
GlobalTimeout: time.Duration(fv.GlobalTimeout) * time.Second,
// SOCKS5代理端口
Socks5ProxyPort: fv.Socks5ProxyPort,
@@ -165,8 +179,8 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
Username: fv.Username,
Password: fv.Password,
Domain: fv.Domain,
Userdict: config.DefaultUserDict,
Passwords: config.DefaultPasswords,
Userdict: cloneStringSliceMap(config.DefaultUserDict),
Passwords: cloneStringSlice(config.DefaultPasswords),
UserPassPairs: nil, // 后续解析
SSHKeyPath: fv.SSHKeyPath,
},
@@ -179,12 +193,13 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
PacketRateLimit: fv.PacketRateLimit,
MaxPacketCount: fv.MaxPacketCount,
ICMPRate: fv.ICMPRate,
ICMPRateExplicit: fv.ICMPRateExplicit,
},
Output: OutputConfig{
File: fv.Outputfile,
Format: fv.OutputFormat,
DisableSave: fv.DisableSave,
NoColor: fv.NoColor,
NoColor: fv.NoColor || !isStdoutTerminal(),
Silent: fv.Silent,
DisableProgress: fv.DisableProgress,
ShowProgress: !fv.DisableProgress,
@@ -197,6 +212,7 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
PocName: fv.PocName,
Full: fv.PocFull,
Num: fv.PocNum,
NumExplicit: fv.PocNumExplicit,
Disabled: fv.DisablePocScan,
},
Redis: RedisConfig{
@@ -209,7 +225,7 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
},
HTTP: HTTPConfig{
Cookie: fv.Cookie,
UserAgent: fv.UserAgent,
UserAgent: defaultUserAgent(fv.UserAgent),
Accept: fv.Accept,
},
LocalExploit: LocalExploitConfig{
@@ -225,3 +241,15 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
},
}
}
func isStdoutTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
// defaultUserAgent 用户未通过 -ua 指定时回退到默认 UA,避免发送空 User-Agent 被 WAF 识别
func defaultUserAgent(ua string) string {
if ua != "" {
return ua
}
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
+3 -2
View File
@@ -955,8 +955,9 @@ func TestBuildConfigFromFlags_BoundaryValues(t *testing.T) {
if cfg.HTTP.Cookie != "" {
t.Errorf("Cookie 应该为空")
}
if cfg.HTTP.UserAgent != "" {
t.Errorf("UserAgent 应该为空")
// 空输入回退到默认 UA,避免发送空 User-Agent
if cfg.HTTP.UserAgent == "" {
t.Errorf("UserAgent 空输入应回退到默认 UA")
}
},
},
+4 -15
View File
@@ -2,19 +2,8 @@
package common
import (
"flag"
// WebMode Web版本始终为true
const WebMode = true
"github.com/shadow1ng/fscan/common/i18n"
)
// WebMode 表示是否启动Web管理界面
var WebMode bool
// WebPort Web服务器端口
var WebPort int
func init() {
flag.BoolVar(&WebMode, "web", false, i18n.GetText("flag_web_mode"))
flag.IntVar(&WebPort, "webport", 10240, i18n.GetText("flag_web_port"))
}
// WebPort 不再使用,端口由 main_web.go 的 -port 参数控制
var WebPort = 0
+6 -2
View File
@@ -31,7 +31,11 @@ type HostInfo struct {
// Target 返回 host:port 格式字符串
func (h *HostInfo) Target() string {
return net.JoinHostPort(h.Host, strconv.Itoa(h.Port))
host := h.Host
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
return net.JoinHostPort(host, strconv.Itoa(h.Port))
}
// =============================================================================
@@ -65,7 +69,7 @@ const (
// 版本信息,通过 ldflags 注入
var (
version = "2.2.0-rc"
version = "2.2.0-rc.1"
commit = "unknown"
date = "unknown"
)
+58 -1
View File
@@ -1,6 +1,10 @@
package common
import "testing"
import (
"errors"
"strings"
"testing"
)
func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) {
info := &HostInfo{Host: "2001:db8::1", Port: 443}
@@ -8,3 +12,56 @@ func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) {
t.Fatalf("Target() = %q, want %q", got, want)
}
}
func TestHostInfoTargetDoesNotDoubleBracketIPv6(t *testing.T) {
info := &HostInfo{Host: "[2001:db8::1]", Port: 443}
if got, want := info.Target(), "[2001:db8::1]:443"; got != want {
t.Fatalf("Target() = %q, want %q", got, want)
}
}
func TestGlobalHelpersAndPacketLimitErrors(t *testing.T) {
if GetVersion() == "" {
t.Fatal("GetVersion returned empty string")
}
if !ContainsAny("hello fscan", "none", "scan") {
t.Fatal("ContainsAny should find a matching substring")
}
if ContainsAny("hello fscan", "none", "missing") {
t.Fatal("ContainsAny should return false when nothing matches")
}
maxErr := &PacketLimitError{Sentinel: ErrMaxPacketReached, Limit: 5, Current: 5}
if !errors.Is(maxErr, ErrMaxPacketReached) || !strings.Contains(maxErr.Error(), "5") {
t.Fatalf("max packet error = %v", maxErr)
}
rateErr := &PacketLimitError{Sentinel: ErrPacketRateLimited, Limit: 3, Current: 2}
if !errors.Is(rateErr, ErrPacketRateLimited) || !strings.Contains(rateErr.Error(), "3") {
t.Fatalf("rate limit error = %v", rateErr)
}
}
func TestCanSendPacketUsesGlobalConfigAndState(t *testing.T) {
previousConfig := GetGlobalConfig()
previousState := GetGlobalState()
t.Cleanup(func() {
SetGlobalConfig(previousConfig)
SetGlobalState(previousState)
})
cfg := NewConfig()
cfg.Network.MaxPacketCount = 1
state := NewState()
state.IncrementPacketCount()
SetGlobalConfig(cfg)
SetGlobalState(state)
ok, reason := CanSendPacket()
if ok {
t.Fatal("CanSendPacket should reject when max packet count is reached")
}
if reason == "" {
t.Fatal("CanSendPacket should return a rejection reason")
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ package i18n
import (
"fmt"
"strconv"
"sync"
"github.com/nicksnyder/go-i18n/v2/i18n"
@@ -80,9 +81,9 @@ func Tr(key string, args ...interface{}) string {
loc := localizer
mu.RUnlock()
data := make(map[string]interface{})
data := make(map[string]interface{}, len(args))
for i, arg := range args {
data[fmt.Sprintf("Arg%d", i+1)] = arg
data["Arg"+strconv.Itoa(i+1)] = arg
}
msg, err := loc.Localize(&i18n.LocalizeConfig{
+37
View File
@@ -0,0 +1,37 @@
package i18n
import (
"strings"
"testing"
)
func TestLanguageLifecycleAndFallbacks(t *testing.T) {
original := GetLanguage()
t.Cleanup(func() { SetLanguage(original) })
SetLanguage(LangEN)
if got := GetLanguage(); got != LangEN {
t.Fatalf("language = %q, want %q", got, LangEN)
}
if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" {
t.Fatalf("english text = %q, want translated text", got)
}
if got := Tr("debug_cpu_profile_started", "/tmp/profiles"); !strings.Contains(got, "/tmp/profiles") {
t.Fatalf("formatted english text = %q, want path included", got)
}
SetLanguage(LangZH)
if got := GetLanguage(); got != LangZH {
t.Fatalf("language = %q, want %q", got, LangZH)
}
if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" {
t.Fatalf("chinese text = %q, want translated text", got)
}
if got := GetText("missing_translation_key"); got != "missing_translation_key" {
t.Fatalf("missing GetText = %q, want key", got)
}
if got := Tr("missing_translation_key", "ignored"); got != "missing_translation_key" {
t.Fatalf("missing Tr = %q, want key", got)
}
}
+25 -1
View File
@@ -64,6 +64,8 @@ flag_urls_file:
other: "URLs file"
flag_cookie:
other: "HTTP Cookie"
flag_user_agent:
other: "Custom User-Agent header"
flag_web_timeout:
other: "Web timeout"
flag_max_redirects:
@@ -198,7 +200,7 @@ scan_alive_hosts_list:
progress_scanning_description:
other: "Scanning Progress"
progress_scan_completed:
other: "Scan Completed:"
other: "Scan Completed"
progress_waiting:
other: "waiting..."
progress_done:
@@ -541,6 +543,28 @@ icmp_debug_stable_done:
other: "[ICMP] response stable, ending early, elapsed {{.Arg1}}, alive {{.Arg2}}/{{.Arg3}}"
adaptive_pool_resource_exhausted:
other: "[AdaptivePool] resource exhaustion rate {{.Arg1}}%, threads {{.Arg2}} -> {{.Arg3}}"
adaptive_pool_decrease:
other: "Concurrency adjusted: {{.Arg1}} -> {{.Arg2}} (pressure detected)"
adaptive_pool_increase:
other: "Concurrency adjusted: {{.Arg1}} -> {{.Arg2}} (network healthy)"
adaptive_pool_slowstart_exit:
other: "Slow start exit: current {{.Arg1}} (congestion detected)"
adaptive_pool_wait_timeout:
other: "Thread pool wait timed out (10 minutes), forcing exit"
net_probe_result:
other: "Network probe: {{.Arg1}}, RTT {{.Arg2}}ms, loss {{.Arg3}}%, concurrency {{.Arg4}}/{{.Arg5}}"
net_env_lan:
other: "LAN"
net_env_wan:
other: "WAN"
net_env_internet:
other: "Internet"
net_env_slow:
other: "Slow network"
env_tune_summary:
other: "Adaptive params: Timeout={{.Arg1}}ms, ModuleThread={{.Arg2}}, Retry={{.Arg3}}, ICMPRate={{.Arg4}}, PocNum={{.Arg5}}"
env_fd_limit:
other: "fd limit constraint: threads {{.Arg1}} -> {{.Arg2}} (ulimit={{.Arg3}})"
# ========================= Service Plugin Messages =========================
# Format: {service}_{type} - type: credential/unauth/service/vuln
+25 -1
View File
@@ -64,6 +64,8 @@ flag_urls_file:
other: "URL文件"
flag_cookie:
other: "HTTP Cookie"
flag_user_agent:
other: "自定义 User-Agent 请求头"
flag_web_timeout:
other: "Web超时时间"
flag_max_redirects:
@@ -198,7 +200,7 @@ scan_alive_hosts_list:
progress_scanning_description:
other: "扫描进度"
progress_scan_completed:
other: "扫描完成:"
other: "扫描完成"
progress_waiting:
other: "等待中..."
progress_done:
@@ -541,6 +543,28 @@ icmp_debug_stable_done:
other: "[ICMP] 响应稳定,提前结束,耗时 {{.Arg1}},存活 {{.Arg2}}/{{.Arg3}}"
adaptive_pool_resource_exhausted:
other: "[AdaptivePool] 资源耗尽率 {{.Arg1}}%, 线程数 {{.Arg2}} -> {{.Arg3}}"
adaptive_pool_decrease:
other: "并发调整: {{.Arg1}} -> {{.Arg2}} (检测到压力)"
adaptive_pool_increase:
other: "并发调整: {{.Arg1}} -> {{.Arg2}} (网络健康)"
adaptive_pool_slowstart_exit:
other: "慢启动退出: 当前 {{.Arg1}} (检测到拥塞)"
adaptive_pool_wait_timeout:
other: "线程池等待超时(10分钟),强制退出"
net_probe_result:
other: "网络探测: {{.Arg1}}, RTT {{.Arg2}}ms, 丢包 {{.Arg3}}%, 并发 {{.Arg4}}/{{.Arg5}}"
net_env_lan:
other: "内网"
net_env_wan:
other: "局域网"
net_env_internet:
other: "公网"
net_env_slow:
other: "慢速网络"
env_tune_summary:
other: "参数自适应: Timeout={{.Arg1}}ms, ModuleThread={{.Arg2}}, Retry={{.Arg3}}, ICMPRate={{.Arg4}}, PocNum={{.Arg5}}"
env_fd_limit:
other: "fd limit 约束: 线程数 {{.Arg1}} -> {{.Arg2}} (ulimit={{.Arg3}})"
# ========================= 服务插件通用消息 =========================
# 格式: {service}_{type} - type: credential/unauth/service/vuln
+55
View File
@@ -0,0 +1,55 @@
package common
import (
"strings"
"testing"
)
func TestValidateExclusiveParams(t *testing.T) {
previous := GetFlagVars()
t.Cleanup(func() { flagVars = previous })
tests := []struct {
name string
info *HostInfo
flags *FlagVars
wantErr string
}{
{name: "host only", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{}},
{name: "url only", info: &HostInfo{}, flags: &FlagVars{TargetURL: "http://example.com"}},
{name: "local only", info: &HostInfo{}, flags: &FlagVars{LocalPlugin: "sshkey"}},
{name: "host and url conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com"}, wantErr: "-h"},
{name: "host url local conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com", LocalPlugin: "sshkey"}, wantErr: "-local"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flagVars = tt.flags
err := ValidateExclusiveParams(tt.info)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("ValidateExclusiveParams error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ValidateExclusiveParams error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestCleanupWithoutOutput(t *testing.T) {
oldResultOutput := ResultOutput
oldStdoutWriter := StdoutWriter
t.Cleanup(func() {
ResultOutput = oldResultOutput
StdoutWriter = oldStdoutWriter
})
ResultOutput = nil
StdoutWriter = nil
if err := Cleanup(); err != nil {
t.Fatalf("Cleanup error = %v", err)
}
}
+63
View File
@@ -0,0 +1,63 @@
package common
import "testing"
func preserveLoggerForTest(t *testing.T) {
t.Helper()
loggerMu.Lock()
oldSilentRefs := silentLoggerRefs
silentLoggerRefs = 0
resetLoggerLocked()
loggerMu.Unlock()
t.Cleanup(func() {
loggerMu.Lock()
closeLoggerLocked()
silentLoggerRefs = oldSilentRefs
resetLoggerLocked()
loggerMu.Unlock()
})
}
func TestLoggerFacadeSilentLifecycle(t *testing.T) {
preserveLoggerForTest(t)
previousFlags := GetFlagVars()
previousState := GetGlobalState()
t.Cleanup(func() {
flagVars = previousFlags
SetGlobalState(previousState)
})
flagVars = &FlagVars{Silent: true, LogLevel: "debug"}
SetGlobalState(NewState())
InitLogger()
LogDebug("debug")
LogInfo("info")
LogSuccess("success")
LogVuln("vuln")
LogError("error")
CloseLogger()
}
func TestPushSilentLoggerReferenceCount(t *testing.T) {
preserveLoggerForTest(t)
restoreOne := PushSilentLogger()
restoreTwo := PushSilentLogger()
if silentLoggerRefs != 2 {
t.Fatalf("silent refs = %d, want 2", silentLoggerRefs)
}
restoreOne()
restoreOne()
if silentLoggerRefs != 1 {
t.Fatalf("silent refs after first restore = %d, want 1", silentLoggerRefs)
}
restoreTwo()
if silentLoggerRefs != 0 {
t.Fatalf("silent refs after second restore = %d, want 0", silentLoggerRefs)
}
}
+40
View File
@@ -2,6 +2,8 @@ package logging
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -160,6 +162,13 @@ func TestLogger_AllLevels(t *testing.T) {
wantMsg: "success message",
wantPfx: PrefixSuccess,
},
{
name: "Vuln级别",
logFunc: logger.Vuln,
message: "vuln message",
wantMsg: "vuln message",
wantPfx: PrefixVuln,
},
{
name: "Error级别",
logFunc: logger.Error,
@@ -650,3 +659,34 @@ func TestLogger_Initialize(t *testing.T) {
t.Logf("✓ Initialize测试通过")
}
func TestLogger_CloseClosesDebugFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "debug.log")
logger := NewLogger(&LoggerConfig{
Level: LevelAll,
EnableColor: false,
ShowProgress: false,
StartTime: time.Now(),
LevelColors: GetDefaultLevelColors(),
DebugLogFile: path,
})
if logger.debugFile == nil {
t.Fatal("debug file should be opened")
}
logger.Info("debug file line")
logger.Close()
if logger.debugFile != nil {
t.Fatal("debug file should be nil after Close")
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read debug file: %v", err)
}
if !strings.Contains(string(content), "debug file line") {
t.Fatalf("debug file content = %q", string(content))
}
logger.Close()
}
+18 -3
View File
@@ -52,16 +52,31 @@ func getGlobalDialer(timeout time.Duration) (proxy.Dialer, error) {
// parseProxyURL 解析代理URL,提取地址和认证信息
func parseProxyURL(proxyURL, fallback string) (host, username, password string) {
parsedURL, err := url.Parse(proxyURL)
if err != nil {
if !strings.Contains(proxyURL, "://") {
if host, username, password, ok := parseProxyURLCandidate("http://" + proxyURL); ok {
return host, username, password
}
}
if host, username, password, ok := parseProxyURLCandidate(proxyURL); ok {
return host, username, password
}
return fallback, "", ""
}
func parseProxyURLCandidate(proxyURL string) (host, username, password string, ok bool) {
parsedURL, err := url.Parse(proxyURL)
if err != nil {
return "", "", "", false
}
host = parsedURL.Host
if host == "" {
return "", "", "", false
}
if parsedURL.User != nil {
username = parsedURL.User.Username()
password, _ = parsedURL.User.Password()
}
return
return host, username, password, true
}
// createProxyConfig 根据全局设置创建代理配置
+52
View File
@@ -0,0 +1,52 @@
package common
import (
"context"
"net/http"
"testing"
"github.com/shadow1ng/fscan/common/proxy"
)
func TestNetworkFacadeProxyState(t *testing.T) {
t.Cleanup(func() { proxy.AutoConfigureProxy(proxy.DefaultProxyConfig()) })
proxy.AutoConfigureProxy(proxy.DefaultProxyConfig())
if IsProxyEnabled() || IsSOCKS5Proxy() || !IsProxyReliable() {
t.Fatal("direct global proxy state should be disabled and reliable")
}
proxy.AutoConfigureProxy(&proxy.ProxyConfig{Type: proxy.ProxyTypeSOCKS5})
if !IsProxyEnabled() || !IsSOCKS5Proxy() || !IsProxyReliable() {
t.Fatal("SOCKS5 global proxy state should be enabled and SOCKS5")
}
}
func TestSafeHTTPDoUsesGlobalPacketLimit(t *testing.T) {
previousConfig := GetGlobalConfig()
previousState := GetGlobalState()
t.Cleanup(func() {
SetGlobalConfig(previousConfig)
SetGlobalState(previousState)
})
cfg := NewConfig()
cfg.Network.MaxPacketCount = 1
state := NewState()
state.IncrementPacketCount()
SetGlobalConfig(cfg)
SetGlobalState(state)
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("transport should not be called when packet limit is reached")
return nil, nil
})}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil)
if err != nil {
t.Fatal(err)
}
if resp, err := SafeHTTPDo(client, req); err == nil || resp != nil {
t.Fatalf("SafeHTTPDo = resp %#v err %v, want limit error", resp, err)
}
}
+4 -4
View File
@@ -22,10 +22,10 @@ type ResultBuffer struct {
// NewResultBuffer 创建新的结果缓冲
func NewResultBuffer() *ResultBuffer {
return &ResultBuffer{
seenHosts: make(map[string]struct{}),
seenPorts: make(map[string]struct{}),
seenServices: make(map[string]int),
seenVulns: make(map[string]struct{}),
seenHosts: make(map[string]struct{}, 256),
seenPorts: make(map[string]struct{}, 512),
seenServices: make(map[string]int, 128),
seenVulns: make(map[string]struct{}, 64),
}
}
+18 -6
View File
@@ -4,7 +4,9 @@ import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
)
@@ -88,7 +90,11 @@ func (w *StdoutNDJSONWriter) flatten(r *ScanResult) *ndjsonRecord {
rec.Service = strVal(d, "service")
rec.Protocol = strVal(d, "protocol")
rec.Banner = strVal(d, "banner")
if banner := strVal(d, "banner"); len(banner) > 200 {
rec.Banner = banner[:200] + "..."
} else {
rec.Banner = banner
}
rec.Title = strVal(d, "title")
rec.URL = strVal(d, "url")
rec.Vulnerability = strVal(d, "vulnerability")
@@ -132,13 +138,19 @@ func toInt(v interface{}) (int, bool) {
}
func splitHostPort(target string) (string, int, bool) {
idx := strings.LastIndex(target, ":")
if idx < 0 {
host, portText, err := net.SplitHostPort(target)
if err != nil {
if strings.Count(target, ":") != 1 {
return "", 0, false
}
host := target[:idx]
var port int
if _, err := fmt.Sscanf(target[idx+1:], "%d", &port); err != nil {
parts := strings.SplitN(target, ":", 2)
host, portText = parts[0], parts[1]
}
port, err := strconv.Atoi(portText)
if err != nil {
return "", 0, false
}
if host == "" || port < 1 || port > 65535 {
return "", 0, false
}
return host, port, true
+122
View File
@@ -0,0 +1,122 @@
package output
import (
"bufio"
"bytes"
"encoding/json"
"testing"
)
func TestSplitHostPort(t *testing.T) {
tests := []struct {
name string
target string
wantHost string
wantPort int
wantOK bool
}{
{name: "ipv4", target: "192.168.1.1:80", wantHost: "192.168.1.1", wantPort: 80, wantOK: true},
{name: "hostname", target: "example.com:443", wantHost: "example.com", wantPort: 443, wantOK: true},
{name: "bracketed ipv6", target: "[2001:db8::1]:8443", wantHost: "2001:db8::1", wantPort: 8443, wantOK: true},
{name: "bare ipv6 without port", target: "2001:db8::1", wantOK: false},
{name: "invalid port", target: "example.com:abc", wantOK: false},
{name: "port out of range", target: "example.com:65536", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
host, port, ok := splitHostPort(tt.target)
if ok != tt.wantOK {
t.Fatalf("splitHostPort(%q) ok = %v, want %v", tt.target, ok, tt.wantOK)
}
if !ok {
return
}
if host != tt.wantHost || port != tt.wantPort {
t.Fatalf("splitHostPort(%q) = (%q, %d), want (%q, %d)", tt.target, host, port, tt.wantHost, tt.wantPort)
}
})
}
}
func TestNewStdoutNDJSONWriter(t *testing.T) {
writer := NewStdoutNDJSONWriter()
if writer == nil || writer.writer == nil {
t.Fatalf("NewStdoutNDJSONWriter = %#v, want initialized writer", writer)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close error = %v", err)
}
}
func TestStdoutNDJSONWriterWriteResult(t *testing.T) {
var buf bytes.Buffer
writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&buf)}
result := &ScanResult{
Type: TypeService,
Target: "[2001:db8::1]:8443",
Status: "OPEN",
Details: map[string]interface{}{
"port": float64(9443),
"service": "https",
"protocol": "tcp",
"banner": 123,
"title": "admin",
"url": "https://[2001:db8::1]:8443",
"vulnerability": "weak credential",
"username": "admin",
"password": "secret",
"plugin": "webtitle",
"version": "1.2.3",
"os": "linux",
},
}
if err := writer.WriteResult(result); err != nil {
t.Fatalf("WriteResult error = %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close error = %v", err)
}
var rec ndjsonRecord
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
t.Fatalf("invalid ndjson output %q: %v", buf.String(), err)
}
if rec.Host != "2001:db8::1" || rec.Port != 9443 {
t.Fatalf("host/port = %q/%d", rec.Host, rec.Port)
}
if rec.Service != "https" || rec.Protocol != "tcp" || rec.Banner != "123" || rec.Title != "admin" {
t.Fatalf("flattened fields missing: %#v", rec)
}
if rec.URL != "https://[2001:db8::1]:8443" || rec.Vulnerability != "weak credential" {
t.Fatalf("url/vuln fields missing: %#v", rec)
}
if rec.Username != "admin" || rec.Password != "secret" || rec.Plugin != "webtitle" || rec.Version != "1.2.3" || rec.OS != "linux" {
t.Fatalf("credential/plugin fields missing: %#v", rec)
}
}
func TestStdoutNDJSONFlattenFallbacks(t *testing.T) {
writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&bytes.Buffer{})}
rec := writer.flatten(&ScanResult{
Type: TypeHost,
Target: "2001:db8::1",
Status: "ALIVE",
Details: map[string]interface{}{
"port": int64(22),
},
})
if rec.Host != "2001:db8::1" || rec.Port != 22 {
t.Fatalf("flatten fallback = %#v", rec)
}
if got, ok := toInt("22"); ok || got != 0 {
t.Fatalf("toInt string = %d/%v, want 0/false", got, ok)
}
if got := strVal(map[string]interface{}{}, "missing"); got != "" {
t.Fatalf("missing strVal = %q, want empty", got)
}
}
+29 -10
View File
@@ -38,6 +38,19 @@ func escapeControlChars(s string) string {
return b.String()
}
func truncateString(s string, maxRunes int) string {
if maxRunes < 0 {
return s
}
for i := range s {
if maxRunes == 0 {
return s[:i] + "..."
}
maxRunes--
}
return s
}
func targetWithPort(target string, port interface{}) string {
if port == nil {
return target
@@ -46,6 +59,12 @@ func targetWithPort(target string, port interface{}) string {
return target
}
portText := fmt.Sprint(port)
if strings.TrimSpace(portText) == "" {
return target
}
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
target = strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
}
if strings.Count(target, ":") == 1 {
return target
}
@@ -190,10 +209,8 @@ func (w *TXTWriter) formatServiceLine(result *ScanResult) string {
parts = append(parts, service)
}
if banner != "" {
if len(banner) > 100 {
banner = banner[:100] + "..."
}
banner = escapeControlChars(banner)
banner = truncateString(banner, 100)
parts = append(parts, banner)
}
return strings.Join(parts, " ")
@@ -333,13 +350,17 @@ func (w *TXTWriter) Close() error {
os.Remove(w.realtimePath)
}
var firstErr error
if err := w.bufWriter.Flush(); err != nil {
return err
firstErr = err
}
if err := w.file.Sync(); err != nil {
return err
if err := w.file.Sync(); err != nil && firstErr == nil {
firstErr = err
}
return w.file.Close()
if err := w.file.Close(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
// writeSection 写入一个分类的所有结果
@@ -739,9 +760,7 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string {
fingerprints = formatFingerprints(result.Details["fingerprints"])
if b, ok := result.Details["banner"].(string); ok {
banner = escapeControlChars(b)
if len(banner) > 100 {
banner = banner[:100] + "..."
}
banner = truncateString(banner, 100)
}
}
target := result.Target
+92
View File
@@ -9,6 +9,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
)
/*
@@ -67,7 +68,10 @@ func TestTargetWithPortIPv6(t *testing.T) {
{name: "ipv4 without port", target: "192.168.1.1", port: 80, want: "192.168.1.1:80"},
{name: "ipv4 with port", target: "192.168.1.1:80", port: 443, want: "192.168.1.1:80"},
{name: "ipv6 without port", target: "2001:db8::1", port: 443, want: "[2001:db8::1]:443"},
{name: "bracketed ipv6 without port", target: "[2001:db8::1]", port: 443, want: "[2001:db8::1]:443"},
{name: "ipv6 with port", target: "[2001:db8::1]:443", port: 80, want: "[2001:db8::1]:443"},
{name: "empty port", target: "example.com", port: "", want: "example.com"},
{name: "blank port", target: "example.com", port: " \t", want: "example.com"},
}
for _, tt := range tests {
@@ -79,6 +83,94 @@ func TestTargetWithPortIPv6(t *testing.T) {
}
}
func TestScanResultFormatDetailsAndDefaultManagerConfig(t *testing.T) {
result := &ScanResult{
Details: map[string]interface{}{
"service": "ssh",
"port": 22,
"banner": "OpenSSH",
},
}
got := result.FormatDetails(";", "%s=%v")
want := "banner=OpenSSH;port=22;service=ssh"
if got != want {
t.Fatalf("FormatDetails = %q, want %q", got, want)
}
empty := (&ScanResult{}).FormatDetails(";", "%s=%v")
if empty != "" {
t.Fatalf("empty FormatDetails = %q, want empty", empty)
}
cfg := DefaultManagerConfig("out.json", FormatJSON)
if cfg.OutputPath != "out.json" || cfg.Format != FormatJSON {
t.Fatalf("DefaultManagerConfig = %#v", cfg)
}
}
func TestCSVWriterFormatRecords(t *testing.T) {
writer := &CSVWriter{}
host := writer.formatHostRecord(&ScanResult{Target: "192.168.1.1"})
if len(host) != 1 || host[0] != "192.168.1.1" {
t.Fatalf("host record = %#v", host)
}
port := writer.formatPortRecord(&ScanResult{
Target: "192.168.1.1",
Details: map[string]interface{}{"port": 22},
})
if got, want := strings.Join(port, "|"), "192.168.1.1|22|open"; got != want {
t.Fatalf("port record = %q, want %q", got, want)
}
longBanner := strings.Repeat("界", 105)
service := writer.formatServiceRecord(&ScanResult{
Target: "2001:db8::1",
Details: map[string]interface{}{
"port": 443,
"name": "https",
"version": "1.2.3",
"title": "hello\nworld",
"status": 200,
"server": "nginx\r\nunit",
"fingerprints": []interface{}{"fp1", "", "fp2", 3},
"banner": longBanner,
},
})
if service[0] != "[2001:db8::1]:443" || service[1] != "https" || service[2] != "1.2.3" {
t.Fatalf("service identity fields = %#v", service)
}
if service[3] != "hello\\nworld" || service[4] != "200" || service[5] != "nginx\\r\\nunit" {
t.Fatalf("service text fields = %#v", service)
}
if service[6] != "fp1,fp2" {
t.Fatalf("fingerprints = %q, want fp1,fp2", service[6])
}
if !utf8.ValidString(service[7]) || len([]rune(service[7])) != 103 || !strings.HasSuffix(service[7], "...") {
t.Fatalf("truncated banner = len %d value %q", len(service[7]), service[7])
}
vuln := writer.formatVulnRecord(&ScanResult{
Target: "http://example.com",
Status: "vulnerable",
Details: map[string]interface{}{"type": "poc"},
})
if got, want := strings.Join(vuln, "|"), "http://example.com|poc|vulnerable"; got != want {
t.Fatalf("vuln record = %q, want %q", got, want)
}
if got := formatFingerprints([]string{"a", "b"}); got != "a,b" {
t.Fatalf("string fingerprints = %q", got)
}
if got := formatFingerprints(123); got != "" {
t.Fatalf("unsupported fingerprints = %q, want empty", got)
}
if writer.GetFormat() != FormatCSV {
t.Fatalf("csv GetFormat = %q", writer.GetFormat())
}
}
// =============================================================================
// TXTWriter - 基础功能测试
// =============================================================================
+153
View File
@@ -0,0 +1,153 @@
package common
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/shadow1ng/fscan/common/output"
)
func readTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(content)
}
func preserveOutputAPIGlobals(t *testing.T) {
t.Helper()
globalMu.RLock()
oldConfig := globalConfig
oldState := globalState
globalMu.RUnlock()
oldFlagVars := flagVars
oldResultOutput := ResultOutput
oldStdoutWriter := StdoutWriter
t.Cleanup(func() {
if ResultOutput != nil && ResultOutput != oldResultOutput {
_ = ResultOutput.Close()
}
if StdoutWriter != nil && StdoutWriter != oldStdoutWriter {
_ = StdoutWriter.Close()
}
ClearResultCallback()
globalMu.Lock()
globalConfig = oldConfig
globalState = oldState
globalMu.Unlock()
flagVars = oldFlagVars
ResultOutput = oldResultOutput
StdoutWriter = oldStdoutWriter
})
ClearResultCallback()
flagVars = &FlagVars{}
ResultOutput = nil
StdoutWriter = nil
SetGlobalConfig(NewConfig())
SetGlobalState(NewState())
}
func TestInitOutputValidationAndDefaultExtension(t *testing.T) {
preserveOutputAPIGlobals(t)
flagVars = &FlagVars{DisableSave: true}
if err := InitOutput(); err != nil {
t.Fatalf("InitOutput disable save error = %v", err)
}
if ResultOutput != nil {
t.Fatalf("ResultOutput = %#v, want nil when save is disabled", ResultOutput)
}
flagVars = &FlagVars{OutputFormat: "txt"}
if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "output file not specified") {
t.Fatalf("missing output error = %v", err)
}
flagVars = &FlagVars{Outputfile: "out.bad", OutputFormat: "xml"}
if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "invalid output format") {
t.Fatalf("invalid format error = %v", err)
}
dir := t.TempDir()
t.Chdir(dir)
flagVars = &FlagVars{Outputfile: "result.txt", OutputFormat: "json"}
if err := InitOutput(); err != nil {
t.Fatalf("InitOutput json error = %v", err)
}
if ResultOutput == nil {
t.Fatal("ResultOutput should be initialized")
}
if err := SaveResult(&output.ScanResult{
Time: time.Date(2026, 6, 13, 1, 2, 3, 0, time.UTC),
Type: output.TypeHost,
Target: "127.0.0.1",
Status: "ALIVE",
}); err != nil {
t.Fatalf("SaveResult json error = %v", err)
}
if err := CloseOutput(); err != nil {
t.Fatalf("CloseOutput error = %v", err)
}
if content := readTestFile(t, filepath.Join(dir, "result.json")); !strings.Contains(content, "127.0.0.1") {
t.Fatalf("result.json content = %q, want saved target", content)
}
}
func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) {
preserveOutputAPIGlobals(t)
cfg := NewConfig()
cfg.Output.DisableSave = true
SetGlobalConfig(cfg)
flagVars = &FlagVars{DisableSave: true}
if err := InitOutput(); err != nil {
t.Fatalf("InitOutput disable save error = %v", err)
}
called := false
SetResultCallback(func(payload interface{}) {
called = true
data, ok := payload.(map[string]interface{})
if !ok {
t.Fatalf("callback payload type = %T", payload)
}
if data["type"] != string(output.TypeVuln) || data["target"] != "http://example.com" {
t.Fatalf("callback payload = %#v", data)
}
})
if err := SaveResult(nil); err != nil {
t.Fatalf("SaveResult nil error = %v", err)
}
if called {
t.Fatal("nil result should not notify callback")
}
if err := SaveResult(&output.ScanResult{
Type: output.TypeVuln,
Target: "http://example.com",
Status: "vulnerable",
Details: map[string]interface{}{"type": "poc"},
}); err != nil {
t.Fatalf("SaveResult disabled save error = %v", err)
}
if !called {
t.Fatal("callback was not notified")
}
if err := CloseOutput(); err != nil {
t.Fatalf("CloseOutput disabled save error = %v", err)
}
}
+3
View File
@@ -45,6 +45,9 @@ func applyLogLevel() {
StartTime: GetGlobalState().GetStartTime(),
LevelColors: logging.GetDefaultLevelColors(),
}
if fv.Debug {
config.DebugLogFile = "fscan_debug.log"
}
newLogger := logging.NewLogger(config)
newLogger.SetCoordinatedOutput(LogWithProgress)
+63
View File
@@ -2,6 +2,7 @@ package parsers
import (
"context"
"errors"
"os"
"reflect"
"strings"
@@ -103,3 +104,65 @@ func TestHostIteratorReadsLongHostFileLine(t *testing.T) {
t.Fatalf("batch = %#v, want long host", batch)
}
}
func TestMultiHostSourceAndMatcherCIDR(t *testing.T) {
src := &multiHostSource{sources: []hostSource{
&singleHostSource{host: "192.168.1.1"},
&singleHostSource{host: "192.168.1.2"},
}}
host, ok, err := src.Next()
if err != nil || !ok || host != "192.168.1.1" {
t.Fatalf("first Next = %q/%v/%v", host, ok, err)
}
host, ok, err = src.Next()
if err != nil || !ok || host != "192.168.1.2" {
t.Fatalf("second Next = %q/%v/%v", host, ok, err)
}
host, ok, err = src.Next()
if err != nil || ok || host != "" {
t.Fatalf("exhausted Next = %q/%v/%v", host, ok, err)
}
if err := src.Close(); err != nil {
t.Fatalf("Close error = %v", err)
}
matcher := newHostMatcher()
if err := matcher.add("192.168.1.0/30,example.com"); err != nil {
t.Fatalf("matcher add error = %v", err)
}
if !matcher.match("192.168.1.1") || !matcher.match("192.168.1.2") || !matcher.match("example.com") {
t.Fatal("matcher should match CIDR hosts and exact host")
}
if matcher.match("192.168.1.3") || matcher.match("nope.example") {
t.Fatal("matcher matched hosts outside its rules")
}
if err := matcher.add("2001:db8::/126"); err == nil {
t.Fatal("IPv6 CIDR should be rejected by IPv4-only matcher")
}
}
func TestCloseHostSourcesIgnoresCloseErrors(t *testing.T) {
first := &closeTrackingSource{err: errors.New("close failed")}
second := &closeTrackingSource{}
closeHostSources([]hostSource{first, second})
if !first.closed || !second.closed {
t.Fatalf("sources closed = %v/%v, want both true", first.closed, second.closed)
}
}
type closeTrackingSource struct {
closed bool
err error
}
func (s *closeTrackingSource) Next() (string, bool, error) {
return "", false, nil
}
func (s *closeTrackingSource) Close() error {
s.closed = true
return s.err
}
+9
View File
@@ -386,6 +386,15 @@ func TestParsePort_PortGroups(t *testing.T) {
}
}
func TestParsePortGroupsRequireWholeToken(t *testing.T) {
if got := ParsePort("web8080"); len(got) != 0 {
t.Fatalf("ParsePort(web8080) = %v, want empty invalid token", got)
}
if got := ParsePort("web,8080"); len(got) == 0 || got[len(got)-1] != 28018 {
t.Fatalf("ParsePort(web,8080) = %v, want expanded web group", got)
}
}
// TestParsePort_WhitespaceHandling 测试空格处理
func TestParsePort_WhitespaceHandling(t *testing.T) {
tests := []struct {
+7 -4
View File
@@ -200,11 +200,14 @@ func parsePortRange(rangeStr string) []int {
// expandPortGroups 展开端口组
func expandPortGroups(ports string) string {
portGroups := config.GetPortGroups()
result := ports
for group, portList := range portGroups {
result = strings.ReplaceAll(result, group, portList)
parts := strings.Split(ports, ",")
for i, part := range parts {
token := strings.TrimSpace(part)
if portList, ok := portGroups[token]; ok {
parts[i] = portList
}
return result
}
return strings.Join(parts, ",")
}
// =============================================================================
+15 -14
View File
@@ -32,7 +32,7 @@ type ProgressManager struct {
current atomic.Int64
description string
startTime time.Time
isActive bool
isActive atomic.Bool
terminalHeight int
reservedLines int // 为进度条保留的行数
lastContentLine int // 最后一行内容的位置
@@ -107,7 +107,7 @@ func GetProgressManager() *ProgressManager {
// InitProgress 初始化进度条
func (pm *ProgressManager) InitProgress(total int64, description string) {
cfg := GetGlobalConfig()
if cfg.Output.DisableProgress || cfg.Output.Silent {
if cfg.Output.DisableProgress || cfg.Output.Silent || cfg.Output.NoColor {
pm.enabled = false
return
}
@@ -121,7 +121,7 @@ func (pm *ProgressManager) InitProgress(total int64, description string) {
pm.current.Store(0)
pm.description = description
pm.startTime = time.Now()
pm.isActive = true
pm.isActive.Store(true)
pm.enabled = true
pm.lastActivity = time.Now()
pm.spinnerIndex = 0
@@ -139,7 +139,7 @@ func (pm *ProgressManager) InitProgress(total int64, description string) {
// UpdateProgress 更新进度
func (pm *ProgressManager) UpdateProgress(increment int64) {
if !pm.enabled || !pm.isActive {
if !pm.enabled || !pm.isActive.Load() {
return
}
@@ -171,7 +171,7 @@ func (pm *ProgressManager) UpdateProgress(increment int64) {
// FinishProgress 完成进度条
func (pm *ProgressManager) FinishProgress() {
if !pm.enabled || !pm.isActive {
if !pm.enabled || !pm.isActive.Load() {
return
}
@@ -189,7 +189,7 @@ func (pm *ProgressManager) FinishProgress() {
// 清理进度条区域,恢复正常输出
pm.clearProgressArea()
pm.isActive = false
pm.isActive.Store(false)
}
// setupProgressSpace 设置进度条空间
@@ -321,12 +321,13 @@ func (pm *ProgressManager) showCompletionInfo() {
completionMsg := i18n.GetText("progress_scan_completed")
doneMsg := i18n.GetText("progress_done")
durationMsg := i18n.GetText("progress_duration")
total := pm.total.Load()
if pm.noColor {
fmt.Printf("[%s] %s %d/%d (%s: %s)\n",
doneMsg, completionMsg, pm.total.Load(), pm.total.Load(), durationMsg, formatDuration(elapsed))
fmt.Printf("[%s] %s: %d/%d (%s: %s)\n",
doneMsg, completionMsg, total, total, durationMsg, formatDuration(elapsed))
} else {
fmt.Printf("%s[%s] %s %d/%d%s %s(%s: %s)%s\n",
AnsiGreen, doneMsg, completionMsg, pm.total.Load(), pm.total.Load(), AnsiReset,
fmt.Printf("%s[%s] %s: %d/%d%s %s(%s: %s)%s\n",
AnsiGreen, doneMsg, completionMsg, total, total, AnsiReset,
AnsiGray, durationMsg, formatDuration(elapsed), AnsiReset)
}
}
@@ -341,7 +342,7 @@ func (pm *ProgressManager) clearProgressArea() {
func (pm *ProgressManager) IsActive() bool {
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.isActive && pm.enabled
return pm.isActive.Load() && pm.enabled
}
// getTerminalHeight 获取终端高度
@@ -478,7 +479,7 @@ func (pm *ProgressManager) GetPercent() float64 {
pm.mu.RLock()
defer pm.mu.RUnlock()
if !pm.isActive || pm.total.Load() == 0 {
if !pm.isActive.Load() || pm.total.Load() == 0 {
return 0
}
return float64(pm.current.Load()) / float64(pm.total.Load()) * 100
@@ -516,7 +517,7 @@ func LogWithProgress(message string) {
// renderProgressUnsafe 不加锁的进度条渲染(内部使用)
func (pm *ProgressManager) renderProgressUnsafe() {
if !pm.enabled || !pm.isActive {
if !pm.enabled || !pm.isActive.Load() {
return
}
@@ -585,7 +586,7 @@ func (pm *ProgressManager) startActivityIndicator() {
select {
case <-pm.activityTicker.C:
// 只有在活跃状态下才更新指示器
if pm.isActive && pm.enabled {
if pm.isActive.Load() && pm.enabled {
pm.mu.Lock()
pm.spinnerIndex = (pm.spinnerIndex + 1) % len(spinnerChars)
pm.mu.Unlock()
+102
View File
@@ -0,0 +1,102 @@
package common
import (
"strings"
"testing"
"time"
)
func TestProgressTextHelpers(t *testing.T) {
tests := []struct {
name string
in string
want int
}{
{name: "ascii", in: "abc", want: 3},
{name: "cjk", in: "中文", want: 4},
{name: "mixed", in: "a中", want: 3},
{name: "symbol", in: "★", want: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := displayWidth(tt.in); got != tt.want {
t.Fatalf("displayWidth(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
truncateTests := []struct {
name string
in string
width int
want string
}{
{name: "exact mixed width", in: "abc中文", width: 5, want: "abc中"},
{name: "wide char does not fit", in: "中文", width: 1, want: ""},
{name: "zero width", in: "abc", width: 0, want: ""},
{name: "negative width", in: "abc", width: -1, want: ""},
}
for _, tt := range truncateTests {
t.Run(tt.name, func(t *testing.T) {
if got := truncateToWidth(tt.in, tt.width); got != tt.want {
t.Fatalf("truncateToWidth(%q, %d) = %q, want %q", tt.in, tt.width, got, tt.want)
}
})
}
if got := stripAnsiCodes("\033[31mred\033[0m plain"); got != "red plain" {
t.Fatalf("stripAnsiCodes removed ANSI = %q, want %q", got, "red plain")
}
if got := stripAnsiCodes("plain"); got != "plain" {
t.Fatalf("stripAnsiCodes plain = %q, want plain", got)
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
name string
in time.Duration
want string
}{
{name: "seconds", in: 1500 * time.Millisecond, want: "1.5s"},
{name: "minutes", in: 90 * time.Second, want: "1.5m"},
{name: "hours", in: 150 * time.Minute, want: "2.5h"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatDuration(tt.in); got != tt.want {
t.Fatalf("formatDuration(%s) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestConcurrencyMonitorTaskStats(t *testing.T) {
monitor := &ConcurrencyMonitor{}
if status := monitor.GetConcurrencyStatus(); status != "" {
t.Fatalf("initial status = %q, want empty", status)
}
monitor.StartPluginTask()
monitor.StartPluginTask()
active, total := monitor.GetPluginTaskStats()
if active != 2 || total != 2 {
t.Fatalf("stats after start = active %d total %d, want 2/2", active, total)
}
if status := monitor.GetConcurrencyStatus(); !strings.HasSuffix(status, ":2") {
t.Fatalf("status after start = %q, want suffix :2", status)
}
monitor.FinishPluginTask()
active, total = monitor.GetPluginTaskStats()
if active != 1 || total != 2 {
t.Fatalf("stats after one finish = active %d total %d, want 1/2", active, total)
}
monitor.FinishPluginTask()
if status := monitor.GetConcurrencyStatus(); status != "" {
t.Fatalf("status after all finish = %q, want empty", status)
}
}
+4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"strings"
"time"
)
@@ -54,6 +55,9 @@ func (h *httpDialer) DialContext(ctx context.Context, network, address string) (
// sendConnectRequest 发送HTTP CONNECT请求
func (h *httpDialer) sendConnectRequest(conn net.Conn, address string) error {
if strings.ContainsAny(address, "\r\n") {
return NewProxyError(ErrTypeProtocol, "invalid CONNECT target", ErrCodeHTTPReadRespFailed, nil)
}
// 构建CONNECT请求
req := fmt.Sprintf(HTTPConnectRequestFormat, address, address)
+23
View File
@@ -0,0 +1,23 @@
package proxy
import (
"net"
"testing"
"time"
)
func TestHTTPDialerRejectsConnectTargetWithLineBreak(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
dialer := &httpDialer{
config: &ProxyConfig{Timeout: time.Second},
stats: &ProxyStats{},
}
err := dialer.sendConnectRequest(client, "example.com:80\r\nX-Injected: yes")
if err == nil {
t.Fatal("sendConnectRequest() error = nil, want invalid target error")
}
}
+62
View File
@@ -0,0 +1,62 @@
package common
import "testing"
func TestResultCallbackLifecycle(t *testing.T) {
ClearResultCallback()
t.Cleanup(ClearResultCallback)
called := false
SetResultCallback(func(result interface{}) {
called = true
if result != "payload" {
t.Fatalf("callback payload = %#v", result)
}
})
NotifyResult("payload")
if !called {
t.Fatal("callback was not called")
}
called = false
ClearResultCallback()
NotifyResult("payload")
if called {
t.Fatal("callback should not be called after ClearResultCallback")
}
}
func TestStateRuntimeTargetsAndShellFlags(t *testing.T) {
state := NewState()
urls := []string{"http://example.com", "https://example.org"}
state.SetURLs(urls)
if got := state.GetURLs(); len(got) != 2 || got[0] != urls[0] || got[1] != urls[1] {
t.Fatalf("urls = %#v", got)
}
hostPorts := []string{"127.0.0.1:80", "[::1]:443"}
state.SetHostPorts(hostPorts)
if got := state.GetHostPorts(); len(got) != 2 || got[0] != hostPorts[0] || got[1] != hostPorts[1] {
t.Fatalf("hostPorts = %#v", got)
}
state.ClearHostPorts()
if got := state.GetHostPorts(); got != nil {
t.Fatalf("hostPorts after clear = %#v, want nil", got)
}
state.SetForwardShellActive(true)
state.SetReverseShellActive(true)
state.SetSocks5ProxyActive(true)
if !state.IsForwardShellActive() || !state.IsReverseShellActive() || !state.IsSocks5ProxyActive() {
t.Fatal("shell/proxy flags should be active")
}
state.SetForwardShellActive(false)
state.SetReverseShellActive(false)
state.SetSocks5ProxyActive(false)
if state.IsForwardShellActive() || state.IsReverseShellActive() || state.IsSocks5ProxyActive() {
t.Fatal("shell/proxy flags should be inactive")
}
}
+20
View File
@@ -141,6 +141,26 @@ func TestScanSessionProxyStateComesFromConfig(t *testing.T) {
}
}
func TestParseProxyURLFallsBackWhenHostIsEmpty(t *testing.T) {
host, username, password := parseProxyURL("127.0.0.1:8080", "127.0.0.1:8080")
if host != "127.0.0.1:8080" {
t.Fatalf("host = %q, want fallback address", host)
}
if username != "" || password != "" {
t.Fatalf("unexpected credentials: %q/%q", username, password)
}
}
func TestParseProxyURLExtractsAuthWithoutScheme(t *testing.T) {
host, username, password := parseProxyURL("user:[email protected]:8080", "user:[email protected]:8080")
if host != "127.0.0.1:8080" {
t.Fatalf("host = %q, want proxy address", host)
}
if username != "user" || password != "pass" {
t.Fatalf("credentials = %q/%q, want user/pass", username, password)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+18
View File
@@ -56,6 +56,10 @@ type State struct {
forwardShellActive int32 // 使用int32以便原子操作
reverseShellActive int32
socks5ProxyActive int32
// 服务识别缓存(per-session,避免跨扫描污染)
// key: "host:port", value: interface{}(core.ServiceInfo 指针)
serviceCache sync.Map
}
// NewState 创建新的状态对象
@@ -455,3 +459,17 @@ func (s *State) CheckAndIncrementPacketRate(rateLimit int64) (bool, error) {
return true, nil
}
// =============================================================================
// 服务识别缓存 - per-session,消除跨扫描污染
// =============================================================================
// CacheService 缓存服务信息
func (s *State) CacheService(key string, info interface{}) {
s.serviceCache.Store(key, info)
}
// GetCachedService 获取缓存的服务信息
func (s *State) GetCachedService(key string) (interface{}, bool) {
return s.serviceCache.Load(key)
}
+240 -78
View File
@@ -1,7 +1,6 @@
package core
import (
"fmt"
"sync"
"sync/atomic"
"time"
@@ -11,140 +10,303 @@ import (
"github.com/shadow1ng/fscan/common/i18n"
)
// AdaptivePool 自适应线程池
// 封装 ants.PoolWithFunc,支持根据资源耗尽率动态调整线程数
// HealthSignal 健康评估结果
type HealthSignal int
const (
HealthUnknown HealthSignal = iota // 样本不足,无法判断
HealthGood // 一切正常,可以提速
HealthOK // 正常,维持现状
HealthStressed // 有压力信号,轻微降速
HealthCongested // 明确拥塞,大幅降速
)
// AdaptivePool 自适应线程池(AIMD + 慢启动)
//
// 三阶段工作模式:
// 1. 慢启动:从 target/4 起步,每个检查周期翻倍,直到达到 target 或检测到拥塞
// 2. 稳态 AIMD:健康时加性增(+5% target),拥塞时乘性减(×0.5)
// 3. 恢复上限受 ceiling 约束,不会无限增长
//
// 健康评估基于两个信号:
// - 资源耗尽率(fd/端口不足)
// - RTT 趋势(fast EMA / slow EMA)
type AdaptivePool struct {
pool *ants.PoolWithFunc
state *common.State
metrics *ScanMetrics
initialSize int
minSize int
maxSize int
currentSize int32 // 原子操作
// 网络环境(影响健康评估阈值)
networkEnv NetworkEnv
// 监控参数
// 并发控制
target int32 // 探测推荐的目标值
ceiling int32 // 绝对上限(用户指定或探测推荐)
currentSize int32
// 慢启动
inSlowStart bool
ssThreshold int32 // 慢启动阈值(拥塞后降为当前值)
// 检查定时
checkInterval time.Duration
lastCheckNano atomic.Int64 // UnixNano
lastExhaustedCount int64
lastPacketCount int64
// 阈值
exhaustedThreshold float64 // 资源耗尽率阈值(触发降级)
recoveryThreshold float64 // 恢复阈值(允许升级)
lastCheck atomic.Int64 // UnixNano
// 增量计算
mu sync.Mutex
prevSnapshot MetricsSnapshot
}
// NewAdaptivePool 创建自适应线程池
func NewAdaptivePool(size int, fn func(interface{}), state *common.State) (*AdaptivePool, error) {
// 移除 WithPreAlloc(true),在大规模扫描时预分配可能导致内存问题
pool, err := ants.NewPoolWithFunc(size, fn)
// target: 目标并发数(来自 NetworkProfile.RecommendConcurrency)
// ceiling: 最大并发上限
// metrics: 共享的扫描度量(scanSinglePort 写入,pool 读取)
func NewAdaptivePool(target, ceiling int, fn func(interface{}), metrics *ScanMetrics, env ...NetworkEnv) (*AdaptivePool, error) {
// 慢启动初始值:target 的 25%,但不低于 10
initial := target / 4
if initial < 10 {
initial = 10
}
if initial > target {
initial = target
}
pool, err := ants.NewPoolWithFunc(initial, fn)
if err != nil {
return nil, err
}
minSize := size / 4
if minSize < 10 {
minSize = 10
netEnv := EnvWAN
if len(env) > 0 {
netEnv = env[0]
}
return &AdaptivePool{
pool: pool,
state: state,
initialSize: size,
minSize: minSize,
maxSize: size,
currentSize: int32(size),
checkInterval: time.Second,
exhaustedThreshold: 0.10, // 10% 资源耗尽率触发降级
recoveryThreshold: 0.02, // 2% 以下允许恢复
metrics: metrics,
networkEnv: netEnv,
target: int32(target),
ceiling: int32(ceiling),
currentSize: int32(initial),
inSlowStart: true,
ssThreshold: int32(target),
checkInterval: 500 * time.Millisecond,
}, nil
}
// Invoke 提交任务,并在适当时机检查是否需要调整线程数
// Invoke 提交任务
func (ap *AdaptivePool) Invoke(task interface{}) error {
ap.maybeAdjust()
return ap.pool.Invoke(task)
}
// maybeAdjust 检查并可能调整线程池大小
// 使用原子 CAS 进行时间检查,99%+ 的调用零锁开销
// maybeAdjust 周期性检查并调整并发数
func (ap *AdaptivePool) maybeAdjust() {
lastCheck := ap.lastCheckNano.Load()
last := ap.lastCheck.Load()
now := time.Now().UnixNano()
if now-lastCheck < int64(ap.checkInterval) {
if now-last < int64(ap.checkInterval) {
return
}
if !ap.lastCheckNano.CompareAndSwap(lastCheck, now) {
return // 其他 goroutine 已在检查
if !ap.lastCheck.CompareAndSwap(last, now) {
return
}
// 获取当前计数
currentExhausted := ap.state.GetResourceExhaustedCount()
currentPackets := ap.state.GetPacketCount()
ap.adjust()
}
func (ap *AdaptivePool) adjust() {
health := ap.assessHealth()
if health == HealthUnknown {
return
}
// RTT 漂移微调:fast EMA 远高于 slow EMA 说明延迟持续恶化
// 压低 target 让 AIMD 的天花板跟着降,而不是只靠乘性减
ap.maybeReduceTarget()
current := int(atomic.LoadInt32(&ap.currentSize))
target := int(atomic.LoadInt32(&ap.target))
ceiling := int(atomic.LoadInt32(&ap.ceiling))
var newSize int
if ap.inSlowStart {
newSize = ap.adjustSlowStart(health, current, target)
} else {
newSize = ap.adjustAIMD(health, current, target)
}
// 下限:ceiling 的 5%,但不低于 10
minSize := ceiling / 20
if minSize < 10 {
minSize = 10
}
if newSize < minSize {
newSize = minSize
}
if newSize > ceiling {
newSize = ceiling
}
if newSize != current {
ap.tune(newSize)
// 显著变化时记录日志
delta := newSize - current
if delta < 0 {
delta = -delta
}
if delta > current/5 {
if newSize < current {
common.LogInfo(i18n.Tr("adaptive_pool_decrease", current, newSize))
} else {
common.LogDebug(i18n.Tr("adaptive_pool_increase", current, newSize))
}
}
}
}
func (ap *AdaptivePool) adjustSlowStart(health HealthSignal, current, target int) int {
switch health {
case HealthCongested, HealthStressed:
// 退出慢启动,设置阈值
ap.ssThreshold = int32(current)
ap.inSlowStart = false
common.LogDebug(i18n.Tr("adaptive_pool_slowstart_exit", current))
return int(float64(current) * 0.5)
default:
// 翻倍
newSize := current * 2
if newSize >= target {
newSize = target
ap.inSlowStart = false
}
return newSize
}
}
func (ap *AdaptivePool) adjustAIMD(health HealthSignal, current, target int) int {
switch health {
case HealthCongested:
// 乘性减:×0.5
newSize := int(float64(current) * 0.5)
ap.ssThreshold = int32(newSize)
return newSize
case HealthStressed:
// 温和降低:×0.85
return int(float64(current) * 0.85)
case HealthGood:
// 加性增:+5% of target,至少 +1
inc := target / 20
if inc < 1 {
inc = 1
}
return current + inc
default:
return current
}
}
// assessHealth 综合健康评估
func (ap *AdaptivePool) assessHealth() HealthSignal {
snap := ap.metrics.Snapshot()
ap.mu.Lock()
// 计算增量(本周期内的耗尽率)
deltaExhausted := currentExhausted - ap.lastExhaustedCount
deltaPackets := currentPackets - ap.lastPacketCount
ap.lastExhaustedCount = currentExhausted
ap.lastPacketCount = currentPackets
prev := ap.prevSnapshot
ap.prevSnapshot = snap
ap.mu.Unlock()
// 需要足够的样本才能判断
if deltaPackets < 100 {
// 计算本周期增量
deltaTotal := snap.Total() - prev.Total()
deltaExhausted := snap.Exhausted - prev.Exhausted
// 样本不足
if deltaTotal < 30 {
return HealthUnknown
}
exhaustRate := float64(deltaExhausted) / float64(deltaTotal)
rttRatio := ap.metrics.RTTRatio()
// 阈值根据网络环境调整:内网收紧,公网放宽
var congestExhaust, stressExhaust, congestRTT, stressRTT, goodRTT float64
switch ap.networkEnv {
case EnvLAN:
congestExhaust, stressExhaust = 0.08, 0.03
congestRTT, stressRTT, goodRTT = 1.8, 1.4, 1.15
case EnvWAN:
congestExhaust, stressExhaust = 0.15, 0.05
congestRTT, stressRTT, goodRTT = 2.5, 1.8, 1.3
default: // Internet / Slow
congestExhaust, stressExhaust = 0.25, 0.10
congestRTT, stressRTT, goodRTT = 3.5, 2.5, 1.5
}
switch {
case exhaustRate > congestExhaust:
return HealthCongested
case rttRatio > congestRTT:
return HealthCongested
case exhaustRate > stressExhaust:
return HealthStressed
case rttRatio > stressRTT:
return HealthStressed
case exhaustRate < 0.01 && rttRatio < goodRTT:
return HealthGood
default:
return HealthOK
}
}
// maybeReduceTarget 当 RTT 持续恶化时压低 target
// 不低于 ceiling 的 20%,避免过度收缩
func (ap *AdaptivePool) maybeReduceTarget() {
rttRatio := ap.metrics.RTTRatio()
if rttRatio <= 3.0 {
return
}
rate := float64(deltaExhausted) / float64(deltaPackets)
currentSize := int(atomic.LoadInt32(&ap.currentSize))
target := atomic.LoadInt32(&ap.target)
ceiling := atomic.LoadInt32(&ap.ceiling)
minTarget := ceiling / 5
if minTarget < 10 {
minTarget = 10
}
if rate > ap.exhaustedThreshold && currentSize > ap.minSize {
// 降级:减少 20% 线程
newSize := int(float64(currentSize) * 0.8)
if newSize < ap.minSize {
newSize = ap.minSize
}
ap.tune(newSize)
common.LogInfo(i18n.Tr("adaptive_pool_resource_exhausted", fmt.Sprintf("%.1f", rate*100), currentSize, newSize))
} else if rate < ap.recoveryThreshold && currentSize < ap.maxSize {
// 恢复:增加 10% 线程(保守恢复)
newSize := int(float64(currentSize) * 1.1)
if newSize > ap.maxSize {
newSize = ap.maxSize
}
if newSize > currentSize {
ap.tune(newSize)
// 压低 10%
newTarget := int32(float64(target) * 0.9)
if newTarget < minTarget {
newTarget = minTarget
}
if newTarget < target {
atomic.StoreInt32(&ap.target, newTarget)
}
}
// tune 调整线程池大小
func (ap *AdaptivePool) tune(newSize int) {
ap.pool.Tune(newSize)
atomic.StoreInt32(&ap.currentSize, int32(newSize))
}
// Running 返回当前运行中的 goroutine 数量
func (ap *AdaptivePool) Running() int {
return ap.pool.Running()
}
func (ap *AdaptivePool) Running() int { return ap.pool.Running() }
// Cap 返回当前池容量
func (ap *AdaptivePool) Cap() int {
return int(atomic.LoadInt32(&ap.currentSize))
}
func (ap *AdaptivePool) Cap() int { return int(atomic.LoadInt32(&ap.currentSize)) }
// Release 释放线程池
func (ap *AdaptivePool) Release() {
ap.pool.Release()
}
func (ap *AdaptivePool) Release() { ap.pool.Release() }
// Wait 等待所有任务完成
// Wait 等待所有任务完成(最多等待 10 分钟)
func (ap *AdaptivePool) Wait() {
// ants 没有原生 Wait,通过 Running() == 0 轮询
deadline := time.After(10 * time.Minute)
for ap.pool.Running() > 0 {
select {
case <-deadline:
common.LogError(i18n.Tr("adaptive_pool_wait_timeout"))
return
default:
time.Sleep(10 * time.Millisecond)
}
}
}
+57 -140
View File
@@ -1,160 +1,101 @@
package core
/*
adaptive_pool_test.go - AdaptivePool 高价值测试
测试重点:
1. 并发安全 - 多goroutine同时调整不崩溃
2. 降级逻辑 - 资源耗尽率高时正确减少线程
3. 恢复逻辑 - 资源耗尽率低时正确增加线程
4. 边界条件 - 不超过minSize/maxSize
不测试:
- 简单的getter方法(太简单,不值得)
- ants库本身的正确性(库作者负责)
*/
import (
"testing"
"time"
"github.com/shadow1ng/fscan/common"
)
// =============================================================================
// 场景1:降级逻辑测试(高价值)
// =============================================================================
// TestAdaptivePool_DowngradeOnHighExhaustion 验证资源耗尽率高时降低线程数
// 这是个核心业务逻辑:耗尽率 > 10% 时应该减少线程
func TestAdaptivePool_DowngradeOnHighExhaustion(t *testing.T) {
state := common.NewState()
pool, err := NewAdaptivePool(100, func(interface{}) {}, state)
// newTestPool 测试辅助:创建测试用的自适应线程池
func newTestPool(t *testing.T, size int, fn func(interface{})) (*AdaptivePool, *ScanMetrics) {
t.Helper()
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(size, size, fn, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
return pool, metrics
}
// TestAdaptivePool_DowngradeOnHighExhaustion 验证资源耗尽率高时降低线程数
func TestAdaptivePool_DowngradeOnHighExhaustion(t *testing.T) {
pool, metrics := newTestPool(t, 100, func(interface{}) {})
defer pool.Release()
// 慢启动先跑到 target
pool.inSlowStart = false
pool.tune(100)
initialCap := pool.Cap()
// 模拟高资源耗尽率:20% 的包都失败了
// 需要至少100个样本才会触发调整
// 模拟高资源耗尽率:20%
for i := 0; i < 200; i++ {
state.IncrementPacketCount()
if i < 40 { // 前40个失败(20%)
state.IncrementResourceExhaustedCount()
if i < 40 {
metrics.RecordExhausted()
} else {
metrics.RecordConnect(time.Millisecond)
}
}
// 触发调整:提交足够多的任务让maybeAdjust被调用
// 触发调整
for i := 0; i < 20; i++ {
_ = pool.Invoke(nil)
time.Sleep(time.Millisecond * 10) // 等待异步调整
time.Sleep(time.Millisecond * 30)
}
// 等待调整完成
time.Sleep(time.Millisecond * 50)
finalCap := pool.Cap()
// 验证:线程数应该减少
if finalCap >= initialCap {
t.Errorf("应该降级: 初始 %d, 最终 %d", initialCap, finalCap)
}
// 验证:不应该降到minSize以下
minSize := initialCap / 4
if minSize < 10 {
minSize = 10
}
if finalCap < minSize {
t.Errorf("降到minSize以下: %d < %d", finalCap, minSize)
if finalCap < 10 {
t.Errorf("降到 minSize 以下: %d", finalCap)
}
t.Logf("降级成功: %d -> %d (min=%d)", initialCap, finalCap, minSize)
t.Logf("降级成功: %d -> %d", initialCap, finalCap)
}
// =============================================================================
// 场景3:恢复逻辑测试(高价值)
// =============================================================================
// TestAdaptivePool_NoRecoveryOnLowExhaustion 验证低耗尽率时不升级
// 防止线程数盲目增长
func TestAdaptivePool_NoRecoveryOnLowExhaustion(t *testing.T) {
state := common.NewState()
pool, err := NewAdaptivePool(50, func(interface{}) {}, state)
// TestAdaptivePool_SlowStart 验证慢启动行为
func TestAdaptivePool_SlowStart(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
// 先降到minSize
for i := 0; i < 500; i++ {
state.IncrementPacketCount()
state.IncrementResourceExhaustedCount() // 100% 耗尽
// 初始应该是 target/4 = 25
initialCap := pool.Cap()
if initialCap > 30 {
t.Errorf("慢启动初始值应该 <= 30, got %d", initialCap)
}
for i := 0; i < 20; i++ {
_ = pool.Invoke(nil)
}
time.Sleep(time.Millisecond * 50)
reducedCap := pool.Cap()
// 现在模拟低耗尽率:只有1%失败
for i := 0; i < 500; i++ {
state.IncrementPacketCount()
if i%100 == 0 { // 只有5个失败(1%)
state.IncrementResourceExhaustedCount()
}
if !pool.inSlowStart {
t.Error("应该处于慢启动状态")
}
for i := 0; i < 20; i++ {
_ = pool.Invoke(nil)
t.Logf("慢启动初始: cap=%d, inSlowStart=%v", initialCap, pool.inSlowStart)
}
time.Sleep(time.Millisecond * 50)
finalCap := pool.Cap()
// 验证:即使耗尽率低,也不应该立即恢复(保守策略)
// 或者即使恢复,也很有限
if finalCap > reducedCap+5 {
t.Logf("恢复行为: %d -> %d", reducedCap, finalCap)
}
}
// =============================================================================
// 场景4:边界条件测试(中价值)
// =============================================================================
// TestAdaptivePool_MinSizeBoundary 验证不会降到 minSize 以下
func TestAdaptivePool_MinSizeBoundary(t *testing.T) {
state := common.NewState()
// 创建小线程池,minSize会是10
pool, err := NewAdaptivePool(40, func(interface{}) {}, state)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
pool, metrics := newTestPool(t, 40, func(interface{}) {})
defer pool.Release()
// 模拟极端的资源耗尽:100%失败
for i := 0; i < 1000; i++ {
state.IncrementPacketCount()
state.IncrementResourceExhaustedCount()
pool.inSlowStart = false
pool.tune(40)
// 极端耗尽
for i := 0; i < 500; i++ {
metrics.RecordExhausted()
}
// 触发多次调整
for i := 0; i < 50; i++ {
_ = pool.Invoke(nil)
time.Sleep(time.Millisecond)
time.Sleep(time.Millisecond * 15)
}
finalCap := pool.Cap()
// 验证:不应该低于10
if finalCap < 10 {
t.Errorf("线程数 < 10: %d", finalCap)
}
@@ -162,74 +103,50 @@ func TestAdaptivePool_MinSizeBoundary(t *testing.T) {
t.Logf("最小边界测试通过: cap=%d", finalCap)
}
// =============================================================================
// 场景5:样本不足测试(低价值但重要)
// =============================================================================
// TestAdaptivePool_NotEnoughSamples 验证样本不足时不调整
// 防止基于小样本做错误决策
func TestAdaptivePool_NotEnoughSamples(t *testing.T) {
state := common.NewState()
pool, err := NewAdaptivePool(100, func(interface{}) {}, state)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
pool, metrics := newTestPool(t, 100, func(interface{}) {})
defer pool.Release()
pool.inSlowStart = false
pool.tune(100)
initialCap := pool.Cap()
// 只增加少量样本(<100),不足以触发调整
for i := 0; i < 50; i++ {
state.IncrementPacketCount()
state.IncrementResourceExhaustedCount() // 即使100%失败也不调整
// 只 20 个样本,不足 30 的阈值
for i := 0; i < 20; i++ {
metrics.RecordExhausted()
}
// 提交任务
for i := 0; i < 10; i++ {
_ = pool.Invoke(nil)
}
time.Sleep(time.Millisecond * 50)
finalCap := pool.Cap()
// 验证:样本不足时不应该调整
if finalCap != initialCap {
t.Errorf("样本不足时不应该调整: %d -> %d", initialCap, finalCap)
}
}
// =============================================================================
// 辅助函数
// =============================================================================
// TestAdaptivePool_Wait 验证Wait方法正确等待所有任务完成
// TestAdaptivePool_Wait 验证 Wait 方法
func TestAdaptivePool_Wait(t *testing.T) {
state := common.NewState()
pool, err := NewAdaptivePool(10, func(interface{}) {
pool, _ := newTestPool(t, 10, func(interface{}) {
time.Sleep(time.Millisecond * 50)
}, state)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
})
defer pool.Release()
// 提交任务
pool.inSlowStart = false
pool.tune(10)
for i := 0; i < 20; i++ {
_ = pool.Invoke(nil)
}
// Wait应该在所有任务完成后返回
start := time.Now()
pool.Wait()
duration := time.Since(start)
// 20个任务,每个50ms,10个线程,应该约100ms完成
if duration < 80*time.Millisecond {
t.Logf("Wait提前返回?可能测试有问题: %v", duration)
}
if duration > 200*time.Millisecond {
if duration > 300*time.Millisecond {
t.Errorf("Wait 耗时过长: %v", duration)
}
+46 -39
View File
@@ -29,6 +29,7 @@ const (
type BaseScanStrategy struct {
strategyName string
filterType PluginFilterType
state *common.State
}
// NewBaseScanStrategy 创建基础扫描策略
@@ -39,6 +40,11 @@ func NewBaseScanStrategy(name string, filterType PluginFilterType) *BaseScanStra
}
}
// SetState 注入 session state(用于 per-session 服务缓存)
func (b *BaseScanStrategy) SetState(state *common.State) {
b.state = state
}
// GetPlugins 获取插件列表
func (b *BaseScanStrategy) GetPlugins(config *common.Config) ([]string, bool) {
scanMode := config.Mode
@@ -86,6 +92,11 @@ func (b *BaseScanStrategy) IsPluginApplicableByName(pluginName string, targetHos
return b.isPluginPassesFilterType(pluginName, isCustomMode, config)
}
// -full 模式下,web 插件对所有开放端口生效(跳过 IsMarkedWebService 检查)
if config.POC.Full && b.isWebPlugin(pluginName) {
return b.isPluginPassesFilterType(pluginName, isCustomMode, config)
}
// 检查端口匹配和过滤器类型
return b.isPluginApplicableToPortWithHost(pluginName, targetHost, targetPort) && b.isPluginPassesFilterType(pluginName, isCustomMode, config)
}
@@ -115,9 +126,10 @@ func (b *BaseScanStrategy) isLocalPluginExplicitlySpecified(pluginName string, c
}
// isPluginApplicableToPortWithHost 检查插件是否适用于指定端口
// 匹配策略:端口匹配 → 服务名称匹配(解决非标准端口问题)
func (b *BaseScanStrategy) isPluginApplicableToPortWithHost(pluginName string, targetHost string, targetPort int) bool {
if b.isWebPlugin(pluginName) {
return IsMarkedWebService(targetHost, targetPort)
return IsMarkedWebServiceWithState(b.state, targetHost, targetPort)
}
pluginPorts := b.getPluginPorts(pluginName)
@@ -136,10 +148,23 @@ func (b *BaseScanStrategy) isPluginApplicableToPortWithHost(pluginName string, t
}
}
// 端口不匹配时,按指纹识别结果匹配
// 例:8881 端口上识别到 ssh 服务 → ssh 插件应该执行
if targetHost != "" && targetPort > 0 {
if info, ok := GetCachedServiceInfoWithState(b.state, targetHost, targetPort); ok && info != nil {
if strings.EqualFold(info.Name, pluginName) {
return true
}
}
}
return false
}
func (b *BaseScanStrategy) isPluginApplicableToPort(pluginName string, targetPort int) bool {
if b.isWebPlugin(pluginName) {
return true
}
return b.isPluginApplicableToPortWithHost(pluginName, "", targetPort)
}
@@ -178,27 +203,9 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM
}
}
// LogPluginInfo 输出插件信息
// LogPluginInfo 默认不输出插件信息(service 默认端口模式有意保持安静,减少干扰)。
// 子类 LocalScanStrategy / ServiceScanStrategy 按需重写。
func (b *BaseScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) {
allPlugins, isCustomMode := b.GetPlugins(config)
var prefix string
switch b.filterType {
case FilterLocal:
prefix = i18n.GetText("concurrency_local_plugin")
case FilterService:
prefix = i18n.GetText("concurrency_service_plugin")
case FilterWeb:
prefix = i18n.GetText("concurrency_web_plugin")
default:
prefix = i18n.GetText("concurrency_plugin")
}
// 插件信息不再输出,减少干扰
_ = allPlugins
_ = isCustomMode
_ = prefix
_ = session
}
// formatPluginList 格式化插件列表(超过5个时精简显示)
@@ -253,32 +260,32 @@ func (b *BaseScanStrategy) getPluginsByFilterType() []string {
filteredPlugins = append(filteredPlugins, pluginName)
}
}
// 确保 webtitle 在 webpoc 之前执行,避免指纹识别竞态
sort.Slice(filteredPlugins, func(i, j int) bool {
// webtitle 必须在 webpoc 之前
if filteredPlugins[i] == "webtitle" {
return true
}
if filteredPlugins[j] == "webtitle" {
return false
}
if filteredPlugins[i] == "webpoc" {
return false
}
if filteredPlugins[j] == "webpoc" {
return true
}
// 其他插件保持字母顺序
return filteredPlugins[i] < filteredPlugins[j]
})
default:
// 无过滤器:返回所有插件
filteredPlugins = allPlugins
}
orderWebPlugins(filteredPlugins)
return filteredPlugins
}
func orderWebPlugins(pluginNames []string) {
sort.SliceStable(pluginNames, func(i, j int) bool {
return webPluginOrder(pluginNames[i]) < webPluginOrder(pluginNames[j])
})
}
func webPluginOrder(pluginName string) int {
switch pluginName {
case "webtitle":
return 0
case "webpoc":
return 2
default:
return 1
}
}
// parsePluginList 解析插件列表字符串
func parsePluginList(pluginStr string) []string {
if pluginStr == "" {
+110
View File
@@ -2,6 +2,9 @@ package core
import (
"testing"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
// =============================================================================
@@ -261,6 +264,113 @@ func slicesEqual(a, b []string) bool {
return true
}
func TestOrderWebPlugins(t *testing.T) {
plugins := []string{"ssh", "webpoc", "redis", "webtitle", "mysql"}
orderWebPlugins(plugins)
expected := []string{"webtitle", "ssh", "redis", "mysql", "webpoc"}
if !slicesEqual(plugins, expected) {
t.Fatalf("orderWebPlugins = %#v, want %#v", plugins, expected)
}
}
func TestBaseScanStrategyPluginSelectionAndApplicability(t *testing.T) {
registerTestPlugins(t)
plugins.RegisterWithOptions("core_test_local", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
plugins.RegisterWithOptions("core_test_udp", func() plugins.Plugin { return nil }, []int{161}, []string{plugins.PluginTypeUDP}, true)
clearServiceCache()
cfg := common.NewConfig()
cfg.Mode = "ssh, missing_plugin, webtitle"
strategy := NewBaseScanStrategy("service", FilterService)
got, custom := strategy.GetPlugins(cfg)
if !custom {
t.Fatal("explicit mode should be marked as custom")
}
if !slicesEqual(got, []string{"ssh", "webtitle"}) {
t.Fatalf("custom plugins = %#v, want ssh/webtitle", got)
}
cfg.Mode = "all"
servicePlugins, custom := strategy.GetPlugins(cfg)
if custom {
t.Fatal("all mode should not be custom")
}
if !containsString(servicePlugins, "ssh") || containsString(servicePlugins, "core_test_local") || containsString(servicePlugins, "core_test_udp") {
t.Fatalf("service filtered plugins = %#v", servicePlugins)
}
if !strategy.pluginExists("ssh") || strategy.pluginExists("missing_plugin") {
t.Fatal("pluginExists returned wrong result")
}
if !strategy.isPluginApplicableToPort("ssh", 22) || strategy.isPluginApplicableToPort("ssh", 23) {
t.Fatal("port applicability for ssh is wrong")
}
CacheServiceInfo("10.0.0.9", 22222, &ServiceInfo{Name: "ssh"})
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.9", 22222) {
t.Fatal("service cache should allow ssh on a non-standard port")
}
if !strategy.IsPluginApplicableByName("ssh", "10.0.0.9", 1, true, cfg) {
t.Fatal("custom mode should respect explicitly selected plugin")
}
if strategy.IsPluginApplicableByName("missing_plugin", "10.0.0.9", 22, true, cfg) {
t.Fatal("missing plugin should never be applicable")
}
}
func TestBaseScanStrategyFilterTypes(t *testing.T) {
plugins.RegisterWithOptions("core_test_local_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
plugins.RegisterWithOptions("core_test_web_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeWeb}, true)
plugins.RegisterWithOptions("core_test_udp_filter", func() plugins.Plugin { return nil }, []int{53}, []string{plugins.PluginTypeUDP}, true)
cfg := common.NewConfig()
localStrategy := NewBaseScanStrategy("local", FilterLocal)
if localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
t.Fatal("local plugin should require explicit -local selection")
}
cfg.LocalPlugin = "core_test_local_filter"
if !localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
t.Fatal("explicit local plugin should pass local filter")
}
serviceStrategy := NewBaseScanStrategy("service", FilterService)
if !serviceStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Fatal("service plugin should pass service filter")
}
if serviceStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) ||
serviceStrategy.isPluginPassesFilterType("core_test_udp_filter", false, cfg) {
t.Fatal("service filter should reject local and UDP plugins")
}
webStrategy := NewBaseScanStrategy("web", FilterWeb)
if !webStrategy.isPluginPassesFilterType("core_test_web_filter", false, cfg) ||
webStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Fatal("web filter should only allow web plugins")
}
if webPluginOrder("webtitle") != 0 || webPluginOrder("webpoc") != 2 || webPluginOrder("other") != 1 {
t.Fatal("web plugin order changed")
}
}
func TestFormatPluginList(t *testing.T) {
if got := formatPluginList([]string{"a", "b", "c"}); got != "a, b, c" {
t.Fatalf("short plugin list = %q", got)
}
if got := formatPluginList([]string{"a", "b", "c", "d", "e", "f"}); got == "" || got == "a, b, c, d, e, f" {
t.Fatalf("long plugin list should be summarized, got %q", got)
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
// TestNewBaseScanStrategy 测试构造函数
func TestNewBaseScanStrategy(t *testing.T) {
tests := []struct {
+651
View File
@@ -0,0 +1,651 @@
package core
import (
"math"
"sync"
"testing"
"time"
)
// =============================================================================
// computeRetries 边界
// =============================================================================
func TestComputeRetries_EdgeCases(t *testing.T) {
tests := []struct {
lossRate float64
wantMin int
wantMax int
desc string
}{
{-0.5, 1, 1, "负数丢包率: 视为零"},
{-1.0, 1, 1, "负一: 视为零"},
{0.0, 1, 1, "精确零"},
{0.001, 1, 1, "精确边界 0.001"},
{0.0009, 1, 1, "低于 0.001 边界"},
{0.0011, 1, 5, "高于 0.001 边界"},
{0.95, 5, 5, "精确边界 0.95"},
{0.949, 1, 5, "低于 0.95 边界"},
{0.951, 5, 5, "高于 0.95 边界"},
{1.0, 5, 5, "精确 1.0"},
{1.5, 5, 5, "超过 1.0"},
{100.0, 5, 5, "极大值"},
{math.SmallestNonzeroFloat64, 1, 1, "最小正浮点数"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got := computeRetries(tt.lossRate, EnvWAN)
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("computeRetries(%v) = %d, want [%d, %d]",
tt.lossRate, got, tt.wantMin, tt.wantMax)
}
if got < 1 || got > 5 {
t.Errorf("computeRetries(%v) = %d, 超出 [1,6] 范围", tt.lossRate, got)
}
})
}
}
func TestComputeRetries_NaN_Inf(t *testing.T) {
// 确保不 panic
for _, v := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
got := computeRetries(v, EnvWAN)
if got < 1 || got > 5 {
t.Errorf("computeRetries(%v) = %d, 超出 [1,6] 范围", v, got)
}
}
}
// =============================================================================
// computeICMPRate 边界
// =============================================================================
func TestComputeICMPRate_EdgeCases(t *testing.T) {
tests := []struct {
env NetworkEnv
fdLimit int
desc string
}{
{EnvLAN, 1, "fd=1: 极小"},
{EnvLAN, -1, "fd=负数: 应被忽略"},
{EnvLAN, 0, "fd=0: 未知"},
{EnvLAN, math.MaxInt32, "fd=极大"},
{NetworkEnv(99), 1024, "未知环境类型"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
net := &NetworkProfile{Env: tt.env}
sys := &SystemProfile{FDLimit: tt.fdLimit}
got := computeICMPRate(net, sys)
if got <= 0 || math.IsNaN(got) || math.IsInf(got, 0) {
t.Errorf("computeICMPRate(env=%v, fd=%d) = %v, 无效值", tt.env, tt.fdLimit, got)
}
})
}
}
// =============================================================================
// classifyEnv 精确边界值
// =============================================================================
func TestClassifyEnv_ExactBoundaries(t *testing.T) {
tests := []struct {
median time.Duration
lossRate float64
want NetworkEnv
desc string
}{
// RTT 边界
{4999 * time.Microsecond, 0.0, EnvLAN, "4.999ms → LAN"},
{5 * time.Millisecond, 0.0, EnvWAN, "精确 5ms → WAN"},
{49999 * time.Microsecond, 0.0, EnvWAN, "49.999ms → WAN"},
{50 * time.Millisecond, 0.0, EnvInternet, "精确 50ms → Internet"},
{199999 * time.Microsecond, 0.0, EnvInternet, "199.999ms → Internet"},
{200 * time.Millisecond, 0.0, EnvSlow, "精确 200ms → Slow"},
// 丢包率边界
{1 * time.Millisecond, 0.009, EnvLAN, "丢包 0.9% → LAN"},
{1 * time.Millisecond, 0.01, EnvWAN, "精确 1% → WAN (不满足 < 0.01)"},
{1 * time.Millisecond, 0.011, EnvWAN, "丢包 1.1% → WAN (超过 LAN 阈值)"},
{20 * time.Millisecond, 0.049, EnvWAN, "丢包 4.9% → WAN"},
{20 * time.Millisecond, 0.05, EnvInternet, "精确 5% → Internet (不满足 < 0.05)"},
{20 * time.Millisecond, 0.051, EnvInternet, "丢包 5.1% → Internet"},
{1 * time.Millisecond, 0.099, EnvInternet, "丢包 9.9% → Internet"},
{1 * time.Millisecond, 0.10, EnvInternet, "精确 10% → Internet (< 判断)"},
{1 * time.Millisecond, 0.101, EnvSlow, "丢包 10.1% → Slow"},
// 零值
{0, 0.0, EnvLAN, "零 RTT 零丢包 → LAN"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got := classifyEnv(tt.median, tt.lossRate)
if got != tt.want {
t.Errorf("classifyEnv(median=%v, loss=%.4f) = %v, want %v",
tt.median, tt.lossRate, got, tt.want)
}
})
}
}
// =============================================================================
// classifyNetwork 边界
// =============================================================================
func TestClassifyNetwork_EdgeCases(t *testing.T) {
t.Run("单个 RTT 样本", func(t *testing.T) {
p := classifyNetwork([]time.Duration{5 * time.Millisecond}, 0, 1)
if p.Samples != 1 {
t.Errorf("samples = %d, want 1", p.Samples)
}
// stddev 应该是 0
if p.RTTStddev != 0 {
t.Errorf("单样本 stddev = %v, want 0", p.RTTStddev)
}
})
t.Run("所有 RTT 相同", func(t *testing.T) {
rtts := make([]time.Duration, 50)
for i := range rtts {
rtts[i] = 10 * time.Millisecond
}
p := classifyNetwork(rtts, 0, 50)
if p.RTTStddev != 0 {
t.Errorf("全相同 RTT stddev = %v, want 0", p.RTTStddev)
}
if p.RTTMedian != 10*time.Millisecond {
t.Errorf("median = %v, want 10ms", p.RTTMedian)
}
})
t.Run("极大 RTT 值", func(t *testing.T) {
rtts := []time.Duration{time.Hour, time.Hour, time.Hour}
p := classifyNetwork(rtts, 0, 3)
if p.Env != EnvSlow {
t.Errorf("env = %v, want Slow", p.Env)
}
})
t.Run("混合极端值", func(t *testing.T) {
rtts := []time.Duration{time.Microsecond, time.Hour}
p := classifyNetwork(rtts, 0, 2)
// 不 panic 就行
if p.Samples != 2 {
t.Errorf("samples = %d, want 2", p.Samples)
}
})
t.Run("全部失败无响应", func(t *testing.T) {
p := classifyNetwork(nil, 100, 100)
if p.Env != EnvWAN {
t.Errorf("env = %v, want WAN (default)", p.Env)
}
})
t.Run("failures > total (异常输入)", func(t *testing.T) {
rtts := []time.Duration{time.Millisecond}
p := classifyNetwork(rtts, 10, 5) // failures > total
// lossRate = 1 - 1/5 = 0.8, 不应 panic
if p.LossRate < 0 {
t.Errorf("lossRate = %.2f, 不应为负", p.LossRate)
}
})
t.Run("total=0", func(t *testing.T) {
p := classifyNetwork(nil, 0, 0)
// 不 panic
if p.Samples != 0 {
t.Errorf("samples = %d, want 0", p.Samples)
}
})
}
// =============================================================================
// RecommendConcurrency 边界
// =============================================================================
func TestRecommendConcurrency_EdgeCases(t *testing.T) {
tests := []struct {
env NetworkEnv
loss float64
userT int
explicit bool
desc string
}{
{EnvLAN, 0.0, 0, false, "userThreadNum=0"},
{EnvLAN, 0.0, 1, false, "userThreadNum=1"},
{EnvLAN, 0.0, -1, false, "userThreadNum 负数"},
{EnvLAN, 0.0, math.MaxInt32, false, "userThreadNum 极大"},
{EnvLAN, 0.99, 600, false, "99% 丢包"},
{EnvLAN, 1.0, 600, false, "100% 丢包"},
{EnvSlow, 0.0, 1, true, "慢速+显式+1"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
p := &NetworkProfile{Env: tt.env, LossRate: tt.loss, Samples: 10}
target, ceiling := p.RecommendConcurrency(tt.userT, tt.explicit)
// 不 panic,且 target >= 1(clamp 保底 10 或 userT)
if target < 0 || ceiling < 0 {
t.Errorf("target=%d ceiling=%d, 不应为负", target, ceiling)
}
if tt.explicit && ceiling != tt.userT && tt.userT > 0 {
t.Errorf("显式模式 ceiling=%d, want %d", ceiling, tt.userT)
}
t.Logf("env=%v loss=%.2f userT=%d explicit=%v → target=%d ceiling=%d",
tt.env, tt.loss, tt.userT, tt.explicit, target, ceiling)
})
}
}
// =============================================================================
// ScanMetrics 边界
// =============================================================================
func TestScanMetrics_EdgeCases(t *testing.T) {
t.Run("RTT=0", func(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(0)
// 不 panic
if m.Total() != 1 {
t.Errorf("Total = %d, want 1", m.Total())
}
})
t.Run("负数 RTT", func(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(-time.Millisecond)
// 不 panic,负数 RTT 应被忽略
if m.rttSamples.Load() != 0 {
t.Errorf("负数 RTT 不应计入采样: got %d", m.rttSamples.Load())
}
})
t.Run("极大 RTT", func(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(time.Hour)
if m.RTTFast() != time.Hour {
t.Errorf("首个样本 RTTFast = %v, want 1h", m.RTTFast())
}
})
t.Run("EMA 首个样本初始化", func(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(10 * time.Millisecond)
if m.rttFastNs.Load() != int64(10*time.Millisecond) {
t.Errorf("首个样本应直接设置 EMA: got %d", m.rttFastNs.Load())
}
})
t.Run("空 Snapshot", func(t *testing.T) {
m := &ScanMetrics{}
snap := m.Snapshot()
if snap.Total() != 0 {
t.Errorf("空 metrics Snapshot.Total = %d, want 0", snap.Total())
}
})
t.Run("RTTRatio 单侧为零", func(t *testing.T) {
m := &ScanMetrics{}
// 手动设置一个但不设另一个——不应该发生,但防御
m.rttFastNs.Store(1000)
m.rttSlowNs.Store(0)
m.rttSamples.Store(30)
ratio := m.RTTRatio()
if ratio != 1.0 {
t.Errorf("slow=0 时 ratio = %.2f, want 1.0", ratio)
}
})
t.Run("大量操作不溢出", func(t *testing.T) {
m := &ScanMetrics{}
for i := 0; i < 100000; i++ {
m.RecordConnect(time.Millisecond)
}
if m.Total() != 100000 {
t.Errorf("Total = %d, want 100000", m.Total())
}
ratio := m.RTTRatio()
if math.IsNaN(ratio) || math.IsInf(ratio, 0) {
t.Errorf("大量样本后 ratio = %v, 不应为 NaN/Inf", ratio)
}
})
}
// =============================================================================
// TuneConfig 边界
// =============================================================================
func TestTuneConfig_EdgeCases(t *testing.T) {
t.Run("RTTMedian=0 RTTStddev=0", func(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{Env: EnvLAN, RTTMedian: 0, RTTStddev: 0, Samples: 10},
System: SystemProfile{FDLimit: 65536},
}
ep.TuneConfig(config, session)
// Timeout: median(0) + 4*stddev(0) = 0 → minTO = 0+200ms → clamp to 1s
if config.Timeout < time.Second {
t.Errorf("零 RTT Timeout = %v, 应该 >= 1s", config.Timeout)
}
})
t.Run("RTTStddev 远大于 RTTMedian", func(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{Env: EnvInternet, RTTMedian: 10 * time.Millisecond, RTTStddev: 5 * time.Second, Samples: 10},
System: SystemProfile{FDLimit: 65536},
}
ep.TuneConfig(config, session)
// Timeout = 10ms + 4*5s = 20.01s → clamp to 10s
if config.Timeout != 10*time.Second {
t.Errorf("极大 stddev Timeout = %v, 应该被 clamp 到 10s", config.Timeout)
}
})
t.Run("ThreadNum=0", func(t *testing.T) {
config := makeDefaultConfig()
config.ThreadNum = 0
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{Env: EnvLAN, RTTMedian: time.Millisecond, RTTStddev: time.Millisecond, Samples: 10},
System: SystemProfile{FDLimit: 65536},
}
ep.TuneConfig(config, session)
// ModuleThreadNum = 0/30 = 0 → clamp to 5
if config.ModuleThreadNum < 5 {
t.Errorf("ThreadNum=0 时 ModuleThreadNum = %d, 应该 >= 5", config.ModuleThreadNum)
}
})
t.Run("多次调用 TuneConfig", func(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{Env: EnvLAN, RTTMedian: time.Millisecond, RTTStddev: time.Millisecond, LossRate: 0.0, Samples: 10},
System: SystemProfile{FDLimit: 65536},
}
ep.TuneConfig(config, session)
first := config.Timeout
// 第二次调用——已经调整过的值不等于默认值,应被视为"显式"
ep.TuneConfig(config, session)
second := config.Timeout
if first != second {
t.Errorf("多次调用 TuneConfig 不应重复调整: %v vs %v", first, second)
}
})
t.Run("fd limit = ThreadNum 精确值", func(t *testing.T) {
config := makeDefaultConfig()
config.ThreadNum = 600
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{Samples: 0},
System: SystemProfile{FDLimit: 1000}, // 1000 * 0.6 = 600
}
ep.TuneConfig(config, session)
// ThreadNum(600) == maxConcurrency(600), 不应触发约束
if config.ThreadNum != 600 {
t.Errorf("fd=1000 时 ThreadNum = %d, 不应被约束", config.ThreadNum)
}
})
t.Run("fd limit 精确低于 ThreadNum", func(t *testing.T) {
config := makeDefaultConfig()
config.ThreadNum = 600
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{Samples: 0},
System: SystemProfile{FDLimit: 999}, // 999 * 0.6 = 599
}
ep.TuneConfig(config, session)
if config.ThreadNum > 599 {
t.Errorf("fd=999 时 ThreadNum = %d, 应该 <= 599", config.ThreadNum)
}
})
}
// =============================================================================
// AdaptivePool 边界
// =============================================================================
func TestAdaptivePool_EdgeCases(t *testing.T) {
t.Run("target=1", func(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(1, 1, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建失败: %v", err)
}
defer pool.Release()
// initial = max(1/4, 10) = 10 → 但 10 > target(1)... 看实现
// 实际上 initial = min(max(1/4, 10), 1) = 1... 不对
// initial = target/4 = 0, 但 < 10, 所以 initial = 10
// 但 initial > target(1)... initial = min(10, 1) = 1
// 看代码:if initial > target { initial = target }
if pool.Cap() != 1 {
t.Errorf("target=1 时 cap = %d, want 1", pool.Cap())
}
})
t.Run("target=0", func(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(0, 0, func(interface{}) {}, metrics)
// ants 可能拒绝 size=0
if err != nil {
t.Logf("target=0 正确返回错误: %v", err)
return
}
defer pool.Release()
t.Logf("target=0 cap = %d", pool.Cap())
})
t.Run("ceiling < target", func(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 50, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建失败: %v", err)
}
defer pool.Release()
// initial = 100/4 = 25, 不超过 ceiling
if pool.Cap() > 50 {
t.Errorf("ceiling=50 但 cap = %d", pool.Cap())
}
})
t.Run("高频 Invoke 不 panic", func(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(10, 10, func(interface{}) {
time.Sleep(time.Millisecond)
}, metrics)
if err != nil {
t.Fatalf("创建失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(10)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = pool.Invoke(nil)
}()
}
wg.Wait()
pool.Wait()
})
t.Run("assessHealth 零增量", func(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建失败: %v", err)
}
defer pool.Release()
// 初始化 prevSnapshot 后不产生新数据
pool.prevSnapshot = metrics.Snapshot()
health := pool.assessHealth()
if health != HealthUnknown {
t.Errorf("零增量应返回 HealthUnknown, got %v", health)
}
})
}
// =============================================================================
// pickSamples 边界
// =============================================================================
func TestPickSamples_EdgeCases(t *testing.T) {
t.Run("maxSamples=0", func(t *testing.T) {
s := pickSamples([]string{"a", "b"}, 0)
if len(s) != 0 {
t.Errorf("maxSamples=0 应返回空, got %d", len(s))
}
})
t.Run("maxSamples=1", func(t *testing.T) {
s := pickSamples([]string{"a", "b", "c"}, 1)
if len(s) != 1 {
t.Errorf("maxSamples=1 应返回 1 个, got %d", len(s))
}
})
t.Run("hosts 等于 maxSamples", func(t *testing.T) {
hosts := []string{"a", "b", "c"}
s := pickSamples(hosts, 3)
if len(s) != 3 {
t.Errorf("应返回全部, got %d", len(s))
}
})
}
// =============================================================================
// isTimeoutError / isConnectionRefused 边界
// =============================================================================
func TestIsTimeoutError_EdgeCases(t *testing.T) {
if isTimeoutError(nil) {
t.Error("nil 不应判为 timeout")
}
}
func TestIsConnectionRefused_EdgeCases(t *testing.T) {
if isConnectionRefused(nil) {
t.Error("nil 不应判为 refused")
}
}
// =============================================================================
// NetworkEnv.String 覆盖
// =============================================================================
func TestNetworkEnv_String(t *testing.T) {
for _, env := range []NetworkEnv{EnvLAN, EnvWAN, EnvInternet, EnvSlow} {
s := env.String()
if s == "" {
t.Errorf("NetworkEnv(%d).String() = 空", env)
}
}
// 未知值
s := NetworkEnv(99).String()
if s == "" {
t.Error("未知 NetworkEnv.String() = 空")
}
}
// =============================================================================
// clampInt / clampDuration 边界
// =============================================================================
func TestClampInt(t *testing.T) {
tests := []struct {
v, min, max, want int
}{
{5, 1, 10, 5},
{0, 1, 10, 1},
{15, 1, 10, 10},
{-5, -10, -1, -5},
{5, 5, 5, 5}, // min == max == v
{3, 5, 5, 5}, // v < min == max
{10, 5, 5, 5}, // v > min == max
}
for _, tt := range tests {
got := clampInt(tt.v, tt.min, tt.max)
if got != tt.want {
t.Errorf("clampInt(%d, %d, %d) = %d, want %d", tt.v, tt.min, tt.max, got, tt.want)
}
}
}
func TestClampDuration(t *testing.T) {
got := clampDuration(5*time.Second, time.Second, 10*time.Second)
if got != 5*time.Second {
t.Errorf("got %v, want 5s", got)
}
got = clampDuration(0, time.Second, 10*time.Second)
if got != time.Second {
t.Errorf("got %v, want 1s", got)
}
got = clampDuration(time.Hour, time.Second, 10*time.Second)
if got != 10*time.Second {
t.Errorf("got %v, want 10s", got)
}
}
// =============================================================================
// isExplicit 边界
// =============================================================================
func TestIsExplicit(t *testing.T) {
config := makeDefaultConfig()
// 默认值 → 非显式
if isExplicit(config, "time") {
t.Error("默认 Timeout 不应视为显式")
}
if isExplicit(config, "mt") {
t.Error("默认 ModuleThreadNum 不应视为显式")
}
if isExplicit(config, "retry") {
t.Error("默认 MaxRetries 不应视为显式")
}
if isExplicit(config, "icmp-rate") {
t.Error("默认 ICMPRate 不应视为显式")
}
if isExplicit(config, "num") {
t.Error("默认 PocNum 不应视为显式")
}
// 未知 flag
if isExplicit(config, "nonexistent") {
t.Error("未知 flag 不应视为显式")
}
// ThreadNumExplicit
config.ThreadNumExplicit = true
if !isExplicit(config, "t") {
t.Error("ThreadNumExplicit=true 应视为显式")
}
config = makeDefaultConfig()
config.TimeoutExplicit = true
config.ModuleThreadNumExplicit = true
config.MaxRetriesExplicit = true
config.Network.ICMPRateExplicit = true
config.POC.NumExplicit = true
if !isExplicit(config, "time") || !isExplicit(config, "mt") ||
!isExplicit(config, "retry") || !isExplicit(config, "icmp-rate") ||
!isExplicit(config, "num") {
t.Error("显式标记为 true 时默认值也应视为显式")
}
}
+256
View File
@@ -0,0 +1,256 @@
package core
import (
"fmt"
"math"
"runtime"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
)
// EnvironmentProfile 综合环境探测结果
type EnvironmentProfile struct {
Net NetworkProfile
System SystemProfile
}
// SystemProfile 系统能力信息
type SystemProfile struct {
FDLimit int // 文件描述符上限(0 表示未知)
NumCPU int
}
// ProbeSystem 探测系统能力(不需要网络目标)
func ProbeSystem() SystemProfile {
p := SystemProfile{
NumCPU: runtime.NumCPU(),
}
p.FDLimit = getFDLimit()
return p
}
// TuneConfig 根据探测结果调整 Config 中的参数
// 只调整用户未显式指定的参数
// 每个参数的推导都有明确的公式和探测依据
func (ep *EnvironmentProfile) TuneConfig(config *common.Config, session *common.ScanSession) {
net := &ep.Net
sys := &ep.System
// ---------- NetworkEnv ----------
config.DetectedNetworkEnv = int(net.Env)
// ---------- ThreadNum / ThreadCeiling ----------
if !isExplicit(config, "t") {
target, ceiling := net.RecommendConcurrency(config.ThreadNum, config.ThreadNumExplicit)
old := config.ThreadNum
config.ThreadNum = target
config.ThreadCeiling = ceiling
session.LogDebug(fmt.Sprintf("ThreadNum: %d -> %d, Ceiling: %d (env=%s)", old, target, ceiling, net.Env))
} else {
config.ThreadCeiling = config.ThreadNum
}
// ---------- Timeout ----------
// 公式: median_rtt + 4 * stddev,下限 1s,上限 10s
// 依据: 与 AdaptiveTimeout 相同的统计原理(覆盖 99.9% 的正常连接)
if !isExplicit(config, "time") && net.Samples > 0 {
computed := net.RTTMedian + 4*net.RTTStddev
// 下限:连接建立至少需要 2 个 RTT(SYN + SYN-ACK)+ 处理时间
minTO := net.RTTMedian*3 + 200*time.Millisecond
if computed < minTO {
computed = minTO
}
computed = clampDuration(computed, time.Second, 10*time.Second)
old := config.Timeout
config.Timeout = computed
session.LogDebug(fmt.Sprintf("Timeout: %v -> %v (RTT median=%v stddev=%v)",
old, computed, net.RTTMedian, net.RTTStddev))
}
// ---------- ModuleThreadNum ----------
// 公式: ThreadNum / 30,下限 5,上限 50
// 依据: 插件级并发(爆破等)不应超过端口扫描并发的 ~3%
// 单个服务的连接能力远低于 TCP SYN 扫描
// 公网服务通常有限流(MaxStartups 等),并发过高适得其反
if !isExplicit(config, "mt") {
computed := config.ThreadNum / 30
computed = clampInt(computed, 5, 50)
// 高丢包环境进一步压低,避免大量连接被丢弃浪费
if net.LossRate > 0.1 {
computed = computed * 2 / 3
if computed < 5 {
computed = 5
}
}
old := config.ModuleThreadNum
config.ModuleThreadNum = computed
session.LogDebug(fmt.Sprintf("ModuleThreadNum: %d -> %d (threadNum=%d)", old, computed, config.ThreadNum))
}
// ---------- MaxRetries ----------
// 公式: ceil(log(0.01) / log(loss_rate))
// 含义: 重试 N 次后仍然全部丢包的概率 < 1%
// 例: 丢包率 5% → N=2, 丢包率 20% → N=3, 丢包率 50% → N=7
// 下限 1(零丢包也至少试一次),上限 6(避免对不可达目标死磕)
if !isExplicit(config, "retry") {
if net.Samples > 0 {
computed := computeRetries(net.LossRate, net.Env)
old := config.MaxRetries
config.MaxRetries = computed
session.LogDebug(fmt.Sprintf("MaxRetries: %d -> %d (loss_rate=%.2f%%)", old, computed, net.LossRate*100))
} else if config.MaxRetries > 2 {
// 无网络探测数据(-np 跳过存活探测),降低默认重试避免对不可达主机死磕
old := config.MaxRetries
config.MaxRetries = 2
session.LogDebug(fmt.Sprintf("MaxRetries: %d -> %d (no network probe data)", old, config.MaxRetries))
}
}
// ---------- ICMPRate ----------
// 公式: 基于 fd limit 和网络环境
// 内网 fd 充裕: 0.5(高速发包)
// 公网或 fd 紧张: 0.1(默认保守)
// 依据: ICMP 发包速率受两个约束:网络带宽和本机 fd/socket 资源
if !isExplicit(config, "icmp-rate") && net.Samples > 0 {
computed := computeICMPRate(net, sys)
old := config.Network.ICMPRate
config.Network.ICMPRate = computed
session.LogDebug(fmt.Sprintf("ICMPRate: %.2f -> %.2f (env=%s fd=%d)", old, computed, net.Env, sys.FDLimit))
}
// ---------- PocNum ----------
// 公式: 与 ModuleThreadNum 一致
// 依据: POC 检测和凭据爆破的并发约束相同——都是对目标服务发起连接
if !isExplicit(config, "num") {
old := config.POC.Num
config.POC.Num = config.ModuleThreadNum
session.LogDebug(fmt.Sprintf("PocNum: %d -> %d (follows ModuleThreadNum)", old, config.POC.Num))
}
// ---------- DisablePing ----------
// 由 probeWithICMP 自动处理(尝试 → 失败 → 降级),无需在此干预
// 总结日志
if net.Samples > 0 {
session.LogInfo(i18n.Tr("env_tune_summary",
config.Timeout.Milliseconds(),
config.ModuleThreadNum,
config.MaxRetries,
fmt.Sprintf("%.2f", config.Network.ICMPRate),
config.POC.Num))
}
// fd limit 约束:总并发不应超过 fd limit 的 60%(留余量给系统)
if sys.FDLimit > 0 {
maxConcurrency := sys.FDLimit * 6 / 10
if config.ThreadNum > maxConcurrency {
session.LogInfo(i18n.Tr("env_fd_limit", config.ThreadNum, maxConcurrency, sys.FDLimit))
config.ThreadNum = maxConcurrency
}
if config.ThreadCeiling > maxConcurrency {
config.ThreadCeiling = maxConcurrency
}
}
}
// computeRetries 基于丢包率和网络环境计算重试次数
// 内网丢包异常,用更严格的目标概率(0.5%)和更低上限
// 公网/慢速丢包常见,放宽目标概率(2%)和更高上限
func computeRetries(lossRate float64, env NetworkEnv) int {
if lossRate <= 0.001 {
return 1
}
var targetProb float64
var maxRetries int
switch env {
case EnvLAN:
targetProb = 0.005
maxRetries = 4
case EnvWAN:
targetProb = 0.01
maxRetries = 5
default:
targetProb = 0.02
maxRetries = 6
}
if lossRate >= 0.95 {
return maxRetries
}
// P(N次全失败) = lossRate^N < targetProb
n := math.Ceil(math.Log(targetProb) / math.Log(lossRate))
return clampInt(int(n), 1, maxRetries)
}
// computeICMPRate 基于环境计算 ICMP 发包速率
func computeICMPRate(net *NetworkProfile, sys *SystemProfile) float64 {
// 基准:根据 RTT 估算网络可承受的速率
// RTT 越低,网络越快,可以发更快
var base float64
switch net.Env {
case EnvLAN:
base = 0.5
case EnvWAN:
base = 0.3
case EnvInternet:
base = 0.1
default:
base = 0.05
}
// fd 约束:fd limit 低时压低速率
if sys.FDLimit > 0 && sys.FDLimit < 1024 {
base = base * float64(sys.FDLimit) / 1024.0
if base < 0.02 {
base = 0.02
}
}
return base
}
// isExplicit 检查参数是否被用户显式指定。
// 显式标记来自 CLI flag.Visit;值比较保留 SDK/测试里直接构造 Config 的旧行为。
func isExplicit(config *common.Config, flagName string) bool {
switch flagName {
case "t":
return config.ThreadNumExplicit
case "time":
return config.TimeoutExplicit || config.Timeout != 3*time.Second
case "mt":
return config.ModuleThreadNumExplicit || config.ModuleThreadNum != 20
case "retry":
return config.MaxRetriesExplicit || config.MaxRetries != 3
case "icmp-rate":
return config.Network.ICMPRateExplicit || config.Network.ICMPRate != 0.1
case "num":
return config.POC.NumExplicit || config.POC.Num != 20
}
return false
}
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func clampDuration(v, min, max time.Duration) time.Duration {
if v < min {
return min
}
if v > max {
return max
}
return v
}
+374
View File
@@ -0,0 +1,374 @@
package core
import (
"math"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
)
// =============================================================================
// 单元测试:computeRetries — 丢包率到重试次数的推导
// =============================================================================
func TestComputeRetries(t *testing.T) {
tests := []struct {
lossRate float64
wantMin int
wantMax int
desc string
}{
{0.0, 1, 1, "零丢包: 只需 1 次"},
{0.001, 1, 1, "极低丢包: 1 次"},
{0.05, 2, 2, "5% 丢包: 0.05^2=0.0025 < 0.01"},
{0.10, 2, 3, "10% 丢包: ceil(log(0.01)/log(0.1))=2, 但边界取 ceil 可能是 3"},
{0.20, 3, 3, "20% 丢包: 0.2^3=0.008 < 0.01"},
{0.30, 3, 4, "30% 丢包"},
{0.50, 5, 5, "50% 丢包: ceil(log(0.01)/log(0.5))=7 但上限 5"},
{0.80, 5, 5, "80% 丢包: 需要很多次但上限 5"},
{0.95, 5, 5, "95% 丢包: 触顶"},
{1.0, 5, 5, "100% 丢包: 触顶"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got := computeRetries(tt.lossRate, EnvWAN)
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("computeRetries(%.2f) = %d, want [%d, %d]",
tt.lossRate, got, tt.wantMin, tt.wantMax)
}
// 验证数学正确性:lossRate^got < 0.01
// 跳过:零丢包、极高丢包(触顶上限 6 时数学不满足,属于设计取舍)
if tt.lossRate > 0.001 && tt.lossRate < 0.45 {
prob := math.Pow(tt.lossRate, float64(got))
if prob >= 0.01 {
t.Errorf("lossRate=%.2f retries=%d: P(全失败)=%.4f >= 0.01, 重试不够",
tt.lossRate, got, prob)
}
}
})
}
}
// =============================================================================
// 单元测试:computeICMPRate
// =============================================================================
func TestComputeICMPRate(t *testing.T) {
tests := []struct {
env NetworkEnv
fdLimit int
wantMin float64
wantMax float64
desc string
}{
{EnvLAN, 65536, 0.4, 0.6, "内网高 fd: 高速"},
{EnvWAN, 65536, 0.2, 0.4, "局域网高 fd: 中速"},
{EnvInternet, 65536, 0.05, 0.15, "公网: 保守"},
{EnvSlow, 65536, 0.03, 0.08, "慢速: 极保守"},
{EnvLAN, 256, 0.01, 0.2, "内网低 fd: 受限"},
{EnvLAN, 0, 0.4, 0.6, "fd 未知: 按环境"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
net := &NetworkProfile{Env: tt.env}
sys := &SystemProfile{FDLimit: tt.fdLimit}
got := computeICMPRate(net, sys)
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("computeICMPRate(env=%v, fd=%d) = %.3f, want [%.3f, %.3f]",
tt.env, tt.fdLimit, got, tt.wantMin, tt.wantMax)
}
})
}
}
// =============================================================================
// 集成测试:TuneConfig — 完整参数调整流程
// =============================================================================
func TestTuneConfig_LAN(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMin: 500 * time.Microsecond,
RTTMedian: 1 * time.Millisecond,
RTTP95: 3 * time.Millisecond,
RTTStddev: 500 * time.Microsecond,
LossRate: 0.0,
Samples: 30,
},
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
// Timeout: median(1ms) + 4*stddev(0.5ms) = 3ms → clamp to 1s 下限
if config.Timeout < time.Second || config.Timeout > 2*time.Second {
t.Errorf("LAN Timeout = %v, 内网应该在 1-2s", config.Timeout)
}
// MaxRetries: 零丢包 → 1
if config.MaxRetries != 1 {
t.Errorf("LAN MaxRetries = %d, 零丢包应该是 1", config.MaxRetries)
}
// ICMPRate: 内网应该比默认 0.1 高
if config.Network.ICMPRate <= 0.1 {
t.Errorf("LAN ICMPRate = %.2f, 应该 > 0.1", config.Network.ICMPRate)
}
// ModuleThreadNum: 基于 ThreadNum/30
if config.ModuleThreadNum < 5 {
t.Errorf("LAN ModuleThreadNum = %d, 应该 >= 5", config.ModuleThreadNum)
}
t.Logf("LAN 参数: Timeout=%v, MT=%d, Retry=%d, ICMP=%.2f, POC=%d",
config.Timeout, config.ModuleThreadNum, config.MaxRetries, config.Network.ICMPRate, config.POC.Num)
}
func TestTuneConfig_Internet(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvInternet,
RTTMin: 50 * time.Millisecond,
RTTMedian: 100 * time.Millisecond,
RTTP95: 250 * time.Millisecond,
RTTStddev: 40 * time.Millisecond,
LossRate: 0.08,
Samples: 25,
},
System: SystemProfile{FDLimit: 1024, NumCPU: 4},
}
ep.TuneConfig(config, session)
// Timeout: median(100ms) + 4*stddev(40ms) = 260ms → 但 minTO = 3*100+200 = 500ms
if config.Timeout < 500*time.Millisecond || config.Timeout > 5*time.Second {
t.Errorf("Internet Timeout = %v, 公网应该在 500ms-5s", config.Timeout)
}
// MaxRetries: 8% 丢包 → ceil(log(0.01)/log(0.08)) ≈ 2
if config.MaxRetries < 2 || config.MaxRetries > 3 {
t.Errorf("Internet MaxRetries = %d, 8%%丢包应该是 2-3", config.MaxRetries)
}
// ICMPRate: 公网应该偏低
if config.Network.ICMPRate > 0.2 {
t.Errorf("Internet ICMPRate = %.2f, 应该 <= 0.2", config.Network.ICMPRate)
}
t.Logf("Internet 参数: Timeout=%v, MT=%d, Retry=%d, ICMP=%.2f, POC=%d",
config.Timeout, config.ModuleThreadNum, config.MaxRetries, config.Network.ICMPRate, config.POC.Num)
}
func TestTuneConfig_SlowLossy(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvSlow,
RTTMin: 200 * time.Millisecond,
RTTMedian: 500 * time.Millisecond,
RTTP95: 2 * time.Second,
RTTStddev: 300 * time.Millisecond,
LossRate: 0.25,
Samples: 15,
},
System: SystemProfile{FDLimit: 512, NumCPU: 2},
}
ep.TuneConfig(config, session)
// Timeout: median(500ms) + 4*stddev(300ms) = 1700ms, minTO = 500*3+200 = 1700ms
if config.Timeout < time.Second {
t.Errorf("Slow Timeout = %v, 慢速网络应该 >= 1s", config.Timeout)
}
// MaxRetries: 25% 丢包 → ceil(log(0.01)/log(0.25)) ≈ 4
if config.MaxRetries < 3 || config.MaxRetries > 5 {
t.Errorf("Slow MaxRetries = %d, 25%%丢包应该是 3-5", config.MaxRetries)
}
// ICMPRate: 慢速 + 低 fd → 应该很低
if config.Network.ICMPRate > 0.1 {
t.Errorf("Slow ICMPRate = %.2f, 应该 <= 0.1", config.Network.ICMPRate)
}
t.Logf("Slow 参数: Timeout=%v, MT=%d, Retry=%d, ICMP=%.2f, POC=%d",
config.Timeout, config.ModuleThreadNum, config.MaxRetries, config.Network.ICMPRate, config.POC.Num)
}
// =============================================================================
// 集成测试:用户显式指定时不覆盖
// =============================================================================
func TestTuneConfig_ExplicitOverride(t *testing.T) {
config := makeDefaultConfig()
config.Timeout = 5 * time.Second // 用户设了 -time 5
config.ModuleThreadNum = 50 // 用户设了 -mt 50
config.MaxRetries = 1 // 用户设了 -retry 1
config.Network.ICMPRate = 0.8 // 用户设了 -icmp-rate 0.8
config.POC.Num = 100 // 用户设了 -num 100
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMedian: 1 * time.Millisecond,
RTTStddev: 500 * time.Microsecond,
LossRate: 0.0,
Samples: 30,
},
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
// 所有非默认值都不应被覆盖
if config.Timeout != 5*time.Second {
t.Errorf("用户 Timeout 被覆盖: %v", config.Timeout)
}
if config.ModuleThreadNum != 50 {
t.Errorf("用户 ModuleThreadNum 被覆盖: %d", config.ModuleThreadNum)
}
if config.MaxRetries != 1 {
t.Errorf("用户 MaxRetries 被覆盖: %d", config.MaxRetries)
}
if config.Network.ICMPRate != 0.8 {
t.Errorf("用户 ICMPRate 被覆盖: %.2f", config.Network.ICMPRate)
}
if config.POC.Num != 100 {
t.Errorf("用户 PocNum 被覆盖: %d", config.POC.Num)
}
}
func TestTuneConfig_ExplicitDefaultValues(t *testing.T) {
config := makeDefaultConfig()
config.TimeoutExplicit = true
config.ModuleThreadNumExplicit = true
config.MaxRetriesExplicit = true
config.Network.ICMPRateExplicit = true
config.POC.NumExplicit = true
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMedian: 1 * time.Millisecond,
RTTStddev: 500 * time.Microsecond,
LossRate: 0.0,
Samples: 30,
},
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
if config.Timeout != 3*time.Second {
t.Errorf("显式默认 Timeout 被覆盖: %v", config.Timeout)
}
if config.ModuleThreadNum != 20 {
t.Errorf("显式默认 ModuleThreadNum 被覆盖: %d", config.ModuleThreadNum)
}
if config.MaxRetries != 3 {
t.Errorf("显式默认 MaxRetries 被覆盖: %d", config.MaxRetries)
}
if config.Network.ICMPRate != 0.1 {
t.Errorf("显式默认 ICMPRate 被覆盖: %.2f", config.Network.ICMPRate)
}
if config.POC.Num != 20 {
t.Errorf("显式默认 PocNum 被覆盖: %d", config.POC.Num)
}
}
// =============================================================================
// 集成测试:fd limit 约束
// =============================================================================
func TestTuneConfig_FDLimitConstraint(t *testing.T) {
config := makeDefaultConfig()
config.ThreadNum = 600
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMedian: 1 * time.Millisecond,
RTTStddev: 500 * time.Microsecond,
LossRate: 0.0,
Samples: 30,
},
System: SystemProfile{FDLimit: 256, NumCPU: 4},
}
ep.TuneConfig(config, session)
// 600 线程 > 256 * 0.6 = 153 → 应该被约束
maxExpected := 256 * 6 / 10
if config.ThreadNum > maxExpected {
t.Errorf("ThreadNum = %d, 应该 <= %d (fd_limit=256)", config.ThreadNum, maxExpected)
}
t.Logf("fd limit 约束: ThreadNum=%d (max=%d)", config.ThreadNum, maxExpected)
}
// =============================================================================
// 集成测试:零样本时不调整
// =============================================================================
func TestTuneConfig_NoSamples(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
origTimeout := config.Timeout
origRetry := config.MaxRetries
origICMP := config.Network.ICMPRate
ep := &EnvironmentProfile{
Net: NetworkProfile{Samples: 0},
System: SystemProfile{FDLimit: 65536},
}
ep.TuneConfig(config, session)
if config.Timeout != origTimeout {
t.Errorf("零样本不应改 Timeout: %v -> %v", origTimeout, config.Timeout)
}
// 零样本时,默认重试降到 2(避免对不可达主机死磕)
if origRetry > 2 && config.MaxRetries != 2 {
t.Errorf("零样本应降 MaxRetries 至 2: %d -> %d", origRetry, config.MaxRetries)
}
if config.Network.ICMPRate != origICMP {
t.Errorf("零样本不应改 ICMPRate: %.2f -> %.2f", origICMP, config.Network.ICMPRate)
}
}
// =============================================================================
// 辅助
// =============================================================================
func makeDefaultConfig() *common.Config {
return &common.Config{
Timeout: 3 * time.Second,
ThreadNum: 600,
ModuleThreadNum: 20,
MaxRetries: 3,
Network: common.NetworkConfig{ICMPRate: 0.1},
POC: common.POCConfig{Num: 20},
Output: common.OutputConfig{LogLevel: "base,info,success"},
}
}
func makeTestSession(config *common.Config) *common.ScanSession {
return common.NewScanSession(config, common.NewState(), &common.FlagVars{})
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package core
import "syscall"
func getFDLimit() int {
var lim syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
return 0
}
return int(lim.Cur)
}
+8
View File
@@ -0,0 +1,8 @@
//go:build windows
package core
// Windows 没有 RLIMIT_NOFILE,句柄上限由系统管理
func getFDLimit() int {
return 0
}
-5
View File
@@ -183,9 +183,6 @@ func probeWithICMP(hostslist []string, chanHosts chan string, aliveHosts *[]stri
return
}
common.LogError(i18n.Tr("icmp_listen_failed", err))
common.LogInfo(i18n.GetText("trying_no_listen_icmp"))
// 尝试无监听ICMP探测
conn2, err := net.DialTimeout("ip4:icmp", "127.0.0.1", 3*time.Second)
if err == nil {
@@ -194,8 +191,6 @@ func probeWithICMP(hostslist []string, chanHosts chan string, aliveHosts *[]stri
return
}
common.LogError(i18n.Tr("icmp_connect_failed", err))
common.LogError(i18n.GetText("insufficient_privileges"))
common.LogInfo(i18n.GetText("switching_to_ping"))
// 降级使用ping探测
+545
View File
@@ -0,0 +1,545 @@
package core
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// =============================================================================
// 集成测试 1:探测 → 参数调整 → 线程池创建 完整链路
// 验证从 NetworkProfile 到 TuneConfig 到 AdaptivePool 的端到端数据流
// =============================================================================
func TestIntegration_ProbeToPool_LAN(t *testing.T) {
// 模拟内网探测结果
profile := classifyNetwork(
makeDurations([]int{1, 1, 2, 2, 2, 3, 3, 3, 4, 5}), // ms
0, 10,
)
if profile.Env != EnvLAN {
t.Fatalf("探测环境 = %v, want LAN", profile.Env)
}
// 构建 Config + TuneConfig
config := makeDefaultConfig()
session := makeTestSession(config)
sys := ProbeSystem()
ep := &EnvironmentProfile{Net: *profile, System: sys}
ep.TuneConfig(config, session)
// 验证参数被合理调整
if config.Timeout > 3*time.Second {
t.Errorf("内网 Timeout = %v, 不应 > 3s", config.Timeout)
}
if config.MaxRetries != 1 {
t.Errorf("内网零丢包 MaxRetries = %d, want 1", config.MaxRetries)
}
// 用调整后的参数创建线程池
target, ceiling := profile.RecommendConcurrency(config.ThreadNum, config.ThreadNumExplicit)
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(target, ceiling, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
if pool.Cap() <= 0 {
t.Errorf("池容量 = %d, 应该 > 0", pool.Cap())
}
t.Logf("内网完整链路: Timeout=%v MT=%d Retry=%d ICMP=%.2f target=%d ceiling=%d poolCap=%d",
config.Timeout, config.ModuleThreadNum, config.MaxRetries,
config.Network.ICMPRate, target, ceiling, pool.Cap())
}
func TestIntegration_ProbeToPool_Internet(t *testing.T) {
profile := classifyNetwork(
makeDurations([]int{60, 70, 80, 90, 100, 110, 120, 130, 140, 150}),
0, 10,
)
if profile.Env != EnvInternet {
t.Fatalf("探测环境 = %v, want Internet", profile.Env)
}
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{Net: *profile, System: SystemProfile{FDLimit: 4096, NumCPU: 4}}
ep.TuneConfig(config, session)
target, ceiling := profile.RecommendConcurrency(config.ThreadNum, config.ThreadNumExplicit)
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(target, ceiling, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
// 公网并发应该明显低于默认 600
if target >= 600 {
t.Errorf("公网 target = %d, 应该 < 600", target)
}
t.Logf("公网完整链路: Timeout=%v MT=%d Retry=%d target=%d ceiling=%d poolCap=%d",
config.Timeout, config.ModuleThreadNum, config.MaxRetries, target, ceiling, pool.Cap())
}
// =============================================================================
// 集成测试 2:AdaptivePool + ScanMetrics 联动
// 验证:任务执行 → metrics 记录 → 池读取 metrics → 做出调整决策
// =============================================================================
func TestIntegration_PoolMetrics_HealthyTraffic(t *testing.T) {
metrics := &ScanMetrics{}
var taskCount atomic.Int64
pool, err := NewAdaptivePool(100, 100, func(i interface{}) {
taskCount.Add(1)
}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(100)
// 注入健康 metrics
for i := 0; i < 200; i++ {
metrics.RecordConnect(time.Millisecond)
}
// 运行任务
var wg sync.WaitGroup
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = pool.Invoke(nil)
}()
}
wg.Wait()
pool.Wait()
// 触发调整
pool.lastCheck.Store(0)
pool.adjust()
if pool.Cap() < 90 {
t.Errorf("健康流量池容量不应大幅下降: cap = %d", pool.Cap())
}
t.Logf("健康流量: tasks=%d connects=%d cap=%d",
taskCount.Load(), metrics.Snapshot().Connects, pool.Cap())
}
func TestIntegration_PoolMetrics_ExhaustedTraffic(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(i interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(100)
// 直接向 metrics 注入大量资源耗尽事件(模拟扫描过程中的 fd 不足)
for i := 0; i < 200; i++ {
metrics.RecordExhausted()
}
// 手动触发调整(清除时间守卫)
pool.lastCheck.Store(0)
pool.adjust()
// 资源耗尽率 100% → 应该降速
if pool.Cap() >= 100 {
t.Errorf("资源耗尽后池应该降速: cap = %d", pool.Cap())
}
t.Logf("资源耗尽: exhausted=%d cap=%d", metrics.Snapshot().Exhausted, pool.Cap())
}
// =============================================================================
// 集成测试 3:慢启动 → 稳态 AIMD 过渡
// 验证慢启动阶段的翻倍行为和过渡到稳态的时机
// =============================================================================
func TestIntegration_SlowStartToSteady(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(i interface{}) {
metrics.RecordConnect(time.Millisecond)
}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
if !pool.inSlowStart {
t.Fatal("初始应该在慢启动状态")
}
initialCap := pool.Cap()
t.Logf("慢启动初始: cap=%d", initialCap)
// 喂入足够的健康 metrics
for i := 0; i < 100; i++ {
metrics.RecordConnect(time.Millisecond)
}
// 模拟多次调整周期
caps := []int{initialCap}
for i := 0; i < 10; i++ {
pool.lastCheck.Store(0) // 强制触发检查
pool.adjust()
caps = append(caps, pool.Cap())
}
// 验证:容量应该逐步增长
growing := false
for i := 1; i < len(caps); i++ {
if caps[i] > caps[i-1] {
growing = true
break
}
}
if !growing {
t.Errorf("慢启动期间容量没有增长: %v", caps)
}
// 最终应该退出慢启动
finalCap := pool.Cap()
if finalCap < initialCap {
t.Errorf("最终容量 %d < 初始 %d, 不合理", finalCap, initialCap)
}
t.Logf("慢启动过渡: %v, inSlowStart=%v", caps, pool.inSlowStart)
}
// =============================================================================
// 集成测试 4:拥塞 → 降速 → 恢复 完整周期
// =============================================================================
func TestIntegration_CongestionRecovery(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(200, 200, func(i interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
// 直接到稳态,满容量
pool.inSlowStart = false
pool.tune(200)
// === 阶段 1: 正常运行 ===
for i := 0; i < 100; i++ {
metrics.RecordConnect(time.Millisecond)
}
pool.lastCheck.Store(0)
pool.adjust()
normalCap := pool.Cap()
t.Logf("正常阶段: cap=%d", normalCap)
// === 阶段 2: 突发拥塞(大量资源耗尽)===
for i := 0; i < 200; i++ {
metrics.RecordExhausted()
}
pool.lastCheck.Store(0)
pool.adjust()
congestedCap := pool.Cap()
if congestedCap >= normalCap {
t.Errorf("拥塞后应降速: normal=%d congested=%d", normalCap, congestedCap)
}
t.Logf("拥塞阶段: cap=%d (降幅 %d%%)", congestedCap, (normalCap-congestedCap)*100/normalCap)
// === 阶段 3: 恢复(大量成功连接)===
for i := 0; i < 500; i++ {
metrics.RecordConnect(time.Millisecond)
}
// 多次调整模拟恢复过程
for i := 0; i < 20; i++ {
pool.lastCheck.Store(0)
pool.adjust()
}
recoveredCap := pool.Cap()
if recoveredCap <= congestedCap {
t.Errorf("恢复后应提速: congested=%d recovered=%d", congestedCap, recoveredCap)
}
// 恢复后不应超过 ceiling
if recoveredCap > 200 {
t.Errorf("恢复后不应超过 ceiling: cap=%d ceiling=200", recoveredCap)
}
t.Logf("恢复阶段: cap=%d", recoveredCap)
}
// =============================================================================
// 集成测试 5:RTT 趋势检测 → 池调整
// 验证 ScanMetrics 的 RTT EMA 趋势信号能正确传导到池的健康判断
// =============================================================================
func TestIntegration_RTTTrend_DrivesPoolAdjustment(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(i interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(100)
// 建立基线:100 个 5ms RTT
for i := 0; i < 200; i++ {
metrics.RecordConnect(5 * time.Millisecond)
}
pool.lastCheck.Store(0)
pool.adjust()
baselineCap := pool.Cap()
// RTT 突增到 100ms(20 倍)
for i := 0; i < 100; i++ {
metrics.RecordConnect(100 * time.Millisecond)
}
ratio := metrics.RTTRatio()
if ratio <= 1.0 {
t.Logf("RTT ratio = %.2f, EMA 可能还没追上(正常)", ratio)
}
// 多次调整看池是否响应
for i := 0; i < 5; i++ {
pool.lastCheck.Store(0)
pool.adjust()
}
afterRTTSpike := pool.Cap()
t.Logf("RTT 趋势: baseline_cap=%d after_spike=%d rtt_ratio=%.2f",
baselineCap, afterRTTSpike, ratio)
// 如果 ratio 足够高,池应该降速
if ratio > 2.0 && afterRTTSpike >= baselineCap {
t.Errorf("RTT ratio=%.2f 但池没有降速: %d -> %d", ratio, baselineCap, afterRTTSpike)
}
}
// =============================================================================
// 集成测试 6:不同网络环境下的参数一致性
// 验证同一组目标在不同环境下参数调整的合理递进关系
// =============================================================================
func TestIntegration_ParameterProgression(t *testing.T) {
environments := []struct {
name string
rtts []int // ms
loss int // failures out of 10
wantEnv NetworkEnv
}{
{"内网", []int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5}, 0, EnvLAN},
{"局域网", []int{10, 15, 20, 25, 30, 35, 40, 45, 48, 49}, 0, EnvWAN},
{"公网", []int{60, 70, 80, 90, 100, 120, 140, 160, 180, 195}, 0, EnvInternet},
{"慢速", []int{200, 300, 400, 500, 600, 700, 800, 900, 1000, 1500}, 0, EnvSlow},
}
type params struct {
timeout time.Duration
mt int
retry int
icmpRate float64
}
var results []params
for _, env := range environments {
profile := classifyNetwork(makeDurations(env.rtts), env.loss, 10)
if profile.Env != env.wantEnv {
t.Errorf("%s: env = %v, want %v", env.name, profile.Env, env.wantEnv)
}
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: *profile,
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
results = append(results, params{
timeout: config.Timeout,
mt: config.ModuleThreadNum,
retry: config.MaxRetries,
icmpRate: config.Network.ICMPRate,
})
t.Logf("%s: Timeout=%v MT=%d Retry=%d ICMP=%.2f",
env.name, config.Timeout, config.ModuleThreadNum, config.MaxRetries, config.Network.ICMPRate)
}
// 验证递进关系:从内网到慢速,Timeout 应递增
for i := 1; i < len(results); i++ {
if results[i].timeout < results[i-1].timeout {
t.Errorf("Timeout 不递增: %v (env[%d]) < %v (env[%d])",
results[i].timeout, i, results[i-1].timeout, i-1)
}
}
// ICMPRate 应递减(内网最高,慢速最低)
for i := 1; i < len(results); i++ {
if results[i].icmpRate > results[i-1].icmpRate {
t.Errorf("ICMPRate 不递减: %.2f (env[%d]) > %.2f (env[%d])",
results[i].icmpRate, i, results[i-1].icmpRate, i-1)
}
}
}
// =============================================================================
// 集成测试 7:用户显式 -t + 网络探测 完整流程
// 验证用户指定值作为 ceiling 但探测仍然影响其他参数
// =============================================================================
func TestIntegration_ExplicitThreadNum_WithProbe(t *testing.T) {
profile := classifyNetwork(
makeDurations([]int{100, 120, 140, 160, 180, 200, 220, 240, 260, 300}),
2, 12, // 部分丢包
)
config := makeDefaultConfig()
config.ThreadNum = 200
config.ThreadNumExplicit = true
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: *profile,
System: SystemProfile{FDLimit: 4096, NumCPU: 4},
}
ep.TuneConfig(config, session)
// ThreadNum 不应被修改(fd limit 允许范围内)
// 但 Timeout、ModuleThreadNum 等应根据探测调整
if config.Timeout == 3*time.Second {
t.Error("即使 -t 显式,Timeout 仍应根据探测调整")
}
// 创建池
target, ceiling := profile.RecommendConcurrency(config.ThreadNum, config.ThreadNumExplicit)
if ceiling != 200 {
t.Errorf("显式 -t 200 的 ceiling = %d, want 200", ceiling)
}
if target > 200 {
t.Errorf("target = %d, 不应超过 ceiling 200", target)
}
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(target, ceiling, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
t.Logf("显式 -t 200: Timeout=%v MT=%d Retry=%d target=%d ceiling=%d cap=%d",
config.Timeout, config.ModuleThreadNum, config.MaxRetries, target, ceiling, pool.Cap())
}
// =============================================================================
// 集成测试 8:AdaptiveTimeout + ScanMetrics 双 RTT 追踪
// 验证两个 RTT 追踪器独立工作不干扰
// =============================================================================
func TestIntegration_DualRTTTracking(t *testing.T) {
adaptiveTO := NewAdaptiveTimeout(3 * time.Second)
metrics := &ScanMetrics{}
// 喂入相同的 RTT 数据到两个追踪器
for i := 0; i < 50; i++ {
rtt := 10 * time.Millisecond
adaptiveTO.Record(rtt)
metrics.RecordConnect(rtt)
}
// AdaptiveTimeout 用于连接超时
toValue := adaptiveTO.Timeout()
// ScanMetrics 用于池健康判断
rttFast := metrics.RTTFast()
ratio := metrics.RTTRatio()
if toValue > 3*time.Second {
t.Errorf("AdaptiveTimeout 应该 < 初始值: %v", toValue)
}
if rttFast < 8*time.Millisecond || rttFast > 12*time.Millisecond {
t.Errorf("ScanMetrics RTTFast 应接近 10ms: %v", rttFast)
}
if ratio < 0.8 || ratio > 1.2 {
t.Errorf("稳定 RTT 的 ratio 应接近 1.0: %.2f", ratio)
}
t.Logf("双追踪: AdaptiveTO=%v, MetricsFast=%v, Ratio=%.2f", toValue, rttFast, ratio)
}
// =============================================================================
// 集成测试 9:丢包环境下 Retry + ModuleThreadNum 联动
// 验证高丢包同时影响重试和并发
// =============================================================================
func TestIntegration_LossyNetwork_RetryAndConcurrency(t *testing.T) {
lossRates := []float64{0.0, 0.05, 0.10, 0.20, 0.40}
type result struct {
loss float64
retry int
mt int
}
var results []result
for _, loss := range lossRates {
profile := &NetworkProfile{
Env: EnvInternet,
RTTMedian: 80 * time.Millisecond,
RTTStddev: 20 * time.Millisecond,
LossRate: loss,
Samples: 20,
}
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: *profile,
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
results = append(results, result{loss, config.MaxRetries, config.ModuleThreadNum})
}
// 重试次数应随丢包率单调递增
for i := 1; i < len(results); i++ {
if results[i].retry < results[i-1].retry {
t.Errorf("Retry 不递增: loss=%.2f retry=%d < loss=%.2f retry=%d",
results[i].loss, results[i].retry, results[i-1].loss, results[i-1].retry)
}
}
// 高丢包时 ModuleThreadNum 应降低
if results[len(results)-1].mt >= results[0].mt {
t.Errorf("40%%丢包的 MT(%d) 应 < 0%%丢包的 MT(%d)",
results[len(results)-1].mt, results[0].mt)
}
for _, r := range results {
t.Logf("loss=%.0f%%: Retry=%d MT=%d", r.loss*100, r.retry, r.mt)
}
}
+281
View File
@@ -0,0 +1,281 @@
package core
import (
"context"
"math"
"net"
"sort"
"strconv"
"sync"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
)
// NetworkEnv 网络环境分类
type NetworkEnv int
const (
EnvLAN NetworkEnv = iota // 内网: RTT < 5ms, 丢包 < 1%
EnvWAN // 局域网/专线: RTT 5~50ms, 丢包 < 5%
EnvInternet // 公网: RTT 50~200ms
EnvSlow // 慢速/高丢包: RTT > 200ms 或 丢包 > 10%
)
func (e NetworkEnv) String() string {
switch e {
case EnvLAN:
return i18n.GetText("net_env_lan")
case EnvWAN:
return i18n.GetText("net_env_wan")
case EnvInternet:
return i18n.GetText("net_env_internet")
default:
return i18n.GetText("net_env_slow")
}
}
// NetworkProfile 网络探测结果
type NetworkProfile struct {
Env NetworkEnv
RTTMin time.Duration
RTTMedian time.Duration
RTTP95 time.Duration
RTTStddev time.Duration
LossRate float64
Samples int
}
// RecommendConcurrency 根据探测结果推荐并发参数
// 返回 (target, ceiling)
// - target: 推荐的目标并发数
// - ceiling: 允许的最大并发数
//
// 如果用户显式指定了 -t,ceiling = 用户值,target 取 min(推荐值, 用户值)
// 如果用户未指定,target 和 ceiling 均为推荐值
func (p *NetworkProfile) RecommendConcurrency(userThreadNum int, explicit bool) (target, ceiling int) {
// 基于网络环境的缩放因子
var factor float64
switch p.Env {
case EnvLAN:
factor = 1.5
case EnvWAN:
factor = 1.0
case EnvInternet:
factor = 0.4
case EnvSlow:
factor = 0.15
}
recommended := int(float64(userThreadNum) * factor)
if recommended < 10 {
recommended = 10
}
// 丢包率高时进一步压缩
if p.LossRate > 0.05 {
recommended = int(float64(recommended) * (1.0 - p.LossRate))
if recommended < 10 {
recommended = 10
}
}
if explicit {
ceiling = userThreadNum
target = recommended
if target > ceiling {
target = ceiling
}
} else {
target = recommended
ceiling = recommended
}
return
}
// probePorts 探测用的端口列表(高响应率的常见端口)
var probePorts = []int{80, 443, 22, 445, 8080, 3389, 21, 8443}
func networkProbeAddress(host string, port int) string {
return net.JoinHostPort(host, strconv.Itoa(port))
}
// ProbeNetwork 探测目标网络环境
// 从 hosts 中抽样,用低并发 TCP 连接测量 RTT 和丢包率
// 整个过程控制在数秒内完成
func ProbeNetwork(ctx context.Context, hosts []string, session *common.ScanSession) *NetworkProfile {
if len(hosts) == 0 {
return defaultProfile()
}
// 抽样:均匀分布,最多 10 个
samples := pickSamples(hosts, 10)
probeTimeout := session.Config.Timeout
if probeTimeout > time.Second {
probeTimeout = time.Second
}
if probeTimeout < 500*time.Millisecond {
probeTimeout = 500 * time.Millisecond
}
var (
mu sync.Mutex
rtts []time.Duration
failures int
total int
)
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, host := range samples {
for _, port := range probePorts {
select {
case <-ctx.Done():
goto done
default:
}
total++
wg.Add(1)
sem <- struct{}{}
go func(h string, p int) {
defer func() { <-sem; wg.Done() }()
addr := networkProbeAddress(h, p)
start := time.Now()
conn, err := session.DialTCP(ctx, "tcp", addr, probeTimeout)
rtt := time.Since(start)
mu.Lock()
defer mu.Unlock()
if err != nil {
// 连接拒绝也是有效的 RTT 样本(说明对端可达)
if isConnectionRefused(err) {
rtts = append(rtts, rtt)
}
failures++
} else {
_ = conn.Close()
rtts = append(rtts, rtt)
}
}(host, port)
}
}
done:
wg.Wait()
return classifyNetwork(rtts, failures, total)
}
func classifyNetwork(rtts []time.Duration, failures, total int) *NetworkProfile {
if len(rtts) == 0 {
return defaultProfile()
}
sort.Slice(rtts, func(i, j int) bool { return rtts[i] < rtts[j] })
n := len(rtts)
median := rtts[n/2]
p95idx := int(float64(n) * 0.95)
if p95idx >= n {
p95idx = n - 1
}
p95 := rtts[p95idx]
// 标准差
var sum float64
for _, r := range rtts {
sum += float64(r)
}
mean := sum / float64(n)
var variance float64
for _, r := range rtts {
d := float64(r) - mean
variance += d * d
}
stddev := time.Duration(math.Sqrt(variance / float64(n)))
// 丢包率:只计算超时的(非 refused),但简化为 1 - 有效响应数/总数
lossRate := 1.0 - float64(n)/float64(total)
if lossRate < 0 {
lossRate = 0
}
// 分类
env := classifyEnv(median, lossRate)
return &NetworkProfile{
Env: env,
RTTMin: rtts[0],
RTTMedian: median,
RTTP95: p95,
RTTStddev: stddev,
LossRate: lossRate,
Samples: n,
}
}
func classifyEnv(median time.Duration, lossRate float64) NetworkEnv {
switch {
case lossRate > 0.10:
return EnvSlow
case median < 5*time.Millisecond && lossRate < 0.01:
return EnvLAN
case median < 50*time.Millisecond && lossRate < 0.05:
return EnvWAN
case median < 200*time.Millisecond:
return EnvInternet
default:
return EnvSlow
}
}
func defaultProfile() *NetworkProfile {
return &NetworkProfile{
Env: EnvWAN,
RTTMedian: 10 * time.Millisecond,
LossRate: 0,
Samples: 0,
}
}
// pickSamples 均匀抽样
func pickSamples(hosts []string, maxSamples int) []string {
if maxSamples <= 0 {
return nil
}
n := len(hosts)
if n <= maxSamples {
return hosts
}
step := n / maxSamples
samples := make([]string, 0, maxSamples)
for i := 0; i < n && len(samples) < maxSamples; i += step {
samples = append(samples, hosts[i])
}
return samples
}
func isConnectionRefused(err error) bool {
if err == nil {
return false
}
// connection refused 通常包含 "refused" 关键词
// 在不同 OS 上表现一致
return containsFold(err.Error(), "refused")
}
// isTimeoutError 判断是否为超时错误
func isTimeoutError(err error) bool {
if err == nil {
return false
}
if ne, ok := err.(net.Error); ok {
return ne.Timeout()
}
return containsFold(err.Error(), "timeout") || containsFold(err.Error(), "deadline")
}
+187
View File
@@ -0,0 +1,187 @@
package core
import (
"testing"
"time"
)
// =============================================================================
// 单元测试:classifyEnv — 网络环境分类
// =============================================================================
func TestClassifyEnv(t *testing.T) {
tests := []struct {
median time.Duration
lossRate float64
wantEnv NetworkEnv
desc string
}{
{1 * time.Millisecond, 0.0, EnvLAN, "1ms 零丢包 → 内网"},
{3 * time.Millisecond, 0.005, EnvLAN, "3ms 0.5%丢包 → 内网"},
{5 * time.Millisecond, 0.0, EnvWAN, "5ms 零丢包 → 局域网边界"},
{20 * time.Millisecond, 0.02, EnvWAN, "20ms 2%丢包 → 局域网"},
{50 * time.Millisecond, 0.03, EnvInternet, "50ms 3%丢包 → 公网边界"},
{100 * time.Millisecond, 0.05, EnvInternet, "100ms 5%丢包 → 公网"},
{300 * time.Millisecond, 0.05, EnvSlow, "300ms → 慢速"},
{50 * time.Millisecond, 0.15, EnvSlow, "50ms 15%丢包 → 高丢包归类慢速"},
{1 * time.Millisecond, 0.20, EnvSlow, "低延迟但高丢包 → 慢速"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got := classifyEnv(tt.median, tt.lossRate)
if got != tt.wantEnv {
t.Errorf("classifyEnv(median=%v, loss=%.2f) = %v, want %v",
tt.median, tt.lossRate, got, tt.wantEnv)
}
})
}
}
// =============================================================================
// 单元测试:classifyNetwork — 从 RTT 样本推导 profile
// =============================================================================
func TestClassifyNetwork(t *testing.T) {
t.Run("内网 RTT 分布", func(t *testing.T) {
rtts := makeDurations([]int{1, 1, 1, 2, 2, 2, 3, 3, 4, 5}) // ms
p := classifyNetwork(rtts, 0, 10)
if p.Env != EnvLAN {
t.Errorf("env = %v, want LAN", p.Env)
}
if p.RTTMedian > 5*time.Millisecond {
t.Errorf("median = %v, want < 5ms", p.RTTMedian)
}
if p.LossRate != 0 {
t.Errorf("lossRate = %.2f, want 0", p.LossRate)
}
})
t.Run("公网 RTT 分布(低丢包)", func(t *testing.T) {
rtts := makeDurations([]int{60, 70, 80, 90, 100, 110, 120, 150, 200, 300}) // ms
p := classifyNetwork(rtts, 0, 10) // 无丢包
if p.Env != EnvInternet {
t.Errorf("env = %v, want Internet", p.Env)
}
if p.LossRate != 0 {
t.Errorf("lossRate = %.2f, want 0", p.LossRate)
}
})
t.Run("高丢包归类为慢速", func(t *testing.T) {
rtts := makeDurations([]int{60, 70, 80, 90, 100}) // ms, 5 responded
p := classifyNetwork(rtts, 5, 10) // 50% loss
if p.Env != EnvSlow {
t.Errorf("env = %v, want Slow (高丢包)", p.Env)
}
})
t.Run("零样本降级", func(t *testing.T) {
p := classifyNetwork(nil, 5, 5)
if p.Env != EnvWAN {
t.Errorf("env = %v, want WAN (default)", p.Env)
}
if p.Samples != 0 {
t.Errorf("samples = %d, want 0", p.Samples)
}
})
}
// =============================================================================
// 单元测试:RecommendConcurrency
// =============================================================================
func TestRecommendConcurrency(t *testing.T) {
tests := []struct {
env NetworkEnv
lossRate float64
userT int
explicit bool
wantTMin int
wantTMax int
wantCeil int
desc string
}{
{EnvLAN, 0.0, 600, false, 800, 1000, -1, "内网自动: ×1.5"},
{EnvWAN, 0.0, 600, false, 550, 650, -1, "局域网自动: ×1.0"},
{EnvInternet, 0.0, 600, false, 200, 280, -1, "公网自动: ×0.4"},
{EnvSlow, 0.0, 600, false, 80, 100, -1, "慢速自动: ×0.15"},
{EnvInternet, 0.0, 200, true, 70, 100, 200, "公网显式: target<ceiling"},
{EnvLAN, 0.0, 100, true, 100, 160, 100, "内网显式: ceiling=用户值"},
{EnvInternet, 0.15, 600, false, 170, 240, -1, "公网高丢包: 进一步压缩"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
p := &NetworkProfile{Env: tt.env, LossRate: tt.lossRate, Samples: 10}
target, ceiling := p.RecommendConcurrency(tt.userT, tt.explicit)
if target < tt.wantTMin || target > tt.wantTMax {
t.Errorf("target = %d, want [%d, %d]", target, tt.wantTMin, tt.wantTMax)
}
if tt.explicit && ceiling != tt.wantCeil {
t.Errorf("ceiling = %d, want %d", ceiling, tt.wantCeil)
}
})
}
}
// =============================================================================
// 单元测试:pickSamples
// =============================================================================
func TestPickSamples(t *testing.T) {
hosts := make([]string, 100)
for i := range hosts {
hosts[i] = "host"
}
s := pickSamples(hosts, 10)
if len(s) != 10 {
t.Errorf("pickSamples(100, 10) = %d items, want 10", len(s))
}
s = pickSamples(hosts[:5], 10)
if len(s) != 5 {
t.Errorf("pickSamples(5, 10) = %d items, want 5", len(s))
}
s = pickSamples(nil, 10)
if len(s) != 0 {
t.Errorf("pickSamples(nil, 10) = %d items, want 0", len(s))
}
}
func TestNetworkProbeAddressUsesJoinHostPort(t *testing.T) {
tests := []struct {
host string
port int
want string
}{
{"127.0.0.1", 80, "127.0.0.1:80"},
{"::1", 443, "[::1]:443"},
{"2001:db8::1", 22, "[2001:db8::1]:22"},
}
for _, tt := range tests {
if got := networkProbeAddress(tt.host, tt.port); got != tt.want {
t.Fatalf("networkProbeAddress(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want)
}
}
}
// =============================================================================
// 辅助
// =============================================================================
func makeDurations(ms []int) []time.Duration {
ds := make([]time.Duration, len(ms))
for i, m := range ms {
ds[i] = time.Duration(m) * time.Millisecond
}
return ds
}
+549
View File
@@ -0,0 +1,549 @@
package core
import (
"sync/atomic"
"testing"
"time"
)
// =============================================================================
// 优化 1:target/ceiling 分离
// =============================================================================
func TestOpt1_TargetCeilingSeparation_TuneConfig(t *testing.T) {
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMedian: 1 * time.Millisecond,
RTTStddev: 500 * time.Microsecond,
LossRate: 0.0,
Samples: 30,
},
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
if config.ThreadCeiling <= 0 {
t.Fatalf("ThreadCeiling 未被设置: %d", config.ThreadCeiling)
}
// 内网 factor=1.5,非显式 → target=ceiling=recommended
// 但 ceiling 应该 >= target
if config.ThreadCeiling < config.ThreadNum {
t.Errorf("Ceiling(%d) < ThreadNum(%d)", config.ThreadCeiling, config.ThreadNum)
}
t.Logf("target=%d, ceiling=%d", config.ThreadNum, config.ThreadCeiling)
}
func TestOpt1_TargetCeilingSeparation_ExplicitT(t *testing.T) {
config := makeDefaultConfig()
config.ThreadNum = 200
config.ThreadNumExplicit = true
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvInternet,
RTTMedian: 100 * time.Millisecond,
RTTStddev: 30 * time.Millisecond,
LossRate: 0.0,
Samples: 20,
},
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(config, session)
// 用户显式指定 -t → ceiling = threadNum = 200
if config.ThreadCeiling != 200 {
t.Errorf("显式 -t 200: ceiling=%d, want 200", config.ThreadCeiling)
}
if config.ThreadNum != 200 {
t.Errorf("显式 -t 200: threadNum=%d, want 200", config.ThreadNum)
}
}
func TestOpt1_PoolUsesCeiling(t *testing.T) {
metrics := &ScanMetrics{}
target, ceiling := 50, 200
pool, err := NewAdaptivePool(target, ceiling, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(target)
// 注入健康 metrics 让池增长
for i := 0; i < 200; i++ {
metrics.RecordConnect(time.Millisecond)
}
// 多次 adjust,池应能增长超过 target 但不超过 ceiling
for i := 0; i < 30; i++ {
pool.lastCheck.Store(0)
pool.adjust()
}
finalCap := pool.Cap()
if finalCap <= target {
t.Errorf("池应能超过 target(%d): cap=%d", target, finalCap)
}
if finalCap > ceiling {
t.Errorf("池不应超过 ceiling(%d): cap=%d", ceiling, finalCap)
}
t.Logf("target=%d, ceiling=%d, finalCap=%d", target, ceiling, finalCap)
}
func TestOpt1_FDLimitConstraintsBothFields(t *testing.T) {
config := makeDefaultConfig()
config.ThreadNum = 1000
session := makeTestSession(config)
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMedian: 1 * time.Millisecond,
RTTStddev: 500 * time.Microsecond,
LossRate: 0.0,
Samples: 30,
},
System: SystemProfile{FDLimit: 256, NumCPU: 4},
}
ep.TuneConfig(config, session)
maxFD := 256 * 6 / 10
if config.ThreadNum > maxFD {
t.Errorf("ThreadNum(%d) 超过 fd 限制(%d)", config.ThreadNum, maxFD)
}
if config.ThreadCeiling > maxFD {
t.Errorf("ThreadCeiling(%d) 超过 fd 限制(%d)", config.ThreadCeiling, maxFD)
}
}
// =============================================================================
// 优化 2:RTT 漂移微调 target
// =============================================================================
func TestOpt2_RTTDriftReducesTarget(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(200, 400, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(200)
// 建立基线:slow EMA 锚定在 1ms 附近
for i := 0; i < 500; i++ {
metrics.RecordConnect(1 * time.Millisecond)
}
origTarget := atomic.LoadInt32(&pool.target)
// RTT 突增到 100ms(100 倍),大量喂入让 fast EMA 拉开差距
for i := 0; i < 1000; i++ {
metrics.RecordConnect(100 * time.Millisecond)
}
ratio := metrics.RTTRatio()
t.Logf("RTT ratio after spike: %.2f", ratio)
if ratio <= 3.0 {
t.Skipf("RTT ratio=%.2f,EMA 差距不够大,跳过", ratio)
}
// 需要足够的新 metrics 让 assessHealth 的 deltaTotal >= 30
for i := 0; i < 50; i++ {
metrics.RecordConnect(100 * time.Millisecond)
}
// 多次 adjust 触发 maybeReduceTarget
for i := 0; i < 10; i++ {
pool.lastCheck.Store(0)
pool.prevSnapshot = MetricsSnapshot{} // 重置快照让 delta 足够
pool.adjust()
}
newTarget := atomic.LoadInt32(&pool.target)
if newTarget >= origTarget {
t.Errorf("RTT 漂移后 target 应降低: %d -> %d (ratio=%.2f)", origTarget, newTarget, ratio)
}
// 不应低于 ceiling/5
minTarget := atomic.LoadInt32(&pool.ceiling) / 5
if newTarget < minTarget {
t.Errorf("target(%d) 低于下限(%d)", newTarget, minTarget)
}
t.Logf("RTT drift: ratio=%.2f, target %d -> %d (min=%d)", ratio, origTarget, newTarget, minTarget)
}
func TestOpt2_NoReductionWhenStable(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(200, 400, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(200)
// 稳定 RTT
for i := 0; i < 200; i++ {
metrics.RecordConnect(10 * time.Millisecond)
}
origTarget := atomic.LoadInt32(&pool.target)
for i := 0; i < 10; i++ {
pool.lastCheck.Store(0)
pool.adjust()
}
newTarget := atomic.LoadInt32(&pool.target)
if newTarget != origTarget {
t.Errorf("稳定 RTT 不应改变 target: %d -> %d", origTarget, newTarget)
}
}
// =============================================================================
// 优化 3:assessHealth 阈值跟 NetworkEnv 关联
// =============================================================================
func TestOpt3_LANTighterThresholds(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics, EnvLAN)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(100)
// 10% exhaust rate — 对 LAN 来说应该是 Congested(阈值 8%)
for i := 0; i < 100; i++ {
if i < 10 {
metrics.RecordExhausted()
} else {
metrics.RecordConnect(time.Millisecond)
}
}
pool.lastCheck.Store(0)
pool.adjust()
if pool.Cap() >= 100 {
t.Errorf("LAN 10%% exhaust 应触发降速: cap=%d", pool.Cap())
}
t.Logf("LAN tight threshold: cap=%d (from 100)", pool.Cap())
}
func TestOpt3_InternetLooseThresholds(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics, EnvInternet)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(100)
// 10% exhaust rate — 对 Internet 来说不算 Congested(阈值 25%),应是 Stressed
for i := 0; i < 100; i++ {
if i < 10 {
metrics.RecordExhausted()
} else {
metrics.RecordConnect(10 * time.Millisecond)
}
}
pool.lastCheck.Store(0)
pool.adjust()
capAfter := pool.Cap()
// Internet 对 10% exhaust 只是 Stressed(×0.85),不是 Congested(×0.5)
if capAfter < 80 {
t.Errorf("Internet 10%% exhaust 不应大幅降速: cap=%d", capAfter)
}
t.Logf("Internet loose threshold: cap=%d (from 100)", capAfter)
}
func TestOpt3_EnvAffectsHealthDecision(t *testing.T) {
envs := []struct {
env NetworkEnv
name string
}{
{EnvLAN, "LAN"},
{EnvWAN, "WAN"},
{EnvInternet, "Internet"},
}
var caps []int
for _, e := range envs {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics, e.env)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
pool.inSlowStart = false
pool.tune(100)
// 相同的 12% exhaust rate
for i := 0; i < 100; i++ {
if i < 12 {
metrics.RecordExhausted()
} else {
metrics.RecordConnect(time.Millisecond)
}
}
pool.lastCheck.Store(0)
pool.adjust()
caps = append(caps, pool.Cap())
pool.Release()
t.Logf("%s: cap=%d (12%% exhaust)", e.name, caps[len(caps)-1])
}
// LAN 反应最激烈(cap 最低),Internet 最宽容(cap 最高)
if caps[0] >= caps[2] {
t.Errorf("LAN cap(%d) 应 < Internet cap(%d) for same exhaust rate", caps[0], caps[2])
}
}
// =============================================================================
// 优化 4:去掉 semaphore,ants 池天然反压
// =============================================================================
func TestOpt4_SemaphoreRemoved(t *testing.T) {
// 验证 portScanTask 结构体不再有 semaphore 字段
// 如果 semaphore 被加回来,这段代码编译就会报 "unknown field"
_ = portScanTask{
host: "127.0.0.1",
port: 80,
addr: "127.0.0.1:80",
}
t.Log("portScanTask 无 semaphore 字段,反压由 ants pool 统一管理")
}
// =============================================================================
// 优化 5:扩充探测端口
// =============================================================================
func TestOpt5_ProbePortsExpanded(t *testing.T) {
if len(probePorts) < 5 {
t.Errorf("probePorts 只有 %d 个,应该扩充到至少 5 个", len(probePorts))
}
// 验证包含关键端口
required := map[int]bool{80: false, 443: false, 22: false}
for _, p := range probePorts {
if _, ok := required[p]; ok {
required[p] = true
}
}
for port, found := range required {
if !found {
t.Errorf("probePorts 缺少关键端口 %d", port)
}
}
// 验证没有重复
seen := make(map[int]bool)
for _, p := range probePorts {
if seen[p] {
t.Errorf("probePorts 有重复端口 %d", p)
}
seen[p] = true
}
t.Logf("probePorts = %v (%d 个)", probePorts, len(probePorts))
}
// =============================================================================
// 优化 6:computeRetries 环境自适应
// =============================================================================
func TestOpt6_RetriesEnvAware(t *testing.T) {
lossRate := 0.3 // 30% 丢包
lanRetry := computeRetries(lossRate, EnvLAN)
wanRetry := computeRetries(lossRate, EnvWAN)
inetRetry := computeRetries(lossRate, EnvInternet)
// LAN 目标概率更严格(0.5%),应该重试更多;但上限更低(4)
// Internet 目标概率更宽松(2%),应该重试更少;但上限更高(6)
t.Logf("30%% loss: LAN=%d, WAN=%d, Internet=%d", lanRetry, wanRetry, inetRetry)
if lanRetry < 1 || lanRetry > 4 {
t.Errorf("LAN retry=%d, 应在 [1,4]", lanRetry)
}
if wanRetry < 1 || wanRetry > 5 {
t.Errorf("WAN retry=%d, 应在 [1,5]", wanRetry)
}
if inetRetry < 1 || inetRetry > 6 {
t.Errorf("Internet retry=%d, 应在 [1,6]", inetRetry)
}
}
func TestOpt6_RetriesMaxByEnv(t *testing.T) {
// 高丢包率,各环境应返回各自上限
lanMax := computeRetries(0.99, EnvLAN)
wanMax := computeRetries(0.99, EnvWAN)
inetMax := computeRetries(0.99, EnvInternet)
if lanMax != 4 {
t.Errorf("LAN max retry=%d, want 4", lanMax)
}
if wanMax != 5 {
t.Errorf("WAN max retry=%d, want 5", wanMax)
}
if inetMax != 6 {
t.Errorf("Internet max retry=%d, want 6", inetMax)
}
}
func TestOpt6_RetriesMathCorrectness(t *testing.T) {
envs := []struct {
env NetworkEnv
targetProb float64
name string
}{
{EnvLAN, 0.005, "LAN"},
{EnvWAN, 0.01, "WAN"},
{EnvInternet, 0.02, "Internet"},
}
for _, e := range envs {
for _, loss := range []float64{0.05, 0.10, 0.20, 0.30} {
retries := computeRetries(loss, e.env)
prob := 1.0
for i := 0; i < retries; i++ {
prob *= loss
}
// 重试后全失败概率应 < targetProb(除非被 clamp 了)
if prob >= e.targetProb && retries < 4 {
t.Errorf("%s loss=%.0f%% retries=%d: P=%.6f >= %.3f",
e.name, loss*100, retries, prob, e.targetProb)
}
}
}
}
// =============================================================================
// 端到端集成:全链路验证
// =============================================================================
func TestOptAll_EndToEnd_LANToPool(t *testing.T) {
// 模拟内网探测 → TuneConfig → 创建池 → 池根据 env 自适应
profile := classifyNetwork(
makeDurations([]int{1, 1, 2, 2, 2, 3, 3, 3, 4, 5}),
0, 10,
)
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{Net: *profile, System: SystemProfile{FDLimit: 65536, NumCPU: 8}}
ep.TuneConfig(config, session)
// 验证 env 被存储
if config.DetectedNetworkEnv != int(EnvLAN) {
t.Errorf("DetectedNetworkEnv=%d, want %d(LAN)", config.DetectedNetworkEnv, int(EnvLAN))
}
// 验证 ceiling 合理
if config.ThreadCeiling < config.ThreadNum {
t.Errorf("ceiling(%d) < target(%d)", config.ThreadCeiling, config.ThreadNum)
}
// 创建池并验证 env 传递
netEnv := NetworkEnv(config.DetectedNetworkEnv)
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(config.ThreadNum, config.ThreadCeiling, func(interface{}) {}, metrics, netEnv)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
if pool.networkEnv != EnvLAN {
t.Errorf("池的 networkEnv=%v, want LAN", pool.networkEnv)
}
t.Logf("端到端 LAN: target=%d ceiling=%d env=%v maxRetry=%d",
config.ThreadNum, config.ThreadCeiling, netEnv, config.MaxRetries)
}
func TestOptAll_EndToEnd_InternetToPool(t *testing.T) {
profile := classifyNetwork(
makeDurations([]int{60, 70, 80, 90, 100, 110, 120, 130, 140, 150}),
0, 10,
)
config := makeDefaultConfig()
session := makeTestSession(config)
ep := &EnvironmentProfile{Net: *profile, System: SystemProfile{FDLimit: 4096, NumCPU: 4}}
ep.TuneConfig(config, session)
if config.DetectedNetworkEnv != int(EnvInternet) {
t.Errorf("DetectedNetworkEnv=%d, want %d(Internet)", config.DetectedNetworkEnv, int(EnvInternet))
}
// 公网 target 应明显低于默认 600
if config.ThreadNum >= 600 {
t.Errorf("公网 threadNum=%d, 应 < 600", config.ThreadNum)
}
// ceiling 应 == target(非显式模式)
if config.ThreadCeiling != config.ThreadNum {
t.Errorf("非显式模式 ceiling(%d) != target(%d)", config.ThreadCeiling, config.ThreadNum)
}
netEnv := NetworkEnv(config.DetectedNetworkEnv)
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(config.ThreadNum, config.ThreadCeiling, func(interface{}) {}, metrics, netEnv)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
// 注入 12% exhaust,Internet 环境应只是 Stressed 而不是 Congested
pool.inSlowStart = false
pool.tune(config.ThreadNum)
for i := 0; i < 100; i++ {
if i < 12 {
metrics.RecordExhausted()
} else {
metrics.RecordConnect(80 * time.Millisecond)
}
}
pool.lastCheck.Store(0)
pool.adjust()
// cap 不应被砍到一半以下(Stressed 只降 15%)
if pool.Cap() < config.ThreadNum*7/10 {
t.Errorf("Internet 12%% exhaust 降速过猛: %d -> %d", config.ThreadNum, pool.Cap())
}
t.Logf("端到端 Internet: target=%d ceiling=%d cap_after_stress=%d",
config.ThreadNum, config.ThreadCeiling, pool.Cap())
}
+107 -34
View File
@@ -104,7 +104,6 @@ type portScanTask struct {
host string
port int
addr string // 预格式化的 host:port,避免 fmt.Sprintf 热路径分配
semaphore chan struct{} // 完成时释放窗口槽位
}
// failedPortInfo 失败端口信息
@@ -187,13 +186,12 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
totalTasks := iter.Total()
session.LogDebug(i18n.Tr("port_scan_debug_total_tasks", totalTasks))
// 使用传入的配置
// 并发参数(已由 EnvironmentProfile.TuneConfig 调整过)
threadNum := config.ThreadNum
// 大规模扫描警告和线程数自动调整
// 大规模扫描额外约束
if totalTasks > 100000 {
session.LogInfo(i18n.Tr("large_scan_notice", totalTasks, len(hosts), len(portList)))
// 如果任务数超过100万且线程数大于300,自动降低线程数
if totalTasks > 1000000 && threadNum > 300 {
oldThreadNum := threadNum
threadNum = 300
@@ -211,26 +209,28 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
// 初始化并发控制
to := time.Duration(timeout) * time.Second
adaptiveTO := NewAdaptiveTimeout(to)
metrics := &ScanMetrics{}
var count atomic.Int64
collector := newResultCollector(stream)
failedCollector := &failedPortCollector{}
var wg sync.WaitGroup
ceiling := config.ThreadCeiling
if ceiling < threadNum {
ceiling = threadNum
}
netEnv := NetworkEnv(config.DetectedNetworkEnv)
session.LogDebug(i18n.Tr("port_scan_debug_pool_create", threadNum))
// 创建自适应线程池(支持动态调整)
pool, err := NewAdaptivePool(threadNum, func(task interface{}) {
pool, err := NewAdaptivePool(threadNum, ceiling, func(task interface{}) {
taskInfo, ok := task.(portScanTask)
if !ok {
return
}
defer func() {
<-taskInfo.semaphore // 释放窗口槽位
wg.Done()
}()
defer wg.Done()
scanSinglePort(ctx, taskInfo.host, taskInfo.port, taskInfo.addr, adaptiveTO, &count, collector, failedCollector, session)
scanSinglePort(ctx, taskInfo.host, taskInfo.port, taskInfo.addr, adaptiveTO, metrics, &count, collector, failedCollector, session)
common.UpdateProgressBar(1)
}, state)
}, metrics, netEnv)
if err != nil {
session.LogError(i18n.Tr("thread_pool_create_failed", err))
if stream != nil {
@@ -242,8 +242,8 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
defer pool.Release()
session.LogDebug(i18n.GetText("port_scan_debug_schedule_start"))
// 滑动窗口调度:维护固定数量的"飞行中"任务
slidingWindowSchedule(iter, pool, &wg, threadNum)
// 滑动窗口调度
slidingWindowSchedule(iter, pool, &wg)
session.LogDebug(i18n.GetText("port_scan_debug_schedule_done"))
// 收集结果
@@ -288,30 +288,21 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
}
// slidingWindowSchedule 滑动窗口调度器
// 核心思想:维护固定数量的"飞行中"任务,一个完成立即补充新的
// 优势:避免任务队列堆积,内存使用恒定
func slidingWindowSchedule(iter *SocketIterator, pool *AdaptivePool, wg *sync.WaitGroup, windowSize int) {
// 使用信号量控制窗口大小
semaphore := make(chan struct{}, windowSize)
// ants.PoolWithFunc.Invoke 在池满时阻塞,天然提供反压,无需额外 semaphore
func slidingWindowSchedule(iter *SocketIterator, pool *AdaptivePool, wg *sync.WaitGroup) {
for {
host, port, ok := iter.Next()
if !ok {
break
}
// 获取窗口槽位(阻塞直到有空位)
semaphore <- struct{}{}
wg.Add(1)
task := portScanTask{
host: host,
port: port,
addr: net.JoinHostPort(host, fmtPort(port)),
semaphore: semaphore,
}
if err := pool.Invoke(task); err != nil {
<-semaphore
wg.Done()
}
}
@@ -420,10 +411,14 @@ func matchFold(a, b string) bool {
}
// buildServiceLogMessage 构建服务识别的日志信息
// 格式: addr service [Product:xxx ||Version:xxx] Banner:(xxx)
// 格式: addr-or-url service [Product:xxx ||Version:xxx] Banner:(xxx)
func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) string {
var msg strings.Builder
fmt.Fprintf(&msg, "%-21s", addr)
displayTarget := addr
if isWeb {
displayTarget = buildWebServiceURL(addr, serviceInfo)
}
fmt.Fprintf(&msg, "%-30s", displayTarget)
if serviceInfo.Name != "unknown" {
fmt.Fprintf(&msg, " %-8s", serviceInfo.Name)
@@ -444,27 +439,80 @@ func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) s
// Banner 信息
if len(serviceInfo.Banner) > 0 {
banner := strings.TrimSpace(serviceInfo.Banner)
if len(banner) > 80 {
banner = banner[:80] + "..."
}
banner = truncateString(banner, 80)
fmt.Fprintf(&msg, " Banner:(%s)", banner)
}
return msg.String()
}
func truncateString(s string, maxRunes int) string {
if maxRunes < 0 {
return s
}
for i := range s {
if maxRunes == 0 {
return s[:i] + "..."
}
maxRunes--
}
return s
}
func buildWebServiceURL(addr string, serviceInfo *ServiceInfo) string {
protocol := "http"
serviceName := ""
if serviceInfo != nil {
serviceName = strings.ToLower(serviceInfo.Name)
}
if strings.Contains(serviceName, "https") || strings.Contains(serviceName, "ssl") || strings.Contains(serviceName, "tls") {
protocol = "https"
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Sprintf("%s://%s", protocol, addr)
}
if protocol == "http" && port == "80" {
return fmt.Sprintf("http://%s", urlHost(host))
}
if protocol == "https" && port == "443" {
return fmt.Sprintf("https://%s", urlHost(host))
}
return fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(host, port))
}
func urlHost(host string) string {
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
return "[" + host + "]"
}
return host
}
// scanSinglePort 扫描单个端口并进行服务识别(重构后的简洁版本)
func scanSinglePort(ctx context.Context, host string, port int, addr string, adaptiveTO *AdaptiveTimeout, count *atomic.Int64, collector *resultCollector, failedCollector *failedPortCollector, session *common.ScanSession) {
func scanSinglePort(ctx context.Context, host string, port int, addr string, adaptiveTO *AdaptiveTimeout, metrics *ScanMetrics, count *atomic.Int64, collector *resultCollector, failedCollector *failedPortCollector, session *common.ScanSession) {
config := session.Config
timeout := adaptiveTO.Timeout()
// 步骤1:建立连接
start := time.Now()
conn, err := connectWithRetry(ctx, session, addr, timeout, 2)
if err != nil {
rtt := time.Since(start)
switch {
case isResourceExhaustedError(err):
metrics.RecordExhausted()
case isTimeoutError(err):
metrics.RecordTimeout()
default:
metrics.RecordRefused(rtt)
}
handleConnectionFailure(err, host, port, addr, failedCollector)
return
}
adaptiveTO.Record(time.Since(start))
rtt := time.Since(start)
metrics.RecordConnect(rtt)
adaptiveTO.Record(rtt)
// 步骤1.5:代理连接深度验证(防止透明代理/全回显代理的假连接问题)
valid, verifyMethod := verifyProxyConnectionDeep(conn, addr, session)
@@ -488,7 +536,6 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada
// 步骤2:记录开放端口
count.Add(1)
collector.Add(addr)
saveOpenPort(session, host, port)
// 步骤3:服务识别(Scanner负责关闭连接,包括探测中可能创建的新连接)
@@ -507,6 +554,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada
// 步骤4:处理结果
processServiceResult(ctx, host, port, addr, serviceInfo, config, session)
collector.Add(addr)
}
// handleConnectionFailure 处理连接失败
@@ -652,6 +700,18 @@ func saveOpenPort(session *common.ScanSession, host string, port int) {
})
}
// correctServiceByBanner 根据 banner 特征校正被 nmap 指纹误匹配的服务名
func correctServiceByBanner(info *ServiceInfo) {
if info == nil || info.Banner == "" {
return
}
banner := strings.ToLower(info.Banner)
// MySQL 握手包包含认证插件名,nmap 随机 salt 可能导致误匹配
if strings.Contains(banner, "mysql_native_password") || strings.Contains(banner, "caching_sha2_password") {
info.Name = "mysql"
}
}
// processServiceResult 处理服务识别结果
func processServiceResult(ctx context.Context, host string, port int, addr string, serviceInfo *ServiceInfo, config *common.Config, session *common.ScanSession) {
if serviceInfo == nil {
@@ -662,13 +722,26 @@ func processServiceResult(ctx context.Context, host string, port int, addr strin
return
}
// Banner 校正:nmap 指纹库可能将 MySQL 握手包的随机 salt 误匹配为其他服务
correctServiceByBanner(serviceInfo)
// 缓存指纹识别结果,供插件按服务类型匹配(解决非标准端口问题)
CacheServiceInfo(host, port, serviceInfo)
// 保存并输出服务信息
details := buildServiceDetails(port, serviceInfo)
isWeb := IsWebServiceByFingerprint(serviceInfo)
// 指纹既不匹配 webKeywords 也不匹配 nonWebKeywords(不确定区间)
// 补做一次 HTTP 探测,覆盖自定义 HTTP 框架等漏网场景
if !isWeb && !isDefinitelyNonWeb(serviceInfo) {
if tryHTTPFallbackDetection(ctx, host, port, addr, config, session) {
isWeb = true
}
}
if isWeb {
details["is_web"] = true
MarkAsWebService(host, port, serviceInfo)
}
_ = session.SaveResult(&output.ScanResult{
+184 -1
View File
@@ -2,6 +2,8 @@ package core
import (
"fmt"
"sort"
"strings"
"testing"
)
@@ -212,6 +214,152 @@ func TestFormatAddress(t *testing.T) {
}
}
func TestBuildWebServiceURLIPv6(t *testing.T) {
tests := []struct {
name string
addr string
serviceInfo *ServiceInfo
want string
}{
{
name: "http default port",
addr: "[2001:db8::1]:80",
serviceInfo: &ServiceInfo{
Name: "http",
},
want: "http://[2001:db8::1]",
},
{
name: "https default port",
addr: "[2001:db8::1]:443",
serviceInfo: &ServiceInfo{
Name: "https",
},
want: "https://[2001:db8::1]",
},
{
name: "http non-default port",
addr: "[2001:db8::1]:8080",
serviceInfo: &ServiceInfo{
Name: "http",
},
want: "http://[2001:db8::1]:8080",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildWebServiceURL(tt.addr, tt.serviceInfo); got != tt.want {
t.Fatalf("buildWebServiceURL(%q) = %q, want %q", tt.addr, got, tt.want)
}
})
}
}
func TestPortScanCollectorsAndHelpers(t *testing.T) {
t.Run("result collector deduplicates and streams", func(t *testing.T) {
stream := make(chan string, 2)
collector := newResultCollector(stream)
collector.Add("127.0.0.1:80")
collector.Add("127.0.0.1:80")
collector.Add("127.0.0.1:443")
got := collector.GetAll()
sort.Strings(got)
expected := []string{"127.0.0.1:443", "127.0.0.1:80"}
if !stringSlicesEqual(got, expected) {
t.Fatalf("collector results = %v, want %v", got, expected)
}
close(stream)
var streamed []string
for addr := range stream {
streamed = append(streamed, addr)
}
sort.Strings(streamed)
if !stringSlicesEqual(streamed, expected) {
t.Fatalf("streamed results = %v, want %v", streamed, expected)
}
})
t.Run("failed collector counts", func(t *testing.T) {
var collector failedPortCollector
collector.Add("127.0.0.1", 80, "127.0.0.1:80")
collector.Add("127.0.0.1", 443, "127.0.0.1:443")
if got := collector.Count(); got != 2 {
t.Fatalf("failed count = %d, want 2", got)
}
})
t.Run("proxy and closed error helpers", func(t *testing.T) {
if !isProxyErrorResponse([]byte{0x05, 0x01, 0x00, 0x01}) {
t.Fatal("SOCKS5 failure reply should be proxy error")
}
if !isProxyErrorResponse([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) {
t.Fatal("HTTP proxy error text should be detected")
}
if isProxyErrorResponse(nil) || isProxyErrorResponse([]byte{0x05, 0x00}) {
t.Fatal("empty or success response should not be proxy error")
}
if !isConnectionClosed(fmt.Errorf("use of closed network connection")) {
t.Fatal("closed connection error should be detected")
}
if isConnectionClosed(nil) || isConnectionClosed(fmt.Errorf("temporary timeout")) {
t.Fatal("non-closed error should not be detected")
}
})
t.Run("service details and subnet prefix", func(t *testing.T) {
details := buildServiceDetails(8443, &ServiceInfo{
Name: "https",
Version: "1.2.3",
Banner: " hello \r\n",
Extras: map[string]string{
"vendor_product": "nginx",
"os": "linux",
"info": "tls",
"empty": "",
"ignored": "value",
},
})
expected := map[string]interface{}{
"port": 8443,
"service": "https",
"version": "1.2.3",
"banner": "hello",
"product": "nginx",
"os": "linux",
"info": "tls",
}
for key, want := range expected {
if got := details[key]; got != want {
t.Fatalf("details[%s] = %#v, want %#v (all=%#v)", key, got, want, details)
}
}
if _, ok := details["ignored"]; ok {
t.Fatalf("unexpected ignored extra in details: %#v", details)
}
if got := subnetPrefix("192.168.1.25"); got != "192.168.1" {
t.Fatalf("subnetPrefix IPv4 = %q", got)
}
if got := subnetPrefix("localhost"); got != "" {
t.Fatalf("subnetPrefix hostname = %q, want empty", got)
}
})
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// =============================================================================
// 排除端口逻辑测试(从EnhancedPortScan:28-32行提取)
// =============================================================================
@@ -478,7 +626,31 @@ func TestBuildServiceLogMessage(t *testing.T) {
Extras: map[string]string{},
},
isWeb: true,
wantContain: []string{"192.168.1.1:80", "http", "1.1"},
wantContain: []string{"http://192.168.1.1", "http", "1.1"},
},
{
name: "非标准端口HTTP服务显示URL",
addr: "192.168.1.1:8080",
serviceInfo: &ServiceInfo{
Name: "http",
Version: "1.1",
Banner: "",
Extras: map[string]string{},
},
isWeb: true,
wantContain: []string{"http://192.168.1.1:8080", "http", "1.1"},
},
{
name: "HTTPS服务显示HTTPS URL",
addr: "192.168.1.1:443",
serviceInfo: &ServiceInfo{
Name: "https",
Version: "1.1",
Banner: "",
Extras: map[string]string{},
},
isWeb: true,
wantContain: []string{"https://192.168.1.1", "https", "1.1"},
},
{
name: "带Banner的SSH服务",
@@ -548,6 +720,17 @@ func TestBuildServiceLogMessage(t *testing.T) {
}
}
func TestBuildServiceLogMessageTruncatesBannerByRune(t *testing.T) {
result := buildServiceLogMessage("10.0.0.1:22", &ServiceInfo{
Name: "ssh",
Banner: strings.Repeat("界", 85),
Extras: map[string]string{},
}, false)
if !strings.Contains(result, strings.Repeat("界", 80)+"...") {
t.Fatalf("truncated banner is not rune-safe: %q", result)
}
}
// contains 检查字符串是否包含子串
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+8 -1
View File
@@ -13,7 +13,7 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
directive = Directive{}
// 查找第一个空格的位置
blankIndex := strings.Index(data, " ")
if blankIndex == -1 {
if blankIndex == -1 || blankIndex+3 > len(data) {
return directive
}
@@ -33,6 +33,10 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
// parseProbeInfo 解析探测器信息,返回错误替代 panic
func (p *Probe) parseProbeInfo(probeStr string) error {
if len(probeStr) < 5 {
return fmt.Errorf("%s", i18n.GetText("portfinger_probe_protocol_invalid"))
}
// 提取协议和其他信息
proto := probeStr[:4]
other := probeStr[4:]
@@ -49,6 +53,9 @@ func (p *Probe) parseProbeInfo(probeStr string) error {
// 解析指令
directive := p.getDirectiveSyntax(other)
if directive.DirectiveName == "" || directive.Delimiter == "" {
return fmt.Errorf("%s", i18n.GetText("portfinger_probe_name_invalid"))
}
// 设置探测器属性
p.Name = directive.DirectiveName
+33
View File
@@ -0,0 +1,33 @@
package portfinger
import "testing"
func TestProbeParserRejectsShortInputs(t *testing.T) {
tests := []string{
"",
"T",
"TCP",
"TCP ",
"TCP Q",
"TCP GetRequest q",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
var probe Probe
if err := probe.fromString(input); err == nil {
t.Fatalf("fromString(%q) error = nil, want malformed input error", input)
}
})
}
}
func TestProbeParserAcceptsMinimalValidProbe(t *testing.T) {
var probe Probe
if err := probe.fromString(`TCP GetRequest q|GET / HTTP/1.0\r\n\r\n|`); err != nil {
t.Fatalf("fromString valid probe error = %v", err)
}
if probe.Name != "GetRequest" || probe.Protocol != "tcp" || probe.Data == "" {
t.Fatalf("probe parsed incorrectly: %#v", probe)
}
}
+487
View File
@@ -0,0 +1,487 @@
package core
import (
"context"
"fmt"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
)
// =============================================================================
// 辅助:启动本地 TCP 监听器
// =============================================================================
// startListeners 启动 N 个本地 TCP 监听端口,返回地址列表和清理函数
func startListeners(t *testing.T, n int) (addrs []string, hosts []string, ports []int, cleanup func()) {
t.Helper()
var listeners []net.Listener
for i := 0; i < n; i++ {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
for _, l := range listeners {
l.Close()
}
t.Fatalf("启动监听失败: %v", err)
}
listeners = append(listeners, ln)
addr := ln.Addr().String()
addrs = append(addrs, addr)
host, portStr, _ := net.SplitHostPort(addr)
hosts = append(hosts, host)
var port int
fmt.Sscanf(portStr, "%d", &port)
ports = append(ports, port)
// 后台 accept(不处理连接,只让 connect 成功)
go func(l net.Listener) {
for {
conn, err := l.Accept()
if err != nil {
return
}
conn.Close()
}
}(ln)
}
return addrs, hosts, ports, func() {
for _, l := range listeners {
l.Close()
}
}
}
// makeRealSession 创建用于真实网络测试的 session
func makeRealSession(t *testing.T) (*common.Config, *common.ScanSession) {
t.Helper()
config := &common.Config{
Timeout: 3 * time.Second,
ThreadNum: 100,
ModuleThreadNum: 10,
MaxRetries: 3,
Network: common.NetworkConfig{ICMPRate: 0.1},
POC: common.POCConfig{Num: 20},
Output: common.OutputConfig{LogLevel: "base,info,success"},
}
session := common.NewScanSession(config, common.NewState(), &common.FlagVars{})
return config, session
}
// =============================================================================
// 真实测试 1:ProbeNetwork 对 localhost 探测
// =============================================================================
func TestReal_ProbeNetwork_Localhost(t *testing.T) {
_, hosts, _, cleanup := startListeners(t, 3)
defer cleanup()
_, session := makeRealSession(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
profile := ProbeNetwork(ctx, hosts, session)
if profile.Samples == 0 {
t.Fatal("localhost 探测应该有样本")
}
// localhost 应该是内网环境
if profile.Env != EnvLAN {
t.Errorf("localhost env = %v, want LAN", profile.Env)
}
// RTT 应该 < 10ms
if profile.RTTMedian > 10*time.Millisecond {
t.Errorf("localhost RTT median = %v, 应该 < 10ms", profile.RTTMedian)
}
// 丢包率应该为 0 或极低
if profile.LossRate > 0.1 {
t.Errorf("localhost loss = %.2f, 应该接近 0", profile.LossRate)
}
t.Logf("localhost 探测: env=%v RTT_median=%v RTT_p95=%v loss=%.2f%% samples=%d",
profile.Env, profile.RTTMedian, profile.RTTP95, profile.LossRate*100, profile.Samples)
}
// =============================================================================
// 真实测试 2:ProbeNetwork 对不可达目标
// =============================================================================
func TestReal_ProbeNetwork_Unreachable(t *testing.T) {
_, session := makeRealSession(t)
// 使用 RFC 5737 保留地址段,保证不可达
hosts := []string{"192.0.2.1", "192.0.2.2", "192.0.2.3"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
profile := ProbeNetwork(ctx, hosts, session)
// 不可达目标应该返回默认 profile 或高丢包
t.Logf("不可达探测: env=%v samples=%d loss=%.2f%%",
profile.Env, profile.Samples, profile.LossRate*100)
}
// =============================================================================
// 真实测试 3:ProbeNetwork 混合可达与不可达
// =============================================================================
func TestReal_ProbeNetwork_Mixed(t *testing.T) {
_, hosts, _, cleanup := startListeners(t, 2)
defer cleanup()
// 混合真实主机和不可达地址
mixed := append(hosts, "192.0.2.1", "192.0.2.2")
_, session := makeRealSession(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
profile := ProbeNetwork(ctx, mixed, session)
if profile.Samples == 0 {
t.Error("混合探测应该有一些成功样本")
}
t.Logf("混合探测: env=%v RTT=%v samples=%d loss=%.2f%%",
profile.Env, profile.RTTMedian, profile.Samples, profile.LossRate*100)
}
// =============================================================================
// 真实测试 4:ProbeSystem
// =============================================================================
func TestReal_ProbeSystem(t *testing.T) {
sys := ProbeSystem()
if sys.NumCPU <= 0 {
t.Errorf("NumCPU = %d, 应该 > 0", sys.NumCPU)
}
t.Logf("系统探测: NumCPU=%d FDLimit=%d", sys.NumCPU, sys.FDLimit)
// Linux/macOS 上 FDLimit 应该 > 0
// Windows 上可能为 0(设计如此)
if sys.FDLimit < 0 {
t.Errorf("FDLimit = %d, 不应为负", sys.FDLimit)
}
}
// =============================================================================
// 真实测试 5:完整链路 —— 探测 → 调参 → 池创建 → 真实任务执行
// =============================================================================
func TestReal_E2E_ProbeAndScan(t *testing.T) {
addrs, hosts, _, cleanup := startListeners(t, 5)
defer cleanup()
config, session := makeRealSession(t)
// 第一步:探测
ctx := context.Background()
profile := ProbeNetwork(ctx, hosts, session)
sys := ProbeSystem()
ep := &EnvironmentProfile{Net: *profile, System: sys}
// 第二步:调参
ep.TuneConfig(config, session)
// 第三步:创建池
target, ceiling := profile.RecommendConcurrency(config.ThreadNum, false)
metrics := &ScanMetrics{}
var successCount atomic.Int64
pool, err := NewAdaptivePool(target, ceiling, func(i interface{}) {
addr := i.(string)
conn, err := net.DialTimeout("tcp", addr, config.Timeout)
if err != nil {
metrics.RecordTimeout()
return
}
defer conn.Close()
successCount.Add(1)
metrics.RecordConnect(time.Millisecond)
}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
// 跳过慢启动测试主要流程
pool.inSlowStart = false
pool.tune(target)
// 第四步:提交任务
var wg sync.WaitGroup
for _, addr := range addrs {
wg.Add(1)
a := addr
go func() {
defer wg.Done()
_ = pool.Invoke(a)
}()
}
wg.Wait()
pool.Wait()
// 第五步:验证
if successCount.Load() != int64(len(addrs)) {
t.Errorf("成功连接 %d/%d", successCount.Load(), len(addrs))
}
snap := metrics.Snapshot()
if snap.Connects != int64(len(addrs)) {
t.Errorf("metrics.Connects = %d, want %d", snap.Connects, len(addrs))
}
t.Logf("E2E: profile=%v timeout=%v mt=%d retry=%d target=%d connects=%d",
profile.Env, config.Timeout, config.ModuleThreadNum, config.MaxRetries,
target, snap.Connects)
}
// =============================================================================
// 真实测试 6:大量连接的自适应行为
// =============================================================================
func TestReal_AdaptivePool_ManyConnections(t *testing.T) {
_, hosts, ports, cleanup := startListeners(t, 3)
defer cleanup()
metrics := &ScanMetrics{}
var successCount, failCount atomic.Int64
pool, err := NewAdaptivePool(50, 50, func(i interface{}) {
addr := i.(string)
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, time.Second)
rtt := time.Since(start)
if err != nil {
failCount.Add(1)
metrics.RecordTimeout()
return
}
defer conn.Close()
successCount.Add(1)
metrics.RecordConnect(rtt)
}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(50)
// 提交 300 个连接任务(对 3 个端口各 100 次)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
for j, host := range hosts {
addr := fmt.Sprintf("%s:%d", host, ports[j])
wg.Add(1)
go func(a string) {
defer wg.Done()
_ = pool.Invoke(a)
}(addr)
}
}
wg.Wait()
pool.Wait()
total := successCount.Load() + failCount.Load()
if total != 300 {
t.Errorf("总任务 %d, want 300", total)
}
snap := metrics.Snapshot()
t.Logf("大量连接: success=%d fail=%d connects=%d timeouts=%d cap=%d rtt_ratio=%.2f",
successCount.Load(), failCount.Load(), snap.Connects, snap.Timeouts, pool.Cap(), metrics.RTTRatio())
// localhost 连接应该几乎全部成功
if successCount.Load() < 280 {
t.Errorf("localhost 成功率过低: %d/300", successCount.Load())
}
}
// =============================================================================
// 真实测试 7:连接关闭端口 + 开放端口混合
// =============================================================================
func TestReal_MixedOpenClosed(t *testing.T) {
_, hosts, ports, cleanup := startListeners(t, 2)
defer cleanup()
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(20, 20, func(i interface{}) {
addr := i.(string)
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, time.Second)
rtt := time.Since(start)
if err != nil {
if isConnectionRefused(err) {
metrics.RecordRefused(rtt)
} else {
metrics.RecordTimeout()
}
return
}
defer conn.Close()
metrics.RecordConnect(rtt)
}, metrics)
if err != nil {
t.Fatalf("创建池失败: %v", err)
}
defer pool.Release()
pool.inSlowStart = false
pool.tune(20)
var wg sync.WaitGroup
// 连接开放端口
for i := 0; i < 20; i++ {
addr := fmt.Sprintf("%s:%d", hosts[0], ports[0])
wg.Add(1)
go func(a string) {
defer wg.Done()
_ = pool.Invoke(a)
}(addr)
}
// 连接关闭端口(用一个不存在的端口)
for i := 0; i < 20; i++ {
addr := fmt.Sprintf("127.0.0.1:%d", 1) // port 1 通常关闭
wg.Add(1)
go func(a string) {
defer wg.Done()
_ = pool.Invoke(a)
}(addr)
}
wg.Wait()
pool.Wait()
snap := metrics.Snapshot()
t.Logf("混合端口: connects=%d refused=%d timeouts=%d total=%d",
snap.Connects, snap.Refused, snap.Timeouts, snap.Total())
// 开放端口应该全部连接成功
if snap.Connects < 18 {
t.Errorf("开放端口连接数 = %d, 应该接近 20", snap.Connects)
}
// RTT ratio 应该合理(不会因为 refused 而异常)
ratio := metrics.RTTRatio()
if ratio > 3.0 || ratio < 0.3 {
t.Errorf("混合流量 RTT ratio = %.2f, 不合理", ratio)
}
}
// =============================================================================
// 真实测试 8:Context 取消时的探测行为
// =============================================================================
func TestReal_ProbeNetwork_ContextCancel(t *testing.T) {
_, hosts, _, cleanup := startListeners(t, 3)
defer cleanup()
_, session := makeRealSession(t)
// 立即取消的 context
ctx, cancel := context.WithCancel(context.Background())
cancel()
profile := ProbeNetwork(ctx, hosts, session)
// 应该优雅返回默认 profile 或部分结果
t.Logf("取消探测: env=%v samples=%d", profile.Env, profile.Samples)
}
// =============================================================================
// 真实测试 9:AdaptiveTimeout 真实 RTT 收敛
// =============================================================================
func TestReal_AdaptiveTimeout_Convergence(t *testing.T) {
addrs, _, _, cleanup := startListeners(t, 1)
defer cleanup()
at := NewAdaptiveTimeout(3 * time.Second)
// 初始应该返回最大超时
if at.Timeout() != 3*time.Second {
t.Errorf("冷启动 Timeout = %v, want 3s", at.Timeout())
}
// 做 20 次真实连接采样
for i := 0; i < 20; i++ {
start := time.Now()
conn, err := net.DialTimeout("tcp", addrs[0], time.Second)
rtt := time.Since(start)
if err != nil {
t.Fatalf("连接失败: %v", err)
}
conn.Close()
at.Record(rtt)
}
// 采样够后 Timeout 应远小于 3s(localhost RTT 通常 < 1ms)
converged := at.Timeout()
if converged >= 3*time.Second {
t.Errorf("采样后 Timeout = %v, 应该 < 3s", converged)
}
if converged < 100*time.Millisecond {
t.Logf("Timeout 收敛到 %v(localhost,正常)", converged)
}
t.Logf("AdaptiveTimeout 收敛: 3s -> %v (%d 个样本)", converged, 20)
}
// =============================================================================
// 真实测试 10:完整 TuneConfig 对真实探测数据
// =============================================================================
func TestReal_TuneConfig_WithRealProbe(t *testing.T) {
_, hosts, _, cleanup := startListeners(t, 5)
defer cleanup()
config, session := makeRealSession(t)
ctx := context.Background()
profile := ProbeNetwork(ctx, hosts, session)
sys := ProbeSystem()
origTimeout := config.Timeout
origMT := config.ModuleThreadNum
origRetry := config.MaxRetries
origICMP := config.Network.ICMPRate
ep := &EnvironmentProfile{Net: *profile, System: sys}
ep.TuneConfig(config, session)
t.Logf("真实调参:")
t.Logf(" Timeout: %v -> %v", origTimeout, config.Timeout)
t.Logf(" MT: %d -> %d", origMT, config.ModuleThreadNum)
t.Logf(" Retry: %d -> %d", origRetry, config.MaxRetries)
t.Logf(" ICMPRate: %.2f -> %.2f", origICMP, config.Network.ICMPRate)
t.Logf(" PocNum: 20 -> %d", config.POC.Num)
t.Logf(" ThreadNum: %d (fd_limit=%d)", config.ThreadNum, sys.FDLimit)
// localhost 环境下的基本验证
if config.Timeout > 3*time.Second {
t.Errorf("localhost Timeout = %v, 不应高于默认 3s", config.Timeout)
}
if config.MaxRetries > 3 {
t.Errorf("localhost Retry = %d, 不应高于默认 3", config.MaxRetries)
}
}
+118
View File
@@ -0,0 +1,118 @@
package core
import (
"runtime"
"sync/atomic"
"time"
)
// ScanMetrics 扫描过程中的实时度量指标
// 所有方法均无锁,使用 atomic 操作,可在高并发下安全调用
type ScanMetrics struct {
connects atomic.Int64 // TCP 连接成功(端口开放)
refused atomic.Int64 // 连接被拒绝(端口关闭,快速 RTT)
timeouts atomic.Int64 // 连接超时(端口过滤/不可达)
exhausted atomic.Int64 // 资源耗尽(fd/端口/内存不足)
// RTT 追踪:双 EMA(指数移动平均)
// fast EMA (α=0.1) 跟踪近期趋势
// slow EMA (α=0.02) 作为基线参考
rttFastNs atomic.Int64 // 纳秒
rttSlowNs atomic.Int64 // 纳秒
rttSamples atomic.Int64
}
func (m *ScanMetrics) RecordConnect(rtt time.Duration) {
m.connects.Add(1)
m.recordRTT(rtt)
}
func (m *ScanMetrics) RecordRefused(rtt time.Duration) {
m.refused.Add(1)
m.recordRTT(rtt)
}
func (m *ScanMetrics) RecordTimeout() { m.timeouts.Add(1) }
func (m *ScanMetrics) RecordExhausted() { m.exhausted.Add(1) }
// recordRTT 更新 RTT 双 EMA(lock-free CAS)
func (m *ScanMetrics) recordRTT(rtt time.Duration) {
ns := int64(rtt)
if ns <= 0 {
return
}
m.rttSamples.Add(1)
// Fast EMA: α = 0.1 → new = old + (sample - old) / 10
updateEMA(&m.rttFastNs, ns, 10)
// Slow EMA: α = 0.02 → new = old + (sample - old) / 50
updateEMA(&m.rttSlowNs, ns, 50)
}
func updateEMA(target *atomic.Int64, sample int64, divisor int64) {
for {
old := target.Load()
if old == 0 {
if target.CompareAndSwap(0, sample) {
return
}
runtime.Gosched()
continue
}
next := old + (sample-old)/divisor
if target.CompareAndSwap(old, next) {
return
}
runtime.Gosched()
}
}
// Total 总操作数
func (m *ScanMetrics) Total() int64 {
return m.connects.Load() + m.refused.Load() + m.timeouts.Load() + m.exhausted.Load()
}
// MetricsSnapshot 度量快照,用于计算窗口内增量
type MetricsSnapshot struct {
Connects int64
Refused int64
Timeouts int64
Exhausted int64
RTTFastNs int64
RTTSlowNs int64
}
func (s MetricsSnapshot) Total() int64 {
return s.Connects + s.Refused + s.Timeouts + s.Exhausted
}
func (m *ScanMetrics) Snapshot() MetricsSnapshot {
return MetricsSnapshot{
Connects: m.connects.Load(),
Refused: m.refused.Load(),
Timeouts: m.timeouts.Load(),
Exhausted: m.exhausted.Load(),
RTTFastNs: m.rttFastNs.Load(),
RTTSlowNs: m.rttSlowNs.Load(),
}
}
// RTTRatio 返回 fast/slow EMA 的比值
// > 1.0 表示延迟在上升(拥塞信号),< 1.0 表示延迟在下降
// 样本不足时返回 1.0
func (m *ScanMetrics) RTTRatio() float64 {
if m.rttSamples.Load() < 20 {
return 1.0
}
fast := m.rttFastNs.Load()
slow := m.rttSlowNs.Load()
if slow <= 0 {
return 1.0
}
return float64(fast) / float64(slow)
}
// RTTFast 返回快速 EMA 值
func (m *ScanMetrics) RTTFast() time.Duration {
return time.Duration(m.rttFastNs.Load())
}
+130
View File
@@ -0,0 +1,130 @@
package core
import (
"sync"
"testing"
"time"
)
// =============================================================================
// 单元测试:ScanMetrics 基本操作
// =============================================================================
func TestScanMetrics_Counters(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(time.Millisecond)
m.RecordConnect(2 * time.Millisecond)
m.RecordRefused(500 * time.Microsecond)
m.RecordTimeout()
m.RecordExhausted()
if m.Total() != 5 {
t.Errorf("Total() = %d, want 5", m.Total())
}
snap := m.Snapshot()
if snap.Connects != 2 {
t.Errorf("Connects = %d, want 2", snap.Connects)
}
if snap.Refused != 1 {
t.Errorf("Refused = %d, want 1", snap.Refused)
}
if snap.Timeouts != 1 {
t.Errorf("Timeouts = %d, want 1", snap.Timeouts)
}
if snap.Exhausted != 1 {
t.Errorf("Exhausted = %d, want 1", snap.Exhausted)
}
}
// =============================================================================
// 单元测试:RTT EMA 收敛
// =============================================================================
func TestScanMetrics_RTT_EMA(t *testing.T) {
m := &ScanMetrics{}
// 喂入稳定的 10ms RTT
for i := 0; i < 100; i++ {
m.RecordConnect(10 * time.Millisecond)
}
fast := m.RTTFast()
if fast < 9*time.Millisecond || fast > 11*time.Millisecond {
t.Errorf("稳定 10ms 后 RTTFast = %v, 应该接近 10ms", fast)
}
ratio := m.RTTRatio()
if ratio < 0.9 || ratio > 1.1 {
t.Errorf("稳定状态 RTTRatio = %.2f, 应该接近 1.0", ratio)
}
}
func TestScanMetrics_RTT_Trend(t *testing.T) {
m := &ScanMetrics{}
// 先喂入 100 个 5ms 建立基线
for i := 0; i < 100; i++ {
m.RecordConnect(5 * time.Millisecond)
}
// 再喂入 50 个 50ms(RTT 突增 10 倍)
for i := 0; i < 50; i++ {
m.RecordConnect(50 * time.Millisecond)
}
ratio := m.RTTRatio()
// fast EMA 应该比 slow EMA 高(fast 跟踪快,slow 还没追上来)
if ratio <= 1.0 {
t.Errorf("RTT 突增后 RTTRatio = %.2f, 应该 > 1.0", ratio)
}
t.Logf("RTT 突增后: ratio=%.2f, fast=%v", ratio, m.RTTFast())
}
func TestScanMetrics_RTT_InsufficientSamples(t *testing.T) {
m := &ScanMetrics{}
// 少于 20 个样本
for i := 0; i < 10; i++ {
m.RecordConnect(time.Millisecond)
}
ratio := m.RTTRatio()
if ratio != 1.0 {
t.Errorf("样本不足时 RTTRatio = %.2f, 应该是 1.0", ratio)
}
}
// =============================================================================
// 并发安全测试
// =============================================================================
func TestScanMetrics_ConcurrentSafety(t *testing.T) {
m := &ScanMetrics{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(4)
go func() { defer wg.Done(); m.RecordConnect(time.Millisecond) }()
go func() { defer wg.Done(); m.RecordRefused(time.Millisecond) }()
go func() { defer wg.Done(); m.RecordTimeout() }()
go func() { defer wg.Done(); m.RecordExhausted() }()
}
wg.Wait()
if m.Total() != 400 {
t.Errorf("并发后 Total() = %d, want 400", m.Total())
}
// 验证 Snapshot 不 panic
snap := m.Snapshot()
if snap.Total() != 400 {
t.Errorf("并发后 Snapshot.Total() = %d, want 400", snap.Total())
}
// 验证 RTTRatio 不 panic
_ = m.RTTRatio()
}
+18 -4
View File
@@ -94,12 +94,21 @@ func selectStrategy(config *common.Config, state *common.State, info common.Host
// RunScan 执行整体扫描流程
func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) (ScanReport, error) {
start := time.Now()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
config := session.Config
// 全局超时:-gt 参数设置整个扫描的硬性截止时间
var cancel context.CancelFunc
if config.GlobalTimeout > 0 {
ctx, cancel = context.WithTimeout(ctx, config.GlobalTimeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
state := session.State
// 设置全局 State(兼容旧代码路径中未传 state 的调用)
SetGlobalState(state)
// 初始化HTTP客户端(静默,无需日志)
if err := lib.Inithttp(config); err != nil {
session.LogError(i18n.Tr("http_client_init_failed", err))
@@ -190,6 +199,11 @@ func finishScan(session *common.ScanSession) {
func ExecuteScanTasks(ctx context.Context, session *common.ScanSession, targets []common.HostInfo, strategy ScanStrategy, ch chan struct{}, wg *sync.WaitGroup) {
config := session.Config
// 注入 session state 到策略(用于 per-session 服务缓存)
if setter, ok := strategy.(interface{ SetState(*common.State) }); ok {
setter.SetState(session.State)
}
// 获取要执行的插件
pluginsToRun, isCustomMode := strategy.GetPlugins(config)
@@ -390,13 +404,13 @@ var resultSerializers = map[plugins.ResultType]resultSerializer{
return r.Banner
},
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
// 优先使用VulInfo,为空则回退到Banner
vuln := r.VulInfo
if vuln == "" {
vuln = r.Banner
}
d["vulnerability"] = vuln
d["service"] = r.Service
d["type"] = "vulnerability"
},
},
plugins.ResultTypeWeb: {
+354
View File
@@ -0,0 +1,354 @@
package core
import (
"sync"
"testing"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
// registerTestPlugins 注册测试用插件(名字和服务识别结果一致)
func registerTestPlugins(t *testing.T) {
t.Helper()
plugins.RegisterWithOptions("ssh", func() plugins.Plugin { return nil }, []int{22, 2222}, nil, true)
plugins.RegisterWithOptions("mysql", func() plugins.Plugin { return nil }, []int{3306}, nil, true)
plugins.RegisterWithOptions("ftp", func() plugins.Plugin { return nil }, []int{21}, nil, true)
plugins.RegisterWithOptions("redis", func() plugins.Plugin { return nil }, []int{6379}, nil, true)
plugins.RegisterWithOptions("postgresql", func() plugins.Plugin { return nil }, []int{5432}, nil, true)
plugins.RegisterWithOptions("telnet", func() plugins.Plugin { return nil }, []int{23}, nil, true)
plugins.RegisterWithOptions("mssql", func() plugins.Plugin { return nil }, []int{1433}, nil, true)
plugins.RegisterWithOptions("vnc", func() plugins.Plugin { return nil }, []int{5900}, nil, true)
plugins.RegisterWithOptions("webtitle", func() plugins.Plugin { return nil }, []int{}, []string{plugins.PluginTypeWeb}, true)
}
func clearServiceCache() {
state := common.NewState()
SetGlobalState(state)
}
// =============================================================================
// 单元测试:CacheServiceInfo / GetCachedServiceInfo
// =============================================================================
func TestCacheServiceInfo_BasicCRUD(t *testing.T) {
clearServiceCache()
t.Run("缓存后可读取", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "ssh", Version: "OpenSSH_8.9"})
info, ok := GetCachedServiceInfo("10.0.0.1", 22)
if !ok {
t.Fatal("缓存未命中")
}
if info.Name != "ssh" || info.Version != "OpenSSH_8.9" {
t.Errorf("got Name=%q Version=%q", info.Name, info.Version)
}
})
t.Run("不同端口独立", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 3306, &ServiceInfo{Name: "mysql"})
CacheServiceInfo("10.0.0.1", 5432, &ServiceInfo{Name: "postgresql"})
i1, _ := GetCachedServiceInfo("10.0.0.1", 3306)
i2, _ := GetCachedServiceInfo("10.0.0.1", 5432)
if i1.Name != "mysql" || i2.Name != "postgresql" {
t.Errorf("端口混淆: 3306=%q 5432=%q", i1.Name, i2.Name)
}
})
t.Run("不同主机独立", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "ssh"})
CacheServiceInfo("10.0.0.2", 22, &ServiceInfo{Name: "telnet"})
i1, _ := GetCachedServiceInfo("10.0.0.1", 22)
i2, _ := GetCachedServiceInfo("10.0.0.2", 22)
if i1.Name != "ssh" || i2.Name != "telnet" {
t.Errorf("主机混淆: .1=%q .2=%q", i1.Name, i2.Name)
}
})
t.Run("覆盖写入", func(t *testing.T) {
CacheServiceInfo("10.0.0.5", 80, &ServiceInfo{Name: "unknown"})
CacheServiceInfo("10.0.0.5", 80, &ServiceInfo{Name: "http"})
info, _ := GetCachedServiceInfo("10.0.0.5", 80)
if info.Name != "http" {
t.Errorf("覆盖失败: %q", info.Name)
}
})
t.Run("未缓存返回 false", func(t *testing.T) {
if _, ok := GetCachedServiceInfo("192.168.99.99", 12345); ok {
t.Error("应返回 false")
}
})
}
// =============================================================================
// 单元测试:Web 服务过滤
// =============================================================================
func TestWebServiceFiltering(t *testing.T) {
clearServiceCache()
webNames := []string{"http", "https", "ssl", "tls", "nginx", "apache", "iis", "tomcat"}
nonWebNames := []string{"ssh", "mysql", "postgresql", "redis", "mongodb", "ftp", "smtp", "telnet", "vnc", "rdp"}
for _, name := range webNames {
clearServiceCache()
CacheServiceInfo("10.0.0.1", 443, &ServiceInfo{Name: name})
if !IsMarkedWebService("10.0.0.1", 443) {
t.Errorf("%q 应被识别为 Web 服务", name)
}
}
for _, name := range nonWebNames {
clearServiceCache()
CacheServiceInfo("10.0.0.1", 9999, &ServiceInfo{Name: name})
if IsMarkedWebService("10.0.0.1", 9999) {
t.Errorf("%q 不应被识别为 Web 服务", name)
}
}
t.Run("GetWebServiceInfo 过滤非 Web", func(t *testing.T) {
clearServiceCache()
CacheServiceInfo("10.0.0.1", 3306, &ServiceInfo{Name: "mysql"})
if _, ok := GetWebServiceInfo("10.0.0.1", 3306); ok {
t.Error("mysql 不应通过 GetWebServiceInfo")
}
})
t.Run("GetWebServiceInfo 返回 Web", func(t *testing.T) {
clearServiceCache()
CacheServiceInfo("10.0.0.1", 8080, &ServiceInfo{Name: "nginx"})
info, ok := GetWebServiceInfo("10.0.0.1", 8080)
if !ok || info.Name != "nginx" {
t.Error("nginx 应通过 GetWebServiceInfo")
}
})
}
// =============================================================================
// 集成测试:指纹驱动插件匹配
// =============================================================================
func TestIntegration_FingerprintDrivenPluginMatch(t *testing.T) {
clearServiceCache()
registerTestPlugins(t)
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "ssh"})
CacheServiceInfo("10.0.0.1", 8881, &ServiceInfo{Name: "ssh"})
CacheServiceInfo("10.0.0.1", 13306, &ServiceInfo{Name: "mysql"})
CacheServiceInfo("10.0.0.1", 80, &ServiceInfo{Name: "http"})
CacheServiceInfo("10.0.0.1", 9443, &ServiceInfo{Name: "https"})
CacheServiceInfo("10.0.0.1", 2121, &ServiceInfo{Name: "ftp"})
CacheServiceInfo("10.0.0.1", 6380, &ServiceInfo{Name: "redis"})
strategy := NewServiceScanStrategy()
tests := []struct {
plugin, host string
port int
want bool
desc string
}{
{"ssh", "10.0.0.1", 22, true, "SSH 标准端口"},
{"ssh", "10.0.0.1", 8881, true, "SSH 非标准端口(指纹匹配)"},
{"mysql", "10.0.0.1", 13306, true, "MySQL 非标准端口"},
{"ftp", "10.0.0.1", 2121, true, "FTP 非标准端口"},
{"redis", "10.0.0.1", 6380, true, "Redis 非标准端口"},
{"ssh", "10.0.0.1", 13306, false, "SSH 不匹配 MySQL 端口"},
{"mysql", "10.0.0.1", 8881, false, "MySQL 不匹配 SSH 端口"},
{"redis", "10.0.0.1", 22, false, "Redis 不匹配 SSH 标准端口"},
{"ssh", "10.0.0.1", 65000, false, "SSH 不匹配未识别端口"},
{"webtitle", "10.0.0.1", 80, true, "Web 匹配 http"},
{"webtitle", "10.0.0.1", 9443, true, "Web 匹配 https 非标准"},
{"webtitle", "10.0.0.1", 22, false, "Web 不匹配 SSH"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got := strategy.isPluginApplicableToPortWithHost(tt.plugin, tt.host, tt.port)
if got != tt.want {
t.Errorf("plugin=%q port=%d: got %v, want %v", tt.plugin, tt.port, got, tt.want)
}
})
}
}
// =============================================================================
// 集成测试:非标准端口完整流程
// =============================================================================
func TestIntegration_NonStandardPortScanFlow(t *testing.T) {
clearServiceCache()
registerTestPlugins(t)
host := "172.16.0.100"
CacheServiceInfo(host, 8881, &ServiceInfo{
Name: "ssh", Version: "OpenSSH_8.2p1",
Banner: "SSH-2.0-OpenSSH_8.2p1", Extras: map[string]string{"os": "Linux"},
})
strategy := NewServiceScanStrategy()
if !strategy.isPluginApplicableToPortWithHost("ssh", host, 8881) {
t.Error("SSH 应匹配 8881")
}
if strategy.isPluginApplicableToPortWithHost("mysql", host, 8881) {
t.Error("MySQL 不应匹配 8881 上的 SSH")
}
if IsMarkedWebService(host, 8881) {
t.Error("SSH 不应标记为 Web")
}
}
// =============================================================================
// 集成测试:同一主机多服务
// =============================================================================
func TestIntegration_MultiServiceSameHost(t *testing.T) {
clearServiceCache()
registerTestPlugins(t)
host := "192.168.1.100"
CacheServiceInfo(host, 2222, &ServiceInfo{Name: "ssh"})
CacheServiceInfo(host, 33060, &ServiceInfo{Name: "mysql"})
CacheServiceInfo(host, 8080, &ServiceInfo{Name: "http"})
CacheServiceInfo(host, 63790, &ServiceInfo{Name: "redis"})
strategy := NewServiceScanStrategy()
checks := []struct {
plugin string
port int
want bool
}{
{"ssh", 2222, true}, {"ssh", 33060, false}, {"ssh", 8080, false},
{"mysql", 33060, true}, {"mysql", 2222, false},
{"redis", 63790, true}, {"redis", 2222, false},
{"webtitle", 8080, true}, {"webtitle", 2222, false},
}
for _, c := range checks {
got := strategy.isPluginApplicableToPortWithHost(c.plugin, host, c.port)
if got != c.want {
t.Errorf("plugin=%q port=%d: got %v, want %v", c.plugin, c.port, got, c.want)
}
}
}
// =============================================================================
// 边界测试
// =============================================================================
func TestServiceCache_EdgeCases(t *testing.T) {
clearServiceCache()
registerTestPlugins(t)
strategy := NewServiceScanStrategy()
t.Run("空服务名不匹配", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 9999, &ServiceInfo{Name: ""})
if strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 9999) {
t.Error("空服务名不应匹配")
}
})
t.Run("unknown 不匹配", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 8888, &ServiceInfo{Name: "unknown"})
if strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 8888) {
t.Error("unknown 不应匹配")
}
})
t.Run("大小写不敏感", func(t *testing.T) {
clearServiceCache()
CacheServiceInfo("10.0.0.1", 5555, &ServiceInfo{Name: "SSH"})
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 5555) {
t.Error("SSH 大写应匹配 ssh 插件")
}
})
t.Run("host 为空不查缓存", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 8881, &ServiceInfo{Name: "ssh"})
if strategy.isPluginApplicableToPortWithHost("ssh", "", 8881) {
t.Error("host 为空不应匹配")
}
})
t.Run("nil ServiceInfo 不 panic", func(t *testing.T) {
CacheServiceInfo("10.0.0.1", 7777, nil)
got := strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 7777)
if got {
t.Error("nil ServiceInfo 不应匹配")
}
})
t.Run("IPv6", func(t *testing.T) {
clearServiceCache()
CacheServiceInfo("::1", 22, &ServiceInfo{Name: "ssh"})
if _, ok := GetCachedServiceInfo("::1", 22); !ok {
t.Error("IPv6 缓存失败")
}
})
}
// =============================================================================
// 并发安全
// =============================================================================
func TestServiceCache_ConcurrentSafety(t *testing.T) {
clearServiceCache()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(3)
go func(p int) { defer wg.Done(); CacheServiceInfo("10.0.0.1", p, &ServiceInfo{Name: "ssh"}) }(i)
go func(p int) { defer wg.Done(); GetCachedServiceInfo("10.0.0.1", p) }(i)
go func(p int) { defer wg.Done(); IsMarkedWebService("10.0.0.1", p) }(i)
}
wg.Wait()
for i := 0; i < 100; i++ {
if _, ok := GetCachedServiceInfo("10.0.0.1", i); !ok {
t.Errorf("并发写入丢失: port=%d", i)
}
}
}
// =============================================================================
// 回归测试:#588
// =============================================================================
func TestRegression_Issue588(t *testing.T) {
clearServiceCache()
registerTestPlugins(t)
CacheServiceInfo("192.168.1.50", 8881, &ServiceInfo{Name: "ssh", Version: "OpenSSH_7.4"})
strategy := NewServiceScanStrategy()
if !strategy.isPluginApplicableToPortWithHost("ssh", "192.168.1.50", 8881) {
t.Fatal("#588: SSH 应匹配 8881")
}
for _, p := range []string{"mysql", "ftp", "redis", "postgresql", "telnet", "vnc", "mssql"} {
if strategy.isPluginApplicableToPortWithHost(p, "192.168.1.50", 8881) {
t.Errorf("#588: %q 不应匹配 8881 上的 SSH", p)
}
}
}
// =============================================================================
// 端口匹配优先于缓存
// =============================================================================
func TestIntegration_PortMatchPrecedence(t *testing.T) {
clearServiceCache()
registerTestPlugins(t)
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "http"})
strategy := NewServiceScanStrategy()
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 22) {
t.Error("SSH 应通过端口匹配命中 22(即使缓存是 http)")
}
if !IsMarkedWebService("10.0.0.1", 22) {
t.Error("缓存是 http,应标记为 Web")
}
}
+4 -2
View File
@@ -21,6 +21,8 @@ const (
defaultIntensity = 7 // 默认探测强度 (1-9)
)
var errConnLost = errors.New("connection lost and reconnect failed")
// sslSecondProbes SSL服务二次探测的探针名称
var sslSecondProbes = []string{"TerminalServerCookie", "TerminalServer"}
@@ -527,7 +529,7 @@ var defaultReadTimeoutMS = WrTimeout * 1000
// Write 写入数据到连接
func (i *Info) Write(msg []byte) error {
if i.Conn == nil {
return nil
return errConnLost
}
// 设置写入超时
@@ -570,7 +572,7 @@ func (i *Info) Write(msg []byte) error {
// Read 从连接读取响应
func (i *Info) Read() ([]byte, error) {
if i.Conn == nil {
return nil, nil
return nil, errConnLost
}
// 设置读取超时(使用动态超时)
+30 -18
View File
@@ -69,7 +69,7 @@ func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config
applicablePlugins = append(applicablePlugins, pluginName)
}
// 输出结果
// 输出结果(仅在有匹配插件时显示,避免因预检端口不完整而输出误导性的"无可用插件")
if len(applicablePlugins) > 0 {
pluginStr := formatPluginList(applicablePlugins)
if isCustomMode {
@@ -77,8 +77,6 @@ func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config
} else {
session.LogInfo(i18n.Tr("service_plugin_info", pluginStr))
}
} else {
session.LogInfo(i18n.GetText("service_plugin_none"))
}
}
@@ -88,18 +86,9 @@ func (s *ServiceScanStrategy) parsePortList(portStr string) []int {
return []int{}
}
ports := []int{} // 初始化为空切片而非nil
parts := strings.Split(portStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if port, err := strconv.Atoi(part); err == nil {
// 验证端口范围 1-65535(与 scanner.go 的 parsePort 保持一致)
if port >= 1 && port <= 65535 {
ports = append(ports, port)
} else {
common.LogError(i18n.Tr("port_out_of_range", port))
}
}
ports := parsers.ParsePort(portStr)
if ports == nil {
return []int{}
}
return ports
}
@@ -164,6 +153,10 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
totalAlive := 0
sawHosts := false
performedLiveness := false
envProfiled := false
// 系统能力探测(不需要网络目标)
sysProfile := ProbeSystem()
for {
hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config))
@@ -185,6 +178,15 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
continue
}
// 首批 alive hosts 出来后做网络探测,调整后续所有参数
if !envProfiled {
envProfiled = true
netProfile := ProbeNetwork(ctx, hosts, session)
ep := &EnvironmentProfile{Net: *netProfile, System: sysProfile}
ep.TuneConfig(config, session)
}
// UDP 插件调度:默认端口模式全量调度,用户指定 -p 时只调度端口有交集的 UDP 插件
s.dispatchUDPPlugins(ctx, session, hosts, info, config, ch, wg)
s.scanHostBatch(ctx, session, hosts, info, pluginsToRun, isCustomMode, ch, wg)
}
@@ -267,9 +269,22 @@ func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *c
return
}
// 用户指定 -p 时,只调度端口有交集的 UDP 插件
var userPorts map[int]bool
if config.Target.Ports != "" && config.Target.Ports != "all" {
parsed := parsers.ParsePort(config.Target.Ports)
userPorts = make(map[int]bool, len(parsed))
for _, p := range parsed {
userPorts[p] = true
}
}
for _, host := range hosts {
for _, pluginName := range udpPlugins {
for _, port := range plugins.GetPluginPorts(pluginName) {
if userPorts != nil && !userPorts[port] {
continue
}
target := baseInfo
target.Host = host
target.Port = port
@@ -328,11 +343,8 @@ func (s *ServiceScanStrategy) LogVulnerabilityPluginInfo(targets []common.HostIn
servicePlugins = append(servicePlugins, pluginName)
}
// 输出插件信息
if len(servicePlugins) > 0 {
common.LogInfo(i18n.Tr("service_plugin_info", strings.Join(servicePlugins, ", ")))
} else {
common.LogInfo(i18n.GetText("scan_no_service_plugins"))
}
}
+30 -7
View File
@@ -65,6 +65,16 @@ func TestParsePortList_BasicParsing(t *testing.T) {
input: "22,80,443,3306",
expected: []int{22, 80, 443, 3306},
},
{
name: "端口范围",
input: "80-82",
expected: []int{80, 81, 82},
},
{
name: "端口和范围混合",
input: "22,80-81",
expected: []int{22, 80, 81},
},
{
name: "空字符串",
input: "",
@@ -281,7 +291,7 @@ func TestParsePortList_ProductionScenarios(t *testing.T) {
t.Run("数据库端口", func(t *testing.T) {
input := "3306,5432,1433,27017"
expected := []int{3306, 5432, 1433, 27017}
expected := []int{1433, 3306, 5432, 27017}
result := s.parsePortList(input)
if !intSlicesEqual(result, expected) {
t.Errorf("应该正确解析常见数据库端口")
@@ -317,6 +327,13 @@ func TestParsePortList_ProductionScenarios(t *testing.T) {
t.Errorf("应该正确解析高端口号")
}
})
t.Run("端口组", func(t *testing.T) {
result := s.parsePortList("web")
if !sliceContains(result, 80) || !sliceContains(result, 443) {
t.Errorf("web端口组应该包含80和443, 实际 %v", result)
}
})
}
// TestParsePortList_ReturnValue 测试返回值特性
@@ -330,14 +347,11 @@ func TestParsePortList_ReturnValue(t *testing.T) {
}
})
t.Run("端口不重复-但不保证去重", func(t *testing.T) {
// 注意:当前实现不去重,如果用户输入 "22,22",会返回 [22, 22]
// 这是可以接受的,因为上层逻辑会处理重复
t.Run("重复端口会去重", func(t *testing.T) {
input := "22,22"
result := s.parsePortList(input)
// 这里我们只测试解析是否正确,不测试去重
if len(result) != 2 || result[0] != 22 || result[1] != 22 {
t.Errorf("当前实现不去重,应该返回两个22")
if len(result) != 1 || result[0] != 22 {
t.Errorf("重复端口应该去重, 实际 %v", result)
}
})
}
@@ -355,6 +369,15 @@ func intSlicesEqual(a, b []int) bool {
return true
}
func sliceContains(values []int, target int) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
// =============================================================================
// 存活检测判断测试
// =============================================================================
+82
View File
@@ -0,0 +1,82 @@
package core
import (
"flag"
"os"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
)
func TestCLIExplicitDefaultTuningFlagsSurviveTuneConfig(t *testing.T) {
oldArgs := os.Args
oldFlagSet := flag.CommandLine
oldFlagVars := *common.GetFlagVars()
defer func() {
os.Args = oldArgs
flag.CommandLine = oldFlagSet
*common.GetFlagVars() = oldFlagVars
}()
*common.GetFlagVars() = common.FlagVars{}
flag.CommandLine = flag.NewFlagSet("fscan-test", flag.ContinueOnError)
os.Args = []string{
"fscan-test",
"-silent",
"-h", "127.0.0.1",
"-time", "3",
"-mt", "20",
"-retry", "3",
"-icmp-rate", "0.1",
"-num", "20",
}
info := &common.HostInfo{}
if err := common.Flag(info); err != nil {
t.Fatalf("Flag error = %v", err)
}
cfg, _, err := common.BuildConfig(common.GetFlagVars(), info)
if err != nil {
t.Fatalf("BuildConfig error = %v", err)
}
if !cfg.TimeoutExplicit || !cfg.ModuleThreadNumExplicit ||
!cfg.MaxRetriesExplicit || !cfg.Network.ICMPRateExplicit ||
!cfg.POC.NumExplicit {
t.Fatalf("explicit flags not propagated: timeout=%v mt=%v retry=%v icmp=%v num=%v",
cfg.TimeoutExplicit,
cfg.ModuleThreadNumExplicit,
cfg.MaxRetriesExplicit,
cfg.Network.ICMPRateExplicit,
cfg.POC.NumExplicit)
}
ep := &EnvironmentProfile{
Net: NetworkProfile{
Env: EnvLAN,
RTTMedian: time.Millisecond,
RTTStddev: 200 * time.Microsecond,
LossRate: 0,
Samples: 30,
},
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
}
ep.TuneConfig(cfg, makeTestSession(cfg))
if cfg.Timeout != 3*time.Second {
t.Fatalf("Timeout = %v, want explicit default 3s", cfg.Timeout)
}
if cfg.ModuleThreadNum != 20 {
t.Fatalf("ModuleThreadNum = %d, want explicit default 20", cfg.ModuleThreadNum)
}
if cfg.MaxRetries != 3 {
t.Fatalf("MaxRetries = %d, want explicit default 3", cfg.MaxRetries)
}
if cfg.Network.ICMPRate != 0.1 {
t.Fatalf("ICMPRate = %.2f, want explicit default 0.10", cfg.Network.ICMPRate)
}
if cfg.POC.Num != 20 {
t.Fatalf("POC.Num = %d, want explicit default 20", cfg.POC.Num)
}
}
+135 -32
View File
@@ -56,7 +56,9 @@ func DetectHTTPSchemeContext(ctx context.Context, host string, port int, config
return "https"
}
// 第二步:尝试国密TLS握手(GM TLS fallback)
// 第二步:仅在标准 TLS 握手级别失败(cipher/protocol 不兼容)时尝试国密
// 连接级别失败(timeout/refused/非 TLS 端口)不需要尝试
if maybeGMTLS(err) {
gmConn, gmErr := gmtls.DialWithDialer(
tlsDialer,
"tcp", addr,
@@ -65,11 +67,11 @@ func DetectHTTPSchemeContext(ctx context.Context, host string, port int, config
InsecureSkipVerify: true,
},
)
if gmErr == nil {
_ = gmConn.Close()
return "https-gm"
}
}
// TLS和GM TLS都失败,尝试HTTP
client := createHTTPClient(config, session)
@@ -112,7 +114,11 @@ func createHTTPClient(config *common.Config, session *common.ScanSession) *http.
networkConfig := config.Network
if networkConfig.HTTPProxy != "" {
// 使用HTTP代理
if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil {
httpProxy := networkConfig.HTTPProxy
if !strings.Contains(httpProxy, "://") {
httpProxy = "http://" + httpProxy
}
if proxyURL, err := url.Parse(httpProxy); err == nil && proxyURL.Host != "" {
transport.Proxy = http.ProxyURL(proxyURL)
} else {
session.LogError(i18n.Tr("http_proxy_config_error", err))
@@ -204,11 +210,9 @@ func (w *WebPortDetector) tryHTTP(ctx context.Context, client *http.Client, sess
// 基于服务指纹的Web服务识别
// ===============================
// Web服务缓存 - 简化的全局缓存
var (
webServiceCache = make(map[string]*ServiceInfo)
webCacheMutex sync.RWMutex
)
// globalState 全局 State 兼容指针(向后兼容不接受 State 的旧调用方)
// 新代码应通过 State 方法访问服务缓存
var globalState *common.State
// IsWebServiceByFingerprint 基于服务指纹判断Web服务 - 保持API兼容
// 服务识别规则 - 编译期常量,避免运行时分配
@@ -259,33 +263,99 @@ func IsWebServiceByFingerprint(serviceInfo *ServiceInfo) bool {
return false
}
// MarkAsWebService 标记Web服务 - 保持API兼容
// isDefinitelyNonWeb 判断服务是否明确不是 Web 服务
// 只检查 nonWebKeywords,不在里面 = 不确定 = 值得做 HTTP 探测
func isDefinitelyNonWeb(serviceInfo *ServiceInfo) bool {
if serviceInfo == nil || serviceInfo.Name == "" {
return false
}
serviceName := strings.ToLower(serviceInfo.Name)
for _, keyword := range nonWebKeywords {
if strings.Contains(serviceName, keyword) {
return true
}
}
return false
}
// SetGlobalState 设置全局 State(RunScan 入口调用,兼容旧代码路径)
func SetGlobalState(state *common.State) {
globalState = state
}
func resolveState(state *common.State) *common.State {
if state != nil {
return state
}
return globalState
}
// CacheServiceInfoWithState 缓存服务信息到指定 State
func CacheServiceInfoWithState(state *common.State, host string, port int, serviceInfo *ServiceInfo) {
s := resolveState(state)
if s == nil {
return
}
key := net.JoinHostPort(host, strconv.Itoa(port))
s.CacheService(key, serviceInfo)
}
// CacheServiceInfo 兼容旧调用(使用全局 State)
func CacheServiceInfo(host string, port int, serviceInfo *ServiceInfo) {
CacheServiceInfoWithState(nil, host, port, serviceInfo)
}
// MarkAsWebService 标记 Web 服务
func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) {
cacheKey := net.JoinHostPort(host, strconv.Itoa(port))
CacheServiceInfo(host, port, serviceInfo)
}
webCacheMutex.Lock()
defer webCacheMutex.Unlock()
// GetCachedServiceInfoWithState 从指定 State 获取缓存的服务信息
func GetCachedServiceInfoWithState(state *common.State, host string, port int) (*ServiceInfo, bool) {
s := resolveState(state)
if s == nil {
return nil, false
}
key := net.JoinHostPort(host, strconv.Itoa(port))
val, ok := s.GetCachedService(key)
if !ok {
return nil, false
}
info, ok := val.(*ServiceInfo)
return info, ok
}
webServiceCache[cacheKey] = serviceInfo
// GetCachedServiceInfo 兼容旧调用
func GetCachedServiceInfo(host string, port int) (*ServiceInfo, bool) {
return GetCachedServiceInfoWithState(nil, host, port)
}
// GetWebServiceInfo 获取 Web 服务信息
func GetWebServiceInfo(host string, port int) (*ServiceInfo, bool) {
cacheKey := net.JoinHostPort(host, strconv.Itoa(port))
webCacheMutex.RLock()
defer webCacheMutex.RUnlock()
serviceInfo, exists := webServiceCache[cacheKey]
return serviceInfo, exists
return GetWebServiceInfoWithState(nil, host, port)
}
// IsMarkedWebService 检查是否已标记为Web服务
// GetWebServiceInfoWithState 从指定 State 获取 Web 服务信息
func GetWebServiceInfoWithState(state *common.State, host string, port int) (*ServiceInfo, bool) {
info, exists := GetCachedServiceInfoWithState(state, host, port)
if !exists || !IsWebServiceByFingerprint(info) {
return nil, false
}
return info, true
}
// IsMarkedWebService 检查是否为 Web 服务(使用全局 State)
func IsMarkedWebService(host string, port int) bool {
_, exists := GetWebServiceInfo(host, port)
return exists
}
// IsMarkedWebServiceWithState 检查是否为 Web 服务(指定 State)
func IsMarkedWebServiceWithState(state *common.State, host string, port int) bool {
_, exists := GetWebServiceInfoWithState(state, host, port)
return exists
}
// ===============================
// Web扫描策略
// ===============================
@@ -375,17 +445,31 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
// 解析URL获取Host和Port信息
parsedURL, err := url.Parse(urlStr)
if err != nil {
if session != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, err))
}
return nil
}
urlInfo := baseInfo
urlInfo.URL = urlStr
urlInfo.Host = parsedURL.Hostname()
if urlInfo.Host == "" {
if session != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, "empty host"))
}
return nil
}
// 设置端口
portStr := parsedURL.Port()
if portStr == "" {
if hasMalformedURLPort(parsedURL.Host) {
if session != nil {
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, ""))
}
return nil
}
// 根据协议设置默认端口
if parsedURL.Scheme == "https" {
urlInfo.Port = 443
@@ -393,18 +477,14 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
urlInfo.Port = 80
}
} else {
// 解析端口字符串为整数
var port int
if _, err := fmt.Sscanf(portStr, "%d", &port); err == nil {
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
if session != nil {
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, portStr))
}
return nil
}
urlInfo.Port = port
} else {
// 解析失败时使用默认端口
if parsedURL.Scheme == "https" {
urlInfo.Port = 443
} else {
urlInfo.Port = 80
}
}
}
// 标记为Web服务,确保Web插件能识别此目标
@@ -412,3 +492,26 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
return &urlInfo
}
// maybeGMTLS 判断标准 TLS 握手错误是否可能是国密服务端
// 只有 cipher/protocol 层面的不兼容才值得尝试国密回退
// 连接超时、拒绝、非 TLS 端口等连接级错误直接跳过
func maybeGMTLS(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "handshake failure") ||
strings.Contains(s, "protocol version") ||
strings.Contains(s, "no mutual") ||
strings.Contains(s, "cipher suite")
}
func hasMalformedURLPort(host string) bool {
if strings.HasPrefix(host, "[") {
end := strings.LastIndexByte(host, ']')
return end >= 0 && len(host) > end+1 && host[end+1] == ':'
}
return strings.Contains(host, ":")
}
+58 -17
View File
@@ -440,9 +440,7 @@ func TestCreateTargetFromURL(t *testing.T) {
// TestWebServiceCache 测试Web服务缓存操作
func TestWebServiceCache(t *testing.T) {
// 清空缓存
webCacheMutex.Lock()
webServiceCache = make(map[string]*ServiceInfo)
webCacheMutex.Unlock()
SetGlobalState(common.NewState())
t.Run("存储和读取", func(t *testing.T) {
serviceInfo := &ServiceInfo{
@@ -517,9 +515,7 @@ func TestWebServiceCache(t *testing.T) {
// TestWebServiceCache_Concurrent 测试并发安全性
func TestWebServiceCache_Concurrent(t *testing.T) {
// 清空缓存
webCacheMutex.Lock()
webServiceCache = make(map[string]*ServiceInfo)
webCacheMutex.Unlock()
SetGlobalState(common.NewState())
t.Run("不同key并发写入", func(t *testing.T) {
var wg sync.WaitGroup
@@ -607,17 +603,43 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
t.Run("空URL", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "")
// url.Parse("")会成功,但Hostname()返回空
if result == nil {
t.Skip("空URL解析行为依赖于url.Parse实现")
if result != nil {
t.Fatalf("空URL应被拒绝,实际 %#v", result)
}
})
t.Run("只有协议", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://")
// url.Parse("http://")会成功,但Host为空
if result != nil && result.Host == "" {
t.Log("Empty host check passed as expected")
if result != nil {
t.Fatalf("空Host URL应被拒绝,实际 %#v", result)
}
})
t.Run("非法URL不会因nil session panic", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://[::1")
if result != nil {
t.Fatalf("非法URL应被拒绝,实际 %#v", result)
}
})
t.Run("越界端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:70000")
if result != nil {
t.Fatalf("越界端口应被拒绝,实际 %#v", result)
}
})
t.Run("非数字端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:bad")
if result != nil {
t.Fatalf("非数字端口应被拒绝,实际 %#v", result)
}
})
t.Run("空端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:")
if result != nil {
t.Fatalf("空端口应被拒绝,实际 %#v", result)
}
})
@@ -642,6 +664,16 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
}
}
})
t.Run("IPv6无端口使用默认端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "https://[::1]/")
if result == nil {
t.Fatal("IPv6无端口URL应能正确解析")
}
if result.Host != "::1" || result.Port != 443 {
t.Fatalf("IPv6默认端口解析错误: %#v", result)
}
})
}
// TestIsWebServiceByFingerprint_Priority 测试识别优先级
@@ -676,12 +708,8 @@ func TestIsWebServiceByFingerprint_Priority(t *testing.T) {
// TestDetectHTTPScheme 测试HTTP/HTTPS协议智能检测
func TestDetectHTTPScheme(t *testing.T) {
// 设置WebTimeout避免测试超时
cfg := common.GetGlobalConfig()
oldTimeout := cfg.Network.WebTimeout
cfg := common.NewConfig()
cfg.Network.WebTimeout = 2 * time.Second
defer func() { cfg.Network.WebTimeout = oldTimeout }()
session := common.NewScanSession(cfg, common.NewState(), common.GetFlagVars())
t.Run("HTTPS服务器检测", func(t *testing.T) {
@@ -816,6 +844,19 @@ func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) {
}
}
func TestCreateHTTPClientNormalizesHTTPProxyWithoutScheme(t *testing.T) {
cfg := common.NewConfig()
cfg.Network.WebTimeout = time.Second
cfg.Network.HTTPProxy = "127.0.0.1:18080"
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
client := createHTTPClient(cfg, session)
proxy := proxyForTest(t, client)
if proxy != "http://127.0.0.1:18080" {
t.Fatalf("proxy = %q, want http://127.0.0.1:18080", proxy)
}
}
func proxyForTest(t *testing.T, client *http.Client) string {
t.Helper()
+10 -3
View File
@@ -1,6 +1,6 @@
module github.com/shadow1ng/fscan
go 1.20
go 1.25.0
require (
github.com/fatih/color v1.18.0
@@ -24,13 +24,14 @@ require (
go.ciq.dev/go-rsync v0.0.0-20240304021629-0a3bb196e6d1
golang.org/x/crypto v0.31.0
golang.org/x/net v0.32.0
golang.org/x/sys v0.28.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c
google.golang.org/protobuf v1.28.1
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.39.0
)
require (
@@ -38,6 +39,7 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
@@ -53,9 +55,14 @@ require (
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/stoewer/go-strcase v1.2.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+48 -3
View File
@@ -5,6 +5,7 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrGr5AyrNMXuA0gves=
@@ -16,6 +17,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@@ -50,6 +53,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -65,6 +70,8 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
github.com/huin/asn1ber v0.0.0-20120622192748-af09f62e6358 h1:hVXNJ57IHkOA8FBq80UG263MEBwNUMfS9c82J2QE5UQ=
@@ -108,6 +115,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed h1:FI2NIv6fpef6BQl2u3IZX/Cj20tfypRF4yd+uaHOMtI=
github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed/go.mod h1:3rdaFaCv4AyBgu5ALFM0+tSuHrBh6v692nyQe3ikrq0=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM=
github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4=
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
@@ -117,6 +126,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
@@ -133,6 +144,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -159,6 +171,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -187,8 +201,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -204,8 +218,9 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -236,6 +251,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
@@ -270,3 +287,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+12 -2
View File
@@ -176,8 +176,7 @@ func (g *Client) ProbeOSInfo(host, domain, user, pwd string, timeout int64, rdpP
exitFlag := make(chan bool, 1)
info = make(map[string]any)
targetSlice := strings.Split(g.Host, ":")
ip := targetSlice[0]
ip := rdpTargetHost(g.Host)
conn, err := WrapperTcpWithTimeout("tcp", g.Host, time.Duration(timeout)*time.Second)
if err != nil {
return
@@ -273,3 +272,14 @@ loop:
glog.Debug("loop ended, elapsed time: ", time.Since(start))
return info
}
func rdpTargetHost(target string) string {
host, _, err := net.SplitHostPort(target)
if err == nil {
return host
}
if strings.Count(target, ":") == 1 {
return strings.SplitN(target, ":", 2)[0]
}
return target
}
+24
View File
@@ -0,0 +1,24 @@
package login
import "testing"
func TestRDPTargetHost(t *testing.T) {
tests := []struct {
name string
target string
want string
}{
{name: "ipv4 with port", target: "192.168.1.1:3389", want: "192.168.1.1"},
{name: "hostname with port", target: "rdp.example.com:3389", want: "rdp.example.com"},
{name: "bracketed ipv6 with port", target: "[2001:db8::1]:3389", want: "2001:db8::1"},
{name: "bare ipv6 without port", target: "2001:db8::1", want: "2001:db8::1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := rdpTargetHost(tt.target); got != tt.want {
t.Fatalf("rdpTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
}
})
}
}
+2 -10
View File
@@ -1,3 +1,5 @@
//go:build !web
package main
import (
@@ -10,7 +12,6 @@ import (
"github.com/shadow1ng/fscan/common/debug"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/core"
"github.com/shadow1ng/fscan/web"
// 导入统一插件系统
_ "github.com/shadow1ng/fscan/plugins/local"
@@ -33,15 +34,6 @@ func main() {
os.Exit(1)
}
// Web模式:启动Web服务器
if common.WebMode {
if err := web.StartServer(common.WebPort); err != nil {
common.LogError(err.Error())
os.Exit(1)
}
return
}
// 检查参数互斥性
if err := common.ValidateExclusiveParams(&info); err != nil {
common.LogError(i18n.Tr("error_generic", err))
+33
View File
@@ -0,0 +1,33 @@
//go:build web
package main
import (
"flag"
"fmt"
"os"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/web"
// 导入统一插件系统
_ "github.com/shadow1ng/fscan/plugins/local"
_ "github.com/shadow1ng/fscan/plugins/services"
_ "github.com/shadow1ng/fscan/plugins/web"
)
func main() {
port := flag.Int("port", 10240, "Web server listen port")
lang := flag.String("lang", "zh", "Language (zh/en)")
flag.Parse()
i18n.SetLanguage(*lang)
fmt.Printf("fscan web v%s\n", common.GetVersion())
if err := web.StartServer(*port); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
+7 -3
View File
@@ -135,21 +135,25 @@ func (r Result) DetailBool(key string) (bool, bool) {
// Port returns the result port from details, or from a target in host:port form.
func (r Result) Port() (int, bool) {
if port, ok := r.DetailInt("port"); ok {
return port, true
return port, validPort(port)
}
if _, portText, err := net.SplitHostPort(r.Target); err == nil {
port, err := strconv.Atoi(portText)
return port, err == nil
return port, err == nil && validPort(port)
}
if strings.Count(r.Target, ":") == 1 {
if idx := strings.LastIndex(r.Target, ":"); idx >= 0 && idx+1 < len(r.Target) {
port, err := strconv.Atoi(r.Target[idx+1:])
return port, err == nil
return port, err == nil && validPort(port)
}
}
return 0, false
}
func validPort(port int) bool {
return port >= 1 && port <= 65535
}
// Service returns the detected service name when present.
func (r Result) Service() (string, bool) { return r.DetailString("service") }
+15 -1
View File
@@ -76,6 +76,21 @@ func TestResultPortNoPort(t *testing.T) {
}
}
func TestResultPortRejectsOutOfRangePorts(t *testing.T) {
tests := []Result{
{Target: "10.0.0.1:70000"},
{Target: "[::1]:0"},
{Details: map[string]interface{}{"port": 70000}},
{Details: map[string]interface{}{"port": 0}},
}
for _, result := range tests {
if port, ok := result.Port(); ok {
t.Fatalf("Port(%#v) = %d/true, want false", result, port)
}
}
}
func TestResultCredentialHelpers(t *testing.T) {
result := Result{
Type: ResultTypeVuln,
@@ -715,4 +730,3 @@ func TestResultSummaryJSON(t *testing.T) {
t.Fatalf("round-trip failed: %#v", decoded)
}
}
+141
View File
@@ -1,6 +1,7 @@
package plugins
import (
"context"
"testing"
"github.com/shadow1ng/fscan/common"
@@ -23,6 +24,122 @@ init_test.go - 插件系统核心逻辑测试
// GenerateCredentials - 核心凭据生成逻辑
// =============================================================================
func preservePluginRegistry(t *testing.T) {
t.Helper()
mutex.RLock()
snapshot := make(map[string]*PluginInfo, len(plugins))
for name, info := range plugins {
copied := *info
copied.ports = append([]int(nil), info.ports...)
copied.types = append([]string(nil), info.types...)
snapshot[name] = &copied
}
mutex.RUnlock()
t.Cleanup(func() {
mutex.Lock()
plugins = snapshot
mutex.Unlock()
})
}
type testPlugin struct {
BasePlugin
}
func (p testPlugin) Scan(context.Context, *common.HostInfo, *common.ScanSession) *Result {
return &Result{Type: ResultTypeService, Success: true}
}
func TestPluginRegistryMetadata(t *testing.T) {
preservePluginRegistry(t)
RegisterWithPorts("unit_tcp", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_tcp")}
}, []int{1234, 5678})
RegisterUDPWithPorts("unit_udp", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_udp")}
}, []int{161})
RegisterWithTypes("unit_local", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_local")}
}, nil, []string{PluginTypeLocal})
RegisterUnsafeWithTypes("unit_unsafe_web", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_unsafe_web")}
}, nil, []string{PluginTypeWeb})
if !Exists("unit_tcp") || Exists("missing_plugin") {
t.Fatal("Exists returned wrong result")
}
if got := Get("unit_tcp"); got == nil || got.Name() != "unit_tcp" {
t.Fatalf("Get(unit_tcp) = %#v", got)
}
if got := Get("missing_plugin"); got != nil {
t.Fatalf("Get(missing_plugin) = %#v, want nil", got)
}
if !HasType("unit_tcp", PluginTypeService) || !HasType("unit_local", PluginTypeLocal) {
t.Fatal("registered plugin types were not recorded")
}
if !IsUDP("unit_udp") || IsUDP("unit_tcp") {
t.Fatal("UDP metadata is wrong")
}
if !IsSafe("unit_tcp") || IsSafe("unit_local") || IsSafe("unit_unsafe_web") || IsSafe("missing_plugin") {
t.Fatal("safe metadata is wrong")
}
ports := GetPluginPorts("unit_tcp")
if len(ports) != 2 || ports[0] != 1234 || ports[1] != 5678 {
t.Fatalf("ports = %#v", ports)
}
if got := GetPluginPorts("missing_plugin"); len(got) != 0 {
t.Fatalf("missing plugin ports = %#v, want empty", got)
}
if !hasPluginType([]string{PluginTypeWeb, PluginTypeLocal}, PluginTypeLocal) ||
hasPluginType([]string{PluginTypeWeb}, PluginTypeUDP) {
t.Fatal("hasPluginType returned wrong result")
}
names := All()
for _, want := range []string{"unit_tcp", "unit_udp", "unit_local", "unit_unsafe_web"} {
if !containsPluginName(names, want) {
t.Fatalf("All() missing %q in %#v", want, names)
}
}
}
func TestPluginLocalModeHook(t *testing.T) {
preservePluginRegistry(t)
RegisterWithTypes("unit_local_mode", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_local_mode")}
}, nil, []string{PluginTypeLocal})
RegisterWithPorts("unit_service_mode", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_service_mode")}
}, []int{22})
if common.IsLocalMode == nil {
t.Fatal("IsLocalMode hook should be installed")
}
if !common.IsLocalMode("unit_local_mode") {
t.Fatal("single local plugin should be local mode")
}
if !common.IsLocalMode("unit_local_mode, unit_local_mode") {
t.Fatal("local plugin list should be local mode")
}
if common.IsLocalMode("") || common.IsLocalMode("all") || common.IsLocalMode("unit_local_mode,unit_service_mode") {
t.Fatal("non-local modes should not be local mode")
}
}
func containsPluginName(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestGenerateCredentials_UserPassPairs_Priority(t *testing.T) {
/*
关键测试:UserPassPairs 应该优先于笛卡尔积
@@ -327,3 +444,27 @@ func TestGenerateCredentials_EmptyUserPassPairs(t *testing.T) {
t.Logf("✓ 空 UserPassPairs 正确回退到笛卡尔积")
}
func TestBuildConfigAdditionalPasswordsAreNotShadowedByExactPair(t *testing.T) {
cfg, _, err := common.BuildConfig(&common.FlagVars{
Username: "root",
Password: "primary",
AddPasswords: "extra",
}, &common.HostInfo{})
if err != nil {
t.Fatalf("BuildConfig error = %v", err)
}
result := GenerateCredentials("ssh", cfg)
found := map[string]bool{}
for _, cred := range result {
found[cred.Username+":"+cred.Password] = true
}
if !found["root:primary"] {
t.Fatal("missing primary password credential")
}
if !found["root:extra"] {
t.Fatal("additional password was shadowed by exact user/password pair")
}
}
+152
View File
@@ -0,0 +1,152 @@
//go:build linux && !no_local
package local
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/shadow1ng/fscan/common"
)
func TestLocalPluginConstructors(t *testing.T) {
tests := []struct {
name string
got Plugin
}{
{name: "cleaner", got: NewCleanerPlugin()},
{name: "crontask", got: NewCronTaskPlugin()},
{name: "forwardshell", got: NewForwardShellPlugin()},
{name: "keylogger", got: NewKeyloggerPlugin()},
{name: "ldpreload", got: NewLDPreloadPlugin()},
{name: "reverseshell", got: NewReverseShellPlugin()},
{name: "socks5proxy", got: NewSocks5ProxyPlugin()},
{name: "sshkey", got: NewSSHKeyPlugin()},
{name: "systemdservice", got: NewSystemdServicePlugin()},
{name: "systeminfo", got: NewSystemInfoPlugin()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got == nil {
t.Fatal("constructor returned nil")
}
if got := tt.got.Name(); got != tt.name {
t.Fatalf("Name() = %q, want %q", got, tt.name)
}
})
}
}
func TestCronTaskScriptDetectionAndJobs(t *testing.T) {
plugin := NewCronTaskPlugin()
for _, name := range []string{"agent.sh", "agent.bash", "agent.zsh"} {
plugin.targetFile = name
if !plugin.isScriptFile() {
t.Fatalf("%s should be treated as script", name)
}
}
plugin.targetFile = "agent.bin"
if plugin.isScriptFile() {
t.Fatal("binary target should not be treated as script")
}
plugin.targetFile = "agent.sh"
jobs := plugin.generateCronJobs("/tmp/agent.sh")
if len(jobs) != 4 {
t.Fatalf("job count = %d, want 4", len(jobs))
}
for _, job := range jobs {
if !strings.Contains(job, "bash /tmp/agent.sh >/dev/null 2>&1") {
t.Fatalf("script cron job missing bash wrapper: %q", job)
}
}
}
func TestLDPreloadValidFileDetection(t *testing.T) {
dir := t.TempDir()
plugin := NewLDPreloadPlugin()
soPath := filepath.Join(dir, "libhook.so")
if err := os.WriteFile(soPath, []byte("not actually elf"), 0600); err != nil {
t.Fatal(err)
}
if !plugin.isValidFile(soPath) {
t.Fatal(".so file should be accepted by extension")
}
elfPath := filepath.Join(dir, "payload.bin")
if err := os.WriteFile(elfPath, []byte{0x7f, 'E', 'L', 'F', 0x02}, 0600); err != nil {
t.Fatal(err)
}
if !plugin.isValidFile(elfPath) {
t.Fatal("ELF magic file should be accepted")
}
textPath := filepath.Join(dir, "payload.txt")
if err := os.WriteFile(textPath, []byte("plain text"), 0600); err != nil {
t.Fatal(err)
}
if plugin.isValidFile(textPath) {
t.Fatal("plain text file should not be accepted")
}
}
func TestKeyloggerBufferAndFileHelpers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "keys.log")
session := common.NewScanSession(common.NewConfig(), common.NewState(), &common.FlagVars{})
plugin := NewKeyloggerPlugin()
if err := plugin.checkOutputFilePermissions(path); err != nil {
t.Fatalf("checkOutputFilePermissions error = %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("output file was not created: %v", err)
}
if err := plugin.saveKeysToFile(path, session); err != nil {
t.Fatalf("save empty keys error = %v", err)
}
plugin.addKeyToBuffer("A")
plugin.addKeyToBuffer("B")
if len(plugin.keyBuffer) != 2 {
t.Fatalf("key buffer length = %d, want 2", len(plugin.keyBuffer))
}
if err := plugin.saveKeysToFile(path, session); err != nil {
t.Fatalf("save keys error = %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "A") || !strings.Contains(string(content), "B") {
t.Fatalf("saved key log missing entries: %q", content)
}
}
func TestShellUtilityHelpers(t *testing.T) {
prompt := NewForwardShellPlugin().getPrompt()
if !strings.HasSuffix(prompt, "$ ") && !strings.HasSuffix(prompt, "> ") && !strings.HasSuffix(prompt, "# ") {
t.Fatalf("unexpected prompt suffix: %q", prompt)
}
if dir := getCurrentDir(); dir == "" || dir == "unknown" {
t.Fatalf("getCurrentDir() = %q", dir)
}
pub, priv, err := NewSSHKeyPlugin().generateKeyPair()
if err != nil {
t.Fatalf("generateKeyPair error = %v", err)
}
if !strings.HasPrefix(pub, "ssh-ed25519 ") {
t.Fatalf("public key should be ssh-ed25519, got %q", pub)
}
if !strings.Contains(priv, "OPENSSH PRIVATE KEY") {
t.Fatal("private key should be OpenSSH PEM")
}
}
+47 -25
View File
@@ -160,21 +160,26 @@ func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Con
// handleSocks5Handshake 处理SOCKS5握手
func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error {
// 读取客户端握手请求
buffer := make([]byte, 256)
n, err := conn.Read(buffer)
if err != nil {
header := make([]byte, 2)
if _, err := io.ReadFull(conn, header); err != nil {
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err)
}
if n < 3 || buffer[0] != 0x05 { // SOCKS版本必须是5
if header[0] != 0x05 || header[1] == 0 {
return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version"))
}
methods := make([]byte, int(header[1]))
if _, err := io.ReadFull(conn, methods); err != nil {
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err)
}
if !containsByte(methods, 0x00) {
_, _ = conn.Write([]byte{0x05, 0xff})
return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version"))
}
// 发送握手响应(无认证)
response := []byte{0x05, 0x00} // 版本5,无认证
_, err = conn.Write(response)
if err != nil {
if _, err := conn.Write(response); err != nil {
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_write_failed"), err)
}
@@ -183,18 +188,16 @@ func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error {
// handleSocks5Request 处理SOCKS5连接请求
func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *common.ScanSession) (net.Conn, int, error) {
// 读取连接请求
buffer := make([]byte, 256)
n, err := clientConn.Read(buffer)
if err != nil {
header := make([]byte, 4)
if _, err := io.ReadFull(clientConn, header); err != nil {
return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_request_read_failed"), err)
}
if n < 7 || buffer[0] != 0x05 {
if header[0] != 0x05 || header[2] != 0x00 {
return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request"))
}
cmd := buffer[1]
cmd := header[1]
if cmd != 0x01 { // 只支持CONNECT命令
// 发送不支持的命令响应
response := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
@@ -203,40 +206,50 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co
}
// 解析目标地址
addrType := buffer[3]
addrType := header[3]
var targetHost string
var targetPort int
switch addrType {
case 0x01: // IPv4
if n < 10 {
addr := make([]byte, 6)
if _, err := io.ReadFull(clientConn, addr); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv4_address_invalid"))
}
targetHost = fmt.Sprintf("%d.%d.%d.%d", buffer[4], buffer[5], buffer[6], buffer[7])
targetPort = int(buffer[8])<<8 + int(buffer[9])
targetHost = fmt.Sprintf("%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3])
targetPort = int(addr[4])<<8 + int(addr[5])
case 0x03: // 域名
if n < 5 {
lenBuf := make([]byte, 1)
if _, err := io.ReadFull(clientConn, lenBuf); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_format_invalid"))
}
domainLen := int(buffer[4])
if n < 5+domainLen+2 {
domainLen := int(lenBuf[0])
if domainLen == 0 {
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid"))
}
targetHost = string(buffer[5 : 5+domainLen])
targetPort = int(buffer[5+domainLen])<<8 + int(buffer[5+domainLen+1])
addr := make([]byte, domainLen+2)
if _, err := io.ReadFull(clientConn, addr); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid"))
}
targetHost = string(addr[:domainLen])
targetPort = int(addr[domainLen])<<8 + int(addr[domainLen+1])
case 0x04: // IPv6
if n < 22 {
addr := make([]byte, 18)
if _, err := io.ReadFull(clientConn, addr); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv6_address_invalid"))
}
// IPv6地址解析(简化实现)
targetHost = net.IP(buffer[4:20]).String()
targetPort = int(buffer[20])<<8 + int(buffer[21])
targetHost = net.IP(addr[:16]).String()
targetPort = int(addr[16])<<8 + int(addr[17])
default:
// 发送不支持的地址类型响应
response := []byte{0x05, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
_, _ = clientConn.Write(response)
return nil, 0, fmt.Errorf(i18n.GetText("socks5_unsupported_address_type")+": %d", addrType)
}
if targetPort == 0 {
return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request"))
}
// 连接目标服务器
targetAddr := net.JoinHostPort(targetHost, strconv.Itoa(int(targetPort)))
@@ -276,6 +289,15 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co
return targetConn, localPort, nil
}
func containsByte(values []byte, target byte) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
// relayData 双向数据转发
func (p *Socks5ProxyPlugin) relayData(clientConn, targetConn net.Conn) {
done := make(chan struct{}, 2)
+94
View File
@@ -0,0 +1,94 @@
//go:build (plugin_socks5proxy || !plugin_selective) && !no_local
package local
import (
"bytes"
"io"
"net"
"testing"
"time"
)
type socksTestConn struct {
r bytes.Reader
w bytes.Buffer
}
func newSocksTestConn(data []byte) *socksTestConn {
return &socksTestConn{r: *bytes.NewReader(data)}
}
func (c *socksTestConn) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if err == io.EOF && n > 0 {
return n, nil
}
return n, err
}
func (c *socksTestConn) Write(p []byte) (int, error) { return c.w.Write(p) }
func (c *socksTestConn) Close() error { return nil }
func (c *socksTestConn) LocalAddr() net.Addr { return nil }
func (c *socksTestConn) RemoteAddr() net.Addr { return nil }
func (c *socksTestConn) SetDeadline(time.Time) error { return nil }
func (c *socksTestConn) SetReadDeadline(time.Time) error {
return nil
}
func (c *socksTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestSocks5HandshakeValidation(t *testing.T) {
p := NewSocks5ProxyPlugin()
t.Run("truncated methods", func(t *testing.T) {
conn := newSocksTestConn([]byte{0x05, 0x02, 0x00})
if err := p.handleSocks5Handshake(conn); err == nil {
t.Fatal("handleSocks5Handshake() error = nil, want truncated method list error")
}
})
t.Run("no no-auth method", func(t *testing.T) {
conn := newSocksTestConn([]byte{0x05, 0x01, 0x02})
if err := p.handleSocks5Handshake(conn); err == nil {
t.Fatal("handleSocks5Handshake() error = nil, want unsupported method error")
}
if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0xff}) {
t.Fatalf("handshake response = % x, want 05 ff", got)
}
})
t.Run("accepts no-auth", func(t *testing.T) {
conn := newSocksTestConn([]byte{0x05, 0x02, 0x02, 0x00})
if err := p.handleSocks5Handshake(conn); err != nil {
t.Fatalf("handleSocks5Handshake() error = %v", err)
}
if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0x00}) {
t.Fatalf("handshake response = % x, want 05 00", got)
}
})
}
func TestSocks5RequestRejectsMalformedInputBeforeDial(t *testing.T) {
p := NewSocks5ProxyPlugin()
tests := []struct {
name string
req []byte
}{
{name: "bad reserved byte", req: []byte{0x05, 0x01, 0x01, 0x01}},
{name: "empty domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x00}},
{name: "truncated domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x04, 't', 'e'}},
{name: "zero ipv4 port", req: []byte{0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 0}},
{name: "truncated ipv6", req: []byte{0x05, 0x01, 0x00, 0x04, 0x20, 0x01}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, _, err := p.handleSocks5Request(newSocksTestConn(tt.req), nil); err == nil {
t.Fatal("handleSocks5Request() error = nil, want malformed request error")
}
})
}
}
+11
View File
@@ -0,0 +1,11 @@
package local
import (
"fmt"
"net"
"strconv"
)
func ldapURL(host string, port int) string {
return fmt.Sprintf("ldap://%s", net.JoinHostPort(host, strconv.Itoa(port)))
}
+24
View File
@@ -0,0 +1,24 @@
package local
import "testing"
func TestLDAPURLUsesJoinHostPort(t *testing.T) {
tests := []struct {
name string
host string
port int
want string
}{
{name: "hostname", host: "dc.example.local", port: 389, want: "ldap://dc.example.local:389"},
{name: "ipv4", host: "192.168.1.10", port: 389, want: "ldap://192.168.1.10:389"},
{name: "ipv6", host: "2001:db8::10", port: 389, want: "ldap://[2001:db8::10]:389"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ldapURL(tt.host, tt.port); got != tt.want {
t.Fatalf("ldapURL(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want)
}
})
}
}
+2 -2
View File
@@ -82,10 +82,10 @@ func (p *SystemInfoPlugin) connectToDomain(domain string) (*domainInfo, error) {
}
defer func() { _ = client.Close() }()
conn, err := ldap.DialURL(fmt.Sprintf("ldap://%s:389", dcHost))
conn, err := ldap.DialURL(ldapURL(dcHost, 389))
if err != nil {
if ipv4, resolveErr := resolveIPv4(dcHost); resolveErr == nil {
conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", ipv4))
conn, err = ldap.DialURL(ldapURL(ipv4, 389))
}
if err != nil {
return nil, fmt.Errorf("LDAP dial: %w", err)
+6 -3
View File
@@ -72,7 +72,7 @@ func (p *ActiveMQPlugin) createAuthFunc(info *common.HostInfo, session *common.S
func (p *ActiveMQPlugin) doActiveMQAuth(ctx context.Context, info *common.HostInfo, cred Credential, session *common.ScanSession) *AuthResult {
target := info.Target()
config := session.Config
timeout := config.Timeout
timeout := config.ModuleTimeout()
resultChan := make(chan *AuthResult, 1)
@@ -157,7 +157,10 @@ func classifyActiveMQErrorType(err error) ErrorType {
// authenticateSTOMP 使用STOMP协议认证ActiveMQ
func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password string, config *common.Config) (bool, error) {
timeout := config.Timeout
timeout := config.ModuleTimeout()
if err := rejectLineBreaks(username, password); err != nil {
return false, err
}
stompConnect := fmt.Sprintf("CONNECT\naccept-version:1.0,1.1,1.2\nhost:/\nlogin:%s\npasscode:%s\n\n\x00",
username, password)
@@ -199,7 +202,7 @@ func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password str
// identifyService ActiveMQ服务识别
func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
target := info.Target()
timeout := session.Config.Timeout
timeout := session.Config.ModuleTimeout()
conn, err := session.DialTCP(ctx, "tcp", target, timeout)
if err != nil {
+4 -15
View File
@@ -22,29 +22,18 @@ func NewBACnetPlugin() *BACnetPlugin {
}
func (p *BACnetPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
timeout := session.Config.Timeout
timeout := session.Config.ModuleTimeout()
if timeout <= 0 {
timeout = 3 * time.Second
}
target := info.Target()
conn, err := session.DialUDP(ctx, target, timeout)
if err != nil {
return &ScanResult{Success: false, Service: "bacnet"}
}
defer conn.Close()
if _, err := conn.Write(bacnetWhoIs); err != nil {
data, n := udpProbe(ctx, session, target, timeout, bacnetWhoIs, 1476)
if data == nil {
return &ScanResult{Success: false, Service: "bacnet"}
}
buf := make([]byte, 1476)
n, err := conn.Read(buf)
if err != nil {
return &ScanResult{Success: false, Service: "bacnet"}
}
banner, ok := parseBACnetResponse(buf[:n])
banner, ok := parseBACnetResponse(data[:n])
if !ok {
return &ScanResult{Success: false, Service: "bacnet"}
}
+37 -17
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net"
"sync/atomic"
"time"
"github.com/shadow1ng/fscan/common"
@@ -77,15 +78,17 @@ const (
cqlOpStartup = 0x01
cqlOpAuthRsp = 0x0f
cqlOpQuery = 0x07
cqlOpResult = 0x08
cqlOpReady = 0x02
cqlOpAuthOk = 0x10
cqlOpAuthChl = 0x0e
cqlOpError = 0x00
maxCQLFrameBody = 1024 * 1024
)
func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
addr := info.Target()
timeout := config.Timeout
timeout := config.ModuleTimeout()
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
@@ -156,8 +159,9 @@ func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.Host
state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
_ = body
_ = opcode
if err := validateCQLQueryResponse(opcode, body); err != nil {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err}
}
state.IncrementTCPSuccessPacketCount()
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
@@ -165,20 +169,19 @@ func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.Host
// ── CQL wire protocol 工具 ──────────────────────────────────────
var cqlStreamID int16
var cqlStreamID uint32
func nextCQLStreamID() uint16 {
return uint16((atomic.AddUint32(&cqlStreamID, 1) - 1) & 0x7fff)
}
func cqlSend(conn net.Conn, opcode byte, body []byte) error {
id := cqlStreamID
if cqlStreamID == 32767 {
cqlStreamID = 0
} else {
cqlStreamID++
}
id := nextCQLStreamID()
// frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body]
header := make([]byte, 8)
header[0] = cqlVersion
binary.BigEndian.PutUint16(header[1:3], uint16(id))
binary.BigEndian.PutUint16(header[1:3], id)
header[3] = opcode
binary.BigEndian.PutUint32(header[4:8], uint32(len(body)))
@@ -187,7 +190,7 @@ func cqlSend(conn net.Conn, opcode byte, body []byte) error {
return err
}
func cqlRecv(conn net.Conn) (byte, []byte, error) {
func cqlRecv(conn io.Reader) (byte, []byte, error) {
// 读取 9 字节头部(响应也有额外标志字节)
header := make([]byte, 9)
if _, err := io.ReadFull(conn, header); err != nil {
@@ -195,8 +198,11 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) {
}
opcode := header[4]
bodyLen := int(binary.BigEndian.Uint32(header[5:9]))
if bodyLen <= 0 || bodyLen > 1024*1024 {
return opcode, nil, nil
if bodyLen == 0 {
return opcode, []byte{}, nil
}
if bodyLen > maxCQLFrameBody {
return opcode, nil, fmt.Errorf("cassandra frame too large: %d", bodyLen)
}
body := make([]byte, bodyLen)
if _, err := io.ReadFull(conn, body); err != nil {
@@ -205,6 +211,16 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) {
return opcode, body, nil
}
func validateCQLQueryResponse(opcode byte, body []byte) error {
if opcode == cqlOpError {
return fmt.Errorf("cassandra query failed: %s", string(body))
}
if opcode != cqlOpResult {
return fmt.Errorf("unexpected query opcode: %d", opcode)
}
return nil
}
// cqlStringMap CQL string map 编码: [2B count] [pairs: [2B len] [str]]
func cqlStringMap(m map[string]string) []byte {
var buf []byte
@@ -254,7 +270,7 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.
state := session.State
target := info.Target()
addr := info.Target()
timeout := config.Timeout
timeout := config.ModuleTimeout()
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
@@ -280,10 +296,14 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.
if err := cqlSend(conn, cqlOpQuery, queryBody); err != nil {
return nil
}
_, body, err := cqlRecv(conn)
var body []byte
opcode, body, err = cqlRecv(conn)
if err != nil {
return nil
}
if err := validateCQLQueryResponse(opcode, body); err != nil {
return nil
}
state.IncrementTCPSuccessPacketCount()
dummy := extractClusterName(body)
@@ -302,7 +322,7 @@ func (p *CassandraPlugin) identifyService(ctx context.Context, info *common.Host
state := session.State
target := info.Target()
addr := info.Target()
timeout := config.Timeout
timeout := config.ModuleTimeout()
dialer := net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
+49
View File
@@ -0,0 +1,49 @@
//go:build plugin_cassandra || !plugin_selective
package services
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func TestCQLRecvRejectsTooLargeFrame(t *testing.T) {
header := make([]byte, 9)
header[4] = cqlOpReady
binary.BigEndian.PutUint32(header[5:9], maxCQLFrameBody+1)
_, _, err := cqlRecv(bytes.NewReader(header))
if err == nil {
t.Fatal("cqlRecv() error = nil, want too-large frame error")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("cqlRecv() error = %v, want too large", err)
}
}
func TestCQLRecvAllowsEmptyBody(t *testing.T) {
header := make([]byte, 9)
header[4] = cqlOpReady
opcode, body, err := cqlRecv(bytes.NewReader(header))
if err != nil {
t.Fatalf("cqlRecv() error = %v", err)
}
if opcode != cqlOpReady || len(body) != 0 {
t.Fatalf("cqlRecv() opcode=%d body=%q, want ready empty body", opcode, body)
}
}
func TestValidateCQLQueryResponseRejectsErrors(t *testing.T) {
if err := validateCQLQueryResponse(cqlOpResult, []byte("rows")); err != nil {
t.Fatalf("validateCQLQueryResponse() error = %v", err)
}
if err := validateCQLQueryResponse(cqlOpError, []byte("permission denied")); err == nil {
t.Fatal("validateCQLQueryResponse() error = nil, want query error")
}
if err := validateCQLQueryResponse(cqlOpReady, nil); err == nil {
t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error")
}
}
+88 -19
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"sync"
"sync/atomic"
"time"
"github.com/shadow1ng/fscan/common"
@@ -37,7 +38,8 @@ type ErrorType int
const (
ErrorTypeAuth ErrorType = iota // 认证错误 - 密码错误,不重试
ErrorTypeNetwork // 网络错误 - 连接问题,可重试
ErrorTypeNetwork // 网络错误 - 连接不可达,可重试但计入连续失败
ErrorTypeThrottle // 限流错误 - 服务端拒绝连接(MaxStartups等),退避后重试,不计入连续失败
ErrorTypeUnknown // 未知错误
)
@@ -61,6 +63,12 @@ type AuthFunc func(ctx context.Context, cred Credential) *AuthResult
// ErrorClassifier 错误分类函数
type ErrorClassifier func(err error) ErrorType
var authCleanupWaitNanos int64 = int64(2 * time.Second)
func authCleanupWait() time.Duration {
return time.Duration(atomic.LoadInt64(&authCleanupWaitNanos))
}
// =============================================================================
// 单凭据测试(解决 goroutine 泄漏)
// =============================================================================
@@ -68,9 +76,36 @@ type ErrorClassifier func(err error) ErrorType
// TestSingleCredential 安全地测试单个凭据
// 正确处理 context 取消时的资源清理
func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc) *AuthResult {
if ctx == nil {
ctx = context.Background()
}
if authFn == nil {
return &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("auth function is nil"),
}
}
if err := ctx.Err(); err != nil {
return &AuthResult{
Success: false,
ErrorType: ErrorTypeNetwork,
Error: err,
}
}
resultChan := make(chan *AuthResult, 1)
go func() {
defer func() {
if r := recover(); r != nil {
resultChan <- &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("auth function panic: %v", r),
}
}
}()
result := authFn(ctx, cred)
resultChan <- result
}()
@@ -79,18 +114,17 @@ func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc)
case result := <-resultChan:
return result
case <-ctx.Done():
// context 被取消,但 authFn goroutine 可能还阻塞在第三方库 IO 上
// 限时等待:超过 5 秒直接放弃,避免 goroutine 无限泄漏
// context 被取消后只做有界等待,避免 authFn 卡死时清理 goroutine 也永久泄漏。
go func() {
timer := time.NewTimer(5 * time.Second)
timer := time.NewTimer(authCleanupWait())
defer timer.Stop()
select {
case result := <-resultChan:
if result != nil && result.Conn != nil {
_ = result.Conn.Close()
}
case <-timer.C:
// 第三方库不响应取消,放弃等待
}
}()
return &AuthResult{
@@ -115,15 +149,35 @@ type ConcurrentTestConfig struct {
UseProxy bool // 代理模式下跳过直连 TCP 预检
}
func normalizeConcurrentTestConfig(testConfig ConcurrentTestConfig) ConcurrentTestConfig {
if testConfig.Concurrency <= 0 {
testConfig.Concurrency = 10
}
if testConfig.MaxRetries <= 0 {
testConfig.MaxRetries = 3
}
if testConfig.RetryDelay <= 0 {
testConfig.RetryDelay = time.Second
}
if testConfig.MaxConsecutiveNetErrors <= 0 {
testConfig.MaxConsecutiveNetErrors = 5
}
return testConfig
}
// DefaultConcurrentTestConfig 默认配置
func DefaultConcurrentTestConfig(config *common.Config) ConcurrentTestConfig {
concurrency := config.ModuleThreadNum
if concurrency <= 0 {
concurrency = 10
}
maxRetries := config.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
return ConcurrentTestConfig{
Concurrency: concurrency,
MaxRetries: 3,
MaxRetries: maxRetries,
RetryDelay: time.Second,
MaxConsecutiveNetErrors: 5,
UseProxy: config.Network.Socks5Proxy != "" || config.Network.HTTPProxy != "",
@@ -146,6 +200,9 @@ func TestCredentialsConcurrently(
serviceName string,
testConfig ConcurrentTestConfig,
) *ScanResult {
if ctx == nil {
ctx = context.Background()
}
if len(credentials) == 0 {
return &ScanResult{
Success: false,
@@ -153,11 +210,16 @@ func TestCredentialsConcurrently(
Error: fmt.Errorf("%s", i18n.GetText("service_no_test_creds")),
}
}
testConfig = normalizeConcurrentTestConfig(testConfig)
// TCP 预检:快速验证目标可达,避免对不可达目标浪费全部凭据尝试
// 代理模式下跳过:net.DialTimeout 直连无法到达代理后的内网目标
if testConfig.TargetAddr != "" && !testConfig.UseProxy {
preConn, err := net.DialTimeout("tcp", testConfig.TargetAddr, 3*time.Second)
dialCtx, dialCancel := context.WithTimeout(ctx, 3*time.Second)
defer dialCancel()
var dialer net.Dialer
preConn, err := dialer.DialContext(dialCtx, "tcp", testConfig.TargetAddr)
if err != nil {
return &ScanResult{
Success: false,
@@ -239,10 +301,6 @@ func workerTestCredentials(
testConfig ConcurrentTestConfig,
) {
consecutiveNetErrors := 0
maxNetErrors := testConfig.MaxConsecutiveNetErrors
if maxNetErrors <= 0 {
maxNetErrors = 5
}
for cred := range credChan {
// 检查是否应该停止
@@ -253,7 +311,7 @@ func workerTestCredentials(
}
// 连续网络错误达到阈值,目标可能不可达,提前退出
if consecutiveNetErrors >= maxNetErrors {
if consecutiveNetErrors >= testConfig.MaxConsecutiveNetErrors {
return
}
@@ -264,10 +322,13 @@ func workerTestCredentials(
return
}
// 跟踪连续网络错误
if errType == ErrorTypeNetwork {
// 跟踪连续网络错误(限流错误不计入,只做短暂退避)
switch errType {
case ErrorTypeNetwork:
consecutiveNetErrors++
} else {
case ErrorTypeThrottle:
time.Sleep(500 * time.Millisecond)
default:
consecutiveNetErrors = 0
}
}
@@ -291,10 +352,18 @@ func testCredentialWithRetry(
// 测试凭据
result := TestSingleCredential(ctx, cred, authFn)
if result == nil {
result = &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("auth function returned nil result"),
}
}
if result.Success && result.Conn != nil {
// 成功,关闭连接并返回
if result.Success {
if result.Conn != nil {
_ = result.Conn.Close()
}
return &ScanResult{
Type: plugins.ResultTypeCredential,
Success: true,
@@ -309,8 +378,8 @@ func testCredentialWithRetry(
case ErrorTypeAuth:
// 认证错误(密码错误),不重试
return nil, result.ErrorType
case ErrorTypeNetwork, ErrorTypeUnknown:
// 网络错误或未知错误,可以重试(可能是服务端限流等临时问题)
case ErrorTypeNetwork, ErrorTypeThrottle, ErrorTypeUnknown:
// 网络/限流/未知错误,可以重试
if attempt < testConfig.MaxRetries-1 {
timer := time.NewTimer(testConfig.RetryDelay)
select {
+249
View File
@@ -4,9 +4,12 @@ import (
"context"
"errors"
"io"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
)
/*
@@ -186,6 +189,84 @@ func TestMatchIgnoreCase(t *testing.T) {
// 并发测试
// =============================================================================
func setAuthCleanupWaitForTest(wait time.Duration) func() {
oldWait := atomic.LoadInt64(&authCleanupWaitNanos)
atomic.StoreInt64(&authCleanupWaitNanos, int64(wait))
return func() { atomic.StoreInt64(&authCleanupWaitNanos, oldWait) }
}
func TestDefaultConcurrentTestConfigUsesConfigRetries(t *testing.T) {
cfg := DefaultConcurrentTestConfig(&common.Config{
ModuleThreadNum: 7,
MaxRetries: 5,
})
if cfg.Concurrency != 7 {
t.Fatalf("Concurrency = %d, want 7", cfg.Concurrency)
}
if cfg.MaxRetries != 5 {
t.Fatalf("MaxRetries = %d, want config MaxRetries 5", cfg.MaxRetries)
}
}
func TestDefaultConcurrentTestConfigRetriesFallback(t *testing.T) {
cfg := DefaultConcurrentTestConfig(&common.Config{
ModuleThreadNum: 0,
MaxRetries: 0,
})
if cfg.Concurrency != 10 {
t.Fatalf("Concurrency = %d, want fallback 10", cfg.Concurrency)
}
if cfg.MaxRetries != 3 {
t.Fatalf("MaxRetries = %d, want fallback 3", cfg.MaxRetries)
}
}
func TestTestCredentialsConcurrently_ZeroValueConfigStillRuns(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{Success: true}
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "u", Password: "p"}}, authFn, "test", ConcurrentTestConfig{})
if !result.Success {
t.Fatalf("zero-value config should still test credentials: %v", result.Error)
}
if calls.Load() != 1 {
t.Fatalf("authFn calls = %d, want 1", calls.Load())
}
}
func TestTestCredentialsConcurrently_PrecheckHonorsCanceledContext(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{Success: false}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
result := TestCredentialsConcurrently(ctx, []Credential{{Username: "u", Password: "p"}}, authFn, "test", ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 1,
TargetAddr: "203.0.113.1:65000",
})
if result.Success {
t.Fatal("canceled context should not return success")
}
if calls.Load() != 0 {
t.Fatalf("authFn calls = %d, want 0 when precheck context is canceled", calls.Load())
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("precheck ignored canceled context, elapsed=%v", elapsed)
}
}
// mockConn 模拟连接
type mockConn struct {
closed atomic.Bool
@@ -335,6 +416,60 @@ func TestTestCredentialsConcurrently_ContextCancel(t *testing.T) {
}
}
func TestTestCredentialsConcurrently_CancelWithStuckAuthReturnsPromptly(t *testing.T) {
defer setAuthCleanupWaitForTest(20 * time.Millisecond)()
credentials := make([]Credential, 10)
for i := range credentials {
credentials[i] = Credential{Username: "user", Password: "pass"}
}
authStarted := make(chan struct{}, len(credentials))
releaseAuth := make(chan struct{})
authFn := func(ctx context.Context, cred Credential) *AuthResult {
authStarted <- struct{}{}
<-releaseAuth
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork}
}
config := ConcurrentTestConfig{
Concurrency: 3,
MaxRetries: 1,
RetryDelay: time.Millisecond,
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan *ScanResult, 1)
go func() {
done <- TestCredentialsConcurrently(ctx, credentials, authFn, "test", config)
}()
for i := 0; i < config.Concurrency; i++ {
select {
case <-authStarted:
case <-time.After(time.Second):
close(releaseAuth)
t.Fatalf("authFn started %d workers, want %d", i, config.Concurrency)
}
}
start := time.Now()
cancel()
select {
case result := <-done:
close(releaseAuth)
if result.Success {
t.Fatal("context取消后不应该返回成功")
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("取消后返回过慢: %v", elapsed)
}
case <-time.After(time.Second):
close(releaseAuth)
t.Fatal("authFn 卡住时并发测试没有及时返回")
}
}
// =============================================================================
// 单凭据测试
// =============================================================================
@@ -360,6 +495,49 @@ func TestTestSingleCredential_Success(t *testing.T) {
}
}
func TestTestSingleCredential_CanceledContextSkipsAuth(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{Success: true}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
result := TestSingleCredential(ctx, Credential{Username: "admin", Password: "admin"}, authFn)
if result.Success {
t.Fatal("canceled context should not return success")
}
if calls.Load() != 0 {
t.Fatalf("authFn calls = %d, want 0", calls.Load())
}
}
func TestTestSingleCredential_NilAuthFunc(t *testing.T) {
result := TestSingleCredential(context.Background(), Credential{Username: "admin", Password: "admin"}, nil)
if result.Success {
t.Fatal("nil authFn should not return success")
}
if result.Error == nil {
t.Fatal("nil authFn should return an error")
}
}
func TestTestSingleCredential_RecoverAuthPanic(t *testing.T) {
authFn := func(ctx context.Context, cred Credential) *AuthResult {
panic("boom")
}
result := TestSingleCredential(context.Background(), Credential{Username: "admin", Password: "admin"}, authFn)
if result.Success {
t.Fatal("panic authFn should not return success")
}
if result.Error == nil {
t.Fatal("panic authFn should return an error")
}
}
// TestTestSingleCredential_ContextCancel 测试context取消时的资源清理
func TestTestSingleCredential_ContextCancel(t *testing.T) {
conn := &mockConn{}
@@ -401,6 +579,38 @@ func TestTestSingleCredential_ContextCancel(t *testing.T) {
}
}
func TestTestSingleCredential_ContextCancelCleanupIsBounded(t *testing.T) {
defer setAuthCleanupWaitForTest(20 * time.Millisecond)()
authStarted := make(chan struct{})
releaseAuth := make(chan struct{})
authFn := func(ctx context.Context, cred Credential) *AuthResult {
close(authStarted)
<-releaseAuth
return nil
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-authStarted
cancel()
}()
before := runtime.NumGoroutine()
result := TestSingleCredential(ctx, Credential{Username: "admin", Password: "admin"}, authFn)
if result.Success {
t.Error("context取消后不应该返回成功")
}
time.Sleep(100 * time.Millisecond)
after := runtime.NumGoroutine()
close(releaseAuth)
if after > before+1 {
t.Fatalf("清理 goroutine 疑似泄漏: before=%d after=%d", before, after)
}
}
// =============================================================================
// 重试逻辑测试
// =============================================================================
@@ -445,6 +655,45 @@ func TestRetryLogic_NetworkErrorRetries(t *testing.T) {
}
}
func TestRetryLogic_SuccessWithoutConn(t *testing.T) {
var attempts atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
attempts.Add(1)
return &AuthResult{Success: true}
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "admin", Password: "admin"}}, authFn, "test", ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 3,
})
if !result.Success {
t.Fatalf("success result without Conn should be accepted: %v", result.Error)
}
if attempts.Load() != 1 {
t.Fatalf("attempts = %d, want 1", attempts.Load())
}
}
func TestRetryLogic_NilAuthResultDoesNotPanic(t *testing.T) {
var attempts atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
attempts.Add(1)
return nil
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "admin", Password: "admin"}}, authFn, "test", ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 2,
RetryDelay: time.Millisecond,
})
if result.Success {
t.Fatal("nil auth result should not return success")
}
if attempts.Load() != 2 {
t.Fatalf("attempts = %d, want 2", attempts.Load())
}
}
// TestRetryLogic_AuthErrorNoRetry 认证错误不应该重试
func TestRetryLogic_AuthErrorNoRetry(t *testing.T) {
var attempts atomic.Int32
+4 -15
View File
@@ -19,7 +19,7 @@ func NewDNSPlugin() *DNSPlugin {
}
func (p *DNSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
timeout := session.Config.Timeout
timeout := session.Config.ModuleTimeout()
if timeout <= 0 {
timeout = 3 * time.Second
}
@@ -28,23 +28,12 @@ func (p *DNSPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co
queryID := randomUint16()
query := buildDNSRootNSQuery(queryID)
conn, err := session.DialUDP(ctx, target, timeout)
if err != nil {
return &ScanResult{Success: false, Service: "dns"}
}
defer conn.Close()
if _, err := conn.Write(query); err != nil {
data, n := udpProbe(ctx, session, target, timeout, query, 1500)
if data == nil || n < 12 {
return &ScanResult{Success: false, Service: "dns"}
}
buf := make([]byte, 1500)
n, err := conn.Read(buf)
if err != nil || n < 12 {
return &ScanResult{Success: false, Service: "dns"}
}
banner, ok := parseDNSResponse(buf[:n], queryID)
banner, ok := parseDNSResponse(data[:n], queryID)
if !ok {
return &ScanResult{Success: false, Service: "dns"}
}
+1 -1
View File
@@ -21,7 +21,7 @@ func NewDNSTCPPlugin() *DNSTCPPlugin {
}
func (p *DNSTCPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
timeout := session.Config.Timeout
timeout := session.Config.ModuleTimeout()
if timeout <= 0 {
timeout = 3 * time.Second
}
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net/http"
"github.com/shadow1ng/fscan/common"
@@ -77,7 +76,7 @@ func (p *ElasticsearchPlugin) Scan(ctx context.Context, info *common.HostInfo, s
func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.HostInfo, cred Credential, session *common.ScanSession) bool {
config := session.Config
client := &http.Client{
Timeout: config.Timeout,
Timeout: config.ModuleTimeout(),
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
@@ -107,7 +106,7 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 {
body, err := io.ReadAll(resp.Body)
body, err := readServiceHTTPBody(resp.Body)
if err != nil {
return false
}
+2 -2
View File
@@ -50,7 +50,7 @@ func (p *FindNetPlugin) Scan(ctx context.Context, info *common.HostInfo, session
}
}
conn, err := session.DialTCP(ctx, "tcp", target, config.Timeout)
conn, err := session.DialTCP(ctx, "tcp", target, config.ModuleTimeout())
if err != nil {
return &ScanResult{
Success: false,
@@ -61,7 +61,7 @@ func (p *FindNetPlugin) Scan(ctx context.Context, info *common.HostInfo, session
defer func() { _ = conn.Close() }()
// 设置超时
_ = conn.SetDeadline(time.Now().Add(config.Timeout))
_ = conn.SetDeadline(time.Now().Add(config.ModuleTimeout()))
// 执行RPC网络发现
networkInfo, err := p.performNetworkDiscovery(conn)
+17 -7
View File
@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"strings"
"time"
ftplib "github.com/jlaffaye/ftp"
"github.com/shadow1ng/fscan/common"
@@ -80,7 +81,7 @@ func (p *FTPPlugin) createAuthFunc(info *common.HostInfo, config *common.Config,
func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
conn, err := ftplib.Dial(target, ftpDialOptions(ctx, config.ModuleTimeout())...)
if err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{
@@ -91,6 +92,11 @@ func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred C
}
state.IncrementTCPSuccessPacketCount()
stopCancelClose := context.AfterFunc(ctx, func() {
_ = conn.Quit()
})
defer stopCancelClose()
err = conn.Login(cred.Username, cred.Password)
if err != nil {
_ = conn.Quit()
@@ -118,6 +124,13 @@ func (w *ftpConnWrapper) Close() error {
return w.Quit()
}
func ftpDialOptions(ctx context.Context, timeout time.Duration) []ftplib.DialOption {
return []ftplib.DialOption{
ftplib.DialWithTimeout(timeout),
ftplib.DialWithContext(ctx),
}
}
// classifyFTPErrorType FTP错误分类
func classifyFTPErrorType(err error) ErrorType {
if err == nil {
@@ -149,7 +162,7 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, session *common.ScanS
state := session.State
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.ModuleTimeout()))
if err != nil {
state.IncrementTCPFailedPacketCount()
return &ScanResult{
@@ -228,7 +241,7 @@ func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostIn
func (p *FTPPlugin) getFileListAfterAuth(info *common.HostInfo, username, password string, config *common.Config, state *common.State) []string {
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.ModuleTimeout()))
if err != nil {
return nil
}
@@ -260,10 +273,7 @@ func (p *FTPPlugin) listFTPFiles(conn *ftplib.ServerConn) []string {
}
fileName := entry.Name
if len(fileName) > 50 {
fileName = fileName[:50] + "..."
}
files = append(files, fileName)
files = append(files, truncateRunes(fileName, 50))
}
return files
+9
View File
@@ -0,0 +1,9 @@
package services
import "io"
const maxServiceHTTPBodyBytes = 2 << 20
func readServiceHTTPBody(r io.Reader) ([]byte, error) {
return io.ReadAll(io.LimitReader(r, maxServiceHTTPBodyBytes))
}

Some files were not shown because too many files have changed in this diff Show More