332 lines
12 KiB
Go
332 lines
12 KiB
Go
|
|
package server
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
|
||
|
|
"warpbox/lib/activity"
|
||
|
|
"warpbox/lib/alerts"
|
||
|
|
"warpbox/lib/security"
|
||
|
|
)
|
||
|
|
|
||
|
|
type adminAlertsActionRequest struct {
|
||
|
|
Action string `json:"action"`
|
||
|
|
IDs []string `json:"ids"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type adminSecurityActionRequest struct {
|
||
|
|
Action string `json:"action"`
|
||
|
|
IP string `json:"ip"`
|
||
|
|
IPs []string `json:"ips"`
|
||
|
|
BanUntil string `json:"ban_until"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) reloadSecurityConfig() error {
|
||
|
|
if app == nil || app.config == nil {
|
||
|
|
return fmt.Errorf("app or config is nil")
|
||
|
|
}
|
||
|
|
if !app.securityFeaturesEnabled() {
|
||
|
|
if app.securityGuard != nil {
|
||
|
|
_ = app.securityGuard.Close()
|
||
|
|
}
|
||
|
|
app.securityGuard = nil
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if app.securityGuard == nil {
|
||
|
|
app.securityGuard = security.NewGuard()
|
||
|
|
}
|
||
|
|
if err := app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")); err != nil {
|
||
|
|
return fmt.Errorf("enable ban persistence: %w", err)
|
||
|
|
}
|
||
|
|
if err := app.securityGuard.Reload(security.Config{
|
||
|
|
IPWhitelist: app.config.SecurityIPWhitelist,
|
||
|
|
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
|
||
|
|
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
|
||
|
|
LoginMaxAttempts: app.config.SecurityLoginMaxAttempts,
|
||
|
|
BanSeconds: app.config.SecurityBanSeconds,
|
||
|
|
ScanWindowSeconds: app.config.SecurityScanWindowSeconds,
|
||
|
|
ScanMaxAttempts: app.config.SecurityScanMaxAttempts,
|
||
|
|
UploadWindowSeconds: app.config.SecurityUploadWindowSeconds,
|
||
|
|
UploadMaxRequests: app.config.SecurityUploadMaxRequests,
|
||
|
|
UploadMaxBytes: app.config.SecurityUploadMaxBytes,
|
||
|
|
}); err != nil {
|
||
|
|
return fmt.Errorf("reload guard config: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) securityFeaturesEnabled() bool {
|
||
|
|
return app != nil && app.config != nil && app.config.SecurityEnabled
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) logActivity(kind string, severity string, message string, ctx *gin.Context, meta map[string]string) {
|
||
|
|
if app.activityStore == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
event := activity.Event{
|
||
|
|
Kind: kind,
|
||
|
|
Severity: severity,
|
||
|
|
Message: message,
|
||
|
|
CreatedAt: time.Now().UTC(),
|
||
|
|
Meta: meta,
|
||
|
|
}
|
||
|
|
if ctx != nil {
|
||
|
|
event.IP = app.clientIP(ctx)
|
||
|
|
event.Path = ctx.Request.URL.Path
|
||
|
|
event.Method = ctx.Request.Method
|
||
|
|
}
|
||
|
|
_ = app.activityStore.Append(event, app.config.ActivityRetentionSeconds)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) createAlert(title string, severity string, group string, code string, trace string, message string, meta map[string]string) {
|
||
|
|
if app.alertStore == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_ = app.alertStore.Add(alerts.Alert{
|
||
|
|
Title: title,
|
||
|
|
Severity: severity,
|
||
|
|
Group: group,
|
||
|
|
Code: code,
|
||
|
|
Trace: trace,
|
||
|
|
Message: message,
|
||
|
|
Status: alerts.StatusOpen,
|
||
|
|
Meta: meta,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) securityMiddleware() gin.HandlerFunc {
|
||
|
|
return func(ctx *gin.Context) {
|
||
|
|
if !app.securityFeaturesEnabled() {
|
||
|
|
ctx.Next()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if app.securityGuard == nil {
|
||
|
|
ctx.Next()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := app.clientIP(ctx)
|
||
|
|
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||
|
|
ctx.Next()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if app.securityGuard.IsBanned(ip) {
|
||
|
|
app.logActivity("security.block", "high", "Blocked banned IP", ctx, nil)
|
||
|
|
ctx.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many abusive requests. Try again later."})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ctx.Next()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) handleNoRoute(ctx *gin.Context) {
|
||
|
|
if !app.securityFeaturesEnabled() {
|
||
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if app.securityGuard == nil {
|
||
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
path := strings.ToLower(ctx.Request.URL.Path)
|
||
|
|
suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env")
|
||
|
|
if suspicious {
|
||
|
|
ip := app.clientIP(ctx)
|
||
|
|
if !app.securityGuard.IsWhitelisted(ip) {
|
||
|
|
banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds)
|
||
|
|
app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)})
|
||
|
|
if banned {
|
||
|
|
app.createAlert("IP auto-banned after malicious path scans", "high", "security", "410", "security.scan.autoban", "Repeated malicious path scans triggered temporary ban.", map[string]string{"ip": ip, "attempts": intToString(attempts)})
|
||
|
|
app.logActivity("security.ban", "high", "IP auto-banned after scans", ctx, map[string]string{"attempts": intToString(attempts)})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) handleAdminActivity(ctx *gin.Context) {
|
||
|
|
if app.activityStore == nil {
|
||
|
|
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
||
|
|
"AdminUsername": app.config.AdminUsername,
|
||
|
|
"AdminEmail": app.config.AdminEmail,
|
||
|
|
"ActivePage": "activity",
|
||
|
|
"Events": []activity.Event{},
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
events, err := app.activityStore.List(400, app.config.ActivityRetentionSeconds)
|
||
|
|
if err != nil {
|
||
|
|
ctx.String(http.StatusInternalServerError, "Could not load activity")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
||
|
|
"AdminUsername": app.config.AdminUsername,
|
||
|
|
"AdminEmail": app.config.AdminEmail,
|
||
|
|
"ActivePage": "activity",
|
||
|
|
"Events": events,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) handleAdminSecurity(ctx *gin.Context) {
|
||
|
|
if !app.securityFeaturesEnabled() {
|
||
|
|
ctx.String(http.StatusNotFound, "Security features are disabled")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
events := []activity.Event{}
|
||
|
|
alertsList := []alerts.Alert{}
|
||
|
|
if app.activityStore != nil {
|
||
|
|
events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
|
||
|
|
}
|
||
|
|
if app.alertStore != nil {
|
||
|
|
alertsList, _ = app.alertStore.List(120)
|
||
|
|
}
|
||
|
|
bans := []security.BanEntry{}
|
||
|
|
if app.securityGuard != nil {
|
||
|
|
bans = app.securityGuard.BanList()
|
||
|
|
}
|
||
|
|
ctx.HTML(http.StatusOK, "admin/security.html", gin.H{
|
||
|
|
"AdminUsername": app.config.AdminUsername,
|
||
|
|
"AdminEmail": app.config.AdminEmail,
|
||
|
|
"ActivePage": "security",
|
||
|
|
"Events": events,
|
||
|
|
"Alerts": alertsList,
|
||
|
|
"Bans": bans,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) handleAdminAlertsAction(ctx *gin.Context) {
|
||
|
|
if app.alertStore == nil {
|
||
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Alert store unavailable"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var request adminAlertsActionRequest
|
||
|
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
switch request.Action {
|
||
|
|
case "ack":
|
||
|
|
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusAcked); err != nil {
|
||
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
case "close":
|
||
|
|
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusClosed); err != nil {
|
||
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
case "delete":
|
||
|
|
if err := app.alertStore.Delete(request.IDs); err != nil {
|
||
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete alerts"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
app.logActivity("alerts.action", "low", "Admin changed alert state", ctx, map[string]string{"action": request.Action, "count": intToString(len(request.IDs))})
|
||
|
|
alertsList, _ := app.alertStore.List(500)
|
||
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) {
|
||
|
|
metaCopy := map[string]string{"ip": ip}
|
||
|
|
for k, v := range meta {
|
||
|
|
metaCopy[k] = v
|
||
|
|
}
|
||
|
|
app.logActivity(kind, severity, message, ctx, metaCopy)
|
||
|
|
app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||
|
|
if !app.securityFeaturesEnabled() {
|
||
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Security features are disabled"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if app.securityGuard == nil {
|
||
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var request adminSecurityActionRequest
|
||
|
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := strings.TrimSpace(request.IP)
|
||
|
|
if ip != "" && net.ParseIP(ip) == nil {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
switch request.Action {
|
||
|
|
case "ban":
|
||
|
|
if ip == "" {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
app.securityGuard.Ban(ip, app.config.SecurityBanSeconds)
|
||
|
|
app.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.")
|
||
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()})
|
||
|
|
case "ban_until":
|
||
|
|
if ip == "" {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
until, err := time.Parse(time.RFC3339, strings.TrimSpace(request.BanUntil))
|
||
|
|
if err != nil || until.Before(time.Now().UTC()) {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ban expiration"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
app.securityGuard.BanUntil(ip, until)
|
||
|
|
meta := map[string]string{"until": until.UTC().Format(time.RFC3339)}
|
||
|
|
app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.")
|
||
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()})
|
||
|
|
case "unban":
|
||
|
|
if ip == "" {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
app.securityGuard.Unban(ip)
|
||
|
|
app.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.")
|
||
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
|
||
|
|
case "bulk_unban":
|
||
|
|
if len(request.IPs) == 0 {
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
count := 0
|
||
|
|
for _, candidate := range request.IPs {
|
||
|
|
candidate = strings.TrimSpace(candidate)
|
||
|
|
if net.ParseIP(candidate) == nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
app.securityGuard.Unban(candidate)
|
||
|
|
count++
|
||
|
|
}
|
||
|
|
app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)})
|
||
|
|
app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)})
|
||
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()})
|
||
|
|
case "unban_all":
|
||
|
|
current := app.securityGuard.BanList()
|
||
|
|
for _, ban := range current {
|
||
|
|
app.securityGuard.Unban(ban.IP)
|
||
|
|
}
|
||
|
|
count := len(current)
|
||
|
|
app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)})
|
||
|
|
app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)})
|
||
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "bans": app.securityGuard.BanList()})
|
||
|
|
default:
|
||
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func intToString(value int) string {
|
||
|
|
return strconv.Itoa(value)
|
||
|
|
}
|