2

Build initial caddy-opnsense-blocker daemon

This commit is contained in:
2026-03-12 00:51:06 +01:00
commit 4e87d84237
21 changed files with 4354 additions and 0 deletions

194
internal/caddylog/parser.go Normal file
View 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)
}

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

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

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

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

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

View 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
View 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,
&currentState,
&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
}

View 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
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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(&quot;' + escapeHtml(item.ip) + '&quot;, &quot;block&quot;)">Block</button>',
' <button onclick="sendAction(&quot;' + escapeHtml(item.ip) + '&quot;, &quot;unblock&quot;)">Unblock</button>',
' <button class="secondary" onclick="sendAction(&quot;' + escapeHtml(item.ip) + '&quot;, &quot;reset&quot;)">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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>`

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