> nginx / Apache access log alerting
Your access loghas a watchdog.
httpdog reads nginx and Apache access logs line by line and notifies you the moment a request looks wrong: a probe for /.env, a burst of failed logins, a wave of 5xx.
- one static binary, about 4 MB
- about 5 MB of memory
- no database
- MIT
- 1listen: 127.0.0.1:5514
- 2instance: web-01
- 3
- 4notifiers:
- 5 console: { type: stderr }
- 6 ops:
- 7 type: webhook
- 8 url_env: HTTPDOG_WEBHOOK_URL
- 9 secret_env: HTTPDOG_WEBHOOK_SECRET
- 10
- 11rules:
- 12 # someone is looking for leaked config files
- 13 - id: sensitive-file-probe
- 14 when:
- 15 any:
- 16 - { field: uri, op: suffix, value: /.env }
- 17 - { field: uri, op: prefix, value: /.git/ }
- 18 - { field: uri, op: regex, value: '\.(sql|bak)$' }
- 19 group_by: remote_addr
- 20 cooldown: 10m
- 21 notify: [console, ops]
- 22 message: "{remote_addr} tried to access {uri} (status {status})"
- 23
- 24 # the same IP fails to log in 20 times within 60 seconds
- 25 - id: login-bruteforce
- 26 when:
- 27 all:
- 28 - { field: uri, op: prefix, value: /login }
- 29 - { field: request_method, op: eq, value: POST }
- 30 - { field: status, op: in, value: [401, 403] }
- 31 group_by: remote_addr
- 32 threshold: { count: 20, window: 60s }
- 33 cooldown: 10m
- 34 notify: [console, ops]
- 35 message: "{remote_addr} repeated login failures"
- 36
- 37 # slow requests, except the export that is slow by design
- 38 - id: slow-request
- 39 when:
- 40 all:
- 41 - { field: request_time, op: gt, value: 5 }
- 42 - not: { field: uri, op: prefix, value: /export/ }
- 43 cooldown: 5m
- 44 notify: [console, ops]
- 45 message: "{uri} took {request_time}s"
- waiting for the first match ...
One log line in, one decision out.
You describe what deserves a notification in a rule file. httpdog checks every line your web server writes against it. It stores nothing and there is nothing to query: it matches, and it tells you.
- 01
Your web server sends its access log
nginx sends each line over syslog (UDP); Apache pipes it. The default log format of both is understood as it is, so the web server config can stay the way it is.
- 02
httpdog matches it against your rules
Conditions on any field of the log line, thresholds (20 times in 60 seconds), grouping (each IP counted on its own) and cooldowns (at most once every 10 minutes).
- 03
A match becomes one notification
A signed JSON POST to a receiver you own, or a line in the system log. A slow or dead receiver never holds up log intake.
!httpdog is not a log store. To look back through old logs, pair it with a tool such as Loki or Graylog.
Say what should wake you up.
A rule is a condition on the fields of a log line, plus how often it may fire. The whole file is validated when it loads, so a typo is reported with its location right away instead of surfacing at 3 a.m.
Someone is looking for /.env, /.git/ or a database dump. Fires on a single request, at most once per IP every 10 minutes.
- id: sensitive-file-probe
when:
any:
- { field: uri, op: suffix, value: /.env }
- { field: uri, op: prefix, value: /.git/ }
- { field: uri, op: regex, value: '\.(sql|bak|tar\.gz)$' }
group_by: remote_addr
cooldown: 10m
notify: [console, ops]
message: "{remote_addr} tried to access {uri} (status {status})"
ALERT [httpdog] sensitive-file-probe | 203.0.113.7 tried to access /.env (status 404) | remote_addr=203.0.113.7
what a rule is made of
- when
- the condition: which field, how to compare, against what. Combine with all / any / not
- threshold
- how many hits within a period before notifying
- group_by
- count separately per value of a field, e.g. per IP
- cooldown
- stay quiet about the same group for this long
- notify
- which notifiers get it
- message
- the text; {field} becomes the value from the log line
12 operators
- eq
- ne
- in
- prefix
- suffix
- contains
- regex
- gt
- gte
- lt
- lte
- exists
6 rule files to start from
Complete configs for security probing, login abuse, site health, traffic anomalies and business-critical paths.
Small on purpose.
It does one job and stays out of the way.
- [ light ]
One static binary
About 4 MB on disk and about 5 MB of memory when idle. No database, no agent, no other service to run.
- [ zero config ]
Reads the logs you already have
Understands the default combined format of nginx and Apache as it is. Switch to JSON when you want to match on any field you like.
- [ reload ]
Change rules without a restart
systemctl reload applies them. A broken rule file keeps the old rules running, so monitoring never stops because of a typo.
- [ non-blocking ]
Notifications never slow matching
They are sent on their own thread and every queue is bounded. A dead webhook cannot back up log intake or eat memory.
- [ signed ]
Webhooks you can verify
Every request carries an HMAC-SHA256 signature and a timestamp. A webhook without a secret does not even load.
- [ strict ]
Mistakes fail at load, not at 3 a.m.
Unknown keys, bad regexes, wrong value types and missing secrets are all rejected when the file loads. --check validates without starting.
- [ hostile input ]
Log content is treated as hostile
It comes from whoever sends the request. The parsers and the rule engine are fuzzed, and a request cannot forge a line in your system log.
- [ systemd ]
A well-behaved service
Type=notify, a watchdog that restarts a hung process, no root, and an exit status that tells systemd when restarting will not help.
Running in a few minutes.
Replay a sample log to watch the rules fire, then install the release binary and point your web server at it.
No web server needed: build it and replay the bundled sample log. Rust 1.85 or later.
git clone https://github.com/guanting112/httpdog.git && cd httpdog
cargo build --release
# the example rules notify a webhook; a dummy URL and secret will do for now
export HTTPDOG_WEBHOOK_URL=http://127.0.0.1:9/dummy
export HTTPDOG_WEBHOOK_SECRET=dummy
./target/release/httpdog --stdin --config rules.example.yaml < sample.log
httpdog started: 4 rule(s), reading from stdin
ALERT [httpdog] sensitive-file-probe | 203.0.113.7 tried to access /.env (status 404) | remote_addr=203.0.113.7
ALERT [httpdog] slow-request | /report/monthly took 7.31s
ALERT [httpdog] sensitive-file-probe | 192.0.2.44 tried to access /backup/db.sql (status 404) | remote_addr=192.0.2.44
done: 6 lines, 0 unparseable, 3 alerts fired
Alerts your own code can trust.
A webhook is a JSON POST to a URL you choose. Every part of the alert has its own field, so your receiver can route by rule, store it, or format it for people.
Signed, always
The signature covers a timestamp and the exact bytes sent, so a receiver can tell the request came from httpdog and reject replays.
Every key, every time
Fields that do not apply are null rather than missing. Your receiver never has to handle an absent key.
Which server sent it
instance names the httpdog that fired, for when several of them report to one receiver.
Retried, then let go
A 5 second timeout and 3 attempts. After that the alert is written to the system log instead of being resent forever.
X-Httpdog-Timestamp: 1794416274
X-Httpdog-Signature: sha256=1728aa567d7ef419cab2bf10fd5e1c764cdb608bd22d6f4d051b2043db19b8fe
{
"source": "httpdog",
"instance": "web-01",
"version": 1,
"kind": "rule",
"rule": "login-bruteforce",
"timestamp": 1794416274,
"text": "[httpdog] login-bruteforce | 198.51.100.9 repeated login failures | 3x in 60s | remote_addr=198.51.100.9",
"message": "198.51.100.9 repeated login failures",
"group_by": "remote_addr",
"group": "198.51.100.9",
"hits": 3,
"window_secs": 60,
"dropped": null,
"event": { "remote_addr": "198.51.100.9", "uri": "/login", "status": 401 }
}
Checked on real systems.
The release binaries were run under a real systemd with a real nginx and Apache on each of these: start-up, reload, crash recovery, the watchdog, signed webhooks, overload alerts.
| distribution | arch | systemd | nginx | Apache | checks |
|---|---|---|---|---|---|
| Ubuntu 22.04 | arm64 | 249 | 1.18.0 | 2.4.52 | 53 / 53 |
| Ubuntu 24.04 | arm64 | 255 | 1.24.0 | 2.4.58 | 53 / 53 |
| Ubuntu 24.04 | amd64 | 255 | 1.24.0 | 2.4.58 | 53 / 53 |
| Ubuntu 26.04 | arm64 | 259 | 1.28.3 | 2.4.66 | 53 / 53 |
| Debian 11 | arm64 | 247 | 1.18.0 | 2.4.67 | 53 / 53 |
| Debian 12 | arm64 | 252 | 1.22.1 | 2.4.71 | 53 / 53 |
| Debian 13 | arm64 | 257 | 1.26.3 | 2.4.71 | 53 / 53 |
| Fedora 44 | arm64 | 259 | 1.30.6 | - | 50 / 50 |
| Rocky Linux 10 | arm64 | 257 | 1.26.3 | - | 50 / 50 |
- Tested in OrbStack machines, which share one kernel; SELinux was disabled on Fedora and Rocky Linux. Apache was only tested on Ubuntu and Debian.
- The log parsers and the rule engine are fuzzed on every push.