add embeddable scanner SDK

This commit is contained in:
ZacharyZcR
2026-05-18 14:41:15 +08:00
parent bce0a718ea
commit 6bfa05cb45
8 changed files with 515 additions and 2 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
result.txt
result.json
main
/main
.idea
fscan.exe
fscan
/fscan
fscanapi.csv
# IDE files / IDE 文件
+17
View File
@@ -17,6 +17,23 @@ func SetResultCallback(cb ResultCallback) {
resultCallback = cb
}
// ReplaceResultCallback temporarily replaces the result callback and returns a
// restore function. This is useful for embedded callers that need to collect
// structured results without permanently stealing the callback from another
// subsystem.
func ReplaceResultCallback(cb ResultCallback) func() {
callbackMu.Lock()
previous := resultCallback
resultCallback = cb
callbackMu.Unlock()
return func() {
callbackMu.Lock()
resultCallback = previous
callbackMu.Unlock()
}
}
// NotifyResult 通知结果给回调函数
func NotifyResult(result interface{}) {
callbackMu.RLock()
+10
View File
@@ -88,3 +88,13 @@ func CloseLogger() {
globalLogger.Close()
}
}
// ResetLogger clears the process-wide logger so embedded callers can rebuild it
// after replacing runtime configuration.
func ResetLogger() {
if globalLogger != nil {
globalLogger.Close()
}
globalLogger = nil
loggerOnce = sync.Once{}
}
+39
View File
@@ -0,0 +1,39 @@
# fscan SDK
`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged.
```go
package main
import (
"context"
"fmt"
"time"
fscan "github.com/shadow1ng/fscan/pkg/fscan"
)
func main() {
scanner := fscan.NewScanner(fscan.Config{
Timeout: 3 * time.Second,
Threads: 128,
DisablePing: true,
DisableBrute: true,
Plugins: []string{"ssh", "mysql", "redis"},
})
results, err := scanner.Scan(context.Background(), fscan.Target{
Host: "192.168.1.10",
Ports: []int{22, 3306, 6379},
})
if err != nil {
panic(err)
}
for _, result := range results {
fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status)
}
}
```
The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state.
+7
View File
@@ -0,0 +1,7 @@
// Package fscan exposes fscan as an embeddable scanner library.
//
// The package is intentionally thin: it reuses the existing scan core and
// plugin registry, while hiding CLI flags, stdout output, and result files from
// callers. The first SDK surface is serialized internally because the current
// scan core still uses process-wide runtime state.
package fscan
+308
View File
@@ -0,0 +1,308 @@
package fscan
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/shadow1ng/fscan/common"
commonconfig "github.com/shadow1ng/fscan/common/config"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/core"
"github.com/shadow1ng/fscan/plugins"
_ "github.com/shadow1ng/fscan/plugins/local"
_ "github.com/shadow1ng/fscan/plugins/services"
_ "github.com/shadow1ng/fscan/plugins/web"
)
var scanMu sync.Mutex
// Scanner runs fscan from another Go process.
type Scanner struct {
config Config
}
// NewScanner creates an embedded scanner.
func NewScanner(config Config) *Scanner {
return &Scanner{config: config}
}
// 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) {
if ctx == nil {
ctx = context.Background()
}
if len(targets) == 0 {
targets = s.config.Targets
}
if err := validateConfig(s.config, targets); err != nil {
return nil, err
}
scanMu.Lock()
defer scanMu.Unlock()
var (
mu sync.Mutex
results []Result
)
restoreCallback := common.ReplaceResultCallback(func(raw interface{}) {
if result, ok := decodeCallbackResult(raw); ok {
mu.Lock()
results = append(results, result)
mu.Unlock()
}
})
defer restoreCallback()
for _, target := range targets {
if err := ctx.Err(); err != nil {
return snapshotResults(&mu, results), err
}
if err := s.scanOne(ctx, target); err != nil {
return snapshotResults(&mu, results), err
}
}
return snapshotResults(&mu, results), ctx.Err()
}
func (s *Scanner) scanOne(ctx context.Context, target Target) error {
fv := buildFlagVars(s.config, target)
globalFV := common.GetFlagVars()
*globalFV = *fv
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)}
i18n.SetLanguage(globalFV.Language)
cfg, state, err := common.BuildConfig(globalFV, &info)
if err != nil {
return err
}
if len(s.config.UserPassPairs) > 0 {
cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs))
for _, pair := range s.config.UserPassPairs {
cfg.Credentials.UserPassPairs = append(cfg.Credentials.UserPassPairs, commonconfig.CredentialPair{
Username: pair.Username,
Password: pair.Password,
})
}
}
cfg.Output.DisableSave = true
cfg.Output.Silent = true
cfg.Output.DisableProgress = true
cfg.Output.ShowProgress = false
common.SetGlobalConfig(cfg)
common.SetGlobalState(state)
common.ResetLogger()
common.InitLogger()
session := common.NewScanSession(cfg, state, globalFV)
core.RunScan(ctx, info, session)
return nil
}
func validateConfig(config Config, targets []Target) error {
if len(targets) == 0 {
return fmt.Errorf("fscan: at least one target is required")
}
for _, plugin := range config.Plugins {
name := strings.TrimSpace(plugin)
if name == "" {
continue
}
if !plugins.Exists(name) {
return fmt.Errorf("fscan: plugin %q not found", name)
}
}
for _, target := range targets {
if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" {
return fmt.Errorf("fscan: target host or URL is required")
}
if strings.TrimSpace(target.Host) != "" && strings.TrimSpace(target.URL) != "" {
return fmt.Errorf("fscan: target cannot set both Host and URL")
}
for _, port := range target.Ports {
if port < 1 || port > 65535 {
return fmt.Errorf("fscan: invalid port %d", port)
}
}
}
for _, port := range config.Ports {
if port < 1 || port > 65535 {
return fmt.Errorf("fscan: invalid port %d", port)
}
}
return nil
}
func buildFlagVars(config Config, target Target) *common.FlagVars {
timeout := secondsOrDefault(config.Timeout, common.DefaultTimeout)
webTimeout := secondsOrDefault(config.WebTimeout, 5)
threadNum := config.Threads
if threadNum <= 0 {
threadNum = common.DefaultThreadNum
}
moduleThreads := config.ModuleThreads
if moduleThreads <= 0 {
moduleThreads = 20
}
maxRetries := config.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
maxRedirects := config.MaxRedirects
if maxRedirects <= 0 {
maxRedirects = 10
}
pocConcurrency := config.POCConcurrency
if pocConcurrency <= 0 {
pocConcurrency = 20
}
icmpRate := config.ICMPRate
if icmpRate <= 0 {
icmpRate = 0.1
}
language := config.Language
if language == "" {
language = common.DefaultLanguage
}
ports := config.Ports
if len(target.Ports) > 0 {
ports = target.Ports
}
return &common.FlagVars{
Host: strings.TrimSpace(target.Host),
Ports: formatPorts(ports),
ScanMode: formatPlugins(config.Plugins),
ThreadNum: threadNum,
ModuleThreadNum: moduleThreads,
TimeoutSec: timeout,
GlobalTimeout: 180,
DisablePing: config.DisablePing,
DisableTcpProbe: config.DisableTCPProbe,
AliveOnly: false,
DisableBrute: config.DisableBrute,
MaxRetries: maxRetries,
Username: strings.Join(config.Usernames, ","),
Password: strings.Join(config.Passwords, ","),
Domain: config.Domain,
SSHKeyPath: config.SSHKeyPath,
TargetURL: strings.TrimSpace(target.URL),
WebTimeout: webTimeout,
MaxRedirects: maxRedirects,
HTTPProxy: config.HTTPProxy,
Socks5Proxy: config.Socks5Proxy,
Iface: config.Interface,
PocPath: config.POCPath,
PocName: config.POCName,
PocFull: config.POCFull,
PocNum: pocConcurrency,
DisablePocScan: config.DisablePOCScan,
PacketRateLimit: config.PacketRateLimit,
MaxPacketCount: config.MaxPacketCount,
ICMPRate: icmpRate,
Outputfile: "result.txt",
OutputFormat: "txt",
DisableSave: true,
Silent: true,
NoColor: true,
LogLevel: common.LogLevelError,
DisableProgress: true,
Language: language,
ForwardShellPort: 4444,
KeyloggerOutputFile: "keylog.txt",
}
}
func formatPlugins(plugins []string) string {
if len(plugins) == 0 {
return "all"
}
parts := make([]string, 0, len(plugins))
for _, plugin := range plugins {
plugin = strings.TrimSpace(plugin)
if plugin != "" {
parts = append(parts, plugin)
}
}
if len(parts) == 0 {
return "all"
}
return strings.Join(parts, ",")
}
func formatPorts(ports []int) string {
if len(ports) == 0 {
return commonconfig.MainPorts
}
ports = append([]int(nil), ports...)
sort.Ints(ports)
parts := make([]string, 0, len(ports))
for _, port := range ports {
parts = append(parts, strconv.Itoa(port))
}
return strings.Join(parts, ",")
}
func secondsOrDefault(value time.Duration, fallback int) int64 {
if value <= 0 {
return int64(fallback)
}
seconds := int64(value.Round(time.Second) / time.Second)
if seconds < 1 {
return 1
}
return seconds
}
func decodeCallbackResult(raw interface{}) (Result, bool) {
item, ok := raw.(map[string]interface{})
if !ok {
return Result{}, false
}
result := Result{
Type: stringValue(item["type"]),
Target: stringValue(item["target"]),
Status: stringValue(item["status"]),
Details: mapValue(item["details"]),
}
if t, ok := item["time"].(time.Time); ok {
result.Time = t
}
return result, result.Target != "" || result.Status != ""
}
func stringValue(value interface{}) string {
if s, ok := value.(string); ok {
return s
}
return ""
}
func mapValue(value interface{}) map[string]interface{} {
if value == nil {
return nil
}
if m, ok := value.(map[string]interface{}); ok {
return m
}
return nil
}
func snapshotResults(mu *sync.Mutex, results []Result) []Result {
mu.Lock()
defer mu.Unlock()
return append([]Result(nil), results...)
}
+66
View File
@@ -0,0 +1,66 @@
package fscan
import (
"context"
"testing"
"time"
commonconfig "github.com/shadow1ng/fscan/common/config"
)
func TestBuildFlagVarsDefaults(t *testing.T) {
fv := buildFlagVars(Config{}, Target{Host: "127.0.0.1"})
if fv.Host != "127.0.0.1" {
t.Fatalf("Host = %q", fv.Host)
}
if fv.Ports != commonconfig.MainPorts {
t.Fatalf("Ports = %q, want MainPorts", fv.Ports)
}
if fv.ScanMode != "all" {
t.Fatalf("ScanMode = %q, want all", fv.ScanMode)
}
if !fv.DisableSave || !fv.Silent || !fv.DisableProgress {
t.Fatalf("embedded defaults should disable output side effects")
}
}
func TestBuildFlagVarsTargetPortsOverride(t *testing.T) {
fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}})
if fv.Ports != "22,3306" {
t.Fatalf("Ports = %q, want sorted target override", fv.Ports)
}
}
func TestValidateConfig(t *testing.T) {
if err := validateConfig(Config{}, nil); err == nil {
t.Fatal("expected missing target error")
}
if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", URL: "http://127.0.0.1"}}); err == nil {
t.Fatal("expected host/url conflict")
}
if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil {
t.Fatal("expected missing plugin error")
}
if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil {
t.Fatal("expected invalid port error")
}
}
func TestScanHonorsCanceledContext(t *testing.T) {
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Plugins: []string{"redis"},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}})
if err != context.Canceled {
t.Fatalf("Scan error = %v, want context.Canceled", err)
}
}
+66
View File
@@ -0,0 +1,66 @@
package fscan
import (
"time"
)
// Target describes one scan target. Use Host for host/IP/CIDR/range service
// scans, or URL for web scans. Ports applies only to Host scans.
type Target struct {
Host string
URL string
Ports []int
}
// CredentialPair pins one username/password pair.
type CredentialPair struct {
Username string
Password string
}
// Config controls an embedded scan. Zero values use the same conservative
// defaults as the CLI, except output is silent and file saving is disabled.
type Config struct {
Targets []Target
Plugins []string
Ports []int
Timeout time.Duration
Threads int
ModuleThreads int
MaxRetries int
DisablePing bool
DisableTCPProbe bool
DisableBrute bool
Usernames []string
Passwords []string
UserPassPairs []CredentialPair
Domain string
SSHKeyPath string
HTTPProxy string
Socks5Proxy string
Interface string
WebTimeout time.Duration
MaxRedirects int
DisablePOCScan bool
POCPath string
POCName string
POCFull bool
POCConcurrency int
PacketRateLimit int64
MaxPacketCount int64
ICMPRate float64
Language string
}
// Result is the structured scan result returned to embedded callers.
type Result struct {
Time time.Time `json:"time"`
Type string `json:"type"`
Target string `json:"target"`
Status string `json:"status"`
Details map[string]interface{} `json:"details,omitempty"`
}