Alerts to Slack and ntfy, each destination with its own queue (closes #90)
check / check (push) Waiting to run
check / check (push) Waiting to run
Each alert is posted as a message to the Slack incoming webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, and published to the ntfy topic SWWAF_ALERT_NTFY_URL names, with SWWAF_ALERT_NTFY_TOKEN as a bearer token and a priority and tag by event. The cooldown and the hourly limit stay shared; past them, each destination has its own bounded queue and backoff, and its own sent, failed and dropped counts. alerts.json keeps the alerts waiting by destination; one whose waiting is still a list stops the start, saying what to change. A control character in the ntfy token, or in the instance name ntfy is sent, stops the start. Judgement call: messages also give the detail's file, source, error and mode. Judgement call: alerts_suppressed_total is the same for every destination. Model: opus-5-5
This commit is contained in:
@@ -19,21 +19,21 @@ ledger with the bans you make, keep and lift, the JSON state files with your
|
||||
edits taken in while it runs and the paths the rate limits do not count, which
|
||||
come next in the build order, `observe` mode and the rest of the request log's
|
||||
fields, which come a little later, and the metrics endpoint and the header size
|
||||
and the idle time as settings, which come last in it. So are four parts of the
|
||||
stage after it: the rule files, the first part, with the bans for a clear sign
|
||||
of attack, the other admin endpoints, the second, alerts to a JSON webhook, the
|
||||
first of the three destinations alerts go to, and remote log sending.
|
||||
`smallwebwaf` passes each request to the app and the app's answer back,
|
||||
unchanged, within its timeouts and size limits, works out each client's address,
|
||||
bans a client that sends too many requests, not counting those for the paths you
|
||||
choose, refuses a client that comes from a country you refuse or from a network
|
||||
you refuse, lets the networks you choose through, checks each request against
|
||||
the rule files and bans a client whose request is a clear sign of attack, keeps
|
||||
its bans, each client's counters and history, and GeoJS's answers in JSON files
|
||||
across restarts, takes in your edits of those files, such as a ban you make,
|
||||
keep or lift, and of the rule files while it runs, writes a JSON log line for
|
||||
every request, sends its log lines to a syslog server too if you name one, sends
|
||||
an alert to a webhook you name for each ban it makes or makes permanent, for
|
||||
and the idle time as settings, which come last in it. So are the four parts of
|
||||
the stage after it: the rule files, with the bans for a clear sign of attack,
|
||||
the other admin endpoints, alerts to all three destinations, a JSON webhook,
|
||||
Slack and ntfy, and remote log sending. `smallwebwaf` passes each request to the
|
||||
app and the app's answer back, unchanged, within its timeouts and size limits,
|
||||
works out each client's address, bans a client that sends too many requests, not
|
||||
counting those for the paths you choose, refuses a client that comes from a
|
||||
country you refuse or from a network you refuse, lets the networks you choose
|
||||
through, checks each request against the rule files and bans a client whose
|
||||
request is a clear sign of attack, keeps its bans, each client's counters and
|
||||
history, and GeoJS's answers in JSON files across restarts, takes in your edits
|
||||
of those files, such as a ban you make, keep or lift, and of the rule files
|
||||
while it runs, writes a JSON log line for every request, sends its log lines to
|
||||
a syslog server too if you name one, sends an alert to a webhook, to Slack and
|
||||
to ntfy, each if you name one, for each ban it makes or makes permanent, for
|
||||
GeoJS failing and for a rule file or state file with an error, serves Prometheus
|
||||
metrics to a scraper that holds the metrics token, lets an admin who holds the
|
||||
admin token list, add and lift bans and ask what it knows of a client, and in
|
||||
@@ -190,10 +190,12 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
||||
- Sends every line it writes on stdout to a syslog server as well, while
|
||||
`SWWAF_LOG_REMOTE_URL` names one (see "Sending the log to a syslog server"
|
||||
below).
|
||||
- Sends an alert, as a JSON object, to the webhook `SWWAF_ALERT_WEBHOOK_URL`
|
||||
names, while it names one, for each ban it makes or makes permanent, for GeoJS
|
||||
failing, and for a rule file or state file with an error, holding back repeats
|
||||
and, past an hourly limit, rolling the rest into one summary (see "Alerts"
|
||||
- Sends an alert for each ban it makes or makes permanent, for GeoJS failing,
|
||||
and for a rule file or state file with an error, holding back repeats and,
|
||||
past an hourly limit, rolling the rest into one summary, to each destination
|
||||
you name: as a JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as
|
||||
a message to the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names,
|
||||
and as a message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts"
|
||||
below).
|
||||
|
||||
## Settings
|
||||
@@ -340,13 +342,29 @@ effective settings are logged at start.
|
||||
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook each alert is posted
|
||||
to, an `http` or `https` URL without a user or a fragment, such as
|
||||
`https://alerts.example/smallwebwaf` (see "Alerts" below). Unset or empty, no
|
||||
alert is sent. Since many webhooks carry their secret in the path or the
|
||||
query, the settings logged at start show `********` in place of them, and a
|
||||
value that stops the start is not shown.
|
||||
alert is sent to a webhook. Since many webhooks carry their secret in the path
|
||||
or the query, the settings logged at start show `********` in place of them,
|
||||
and a value that stops the start is not shown.
|
||||
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each alert,
|
||||
such as one that authenticates it, as a list of a name, `:` and a value, such
|
||||
as `Authorization:Bearer 0123456789abcdef`. A value cannot hold a comma. The
|
||||
settings logged at start show `********` in place of each value.
|
||||
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
||||
each alert is posted to as a message, such as
|
||||
`https://hooks.slack.com/services/T0123/B4567/abcdef`. Unset or empty, no
|
||||
alert is sent to Slack. It is checked and logged as `SWWAF_ALERT_WEBHOOK_URL`
|
||||
is.
|
||||
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic each alert is published
|
||||
to, as the topic's full URL, such as `https://ntfy.sh/my-alerts`. Unset or
|
||||
empty, no alert is sent to ntfy. It is checked and logged as
|
||||
`SWWAF_ALERT_WEBHOOK_URL` is, since anyone who knows a topic on a server open
|
||||
to all can read it.
|
||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token, sent to ntfy
|
||||
with each alert as `Authorization: Bearer <token>`, for a topic that needs
|
||||
one. The settings logged at start show `********` in its place. A control
|
||||
character in it, such as the carriage return of a file saved with Windows line
|
||||
ends, stops the start, and while `SWWAF_ALERT_NTFY_URL` is set, so does one in
|
||||
`SWWAF_INSTANCE_NAME`, which ntfy is sent in the title.
|
||||
- `SWWAF_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||
the events alerts are sent for. `waf_block`, `anomaly` and `reputation_hit`
|
||||
@@ -532,11 +550,13 @@ them.
|
||||
|
||||
## Alerts
|
||||
|
||||
While `SWWAF_ALERT_WEBHOOK_URL` is set, `smallwebwaf` posts each alert to it as
|
||||
one JSON object, with `Content-Type: application/json` and the headers
|
||||
`SWWAF_ALERT_WEBHOOK_HEADERS` gives, as "Alert webhook schema" in
|
||||
[`SPEC.md`](SPEC.md) describes. An alert is for one of these events, and is sent
|
||||
when `SWWAF_ALERT_EVENTS` names its event:
|
||||
`smallwebwaf` sends each alert to each destination you name: to
|
||||
`SWWAF_ALERT_WEBHOOK_URL` it posts the alert as one JSON object, with
|
||||
`Content-Type: application/json` and the headers `SWWAF_ALERT_WEBHOOK_HEADERS`
|
||||
gives, as "Alert webhook schema" in [`SPEC.md`](SPEC.md) describes, and to
|
||||
`SWWAF_ALERT_SLACK_WEBHOOK_URL` and `SWWAF_ALERT_NTFY_URL` a message made from
|
||||
it, as below. An alert is for one of these events, and is sent when
|
||||
`SWWAF_ALERT_EVENTS` names its event:
|
||||
|
||||
- `ban`: a ban `smallwebwaf` makes, for a broken rate limit or a clear sign of
|
||||
attack.
|
||||
@@ -612,6 +632,47 @@ is sent on one line:
|
||||
- `suppressed_repeats` is how many repeats the cooldown held back before this
|
||||
alert.
|
||||
|
||||
Slack and ntfy are each sent the alert as a message: a title, the instance and
|
||||
the event, such as `fsn1app1/gitea: ban`, and a text, the `reason`, then a line
|
||||
for each of the `client`, the `netblock` and the `country`, the `file`, the
|
||||
`source`, the `error` and the `mode` the `detail` gives, and the
|
||||
`suppressed_repeats`, leaving out those that are empty or 0. Slack is posted, as
|
||||
JSON, the title in bold and the text below it, with `&`, `<` and `>` escaped, so
|
||||
that nothing in them is read as a link or a mention. ntfy is posted the text,
|
||||
with the title as `Title`, `SWWAF_ALERT_NTFY_TOKEN`, while it is set, as
|
||||
`Authorization: Bearer <token>`, and the priority and the tag, which ntfy shows
|
||||
as an emoji, of the alert's event, as `Priority` and `Tags`:
|
||||
|
||||
| Event | Priority | Tag |
|
||||
| ------------------------------ | --------- | -------------------------- |
|
||||
| `ban` | `default` | `no_entry` |
|
||||
| `permanent_ban` | `high` | `no_entry` |
|
||||
| `waf_block` | `default` | `shield` |
|
||||
| `anomaly` | `high` | `chart_with_upwards_trend` |
|
||||
| `reputation_hit` | `low` | `label` |
|
||||
| `source_failure`, `file_error` | `high` | `warning` |
|
||||
| `summary` | `default` | `bar_chart` |
|
||||
|
||||
For the alert above, ntfy is sent these headers and this text:
|
||||
|
||||
```text
|
||||
Title: fsn1app1/gitea: ban
|
||||
Priority: default
|
||||
Tags: no_entry
|
||||
|
||||
requests per minute over the limit of 1000
|
||||
client: 203.0.113.9
|
||||
netblock: 203.0.113.9/32
|
||||
```
|
||||
|
||||
and Slack this JSON object, shown indented; it is sent on one line:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "*fsn1app1/gitea: ban*\nrequests per minute over the limit of 1000\nclient: 203.0.113.9\nnetblock: 203.0.113.9/32"
|
||||
}
|
||||
```
|
||||
|
||||
An alert for the same event as the last one sent, on the same netblock, or for a
|
||||
`file_error` about the same file, or for a `source_failure` about the same
|
||||
source, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it is held back
|
||||
@@ -626,18 +687,19 @@ were held back, and its `detail` gives the `hour` as when it started, the
|
||||
starts no cooldown, and the repeats held back before it are given by the next
|
||||
alert sent for the same event and netblock, file or source.
|
||||
|
||||
The alerts wait in a queue of at most 1000, from which they are sent one at a
|
||||
time, the oldest first, so a webhook that is slow or down never holds up a
|
||||
request. The webhook takes an alert by answering with a 2xx status, and refuses
|
||||
it with a 4xx status other than `408` and `429`: a refused alert is logged,
|
||||
counted as dropped, and given up, so that the next is sent. Any other answer, a
|
||||
redirect included, a connection that fails, or no answer within 10 seconds is a
|
||||
failure: it is logged, without the webhook's URL, and the alert is sent again a
|
||||
second later, twice as long after each further failure in a row, up to a minute.
|
||||
With 1000 alerts waiting, the oldest is dropped to make room for a new one. The
|
||||
cooldowns, the hour under way and the alerts still waiting are kept in
|
||||
`alerts.json` (see "State files" below), so that after a restart the alerts
|
||||
waiting are sent, and the cooldowns go on.
|
||||
Each destination has a queue of its own, of at most 1000 alerts, from which they
|
||||
are sent to it one at a time, the oldest first, so a destination that is slow or
|
||||
down holds up neither the others nor any request. A destination takes an alert
|
||||
by answering with a 2xx status, and refuses it with a 4xx status other than
|
||||
`408` and `429`: a refused alert is logged, counted as dropped, and given up, so
|
||||
that the next is sent. Any other answer, a redirect included, a connection that
|
||||
fails, or no answer within 10 seconds is a failure: it is logged, naming the
|
||||
destination's setting and not its URL, and the alert is sent again a second
|
||||
later, twice as long after each further failure in a row, up to a minute. With
|
||||
1000 alerts waiting for a destination, the oldest is dropped to make room for a
|
||||
new one. The cooldowns, the hour under way and the alerts still waiting for each
|
||||
destination are kept in `alerts.json` (see "State files" below), so that after a
|
||||
restart the alerts waiting are sent, and the cooldowns go on.
|
||||
|
||||
## State files
|
||||
|
||||
@@ -668,9 +730,13 @@ entries by client address, but for the alerts waiting, with times in UTC.
|
||||
`source`, or event alone, when the last alert was sent, `sent`, and the
|
||||
repeats held back since, `suppressed_repeats`; under `hour`, the hour under
|
||||
way, from its `start`, the alerts `sent` in it and those `held_back` for its
|
||||
summary, by event; and under `waiting`, the alerts still waiting to be sent,
|
||||
the oldest first, each as the webhook is sent it. As an hour ends, the
|
||||
cooldowns that have run out with no repeat held back are dropped.
|
||||
summary, by event; and under `waiting`, for each destination you name,
|
||||
`webhook`, `slack` or `ntfy`, the alerts still waiting to be sent to it, the
|
||||
oldest first, each as the webhook is sent it. As an hour ends, the cooldowns
|
||||
that have run out with no repeat held back are dropped. As the file is read,
|
||||
the alerts waiting for a destination you no longer name are dropped. A file
|
||||
whose `waiting` is a list, as it was before alerts went to Slack and ntfy too,
|
||||
stops the start: put the list under `"webhook"`, or remove the file.
|
||||
|
||||
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted
|
||||
through `DELETE /_smallwebwaf/bans/<client>`, or made permanent, with every such
|
||||
@@ -695,8 +761,9 @@ without a field it needs, named with the entry's place in the file: a ban's
|
||||
client's `client`, or the `start` of a window in which it has requests; an
|
||||
answer's `client`, `country`, which is `""` for a client GeoJS cannot place, or
|
||||
`answered`; a cooldown's `event` or `sent`; an alert waiting's `event` or
|
||||
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`. The AS
|
||||
number and AS name come with their lookup.
|
||||
`time`. So does a ban whose `cause` is not `limit`, `attack` or `admin`, and
|
||||
alerts waiting for a destination that is not `webhook`, `slack` or `ntfy`. The
|
||||
AS number and AS name come with their lookup.
|
||||
|
||||
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
|
||||
a state file as soon as you save it: what the file then holds replaces what
|
||||
@@ -705,14 +772,14 @@ yours by comparing the file with what it last read or wrote, and before it
|
||||
writes a file it takes in any edit made since, so your edit is not overwritten;
|
||||
a change `smallwebwaf` made after you opened the file, such as a new ban, is
|
||||
lost when you save over it. An edit that would stop the start, because it does
|
||||
not parse, has another `version`, leaves out a field an entry needs or gives a
|
||||
ban another `cause`, does not stop the running `smallwebwaf`: it keeps what it
|
||||
holds, and at the file's next write renames your file to `<name>.bad`, such as
|
||||
`bans.json.bad`, writes the file again from memory, logs the file and where the
|
||||
error is, and raises a `file_error` alert for it. It waits for that write
|
||||
because an editor's file can be read before the editor has finished writing it.
|
||||
Mend the `.bad` file and move it back. A file you remove is written again at its
|
||||
next write.
|
||||
not parse, has another `version`, leaves out a field an entry needs, gives a ban
|
||||
another `cause` or names another destination, does not stop the running
|
||||
`smallwebwaf`: it keeps what it holds, and at the file's next write renames your
|
||||
file to `<name>.bad`, such as `bans.json.bad`, writes the file again from
|
||||
memory, logs the file and where the error is, and raises a `file_error` alert
|
||||
for it. It waits for that write because an editor's file can be read before the
|
||||
editor has finished writing it. Mend the `.bad` file and move it back. A file
|
||||
you remove is written again at its next write.
|
||||
|
||||
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
|
||||
and its `expires`, `null` for a ban that never ends; its `reason` and its
|
||||
@@ -876,12 +943,13 @@ other request. No metric carries a client's address.
|
||||
`smallwebwaf_remote_log_lines_dropped_total`: those dropped, from a full
|
||||
buffer or because their sending failed; and
|
||||
`smallwebwaf_remote_log_buffer_depth`: those waiting in the buffer.
|
||||
- While `SWWAF_ALERT_WEBHOOK_URL` is set, by `destination`, `webhook`:
|
||||
`smallwebwaf_alerts_sent_total`: the alerts the webhook took;
|
||||
- For each destination you name, by `destination`, `webhook`, `slack` or `ntfy`:
|
||||
`smallwebwaf_alerts_sent_total`: the alerts it took;
|
||||
`smallwebwaf_alerts_failed_total`: the requests to it that failed;
|
||||
`smallwebwaf_alerts_suppressed_total`: the alerts held back, as repeats or for
|
||||
an hour's summary; and `smallwebwaf_alerts_dropped_total`: those dropped from
|
||||
a full queue, or given up as the webhook refused them.
|
||||
an hour's summary, which are the same for every destination; and
|
||||
`smallwebwaf_alerts_dropped_total`: those dropped from its full queue, or
|
||||
given up as it refused them.
|
||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||
|
||||
The requests Go's HTTP server ends before `smallwebwaf` sees them (see "Request
|
||||
@@ -1259,8 +1327,8 @@ addresses are never sent to GeoJS.
|
||||
standard library alone, whose `log/syslog` writes only the older syslog
|
||||
format.
|
||||
- `internal/alerts`: takes the alerts the other parts raise, holds back repeats
|
||||
and those past the hourly limit, and sends the others to
|
||||
`SWWAF_ALERT_WEBHOOK_URL` from a queue of its own.
|
||||
and those past the hourly limit, and sends the others to the webhook, Slack
|
||||
and ntfy, each from a queue of its own.
|
||||
- `Dockerfile`: the lint and test phases, then the image, whose last stage
|
||||
installs Ubuntu's packages, nixpkgs, `runsvinit` and `smallwebwaf`, with
|
||||
`share/smallwebwaf.run` as runit's `run` script for `smallwebwaf` and
|
||||
|
||||
Reference in New Issue
Block a user