3 Commits

Author SHA1 Message Date
88ab6e808b feat(admin): add security and activity management features 2026-05-01 13:10:23 +03:00
dd8dd7cdc2 feat(versioning): Implemented APP_VERSION from build tags
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m35s
2026-05-01 03:45:15 +03:00
fc54f7bb86 fix(ci/cd): Naming fix
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
2026-05-01 03:41:32 +03:00
28 changed files with 2218 additions and 266 deletions

View File

@@ -28,8 +28,9 @@ jobs:
- name: Build Docker Image - name: Build Docker Image
run: | run: |
docker build \ docker build \
-t tea.chunkbyte.com/kato/WarpBox:${{ gitea.ref_name }} \ --build-arg APP_VERSION=${{ gitea.ref_name }} \
-t tea.chunkbyte.com/kato/WarpBox:latest \ -t tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }} \
-t tea.chunkbyte.com/kato/warpbox:latest \
. .
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
@@ -41,5 +42,5 @@ jobs:
- name: Push Docker Image - name: Push Docker Image
run: | run: |
docker push tea.chunkbyte.com/kato/WarpBox:${{ gitea.ref_name }} docker push tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }}
docker push tea.chunkbyte.com/kato/WarpBox:latest docker push tea.chunkbyte.com/kato/warpbox:latest

View File

@@ -1,6 +1,8 @@
# Stage 1: Build # Stage 1: Build
FROM golang:1.23-alpine AS builder FROM golang:1.23-alpine AS builder
ARG APP_VERSION=""
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
WORKDIR /build WORKDIR /build
@@ -21,6 +23,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
# Stage 2: Runtime # Stage 2: Runtime
FROM alpine:3.21 FROM alpine:3.21
ARG APP_VERSION=""
ENV APP_VERSION=${APP_VERSION}
RUN apk add \ RUN apk add \
--no-cache \ --no-cache \
ca-certificates \ ca-certificates \

114
TO-DO.md Normal file
View File

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

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

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

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

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

View File

@@ -20,6 +20,17 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000}, {Key: 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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

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

View File

@@ -2,11 +2,14 @@ package server
import ( 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),
}) })
} }

View File

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

View File

@@ -269,6 +269,7 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
app.config = nextCfg 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
View File

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

View File

@@ -9,14 +9,20 @@ import (
"github.com/gin-contrib/gzip" "github.com/gin-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,

View File

@@ -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 {

View File

@@ -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
View File

@@ -25,6 +25,17 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}" export WARPBOX_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
View File

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

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

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

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

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

View File

@@ -1,25 +1,16 @@
(() => { (() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { 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) => {
setRowStatus(row, nextStatus);
row.querySelector(".row-check").checked = false;
});
if (selectAll) selectAll.checked = false;
updateSelectedCount();
updateSummaryCounts();
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
if (currentRow) updateDetails(currentRow);
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
}
const commandMessages = {
refresh: "Alerts refreshed in mock view",
export: "Visible alerts exported in mock view",
"copy-meta": "Metadata copied in mock view",
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
"help-meta": "Metadata explains why the alert happened and includes extra context."
};
function runCommand(command) {
switch (command) {
case "ack":
changeSelectedStatus("acked");
return;
case "close":
changeSelectedStatus("closed");
return;
case "open-only":
statusFilter.value = "open"; statusFilter.value = "open";
applyFilters(); render();
showToast("Showing open alerts only"); showToast("Showing open alerts only");
return; return;
default:
showToast(commandMessages[command] || `Mock action: ${command}`);
} }
if (action === "refresh") {
window.location.reload();
return;
}
if (action === "copy-meta") {
const active = allAlerts().find((item) => item.id === state.activeID);
if (active) {
navigator.clipboard?.writeText(JSON.stringify(active.meta || {}, null, 2)).catch(() => {});
}
showToast("Metadata copied");
return;
}
if (action === "export") {
const blob = new Blob([JSON.stringify(filteredAlerts(), null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `warpbox-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`;
anchor.click();
URL.revokeObjectURL(url);
showToast("Visible alerts exported");
return;
}
if (action === "help-codes") {
showToast("Codes map to internal security and service traces.");
return;
}
if (action === "help-meta") {
showToast("Metadata shows extra context for each alert.");
return;
}
await postAction(action, ids);
state.selected.clear();
render();
showToast(`Action complete: ${action}`, "success");
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
} }
[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
View File

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

View File

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

View File

@@ -61,22 +61,22 @@
<section class="alerts-summary-grid" aria-label="Alerts summary"> <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>

View File

@@ -8,7 +8,9 @@
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a> <a class="admin-taskbar-button{{ if eq .ActivePage "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">

View File

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