修复扫描参数、会话状态和结果备份异常

This commit is contained in:
ZacharyZcR
2026-10-01 13:02:33 +08:00
parent b73c707a3b
commit dcf0fc31e8
14 changed files with 444 additions and 95 deletions
+3
View File
@@ -128,6 +128,9 @@ jobs:
PKGS=$(go list ./... | grep -v '/libs/grdp/')
go test -vet=off -race -coverprofile=coverage.out -covermode=atomic $PKGS
- name: Web 接口回归测试
run: go test -vet=off -race -tags web ./web/api
- name: 上传覆盖率
uses: actions/upload-artifact@v4
with:
+13 -1
View File
@@ -238,8 +238,20 @@ func parseHashes(fv *FlagVars) ([]string, [][]byte, error) {
// =============================================================================
func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error {
// 检查是否为 host:port 格式
ports := fv.Ports
if fv.PortsFile != "" {
lines, err := parsers.ReadLinesFromFile(fv.PortsFile)
if err != nil {
return fmt.Errorf("%s", i18n.Tr("config_read_ports_failed", fv.PortsFile, err))
}
ports = strings.Join(lines, ",")
if len(parsers.ParsePort(ports)) == 0 {
return fmt.Errorf("%s", i18n.Tr("invalid_port", ports))
}
cfg.Target.Ports = ports
}
// 检查是否为 host:port 格式
if info.Host != "" && strings.Contains(info.Host, ":") {
if _, portStr, err := net.SplitHostPort(info.Host); err == nil {
if port, portErr := strconv.Atoi(portStr); portErr == nil && port >= 1 && port <= 65535 {
+2
View File
@@ -311,6 +311,8 @@ config_read_passwords_failed:
other: "Failed to read password file {{.Arg1}}: {{.Arg2}}"
config_read_urls_failed:
other: "Failed to read URL file {{.Arg1}}: {{.Arg2}}"
config_read_ports_failed:
other: "Failed to read port file {{.Arg1}}: {{.Arg2}}"
# ========================= Plugin Scan Messages (with parameters) =========================
scan_plugin_not_found:
+2
View File
@@ -311,6 +311,8 @@ config_read_passwords_failed:
other: "读取密码文件 {{.Arg1}} 失败: {{.Arg2}}"
config_read_urls_failed:
other: "读取URL文件 {{.Arg1}} 失败: {{.Arg2}}"
config_read_ports_failed:
other: "读取端口文件 {{.Arg1}} 失败: {{.Arg2}}"
# ========================= 插件扫描消息 (带参数) =========================
scan_plugin_not_found:
+48
View File
@@ -0,0 +1,48 @@
package output
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestWritersPreserveBackupOnFinalWriteFailure(t *testing.T) {
for _, format := range []Format{FormatTXT, FormatJSON, FormatCSV} {
t.Run(string(format), func(t *testing.T) {
path := filepath.Join(t.TempDir(), "result."+string(format))
manager, err := NewManager(DefaultManagerConfig(path, format))
if err != nil {
t.Fatal(err)
}
defer manager.Close()
if err := manager.SaveResult(&ScanResult{Type: TypeHost, Target: "127.0.0.1"}); err != nil {
t.Fatal(err)
}
backupPath := path + ".realtime.tmp"
before, err := os.ReadFile(backupPath)
if err != nil || len(before) == 0 {
t.Fatalf("backup before Close: %q, %v", before, err)
}
var file *os.File
switch writer := manager.writer.(type) {
case *TXTWriter:
file = writer.file
case *JSONWriter:
file = writer.file
case *CSVWriter:
file = writer.file
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := manager.Close(); err == nil {
t.Fatal("final write failure was not reported")
}
after, err := os.ReadFile(backupPath)
if err != nil || !bytes.Equal(before, after) {
t.Fatalf("recovery backup was lost after final write failed: %q, %v", after, err)
}
})
}
}
+27 -43
View File
@@ -71,6 +71,25 @@ func targetWithPort(target string, port interface{}) string {
return net.JoinHostPort(target, portText)
}
// closeOutputFiles 仅在最终结果完整落盘并关闭后删除恢复备份。
func closeOutputFiles(file, backup *os.File, backupPath string, writeErr error) error {
if writeErr == nil {
writeErr = file.Sync()
}
if err := file.Close(); err != nil && writeErr == nil {
writeErr = err
}
if backup != nil {
if err := backup.Close(); err != nil && writeErr == nil {
writeErr = err
}
if writeErr == nil {
writeErr = os.Remove(backupPath)
}
}
return writeErr
}
// =============================================================================
// TXTWriter - 文本格式写入器
// =============================================================================
@@ -347,23 +366,7 @@ func (w *TXTWriter) Close() error {
w.closed = true
// 关闭并删除实时备份文件(正常结束,不再需要)
if w.realtimeFile != nil {
w.realtimeFile.Close()
os.Remove(w.realtimePath)
}
var firstErr error
if err := w.bufWriter.Flush(); err != nil {
firstErr = err
}
if err := w.file.Sync(); err != nil && firstErr == nil {
firstErr = err
}
if err := w.file.Close(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
return closeOutputFiles(w.file, w.realtimeFile, w.realtimePath, w.bufWriter.Flush())
}
// writeSection 写入一个分类的所有结果
@@ -565,22 +568,11 @@ func (w *JSONWriter) Close() error {
}
data, err := json.MarshalIndent(output, JSONIndentPrefix, JSONIndentString)
if err != nil {
return err
}
w.closed = true
// 关闭并删除实时备份文件(正常结束,不再需要)
if w.realtimeFile != nil {
w.realtimeFile.Close()
os.Remove(w.realtimePath)
if err == nil {
_, err = w.file.Write(data)
}
if _, err := w.file.Write(data); err != nil {
return err
}
return w.file.Close()
return closeOutputFiles(w.file, w.realtimeFile, w.realtimePath, err)
}
// GetFormat 获取格式类型
@@ -692,20 +684,12 @@ func (w *CSVWriter) Close() error {
w.closed = true
// 关闭并删除实时备份文件(正常结束,不再需要)
if w.realtimeFile != nil {
w.realtimeFile.Close()
os.Remove(w.realtimePath)
}
w.csvWriter.Flush()
if err := w.csvWriter.Error(); err != nil {
return err
err := w.csvWriter.Error()
if err == nil {
err = w.bufWriter.Flush()
}
if err := w.bufWriter.Flush(); err != nil {
return err
}
return w.file.Close()
return closeOutputFiles(w.file, w.realtimeFile, w.realtimePath, err)
}
func (w *CSVWriter) writeSection(title string, headers []string, results []*ScanResult, formatter func(*ScanResult) []string) {
+6 -1
View File
@@ -73,6 +73,11 @@ func (it *HostIterator) Close() error {
return firstErr
}
// IsExcluded 检查单个主机是否匹配当前迭代器的排除规则。
func (it *HostIterator) IsExcluded(host string) bool {
return it.exclude != nil && it.exclude.match(host)
}
func (it *HostIterator) Next() (string, bool, error) {
for {
if it.current == nil {
@@ -94,7 +99,7 @@ func (it *HostIterator) Next() (string, bool, error) {
it.current = nil
continue
}
if it.exclude != nil && it.exclude.match(host) {
if it.IsExcluded(host) {
continue
}
return host, true, nil
+40
View File
@@ -0,0 +1,40 @@
package common
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/shadow1ng/fscan/common/config"
"github.com/shadow1ng/fscan/common/parsers"
)
func TestBuildConfigLoadsPortsFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "ports.txt")
if err := os.WriteFile(path, []byte("# ports\n22\n8000-8002\n443,22\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, _, err := BuildConfig(&FlagVars{Ports: config.MainPorts, PortsFile: path}, &HostInfo{Host: "127.0.0.1"})
if err != nil {
t.Fatal(err)
}
if got, want := parsers.ParsePort(cfg.Target.Ports), []int{22, 443, 8000, 8001, 8002}; !reflect.DeepEqual(got, want) {
t.Fatalf("ports = %v, want %v", got, want)
}
}
func TestBuildConfigRejectsUnusablePortsFile(t *testing.T) {
for _, contents := range []string{"", "# no ports\n", "invalid\n0\n65536\n"} {
path := filepath.Join(t.TempDir(), "ports.txt")
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := BuildConfig(&FlagVars{Ports: "22", PortsFile: path}, &HostInfo{}); err == nil {
t.Errorf("accepted unusable port file %q", contents)
}
}
if _, _, err := BuildConfig(&FlagVars{PortsFile: filepath.Join(t.TempDir(), "missing.txt")}, &HostInfo{}); err == nil {
t.Error("accepted missing port file")
}
}
+19 -8
View File
@@ -106,9 +106,10 @@ func (s *ServiceScanStrategy) Description() string {
// Execute 执行服务扫描策略
func (s *ServiceScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
config := session.Config
s.SetState(session.State)
// 验证扫描目标(需要同时检查 -h 和 -hf 参数)
if info.Host == "" && session.Params.HostsFile == "" {
// host:port 已在配置解析阶段转入 State。
if info.Host == "" && session.Params.HostsFile == "" && len(session.State.GetHostPorts()) == 0 {
session.LogError(i18n.GetText("parse_error_target_empty"))
return
}
@@ -209,7 +210,11 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
if len(hostPorts) > 0 {
merged := mergeHostPorts(nil, hostPorts)
targets := s.convertToTargetInfos(merged, info)
excludedPorts := portSet(config.Target.ExcludePorts)
for _, target := range targets {
if iter.IsExcluded(target.Host) || excludedPorts[target.Port] {
continue
}
for _, pluginName := range pluginsToRun {
if s.IsPluginApplicableByName(pluginName, target.Host, target.Port, isCustomMode, config) {
executeScanTask(ctx, session, pluginName, target, ch, wg)
@@ -277,17 +282,14 @@ func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *c
// 用户指定 -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
}
userPorts = portSet(config.Target.Ports)
}
excludedPorts := portSet(config.Target.ExcludePorts)
for _, host := range hosts {
for _, pluginName := range udpPlugins {
for _, port := range plugins.GetPluginPorts(pluginName) {
if userPorts != nil && !userPorts[port] {
if excludedPorts[port] || (userPorts != nil && !userPorts[port]) {
continue
}
target := baseInfo
@@ -299,6 +301,15 @@ func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *c
}
}
func portSet(ports string) map[int]bool {
parsed := parsers.ParsePort(ports)
set := make(map[int]bool, len(parsed))
for _, port := range parsed {
set[port] = true
}
return set
}
// PrepareTargets 准备目标信息
func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo {
// 发现目标主机和端口
+130
View File
@@ -0,0 +1,130 @@
package core
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
type targetRecorderPlugin struct {
plugins.BasePlugin
calls chan common.HostInfo
}
func (p *targetRecorderPlugin) Scan(_ context.Context, info *common.HostInfo, _ *common.ScanSession) *plugins.Result {
p.calls <- *info
return &plugins.Result{Skipped: true}
}
func registerTargetRecorder(t *testing.T, kind string, ports ...int) (string, chan common.HostInfo) {
t.Helper()
name := "scope_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
calls := make(chan common.HostInfo, 16)
plugins.RegisterWithTypes(name, func() plugins.Plugin {
return &targetRecorderPlugin{BasePlugin: plugins.NewBasePlugin(name), calls: calls}
}, ports, []string{kind})
return name, calls
}
func TestServiceScanAcceptsExplicitHostPort(t *testing.T) {
for _, target := range []string{"127.0.0.1:10001", "[::1]:10001"} {
t.Run(target, func(t *testing.T) {
name, calls := registerTargetRecorder(t, plugins.PluginTypeService, 10001)
flags := &common.FlagVars{ScanMode: name, ThreadNum: 1, Silent: true}
info := common.HostInfo{Host: target}
cfg, state, err := common.BuildConfig(flags, &info)
if err != nil {
t.Fatal(err)
}
session := common.NewScanSession(cfg, state, flags)
var wg sync.WaitGroup
NewServiceScanStrategy().Execute(context.Background(), session, info, make(chan struct{}, 1), &wg)
wg.Wait()
if len(calls) != 1 {
t.Fatalf("dispatched %d targets, want the explicit host:port", len(calls))
}
called := <-calls
if got := called.Target(); got != target {
t.Errorf("dispatched %q, want %q", got, target)
}
})
}
}
func TestCachedTargetsRespectExclusions(t *testing.T) {
name, calls := registerTargetRecorder(t, plugins.PluginTypeService, 10001, 10002, 10003)
dir := t.TempDir()
hostsFile := filepath.Join(dir, "hosts.txt")
excludeFile := filepath.Join(dir, "exclude.txt")
if err := os.WriteFile(hostsFile, nil, 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(excludeFile, []byte("192.0.2.2\n::1\n"), 0600); err != nil {
t.Fatal(err)
}
flags := &common.FlagVars{ScanMode: name, HostsFile: hostsFile, ExcludeHosts: "192.0.2.1,198.51.100.0/24", ExcludeHostsFile: excludeFile, ExcludePorts: "10002-10003", Silent: true}
cfg := common.BuildConfigFromFlags(flags)
state := common.NewState()
state.SetHostPorts([]string{"127.0.0.1:10001", "127.0.0.1:10002", "127.0.0.1:10003", "192.0.2.1:10001", "192.0.2.2:10001", "198.51.100.20:10001", "[::1]:10001"})
session := common.NewScanSession(cfg, state, flags)
var wg sync.WaitGroup
NewServiceScanStrategy().Execute(context.Background(), session, common.HostInfo{}, make(chan struct{}, 8), &wg)
wg.Wait()
if len(calls) != 1 {
t.Fatalf("dispatched %d cached targets, want only the allowed target", len(calls))
}
called := <-calls
if got := called.Target(); got != "127.0.0.1:10001" {
t.Errorf("dispatched excluded target %q", got)
}
}
func TestUDPDispatchRespectsExcludedPorts(t *testing.T) {
name, calls := registerTargetRecorder(t, plugins.PluginTypeUDP, 10001, 10002)
cfg := common.NewConfig()
cfg.Mode = name
cfg.Target.Ports = "10001,10002"
cfg.Target.ExcludePorts = "10002"
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
var wg sync.WaitGroup
NewServiceScanStrategy().dispatchUDPPlugins(context.Background(), session, []string{"127.0.0.1"}, common.HostInfo{}, cfg, make(chan struct{}, 2), &wg)
wg.Wait()
if len(calls) != 1 {
t.Fatalf("dispatched %d UDP targets, want 1", len(calls))
}
if got := (<-calls).Port; got != 10001 {
t.Errorf("dispatched excluded UDP port %d", got)
}
}
func TestServiceScanUsesSessionCache(t *testing.T) {
name, calls := registerTargetRecorder(t, plugins.PluginTypeService, 10002)
hostsFile := filepath.Join(t.TempDir(), "hosts.txt")
if err := os.WriteFile(hostsFile, nil, 0600); err != nil {
t.Fatal(err)
}
flags := &common.FlagVars{ScanMode: "all", HostsFile: hostsFile, Silent: true}
cfg := common.BuildConfigFromFlags(flags)
state := common.NewState()
state.SetHostPorts([]string{"127.0.0.1:10003"})
CacheServiceInfoWithState(state, "127.0.0.1", 10003, &ServiceInfo{Name: name})
previous := globalState
SetGlobalState(common.NewState())
t.Cleanup(func() { SetGlobalState(previous) })
session := common.NewScanSession(cfg, state, flags)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
var wg sync.WaitGroup
NewServiceScanStrategy().Execute(ctx, session, common.HostInfo{}, make(chan struct{}, 16), &wg)
wg.Wait()
if len(calls) != 1 {
t.Fatalf("session service cache was ignored: dispatched %d matching plugins", len(calls))
}
}
+53
View File
@@ -0,0 +1,53 @@
package fscan
import (
"context"
"sync"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
type progressPlugin struct {
plugins.BasePlugin
observed <-chan struct{}
}
func (p *progressPlugin) Scan(ctx context.Context, _ *common.HostInfo, session *common.ScanSession) *plugins.Result {
session.State.IncrementPacketCount()
select {
case <-p.observed:
case <-ctx.Done():
}
return &plugins.Result{Skipped: true}
}
func TestOnProgressIncludesScanStateWithoutController(t *testing.T) {
const name = "test_progress_state"
observed := make(chan struct{})
plugins.RegisterWithTypes(name, func() plugins.Plugin {
return &progressPlugin{BasePlugin: plugins.NewBasePlugin(name), observed: observed}
}, []int{10001}, []string{plugins.PluginTypeService})
var once sync.Once
scanner := NewScanner(Config{
Plugins: []string{name},
OnProgress: func(p ScanProgress) {
if p.Packets > 0 {
once.Do(func() { close(observed) })
}
},
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := scanner.Scan(ctx, Target{Host: "127.0.0.1:10001"})
select {
case <-observed:
default:
t.Fatalf("OnProgress never observed the active scan state: %v", err)
}
if err != nil {
t.Fatal(err)
}
}
+1
View File
@@ -206,6 +206,7 @@ func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHand
ctrl := opts.controller
if ctrl == nil && s.config.OnProgress != nil {
ctrl = newScanController()
opts.controller = ctrl
}
ctx, cancel := context.WithCancel(ctx)
+54 -42
View File
@@ -34,14 +34,14 @@ type ScanRequest struct {
ExcludePorts string `json:"exclude_ports"`
// 扫描控制
ScanMode string `json:"scan_mode"`
ThreadNum int `json:"thread_num"`
Timeout int `json:"timeout"`
ModuleThreadNum int `json:"module_thread_num"`
DisablePing bool `json:"disable_ping"`
DisableBrute bool `json:"disable_brute"`
DisableSubnetProbe bool `json:"disable_subnet_probe"`
AliveOnly bool `json:"alive_only"`
ScanMode string `json:"scan_mode"`
ThreadNum int `json:"thread_num"`
Timeout int `json:"timeout"`
ModuleThreadNum int `json:"module_thread_num"`
DisablePing bool `json:"disable_ping"`
DisableBrute bool `json:"disable_brute"`
DisableSubnetProbe bool `json:"disable_subnet_probe"`
AliveOnly bool `json:"alive_only"`
// 认证
Username string `json:"username"`
@@ -169,6 +169,47 @@ func (h *ScanHandler) runScan(req ScanRequest) {
})
}()
info, session, err := buildScanSession(req)
if err != nil {
common.LogError(err.Error())
return
}
state := session.State
// 过渡桥:全局状态同步(待 Phase 5 移除)
common.SetGlobalConfig(session.Config)
common.SetGlobalState(state)
// 项目缓存注入:把已知的 host:port 加入扫描目标
if req.ProjectID != "" {
if cached := globalProjectStore.CachedHostPorts(req.ProjectID); len(cached) > 0 {
state.SetHostPorts(append(state.GetHostPorts(), cached...))
}
}
// 设置WebSocket结果回调
common.SetResultCallback(func(result interface{}) {
item := h.results.Add(result)
if item != nil {
h.hub.Broadcast(ws.MsgScanResult, item)
}
})
// 执行扫描
if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) {
common.LogError(err.Error())
}
// 项目缓存回写:合并本次扫描结果
if req.ProjectID != "" {
items := h.results.List()
if len(items) > 0 {
_ = globalProjectStore.MergeResults(req.ProjectID, items)
}
}
}
func buildScanSession(req ScanRequest) (common.HostInfo, *common.ScanSession, error) {
// 构建HostInfo
info := common.HostInfo{
Host: req.Host,
@@ -218,41 +259,12 @@ func (h *ScanHandler) runScan(req ScanRequest) {
}
// 构建Config和Session
config := common.BuildConfigFromFlags(fv)
state := common.NewState()
config, state, err := common.BuildConfig(fv, &info)
if err != nil {
return info, nil, err
}
session := common.NewScanSession(config, state, fv)
// 过渡桥:全局状态同步(待 Phase 5 移除)
common.SetGlobalConfig(config)
common.SetGlobalState(state)
// 项目缓存注入:把已知的 host:port 加入扫描目标
if req.ProjectID != "" {
if cached := globalProjectStore.CachedHostPorts(req.ProjectID); len(cached) > 0 {
state.SetHostPorts(cached)
}
}
// 设置WebSocket结果回调
common.SetResultCallback(func(result interface{}) {
item := h.results.Add(result)
if item != nil {
h.hub.Broadcast(ws.MsgScanResult, item)
}
})
// 执行扫描
if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) {
common.LogError(err.Error())
}
// 项目缓存回写:合并本次扫描结果
if req.ProjectID != "" {
items := h.results.List()
if len(items) > 0 {
_ = globalProjectStore.MergeResults(req.ProjectID, items)
}
}
return info, session, nil
}
// Stop 停止扫描
+46
View File
@@ -0,0 +1,46 @@
//go:build web
package api
import (
"reflect"
"testing"
scanplugins "github.com/shadow1ng/fscan/plugins"
)
func TestBuildScanSessionUsesRequestedCredentials(t *testing.T) {
for _, tc := range []struct {
name string
username string
password string
want []scanplugins.Credential
}{
{"single pair", "operator", " secret ", []scanplugins.Credential{{Username: "operator", Password: " secret "}}},
{"multiple users", "alice,bob", "secret", []scanplugins.Credential{{Username: "alice", Password: "secret"}, {Username: "bob", Password: "secret"}}},
} {
t.Run(tc.name, func(t *testing.T) {
_, session, err := buildScanSession(ScanRequest{Host: "127.0.0.1", Username: tc.username, Password: tc.password})
if err != nil {
t.Fatal(err)
}
if got := scanplugins.GenerateCredentials("ssh", session.Config); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("credentials = %v, want %v", got, tc.want)
}
})
}
}
func TestBuildScanSessionParsesExplicitHostPort(t *testing.T) {
for _, host := range []string{"127.0.0.1:10001", "[::1]:10001"} {
t.Run(host, func(t *testing.T) {
info, session, err := buildScanSession(ScanRequest{Host: host})
if err != nil {
t.Fatal(err)
}
if got := session.State.GetHostPorts(); info.Host != "" || !reflect.DeepEqual(got, []string{host}) {
t.Fatalf("explicit target was not parsed: host=%q, cached=%v", info.Host, got)
}
})
}
}