2026-05-01 13:10:23 +03:00
package server
import (
"net"
"net/http"
2026-05-03 22:46:54 +03:00
"path/filepath"
2026-05-01 13:10:23 +03:00
"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 {
2026-05-03 22:46:54 +03:00
Action string ` json:"action" `
IP string ` json:"ip" `
IPs [ ] string ` json:"ips" `
BanUntil string ` json:"ban_until" `
2026-05-01 13:10:23 +03:00
}
func ( app * App ) reloadSecurityConfig ( ) {
if app . securityGuard == nil {
app . securityGuard = security . NewGuard ( )
}
2026-05-03 22:46:54 +03:00
if app . config != nil {
_ = app . securityGuard . EnableBanPersistence ( filepath . Join ( app . config . DBDir , "bans.badger" ) )
}
_ = app . securityGuard . Reload ( security . Config {
2026-05-01 13:10:23 +03:00
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 ,
} )
}
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 {
2026-05-03 22:46:54 +03:00
event . IP = app . clientIP ( ctx )
2026-05-01 13:10:23 +03:00
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 . securityGuard == nil {
ctx . Next ( )
return
}
2026-05-03 22:46:54 +03:00
ip := app . clientIP ( ctx )
2026-05-01 13:10:23 +03:00
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 . 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 {
2026-05-03 22:46:54 +03:00
ip := app . clientIP ( ctx )
2026-05-01 13:10:23 +03:00
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 ) {
events := [ ] activity . Event { }
alertsList := [ ] alerts . Alert { }
if app . activityStore != nil {
2026-05-03 22:46:54 +03:00
events , _ = app . activityStore . List ( 300 , app . config . ActivityRetentionSeconds )
2026-05-01 13:10:23 +03:00
}
if app . alertStore != nil {
2026-05-03 22:46:54 +03:00
alertsList , _ = app . alertStore . List ( 120 )
2026-05-01 13:10:23 +03:00
}
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 } )
}
2026-05-03 22:46:54 +03:00
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 )
}
2026-05-01 13:10:23 +03:00
func ( app * App ) handleAdminSecurityAction ( ctx * gin . Context ) {
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
}
2026-05-03 22:46:54 +03:00
2026-05-01 13:10:23 +03:00
switch request . Action {
case "ban" :
if ip == "" {
ctx . JSON ( http . StatusBadRequest , gin . H { "error" : "Missing IP" } )
return
}
app . securityGuard . Ban ( ip , app . config . SecurityBanSeconds )
2026-05-03 22:46:54 +03:00
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." )
2026-05-01 13:10:23 +03:00
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 )
2026-05-03 22:46:54 +03:00
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." )
2026-05-01 13:10:23 +03:00
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 )
2026-05-03 22:46:54 +03:00
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." )
2026-05-01 13:10:23 +03:00
ctx . JSON ( http . StatusOK , gin . H { "ok" : true , "message" : "IP unbanned" , "bans" : app . securityGuard . BanList ( ) } )
2026-05-03 22:46:54 +03:00
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 ( ) } )
2026-05-01 13:10:23 +03:00
default :
ctx . JSON ( http . StatusBadRequest , gin . H { "error" : "Unknown action" } )
}
}
func intToString ( value int ) string {
return strconv . Itoa ( value )
}