v2.1.3 Release (#572)

* add CVE-2026-24061 detect logic  (#562)

* add CVE-2026-24061 detect logic

* fix(telnet): 修复 errcheck 警告,统一错误处理风格

---------

Co-authored-by: ZacharyZcR <[email protected]>

* fix: 修复 Hub 广播 data race 和端口扫描潜在死锁,清理死代码

- hub.go: broadcast 路径 RLock 改 Lock,修复并发 delete/close 竞争
- port_scan.go: pool.Invoke 失败时释放 wg 和 semaphore,防止死锁
- web_scanner.go: 删除只写不读的 fingerprintCache
- webtitle.go: 移除对已删除 SetFingerprints 的调用
- keylogger.go: 删除未使用的 stopChan 和 isRunning 字段

* refactor: context 穿透扫描生命周期,修复长驻插件阻塞和 Web Stop 无效

- RunScan 接受 context.Context,创建可取消上下文并穿透到所有策略和插件
- 长驻插件(forwardshell/socks5proxy/reverseshell)不再进入 scan WaitGroup,
  通过 ctx.Done() 管理生命周期,解除 wg.Wait() 死锁
- Web Stop API 从 stopChan 改为 context.CancelFunc,取消信号真正传播到扫描链路
- ExecuteScanTasks 和 executeScanTask 支持 context 取消检查,停止分发新任务
- CLI 模式传 context.Background(),行为完全不变

* fix: 修复 Web Stop 信号等待阻塞和 SMB 响应解析越界 panic

- scanner.go: 长驻插件等待信号时同时监听 ctx.Done(),Web Stop 可正常返回
- smb_protocol.go: 响应长度检查修正为 47,远端偏移量全部做边界校验

* fix: POC 扫描接入调用方 context,修复 cachedPocPath 竞争和 ProxyStats data race

- webscan/web_scan.go: WebScan 接受 ctx 参数,替换 context.Background();
  sync.Once 改为 sync.Mutex 保护 POC 加载,消除 cachedPocPath 并发写竞争
- webtitle.go: ctx 从 Scan 穿透到 identifyFingerprintsMulti → triggerPocScan → WebScan
- webpoc.go: 传递 ctx 到 WebScan
- proxy/types.go: ProxyStats 增加 sync.Mutex
- proxy/manager.go: LastConnectTime/LastError/AverageConnectTime 读写加锁

* fix: 修复 ProxyStats 含 mutex 导致的 copylocks 告警

Stats() 方法改为手动构造副本,避免值拷贝复制 sync.Mutex

* fix: 补全 HTTP/TLS proxy stats 加锁,修复 RPC/SMB 解析越界和 POC 加载逻辑

- httpdialer.go/tlsdialer.go: LastError/LastConnectTime/AverageConnectTime 加 mutex
- findnet.go: RPC 响应结束标记位置 < 4 时跳过截断,防止负数切片 panic
- ms17010.go: SMB 会话响应最小长度改为 45,sessionSetupResponse 加长度校验
- web_scan.go: POC 加载失败时不标记 pocLoaded,允许后续重试
- Eval.go: DNSLog 配置去掉 sync.Once,允许多次扫描更新配置

* fix: Web 全局状态同步、字典文件错误提示、长驻插件连接可取消

- scan.go: Web API 构建 config/state 后同步到全局实例
- config_builder.go: 用户名/密码/URL 文件读取失败时输出错误日志
- reverseshell.go: 读命令设 1s 超时,超时后检查 ctx 实现可取消
- forwardshell.go: handleClient 接受 ctx,取消时关闭连接解除阻塞
- socks5proxy.go: handleClient 接受 ctx,取消时关闭连接解除 IO 阻塞

* refactor: 引入 ScanSession,替代全局状态穿透扫描管道 (Phase 1-3)

- 新增 common/session.go: ScanSession 结构体封装 Config/State/Params/Dialer
- RunScan/Strategy/ExecuteScanTasks/executeScanTask 全部接收 session
- Plugin 接口从 Scan(ctx, info, config, state) 改为 Scan(ctx, info, session)
- 48 个插件实现统一更新签名
- Web API 构建 ScanSession 传给 RunScan
- CLI 模式通过 Initialize() 创建 session

* refactor: 全量替换 WrapperTcpWithTimeout 为 session.DialTCP (Phase 4)

- core/port_scan.go: EnhancedPortScan/connectWithRetry/scanSinglePort 接入 session
- core/service_probe.go: SmartPortInfoScanner 持有 session,重连走 session.DialTCP
- core/icmp.go: CheckLive/tcpProbeAlive 接入 session
- 17 个 service 插件: 内部 helper 函数全部穿透 ctx+session
- 移除插件中冗余的手动 TCP 计数(DialTCP 内部已处理)
- plugins/core 下已无 WrapperTcpWithTimeout/SafeTCPDial 调用残留

* refactor: 清除 core/plugins 全局状态依赖,ProgressManager 缓存引用 (Phase 5)

- core/alive_scanner.go: GetFlagVars() → session.Params
- core/service_scanner.go: GetFlagVars() → session.Params 和 config.Target.Ports
- common/progress_manager.go: 缓存 State 和 NoColor 到字段,不再运行时读全局
- common/output_api.go: SaveResult 改用 GetGlobalConfig().Output.DisableSave
- common/network.go: WrapperTcpWithTimeout 标记 Deprecated
- core/ 和 plugins/ 下已无全局状态调用残留

* fix: 修复 dialer timeout 锁死、CVE 检测绕过 session 和误报问题

* fix: 修复 pocDNSLog data race,穿透 ctx 到全链路,消除残余 net.DialTimeout 绕过

* perf: CVE-2026-24061 检测改并发执行,消除硬 sleep 用 deadline 替代

* feat: 项目缓存系统,跨扫描合并资产,缓存 host:port 避免漏报

* perf: 三阶段性能优化,ICMP 并发提升+TCP 并行探测,端口扫描退避调整,服务探测超时减半

* fix: 修复凭据测试清理 goroutine 无限阻塞导致的 goroutine 泄漏

* fix: 凭据测试连续网络错误短路、resultChan 缓冲防阻塞、timer 泄漏修复

* perf: 大规模扫描网段预筛,按 /24 探活跳过空子网,B 段扫描从 2h+ 降至 2min

* fix: 网段预筛从抽样改全覆盖,每台主机发 1 个探测包,消除漏报

* perf: 网段预筛增加网关启发式,.1/.254 多端口优先探测,命中即跳过逐主机兜底

* fix: MSSQL 连接加 encrypt=disable 修复无 TLS 环境扫描失败,Web API 参数校验负数

* feat: Release 增加 armv5 架构支持

* chore: bump version to 2.1.3

* fix: 锁定 golangci-lint 版本为 v2.12.1 修复 CI checksum 校验失败

* fix: golangci-lint 改用 go install 安装,绕过上游安装脚本 checksum 校验问题

* feat: -silent 模式输出 NDJSON 到 stdout,支持 AI agent 管道消费

- 新增 StdoutNDJSONWriter,silent 模式下每条扫描结果实时输出一行 JSON
- LogWithProgress 层拦截人类可读日志,绕过 logger sync.Once 初始化时序问题
- 支持 fscan -h xxx -silent | jq 管道用法

* fix rdp invalid random panic (#573)

* restore ms17010 legacy detection and exploit (#574)

* fix ms17010 legacy packet decoding (#574)

* fix csv web title output (#575)

* fix web result protocol output (#577)

* feat: add -ntp flag to disable TCP supplementary probe

* fix: skip TCP supplementary probe in icmp mode

* feat: add -debug flag with file logging to fscan_debug.log

* fix: resolve golangci-lint errcheck and staticcheck warnings

* fix: skip proxy deep verification for SOCKS5 connections (#579)

SOCKS5 protocol validates connection reachability at protocol level,
deep verification was incorrectly rejecting non-banner services like
SMB(445), RPC(139) and Kerberos(88).

* fix: exclude timeout from scan failure rate calculation (#578)

Timeout is a normal scan result when firewalls drop packets, not a
scan failure. Only resource exhaustion errors count toward failure rate.

* feat: flatten NDJSON output for AI agent consumption and add SKILL.md

* perf: 端口扫描自适应超时,基于 RTT 采样动态调整连接超时

* perf: 四项扫描性能优化

- SO_LINGER=0 快速释放连接,减少 TIME_WAIT 堆积
- 服务探测超时自适应,RTT 采样约束读超时上限
- 端口扫描结果流式传递,pipeline 并行端口扫描和插件执行
- ICMP 批量预构建包和地址,减少发送循环开销

* perf: 六项性能优化

- DNS 解析缓存:sync.Map 缓存避免重复系统调用
- 凭据测试 TCP 预检:不可达目标直接跳过全部凭据
- Web 探测 HTTP Client 复用:全局共享连接池
- 端口扫描 Bloom Filter 去重:替代 map 降低内存
- 进度条 atomic 累加 + 50ms 节流渲染:消除锁竞争
- 服务探针预解码:Init 时预编译,运行时零解码开销

* refactor: replace bloom filter with map for deduplication

Bloom filter has false positive risk which can silently drop valid
scan results. Map provides exact deduplication with negligible memory
overhead at the scale of open ports (typically thousands, not millions).

* fix: credential TCP precheck bypass proxy and pipeline goroutine leak

- Skip TCP precheck when proxy is enabled, net.DialTimeout cannot
  reach targets behind SOCKS5/HTTP proxy
- Drain stream channel on ctx cancellation to prevent EnhancedPortScan
  goroutine from blocking on a full channel

* fix: stream channel 提前返回未关闭导致 goroutine 泄漏,服务探测超时下限 500ms

* fix: resolve golangci-lint errcheck and staticcheck warnings

---------

Co-authored-by: r00t <[email protected]>
This commit is contained in:
ZacharyZcR
2026-05-13 14:41:23 +08:00
committed by GitHub
co-authored by r00t
parent db0b53b139
commit 2c2ca6ace3
110 changed files with 4268 additions and 1057 deletions
+6
View File
@@ -100,6 +100,8 @@ func parseUsernames(fv *FlagVars) []string {
if fv.UsersFile != "" {
if lines, err := parsers.ReadLinesFromFile(fv.UsersFile); err == nil {
usernames = append(usernames, lines...)
} else {
LogError(fmt.Sprintf("读取用户名文件 %s 失败: %v", fv.UsersFile, err))
}
}
@@ -128,6 +130,8 @@ func parsePasswords(fv *FlagVars) []string {
if fv.PasswordsFile != "" {
if lines, err := parsers.ReadLinesFromFile(fv.PasswordsFile); err == nil {
passwords = append(passwords, lines...)
} else {
LogError(fmt.Sprintf("读取密码文件 %s 失败: %v", fv.PasswordsFile, err))
}
}
@@ -246,6 +250,8 @@ func parseURLs(fv *FlagVars) []string {
for _, line := range lines {
urls = append(urls, normalizeURL(line))
}
} else {
LogError(fmt.Sprintf("读取URL文件 %s 失败: %v", fv.URLsFile, err))
}
}
+2
View File
@@ -27,6 +27,7 @@ type Config struct {
ModuleThreadNum int // 模块线程数
DisableBrute bool // 禁用暴力破解
DisablePing bool // 禁用Ping检测
DisableTcpProbe bool // 禁用TCP补充探测
// 扫描模式
Mode string // 扫描模式
@@ -147,6 +148,7 @@ func NewConfig() *Config {
ModuleThreadNum: 10,
DisableBrute: false,
DisablePing: false,
DisableTcpProbe: false,
// 扫描模式
Mode: DefaultScanMode,
+28
View File
@@ -0,0 +1,28 @@
package common
import (
"net"
"sync"
)
// DNSCache 并发安全的 DNS 解析缓存
// 对纯 IP 输入零开销(直接返回),对域名避免重复系统调用
var DNSCache = &dnsCache{}
type dnsCache struct {
m sync.Map // host -> *net.IPAddr
}
// ResolveIP 解析 host 为 *net.IPAddr,结果缓存
func (c *dnsCache) ResolveIP(host string) (*net.IPAddr, error) {
if v, ok := c.m.Load(host); ok {
addr, _ := v.(*net.IPAddr)
return addr, nil
}
addr, err := net.ResolveIPAddr("ip", host)
if err != nil {
return nil, err
}
c.m.Store(host, addr)
return addr, nil
}
+7
View File
@@ -109,6 +109,7 @@ func Flag(Info *HostInfo) error {
flag.IntVar(&fv.ModuleThreadNum, "mt", 20, i18n.GetText("flag_module_thread_num"))
flag.Int64Var(&fv.GlobalTimeout, "gt", 180, i18n.GetText("flag_global_timeout"))
flag.BoolVar(&fv.DisablePing, "np", false, i18n.GetText("flag_disable_ping"))
flag.BoolVar(&fv.DisableTcpProbe, "ntp", false, i18n.GetText("flag_disable_tcp_probe"))
flag.StringVar(&fv.LocalPlugin, "local", "", "指定本地插件名称 (如: cleaner, avdetect, keylogger 等)")
flag.BoolVar(&fv.AliveOnly, "ao", false, i18n.GetText("flag_alive_only"))
@@ -181,6 +182,7 @@ func Flag(Info *HostInfo) error {
flag.BoolVar(&fv.Silent, "silent", false, i18n.GetText("flag_silent_mode"))
flag.BoolVar(&fv.NoColor, "nocolor", false, i18n.GetText("flag_no_color"))
flag.StringVar(&fv.LogLevel, "log", LogLevelBaseInfoSuccess, i18n.GetText("flag_log_level"))
flag.BoolVar(&fv.Debug, "debug", false, i18n.GetText("flag_debug"))
flag.BoolVar(&fv.DisableProgress, "nopg", false, i18n.GetText("flag_disable_progress"))
flag.BoolVar(&fv.PerfStats, "perf", false, "输出性能统计JSON")
@@ -284,6 +286,11 @@ func shouldShowHelp(Info *HostInfo, fv *FlagVars) bool {
func checkParameterConflicts() error {
fv := flagVars
// -debug 等价于 -log debug
if fv.Debug {
fv.LogLevel = LogLevelDebug
}
// 检查 -ao 和 -m icmp 同时指定的情况(向后兼容提示)
if fv.AliveOnly && fv.ScanMode == "icmp" {
LogInfo(i18n.GetText("param_conflict_ao_icmp_both"))
+3
View File
@@ -36,6 +36,7 @@ type FlagVars struct {
TimeoutSec int64 // 秒,需转换为 time.Duration
GlobalTimeout int64
DisablePing bool
DisableTcpProbe bool
LocalPlugin string
AliveOnly bool
DisableBrute bool
@@ -94,6 +95,7 @@ type FlagVars struct {
Silent bool
NoColor bool
LogLevel string
Debug bool
DisableProgress bool
PerfStats bool
Language string
@@ -137,6 +139,7 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
ModuleThreadNum: fv.ModuleThreadNum,
DisableBrute: fv.DisableBrute,
DisablePing: fv.DisablePing,
DisableTcpProbe: fv.DisableTcpProbe,
// 扫描模式
Mode: fv.ScanMode,
+1 -1
View File
@@ -62,7 +62,7 @@ const (
// 版本信息,通过 ldflags 注入
var (
version = "2.1.2"
version = "2.1.3"
commit = "unknown"
date = "unknown"
)
+8
View File
@@ -28,6 +28,10 @@ flag_global_timeout:
other: "Global timeout"
flag_disable_ping:
other: "Disable ping detection"
flag_disable_tcp_probe:
other: "Disable TCP supplementary probe"
flag_debug:
other: "Enable debug mode, write logs to fscan_debug.log"
flag_alive_only:
other: "Alive detection only"
flag_username:
@@ -341,6 +345,8 @@ port_open:
other: "Port open {{.Arg1}}"
port_open_http:
other: "Port open {{.Arg1}} [http](HTTP probe)"
port_scan_no_alive_subnet:
other: "Subnet probe found no alive subnets, skipping port scan"
# ========================= Local Scan Messages =========================
local_plugin_info:
@@ -426,6 +432,8 @@ telnet_unauth_rce:
other: "Telnet {{.Arg1}} unauthorized RCE [{{.Arg2}}] {{.Arg3}}"
telnet_credential_rce:
other: "Telnet {{.Arg1}} {{.Arg2}}:{{.Arg3}} RCE verified [{{.Arg4}}] {{.Arg5}}"
telnet_cve202624061:
other: "Telnet {{.Arg1}} CVE-2026-24061 Telnetd Authentication Bypass (user: {{.Arg2}}) {{.Arg3}}"
cassandra_credential:
other: "Cassandra {{.Arg1}} {{.Arg2}}:{{.Arg3}}"
cassandra_service:
+8
View File
@@ -28,6 +28,10 @@ flag_global_timeout:
other: "全局超时时间"
flag_disable_ping:
other: "禁用ping探测"
flag_disable_tcp_probe:
other: "禁用TCP补充探测"
flag_debug:
other: "开启调试模式,日志写入fscan_debug.log"
flag_alive_only:
other: "仅进行存活探测"
flag_username:
@@ -341,6 +345,8 @@ port_open:
other: "端口开放 {{.Arg1}}"
port_open_http:
other: "端口开放 {{.Arg1}} [http](HTTP探测)"
port_scan_no_alive_subnet:
other: "网段预筛未发现存活子网,跳过端口扫描"
# ========================= 本地扫描消息 =========================
local_plugin_info:
@@ -426,6 +432,8 @@ telnet_unauth_rce:
other: "Telnet {{.Arg1}} 未授权访问且可执行命令 [{{.Arg2}}] {{.Arg3}}"
telnet_credential_rce:
other: "Telnet {{.Arg1}} {{.Arg2}}:{{.Arg3}} 命令执行验证成功 [{{.Arg4}}] {{.Arg5}}"
telnet_cve202624061:
other: "Telnet {{.Arg1}} CVE-2026-24061 Telnet认证绕过 (用户: {{.Arg2}}) {{.Arg3}}"
cassandra_credential:
other: "Cassandra {{.Arg1}} {{.Arg2}}:{{.Arg3}}"
cassandra_service:
+10 -6
View File
@@ -13,9 +13,10 @@ initialize.go - 统一初始化入口
// InitResult 初始化结果
type InitResult struct {
Config *Config
State *State
Info *HostInfo
Config *Config
State *State
Info *HostInfo
Session *ScanSession
}
// Initialize 统一初始化函数
@@ -39,10 +40,13 @@ func Initialize(info *HostInfo) (*InitResult, error) {
return nil, fmt.Errorf("输出初始化失败: %w", err)
}
session := NewScanSession(cfg, state, GetFlagVars())
return &InitResult{
Config: cfg,
State: state,
Info: info,
Config: cfg,
State: state,
Info: info,
Session: session,
}, nil
}
+11
View File
@@ -27,8 +27,12 @@ func getGlobalLogger() *logging.Logger {
EnableColor: !fv.NoColor,
SlowOutput: false,
ShowProgress: !fv.DisableProgress,
Silent: fv.Silent,
StartTime: GetGlobalState().GetStartTime(),
}
if fv.Debug {
config.DebugLogFile = "fscan_debug.log"
}
globalLogger = logging.NewLogger(config)
globalLogger.SetCoordinatedOutput(LogWithProgress)
})
@@ -77,3 +81,10 @@ func LogVuln(result string) { getGlobalLogger().Vuln(result) }
// LogError 输出错误日志
func LogError(errMsg string) { getGlobalLogger().Error(errMsg) }
// CloseLogger 关闭日志系统,释放文件资源
func CloseLogger() {
if globalLogger != nil {
globalLogger.Close()
}
}
+43 -1
View File
@@ -2,6 +2,7 @@ package logging
import (
"fmt"
"os"
"strings"
"sync"
"time"
@@ -24,8 +25,10 @@ type LoggerConfig struct {
EnableColor bool `json:"enable_color"`
SlowOutput bool `json:"slow_output"`
ShowProgress bool `json:"show_progress"`
Silent bool `json:"silent"`
StartTime time.Time `json:"start_time"`
LevelColors map[LogLevel]interface{} `json:"-"`
DebugLogFile string `json:"debug_log_file"`
}
// DefaultLoggerConfig 默认日志器配置
@@ -47,6 +50,7 @@ type Logger struct {
startTime time.Time
coordinatedOutput func(string)
initialized bool
debugFile *os.File
}
// NewLogger 创建新的日志管理器
@@ -55,11 +59,20 @@ func NewLogger(config *LoggerConfig) *Logger {
config = DefaultLoggerConfig()
}
return &Logger{
l := &Logger{
config: config,
startTime: config.StartTime,
initialized: true,
}
if config.DebugLogFile != "" {
f, err := os.OpenFile(config.DebugLogFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err == nil {
l.debugFile = f
}
}
return l
}
// Initialize 初始化日志器
@@ -111,6 +124,10 @@ func (l *Logger) log(level LogLevel, content string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.config.Silent {
return
}
if !l.shouldLog(level) {
return
}
@@ -134,12 +151,37 @@ func (l *Logger) log(level LogLevel, content string) {
l.outputMessage(level, logMsg)
}
// 写入debug日志文件(纯文本,无颜色)
if l.debugFile != nil {
timestamp := time.Since(l.startTime).Truncate(time.Millisecond)
if strings.Contains(content, "\n") {
lines := strings.Split(content, "\n")
for _, line := range lines {
if line != "" {
_, _ = fmt.Fprintf(l.debugFile, "[%s] %s %s\n", timestamp, prefix, line)
}
}
} else {
_, _ = fmt.Fprintf(l.debugFile, "[%s] %s %s\n", timestamp, prefix, content)
}
}
// 根据慢速输出设置决定是否添加延迟
if l.config.SlowOutput {
time.Sleep(SlowOutputDelay)
}
}
// Close 关闭日志器,释放文件资源
func (l *Logger) Close() {
l.mu.Lock()
defer l.mu.Unlock()
if l.debugFile != nil {
_ = l.debugFile.Close()
l.debugFile = nil
}
}
// shouldLog 检查是否应该记录该级别的日志
// 层级过滤:消息级别 >= 配置级别 时显示,Error 始终显示
func (l *Logger) shouldLog(level LogLevel) bool {
+7 -4
View File
@@ -102,11 +102,9 @@ func createProxyConfig(timeout time.Duration) *proxy.ProxyConfig {
// TCP 连接
// =============================================================================
// WrapperTcpWithTimeout TCP连接包装器,带超时
// 支持通过代理管理器进行SOCKS5和HTTP代理连接,并集成发包控制
// 使用全局拨号器复用连接,避免重复创建代理握手开销
// Deprecated: WrapperTcpWithTimeout 仅供 mylib/grdp 兼容使用,新代码请用 ScanSession.DialTCP
//
//nolint:revive // 保持向后兼容性,避免破坏大量现有代码
//nolint:revive
func WrapperTcpWithTimeout(network, address string, timeout time.Duration) (net.Conn, error) {
// 检查发包限制 - 在代理连接前进行控制
if canSend, reason := CanSendPacket(); !canSend {
@@ -158,6 +156,11 @@ func IsProxyReliable() bool {
return proxy.IsProxyReliable()
}
// IsSOCKS5Proxy 检查当前代理是否为SOCKS5类型
func IsSOCKS5Proxy() bool {
return proxy.IsSOCKS5Proxy()
}
// SafeHTTPDo 带发包控制的HTTP请求
func SafeHTTPDo(client *http.Client, req *http.Request) (*http.Response, error) {
// 检查发包限制
+24 -1
View File
@@ -59,7 +59,8 @@ func (b *ResultBuffer) Add(result *ScanResult) {
b.seenServices[key] = len(b.ServiceResults)
b.ServiceResults = append(b.ServiceResults, result)
} else {
// 保留信息更完整的记录
b.mergeDetails(b.ServiceResults[idx], result)
// 保留信息更完整的记录,同时保留另一条记录补充的字段
if b.isMoreComplete(result, b.ServiceResults[idx]) {
b.ServiceResults[idx] = result
}
@@ -72,6 +73,28 @@ func (b *ResultBuffer) Add(result *ScanResult) {
}
}
func (b *ResultBuffer) mergeDetails(oldResult, newResult *ScanResult) {
if oldResult == nil || newResult == nil {
return
}
if oldResult.Details == nil {
oldResult.Details = make(map[string]interface{})
}
if newResult.Details == nil {
newResult.Details = make(map[string]interface{})
}
for k, v := range oldResult.Details {
if _, exists := newResult.Details[k]; !exists {
newResult.Details[k] = v
}
}
for k, v := range newResult.Details {
if _, exists := oldResult.Details[k]; !exists {
oldResult.Details[k] = v
}
}
}
// generateKey 生成结果的唯一键(用于去重)
func (b *ResultBuffer) generateKey(result *ScanResult) string {
switch result.Type {
+34
View File
@@ -226,6 +226,40 @@ func TestResultBuffer_ServiceUpdate(t *testing.T) {
}
}
func TestResultBuffer_ServiceUpdateMergesDetails(t *testing.T) {
buf := NewResultBuffer()
buf.Add(&ScanResult{
Type: TypeService,
Target: "192.168.1.1:80",
Status: "identified",
Details: map[string]interface{}{
"service": "http",
"banner": "HTTP/1.1 200 OK",
},
})
buf.Add(&ScanResult{
Type: TypeService,
Target: "192.168.1.1:80",
Status: "web",
Details: map[string]interface{}{
"title": "Home",
"status": 200,
"server": "nginx",
},
})
if len(buf.ServiceResults) != 1 {
t.Fatalf("期望1条服务记录,实际 %d", len(buf.ServiceResults))
}
details := buf.ServiceResults[0].Details
for _, key := range []string{"service", "banner", "title", "status", "server"} {
if _, ok := details[key]; !ok {
t.Errorf("合并后的服务记录缺少字段 %q: %#v", key, details)
}
}
}
// TestResultBuffer_ServiceNoDowngrade 测试不降级服务记录
//
// 当新记录不如旧记录完整时,不应替换
+145
View File
@@ -0,0 +1,145 @@
package output
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
)
type StdoutNDJSONWriter struct {
mu sync.Mutex
writer *bufio.Writer
}
func NewStdoutNDJSONWriter() *StdoutNDJSONWriter {
return &StdoutNDJSONWriter{
writer: bufio.NewWriter(os.Stdout),
}
}
// ndjsonRecord NDJSON 输出的扁平化结构
type ndjsonRecord struct {
Type ResultType `json:"type"`
Target string `json:"target"`
Status string `json:"status"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Service string `json:"service,omitempty"`
// 通用可选字段
Protocol string `json:"protocol,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
// 漏洞/弱口令
Vulnerability string `json:"vulnerability,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
// 其他
Plugin string `json:"plugin,omitempty"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
}
func (w *StdoutNDJSONWriter) WriteResult(result *ScanResult) error {
w.mu.Lock()
defer w.mu.Unlock()
rec := w.flatten(result)
data, err := json.Marshal(rec)
if err != nil {
return err
}
data = append(data, '\n')
if _, err := w.writer.Write(data); err != nil {
return err
}
return w.writer.Flush()
}
func (w *StdoutNDJSONWriter) flatten(r *ScanResult) *ndjsonRecord {
rec := &ndjsonRecord{
Type: r.Type,
Target: r.Target,
Status: r.Status,
}
// 从 target 拆分 host:port
if host, port, ok := splitHostPort(r.Target); ok {
rec.Host = host
rec.Port = port
} else {
rec.Host = r.Target
}
d := r.Details
if d == nil {
return rec
}
// 从 details 提升一级字段(覆盖拆分结果)
if v, ok := d["port"]; ok {
if p, ok := toInt(v); ok {
rec.Port = p
}
}
rec.Service = strVal(d, "service")
rec.Protocol = strVal(d, "protocol")
rec.Banner = strVal(d, "banner")
rec.Title = strVal(d, "title")
rec.URL = strVal(d, "url")
rec.Vulnerability = strVal(d, "vulnerability")
rec.Username = strVal(d, "username")
rec.Password = strVal(d, "password")
rec.Plugin = strVal(d, "plugin")
rec.Version = strVal(d, "version")
rec.OS = strVal(d, "os")
return rec
}
func (w *StdoutNDJSONWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.writer.Flush()
}
func strVal(d map[string]interface{}, key string) string {
v, ok := d[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return fmt.Sprintf("%v", v)
}
return s
}
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int64:
return int(n), true
case float64:
return int(n), true
}
return 0, false
}
func splitHostPort(target string) (string, int, bool) {
idx := strings.LastIndex(target, ":")
if idx < 0 {
return "", 0, false
}
host := target[:idx]
var port int
if _, err := fmt.Sscanf(target[idx+1:], "%d", &port); err != nil {
return "", 0, false
}
return host, port, true
}
+68 -24
View File
@@ -13,13 +13,26 @@ import (
// escapeControlChars 转义控制字符
func escapeControlChars(s string) string {
replacer := strings.NewReplacer(
"\r\n", "\\r\\n",
"\n", "\\n",
"\r", "\\r",
"\t", "\\t",
)
return replacer.Replace(s)
s = strings.ToValidUTF8(s, "?")
var b strings.Builder
for _, r := range s {
switch r {
case '\n':
b.WriteString("\\n")
case '\r':
b.WriteString("\\r")
case '\t':
b.WriteString("\\t")
default:
if r < 0x20 || r == 0x7f {
fmt.Fprintf(&b, "\\x%02x", r)
continue
}
b.WriteRune(r)
}
}
return b.String()
}
// =============================================================================
@@ -183,13 +196,7 @@ func (w *TXTWriter) formatWebServiceLine(result *ScanResult) string {
}
}
protocol := "http"
service := w.getDetailStr(result, "service")
if service == "https" || strings.Contains(target, ":443") {
protocol = "https"
}
url := fmt.Sprintf("%s://%s", protocol, target)
url := fmt.Sprintf("%s://%s", w.webProtocol(result, target), target)
title := w.getDetailStr(result, "title")
status := w.getDetail(result, "status")
server := w.getDetailStr(result, "server")
@@ -362,13 +369,7 @@ func (w *TXTWriter) writeWebServices() {
}
}
protocol := "http"
service := w.getDetailStr(result, "service")
if service == "https" || strings.Contains(target, ":443") {
protocol = "https"
}
urls = append(urls, fmt.Sprintf("%s://%s", protocol, target))
urls = append(urls, fmt.Sprintf("%s://%s", w.webProtocol(result, target), target))
}
if len(urls) == 0 {
@@ -397,6 +398,19 @@ func (w *TXTWriter) isWebService(result *ScanResult) bool {
return service == "http" || service == "https"
}
func (w *TXTWriter) webProtocol(result *ScanResult, target string) string {
protocol := strings.ToLower(w.getDetailStr(result, "protocol"))
if protocol == "http" || protocol == "https" {
return protocol
}
service := strings.ToLower(w.getDetailStr(result, "service"))
if service == "https" || strings.Contains(target, ":443") {
return "https"
}
return "http"
}
// GetFormat 获取格式类型
func (w *TXTWriter) GetFormat() Format {
return FormatTXT
@@ -647,7 +661,7 @@ func (w *CSVWriter) Close() error {
// 写入各分类
w.writeSection("# Hosts", []string{"Target"}, w.buffer.HostResults, w.formatHostRecord)
w.writeSection("# Ports", []string{"Target", "Port", "Status"}, w.buffer.PortResults, w.formatPortRecord)
w.writeSection("# Services", []string{"Target", "Service", "Version", "Banner"}, w.buffer.ServiceResults, w.formatServiceRecord)
w.writeSection("# Services", []string{"Target", "Service", "Version", "Title", "Status", "Server", "Fingerprints", "Banner"}, w.buffer.ServiceResults, w.formatServiceRecord)
w.writeSection("# Vulns", []string{"Target", "Type", "Details"}, w.buffer.VulnResults, w.formatVulnRecord)
w.closed = true
@@ -697,7 +711,7 @@ func (w *CSVWriter) formatPortRecord(result *ScanResult) []string {
}
func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string {
service, version, banner := "", "", ""
service, version, title, status, server, fingerprints, banner := "", "", "", "", "", "", ""
if result.Details != nil {
if s, ok := result.Details["service"].(string); ok {
service = s
@@ -705,9 +719,22 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string {
if s, ok := result.Details["name"].(string); ok && service == "" {
service = s
}
if s, ok := result.Details["plugin"].(string); ok && service == "" {
service = s
}
if v, ok := result.Details["version"].(string); ok {
version = v
}
if t, ok := result.Details["title"].(string); ok {
title = escapeControlChars(t)
}
if s, ok := result.Details["status"]; ok && s != nil && s != 0 {
status = fmt.Sprintf("%v", s)
}
if s, ok := result.Details["server"].(string); ok {
server = escapeControlChars(s)
}
fingerprints = formatFingerprints(result.Details["fingerprints"])
if b, ok := result.Details["banner"].(string); ok {
banner = escapeControlChars(b)
if len(banner) > 100 {
@@ -721,7 +748,24 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string {
target = fmt.Sprintf("%s:%v", target, p)
}
}
return []string{target, service, version, banner}
return []string{target, service, version, title, status, server, fingerprints, banner}
}
func formatFingerprints(value interface{}) string {
switch v := value.(type) {
case []string:
return strings.Join(v, ",")
case []interface{}:
parts := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, ",")
default:
return ""
}
}
func (w *CSVWriter) formatVulnRecord(result *ScanResult) []string {
+81 -1
View File
@@ -1139,7 +1139,7 @@ func TestCSVWriter_ErrorHandling(t *testing.T) {
// TestCSVWriter_DetailsFormatting 测试CSV的Details字段格式化
//
// CSVWriter 对不同类型有不同的格式:
// - Service类型:Target, Service, Version, Banner
// - Service类型:Target, Service, Version, Title, Status, Server, Fingerprints, Banner
func TestCSVWriter_DetailsFormatting(t *testing.T) {
dir := createTestDir(t)
filePath := filepath.Join(dir, "test.csv")
@@ -1188,6 +1188,86 @@ func TestCSVWriter_DetailsFormatting(t *testing.T) {
t.Logf("✓ CSV Details格式化测试通过")
}
func TestCSVWriter_WebServiceFields(t *testing.T) {
dir := createTestDir(t)
filePath := filepath.Join(dir, "test.csv")
writer, _ := NewCSVWriter(filePath)
defer func() { _ = writer.Close() }()
_ = writer.WriteHeader()
result := createTestResult(
TypeService,
"192.168.1.1:80",
"web",
map[string]interface{}{
"plugin": "webtitle",
"is_web": true,
"port": 80,
"title": "Home",
"status": 200,
"server": "nginx",
"fingerprints": []string{"nginx", "php"},
"banner": "HTTP/1.1 200 OK\x00\nServer: nginx",
},
)
_ = writer.Write(result)
writer.Close()
content := readFileContent(t, filePath)
for _, want := range []string{
"Target,Service,Version,Title,Status,Server,Fingerprints,Banner",
"webtitle",
"Home",
"200",
"nginx",
"nginx,php",
"\\x00\\nServer: nginx",
} {
if !strings.Contains(content, want) {
t.Errorf("CSV文件缺少 %q,内容:\n%s", want, content)
}
}
}
func TestTXTWriter_WebServiceProtocolFromDetails(t *testing.T) {
dir := createTestDir(t)
filePath := filepath.Join(dir, "test_web_protocol.txt")
writer, err := NewTXTWriter(filePath)
if err != nil {
t.Fatalf("创建TXTWriter失败: %v", err)
}
result := createTestResult(
TypeService,
"192.168.1.1:8443",
"web",
map[string]interface{}{
"plugin": "webtitle",
"is_web": true,
"port": 8443,
"protocol": "https",
"title": "Home",
"status": 200,
},
)
if err := writer.Write(result); err != nil {
t.Fatalf("Write()失败: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("Close()失败: %v", err)
}
content := readFileContent(t, filePath)
if !strings.Contains(content, "https://192.168.1.1:8443") {
t.Fatalf("TXT输出缺少HTTPS URL,内容:\n%s", content)
}
if strings.Contains(content, "http://192.168.1.1:8443") {
t.Fatalf("TXT输出不应把HTTPS目标降级为HTTP,内容:\n%s", content)
}
}
// TestJSONWriter_FlushAndFormat 测试JSON的Flush和GetFormat
func TestJSONWriter_FlushAndFormat(t *testing.T) {
dir := createTestDir(t)
+17 -1
View File
@@ -15,10 +15,18 @@ import (
// ResultOutput 全局输出管理器
var ResultOutput *output.Manager
// StdoutWriter silent模式下的NDJSON stdout写入器
var StdoutWriter *output.StdoutNDJSONWriter
// InitOutput 初始化输出系统
func InitOutput() error {
fv := GetFlagVars()
// silent模式:初始化NDJSON stdout写入器(独立于文件输出)
if fv.Silent {
StdoutWriter = output.NewStdoutNDJSONWriter()
}
// 用户通过-no flag禁用保存时,跳过文件初始化避免不必要的资源开销
if fv.DisableSave {
return nil
@@ -59,6 +67,9 @@ func InitOutput() error {
// CloseOutput 关闭输出系统
func CloseOutput() error {
if StdoutWriter != nil {
_ = StdoutWriter.Close()
}
if ResultOutput == nil {
return nil
}
@@ -80,8 +91,13 @@ func SaveResult(result *output.ScanResult) error {
"details": result.Details,
})
// silent模式:NDJSON实时输出到stdout
if StdoutWriter != nil {
_ = StdoutWriter.WriteResult(result)
}
// 用户禁用保存或输出未初始化时,跳过文件保存
if GetFlagVars().DisableSave || ResultOutput == nil {
if GetGlobalConfig().Output.DisableSave || ResultOutput == nil {
return nil
}
return ResultOutput.SaveResult(result)
+46 -24
View File
@@ -48,6 +48,10 @@ type ProgressManager struct {
// 进度条更新控制(减少 Windows 终端的重复输出)
lastRenderedPercent int
// 引用,避免读全局
state *State
noColor bool
}
// =============================================================================
@@ -102,11 +106,13 @@ func GetProgressManager() *ProgressManager {
// InitProgress 初始化进度条
func (pm *ProgressManager) InitProgress(total int64, description string) {
fv := GetFlagVars()
if fv.DisableProgress || fv.Silent {
cfg := GetGlobalConfig()
if cfg.Output.DisableProgress || cfg.Output.Silent {
pm.enabled = false
return
}
pm.state = GetGlobalState()
pm.noColor = cfg.Output.NoColor
pm.mu.Lock()
defer pm.mu.Unlock()
@@ -137,16 +143,24 @@ func (pm *ProgressManager) UpdateProgress(increment int64) {
return
}
pm.mu.Lock()
defer pm.mu.Unlock()
pm.current += increment
if pm.current > pm.total {
pm.current = pm.total
// 原子累加,避免高并发下的锁竞争
newCurrent := atomic.AddInt64(&pm.current, increment)
if newCurrent > pm.total {
atomic.StoreInt64(&pm.current, pm.total)
}
// 更新活跃时间
pm.lastActivity = time.Now()
// 节流渲染:距上次渲染不足 50ms 则跳过
now := time.Now()
pm.mu.RLock()
lastAct := pm.lastActivity
pm.mu.RUnlock()
if now.Sub(lastAct) < 50*time.Millisecond {
return
}
pm.mu.Lock()
pm.lastActivity = now
pm.mu.Unlock()
pm.renderProgress()
}
@@ -164,7 +178,7 @@ func (pm *ProgressManager) FinishProgress() {
pm.mu.Lock()
defer pm.mu.Unlock()
pm.current = pm.total
atomic.StoreInt64(&pm.current, pm.total)
pm.renderProgress()
// 停止活跃指示器
@@ -214,11 +228,12 @@ func (pm *ProgressManager) generateProgressBar() string {
return base
}
percentage := float64(pm.current) / float64(pm.total) * 100
percentage := float64(atomic.LoadInt64(&pm.current)) / float64(pm.total) * 100
elapsed := time.Since(pm.startTime)
current := atomic.LoadInt64(&pm.current)
// 计算速度
speed := float64(pm.current) / elapsed.Seconds()
speed := float64(current) / elapsed.Seconds()
speedStr := ""
if speed > 0 {
speedStr = fmt.Sprintf(" %.0f/s", speed)
@@ -226,8 +241,8 @@ func (pm *ProgressManager) generateProgressBar() string {
// 计算预估剩余时间
var eta string
if pm.current > 0 && pm.current < pm.total {
totalTime := elapsed * time.Duration(pm.total) / time.Duration(pm.current)
if current > 0 && current < pm.total {
totalTime := elapsed * time.Duration(pm.total) / time.Duration(current)
remaining := totalTime - elapsed
if remaining > 0 {
eta = fmt.Sprintf(" ETA:%s", formatDuration(remaining))
@@ -239,7 +254,7 @@ func (pm *ProgressManager) generateProgressBar() string {
// 计算固定部分的宽度
fixedPart := fmt.Sprintf("%s %s %5.1f%% [] (%d/%d)%s%s %s",
pm.description, spinner, percentage, pm.current, pm.total, speedStr, eta, packetInfo)
pm.description, spinner, percentage, current, pm.total, speedStr, eta, packetInfo)
fixedWidth := displayWidth(fixedPart)
// 计算进度条槽位可用宽度(预留2字符余量)
@@ -266,7 +281,7 @@ func (pm *ProgressManager) generateProgressBar() string {
// 构建最终进度条
result := fmt.Sprintf("%s %s %5.1f%% %s (%d/%d)%s%s",
pm.description, spinner, percentage, bar, pm.current, pm.total, speedStr, eta)
pm.description, spinner, percentage, bar, current, pm.total, speedStr, eta)
if packetInfo != "" {
result += " " + packetInfo
@@ -277,13 +292,16 @@ func (pm *ProgressManager) generateProgressBar() string {
// getPacketInfo 获取发包统计信息(简化版)
func (pm *ProgressManager) getPacketInfo() string {
packetCount := GetGlobalState().GetPacketCount()
if pm.state == nil {
return ""
}
packetCount := pm.state.GetPacketCount()
if packetCount == 0 {
return ""
}
tcpSuccess := GetGlobalState().GetTCPSuccessPacketCount()
tcpFailed := GetGlobalState().GetTCPFailedPacketCount()
tcpSuccess := pm.state.GetTCPSuccessPacketCount()
tcpFailed := pm.state.GetTCPFailedPacketCount()
// 简化格式:TCP:成功/失败
if tcpSuccess > 0 || tcpFailed > 0 {
@@ -301,7 +319,7 @@ func (pm *ProgressManager) showCompletionInfo() {
fmt.Print("\n")
completionMsg := i18n.GetText("progress_scan_completed")
if GetFlagVars().NoColor {
if pm.noColor {
fmt.Printf("[完成] %s %d/%d (耗时: %s)\n",
completionMsg, pm.total, pm.total, formatDuration(elapsed))
} else {
@@ -461,7 +479,7 @@ func (pm *ProgressManager) GetPercent() float64 {
if !pm.isActive || pm.total == 0 {
return 0
}
return float64(pm.current) / float64(pm.total) * 100
return float64(atomic.LoadInt64(&pm.current)) / float64(pm.total) * 100
}
// =============================================================================
@@ -470,6 +488,10 @@ func (pm *ProgressManager) GetPercent() float64 {
// LogWithProgress 在进度条活跃时协调日志输出
func LogWithProgress(message string) {
if cfg := GetGlobalConfig(); cfg != nil && cfg.Output.Silent {
return
}
pm := GetProgressManager()
if !pm.IsActive() {
// 如果进度条不活跃,直接输出
@@ -499,7 +521,7 @@ func (pm *ProgressManager) renderProgressUnsafe() {
// 计算当前百分比(避免除零)
currentPercent := 0
if pm.total > 0 {
currentPercent = int((pm.current * 100) / pm.total)
currentPercent = int((atomic.LoadInt64(&pm.current) * 100) / pm.total)
}
// 只在百分比变化时更新,减少不必要的渲染
@@ -532,7 +554,7 @@ func (pm *ProgressManager) renderProgressUnsafe() {
fmt.Print(clearStr)
// 输出进度条(带颜色,如果启用)
if GetFlagVars().NoColor {
if pm.noColor {
fmt.Print(progressBar)
} else {
fmt.Printf("%s%s%s", AnsiCyan, progressBar, AnsiReset)
+9
View File
@@ -19,6 +19,9 @@ var (
// proxyProbed 标记代理是否已经探测过(避免重复探测)
proxyProbed atomic.Bool
// currentProxyType 当前代理类型
currentProxyType atomic.Int32
)
// SetProxyEnabled 设置代理启用状态
@@ -61,6 +64,11 @@ func IsProxyProbed() bool {
return proxyProbed.Load()
}
// IsSOCKS5Proxy 检查当前代理是否为SOCKS5类型
func IsSOCKS5Proxy() bool {
return proxyEnabled.Load() && ProxyType(currentProxyType.Load()) == ProxyTypeSOCKS5
}
// AutoConfigureProxy 自动配置代理相关行为
// 根据代理类型和状态自动调整扫描策略
func AutoConfigureProxy(config *ProxyConfig) {
@@ -74,6 +82,7 @@ func AutoConfigureProxy(config *ProxyConfig) {
// 启用代理标记
SetProxyEnabled(true)
currentProxyType.Store(int32(config.Type))
// SOCKS5代理默认假设非标准(后续由探测函数验证)
if config.Type == ProxyTypeSOCKS5 {
+8 -1
View File
@@ -30,7 +30,9 @@ func (h *httpDialer) DialContext(ctx context.Context, network, address string) (
proxyConn, err := h.baseDial.DialContext(ctx, NetworkTCP, h.config.Address)
if err != nil {
atomic.AddInt64(&h.stats.FailedConnections, 1)
h.stats.mu.Lock()
h.stats.LastError = err.Error()
h.stats.mu.Unlock()
return nil, NewProxyError(ErrTypeConnection, ErrMsgHTTPConnFailed, ErrCodeHTTPConnFailed, err)
}
@@ -38,12 +40,16 @@ func (h *httpDialer) DialContext(ctx context.Context, network, address string) (
if err := h.sendConnectRequest(proxyConn, address); err != nil {
_ = proxyConn.Close() // 错误处理路径,Close错误可忽略
atomic.AddInt64(&h.stats.FailedConnections, 1)
h.stats.mu.Lock()
h.stats.LastError = err.Error()
h.stats.mu.Unlock()
return nil, err
}
duration := time.Since(start)
h.stats.mu.Lock()
h.stats.LastConnectTime = start
h.stats.mu.Unlock()
atomic.AddInt64(&h.stats.ActiveConnections, 1)
h.updateAverageConnectTime(duration)
@@ -108,7 +114,8 @@ func (h *httpDialer) sendConnectRequest(conn net.Conn, address string) error {
// updateAverageConnectTime 更新平均连接时间
func (h *httpDialer) updateAverageConnectTime(duration time.Duration) {
// 简单的移动平均
h.stats.mu.Lock()
defer h.stats.mu.Unlock()
if h.stats.AverageConnectTime == 0 {
h.stats.AverageConnectTime = duration
} else {
+29 -5
View File
@@ -128,9 +128,19 @@ func (m *manager) Stats() *ProxyStats {
m.mu.RLock()
defer m.mu.RUnlock()
// 返回副本以避免并发问题
statsCopy := *m.stats
return &statsCopy
m.stats.mu.Lock()
defer m.stats.mu.Unlock()
return &ProxyStats{
TotalConnections: atomic.LoadInt64(&m.stats.TotalConnections),
ActiveConnections: atomic.LoadInt64(&m.stats.ActiveConnections),
FailedConnections: atomic.LoadInt64(&m.stats.FailedConnections),
AverageConnectTime: m.stats.AverageConnectTime,
LastConnectTime: m.stats.LastConnectTime,
LastError: m.stats.LastError,
ProxyType: m.stats.ProxyType,
ProxyAddress: m.stats.ProxyAddress,
}
}
// createDirectDialer 创建直连拨号器
@@ -264,11 +274,16 @@ func (d *directDialer) DialContext(ctx context.Context, network, address string)
conn, err := dialer.DialContext(ctx, network, address)
duration := time.Since(start)
d.stats.mu.Lock()
d.stats.LastConnectTime = start
d.stats.mu.Unlock()
if err != nil {
atomic.AddInt64(&d.stats.FailedConnections, 1)
d.stats.mu.Lock()
d.stats.LastError = err.Error()
d.stats.mu.Unlock()
return nil, NewProxyError(ErrTypeConnection, ErrMsgDirectConnFailed, ErrCodeDirectConnFailed, err)
}
@@ -323,15 +338,22 @@ func (s *socks5Dialer) DialContext(ctx context.Context, network, address string)
select {
case <-dialCtx.Done():
atomic.AddInt64(&s.stats.FailedConnections, 1)
s.stats.mu.Lock()
s.stats.LastError = dialCtx.Err().Error()
s.stats.mu.Unlock()
return nil, NewProxyError(ErrTypeTimeout, ErrMsgSOCKS5ConnTimeout, ErrCodeSOCKS5ConnTimeout, dialCtx.Err())
case result := <-connChan:
duration := time.Since(start)
s.stats.mu.Lock()
s.stats.LastConnectTime = start
s.stats.mu.Unlock()
if result.err != nil {
atomic.AddInt64(&s.stats.FailedConnections, 1)
s.stats.mu.Lock()
s.stats.LastError = result.err.Error()
s.stats.mu.Unlock()
return nil, NewProxyError(ErrTypeConnection, ErrMsgSOCKS5ConnFailed, ErrCodeSOCKS5ConnFailed, result.err)
}
@@ -347,7 +369,8 @@ func (s *socks5Dialer) DialContext(ctx context.Context, network, address string)
// updateAverageConnectTime 更新平均连接时间
func (d *directDialer) updateAverageConnectTime(duration time.Duration) {
// 简单的移动平均
d.stats.mu.Lock()
defer d.stats.mu.Unlock()
if d.stats.AverageConnectTime == 0 {
d.stats.AverageConnectTime = duration
} else {
@@ -356,7 +379,8 @@ func (d *directDialer) updateAverageConnectTime(duration time.Duration) {
}
func (s *socks5Dialer) updateAverageConnectTime(duration time.Duration) {
// 简单的移动平均
s.stats.mu.Lock()
defer s.stats.mu.Unlock()
if s.stats.AverageConnectTime == 0 {
s.stats.AverageConnectTime = duration
} else {
+4 -1
View File
@@ -50,7 +50,9 @@ func (t *tlsDialerWrapper) DialTLSContext(ctx context.Context, network, address
if err := tlsConn.Handshake(); err != nil {
_ = tcpConn.Close() // TLS握手失败,Close错误可忽略
atomic.AddInt64(&t.stats.FailedConnections, 1)
t.stats.mu.Lock()
t.stats.LastError = err.Error()
t.stats.mu.Unlock()
return nil, NewProxyError(ErrTypeConnection, ErrMsgTLSHandshakeFailed, ErrCodeTLSHandshakeFailed, err)
}
@@ -71,7 +73,8 @@ func (t *tlsDialerWrapper) DialTLSContext(ctx context.Context, network, address
// updateAverageConnectTime 更新平均连接时间
func (t *tlsDialerWrapper) updateAverageConnectTime(duration time.Duration) {
// 简单的移动平均
t.stats.mu.Lock()
defer t.stats.mu.Unlock()
if t.stats.AverageConnectTime == 0 {
t.stats.AverageConnectTime = duration
} else {
+5 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"net"
"sync"
"time"
)
@@ -95,9 +96,10 @@ type ProxyManager interface {
//
//nolint:revive // 保持与现有代码的向后兼容性
type ProxyStats struct {
TotalConnections int64 `json:"total_connections"`
ActiveConnections int64 `json:"active_connections"`
FailedConnections int64 `json:"failed_connections"`
TotalConnections int64 `json:"total_connections"`
ActiveConnections int64 `json:"active_connections"`
FailedConnections int64 `json:"failed_connections"`
mu sync.Mutex `json:"-"`
AverageConnectTime time.Duration `json:"average_connect_time"`
LastConnectTime time.Time `json:"last_connect_time"`
LastError string `json:"last_error,omitempty"`
+106
View File
@@ -0,0 +1,106 @@
package common
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/shadow1ng/fscan/common/proxy"
)
// ScanSession 封装单次扫描的全部上下文
// 一次扫描一个 session,并发扫描各自独立
type ScanSession struct {
Config *Config // 不可变,创建后只读
State *State // 可变,原子操作,每会话独立
Params *FlagVars // 原始参数,只读
// 每会话 dialer(懒初始化,取决于代理配置)
dialerOnce sync.Once
dialer proxy.Dialer
dialerErr error
}
// NewScanSession 从已构建的 Config、State 和 FlagVars 创建会话
func NewScanSession(config *Config, state *State, params *FlagVars) *ScanSession {
return &ScanSession{
Config: config,
State: state,
Params: params,
}
}
// DialTCP 创建 TCP 连接,内含限速检查、代理、计数
func (s *ScanSession) DialTCP(ctx context.Context, network, address string, timeout time.Duration) (net.Conn, error) {
// 检查发包限制
if ok, err := CanSendPacketWith(s.Config, s.State); !ok {
LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, err.Error()))
return nil, fmt.Errorf("发包受限: %s", err.Error())
}
// 获取 dialer
dialer, err := s.getDialer()
if err != nil {
LogError(fmt.Sprintf("获取代理拨号器失败: %v", err))
s.State.IncrementTCPFailedPacketCount()
return nil, err
}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
s.State.IncrementTCPFailedPacketCount()
LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err))
return nil, err
}
// SO_LINGER=0: 连接关闭时立即发送 RST,避免 TIME_WAIT 堆积
if tc, ok := conn.(*net.TCPConn); ok {
_ = tc.SetLinger(0)
}
s.State.IncrementTCPSuccessPacketCount()
return conn, nil
}
func (s *ScanSession) getDialer() (proxy.Dialer, error) {
s.dialerOnce.Do(func() {
cfg := s.createProxyConfig()
manager := proxy.NewProxyManager(cfg)
s.dialer, s.dialerErr = manager.GetDialer()
})
return s.dialer, s.dialerErr
}
func (s *ScanSession) createProxyConfig() *proxy.ProxyConfig {
cfg := proxy.DefaultProxyConfig()
cfg.Timeout = s.Config.Timeout
cfg.LocalAddr = s.Config.Network.Iface
// 优先 SOCKS5
if s.Config.Network.Socks5Proxy != "" {
cfg.Type = proxy.ProxyTypeSOCKS5
socks5URL := s.Config.Network.Socks5Proxy
if !strings.HasPrefix(socks5URL, "socks5://") {
socks5URL = "socks5://" + socks5URL
}
cfg.Address, cfg.Username, cfg.Password = parseProxyURL(socks5URL, s.Config.Network.Socks5Proxy)
return cfg
}
// 其次 HTTP
if s.Config.Network.HTTPProxy != "" {
if strings.HasPrefix(s.Config.Network.HTTPProxy, "https://") {
cfg.Type = proxy.ProxyTypeHTTPS
} else {
cfg.Type = proxy.ProxyTypeHTTP
}
cfg.Address, cfg.Username, cfg.Password = parseProxyURL(s.Config.Network.HTTPProxy, s.Config.Network.HTTPProxy)
return cfg
}
cfg.Type = proxy.ProxyTypeNone
return cfg
}