Cybersecurity-Projects/PROJECTS/advanced/honeypot-network/internal/api/handlers.go

666 lines
13 KiB
Go

/*
©AngelaMos | 2026
handlers.go
REST endpoint handlers for the hive dashboard API
Each handler reads from the persistent store, formats the response
as JSON, and returns it with appropriate HTTP status codes.
Pagination is supported on list endpoints via limit and offset
query parameters. Export endpoints stream STIX bundles and
firewall blocklists as downloadable files.
*/
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/CarterPerez-dev/hive/internal/intel"
"github.com/CarterPerez-dev/hive/pkg/types"
)
type apiResponse struct {
Data interface{} `json:"data"`
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type apiError struct {
Error string `json:"error"`
}
type overviewStats struct {
TotalEvents int64 `json:"total_events"`
ActiveSessions int `json:"active_sessions"`
EventsByService map[string]int64 `json:"events_by_service"`
}
type credentialStats struct {
TopUsernames []credentialEntry `json:"top_usernames"`
TopPasswords []credentialEntry `json:"top_passwords"`
TopPairs []credentialPair `json:"top_pairs"`
}
type credentialEntry struct {
Value string `json:"value"`
Count int64 `json:"count"`
}
type credentialPair struct {
Username string `json:"username"`
Password string `json:"password"`
Count int64 `json:"count"`
}
type techniqueInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Tactic string `json:"tactic"`
}
type heatmapEntry struct {
TechniqueID string `json:"technique_id"`
Name string `json:"name"`
Tactic string `json:"tactic"`
Count int64 `json:"count"`
}
type sensorInfo struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Region string `json:"region"`
Services []string `json:"services"`
StartedAt time.Time `json:"started_at"`
Status string `json:"status"`
}
func (s *Server) writeJSON(
w http.ResponseWriter,
status int,
data interface{},
) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
func (s *Server) writeError(
w http.ResponseWriter, status int, msg string,
) {
s.writeJSON(w, status, apiError{Error: msg})
}
func (s *Server) handleHealth(
w http.ResponseWriter, _ *http.Request,
) {
s.writeJSON(w, http.StatusOK, apiResponse{
Data: map[string]interface{}{
"status": "ok",
"version": types.Version,
"sensor": s.cfg.Sensor.ID,
},
})
}
func (s *Server) handleStatsOverview(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
since := time.Now().UTC().Add(
-parseDuration(r),
)
totalEvents, err := s.store.TotalCount(ctx)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to count events",
)
return
}
byService, err := s.store.CountByService(ctx, since)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to count by service",
)
return
}
serviceMap := make(map[string]int64, len(byService))
for svc, count := range byService {
serviceMap[svc.String()] = count
}
s.writeJSON(w, http.StatusOK, apiResponse{
Data: overviewStats{
TotalEvents: totalEvents,
ActiveSessions: s.tracker.Count(),
EventsByService: serviceMap,
},
})
}
func (s *Server) handleStatsCountries(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
since := time.Now().UTC().Add(
-parseDuration(r),
)
countries, err := s.store.CountByCountry(ctx, since)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to count by country",
)
return
}
s.writeJSON(w, http.StatusOK, apiResponse{
Data: countries,
})
}
func (s *Server) handleStatsCredentials(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
usernames, err := s.store.TopUsernames(
ctx, defaultCredentialTop,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query usernames",
)
return
}
passwords, err := s.store.TopPasswords(
ctx, defaultCredentialTop,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query passwords",
)
return
}
pairs, err := s.store.TopPairs(
ctx, defaultCredentialTop,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query credential pairs",
)
return
}
topU := make([]credentialEntry, len(usernames))
for i, u := range usernames {
topU[i] = credentialEntry{
Value: u.Value, Count: u.Count,
}
}
topP := make([]credentialEntry, len(passwords))
for i, p := range passwords {
topP[i] = credentialEntry{
Value: p.Value, Count: p.Count,
}
}
topPairs := make([]credentialPair, len(pairs))
for i, p := range pairs {
topPairs[i] = credentialPair{
Username: p.Username,
Password: p.Password,
Count: p.Count,
}
}
s.writeJSON(w, http.StatusOK, apiResponse{
Data: credentialStats{
TopUsernames: topU,
TopPasswords: topP,
TopPairs: topPairs,
},
})
}
func (s *Server) handleEvents(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
limit, offset := parsePagination(r)
ip := r.URL.Query().Get("ip")
if ip != "" {
events, err := s.store.FindByIP(
ctx, ip, limit, offset,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query events",
)
return
}
s.writeJSON(
w, http.StatusOK,
apiResponse{Data: events},
)
return
}
events, err := s.store.RecentEvents(ctx, limit)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query events",
)
return
}
s.writeJSON(
w, http.StatusOK, apiResponse{Data: events},
)
}
func (s *Server) handleSessions(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
limit, offset := parsePagination(r)
service := r.URL.Query().Get("service")
sessions, total, err := s.store.ListSessions(
ctx, service, limit, offset,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query sessions",
)
return
}
s.writeJSON(w, http.StatusOK, paginatedResponse{
Data: sessions,
Total: total,
Limit: limit,
Offset: offset,
})
}
func (s *Server) handleSessionByID(
w http.ResponseWriter, r *http.Request,
) {
id := chi.URLParam(r, "id")
sess, err := s.store.GetSession(r.Context(), id)
if err != nil {
s.writeError(
w, http.StatusNotFound, "session not found",
)
return
}
s.writeJSON(
w, http.StatusOK, apiResponse{Data: sess},
)
}
func isValidSessionID(id string) bool {
if len(id) < 8 || len(id) > 64 {
return false
}
for _, c := range id {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func (s *Server) handleSessionReplay(
w http.ResponseWriter, r *http.Request,
) {
id := chi.URLParam(r, "id")
if !isValidSessionID(id) {
s.writeError(
w, http.StatusBadRequest, "invalid session id",
)
return
}
castPath := filepath.Join(
s.replayDir, fmt.Sprintf("%s.cast", id),
)
data, err := os.ReadFile(castPath)
if err != nil {
s.writeError(
w, http.StatusNotFound, "replay not found",
)
return
}
w.Header().Set(
"Content-Type", "application/x-asciicast",
)
w.Header().Set(
"Content-Disposition",
fmt.Sprintf(
"inline; filename=\"%s.cast\"", id,
),
)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (s *Server) handleAttackers(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
since := time.Now().UTC().Add(
-parseDuration(r),
)
limit, offset := parsePagination(r)
attackers, err := s.store.TopAttackers(
ctx, since, limit, offset,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query attackers",
)
return
}
s.writeJSON(
w, http.StatusOK, apiResponse{Data: attackers},
)
}
func (s *Server) handleAttackerByID(
w http.ResponseWriter, r *http.Request,
) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
s.writeError(
w, http.StatusBadRequest,
"invalid attacker id",
)
return
}
attacker, err := s.store.GetAttacker(
r.Context(), id,
)
if err != nil {
s.writeError(
w, http.StatusNotFound,
"attacker not found",
)
return
}
s.writeJSON(
w, http.StatusOK, apiResponse{Data: attacker},
)
}
func (s *Server) handleMITRETechniques(
w http.ResponseWriter, _ *http.Request,
) {
techniques := s.mitreIdx.All()
infos := make([]techniqueInfo, len(techniques))
for i, t := range techniques {
infos[i] = techniqueInfo{
ID: t.ID,
Name: t.Name,
Tactic: t.Tactic,
}
}
s.writeJSON(
w, http.StatusOK, apiResponse{Data: infos},
)
}
func (s *Server) handleMITREHeatmap(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
since := time.Now().UTC().Add(
-parseDuration(r),
)
counts, err := s.store.TechniqueHeatmap(ctx, since)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query heatmap",
)
return
}
entries := make([]heatmapEntry, len(counts))
for i, tc := range counts {
name := tc.TechniqueID
if t := s.mitreIdx.Get(tc.TechniqueID); t != nil {
name = t.Name
}
entries[i] = heatmapEntry{
TechniqueID: tc.TechniqueID,
Name: name,
Tactic: tc.Tactic,
Count: tc.Count,
}
}
s.writeJSON(
w, http.StatusOK, apiResponse{Data: entries},
)
}
func (s *Server) handleIOCs(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
limit, offset := parsePagination(r)
iocs, total, err := s.store.ListIOCs(
ctx, limit, offset,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query iocs",
)
return
}
s.writeJSON(w, http.StatusOK, paginatedResponse{
Data: iocs,
Total: total,
Limit: limit,
Offset: offset,
})
}
func (s *Server) handleIOCExportSTIX(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
iocs, _, err := s.store.ListIOCs(
ctx, maxPageLimit, 0,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query iocs",
)
return
}
bundle, err := intel.GenerateSTIXBundle(iocs)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to generate stix bundle",
)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(
"Content-Disposition",
"attachment; filename=\"hive-iocs.stix.json\"",
)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bundle)
}
func (s *Server) handleIOCExportBlocklist(
w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
format := r.URL.Query().Get("format")
if format == "" {
format = intel.FormatPlain
}
iocs, _, err := s.store.ListIOCs(
ctx, maxPageLimit, 0,
)
if err != nil {
s.writeError(
w, http.StatusInternalServerError,
"failed to query iocs",
)
return
}
blocklist := intel.GenerateBlocklist(iocs, format)
contentType := "text/plain"
ext := ".txt"
if format == intel.FormatCSV {
contentType = "text/csv"
ext = ".csv"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set(
"Content-Disposition",
fmt.Sprintf(
"attachment; filename=\"hive-blocklist%s\"",
ext,
),
)
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, blocklist)
}
func (s *Server) handleSensors(
w http.ResponseWriter, _ *http.Request,
) {
var services []string
if s.cfg.SSH.Enabled {
services = append(services, "ssh")
}
if s.cfg.HTTP.Enabled {
services = append(services, "http")
}
if s.cfg.FTP.Enabled {
services = append(services, "ftp")
}
if s.cfg.SMB.Enabled {
services = append(services, "smb")
}
if s.cfg.MySQL.Enabled {
services = append(services, "mysql")
}
if s.cfg.Redis.Enabled {
services = append(services, "redis")
}
sensor := sensorInfo{
ID: s.cfg.Sensor.ID,
Hostname: s.cfg.Sensor.Hostname,
Region: s.cfg.Sensor.Region,
Services: services,
StartedAt: s.startedAt,
Status: "active",
}
s.writeJSON(w, http.StatusOK, apiResponse{
Data: []sensorInfo{sensor},
})
}
func parsePagination(r *http.Request) (int, int) {
limit := defaultPageLimit
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
if limit > maxPageLimit {
limit = maxPageLimit
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return limit, offset
}
func parseDuration(r *http.Request) time.Duration {
v := r.URL.Query().Get("since")
if v == "" {
return defaultStatsDuration
}
d, err := time.ParseDuration(v)
if err != nil {
return defaultStatsDuration
}
return d
}