4.9 KiB
caddy-opnsense-blocker
caddy-opnsense-blocker is a local-first daemon that ingests Caddy access logs in their default JSON format, evaluates suspicious requests, keeps persistent local state in SQLite, provides a lightweight web UI for review, and blocks or unblocks IP addresses through an OPNsense alias.
Features
- Real-time ingestion of multiple Caddy JSON log files.
- One heuristic profile per log source.
- Persistent local state in SQLite.
- Local-only web UI with a sortable “Recent IPs” view for the last 24 hours and a full request history for each selected address.
- On-demand IP investigation with persistent caching for bot verification, reverse DNS, RDAP, and Spamhaus lookups.
- Manual block, unblock, and clear-override actions with OPNsense-aware UI state.
- OPNsense alias backend with automatic alias creation.
- Concurrent polling across multiple log files.
Current scope
This first version is intentionally strong on ingestion, persistence, UI, and OPNsense integration. The decision engine is deliberately simple and deterministic for now:
- suspicious path prefixes
- unexpected
POSTrequests .phppath detection- explicit known-agent allow/deny rules
- excluded CIDR ranges
- manual overrides
This keeps the application usable immediately while leaving room for a more advanced policy engine later.
Architecture
internal/caddylog: parses default Caddy JSON access logsinternal/engine: evaluates requests against a profileinternal/investigation: performs on-demand bot verification and IP enrichmentinternal/store: persists events, IP state, manual decisions, backend actions, and source offsetsinternal/opnsense: manages the target OPNsense alias through its APIinternal/service: runs concurrent log followers and applies automatic decisionsinternal/web: serves the local review UI and JSON API
License
This project is licensed under the MIT License. See LICENSE.
Quick start
- Generate or provision OPNsense API credentials.
- Copy
config.example.yamltoconfig.yamland adapt it. - Start the daemon:
CGO_ENABLED=0 go run ./cmd/caddy-opnsense-blocker -config ./config.yaml
- Open the local UI on the configured address, for example
http://127.0.0.1:9080.
Example configuration
See config.example.yaml.
Important points:
- Each source points to one Caddy log file.
- Each source references exactly one profile.
initial_position: endmeans “start following new lines only” on first boot.- The
investigationsection controls how long IP enrichment is cached and whether on-demand Spamhaus lookups are enabled. - The web UI should stay bound to a local address such as
127.0.0.1:9080.
Web UI and API
The web UI is intentionally small and server-rendered. It refreshes through lightweight JSON polling and exposes these endpoints:
GET /healthzGET /api/overviewGET /api/eventsGET /api/ipsGET /api/recent-ips?hours=24GET /api/ips/{ip}POST /api/ips/{ip}/investigatePOST /api/ips/{ip}/blockPOST /api/ips/{ip}/unblockPOST /api/ips/{ip}/clear-override
The legacy POST /api/ips/{ip}/reset endpoint is still accepted as a backwards-compatible alias for clear-override.
Development
Run the test suite:
CGO_ENABLED=0 go test ./...
Build the daemon:
CGO_ENABLED=0 go build ./cmd/caddy-opnsense-blocker
CGO_ENABLED=0 is useful on systems without a C toolchain. The application itself only relies on pure-Go dependencies.
Nix packaging
The repository ships with first-class Nix files:
package.nix: reusable package definitiondefault.nix: convenience entry point fornix-buildmodule.nix: reusable NixOS module
Build the package directly from the repository root:
nix-build
Use the NixOS module from another configuration:
{
imports = [ /path/to/caddy-opnsense-blocker/module.nix ];
services.caddy-opnsense-blocker = {
enable = true;
credentials.opnsenseApiKeyFile = "/run/secrets/opnsense-api-key";
credentials.opnsenseApiSecretFile = "/run/secrets/opnsense-api-secret";
settings = {
opnsense = {
enabled = true;
base_url = "https://router.example.test";
ensure_alias = true;
alias.name = "blocked-ips";
};
profiles.public-web = {
auto_block = true;
block_unexpected_posts = true;
block_php_paths = true;
suspicious_path_prefixes = [ "/wp-admin" "/wp-login.php" "/.env" ];
};
sources = [
{
name = "public-web";
path = "/var/log/caddy/public-web.json";
profile = "public-web";
}
];
};
};
}
Roadmap
- richer decision engine
- optional GeoIP and ASN providers beyond RDAP
- richer review filters in the UI
- alternative blocking backends besides OPNsense
- direct streaming ingestion targets in addition to file polling