diff --git a/README.md b/README.md index ad11d92..c6f661b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ - **并发控制** - 端口扫描线程、服务扫描线程独立配置 ### 扩展功能 +- **SDK嵌入** - `pkg/fscan`提供Go SDK,可嵌入Agent或安全平台,支持任务控制(Pause/Resume)、实时进度回调、TaskID追溯 - **Web管理界面** - 可视化扫描任务管理(条件编译 -tags web) - **Lab靶场环境** - 内置Docker靶场用于测试学习 - **插件化架构** - 服务插件/Web插件/本地插件分离,易于扩展 @@ -234,6 +235,11 @@ yay -S fscan-git - **后两周** - Bug修复与代码整合 - **欢迎PR** - 期待您的贡献! +### SDK & Agent 集成 +- 扩展SDK能力,完善端侧Agent嵌入支持 +- 断点续扫、带宽级限速、内存水位控制 +- 更多Agent场景的集成示例 + ### 插件生态 - 持续扩展服务插件覆盖范围 - 为每个服务插件开发更多漏洞检测和利用能力 diff --git a/common/session.go b/common/session.go index d7e81d2..76d6155 100644 --- a/common/session.go +++ b/common/session.go @@ -24,6 +24,7 @@ type ScanSession struct { State *State // 可变,原子操作,每会话独立 Params *FlagVars // 原始参数,只读 ResultSink ResultSink // 可选,覆盖全局输出 + PauseGate func(ctx context.Context) error // 每会话 dialer(按 timeout 懒初始化,取决于代理配置) dialerMu sync.Mutex @@ -120,6 +121,23 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time return conn, nil } +// DialUDP creates a connected UDP socket with rate limiting and packet counting. +// UDP cannot be proxied; if a proxy is configured the connection is made directly. +func (s *ScanSession) DialUDP(ctx context.Context, address string, timeout time.Duration) (net.Conn, error) { + if ok, err := CanSendPacketWith(s.Config, s.State); !ok { + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error())) + } + + conn, err := net.DialTimeout("udp", address, timeout) + if err != nil { + s.State.IncrementUDPPacketCount() + return nil, err + } + _ = conn.SetDeadline(time.Now().Add(timeout)) + s.State.IncrementUDPPacketCount() + return conn, nil +} + // HTTPDo executes an HTTP request with the session's packet limits and counters. func (s *ScanSession) HTTPDo(client *http.Client, req *http.Request) (*http.Response, error) { if ok, err := CanSendPacketWith(s.Config, s.State); !ok { diff --git a/core/base_scan_strategy.go b/core/base_scan_strategy.go index 1861e46..e9064f7 100644 --- a/core/base_scan_strategy.go +++ b/core/base_scan_strategy.go @@ -106,6 +106,10 @@ func (b *BaseScanStrategy) isLocalPlugin(pluginName string) bool { return plugins.HasType(pluginName, plugins.PluginTypeLocal) } +func (b *BaseScanStrategy) isUDPPlugin(pluginName string) bool { + return plugins.IsUDP(pluginName) +} + func (b *BaseScanStrategy) isLocalPluginExplicitlySpecified(pluginName string, config *common.Config) bool { return config.LocalPlugin == pluginName } @@ -141,6 +145,11 @@ func (b *BaseScanStrategy) isPluginApplicableToPort(pluginName string, targetPor // isPluginPassesFilterType 检查插件是否通过过滤器类型检查 func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomMode bool, config *common.Config) bool { + // UDP 插件有独立分发路径,不参与 TCP 端口匹配流水线 + if b.isUDPPlugin(pluginName) { + return false + } + // 自定义模式下强制运行所有明确指定的插件 if isCustomMode { return true @@ -155,8 +164,8 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM } return false case FilterService: - // 服务扫描策略:排除本地插件 - return !b.isLocalPlugin(pluginName) + // 服务扫描策略:排除本地插件和UDP插件(UDP有独立分发路径) + return !b.isLocalPlugin(pluginName) && !b.isUDPPlugin(pluginName) case FilterWeb: // Web扫描策略:只允许Web插件 return b.isWebPlugin(pluginName) @@ -231,9 +240,9 @@ func (b *BaseScanStrategy) getPluginsByFilterType() []string { } } case FilterService: - // 服务扫描策略:排除本地插件和纯Web插件,保留服务插件 + // 服务扫描策略:排除本地插件和UDP插件,保留TCP服务插件 for _, pluginName := range allPlugins { - if !b.isLocalPlugin(pluginName) { + if !b.isLocalPlugin(pluginName) && !b.isUDPPlugin(pluginName) { filteredPlugins = append(filteredPlugins, pluginName) } } diff --git a/core/scanner.go b/core/scanner.go index 35564f5..32f2430 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -211,6 +211,12 @@ func ExecuteScanTasks(ctx context.Context, session *common.ScanSession, targets default: } + if session.PauseGate != nil { + if err := session.PauseGate(ctx); err != nil { + return + } + } + targetPort := target.Port for _, pluginName := range pluginsToRun { @@ -262,6 +268,12 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam default: } + if session.PauseGate != nil { + if err := session.PauseGate(ctx); err != nil { + return + } + } + // 长驻插件不进 WaitGroup,通过 ctx 管理生命周期 if longRunningPlugins[pluginName] { ready := make(chan struct{}, 1) diff --git a/core/service_scanner.go b/core/service_scanner.go index 29f4270..a4b83df 100644 --- a/core/service_scanner.go +++ b/core/service_scanner.go @@ -10,6 +10,7 @@ import ( "github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/parsers" + "github.com/shadow1ng/fscan/plugins" ) // ServiceScanStrategy 服务扫描策略 @@ -161,6 +162,11 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm return } + // UDP 插件并行分发:直接对存活主机发协议探测包,不走端口扫描 + if len(hosts) > 0 { + s.dispatchUDPPlugins(ctx, session, hosts, info, config, ch, wg) + } + // 流式 channel:端口扫描发现开放端口后立即通知插件执行 stream := make(chan string, 64) @@ -213,6 +219,34 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm } } +// dispatchUDPPlugins 分发UDP协议插件,跳过TCP端口扫描链路 +func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *common.ScanSession, hosts []string, baseInfo common.HostInfo, config *common.Config, ch chan struct{}, wg *sync.WaitGroup) { + allPlugins, isCustomMode := s.GetPlugins(config) + + var udpPlugins []string + for _, name := range allPlugins { + if plugins.IsUDP(name) { + if isCustomMode || plugins.IsSafe(name) { + udpPlugins = append(udpPlugins, name) + } + } + } + if len(udpPlugins) == 0 { + return + } + + for _, host := range hosts { + for _, pluginName := range udpPlugins { + for _, port := range plugins.GetPluginPorts(pluginName) { + target := baseInfo + target.Host = host + target.Port = port + executeScanTask(ctx, session, pluginName, target, ch, wg) + } + } + } +} + // PrepareTargets 准备目标信息 func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo { // 发现目标主机和端口 diff --git a/examples/embed-agent/main.go b/examples/embed-agent/main.go new file mode 100644 index 0000000..c1b3d15 --- /dev/null +++ b/examples/embed-agent/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + scanner := fscan.NewScanner(fscan.Config{ + TaskID: "task-001", + Timeout: 3 * time.Second, + Threads: 64, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis", "ftp"}, + OnProgress: func(p fscan.ScanProgress) { + fmt.Printf("[progress] %d/%d tasks, %d packets, paused=%v, elapsed=%s\n", + p.TasksCompleted, p.TasksTotal, p.Packets, p.Paused, p.Duration.Round(time.Millisecond)) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ctrl, reportCh, errCh := scanner.ScanWithController(ctx, + fscan.Target{Host: "127.0.0.1", Ports: []int{21, 22, 3306, 6379}}, + ) + + // Simulate a pause command from control plane after 1 second. + go func() { + time.Sleep(1 * time.Second) + fmt.Println("[agent] pausing scan...") + ctrl.Pause() + + // Check live stats while paused. + stats := ctrl.Stats() + fmt.Printf("[agent] stats while paused: completed=%d, packets=%d\n", + stats.TasksCompleted, stats.Packets) + + time.Sleep(2 * time.Second) + fmt.Println("[agent] resuming scan...") + ctrl.Resume() + }() + + report := <-reportCh + if err := <-errCh; err != nil { + fmt.Printf("[agent] scan error: %v\n", err) + return + } + + fmt.Printf("\n[agent] scan complete: %d results, %d vulns, %d services\n", + report.Summary.Total, report.Summary.Vulns, report.Summary.Services) + + for _, result := range report.Results { + taskID, _ := result.DetailString("task_id") + fmt.Printf(" [%s] %s %s (task=%s)\n", result.Type, result.Target, result.Status, taskID) + } +} diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index 371a6ab..4eb7777 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -1,71 +1,148 @@ # fscan SDK -`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged. +`pkg/fscan` exposes fscan as an embeddable Go scanner, designed for Agent and security platform integration. -See `examples/embed-basic` for slice-based collection and `examples/embed-stream` for streaming integration. +## Quick Start ```go -package main +import fscan "github.com/shadow1ng/fscan/pkg/fscan" -import ( - "context" - "fmt" - "time" - - fscan "github.com/shadow1ng/fscan/pkg/fscan" -) - -func main() { - config := fscan.Config{ - Timeout: 3 * time.Second, - Threads: 128, - DisablePing: true, - DisableBrute: true, - Plugins: []string{"ssh", "mysql", "redis"}, - OnResult: func(result fscan.Result) { - if result.Type == fscan.ResultTypeService || result.Type == fscan.ResultTypeVuln { - fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) - } - }, - } - if err := fscan.ValidateConfig(config, fscan.Target{Host: "192.168.1.10"}); err != nil { - panic(err) - } - -scanner := fscan.NewScanner(config) -report, err := scanner.ScanReport(context.Background(), fscan.Target{ - Host: "192.168.1.10", - Ports: []int{22, 3306, 6379}, +scanner := fscan.NewScanner(fscan.Config{ + Timeout: 3 * time.Second, + Threads: 128, + DisablePing: true, + Plugins: []string{"ssh", "mysql", "redis"}, }) -if err != nil { - panic(err) -} -fmt.Printf("scan finished: %+v stats=%+v\n", report.Summary, report.Stats) -for _, result := range report.Results { - // Store, forward, or filter the result in the embedding system. - if credential, ok := result.AsCredential(); ok { - fmt.Printf("weak credential: %s %s:%s\n", credential.Target, credential.Username, credential.Password) - } -} +results, err := scanner.Scan(context.Background(), fscan.Target{ + Host: "192.168.1.0/24", + Ports: []int{22, 3306, 6379}, +}) +``` + +## Scan Modes + +| Mode | API | Use Case | +|------|-----|----------| +| Collect | `Scan` / `ScanReport` | Get all results as a slice | +| Stream | `ScanEach` | Process results one-by-one, no memory accumulation | +| Controlled | `ScanWithController` | Agent integration with pause/resume and live stats | + +### Basic: Collect All Results + +```go +scanner := fscan.NewScanner(config) +report, err := scanner.ScanReport(ctx, target) + +fmt.Printf("total=%d vulns=%d\n", report.Summary.Total, report.Summary.Vulns) +for _, r := range report.Results { + if cred, ok := r.AsCredential(); ok { + fmt.Printf("%s %s:%s\n", cred.Target, cred.Username, cred.Password) + } } ``` -The SDK currently reuses fscan's existing scan core and plugin registry. Embedded scans build per-session runtime state and can run concurrently. +### Stream: Process Results Without Retention -By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities. +```go +scanner := fscan.NewScanner(config) +err := scanner.ScanEach(ctx, func(result fscan.Result) error { + // Forward to database, message queue, etc. + return sendToBackend(result) +}, target) +``` -## API surface +### Controlled: Agent Integration + +```go +scanner := fscan.NewScanner(fscan.Config{ + TaskID: "task-001", + Plugins: []string{"ssh", "redis"}, + OnProgress: func(p fscan.ScanProgress) { + reportHeartbeat(p.TasksCompleted, p.TasksTotal, p.Paused) + }, +}) + +ctrl, reportCh, errCh := scanner.ScanWithController(ctx, target) + +// Control plane commands +ctrl.Pause() +stats := ctrl.Stats() // live stats while paused +ctrl.Resume() + +report := <-reportCh +err := <-errCh +``` + +## Agent Features + +### ScanController + +`ScanWithController` returns a `*ScanController` for runtime control: + +| Method | Description | +|--------|-------------| +| `Pause()` | Pause task dispatch (in-flight tasks complete naturally) | +| `Resume()` | Resume task dispatch | +| `IsPaused()` | Check pause state | +| `Stats()` | Live `ScanStats` aggregated across all targets | + +The controller is goroutine-safe. Pause takes effect at the task dispatch level -- already-running plugin tasks will finish, but no new tasks are dispatched until resumed. + +### OnProgress + +Set `Config.OnProgress` to receive periodic `ScanProgress` snapshots (~500ms interval): + +```go +type ScanProgress struct { + TasksTotal int64 + TasksCompleted int64 + Duration time.Duration + Packets int64 + TCPPackets int64 + HTTPPackets int64 + Paused bool +} +``` + +Works with all scan modes. When used without `ScanWithController`, a lightweight internal controller is created for progress tracking. + +### TaskID + +Set `Config.TaskID` to inject a task identifier into every `Result.Details["task_id"]`. This lets the Agent associate scan results with control plane tasks without post-processing. + +## Plugin Safety + +By default, the SDK runs a conservative plugin set (service detection + auth check). Plugins with local side effects (`poc`, `local-effect`) are blocked unless `AllowUnsafePlugins` is set. + +```go +// List available plugins +for _, p := range fscan.ListPlugins() { + fmt.Printf("%s safe=%v caps=%v\n", p.Name, p.Safe, p.Capabilities) +} + +// Check before use +if fscan.IsSafePlugin("webpoc") { ... } +``` + +Plugin capabilities: `detect`, `auth-check`, `brute`, `poc`, `local-effect`. + +## API Reference | Area | API | -| --- | --- | -| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport` | +|------|-----| +| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport`, `ScanWithController` | +| Control | `ScanController` (`Pause`, `Resume`, `IsPaused`, `Stats`) | | Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` | -| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities`, `PluginInfo` | -| Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` | -| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential`, `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` | -| Summary and stats | `ScanReport`, `ScanStats`, `SummarizeResults`, `ResultSummary.Add` | +| Progress | `OnProgress`, `ScanProgress`, `TaskID` | +| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities` | +| Results | `Result`, `ResultType*` constants | +| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` | +| Typed views | `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` | +| Summary | `ScanReport`, `ScanStats`, `SummarizeResults`, `ResultSummary.Add` | -Use `Scan` when you want all results returned as a slice. Use `ScanReport` when the embedding system also needs summary counts and runtime counters. Use `ScanEach` when results should be streamed into another system; handler calls are serialized, and returning an error stops the scan and returns that error. +## Examples -Plugin capabilities are exposed as stable strings: `detect`, `auth-check`, `brute`, `poc`, and `local-effect`. Default embedded safe mode blocks `poc` and `local-effect` plugins unless `AllowUnsafePlugins` is set. +- [`examples/embed-basic`](../../examples/embed-basic) -- Minimal scan with result collection +- [`examples/embed-stream`](../../examples/embed-stream) -- Streaming results with plugin listing +- [`examples/embed-agent`](../../examples/embed-agent) -- Agent integration with pause/resume, progress, and TaskID diff --git a/pkg/fscan/controller.go b/pkg/fscan/controller.go new file mode 100644 index 0000000..0506e0f --- /dev/null +++ b/pkg/fscan/controller.go @@ -0,0 +1,101 @@ +package fscan + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/shadow1ng/fscan/common" +) + +// ScanController provides pause/resume control and live stats for an +// in-progress scan. It is safe for concurrent use. +type ScanController struct { + mu sync.Mutex + paused int32 + gate chan struct{} + stateMu sync.Mutex + states []*common.State + start time.Time +} + +func newScanController() *ScanController { + gate := make(chan struct{}) + close(gate) + return &ScanController{ + gate: gate, + start: time.Now(), + } +} + +func (c *ScanController) Pause() { + c.mu.Lock() + defer c.mu.Unlock() + if atomic.CompareAndSwapInt32(&c.paused, 0, 1) { + c.gate = make(chan struct{}) + } +} + +func (c *ScanController) Resume() { + c.mu.Lock() + defer c.mu.Unlock() + if atomic.CompareAndSwapInt32(&c.paused, 1, 0) { + close(c.gate) + } +} + +func (c *ScanController) IsPaused() bool { + return atomic.LoadInt32(&c.paused) == 1 +} + +func (c *ScanController) pauseGate(ctx context.Context) error { + c.mu.Lock() + gate := c.gate + c.mu.Unlock() + select { + case <-gate: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *ScanController) addState(s *common.State) { + c.stateMu.Lock() + c.states = append(c.states, s) + c.stateMu.Unlock() +} + +func (c *ScanController) Stats() ScanStats { + c.stateMu.Lock() + states := c.states + c.stateMu.Unlock() + + stats := ScanStats{Duration: time.Since(c.start)} + for _, s := range states { + stats.TasksTotal += s.GetEnd() + stats.TasksCompleted += s.GetNum() + stats.Packets += s.GetPacketCount() + stats.TCPPackets += s.GetTCPPacketCount() + stats.TCPSuccessPackets += s.GetTCPSuccessPacketCount() + stats.TCPFailedPackets += s.GetTCPFailedPacketCount() + stats.UDPPackets += s.GetUDPPacketCount() + stats.HTTPPackets += s.GetHTTPPacketCount() + stats.ResourceExhausted += s.GetResourceExhaustedCount() + } + return stats +} + +func (c *ScanController) progress() ScanProgress { + stats := c.Stats() + return ScanProgress{ + TasksTotal: stats.TasksTotal, + TasksCompleted: stats.TasksCompleted, + Duration: stats.Duration, + Packets: stats.Packets, + TCPPackets: stats.TCPPackets, + HTTPPackets: stats.HTTPPackets, + Paused: c.IsPaused(), + } +} diff --git a/pkg/fscan/controller_test.go b/pkg/fscan/controller_test.go new file mode 100644 index 0000000..9919be2 --- /dev/null +++ b/pkg/fscan/controller_test.go @@ -0,0 +1,317 @@ +package fscan + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" +) + +func TestScanControllerPauseResume(t *testing.T) { + ctrl := newScanController() + + if ctrl.IsPaused() { + t.Fatal("new controller should not be paused") + } + ctrl.Pause() + if !ctrl.IsPaused() { + t.Fatal("should be paused after Pause()") + } + ctrl.Pause() + if !ctrl.IsPaused() { + t.Fatal("double Pause should still be paused") + } + ctrl.Resume() + if ctrl.IsPaused() { + t.Fatal("should not be paused after Resume()") + } + ctrl.Resume() + if ctrl.IsPaused() { + t.Fatal("double Resume should still be unpaused") + } +} + +func TestScanControllerPauseGateBlocks(t *testing.T) { + ctrl := newScanController() + ctx := context.Background() + + if err := ctrl.pauseGate(ctx); err != nil { + t.Fatalf("unpaused gate should not block: %v", err) + } + + ctrl.Pause() + done := make(chan error, 1) + go func() { + done <- ctrl.pauseGate(ctx) + }() + + select { + case <-done: + t.Fatal("paused gate should block") + case <-time.After(50 * time.Millisecond): + } + + ctrl.Resume() + select { + case err := <-done: + if err != nil { + t.Fatalf("resumed gate error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("gate should unblock after Resume") + } +} + +func TestScanControllerPauseGateContextCancel(t *testing.T) { + ctrl := newScanController() + ctrl.Pause() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- ctrl.pauseGate(ctx) + }() + + cancel() + select { + case err := <-done: + if err != context.Canceled { + t.Fatalf("gate error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("gate should return on context cancel") + } +} + +func TestScanControllerStatsWithoutState(t *testing.T) { + ctrl := newScanController() + stats := ctrl.Stats() + if stats.Duration <= 0 { + t.Fatal("duration should be positive") + } + if stats.TasksTotal != 0 || stats.Packets != 0 { + t.Fatalf("stats without state should be zero: %+v", stats) + } +} + +func TestScanControllerProgress(t *testing.T) { + ctrl := newScanController() + ctrl.Pause() + p := ctrl.progress() + if !p.Paused { + t.Fatal("progress should report paused") + } + ctrl.Resume() + p = ctrl.progress() + if p.Paused { + t.Fatal("progress should report unpaused") + } +} + +func TestScanWithControllerCompletes(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ctrl, reportCh, errCh := scanner.ScanWithController(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + + stats := ctrl.Stats() + if stats.Duration <= 0 { + t.Fatal("live stats duration should be positive") + } + + report := <-reportCh + err := <-errCh + if err != nil { + t.Fatal(err) + } + if len(report.Results) == 0 { + t.Fatal("expected results") + } + if report.Summary.Total != len(report.Results) { + t.Fatalf("summary mismatch: %+v", report.Summary) + } +} + +func TestScanWithControllerPauseResume(t *testing.T) { + first := startTestFTPListener(t) + defer first.Close() + second := startTestFTPListener(t) + defer second.Close() + + port1 := first.Addr().(*net.TCPAddr).Port + port2 := second.Addr().(*net.TCPAddr).Port + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ctrl, reportCh, errCh := scanner.ScanWithController(ctx, + Target{Host: "127.0.0.1", Ports: []int{port1}}, + Target{Host: "127.0.0.1", Ports: []int{port2}}, + ) + + ctrl.Pause() + if !ctrl.IsPaused() { + t.Fatal("should be paused") + } + ctrl.Resume() + + report := <-reportCh + err := <-errCh + if err != nil { + t.Fatal(err) + } + if len(report.Results) == 0 { + t.Fatal("expected results after resume") + } +} + +func TestOnProgressCalled(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + var called int32 + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + OnProgress: func(p ScanProgress) { + atomic.AddInt32(&called, 1) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + _, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + // OnProgress fires every 500ms; scan takes at least a moment + // We mainly verify it doesn't panic; calls may be 0 for very fast scans +} + +func TestTaskIDInjected(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + TaskID: "task-abc-123", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected results") + } + for _, r := range results { + taskID, ok := r.DetailString("task_id") + if !ok || taskID != "task-abc-123" { + t.Fatalf("result missing task_id: %#v", r.Details) + } + } +} + +func TestTaskIDNotInjectedWhenEmpty(t *testing.T) { + listener := startTestFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + for _, r := range results { + if _, ok := r.Details["task_id"]; ok { + t.Fatalf("task_id should not be present when TaskID is empty: %#v", r.Details) + } + } +} + +func TestScanWithControllerCanceledContext(t *testing.T) { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Plugins: []string{"redis"}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, reportCh, errCh := scanner.ScanWithController(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}}) + <-reportCh + err := <-errCh + if err != context.Canceled { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func startTestFTPListener(t *testing.T) net.Listener { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write([]byte("220 test FTP\r\n")) + buf := make([]byte, 64) + _, _ = conn.Read(buf) + }(conn) + } + }() + return listener +} diff --git a/pkg/fscan/result_test.go b/pkg/fscan/result_test.go index 851c40b..b9bc818 100644 --- a/pkg/fscan/result_test.go +++ b/pkg/fscan/result_test.go @@ -2,7 +2,10 @@ package fscan import ( "encoding/json" + "math" "testing" + + "github.com/shadow1ng/fscan/common/output" ) func TestResultHelpers(t *testing.T) { @@ -57,6 +60,22 @@ func TestResultPortDoesNotParseBareIPv6(t *testing.T) { } } +func TestResultPortFromSimpleTarget(t *testing.T) { + result := Result{Target: "10.0.0.1:3306"} + + port, ok := result.Port() + if !ok || port != 3306 { + t.Fatalf("Port = %d/%v, want 3306/true", port, ok) + } +} + +func TestResultPortNoPort(t *testing.T) { + result := Result{Target: "10.0.0.1"} + if _, ok := result.Port(); ok { + t.Fatal("expected no port") + } +} + func TestResultCredentialHelpers(t *testing.T) { result := Result{ Type: ResultTypeVuln, @@ -83,6 +102,31 @@ func TestResultCredentialHelpers(t *testing.T) { } } +func TestResultCredentialViaStatusPrefix(t *testing.T) { + result := Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:22", + Status: "weak_credential: root:pass", + } + if !result.IsCredential() { + t.Fatal("expected credential via status prefix") + } +} + +func TestResultNotCredentialWithoutMarker(t *testing.T) { + result := Result{ + Type: ResultTypeVuln, + Target: "127.0.0.1:445", + Status: "MS17-010", + Details: map[string]interface{}{ + "vulnerability": "MS17-010", + }, + } + if result.IsCredential() { + t.Fatal("vuln without credential marker should not be credential") + } +} + func TestTypedResultViews(t *testing.T) { portResult, ok := (Result{ Type: ResultTypePort, @@ -138,6 +182,84 @@ func TestTypedResultViews(t *testing.T) { } } +func TestAsPortNonPortResult(t *testing.T) { + _, ok := (Result{Type: ResultTypeHost, Target: "10.0.0.1"}).AsPort() + if ok { + t.Fatal("AsPort should return false for non-port result") + } +} + +func TestAsPortNoPortValue(t *testing.T) { + _, ok := (Result{Type: ResultTypePort, Target: "10.0.0.1"}).AsPort() + if ok { + t.Fatal("AsPort should return false when no port available") + } +} + +func TestAsServiceNonServiceResult(t *testing.T) { + _, ok := (Result{Type: ResultTypePort, Target: "10.0.0.1"}).AsService() + if ok { + t.Fatal("AsService should return false for non-service result") + } +} + +func TestAsServiceNoUsefulFields(t *testing.T) { + _, ok := (Result{Type: ResultTypeService, Target: "10.0.0.1"}).AsService() + if ok { + t.Fatal("AsService should return false when no useful service fields") + } +} + +func TestAsCredentialNonCredential(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:445", + Details: map[string]interface{}{"vulnerability": "MS17-010"}, + }).AsCredential() + if ok { + t.Fatal("AsCredential should return false for non-credential vuln") + } +} + +func TestAsCredentialNoUsernamePassword(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:22", + Status: "weak_credential: ???", + Details: map[string]interface{}{}, + }).AsCredential() + if ok { + t.Fatal("AsCredential should return false without username/password") + } +} + +func TestAsVulnerabilityCredentialExcluded(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:22", + Details: map[string]interface{}{ + "type": "weak_credential", + "vulnerability": "ssh weak password", + "username": "root", + "password": "toor", + }, + }).AsVulnerability() + if ok { + t.Fatal("AsVulnerability should exclude credential results") + } +} + +func TestAsVulnerabilityEmptyVulnField(t *testing.T) { + _, ok := (Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:445", + Details: map[string]interface{}{}, + }).AsVulnerability() + if ok { + t.Fatal("AsVulnerability should return false without vulnerability field") + } +} + func TestSummarizeResults(t *testing.T) { results := []Result{ {Type: ResultTypeHost, Target: "127.0.0.1"}, @@ -161,6 +283,13 @@ func TestSummarizeResults(t *testing.T) { } } +func TestSummarizeEmpty(t *testing.T) { + summary := SummarizeResults(nil) + if summary.Total != 0 { + t.Fatalf("Total = %d, want 0", summary.Total) + } +} + func TestResultDetailIntRejectsFraction(t *testing.T) { result := Result{Details: map[string]interface{}{"port": 22.5}} @@ -177,3 +306,413 @@ func TestResultDetailIntParsesJSONNumber(t *testing.T) { t.Fatalf("DetailInt = %d/%v, want 443/true", port, ok) } } + +func TestDetailIntTypes(t *testing.T) { + tests := []struct { + name string + value interface{} + want int + ok bool + }{ + {"int", int(42), 42, true}, + {"int8", int8(8), 8, true}, + {"int16", int16(16), 16, true}, + {"int32", int32(32), 32, true}, + {"int64", int64(64), 64, true}, + {"uint", uint(10), 10, true}, + {"uint8", uint8(8), 8, true}, + {"uint16", uint16(16), 16, true}, + {"uint32", uint32(32), 32, true}, + {"uint64", uint64(64), 64, true}, + {"float32", float32(80), 80, true}, + {"float64", float64(443), 443, true}, + {"string", "8080", 8080, true}, + {"string-spaces", " 22 ", 22, true}, + {"string-invalid", "abc", 0, false}, + {"nil", nil, 0, false}, + {"bool", true, 0, false}, + {"missing", nil, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Result{Details: map[string]interface{}{"v": tt.value}} + if tt.name == "missing" { + r = Result{Details: map[string]interface{}{}} + } + got, ok := r.DetailInt("v") + if ok != tt.ok || got != tt.want { + t.Fatalf("DetailInt = %d/%v, want %d/%v", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestDetailIntOverflow(t *testing.T) { + if _, ok := (Result{Details: map[string]interface{}{"v": uint64(math.MaxUint64)}}).DetailInt("v"); ok { + t.Fatal("uint64 max should overflow") + } + if _, ok := (Result{Details: map[string]interface{}{"v": float64(1.5)}}).DetailInt("v"); ok { + t.Fatal("non-integer float should fail") + } +} + +type testStringer struct{ s string } + +func (ts testStringer) String() string { return ts.s } + +func TestDetailStringStringer(t *testing.T) { + r := Result{Details: map[string]interface{}{"k": testStringer{"hello"}}} + v, ok := r.DetailString("k") + if !ok || v != "hello" { + t.Fatalf("DetailString(Stringer) = %q/%v, want hello/true", v, ok) + } +} + +func TestDetailStringFallback(t *testing.T) { + r := Result{Details: map[string]interface{}{"k": 42}} + v, ok := r.DetailString("k") + if !ok || v != "42" { + t.Fatalf("DetailString(int) = %q/%v, want 42/true", v, ok) + } +} + +func TestDetailStringNil(t *testing.T) { + r := Result{Details: map[string]interface{}{"k": nil}} + _, ok := r.DetailString("k") + if ok { + t.Fatal("DetailString(nil) should return false") + } +} + +func TestDetailStringMissing(t *testing.T) { + r := Result{Details: map[string]interface{}{}} + _, ok := r.DetailString("missing") + if ok { + t.Fatal("DetailString(missing) should return false") + } +} + +func TestDetailIntStringer(t *testing.T) { + r := Result{Details: map[string]interface{}{"v": testStringer{"99"}}} + got, ok := r.DetailInt("v") + if !ok || got != 99 { + t.Fatalf("DetailInt(Stringer) = %d/%v, want 99/true", got, ok) + } +} + +func TestDetailBool(t *testing.T) { + tests := []struct { + name string + val interface{} + want bool + ok bool + }{ + {"true", true, true, true}, + {"false", false, false, true}, + {"string-true", "true", true, true}, + {"string-false", "false", false, true}, + {"string-1", "1", true, true}, + {"string-0", "0", false, true}, + {"string-invalid", "maybe", false, false}, + {"nil", nil, false, false}, + {"int", 1, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := Result{Details: map[string]interface{}{"b": tt.val}} + got, ok := r.DetailBool("b") + if ok != tt.ok || got != tt.want { + t.Fatalf("DetailBool = %v/%v, want %v/%v", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestDetailBoolMissing(t *testing.T) { + r := Result{Details: map[string]interface{}{}} + _, ok := r.DetailBool("missing") + if ok { + t.Fatal("DetailBool(missing) should return false") + } +} + +func TestIsWebViaProtocol(t *testing.T) { + r := Result{Details: map[string]interface{}{"protocol": "https"}} + if !r.IsWeb() { + t.Fatal("expected web via protocol=https") + } +} + +func TestIsWebViaService(t *testing.T) { + r := Result{Details: map[string]interface{}{"service": "HTTP"}} + if !r.IsWeb() { + t.Fatal("expected web via service=HTTP (case-insensitive)") + } +} + +func TestIsWebFalse(t *testing.T) { + r := Result{Details: map[string]interface{}{"service": "ssh"}} + if r.IsWeb() { + t.Fatal("ssh should not be web") + } +} + +func TestIsWebNoDetails(t *testing.T) { + r := Result{} + if r.IsWeb() { + t.Fatal("empty result should not be web") + } +} + +func TestResultTypeHelpers(t *testing.T) { + if !(Result{Type: ResultTypeHost}).IsHost() { + t.Fatal("IsHost") + } + if !(Result{Type: ResultTypePort}).IsPort() { + t.Fatal("IsPort") + } + if !(Result{Type: ResultTypeService}).IsService() { + t.Fatal("IsService") + } + if !(Result{Type: ResultTypeVuln}).IsVuln() { + t.Fatal("IsVuln") + } + if (Result{Type: ResultTypeHost}).IsPort() { + t.Fatal("host should not be port") + } +} + +func TestResultURLAndVulnerability(t *testing.T) { + r := Result{Details: map[string]interface{}{ + "url": "http://example.com", + "vulnerability": "CVE-2021-1234", + }} + if u, ok := r.URL(); !ok || u != "http://example.com" { + t.Fatalf("URL = %q/%v", u, ok) + } + if v, ok := r.Vulnerability(); !ok || v != "CVE-2021-1234" { + t.Fatalf("Vulnerability = %q/%v", v, ok) + } +} + +func TestResultProtocol(t *testing.T) { + r := Result{Details: map[string]interface{}{"protocol": "tcp"}} + if p, ok := r.Protocol(); !ok || p != "tcp" { + t.Fatalf("Protocol = %q/%v", p, ok) + } +} + +func TestDetailIntNilDetails(t *testing.T) { + r := Result{} + _, ok := r.DetailInt("port") + if ok { + t.Fatal("DetailInt on nil details should return false") + } +} + +func TestDetailStringNilDetails(t *testing.T) { + r := Result{} + _, ok := r.DetailString("service") + if ok { + t.Fatal("DetailString on nil details should return false") + } +} + +func TestDetailBoolNilDetails(t *testing.T) { + r := Result{} + _, ok := r.DetailBool("is_web") + if ok { + t.Fatal("DetailBool on nil details should return false") + } +} + +func TestIsWebExplicitBoolDetail(t *testing.T) { + r := Result{Details: map[string]interface{}{"is_web": true}} + if !r.IsWeb() { + t.Fatal("explicit is_web=true should mark as web") + } + + r2 := Result{Details: map[string]interface{}{"is_web": false, "service": "http"}} + if r2.IsWeb() { + t.Fatal("explicit is_web=false should override service heuristic") + } +} + +func TestIntFromInt64Overflow(t *testing.T) { + if _, ok := intFromInt64(math.MaxInt64); !ok { + t.Fatal("max int64 should fit on 64-bit") + } +} + +func TestIntFromUint64Overflow(t *testing.T) { + if _, ok := intFromUint64(math.MaxUint64); ok { + t.Fatal("max uint64 should overflow int") + } + if v, ok := intFromUint64(0); !ok || v != 0 { + t.Fatalf("intFromUint64(0) = %d/%v", v, ok) + } +} + +func TestIntFromFloat64NonInteger(t *testing.T) { + if _, ok := intFromFloat64(3.14); ok { + t.Fatal("non-integer float should fail") + } + if v, ok := intFromFloat64(100.0); !ok || v != 100 { + t.Fatalf("intFromFloat64(100.0) = %d/%v", v, ok) + } +} + +func TestResultSummaryAddWebCredential(t *testing.T) { + var s ResultSummary + s.Add(Result{ + Type: ResultTypeVuln, + Target: "10.0.0.1:80", + Status: "weak_credential: admin:admin", + Details: map[string]interface{}{ + "service": "http", + "is_web": true, + "username": "admin", + "password": "admin", + }, + }) + if s.Vulns != 1 || s.Web != 1 || s.Credentials != 1 { + t.Fatalf("summary = %+v, want vulns=1 web=1 credentials=1", s) + } +} + +func TestAsServiceWithPortOnly(t *testing.T) { + sr, ok := (Result{ + Type: ResultTypeService, + Target: "10.0.0.1:3306", + Details: map[string]interface{}{"port": 3306}, + }).AsService() + if !ok || sr.Port != 3306 { + t.Fatalf("AsService with port only = %#v/%v", sr, ok) + } +} + +func TestConvertOutputResultNil(t *testing.T) { + _, ok := convertOutputResult(nil) + if ok { + t.Fatal("convertOutputResult(nil) should return false") + } +} + +func TestConvertOutputResultEmpty(t *testing.T) { + _, ok := convertOutputResult(&output.ScanResult{}) + if ok { + t.Fatal("empty output result should return false") + } +} + +func TestConvertOutputResultValid(t *testing.T) { + raw := &output.ScanResult{ + Type: output.ResultType(ResultTypePort), + Target: "10.0.0.1", + Status: "open", + Details: map[string]interface{}{ + "port": 22, + }, + } + r, ok := convertOutputResult(raw) + if !ok { + t.Fatal("expected valid conversion") + } + if r.Type != ResultTypePort || r.Target != "10.0.0.1" { + t.Fatalf("converted = %#v", r) + } +} + +func TestResultJSON(t *testing.T) { + r := Result{ + Type: ResultTypePort, + Target: "10.0.0.1", + Status: "open", + Details: map[string]interface{}{ + "port": 22, + }, + } + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var decoded Result + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Type != ResultTypePort || decoded.Target != "10.0.0.1" { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestPortResultJSON(t *testing.T) { + pr := PortResult{Target: "10.0.0.1", Port: 80} + data, err := json.Marshal(pr) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != `{"target":"10.0.0.1","port":80}` { + t.Fatalf("PortResult JSON = %s", got) + } +} + +func TestServiceResultJSON(t *testing.T) { + sr := ServiceResult{Target: "10.0.0.1:80", Port: 80, Service: "http", IsWeb: true} + data, err := json.Marshal(sr) + if err != nil { + t.Fatal(err) + } + var decoded ServiceResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Service != "http" || !decoded.IsWeb { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestCredentialResultJSON(t *testing.T) { + cr := CredentialResult{Target: "10.0.0.1:22", Service: "ssh", Username: "root", Password: "toor"} + data, err := json.Marshal(cr) + if err != nil { + t.Fatal(err) + } + var decoded CredentialResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Username != "root" || decoded.Password != "toor" { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestVulnerabilityResultJSON(t *testing.T) { + vr := VulnerabilityResult{Target: "10.0.0.1:445", Service: "smb", Vulnerability: "MS17-010"} + data, err := json.Marshal(vr) + if err != nil { + t.Fatal(err) + } + var decoded VulnerabilityResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Vulnerability != "MS17-010" { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + +func TestResultSummaryJSON(t *testing.T) { + summary := ResultSummary{Total: 10, Hosts: 2, Ports: 3, Services: 3, Vulns: 2} + data, err := json.Marshal(summary) + if err != nil { + t.Fatal(err) + } + var decoded ResultSummary + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Total != 10 || decoded.Hosts != 2 { + t.Fatalf("round-trip failed: %#v", decoded) + } +} + diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index cf0aa74..c459420 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -42,6 +42,7 @@ var defaultSafePlugins = []string{ "rsync", "smb", "smtp", + "snmp", "ssh", "telnet", "vnc", @@ -122,6 +123,10 @@ func PluginCapabilities(name string) []string { return pluginCapabilities(name) } +type scanOpts struct { + controller *ScanController +} + // Scan runs the scanner for the provided targets and returns structured // findings. If no targets are provided, Config.Targets is used. func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { @@ -131,11 +136,40 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) // ScanReport runs the scanner and returns results with summary and runtime stats. func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport, error) { + return s.collectReport(ctx, scanOpts{}, targets...) +} + +// ScanEach runs the scanner and calls handle serially for each structured +// result without retaining all results in memory. If handle returns an error, +// the scan context is canceled and that error is returned. +func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error { + _, err := s.scanEach(ctx, scanOpts{}, handle, targets...) + return err +} + +// ScanWithController starts a scan and returns a controller for pause/resume +// and live stats. The scan runs in a background goroutine; read the returned +// channels to get the report and error when the scan completes. +func (s *Scanner) ScanWithController(ctx context.Context, targets ...Target) (*ScanController, <-chan ScanReport, <-chan error) { + ctrl := newScanController() + reportCh := make(chan ScanReport, 1) + errCh := make(chan error, 1) + + go func() { + report, err := s.collectReport(ctx, scanOpts{controller: ctrl}, targets...) + reportCh <- report + errCh <- err + }() + + return ctrl, reportCh, errCh +} + +func (s *Scanner) collectReport(ctx context.Context, opts scanOpts, targets ...Target) (ScanReport, error) { var ( mu sync.Mutex results []Result ) - stats, err := s.scanEach(ctx, func(result Result) error { + stats, err := s.scanEach(ctx, opts, func(result Result) error { mu.Lock() results = append(results, result) mu.Unlock() @@ -149,15 +183,7 @@ func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport }, err } -// ScanEach runs the scanner and calls handle serially for each structured -// result without retaining all results in memory. If handle returns an error, -// the scan context is canceled and that error is returned. -func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error { - _, err := s.scanEach(ctx, handle, targets...) - return err -} - -func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ...Target) (ScanStats, error) { +func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHandler, targets ...Target) (ScanStats, error) { if handle == nil { return ScanStats{}, fmt.Errorf("fscan: result handler is required") } @@ -171,11 +197,34 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets .. return ScanStats{}, err } + ctrl := opts.controller + if ctrl == nil && s.config.OnProgress != nil { + ctrl = newScanController() + } + ctx, cancel := context.WithCancel(ctx) defer cancel() restoreLogger := common.PushSilentLogger() defer restoreLogger() + if ctrl != nil && s.config.OnProgress != nil { + progressCtx, progressCancel := context.WithCancel(ctx) + defer progressCancel() + onProgress := s.config.OnProgress + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + onProgress(ctrl.progress()) + case <-progressCtx.Done(): + return + } + } + }() + } + var ( errMu sync.Mutex handleMu sync.Mutex @@ -192,6 +241,7 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets .. } sink := func(raw *output.ScanResult) error { if result, ok := convertOutputResult(raw); ok { + s.injectTaskID(&result) handleMu.Lock() if err := handle(result); err != nil { handleMu.Unlock() @@ -206,7 +256,7 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets .. } return nil } - report, err := s.scanOne(ctx, target, sink) + report, err := s.scanOne(ctx, target, sink, opts) stats.add(coreStatsToSDK(report)) if err != nil { if stored := getHandlerError(&errMu, &handlerErr); stored != nil { @@ -225,7 +275,17 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets .. return stats, ctx.Err() } -func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) (core.ScanReport, error) { +func (s *Scanner) injectTaskID(result *Result) { + if s.config.TaskID == "" { + return + } + if result.Details == nil { + result.Details = make(map[string]interface{}) + } + result.Details["task_id"] = s.config.TaskID +} + +func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink, opts scanOpts) (core.ScanReport, error) { fv := buildFlagVars(s.config, target) info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} @@ -255,6 +315,12 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result session := common.NewScanSession(cfg, state, fv) session.ResultSink = sink + + if opts.controller != nil { + session.PauseGate = opts.controller.pauseGate + opts.controller.addState(state) + } + return core.RunScan(ctx, info, session) } @@ -409,8 +475,8 @@ func normalizePlugins(pluginNames []string) []string { } func pluginTypes(name string) []string { - types := make([]string, 0, 3) - for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal} { + types := make([]string, 0, 4) + for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal, PluginTypeUDP} { if plugins.HasType(name, pluginType) { types = append(types, pluginType) } @@ -431,7 +497,7 @@ func pluginCapabilities(name string) []string { capSet[capability] = struct{}{} } - if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) { + if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) || plugins.HasType(name, PluginTypeUDP) { add(PluginCapabilityDetect) } if serviceAuthPlugins[name] { @@ -484,6 +550,7 @@ var serviceAuthPlugins = map[string]bool{ "rsync": true, "smb": true, "smtp": true, + "snmp": true, "ssh": true, "telnet": true, "vnc": true, diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 89f1e70..5d5a827 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -575,3 +575,280 @@ func hasPortResult(results []Result, port int) bool { } return false } + +func TestDefaultSafePluginsReturnsIndependentCopy(t *testing.T) { + a := DefaultSafePlugins() + b := DefaultSafePlugins() + if len(a) == 0 { + t.Fatal("empty default safe plugins") + } + a[0] = "MODIFIED" + if b[0] == "MODIFIED" { + t.Fatal("DefaultSafePlugins returned shared slice") + } +} + +func TestGetPluginWhitespaceAndEmpty(t *testing.T) { + if _, ok := GetPlugin(""); ok { + t.Fatal("empty name should not exist") + } + if _, ok := GetPlugin(" "); ok { + t.Fatal("whitespace name should not exist") + } + if info, ok := GetPlugin(" ssh "); !ok || info.Name != "ssh" { + t.Fatalf("trimmed lookup failed: %#v/%v", info, ok) + } +} + +func TestPluginCapabilitiesEmpty(t *testing.T) { + if caps := PluginCapabilities(""); caps != nil { + t.Fatalf("empty name caps = %#v, want nil", caps) + } + if caps := PluginCapabilities("definitely-missing"); caps != nil { + t.Fatalf("missing plugin caps = %#v, want nil", caps) + } +} + +func TestIsSafePluginWhitespace(t *testing.T) { + if IsSafePlugin("") { + t.Fatal("empty should not be safe") + } + if IsSafePlugin(" ") { + t.Fatal("whitespace should not be safe") + } + if !IsSafePlugin(" ssh ") { + t.Fatal("trimmed ssh should be safe") + } +} + +func TestValidateConfigPortOnConfig(t *testing.T) { + if err := validateConfig(Config{Ports: []int{0}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected invalid config port error for port 0") + } + if err := validateConfig(Config{Ports: []int{99999}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected invalid config port error for port 99999") + } +} + +func TestValidateConfigEmptyTarget(t *testing.T) { + if err := validateConfig(Config{}, []Target{{}}); err == nil { + t.Fatal("expected empty target error") + } +} + +func TestBuildFlagVarsCustomValues(t *testing.T) { + config := Config{ + Timeout: 10 * time.Second, + WebTimeout: 15 * time.Second, + Threads: 100, + ModuleThreads: 50, + MaxRetries: 5, + MaxRedirects: 3, + POCConcurrency: 10, + ICMPRate: 0.5, + DisablePing: true, + DisableTCPProbe: true, + DisableBrute: true, + Domain: "WORKGROUP", + SSHKeyPath: "/tmp/id_rsa", + HTTPProxy: "http://proxy:8080", + Socks5Proxy: "127.0.0.1:1080", + Interface: "eth0", + POCPath: "/tmp/pocs", + POCName: "test-poc", + POCFull: true, + DisablePOCScan: true, + Language: "zh", + Usernames: []string{"admin", "root"}, + Passwords: []string{"pass1", "pass2"}, + } + fv := buildFlagVars(config, Target{Host: "10.0.0.1"}) + + if fv.TimeoutSec != 10 { + t.Fatalf("TimeoutSec = %d, want 10", fv.TimeoutSec) + } + if fv.WebTimeout != 15 { + t.Fatalf("WebTimeout = %d, want 15", fv.WebTimeout) + } + if fv.ThreadNum != 100 { + t.Fatalf("ThreadNum = %d, want 100", fv.ThreadNum) + } + if fv.ModuleThreadNum != 50 { + t.Fatalf("ModuleThreadNum = %d, want 50", fv.ModuleThreadNum) + } + if fv.MaxRetries != 5 { + t.Fatalf("MaxRetries = %d, want 5", fv.MaxRetries) + } + if fv.MaxRedirects != 3 { + t.Fatalf("MaxRedirects = %d, want 3", fv.MaxRedirects) + } + if fv.PocNum != 10 { + t.Fatalf("PocNum = %d, want 10", fv.PocNum) + } + if fv.ICMPRate != 0.5 { + t.Fatalf("ICMPRate = %f, want 0.5", fv.ICMPRate) + } + if !fv.DisablePing { + t.Fatal("DisablePing should be true") + } + if !fv.DisableTcpProbe { + t.Fatal("DisableTcpProbe should be true") + } + if !fv.DisableBrute { + t.Fatal("DisableBrute should be true") + } + if fv.Domain != "WORKGROUP" { + t.Fatalf("Domain = %q", fv.Domain) + } + if fv.SSHKeyPath != "/tmp/id_rsa" { + t.Fatalf("SSHKeyPath = %q", fv.SSHKeyPath) + } + if fv.HTTPProxy != "http://proxy:8080" { + t.Fatalf("HTTPProxy = %q", fv.HTTPProxy) + } + if fv.Socks5Proxy != "127.0.0.1:1080" { + t.Fatalf("Socks5Proxy = %q", fv.Socks5Proxy) + } + if fv.Iface != "eth0" { + t.Fatalf("Iface = %q", fv.Iface) + } + if fv.PocPath != "/tmp/pocs" { + t.Fatalf("PocPath = %q", fv.PocPath) + } + if fv.PocName != "test-poc" { + t.Fatalf("PocName = %q", fv.PocName) + } + if !fv.PocFull { + t.Fatal("PocFull should be true") + } + if !fv.DisablePocScan { + t.Fatal("DisablePocScan should be true") + } + if fv.Language != "zh" { + t.Fatalf("Language = %q", fv.Language) + } + if fv.Username != "admin,root" { + t.Fatalf("Username = %q", fv.Username) + } + if fv.Password != "pass1,pass2" { + t.Fatalf("Password = %q", fv.Password) + } +} + +func TestBuildFlagVarsURLTarget(t *testing.T) { + fv := buildFlagVars(Config{}, Target{URL: "https://example.com"}) + if fv.TargetURL != "https://example.com" { + t.Fatalf("TargetURL = %q", fv.TargetURL) + } + if fv.Host != "" { + t.Fatalf("Host should be empty for URL target, got %q", fv.Host) + } +} + +func TestFormatPortsEmpty(t *testing.T) { + result := formatPorts(nil) + if result != commonconfig.MainPorts { + t.Fatalf("formatPorts(nil) = %q, want MainPorts", result) + } +} + +func TestFormatPortsSorted(t *testing.T) { + result := formatPorts([]int{443, 22, 80}) + if result != "22,80,443" { + t.Fatalf("formatPorts = %q, want sorted", result) + } +} + +func TestFormatPluginsAllowUnsafe(t *testing.T) { + result := formatPlugins(Config{AllowUnsafePlugins: true}) + if result != "all" { + t.Fatalf("formatPlugins(unsafe) = %q, want all", result) + } +} + +func TestFormatPluginsExplicit(t *testing.T) { + result := formatPlugins(Config{Plugins: []string{"ssh", "ftp"}}) + if result != "ssh,ftp" { + t.Fatalf("formatPlugins = %q, want ssh,ftp", result) + } +} + +func TestNormalizePluginsTrimsWhitespace(t *testing.T) { + result := normalizePlugins([]string{" ssh ", "", " ftp "}) + if len(result) != 2 || result[0] != "ssh" || result[1] != "ftp" { + t.Fatalf("normalizePlugins = %#v", result) + } +} + +func TestSecondsOrDefault(t *testing.T) { + if got := secondsOrDefault(0, 3); got != 3 { + t.Fatalf("secondsOrDefault(0, 3) = %d, want 3", got) + } + if got := secondsOrDefault(-1*time.Second, 5); got != 5 { + t.Fatalf("secondsOrDefault(-1s, 5) = %d, want 5", got) + } + if got := secondsOrDefault(10*time.Second, 3); got != 10 { + t.Fatalf("secondsOrDefault(10s, 3) = %d, want 10", got) + } + if got := secondsOrDefault(500*time.Millisecond, 3); got != 1 { + t.Fatalf("secondsOrDefault(500ms, 3) = %d, want 1", got) + } +} + +func TestSNMPPluginRegistration(t *testing.T) { + info, ok := GetPlugin("snmp") + if !ok { + t.Fatal("snmp plugin not registered") + } + if !info.Safe { + t.Fatal("snmp should be safe") + } + if !info.Default { + t.Fatal("snmp should be in default safe plugins") + } + if !containsString(info.Types, PluginTypeUDP) { + t.Fatalf("snmp types = %#v, want udp", info.Types) + } + if containsString(info.Types, PluginTypeService) { + t.Fatal("snmp should not be service type") + } + if !containsInt(info.Ports, 161) { + t.Fatalf("snmp ports = %#v, want 161", info.Ports) + } + if !containsString(info.Capabilities, PluginCapabilityDetect) { + t.Fatalf("snmp capabilities = %#v, want detect", info.Capabilities) + } +} + +func TestScanStatsAdd(t *testing.T) { + var s ScanStats + s.add(ScanStats{ + Duration: 2 * time.Second, + TasksTotal: 10, + TasksCompleted: 8, + Packets: 100, + TCPPackets: 80, + UDPPackets: 20, + HTTPPackets: 5, + }) + s.add(ScanStats{ + Duration: 3 * time.Second, + TasksTotal: 5, + TasksCompleted: 5, + Packets: 50, + TCPPackets: 40, + UDPPackets: 10, + }) + if s.Duration != 5*time.Second { + t.Fatalf("Duration = %s, want 5s", s.Duration) + } + if s.TasksTotal != 15 || s.TasksCompleted != 13 { + t.Fatalf("Tasks = %d/%d, want 15/13", s.TasksTotal, s.TasksCompleted) + } + if s.Packets != 150 || s.TCPPackets != 120 || s.UDPPackets != 30 { + t.Fatalf("Packets = %d/%d/%d", s.Packets, s.TCPPackets, s.UDPPackets) + } + if s.HTTPPackets != 5 { + t.Fatalf("HTTPPackets = %d, want 5", s.HTTPPackets) + } +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 7e819d2..615e7ff 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -11,6 +11,8 @@ const ( PluginTypeLocal = "local" // PluginTypeService marks network service plugins. PluginTypeService = "service" + // PluginTypeUDP marks UDP protocol plugins that bypass TCP port scanning. + PluginTypeUDP = "udp" ) const ( @@ -86,6 +88,17 @@ type ScanStats struct { ResourceExhausted int64 `json:"resource_exhausted"` } +// ScanProgress reports live scan progress for Agent integrations. +type ScanProgress struct { + TasksTotal int64 `json:"tasks_total"` + TasksCompleted int64 `json:"tasks_completed"` + Duration time.Duration `json:"duration"` + Packets int64 `json:"packets"` + TCPPackets int64 `json:"tcp_packets"` + HTTPPackets int64 `json:"http_packets"` + Paused bool `json:"paused"` +} + // ScanReport returns structured results with summary and runtime counters. type ScanReport struct { Results []Result `json:"results"` @@ -110,6 +123,10 @@ type Config struct { AllowUnsafePlugins bool // OnResult is called for every structured result as it is discovered. OnResult func(Result) + // OnProgress is called periodically with live scan progress. + OnProgress func(ScanProgress) + // TaskID is injected into every Result.Details["task_id"] when non-empty. + TaskID string Timeout time.Duration Threads int diff --git a/plugins/init.go b/plugins/init.go index 7978af1..2686d68 100644 --- a/plugins/init.go +++ b/plugins/init.go @@ -92,6 +92,7 @@ const ( PluginTypeWeb = "web" // Web类型插件 PluginTypeLocal = "local" // 本地类型插件 PluginTypeService = "service" // 服务类型插件 + PluginTypeUDP = "udp" // UDP协议插件,跳过TCP端口扫描 ) var ( @@ -122,6 +123,16 @@ func RegisterWithPorts(name string, factory func() Plugin, ports []int) { RegisterWithTypes(name, factory, ports, []string{PluginTypeService}) } +// RegisterUDPWithPorts 注册UDP协议插件,跳过TCP端口扫描链路 +func RegisterUDPWithPorts(name string, factory func() Plugin, ports []int) { + RegisterWithTypes(name, factory, ports, []string{PluginTypeUDP}) +} + +// IsUDP 检查插件是否为UDP协议插件 +func IsUDP(pluginName string) bool { + return HasType(pluginName, PluginTypeUDP) +} + // RegisterWithTypes 注册带类型标签的插件 func RegisterWithTypes(name string, factory func() Plugin, ports []int, types []string) { RegisterWithOptions(name, factory, ports, types, !hasPluginType(types, PluginTypeLocal)) diff --git a/plugins/services/snmp.go b/plugins/services/snmp.go new file mode 100644 index 0000000..954227b --- /dev/null +++ b/plugins/services/snmp.go @@ -0,0 +1,256 @@ +//go:build plugin_snmp || !plugin_selective + +package services + +import ( + "context" + "encoding/asn1" + "fmt" + "strings" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type SNMPPlugin struct { + plugins.BasePlugin +} + +func NewSNMPPlugin() *SNMPPlugin { + return &SNMPPlugin{BasePlugin: plugins.NewBasePlugin("snmp")} +} + +func (p *SNMPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + timeout := time.Duration(config.Timeout.Seconds()) * time.Second + if timeout <= 0 { + timeout = 3 * time.Second + } + + target := fmt.Sprintf("%s:%d", info.Host, info.Port) + + result := p.probe(ctx, target, "public", timeout, session) + if result == nil { + return &ScanResult{Success: false, Service: "snmp"} + } + + if config.DisableBrute { + return result + } + + communities := p.buildCommunityList(config) + var found []string + for _, community := range communities { + select { + case <-ctx.Done(): + return result + default: + } + if r := p.probe(ctx, target, community, timeout, session); r != nil && r.Success { + found = append(found, community) + } + } + + if len(found) > 0 { + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeCredential, + Service: "snmp", + Password: strings.Join(found, ","), + Banner: result.Banner, + } + } + return result +} + +func (p *SNMPPlugin) probe(ctx context.Context, target, community string, timeout time.Duration, session *common.ScanSession) *ScanResult { + conn, err := session.DialUDP(ctx, target, timeout) + if err != nil { + return nil + } + defer conn.Close() + + pkt := buildSNMPGetRequest(community, []int{1, 3, 6, 1, 2, 1, 1, 1, 0}) + if _, err := conn.Write(pkt); err != nil { + return nil + } + + buf := make([]byte, 1500) + n, err := conn.Read(buf) + if err != nil { + return nil + } + + sysDescr := parseSNMPResponse(buf[:n]) + if sysDescr == "" { + return nil + } + + return &ScanResult{ + Success: true, + Type: plugins.ResultTypeService, + Service: "snmp", + Banner: fmt.Sprintf("community=%s sysDescr=%s", community, sysDescr), + } +} + +func (p *SNMPPlugin) buildCommunityList(config *common.Config) []string { + defaults := []string{"public", "private", "community", "manager", "monitor", "admin", "snmp", "default"} + + passwords := config.Credentials.Passwords + if len(passwords) > 0 { + seen := make(map[string]struct{}, len(defaults)+len(passwords)) + var merged []string + for _, c := range append(defaults, passwords...) { + if _, ok := seen[c]; !ok { + seen[c] = struct{}{} + merged = append(merged, c) + } + } + return merged + } + return defaults +} + +// SNMPv2c GetRequest 编码 +func buildSNMPGetRequest(community string, oid []int) []byte { + requestID := int(time.Now().UnixNano() & 0x7FFFFFFF) + + varbind, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: marshalOIDWithNull(oid), + }) + + varbindList, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: varbind, + }) + + reqIDBytes, _ := asn1.Marshal(requestID) + errorStatusBytes, _ := asn1.Marshal(0) + errorIndexBytes, _ := asn1.Marshal(0) + + var pduContent []byte + pduContent = append(pduContent, reqIDBytes...) + pduContent = append(pduContent, errorStatusBytes...) + pduContent = append(pduContent, errorIndexBytes...) + pduContent = append(pduContent, varbindList...) + + pdu := asn1.RawValue{ + Class: asn1.ClassContextSpecific, + Tag: 0, // GetRequest-PDU + IsCompound: true, + Bytes: pduContent, + } + pduBytes, _ := asn1.Marshal(pdu) + + versionBytes, _ := asn1.Marshal(1) // SNMPv2c + communityBytes, _ := asn1.Marshal([]byte(community)) + + var messageContent []byte + messageContent = append(messageContent, versionBytes...) + messageContent = append(messageContent, communityBytes...) + messageContent = append(messageContent, pduBytes...) + + message, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: messageContent, + }) + return message +} + +func marshalOIDWithNull(oid []int) []byte { + oidBytes, _ := asn1.Marshal(asn1.ObjectIdentifier(oid)) + nullBytes, _ := asn1.Marshal(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagNull}) + var result []byte + result = append(result, oidBytes...) + result = append(result, nullBytes...) + return result +} + +func parseSNMPResponse(data []byte) string { + var message asn1.RawValue + if _, err := asn1.Unmarshal(data, &message); err != nil { + return "" + } + if message.Tag != asn1.TagSequence { + return "" + } + + rest := message.Bytes + // version + var version asn1.RawValue + rest, _ = asn1.Unmarshal(rest, &version) + if len(rest) == 0 { + return "" + } + // community + var community asn1.RawValue + rest, _ = asn1.Unmarshal(rest, &community) + if len(rest) == 0 { + return "" + } + // PDU (GetResponse = context-specific tag 2) + var pdu asn1.RawValue + if _, err := asn1.Unmarshal(rest, &pdu); err != nil { + return "" + } + + pduRest := pdu.Bytes + // skip requestID, errorStatus, errorIndex + for i := 0; i < 3; i++ { + var skip asn1.RawValue + var err error + pduRest, err = asn1.Unmarshal(pduRest, &skip) + if err != nil || len(pduRest) == 0 { + return "" + } + } + + // varbindList -> varbind -> (oid, value) + var varbindList asn1.RawValue + if _, err := asn1.Unmarshal(pduRest, &varbindList); err != nil { + return "" + } + var varbind asn1.RawValue + if _, err := asn1.Unmarshal(varbindList.Bytes, &varbind); err != nil { + return "" + } + + vbRest := varbind.Bytes + // skip OID + var oidVal asn1.RawValue + vbRest, _ = asn1.Unmarshal(vbRest, &oidVal) + if len(vbRest) == 0 { + return "" + } + + // value + var value asn1.RawValue + if _, err := asn1.Unmarshal(vbRest, &value); err != nil { + return "" + } + + if value.Tag == asn1.TagOctetString || value.Tag == asn1.TagUTF8String { + s := strings.TrimSpace(string(value.Bytes)) + if len(s) > 200 { + s = s[:200] + } + return s + } + return fmt.Sprintf("(type=%d, len=%d)", value.Tag, len(value.Bytes)) +} + +func init() { + RegisterUDPPluginWithPorts("snmp", func() Plugin { + return NewSNMPPlugin() + }, []int{161}) +} + diff --git a/plugins/services/types.go b/plugins/services/types.go index a3b1afb..c89d164 100644 --- a/plugins/services/types.go +++ b/plugins/services/types.go @@ -25,4 +25,11 @@ func RegisterPluginWithPorts(name string, factory func() Plugin, ports []int) { }, ports) } +// RegisterUDPPluginWithPorts 注册UDP协议插件 +func RegisterUDPPluginWithPorts(name string, factory func() Plugin, ports []int) { + plugins.RegisterUDPWithPorts(name, func() plugins.Plugin { + return factory() + }, ports) +} + var GenerateCredentials = plugins.GenerateCredentials