You've already forked caddy-opnsense-blocker
Expand public installation and API documentation
This commit is contained in:
220
README.md
220
README.md
@@ -1,76 +1,119 @@
|
||||
# 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.
|
||||
`caddy-opnsense-blocker` is a local-first daemon that follows one or more Caddy access log files in the default JSON format, applies per-source heuristics, stores events and investigations in SQLite, exposes a lightweight review UI, and optionally synchronizes manual or automatic block decisions to an OPNsense alias.
|
||||
|
||||
## Features
|
||||
## Highlights
|
||||
|
||||
- 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 summary cards, a sortable “Recent IPs” view for the last 24 hours, bot badges, 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.
|
||||
- Background IP investigation workers so missing cached intelligence appears without blocking page loads.
|
||||
- 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.
|
||||
- Real-time ingestion of multiple Caddy JSON access log files.
|
||||
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias.
|
||||
- Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets.
|
||||
- Lightweight web UI with overview cards, a sortable “Recent IPs” table, IP detail pages, decision history, and full request history per address.
|
||||
- Background investigation workers that fill in missing cached intelligence without slowing down page loads.
|
||||
- Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API.
|
||||
- Optional OPNsense integration; the daemon also works in review-only mode.
|
||||
- Pure-Go build and first-class Nix and NixOS packaging.
|
||||
|
||||
## Current scope
|
||||
## Documentation
|
||||
|
||||
This first version is intentionally strong on ingestion, persistence, UI, and OPNsense integration.
|
||||
The decision engine is deliberately simple and deterministic for now:
|
||||
- Installation guide: [`docs/install.md`](docs/install.md)
|
||||
- Configuration reference: [`docs/configuration.md`](docs/configuration.md)
|
||||
- HTTP API reference: [`docs/api.md`](docs/api.md)
|
||||
- Example Caddy configuration: [`examples/Caddyfile`](examples/Caddyfile)
|
||||
- Example systemd unit: [`examples/caddy-opnsense-blocker.service`](examples/caddy-opnsense-blocker.service)
|
||||
|
||||
- suspicious path prefixes
|
||||
- unexpected `POST` requests
|
||||
- `.php` path detection
|
||||
- explicit known-agent allow/deny rules
|
||||
- excluded CIDR ranges
|
||||
- manual overrides
|
||||
## Requirements
|
||||
|
||||
This keeps the application usable immediately while leaving room for a more advanced policy engine later.
|
||||
- Linux.
|
||||
- Caddy access logs written to files in the default JSON format.
|
||||
- Read access to every configured log file.
|
||||
- A writable state directory for the SQLite database.
|
||||
- Outbound DNS and HTTPS access if IP investigation is enabled.
|
||||
- OPNsense only if you want the daemon to push block or unblock actions to a firewall alias.
|
||||
|
||||
## Architecture
|
||||
## Security model
|
||||
|
||||
- `internal/caddylog`: parses default Caddy JSON access logs
|
||||
- `internal/engine`: evaluates requests against a profile
|
||||
- `internal/investigation`: performs bot verification and IP enrichment
|
||||
- `internal/store`: persists events, IP state, manual decisions, backend actions, and source offsets
|
||||
- `internal/opnsense`: manages the target OPNsense alias through its API
|
||||
- `internal/service`: runs concurrent log followers and applies automatic decisions
|
||||
- `internal/web`: serves the local review UI and JSON API
|
||||
- The built-in web UI and HTTP API do not provide authentication or TLS.
|
||||
- The default listen address is `127.0.0.1:9080`; keep it on loopback unless another trusted layer protects access.
|
||||
- OPNsense credentials should be supplied through files, not inline secrets committed to source control.
|
||||
- Raw Caddy JSON log entries are stored in SQLite for inspection and auditing; plan storage and retention accordingly.
|
||||
|
||||
## License
|
||||
## How it works
|
||||
|
||||
This project is licensed under the MIT License. See `LICENSE`.
|
||||
1. A dedicated follower polls each configured log file and keeps a persistent inode plus offset checkpoint.
|
||||
2. New Caddy JSON lines are parsed into normalized events.
|
||||
3. Each source is evaluated against exactly one heuristic profile.
|
||||
4. Events, decisions, and IP state are written to SQLite.
|
||||
5. Manual overrides can force an IP to `blocked` or `allowed` regardless of later events.
|
||||
6. If OPNsense is enabled, block and unblock decisions can be applied to one target alias.
|
||||
7. Missing IP intelligence is fetched in the background and cached for later UI and API reads.
|
||||
|
||||
## State model
|
||||
|
||||
- `observed`: traffic was recorded, but the current rule set did not produce a suspicious decision.
|
||||
- `review`: the rule set matched suspicious behavior, but the configured profile did not auto-block.
|
||||
- `blocked`: the IP is currently blocked, either automatically or through a manual override.
|
||||
- `allowed`: the IP is explicitly allowed, typically because of a manual override or an allow rule.
|
||||
|
||||
`Clear override` removes only the local manual override. It does not directly add or remove the IP on OPNsense.
|
||||
|
||||
## Investigation model
|
||||
|
||||
- IP investigations are cached in SQLite.
|
||||
- The background worker only fills in missing investigations; it does not continuously re-check cached intelligence.
|
||||
- Opening an IP details page reuses the cached investigation.
|
||||
- `Refresh investigation` is the explicit action that forces a new lookup.
|
||||
- Verified bot detection currently uses built-in provider logic for Google, Bing, Apple, Meta, and DuckDuckGo.
|
||||
- When an address is not identified as a verified bot, the daemon can collect reverse DNS, forward-confirmed reverse DNS, RDAP registration details, and Spamhaus DNSBL status.
|
||||
|
||||
## Caddy log requirements
|
||||
|
||||
The daemon expects Caddy access log entries in the default JSON structure. In practice, these fields must remain present:
|
||||
|
||||
- `ts`
|
||||
- `status`
|
||||
- `request.remote_ip`
|
||||
- `request.client_ip` when available
|
||||
- `request.host`
|
||||
- `request.method`
|
||||
- `request.uri`
|
||||
- `request.headers.User-Agent`
|
||||
|
||||
The parser prefers `request.client_ip` and falls back to `request.remote_ip`. If Caddy itself sits behind another proxy or load balancer, configure Caddy so that `request.client_ip` reflects the real client address before feeding those logs to the blocker.
|
||||
|
||||
Use one log file per logical source. Different sources can share the same OPNsense alias while using different heuristic profiles.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Generate or provision OPNsense API credentials.
|
||||
2. Copy `config.example.yaml` to `config.yaml` and adapt it.
|
||||
3. Start the daemon:
|
||||
For a local test run:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
CGO_ENABLED=0 go run ./cmd/caddy-opnsense-blocker -config ./config.yaml
|
||||
```
|
||||
|
||||
4. Open the local UI on the configured address, for example `http://127.0.0.1:9080`.
|
||||
Then open the configured address, for example `http://127.0.0.1:9080`.
|
||||
|
||||
## Example configuration
|
||||
For production deployment instructions, see [`docs/install.md`](docs/install.md).
|
||||
|
||||
See `config.example.yaml`.
|
||||
## Nix and NixOS
|
||||
|
||||
Important points:
|
||||
The repository ships with:
|
||||
|
||||
- Each source points to one Caddy log file.
|
||||
- Each source references exactly one profile.
|
||||
- `initial_position: end` means “start following new lines only” on first boot.
|
||||
- The `investigation` section controls how long IP enrichment is cached and whether on-demand Spamhaus lookups are enabled.
|
||||
- The investigation worker fills missing cached intelligence in the background so the dashboard stays fast while bot badges and cached intel keep filling in. Opening an IP details page reuses the cache; the `Refresh investigation` button is the manual override when you explicitly want a new lookup.
|
||||
- The web UI should stay bound to a local address such as `127.0.0.1:9080`.
|
||||
- `package.nix`: reusable package definition
|
||||
- `default.nix`: convenience entry point for `nix-build`
|
||||
- `module.nix`: reusable NixOS module
|
||||
|
||||
## Web UI and API
|
||||
Build the package directly from the repository root:
|
||||
|
||||
The web UI is intentionally small and server-rendered.
|
||||
It refreshes through lightweight JSON polling and exposes these endpoints:
|
||||
```bash
|
||||
nix-build
|
||||
```
|
||||
|
||||
Detailed NixOS installation examples are in [`docs/install.md`](docs/install.md).
|
||||
|
||||
## HTTP API
|
||||
|
||||
The UI is backed by a small JSON API. The main endpoints are:
|
||||
|
||||
- `GET /healthz`
|
||||
- `GET /api/overview`
|
||||
@@ -83,7 +126,20 @@ It refreshes through lightweight JSON polling and exposes these endpoints:
|
||||
- `POST /api/ips/{ip}/unblock`
|
||||
- `POST /api/ips/{ip}/clear-override`
|
||||
|
||||
The legacy `POST /api/ips/{ip}/reset` endpoint is still accepted as a backwards-compatible alias for `clear-override`.
|
||||
The legacy `POST /api/ips/{ip}/reset` route is still accepted as a backwards-compatible alias for `clear-override`.
|
||||
|
||||
The full API reference, including payloads and response models, lives in [`docs/api.md`](docs/api.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
See [`config.example.yaml`](config.example.yaml) for a ready-to-edit example and [`docs/configuration.md`](docs/configuration.md) for a field-by-field reference.
|
||||
|
||||
Key ideas:
|
||||
|
||||
- each `source` points to one log file
|
||||
- each `source` references one `profile`
|
||||
- multiple sources can share the same global OPNsense backend configuration
|
||||
- `initial_position: end` means “start following new lines only” on first boot
|
||||
|
||||
## Development
|
||||
|
||||
@@ -99,64 +155,22 @@ 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.
|
||||
`CGO_ENABLED=0` is useful on systems without a C toolchain. The application depends only on pure-Go packages.
|
||||
|
||||
## Nix packaging
|
||||
## Scope and roadmap
|
||||
|
||||
The repository ships with first-class Nix files:
|
||||
This first public version is intentionally strong on ingestion, persistence, investigation, UI, and OPNsense integration.
|
||||
The current decision engine is deliberately simple and deterministic:
|
||||
|
||||
- `package.nix`: reusable package definition
|
||||
- `default.nix`: convenience entry point for `nix-build`
|
||||
- `module.nix`: reusable NixOS module
|
||||
- suspicious path prefixes
|
||||
- unexpected `POST` requests
|
||||
- `.php` path detection
|
||||
- explicit known-agent allow and deny rules
|
||||
- excluded CIDR ranges
|
||||
- manual overrides
|
||||
|
||||
Build the package directly from the repository root:
|
||||
Planned improvements include richer decision strategies, more investigation providers, additional blocking backends, and alternative ingestion transports beyond file polling.
|
||||
|
||||
```bash
|
||||
nix-build
|
||||
```
|
||||
## License
|
||||
|
||||
Use the NixOS module from another configuration:
|
||||
|
||||
```nix
|
||||
{
|
||||
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
|
||||
This project is licensed under the MIT License. See [`LICENSE`](LICENSE).
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
server:
|
||||
# The built-in UI/API has no authentication. Keep this on loopback unless
|
||||
# another trusted layer protects access.
|
||||
listen_address: 127.0.0.1:9080
|
||||
read_timeout: 5s
|
||||
write_timeout: 10s
|
||||
@@ -9,6 +11,8 @@ storage:
|
||||
|
||||
investigation:
|
||||
enabled: true
|
||||
# Reserved for future automatic revalidation logic. Current releases keep
|
||||
# cached investigations until you explicitly refresh them.
|
||||
refresh_after: 24h
|
||||
timeout: 8s
|
||||
user_agent: caddy-opnsense-blocker/0.2
|
||||
@@ -19,6 +23,7 @@ investigation:
|
||||
background_batch_size: 256
|
||||
|
||||
opnsense:
|
||||
# Set to false for review-only mode.
|
||||
enabled: true
|
||||
base_url: https://router.example.test
|
||||
api_key_file: /run/secrets/opnsense-api-key
|
||||
@@ -70,6 +75,8 @@ profiles:
|
||||
- /.git
|
||||
|
||||
sources:
|
||||
# One log path equals one selected profile. Different sources can still share
|
||||
# the same global OPNsense backend defined above.
|
||||
- name: public-web
|
||||
path: /var/log/caddy/public-web-access.json
|
||||
profile: public-web
|
||||
|
||||
263
docs/api.md
Normal file
263
docs/api.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# HTTP API
|
||||
|
||||
The daemon serves a small JSON API used by the built-in web UI.
|
||||
|
||||
## General behavior
|
||||
|
||||
- All endpoints are served from the same address as the web UI.
|
||||
- Responses are JSON.
|
||||
- Error responses use the shape `{"error":"..."}`.
|
||||
- Unsupported HTTP methods return `405 Method Not Allowed`.
|
||||
- There is no built-in authentication or TLS.
|
||||
|
||||
## `GET /healthz`
|
||||
|
||||
Liveness probe.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"time": "2026-03-12T18:22:11Z"
|
||||
}
|
||||
```
|
||||
|
||||
## `GET /api/overview`
|
||||
|
||||
Returns summary counters plus recent IP and recent event samples.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `limit`
|
||||
- optional
|
||||
- default: `50`
|
||||
- maximum: `1000`
|
||||
|
||||
Main response fields:
|
||||
|
||||
- `total_events`
|
||||
- `total_ips`
|
||||
- `blocked_ips`
|
||||
- `review_ips`
|
||||
- `allowed_ips`
|
||||
- `observed_ips`
|
||||
- `recent_ips`
|
||||
- `recent_events`
|
||||
|
||||
## `GET /api/events`
|
||||
|
||||
Returns recent raw events.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `limit`
|
||||
- optional
|
||||
- default: `100`
|
||||
- maximum: `1000`
|
||||
|
||||
Each event includes:
|
||||
|
||||
- source and profile names
|
||||
- timestamps
|
||||
- remote and client IPs
|
||||
- host, method, URI, path, status, and User-Agent
|
||||
- decision, primary reason, all reasons, and whether it was enforced
|
||||
- raw Caddy JSON
|
||||
- current IP state and manual override
|
||||
|
||||
## `GET /api/ips`
|
||||
|
||||
Returns current IP state rows.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `limit`
|
||||
- optional
|
||||
- default: `100`
|
||||
- maximum: `1000`
|
||||
- `state`
|
||||
- optional
|
||||
- filter by one state such as `review`, `blocked`, `allowed`, or `observed`
|
||||
|
||||
Each row includes:
|
||||
|
||||
- `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`
|
||||
|
||||
## `GET /api/recent-ips`
|
||||
|
||||
Returns aggregated IP rows over a recent time window.
|
||||
|
||||
Query parameters:
|
||||
|
||||
- `hours`
|
||||
- optional
|
||||
- default: `24`
|
||||
- values less than or equal to zero fall back to `24`
|
||||
- `limit`
|
||||
- optional
|
||||
- default: `200`
|
||||
- maximum: `1000`
|
||||
|
||||
Each row includes:
|
||||
|
||||
- `ip`
|
||||
- `source_name`
|
||||
- `state`
|
||||
- `events`
|
||||
- `last_seen_at`
|
||||
- `reason`
|
||||
- `manual_override`
|
||||
- optional `bot`
|
||||
- `actions`
|
||||
|
||||
`actions` contains:
|
||||
|
||||
- `can_block`
|
||||
- `can_unblock`
|
||||
- `can_clear_override`
|
||||
|
||||
## `GET /api/ips/{ip}`
|
||||
|
||||
Returns the complete details for one IP address.
|
||||
|
||||
Response fields:
|
||||
|
||||
- `state`
|
||||
- `recent_events`
|
||||
- `decisions`
|
||||
- `backend_actions`
|
||||
- optional `investigation`
|
||||
- `opnsense`
|
||||
- `actions`
|
||||
|
||||
The current implementation returns up to:
|
||||
|
||||
- 100 recent events
|
||||
- 100 decision records
|
||||
- 100 backend action records
|
||||
|
||||
## `POST /api/ips/{ip}/investigate`
|
||||
|
||||
Forces a fresh investigation for the selected IP and returns the updated `IPDetails` payload.
|
||||
|
||||
This bypasses the cached investigation for that request.
|
||||
|
||||
No request body is required.
|
||||
|
||||
## Action endpoints
|
||||
|
||||
These endpoints accept an optional JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": "alice@example.net",
|
||||
"reason": "short human-readable explanation"
|
||||
}
|
||||
```
|
||||
|
||||
If omitted:
|
||||
|
||||
- `actor` defaults to `web-ui`
|
||||
- `reason` defaults to an action-specific fallback
|
||||
|
||||
All action endpoints return the updated `IPDetails` payload on success.
|
||||
|
||||
### `POST /api/ips/{ip}/block`
|
||||
|
||||
- Sets a manual force-block override.
|
||||
- Default reason: `manual block`.
|
||||
- If OPNsense is enabled, the daemon adds the IP to the configured alias if it is missing.
|
||||
|
||||
### `POST /api/ips/{ip}/unblock`
|
||||
|
||||
- Sets a manual force-allow override.
|
||||
- Default reason: `manual allow`.
|
||||
- If OPNsense is enabled, the daemon removes the IP from the configured alias if it is present.
|
||||
|
||||
### `POST /api/ips/{ip}/clear-override`
|
||||
|
||||
- Removes the local manual override.
|
||||
- Default reason: `manual override cleared`.
|
||||
- Does not directly call OPNsense.
|
||||
|
||||
### `POST /api/ips/{ip}/reset`
|
||||
|
||||
Backwards-compatible alias for `clear-override`.
|
||||
|
||||
## Response model details
|
||||
|
||||
### `investigation`
|
||||
|
||||
When present, `investigation` may contain:
|
||||
|
||||
- `ip`
|
||||
- `updated_at`
|
||||
- `error`
|
||||
- `bot`
|
||||
- `reverse_dns`
|
||||
- `registration`
|
||||
- `reputation`
|
||||
|
||||
### `bot`
|
||||
|
||||
- `provider_id`
|
||||
- `name`
|
||||
- `icon`
|
||||
- `method`
|
||||
- `verified`
|
||||
|
||||
### `reverse_dns`
|
||||
|
||||
- `ptr`
|
||||
- `forward_confirmed`
|
||||
|
||||
### `registration`
|
||||
|
||||
- `source`
|
||||
- `handle`
|
||||
- `name`
|
||||
- `prefix`
|
||||
- `organization`
|
||||
- `country`
|
||||
- `abuse_email`
|
||||
|
||||
### `reputation`
|
||||
|
||||
- `spamhaus_lookup`
|
||||
- `spamhaus_listed`
|
||||
- optional `spamhaus_codes`
|
||||
- optional `error`
|
||||
|
||||
### `opnsense`
|
||||
|
||||
- `configured`
|
||||
- `present`
|
||||
- `checked_at`
|
||||
- optional `error`
|
||||
|
||||
## Example manual block request
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"actor":"cli","reason":"confirmed credential stuffing"}' \
|
||||
http://127.0.0.1:9080/api/ips/203.0.113.10/block
|
||||
```
|
||||
|
||||
## Example recent IP query
|
||||
|
||||
```bash
|
||||
curl 'http://127.0.0.1:9080/api/recent-ips?hours=24&limit=250'
|
||||
```
|
||||
237
docs/configuration.md
Normal file
237
docs/configuration.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Configuration reference
|
||||
|
||||
The daemon is configured from one YAML file passed with `-config`.
|
||||
|
||||
Start from [`../config.example.yaml`](../config.example.yaml).
|
||||
|
||||
## Top-level structure
|
||||
|
||||
```yaml
|
||||
server:
|
||||
storage:
|
||||
investigation:
|
||||
opnsense:
|
||||
profiles:
|
||||
sources:
|
||||
```
|
||||
|
||||
## `server`
|
||||
|
||||
Controls the built-in HTTP server.
|
||||
|
||||
- `listen_address`
|
||||
- default: `127.0.0.1:9080`
|
||||
- TCP listen address for both the UI and the JSON API.
|
||||
- `read_timeout`
|
||||
- default: `5s`
|
||||
- `write_timeout`
|
||||
- default: `10s`
|
||||
- `shutdown_timeout`
|
||||
- default: `15s`
|
||||
|
||||
The HTTP server has no built-in authentication or TLS.
|
||||
|
||||
## `storage`
|
||||
|
||||
- `path`
|
||||
- default: `./data/caddy-opnsense-blocker.db`
|
||||
- SQLite database path.
|
||||
|
||||
The parent directory is created automatically if it does not already exist.
|
||||
|
||||
## `investigation`
|
||||
|
||||
Controls bot detection and external IP lookups.
|
||||
|
||||
- `enabled`
|
||||
- default: `true`
|
||||
- Enables investigations entirely.
|
||||
- `refresh_after`
|
||||
- default: `24h`
|
||||
- Reserved for future automatic revalidation logic. Current releases do not automatically refresh cached investigations; cached entries are reused until a manual `Refresh investigation` action is triggered.
|
||||
- `timeout`
|
||||
- default: `8s`
|
||||
- Timeout applied to one investigation run.
|
||||
- `user_agent`
|
||||
- default: `caddy-opnsense-blocker/0.2`
|
||||
- User-Agent sent to HTTP-based investigation providers.
|
||||
- `spamhaus_enabled`
|
||||
- default: `true`
|
||||
- Enables Spamhaus DNSBL lookups for non-bot investigations.
|
||||
- `background_workers`
|
||||
- default: `2`
|
||||
- Number of background workers that fetch missing investigations.
|
||||
- `background_poll_interval`
|
||||
- default: `30s`
|
||||
- Delay between background queue refill passes.
|
||||
- `background_lookback`
|
||||
- default: `0s`
|
||||
- If `0s`, the scheduler can pick any known IP missing cached intelligence.
|
||||
- If greater than zero, only IPs seen within that lookback window are queued.
|
||||
- `background_batch_size`
|
||||
- default: `256`
|
||||
- Maximum number of IPs to queue per scheduler pass.
|
||||
|
||||
### Built-in investigation sources
|
||||
|
||||
Current releases can collect:
|
||||
|
||||
- verified bot matches based on published ranges and reverse DNS logic
|
||||
- probable bot hints based on the observed User-Agent
|
||||
- reverse DNS and forward-confirmed reverse DNS
|
||||
- RDAP registration details such as network name, organization, country, prefix, and abuse contact
|
||||
- Spamhaus listed or not listed status
|
||||
|
||||
## `opnsense`
|
||||
|
||||
Controls the optional firewall backend.
|
||||
|
||||
- `enabled`
|
||||
- default: `false`
|
||||
- When `false`, the daemon stays in review-only mode and does not call OPNsense.
|
||||
- `base_url`
|
||||
- required when `enabled: true`
|
||||
- Example: `https://router.example.test`
|
||||
- `api_key`
|
||||
- optional if `api_key_file` is set
|
||||
- `api_secret`
|
||||
- optional if `api_secret_file` is set
|
||||
- `api_key_file`
|
||||
- recommended
|
||||
- Path to a file containing the OPNsense API key.
|
||||
- `api_secret_file`
|
||||
- recommended
|
||||
- Path to a file containing the OPNsense API secret.
|
||||
- `timeout`
|
||||
- default: `8s`
|
||||
- `insecure_skip_verify`
|
||||
- default: `false`
|
||||
- Only use this for development or tightly controlled environments.
|
||||
- `ensure_alias`
|
||||
- default: `true`
|
||||
- If the target alias does not exist, the daemon will try to create it automatically.
|
||||
|
||||
### `opnsense.alias`
|
||||
|
||||
- `name`
|
||||
- required when OPNsense is enabled
|
||||
- `type`
|
||||
- default: `host`
|
||||
- `description`
|
||||
- default: `Managed by caddy-opnsense-blocker`
|
||||
|
||||
### `opnsense.api_paths`
|
||||
|
||||
Advanced option for environments where the default OPNsense API endpoints differ.
|
||||
|
||||
Defaults:
|
||||
|
||||
- `alias_get_uuid`: `/api/firewall/alias/get_alias_u_u_i_d/{alias}`
|
||||
- `alias_add_item`: `/api/firewall/alias/add_item`
|
||||
- `alias_set_item`: `/api/firewall/alias/set_item/{uuid}`
|
||||
- `alias_reconfigure`: `/api/firewall/alias/reconfigure`
|
||||
- `alias_util_list`: `/api/firewall/alias_util/list/{alias}`
|
||||
- `alias_util_add`: `/api/firewall/alias_util/add/{alias}`
|
||||
- `alias_util_delete`: `/api/firewall/alias_util/delete/{alias}`
|
||||
|
||||
## `profiles`
|
||||
|
||||
`profiles` is a mapping. Each source references one profile by name.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
public-web:
|
||||
auto_block: true
|
||||
suspicious_path_prefixes:
|
||||
- /wp-admin
|
||||
```
|
||||
|
||||
Supported fields per profile:
|
||||
|
||||
- `auto_block`
|
||||
- When `true`, suspicious matches immediately become `blocked` decisions.
|
||||
- When `false`, suspicious matches become `review` decisions.
|
||||
- `min_status`
|
||||
- default: `400`
|
||||
- `max_status`
|
||||
- default: `599`
|
||||
- Only events within this inclusive status range are evaluated.
|
||||
- `block_unexpected_posts`
|
||||
- When `true`, `POST` requests are suspicious unless their normalized path is listed in `allowed_post_paths`.
|
||||
- `block_php_paths`
|
||||
- When `true`, paths ending in `.php` are suspicious.
|
||||
- `allowed_post_paths`
|
||||
- Exact normalized paths that remain allowed for `POST` requests.
|
||||
- `suspicious_path_prefixes`
|
||||
- Prefixes matched against the normalized request path.
|
||||
- `/` is rejected because it would be too broad.
|
||||
- `excluded_cidrs`
|
||||
- CIDRs always allowed for this profile.
|
||||
- `known_agents`
|
||||
- Explicit allow or deny rules matched against User-Agent prefixes, CIDR ranges, or both.
|
||||
|
||||
### `profiles.<name>.known_agents[]`
|
||||
|
||||
- `name`
|
||||
- Human-readable rule name.
|
||||
- `decision`
|
||||
- Required.
|
||||
- Must be `allow` or `deny`.
|
||||
- `user_agent_prefixes`
|
||||
- Optional if `cidrs` is present.
|
||||
- `cidrs`
|
||||
- Optional if `user_agent_prefixes` is present.
|
||||
|
||||
At least one of `user_agent_prefixes` or `cidrs` must be defined.
|
||||
|
||||
## `sources`
|
||||
|
||||
`sources` is a list of monitored log files.
|
||||
|
||||
Each item supports:
|
||||
|
||||
- `name`
|
||||
- required and unique
|
||||
- `path`
|
||||
- required and unique
|
||||
- `profile`
|
||||
- required
|
||||
- Must reference an existing profile name.
|
||||
- `initial_position`
|
||||
- default: `end`
|
||||
- Accepted values: `beginning`, `end`
|
||||
- `end` means “follow only new lines on first start”.
|
||||
- `poll_interval`
|
||||
- default: `1s`
|
||||
- `batch_size`
|
||||
- default: `256`
|
||||
- Maximum number of lines read per poll.
|
||||
|
||||
## Validation rules
|
||||
|
||||
The configuration loader rejects:
|
||||
|
||||
- an empty `profiles` map
|
||||
- an empty `sources` list
|
||||
- invalid `server.listen_address`
|
||||
- duplicate source names
|
||||
- duplicate source paths
|
||||
- sources pointing to unknown profiles
|
||||
- invalid `initial_position` values
|
||||
- invalid status ranges
|
||||
- overly broad suspicious prefixes such as `/`
|
||||
- malformed CIDRs
|
||||
- invalid known-agent decisions
|
||||
- missing OPNsense credentials when `opnsense.enabled: true`
|
||||
|
||||
## Design note: one source, one profile
|
||||
|
||||
One monitored log path equals one profile selection. This makes it easy to monitor, for example:
|
||||
|
||||
- one public web vhost with aggressive auto-blocking
|
||||
- one Gitea vhost with a more conservative review-first profile
|
||||
- one internal service with no auto-blocking at all
|
||||
|
||||
All of them can still share the same OPNsense alias backend because OPNsense configuration is global.
|
||||
229
docs/install.md
Normal file
229
docs/install.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Installation
|
||||
|
||||
This document covers both generic Linux deployments and NixOS deployments.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need:
|
||||
|
||||
- Linux.
|
||||
- Caddy access logs written to files in the default JSON format.
|
||||
- One readable log file per logical source you want to monitor.
|
||||
- A writable directory for the SQLite database.
|
||||
- Go 1.25 or newer if you build from source without Nix.
|
||||
- Optional: OPNsense API credentials if you want firewall alias synchronization.
|
||||
- Optional: outbound DNS and HTTPS access if you want IP investigation and bot verification.
|
||||
|
||||
## Deployment checklist
|
||||
|
||||
Before starting the daemon, decide:
|
||||
|
||||
- which Caddy log files you want to ingest
|
||||
- which heuristic profile each log file should use
|
||||
- whether the daemon should auto-block for each profile or only mark addresses for review
|
||||
- whether OPNsense integration is enabled
|
||||
- which address the web UI should listen on
|
||||
|
||||
## 1. Configure Caddy access logs
|
||||
|
||||
The daemon reads files, not stdout and not journald. Each monitored source should write to its own file.
|
||||
|
||||
An example Caddy configuration is available in [`../examples/Caddyfile`](../examples/Caddyfile).
|
||||
|
||||
Important points:
|
||||
|
||||
- Keep the default JSON log format.
|
||||
- Use one file per source.
|
||||
- Make sure the service account running `caddy-opnsense-blocker` can read those files.
|
||||
- If Caddy sits behind another proxy, make sure Caddy records the actual client IP in `request.client_ip`.
|
||||
|
||||
## 2. Prepare OPNsense credentials (optional)
|
||||
|
||||
If you want the daemon to block and unblock IP addresses on OPNsense:
|
||||
|
||||
1. Create or reuse an API key and secret.
|
||||
2. Decide which alias name should contain blocked IPs.
|
||||
3. Keep the credentials in files readable only by the service account.
|
||||
|
||||
If OPNsense integration is disabled, the daemon still ingests logs, records decisions, and supports manual review.
|
||||
|
||||
## 3. Build the binary
|
||||
|
||||
Clone the repository and build the daemon:
|
||||
|
||||
```bash
|
||||
git clone https://git.dern.ovh/infrastructure/caddy-opnsense-blocker.git
|
||||
cd caddy-opnsense-blocker
|
||||
CGO_ENABLED=0 go test ./...
|
||||
CGO_ENABLED=0 go build -o caddy-opnsense-blocker ./cmd/caddy-opnsense-blocker
|
||||
```
|
||||
|
||||
You can also use `nix-build` if you prefer the Nix package defined in this repository.
|
||||
|
||||
## 4. Create the runtime user and directories
|
||||
|
||||
Example for a non-NixOS host:
|
||||
|
||||
```bash
|
||||
useradd --system --home-dir /var/lib/caddy-opnsense-blocker --create-home --shell /usr/sbin/nologin blocker
|
||||
install -d -o blocker -g blocker -m 0750 /var/lib/caddy-opnsense-blocker
|
||||
install -d -o root -g blocker -m 0750 /etc/caddy-opnsense-blocker
|
||||
install -d -o root -g blocker -m 0750 /var/log/caddy
|
||||
install -m 0755 caddy-opnsense-blocker /usr/local/bin/caddy-opnsense-blocker
|
||||
```
|
||||
|
||||
If your Caddy logs are group-readable by a different group, either adjust permissions or grant the runtime user supplementary group access.
|
||||
|
||||
## 5. Create the configuration file
|
||||
|
||||
Start from the repository example:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml /etc/caddy-opnsense-blocker/config.yaml
|
||||
chmod 0640 /etc/caddy-opnsense-blocker/config.yaml
|
||||
```
|
||||
|
||||
Then edit:
|
||||
|
||||
- `server.listen_address`
|
||||
- `storage.path`
|
||||
- `profiles`
|
||||
- `sources`
|
||||
- `opnsense.*` if OPNsense integration is enabled
|
||||
|
||||
For a field-by-field reference, see [`configuration.md`](configuration.md).
|
||||
|
||||
Store OPNsense credentials in files such as:
|
||||
|
||||
```bash
|
||||
install -m 0400 -o root -g blocker /path/to/api-key /etc/caddy-opnsense-blocker/opnsense-api-key
|
||||
install -m 0400 -o root -g blocker /path/to/api-secret /etc/caddy-opnsense-blocker/opnsense-api-secret
|
||||
```
|
||||
|
||||
Then reference those files from `config.yaml`.
|
||||
|
||||
## 6. Run the daemon manually once
|
||||
|
||||
Before installing a service unit, validate that the daemon starts correctly:
|
||||
|
||||
```bash
|
||||
sudo -u blocker /usr/local/bin/caddy-opnsense-blocker -config /etc/caddy-opnsense-blocker/config.yaml
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9080/healthz
|
||||
```
|
||||
|
||||
If the daemon is bound to another address, adjust the URL accordingly.
|
||||
|
||||
## 7. Install the systemd service
|
||||
|
||||
An example unit file is available in [`../examples/caddy-opnsense-blocker.service`](../examples/caddy-opnsense-blocker.service).
|
||||
|
||||
Typical installation steps:
|
||||
|
||||
```bash
|
||||
cp examples/caddy-opnsense-blocker.service /etc/systemd/system/caddy-opnsense-blocker.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now caddy-opnsense-blocker.service
|
||||
```
|
||||
|
||||
After that:
|
||||
|
||||
```bash
|
||||
systemctl status caddy-opnsense-blocker.service
|
||||
journalctl -u caddy-opnsense-blocker.service -f
|
||||
```
|
||||
|
||||
## 8. Verify end-to-end behavior
|
||||
|
||||
Recommended checks:
|
||||
|
||||
- `GET /healthz` returns `{"status":"ok",...}`
|
||||
- the UI loads on the configured address
|
||||
- new suspicious requests appear in the dashboard
|
||||
- the IP detail page shows the full request history for one address
|
||||
- manual `Block` and `Unblock` actions produce backend actions if OPNsense is enabled
|
||||
|
||||
## 9. Log rotation and upgrades
|
||||
|
||||
- The daemon stores inode and offset per source, so it can resume after restarts.
|
||||
- File truncation or inode changes are detected automatically, which makes the common rename-based log rotation pattern safe.
|
||||
- Upgrades are usually just “replace the binary, restart the service”. The SQLite database is kept separately in the configured state directory.
|
||||
|
||||
## Nix build
|
||||
|
||||
Build the packaged binary directly from the repository root:
|
||||
|
||||
```bash
|
||||
nix-build
|
||||
```
|
||||
|
||||
The result symlink contains the packaged daemon under `result/bin/caddy-opnsense-blocker`.
|
||||
|
||||
## NixOS module
|
||||
|
||||
The repository includes a reusable NixOS module in [`../module.nix`](../module.nix).
|
||||
|
||||
### Import from a local checkout
|
||||
|
||||
```nix
|
||||
{
|
||||
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 = {
|
||||
server.listen_address = "127.0.0.1:9080";
|
||||
|
||||
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-access.json";
|
||||
profile = "public-web";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Notes about the NixOS module
|
||||
|
||||
- The module defaults the service user and group to `caddy`.
|
||||
- The generated configuration file is written from `services.caddy-opnsense-blocker.settings`.
|
||||
- OPNsense credentials can be injected through systemd credentials with `credentials.opnsenseApiKeyFile` and `credentials.opnsenseApiSecretFile`.
|
||||
- The default database path is `/var/lib/caddy-opnsense-blocker/caddy-opnsense-blocker.db`.
|
||||
|
||||
## Operating without OPNsense
|
||||
|
||||
Set `opnsense.enabled: false` to run in review-only mode.
|
||||
|
||||
In that mode the daemon still:
|
||||
|
||||
- ingests logs
|
||||
- stores events and IP state
|
||||
- runs investigations
|
||||
- serves the UI and API
|
||||
- records manual override decisions locally
|
||||
|
||||
It simply stops short of calling an external firewall API.
|
||||
23
examples/Caddyfile
Normal file
23
examples/Caddyfile
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
# caddy-opnsense-blocker expects the default JSON access log format.
|
||||
# If Caddy is itself behind another proxy, make sure request.client_ip
|
||||
# contains the real client address before you feed logs to the blocker.
|
||||
}
|
||||
|
||||
example.com {
|
||||
log {
|
||||
output file /var/log/caddy/public-web-access.json
|
||||
format json
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
git.example.com {
|
||||
log {
|
||||
output file /var/log/caddy/gitea-access.json
|
||||
format json
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
31
examples/caddy-opnsense-blocker.service
Normal file
31
examples/caddy-opnsense-blocker.service
Normal file
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Caddy OPNsense Blocker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=blocker
|
||||
Group=blocker
|
||||
SupplementaryGroups=caddy
|
||||
WorkingDirectory=/var/lib/caddy-opnsense-blocker
|
||||
ExecStart=/usr/local/bin/caddy-opnsense-blocker -config /etc/caddy-opnsense-blocker/config.yaml
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
SystemCallArchitectures=native
|
||||
ReadWritePaths=/var/lib/caddy-opnsense-blocker
|
||||
ReadOnlyPaths=/etc/caddy-opnsense-blocker /var/log/caddy
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user