1 Commits

Author SHA1 Message Date
88ab6e808b feat(admin): add security and activity management features 2026-05-01 13:10:23 +03:00
26 changed files with 2208 additions and 262 deletions

114
TO-DO.md Normal file
View File

@@ -0,0 +1,114 @@
# WarpBox Security TO-DO
## 1) High Priority (Do Next)
- [ ] Persist IP bans across restarts
- Current: bans stored in-memory (`lib/security/guard.go`)
- Target: durable store in `DBDir` (similar style to `activity`/`alerts`)
- Include: startup load, expiry cleanup, atomic writes, corruption-safe fallback
- [ ] Add trusted proxy CIDR config
- Current: forwarded headers trusted only when remote hop is private/local (`lib/server/ip.go`)
- Risk: heuristic-only trust model
- Target:
- `WARPBOX_TRUSTED_PROXY_CIDRS` setting
- trust `X-Forwarded-For` only when `RemoteAddr` in trusted CIDR
- fallback to direct remote IP otherwise
- [ ] Add CIDR/range support for whitelists
- Current: exact IP match only (`WARPBOX_SECURITY_IP_WHITELIST`, `WARPBOX_SECURITY_ADMIN_IP_WHITELIST`)
- Target: support exact IP + CIDR entries
- Include strict parser + validation errors in settings save
- [ ] Add unban / ban edit API audit trail hardening
- Ensure all manual ban/unban/ban-until actions always write:
- activity event
- alert (or policy-based selective alerting)
- Add tests for these paths
## 2) Medium Priority
- [ ] GeoIP integration for security detail pane
- Current: placeholder fields in `/admin/security`
- Target: wire geoipfast provider for country/region/ASN fields
- Add caching + timeout/failure-safe behavior
- [ ] Expand malicious path detection rules
- Current: simple substring checks in `handleNoRoute`
- Target:
- rule list/pattern config
- normalize URL + decode checks
- classify severity by signature group
- [ ] Add global abuse score per IP
- Combine signals:
- failed admin auth
- malicious path scans
- upload abuse
- Use score to escalate ban duration automatically
- [ ] Ban duration policy ladder
- Current: fixed `WARPBOX_SECURITY_BAN_SECONDS`
- Target:
- progressive durations (e.g., 30m, 2h, 24h)
- reset after quiet period
- [ ] Add security settings validation UX
- Ensure invalid values (negative, malformed lists, invalid CIDR) rejected with clear UI errors
- Add server tests for malformed security override payloads
## 3) Admin UX Follow-Ups
- [ ] Add dedicated “Active Bans” page-level controls
- bulk unban
- filter/sort by expiry and IP
- copy IP and quick search in activity/alerts
- [ ] Add “why banned” detail
- link ban entry to latest triggering events and alerts
- show counts in active windows (login/scan/upload)
- [ ] Add optional confirmation modal for destructive security actions
- unban all / bulk unban / long custom bans
## 4) Testing & QA
- [ ] Add unit tests for `lib/security/guard.go`
- `Ban`, `BanUntil`, `Unban`, `BanList` expiry pruning
- login/scan threshold behavior
- upload rate limiting behavior
- [ ] Add tests for real-IP resolution edge cases (`lib/server/ip.go`)
- direct client
- trusted proxy chain
- spoofed forwarding headers from untrusted remote
- [ ] Add integration tests for security endpoints
- `/admin/security/actions` ban/ban_until/unban
- `/admin/alerts/actions`
- admin login brute-force auto-ban flow
- [ ] Add concurrency/race test pass in CI
- run `go test ./... -race` in workflow (where Go toolchain available)
## 5) Operational / Deployment
- [ ] Document reverse-proxy setup requirements
- Caddy / ingress config examples for forwarding headers
- guidance for trusted proxy CIDRs
- [ ] Add security runbook
- how to investigate alerts
- how to ban/unban safely
- how to tune thresholds for low/high traffic environments
- [ ] Add metrics hooks (future)
- counts: blocked requests, bans issued, unbans, alert volume
- expose to Prometheus-compatible endpoint later
## 6) Nice-to-Have (Later)
- [ ] Optional external enforcement bridge (fail2ban-compatible log format)
- [ ] Webhook notifications for high-severity security alerts
- [ ] Per-account/API-key limits once account system matures

116
lib/activity/activity.go Normal file
View File

@@ -0,0 +1,116 @@
package activity
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
type Event struct {
ID string `json:"id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Message string `json:"message"`
Actor string `json:"actor"`
IP string `json:"ip"`
Path string `json:"path"`
Method string `json:"method"`
CreatedAt time.Time `json:"created_at"`
Meta map[string]string `json:"meta,omitempty"`
}
type Store struct {
path string
mu sync.Mutex
}
func NewStore(path string) *Store {
return &Store{path: path}
}
func (s *Store) Append(event Event, retentionSeconds int64) error {
s.mu.Lock()
defer s.mu.Unlock()
events, err := s.readLocked()
if err != nil {
return err
}
if event.CreatedAt.IsZero() {
event.CreatedAt = time.Now().UTC()
}
if event.ID == "" {
event.ID = event.CreatedAt.Format("20060102T150405.000000000")
}
events = append(events, event)
events = pruneByRetention(events, retentionSeconds)
return s.writeLocked(events)
}
func (s *Store) List(limit int, retentionSeconds int64) ([]Event, error) {
s.mu.Lock()
defer s.mu.Unlock()
events, err := s.readLocked()
if err != nil {
return nil, err
}
events = pruneByRetention(events, retentionSeconds)
if err := s.writeLocked(events); err != nil {
return nil, err
}
sort.Slice(events, func(i, j int) bool {
return events[i].CreatedAt.After(events[j].CreatedAt)
})
if limit > 0 && len(events) > limit {
return events[:limit], nil
}
return events, nil
}
func pruneByRetention(events []Event, retentionSeconds int64) []Event {
if retentionSeconds <= 0 {
return events
}
cutoff := time.Now().UTC().Add(-time.Duration(retentionSeconds) * time.Second)
out := make([]Event, 0, len(events))
for _, event := range events {
if event.CreatedAt.IsZero() || event.CreatedAt.After(cutoff) {
out = append(out, event)
}
}
return out
}
func (s *Store) readLocked() ([]Event, error) {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return []Event{}, nil
}
return nil, err
}
if len(data) == 0 {
return []Event{}, nil
}
var events []Event
if err := json.Unmarshal(data, &events); err != nil {
return []Event{}, nil
}
return events, nil
}
func (s *Store) writeLocked(events []Event) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(events, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}

151
lib/alerts/alerts.go Normal file
View File

@@ -0,0 +1,151 @@
package alerts
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
)
type Status string
const (
StatusOpen Status = "open"
StatusAcked Status = "acked"
StatusClosed Status = "closed"
)
type Alert struct {
ID string `json:"id"`
Title string `json:"title"`
Severity string `json:"severity"`
Status Status `json:"status"`
Group string `json:"group"`
Code string `json:"code"`
Trace string `json:"trace"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Meta map[string]string `json:"meta,omitempty"`
}
type Store struct {
path string
mu sync.Mutex
}
func NewStore(path string) *Store {
return &Store{path: path}
}
func (s *Store) Add(alert Alert) error {
s.mu.Lock()
defer s.mu.Unlock()
alertsList, err := s.readLocked()
if err != nil {
return err
}
now := time.Now().UTC()
if alert.ID == "" {
alert.ID = strconv.FormatInt(now.UnixNano(), 10)
}
if alert.Status == "" {
alert.Status = StatusOpen
}
if alert.CreatedAt.IsZero() {
alert.CreatedAt = now
}
alert.UpdatedAt = now
alertsList = append(alertsList, alert)
return s.writeLocked(alertsList)
}
func (s *Store) List(limit int) ([]Alert, error) {
s.mu.Lock()
defer s.mu.Unlock()
alertsList, err := s.readLocked()
if err != nil {
return nil, err
}
sort.Slice(alertsList, func(i, j int) bool {
return alertsList[i].CreatedAt.After(alertsList[j].CreatedAt)
})
if limit > 0 && len(alertsList) > limit {
return alertsList[:limit], nil
}
return alertsList, nil
}
func (s *Store) SetStatus(ids []string, status Status) error {
s.mu.Lock()
defer s.mu.Unlock()
alertsList, err := s.readLocked()
if err != nil {
return err
}
target := map[string]bool{}
for _, id := range ids {
target[id] = true
}
now := time.Now().UTC()
for i := range alertsList {
if target[alertsList[i].ID] {
alertsList[i].Status = status
alertsList[i].UpdatedAt = now
}
}
return s.writeLocked(alertsList)
}
func (s *Store) Delete(ids []string) error {
s.mu.Lock()
defer s.mu.Unlock()
alertsList, err := s.readLocked()
if err != nil {
return err
}
target := map[string]bool{}
for _, id := range ids {
target[id] = true
}
kept := make([]Alert, 0, len(alertsList))
for _, alert := range alertsList {
if !target[alert.ID] {
kept = append(kept, alert)
}
}
return s.writeLocked(kept)
}
func (s *Store) readLocked() ([]Alert, error) {
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return []Alert{}, nil
}
return nil, err
}
if len(data) == 0 {
return []Alert{}, nil
}
var alertsList []Alert
if err := json.Unmarshal(data, &alertsList); err != nil {
return []Alert{}, nil
}
return alertsList, nil
}
func (s *Store) writeLocked(alertsList []Alert) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(alertsList, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}

View File

@@ -20,6 +20,17 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true},
{Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true},
{Key: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityScanWindowSecs, EnvName: "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", Label: "Malicious path window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityScanMaxAttempts, EnvName: "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", Label: "Malicious path max attempts", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingSecurityUploadWindowSecs, EnvName: "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", Label: "Upload limit window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityUploadMaxRequests, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", Label: "Upload max requests per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingSecurityUploadMaxGB, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_GB", Label: "Upload max total GB per window", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
}
func (cfg *Config) SettingRows() []SettingRow {

View File

@@ -26,6 +26,15 @@ func Load() (*Config, error) {
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
ActivityRetentionSeconds: 7 * 24 * 60 * 60,
SecurityLoginWindowSeconds: 10 * 60,
SecurityLoginMaxAttempts: 8,
SecurityBanSeconds: 30 * 60,
SecurityScanWindowSeconds: 5 * 60,
SecurityScanMaxAttempts: 12,
SecurityUploadWindowSeconds: 60,
SecurityUploadMaxRequests: 20,
SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024,
sources: make(map[string]Source),
values: make(map[string]string),
defaults: make(map[string]string),
@@ -47,6 +56,12 @@ func Load() (*Config, error) {
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
@@ -90,6 +105,11 @@ func Load() (*Config, error) {
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
{SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds},
{SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds},
{SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds},
{SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds},
{SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
@@ -107,6 +127,7 @@ func Load() (*Config, error) {
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
{SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
@@ -123,6 +144,9 @@ func Load() (*Config, error) {
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts},
{SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts},
{SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
@@ -172,6 +196,17 @@ func (cfg *Config) captureDefaults() {
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10))
cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts))
cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10))
cfg.captureDefaultValue(SettingSecurityScanWindowSecs, strconv.FormatInt(cfg.SecurityScanWindowSeconds, 10))
cfg.captureDefaultValue(SettingSecurityScanMaxAttempts, strconv.Itoa(cfg.SecurityScanMaxAttempts))
cfg.captureDefaultValue(SettingSecurityUploadWindowSecs, strconv.FormatInt(cfg.SecurityUploadWindowSeconds, 10))
cfg.captureDefaultValue(SettingSecurityUploadMaxRequests, strconv.Itoa(cfg.SecurityUploadMaxRequests))
cfg.captureDefaultValue(SettingSecurityUploadMaxGB, formatGigabytesFromBytes(cfg.SecurityUploadMaxBytes))
}
func (cfg *Config) captureDefaultValue(key string, value string) {

View File

@@ -36,6 +36,17 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
SettingActivityRetentionSeconds = "activity_retention_seconds"
SettingSecurityIPWhitelist = "security_ip_whitelist"
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
SettingSecurityLoginWindowSecs = "security_login_window_seconds"
SettingSecurityLoginMaxAttempts = "security_login_max_attempts"
SettingSecurityBanSeconds = "security_ban_seconds"
SettingSecurityScanWindowSecs = "security_scan_window_seconds"
SettingSecurityScanMaxAttempts = "security_scan_max_attempts"
SettingSecurityUploadWindowSecs = "security_upload_window_seconds"
SettingSecurityUploadMaxRequests = "security_upload_max_requests"
SettingSecurityUploadMaxGB = "security_upload_max_gb"
)
type SettingType string
@@ -95,6 +106,17 @@ type Config struct {
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
ActivityRetentionSeconds int64
SecurityIPWhitelist string
SecurityAdminIPWhitelist string
SecurityLoginWindowSeconds int64
SecurityLoginMaxAttempts int
SecurityBanSeconds int64
SecurityScanWindowSeconds int64
SecurityScanMaxAttempts int
SecurityUploadWindowSeconds int64
SecurityUploadMaxRequests int
SecurityUploadMaxBytes int64
sources map[string]Source
values map[string]string

View File

@@ -51,6 +51,8 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
case SettingTypeText:
cfg.assignText(key, value, SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
@@ -92,8 +94,20 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
case SettingActivityRetentionSeconds:
cfg.ActivityRetentionSeconds = value
case SettingSecurityLoginWindowSecs:
cfg.SecurityLoginWindowSeconds = value
case SettingSecurityBanSeconds:
cfg.SecurityBanSeconds = value
case SettingSecurityScanWindowSecs:
cfg.SecurityScanWindowSeconds = value
case SettingSecurityUploadWindowSecs:
cfg.SecurityUploadWindowSeconds = value
case SettingSecurityUploadMaxGB:
cfg.SecurityUploadMaxBytes = value
}
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes || key == SettingSecurityUploadMaxGB {
cfg.setValue(key, formatGigabytesFromBytes(value), source)
return
}
@@ -108,10 +122,26 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
case SettingSecurityLoginMaxAttempts:
cfg.SecurityLoginMaxAttempts = value
case SettingSecurityScanMaxAttempts:
cfg.SecurityScanMaxAttempts = value
case SettingSecurityUploadMaxRequests:
cfg.SecurityUploadMaxRequests = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}
func (cfg *Config) assignText(key string, value string, source Source) {
switch key {
case SettingSecurityIPWhitelist:
cfg.SecurityIPWhitelist = value
case SettingSecurityAdminIPWhitelist:
cfg.SecurityAdminIPWhitelist = value
}
cfg.setValue(key, value, source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return

View File

@@ -26,6 +26,10 @@ type Handlers struct {
AdminBoxes gin.HandlerFunc
AdminBoxesAction gin.HandlerFunc
AdminUsers gin.HandlerFunc
AdminActivity gin.HandlerFunc
AdminSecurity gin.HandlerFunc
AdminAlertsAction gin.HandlerFunc
AdminSecurityAction gin.HandlerFunc
AdminSettings gin.HandlerFunc
AdminSettingsExport gin.HandlerFunc
AdminSettingsSave gin.HandlerFunc
@@ -62,9 +66,13 @@ func Register(router *gin.Engine, handlers Handlers) {
protected := router.Group("/admin", handlers.AdminAuth)
protected.GET("/dashboard", handlers.AdminDashboard)
protected.GET("/alerts", handlers.AdminAlerts)
protected.POST("/alerts/actions", handlers.AdminAlertsAction)
protected.GET("/boxes", handlers.AdminBoxes)
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
protected.GET("/users", handlers.AdminUsers)
protected.GET("/activity", handlers.AdminActivity)
protected.GET("/security", handlers.AdminSecurity)
protected.POST("/security/actions", handlers.AdminSecurityAction)
protected.GET("/settings", handlers.AdminSettings)
protected.GET("/settings/export", handlers.AdminSettingsExport)
protected.POST("/settings/save", handlers.AdminSettingsSave)

217
lib/security/guard.go Normal file
View File

@@ -0,0 +1,217 @@
package security
import (
"sort"
"strings"
"sync"
"time"
)
type Config struct {
IPWhitelist string
AdminIPWhitelist string
LoginWindowSeconds int64
LoginMaxAttempts int
BanSeconds int64
ScanWindowSeconds int64
ScanMaxAttempts int
UploadWindowSeconds int64
UploadMaxRequests int
UploadMaxBytes int64
}
type Guard struct {
mu sync.Mutex
failedLogins map[string][]time.Time
scanAttempts map[string][]time.Time
uploadEvents map[string][]uploadEvent
bannedUntil map[string]time.Time
ipWhitelist map[string]bool
adminWhitelist map[string]bool
}
type uploadEvent struct {
at time.Time
bytes int64
}
type BanEntry struct {
IP string `json:"ip"`
Until time.Time `json:"until"`
}
func NewGuard() *Guard {
return &Guard{
failedLogins: map[string][]time.Time{},
scanAttempts: map[string][]time.Time{},
uploadEvents: map[string][]uploadEvent{},
bannedUntil: map[string]time.Time{},
ipWhitelist: map[string]bool{},
adminWhitelist: map[string]bool{},
}
}
func (g *Guard) Reload(cfg Config) {
g.mu.Lock()
defer g.mu.Unlock()
g.ipWhitelist = parseList(cfg.IPWhitelist)
g.adminWhitelist = parseList(cfg.AdminIPWhitelist)
}
func (g *Guard) IsWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return g.ipWhitelist[ip]
}
func (g *Guard) IsAdminWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return g.adminWhitelist[ip] || g.ipWhitelist[ip]
}
func (g *Guard) IsBanned(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
until, ok := g.bannedUntil[ip]
if !ok {
return false
}
if time.Now().UTC().After(until) {
delete(g.bannedUntil, ip)
return false
}
return true
}
func (g *Guard) Ban(ip string, seconds int64) {
if seconds <= 0 || ip == "" {
return
}
g.mu.Lock()
defer g.mu.Unlock()
g.bannedUntil[ip] = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
}
func (g *Guard) BanUntil(ip string, until time.Time) {
if ip == "" || until.IsZero() {
return
}
g.mu.Lock()
defer g.mu.Unlock()
g.bannedUntil[ip] = until.UTC()
}
func (g *Guard) Unban(ip string) {
if ip == "" {
return
}
g.mu.Lock()
defer g.mu.Unlock()
delete(g.bannedUntil, ip)
}
func (g *Guard) BanList() []BanEntry {
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UTC()
out := make([]BanEntry, 0, len(g.bannedUntil))
for ip, until := range g.bannedUntil {
if now.After(until) {
delete(g.bannedUntil, ip)
continue
}
out = append(out, BanEntry{IP: ip, Until: until})
}
sort.Slice(out, func(i, j int) bool {
return out[i].Until.Before(out[j].Until)
})
return out
}
func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) {
if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 {
return false, 0
}
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UTC()
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
attempts := pruneTimes(g.failedLogins[ip], cutoff)
attempts = append(attempts, now)
g.failedLogins[ip] = attempts
if len(attempts) >= maxAttempts {
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
return true, len(attempts)
}
return false, len(attempts)
}
func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) {
if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 {
return false, 0
}
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UTC()
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
attempts := pruneTimes(g.scanAttempts[ip], cutoff)
attempts = append(attempts, now)
g.scanAttempts[ip] = attempts
if len(attempts) >= maxAttempts {
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
return true, len(attempts)
}
return false, len(attempts)
}
func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxRequests int, maxBytes int64) (bool, int, int64) {
if ip == "" || windowSeconds <= 0 || maxRequests <= 0 {
return true, 0, 0
}
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now().UTC()
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
events := g.uploadEvents[ip]
kept := make([]uploadEvent, 0, len(events)+1)
totalBytes := int64(0)
for _, event := range events {
if event.at.After(cutoff) {
kept = append(kept, event)
totalBytes += event.bytes
}
}
nextCount := len(kept) + 1
nextBytes := totalBytes + size
if nextCount > maxRequests {
return false, nextCount, nextBytes
}
if maxBytes > 0 && nextBytes > maxBytes {
return false, nextCount, nextBytes
}
kept = append(kept, uploadEvent{at: now, bytes: size})
g.uploadEvents[ip] = kept
return true, nextCount, nextBytes
}
func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
kept := make([]time.Time, 0, len(values))
for _, value := range values {
if value.After(cutoff) {
kept = append(kept, value)
}
}
return kept
}
func parseList(raw string) map[string]bool {
out := map[string]bool{}
for _, chunk := range strings.Split(raw, ",") {
value := strings.TrimSpace(chunk)
if value != "" {
out[value] = true
}
}
return out
}

View File

@@ -2,11 +2,14 @@ package server
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/alerts"
"warpbox/lib/config"
"warpbox/lib/security"
)
const adminSessionCookie = "warpbox_admin_session"
@@ -59,17 +62,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ip := clientIP(ctx)
guard := app.securityGuard
if guard == nil {
guard = security.NewGuard()
app.securityGuard = guard
}
if !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil)
ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{
"ErrorMessage": "Too many failed attempts. Try again later.",
})
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
if username != app.config.AdminUsername || password != app.config.AdminPassword {
if !guard.IsAdminWhitelisted(ip) {
banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds)
app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
if banned {
app.createAlert("Admin login brute-force blocked", "high", "security", "401", "auth.admin.bruteforce", "Too many failed admin logins triggered temporary ban.", map[string]string{"ip": ip, "attempts": strconv.Itoa(attempts)})
app.logActivity("security.ban", "high", "Auto-banned IP after admin login failures", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
}
}
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
"ErrorMessage": "Invalid username or password.",
})
return
}
app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil)
secure := app.config.AdminCookieSecure
maxAge := int(app.config.SessionTTLSeconds)
@@ -108,9 +133,41 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
return
}
alertsList := []alerts.Alert{}
if app.alertStore != nil {
var err error
alertsList, err = app.alertStore.List(500)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load alerts")
return
}
}
openCount := 0
highCount := 0
ackedCount := 0
closedCount := 0
for _, alert := range alertsList {
switch string(alert.Status) {
case "open":
openCount++
case "acked":
ackedCount++
case "closed":
closedCount++
}
if alert.Severity == "high" && string(alert.Status) != "closed" {
highCount++
}
}
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "alerts",
"Alerts": alertsList,
"OpenCount": strconv.Itoa(openCount),
"HighCount": strconv.Itoa(highCount),
"AckCount": strconv.Itoa(ackedCount),
"ClosedCount": strconv.Itoa(closedCount),
})
}

View File

@@ -0,0 +1,258 @@
package server
import (
"net"
"net/http"
"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"`
BanUntil string `json:"ban_until"`
}
func (app *App) reloadSecurityConfig() {
if app.securityGuard == nil {
app.securityGuard = security.NewGuard()
}
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,
})
}
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 = 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.securityGuard == nil {
ctx.Next()
return
}
ip := 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.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 := 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) {
events := []activity.Event{}
alertsList := []alerts.Alert{}
if app.activityStore != nil {
events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds)
}
if app.alertStore != nil {
alertsList, _ = app.alertStore.List(50)
}
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) 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
}
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.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, map[string]string{"ip": ip})
app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip})
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)
app.logActivity("security.manual_ban_until", "high", "Admin set custom ban expiration", ctx, map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
app.createAlert("Custom IP ban applied by admin", "medium", "security", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.", map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
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.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, map[string]string{"ip": ip})
app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip})
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
}
}
func intToString(value int) string {
return strconv.Itoa(value)
}

View File

@@ -269,6 +269,7 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
app.reloadSecurityConfig()
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
@@ -399,6 +400,8 @@ func settingsCategoryMeta() []settingsCategoryInfo {
{Key: "uploads", Label: "Uploads", Icon: "↥"},
{Key: "downloads", Label: "Downloads", Icon: "↧"},
{Key: "retention", Label: "Retention", Icon: "⌛"},
{Key: "security", Label: "Security", Icon: "🔒"},
{Key: "activity", Label: "Activity", Icon: "☰"},
{Key: "accounts", Label: "Accounts", Icon: "☺"},
{Key: "api", Label: "API", Icon: "{ }"},
{Key: "storage", Label: "Storage", Icon: "▥"},
@@ -428,10 +431,16 @@ func settingsCategoryForKey(key string) string {
switch key {
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
return "uploads"
case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB:
return "uploads"
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
return "downloads"
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
return "retention"
case config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts:
return "security"
case config.SettingActivityRetentionSeconds:
return "activity"
case config.SettingSessionTTLSeconds:
return "accounts"
case config.SettingAPIEnabled:
@@ -466,6 +475,17 @@ func settingsDescription(key string) string {
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.",
config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.",
config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.",
config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.",
config.SettingSecurityLoginMaxAttempts: "Max failed admin logins per window before temporary ban.",
config.SettingSecurityBanSeconds: "Duration for automatic temporary IP bans.",
config.SettingSecurityScanWindowSecs: "Window used for malicious path scan detection.",
config.SettingSecurityScanMaxAttempts: "Max suspicious path probes per window before temporary ban.",
config.SettingSecurityUploadWindowSecs: "Window used for per-IP upload throttling.",
config.SettingSecurityUploadMaxRequests: "Max upload requests per IP per upload window.",
config.SettingSecurityUploadMaxGB: "Max upload volume in GB per IP per upload window.",
}
return descriptions[key]
}

89
lib/server/ip.go Normal file
View File

@@ -0,0 +1,89 @@
package server
import (
"net"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func clientIP(ctx *gin.Context) string {
if ctx == nil || ctx.Request == nil {
return ""
}
remoteIP := remoteAddrIP(ctx.Request)
// Only trust forwarding headers when remote hop looks like local/internal proxy.
if isPrivateOrLoopback(remoteIP) {
for _, candidate := range headerIPs(ctx.Request.Header) {
if isPublicIP(candidate) {
return candidate
}
}
candidates := headerIPs(ctx.Request.Header)
if len(candidates) > 0 {
return candidates[0]
}
}
return remoteIP
}
func headerIPs(header http.Header) []string {
keys := []string{
"X-Forwarded-For",
"X-Real-Ip",
"CF-Connecting-IP",
"X-Envoy-External-Address",
"Fly-Client-IP",
}
out := make([]string, 0, 4)
seen := map[string]bool{}
for _, key := range keys {
raw := strings.TrimSpace(header.Get(key))
if raw == "" {
continue
}
for _, part := range strings.Split(raw, ",") {
ip := normalizeIP(strings.TrimSpace(part))
if ip == "" || seen[ip] {
continue
}
seen[ip] = true
out = append(out, ip)
}
}
return out
}
func remoteAddrIP(request *http.Request) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr))
if err != nil {
return normalizeIP(strings.TrimSpace(request.RemoteAddr))
}
return normalizeIP(host)
}
func normalizeIP(raw string) string {
ip := net.ParseIP(strings.TrimSpace(raw))
if ip == nil {
return ""
}
return ip.String()
}
func isPublicIP(value string) bool {
ip := net.ParseIP(value)
if ip == nil || !ip.IsGlobalUnicast() {
return false
}
return !isPrivateOrLoopback(value)
}
func isPrivateOrLoopback(value string) bool {
ip := net.ParseIP(value)
if ip == nil {
return true
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}

View File

@@ -9,14 +9,20 @@ import (
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/routing"
"warpbox/lib/security"
)
type App struct {
config *config.Config
settingsOverridesPath string
activityStore *activity.Store
alertStore *alerts.Store
securityGuard *security.Guard
}
func Run(addr string) error {
@@ -38,9 +44,18 @@ func Run(addr string) error {
applyBoxstoreRuntimeConfig(cfg)
app := &App{config: cfg, settingsOverridesPath: overridesPath}
app := &App{
config: cfg,
settingsOverridesPath: overridesPath,
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")),
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
}
app.reloadSecurityConfig()
router := gin.Default()
router.Use(app.securityMiddleware())
router.NoRoute(app.handleNoRoute)
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
return err
@@ -71,6 +86,10 @@ func Run(addr string) error {
AdminBoxes: app.handleAdminBoxes,
AdminBoxesAction: app.handleAdminBoxesAction,
AdminUsers: app.handleAdminUsers,
AdminActivity: app.handleAdminActivity,
AdminSecurity: app.handleAdminSecurity,
AdminAlertsAction: app.handleAdminAlertsAction,
AdminSecurityAction: app.handleAdminSecurityAction,
AdminSettings: app.handleAdminSettings,
AdminSettingsExport: app.handleAdminSettingsExport,
AdminSettingsSave: app.handleAdminSettingsSave,

View File

@@ -39,6 +39,13 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize := int64(0)
for _, file := range request.Files {
totalSize += file.Size
}
if !app.enforceUploadRateLimit(ctx, totalSize) {
return
}
files, err := boxstore.CreateManifest(boxID, request)
if err != nil {
@@ -73,6 +80,10 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !app.enforceUploadRateLimit(ctx, file.Size) {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
@@ -141,6 +152,9 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !app.enforceUploadRateLimit(ctx, file.Size) {
return
}
savedFile, err := boxstore.SaveUpload(boxID, file)
if err != nil {
@@ -180,6 +194,9 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !app.enforceUploadRateLimit(ctx, totalSize) {
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {

View File

@@ -3,6 +3,7 @@ package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -153,3 +154,36 @@ func (app *App) maxRequestBodyBytes() int64 {
}
return limit + 10*1024*1024
}
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
ip := clientIP(ctx)
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
return true
}
allowed, requestCount, totalBytes := app.securityGuard.AllowUpload(
ip,
size,
app.config.SecurityUploadWindowSeconds,
app.config.SecurityUploadMaxRequests,
app.config.SecurityUploadMaxBytes,
)
if allowed {
return true
}
app.logActivity("security.upload_limit", "high", "Upload rate limit exceeded", ctx, map[string]string{
"requests": strconv.Itoa(requestCount),
"bytes": strconv.FormatInt(totalBytes, 10),
})
app.createAlert(
"Upload rate limit triggered",
"medium",
"security",
"430",
"security.upload.rate_limit",
"Per-IP upload rate limit blocked request.",
map[string]string{"ip": ip, "requests": strconv.Itoa(requestCount)},
)
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many uploads from this IP. Try again later."})
return false
}

11
run.sh
View File

@@ -25,6 +25,17 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}"
export WARPBOX_SECURITY_IP_WHITELIST="${WARPBOX_SECURITY_IP_WHITELIST:-}"
export WARPBOX_SECURITY_ADMIN_IP_WHITELIST="${WARPBOX_SECURITY_ADMIN_IP_WHITELIST:-}"
export WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS="${WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS:-600}"
export WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS="${WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS:-8}"
export WARPBOX_SECURITY_BAN_SECONDS="${WARPBOX_SECURITY_BAN_SECONDS:-1800}"
export WARPBOX_SECURITY_SCAN_WINDOW_SECONDS="${WARPBOX_SECURITY_SCAN_WINDOW_SECONDS:-300}"
export WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS="${WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS:-12}"
export WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS="${WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS:-60}"
export WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS="${WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS:-20}"
export WARPBOX_SECURITY_UPLOAD_MAX_GB="${WARPBOX_SECURITY_UPLOAD_MAX_GB:-10}"
# Data location.
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"

63
static/css/activity.css Normal file
View File

@@ -0,0 +1,63 @@
.activity-page-body { display: grid; gap: 10px; }
.activity-panel {
min-height: 0;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 10px;
}
.activity-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) minmax(130px, .4fr) minmax(150px, .5fr);
gap: 8px;
margin-bottom: 8px;
}
.activity-input,
.activity-select {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.activity-table-wrap {
min-height: 420px;
height: 520px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.activity-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.activity-table th,
.activity-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.activity-table th {
position: sticky;
top: 0;
background: #dfdfdf;
z-index: 2;
}

115
static/css/security.css Normal file
View File

@@ -0,0 +1,115 @@
.security-page-body { display: grid; gap: 10px; }
.security-grid { display: grid; grid-template-columns: minmax(260px, .65fr) minmax(0, 1.35fr); gap: 10px; }
.security-panel {
min-height: 0;
display: flex;
flex-direction: column;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.security-panel-header {
min-height: 34px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.security-panel-header span { color: #444444; font-size: 12px; }
.security-panel-body { flex: 1 1 auto; min-height: 0; padding: 10px; overflow: auto; }
.security-field { display: grid; gap: 4px; font-size: 12px; }
.security-input {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.security-button { margin-top: 8px; min-width: 100px; height: 24px; padding: 0 8px; font-size: 12px; line-height: 12px; }
.security-note {
margin-top: 8px;
padding: 8px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
.security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; }
.security-ban-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr);
gap: 10px;
}
.security-bans-wrap { height: 220px; min-height: 220px; }
.security-ip-detail {
min-height: 0;
padding: 10px;
background: #f5f5f5;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
}
.security-ip-detail h3 {
margin: 0 0 8px;
font-size: 16px;
line-height: 16px;
}
.security-ip-detail ul {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
font-size: 12px;
}
.security-bans-body-row.is-selected { background: #c5dcff; }
.security-table-wrap {
min-height: 280px;
height: 320px;
overflow: auto;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.security-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.security-table th,
.security-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.security-table th { position: sticky; top: 0; background: #dfdfdf; z-index: 2; }
@media (max-width: 980px) {
.security-grid,
.security-ban-grid {
grid-template-columns: 1fr;
}
}

104
static/js/admin/activity.js Normal file
View File

@@ -0,0 +1,104 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const dataNode = document.getElementById("activity-data");
const body = document.getElementById("activity-body");
const searchInput = document.getElementById("activity-search");
const severityFilter = document.getElementById("activity-severity");
const kindFilter = document.getElementById("activity-kind");
const statusLeft = document.getElementById("activity-status-left");
const toast = document.getElementById("toast");
if (!dataNode || !body || !searchInput) return;
const events = parseData();
function parseData() {
try {
return JSON.parse(dataNode.textContent || "[]");
} catch (_) {
return [];
}
}
function showToast(message, type = "info", duration = 1800) {
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
}
function renderKindFilter() {
const kinds = new Set(events.map((event) => event.kind || "unknown"));
Array.from(kinds).sort().forEach((kind) => {
const option = document.createElement("option");
option.value = kind;
option.textContent = kind;
kindFilter.appendChild(option);
});
}
function createdLabel(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
function filtered() {
const query = searchInput.value.trim().toLowerCase();
const severity = severityFilter.value;
const kind = kindFilter.value;
return events.filter((event) => {
const haystack = [event.kind, event.message, event.ip, event.path, event.method].join(" ").toLowerCase();
const matchesQuery = !query || haystack.includes(query);
const matchesSeverity = severity === "all" || event.severity === severity;
const matchesKind = kind === "all" || event.kind === kind;
return matchesQuery && matchesSeverity && matchesKind;
});
}
function render() {
const rows = filtered();
body.innerHTML = "";
rows.forEach((event) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${createdLabel(event.created_at)}</td>
<td>${escapeHtml(event.kind || "-")}</td>
<td>${escapeHtml(event.severity || "-")}</td>
<td>${escapeHtml(event.ip || "-")}</td>
<td>${escapeHtml(event.method || "-")}</td>
<td title="${escapeHtml(event.path || "-")}">${escapeHtml(event.path || "-")}</td>
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
`;
body.appendChild(row);
});
statusLeft.textContent = `${rows.length} activity event(s) visible`;
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
}
[searchInput, severityFilter, kindFilter].forEach((element) => {
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", render);
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
if (button.dataset.command === "refresh") {
window.location.reload();
return;
}
if (button.dataset.command === "export") {
const blob = new Blob([JSON.stringify(filtered(), null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `warpbox-activity-${new Date().toISOString().replaceAll(":", "-")}.json`;
anchor.click();
URL.revokeObjectURL(url);
showToast("Visible activity exported");
}
});
});
renderKindFilter();
render();
})();

View File

@@ -1,25 +1,16 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toast = document.getElementById("toast");
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const dataNode = document.getElementById("alerts-data");
const alertsBody = document.getElementById("alerts-body");
const searchInput = document.getElementById("search-input");
const severityFilter = document.getElementById("severity-filter");
const statusFilter = document.getElementById("status-filter");
const sourceFilter = document.getElementById("source-filter");
const sortFilter = document.getElementById("sort-filter");
const alertsBody = document.getElementById("alerts-body");
const selectedCountEl = document.getElementById("selected-count");
const openCountEl = document.querySelector("[data-open-count]");
const highCountEl = document.querySelector("[data-high-count]");
const ackCountEl = document.querySelector("[data-ack-count]");
const closedCountEl = document.querySelector("[data-closed-count]");
const selectAll = document.getElementById("select-all");
const selectedCountEl = document.getElementById("selected-count");
const totalPill = document.getElementById("alerts-total-pill");
const toast = document.getElementById("toast");
const detailEls = {
title: document.getElementById("detail-title"),
@@ -32,185 +23,243 @@
metadata: document.getElementById("detail-metadata")
};
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
if (!dataNode || !alertsBody) return;
const state = {
alerts: parseData(),
selected: new Set(),
activeID: null
};
function parseData() {
try {
return JSON.parse(dataNode.textContent || "[]");
} catch (_) {
return [];
}
}
function showToast(message, type = "info", duration = 1800) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast, duration });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
}
function allRows() {
return Array.from(alertsBody.querySelectorAll("tr"));
function createdLabel(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
function visibleRows() {
return allRows().filter((row) => row.style.display !== "none");
function allAlerts() {
return state.alerts.slice();
}
function selectedRows() {
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
}
function updateSelectedCount() {
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
}
function updateSummaryCounts() {
const rows = visibleRows();
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
}
function updateDetails(row) {
if (!row) return;
allRows().forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
detailEls.title.textContent = row.dataset.title || "";
detailEls.severity.textContent = row.dataset.severity || "";
detailEls.status.textContent = row.dataset.status || "";
detailEls.code.textContent = row.dataset.code || "";
detailEls.trace.textContent = row.dataset.trace || "";
detailEls.time.textContent = row.dataset.time || "";
detailEls.description.textContent = row.dataset.description || "";
try {
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
} catch (_) {
detailEls.metadata.textContent = row.dataset.metadata || "{}";
}
}
function applyFilters() {
const search = searchInput.value.trim().toLowerCase();
function filteredAlerts() {
const query = searchInput.value.trim().toLowerCase();
const severity = severityFilter.value;
const status = statusFilter.value;
const group = sourceFilter.value;
allRows().forEach((row) => {
const rows = allAlerts().filter((alert) => {
const haystack = [
row.dataset.title,
row.dataset.description,
row.dataset.code,
row.dataset.trace,
row.dataset.group
alert.title,
alert.message,
alert.code,
alert.trace,
alert.group
].join(" ").toLowerCase();
const matchesSearch = !search || haystack.includes(search);
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
const matchesStatus = status === "all" || row.dataset.status === status;
const matchesGroup = group === "all" || row.dataset.group === group;
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
const matchesSearch = !query || haystack.includes(query);
const matchesSeverity = severity === "all" || alert.severity === severity;
const matchesStatus = status === "all" || alert.status === status;
const matchesGroup = group === "all" || alert.group === group;
return matchesSearch && matchesSeverity && matchesStatus && matchesGroup;
});
const order = { high: 3, medium: 2, low: 1 };
visibleRows().sort((a, b) => {
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
return Number(b.dataset.id) - Number(a.dataset.id);
}).forEach((row) => alertsBody.appendChild(row));
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
updateSelectedCount();
updateSummaryCounts();
rows.sort((a, b) => {
if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0);
if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at));
return String(b.created_at).localeCompare(String(a.created_at));
});
return rows;
}
function setRowStatus(row, nextStatus) {
row.dataset.status = nextStatus;
const statusCell = row.children[3]?.querySelector(".alerts-pill");
if (!statusCell) return;
statusCell.className = `alerts-pill ${nextStatus}`;
statusCell.textContent = nextStatus;
function ensureActive(rows) {
if (rows.length === 0) {
state.activeID = null;
return null;
}
const found = rows.find((item) => item.id === state.activeID);
if (found) return found;
state.activeID = rows[0].id;
return rows[0];
}
function changeSelectedStatus(nextStatus) {
const rows = selectedRows();
if (!rows.length) {
function render() {
const rows = filteredAlerts();
alertsBody.innerHTML = "";
rows.forEach((alert) => alertsBody.appendChild(buildRow(alert)));
const active = ensureActive(rows);
if (active) renderDetails(active);
renderSummary(rows);
syncSelected();
syncSelectAll(rows);
}
function buildRow(alert) {
const row = document.createElement("tr");
if (state.activeID === alert.id) row.classList.add("is-selected");
row.innerHTML = `
<td><input type="checkbox" class="row-check"${state.selected.has(alert.id) ? " checked" : ""}></td>
<td>${escapeHtml(alert.title || "-")}</td>
<td><span class="alerts-pill ${escapeHtml(alert.severity || "low")}">${escapeHtml(alert.severity || "low")}</span></td>
<td><span class="alerts-pill ${escapeHtml(alert.status || "open")}">${escapeHtml(alert.status || "open")}</span></td>
<td>${escapeHtml(alert.code || "-")}</td>
<td>${escapeHtml(alert.trace || "-")}</td>
<td>${createdLabel(alert.created_at)}</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
`;
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
state.activeID = alert.id;
render();
});
row.querySelector(".row-open")?.addEventListener("click", () => {
state.activeID = alert.id;
render();
});
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(alert.id);
else state.selected.delete(alert.id);
syncSelected();
syncSelectAll(filteredAlerts());
});
return row;
}
function renderDetails(alert) {
detailEls.title.textContent = alert.title || "";
detailEls.severity.textContent = alert.severity || "";
detailEls.status.textContent = alert.status || "";
detailEls.code.textContent = alert.code || "";
detailEls.trace.textContent = alert.trace || "";
detailEls.time.textContent = createdLabel(alert.created_at);
detailEls.description.textContent = alert.message || "";
detailEls.metadata.textContent = JSON.stringify(alert.meta || {}, null, 2);
}
function renderSummary(rows) {
const open = rows.filter((item) => item.status === "open").length;
const high = rows.filter((item) => item.severity === "high" && item.status !== "closed").length;
const ack = rows.filter((item) => item.status === "acked").length;
const closed = rows.filter((item) => item.status === "closed").length;
document.querySelector("[data-open-count]").textContent = String(open);
document.querySelector("[data-high-count]").textContent = String(high);
document.querySelector("[data-ack-count]").textContent = String(ack);
document.querySelector("[data-closed-count]").textContent = String(closed);
totalPill.textContent = `${rows.length} alerts`;
}
function syncSelected() {
selectedCountEl.textContent = `Selected: ${state.selected.size}`;
}
function syncSelectAll(rows) {
selectAll.checked = rows.length > 0 && rows.every((alert) => state.selected.has(alert.id));
}
async function postAction(action, ids) {
const response = await fetch("/admin/alerts/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ids })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "Request failed");
state.alerts = payload.alerts || [];
}
async function runAction(action) {
const ids = Array.from(state.selected);
if (!ids.length && (action === "ack" || action === "close" || action === "delete")) {
showToast("Select one or more alerts first", "warning");
return;
}
rows.forEach((row) => {
setRowStatus(row, nextStatus);
row.querySelector(".row-check").checked = false;
});
if (selectAll) selectAll.checked = false;
updateSelectedCount();
updateSummaryCounts();
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
if (currentRow) updateDetails(currentRow);
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
if (action === "open-only") {
statusFilter.value = "open";
render();
showToast("Showing open alerts only");
return;
}
if (action === "refresh") {
window.location.reload();
return;
}
if (action === "copy-meta") {
const active = allAlerts().find((item) => item.id === state.activeID);
if (active) {
navigator.clipboard?.writeText(JSON.stringify(active.meta || {}, null, 2)).catch(() => {});
}
showToast("Metadata copied");
return;
}
if (action === "export") {
const blob = new Blob([JSON.stringify(filteredAlerts(), null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `warpbox-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`;
anchor.click();
URL.revokeObjectURL(url);
showToast("Visible alerts exported");
return;
}
if (action === "help-codes") {
showToast("Codes map to internal security and service traces.");
return;
}
if (action === "help-meta") {
showToast("Metadata shows extra context for each alert.");
return;
}
await postAction(action, ids);
state.selected.clear();
render();
showToast(`Action complete: ${action}`, "success");
}
const commandMessages = {
refresh: "Alerts refreshed in mock view",
export: "Visible alerts exported in mock view",
"copy-meta": "Metadata copied in mock view",
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
"help-meta": "Metadata explains why the alert happened and includes extra context."
};
function runCommand(command) {
switch (command) {
case "ack":
changeSelectedStatus("acked");
return;
case "close":
changeSelectedStatus("closed");
return;
case "open-only":
statusFilter.value = "open";
applyFilters();
showToast("Showing open alerts only");
return;
default:
showToast(commandMessages[command] || `Mock action: ${command}`);
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
}
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
});
allRows().forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
updateDetails(row);
});
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
});
selectAll?.addEventListener("change", () => {
visibleRows().forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (checkbox) checkbox.checked = selectAll.checked;
const rows = filteredAlerts();
rows.forEach((alert) => {
if (selectAll.checked) state.selected.add(alert.id);
else state.selected.delete(alert.id);
});
updateSelectedCount();
render();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
button.addEventListener("click", async () => {
menuController.close();
runCommand(button.dataset.command);
try {
await runAction(button.dataset.command);
} catch (error) {
showToast(error.message, "error", 3200);
}
});
});
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
await runAction("refresh");
}
});
applyFilters();
updateDetails(allRows()[0]);
render();
})();

272
static/js/admin/security.js Normal file
View File

@@ -0,0 +1,272 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const eventsNode = document.getElementById("security-events-data");
const alertsNode = document.getElementById("security-alerts-data");
const bansNode = document.getElementById("security-bans-data");
const ipInput = document.getElementById("security-ip-input");
const banUntilInput = document.getElementById("security-ban-until");
const alertList = document.getElementById("security-alert-list");
const activityBody = document.getElementById("security-activity-body");
const bansBody = document.getElementById("security-bans-body");
const bansCount = document.getElementById("security-bans-count");
const toast = document.getElementById("toast");
const detail = {
ip: document.getElementById("security-detail-ip"),
risk: document.getElementById("security-detail-risk"),
threat: document.getElementById("security-detail-threat"),
geo: document.getElementById("security-detail-geo"),
asn: document.getElementById("security-detail-asn"),
until: document.getElementById("security-detail-until")
};
if (!eventsNode || !alertsNode || !bansNode) return;
const state = {
events: parse(eventsNode),
alerts: parse(alertsNode),
bans: parse(bansNode),
selectedIP: ""
};
setDefaultBanUntil();
function parse(node) {
try {
return JSON.parse(node.textContent || "[]");
} catch (_) {
return [];
}
}
function showToast(message, type = "info", duration = 1800) {
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
}
function createdLabel(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
function setDefaultBanUntil() {
const base = new Date(Date.now() + 30 * 60 * 1000);
const yyyy = String(base.getUTCFullYear());
const mm = String(base.getUTCMonth() + 1).padStart(2, "0");
const dd = String(base.getUTCDate()).padStart(2, "0");
const hh = String(base.getUTCHours()).padStart(2, "0");
const mi = String(base.getUTCMinutes()).padStart(2, "0");
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
function toRFC3339FromLocalUTC(datetimeLocalValue) {
if (!datetimeLocalValue) return "";
const date = new Date(datetimeLocalValue + ":00Z");
if (Number.isNaN(date.getTime())) return "";
return date.toISOString();
}
function setSelectedIP(ip) {
state.selectedIP = ip || "";
if (state.selectedIP) ipInput.value = state.selectedIP;
renderBans();
renderIPDetails();
}
function render() {
renderAlerts();
renderActivity();
renderBans();
renderIPDetails();
}
function renderAlerts() {
alertList.innerHTML = "";
state.alerts.slice(0, 12).forEach((alert) => {
const entry = document.createElement("li");
entry.textContent = `${createdLabel(alert.created_at)} | ${alert.severity || "low"} | ${alert.title || "-"}`;
alertList.appendChild(entry);
});
}
function renderActivity() {
activityBody.innerHTML = "";
state.events
.filter((event) => String(event.kind || "").startsWith("security") || String(event.kind || "").startsWith("auth.admin"))
.slice(0, 60)
.forEach((event) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${createdLabel(event.created_at)}</td>
<td>${escapeHtml(event.kind || "-")}</td>
<td>${escapeHtml(event.severity || "-")}</td>
<td>${escapeHtml(event.ip || "-")}</td>
<td>${escapeHtml(event.path || "-")}</td>
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
`;
activityBody.appendChild(row);
});
}
function renderBans() {
bansBody.innerHTML = "";
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
const ips = new Set();
state.events.forEach((event) => {
const ip = String(event.ip || "").trim();
if (ip) ips.add(ip);
});
state.bans.forEach((entry) => {
const ip = String(entry.ip || "").trim();
if (ip) ips.add(ip);
});
const rows = Array.from(ips).sort();
rows.forEach((ip) => {
const ban = banMap.get(ip) || null;
const status = ban ? "banned" : "observed";
const row = document.createElement("tr");
row.className = "security-bans-body-row";
if (ip === state.selectedIP) row.classList.add("is-selected");
row.innerHTML = `
<td>${escapeHtml(ip || "-")}</td>
<td>${status}</td>
<td>${ban ? createdLabel(ban.until) : "-"}</td>
`;
row.addEventListener("click", () => setSelectedIP(ip));
bansBody.appendChild(row);
});
bansCount.textContent = `${state.bans.length} active bans`;
}
function renderIPDetails() {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) {
detail.ip.textContent = "No IP selected";
detail.risk.textContent = "-";
detail.threat.textContent = "-";
detail.geo.textContent = "Placeholder (geoipfast later)";
detail.asn.textContent = "Placeholder";
detail.until.textContent = "-";
return;
}
const ban = state.bans.find((entry) => entry.ip === ip);
detail.ip.textContent = ip;
detail.risk.textContent = ban ? "high" : "medium";
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
detail.geo.textContent = "Placeholder country/region lookup";
detail.asn.textContent = "Placeholder ASN/provider lookup";
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
if (ban && ban.until) {
const parsed = new Date(ban.until);
if (!Number.isNaN(parsed.getTime())) {
const yyyy = String(parsed.getUTCFullYear());
const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0");
const dd = String(parsed.getUTCDate()).padStart(2, "0");
const hh = String(parsed.getUTCHours()).padStart(2, "0");
const mi = String(parsed.getUTCMinutes()).padStart(2, "0");
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
}
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
}
async function postAction(action, payload = {}) {
const response = await fetch("/admin/security/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...payload })
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "Request failed");
if (Array.isArray(result.bans)) state.bans = result.bans;
return result;
}
async function banIP() {
const ip = String(ipInput.value || "").trim();
if (!ip) {
showToast("Enter IP first", "warning");
return;
}
const payload = await postAction("ban", { ip });
setSelectedIP(ip);
showToast(payload.message || "IP banned", "success");
}
async function banUntil() {
const ip = String(ipInput.value || "").trim();
if (!ip) {
showToast("Enter IP first", "warning");
return;
}
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
if (!banUntil) {
showToast("Set valid expiration date", "warning");
return;
}
const payload = await postAction("ban_until", { ip, ban_until: banUntil });
setSelectedIP(ip);
showToast(payload.message || "IP ban expiration updated", "success");
}
async function unbanIP() {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) {
showToast("Select or enter IP first", "warning");
return;
}
const payload = await postAction("unban", { ip });
setSelectedIP("");
showToast(payload.message || "IP unbanned", "success");
}
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
try {
const command = button.dataset.command;
if (command === "refresh") {
window.location.reload();
return;
}
if (command === "ban-ip") {
await banIP();
return;
}
if (command === "ban-until") {
await banUntil();
return;
}
if (command === "unban-ip") {
await unbanIP();
return;
}
} catch (error) {
showToast(error.message, "error", 3200);
} finally {
renderBans();
renderIPDetails();
}
});
});
ipInput.addEventListener("input", () => renderIPDetails());
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
window.location.reload();
}
});
if (state.bans.length > 0) {
setSelectedIP(state.bans[0].ip);
} else {
const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== "");
if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim());
}
render();
})();

View File

@@ -0,0 +1,92 @@
{{ define "admin/activity.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Activity</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/activity.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Activity</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Activity toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh list</span><span>F5</span></button>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible JSON</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body activity-page-body">
<section class="activity-panel">
<div class="activity-toolbar-grid">
<input class="activity-input" id="activity-search" type="search" placeholder="Search kind, message, ip, path">
<select class="activity-select" id="activity-severity">
<option value="all" selected>All severities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<select class="activity-select" id="activity-kind">
<option value="all" selected>All kinds</option>
</select>
</div>
<div class="activity-table-wrap">
<table class="activity-table">
<thead>
<tr>
<th>Time</th>
<th>Kind</th>
<th>Severity</th>
<th>IP</th>
<th>Method</th>
<th>Path</th>
<th>Message</th>
</tr>
</thead>
<tbody id="activity-body"></tbody>
</table>
</div>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="activity-status-left">{{ len .Events }} events loaded</span>
<span id="activity-status-middle">retention from settings</span>
<span id="activity-status-right">admin only</span>
</footer>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script id="activity-data" type="application/json">{{ toJSON .Events }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/activity.js"></script>
</body>
</html>
{{ end }}

View File

@@ -61,22 +61,22 @@
<section class="alerts-summary-grid" aria-label="Alerts summary">
<article class="alerts-stat-card is-danger">
<p class="alerts-stat-label">Open alerts</p>
<p class="alerts-stat-value" data-open-count>5</p>
<p class="alerts-stat-value" data-open-count>{{ .OpenCount }}</p>
<p class="alerts-stat-note">Requires attention</p>
</article>
<article class="alerts-stat-card is-warning">
<p class="alerts-stat-label">High severity</p>
<p class="alerts-stat-value" data-high-count>2</p>
<p class="alerts-stat-value" data-high-count>{{ .HighCount }}</p>
<p class="alerts-stat-note">Escalate first</p>
</article>
<article class="alerts-stat-card is-info">
<p class="alerts-stat-label">Acknowledged</p>
<p class="alerts-stat-value" data-ack-count>3</p>
<p class="alerts-stat-value" data-ack-count>{{ .AckCount }}</p>
<p class="alerts-stat-note">Seen but not closed</p>
</article>
<article class="alerts-stat-card is-info">
<p class="alerts-stat-label">Closed today</p>
<p class="alerts-stat-value" data-closed-count>2</p>
<p class="alerts-stat-value" data-closed-count>{{ .ClosedCount }}</p>
<p class="alerts-stat-note">History stays lightweight</p>
</article>
</section>
@@ -134,108 +134,7 @@
<th class="alerts-col-actions">Actions</th>
</tr>
</thead>
<tbody id="alerts-body">
<tr data-id="10" data-severity="high" data-status="open" data-group="storage" data-title="Storage connector unavailable" data-description="Primary local storage connector failed health check and new writes are paused." data-code="301" data-trace="storage.connector.health_failed" data-time="today 14:08" data-metadata='{"connector":"local-main","mode":"read_only","retry_in":"30s"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Storage connector unavailable</td>
<td><span class="alerts-pill high">high</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>301</td>
<td>storage.connector.health_failed</td>
<td>today 14:08</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="9" data-severity="medium" data-status="open" data-group="thumbnails" data-title="Thumbnail generation failed" data-description="Thumbnail generation failed for one uploaded image. Original file remains available." data-code="601" data-trace="thumbnail.generate.failed" data-time="today 13:40" data-metadata='{"box":"bx_49aa","file":"poster.png","worker":"thumb-2"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Thumbnail generation failed</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>601</td>
<td>thumbnail.generate.failed</td>
<td>today 13:40</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="8" data-severity="low" data-status="acked" data-group="uploads" data-title="Large upload nearing account cap" data-description="A user is close to their daily upload budget." data-code="124" data-trace="upload.quota.nearing_cap" data-time="today 12:58" data-metadata='{"user":"geo","used":"44 GB","limit":"50 GB"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Large upload nearing account cap</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>124</td>
<td>upload.quota.nearing_cap</td>
<td>today 12:58</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="7" data-severity="high" data-status="open" data-group="auth" data-title="Repeated admin login failures" data-description="Multiple failed admin login attempts were detected from the same source." data-code="211" data-trace="auth.admin.failed_login_burst" data-time="today 12:10" data-metadata='{"ip":"198.51.100.4","attempts":7,"window":"10m"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Repeated admin login failures</td>
<td><span class="alerts-pill high">high</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>211</td>
<td>auth.admin.failed_login_burst</td>
<td>today 12:10</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="6" data-severity="medium" data-status="acked" data-group="storage" data-title="Cleanup skipped locked files" data-description="Cleanup job encountered locked files and skipped them." data-code="342" data-trace="cleanup.skip.locked_files" data-time="today 10:22" data-metadata='{"count":3,"connector":"local-main"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Cleanup skipped locked files</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>342</td>
<td>cleanup.skip.locked_files</td>
<td>today 10:22</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="5" data-severity="low" data-status="closed" data-group="uploads" data-title="Archive completed with warnings" data-description="ZIP archive completed but excluded one unreadable temporary file." data-code="145" data-trace="archive.complete.with_warning" data-time="today 09:02" data-metadata='{"box":"bx_3901","skipped":1}'>
<td><input type="checkbox" class="row-check"></td>
<td>Archive completed with warnings</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>145</td>
<td>archive.complete.with_warning</td>
<td>today 09:02</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="4" data-severity="medium" data-status="open" data-group="uploads" data-title="Upload session expired mid-transfer" data-description="A long-running upload lost session validity before final commit." data-code="156" data-trace="upload.session.expired_mid_transfer" data-time="yesterday" data-metadata='{"user":"teo","partial_bytes":"1.2 GB"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Upload session expired mid-transfer</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>156</td>
<td>upload.session.expired_mid_transfer</td>
<td>yesterday</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="3" data-severity="low" data-status="closed" data-group="thumbnails" data-title="Thumbnail worker restarted" data-description="Thumbnail worker restarted after a normal watchdog recycle." data-code="602" data-trace="thumbnail.worker.restarted" data-time="yesterday" data-metadata='{"worker":"thumb-1","reason":"watchdog"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Thumbnail worker restarted</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>602</td>
<td>thumbnail.worker.restarted</td>
<td>yesterday</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="2" data-severity="medium" data-status="acked" data-group="auth" data-title="User invited without email delivery confirmation" data-description="Invite creation succeeded but email delivery confirmation was not returned." data-code="224" data-trace="auth.invite.delivery_unknown" data-time="2 days ago" data-metadata='{"user":"reo","provider":"smtp-primary"}'>
<td><input type="checkbox" class="row-check"></td>
<td>User invited without email delivery confirmation</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>224</td>
<td>auth.invite.delivery_unknown</td>
<td>2 days ago</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="1" data-severity="low" data-status="closed" data-group="storage" data-title="Secondary connector caught up" data-description="Delayed sync on a secondary storage connector completed successfully." data-code="329" data-trace="storage.secondary.sync_recovered" data-time="2 days ago" data-metadata='{"connector":"bucket-archive","lag":"0"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Secondary connector caught up</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>329</td>
<td>storage.secondary.sync_recovered</td>
<td>2 days ago</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
</tbody>
<tbody id="alerts-body"></tbody>
</table>
</div>
</div>
@@ -287,10 +186,11 @@
<div class="alerts-action-stack">
<button class="win98-button alerts-action-button" type="button" data-command="ack">Acknowledge selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="close">Close selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="delete">Delete selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="refresh">Refresh alerts</button>
</div>
<div class="alerts-mini-note">
CURRENTLY_MOCKED_LEAVE_AS_IS: alerts use a lightweight lifecycle for now: open, acknowledged, closed.
Alerts persist until deleted. Acknowledge and close update state; delete removes permanently.
</div>
</div>
</section>
@@ -301,11 +201,12 @@
<div class="alerts-footerbar">
<div class="alerts-footer-left">
<span class="alerts-status-pill" id="selected-count">Selected: 0</span>
<span class="alerts-status-pill">10 mocked alerts</span>
<span class="alerts-status-pill" id="alerts-total-pill">{{ len .Alerts }} alerts</span>
</div>
<div class="alerts-footer-right">
<button class="win98-button alerts-footer-button" type="button" data-command="ack">Acknowledge</button>
<button class="win98-button alerts-footer-button" type="button" data-command="close">Close</button>
<button class="win98-button alerts-footer-button" type="button" data-command="delete">Delete</button>
</div>
</div>
</div>
@@ -314,6 +215,7 @@
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script id="alerts-data" type="application/json">{{ toJSON .Alerts }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/alerts.js"></script>
</body>

View File

@@ -8,7 +8,9 @@
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "alerts" }} is-active{{ end }}" href="/admin/alerts">Alerts</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "boxes" }} is-active{{ end }}" href="/admin/boxes">Boxes</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "activity" }} is-active{{ end }}" href="/admin/activity">Activity</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "users" }} is-active{{ end }}" href="/admin/users">Users</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "security" }} is-active{{ end }}" href="/admin/security">Security</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
</nav>
<div class="admin-taskbar-session" aria-label="Admin session summary">

View File

@@ -0,0 +1,138 @@
{{ define "admin/security.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Security</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/security.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Security</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Security toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Security</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="ban-ip"><span>B</span><span>Ban IP now</span><span></span></button>
<button class="menu-action" type="button" data-command="ban-until"><span>T</span><span>Set ban expiration</span><span></span></button>
<button class="menu-action" type="button" data-command="unban-ip"><span>U</span><span>Unban selected IP</span><span></span></button>
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh data</span><span>F5</span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body security-page-body">
<section class="security-grid">
<section class="security-panel">
<div class="security-panel-header"><strong>Manual controls</strong><span>basic first version</span></div>
<div class="security-panel-body">
<label class="security-field">IP address
<input class="security-input" id="security-ip-input" type="text" placeholder="203.0.113.12">
</label>
<button class="win98-button security-button" type="button" data-command="ban-ip">Ban IP (temporary)</button>
<label class="security-field">Ban expires (UTC)
<input class="security-input" id="security-ban-until" type="datetime-local">
</label>
<button class="win98-button security-button" type="button" data-command="ban-until">Set ban expiration</button>
<button class="win98-button security-button" type="button" data-command="unban-ip">Unban selected IP</button>
<div class="security-note">Ban duration and auto-ban thresholds come from Settings -> Security.</div>
</div>
</section>
<section class="security-panel">
<div class="security-panel-header"><strong>Recent alerts</strong><span>{{ len .Alerts }} total</span></div>
<div class="security-panel-body">
<ul class="security-list" id="security-alert-list"></ul>
</div>
</section>
</section>
<section class="security-panel">
<div class="security-panel-header"><strong>IP addresses</strong><span id="security-bans-count">{{ len .Bans }} active bans</span></div>
<div class="security-panel-body security-ban-grid">
<div class="security-table-wrap security-bans-wrap">
<table class="security-table">
<thead>
<tr>
<th>IP</th>
<th>Status</th>
<th>Ban expires (UTC)</th>
</tr>
</thead>
<tbody id="security-bans-body"></tbody>
</table>
</div>
<div class="security-ip-detail">
<h3 id="security-detail-ip">No IP selected</h3>
<ul>
<li><strong>Risk:</strong> <span id="security-detail-risk">-</span></li>
<li><strong>Threat:</strong> <span id="security-detail-threat">-</span></li>
<li><strong>Geo:</strong> <span id="security-detail-geo">Placeholder (geoipfast later)</span></li>
<li><strong>ASN:</strong> <span id="security-detail-asn">Placeholder</span></li>
<li><strong>Ban until:</strong> <span id="security-detail-until">-</span></li>
</ul>
</div>
</div>
</section>
<section class="security-panel">
<div class="security-panel-header"><strong>Recent security activity</strong><span>{{ len .Events }} rows</span></div>
<div class="security-panel-body">
<div class="security-table-wrap">
<table class="security-table">
<thead>
<tr>
<th>Time</th>
<th>Kind</th>
<th>Severity</th>
<th>IP</th>
<th>Path</th>
<th>Message</th>
</tr>
</thead>
<tbody id="security-activity-body"></tbody>
</table>
</div>
</div>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="security-status-left">Security controls active</span>
<span id="security-status-middle">alerts + activity linked</span>
<span id="security-status-right">admin only</span>
</footer>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script id="security-events-data" type="application/json">{{ toJSON .Events }}</script>
<script id="security-alerts-data" type="application/json">{{ toJSON .Alerts }}</script>
<script id="security-bans-data" type="application/json">{{ toJSON .Bans }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/security.js"></script>
</body>
</html>
{{ end }}