adcad167df
TODO: Test elimination per finding type in web view Figure out where RustDesk registry persistence is located. The installer is aware of it somehow
302 lines
7.8 KiB
Go
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{}{} }()
|
|
}
|