package main import ( "context" "encoding/json" "fmt" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" "sysmon/backend" ) func runServer(port int) { _, cancel := context.WithCancel(context.Background()) defer cancel() mux := http.NewServeMux() // GET /api/metrics — latest snapshot mux.HandleFunc("/api/metrics", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/metrics" { http.NotFound(w, r) return } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } m, err := backend.CollectFull(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(m) //nolint:errcheck }) // GET /api/metrics/stream — SSE push every 1 s mux.HandleFunc("/api/metrics/stream", func(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case <-ticker.C: m, err := backend.CollectFull(r.Context()) if err != nil { continue } data, _ := json.Marshal(m) fmt.Fprintf(w, "data: %s\n\n", data) //nolint:errcheck flusher.Flush() } } }) // GET /api/process/ // POST /api/process//kill[?force=true] mux.HandleFunc("/api/process/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") path := strings.TrimPrefix(r.URL.Path, "/api/process/") parts := strings.SplitN(path, "/", 2) if parts[0] == "" { http.Error(w, "missing pid", http.StatusBadRequest) return } pid64, err := strconv.ParseInt(parts[0], 10, 32) if err != nil || pid64 <= 0 { http.Error(w, "invalid pid", http.StatusBadRequest) return } // Kill action if len(parts) == 2 && parts[1] == "kill" { if r.Method != http.MethodPost { http.Error(w, "use POST /api/process//kill", http.StatusMethodNotAllowed) return } force := r.URL.Query().Get("force") == "true" if err := backend.KillProcess(int32(pid64), force); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) return } // Process detail if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } detail, err := backend.FetchProcessDetail(int32(pid64)) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(detail) //nolint:errcheck }) // GET /api/man/ mux.HandleFunc("/api/man/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } name := strings.TrimPrefix(r.URL.Path, "/api/man/") // Strict validation: only allow characters common in command names. // exec.Command passes the name as a separate arg (no shell), but we // reject obviously bad input early to avoid surprising behaviour. if name == "" || len(name) > 64 || strings.ContainsAny(name, "/ \t\n\r\\\"';|&`$<>(){}") { http.Error(w, "invalid name", http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") fmt.Fprint(w, backend.GetManPage(name)) //nolint:errcheck }) addr := fmt.Sprintf(":%d", port) fmt.Printf("sysmon server → http://localhost%s\n\n", addr) fmt.Println("Endpoints:") fmt.Printf(" GET /api/metrics — latest metrics snapshot (JSON)\n") fmt.Printf(" GET /api/metrics/stream — Server-Sent Events stream (1 s)\n") fmt.Printf(" GET /api/process/ — process detail (JSON)\n") fmt.Printf(" POST /api/process//kill — kill process (?force=true → SIGKILL)\n") fmt.Printf(" GET /api/man/ — manual page for a command\n") fmt.Println("\nPress Ctrl+C to stop.") srv := &http.Server{ Addr: addr, Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 0, // disabled: SSE streams are long-lived IdleTimeout: 60 * time.Second, } quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { <-quit fmt.Println("\nShutting down…") cancel() shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutCancel() srv.Shutdown(shutCtx) //nolint:errcheck }() if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) } }