From 01113551fbe659e85d48382a5d5e4cea78948ce8 Mon Sep 17 00:00:00 2001 From: Evan Hosinski Date: Sun, 12 Oct 2025 18:46:59 -0400 Subject: [PATCH] Add web server implementation for RMM-Hunter with API endpoints and WebSocket support --- cmd/root.go | 6 +- go.mod | 2 + go.sum | 4 + internal/web/webserver.go | 224 +++++++++++++++++++++++++++++++++++++- main.go | 14 +++ 5 files changed, 245 insertions(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 5efa5bd..5f03673 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "rmm-hunter/internal/pkg" "rmm-hunter/internal/pkg/hunter" "rmm-hunter/internal/tui" + "rmm-hunter/internal/web" scurvy "github.com/Kraken-OffSec/Scurvy" "github.com/Kraken-OffSec/Scurvy/core/escalator" @@ -145,9 +146,8 @@ func runHunt() { func runEliminate() { if webUI { - // Launch the web UI for elimination flow - // TODO: Launch web UI - fmt.Println("Web UI not implemented yet") + fmt.Println("Starting Web UI on http://127.0.0.1:8080 ...") + web.StartWebServer() return } else if cliUI { // Launch the TUI for elimination flow diff --git a/go.mod b/go.mod index 0cb8b12..944169c 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/ethereum/go-ethereum v1.14.12 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -49,6 +50,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + golang.org/x/net v0.27.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect howett.net/plist v1.0.1 // indirect diff --git a/go.sum b/go.sum index 0295d34..be8cd78 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrT github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -102,6 +104,8 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/web/webserver.go b/internal/web/webserver.go index 212ae57..5649e49 100644 --- a/internal/web/webserver.go +++ b/internal/web/webserver.go @@ -1,5 +1,225 @@ package web -func StartWebServer() { - // TODO: Start web server +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() { + h := newHub() + s := &server{hub: h, quitCh: make(chan struct{})} + + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/logo", s.handleLogo) + 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: ":8080", Handler: logRequests(mux)} + go func() { + log.Printf("[web] starting on http://127.0.0.1:8080\n") + if err := s.http.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + // block until quit + <-s.quitCh + 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) +} + +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{}{} }() } diff --git a/main.go b/main.go index ec9ba86..e2d7719 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,23 @@ package main import ( + "fmt" + "os" "rmm-hunter/cmd" + "rmm-hunter/internal/web" + + scurvy "github.com/Kraken-OffSec/Scurvy" ) func main() { + if len(os.Args) == 1 { + escErr := scurvy.CheckAndEscalateBinary() + if escErr != nil { + fmt.Printf("Failed to elevate: %v\n", escErr) + os.Exit(1) + } + web.StartWebServer() + return + } cmd.Execute() }