Files
RMM-Hunter/internal/web/webserver.go
T
Evan Hosinski adcad167df Add support for dynamic hosts file management, browser opening, and new favicon handling in web server. Add elimination workflow UI enhancements with better state management and design.
TODO: Test elimination per finding type in web view

Figure out where RustDesk registry persistence is located. The installer is aware of it somehow
2025-10-12 20:02:49 -04:00

302 lines
7.8 KiB
Go

package web
import (
"bufio"
"context"
"embed"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"rmm-hunter/internal/pkg"
"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/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)
if err := OpenBrowser(browserURL); 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, "..") {
http.Error(w, "bad file", 400)
return
}
b, err := os.ReadFile(f)
if err != nil {
http.Error(w, "not found", 404)
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 {
http.Error(w, "use POST", 405)
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) handleQuit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "use POST", 405)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
go func() { time.Sleep(200 * time.Millisecond); s.quitCh <- struct{}{} }()
}