> 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
rules.yaml watching · udp 127.0.0.1:5514
  1. 1listen: 127.0.0.1:5514
  2. 2instance: web-01
  3. 3
  4. 4notifiers:
  5. 5 console: { type: stderr }
  6. 6 ops:
  7. 7 type: webhook
  8. 8 url_env: HTTPDOG_WEBHOOK_URL
  9. 9 secret_env: HTTPDOG_WEBHOOK_SECRET
  10. 10
  11. 11rules:
  12. 12 # someone is looking for leaked config files
  13. 13 - id: sensitive-file-probe
  14. 14 when:
  15. 15 any:
  16. 16 - { field: uri, op: suffix, value: /.env }
  17. 17 - { field: uri, op: prefix, value: /.git/ }
  18. 18 - { field: uri, op: regex, value: '\.(sql|bak)$' }
  19. 19 group_by: remote_addr
  20. 20 cooldown: 10m
  21. 21 notify: [console, ops]
  22. 22 message: "{remote_addr} tried to access {uri} (status {status})"
  23. 23
  24. 24 # the same IP fails to log in 20 times within 60 seconds
  25. 25 - id: login-bruteforce
  26. 26 when:
  27. 27 all:
  28. 28 - { field: uri, op: prefix, value: /login }
  29. 29 - { field: request_method, op: eq, value: POST }
  30. 30 - { field: status, op: in, value: [401, 403] }
  31. 31 group_by: remote_addr
  32. 32 threshold: { count: 20, window: 60s }
  33. 33 cooldown: 10m
  34. 34 notify: [console, ops]
  35. 35 message: "{remote_addr} repeated login failures"
  36. 36
  37. 37 # slow requests, except the export that is slow by design
  38. 38 - id: slow-request
  39. 39 when:
  40. 40 all:
  41. 41 - { field: request_time, op: gt, value: 5 }
  42. 42 - not: { field: uri, op: prefix, value: /export/ }
  43. 43 cooldown: 5m
  44. 44 notify: [console, ops]
  45. 45 message: "{uri} took {request_time}s"
$ journalctl -u httpdog -f0 alerts
  1. waiting for the first match ...
01-how-it-works match, then notify

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.

  1. 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.

  2. 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).

  3. 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.

02-rules.yaml checked at load time

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.

rules.yaml
- 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})"
fires as
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.

full rule reference

03-features 8 of 8

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.

04-install.sh amd64 + arm64

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.

shell
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
output
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

full instructions in the README

05-webhook.json HMAC-SHA256

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.

request headers
X-Httpdog-Timestamp: 1794416274
X-Httpdog-Signature: sha256=1728aa567d7ef419cab2bf10fd5e1c764cdb608bd22d6f4d051b2043db19b8fe
request body
{
  "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 }
}

webhook reference and a receiver example

06-verified 9 of 9 passed

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.

distributionarchsystemdnginxApachechecks
Ubuntu 22.04arm642491.18.02.4.5253 / 53
Ubuntu 24.04arm642551.24.02.4.5853 / 53
Ubuntu 24.04amd642551.24.02.4.5853 / 53
Ubuntu 26.04arm642591.28.32.4.6653 / 53
Debian 11arm642471.18.02.4.6753 / 53
Debian 12arm642521.22.12.4.7153 / 53
Debian 13arm642571.26.32.4.7153 / 53
Fedora 44arm642591.30.6-50 / 50
Rocky Linux 10arm642571.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.

known limitations and what has not been verified

Give your access log a watchdog.

Open source under the MIT License.