package web import ( "bufio" "context" "embed" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "rmm-hunter/internal/suspicious" "strings" "sync" "time" "rmm-hunter/internal/pkg" "rmm-hunter/internal/pkg/hunt/eliminate" "rmm-hunter/internal/pkg/hunter" "github.com/gorilla/websocket" ) //go:embed templates/* var contentFS embed.FS // broadcaster for hunt logs type wsHub struct { mu sync.Mutex conns map[*websocket.Conn]struct{} } func newHub() *wsHub { return &wsHub{conns: make(map[*websocket.Conn]struct{})} } func (h *wsHub) add(c *websocket.Conn) { h.mu.Lock(); h.conns[c] = struct{}{}; h.mu.Unlock() } func (h *wsHub) rm(c *websocket.Conn) { h.mu.Lock(); delete(h.conns, c); h.mu.Unlock() } func (h *wsHub) send(msg string) { h.mu.Lock() for c := range h.conns { _ = c.WriteMessage(websocket.TextMessage, []byte(msg)) } h.mu.Unlock() } // JSONReportMeta is a lightweight descriptor for previous hunts type JSONReportMeta struct { File string `json:"file"` ReportName string `json:"reportName"` GeneratedAt string `json:"generatedAt"` } type server struct { hub *wsHub http *http.Server quitCh chan struct{} } func StartWebServer() { var hostAdded bool h := newHub() s := &server{hub: h, quitCh: make(chan struct{})} // Add hosts file entry for rmm-hunter if err := AddHostsEntry(); err != nil { log.Printf("[web] Warning: Failed to add hosts entry: %v\n", err) } else { hostAdded = true } mux := http.NewServeMux() mux.HandleFunc("/", s.handleIndex) mux.HandleFunc("/logo", s.handleLogo) mux.HandleFunc("/favicon.ico", s.handleFavicon) mux.HandleFunc("/favicon-32x32.png", s.handleFavicon) mux.HandleFunc("/favicon-16x16.png", s.handleFavicon) mux.HandleFunc("/apple-touch-icon.png", s.handleFavicon) mux.HandleFunc("/site.webmanifest", s.handleManifest) mux.HandleFunc("/api/hunts", s.handleListHunts) mux.HandleFunc("/api/hunt/start", s.handleStartHunt) mux.HandleFunc("/api/report", s.handleGetReport) mux.HandleFunc("/api/eliminate", s.handleEliminate) mux.HandleFunc("/api/quit", s.handleQuit) mux.HandleFunc("/ws/hunt", s.handleWS) s.http = &http.Server{Addr: ":80", Handler: logRequests(mux)} // Determine which URL to open in browser browserURL := "http://rmm-hunter" if !hostAdded { browserURL = "http://127.0.0.1" } // Channel to signal when server is ready serverReady := make(chan struct{}) go func() { // Signal that we're about to start listening close(serverReady) if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %v", err) } }() // Wait for server to start, then open browser <-serverReady time.Sleep(500 * time.Millisecond) // Give server a moment to fully initialize log.Printf("[web] Opening browser to %s...\n", browserURL) _, err := OpenBrowser(browserURL) if err != nil { log.Printf("[web] Warning: Failed to open browser: %v\n", err) if !hostAdded { log.Printf("[web] Please open your browser and navigate to http://127.0.0.1\n") } log.Printf("[web] Please open your browser and navigate to http://rmm-hunter\n") } // block until quit <-s.quitCh // Clean up hosts entry on exit log.Printf("[web] Cleaning up hosts entry...\n") if err := RemoveHostsEntry(); err != nil { log.Printf("[web] Warning: Failed to remove hosts entry: %v\n", err) } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = s.http.Shutdown(ctx) os.Exit(0) } func logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s", r.Method, r.URL.Path) next.ServeHTTP(w, r) }) } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { b, err := contentFS.ReadFile("templates/index.html") if err != nil { http.Error(w, "template missing", 500) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(b) } // serve logo from repo .img; fallback to 404 func (s *server) handleLogo(w http.ResponseWriter, r *http.Request) { path := filepath.Join(".img", "rmm-hunter.png") f, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Content-Type", "image/png") http.ServeContent(w, r, "rmm-hunter.png", time.Now(), f) } // serve favicon files from embedded templates folder func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) { filename := filepath.Base(r.URL.Path) b, err := contentFS.ReadFile("templates/" + filename) if err != nil { http.NotFound(w, r) return } // Set appropriate content type contentType := "image/x-icon" if filepath.Ext(filename) == ".png" { contentType = "image/png" } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=31536000") w.Write(b) } // serve site.webmanifest from embedded templates folder func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) { b, err := contentFS.ReadFile("templates/site.webmanifest") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/manifest+json") w.Header().Set("Cache-Control", "public, max-age=31536000") w.Write(b) } func (s *server) handleListHunts(w http.ResponseWriter, r *http.Request) { files, _ := filepath.Glob("*.json") var out []JSONReportMeta for _, f := range files { // read small head of file to verify b, err := os.ReadFile(f) if err != nil { continue } var env struct { ReportName string `json:"reportName"` GeneratedAt string `json:"generatedAt"` } if json.Unmarshal(b, &env) == nil && (env.ReportName != "" || strings.Contains(string(b), "\"findings\"")) { out = append(out, JSONReportMeta{File: f, ReportName: env.ReportName, GeneratedAt: env.GeneratedAt}) } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) } func (s *server) handleGetReport(w http.ResponseWriter, r *http.Request) { f := r.URL.Query().Get("file") if f == "" || strings.Contains(f, "..") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "bad file"}) return } b, err := os.ReadFile(f) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) return } w.Header().Set("Content-Type", "application/json") w.Write(b) } func (s *server) handleStartHunt(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "use POST"}) return } name := fmt.Sprintf("hunt-%s", time.Now().Format("20060102-150405")) go s.runHunt(name) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"reportName": name}) } func (s *server) runHunt(name string) { // redirect stdout to our pipe oldStdout := os.Stdout pr, pw, _ := os.Pipe() os.Stdout = pw // also mirror stderr oldStderr := os.Stderr pr2, pw2, _ := os.Pipe() os.Stderr = pw2 // reader goroutines done := make(chan struct{}) go func() { sc := bufio.NewScanner(pr) for sc.Scan() { s.hub.send(sc.Text()) } done <- struct{}{} }() go func() { sc := bufio.NewScanner(pr2) for sc.Scan() { s.hub.send(sc.Text()) } done <- struct{}{} }() // run hunter hunter.Start(pkg.RunOptions{Name: name}) // close writers and restore _ = pw.Close() _ = pw2.Close() <-done <-done os.Stdout = oldStdout os.Stderr = oldStderr s.hub.send("[+] Hunt complete") } func (s *server) handleWS(w http.ResponseWriter, r *http.Request) { up := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} c, err := up.Upgrade(w, r, nil) if err != nil { return } s.hub.add(c) defer func() { s.hub.rm(c); _ = c.Close() }() for { // keep alive until client closes if _, _, err := c.ReadMessage(); err != nil { return } } } func (s *server) handleEliminate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) return } var req struct { ReportFile string `json:"reportFile"` Type string `json:"type"` Index int `json:"index"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"}) return } // Load the report file reportFile := req.ReportFile if !strings.HasSuffix(reportFile, ".json") { reportFile += ".json" } reportPath := filepath.Join(".", reportFile) data, err := os.ReadFile(reportPath) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read report: %v", err)}) return } // Parse the full report structure with findings wrapper var fullReport struct { ReportName string `json:"reportName"` GeneratedAt string `json:"generatedAt"` RiskRating interface{} `json:"riskRating"` Findings suspicious.Suspicious `json:"findings"` } if err := json.Unmarshal(data, &fullReport); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to parse report: %v", err)}) return } // Perform elimination based on type if err := performElimination(&fullReport.Findings, req.Type, req.Index); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } // Save updated report updatedData, err := json.MarshalIndent(fullReport, "", " ") if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to marshal report: %v", err)}) return } if err := os.WriteFile(reportPath, updatedData, 0644); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to save report: %v", err)}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } func (s *server) handleQuit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusMethodNotAllowed) json.NewEncoder(w).Encode(map[string]string{"error": "use POST"}) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"ok":true}`)) go func() { time.Sleep(200 * time.Millisecond); s.quitCh <- struct{}{} }() } // performElimination executes the elimination logic for a specific finding type and index func performElimination(report *suspicious.Suspicious, typeKey string, idx int) error { switch typeKey { case "connections": if idx < 0 || idx >= len(report.OutboundConnections) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.OutboundConnections)) } conn := report.OutboundConnections[idx] if err := eliminate.EliminateConnection(conn.RemoteHost); err != nil { return err } report.OutboundConnections[idx].Eliminated = true case "processes": if idx < 0 || idx >= len(report.Processes) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Processes)) } proc := report.Processes[idx] if err := eliminate.EliminateProcess(proc); err != nil { return err } report.Processes[idx].Eliminated = true case "services": if idx < 0 || idx >= len(report.Services) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Services)) } svc := report.Services[idx] if svc == nil { return fmt.Errorf("service is nil") } if err := eliminate.EliminateService(*svc); err != nil { return err } report.Services[idx].Eliminated = true case "tasks": if idx < 0 || idx >= len(report.ScheduledTasks) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.ScheduledTasks)) } task := report.ScheduledTasks[idx] if task == nil { return fmt.Errorf("task is nil") } if err := eliminate.EliminateScheduledTask(*task); err != nil { return err } report.ScheduledTasks[idx].Eliminated = true case "autoruns": if idx < 0 || idx >= len(report.AutoRuns) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.AutoRuns)) } ar := report.AutoRuns[idx] if err := eliminate.EliminateAutoRun(ar); err != nil { return err } report.AutoRuns[idx].Eliminated = true case "binaries": if idx < 0 || idx >= len(report.Binaries) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Binaries)) } bin := report.Binaries[idx] // Check if binary is blocked by active processes/services if err := checkBinaryBlocked(bin.Path, *report); err != nil { return err } if err := eliminate.EliminateBinary(bin.Path); err != nil { return err } report.Binaries[idx].Eliminated = true case "directories": if idx < 0 || idx >= len(report.Directories) { return fmt.Errorf("invalid index: %d (array length: %d)", idx, len(report.Directories)) } dir := report.Directories[idx] // Check if directory is blocked by active processes/services if err := checkDirectoryBlocked(dir.Path, *report); err != nil { return err } if err := eliminate.EliminateDirectory(dir.Path); err != nil { return err } report.Directories[idx].Eliminated = true default: return fmt.Errorf("unknown type: %s", typeKey) } return nil } // checkBinaryBlocked checks if a binary is in use by active processes or services func checkBinaryBlocked(path string, data suspicious.Suspicious) error { normPath := func(p string) string { return strings.ToLower(filepath.Clean(p)) } np := normPath(path) // Check active processes for _, p := range data.Processes { if p.Eliminated { continue } if normPath(p.Path) == np { return fmt.Errorf("binary in use by running process %s (PID %d). Eliminate the process first", p.Name, p.PID) } } // Check enabled services for _, s := range data.Services { if s == nil || s.Eliminated { continue } sp := normPath(s.BinaryPathName) if sp == np && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") { // Check if service has a running process for _, p := range data.Processes { if p.Eliminated { continue } if normPath(p.Path) == sp { return fmt.Errorf("binary used by active and enabled service %s. Stop/delete the service first", s.Name) } } } } return nil } // checkDirectoryBlocked checks if a directory contains binaries used by active processes or services func checkDirectoryBlocked(dir string, data suspicious.Suspicious) error { normPath := func(p string) string { return strings.ToLower(filepath.Clean(p)) } dn := normPath(dir) if !strings.HasSuffix(dn, string(filepath.Separator)) { dn += string(filepath.Separator) } inDir := func(p string) bool { pp := normPath(p) if pp == "" { return false } return strings.HasPrefix(pp, dn) } // Check processes for _, p := range data.Processes { if p.Eliminated { continue } if inDir(p.Path) { return fmt.Errorf("directory contains active process %s (PID %d). Eliminate the process first", p.Name, p.PID) } } // Check services for _, s := range data.Services { if s == nil || s.Eliminated { continue } if inDir(s.BinaryPathName) && !strings.EqualFold(strings.TrimSpace(s.StartType), "disabled") { // Check if service has a running process for _, p := range data.Processes { if p.Eliminated { continue } if normPath(p.Path) == normPath(s.BinaryPathName) { return fmt.Errorf("directory contains active and enabled service binary for %s. Stop/delete the service first", s.Name) } } } } return nil }