mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 06:34:36 +03:00
add internal scanner with iran Famous Public DNS Servers from SlipNet
This commit is contained in:
@@ -35,6 +35,24 @@ thefeed یک سیستم تونل DNS است که به شما اجازه می
|
||||
- فشردهسازی پیامها (deflate)
|
||||
- محافظت رابط وب با رمز عبور (`--password` سمت کلاینت)
|
||||
- لاگ زنده درخواستهای DNS در مرورگر
|
||||
- **اسکنر ریزالور**: اسکن بازههای IP و CIDR برای پیدا کردن سرورهای DNS کارآمد
|
||||
|
||||
### اسکنر ریزالور
|
||||
|
||||
رابط وب شامل یک اسکنر ریزالور داخلی است (آیکون 🔍 در نوار کناری) که بازههای IP را بررسی میکند تا سرورهای DNS قابل دسترسی به سرور thefeed شما را پیدا کند:
|
||||
|
||||
- **اهداف متنوع**: آیپیهای تکی، CIDR (مثل `5.1.0.0/16`)، یا نام دامنه — هر خط یکی
|
||||
- **بارگذاری CIDR ایران**: دکمه یککلیکی برای بارگذاری لیست بازههای ISP ایران
|
||||
- **انتخاب پروفایل**: انتخاب کنید کدام پروفایل برای تست استفاده شود
|
||||
- **قابل تنظیم**: همزمانی (پیشفرض ۵۰)، تایماوت (پیشفرض ۱۵ ثانیه)، حداکثر آیپی
|
||||
- **گسترش /24**: وقتی ریزالور کارآمد پیدا شد، آیپیهای نزدیک در همان /24 هم بررسی میشوند
|
||||
- **مکث / ادامه / توقف**: کنترل کامل روی اسکنهای طولانی (مکث واقعاً ارسال درخواستهای جدید را متوقف میکند)
|
||||
- **زمان پاسخ**: نتایج شامل تأخیر هستند تا سریعترینها را انتخاب کنید
|
||||
- **انتخاب نتایج**: چکباکس برای انتخاب ریزالورهای مورد نظر
|
||||
- **اعمال نتایج**: افزودن یا جایگزینی لیست ریزالورهای پروفایل مستقیم از اسکنر
|
||||
- **کپی**: دکمه کپی برای هر آیپی، کپی انتخابشدهها، یا کپی همه
|
||||
- **اسکن جدید**: بازنشانی رابط کاربری برای شروع اسکن جدید پس از اتمام
|
||||
- **لاگ دیباگ**: در حالت دیباگ، کوئریها و پاسخهای هر probe ثبت میشوند
|
||||
|
||||
### ضد DPI
|
||||
- **اندازه متغیر پاسخ**: Padding تصادفی (۰-۳۲ بایت)
|
||||
|
||||
@@ -232,6 +232,25 @@ The browser-based UI has:
|
||||
- **Log panel** (bottom): live DNS query log
|
||||
- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit, concurrent requests (scatter), timeout, debug mode
|
||||
- **Per-profile cache**: 1-hour browser cache so data is visible instantly on reopen
|
||||
- **Resolver Scanner**: scan IP ranges and CIDRs to discover working DNS resolvers
|
||||
|
||||
### Resolver Scanner
|
||||
|
||||
The web UI includes a built-in resolver scanner (🔍 icon in sidebar) that probes IP ranges to discover DNS servers capable of reaching your thefeed server. Features:
|
||||
|
||||
- **Flexible targets**: enter individual IPs, CIDRs (e.g. `5.1.0.0/16`), or domain names — one per line
|
||||
- **Iran CIDRs preset**: one-click button to load a curated list of Iranian ISP ranges
|
||||
- **Profile-aware**: select which profile's domain and passphrase to use for probing
|
||||
- **Configurable**: set concurrency (default 50), timeout (default 15s), and max IPs to scan
|
||||
- **Expand /24**: when a working resolver is found, automatically scan all nearby IPs in the same /24 subnet
|
||||
- **Pause / Resume / Stop**: full control over long-running scans (pause actually stops dispatching new probes)
|
||||
- **Response time**: results include latency so you can pick the fastest resolvers
|
||||
- **Selectable results**: checkboxes to select which resolvers to apply or copy
|
||||
- **Apply results**: append to or overwrite your profile's resolver list directly from the scanner
|
||||
- **Copy**: per-IP copy buttons, copy selected, or copy all discovered resolver IPs
|
||||
- **New Scan**: reset the UI to start a fresh scan after completion
|
||||
- **Debug logging**: when debug mode is enabled, individual probe queries/responses are logged
|
||||
- **Profile editor shortcut**: open the scanner directly from a profile's edit page with "Find Resolvers" button
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/protocol"
|
||||
)
|
||||
|
||||
// ScannerState represents the current state of the scanner.
|
||||
type ScannerState string
|
||||
|
||||
const (
|
||||
ScannerIdle ScannerState = "idle"
|
||||
ScannerRunning ScannerState = "running"
|
||||
ScannerPaused ScannerState = "paused"
|
||||
ScannerDone ScannerState = "done"
|
||||
)
|
||||
|
||||
// ScannerConfig holds the configuration for a resolver scan.
|
||||
type ScannerConfig struct {
|
||||
// Targets is a list of IPs, domains, or CIDRs to scan.
|
||||
Targets []string `json:"targets"`
|
||||
// MaxIPs limits how many IPs to scan from the expanded list (0 = all).
|
||||
MaxIPs int `json:"maxIPs"`
|
||||
// RateLimit is the concurrent probe limit (default 50).
|
||||
RateLimit int `json:"rateLimit"`
|
||||
// Timeout is the per-probe timeout in seconds (default 15).
|
||||
Timeout float64 `json:"timeout"`
|
||||
// ExpandSubnet: if true, when a working resolver is found, also scan its /24.
|
||||
ExpandSubnet bool `json:"expandSubnet"`
|
||||
// QueryMode is "single" or "double".
|
||||
QueryMode string `json:"queryMode"`
|
||||
// Domain is the thefeed server domain.
|
||||
Domain string `json:"domain"`
|
||||
// Passphrase is the encryption key.
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
|
||||
// ScannerResult represents a single working resolver found by the scanner.
|
||||
type ScannerResult struct {
|
||||
IP string `json:"ip"`
|
||||
LatencyMs float64 `json:"latencyMs"` // milliseconds
|
||||
FoundAt int64 `json:"foundAt"` // unix timestamp
|
||||
}
|
||||
|
||||
// ScannerProgress holds the current progress of the scanner.
|
||||
type ScannerProgress struct {
|
||||
State ScannerState `json:"state"`
|
||||
Total int `json:"total"`
|
||||
Scanned int `json:"scanned"`
|
||||
Found int `json:"found"`
|
||||
Results []ScannerResult `json:"results"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt int64 `json:"startedAt,omitempty"`
|
||||
}
|
||||
|
||||
// ResolverScanner scans IP ranges to find working DNS resolvers for thefeed.
|
||||
type ResolverScanner struct {
|
||||
mu sync.Mutex
|
||||
state ScannerState
|
||||
ctx context.Context // main scan context
|
||||
cancel context.CancelFunc // cancels the main scan context
|
||||
pauseCh chan struct{}
|
||||
resumeCh chan struct{}
|
||||
logFunc LogFunc
|
||||
|
||||
// probeCtx is a child of ctx that is cancelled on pause/stop to abort
|
||||
// in-flight DNS exchanges immediately. Recreated on resume.
|
||||
probeCtx context.Context
|
||||
probeCancel context.CancelFunc
|
||||
|
||||
// Progress tracking (atomic for concurrent reads).
|
||||
total atomic.Int64
|
||||
scanned atomic.Int64
|
||||
|
||||
// Results are protected by resultMu.
|
||||
resultMu sync.Mutex
|
||||
results []ScannerResult
|
||||
|
||||
// Error from the scan.
|
||||
scanErr atomic.Value // stores string
|
||||
|
||||
startedAt int64
|
||||
|
||||
debug bool // when true, log individual probe queries/responses
|
||||
|
||||
// expandedIPs tracks /24 subnets already expanded so we don't re-expand.
|
||||
expandMu sync.Mutex
|
||||
expandedNets map[string]bool
|
||||
expandQueue chan string // IPs whose /24 needs scanning
|
||||
}
|
||||
|
||||
// SetDebug enables or disables verbose scanner logging.
|
||||
func (rs *ResolverScanner) SetDebug(v bool) {
|
||||
rs.debug = v
|
||||
}
|
||||
|
||||
// NewResolverScanner creates a new scanner instance.
|
||||
func NewResolverScanner() *ResolverScanner {
|
||||
return &ResolverScanner{
|
||||
state: ScannerIdle,
|
||||
expandedNets: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLogFunc sets the log callback.
|
||||
func (rs *ResolverScanner) SetLogFunc(fn LogFunc) {
|
||||
rs.logFunc = fn
|
||||
}
|
||||
|
||||
// State returns the current scanner state.
|
||||
func (rs *ResolverScanner) State() ScannerState {
|
||||
rs.mu.Lock()
|
||||
defer rs.mu.Unlock()
|
||||
return rs.state
|
||||
}
|
||||
|
||||
// Progress returns the current scan progress.
|
||||
func (rs *ResolverScanner) Progress() ScannerProgress {
|
||||
rs.mu.Lock()
|
||||
state := rs.state
|
||||
rs.mu.Unlock()
|
||||
|
||||
rs.resultMu.Lock()
|
||||
results := make([]ScannerResult, len(rs.results))
|
||||
copy(results, rs.results)
|
||||
rs.resultMu.Unlock()
|
||||
|
||||
total := int(rs.total.Load())
|
||||
scanned := int(rs.scanned.Load())
|
||||
|
||||
// If all IPs have been scanned but goroutine hasn't exited yet, report done.
|
||||
if state == ScannerRunning && total > 0 && scanned >= total {
|
||||
state = ScannerDone
|
||||
}
|
||||
|
||||
errVal := rs.scanErr.Load()
|
||||
var errStr string
|
||||
if errVal != nil {
|
||||
errStr = errVal.(string)
|
||||
}
|
||||
|
||||
return ScannerProgress{
|
||||
State: state,
|
||||
Total: total,
|
||||
Scanned: scanned,
|
||||
Found: len(results),
|
||||
Results: results,
|
||||
Error: errStr,
|
||||
StartedAt: rs.startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins scanning with the given config. Returns error if already running.
|
||||
func (rs *ResolverScanner) Start(cfg ScannerConfig) error {
|
||||
rs.mu.Lock()
|
||||
if rs.state == ScannerRunning || rs.state == ScannerPaused {
|
||||
rs.mu.Unlock()
|
||||
return fmt.Errorf("scanner already running")
|
||||
}
|
||||
|
||||
// Derive keys.
|
||||
qk, rk, err := protocol.DeriveKeys(cfg.Passphrase)
|
||||
if err != nil {
|
||||
rs.mu.Unlock()
|
||||
return fmt.Errorf("invalid passphrase: %w", err)
|
||||
}
|
||||
|
||||
// Parse query mode.
|
||||
queryMode := protocol.QuerySingleLabel
|
||||
if cfg.QueryMode == "double" {
|
||||
queryMode = protocol.QueryMultiLabel
|
||||
}
|
||||
|
||||
// Expand targets to IPs.
|
||||
ips, err := expandTargets(cfg.Targets)
|
||||
if err != nil {
|
||||
rs.mu.Unlock()
|
||||
return fmt.Errorf("expand targets: %w", err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
rs.mu.Unlock()
|
||||
return fmt.Errorf("no IPs to scan")
|
||||
}
|
||||
|
||||
// Shuffle IPs.
|
||||
rand.Shuffle(len(ips), func(i, j int) { ips[i], ips[j] = ips[j], ips[i] })
|
||||
|
||||
// Apply maxIPs limit.
|
||||
if cfg.MaxIPs > 0 && cfg.MaxIPs < len(ips) {
|
||||
ips = ips[:cfg.MaxIPs]
|
||||
}
|
||||
|
||||
// Set defaults.
|
||||
rateLimit := cfg.RateLimit
|
||||
if rateLimit <= 0 {
|
||||
rateLimit = 50
|
||||
}
|
||||
timeout := time.Duration(cfg.Timeout * float64(time.Second))
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
domain := strings.TrimSuffix(cfg.Domain, ".")
|
||||
|
||||
// Reset state.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
rs.ctx = ctx
|
||||
rs.cancel = cancel
|
||||
rs.probeCtx, rs.probeCancel = context.WithCancel(ctx)
|
||||
rs.pauseCh = make(chan struct{})
|
||||
rs.resumeCh = make(chan struct{})
|
||||
rs.state = ScannerRunning
|
||||
rs.total.Store(int64(len(ips)))
|
||||
rs.scanned.Store(0)
|
||||
rs.resultMu.Lock()
|
||||
rs.results = nil
|
||||
rs.resultMu.Unlock()
|
||||
rs.scanErr.Store("")
|
||||
rs.startedAt = time.Now().Unix()
|
||||
rs.expandMu.Lock()
|
||||
rs.expandedNets = make(map[string]bool)
|
||||
rs.expandMu.Unlock()
|
||||
|
||||
if cfg.ExpandSubnet {
|
||||
rs.expandQueue = make(chan string, 1000)
|
||||
} else {
|
||||
rs.expandQueue = nil
|
||||
}
|
||||
|
||||
rs.mu.Unlock()
|
||||
|
||||
rs.log("SCANNER_START %d", len(ips))
|
||||
rs.log("Scanner started: probing %d IPs (concurrency=%d, timeout=%.0fs)", len(ips), rateLimit, timeout.Seconds())
|
||||
|
||||
go rs.runScan(ctx, ips, qk, rk, domain, queryMode, rateLimit, timeout, cfg.ExpandSubnet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the scanner.
|
||||
func (rs *ResolverScanner) Stop() {
|
||||
rs.mu.Lock()
|
||||
defer rs.mu.Unlock()
|
||||
if rs.probeCancel != nil {
|
||||
rs.probeCancel() // cancel in-flight probes immediately
|
||||
}
|
||||
if rs.cancel != nil {
|
||||
rs.cancel()
|
||||
}
|
||||
rs.state = ScannerDone
|
||||
}
|
||||
|
||||
// Pause pauses the scanner.
|
||||
func (rs *ResolverScanner) Pause() {
|
||||
rs.mu.Lock()
|
||||
defer rs.mu.Unlock()
|
||||
if rs.state == ScannerRunning {
|
||||
rs.state = ScannerPaused
|
||||
if rs.probeCancel != nil {
|
||||
rs.probeCancel() // cancel in-flight probes immediately
|
||||
}
|
||||
rs.pauseCh = make(chan struct{})
|
||||
close(rs.pauseCh) // signal pause
|
||||
rs.resumeCh = make(chan struct{})
|
||||
rs.log("Scanner paused")
|
||||
}
|
||||
}
|
||||
|
||||
// Resume resumes the scanner from pause.
|
||||
func (rs *ResolverScanner) Resume() {
|
||||
rs.mu.Lock()
|
||||
defer rs.mu.Unlock()
|
||||
if rs.state == ScannerPaused {
|
||||
rs.state = ScannerRunning
|
||||
// Recreate probe context so new probes can proceed.
|
||||
rs.probeCtx, rs.probeCancel = context.WithCancel(rs.ctx)
|
||||
close(rs.resumeCh) // signal resume
|
||||
rs.log("Scanner resumed")
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *ResolverScanner) runScan(ctx context.Context, ips []string, qk, rk [protocol.KeySize]byte, domain string, queryMode protocol.QueryEncoding, rateLimit int, timeout time.Duration, expandSubnet bool) {
|
||||
defer func() {
|
||||
rs.mu.Lock()
|
||||
if rs.state != ScannerDone {
|
||||
rs.state = ScannerDone
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
total := int(rs.total.Load())
|
||||
scanned := int(rs.scanned.Load())
|
||||
rs.resultMu.Lock()
|
||||
found := len(rs.results)
|
||||
rs.resultMu.Unlock()
|
||||
rs.log("SCANNER_DONE %d/%d found=%d", scanned, total, found)
|
||||
rs.log("Scanner finished: %d/%d scanned, %d working resolvers found", scanned, total, found)
|
||||
}()
|
||||
|
||||
// Feed IPs through a channel so dispatch can be paused.
|
||||
ipCh := make(chan string, rateLimit)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Worker pool: rateLimit workers.
|
||||
for w := 0; w < rateLimit; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ip := range ipCh {
|
||||
if ctx.Err() != nil {
|
||||
rs.scanned.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for pause before probing.
|
||||
rs.mu.Lock()
|
||||
paused := rs.state == ScannerPaused
|
||||
var resumeCh chan struct{}
|
||||
if paused {
|
||||
resumeCh = rs.resumeCh
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
|
||||
if paused && resumeCh != nil {
|
||||
select {
|
||||
case <-resumeCh:
|
||||
case <-ctx.Done():
|
||||
rs.scanned.Add(1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current probe context (cancelled on pause/stop).
|
||||
rs.mu.Lock()
|
||||
pCtx := rs.probeCtx
|
||||
rs.mu.Unlock()
|
||||
|
||||
latency, ok := rs.probeResolver(pCtx, ip, qk, rk, domain, queryMode, timeout)
|
||||
rs.scanned.Add(1)
|
||||
|
||||
scanned := int(rs.scanned.Load())
|
||||
total := int(rs.total.Load())
|
||||
if scanned%100 == 0 || scanned == total {
|
||||
rs.resultMu.Lock()
|
||||
found := len(rs.results)
|
||||
rs.resultMu.Unlock()
|
||||
rs.log("SCANNER_PROGRESS %d/%d found=%d", scanned, total, found)
|
||||
}
|
||||
|
||||
if ok {
|
||||
result := ScannerResult{
|
||||
IP: ip,
|
||||
LatencyMs: float64(latency.Milliseconds()),
|
||||
FoundAt: time.Now().Unix(),
|
||||
}
|
||||
rs.resultMu.Lock()
|
||||
rs.results = append(rs.results, result)
|
||||
rs.resultMu.Unlock()
|
||||
rs.log("Scanner: resolver OK %s (%.0fms)", ip, result.LatencyMs)
|
||||
|
||||
if expandSubnet {
|
||||
rs.expandMu.Lock()
|
||||
eq := rs.expandQueue
|
||||
if eq != nil {
|
||||
select {
|
||||
case eq <- ip:
|
||||
default:
|
||||
}
|
||||
}
|
||||
rs.expandMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// If expandSubnet is enabled, start the expand goroutine now so it
|
||||
// processes found IPs concurrently with primary dispatch.
|
||||
var expandDone chan struct{}
|
||||
if expandSubnet && rs.expandQueue != nil {
|
||||
expandDone = make(chan struct{})
|
||||
go func() {
|
||||
defer close(expandDone)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case foundIP, ok := <-rs.expandQueue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rs.expandSubnetOf(ctx, foundIP, ips, qk, rk, domain, queryMode, timeout, ipCh)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Dispatch IPs, respecting pause.
|
||||
for _, ip := range ips {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
// Check for pause before dispatching.
|
||||
for {
|
||||
rs.mu.Lock()
|
||||
paused := rs.state == ScannerPaused
|
||||
var resumeCh chan struct{}
|
||||
if paused {
|
||||
resumeCh = rs.resumeCh
|
||||
}
|
||||
rs.mu.Unlock()
|
||||
if !paused {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-resumeCh:
|
||||
case <-ctx.Done():
|
||||
goto doneDispatch
|
||||
}
|
||||
}
|
||||
select {
|
||||
case ipCh <- ip:
|
||||
case <-ctx.Done():
|
||||
goto doneDispatch
|
||||
}
|
||||
}
|
||||
doneDispatch:
|
||||
|
||||
// Safely close expandQueue so no worker can send to a closed channel.
|
||||
if expandSubnet {
|
||||
rs.expandMu.Lock()
|
||||
eq := rs.expandQueue
|
||||
rs.expandQueue = nil
|
||||
rs.expandMu.Unlock()
|
||||
if eq != nil {
|
||||
close(eq)
|
||||
}
|
||||
if expandDone != nil {
|
||||
<-expandDone
|
||||
}
|
||||
}
|
||||
|
||||
close(ipCh)
|
||||
wg.Wait()
|
||||
|
||||
// Sort results by latency.
|
||||
rs.resultMu.Lock()
|
||||
sort.Slice(rs.results, func(i, j int) bool {
|
||||
return rs.results[i].LatencyMs < rs.results[j].LatencyMs
|
||||
})
|
||||
rs.resultMu.Unlock()
|
||||
}
|
||||
|
||||
func (rs *ResolverScanner) expandSubnetOf(ctx context.Context, foundIP string, alreadyScanned []string, qk, rk [protocol.KeySize]byte, domain string, queryMode protocol.QueryEncoding, timeout time.Duration, ipCh chan<- string) {
|
||||
ip := net.ParseIP(foundIP)
|
||||
if ip == nil {
|
||||
return
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return // skip IPv6
|
||||
}
|
||||
|
||||
// Get the /24 prefix.
|
||||
prefix := fmt.Sprintf("%d.%d.%d", ip[0], ip[1], ip[2])
|
||||
|
||||
rs.expandMu.Lock()
|
||||
if rs.expandedNets[prefix] {
|
||||
rs.expandMu.Unlock()
|
||||
return
|
||||
}
|
||||
rs.expandedNets[prefix] = true
|
||||
rs.expandMu.Unlock()
|
||||
|
||||
// Build a set of already-known IPs for quick lookup.
|
||||
known := make(map[string]bool, len(alreadyScanned))
|
||||
for _, s := range alreadyScanned {
|
||||
known[s] = true
|
||||
}
|
||||
rs.resultMu.Lock()
|
||||
for _, r := range rs.results {
|
||||
known[r.IP] = true
|
||||
}
|
||||
rs.resultMu.Unlock()
|
||||
|
||||
// Generate all /24 IPs that aren't in the known set.
|
||||
var newIPs []string
|
||||
for i := 1; i < 255; i++ {
|
||||
candidate := fmt.Sprintf("%s.%d", prefix, i)
|
||||
addr := candidate + ":53"
|
||||
if !known[candidate] && !known[addr] {
|
||||
newIPs = append(newIPs, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newIPs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rs.log("Scanner: expanding /24 of %s — scanning %d additional IPs", foundIP, len(newIPs))
|
||||
rs.total.Add(int64(len(newIPs)))
|
||||
|
||||
for _, newIP := range newIPs {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case ipCh <- newIP:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *ResolverScanner) probeResolver(ctx context.Context, ip string, qk, rk [protocol.KeySize]byte, domain string, queryMode protocol.QueryEncoding, timeout time.Duration) (time.Duration, bool) {
|
||||
resolver := ip
|
||||
if !strings.Contains(resolver, ":") {
|
||||
resolver += ":53"
|
||||
}
|
||||
|
||||
qname, err := protocol.EncodeQuery(qk, protocol.MetadataChannel, 0, domain, queryMode)
|
||||
if err != nil {
|
||||
if rs.debug {
|
||||
rs.log("[debug] scanner probe %s: encode error: %v", ip, err)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
c := &dns.Client{Timeout: timeout, Net: "udp"}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(qname), dns.TypeTXT)
|
||||
m.RecursionDesired = true
|
||||
m.SetEdns0(4096, false)
|
||||
|
||||
if rs.debug {
|
||||
rs.log("[debug] scanner query %s qname=%s", ip, qname)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, _, err := c.ExchangeContext(probeCtx, m, resolver)
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil || resp == nil {
|
||||
if rs.debug {
|
||||
rs.log("[debug] scanner probe %s: err=%v latency=%s", ip, err, latency)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, ans := range resp.Answer {
|
||||
if txt, ok := ans.(*dns.TXT); ok {
|
||||
encoded := strings.Join(txt.Txt, "")
|
||||
_, decErr := protocol.DecodeResponse(rk, encoded)
|
||||
if decErr == nil {
|
||||
if rs.debug {
|
||||
rs.log("[debug] scanner probe %s: OK latency=%s", ip, latency)
|
||||
}
|
||||
return latency, true
|
||||
}
|
||||
if rs.debug {
|
||||
rs.log("[debug] scanner probe %s: decode failed: %v", ip, decErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rs.debug {
|
||||
rs.log("[debug] scanner probe %s: no valid TXT record (answers=%d)", ip, len(resp.Answer))
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (rs *ResolverScanner) log(format string, args ...any) {
|
||||
if rs.logFunc != nil {
|
||||
rs.logFunc(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// expandTargets expands a list of IPs, domains, and CIDRs into individual IP strings.
|
||||
func expandTargets(targets []string) ([]string, error) {
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, target := range targets {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try as CIDR.
|
||||
if strings.Contains(target, "/") {
|
||||
ips, err := expandCIDR(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", target, err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !seen[ip] {
|
||||
seen[ip] = true
|
||||
result = append(result, ip)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Try as IP.
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
s := ip.String()
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Try as IP:port (e.g. "1.2.3.4:53").
|
||||
if host, _, splitErr := net.SplitHostPort(target); splitErr == nil {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
s := ip.String()
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
result = append(result, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Try as domain — resolve to IPs.
|
||||
addrs, err := net.LookupHost(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve %q: %w", target, err)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if !seen[addr] {
|
||||
seen[addr] = true
|
||||
result = append(result, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// expandCIDR expands a CIDR to individual IP addresses, skipping network and broadcast
|
||||
// addresses for IPv4 networks larger than /31.
|
||||
func expandCIDR(cidr string) ([]string, error) {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mask := network.Mask
|
||||
ones, bits := mask.Size()
|
||||
|
||||
// For IPv4, limit to /16 to avoid memory issues.
|
||||
if bits == 32 && ones < 16 {
|
||||
return nil, fmt.Errorf("CIDR %s is too large (minimum /16)", cidr)
|
||||
}
|
||||
|
||||
var ips []string
|
||||
ip := make(net.IP, len(network.IP))
|
||||
copy(ip, network.IP)
|
||||
|
||||
for {
|
||||
if !network.Contains(ip) {
|
||||
break
|
||||
}
|
||||
// Skip network and broadcast addresses for networks > /31.
|
||||
if bits == 32 && ones < 31 {
|
||||
// Check if it's the network address (all host bits zero)
|
||||
// or broadcast address (all host bits one).
|
||||
hostBits := bits - ones
|
||||
if hostBits > 1 {
|
||||
ipInt := ipToUint32(ip.To4())
|
||||
netInt := ipToUint32(network.IP.To4())
|
||||
hostMask := uint32((1 << hostBits) - 1)
|
||||
hostPart := ipInt - netInt
|
||||
if hostPart == 0 || hostPart == hostMask {
|
||||
incrementIP(ip)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
incrementIP(ip)
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func incrementIP(ip net.IP) {
|
||||
for j := len(ip) - 1; j >= 0; j-- {
|
||||
ip[j]++
|
||||
if ip[j] > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ipToUint32(ip net.IP) uint32 {
|
||||
return binary.BigEndian.Uint32(ip[:4])
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandTargets_SingleIP(t *testing.T) {
|
||||
ips, err := expandTargets([]string{"1.2.3.4"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "1.2.3.4" {
|
||||
t.Errorf("got %v, want [1.2.3.4]", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTargets_MultipleIPs(t *testing.T) {
|
||||
ips, err := expandTargets([]string{"1.2.3.4", "5.6.7.8", "9.10.11.12"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 3 {
|
||||
t.Errorf("got %d IPs, want 3", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTargets_DeduplicatesIPs(t *testing.T) {
|
||||
ips, err := expandTargets([]string{"1.2.3.4", "1.2.3.4", "5.6.7.8"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 2 {
|
||||
t.Errorf("got %d IPs, want 2 (dedup)", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTargets_SkipsEmpty(t *testing.T) {
|
||||
ips, err := expandTargets([]string{"", " ", "1.2.3.4"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 1 {
|
||||
t.Errorf("got %d IPs, want 1 (empty lines skipped)", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTargets_IPWithPort(t *testing.T) {
|
||||
// IP with port should be parsed by SplitHostPort fallback.
|
||||
// Note: this test may fail if DNS interception resolves "1.2.3.4:53" as a hostname.
|
||||
// We test with 127.0.0.1 which is less likely to be intercepted.
|
||||
ips, err := expandTargets([]string{"127.0.0.1:53"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "127.0.0.1" {
|
||||
t.Errorf("got %v, want [127.0.0.1]", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_Slash24(t *testing.T) {
|
||||
ips, err := expandCIDR("192.168.1.0/24")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// /24 = 256 addresses, minus network (192.168.1.0) and broadcast (192.168.1.255) = 254
|
||||
if len(ips) != 254 {
|
||||
t.Errorf("got %d IPs, want 254", len(ips))
|
||||
}
|
||||
// Should not contain network or broadcast address.
|
||||
for _, ip := range ips {
|
||||
if ip == "192.168.1.0" {
|
||||
t.Error("should not contain network address 192.168.1.0")
|
||||
}
|
||||
if ip == "192.168.1.255" {
|
||||
t.Error("should not contain broadcast address 192.168.1.255")
|
||||
}
|
||||
}
|
||||
// First should be .1, last should be .254.
|
||||
if ips[0] != "192.168.1.1" {
|
||||
t.Errorf("first IP = %s, want 192.168.1.1", ips[0])
|
||||
}
|
||||
if ips[len(ips)-1] != "192.168.1.254" {
|
||||
t.Errorf("last IP = %s, want 192.168.1.254", ips[len(ips)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_Slash30(t *testing.T) {
|
||||
ips, err := expandCIDR("10.0.0.0/30")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// /30 = 4 addresses, minus network and broadcast = 2
|
||||
if len(ips) != 2 {
|
||||
t.Errorf("got %d IPs, want 2", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_Slash32(t *testing.T) {
|
||||
ips, err := expandCIDR("10.0.0.5/32")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "10.0.0.5" {
|
||||
t.Errorf("got %v, want [10.0.0.5]", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_TooLarge(t *testing.T) {
|
||||
_, err := expandCIDR("10.0.0.0/8")
|
||||
if err == nil {
|
||||
t.Error("expected error for /8, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_Slash16_Limit(t *testing.T) {
|
||||
ips, err := expandCIDR("10.0.0.0/16")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// /16 = 65536 addresses, minus network and broadcast = 65534
|
||||
if len(ips) != 65534 {
|
||||
t.Errorf("got %d IPs, want 65534", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandCIDR_Invalid(t *testing.T) {
|
||||
_, err := expandCIDR("not-a-cidr")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid CIDR, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTargets_CIDR(t *testing.T) {
|
||||
ips, err := expandTargets([]string{"10.0.0.0/30"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 2 {
|
||||
t.Errorf("got %d IPs, want 2", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTargets_Mixed(t *testing.T) {
|
||||
ips, err := expandTargets([]string{"1.2.3.4", "10.0.0.0/30"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// 1 IP + 2 from /30 = 3
|
||||
if len(ips) != 3 {
|
||||
t.Errorf("got %d IPs, want 3", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolverScanner(t *testing.T) {
|
||||
rs := NewResolverScanner()
|
||||
if rs.state != ScannerIdle {
|
||||
t.Errorf("initial state = %s, want idle", rs.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_Progress_Idle(t *testing.T) {
|
||||
rs := NewResolverScanner()
|
||||
p := rs.Progress()
|
||||
if p.State != ScannerIdle {
|
||||
t.Errorf("state = %s, want idle", p.State)
|
||||
}
|
||||
if p.Total != 0 || p.Scanned != 0 || p.Found != 0 {
|
||||
t.Errorf("expected all zeroes: total=%d scanned=%d found=%d", p.Total, p.Scanned, p.Found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_Start_NoTargets(t *testing.T) {
|
||||
rs := NewResolverScanner()
|
||||
err := rs.Start(ScannerConfig{
|
||||
Targets: []string{},
|
||||
Passphrase: "test",
|
||||
Domain: "example.com",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty targets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_Start_InvalidCIDR(t *testing.T) {
|
||||
rs := NewResolverScanner()
|
||||
err := rs.Start(ScannerConfig{
|
||||
Targets: []string{"10.0.0.0/8"},
|
||||
Passphrase: "test",
|
||||
Domain: "example.com",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for too-large CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_StopIdle(t *testing.T) {
|
||||
rs := NewResolverScanner()
|
||||
// Stop on idle should not panic.
|
||||
rs.Stop()
|
||||
if rs.State() != ScannerDone {
|
||||
t.Errorf("state after stop = %s, want done", rs.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_PauseResumeIdle(t *testing.T) {
|
||||
rs := NewResolverScanner()
|
||||
// Pause/Resume on idle should be no-ops.
|
||||
rs.Pause()
|
||||
if rs.State() != ScannerIdle {
|
||||
t.Errorf("state after pause on idle = %s, want idle", rs.State())
|
||||
}
|
||||
rs.Resume()
|
||||
if rs.State() != ScannerIdle {
|
||||
t.Errorf("state after resume on idle = %s, want idle", rs.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"1.2.3.4", "1.2.3.5"},
|
||||
{"1.2.3.255", "1.2.4.0"},
|
||||
{"1.2.255.255", "1.3.0.0"},
|
||||
{"255.255.255.255", "0.0.0.0"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ip := parseIPv4(tt.in)
|
||||
incrementIP(ip)
|
||||
got := ip.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("incrementIP(%s) = %s, want %s", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPToUint32(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want uint32
|
||||
}{
|
||||
{"0.0.0.0", 0},
|
||||
{"0.0.0.1", 1},
|
||||
{"0.0.1.0", 256},
|
||||
{"1.0.0.0", 1 << 24},
|
||||
{"255.255.255.255", 0xFFFFFFFF},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ip := parseIPv4(tt.in)
|
||||
got := ipToUint32(ip)
|
||||
if got != tt.want {
|
||||
t.Errorf("ipToUint32(%s) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPv4(s string) net.IP {
|
||||
return net.ParseIP(s).To4()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/client"
|
||||
)
|
||||
|
||||
func (s *Server) handleScannerPresets(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
src := defaultScannerPresets
|
||||
var lines []string
|
||||
for _, line := range strings.Split(src, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "#") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
writeJSON(w, lines)
|
||||
}
|
||||
|
||||
func (s *Server) handleScannerStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Targets []string `json:"targets"`
|
||||
MaxIPs int `json:"maxIPs"`
|
||||
RateLimit int `json:"rateLimit"`
|
||||
Timeout float64 `json:"timeout"`
|
||||
ExpandSubnet bool `json:"expandSubnet"`
|
||||
QueryMode string `json:"queryMode"`
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Targets) == 0 {
|
||||
http.Error(w, "targets required", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve profile config.
|
||||
pl, err := s.loadProfiles()
|
||||
if err != nil || pl == nil {
|
||||
http.Error(w, "no profiles configured", 400)
|
||||
return
|
||||
}
|
||||
|
||||
var profileCfg *Config
|
||||
if req.ProfileID != "" {
|
||||
for _, p := range pl.Profiles {
|
||||
if p.ID == req.ProfileID {
|
||||
profileCfg = &p.Config
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if profileCfg == nil {
|
||||
// Fall back to active profile.
|
||||
for _, p := range pl.Profiles {
|
||||
if p.ID == pl.Active {
|
||||
profileCfg = &p.Config
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if profileCfg == nil {
|
||||
http.Error(w, "no profile found", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if profileCfg.Domain == "" || profileCfg.Key == "" {
|
||||
http.Error(w, "profile missing domain or passphrase", 400)
|
||||
return
|
||||
}
|
||||
|
||||
queryMode := req.QueryMode
|
||||
if queryMode == "" {
|
||||
queryMode = profileCfg.QueryMode
|
||||
}
|
||||
|
||||
cfg := client.ScannerConfig{
|
||||
Targets: req.Targets,
|
||||
MaxIPs: req.MaxIPs,
|
||||
RateLimit: req.RateLimit,
|
||||
Timeout: req.Timeout,
|
||||
ExpandSubnet: req.ExpandSubnet,
|
||||
QueryMode: queryMode,
|
||||
Domain: profileCfg.Domain,
|
||||
Passphrase: profileCfg.Key,
|
||||
}
|
||||
|
||||
s.scanner.SetLogFunc(func(msg string) {
|
||||
s.addLog(msg)
|
||||
})
|
||||
|
||||
if err := s.scanner.Start(cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("start scanner: %v", err), 400)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleScannerStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
s.scanner.Stop()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleScannerPause(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
s.scanner.Pause()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleScannerResume(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
s.scanner.Resume()
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleScannerProgress(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
prog := s.scanner.Progress()
|
||||
writeJSON(w, prog)
|
||||
}
|
||||
|
||||
func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Resolvers []string `json:"resolvers"`
|
||||
Mode string `json:"mode"` // "append" or "overwrite"
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// If no resolvers explicitly provided, pull them from scanner results.
|
||||
resolvers := req.Resolvers
|
||||
if len(resolvers) == 0 {
|
||||
prog := s.scanner.Progress()
|
||||
for _, r := range prog.Results {
|
||||
resolvers = append(resolvers, r.IP)
|
||||
}
|
||||
}
|
||||
if len(resolvers) == 0 {
|
||||
http.Error(w, "no resolvers to apply", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure resolvers have :53 suffix.
|
||||
for i, r := range resolvers {
|
||||
if !strings.Contains(r, ":") {
|
||||
resolvers[i] = r + ":53"
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which profile to apply to.
|
||||
pl, _ := s.loadProfiles()
|
||||
if pl == nil {
|
||||
http.Error(w, "no profiles configured", 400)
|
||||
return
|
||||
}
|
||||
|
||||
targetProfileID := req.ProfileID
|
||||
if targetProfileID == "" {
|
||||
targetProfileID = pl.Active
|
||||
}
|
||||
|
||||
var targetIdx int = -1
|
||||
for i, p := range pl.Profiles {
|
||||
if p.ID == targetProfileID {
|
||||
targetIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetIdx < 0 {
|
||||
http.Error(w, "profile not found", 400)
|
||||
return
|
||||
}
|
||||
|
||||
var newResolvers []string
|
||||
if req.Mode == "overwrite" {
|
||||
newResolvers = resolvers
|
||||
} else {
|
||||
// Append — deduplicate.
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range pl.Profiles[targetIdx].Config.Resolvers {
|
||||
seen[r] = true
|
||||
newResolvers = append(newResolvers, r)
|
||||
}
|
||||
for _, r := range resolvers {
|
||||
if !seen[r] {
|
||||
newResolvers = append(newResolvers, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pl.Profiles[targetIdx].Config.Resolvers = newResolvers
|
||||
if err := s.saveProfiles(pl); err != nil {
|
||||
http.Error(w, fmt.Sprintf("save profiles: %v", err), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// If this is the active profile, also update config + fetcher.
|
||||
if targetProfileID == pl.Active {
|
||||
s.mu.Lock()
|
||||
cfg := s.config
|
||||
s.mu.Unlock()
|
||||
if cfg != nil {
|
||||
cfg.Resolvers = newResolvers
|
||||
_ = s.saveConfig(cfg)
|
||||
s.mu.Lock()
|
||||
s.config = cfg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if err := s.initFetcher(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
|
||||
return
|
||||
}
|
||||
// The scanner already verified these resolvers, so skip the initial
|
||||
// health-check scan — set them as active directly, start only the
|
||||
// periodic checker, and fetch metadata immediately.
|
||||
s.mu.RLock()
|
||||
fetcher := s.fetcher
|
||||
checker := s.checker
|
||||
ctx := s.fetcherCtx
|
||||
s.mu.RUnlock()
|
||||
if fetcher != nil {
|
||||
fetcher.SetActiveResolvers(newResolvers)
|
||||
s.saveLastScan(newResolvers)
|
||||
}
|
||||
if checker != nil && ctx != nil {
|
||||
checker.StartPeriodic(ctx)
|
||||
}
|
||||
go s.refreshMetadataOnly()
|
||||
}
|
||||
|
||||
s.addLog(fmt.Sprintf("Scanner resolvers applied: %d resolvers (%s) to profile %s", len(resolvers), req.Mode, pl.Profiles[targetIdx].Nickname))
|
||||
writeJSON(w, map[string]any{"ok": true, "count": len(newResolvers)})
|
||||
}
|
||||
@@ -192,6 +192,11 @@
|
||||
color: var(--text)
|
||||
}
|
||||
|
||||
.icon-btn.scanning {
|
||||
animation: spin 1.2s linear infinite;
|
||||
color: var(--accent)
|
||||
}
|
||||
|
||||
/* ===== CHANNEL LIST ===== */
|
||||
.channel-list {
|
||||
flex: 1;
|
||||
@@ -1294,6 +1299,7 @@
|
||||
<span class="profile-btn-arrow"><span class="plus">+</span>▼</span>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings" data-i18n-title="settings">⚙</button>
|
||||
<button class="icon-btn" id="scannerIconBtn" onclick="openScanner()" title="Scanner" data-i18n-title="scanner_title" style="font-size:18px">🔍</button>
|
||||
<button class="icon-btn" onclick="jumpToLog()" title="LOG">📜</button>
|
||||
</div>
|
||||
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
|
||||
@@ -1478,11 +1484,129 @@
|
||||
<button class="btn btn-flat" onclick="closeProfileEditor()" data-i18n="cancel">Cancel</button>
|
||||
<button class="btn btn-danger" id="peDeleteBtn" style="display:none" onclick="deleteEditingProfile()"
|
||||
data-i18n="delete">Delete</button>
|
||||
<button class="btn btn-outline" id="peScannerBtn" style="display:none" onclick="openScannerFromProfile()"
|
||||
data-i18n="scanner_find_resolvers">🔍 Find Resolvers</button>
|
||||
<button class="btn btn-primary" onclick="saveProfile()" data-i18n="save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SCANNER MODAL ===== -->
|
||||
<div class="modal-overlay" id="scannerModal">
|
||||
<div class="modal" style="max-width:520px">
|
||||
<h2 data-i18n="scanner_title">🔍 Resolver Scanner</h2>
|
||||
|
||||
<!-- Collapsible help -->
|
||||
<div style="margin-bottom:14px">
|
||||
<p id="scannerAboutShort" style="font-size:13px;color:var(--text-dim);line-height:1.5;margin:0">
|
||||
<span data-i18n="scanner_about_short">Scan IP ranges to find DNS resolvers that work with your server.</span>
|
||||
<a href="#" id="scannerReadMoreLink" onclick="event.preventDefault();document.getElementById('scannerAboutFull').style.display='';this.style.display='none'" style="color:var(--accent);text-decoration:none" data-i18n="scanner_read_more">Read more...</a>
|
||||
</p>
|
||||
<p id="scannerAboutFull" style="display:none;font-size:12px;color:var(--text-dim);line-height:1.6;margin:0"><span data-i18n="scanner_about">This tool scans IP ranges to find DNS servers that can reach your thefeed server. Enter CIDRs (like 192.168.1.0/24) or individual IPs, pick a profile, and hit Scan. The app sends a small test query to each IP. If the IP answers correctly, it is a working resolver. You can also turn on "Expand /24" — when a working IP is found, the app will automatically check nearby IPs in the same network. Results show response time so you can pick the fastest ones. You can pause, resume, or stop the scan at any time.</span> <a href="#" onclick="event.preventDefault();document.getElementById('scannerAboutFull').style.display='none';document.getElementById('scannerReadMoreLink').style.display=''" style="color:var(--accent);text-decoration:none" data-i18n="scanner_read_less">Show less</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Config section -->
|
||||
<div id="scannerConfig">
|
||||
<div class="form-group">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||||
<label data-i18n="scanner_targets">IPs or CIDRs (one per line)</label>
|
||||
<button class="btn btn-flat" onclick="loadScannerPresets()" data-i18n="scanner_load_presets" style="font-size:12px;padding:4px 10px">🇮🇷 IR</button>
|
||||
</div>
|
||||
<textarea id="scanTargets" rows="3" placeholder="5.1.0.0/16 8.8.8.8 1.1.1.1" style="width:100%;font-family:monospace;font-size:13px"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="scanner_profile">Profile</label>
|
||||
<select id="scanProfile" style="width:100%"></select>
|
||||
</div>
|
||||
<details style="margin-bottom:12px">
|
||||
<summary style="cursor:pointer;font-size:13px;color:var(--text-dim);user-select:none" data-i18n="scanner_advanced">Advanced options</summary>
|
||||
<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<div class="form-group">
|
||||
<label data-i18n="query_mode">Query Mode</label>
|
||||
<select id="scanQueryMode" style="width:100%">
|
||||
<option value="single">Single label</option>
|
||||
<option value="double">Multi-label</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="scanner_rate_limit">Concurrency</label>
|
||||
<input type="number" id="scanRateLimit" value="50" min="1" max="500">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="scanner_timeout">Timeout (s)</label>
|
||||
<input type="number" id="scanTimeout" value="15" min="1" max="60">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="scanner_max_ips">Max IPs (0=all)</label>
|
||||
<input type="number" id="scanMaxIPs" value="0" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:4px">
|
||||
<div class="row" style="gap:6px;align-items:center">
|
||||
<input type="checkbox" id="scanExpand">
|
||||
<label for="scanExpand" style="font-size:13px" data-i18n="scanner_expand_subnet">Expand /24 — scan nearby IPs when a resolver is found</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Progress section -->
|
||||
<div id="scannerProgressSection" style="display:none">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||
<div style="font-size:14px;color:var(--text)">
|
||||
<strong id="scanStatusLabel">-</strong>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--text-dim)">
|
||||
<span id="scanProgressText">0 / 0</span> — <span id="scanFoundText">0</span> <span data-i18n="scanner_found">found</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:6px;border-radius:3px;margin-bottom:14px;background:var(--border);overflow:hidden">
|
||||
<div id="scanProgressFill" style="width:0%;height:100%;border-radius:3px;background:var(--accent);transition:width .3s"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results section -->
|
||||
<div id="scannerResults" style="display:none;max-height:220px;overflow-y:auto;margin-bottom:14px;border:1px solid var(--border);border-radius:8px">
|
||||
<table style="width:100%;font-size:13px;border-collapse:collapse">
|
||||
<thead><tr style="background:var(--bg);position:sticky;top:0;z-index:1">
|
||||
<th style="padding:8px;text-align:left;width:32px"><input type="checkbox" id="scanSelectAll" checked onchange="toggleScanSelectAll(this.checked)"></th>
|
||||
<th style="padding:8px;text-align:left">IP</th>
|
||||
<th style="padding:8px;text-align:right" data-i18n="scanner_latency">ms</th>
|
||||
<th style="padding:8px;width:36px"></th>
|
||||
</tr></thead>
|
||||
<tbody id="scanResultsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Action bar -->
|
||||
<div id="scannerApplySection" style="display:none;margin-bottom:14px">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" style="flex:1;min-width:0;padding:10px 0;font-size:14px" onclick="applyScanResults('append')">
|
||||
<span data-i18n="scanner_append">Append</span> <span id="scanAppendCount" style="opacity:.7"></span>
|
||||
</button>
|
||||
<button class="btn btn-outline" style="flex:1;min-width:0;padding:10px 0;font-size:14px" onclick="applyScanResults('overwrite')">
|
||||
<span data-i18n="scanner_overwrite">Overwrite</span> <span id="scanOverwriteCount" style="opacity:.7"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-flat" style="flex:1;padding:8px 0;font-size:13px" onclick="copySelectedScanResults()">
|
||||
<span data-i18n="copy">Copy</span> <span id="scanCopyCount" style="opacity:.7"></span>
|
||||
</button>
|
||||
<button class="btn btn-flat" style="flex:1;padding:8px 0;font-size:13px" onclick="copyAllScanResults()" data-i18n="scanner_copy_all">Copy All</button>
|
||||
<button class="btn btn-outline" style="flex:1;padding:8px 0;font-size:13px;font-weight:600" onclick="resetScannerUI()" data-i18n="scanner_new_scan">↺ New Scan</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions" style="gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-flat" onclick="closeScanner()" data-i18n="close" style="padding:10px 20px;font-size:14px">Close</button>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn btn-outline" id="scanPauseBtn" style="display:none;padding:10px 20px;font-size:14px" onclick="toggleScanPause()" data-i18n="scanner_pause">Pause</button>
|
||||
<button class="btn btn-danger" id="scanStopBtn" style="display:none;padding:10px 20px;font-size:14px" onclick="stopScan()" data-i18n="scanner_stop">Stop</button>
|
||||
<button class="btn btn-primary" id="scanStartBtn" onclick="startScan()" data-i18n="scanner_start" style="padding:10px 24px;font-size:14px">Start Scan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== i18n =====
|
||||
var I18N = {
|
||||
@@ -1534,6 +1658,38 @@
|
||||
saved_resolvers_applied: 'سرورهای DNS ذخیرهشده اعمال شدند',
|
||||
minutes_ago: 'دقیقه پیش',
|
||||
hours_ago: 'ساعت پیش',
|
||||
scanner_title: '\uD83D\uDD0D اسکنر ریزالور',
|
||||
scanner_about: 'این ابزار بازههای IP را برای پیدا کردن سرورهای DNS که به سرور thefeed شما دسترسی دارند اسکن میکند. CIDR (مثل 192.168.1.0/24) یا IP وارد کنید، پروفایل را انتخاب کنید و اسکن را شروع کنید. برنامه یک کوئری آزمایشی کوچک به هر IP میفرستد. اگر جواب درست بدهد، یک ریزالور کارآمد است. میتوانید «گسترش /24» را فعال کنید — وقتی یک IP کارآمد پیدا شد، آیپیهای نزدیک هم بررسی میشوند. نتایج زمان پاسخدهی را نشان میدهند تا بتوانید سریعترینها را انتخاب کنید. میتوانید هر زمان اسکن را متوقف، مکث یا ادامه دهید.',
|
||||
scanner_targets: 'آیپی یا CIDR (هر خط یکی)',
|
||||
scanner_profile: 'پروفایل',
|
||||
scanner_rate_limit: 'همزمانی',
|
||||
scanner_timeout: 'تایماوت (ثانیه)',
|
||||
scanner_max_ips: 'حداکثر آیپی (0=همه)',
|
||||
scanner_expand_subnet: 'گسترش /24 — وقتی ریزالور کارآمد پیدا شد آیپیهای نزدیک هم بررسی شوند',
|
||||
scanner_status: 'وضعیت',
|
||||
scanner_found: 'پیدا شده',
|
||||
scanner_latency: 'زمان پاسخ',
|
||||
scanner_append: 'افزودن به تنظیمات',
|
||||
scanner_overwrite: 'جایگزینی تنظیمات',
|
||||
scanner_start: 'شروع اسکن',
|
||||
scanner_stop: 'توقف',
|
||||
scanner_pause: 'مکث',
|
||||
scanner_resume: 'ادامه',
|
||||
scanner_find_resolvers: '\uD83D\uDD0D پیدا کردن ریزالور',
|
||||
scanner_running: 'در حال اسکن',
|
||||
scanner_paused: 'مکث شده',
|
||||
scanner_done: 'تمام شد',
|
||||
scanner_idle: 'آماده',
|
||||
scanner_applied: 'ریزالورها اعمال شدند',
|
||||
scanner_no_results: 'هیچ ریزالور کارآمدی پیدا نشد',
|
||||
scanner_already_running: 'اسکنر در حال اجراست',
|
||||
scanner_about_short: 'بازههای IP را اسکن کنید تا ریزالورهای DNS سازگار با سرور شما پیدا شوند.',
|
||||
scanner_read_more: 'بیشتر بخوانید...',
|
||||
scanner_read_less: 'بستن',
|
||||
scanner_load_presets: '\uD83C\uDDEE\uD83C\uDDF7 بارگذاری لیست ایران',
|
||||
scanner_new_scan: 'اسکن جدید',
|
||||
scanner_advanced: 'تنظیمات پیشرفته',
|
||||
scanner_copy_all: 'کپی همه',
|
||||
},
|
||||
en: {
|
||||
search: 'Search...', settings: 'Settings', profiles: 'Profiles',
|
||||
@@ -1583,6 +1739,38 @@
|
||||
saved_resolvers_applied: 'Saved DNS servers applied!',
|
||||
minutes_ago: 'min ago',
|
||||
hours_ago: 'hr ago',
|
||||
scanner_title: '\uD83D\uDD0D Resolver Scanner',
|
||||
scanner_about: 'This tool scans IP ranges to find DNS servers that can reach your thefeed server. Enter CIDRs (like 192.168.1.0/24) or individual IPs, pick a profile, and hit Scan. The app sends a small test query to each IP. If the IP answers correctly, it is a working resolver. You can also turn on "Expand /24" — when a working IP is found, the app will automatically check nearby IPs in the same network. Results show response time so you can pick the fastest ones. You can pause, resume, or stop the scan at any time.',
|
||||
scanner_targets: 'IPs or CIDRs (one per line)',
|
||||
scanner_profile: 'Profile',
|
||||
scanner_rate_limit: 'Concurrency',
|
||||
scanner_timeout: 'Timeout (s)',
|
||||
scanner_max_ips: 'Max IPs (0=all)',
|
||||
scanner_expand_subnet: 'Expand /24 — scan nearby IPs when a working resolver is found',
|
||||
scanner_status: 'Status',
|
||||
scanner_found: 'found',
|
||||
scanner_latency: 'Latency',
|
||||
scanner_append: 'Append to Config',
|
||||
scanner_overwrite: 'Overwrite Config',
|
||||
scanner_start: 'Start Scan',
|
||||
scanner_stop: 'Stop',
|
||||
scanner_pause: 'Pause',
|
||||
scanner_resume: 'Resume',
|
||||
scanner_find_resolvers: '\uD83D\uDD0D Find Resolvers',
|
||||
scanner_running: 'Running',
|
||||
scanner_paused: 'Paused',
|
||||
scanner_done: 'Done',
|
||||
scanner_idle: 'Ready',
|
||||
scanner_applied: 'Resolvers applied',
|
||||
scanner_no_results: 'No working resolvers found',
|
||||
scanner_already_running: 'Scanner is already running',
|
||||
scanner_about_short: 'Scan IP ranges to find DNS resolvers that work with your server.',
|
||||
scanner_read_more: 'Read more...',
|
||||
scanner_read_less: 'Show less',
|
||||
scanner_load_presets: '\uD83C\uDDEE\uD83C\uDDF7 Load IR Presets',
|
||||
scanner_new_scan: 'New Scan',
|
||||
scanner_advanced: 'Advanced options',
|
||||
scanner_copy_all: 'Copy All',
|
||||
}
|
||||
};
|
||||
var lang = localStorage.getItem('thefeed_lang') || 'fa';
|
||||
@@ -1937,6 +2125,7 @@
|
||||
if (id) {
|
||||
document.getElementById('profileEditorTitle').textContent = t('edit_profile');
|
||||
document.getElementById('peDeleteBtn').style.display = '';
|
||||
document.getElementById('peScannerBtn').style.display = '';
|
||||
var p = profiles && profiles.profiles && profiles.profiles.find(function (x) { return x.id === id });
|
||||
if (p) {
|
||||
document.getElementById('peNick').value = p.nickname || '';
|
||||
@@ -1961,6 +2150,7 @@
|
||||
} else {
|
||||
document.getElementById('profileEditorTitle').textContent = t('new_profile');
|
||||
document.getElementById('peDeleteBtn').style.display = 'none';
|
||||
document.getElementById('peScannerBtn').style.display = 'none';
|
||||
document.getElementById('peNick').value = '';
|
||||
document.getElementById('peDomain').value = '';
|
||||
document.getElementById('peKey').value = '';
|
||||
@@ -2525,11 +2715,271 @@
|
||||
function esc(s) { var d = document.createElement('div'); d.appendChild(document.createTextNode(s)); return d.innerHTML }
|
||||
function isPersian(text) { return text && (text.match(/[\u0600-\u06FF]/g) || []).length > text.length * 0.25 }
|
||||
|
||||
// ===== SCANNER =====
|
||||
var scanPollTimer = null;
|
||||
var scanLastResults = []; // cache for selection
|
||||
|
||||
function openScanner() {
|
||||
document.getElementById('scannerModal').classList.add('active');
|
||||
populateScanProfileSelect();
|
||||
pollScannerOnce();
|
||||
}
|
||||
function closeScanner() {
|
||||
document.getElementById('scannerModal').classList.remove('active');
|
||||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
||||
}
|
||||
function openScannerFromProfile() {
|
||||
var profileId = editingProfileId;
|
||||
closeProfileEditor();
|
||||
openScanner();
|
||||
if (profileId) {
|
||||
var sel = document.getElementById('scanProfile');
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === profileId) { sel.selectedIndex = i; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function populateScanProfileSelect() {
|
||||
var sel = document.getElementById('scanProfile');
|
||||
sel.innerHTML = '';
|
||||
if (profiles && profiles.profiles) {
|
||||
for (var i = 0; i < profiles.profiles.length; i++) {
|
||||
var p = profiles.profiles[i];
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.nickname || p.id;
|
||||
if (p.id === activeProfileId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScannerPresets() {
|
||||
try {
|
||||
var r = await fetch('/api/scanner/presets');
|
||||
if (!r.ok) return;
|
||||
var lines = await r.json();
|
||||
if (lines && lines.length) {
|
||||
var el = document.getElementById('scanTargets');
|
||||
var existing = el.value.trim();
|
||||
el.value = existing ? existing + '\n' + lines.join('\n') : lines.join('\n');
|
||||
}
|
||||
} catch (e) { showToast(e.message) }
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
var targets = document.getElementById('scanTargets').value.trim().split('\n').filter(function (s) { return s.trim() });
|
||||
if (!targets.length) { showToast(t('scanner_targets')); return }
|
||||
// Clear stale results from previous scan.
|
||||
scanLastResults = [];
|
||||
document.getElementById('scanResultsBody').innerHTML = '';
|
||||
document.getElementById('scannerApplySection').style.display = 'none';
|
||||
var body = {
|
||||
targets: targets,
|
||||
profileId: document.getElementById('scanProfile').value,
|
||||
rateLimit: parseInt(document.getElementById('scanRateLimit').value) || 50,
|
||||
timeout: parseInt(document.getElementById('scanTimeout').value) || 15,
|
||||
maxIPs: parseInt(document.getElementById('scanMaxIPs').value) || 0,
|
||||
expandSubnet: document.getElementById('scanExpand').checked,
|
||||
queryMode: document.getElementById('scanQueryMode').value
|
||||
};
|
||||
try {
|
||||
var r = await fetch('/api/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) { showToast(await r.text() || 'Failed to start'); return }
|
||||
showScanRunning();
|
||||
startScanPolling();
|
||||
} catch (e) { showToast(e.message) }
|
||||
}
|
||||
|
||||
async function stopScan() {
|
||||
try { await fetch('/api/scanner/stop', { method: 'POST' }) } catch (e) {}
|
||||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
||||
setTimeout(pollScannerOnce, 300);
|
||||
}
|
||||
|
||||
async function toggleScanPause() {
|
||||
var btn = document.getElementById('scanPauseBtn');
|
||||
var isPaused = btn.dataset.paused === '1';
|
||||
try {
|
||||
await fetch('/api/scanner/' + (isPaused ? 'resume' : 'pause'), { method: 'POST' });
|
||||
pollScannerOnce();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function showScanRunning() {
|
||||
document.getElementById('scannerConfig').style.display = 'none';
|
||||
document.getElementById('scannerProgressSection').style.display = '';
|
||||
document.getElementById('scannerResults').style.display = '';
|
||||
document.getElementById('scanStartBtn').style.display = 'none';
|
||||
document.getElementById('scanStopBtn').style.display = '';
|
||||
document.getElementById('scanPauseBtn').style.display = '';
|
||||
document.getElementById('scannerApplySection').style.display = 'none';
|
||||
document.getElementById('scannerIconBtn').classList.add('scanning');
|
||||
}
|
||||
|
||||
function showScanIdle() {
|
||||
document.getElementById('scannerConfig').style.display = '';
|
||||
document.getElementById('scannerProgressSection').style.display = 'none';
|
||||
document.getElementById('scannerResults').style.display = 'none';
|
||||
document.getElementById('scannerApplySection').style.display = 'none';
|
||||
document.getElementById('scanStartBtn').style.display = '';
|
||||
document.getElementById('scanStartBtn').textContent = t('scanner_start');
|
||||
document.getElementById('scanStopBtn').style.display = 'none';
|
||||
document.getElementById('scanPauseBtn').style.display = 'none';
|
||||
document.getElementById('scannerIconBtn').classList.remove('scanning');
|
||||
}
|
||||
|
||||
function resetScannerUI() {
|
||||
showScanIdle();
|
||||
document.getElementById('scannerAboutFull').style.display = 'none';
|
||||
document.getElementById('scannerAboutShort').querySelector('a').style.display = '';
|
||||
}
|
||||
|
||||
function showScanDone(progress) {
|
||||
document.getElementById('scannerConfig').style.display = 'none';
|
||||
document.getElementById('scannerProgressSection').style.display = '';
|
||||
document.getElementById('scannerResults').style.display = '';
|
||||
document.getElementById('scanStartBtn').style.display = 'none';
|
||||
document.getElementById('scanStopBtn').style.display = 'none';
|
||||
document.getElementById('scanPauseBtn').style.display = 'none';
|
||||
document.getElementById('scannerIconBtn').classList.remove('scanning');
|
||||
// Always show the apply section (it has the New Scan button).
|
||||
document.getElementById('scannerApplySection').style.display = '';
|
||||
if (progress && progress.results && progress.results.length > 0) {
|
||||
updateScanSelectedCount();
|
||||
}
|
||||
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
|
||||
}
|
||||
|
||||
function getSelectedScanIPs() {
|
||||
var cbs = document.querySelectorAll('.scan-select-cb:checked');
|
||||
var ips = [];
|
||||
for (var i = 0; i < cbs.length; i++) ips.push(cbs[i].dataset.ip);
|
||||
return ips;
|
||||
}
|
||||
|
||||
function updateScanSelectedCount() {
|
||||
var n = getSelectedScanIPs().length;
|
||||
var label = '(' + n + ')';
|
||||
var el1 = document.getElementById('scanAppendCount');
|
||||
var el2 = document.getElementById('scanOverwriteCount');
|
||||
var el3 = document.getElementById('scanCopyCount');
|
||||
if (el1) el1.textContent = label;
|
||||
if (el2) el2.textContent = label;
|
||||
if (el3) el3.textContent = label;
|
||||
}
|
||||
|
||||
function toggleScanSelectAll(checked) {
|
||||
var cbs = document.querySelectorAll('.scan-select-cb');
|
||||
for (var i = 0; i < cbs.length; i++) cbs[i].checked = checked;
|
||||
updateScanSelectedCount();
|
||||
}
|
||||
|
||||
function renderScanProgress(p) {
|
||||
var pct = p.total > 0 ? Math.round(p.scanned / p.total * 100) : 0;
|
||||
document.getElementById('scanProgressFill').style.width = pct + '%';
|
||||
document.getElementById('scanProgressText').textContent = p.scanned + ' / ' + p.total;
|
||||
document.getElementById('scanFoundText').textContent = p.found || 0;
|
||||
|
||||
var stateKey = 'scanner_' + p.state;
|
||||
document.getElementById('scanStatusLabel').textContent = t(stateKey);
|
||||
|
||||
var pauseBtn = document.getElementById('scanPauseBtn');
|
||||
if (p.state === 'paused') {
|
||||
pauseBtn.textContent = t('scanner_resume');
|
||||
pauseBtn.dataset.paused = '1';
|
||||
} else {
|
||||
pauseBtn.textContent = t('scanner_pause');
|
||||
pauseBtn.dataset.paused = '0';
|
||||
}
|
||||
|
||||
// Render results table
|
||||
var results = p.results || [];
|
||||
scanLastResults = results;
|
||||
var body = document.getElementById('scanResultsBody');
|
||||
body.innerHTML = '';
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var r = results[i];
|
||||
var tr = document.createElement('tr');
|
||||
tr.style.borderTop = '1px solid var(--border)';
|
||||
tr.innerHTML = '<td style="padding:8px"><input type="checkbox" class="scan-select-cb" data-ip="' + esc(r.ip) + '" checked onchange="updateScanSelectedCount()"></td>' +
|
||||
'<td style="padding:8px;font-family:monospace;font-size:13px">' + esc(r.ip) + '</td>' +
|
||||
'<td style="padding:8px;text-align:right;font-size:13px;color:var(--text-dim)">' + (r.latencyMs != null ? Math.round(r.latencyMs) + 'ms' : '-') + '</td>' +
|
||||
'<td style="padding:4px 8px"><button class="btn btn-flat" style="font-size:12px;padding:4px 8px;min-width:0" onclick="navigator.clipboard.writeText(\'' + esc(r.ip) + '\');showToast(t(\'copied\'))">☰</button></td>';
|
||||
body.appendChild(tr);
|
||||
}
|
||||
|
||||
if (p.state === 'done') {
|
||||
showScanDone(p);
|
||||
} else if (p.state === 'running' || p.state === 'paused') {
|
||||
showScanRunning();
|
||||
if (p.state === 'paused') {
|
||||
document.getElementById('scanPauseBtn').textContent = t('scanner_resume');
|
||||
document.getElementById('scanPauseBtn').dataset.paused = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startScanPolling() {
|
||||
if (scanPollTimer) clearInterval(scanPollTimer);
|
||||
scanPollTimer = setInterval(pollScannerOnce, 1500);
|
||||
}
|
||||
|
||||
async function pollScannerOnce() {
|
||||
try {
|
||||
var r = await fetch('/api/scanner/progress');
|
||||
if (!r.ok) return;
|
||||
var p = await r.json();
|
||||
if (p.state === 'running' || p.state === 'paused') {
|
||||
renderScanProgress(p);
|
||||
if (!scanPollTimer) startScanPolling();
|
||||
} else if (p.state === 'done') {
|
||||
renderScanProgress(p);
|
||||
} else {
|
||||
// idle
|
||||
if (p.results && p.results.length > 0) {
|
||||
renderScanProgress(p);
|
||||
showScanDone(p);
|
||||
} else {
|
||||
showScanIdle();
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function applyScanResults(mode) {
|
||||
var ips = getSelectedScanIPs();
|
||||
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
||||
try {
|
||||
var r = await fetch('/api/scanner/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolvers: ips, mode: mode, profileId: document.getElementById('scanProfile').value })
|
||||
});
|
||||
if (!r.ok) { showToast(await r.text() || 'Failed'); return }
|
||||
showToast(t('scanner_applied'));
|
||||
await loadProfiles();
|
||||
} catch (e) { showToast(e.message) }
|
||||
}
|
||||
|
||||
function copySelectedScanResults() {
|
||||
var ips = getSelectedScanIPs();
|
||||
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
||||
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
||||
}
|
||||
|
||||
function copyAllScanResults() {
|
||||
var ips = scanLastResults.map(function (r) { return r.ip });
|
||||
if (!ips.length) { showToast(t('scanner_no_results')); return }
|
||||
navigator.clipboard.writeText(ips.join('\n')).then(function () { showToast(t('copied')) });
|
||||
}
|
||||
|
||||
// ===== EVENTS =====
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('sendInput')) { e.preventDefault(); sendMessage() }
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('peAddChannelInput')) { e.preventDefault(); addChannelEditor() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner() }
|
||||
});
|
||||
window.addEventListener('resize', function () { if (window.innerWidth > 768) document.getElementById('app').classList.remove('chat-open') });
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ type Server struct {
|
||||
clients map[chan string]struct{}
|
||||
|
||||
stopRefresh chan struct{}
|
||||
|
||||
scanner *client.ResolverScanner
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -124,6 +126,8 @@ func New(dataDir string, port int, password string) (*Server, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := client.NewResolverScanner()
|
||||
|
||||
s := &Server{
|
||||
dataDir: dataDir,
|
||||
port: port,
|
||||
@@ -133,6 +137,7 @@ func New(dataDir string, port int, password string) (*Server, error) {
|
||||
channelFetching: make(map[int]bool),
|
||||
lastMsgIDs: make(map[int]uint32),
|
||||
lastHashes: make(map[int]uint32),
|
||||
scanner: scanner,
|
||||
}
|
||||
|
||||
cfg, err := s.loadConfig()
|
||||
@@ -182,6 +187,13 @@ func (s *Server) Run() error {
|
||||
mux.HandleFunc("/api/version-check", s.handleVersionCheck)
|
||||
mux.HandleFunc("/api/cache/clear", s.handleClearCache)
|
||||
mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers)
|
||||
mux.HandleFunc("/api/scanner/start", s.handleScannerStart)
|
||||
mux.HandleFunc("/api/scanner/stop", s.handleScannerStop)
|
||||
mux.HandleFunc("/api/scanner/pause", s.handleScannerPause)
|
||||
mux.HandleFunc("/api/scanner/resume", s.handleScannerResume)
|
||||
mux.HandleFunc("/api/scanner/progress", s.handleScannerProgress)
|
||||
mux.HandleFunc("/api/scanner/apply", s.handleScannerApply)
|
||||
mux.HandleFunc("/api/scanner/presets", s.handleScannerPresets)
|
||||
mux.HandleFunc("/", s.handleIndex)
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
||||
@@ -643,6 +655,7 @@ func (s *Server) initFetcher() error {
|
||||
debug = pl.Debug
|
||||
}
|
||||
fetcher.SetDebug(debug)
|
||||
s.scanner.SetDebug(debug)
|
||||
if cfg.RateLimit > 0 {
|
||||
fetcher.SetRateLimit(cfg.RateLimit)
|
||||
}
|
||||
@@ -707,6 +720,7 @@ func (s *Server) checkLatestVersion(ctx context.Context) (string, error) {
|
||||
debug = pl.Debug
|
||||
}
|
||||
fetcher.SetDebug(debug)
|
||||
s.scanner.SetDebug(debug)
|
||||
if cfg.RateLimit > 0 {
|
||||
fetcher.SetRateLimit(cfg.RateLimit)
|
||||
}
|
||||
@@ -1405,6 +1419,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if f != nil {
|
||||
f.SetDebug(req.Debug)
|
||||
}
|
||||
s.scanner.SetDebug(req.Debug)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestE2E_Scanner_ProgressIdle(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := getJSON(t, base+"/api/scanner/progress")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/scanner/progress: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
m := decodeJSON(t, resp)
|
||||
if m["state"] != "idle" {
|
||||
t.Errorf("state = %v, want idle", m["state"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_StartWithoutBody(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp, err := http.Post(base+"/api/scanner/start", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Should fail — no targets.
|
||||
if resp.StatusCode != 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 400, got %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_StartNoProfile(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
body := `{"targets":["192.168.0.0/28"]}`
|
||||
resp := postJSON(t, base+"/api/scanner/start", body)
|
||||
defer resp.Body.Close()
|
||||
// Without any profile configured, should fail.
|
||||
if resp.StatusCode != 400 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 400, got %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_StopIdle(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp, err := http.Post(base+"/api/scanner/stop", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_PauseIdle(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp, err := http.Post(base+"/api/scanner/pause", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_ResumeIdle(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp, err := http.Post(base+"/api/scanner/resume", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_ApplyNoResults(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
body := `{"mode":"append"}`
|
||||
resp := postJSON(t, base+"/api/scanner/apply", body)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("expected 400 (no results), got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_MethodNotAllowed(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
// GET on a POST-only endpoint.
|
||||
resp, err := http.Get(base + "/api/scanner/start")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 405 {
|
||||
t.Errorf("expected 405, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_StartWithProfile(t *testing.T) {
|
||||
base, srv := startWebServer(t)
|
||||
|
||||
// Create a profile first.
|
||||
profileBody := `{"action":"create","profile":{"id":"","nickname":"ScanTest","config":{"domain":"test.example.com","key":"testkey","resolvers":["127.0.0.1:19999"],"queryMode":"single","rateLimit":5}}}`
|
||||
resp := postJSON(t, base+"/api/profiles", profileBody)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("create profile: expected 200, got %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
// Start a scan with non-routable IPs (will time out quickly).
|
||||
scanBody := `{"targets":["192.0.2.1","192.0.2.2"],"timeout":1,"rateLimit":2}`
|
||||
resp2 := postJSON(t, base+"/api/scanner/start", scanBody)
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf("start scanner: expected 200, got %d: %s", resp2.StatusCode, string(b))
|
||||
}
|
||||
|
||||
// Check progress.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
resp3 := getJSON(t, base+"/api/scanner/progress")
|
||||
m := decodeJSON(t, resp3)
|
||||
state := m["state"].(string)
|
||||
if state != "running" && state != "done" {
|
||||
t.Errorf("state = %s, want running or done", state)
|
||||
}
|
||||
|
||||
// Wait for completion.
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
resp4 := getJSON(t, base+"/api/scanner/progress")
|
||||
m2 := decodeJSON(t, resp4)
|
||||
if m2["state"].(string) == "done" || m2["state"].(string) == "idle" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_ = srv
|
||||
}
|
||||
|
||||
func TestE2E_Scanner_Presets(t *testing.T) {
|
||||
base, _ := startWebServer(t)
|
||||
|
||||
resp := getJSON(t, base+"/api/scanner/presets")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/scanner/presets: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var lines []string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lines); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
t.Error("expected non-empty presets list")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user