feat/security
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-05-04 00:00:36 +03:00
parent dd8dd7cdc2
commit fbeff3f6c0
43 changed files with 3268 additions and 299 deletions

4
NOTICE
View File

@@ -1,4 +0,0 @@
WarpBox
Copyright (c) 2026 Daniel Legt
This product includes software developed by Daniel Legt.

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

View File

@@ -1,2 +0,0 @@
The name "WarpBox" and associated branding are not licensed under the Apache License 2.0.
You may not use them without permission.

19
docs/geoip-guide.md Normal file
View File

@@ -0,0 +1,19 @@
# GeoIP Guide (Planning)
This project intentionally does not enable GeoIP enforcement yet.
Planned integration target: `github.com/rabuchaim/geoip2fast`.
## Recommended approach
1. Load one shared GeoIP provider instance at startup.
2. Add a small in-memory cache keyed by IP with TTL.
3. Apply lookup timeout and fallback to `unknown` values on failures.
4. Use results first in the admin security detail pane.
5. Add aggregated statistics only after detail pane behavior is stable.
## Why this is safe
- No request path should fail because GeoIP lookup fails.
- Lookup cost stays bounded with caching.
- Security decisions remain independent from GeoIP quality.

40
docs/security-runbook.md Normal file
View File

@@ -0,0 +1,40 @@
# Security Runbook
## Trusted Proxy Setup (Caddy)
Set `WARPBOX_TRUSTED_PROXY_CIDRS` to only the CIDRs of your reverse proxies/load balancers.
Example:
```bash
WARPBOX_TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16
```
Caddy example:
```caddyfile
:443 {
reverse_proxy 127.0.0.1:8080 {
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host}
}
}
```
WarpBox will trust `X-Forwarded-For` only if the direct remote IP is inside `WARPBOX_TRUSTED_PROXY_CIDRS`.
## IP Ban Operations
- Use temporary bans by default.
- Use `ban_until` only for active incidents requiring explicit windows.
- Before unbanning, inspect related activity and alerts for repeated abuse patterns.
- For destructive actions (`bulk_unban`, `unban_all`), require explicit confirmation.
## Tuning Guidance
- Low traffic deployments: reduce max-attempt thresholds to catch abuse faster.
- High traffic deployments: increase windows and max-attempts incrementally to reduce false positives.
- Watch for:
- repeated `auth.admin.failed`
- repeated `security.scan`
- frequent `security.upload_limit`

26
go.mod
View File

@@ -3,43 +3,51 @@ module warpbox
go 1.23.0
require (
github.com/dgraph-io/badger/v4 v4.9.1
github.com/gin-contrib/gzip v1.0.1
github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.41.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

53
go.sum
View File

@@ -2,15 +2,24 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w=
github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
@@ -19,6 +28,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -29,6 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -36,15 +52,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -58,10 +73,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -86,21 +99,29 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

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)
}

60
lib/boxstore/cleanup.go Normal file
View File

@@ -0,0 +1,60 @@
package boxstore
import (
"fmt"
"os"
)
type CleanupExpiredResult struct {
Scanned int
Deleted int
Skipped int
DeletedIDs []string
Warnings []string
}
func CleanupExpiredBoxes() (CleanupExpiredResult, error) {
entries, err := os.ReadDir(uploadRoot)
if err != nil {
if os.IsNotExist(err) {
return CleanupExpiredResult{}, nil
}
return CleanupExpiredResult{}, err
}
result := CleanupExpiredResult{
DeletedIDs: make([]string, 0),
Warnings: make([]string, 0),
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
boxID := entry.Name()
if !ValidBoxID(boxID) {
continue
}
result.Scanned++
manifest, err := ReadManifest(boxID)
if err != nil {
result.Skipped++
if !os.IsNotExist(err) {
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err))
}
continue
}
if !IsExpired(manifest) {
continue
}
if err := DeleteBox(boxID); err != nil {
result.Skipped++
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err))
continue
}
result.Deleted++
result.DeletedIDs = append(result.DeletedIDs, boxID)
}
return result, nil
}

View File

@@ -0,0 +1,58 @@
package boxstore
import (
"os"
"path/filepath"
"testing"
"time"
"warpbox/lib/models"
)
func TestCleanupExpiredBoxesDeletesOnlyExpiredManifestBoxes(t *testing.T) {
root := filepath.Join(t.TempDir(), "uploads")
previousRoot := UploadRoot()
t.Cleanup(func() { SetUploadRoot(previousRoot) })
SetUploadRoot(root)
expiredID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
activeID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
legacyID := "cccccccccccccccccccccccccccccccc"
if err := os.MkdirAll(BoxPath(expiredID), 0755); err != nil {
t.Fatalf("mkdir expired: %v", err)
}
if err := os.MkdirAll(BoxPath(activeID), 0755); err != nil {
t.Fatalf("mkdir active: %v", err)
}
if err := os.MkdirAll(BoxPath(legacyID), 0755); err != nil {
t.Fatalf("mkdir legacy: %v", err)
}
if err := WriteManifest(expiredID, models.BoxManifest{CreatedAt: time.Now().UTC().Add(-2 * time.Hour), ExpiresAt: time.Now().UTC().Add(-time.Minute)}); err != nil {
t.Fatalf("write expired manifest: %v", err)
}
if err := WriteManifest(activeID, models.BoxManifest{CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour)}); err != nil {
t.Fatalf("write active manifest: %v", err)
}
result, err := CleanupExpiredBoxes()
if err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if result.Deleted != 1 {
t.Fatalf("expected 1 deleted box, got %d", result.Deleted)
}
if len(result.DeletedIDs) != 1 || result.DeletedIDs[0] != expiredID {
t.Fatalf("expected deleted id %s, got %#v", expiredID, result.DeletedIDs)
}
if _, err := os.Stat(BoxPath(expiredID)); !os.IsNotExist(err) {
t.Fatalf("expected expired box dir removed, stat err=%v", err)
}
if _, err := os.Stat(BoxPath(activeID)); err != nil {
t.Fatalf("expected active box to remain, stat err=%v", err)
}
if _, err := os.Stat(BoxPath(legacyID)); err != nil {
t.Fatalf("expected legacy box to remain, stat err=%v", err)
}
}

View File

@@ -22,6 +22,9 @@ func TestDefaults(t *testing.T) {
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
t.Fatal("expected default guest/API/download toggles to be enabled")
}
if !cfg.SecurityEnabled {
t.Fatal("expected security features to be enabled by default")
}
if cfg.AdminUsername != "admin" {
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
}
@@ -39,6 +42,7 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
t.Setenv("WARPBOX_SECURITY_ENABLED", "false")
cfg, err := Load()
if err != nil {
@@ -63,6 +67,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure {
t.Fatal("expected one-time retry-on-failure env override to be applied")
}
if cfg.SecurityEnabled {
t.Fatal("expected security features toggle from environment to be applied")
}
if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
}
@@ -191,6 +198,8 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_SECURITY_ENABLED",
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}

View File

@@ -20,6 +20,20 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingSecurityEnabled, EnvName: "WARPBOX_SECURITY_ENABLED", Label: "Security features enabled", Type: SettingTypeBool, Editable: true},
{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: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", 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},
{Key: SettingExpiredCleanupIntervalSecs, EnvName: "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", Label: "Expired boxes cleanup interval seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
}
func (cfg *Config) SettingRows() []SettingRow {

View File

@@ -26,6 +26,17 @@ func Load() (*Config, error) {
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
ActivityRetentionSeconds: 7 * 24 * 60 * 60,
SecurityEnabled: true,
SecurityLoginWindowSeconds: 10 * 60,
SecurityLoginMaxAttempts: 8,
SecurityBanSeconds: 30 * 60,
SecurityScanWindowSeconds: 5 * 60,
SecurityScanMaxAttempts: 12,
SecurityUploadWindowSeconds: 60,
SecurityUploadMaxRequests: 20,
SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024,
ExpiredCleanupIntervalSeconds: 300,
sources: make(map[string]Source),
values: make(map[string]string),
defaults: make(map[string]string),
@@ -47,6 +58,15 @@ func Load() (*Config, error) {
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingTrustedProxyCIDRs, "WARPBOX_TRUSTED_PROXY_CIDRS", &cfg.TrustedProxyCIDRs); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
@@ -73,6 +93,7 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
{SettingSecurityEnabled, "WARPBOX_SECURITY_ENABLED", &cfg.SecurityEnabled},
}
for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
@@ -90,6 +111,12 @@ func Load() (*Config, error) {
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
{SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds},
{SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds},
{SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds},
{SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds},
{SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds},
{SettingExpiredCleanupIntervalSecs, "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", 0, &cfg.ExpiredCleanupIntervalSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
@@ -107,6 +134,7 @@ func Load() (*Config, error) {
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
{SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
@@ -123,6 +151,9 @@ func Load() (*Config, error) {
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts},
{SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts},
{SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
@@ -138,6 +169,15 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
}
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
if err := validateSecurityTextSetting(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist); err != nil {
return nil, err
}
if err := validateSecurityTextSetting(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist); err != nil {
return nil, err
}
if err := validateSecurityTextSetting(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs); err != nil {
return nil, err
}
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
@@ -172,6 +212,20 @@ func (cfg *Config) captureDefaults() {
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
cfg.captureDefaultValue(SettingSecurityEnabled, formatBool(cfg.SecurityEnabled))
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs)
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))
cfg.captureDefaultValue(SettingExpiredCleanupIntervalSecs, strconv.FormatInt(cfg.ExpiredCleanupIntervalSeconds, 10))
}
func (cfg *Config) captureDefaultValue(key string, value string) {

View File

@@ -36,6 +36,20 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
SettingActivityRetentionSeconds = "activity_retention_seconds"
SettingSecurityEnabled = "security_enabled"
SettingSecurityIPWhitelist = "security_ip_whitelist"
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
SettingTrustedProxyCIDRs = "trusted_proxy_cidrs"
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"
SettingExpiredCleanupIntervalSecs = "expired_cleanup_interval_seconds"
)
type SettingType string
@@ -95,6 +109,20 @@ type Config struct {
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
ActivityRetentionSeconds int64
SecurityEnabled bool
SecurityIPWhitelist string
SecurityAdminIPWhitelist string
TrustedProxyCIDRs string
SecurityLoginWindowSeconds int64
SecurityLoginMaxAttempts int
SecurityBanSeconds int64
SecurityScanWindowSeconds int64
SecurityScanMaxAttempts int
SecurityUploadWindowSeconds int64
SecurityUploadMaxRequests int
SecurityUploadMaxBytes int64
ExpiredCleanupIntervalSeconds int64
sources map[string]Source
values map[string]string

View File

@@ -3,6 +3,9 @@ package config
import (
"fmt"
"strconv"
"strings"
"warpbox/lib/security"
)
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
@@ -26,6 +29,11 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
}
value = strings.TrimSpace(value)
if err := validateSecurityTextSetting(key, value); err != nil {
return err
}
switch def.Type {
case SettingTypeBool:
parsed, err := parseBool(value)
@@ -51,11 +59,28 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
case SettingTypeText:
cfg.assignText(key, value, SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
return nil
}
func validateSecurityTextSetting(key string, value string) error {
switch key {
case SettingSecurityIPWhitelist, SettingSecurityAdminIPWhitelist:
if _, err := security.ParseIPMatchers(value, true); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
case SettingTrustedProxyCIDRs:
if _, err := security.ParseCIDRList(value); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
}
return nil
}
func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key {
case SettingGuestUploadsEnabled:
@@ -70,6 +95,8 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
case SettingSecurityEnabled:
cfg.SecurityEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
@@ -92,8 +119,22 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
case SettingActivityRetentionSeconds:
cfg.ActivityRetentionSeconds = value
case SettingSecurityLoginWindowSecs:
cfg.SecurityLoginWindowSeconds = value
case SettingSecurityBanSeconds:
cfg.SecurityBanSeconds = value
case SettingSecurityScanWindowSecs:
cfg.SecurityScanWindowSeconds = value
case SettingSecurityUploadWindowSecs:
cfg.SecurityUploadWindowSeconds = value
case SettingSecurityUploadMaxGB:
cfg.SecurityUploadMaxBytes = value
case SettingExpiredCleanupIntervalSecs:
cfg.ExpiredCleanupIntervalSeconds = value
}
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes || key == SettingSecurityUploadMaxGB {
cfg.setValue(key, formatGigabytesFromBytes(value), source)
return
}
@@ -108,10 +149,28 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
case SettingSecurityLoginMaxAttempts:
cfg.SecurityLoginMaxAttempts = value
case SettingSecurityScanMaxAttempts:
cfg.SecurityScanMaxAttempts = value
case SettingSecurityUploadMaxRequests:
cfg.SecurityUploadMaxRequests = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}
func (cfg *Config) assignText(key string, value string, source Source) {
switch key {
case SettingSecurityIPWhitelist:
cfg.SecurityIPWhitelist = value
case SettingSecurityAdminIPWhitelist:
cfg.SecurityAdminIPWhitelist = value
case SettingTrustedProxyCIDRs:
cfg.TrustedProxyCIDRs = value
}
cfg.setValue(key, value, source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return

View File

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

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

@@ -0,0 +1,426 @@
package security
import (
"encoding/binary"
"fmt"
"net"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/dgraph-io/badger/v4"
)
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 []ipMatcher
adminWhitelist []ipMatcher
banDB *badger.DB
}
type ipMatcher struct {
exact net.IP
prefix *net.IPNet
}
type uploadEvent struct {
at time.Time
bytes int64
}
type BanEntry struct {
IP string `json:"ip"`
Until time.Time `json:"until"`
}
const banKeyPrefix = "ban:"
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: []ipMatcher{},
adminWhitelist: []ipMatcher{},
}
}
func (g *Guard) Close() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.banDB == nil {
return nil
}
err := g.banDB.Close()
g.banDB = nil
return err
}
func (g *Guard) EnableBanPersistence(path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
if g.banDB != nil {
return nil
}
opts := badger.DefaultOptions(path)
opts.Logger = nil
db, err := badger.Open(opts)
if err != nil {
// Corruption-safe fallback: quarantine badger files and start fresh.
_ = os.Rename(path, path+".corrupt."+time.Now().UTC().Format("20060102T150405"))
db, err = badger.Open(opts)
}
if err != nil {
return err
}
g.banDB = db
if err := g.loadBansLocked(); err != nil {
_ = g.banDB.Close()
g.banDB = nil
return err
}
return nil
}
func (g *Guard) Reload(cfg Config) error {
ipWhitelist, err := ParseIPMatchers(cfg.IPWhitelist, true)
if err != nil {
return fmt.Errorf("ip whitelist: %w", err)
}
adminWhitelist, err := ParseIPMatchers(cfg.AdminIPWhitelist, true)
if err != nil {
return fmt.Errorf("admin ip whitelist: %w", err)
}
g.mu.Lock()
defer g.mu.Unlock()
g.ipWhitelist = ipWhitelist
g.adminWhitelist = adminWhitelist
return nil
}
func (g *Guard) IsWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return matchIP(g.ipWhitelist, ip)
}
func (g *Guard) IsAdminWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return matchIP(g.adminWhitelist, ip) || matchIP(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)
g.deleteBanLocked(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()
until := time.Now().UTC().Add(time.Duration(seconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
}
func (g *Guard) BanUntil(ip string, until time.Time) {
if ip == "" || until.IsZero() {
return
}
g.mu.Lock()
defer g.mu.Unlock()
until = until.UTC()
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
}
func (g *Guard) Unban(ip string) {
if ip == "" {
return
}
g.mu.Lock()
defer g.mu.Unlock()
delete(g.bannedUntil, ip)
g.deleteBanLocked(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)
g.deleteBanLocked(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 {
until := now.Add(time.Duration(banSeconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
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 {
until := now.Add(time.Duration(banSeconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
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 ParseIPMatchers(raw string, allowCIDR bool) ([]ipMatcher, error) {
entries := []ipMatcher{}
for _, chunk := range strings.Split(raw, ",") {
value := strings.TrimSpace(chunk)
if value == "" {
continue
}
if strings.Contains(value, "/") {
if !allowCIDR {
return nil, fmt.Errorf("%q must be a CIDR", value)
}
_, network, err := net.ParseCIDR(value)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q", value)
}
entries = append(entries, ipMatcher{prefix: network})
continue
}
parsed := net.ParseIP(value)
if parsed == nil {
return nil, fmt.Errorf("invalid IP %q", value)
}
entries = append(entries, ipMatcher{exact: parsed})
}
return entries, nil
}
func ParseCIDRList(raw string) ([]net.IPNet, error) {
entries := []net.IPNet{}
for _, chunk := range strings.Split(raw, ",") {
value := strings.TrimSpace(chunk)
if value == "" {
continue
}
_, network, err := net.ParseCIDR(value)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q", value)
}
entries = append(entries, *network)
}
return entries, nil
}
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 matchIP(rules []ipMatcher, value string) bool {
ip := net.ParseIP(strings.TrimSpace(value))
if ip == nil {
return false
}
for _, rule := range rules {
if rule.exact != nil && rule.exact.Equal(ip) {
return true
}
if rule.prefix != nil && rule.prefix.Contains(ip) {
return true
}
}
return false
}
func (g *Guard) saveBanLocked(ip string, until time.Time) {
if g.banDB == nil || ip == "" || until.IsZero() {
return
}
seconds := int64(time.Until(until).Seconds())
if seconds <= 0 {
_ = g.banDB.Update(func(txn *badger.Txn) error {
return txn.Delete([]byte(banKeyPrefix + ip))
})
return
}
value := make([]byte, 8)
binary.BigEndian.PutUint64(value, uint64(until.Unix()))
_ = g.banDB.Update(func(txn *badger.Txn) error {
entry := badger.NewEntry([]byte(banKeyPrefix+ip), value).WithTTL(time.Duration(seconds) * time.Second)
return txn.SetEntry(entry)
})
}
func (g *Guard) deleteBanLocked(ip string) {
if g.banDB == nil || ip == "" {
return
}
_ = g.banDB.Update(func(txn *badger.Txn) error {
return txn.Delete([]byte(banKeyPrefix + ip))
})
}
func (g *Guard) loadBansLocked() error {
if g.banDB == nil {
return nil
}
now := time.Now().UTC()
loaded := map[string]time.Time{}
expired := [][]byte{}
err := g.banDB.View(func(txn *badger.Txn) error {
it := txn.NewIterator(badger.DefaultIteratorOptions)
defer it.Close()
for it.Seek([]byte(banKeyPrefix)); it.ValidForPrefix([]byte(banKeyPrefix)); it.Next() {
item := it.Item()
key := string(item.Key())
ip := strings.TrimPrefix(key, banKeyPrefix)
err := item.Value(func(val []byte) error {
if len(val) != 8 {
expired = append(expired, append([]byte(nil), item.Key()...))
return nil
}
unix := int64(binary.BigEndian.Uint64(val))
until := time.Unix(unix, 0).UTC()
if now.After(until) {
expired = append(expired, append([]byte(nil), item.Key()...))
return nil
}
loaded[ip] = until
return nil
})
if err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
g.bannedUntil = loaded
if len(expired) == 0 {
return nil
}
return g.banDB.Update(func(txn *badger.Txn) error {
for _, key := range expired {
if err := txn.Delete(key); err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,52 @@
package security
import (
"path/filepath"
"testing"
"time"
)
func TestGuardWhitelistSupportsIPAndCIDR(t *testing.T) {
g := NewGuard()
if err := g.Reload(Config{IPWhitelist: "203.0.113.10,10.0.0.0/8", AdminIPWhitelist: "192.168.1.0/24"}); err != nil {
t.Fatalf("Reload returned error: %v", err)
}
if !g.IsWhitelisted("203.0.113.10") || !g.IsWhitelisted("10.2.3.4") {
t.Fatal("expected IP and CIDR entries to match")
}
if !g.IsAdminWhitelisted("192.168.1.5") {
t.Fatal("expected admin CIDR whitelist match")
}
}
func TestGuardBanPersistenceAcrossRestart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "bans.badger")
g1 := NewGuard()
if err := g1.EnableBanPersistence(dir); err != nil {
t.Fatalf("EnableBanPersistence returned error: %v", err)
}
g1.Ban("198.51.100.4", 3600)
if err := g1.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
g2 := NewGuard()
if err := g2.EnableBanPersistence(dir); err != nil {
t.Fatalf("EnableBanPersistence returned error: %v", err)
}
defer g2.Close()
if !g2.IsBanned("198.51.100.4") {
t.Fatal("expected ban to persist across guard restart")
}
}
func TestGuardBanListPrunesExpired(t *testing.T) {
g := NewGuard()
g.BanUntil("198.51.100.7", time.Now().UTC().Add(-time.Minute))
if g.IsBanned("198.51.100.7") {
t.Fatal("expected expired ban to be treated as inactive")
}
if len(g.BanList()) != 0 {
t.Fatal("expected BanList to prune expired entries")
}
}

View File

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

View File

@@ -84,22 +84,41 @@ func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
return
}
if len(request.BoxIDs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
return
}
switch request.Action {
case "delete", "expire", "bump":
case "delete", "expire", "bump", "cleanup_expired":
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
return
}
if request.Action != "cleanup_expired" && len(request.BoxIDs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
return
}
if request.Action == "bump" && request.DeltaSeconds <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
return
}
if request.Action == "cleanup_expired" {
result, err := app.runExpiredCleanup("admin")
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Expired cleanup job failed"})
return
}
boxes, listErr := app.listAdminBoxes()
if listErr != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Cleanup finished, but boxes could not be reloaded"})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": len(result.Warnings) == 0,
"message": fmt.Sprintf("Expired cleanup done: deleted %d box(es), skipped %d", result.Deleted, result.Skipped),
"warnings": result.Warnings,
"boxes": boxes,
})
return
}
processed := 0
warnings := make([]string, 0)
@@ -299,6 +318,8 @@ func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) s
return fmt.Sprintf("Expired %d box(es)", processed)
case "bump":
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
case "cleanup_expired":
return fmt.Sprintf("Expired cleanup processed %d box(es)", processed)
default:
return "Action complete"
}

View File

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

View File

@@ -0,0 +1,125 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/config"
"warpbox/lib/security"
)
func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) {
app, router := setupAdminSecurityTest(t)
for _, body := range []string{
`{"action":"ban","ip":"203.0.113.7"}`,
`{"action":"unban","ip":"203.0.113.7"}`,
} {
request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", response.Code, response.Body.String())
}
}
events, err := app.activityStore.List(100, app.config.ActivityRetentionSeconds)
if err != nil {
t.Fatalf("activity list error: %v", err)
}
if len(events) < 2 {
t.Fatalf("expected activity events, got %d", len(events))
}
alertsList, err := app.alertStore.List(100)
if err != nil {
t.Fatalf("alerts list error: %v", err)
}
if len(alertsList) < 2 {
t.Fatalf("expected alerts for manual actions, got %d", len(alertsList))
}
}
func TestAdminSecurityBulkUnbanAndUnbanAll(t *testing.T) {
app, router := setupAdminSecurityTest(t)
app.securityGuard.Ban("203.0.113.8", 300)
app.securityGuard.Ban("203.0.113.9", 300)
request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"bulk_unban","ips":["203.0.113.8"]}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("bulk_unban expected 200, got %d", response.Code)
}
if app.securityGuard.IsBanned("203.0.113.8") {
t.Fatal("expected selected IP to be unbanned")
}
if !app.securityGuard.IsBanned("203.0.113.9") {
t.Fatal("expected non-selected IP to remain banned")
}
requestAll := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"unban_all"}`))
requestAll.Header.Set("Content-Type", "application/json")
requestAll.AddCookie(authCookie(app))
responseAll := httptest.NewRecorder()
router.ServeHTTP(responseAll, requestAll)
if responseAll.Code != http.StatusOK {
t.Fatalf("unban_all expected 200, got %d", responseAll.Code)
}
if len(app.securityGuard.BanList()) != 0 {
t.Fatal("expected all bans to be removed")
}
}
func setupAdminSecurityTest(t *testing.T) (*App, *gin.Engine) {
t.Helper()
gin.SetMode(gin.TestMode)
cwd, _ := os.Getwd()
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
clearAdminSettingsEnv(t)
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
cfg, err := config.Load()
if err != nil {
t.Fatalf("config load: %v", err)
}
if err := cfg.EnsureDirectories(); err != nil {
t.Fatalf("ensure dirs: %v", err)
}
app := &App{
config: cfg,
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity.json")),
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
}
if err := app.reloadSecurityConfig(); err != nil {
t.Fatalf("reload security config: %v", err)
}
t.Cleanup(func() { _ = app.securityGuard.Close() })
router := gin.New()
admin := router.Group("/admin")
admin.GET("/login", app.handleAdminLogin)
protected := router.Group("/admin", app.adminAuthMiddleware)
protected.POST("/security/actions", app.handleAdminSecurityAction)
return app, router
}

View File

@@ -269,6 +269,9 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
if err := app.reloadSecurityConfig(); err != nil {
return nil, nil, err
}
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
@@ -399,6 +402,8 @@ func settingsCategoryMeta() []settingsCategoryInfo {
{Key: "uploads", Label: "Uploads", Icon: "↥"},
{Key: "downloads", Label: "Downloads", Icon: "↧"},
{Key: "retention", Label: "Retention", Icon: "⌛"},
{Key: "security", Label: "Security", Icon: "🔒"},
{Key: "activity", Label: "Activity", Icon: "☰"},
{Key: "accounts", Label: "Accounts", Icon: "☺"},
{Key: "api", Label: "API", Icon: "{ }"},
{Key: "storage", Label: "Storage", Icon: "▥"},
@@ -428,10 +433,16 @@ func settingsCategoryForKey(key string) string {
switch key {
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
return "uploads"
case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB:
return "uploads"
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
return "downloads"
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
return "retention"
case config.SettingSecurityEnabled, config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts:
return "security"
case config.SettingActivityRetentionSeconds:
return "activity"
case config.SettingSessionTTLSeconds:
return "accounts"
case config.SettingAPIEnabled:
@@ -440,6 +451,8 @@ func settingsCategoryForKey(key string) string {
return "storage"
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
return "workers"
case config.SettingExpiredCleanupIntervalSecs:
return "workers"
default:
return "accounts"
}
@@ -466,6 +479,19 @@ func settingsDescription(key string) string {
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.",
config.SettingSecurityEnabled: "Master switch for security middleware, automated bans, suspicious path detection, and upload throttling.",
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.",
config.SettingExpiredCleanupIntervalSecs: "Background interval for deleting expired boxes. Set 0 to disable periodic cleanup.",
}
return descriptions[key]
}

View File

@@ -265,7 +265,36 @@ func clearAdminSettingsEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_SECURITY_ENABLED",
"WARPBOX_SECURITY_IP_WHITELIST",
"WARPBOX_SECURITY_ADMIN_IP_WHITELIST",
"WARPBOX_TRUSTED_PROXY_CIDRS",
"WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS",
"WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS",
"WARPBOX_SECURITY_BAN_SECONDS",
"WARPBOX_SECURITY_SCAN_WINDOW_SECONDS",
"WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS",
"WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS",
"WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS",
"WARPBOX_SECURITY_UPLOAD_MAX_GB",
"WARPBOX_SECURITY_UPLOAD_MAX_MB",
"WARPBOX_SECURITY_UPLOAD_MAX_BYTES",
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}
func TestAdminSettingsSaveRejectsInvalidTrustedProxyCIDR(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"trusted_proxy_cidrs":"not-a-cidr"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
}
}

62
lib/server/cleanup.go Normal file
View File

@@ -0,0 +1,62 @@
package server
import (
"log"
"strings"
"time"
"warpbox/lib/boxstore"
)
func (app *App) runExpiredCleanup(trigger string) (boxstore.CleanupExpiredResult, error) {
result, err := boxstore.CleanupExpiredBoxes()
if err != nil {
log.Printf("warpbox cleanup[%s] failed: %v", trigger, err)
app.logActivity("boxes.cleanup.failed", "high", "Expired boxes cleanup failed", nil, map[string]string{
"trigger": trigger,
"error": err.Error(),
})
return result, err
}
meta := map[string]string{
"trigger": trigger,
"scanned": intToString(result.Scanned),
"deleted": intToString(result.Deleted),
"skipped": intToString(result.Skipped),
}
if len(result.DeletedIDs) > 0 {
limit := len(result.DeletedIDs)
if limit > 20 {
limit = 20
}
meta["deleted_ids"] = strings.Join(result.DeletedIDs[:limit], ",")
}
if len(result.Warnings) > 0 {
limit := len(result.Warnings)
if limit > 3 {
limit = 3
}
meta["warnings"] = strings.Join(result.Warnings[:limit], " | ")
}
app.logActivity("boxes.cleanup", "medium", "Expired boxes cleanup run completed", nil, meta)
log.Printf("warpbox cleanup[%s] scanned=%d deleted=%d skipped=%d", trigger, result.Scanned, result.Deleted, result.Skipped)
return result, nil
}
func (app *App) startExpiredCleanupWorker() {
if app == nil || app.config == nil {
return
}
go func() {
for {
interval := app.config.ExpiredCleanupIntervalSeconds
if interval <= 0 {
time.Sleep(30 * time.Second)
continue
}
time.Sleep(time.Duration(interval) * time.Second)
_, _ = app.runExpiredCleanup("worker")
}
}()
}

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

@@ -0,0 +1,107 @@
package server
import (
"net"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/security"
)
func (app *App) clientIP(ctx *gin.Context) string {
if ctx == nil || ctx.Request == nil {
return ""
}
remoteIP := remoteAddrIP(ctx.Request)
trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs)
if err != nil {
return remoteIP
}
if !remoteIsTrusted(remoteIP, trusted) {
return 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 remoteIsTrusted(remoteIP string, trusted []net.IPNet) bool {
ip := net.ParseIP(strings.TrimSpace(remoteIP))
if ip == nil {
return false
}
for _, prefix := range trusted {
if prefix.Contains(ip) {
return true
}
}
return false
}
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()
}

44
lib/server/ip_test.go Normal file
View File

@@ -0,0 +1,44 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
)
func TestClientIPDirectClient(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "198.51.100.10:1234"
ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.4")
if got := app.clientIP(ctx); got != "198.51.100.10" {
t.Fatalf("expected direct remote IP, got %q", got)
}
}
func TestClientIPTrustedProxyChain(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "10.1.2.3:8080"
ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.44, 10.0.0.5")
if got := app.clientIP(ctx); got != "203.0.113.44" {
t.Fatalf("expected forwarded public client IP, got %q", got)
}
}
func TestClientIPSpoofedHeaderFromUntrustedRemote(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "203.0.113.200:8080"
ctx.Request.Header.Set("X-Forwarded-For", "198.51.100.55")
if got := app.clientIP(ctx); got != "203.0.113.200" {
t.Fatalf("expected untrusted remote IP, got %q", got)
}
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -153,3 +154,39 @@ func (app *App) maxRequestBodyBytes() int64 {
}
return limit + 10*1024*1024
}
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
if !app.securityFeaturesEnabled() || app.securityGuard == nil {
return true
}
ip := app.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
}

13
run.sh
View File

@@ -25,6 +25,19 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}"
export WARPBOX_SECURITY_ENABLED="${WARPBOX_SECURITY_ENABLED:-true}"
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}"
export WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS="${WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS:-300}"
# Data location.
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"

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

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

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

@@ -0,0 +1,100 @@
.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-danger { color: #7a0000; }
.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-table-toolbar { display: grid; grid-template-columns: 1fr 180px; gap: 8px; margin-bottom: 8px; }
.security-bans-wrap { height: 260px; min-height: 260px; }
.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; }
.security-docs h4 { margin: 4px 0; font-size: 13px; }
.security-docs p { margin: 0 0 8px; font-size: 12px; line-height: 1.4; }
.security-docs pre {
margin: 0 0 10px;
padding: 8px;
background: #f2f2f2;
border: 1px solid #c0c0c0;
overflow: auto;
font-size: 11px;
}
@media (max-width: 980px) {
.security-grid,
.security-ban-grid,
.security-table-toolbar {
grid-template-columns: 1fr;
}
}

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

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

View File

@@ -1,25 +1,16 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toast = document.getElementById("toast");
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const dataNode = document.getElementById("alerts-data");
const alertsBody = document.getElementById("alerts-body");
const searchInput = document.getElementById("search-input");
const severityFilter = document.getElementById("severity-filter");
const statusFilter = document.getElementById("status-filter");
const sourceFilter = document.getElementById("source-filter");
const sortFilter = document.getElementById("sort-filter");
const alertsBody = document.getElementById("alerts-body");
const selectedCountEl = document.getElementById("selected-count");
const openCountEl = document.querySelector("[data-open-count]");
const highCountEl = document.querySelector("[data-high-count]");
const ackCountEl = document.querySelector("[data-ack-count]");
const closedCountEl = document.querySelector("[data-closed-count]");
const selectAll = document.getElementById("select-all");
const selectedCountEl = document.getElementById("selected-count");
const totalPill = document.getElementById("alerts-total-pill");
const toast = document.getElementById("toast");
const detailEls = {
title: document.getElementById("detail-title"),
@@ -32,185 +23,245 @@
metadata: document.getElementById("detail-metadata")
};
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
if (!dataNode || !alertsBody) return;
const state = {
alerts: parseData(),
selected: new Set(),
activeID: null
};
const initialQuery = new URLSearchParams(window.location.search).get("q");
if (initialQuery) searchInput.value = initialQuery;
function parseData() {
try {
return JSON.parse(dataNode.textContent || "[]");
} catch (_) {
return [];
}
}
function showToast(message, type = "info", duration = 1800) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast, duration });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
}
function allRows() {
return Array.from(alertsBody.querySelectorAll("tr"));
function createdLabel(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
function visibleRows() {
return allRows().filter((row) => row.style.display !== "none");
function allAlerts() {
return state.alerts.slice();
}
function selectedRows() {
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
}
function updateSelectedCount() {
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
}
function updateSummaryCounts() {
const rows = visibleRows();
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
}
function updateDetails(row) {
if (!row) return;
allRows().forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
detailEls.title.textContent = row.dataset.title || "";
detailEls.severity.textContent = row.dataset.severity || "";
detailEls.status.textContent = row.dataset.status || "";
detailEls.code.textContent = row.dataset.code || "";
detailEls.trace.textContent = row.dataset.trace || "";
detailEls.time.textContent = row.dataset.time || "";
detailEls.description.textContent = row.dataset.description || "";
try {
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
} catch (_) {
detailEls.metadata.textContent = row.dataset.metadata || "{}";
}
}
function applyFilters() {
const search = searchInput.value.trim().toLowerCase();
function filteredAlerts() {
const query = searchInput.value.trim().toLowerCase();
const severity = severityFilter.value;
const status = statusFilter.value;
const group = sourceFilter.value;
allRows().forEach((row) => {
const rows = allAlerts().filter((alert) => {
const haystack = [
row.dataset.title,
row.dataset.description,
row.dataset.code,
row.dataset.trace,
row.dataset.group
alert.title,
alert.message,
alert.code,
alert.trace,
alert.group
].join(" ").toLowerCase();
const matchesSearch = !search || haystack.includes(search);
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
const matchesStatus = status === "all" || row.dataset.status === status;
const matchesGroup = group === "all" || row.dataset.group === group;
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
const matchesSearch = !query || haystack.includes(query);
const matchesSeverity = severity === "all" || alert.severity === severity;
const matchesStatus = status === "all" || alert.status === status;
const matchesGroup = group === "all" || alert.group === group;
return matchesSearch && matchesSeverity && matchesStatus && matchesGroup;
});
const order = { high: 3, medium: 2, low: 1 };
visibleRows().sort((a, b) => {
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
return Number(b.dataset.id) - Number(a.dataset.id);
}).forEach((row) => alertsBody.appendChild(row));
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
updateSelectedCount();
updateSummaryCounts();
rows.sort((a, b) => {
if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0);
if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at));
return String(b.created_at).localeCompare(String(a.created_at));
});
return rows;
}
function setRowStatus(row, nextStatus) {
row.dataset.status = nextStatus;
const statusCell = row.children[3]?.querySelector(".alerts-pill");
if (!statusCell) return;
statusCell.className = `alerts-pill ${nextStatus}`;
statusCell.textContent = nextStatus;
function ensureActive(rows) {
if (rows.length === 0) {
state.activeID = null;
return null;
}
const found = rows.find((item) => item.id === state.activeID);
if (found) return found;
state.activeID = rows[0].id;
return rows[0];
}
function changeSelectedStatus(nextStatus) {
const rows = selectedRows();
if (!rows.length) {
function render() {
const rows = filteredAlerts();
alertsBody.innerHTML = "";
rows.forEach((alert) => alertsBody.appendChild(buildRow(alert)));
const active = ensureActive(rows);
if (active) renderDetails(active);
renderSummary(rows);
syncSelected();
syncSelectAll(rows);
}
function buildRow(alert) {
const row = document.createElement("tr");
if (state.activeID === alert.id) row.classList.add("is-selected");
row.innerHTML = `
<td><input type="checkbox" class="row-check"${state.selected.has(alert.id) ? " checked" : ""}></td>
<td>${escapeHtml(alert.title || "-")}</td>
<td><span class="alerts-pill ${escapeHtml(alert.severity || "low")}">${escapeHtml(alert.severity || "low")}</span></td>
<td><span class="alerts-pill ${escapeHtml(alert.status || "open")}">${escapeHtml(alert.status || "open")}</span></td>
<td>${escapeHtml(alert.code || "-")}</td>
<td>${escapeHtml(alert.trace || "-")}</td>
<td>${createdLabel(alert.created_at)}</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
`;
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
state.activeID = alert.id;
render();
});
row.querySelector(".row-open")?.addEventListener("click", () => {
state.activeID = alert.id;
render();
});
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(alert.id);
else state.selected.delete(alert.id);
syncSelected();
syncSelectAll(filteredAlerts());
});
return row;
}
function renderDetails(alert) {
detailEls.title.textContent = alert.title || "";
detailEls.severity.textContent = alert.severity || "";
detailEls.status.textContent = alert.status || "";
detailEls.code.textContent = alert.code || "";
detailEls.trace.textContent = alert.trace || "";
detailEls.time.textContent = createdLabel(alert.created_at);
detailEls.description.textContent = alert.message || "";
detailEls.metadata.textContent = JSON.stringify(alert.meta || {}, null, 2);
}
function renderSummary(rows) {
const open = rows.filter((item) => item.status === "open").length;
const high = rows.filter((item) => item.severity === "high" && item.status !== "closed").length;
const ack = rows.filter((item) => item.status === "acked").length;
const closed = rows.filter((item) => item.status === "closed").length;
document.querySelector("[data-open-count]").textContent = String(open);
document.querySelector("[data-high-count]").textContent = String(high);
document.querySelector("[data-ack-count]").textContent = String(ack);
document.querySelector("[data-closed-count]").textContent = String(closed);
totalPill.textContent = `${rows.length} alerts`;
}
function syncSelected() {
selectedCountEl.textContent = `Selected: ${state.selected.size}`;
}
function syncSelectAll(rows) {
selectAll.checked = rows.length > 0 && rows.every((alert) => state.selected.has(alert.id));
}
async function postAction(action, ids) {
const response = await fetch("/admin/alerts/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ids })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "Request failed");
state.alerts = payload.alerts || [];
}
async function runAction(action) {
const ids = Array.from(state.selected);
if (!ids.length && (action === "ack" || action === "close" || action === "delete")) {
showToast("Select one or more alerts first", "warning");
return;
}
rows.forEach((row) => {
setRowStatus(row, nextStatus);
row.querySelector(".row-check").checked = false;
});
if (selectAll) selectAll.checked = false;
updateSelectedCount();
updateSummaryCounts();
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
if (currentRow) updateDetails(currentRow);
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
}
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":
if (action === "open-only") {
statusFilter.value = "open";
applyFilters();
render();
showToast("Showing open alerts only");
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) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
});
allRows().forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
updateDetails(row);
});
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
});
selectAll?.addEventListener("change", () => {
visibleRows().forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (checkbox) checkbox.checked = selectAll.checked;
const rows = filteredAlerts();
rows.forEach((alert) => {
if (selectAll.checked) state.selected.add(alert.id);
else state.selected.delete(alert.id);
});
updateSelectedCount();
render();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
button.addEventListener("click", async () => {
menuController.close();
runCommand(button.dataset.command);
try {
await runAction(button.dataset.command);
} catch (error) {
showToast(error.message, "error", 3200);
}
});
});
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
await runAction("refresh");
}
});
applyFilters();
updateDetails(allRows()[0]);
render();
})();

View File

@@ -373,6 +373,35 @@
}
}
async function runCleanupAction() {
try {
const response = await fetch("/admin/boxes/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "cleanup_expired", box_ids: [] })
});
const payload = await response.json();
if (!response.ok) {
const message = payload.error || payload.message || "Cleanup failed";
showToast(message, "error", 3200);
return;
}
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
state.selected.clear();
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
state.activeId = null;
}
renderTable();
let message = payload.message || "Expired cleanup completed";
if (Array.isArray(payload.warnings) && payload.warnings.length) {
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
}
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 3200);
} catch (_) {
showToast("Network error while running cleanup", "error", 3200);
}
}
function selectedIDsOrActive() {
if (state.selected.size) return Array.from(state.selected);
const active = currentActiveBox();
@@ -419,6 +448,10 @@
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
await runBulkAction("delete", selectedIDsOrActive());
return;
case "cleanup-expired":
if (!window.confirm("Run cleanup for expired boxes now?")) return;
await runCleanupAction();
return;
case "help-scope":
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
return;

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

@@ -0,0 +1,314 @@
(() => {
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 filterInput = document.getElementById("security-ban-filter");
const sortSelect = document.getElementById("security-ban-sort");
const selectAll = document.getElementById("security-select-all");
const copyIPButton = document.getElementById("security-copy-ip");
const openActivityButton = document.getElementById("security-open-activity");
const openAlertsButton = document.getElementById("security-open-alerts");
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"),
why: document.getElementById("security-detail-why")
};
if (!eventsNode || !alertsNode || !bansNode) return;
const state = {
events: parse(eventsNode),
alerts: parse(alertsNode),
bans: parse(bansNode),
selectedIP: "",
selectedIPs: new Set()
};
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 rowData() {
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
const filter = String(filterInput?.value || "").trim().toLowerCase();
let rows = state.bans.map((entry) => ({ ip: entry.ip, status: "banned", until: entry.until, ban: entry }));
if (filter) rows = rows.filter((row) => row.ip.toLowerCase().includes(filter));
const sort = sortSelect?.value || "expiry_asc";
rows.sort((a, b) => {
if (sort === "ip_asc") return a.ip.localeCompare(b.ip);
if (sort === "ip_desc") return b.ip.localeCompare(a.ip);
const av = new Date(a.until).getTime();
const bv = new Date(b.until).getTime();
return sort === "expiry_desc" ? bv - av : av - bv;
});
return { rows, banMap };
}
function renderBans() {
bansBody.innerHTML = "";
const { rows } = rowData();
rows.forEach((rowData) => {
const row = document.createElement("tr");
row.className = "security-bans-body-row";
if (rowData.ip === state.selectedIP) row.classList.add("is-selected");
row.innerHTML = `
<td><input type="checkbox" class="security-row-select" data-ip="${escapeHtml(rowData.ip)}" ${state.selectedIPs.has(rowData.ip) ? "checked" : ""}></td>
<td>${escapeHtml(rowData.ip || "-")}</td>
<td>${rowData.status}</td>
<td>${createdLabel(rowData.until)}</td>
`;
row.addEventListener("click", (event) => {
if (event.target && event.target.classList.contains("security-row-select")) return;
setSelectedIP(rowData.ip);
});
bansBody.appendChild(row);
});
bansBody.querySelectorAll(".security-row-select").forEach((checkbox) => {
checkbox.addEventListener("change", () => {
const ip = checkbox.getAttribute("data-ip");
if (!ip) return;
if (checkbox.checked) state.selectedIPs.add(ip); else state.selectedIPs.delete(ip);
});
});
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 = "GeoIP not enabled yet";
detail.asn.textContent = "GeoIP not enabled yet";
detail.until.textContent = "-";
detail.why.textContent = "-";
return;
}
const ban = state.bans.find((entry) => entry.ip === ip);
const matchingEvents = state.events.filter((event) => String(event.ip || "") === ip);
const matchingAlerts = state.alerts.filter((alert) => String(alert?.meta?.ip || "") === ip);
const lastEvent = matchingEvents[0] || null;
detail.ip.textContent = ip;
detail.risk.textContent = ban ? "high" : "medium";
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
detail.geo.textContent = "GeoIP not enabled yet";
detail.asn.textContent = "GeoIP not enabled yet";
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
detail.why.textContent = `${matchingEvents.length} events, ${matchingAlerts.length} alerts${lastEvent ? `, latest=${lastEvent.kind}` : ""}`;
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) return showToast("Enter IP first", "warning");
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) return showToast("Enter IP first", "warning");
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
if (!banUntil) return showToast("Set valid expiration date", "warning");
if (!window.confirm("Apply custom ban expiration?")) 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) return showToast("Select or enter IP first", "warning");
if (!window.confirm(`Unban ${ip}?`)) return;
const payload = await postAction("unban", { ip });
state.selectedIPs.delete(ip);
setSelectedIP("");
showToast(payload.message || "IP unbanned", "success");
}
async function bulkUnban() {
const ips = Array.from(state.selectedIPs);
if (ips.length === 0) return showToast("Select at least one banned IP", "warning");
if (!window.confirm(`Unban ${ips.length} selected IPs?`)) return;
const payload = await postAction("bulk_unban", { ips });
state.selectedIPs.clear();
setSelectedIP("");
showToast(payload.message || "Bulk unban complete", "success");
}
async function unbanAll() {
if (!window.confirm("Unban all active bans?")) return;
const payload = await postAction("unban_all");
state.selectedIPs.clear();
setSelectedIP("");
showToast(payload.message || "All bans cleared", "success");
}
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
try {
const command = button.dataset.command;
if (command === "refresh") return window.location.reload();
if (command === "ban-ip") return await banIP();
if (command === "ban-until") return await banUntil();
if (command === "unban-ip") return await unbanIP();
if (command === "bulk-unban") return await bulkUnban();
if (command === "unban-all") return await unbanAll();
} catch (error) {
showToast(error.message, "error", 3200);
} finally {
renderBans();
renderIPDetails();
}
});
});
ipInput.addEventListener("input", () => renderIPDetails());
filterInput?.addEventListener("input", () => renderBans());
sortSelect?.addEventListener("change", () => renderBans());
selectAll?.addEventListener("change", () => {
if (selectAll.checked) state.bans.forEach((ban) => state.selectedIPs.add(ban.ip));
else state.selectedIPs.clear();
renderBans();
});
copyIPButton?.addEventListener("click", async () => {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) return showToast("No IP selected", "warning");
await navigator.clipboard.writeText(ip);
showToast("IP copied", "success");
});
openActivityButton?.addEventListener("click", () => {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) return showToast("No IP selected", "warning");
window.location.href = `/admin/activity?q=${encodeURIComponent(ip)}`;
});
openAlertsButton?.addEventListener("click", () => {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) return showToast("No IP selected", "warning");
window.location.href = `/admin/alerts?q=${encodeURIComponent(ip)}`;
});
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);
render();
})();

View File

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

View File

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

View File

@@ -54,6 +54,7 @@
<button class="menu-action" type="button" data-command="extend-day"><span>+</span><span>Extend selected by 24h</span><span></span></button>
<button class="menu-action" type="button" data-command="extend-week"><span>7</span><span>Extend selected by 7d</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="cleanup-expired"><span>C</span><span>Cleanup expired boxes</span><span></span></button>
<button class="menu-action" type="button" data-command="delete"><span>D</span><span>Delete selected</span><span></span></button>
</div>
</div>
@@ -112,6 +113,7 @@
<button class="win98-button boxes-tool-button" type="button" data-command="export">Export CSV</button>
<button class="win98-button boxes-tool-button" type="button" data-command="expire">Expire</button>
<button class="win98-button boxes-tool-button" type="button" data-command="extend-day">+24h</button>
<button class="win98-button boxes-tool-button" type="button" data-command="cleanup-expired">Cleanup expired</button>
<button class="win98-button boxes-tool-button is-danger" type="button" data-command="delete">Delete</button>
</div>
</div>
@@ -211,6 +213,9 @@
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-week">+7d</button>
<button class="win98-button boxes-action-button is-danger" type="button" data-command="active-delete">Delete</button>
</div>
<div class="boxes-action-grid">
<button class="win98-button boxes-action-button" type="button" data-command="cleanup-expired">Cleanup expired</button>
</div>
</div>
</div>
</section>

View File

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

View File

@@ -0,0 +1,179 @@
{{ 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="bulk-unban"><span>K</span><span>Bulk unban selected</span><span></span></button>
<button class="menu-action" type="button" data-command="unban-all"><span>A</span><span>Unban all</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>admin actions</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>
<button class="win98-button security-button" type="button" data-command="bulk-unban">Bulk unban selected</button>
<button class="win98-button security-button security-danger" type="button" data-command="unban-all">Unban all</button>
<div class="security-note">Ban duration, whitelist rules and trusted proxies are managed in 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>Active bans</strong><span id="security-bans-count">{{ len .Bans }} active bans</span></div>
<div class="security-panel-body security-ban-grid">
<div>
<div class="security-table-toolbar">
<input id="security-ban-filter" class="security-input" type="text" placeholder="Filter by IP">
<select id="security-ban-sort" class="security-input">
<option value="expiry_asc">Expiry ↑</option>
<option value="expiry_desc">Expiry ↓</option>
<option value="ip_asc">IP A-Z</option>
<option value="ip_desc">IP Z-A</option>
</select>
</div>
<div class="security-table-wrap security-bans-wrap">
<table class="security-table">
<thead>
<tr>
<th><input id="security-select-all" type="checkbox" aria-label="Select all"></th>
<th>IP</th>
<th>Status</th>
<th>Ban expires (UTC)</th>
</tr>
</thead>
<tbody id="security-bans-body"></tbody>
</table>
</div>
</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">GeoIP not enabled yet</span></li>
<li><strong>ASN:</strong> <span id="security-detail-asn">GeoIP not enabled yet</span></li>
<li><strong>Ban until:</strong> <span id="security-detail-until">-</span></li>
<li><strong>Why banned:</strong> <span id="security-detail-why">-</span></li>
<li><button id="security-copy-ip" class="win98-button security-button" type="button">Copy IP</button></li>
<li><button id="security-open-activity" class="win98-button security-button" type="button">Search in activity</button></li>
<li><button id="security-open-alerts" class="win98-button security-button" type="button">Search in alerts</button></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>
<section class="security-panel">
<div class="security-panel-header"><strong>Security Runbook</strong><span>ops quick reference</span></div>
<div class="security-panel-body security-docs">
<h4>Reverse Proxy and Trusted CIDRs</h4>
<p>Set <code>WARPBOX_TRUSTED_PROXY_CIDRS</code> to the CIDRs of your proxy nodes only. WarpBox will trust forwarding headers only when the direct remote IP is in this list.</p>
<pre>Caddyfile
:443 {
reverse_proxy 127.0.0.1:8080 {
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host}
}
}</pre>
<h4>Ban / Unban Safety</h4>
<p>Use custom ban durations only for active incidents. Prefer temporary bans. Review the "why banned" detail before unbanning to avoid immediate re-abuse.</p>
<h4>Tuning Guidance</h4>
<p>Low traffic: lower <code>security_*_max_attempts</code>. High traffic: increase windows and attempt thresholds gradually, then monitor alerts/activity for false positives.</p>
<h4>GeoIP Guide (planned)</h4>
<p>For <code>geoip2fast</code>, keep lookups async-safe with a single loaded database, add a short timeout per lookup, cache by IP with TTL, and degrade gracefully to "unknown" on failures. Start with security detail pane only, then aggregate stats later.</p>
</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 }}