feat(admin): add security and activity management features
This commit is contained in:
114
TO-DO.md
Normal file
114
TO-DO.md
Normal 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
116
lib/activity/activity.go
Normal 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
151
lib/alerts/alerts.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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: 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: 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: 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 {
|
func (cfg *Config) SettingRows() []SettingRow {
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ func Load() (*Config, error) {
|
|||||||
BoxPollIntervalMS: 5000,
|
BoxPollIntervalMS: 5000,
|
||||||
ThumbnailBatchSize: 10,
|
ThumbnailBatchSize: 10,
|
||||||
ThumbnailIntervalSeconds: 30,
|
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),
|
sources: make(map[string]Source),
|
||||||
values: make(map[string]string),
|
values: make(map[string]string),
|
||||||
defaults: 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 {
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||||
return nil, err
|
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 != "" {
|
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||||
mode := AdminEnabledMode(strings.ToLower(raw))
|
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||||
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||||
@@ -90,6 +105,11 @@ func Load() (*Config, error) {
|
|||||||
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||||
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
||||||
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
{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 {
|
for _, item := range envInt64s {
|
||||||
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
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},
|
{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},
|
{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},
|
{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 {
|
for _, item := range sizeEnvVars {
|
||||||
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
|
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},
|
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||||
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||||
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
{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 {
|
for _, item := range envInts {
|
||||||
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
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(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
||||||
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
||||||
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
|
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) {
|
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ const (
|
|||||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||||
SettingDataDir = "data_dir"
|
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
|
type SettingType string
|
||||||
@@ -95,6 +106,17 @@ type Config struct {
|
|||||||
BoxPollIntervalMS int
|
BoxPollIntervalMS int
|
||||||
ThumbnailBatchSize int
|
ThumbnailBatchSize int
|
||||||
ThumbnailIntervalSeconds 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
|
sources map[string]Source
|
||||||
values map[string]string
|
values map[string]string
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
|
|||||||
return fmt.Errorf("%s: %w", key, err)
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
}
|
}
|
||||||
cfg.assignInt(key, int(parsed64), SourceDB)
|
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||||
|
case SettingTypeText:
|
||||||
|
cfg.assignText(key, value, SourceDB)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("setting %q is not runtime editable", key)
|
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
|
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||||
case SettingSessionTTLSeconds:
|
case SettingSessionTTLSeconds:
|
||||||
cfg.SessionTTLSeconds = value
|
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)
|
cfg.setValue(key, formatGigabytesFromBytes(value), source)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,10 +122,26 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
|
|||||||
cfg.ThumbnailBatchSize = value
|
cfg.ThumbnailBatchSize = value
|
||||||
case SettingThumbnailIntervalSeconds:
|
case SettingThumbnailIntervalSeconds:
|
||||||
cfg.ThumbnailIntervalSeconds = value
|
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)
|
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) {
|
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ type Handlers struct {
|
|||||||
AdminBoxes gin.HandlerFunc
|
AdminBoxes gin.HandlerFunc
|
||||||
AdminBoxesAction gin.HandlerFunc
|
AdminBoxesAction gin.HandlerFunc
|
||||||
AdminUsers gin.HandlerFunc
|
AdminUsers gin.HandlerFunc
|
||||||
|
AdminActivity gin.HandlerFunc
|
||||||
|
AdminSecurity gin.HandlerFunc
|
||||||
|
AdminAlertsAction gin.HandlerFunc
|
||||||
|
AdminSecurityAction gin.HandlerFunc
|
||||||
AdminSettings gin.HandlerFunc
|
AdminSettings gin.HandlerFunc
|
||||||
AdminSettingsExport gin.HandlerFunc
|
AdminSettingsExport gin.HandlerFunc
|
||||||
AdminSettingsSave gin.HandlerFunc
|
AdminSettingsSave gin.HandlerFunc
|
||||||
@@ -62,9 +66,13 @@ func Register(router *gin.Engine, handlers Handlers) {
|
|||||||
protected := router.Group("/admin", handlers.AdminAuth)
|
protected := router.Group("/admin", handlers.AdminAuth)
|
||||||
protected.GET("/dashboard", handlers.AdminDashboard)
|
protected.GET("/dashboard", handlers.AdminDashboard)
|
||||||
protected.GET("/alerts", handlers.AdminAlerts)
|
protected.GET("/alerts", handlers.AdminAlerts)
|
||||||
|
protected.POST("/alerts/actions", handlers.AdminAlertsAction)
|
||||||
protected.GET("/boxes", handlers.AdminBoxes)
|
protected.GET("/boxes", handlers.AdminBoxes)
|
||||||
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||||
protected.GET("/users", handlers.AdminUsers)
|
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", handlers.AdminSettings)
|
||||||
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
||||||
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
||||||
|
|||||||
217
lib/security/guard.go
Normal file
217
lib/security/guard.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/alerts"
|
||||||
"warpbox/lib/config"
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
const adminSessionCookie = "warpbox_admin_session"
|
const adminSessionCookie = "warpbox_admin_session"
|
||||||
@@ -59,17 +62,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
|||||||
ctx.Redirect(http.StatusSeeOther, "/")
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
return
|
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"))
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
password := ctx.PostForm("password")
|
password := ctx.PostForm("password")
|
||||||
|
|
||||||
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
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{
|
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
|
||||||
"ErrorMessage": "Invalid username or password.",
|
"ErrorMessage": "Invalid username or password.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil)
|
||||||
secure := app.config.AdminCookieSecure
|
secure := app.config.AdminCookieSecure
|
||||||
maxAge := int(app.config.SessionTTLSeconds)
|
maxAge := int(app.config.SessionTTLSeconds)
|
||||||
|
|
||||||
@@ -108,9 +133,41 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
|
|||||||
return
|
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{
|
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
||||||
"AdminUsername": app.config.AdminUsername,
|
"AdminUsername": app.config.AdminUsername,
|
||||||
"AdminEmail": app.config.AdminEmail,
|
"AdminEmail": app.config.AdminEmail,
|
||||||
"ActivePage": "alerts",
|
"ActivePage": "alerts",
|
||||||
|
"Alerts": alertsList,
|
||||||
|
"OpenCount": strconv.Itoa(openCount),
|
||||||
|
"HighCount": strconv.Itoa(highCount),
|
||||||
|
"AckCount": strconv.Itoa(ackedCount),
|
||||||
|
"ClosedCount": strconv.Itoa(closedCount),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
258
lib/server/admin_security.go
Normal file
258
lib/server/admin_security.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -269,6 +269,7 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
|
|||||||
|
|
||||||
app.config = nextCfg
|
app.config = nextCfg
|
||||||
applyBoxstoreRuntimeConfig(app.config)
|
applyBoxstoreRuntimeConfig(app.config)
|
||||||
|
app.reloadSecurityConfig()
|
||||||
rows, _ := app.buildAdminSettingsRows()
|
rows, _ := app.buildAdminSettingsRows()
|
||||||
return rows, warnings, nil
|
return rows, warnings, nil
|
||||||
}
|
}
|
||||||
@@ -399,6 +400,8 @@ func settingsCategoryMeta() []settingsCategoryInfo {
|
|||||||
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||||
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||||
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||||
|
{Key: "security", Label: "Security", Icon: "🔒"},
|
||||||
|
{Key: "activity", Label: "Activity", Icon: "☰"},
|
||||||
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||||
{Key: "api", Label: "API", Icon: "{ }"},
|
{Key: "api", Label: "API", Icon: "{ }"},
|
||||||
{Key: "storage", Label: "Storage", Icon: "▥"},
|
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||||
@@ -428,10 +431,16 @@ func settingsCategoryForKey(key string) string {
|
|||||||
switch key {
|
switch key {
|
||||||
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||||
return "uploads"
|
return "uploads"
|
||||||
|
case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB:
|
||||||
|
return "uploads"
|
||||||
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||||
return "downloads"
|
return "downloads"
|
||||||
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||||
return "retention"
|
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:
|
case config.SettingSessionTTLSeconds:
|
||||||
return "accounts"
|
return "accounts"
|
||||||
case config.SettingAPIEnabled:
|
case config.SettingAPIEnabled:
|
||||||
@@ -466,6 +475,17 @@ func settingsDescription(key string) string {
|
|||||||
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||||
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||||
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
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]
|
return descriptions[key]
|
||||||
}
|
}
|
||||||
|
|||||||
89
lib/server/ip.go
Normal file
89
lib/server/ip.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -9,14 +9,20 @@ import (
|
|||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/activity"
|
||||||
|
"warpbox/lib/alerts"
|
||||||
"warpbox/lib/boxstore"
|
"warpbox/lib/boxstore"
|
||||||
"warpbox/lib/config"
|
"warpbox/lib/config"
|
||||||
"warpbox/lib/routing"
|
"warpbox/lib/routing"
|
||||||
|
"warpbox/lib/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
settingsOverridesPath string
|
settingsOverridesPath string
|
||||||
|
activityStore *activity.Store
|
||||||
|
alertStore *alerts.Store
|
||||||
|
securityGuard *security.Guard
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(addr string) error {
|
func Run(addr string) error {
|
||||||
@@ -38,9 +44,18 @@ func Run(addr string) error {
|
|||||||
|
|
||||||
applyBoxstoreRuntimeConfig(cfg)
|
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 := gin.Default()
|
||||||
|
router.Use(app.securityMiddleware())
|
||||||
|
router.NoRoute(app.handleNoRoute)
|
||||||
htmlTemplates, err := loadHTMLTemplates()
|
htmlTemplates, err := loadHTMLTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -71,6 +86,10 @@ func Run(addr string) error {
|
|||||||
AdminBoxes: app.handleAdminBoxes,
|
AdminBoxes: app.handleAdminBoxes,
|
||||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||||
AdminUsers: app.handleAdminUsers,
|
AdminUsers: app.handleAdminUsers,
|
||||||
|
AdminActivity: app.handleAdminActivity,
|
||||||
|
AdminSecurity: app.handleAdminSecurity,
|
||||||
|
AdminAlertsAction: app.handleAdminAlertsAction,
|
||||||
|
AdminSecurityAction: app.handleAdminSecurityAction,
|
||||||
AdminSettings: app.handleAdminSettings,
|
AdminSettings: app.handleAdminSettings,
|
||||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range request.Files {
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, totalSize) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
files, err := boxstore.CreateManifest(boxID, request)
|
files, err := boxstore.CreateManifest(boxID, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,6 +80,10 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, file.Size) {
|
||||||
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,6 +152,9 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, file.Size) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,6 +194,9 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, totalSize) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
boxID, err := boxstore.NewBoxID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -153,3 +154,36 @@ func (app *App) maxRequestBodyBytes() int64 {
|
|||||||
}
|
}
|
||||||
return limit + 10*1024*1024
|
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
11
run.sh
@@ -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_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
|
||||||
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
||||||
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
|
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.
|
# Data location.
|
||||||
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
||||||
|
|||||||
63
static/css/activity.css
Normal file
63
static/css/activity.css
Normal 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
115
static/css/security.css
Normal 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
104
static/js/admin/activity.js
Normal 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();
|
||||||
|
})();
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||||
close() {
|
const dataNode = document.getElementById("alerts-data");
|
||||||
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
const alertsBody = document.getElementById("alerts-body");
|
||||||
item.classList.remove("is-open");
|
|
||||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const toast = document.getElementById("toast");
|
|
||||||
const searchInput = document.getElementById("search-input");
|
const searchInput = document.getElementById("search-input");
|
||||||
const severityFilter = document.getElementById("severity-filter");
|
const severityFilter = document.getElementById("severity-filter");
|
||||||
const statusFilter = document.getElementById("status-filter");
|
const statusFilter = document.getElementById("status-filter");
|
||||||
const sourceFilter = document.getElementById("source-filter");
|
const sourceFilter = document.getElementById("source-filter");
|
||||||
const sortFilter = document.getElementById("sort-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 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 = {
|
const detailEls = {
|
||||||
title: document.getElementById("detail-title"),
|
title: document.getElementById("detail-title"),
|
||||||
@@ -32,185 +23,243 @@
|
|||||||
metadata: document.getElementById("detail-metadata")
|
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) {
|
function showToast(message, type = "info", duration = 1800) {
|
||||||
if (window.WarpBoxUI) {
|
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function allRows() {
|
function createdLabel(value) {
|
||||||
return Array.from(alertsBody.querySelectorAll("tr"));
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "-";
|
||||||
|
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleRows() {
|
function allAlerts() {
|
||||||
return allRows().filter((row) => row.style.display !== "none");
|
return state.alerts.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedRows() {
|
function filteredAlerts() {
|
||||||
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
const severity = severityFilter.value;
|
const severity = severityFilter.value;
|
||||||
const status = statusFilter.value;
|
const status = statusFilter.value;
|
||||||
const group = sourceFilter.value;
|
const group = sourceFilter.value;
|
||||||
|
const rows = allAlerts().filter((alert) => {
|
||||||
allRows().forEach((row) => {
|
|
||||||
const haystack = [
|
const haystack = [
|
||||||
row.dataset.title,
|
alert.title,
|
||||||
row.dataset.description,
|
alert.message,
|
||||||
row.dataset.code,
|
alert.code,
|
||||||
row.dataset.trace,
|
alert.trace,
|
||||||
row.dataset.group
|
alert.group
|
||||||
].join(" ").toLowerCase();
|
].join(" ").toLowerCase();
|
||||||
const matchesSearch = !search || haystack.includes(search);
|
const matchesSearch = !query || haystack.includes(query);
|
||||||
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
|
const matchesSeverity = severity === "all" || alert.severity === severity;
|
||||||
const matchesStatus = status === "all" || row.dataset.status === status;
|
const matchesStatus = status === "all" || alert.status === status;
|
||||||
const matchesGroup = group === "all" || row.dataset.group === group;
|
const matchesGroup = group === "all" || alert.group === group;
|
||||||
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
|
return matchesSearch && matchesSeverity && matchesStatus && matchesGroup;
|
||||||
});
|
});
|
||||||
|
|
||||||
const order = { high: 3, medium: 2, low: 1 };
|
const order = { high: 3, medium: 2, low: 1 };
|
||||||
visibleRows().sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
|
if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0);
|
||||||
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
|
if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at));
|
||||||
return Number(b.dataset.id) - Number(a.dataset.id);
|
return String(b.created_at).localeCompare(String(a.created_at));
|
||||||
}).forEach((row) => alertsBody.appendChild(row));
|
});
|
||||||
|
return rows;
|
||||||
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
|
|
||||||
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
|
|
||||||
updateSelectedCount();
|
|
||||||
updateSummaryCounts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRowStatus(row, nextStatus) {
|
function ensureActive(rows) {
|
||||||
row.dataset.status = nextStatus;
|
if (rows.length === 0) {
|
||||||
const statusCell = row.children[3]?.querySelector(".alerts-pill");
|
state.activeID = null;
|
||||||
if (!statusCell) return;
|
return null;
|
||||||
statusCell.className = `alerts-pill ${nextStatus}`;
|
}
|
||||||
statusCell.textContent = nextStatus;
|
const found = rows.find((item) => item.id === state.activeID);
|
||||||
|
if (found) return found;
|
||||||
|
state.activeID = rows[0].id;
|
||||||
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSelectedStatus(nextStatus) {
|
function render() {
|
||||||
const rows = selectedRows();
|
const rows = filteredAlerts();
|
||||||
if (!rows.length) {
|
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");
|
showToast("Select one or more alerts first", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "open-only") {
|
||||||
rows.forEach((row) => {
|
statusFilter.value = "open";
|
||||||
setRowStatus(row, nextStatus);
|
render();
|
||||||
row.querySelector(".row-check").checked = false;
|
showToast("Showing open alerts only");
|
||||||
});
|
return;
|
||||||
if (selectAll) selectAll.checked = false;
|
}
|
||||||
updateSelectedCount();
|
if (action === "refresh") {
|
||||||
updateSummaryCounts();
|
window.location.reload();
|
||||||
|
return;
|
||||||
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
|
}
|
||||||
if (currentRow) updateDetails(currentRow);
|
if (action === "copy-meta") {
|
||||||
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
|
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 = {
|
function escapeHtml(value) {
|
||||||
refresh: "Alerts refreshed in mock view",
|
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
||||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
|
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
selectAll?.addEventListener("change", () => {
|
selectAll?.addEventListener("change", () => {
|
||||||
visibleRows().forEach((row) => {
|
const rows = filteredAlerts();
|
||||||
const checkbox = row.querySelector(".row-check");
|
rows.forEach((alert) => {
|
||||||
if (checkbox) checkbox.checked = selectAll.checked;
|
if (selectAll.checked) state.selected.add(alert.id);
|
||||||
|
else state.selected.delete(alert.id);
|
||||||
});
|
});
|
||||||
updateSelectedCount();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", async () => {
|
||||||
menuController.close();
|
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 === "Escape") menuController.close();
|
||||||
if (event.key === "F5") {
|
if (event.key === "F5") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
runCommand("refresh");
|
await runAction("refresh");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
applyFilters();
|
render();
|
||||||
updateDetails(allRows()[0]);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
272
static/js/admin/security.js
Normal file
272
static/js/admin/security.js
Normal 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();
|
||||||
|
})();
|
||||||
92
templates/admin/activity.html
Normal file
92
templates/admin/activity.html
Normal 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 }}
|
||||||
@@ -61,22 +61,22 @@
|
|||||||
<section class="alerts-summary-grid" aria-label="Alerts summary">
|
<section class="alerts-summary-grid" aria-label="Alerts summary">
|
||||||
<article class="alerts-stat-card is-danger">
|
<article class="alerts-stat-card is-danger">
|
||||||
<p class="alerts-stat-label">Open alerts</p>
|
<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>
|
<p class="alerts-stat-note">Requires attention</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="alerts-stat-card is-warning">
|
<article class="alerts-stat-card is-warning">
|
||||||
<p class="alerts-stat-label">High severity</p>
|
<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>
|
<p class="alerts-stat-note">Escalate first</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="alerts-stat-card is-info">
|
<article class="alerts-stat-card is-info">
|
||||||
<p class="alerts-stat-label">Acknowledged</p>
|
<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>
|
<p class="alerts-stat-note">Seen but not closed</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="alerts-stat-card is-info">
|
<article class="alerts-stat-card is-info">
|
||||||
<p class="alerts-stat-label">Closed today</p>
|
<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>
|
<p class="alerts-stat-note">History stays lightweight</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -134,108 +134,7 @@
|
|||||||
<th class="alerts-col-actions">Actions</th>
|
<th class="alerts-col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="alerts-body">
|
<tbody id="alerts-body"></tbody>
|
||||||
<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>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,10 +186,11 @@
|
|||||||
<div class="alerts-action-stack">
|
<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="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="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>
|
<button class="win98-button alerts-action-button" type="button" data-command="refresh">Refresh alerts</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="alerts-mini-note">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -301,11 +201,12 @@
|
|||||||
<div class="alerts-footerbar">
|
<div class="alerts-footerbar">
|
||||||
<div class="alerts-footer-left">
|
<div class="alerts-footer-left">
|
||||||
<span class="alerts-status-pill" id="selected-count">Selected: 0</span>
|
<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>
|
||||||
<div class="alerts-footer-right">
|
<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="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="close">Close</button>
|
||||||
|
<button class="win98-button alerts-footer-button" type="button" data-command="delete">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +215,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
<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/warpbox-ui.js"></script>
|
||||||
<script src="/static/js/admin/alerts.js"></script>
|
<script src="/static/js/admin/alerts.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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 "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 "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 "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 "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>
|
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
||||||
|
|||||||
138
templates/admin/security.html
Normal file
138
templates/admin/security.html
Normal 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 }}
|
||||||
Reference in New Issue
Block a user