You've already forked caddy-opnsense-blocker
Build initial caddy-opnsense-blocker daemon
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/data/
|
||||||
|
/caddy-opnsense-blocker
|
||||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# caddy-opnsense-blocker
|
||||||
|
|
||||||
|
`caddy-opnsense-blocker` is a local-first daemon that ingests Caddy access logs in their default JSON format, evaluates suspicious requests, keeps persistent local state in SQLite, provides a lightweight web UI for review, and blocks or unblocks IP addresses through an OPNsense alias.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time ingestion of multiple Caddy JSON log files.
|
||||||
|
- One heuristic profile per log source.
|
||||||
|
- Persistent local state in SQLite.
|
||||||
|
- Local-only web UI for reviewing events and IPs.
|
||||||
|
- Manual block, unblock, and override reset actions.
|
||||||
|
- OPNsense alias backend with automatic alias creation.
|
||||||
|
- Concurrent polling across multiple log files.
|
||||||
|
|
||||||
|
## Current scope
|
||||||
|
|
||||||
|
This first version is intentionally strong on ingestion, persistence, UI, and OPNsense integration.
|
||||||
|
The decision engine is deliberately simple and deterministic for now:
|
||||||
|
|
||||||
|
- suspicious path prefixes
|
||||||
|
- unexpected `POST` requests
|
||||||
|
- `.php` path detection
|
||||||
|
- explicit known-agent allow/deny rules
|
||||||
|
- excluded CIDR ranges
|
||||||
|
- manual overrides
|
||||||
|
|
||||||
|
This keeps the application usable immediately while leaving room for a more advanced network-intelligence engine later.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `internal/caddylog`: parses default Caddy JSON access logs
|
||||||
|
- `internal/engine`: evaluates requests against a profile
|
||||||
|
- `internal/store`: persists events, IP state, manual decisions, backend actions, and source offsets
|
||||||
|
- `internal/opnsense`: manages the target OPNsense alias through its API
|
||||||
|
- `internal/service`: runs concurrent log followers and applies automatic decisions
|
||||||
|
- `internal/web`: serves the local review UI and JSON API
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Generate or provision OPNsense API credentials.
|
||||||
|
2. Copy `config.example.yaml` to `config.yaml` and adapt it.
|
||||||
|
3. Start the daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go run ./cmd/caddy-opnsense-blocker -config ./config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open the local UI on the configured address, for example `http://127.0.0.1:9080`.
|
||||||
|
|
||||||
|
## Example configuration
|
||||||
|
|
||||||
|
See `config.example.yaml`.
|
||||||
|
|
||||||
|
Important points:
|
||||||
|
|
||||||
|
- Each source points to one Caddy log file.
|
||||||
|
- Each source references exactly one profile.
|
||||||
|
- `initial_position: end` means “start following new lines only” on first boot.
|
||||||
|
- The web UI should stay bound to a local address such as `127.0.0.1:9080`.
|
||||||
|
|
||||||
|
## Web UI and API
|
||||||
|
|
||||||
|
The web UI is intentionally small and server-rendered.
|
||||||
|
It refreshes through lightweight JSON polling and exposes these endpoints:
|
||||||
|
|
||||||
|
- `GET /healthz`
|
||||||
|
- `GET /api/overview`
|
||||||
|
- `GET /api/events`
|
||||||
|
- `GET /api/ips`
|
||||||
|
- `GET /api/ips/{ip}`
|
||||||
|
- `POST /api/ips/{ip}/block`
|
||||||
|
- `POST /api/ips/{ip}/unblock`
|
||||||
|
- `POST /api/ips/{ip}/reset`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run the test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=0 go build ./cmd/caddy-opnsense-blocker
|
||||||
|
```
|
||||||
|
|
||||||
|
`CGO_ENABLED=0` is useful on systems without a C toolchain. The application itself only relies on pure-Go dependencies.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- richer decision engine
|
||||||
|
- asynchronous DNS / RDAP / ASN enrichment
|
||||||
|
- richer review filters in the UI
|
||||||
|
- alternative blocking backends besides OPNsense
|
||||||
|
- direct streaming ingestion targets in addition to file polling
|
||||||
91
cmd/caddy-opnsense-blocker/main.go
Normal file
91
cmd/caddy-opnsense-blocker/main.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/opnsense"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/service"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/store"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
var configPath string
|
||||||
|
flag.StringVar(&configPath, "config", "./config.yaml", "Path to the YAML configuration file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := store.Open(cfg.Storage.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open store: %w", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
logger := log.New(os.Stderr, "caddy-opnsense-blocker: ", log.LstdFlags|log.Lmsgprefix)
|
||||||
|
|
||||||
|
var blocker opnsense.AliasClient
|
||||||
|
if cfg.OPNsense.Enabled {
|
||||||
|
blocker = opnsense.NewClient(cfg.OPNsense)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := service.New(cfg, database, blocker, logger)
|
||||||
|
handler := web.NewHandler(svc)
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: cfg.Server.ListenAddress,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: cfg.Server.ReadTimeout.Duration,
|
||||||
|
WriteTimeout: cfg.Server.WriteTimeout.Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
errCh := make(chan error, 2)
|
||||||
|
go func() {
|
||||||
|
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
logger.Printf("serving on %s", cfg.Server.ListenAddress)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
stop()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout.Duration)
|
||||||
|
defer cancel()
|
||||||
|
_ = httpServer.Shutdown(shutdownCtx)
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout.Duration)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpServer.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return fmt.Errorf("shutdown http server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
74
config.example.yaml
Normal file
74
config.example.yaml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
server:
|
||||||
|
listen_address: 127.0.0.1:9080
|
||||||
|
read_timeout: 5s
|
||||||
|
write_timeout: 10s
|
||||||
|
shutdown_timeout: 15s
|
||||||
|
|
||||||
|
storage:
|
||||||
|
path: ./data/caddy-opnsense-blocker.db
|
||||||
|
|
||||||
|
opnsense:
|
||||||
|
enabled: true
|
||||||
|
base_url: https://router.example.test
|
||||||
|
api_key_file: /run/secrets/opnsense-api-key
|
||||||
|
api_secret_file: /run/secrets/opnsense-api-secret
|
||||||
|
timeout: 8s
|
||||||
|
insecure_skip_verify: false
|
||||||
|
ensure_alias: true
|
||||||
|
alias:
|
||||||
|
name: blocked-ips
|
||||||
|
type: host
|
||||||
|
description: Managed by caddy-opnsense-blocker
|
||||||
|
|
||||||
|
profiles:
|
||||||
|
public-web:
|
||||||
|
auto_block: true
|
||||||
|
min_status: 400
|
||||||
|
max_status: 599
|
||||||
|
block_unexpected_posts: true
|
||||||
|
block_php_paths: true
|
||||||
|
allowed_post_paths:
|
||||||
|
- /search
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /wp-admin
|
||||||
|
- /wp-login.php
|
||||||
|
- /.env
|
||||||
|
- /.git
|
||||||
|
excluded_cidrs:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
- 127.0.0.0/8
|
||||||
|
known_agents:
|
||||||
|
- name: friendly-bot
|
||||||
|
decision: allow
|
||||||
|
user_agent_prefixes:
|
||||||
|
- FriendlyBot/
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
auto_block: false
|
||||||
|
min_status: 400
|
||||||
|
max_status: 599
|
||||||
|
block_unexpected_posts: true
|
||||||
|
block_php_paths: false
|
||||||
|
allowed_post_paths:
|
||||||
|
- /user/login
|
||||||
|
- /user/sign_up
|
||||||
|
- /user/forgot_password
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /install.php
|
||||||
|
- /.env
|
||||||
|
- /.git
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- name: public-web
|
||||||
|
path: /var/log/caddy/public-web-access.json
|
||||||
|
profile: public-web
|
||||||
|
initial_position: end
|
||||||
|
poll_interval: 1s
|
||||||
|
batch_size: 256
|
||||||
|
|
||||||
|
- name: gitea
|
||||||
|
path: /var/log/caddy/gitea-access.json
|
||||||
|
profile: gitea
|
||||||
|
initial_position: end
|
||||||
|
poll_interval: 1s
|
||||||
|
batch_size: 256
|
||||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module git.dern.ovh/infrastructure/caddy-opnsense-blocker
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.39.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
53
go.sum
Normal file
53
go.sum
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||||
|
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
194
internal/caddylog/parser.go
Normal file
194
internal/caddylog/parser.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package caddylog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrEmptyLine = errors.New("empty log line")
|
||||||
|
|
||||||
|
type accessLogEntry struct {
|
||||||
|
Timestamp json.RawMessage `json:"ts"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Request accessLogRequest `json:"request"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accessLogRequest struct {
|
||||||
|
RemoteIP string `json:"remote_ip"`
|
||||||
|
ClientIP string `json:"client_ip"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Headers map[string][]string `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLine(line string) (model.AccessLogRecord, error) {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
return model.AccessLogRecord{}, ErrEmptyLine
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry accessLogEntry
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &entry); err != nil {
|
||||||
|
return model.AccessLogRecord{}, fmt.Errorf("decode caddy log line: %w", err)
|
||||||
|
}
|
||||||
|
if entry.Status == 0 {
|
||||||
|
return model.AccessLogRecord{}, errors.New("missing caddy status")
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteIP, err := normalizeIP(entry.Request.RemoteIP)
|
||||||
|
if err != nil && strings.TrimSpace(entry.Request.RemoteIP) != "" {
|
||||||
|
return model.AccessLogRecord{}, fmt.Errorf("normalize remote ip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCandidate := entry.Request.ClientIP
|
||||||
|
if strings.TrimSpace(clientCandidate) == "" {
|
||||||
|
clientCandidate = entry.Request.RemoteIP
|
||||||
|
}
|
||||||
|
clientIP, err := normalizeIP(clientCandidate)
|
||||||
|
if err != nil {
|
||||||
|
return model.AccessLogRecord{}, fmt.Errorf("normalize client ip: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
occurredAt, err := parseTimestamp(entry.Timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return model.AccessLogRecord{}, fmt.Errorf("parse timestamp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uri := entry.Request.URI
|
||||||
|
if strings.TrimSpace(uri) == "" {
|
||||||
|
uri = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.AccessLogRecord{
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
RemoteIP: remoteIP,
|
||||||
|
ClientIP: clientIP,
|
||||||
|
Host: strings.TrimSpace(entry.Request.Host),
|
||||||
|
Method: strings.ToUpper(strings.TrimSpace(entry.Request.Method)),
|
||||||
|
URI: uri,
|
||||||
|
Path: pathFromURI(uri),
|
||||||
|
Status: entry.Status,
|
||||||
|
UserAgent: firstUserAgent(entry.Request.Headers),
|
||||||
|
RawJSON: trimmed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLines(lines []string) ([]model.AccessLogRecord, error) {
|
||||||
|
records := make([]model.AccessLogRecord, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
record, err := ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrEmptyLine) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstUserAgent(headers map[string][]string) string {
|
||||||
|
if len(headers) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"User-Agent", "user-agent", "USER-AGENT"} {
|
||||||
|
if values, ok := headers[key]; ok && len(values) > 0 {
|
||||||
|
return strings.TrimSpace(values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, values := range headers {
|
||||||
|
if strings.EqualFold(key, "user-agent") && len(values) > 0 {
|
||||||
|
return strings.TrimSpace(values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTimestamp(raw json.RawMessage) (time.Time, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return time.Time{}, errors.New("missing timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
var numeric float64
|
||||||
|
if err := json.Unmarshal(raw, &numeric); err == nil {
|
||||||
|
seconds := int64(numeric)
|
||||||
|
nanos := int64((numeric - float64(seconds)) * float64(time.Second))
|
||||||
|
return time.Unix(seconds, nanos).UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return time.Time{}, errors.New("empty timestamp")
|
||||||
|
}
|
||||||
|
if numeric, err := strconv.ParseFloat(text, 64); err == nil {
|
||||||
|
seconds := int64(numeric)
|
||||||
|
nanos := int64((numeric - float64(seconds)) * float64(time.Second))
|
||||||
|
return time.Unix(seconds, nanos).UTC(), nil
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||||
|
parsed, err := time.Parse(layout, text)
|
||||||
|
if err == nil {
|
||||||
|
return parsed.UTC(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, fmt.Errorf("unsupported timestamp payload %s", string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIP(value string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", errors.New("missing ip address")
|
||||||
|
}
|
||||||
|
parsed := net.ParseIP(trimmed)
|
||||||
|
if parsed == nil {
|
||||||
|
return "", fmt.Errorf("invalid ip address %q", value)
|
||||||
|
}
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathFromURI(rawURI string) string {
|
||||||
|
trimmed := strings.TrimSpace(rawURI)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.ParseRequestURI(trimmed)
|
||||||
|
if err == nil {
|
||||||
|
if parsed.Path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return normalizePath(parsed.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
value := strings.SplitN(trimmed, "?", 2)[0]
|
||||||
|
value = strings.SplitN(value, "#", 2)[0]
|
||||||
|
return normalizePath(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePath(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "/") {
|
||||||
|
trimmed = "/" + trimmed
|
||||||
|
}
|
||||||
|
if trimmed != "/" {
|
||||||
|
trimmed = strings.TrimRight(trimmed, "/")
|
||||||
|
}
|
||||||
|
return strings.ToLower(trimmed)
|
||||||
|
}
|
||||||
57
internal/caddylog/parser_test.go
Normal file
57
internal/caddylog/parser_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package caddylog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLineWithNumericTimestamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
line := `{"ts":1710000000.5,"status":404,"request":{"remote_ip":"198.51.100.10","client_ip":"203.0.113.5","host":"example.test","method":"GET","uri":"/wp-login.php?foo=bar","headers":{"User-Agent":["UnitTestBot/1.0"]}}}`
|
||||||
|
record, err := ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse line: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := record.ClientIP, "203.0.113.5"; got != want {
|
||||||
|
t.Fatalf("unexpected client ip: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := record.RemoteIP, "198.51.100.10"; got != want {
|
||||||
|
t.Fatalf("unexpected remote ip: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := record.Path, "/wp-login.php"; got != want {
|
||||||
|
t.Fatalf("unexpected path: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := record.UserAgent, "UnitTestBot/1.0"; got != want {
|
||||||
|
t.Fatalf("unexpected user agent: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := record.Method, "GET"; got != want {
|
||||||
|
t.Fatalf("unexpected method: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
expected := time.Unix(1710000000, 500000000).UTC()
|
||||||
|
if !record.OccurredAt.Equal(expected) {
|
||||||
|
t.Fatalf("unexpected timestamp: got %s want %s", record.OccurredAt, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLineWithRFC3339TimestampAndMissingClientIP(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
line := `{"ts":"2025-03-11T12:13:14.123456Z","status":401,"request":{"remote_ip":"2001:db8::1","host":"git.example.test","method":"POST","uri":"user/login","headers":{"user-agent":["curl/8.0"]}}}`
|
||||||
|
record, err := ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse line: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := record.ClientIP, "2001:db8::1"; got != want {
|
||||||
|
t.Fatalf("unexpected fallback client ip: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := record.Path, "/user/login"; got != want {
|
||||||
|
t.Fatalf("unexpected path: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(record.RawJSON, `"status":401`) {
|
||||||
|
t.Fatalf("raw json was not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
414
internal/config/config.go
Normal file
414
internal/config/config.go
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Duration struct {
|
||||||
|
time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node == nil || node.Value == "" {
|
||||||
|
d.Duration = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseDuration(node.Value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid duration %q: %w", node.Value, err)
|
||||||
|
}
|
||||||
|
d.Duration = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Duration) MarshalYAML() (any, error) {
|
||||||
|
return d.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Storage StorageConfig `yaml:"storage"`
|
||||||
|
OPNsense OPNsenseConfig `yaml:"opnsense"`
|
||||||
|
Profiles map[string]ProfileConfig `yaml:"profiles"`
|
||||||
|
Sources []SourceConfig `yaml:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
ListenAddress string `yaml:"listen_address"`
|
||||||
|
ReadTimeout Duration `yaml:"read_timeout"`
|
||||||
|
WriteTimeout Duration `yaml:"write_timeout"`
|
||||||
|
ShutdownTimeout Duration `yaml:"shutdown_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageConfig struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OPNsenseConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
APISecret string `yaml:"api_secret"`
|
||||||
|
APIKeyFile string `yaml:"api_key_file"`
|
||||||
|
APISecretFile string `yaml:"api_secret_file"`
|
||||||
|
Timeout Duration `yaml:"timeout"`
|
||||||
|
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
|
||||||
|
EnsureAlias bool `yaml:"ensure_alias"`
|
||||||
|
Alias AliasConfig `yaml:"alias"`
|
||||||
|
APIPaths APIPathsConfig `yaml:"api_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AliasConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIPathsConfig struct {
|
||||||
|
AliasGetUUID string `yaml:"alias_get_uuid"`
|
||||||
|
AliasAddItem string `yaml:"alias_add_item"`
|
||||||
|
AliasSetItem string `yaml:"alias_set_item"`
|
||||||
|
AliasReconfig string `yaml:"alias_reconfigure"`
|
||||||
|
AliasUtilList string `yaml:"alias_util_list"`
|
||||||
|
AliasUtilAdd string `yaml:"alias_util_add"`
|
||||||
|
AliasUtilDelete string `yaml:"alias_util_delete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Profile string `yaml:"profile"`
|
||||||
|
InitialPosition string `yaml:"initial_position"`
|
||||||
|
PollInterval Duration `yaml:"poll_interval"`
|
||||||
|
BatchSize int `yaml:"batch_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownAgentRule struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Decision string `yaml:"decision"`
|
||||||
|
UserAgentPrefixes []string `yaml:"user_agent_prefixes"`
|
||||||
|
CIDRs []string `yaml:"cidrs"`
|
||||||
|
|
||||||
|
normalizedPrefixes []string
|
||||||
|
networks []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileConfig struct {
|
||||||
|
AutoBlock bool `yaml:"auto_block"`
|
||||||
|
MinStatus int `yaml:"min_status"`
|
||||||
|
MaxStatus int `yaml:"max_status"`
|
||||||
|
BlockUnexpectedPosts bool `yaml:"block_unexpected_posts"`
|
||||||
|
BlockPHPPaths bool `yaml:"block_php_paths"`
|
||||||
|
AllowedPostPaths []string `yaml:"allowed_post_paths"`
|
||||||
|
SuspiciousPathPrefixes []string `yaml:"suspicious_path_prefixes"`
|
||||||
|
ExcludedCIDRs []string `yaml:"excluded_cidrs"`
|
||||||
|
KnownAgents []KnownAgentRule `yaml:"known_agents"`
|
||||||
|
|
||||||
|
normalizedAllowedPostPaths map[string]struct{}
|
||||||
|
normalizedSuspiciousPaths []string
|
||||||
|
excludedNetworks []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
payload, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(payload, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.applyDefaults(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.validate(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) applyDefaults() error {
|
||||||
|
if c.Server.ListenAddress == "" {
|
||||||
|
c.Server.ListenAddress = "127.0.0.1:9080"
|
||||||
|
}
|
||||||
|
if c.Server.ReadTimeout.Duration == 0 {
|
||||||
|
c.Server.ReadTimeout.Duration = 5 * time.Second
|
||||||
|
}
|
||||||
|
if c.Server.WriteTimeout.Duration == 0 {
|
||||||
|
c.Server.WriteTimeout.Duration = 10 * time.Second
|
||||||
|
}
|
||||||
|
if c.Server.ShutdownTimeout.Duration == 0 {
|
||||||
|
c.Server.ShutdownTimeout.Duration = 15 * time.Second
|
||||||
|
}
|
||||||
|
if c.Storage.Path == "" {
|
||||||
|
c.Storage.Path = "./data/caddy-opnsense-blocker.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.OPNsense.Timeout.Duration == 0 {
|
||||||
|
c.OPNsense.Timeout.Duration = 8 * time.Second
|
||||||
|
}
|
||||||
|
if c.OPNsense.Alias.Type == "" {
|
||||||
|
c.OPNsense.Alias.Type = "host"
|
||||||
|
}
|
||||||
|
if c.OPNsense.Alias.Description == "" {
|
||||||
|
c.OPNsense.Alias.Description = "Managed by caddy-opnsense-blocker"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasGetUUID == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasGetUUID = "/api/firewall/alias/get_alias_u_u_i_d/{alias}"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasAddItem == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasAddItem = "/api/firewall/alias/add_item"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasSetItem == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasSetItem = "/api/firewall/alias/set_item/{uuid}"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasReconfig == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasReconfig = "/api/firewall/alias/reconfigure"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasUtilList == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasUtilList = "/api/firewall/alias_util/list/{alias}"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasUtilAdd == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasUtilAdd = "/api/firewall/alias_util/add/{alias}"
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIPaths.AliasUtilDelete == "" {
|
||||||
|
c.OPNsense.APIPaths.AliasUtilDelete = "/api/firewall/alias_util/delete/{alias}"
|
||||||
|
}
|
||||||
|
if !c.OPNsense.EnsureAlias {
|
||||||
|
c.OPNsense.EnsureAlias = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, profile := range c.Profiles {
|
||||||
|
if profile.MinStatus == 0 {
|
||||||
|
profile.MinStatus = 400
|
||||||
|
}
|
||||||
|
if profile.MaxStatus == 0 {
|
||||||
|
profile.MaxStatus = 599
|
||||||
|
}
|
||||||
|
profile.normalizedAllowedPostPaths = make(map[string]struct{}, len(profile.AllowedPostPaths))
|
||||||
|
for _, path := range profile.AllowedPostPaths {
|
||||||
|
profile.normalizedAllowedPostPaths[normalizePath(path)] = struct{}{}
|
||||||
|
}
|
||||||
|
profile.normalizedSuspiciousPaths = make([]string, 0, len(profile.SuspiciousPathPrefixes))
|
||||||
|
for _, prefix := range profile.SuspiciousPathPrefixes {
|
||||||
|
profile.normalizedSuspiciousPaths = append(profile.normalizedSuspiciousPaths, normalizePrefix(prefix))
|
||||||
|
}
|
||||||
|
sort.Strings(profile.normalizedSuspiciousPaths)
|
||||||
|
|
||||||
|
for _, cidr := range profile.ExcludedCIDRs {
|
||||||
|
_, network, err := net.ParseCIDR(strings.TrimSpace(cidr))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("profile %q has invalid excluded_cidr %q: %w", name, cidr, err)
|
||||||
|
}
|
||||||
|
profile.excludedNetworks = append(profile.excludedNetworks, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, rule := range profile.KnownAgents {
|
||||||
|
decision := strings.ToLower(strings.TrimSpace(rule.Decision))
|
||||||
|
profile.KnownAgents[index].Decision = decision
|
||||||
|
for _, prefix := range rule.UserAgentPrefixes {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(prefix))
|
||||||
|
if normalized != "" {
|
||||||
|
profile.KnownAgents[index].normalizedPrefixes = append(profile.KnownAgents[index].normalizedPrefixes, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cidr := range rule.CIDRs {
|
||||||
|
_, network, err := net.ParseCIDR(strings.TrimSpace(cidr))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("profile %q rule %q has invalid cidr %q: %w", name, rule.Name, cidr, err)
|
||||||
|
}
|
||||||
|
profile.KnownAgents[index].networks = append(profile.KnownAgents[index].networks, network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Profiles[name] = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range c.Sources {
|
||||||
|
c.Sources[index].InitialPosition = strings.ToLower(strings.TrimSpace(c.Sources[index].InitialPosition))
|
||||||
|
if c.Sources[index].InitialPosition == "" {
|
||||||
|
c.Sources[index].InitialPosition = "end"
|
||||||
|
}
|
||||||
|
if c.Sources[index].PollInterval.Duration == 0 {
|
||||||
|
c.Sources[index].PollInterval.Duration = time.Second
|
||||||
|
}
|
||||||
|
if c.Sources[index].BatchSize <= 0 {
|
||||||
|
c.Sources[index].BatchSize = 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) validate(sourcePath string) error {
|
||||||
|
if len(c.Profiles) == 0 {
|
||||||
|
return errors.New("at least one profile is required")
|
||||||
|
}
|
||||||
|
if len(c.Sources) == 0 {
|
||||||
|
return errors.New("at least one source is required")
|
||||||
|
}
|
||||||
|
if _, _, err := net.SplitHostPort(c.Server.ListenAddress); err != nil {
|
||||||
|
return fmt.Errorf("invalid server.listen_address: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(c.Storage.Path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("prepare storage directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenNames := map[string]struct{}{}
|
||||||
|
seenPaths := map[string]struct{}{}
|
||||||
|
for _, source := range c.Sources {
|
||||||
|
if source.Name == "" {
|
||||||
|
return errors.New("source.name must not be empty")
|
||||||
|
}
|
||||||
|
if source.Path == "" {
|
||||||
|
return fmt.Errorf("source %q must define a path", source.Name)
|
||||||
|
}
|
||||||
|
if _, ok := seenNames[source.Name]; ok {
|
||||||
|
return fmt.Errorf("duplicate source name %q", source.Name)
|
||||||
|
}
|
||||||
|
seenNames[source.Name] = struct{}{}
|
||||||
|
if _, ok := seenPaths[source.Path]; ok {
|
||||||
|
return fmt.Errorf("duplicate source path %q", source.Path)
|
||||||
|
}
|
||||||
|
seenPaths[source.Path] = struct{}{}
|
||||||
|
if _, ok := c.Profiles[source.Profile]; !ok {
|
||||||
|
return fmt.Errorf("source %q references unknown profile %q", source.Name, source.Profile)
|
||||||
|
}
|
||||||
|
if source.InitialPosition != "beginning" && source.InitialPosition != "end" {
|
||||||
|
return fmt.Errorf("source %q has invalid initial_position %q", source.Name, source.InitialPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, profile := range c.Profiles {
|
||||||
|
if profile.MinStatus < 100 || profile.MinStatus > 599 {
|
||||||
|
return fmt.Errorf("profile %q has invalid min_status %d", name, profile.MinStatus)
|
||||||
|
}
|
||||||
|
if profile.MaxStatus < profile.MinStatus || profile.MaxStatus > 599 {
|
||||||
|
return fmt.Errorf("profile %q has invalid max_status %d", name, profile.MaxStatus)
|
||||||
|
}
|
||||||
|
for _, prefix := range profile.normalizedSuspiciousPaths {
|
||||||
|
if prefix == "/" {
|
||||||
|
return fmt.Errorf("profile %q has overly broad suspicious path prefix %q", name, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rule := range profile.KnownAgents {
|
||||||
|
if rule.Decision != "allow" && rule.Decision != "deny" {
|
||||||
|
return fmt.Errorf("profile %q known agent %q has invalid decision %q", name, rule.Name, rule.Decision)
|
||||||
|
}
|
||||||
|
if len(rule.normalizedPrefixes) == 0 && len(rule.networks) == 0 {
|
||||||
|
return fmt.Errorf("profile %q known agent %q must define user_agent_prefixes and/or cidrs", name, rule.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.OPNsense.Enabled {
|
||||||
|
if c.OPNsense.BaseURL == "" {
|
||||||
|
return errors.New("opnsense.base_url is required when opnsense is enabled")
|
||||||
|
}
|
||||||
|
if c.OPNsense.Alias.Name == "" {
|
||||||
|
return errors.New("opnsense.alias.name is required when opnsense is enabled")
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIKey == "" && c.OPNsense.APIKeyFile == "" {
|
||||||
|
return errors.New("opnsense.api_key or opnsense.api_key_file is required when opnsense is enabled")
|
||||||
|
}
|
||||||
|
if c.OPNsense.APISecret == "" && c.OPNsense.APISecretFile == "" {
|
||||||
|
return errors.New("opnsense.api_secret or opnsense.api_secret_file is required when opnsense is enabled")
|
||||||
|
}
|
||||||
|
if c.OPNsense.APIKey == "" {
|
||||||
|
payload, err := os.ReadFile(c.OPNsense.APIKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read opnsense.api_key_file: %w", err)
|
||||||
|
}
|
||||||
|
c.OPNsense.APIKey = strings.TrimSpace(string(payload))
|
||||||
|
}
|
||||||
|
if c.OPNsense.APISecret == "" {
|
||||||
|
payload, err := os.ReadFile(c.OPNsense.APISecretFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read opnsense.api_secret_file: %w", err)
|
||||||
|
}
|
||||||
|
c.OPNsense.APISecret = strings.TrimSpace(string(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = sourcePath
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePath(input string) string {
|
||||||
|
value := strings.TrimSpace(input)
|
||||||
|
if value == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
value = strings.SplitN(value, "?", 2)[0]
|
||||||
|
value = strings.SplitN(value, "#", 2)[0]
|
||||||
|
if !strings.HasPrefix(value, "/") {
|
||||||
|
value = "/" + value
|
||||||
|
}
|
||||||
|
if value != "/" {
|
||||||
|
value = strings.TrimRight(value, "/")
|
||||||
|
}
|
||||||
|
return strings.ToLower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePrefix(input string) string {
|
||||||
|
return normalizePath(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ProfileConfig) IsExcluded(ip net.IP) bool {
|
||||||
|
for _, network := range p.excludedNetworks {
|
||||||
|
if network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ProfileConfig) IsAllowedPostPath(path string) bool {
|
||||||
|
_, ok := p.normalizedAllowedPostPaths[normalizePath(path)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ProfileConfig) SuspiciousPrefixes() []string {
|
||||||
|
return append([]string(nil), p.normalizedSuspiciousPaths...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p ProfileConfig) MatchKnownAgent(ip net.IP, userAgent string) (KnownAgentRule, bool) {
|
||||||
|
normalizedUA := strings.ToLower(strings.TrimSpace(userAgent))
|
||||||
|
for _, rule := range p.KnownAgents {
|
||||||
|
uaMatched := len(rule.normalizedPrefixes) == 0
|
||||||
|
for _, prefix := range rule.normalizedPrefixes {
|
||||||
|
if strings.HasPrefix(normalizedUA, prefix) {
|
||||||
|
uaMatched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !uaMatched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cidrMatched := len(rule.networks) == 0
|
||||||
|
for _, network := range rule.networks {
|
||||||
|
if network.Contains(ip) {
|
||||||
|
cidrMatched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cidrMatched {
|
||||||
|
return rule, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return KnownAgentRule{}, false
|
||||||
|
}
|
||||||
106
internal/config/config_test.go
Normal file
106
internal/config/config_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadAppliesDefaultsAndReadsSecrets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(tempDir, "api-key")
|
||||||
|
secretPath := filepath.Join(tempDir, "api-secret")
|
||||||
|
if err := os.WriteFile(keyPath, []byte("test-key\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write key file: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(secretPath, []byte("test-secret\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write secret file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.yaml")
|
||||||
|
payload := fmt.Sprintf(`storage:
|
||||||
|
path: %s/data/blocker.db
|
||||||
|
opnsense:
|
||||||
|
enabled: true
|
||||||
|
base_url: https://router.example.test
|
||||||
|
api_key_file: %s
|
||||||
|
api_secret_file: %s
|
||||||
|
ensure_alias: true
|
||||||
|
alias:
|
||||||
|
name: blocked-ips
|
||||||
|
profiles:
|
||||||
|
main:
|
||||||
|
auto_block: true
|
||||||
|
block_unexpected_posts: true
|
||||||
|
block_php_paths: true
|
||||||
|
allowed_post_paths:
|
||||||
|
- /search
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /wp-admin
|
||||||
|
excluded_cidrs:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
known_agents:
|
||||||
|
- name: friendly-bot
|
||||||
|
decision: allow
|
||||||
|
user_agent_prefixes:
|
||||||
|
- FriendlyBot/
|
||||||
|
sources:
|
||||||
|
- name: main
|
||||||
|
path: %s/access.json
|
||||||
|
profile: main
|
||||||
|
`, tempDir, keyPath, secretPath, tempDir)
|
||||||
|
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := cfg.Server.ListenAddress, "127.0.0.1:9080"; got != want {
|
||||||
|
t.Fatalf("unexpected listen address: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := cfg.Sources[0].InitialPosition, "end"; got != want {
|
||||||
|
t.Fatalf("unexpected initial position: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := cfg.OPNsense.APIKey, "test-key"; got != want {
|
||||||
|
t.Fatalf("unexpected api key: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := cfg.OPNsense.APISecret, "test-secret"; got != want {
|
||||||
|
t.Fatalf("unexpected api secret: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
profile := cfg.Profiles["main"]
|
||||||
|
if !profile.IsAllowedPostPath("/search") {
|
||||||
|
t.Fatalf("expected /search to be normalized as an allowed POST path")
|
||||||
|
}
|
||||||
|
if len(profile.SuspiciousPrefixes()) != 1 || profile.SuspiciousPrefixes()[0] != "/wp-admin" {
|
||||||
|
t.Fatalf("unexpected suspicious prefixes: %#v", profile.SuspiciousPrefixes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsInvalidInitialPosition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tempDir, "config.yaml")
|
||||||
|
payload := fmt.Sprintf(`profiles:
|
||||||
|
main:
|
||||||
|
auto_block: true
|
||||||
|
sources:
|
||||||
|
- name: main
|
||||||
|
path: %s/access.json
|
||||||
|
profile: main
|
||||||
|
initial_position: sideways
|
||||||
|
`, tempDir)
|
||||||
|
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Load(configPath); err == nil {
|
||||||
|
t.Fatalf("expected invalid initial_position to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/engine/evaluator.go
Normal file
69
internal/engine/evaluator.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Evaluator struct{}
|
||||||
|
|
||||||
|
func NewEvaluator() *Evaluator {
|
||||||
|
return &Evaluator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) Evaluate(record model.AccessLogRecord, profile config.ProfileConfig, override model.ManualOverride) model.Decision {
|
||||||
|
switch override {
|
||||||
|
case model.ManualOverrideForceAllow:
|
||||||
|
return model.Decision{Action: model.DecisionActionAllow, Reasons: []string{"manual_override_force_allow"}}
|
||||||
|
case model.ManualOverrideForceBlock:
|
||||||
|
return model.Decision{Action: model.DecisionActionBlock, Reasons: []string{"manual_override_force_block"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Status < profile.MinStatus || record.Status > profile.MaxStatus {
|
||||||
|
return model.Decision{Action: model.DecisionActionNone}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(record.ClientIP)
|
||||||
|
if ip == nil {
|
||||||
|
return model.Decision{Action: model.DecisionActionReview, Reasons: []string{"invalid_client_ip"}}
|
||||||
|
}
|
||||||
|
if profile.IsExcluded(ip) {
|
||||||
|
return model.Decision{Action: model.DecisionActionAllow, Reasons: []string{"excluded_cidr"}}
|
||||||
|
}
|
||||||
|
if rule, ok := profile.MatchKnownAgent(ip, record.UserAgent); ok {
|
||||||
|
if rule.Decision == "allow" {
|
||||||
|
return model.Decision{Action: model.DecisionActionAllow, Reasons: []string{fmt.Sprintf("known_agent_allow:%s", rule.Name)}}
|
||||||
|
}
|
||||||
|
return blockDecision(profile.AutoBlock, []string{fmt.Sprintf("known_agent_deny:%s", rule.Name)})
|
||||||
|
}
|
||||||
|
|
||||||
|
blockReasons := make([]string, 0, 3)
|
||||||
|
for _, prefix := range profile.SuspiciousPrefixes() {
|
||||||
|
if strings.HasPrefix(record.Path, prefix) {
|
||||||
|
blockReasons = append(blockReasons, fmt.Sprintf("suspicious_path_prefix:%s", prefix))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.BlockUnexpectedPosts && strings.EqualFold(record.Method, "POST") && !profile.IsAllowedPostPath(record.Path) {
|
||||||
|
blockReasons = append(blockReasons, "unexpected_post")
|
||||||
|
}
|
||||||
|
if profile.BlockPHPPaths && strings.HasSuffix(record.Path, ".php") {
|
||||||
|
blockReasons = append(blockReasons, "php_path")
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockDecision(profile.AutoBlock, blockReasons)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockDecision(autoBlock bool, reasons []string) model.Decision {
|
||||||
|
if len(reasons) == 0 {
|
||||||
|
return model.Decision{Action: model.DecisionActionNone}
|
||||||
|
}
|
||||||
|
if autoBlock {
|
||||||
|
return model.Decision{Action: model.DecisionActionBlock, Reasons: reasons}
|
||||||
|
}
|
||||||
|
return model.Decision{Action: model.DecisionActionReview, Reasons: reasons}
|
||||||
|
}
|
||||||
153
internal/engine/evaluator_test.go
Normal file
153
internal/engine/evaluator_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvaluatorManualOverridesTakePriority(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
profile := loadProfile(t, `
|
||||||
|
auto_block: true
|
||||||
|
block_unexpected_posts: true
|
||||||
|
block_php_paths: true
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /wp-admin
|
||||||
|
`)
|
||||||
|
evaluator := NewEvaluator()
|
||||||
|
record := model.AccessLogRecord{ClientIP: "203.0.113.10", Status: 404, Method: "GET", Path: "/wp-admin/install.php", UserAgent: "curl/8.0"}
|
||||||
|
|
||||||
|
if decision := evaluator.Evaluate(record, profile, model.ManualOverrideForceAllow); decision.Action != model.DecisionActionAllow {
|
||||||
|
t.Fatalf("expected manual allow to win, got %+v", decision)
|
||||||
|
}
|
||||||
|
if decision := evaluator.Evaluate(record, profile, model.ManualOverrideForceBlock); decision.Action != model.DecisionActionBlock {
|
||||||
|
t.Fatalf("expected manual block to win, got %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatorBlocksSuspiciousRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
profile := loadProfile(t, `
|
||||||
|
auto_block: true
|
||||||
|
block_unexpected_posts: true
|
||||||
|
block_php_paths: true
|
||||||
|
allowed_post_paths:
|
||||||
|
- /search
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /wp-admin
|
||||||
|
`)
|
||||||
|
evaluator := NewEvaluator()
|
||||||
|
record := model.AccessLogRecord{ClientIP: "203.0.113.11", Status: 404, Method: "POST", Path: "/wp-admin/install.php", UserAgent: "curl/8.0"}
|
||||||
|
|
||||||
|
decision := evaluator.Evaluate(record, profile, model.ManualOverrideNone)
|
||||||
|
if decision.Action != model.DecisionActionBlock {
|
||||||
|
t.Fatalf("expected block decision, got %+v", decision)
|
||||||
|
}
|
||||||
|
if len(decision.Reasons) < 2 {
|
||||||
|
t.Fatalf("expected multiple blocking reasons, got %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatorAllowsExcludedCIDRAndKnownAgents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
profile := loadProfile(t, `
|
||||||
|
auto_block: true
|
||||||
|
excluded_cidrs:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
known_agents:
|
||||||
|
- name: friendly-bot
|
||||||
|
decision: allow
|
||||||
|
user_agent_prefixes:
|
||||||
|
- FriendlyBot/
|
||||||
|
`)
|
||||||
|
evaluator := NewEvaluator()
|
||||||
|
|
||||||
|
excluded := model.AccessLogRecord{ClientIP: "10.0.0.5", Status: 404, Method: "GET", Path: "/wp-login.php", UserAgent: "curl/8.0"}
|
||||||
|
if decision := evaluator.Evaluate(excluded, profile, model.ManualOverrideNone); decision.Action != model.DecisionActionAllow {
|
||||||
|
t.Fatalf("expected excluded cidr to be allowed, got %+v", decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
knownAgent := model.AccessLogRecord{ClientIP: "203.0.113.12", Status: 404, Method: "GET", Path: "/wp-login.php", UserAgent: "FriendlyBot/2.0"}
|
||||||
|
if decision := evaluator.Evaluate(knownAgent, profile, model.ManualOverrideNone); decision.Action != model.DecisionActionAllow {
|
||||||
|
t.Fatalf("expected known agent to be allowed, got %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatorReturnsReviewWhenAutoBlockIsDisabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
profile := loadProfile(t, `
|
||||||
|
auto_block: false
|
||||||
|
block_unexpected_posts: true
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /admin
|
||||||
|
`)
|
||||||
|
evaluator := NewEvaluator()
|
||||||
|
record := model.AccessLogRecord{ClientIP: "203.0.113.13", Status: 404, Method: "POST", Path: "/admin", UserAgent: "curl/8.0"}
|
||||||
|
|
||||||
|
decision := evaluator.Evaluate(record, profile, model.ManualOverrideNone)
|
||||||
|
if decision.Action != model.DecisionActionReview {
|
||||||
|
t.Fatalf("expected review decision, got %+v", decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfile(t *testing.T, profileSnippet string) config.ProfileConfig {
|
||||||
|
t.Helper()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tempDir, "config.yaml")
|
||||||
|
payload := fmt.Sprintf(`profiles:
|
||||||
|
main:%s
|
||||||
|
sources:
|
||||||
|
- name: main
|
||||||
|
path: %s/access.json
|
||||||
|
profile: main
|
||||||
|
`, indent(profileSnippet, 4), tempDir)
|
||||||
|
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config file: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
return cfg.Profiles["main"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(value string, spaces int) string {
|
||||||
|
padding := ""
|
||||||
|
for range spaces {
|
||||||
|
padding += " "
|
||||||
|
}
|
||||||
|
lines := []byte(value)
|
||||||
|
_ = lines
|
||||||
|
var output string
|
||||||
|
for _, line := range splitLines(value) {
|
||||||
|
trimmed := line
|
||||||
|
if trimmed == "" {
|
||||||
|
output += "\n"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output += "\n" + padding + trimmed
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(value string) []string {
|
||||||
|
var lines []string
|
||||||
|
start := 0
|
||||||
|
for index, character := range value {
|
||||||
|
if character == '\n' {
|
||||||
|
lines = append(lines, value[start:index])
|
||||||
|
start = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, value[start:])
|
||||||
|
return lines
|
||||||
|
}
|
||||||
140
internal/model/types.go
Normal file
140
internal/model/types.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type DecisionAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DecisionActionNone DecisionAction = "none"
|
||||||
|
DecisionActionReview DecisionAction = "review"
|
||||||
|
DecisionActionBlock DecisionAction = "block"
|
||||||
|
DecisionActionAllow DecisionAction = "allow"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ManualOverride string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ManualOverrideNone ManualOverride = "none"
|
||||||
|
ManualOverrideForceAllow ManualOverride = "force_allow"
|
||||||
|
ManualOverrideForceBlock ManualOverride = "force_block"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IPStateStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IPStateObserved IPStateStatus = "observed"
|
||||||
|
IPStateReview IPStateStatus = "review"
|
||||||
|
IPStateBlocked IPStateStatus = "blocked"
|
||||||
|
IPStateAllowed IPStateStatus = "allowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccessLogRecord struct {
|
||||||
|
OccurredAt time.Time
|
||||||
|
RemoteIP string
|
||||||
|
ClientIP string
|
||||||
|
Host string
|
||||||
|
Method string
|
||||||
|
URI string
|
||||||
|
Path string
|
||||||
|
Status int
|
||||||
|
UserAgent string
|
||||||
|
RawJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Decision struct {
|
||||||
|
Action DecisionAction
|
||||||
|
Reasons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d Decision) PrimaryReason() string {
|
||||||
|
if len(d.Reasons) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Reasons[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SourceName string `json:"source_name"`
|
||||||
|
ProfileName string `json:"profile_name"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
RemoteIP string `json:"remote_ip"`
|
||||||
|
ClientIP string `json:"client_ip"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
Decision DecisionAction `json:"decision"`
|
||||||
|
DecisionReason string `json:"decision_reason"`
|
||||||
|
DecisionReasons []string `json:"decision_reasons,omitempty"`
|
||||||
|
Enforced bool `json:"enforced"`
|
||||||
|
RawJSON string `json:"raw_json"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CurrentState IPStateStatus `json:"current_state"`
|
||||||
|
ManualOverride ManualOverride `json:"manual_override"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPState struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
LastSourceName string `json:"last_source_name"`
|
||||||
|
LastUserAgent string `json:"last_user_agent"`
|
||||||
|
LatestStatus int `json:"latest_status"`
|
||||||
|
TotalEvents int64 `json:"total_events"`
|
||||||
|
State IPStateStatus `json:"state"`
|
||||||
|
StateReason string `json:"state_reason"`
|
||||||
|
ManualOverride ManualOverride `json:"manual_override"`
|
||||||
|
LastEventID int64 `json:"last_event_id"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DecisionRecord struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
EventID int64 `json:"event_id"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
SourceName string `json:"source_name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Action DecisionAction `json:"action"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Enforced bool `json:"enforced"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OPNsenseAction struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceOffset struct {
|
||||||
|
SourceName string
|
||||||
|
Path string
|
||||||
|
Inode string
|
||||||
|
Offset int64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPDetails struct {
|
||||||
|
State IPState `json:"state"`
|
||||||
|
RecentEvents []Event `json:"recent_events"`
|
||||||
|
Decisions []DecisionRecord `json:"decisions"`
|
||||||
|
BackendActions []OPNsenseAction `json:"backend_actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Overview struct {
|
||||||
|
TotalEvents int64 `json:"total_events"`
|
||||||
|
TotalIPs int64 `json:"total_ips"`
|
||||||
|
BlockedIPs int64 `json:"blocked_ips"`
|
||||||
|
ReviewIPs int64 `json:"review_ips"`
|
||||||
|
AllowedIPs int64 `json:"allowed_ips"`
|
||||||
|
ObservedIPs int64 `json:"observed_ips"`
|
||||||
|
RecentIPs []IPState `json:"recent_ips"`
|
||||||
|
RecentEvents []Event `json:"recent_events"`
|
||||||
|
}
|
||||||
306
internal/opnsense/client.go
Normal file
306
internal/opnsense/client.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package opnsense
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AliasClient interface {
|
||||||
|
AddIPIfMissing(ctx context.Context, ip string) (string, error)
|
||||||
|
RemoveIPIfPresent(ctx context.Context, ip string) (string, error)
|
||||||
|
IsIPPresent(ctx context.Context, ip string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg config.OPNsenseConfig
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
aliasUUID string
|
||||||
|
knownAliasIPs map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg config.OPNsenseConfig) *Client {
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: cfg.Timeout.Duration,
|
||||||
|
Transport: transport,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddIPIfMissing(ctx context.Context, ip string) (string, error) {
|
||||||
|
normalized, err := normalizeIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
snapshot, err := c.ensureAliasSnapshotLocked(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, ok := snapshot[normalized]; ok {
|
||||||
|
return "already_present", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasUtilAdd, map[string]string{"alias": c.cfg.Alias.Name}, map[string]string{"address": normalized})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status := strings.ToLower(strings.TrimSpace(asString(payload["status"]))); status != "done" {
|
||||||
|
return "", fmt.Errorf("opnsense alias add failed: %v", payload)
|
||||||
|
}
|
||||||
|
snapshot[normalized] = struct{}{}
|
||||||
|
return "added", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RemoveIPIfPresent(ctx context.Context, ip string) (string, error) {
|
||||||
|
normalized, err := normalizeIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
snapshot, err := c.ensureAliasSnapshotLocked(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, ok := snapshot[normalized]; !ok {
|
||||||
|
return "already_absent", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasUtilDelete, map[string]string{"alias": c.cfg.Alias.Name}, map[string]string{"address": normalized})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status := strings.ToLower(strings.TrimSpace(asString(payload["status"]))); status != "done" {
|
||||||
|
return "", fmt.Errorf("opnsense alias delete failed: %v", payload)
|
||||||
|
}
|
||||||
|
delete(snapshot, normalized)
|
||||||
|
return "removed", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) IsIPPresent(ctx context.Context, ip string) (bool, error) {
|
||||||
|
normalized, err := normalizeIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
snapshot, err := c.ensureAliasSnapshotLocked(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, ok := snapshot[normalized]
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureAliasSnapshotLocked(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
if c.knownAliasIPs != nil {
|
||||||
|
return c.knownAliasIPs, nil
|
||||||
|
}
|
||||||
|
if err := c.ensureAliasExistsLocked(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payload, err := c.requestJSON(ctx, http.MethodGet, c.cfg.APIPaths.AliasUtilList, map[string]string{"alias": c.cfg.Alias.Name}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, ok := payload["rows"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected opnsense alias listing payload: %v", payload)
|
||||||
|
}
|
||||||
|
snapshot := make(map[string]struct{}, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
rowMap, ok := row.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected opnsense alias row payload: %T", row)
|
||||||
|
}
|
||||||
|
candidate := asString(rowMap["ip"])
|
||||||
|
if candidate == "" {
|
||||||
|
candidate = asString(rowMap["address"])
|
||||||
|
}
|
||||||
|
if candidate == "" {
|
||||||
|
candidate = asString(rowMap["item"])
|
||||||
|
}
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized, err := normalizeIP(candidate)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot[normalized] = struct{}{}
|
||||||
|
}
|
||||||
|
c.knownAliasIPs = snapshot
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureAliasExistsLocked(ctx context.Context) error {
|
||||||
|
if c.aliasUUID != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid, err := c.getAliasUUIDLocked(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if uuid == "" {
|
||||||
|
if !c.cfg.EnsureAlias {
|
||||||
|
return fmt.Errorf("opnsense alias %q does not exist and ensure_alias is disabled", c.cfg.Alias.Name)
|
||||||
|
}
|
||||||
|
if _, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasAddItem, nil, map[string]any{
|
||||||
|
"alias": map[string]string{
|
||||||
|
"enabled": "1",
|
||||||
|
"name": c.cfg.Alias.Name,
|
||||||
|
"type": c.cfg.Alias.Type,
|
||||||
|
"content": "",
|
||||||
|
"description": c.cfg.Alias.Description,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
uuid, err = c.getAliasUUIDLocked(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if uuid == "" {
|
||||||
|
return fmt.Errorf("unable to create opnsense alias %q", c.cfg.Alias.Name)
|
||||||
|
}
|
||||||
|
if _, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasSetItem, map[string]string{"uuid": uuid}, map[string]any{
|
||||||
|
"alias": map[string]string{
|
||||||
|
"enabled": "1",
|
||||||
|
"name": c.cfg.Alias.Name,
|
||||||
|
"type": c.cfg.Alias.Type,
|
||||||
|
"content": "",
|
||||||
|
"description": c.cfg.Alias.Description,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.reconfigureLocked(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.aliasUUID = uuid
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getAliasUUIDLocked(ctx context.Context) (string, error) {
|
||||||
|
payload, err := c.requestJSON(ctx, http.MethodGet, c.cfg.APIPaths.AliasGetUUID, map[string]string{"alias": c.cfg.Alias.Name}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(asString(payload["uuid"])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) reconfigureLocked(ctx context.Context) error {
|
||||||
|
payload, err := c.requestJSON(ctx, http.MethodPost, c.cfg.APIPaths.AliasReconfig, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status := strings.ToLower(strings.TrimSpace(asString(payload["status"])))
|
||||||
|
if status != "ok" && status != "done" {
|
||||||
|
return fmt.Errorf("opnsense alias reconfigure failed: %v", payload)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) requestJSON(ctx context.Context, method, pathTemplate string, pathValues map[string]string, body any) (map[string]any, error) {
|
||||||
|
requestURL, err := c.buildURL(pathTemplate, pathValues)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload io.Reader
|
||||||
|
if body != nil {
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode request body: %w", err)
|
||||||
|
}
|
||||||
|
payload = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, requestURL, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(c.cfg.APIKey, c.cfg.APISecret)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("perform request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
|
||||||
|
return nil, fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) buildURL(pathTemplate string, values map[string]string) (string, error) {
|
||||||
|
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
return "", fmt.Errorf("missing opnsense base url")
|
||||||
|
}
|
||||||
|
path := pathTemplate
|
||||||
|
for key, value := range values {
|
||||||
|
path = strings.ReplaceAll(path, "{"+key+"}", url.PathEscape(value))
|
||||||
|
}
|
||||||
|
return baseURL + path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIP(ip string) (string, error) {
|
||||||
|
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||||
|
if parsed == nil {
|
||||||
|
return "", fmt.Errorf("invalid ip address %q", ip)
|
||||||
|
}
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case fmt.Stringer:
|
||||||
|
return typed.String()
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/opnsense/client_test.go
Normal file
134
internal/opnsense/client_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package opnsense
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientCreatesAliasAndBlocksAndUnblocksIPs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
aliasUUID string
|
||||||
|
aliasExists bool
|
||||||
|
ips map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
backendState := &state{ips: map[string]struct{}{}}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != "key" || password != "secret" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
backendState.mu.Lock()
|
||||||
|
defer backendState.mu.Unlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/firewall/alias/get_alias_u_u_i_d/blocked-ips":
|
||||||
|
if backendState.aliasExists {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"uuid": backendState.aliasUUID})
|
||||||
|
} else {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"uuid": ""})
|
||||||
|
}
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/add_item":
|
||||||
|
backendState.aliasExists = true
|
||||||
|
backendState.aliasUUID = "uuid-1"
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/set_item/uuid-1":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/reconfigure":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/firewall/alias_util/list/blocked-ips":
|
||||||
|
rows := make([]map[string]string, 0, len(backendState.ips))
|
||||||
|
for ip := range backendState.ips {
|
||||||
|
rows = append(rows, map[string]string{"ip": ip})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"rows": rows})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias_util/add/blocked-ips":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backendState.ips[payload["address"]] = struct{}{}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "done"})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias_util/delete/blocked-ips":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(backendState.ips, payload["address"])
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "done"})
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(config.OPNsenseConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BaseURL: server.URL,
|
||||||
|
APIKey: "key",
|
||||||
|
APISecret: "secret",
|
||||||
|
EnsureAlias: true,
|
||||||
|
Timeout: config.Duration{Duration: time.Second},
|
||||||
|
Alias: config.AliasConfig{
|
||||||
|
Name: "blocked-ips",
|
||||||
|
Type: "host",
|
||||||
|
},
|
||||||
|
APIPaths: config.APIPathsConfig{
|
||||||
|
AliasGetUUID: "/api/firewall/alias/get_alias_u_u_i_d/{alias}",
|
||||||
|
AliasAddItem: "/api/firewall/alias/add_item",
|
||||||
|
AliasSetItem: "/api/firewall/alias/set_item/{uuid}",
|
||||||
|
AliasReconfig: "/api/firewall/alias/reconfigure",
|
||||||
|
AliasUtilList: "/api/firewall/alias_util/list/{alias}",
|
||||||
|
AliasUtilAdd: "/api/firewall/alias_util/add/{alias}",
|
||||||
|
AliasUtilDelete: "/api/firewall/alias_util/delete/{alias}",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if result, err := client.AddIPIfMissing(ctx, "203.0.113.10"); err != nil || result != "added" {
|
||||||
|
t.Fatalf("unexpected add result: result=%q err=%v", result, err)
|
||||||
|
}
|
||||||
|
if result, err := client.AddIPIfMissing(ctx, "203.0.113.10"); err != nil || result != "already_present" {
|
||||||
|
t.Fatalf("unexpected add replay result: result=%q err=%v", result, err)
|
||||||
|
}
|
||||||
|
present, err := client.IsIPPresent(ctx, "203.0.113.10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is ip present: %v", err)
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
t.Fatalf("expected IP to be present in alias")
|
||||||
|
}
|
||||||
|
if result, err := client.RemoveIPIfPresent(ctx, "203.0.113.10"); err != nil || result != "removed" {
|
||||||
|
t.Fatalf("unexpected remove result: result=%q err=%v", result, err)
|
||||||
|
}
|
||||||
|
if result, err := client.RemoveIPIfPresent(ctx, "203.0.113.10"); err != nil || result != "already_absent" {
|
||||||
|
t.Fatalf("unexpected remove replay result: result=%q err=%v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backendState.mu.Lock()
|
||||||
|
defer backendState.mu.Unlock()
|
||||||
|
if !backendState.aliasExists || backendState.aliasUUID == "" {
|
||||||
|
t.Fatalf("expected alias to exist after first add")
|
||||||
|
}
|
||||||
|
if len(backendState.ips) != 0 {
|
||||||
|
t.Fatalf("expected alias to be empty after remove, got %v", backendState.ips)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(backendState.aliasUUID) == "" {
|
||||||
|
t.Fatalf("expected alias uuid to be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
412
internal/service/service.go
Normal file
412
internal/service/service.go
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/caddylog"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/engine"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/opnsense"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *store.Store
|
||||||
|
evaluator *engine.Evaluator
|
||||||
|
blocker opnsense.AliasClient
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, db *store.Store, blocker opnsense.AliasClient, logger *log.Logger) *Service {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.New(io.Discard, "", 0)
|
||||||
|
}
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
store: db,
|
||||||
|
evaluator: engine.NewEvaluator(),
|
||||||
|
blocker: blocker,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Run(ctx context.Context) error {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, source := range s.cfg.Sources {
|
||||||
|
source := source
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
s.runSource(ctx, source)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
wg.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetOverview(ctx context.Context, limit int) (model.Overview, error) {
|
||||||
|
return s.store.GetOverview(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||||
|
return s.store.ListRecentEvents(ctx, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
|
||||||
|
return s.store.ListIPStates(ctx, limit, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetIPDetails(ctx context.Context, ip string) (model.IPDetails, error) {
|
||||||
|
normalized, err := normalizeIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPDetails{}, err
|
||||||
|
}
|
||||||
|
return s.store.GetIPDetails(ctx, normalized, 100, 100, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ForceBlock(ctx context.Context, ip string, actor string, reason string) error {
|
||||||
|
return s.applyManualOverride(ctx, ip, model.ManualOverrideForceBlock, model.IPStateBlocked, actor, defaultReason(reason, "manual block"), "block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ForceAllow(ctx context.Context, ip string, actor string, reason string) error {
|
||||||
|
return s.applyManualOverride(ctx, ip, model.ManualOverrideForceAllow, model.IPStateAllowed, actor, defaultReason(reason, "manual allow"), "unblock")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ClearOverride(ctx context.Context, ip string, actor string, reason string) error {
|
||||||
|
normalized, err := normalizeIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reason = defaultReason(reason, "manual override cleared")
|
||||||
|
state, err := s.store.ClearManualOverride(ctx, normalized, reason)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.AddDecision(ctx, &model.DecisionRecord{
|
||||||
|
EventID: state.LastEventID,
|
||||||
|
IP: normalized,
|
||||||
|
SourceName: state.LastSourceName,
|
||||||
|
Kind: "manual",
|
||||||
|
Action: model.DecisionActionNone,
|
||||||
|
Reason: reason,
|
||||||
|
Actor: defaultActor(actor),
|
||||||
|
Enforced: false,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) runSource(ctx context.Context, source config.SourceConfig) {
|
||||||
|
s.pollSource(ctx, source)
|
||||||
|
ticker := time.NewTicker(source.PollInterval.Duration)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pollSource(ctx, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) pollSource(ctx context.Context, source config.SourceConfig) {
|
||||||
|
lines, err := s.readNewLines(ctx, source)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("source %s: %v", source.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := s.cfg.Profiles[source.Profile]
|
||||||
|
for _, line := range lines {
|
||||||
|
record, err := caddylog.ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, caddylog.ErrEmptyLine) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.logger.Printf("source %s: parse line: %v", source.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status < profile.MinStatus || record.Status > profile.MaxStatus {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.processRecord(ctx, source, profile, record); err != nil {
|
||||||
|
s.logger.Printf("source %s: process record: %v", source.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) processRecord(ctx context.Context, source config.SourceConfig, profile config.ProfileConfig, record model.AccessLogRecord) error {
|
||||||
|
state, found, err := s.store.GetIPState(ctx, record.ClientIP)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
override := model.ManualOverrideNone
|
||||||
|
if found {
|
||||||
|
override = state.ManualOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
decision := s.evaluator.Evaluate(record, profile, override)
|
||||||
|
event := model.Event{
|
||||||
|
SourceName: source.Name,
|
||||||
|
ProfileName: source.Profile,
|
||||||
|
OccurredAt: record.OccurredAt,
|
||||||
|
RemoteIP: record.RemoteIP,
|
||||||
|
ClientIP: record.ClientIP,
|
||||||
|
Host: record.Host,
|
||||||
|
Method: record.Method,
|
||||||
|
URI: record.URI,
|
||||||
|
Path: record.Path,
|
||||||
|
Status: record.Status,
|
||||||
|
UserAgent: record.UserAgent,
|
||||||
|
Decision: decision.Action,
|
||||||
|
DecisionReason: decision.PrimaryReason(),
|
||||||
|
DecisionReasons: append([]string(nil), decision.Reasons...),
|
||||||
|
Enforced: false,
|
||||||
|
RawJSON: record.RawJSON,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var backendAction *model.OPNsenseAction
|
||||||
|
if decision.Action == model.DecisionActionBlock && s.blocker != nil {
|
||||||
|
result, blockErr := s.blocker.AddIPIfMissing(ctx, record.ClientIP)
|
||||||
|
backendAction = &model.OPNsenseAction{
|
||||||
|
IP: record.ClientIP,
|
||||||
|
Action: "block",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if blockErr != nil {
|
||||||
|
backendAction.Result = "error"
|
||||||
|
backendAction.Message = blockErr.Error()
|
||||||
|
} else {
|
||||||
|
backendAction.Result = result
|
||||||
|
backendAction.Message = decision.PrimaryReason()
|
||||||
|
event.Enforced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.RecordEvent(ctx, &event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if decision.Action != model.DecisionActionNone {
|
||||||
|
if err := s.store.AddDecision(ctx, &model.DecisionRecord{
|
||||||
|
EventID: event.ID,
|
||||||
|
IP: record.ClientIP,
|
||||||
|
SourceName: source.Name,
|
||||||
|
Kind: "automatic",
|
||||||
|
Action: decision.Action,
|
||||||
|
Reason: strings.Join(decision.Reasons, ", "),
|
||||||
|
Actor: "engine",
|
||||||
|
Enforced: event.Enforced,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if backendAction != nil {
|
||||||
|
if err := s.store.AddBackendAction(ctx, backendAction); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) readNewLines(ctx context.Context, source config.SourceConfig) ([]string, error) {
|
||||||
|
info, err := os.Stat(source.Path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("stat source path %q: %w", source.Path, err)
|
||||||
|
}
|
||||||
|
inode := fileIdentity(info)
|
||||||
|
size := info.Size()
|
||||||
|
|
||||||
|
offset, found, err := s.store.GetSourceOffset(ctx, source.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
start := int64(0)
|
||||||
|
if source.InitialPosition == "end" {
|
||||||
|
start = size
|
||||||
|
}
|
||||||
|
offset = model.SourceOffset{
|
||||||
|
SourceName: source.Name,
|
||||||
|
Path: source.Path,
|
||||||
|
Inode: inode,
|
||||||
|
Offset: start,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := s.store.SaveSourceOffset(ctx, offset); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if start >= size {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
} else if offset.Inode != inode || size < offset.Offset {
|
||||||
|
offset = model.SourceOffset{
|
||||||
|
SourceName: source.Name,
|
||||||
|
Path: source.Path,
|
||||||
|
Inode: inode,
|
||||||
|
Offset: 0,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(source.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open source path %q: %w", source.Path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if _, err := file.Seek(offset.Offset, io.SeekStart); err != nil {
|
||||||
|
return nil, fmt.Errorf("seek source path %q: %w", source.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(file)
|
||||||
|
lines := make([]string, 0, source.BatchSize)
|
||||||
|
currentOffset := offset.Offset
|
||||||
|
|
||||||
|
for len(lines) < source.BatchSize {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read source path %q: %w", source.Path, err)
|
||||||
|
}
|
||||||
|
currentOffset += int64(len(line))
|
||||||
|
lines = append(lines, strings.TrimRight(line, "\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
offset.Path = source.Path
|
||||||
|
offset.Inode = inode
|
||||||
|
offset.Offset = currentOffset
|
||||||
|
offset.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.store.SaveSourceOffset(ctx, offset); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) applyManualOverride(ctx context.Context, ip string, override model.ManualOverride, state model.IPStateStatus, actor string, reason string, backendAction string) error {
|
||||||
|
normalized, err := normalizeIP(ip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
enforced := false
|
||||||
|
var backendRecord *model.OPNsenseAction
|
||||||
|
if s.blocker != nil {
|
||||||
|
backendRecord = &model.OPNsenseAction{
|
||||||
|
IP: normalized,
|
||||||
|
Action: backendAction,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
switch override {
|
||||||
|
case model.ManualOverrideForceBlock:
|
||||||
|
result, callErr := s.blocker.AddIPIfMissing(ctx, normalized)
|
||||||
|
if callErr != nil {
|
||||||
|
backendRecord.Result = "error"
|
||||||
|
backendRecord.Message = callErr.Error()
|
||||||
|
} else {
|
||||||
|
backendRecord.Result = result
|
||||||
|
backendRecord.Message = reason
|
||||||
|
enforced = true
|
||||||
|
}
|
||||||
|
case model.ManualOverrideForceAllow:
|
||||||
|
result, callErr := s.blocker.RemoveIPIfPresent(ctx, normalized)
|
||||||
|
if callErr != nil {
|
||||||
|
backendRecord.Result = "error"
|
||||||
|
backendRecord.Message = callErr.Error()
|
||||||
|
} else {
|
||||||
|
backendRecord.Result = result
|
||||||
|
backendRecord.Message = reason
|
||||||
|
enforced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.store.SetManualOverride(ctx, normalized, override, state, reason)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.AddDecision(ctx, &model.DecisionRecord{
|
||||||
|
EventID: current.LastEventID,
|
||||||
|
IP: normalized,
|
||||||
|
SourceName: current.LastSourceName,
|
||||||
|
Kind: "manual",
|
||||||
|
Action: actionForOverride(override),
|
||||||
|
Reason: reason,
|
||||||
|
Actor: defaultActor(actor),
|
||||||
|
Enforced: enforced,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if backendRecord != nil {
|
||||||
|
if err := s.store.AddBackendAction(ctx, backendRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIP(ip string) (string, error) {
|
||||||
|
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||||
|
if parsed == nil {
|
||||||
|
return "", fmt.Errorf("invalid ip address %q", ip)
|
||||||
|
}
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileIdentity(info os.FileInfo) string {
|
||||||
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("fallback:%d:%d", info.ModTime().UnixNano(), info.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionForOverride(override model.ManualOverride) model.DecisionAction {
|
||||||
|
switch override {
|
||||||
|
case model.ManualOverrideForceBlock:
|
||||||
|
return model.DecisionActionBlock
|
||||||
|
case model.ManualOverrideForceAllow:
|
||||||
|
return model.DecisionActionAllow
|
||||||
|
default:
|
||||||
|
return model.DecisionActionNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultActor(actor string) string {
|
||||||
|
if strings.TrimSpace(actor) == "" {
|
||||||
|
return "web-ui"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultReason(reason string, fallback string) string {
|
||||||
|
if strings.TrimSpace(reason) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(reason)
|
||||||
|
}
|
||||||
247
internal/service/service_test.go
Normal file
247
internal/service/service_test.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/opnsense"
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServiceProcessesMultipleSourcesAndManualActions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
mainLogPath := filepath.Join(tempDir, "main.log")
|
||||||
|
giteaLogPath := filepath.Join(tempDir, "gitea.log")
|
||||||
|
if err := os.WriteFile(mainLogPath, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("create main log: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(giteaLogPath, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("create gitea log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backend := newFakeOPNsenseServer(t)
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
configPath := filepath.Join(tempDir, "config.yaml")
|
||||||
|
payload := fmt.Sprintf(`storage:
|
||||||
|
path: %s/blocker.db
|
||||||
|
opnsense:
|
||||||
|
enabled: true
|
||||||
|
base_url: %s
|
||||||
|
api_key: key
|
||||||
|
api_secret: secret
|
||||||
|
ensure_alias: true
|
||||||
|
alias:
|
||||||
|
name: blocked-ips
|
||||||
|
profiles:
|
||||||
|
main:
|
||||||
|
auto_block: true
|
||||||
|
block_unexpected_posts: true
|
||||||
|
block_php_paths: true
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /wp-login.php
|
||||||
|
gitea:
|
||||||
|
auto_block: false
|
||||||
|
block_unexpected_posts: true
|
||||||
|
allowed_post_paths:
|
||||||
|
- /user/login
|
||||||
|
suspicious_path_prefixes:
|
||||||
|
- /install.php
|
||||||
|
sources:
|
||||||
|
- name: main
|
||||||
|
path: %s
|
||||||
|
profile: main
|
||||||
|
initial_position: beginning
|
||||||
|
poll_interval: 20ms
|
||||||
|
batch_size: 128
|
||||||
|
- name: gitea
|
||||||
|
path: %s
|
||||||
|
profile: gitea
|
||||||
|
initial_position: beginning
|
||||||
|
poll_interval: 20ms
|
||||||
|
batch_size: 128
|
||||||
|
`, tempDir, backend.URL, mainLogPath, giteaLogPath)
|
||||||
|
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
database, err := store.Open(cfg.Storage.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
svc := New(cfg, database, opnsense.NewClient(cfg.OPNsense), log.New(os.Stderr, "", 0))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go func() { _ = svc.Run(ctx) }()
|
||||||
|
|
||||||
|
appendLine(t, mainLogPath, caddyJSONLine("203.0.113.10", "198.51.100.10", "example.test", "GET", "/wp-login.php", 404, "curl/8.0", time.Now().UTC()))
|
||||||
|
appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.11", "198.51.100.11", "git.example.test", "POST", "/user/login", 401, "curl/8.0", time.Now().UTC()))
|
||||||
|
appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.12", "198.51.100.12", "git.example.test", "GET", "/install.php", 404, "curl/8.0", time.Now().UTC()))
|
||||||
|
|
||||||
|
waitFor(t, 3*time.Second, func() bool {
|
||||||
|
overview, err := database.GetOverview(context.Background(), 10)
|
||||||
|
return err == nil && overview.TotalEvents == 3
|
||||||
|
})
|
||||||
|
|
||||||
|
blockedState, found, err := database.GetIPState(context.Background(), "203.0.113.10")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("load blocked state: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if blockedState.State != model.IPStateBlocked {
|
||||||
|
t.Fatalf("expected blocked state, got %+v", blockedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
reviewState, found, err := database.GetIPState(context.Background(), "203.0.113.12")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("load review state: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if reviewState.State != model.IPStateReview {
|
||||||
|
t.Fatalf("expected review state, got %+v", reviewState)
|
||||||
|
}
|
||||||
|
|
||||||
|
observedState, found, err := database.GetIPState(context.Background(), "203.0.113.11")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("load observed state: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if observedState.State != model.IPStateObserved {
|
||||||
|
t.Fatalf("expected observed state, got %+v", observedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.ForceAllow(context.Background(), "203.0.113.10", "test", "manual unblock"); err != nil {
|
||||||
|
t.Fatalf("force allow: %v", err)
|
||||||
|
}
|
||||||
|
state, found, err := database.GetIPState(context.Background(), "203.0.113.10")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("reload unblocked state: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if state.ManualOverride != model.ManualOverrideForceAllow || state.State != model.IPStateAllowed {
|
||||||
|
t.Fatalf("unexpected manual allow state: %+v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.mu.Lock()
|
||||||
|
defer backend.mu.Unlock()
|
||||||
|
if _, ok := backend.ips["203.0.113.10"]; ok {
|
||||||
|
t.Fatalf("expected IP to be removed from backend alias after manual unblock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeOPNsenseServer struct {
|
||||||
|
*httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
aliasUUID string
|
||||||
|
aliasExists bool
|
||||||
|
ips map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeOPNsenseServer(t *testing.T) *fakeOPNsenseServer {
|
||||||
|
t.Helper()
|
||||||
|
backend := &fakeOPNsenseServer{ips: map[string]struct{}{}}
|
||||||
|
backend.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok || username != "key" || password != "secret" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
backend.mu.Lock()
|
||||||
|
defer backend.mu.Unlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/firewall/alias/get_alias_u_u_i_d/blocked-ips":
|
||||||
|
if backend.aliasExists {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"uuid": backend.aliasUUID})
|
||||||
|
} else {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"uuid": ""})
|
||||||
|
}
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/add_item":
|
||||||
|
backend.aliasExists = true
|
||||||
|
backend.aliasUUID = "uuid-1"
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/set_item/uuid-1":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/reconfigure":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/firewall/alias_util/list/blocked-ips":
|
||||||
|
rows := make([]map[string]string, 0, len(backend.ips))
|
||||||
|
for ip := range backend.ips {
|
||||||
|
rows = append(rows, map[string]string{"ip": ip})
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"rows": rows})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias_util/add/blocked-ips":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backend.ips[payload["address"]] = struct{}{}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "done"})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias_util/delete/blocked-ips":
|
||||||
|
var payload map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(backend.ips, payload["address"])
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "done"})
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendLine(t *testing.T, path string, line string) {
|
||||||
|
t.Helper()
|
||||||
|
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open log file for append: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
if _, err := file.WriteString(line + "\n"); err != nil {
|
||||||
|
t.Fatalf("append log line: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caddyJSONLine(clientIP string, remoteIP string, host string, method string, uri string, status int, userAgent string, occurredAt time.Time) string {
|
||||||
|
return fmt.Sprintf(`{"ts":%q,"status":%d,"request":{"remote_ip":%q,"client_ip":%q,"host":%q,"method":%q,"uri":%q,"headers":{"User-Agent":[%q]}}}`,
|
||||||
|
occurredAt.UTC().Format(time.RFC3339Nano),
|
||||||
|
status,
|
||||||
|
remoteIP,
|
||||||
|
clientIP,
|
||||||
|
host,
|
||||||
|
method,
|
||||||
|
uri,
|
||||||
|
userAgent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if condition() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("condition was not met within %s", timeout)
|
||||||
|
}
|
||||||
961
internal/store/store.go
Normal file
961
internal/store/store.go
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
profile_name TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
remote_ip TEXT NOT NULL,
|
||||||
|
client_ip TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
uri TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL,
|
||||||
|
decision TEXT NOT NULL,
|
||||||
|
decision_reason TEXT NOT NULL,
|
||||||
|
decision_reasons_json TEXT NOT NULL,
|
||||||
|
enforced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_client_ip ON events(client_ip, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_source_name ON events(source_name, occurred_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_decision ON events(decision, occurred_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ip_state (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
first_seen_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
last_source_name TEXT NOT NULL,
|
||||||
|
last_user_agent TEXT NOT NULL,
|
||||||
|
latest_status INTEGER NOT NULL,
|
||||||
|
total_events INTEGER NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
state_reason TEXT NOT NULL,
|
||||||
|
manual_override TEXT NOT NULL,
|
||||||
|
last_event_id INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ip_state_last_seen ON ip_state(last_seen_at DESC, ip ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ip_state_state ON ip_state(state, last_seen_at DESC, ip ASC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id INTEGER NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
enforced INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_ip ON decisions(ip, created_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_event_id ON decisions(event_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backend_actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backend_actions_ip ON backend_actions(ip, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS source_offsets (
|
||||||
|
source_name TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
inode TEXT NOT NULL,
|
||||||
|
offset INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create storage directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for _, statement := range []string{
|
||||||
|
"PRAGMA journal_mode = WAL;",
|
||||||
|
"PRAGMA busy_timeout = 5000;",
|
||||||
|
"PRAGMA foreign_keys = ON;",
|
||||||
|
} {
|
||||||
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("apply sqlite pragma %q: %w", statement, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, schema); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("apply sqlite schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error {
|
||||||
|
if event == nil {
|
||||||
|
return errors.New("nil event")
|
||||||
|
}
|
||||||
|
if event.OccurredAt.IsZero() {
|
||||||
|
event.OccurredAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if event.CreatedAt.IsZero() {
|
||||||
|
event.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
encodedReasons, err := json.Marshal(event.DecisionReasons)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode decision reasons: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
state, found, err := getIPStateTx(tx, event.ClientIP)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO events (
|
||||||
|
source_name, profile_name, occurred_at, remote_ip, client_ip, host, method, uri, path,
|
||||||
|
status, user_agent, decision, decision_reason, decision_reasons_json, enforced, raw_json, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
event.SourceName,
|
||||||
|
event.ProfileName,
|
||||||
|
formatTime(event.OccurredAt),
|
||||||
|
event.RemoteIP,
|
||||||
|
event.ClientIP,
|
||||||
|
event.Host,
|
||||||
|
event.Method,
|
||||||
|
event.URI,
|
||||||
|
event.Path,
|
||||||
|
event.Status,
|
||||||
|
event.UserAgent,
|
||||||
|
string(event.Decision),
|
||||||
|
event.DecisionReason,
|
||||||
|
string(encodedReasons),
|
||||||
|
boolToInt(event.Enforced),
|
||||||
|
event.RawJSON,
|
||||||
|
formatTime(event.CreatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert event: %w", err)
|
||||||
|
}
|
||||||
|
eventID, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load inserted event id: %w", err)
|
||||||
|
}
|
||||||
|
event.ID = eventID
|
||||||
|
|
||||||
|
updatedState := mergeEventIntoState(state, found, *event)
|
||||||
|
event.CurrentState = updatedState.State
|
||||||
|
event.ManualOverride = updatedState.ManualOverride
|
||||||
|
|
||||||
|
if err := upsertIPStateTx(ctx, tx, updatedState); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit event transaction: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddDecision(ctx context.Context, decision *model.DecisionRecord) error {
|
||||||
|
if decision == nil {
|
||||||
|
return errors.New("nil decision record")
|
||||||
|
}
|
||||||
|
if decision.CreatedAt.IsZero() {
|
||||||
|
decision.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO decisions (event_id, ip, source_name, kind, action, reason, actor, enforced, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
decision.EventID,
|
||||||
|
decision.IP,
|
||||||
|
decision.SourceName,
|
||||||
|
decision.Kind,
|
||||||
|
string(decision.Action),
|
||||||
|
decision.Reason,
|
||||||
|
decision.Actor,
|
||||||
|
boolToInt(decision.Enforced),
|
||||||
|
formatTime(decision.CreatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert decision record: %w", err)
|
||||||
|
}
|
||||||
|
decision.ID, err = result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load inserted decision id: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddBackendAction(ctx context.Context, action *model.OPNsenseAction) error {
|
||||||
|
if action == nil {
|
||||||
|
return errors.New("nil backend action")
|
||||||
|
}
|
||||||
|
if action.CreatedAt.IsZero() {
|
||||||
|
action.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
result, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO backend_actions (ip, action, result, message, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
action.IP,
|
||||||
|
action.Action,
|
||||||
|
action.Result,
|
||||||
|
action.Message,
|
||||||
|
formatTime(action.CreatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert backend action: %w", err)
|
||||||
|
}
|
||||||
|
action.ID, err = result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load inserted backend action id: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetIPState(ctx context.Context, ip string) (model.IPState, bool, error) {
|
||||||
|
return getIPStateDB(ctx, s.db, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetManualOverride(ctx context.Context, ip string, override model.ManualOverride, state model.IPStateStatus, reason string) (model.IPState, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
current, found, err := getIPStateTx(tx, ip)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !found {
|
||||||
|
current = model.IPState{
|
||||||
|
IP: ip,
|
||||||
|
FirstSeenAt: now,
|
||||||
|
LastSeenAt: now,
|
||||||
|
LastSourceName: "",
|
||||||
|
LastUserAgent: "",
|
||||||
|
LatestStatus: 0,
|
||||||
|
TotalEvents: 0,
|
||||||
|
State: state,
|
||||||
|
StateReason: strings.TrimSpace(reason),
|
||||||
|
ManualOverride: override,
|
||||||
|
LastEventID: 0,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.ManualOverride = override
|
||||||
|
current.State = state
|
||||||
|
if strings.TrimSpace(reason) != "" {
|
||||||
|
current.StateReason = strings.TrimSpace(reason)
|
||||||
|
}
|
||||||
|
current.UpdatedAt = now
|
||||||
|
}
|
||||||
|
if err := upsertIPStateTx(ctx, tx, current); err != nil {
|
||||||
|
return model.IPState{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ClearManualOverride(ctx context.Context, ip string, reason string) (model.IPState, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
current, found, err := getIPStateTx(tx, ip)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !found {
|
||||||
|
current = model.IPState{
|
||||||
|
IP: ip,
|
||||||
|
FirstSeenAt: now,
|
||||||
|
LastSeenAt: now,
|
||||||
|
State: model.IPStateObserved,
|
||||||
|
StateReason: strings.TrimSpace(reason),
|
||||||
|
ManualOverride: model.ManualOverrideNone,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.ManualOverride = model.ManualOverrideNone
|
||||||
|
if current.State == "" {
|
||||||
|
current.State = model.IPStateObserved
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(reason) != "" {
|
||||||
|
current.StateReason = strings.TrimSpace(reason)
|
||||||
|
}
|
||||||
|
current.UpdatedAt = now
|
||||||
|
}
|
||||||
|
if err := upsertIPStateTx(ctx, tx, current); err != nil {
|
||||||
|
return model.IPState{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetOverview(ctx context.Context, limit int) (model.Overview, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
var overview model.Overview
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events`).Scan(&overview.TotalEvents); err != nil {
|
||||||
|
return model.Overview{}, fmt.Errorf("count events: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_state`).Scan(&overview.TotalIPs); err != nil {
|
||||||
|
return model.Overview{}, fmt.Errorf("count ip states: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_state WHERE state = ?`, string(model.IPStateBlocked)).Scan(&overview.BlockedIPs); err != nil {
|
||||||
|
return model.Overview{}, fmt.Errorf("count blocked ip states: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_state WHERE state = ?`, string(model.IPStateReview)).Scan(&overview.ReviewIPs); err != nil {
|
||||||
|
return model.Overview{}, fmt.Errorf("count review ip states: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_state WHERE state = ?`, string(model.IPStateAllowed)).Scan(&overview.AllowedIPs); err != nil {
|
||||||
|
return model.Overview{}, fmt.Errorf("count allowed ip states: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_state WHERE state = ?`, string(model.IPStateObserved)).Scan(&overview.ObservedIPs); err != nil {
|
||||||
|
return model.Overview{}, fmt.Errorf("count observed ip states: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recentIPs, err := s.ListIPStates(ctx, limit, "")
|
||||||
|
if err != nil {
|
||||||
|
return model.Overview{}, err
|
||||||
|
}
|
||||||
|
recentEvents, err := s.ListRecentEvents(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return model.Overview{}, err
|
||||||
|
}
|
||||||
|
overview.RecentIPs = recentIPs
|
||||||
|
overview.RecentEvents = recentEvents
|
||||||
|
return overview, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT e.id, e.source_name, e.profile_name, e.occurred_at, e.remote_ip, e.client_ip, e.host,
|
||||||
|
e.method, e.uri, e.path, e.status, e.user_agent, e.decision, e.decision_reason,
|
||||||
|
e.decision_reasons_json, e.enforced, e.raw_json, e.created_at,
|
||||||
|
COALESCE(s.state, ''), COALESCE(s.manual_override, '')
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN ip_state s ON s.ip = e.client_ip
|
||||||
|
ORDER BY e.occurred_at DESC, e.id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list recent events: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]model.Event, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate recent events: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListIPStates(ctx context.Context, limit int, stateFilter string) ([]model.IPState, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
query := `SELECT ip, first_seen_at, last_seen_at, last_source_name, last_user_agent, latest_status,
|
||||||
|
total_events, state, state_reason, manual_override, last_event_id, updated_at
|
||||||
|
FROM ip_state`
|
||||||
|
args := []any{}
|
||||||
|
if strings.TrimSpace(stateFilter) != "" {
|
||||||
|
query += ` WHERE state = ?`
|
||||||
|
args = append(args, strings.TrimSpace(stateFilter))
|
||||||
|
}
|
||||||
|
query += ` ORDER BY last_seen_at DESC, ip ASC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list ip states: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]model.IPState, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanIPState(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate ip states: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetIPDetails(ctx context.Context, ip string, eventLimit, decisionLimit, actionLimit int) (model.IPDetails, error) {
|
||||||
|
state, _, err := s.GetIPState(ctx, ip)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPDetails{}, err
|
||||||
|
}
|
||||||
|
events, err := s.listEventsForIP(ctx, ip, eventLimit)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPDetails{}, err
|
||||||
|
}
|
||||||
|
decisions, err := s.listDecisionsForIP(ctx, ip, decisionLimit)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPDetails{}, err
|
||||||
|
}
|
||||||
|
actions, err := s.listBackendActionsForIP(ctx, ip, actionLimit)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPDetails{}, err
|
||||||
|
}
|
||||||
|
return model.IPDetails{
|
||||||
|
State: state,
|
||||||
|
RecentEvents: events,
|
||||||
|
Decisions: decisions,
|
||||||
|
BackendActions: actions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSourceOffset(ctx context.Context, sourceName string) (model.SourceOffset, bool, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `SELECT source_name, path, inode, offset, updated_at FROM source_offsets WHERE source_name = ?`, sourceName)
|
||||||
|
var offset model.SourceOffset
|
||||||
|
var updatedAt string
|
||||||
|
if err := row.Scan(&offset.SourceName, &offset.Path, &offset.Inode, &offset.Offset, &updatedAt); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return model.SourceOffset{}, false, nil
|
||||||
|
}
|
||||||
|
return model.SourceOffset{}, false, fmt.Errorf("query source offset %q: %w", sourceName, err)
|
||||||
|
}
|
||||||
|
parsed, err := parseTime(updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.SourceOffset{}, false, fmt.Errorf("parse source offset updated_at: %w", err)
|
||||||
|
}
|
||||||
|
offset.UpdatedAt = parsed
|
||||||
|
return offset, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveSourceOffset(ctx context.Context, offset model.SourceOffset) error {
|
||||||
|
if offset.UpdatedAt.IsZero() {
|
||||||
|
offset.UpdatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO source_offsets (source_name, path, inode, offset, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(source_name) DO UPDATE SET
|
||||||
|
path = excluded.path,
|
||||||
|
inode = excluded.inode,
|
||||||
|
offset = excluded.offset,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
offset.SourceName,
|
||||||
|
offset.Path,
|
||||||
|
offset.Inode,
|
||||||
|
offset.Offset,
|
||||||
|
formatTime(offset.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert source offset: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listEventsForIP(ctx context.Context, ip string, limit int) ([]model.Event, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT e.id, e.source_name, e.profile_name, e.occurred_at, e.remote_ip, e.client_ip, e.host,
|
||||||
|
e.method, e.uri, e.path, e.status, e.user_agent, e.decision, e.decision_reason,
|
||||||
|
e.decision_reasons_json, e.enforced, e.raw_json, e.created_at,
|
||||||
|
COALESCE(s.state, ''), COALESCE(s.manual_override, '')
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN ip_state s ON s.ip = e.client_ip
|
||||||
|
WHERE e.client_ip = ?
|
||||||
|
ORDER BY e.occurred_at DESC, e.id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
ip,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list events for ip %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]model.Event, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanEvent(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate events for ip %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listDecisionsForIP(ctx context.Context, ip string, limit int) ([]model.DecisionRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, event_id, ip, source_name, kind, action, reason, actor, enforced, created_at
|
||||||
|
FROM decisions
|
||||||
|
WHERE ip = ?
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
ip,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list decisions for ip %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]model.DecisionRecord, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var item model.DecisionRecord
|
||||||
|
var action string
|
||||||
|
var enforced int
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&item.ID, &item.EventID, &item.IP, &item.SourceName, &item.Kind, &action, &item.Reason, &item.Actor, &enforced, &createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan decision record: %w", err)
|
||||||
|
}
|
||||||
|
parsed, err := parseTime(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse decision created_at: %w", err)
|
||||||
|
}
|
||||||
|
item.Action = model.DecisionAction(action)
|
||||||
|
item.Enforced = enforced != 0
|
||||||
|
item.CreatedAt = parsed
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate decisions for ip %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listBackendActionsForIP(ctx context.Context, ip string, limit int) ([]model.OPNsenseAction, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, ip, action, result, message, created_at
|
||||||
|
FROM backend_actions
|
||||||
|
WHERE ip = ?
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
ip,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list backend actions for ip %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]model.OPNsenseAction, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var item model.OPNsenseAction
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&item.ID, &item.IP, &item.Action, &item.Result, &item.Message, &createdAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan backend action: %w", err)
|
||||||
|
}
|
||||||
|
parsed, err := parseTime(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse backend action created_at: %w", err)
|
||||||
|
}
|
||||||
|
item.CreatedAt = parsed
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate backend actions for ip %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIPStateDB(ctx context.Context, db queryer, ip string) (model.IPState, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `
|
||||||
|
SELECT ip, first_seen_at, last_seen_at, last_source_name, last_user_agent, latest_status,
|
||||||
|
total_events, state, state_reason, manual_override, last_event_id, updated_at
|
||||||
|
FROM ip_state WHERE ip = ?`, ip)
|
||||||
|
|
||||||
|
var item model.IPState
|
||||||
|
var firstSeenAt string
|
||||||
|
var lastSeenAt string
|
||||||
|
var updatedAt string
|
||||||
|
var state string
|
||||||
|
var manualOverride string
|
||||||
|
if err := row.Scan(
|
||||||
|
&item.IP,
|
||||||
|
&firstSeenAt,
|
||||||
|
&lastSeenAt,
|
||||||
|
&item.LastSourceName,
|
||||||
|
&item.LastUserAgent,
|
||||||
|
&item.LatestStatus,
|
||||||
|
&item.TotalEvents,
|
||||||
|
&state,
|
||||||
|
&item.StateReason,
|
||||||
|
&manualOverride,
|
||||||
|
&item.LastEventID,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return model.IPState{}, false, nil
|
||||||
|
}
|
||||||
|
return model.IPState{}, false, fmt.Errorf("query ip state %q: %w", ip, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
item.FirstSeenAt, err = parseTime(firstSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, false, fmt.Errorf("parse ip state first_seen_at: %w", err)
|
||||||
|
}
|
||||||
|
item.LastSeenAt, err = parseTime(lastSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, false, fmt.Errorf("parse ip state last_seen_at: %w", err)
|
||||||
|
}
|
||||||
|
item.UpdatedAt, err = parseTime(updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, false, fmt.Errorf("parse ip state updated_at: %w", err)
|
||||||
|
}
|
||||||
|
item.State = model.IPStateStatus(state)
|
||||||
|
item.ManualOverride = model.ManualOverride(manualOverride)
|
||||||
|
return item, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIPStateTx(tx *sql.Tx, ip string) (model.IPState, bool, error) {
|
||||||
|
return getIPStateDB(context.Background(), tx, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertIPStateTx(ctx context.Context, tx *sql.Tx, state model.IPState) error {
|
||||||
|
if state.UpdatedAt.IsZero() {
|
||||||
|
state.UpdatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if state.FirstSeenAt.IsZero() {
|
||||||
|
state.FirstSeenAt = state.UpdatedAt
|
||||||
|
}
|
||||||
|
if state.LastSeenAt.IsZero() {
|
||||||
|
state.LastSeenAt = state.UpdatedAt
|
||||||
|
}
|
||||||
|
if state.State == "" {
|
||||||
|
state.State = model.IPStateObserved
|
||||||
|
}
|
||||||
|
if state.ManualOverride == "" {
|
||||||
|
state.ManualOverride = model.ManualOverrideNone
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO ip_state (
|
||||||
|
ip, first_seen_at, last_seen_at, last_source_name, last_user_agent, latest_status,
|
||||||
|
total_events, state, state_reason, manual_override, last_event_id, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
first_seen_at = excluded.first_seen_at,
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
last_source_name = excluded.last_source_name,
|
||||||
|
last_user_agent = excluded.last_user_agent,
|
||||||
|
latest_status = excluded.latest_status,
|
||||||
|
total_events = excluded.total_events,
|
||||||
|
state = excluded.state,
|
||||||
|
state_reason = excluded.state_reason,
|
||||||
|
manual_override = excluded.manual_override,
|
||||||
|
last_event_id = excluded.last_event_id,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
state.IP,
|
||||||
|
formatTime(state.FirstSeenAt),
|
||||||
|
formatTime(state.LastSeenAt),
|
||||||
|
state.LastSourceName,
|
||||||
|
state.LastUserAgent,
|
||||||
|
state.LatestStatus,
|
||||||
|
state.TotalEvents,
|
||||||
|
string(state.State),
|
||||||
|
state.StateReason,
|
||||||
|
string(state.ManualOverride),
|
||||||
|
state.LastEventID,
|
||||||
|
formatTime(state.UpdatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert ip state %q: %w", state.IP, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeEventIntoState(existing model.IPState, found bool, event model.Event) model.IPState {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
state := existing
|
||||||
|
if !found {
|
||||||
|
state = model.IPState{
|
||||||
|
IP: event.ClientIP,
|
||||||
|
FirstSeenAt: event.OccurredAt,
|
||||||
|
LastSeenAt: event.OccurredAt,
|
||||||
|
LastSourceName: event.SourceName,
|
||||||
|
LastUserAgent: event.UserAgent,
|
||||||
|
LatestStatus: event.Status,
|
||||||
|
TotalEvents: 0,
|
||||||
|
State: model.IPStateObserved,
|
||||||
|
StateReason: "",
|
||||||
|
ManualOverride: model.ManualOverrideNone,
|
||||||
|
LastEventID: 0,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.FirstSeenAt.IsZero() || event.OccurredAt.Before(state.FirstSeenAt) {
|
||||||
|
state.FirstSeenAt = event.OccurredAt
|
||||||
|
}
|
||||||
|
if state.LastSeenAt.IsZero() || event.OccurredAt.After(state.LastSeenAt) {
|
||||||
|
state.LastSeenAt = event.OccurredAt
|
||||||
|
}
|
||||||
|
state.LastSourceName = event.SourceName
|
||||||
|
state.LastUserAgent = event.UserAgent
|
||||||
|
state.LatestStatus = event.Status
|
||||||
|
state.TotalEvents++
|
||||||
|
state.LastEventID = event.ID
|
||||||
|
state.UpdatedAt = now
|
||||||
|
if state.ManualOverride == "" {
|
||||||
|
state.ManualOverride = model.ManualOverrideNone
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state.ManualOverride {
|
||||||
|
case model.ManualOverrideForceBlock:
|
||||||
|
state.State = model.IPStateBlocked
|
||||||
|
if event.DecisionReason != "" {
|
||||||
|
state.StateReason = event.DecisionReason
|
||||||
|
} else if state.StateReason == "" {
|
||||||
|
state.StateReason = "manual override: block"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
case model.ManualOverrideForceAllow:
|
||||||
|
state.State = model.IPStateAllowed
|
||||||
|
if event.DecisionReason != "" {
|
||||||
|
state.StateReason = event.DecisionReason
|
||||||
|
} else if state.StateReason == "" {
|
||||||
|
state.StateReason = "manual override: allow"
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.Decision {
|
||||||
|
case model.DecisionActionBlock:
|
||||||
|
state.State = model.IPStateBlocked
|
||||||
|
state.StateReason = event.DecisionReason
|
||||||
|
case model.DecisionActionReview:
|
||||||
|
if state.State != model.IPStateBlocked && state.State != model.IPStateAllowed {
|
||||||
|
state.State = model.IPStateReview
|
||||||
|
state.StateReason = event.DecisionReason
|
||||||
|
}
|
||||||
|
case model.DecisionActionAllow:
|
||||||
|
state.State = model.IPStateAllowed
|
||||||
|
state.StateReason = event.DecisionReason
|
||||||
|
default:
|
||||||
|
if state.State == "" {
|
||||||
|
state.State = model.IPStateObserved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanEvent(scanner interface{ Scan(dest ...any) error }) (model.Event, error) {
|
||||||
|
var item model.Event
|
||||||
|
var occurredAt string
|
||||||
|
var createdAt string
|
||||||
|
var decision string
|
||||||
|
var decisionReasonsJSON string
|
||||||
|
var enforced int
|
||||||
|
var currentState string
|
||||||
|
var manualOverride string
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&item.ID,
|
||||||
|
&item.SourceName,
|
||||||
|
&item.ProfileName,
|
||||||
|
&occurredAt,
|
||||||
|
&item.RemoteIP,
|
||||||
|
&item.ClientIP,
|
||||||
|
&item.Host,
|
||||||
|
&item.Method,
|
||||||
|
&item.URI,
|
||||||
|
&item.Path,
|
||||||
|
&item.Status,
|
||||||
|
&item.UserAgent,
|
||||||
|
&decision,
|
||||||
|
&item.DecisionReason,
|
||||||
|
&decisionReasonsJSON,
|
||||||
|
&enforced,
|
||||||
|
&item.RawJSON,
|
||||||
|
&createdAt,
|
||||||
|
¤tState,
|
||||||
|
&manualOverride,
|
||||||
|
); err != nil {
|
||||||
|
return model.Event{}, fmt.Errorf("scan event: %w", err)
|
||||||
|
}
|
||||||
|
parsedOccurredAt, err := parseTime(occurredAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.Event{}, fmt.Errorf("parse event occurred_at: %w", err)
|
||||||
|
}
|
||||||
|
parsedCreatedAt, err := parseTime(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.Event{}, fmt.Errorf("parse event created_at: %w", err)
|
||||||
|
}
|
||||||
|
var reasons []string
|
||||||
|
if strings.TrimSpace(decisionReasonsJSON) != "" {
|
||||||
|
if err := json.Unmarshal([]byte(decisionReasonsJSON), &reasons); err != nil {
|
||||||
|
return model.Event{}, fmt.Errorf("decode event decision_reasons_json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.OccurredAt = parsedOccurredAt
|
||||||
|
item.CreatedAt = parsedCreatedAt
|
||||||
|
item.Decision = model.DecisionAction(decision)
|
||||||
|
item.DecisionReasons = reasons
|
||||||
|
item.Enforced = enforced != 0
|
||||||
|
item.CurrentState = model.IPStateStatus(currentState)
|
||||||
|
item.ManualOverride = model.ManualOverride(manualOverride)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanIPState(scanner interface{ Scan(dest ...any) error }) (model.IPState, error) {
|
||||||
|
var item model.IPState
|
||||||
|
var firstSeenAt string
|
||||||
|
var lastSeenAt string
|
||||||
|
var updatedAt string
|
||||||
|
var state string
|
||||||
|
var manualOverride string
|
||||||
|
if err := scanner.Scan(
|
||||||
|
&item.IP,
|
||||||
|
&firstSeenAt,
|
||||||
|
&lastSeenAt,
|
||||||
|
&item.LastSourceName,
|
||||||
|
&item.LastUserAgent,
|
||||||
|
&item.LatestStatus,
|
||||||
|
&item.TotalEvents,
|
||||||
|
&state,
|
||||||
|
&item.StateReason,
|
||||||
|
&manualOverride,
|
||||||
|
&item.LastEventID,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("scan ip state: %w", err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
item.FirstSeenAt, err = parseTime(firstSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("parse ip state first_seen_at: %w", err)
|
||||||
|
}
|
||||||
|
item.LastSeenAt, err = parseTime(lastSeenAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("parse ip state last_seen_at: %w", err)
|
||||||
|
}
|
||||||
|
item.UpdatedAt, err = parseTime(updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return model.IPState{}, fmt.Errorf("parse ip state updated_at: %w", err)
|
||||||
|
}
|
||||||
|
item.State = model.IPStateStatus(state)
|
||||||
|
item.ManualOverride = model.ManualOverride(manualOverride)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(value string) (time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339Nano, trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return parsed.UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(value bool) int {
|
||||||
|
if value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryer interface {
|
||||||
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||||
|
}
|
||||||
116
internal/store/store_test.go
Normal file
116
internal/store/store_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreRecordsEventsAndState(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "blocker.db")
|
||||||
|
db, err := Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
occurredAt := time.Date(2025, 3, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
event := &model.Event{
|
||||||
|
SourceName: "main",
|
||||||
|
ProfileName: "main",
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
RemoteIP: "198.51.100.10",
|
||||||
|
ClientIP: "203.0.113.10",
|
||||||
|
Host: "example.test",
|
||||||
|
Method: "GET",
|
||||||
|
URI: "/wp-login.php",
|
||||||
|
Path: "/wp-login.php",
|
||||||
|
Status: 404,
|
||||||
|
UserAgent: "curl/8.0",
|
||||||
|
Decision: model.DecisionActionBlock,
|
||||||
|
DecisionReason: "php_path",
|
||||||
|
DecisionReasons: []string{"php_path"},
|
||||||
|
Enforced: true,
|
||||||
|
RawJSON: `{"status":404}`,
|
||||||
|
}
|
||||||
|
if err := db.RecordEvent(ctx, event); err != nil {
|
||||||
|
t.Fatalf("record event: %v", err)
|
||||||
|
}
|
||||||
|
if event.ID == 0 {
|
||||||
|
t.Fatalf("expected inserted event ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, found, err := db.GetIPState(ctx, "203.0.113.10")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get ip state: %v", err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("expected IP state to exist")
|
||||||
|
}
|
||||||
|
if state.State != model.IPStateBlocked {
|
||||||
|
t.Fatalf("unexpected ip state: %+v", state)
|
||||||
|
}
|
||||||
|
if state.TotalEvents != 1 {
|
||||||
|
t.Fatalf("unexpected total events: %d", state.TotalEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.SetManualOverride(ctx, "203.0.113.10", model.ManualOverrideForceAllow, model.IPStateAllowed, "manual allow"); err != nil {
|
||||||
|
t.Fatalf("set manual override: %v", err)
|
||||||
|
}
|
||||||
|
state, found, err = db.GetIPState(ctx, "203.0.113.10")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("get overridden ip state: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if state.ManualOverride != model.ManualOverrideForceAllow {
|
||||||
|
t.Fatalf("unexpected override after set: %+v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ClearManualOverride(ctx, "203.0.113.10", "reset"); err != nil {
|
||||||
|
t.Fatalf("clear manual override: %v", err)
|
||||||
|
}
|
||||||
|
state, found, err = db.GetIPState(ctx, "203.0.113.10")
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("get reset ip state: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if state.ManualOverride != model.ManualOverrideNone {
|
||||||
|
t.Fatalf("expected cleared override, got %+v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AddDecision(ctx, &model.DecisionRecord{EventID: event.ID, IP: event.ClientIP, SourceName: event.SourceName, Kind: "automatic", Action: model.DecisionActionBlock, Reason: "php_path", Actor: "engine", Enforced: true}); err != nil {
|
||||||
|
t.Fatalf("add decision: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AddBackendAction(ctx, &model.OPNsenseAction{IP: event.ClientIP, Action: "block", Result: "added", Message: "php_path"}); err != nil {
|
||||||
|
t.Fatalf("add backend action: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SaveSourceOffset(ctx, model.SourceOffset{SourceName: "main", Path: "/tmp/main.log", Inode: "1:2", Offset: 42, UpdatedAt: occurredAt}); err != nil {
|
||||||
|
t.Fatalf("save source offset: %v", err)
|
||||||
|
}
|
||||||
|
offset, found, err := db.GetSourceOffset(ctx, "main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get source offset: %v", err)
|
||||||
|
}
|
||||||
|
if !found || offset.Offset != 42 {
|
||||||
|
t.Fatalf("unexpected source offset: found=%v offset=%+v", found, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := db.GetOverview(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get overview: %v", err)
|
||||||
|
}
|
||||||
|
if overview.TotalEvents != 1 || overview.TotalIPs != 1 {
|
||||||
|
t.Fatalf("unexpected overview counters: %+v", overview)
|
||||||
|
}
|
||||||
|
details, err := db.GetIPDetails(ctx, event.ClientIP, 10, 10, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get ip details: %v", err)
|
||||||
|
}
|
||||||
|
if len(details.RecentEvents) != 1 || len(details.Decisions) != 1 || len(details.BackendActions) != 1 {
|
||||||
|
t.Fatalf("unexpected ip details: %+v", details)
|
||||||
|
}
|
||||||
|
}
|
||||||
583
internal/web/handler.go
Normal file
583
internal/web/handler.go
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App interface {
|
||||||
|
GetOverview(ctx context.Context, limit int) (model.Overview, error)
|
||||||
|
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
|
||||||
|
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
|
||||||
|
GetIPDetails(ctx context.Context, ip string) (model.IPDetails, error)
|
||||||
|
ForceBlock(ctx context.Context, ip string, actor string, reason string) error
|
||||||
|
ForceAllow(ctx context.Context, ip string, actor string, reason string) error
|
||||||
|
ClearOverride(ctx context.Context, ip string, actor string, reason string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
app App
|
||||||
|
overviewPage *template.Template
|
||||||
|
ipDetailsPage *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Title string
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
type actionPayload struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(app App) http.Handler {
|
||||||
|
h := &handler{
|
||||||
|
app: app,
|
||||||
|
overviewPage: template.Must(template.New("overview").Parse(overviewHTML)),
|
||||||
|
ipDetailsPage: template.Must(template.New("ip-details").Parse(ipDetailsHTML)),
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", h.handleOverviewPage)
|
||||||
|
mux.HandleFunc("/healthz", h.handleHealth)
|
||||||
|
mux.HandleFunc("/ips/", h.handleIPPage)
|
||||||
|
mux.HandleFunc("/api/overview", h.handleAPIOverview)
|
||||||
|
mux.HandleFunc("/api/events", h.handleAPIEvents)
|
||||||
|
mux.HandleFunc("/api/ips", h.handleAPIIPs)
|
||||||
|
mux.HandleFunc("/api/ips/", h.handleAPIIP)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleOverviewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderTemplate(w, h.overviewPage, pageData{Title: "Caddy OPNsense Blocker"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleIPPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip, ok := extractPathValue(r.URL.Path, "/ips/")
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
renderTemplate(w, h.ipDetailsPage, pageData{Title: "IP details", IP: ip})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryLimit(r, 50)
|
||||||
|
overview, err := h.app.GetOverview(r.Context(), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryLimit(r, 100)
|
||||||
|
events, err := h.app.ListEvents(r.Context(), limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIIPs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/ips" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryLimit(r, 100)
|
||||||
|
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
||||||
|
items, err := h.app.ListIPs(r.Context(), limit, state)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIIP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip, action, ok := extractAPIPath(r.URL.Path)
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "" {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
details, err := h.app.GetIPDetails(r.Context(), ip)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, details)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := decodeActionPayload(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "block":
|
||||||
|
err = h.app.ForceBlock(r.Context(), ip, payload.Actor, payload.Reason)
|
||||||
|
case "unblock":
|
||||||
|
err = h.app.ForceAllow(r.Context(), ip, payload.Actor, payload.Reason)
|
||||||
|
case "reset":
|
||||||
|
err = h.app.ClearOverride(r.Context(), ip, payload.Actor, payload.Reason)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
details, err := h.app.GetIPDetails(r.Context(), ip)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeActionPayload(r *http.Request) (actionPayload, error) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
var payload actionPayload
|
||||||
|
if r.ContentLength == 0 {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
return actionPayload{}, fmt.Errorf("decode request body: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPathValue(path string, prefix string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(path, prefix)
|
||||||
|
rest = strings.Trim(rest, "/")
|
||||||
|
if rest == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
decoded, err := url.PathUnescape(rest)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return decoded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAPIPath(path string) (ip string, action string, ok bool) {
|
||||||
|
if !strings.HasPrefix(path, "/api/ips/") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(path, "/api/ips/")
|
||||||
|
rest = strings.Trim(rest, "/")
|
||||||
|
if rest == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
decoded, err := url.PathUnescape(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return decoded, "", true
|
||||||
|
}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return decoded, parts[1], true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryLimit(r *http.Request, fallback int) int {
|
||||||
|
value := strings.TrimSpace(r.URL.Query().Get("limit"))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if parsed > 500 {
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, err error) {
|
||||||
|
writeJSON(w, status, map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func methodNotAllowed(w http.ResponseWriter) {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTemplate(w http.ResponseWriter, tmpl *template.Template, data pageData) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overviewHTML = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ .Title }}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
|
||||||
|
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; position: sticky; top: 0; background: rgba(15,23,42,.97); }
|
||||||
|
main { padding: 1.5rem; display: grid; gap: 1.5rem; }
|
||||||
|
h1, h2 { margin: 0 0 .75rem 0; }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; }
|
||||||
|
.card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: .9rem; }
|
||||||
|
.stat-value { font-size: 1.7rem; font-weight: 700; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
|
||||||
|
th { color: #93c5fd; }
|
||||||
|
a { color: #93c5fd; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
|
||||||
|
.status.blocked { background: #7f1d1d; }
|
||||||
|
.status.review { background: #78350f; }
|
||||||
|
.status.allowed { background: #14532d; }
|
||||||
|
.status.observed { background: #1e293b; }
|
||||||
|
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
|
||||||
|
button.secondary { background: #475569; }
|
||||||
|
button.danger { background: #dc2626; }
|
||||||
|
.muted { color: #94a3b8; }
|
||||||
|
.mono { font-family: ui-monospace, monospace; }
|
||||||
|
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
<div class="muted">Local-only review and enforcement console</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="stats" id="stats"></section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent IPs</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>IP</th><th>State</th><th>Override</th><th>Events</th><th>Last seen</th><th>Reason</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ips-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent Events</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Source</th><th>IP</th><th>Host</th><th>Method</th><th>Path</th><th>Status</th><th>Decision</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="events-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAction(ip, action) {
|
||||||
|
const reason = window.prompt('Optional reason', '');
|
||||||
|
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason, actor: 'web-ui' }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
window.alert(payload.error || 'Request failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(data) {
|
||||||
|
const stats = [
|
||||||
|
['Total events', data.total_events],
|
||||||
|
['Tracked IPs', data.total_ips],
|
||||||
|
['Blocked', data.blocked_ips],
|
||||||
|
['Review', data.review_ips],
|
||||||
|
['Allowed', data.allowed_ips],
|
||||||
|
['Observed', data.observed_ips],
|
||||||
|
];
|
||||||
|
document.getElementById('stats').innerHTML = stats.map(([label, value]) => [
|
||||||
|
'<div class="card">',
|
||||||
|
' <div class="muted">' + escapeHtml(label) + '</div>',
|
||||||
|
' <div class="stat-value">' + escapeHtml(value) + '</div>',
|
||||||
|
'</div>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIPs(items) {
|
||||||
|
document.getElementById('ips-body').innerHTML = items.map(item => [
|
||||||
|
'<tr>',
|
||||||
|
' <td class="mono"><a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></td>',
|
||||||
|
' <td><span class="status ' + escapeHtml(item.state) + '">' + escapeHtml(item.state) + '</span></td>',
|
||||||
|
' <td>' + escapeHtml(item.manual_override) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.total_events) + '</td>',
|
||||||
|
' <td>' + escapeHtml(new Date(item.last_seen_at).toLocaleString()) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.state_reason) + '</td>',
|
||||||
|
' <td>',
|
||||||
|
' <div class="actions">',
|
||||||
|
' <button class="danger" onclick="sendAction("' + escapeHtml(item.ip) + '", "block")">Block</button>',
|
||||||
|
' <button onclick="sendAction("' + escapeHtml(item.ip) + '", "unblock")">Unblock</button>',
|
||||||
|
' <button class="secondary" onclick="sendAction("' + escapeHtml(item.ip) + '", "reset")">Reset</button>',
|
||||||
|
' </div>',
|
||||||
|
' </td>',
|
||||||
|
'</tr>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvents(items) {
|
||||||
|
document.getElementById('events-body').innerHTML = items.map(item => [
|
||||||
|
'<tr>',
|
||||||
|
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.source_name) + '</td>',
|
||||||
|
' <td class="mono"><a href="/ips/' + encodeURIComponent(item.client_ip) + '">' + escapeHtml(item.client_ip) + '</a></td>',
|
||||||
|
' <td>' + escapeHtml(item.host) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.method) + '</td>',
|
||||||
|
' <td class="mono">' + escapeHtml(item.path) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.status) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
|
||||||
|
'</tr>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const response = await fetch('/api/overview?limit=50');
|
||||||
|
const data = await response.json();
|
||||||
|
renderStats(data);
|
||||||
|
renderIPs(data.recent_ips || []);
|
||||||
|
renderEvents(data.recent_events || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
const ipDetailsHTML = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ .Title }}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
|
||||||
|
header { padding: 1rem 1.5rem; border-bottom: 1px solid #334155; }
|
||||||
|
main { padding: 1.5rem; display: grid; gap: 1.5rem; }
|
||||||
|
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: .55rem .65rem; border-bottom: 1px solid #1e293b; text-align: left; vertical-align: top; }
|
||||||
|
th { color: #93c5fd; }
|
||||||
|
.status { display: inline-block; padding: .15rem .45rem; border-radius: 999px; font-size: .8rem; background: #1e293b; }
|
||||||
|
.status.blocked { background: #7f1d1d; }
|
||||||
|
.status.review { background: #78350f; }
|
||||||
|
.status.allowed { background: #14532d; }
|
||||||
|
.status.observed { background: #1e293b; }
|
||||||
|
.actions { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
button { background: #2563eb; color: white; border: 0; border-radius: .45rem; padding: .35rem .6rem; cursor: pointer; }
|
||||||
|
button.secondary { background: #475569; }
|
||||||
|
button.danger { background: #dc2626; }
|
||||||
|
.mono { font-family: ui-monospace, monospace; }
|
||||||
|
a { color: #93c5fd; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div><a href="/">← Back</a></div>
|
||||||
|
<h1 class="mono">{{ .IP }}</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>State</h2>
|
||||||
|
<div id="state"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="danger" onclick="sendAction('block')">Block</button>
|
||||||
|
<button onclick="sendAction('unblock')">Unblock</button>
|
||||||
|
<button class="secondary" onclick="sendAction('reset')">Reset</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent events</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Source</th><th>Method</th><th>Path</th><th>Status</th><th>Decision</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="events-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Decisions</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Kind</th><th>Action</th><th>Reason</th><th>Actor</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="decisions-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Backend actions</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Time</th><th>Action</th><th>Result</th><th>Message</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="backend-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const ip = {{ printf "%q" .IP }};
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAction(action) {
|
||||||
|
const reason = window.prompt('Optional reason', '');
|
||||||
|
const response = await fetch('/api/ips/' + encodeURIComponent(ip) + '/' + action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ reason, actor: 'web-ui' }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
window.alert(payload.error || 'Request failed');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderState(state) {
|
||||||
|
document.getElementById('state').innerHTML = [
|
||||||
|
'<div><strong>State</strong>: <span class="status ' + escapeHtml(state.state) + '">' + escapeHtml(state.state) + '</span></div>',
|
||||||
|
'<div><strong>Override</strong>: ' + escapeHtml(state.manual_override) + '</div>',
|
||||||
|
'<div><strong>Total events</strong>: ' + escapeHtml(state.total_events) + '</div>',
|
||||||
|
'<div><strong>Last seen</strong>: ' + escapeHtml(new Date(state.last_seen_at).toLocaleString()) + '</div>',
|
||||||
|
'<div><strong>Reason</strong>: ' + escapeHtml(state.state_reason) + '</div>'
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEvents(items) {
|
||||||
|
document.getElementById('events-body').innerHTML = items.map(item => [
|
||||||
|
'<tr>',
|
||||||
|
' <td>' + escapeHtml(new Date(item.occurred_at).toLocaleString()) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.source_name) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.method) + '</td>',
|
||||||
|
' <td class="mono">' + escapeHtml(item.path) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.status) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.decision) + (item.enforced ? ' · enforced' : '') + '</td>',
|
||||||
|
'</tr>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDecisions(items) {
|
||||||
|
document.getElementById('decisions-body').innerHTML = items.map(item => [
|
||||||
|
'<tr>',
|
||||||
|
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.kind) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.action) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.reason) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.actor) + '</td>',
|
||||||
|
'</tr>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBackend(items) {
|
||||||
|
document.getElementById('backend-body').innerHTML = items.map(item => [
|
||||||
|
'<tr>',
|
||||||
|
' <td>' + escapeHtml(new Date(item.created_at).toLocaleString()) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.action) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.result) + '</td>',
|
||||||
|
' <td>' + escapeHtml(item.message) + '</td>',
|
||||||
|
'</tr>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const response = await fetch('/api/ips/' + encodeURIComponent(ip));
|
||||||
|
const data = await response.json();
|
||||||
|
renderState(data.state || {});
|
||||||
|
renderEvents(data.recent_events || []);
|
||||||
|
renderDecisions(data.decisions || []);
|
||||||
|
renderBackend(data.backend_actions || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 2000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
124
internal/web/handler_test.go
Normal file
124
internal/web/handler_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := &stubApp{}
|
||||||
|
handler := NewHandler(app)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api/overview?limit=10", nil)
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected overview status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
var overview model.Overview
|
||||||
|
if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil {
|
||||||
|
t.Fatalf("decode overview payload: %v", err)
|
||||||
|
}
|
||||||
|
if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 {
|
||||||
|
t.Fatalf("unexpected overview payload: %+v", overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected block status: %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if app.lastAction != "block:203.0.113.10:tester:test reason" {
|
||||||
|
t.Fatalf("unexpected recorded action: %q", app.lastAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
handler.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected overview page status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "Local-only review and enforcement console") {
|
||||||
|
t.Fatalf("overview page did not render expected content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubApp struct {
|
||||||
|
lastAction string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return model.Overview{
|
||||||
|
TotalEvents: 1,
|
||||||
|
TotalIPs: 1,
|
||||||
|
BlockedIPs: 1,
|
||||||
|
RecentIPs: []model.IPState{{
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
State: model.IPStateBlocked,
|
||||||
|
ManualOverride: model.ManualOverrideNone,
|
||||||
|
TotalEvents: 1,
|
||||||
|
LastSeenAt: now,
|
||||||
|
}},
|
||||||
|
RecentEvents: []model.Event{{
|
||||||
|
ID: 1,
|
||||||
|
SourceName: "main",
|
||||||
|
ClientIP: "203.0.113.10",
|
||||||
|
OccurredAt: now,
|
||||||
|
Decision: model.DecisionActionBlock,
|
||||||
|
CurrentState: model.IPStateBlocked,
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||||
|
overview, _ := s.GetOverview(ctx, limit)
|
||||||
|
return overview.RecentEvents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
|
||||||
|
overview, _ := s.GetOverview(ctx, limit)
|
||||||
|
return overview.RecentIPs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) GetIPDetails(context.Context, string) (model.IPDetails, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return model.IPDetails{
|
||||||
|
State: model.IPState{
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
State: model.IPStateBlocked,
|
||||||
|
ManualOverride: model.ManualOverrideNone,
|
||||||
|
TotalEvents: 1,
|
||||||
|
LastSeenAt: now,
|
||||||
|
},
|
||||||
|
RecentEvents: []model.Event{{ID: 1, ClientIP: "203.0.113.10", OccurredAt: now, Decision: model.DecisionActionBlock}},
|
||||||
|
Decisions: []model.DecisionRecord{{ID: 1, IP: "203.0.113.10", Action: model.DecisionActionBlock, CreatedAt: now}},
|
||||||
|
BackendActions: []model.OPNsenseAction{{ID: 1, IP: "203.0.113.10", Action: "block", Result: "added", CreatedAt: now}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) ForceBlock(_ context.Context, ip string, actor string, reason string) error {
|
||||||
|
s.lastAction = "block:" + ip + ":" + actor + ":" + reason
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) ForceAllow(_ context.Context, ip string, actor string, reason string) error {
|
||||||
|
s.lastAction = "allow:" + ip + ":" + actor + ":" + reason
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubApp) ClearOverride(_ context.Context, ip string, actor string, reason string) error {
|
||||||
|
s.lastAction = "reset:" + ip + ":" + actor + ":" + reason
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user