mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 10:14:35 +03:00
342 lines
8.7 KiB
Go
342 lines
8.7 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
type preset struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Count int `json:"count"`
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"presets": []preset{
|
|
{Name: "default", Label: "Default", Count: parseScannerPresetCount()},
|
|
},
|
|
})
|
|
}
|
|
|
|
// parseScannerPresetLines returns the parsed non-empty, non-comment lines from the preset.
|
|
func parseScannerPresetLines() []string {
|
|
var lines []string
|
|
for _, line := range strings.Split(defaultScannerPresets, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func parseScannerPresetCount() int {
|
|
return len(parseScannerPresetLines())
|
|
}
|
|
|
|
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"`
|
|
Preset string `json:"preset"` // e.g. "default" — server-side preset, avoids sending 50K IPs
|
|
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
|
|
}
|
|
|
|
// Resolve preset into targets server-side.
|
|
if req.Preset == "default" && len(req.Targets) == 0 {
|
|
req.Targets = parseScannerPresetLines()
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
// Cancel any in-progress resolver checker scan to avoid resource
|
|
// contention (both the checker and scanner do DNS probes).
|
|
s.mu.RLock()
|
|
checker := s.checker
|
|
s.mu.RUnlock()
|
|
if checker != nil {
|
|
checker.CancelCurrentScan()
|
|
}
|
|
|
|
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"`
|
|
// ListName picks which named active-list receives these
|
|
// resolvers. Empty → currently-selected list, or "Default"
|
|
// for legacy installs that haven't been migrated yet.
|
|
ListName string `json:"listName,omitempty"`
|
|
}
|
|
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 (for logging purposes / active check).
|
|
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
|
|
}
|
|
|
|
// Update the shared resolver bank instead of per-profile resolvers.
|
|
if req.Mode == "overwrite" {
|
|
pl.ResolverBank = resolvers
|
|
} else {
|
|
// Append — deduplicate against existing bank.
|
|
addToBank(pl, resolvers)
|
|
}
|
|
|
|
// Save under the requested list (or current selection / Default).
|
|
listName := sanitizeListName(req.ListName)
|
|
if listName == "" {
|
|
listName = strings.TrimSpace(pl.SelectedList)
|
|
}
|
|
if listName == "" && len(pl.ActiveLists) > 0 {
|
|
listName = pl.ActiveLists[0].Name
|
|
}
|
|
if listName == "" {
|
|
listName = defaultListName
|
|
}
|
|
target := findList(pl, listName)
|
|
if target == nil {
|
|
pl.ActiveLists = append(pl.ActiveLists, ActiveList{Name: listName})
|
|
target = &pl.ActiveLists[len(pl.ActiveLists)-1]
|
|
}
|
|
if req.Mode == "overwrite" {
|
|
target.Resolvers = append([]string(nil), resolvers...)
|
|
} else {
|
|
seen := map[string]bool{}
|
|
for _, r := range target.Resolvers {
|
|
seen[r] = true
|
|
}
|
|
for _, r := range resolvers {
|
|
if !seen[r] {
|
|
target.Resolvers = append(target.Resolvers, r)
|
|
seen[r] = true
|
|
}
|
|
}
|
|
}
|
|
target.LastUsed = time.Now().Unix()
|
|
pl.SelectedList = target.Name
|
|
|
|
if err := s.saveProfiles(pl); err != nil {
|
|
http.Error(w, fmt.Sprintf("save profiles: %v", err), 500)
|
|
return
|
|
}
|
|
|
|
// If this is the active profile, re-init the fetcher with the updated bank.
|
|
if targetProfileID == pl.Active {
|
|
// Cancel any in-progress checker scan before re-initializing so the
|
|
// old goroutine exits quickly and doesn't race with the new fetcher.
|
|
s.mu.RLock()
|
|
oldChecker := s.checker
|
|
s.mu.RUnlock()
|
|
if oldChecker != nil {
|
|
oldChecker.CancelCurrentScan()
|
|
}
|
|
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 {
|
|
// Pool AND active are the full saved list, not just the
|
|
// freshly-scanned subset — append mode merges the new
|
|
// resolvers into pre-existing list entries, and the user
|
|
// expects all of them live (otherwise the Active panel
|
|
// shows N while the tab badge shows N+M, which looks
|
|
// like the apply lost the old ones).
|
|
if target != nil && len(target.Resolvers) > 0 {
|
|
fetcher.UpdateResolverPool(target.Resolvers)
|
|
fetcher.SetActiveResolvers(target.Resolvers)
|
|
} else {
|
|
fetcher.SetActiveResolvers(resolvers)
|
|
}
|
|
s.saveLastScan(resolvers)
|
|
}
|
|
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))
|
|
s.broadcast("event: update\ndata: \"resolver-lists\"\n\n")
|
|
writeJSON(w, map[string]any{"ok": true, "count": len(pl.ResolverBank)})
|
|
}
|