ratelimit: watch YAML policy files with debounce

Why
YAML-backed ratelimit policies were only reloaded on HUP, which made
policy tuning slower and harder to automate.

Impact
policyWatch and policyWatchDebounce now reload watched ratelimit
policies automatically when inotify is available, and otherwise fall
back cleanly to HUP-only behavior.

Before/After
Before: external ratelimit policy changes required HUP to take effect.
After: watched policy files reload automatically after a debounce
interval, while unsupported builds keep the previous behavior.

Technical Overview
Add policyWatch and policyWatchDebounce to ratelimit() config parsing
and store the settings in shared ratelimit state.

Introduce an inotify-backed watcher path in runtime/ratelimit.c that
watches parent directories, coalesces rapid file events with a debounce
window, and reloads both policy and per-source policy files by reusing
existing HUP-era parse-and-swap logic.

Keep unsupported builds working by compiling the watcher code behind
inotify feature checks and downgrading watch requests to warning-only
HUP semantics.

Document the new options and add integration coverage for direct file
updates, debounce behavior, rename-based replacement, and YAML frontend
parity via tests guarded by inotify availability.

Closes https://github.com/rsyslog/rsyslog/issues/6599

With the help of AI-Agents: Codex
This commit is contained in:
Rainer Gerhards 2026-04-07 09:30:49 +02:00
parent 30413f6115
commit 1cc464e8d0
No known key found for this signature in database
GPG Key ID: 0CB6B2A8BE80B499
8 changed files with 1057 additions and 91 deletions

View File

@ -51,6 +51,38 @@ burst
The maximum number of messages allowed within the ``interval``.
.. _ratelimit_policywatch:
policyWatch
^^^^^^^^^^^
.. csv-table::
:header: "type", "required", "default"
:widths: 20, 10, 20
"boolean", "no", "off"
Enable automatic reload of configured external policy files when they change.
When watch support is available, rsyslog monitors the configured ``policy`` and
``perSourcePolicy`` files and reloads them after the debounce interval. When
watch support is unavailable in the current build or runtime environment,
rsyslog logs a warning and continues with HUP-only reload behavior.
.. _ratelimit_policywatchdebounce:
policyWatchDebounce
^^^^^^^^^^^^^^^^^^^
.. csv-table::
:header: "type", "required", "default"
:widths: 20, 10, 20
"time interval", "no", "5s"
Quiet period applied to ``policyWatch`` reloads. Each new file event resets the
timer, so rapid updates are coalesced into one reload. Supported suffixes are
``ms``, ``s``, ``m``, and ``h``. Bare numbers are interpreted as seconds.
.. _ratelimit_persource:
perSource
@ -138,6 +170,12 @@ Example
# Define a strict rate limit for public facing ports
ratelimit(name="strict" interval="1" burst="50")
# Define a watched YAML policy for automatic reload
ratelimit(name="watched"
policy="/etc/rsyslog/ratelimit.yaml"
policyWatch="on"
policyWatchDebounce="500ms")
# Define per-source policy for TCP inputs
ratelimit(name="per_source"
perSource="on"

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,8 @@ typedef struct ratelimit_shared_s {
unsigned int burst;
intTiny severity;
char *policy_file;
sbool policy_watch;
unsigned int policy_watch_debounce_ms;
pthread_mutex_t mut;
sbool per_source_enabled;
char *per_source_policy_file;
@ -99,6 +101,8 @@ rsRetVal ratelimitAddConfig(rsconf_t *conf,
unsigned int burst,
intTiny severity,
const char *policy_file,
sbool policy_watch,
const char *policy_watch_debounce,
sbool per_source_enabled,
const char *per_source_policy_file,
const char *per_source_key_tpl_name,

View File

@ -159,6 +159,8 @@ static struct cnfparamdescr ratelimitpdescr[] = {{"name", eCmdHdlrString, CNFPAR
{"burst", eCmdHdlrInt, 0},
{"severity", eCmdHdlrSeverity, 0},
{"policy", eCmdHdlrString, 0},
{"policyWatch", eCmdHdlrBinary, 0},
{"policyWatchDebounce", eCmdHdlrString, 0},
{"perSource", eCmdHdlrBinary, 0},
{"perSourcePolicy", eCmdHdlrString, 0},
{"perSourceKeyTpl", eCmdHdlrString, 0},
@ -493,6 +495,8 @@ static rsRetVal initFunc_ratelimit(struct cnfobj *o) {
int burst = 10000;
int severity = -1; /* -1 means not set/all */
uchar *policy = NULL;
int policy_watch = 0;
uchar *policy_watch_debounce = NULL;
int per_source_enabled = 0;
uchar *per_source_policy = NULL;
uchar *per_source_key_tpl = NULL;
@ -517,6 +521,10 @@ static rsRetVal initFunc_ratelimit(struct cnfobj *o) {
severity = (int)pvals[i].val.d.n;
} else if (!strcmp(ratelimitpblk.descr[i].name, "policy")) {
policy = (uchar *)es_str2cstr(pvals[i].val.d.estr, NULL);
} else if (!strcmp(ratelimitpblk.descr[i].name, "policyWatch")) {
policy_watch = (int)pvals[i].val.d.n;
} else if (!strcmp(ratelimitpblk.descr[i].name, "policyWatchDebounce")) {
policy_watch_debounce = (uchar *)es_str2cstr(pvals[i].val.d.estr, NULL);
} else if (!strcmp(ratelimitpblk.descr[i].name, "perSource")) {
per_source_enabled = (int)pvals[i].val.d.n;
} else if (!strcmp(ratelimitpblk.descr[i].name, "perSourcePolicy")) {
@ -548,12 +556,14 @@ static rsRetVal initFunc_ratelimit(struct cnfobj *o) {
}
CHKiRet(ratelimitAddConfig(loadConf, (char *)name, (unsigned)interval, (unsigned)burst, (intTiny)severity,
(char *)policy, per_source_enabled, (char *)per_source_policy,
(char *)per_source_key_tpl, (unsigned)per_source_max_states, (unsigned)per_source_topn));
(char *)policy, policy_watch, (char *)policy_watch_debounce, per_source_enabled,
(char *)per_source_policy, (char *)per_source_key_tpl, (unsigned)per_source_max_states,
(unsigned)per_source_topn));
finalize_it:
free(name);
free(policy);
free(policy_watch_debounce);
free(per_source_policy);
free(per_source_key_tpl);
cnfparamvalsDestruct(pvals, &ratelimitpblk);

View File

@ -584,6 +584,11 @@ TESTS_LIBYAML = \
yaml-template-subtree.sh \
yaml-statements-reload-lookup.sh
TESTS_RATELIMIT_WATCH = \
ratelimit_policy_watch.sh \
ratelimit_policy_watch_debounce.sh \
yaml-ratelimit-policywatch.sh
TESTS_IMTCP = \
fromhost-port.sh \
fromhost-port-tuple.sh \
@ -1733,6 +1738,7 @@ EXTRA_DIST += $(TESTS_OSSL_WRONG_OPT)
EXTRA_DIST += $(TESTS_DEFAULT_VALGRIND)
EXTRA_DIST += $(TESTS_LIBGCRYPT_VALGRIND)
EXTRA_DIST += $(TESTS_LIBYAML)
EXTRA_DIST += $(TESTS_RATELIMIT_WATCH)
EXTRA_DIST += $(TESTS_LIBYAML_IMTCP)
EXTRA_DIST += $(TESTS_LIBYAML_OMSTDOUT)
EXTRA_DIST += $(TESTS_IMTCP)
@ -2096,6 +2102,9 @@ endif # ENABLE_LIBGCRYPT
endif # HAVE_VALGRIND
if HAVE_LIBYAML
TESTS += $(TESTS_LIBYAML)
if ENABLE_INOTIFY
TESTS += $(TESTS_RATELIMIT_WATCH)
endif # ENABLE_INOTIFY
if ENABLE_IMTCP_TESTS
TESTS += $(TESTS_LIBYAML_IMTCP)
endif # ENABLE_IMTCP_TESTS

69
tests/ratelimit_policy_watch.sh Executable file
View File

@ -0,0 +1,69 @@
#!/bin/bash
# Test inotify-based reload of external rate limit policies, including
# rename-based atomic replacement.
. ${srcdir:=.}/diag.sh init
. $srcdir/diag.sh check-inotify
export PORT_RCVR="$(get_free_port)"
export POLICY_FILE="$(pwd)/${RSYSLOG_DYNNAME}.policy.yaml"
export POLICY_TMP="$(pwd)/${RSYSLOG_DYNNAME}.policy.tmp.yaml"
export SENDMESSAGES=20
cat > "$POLICY_FILE" <<'YAML'
interval: 1
burst: 1000
severity: 0
YAML
generate_conf
add_conf '
global(processInternalMessages="on")
ratelimit(name="watch_limiter" policy="'$POLICY_FILE'" policyWatch="on" policyWatchDebounce="200ms")
module(load="../plugins/imudp/.libs/imudp" batchSize="1")
input(type="imudp" port="'$PORT_RCVR'" ratelimit.name="watch_limiter" ruleset="main")
template(name="outfmt" type="string" string="RECEIVED RAW: %rawmsg%\n")
ruleset(name="main") {
action(type="omfile" file="'$RSYSLOG_OUT_LOG'" template="outfmt")
}
'
startup
./tcpflood -Tudp -p$PORT_RCVR -m $SENDMESSAGES -M "msgnum:"
wait_file_lines "$RSYSLOG_OUT_LOG" 20 100
: > "$RSYSLOG_OUT_LOG"
cat > "$POLICY_FILE" <<'YAML'
interval: 10
burst: 0
severity: 0
YAML
./msleep 1500
./tcpflood -Tudp -p$PORT_RCVR -m $SENDMESSAGES -M "msgnum:"
./msleep 1000
wait_queueempty
content_count=$(grep -c "msgnum:" "$RSYSLOG_OUT_LOG" || true)
if [ "$content_count" -ne 0 ]; then
echo "FAIL: watch reload expected 0 messages after restrictive update, got $content_count"
error_exit 1
fi
cat > "$POLICY_TMP" <<'YAML'
interval: 1
burst: 1000
severity: 0
YAML
mv -f "$POLICY_TMP" "$POLICY_FILE"
./msleep 1500
./tcpflood -Tudp -p$PORT_RCVR -m $SENDMESSAGES -M "msgnum:"
wait_file_lines "$RSYSLOG_OUT_LOG" 20 100
shutdown_when_empty
wait_shutdown
exit_test

View File

@ -0,0 +1,68 @@
#!/bin/bash
# Test debounce handling for inotify-based ratelimit policy reloads.
. ${srcdir:=.}/diag.sh init
. $srcdir/diag.sh check-inotify
export PORT_RCVR="$(get_free_port)"
export POLICY_FILE="$(pwd)/${RSYSLOG_DYNNAME}.policy.yaml"
export SENDMESSAGES=20
cat > "$POLICY_FILE" <<'YAML'
interval: 1
burst: 1000
severity: 0
YAML
generate_conf
add_conf '
global(processInternalMessages="on")
ratelimit(name="debounce_limiter" policy="'$POLICY_FILE'" policyWatch="on" policyWatchDebounce="500ms")
module(load="../plugins/imudp/.libs/imudp" batchSize="1")
input(type="imudp" port="'$PORT_RCVR'" ratelimit.name="debounce_limiter" ruleset="main")
template(name="outfmt" type="string" string="RECEIVED RAW: %rawmsg%\n")
ruleset(name="main") {
action(type="omfile" file="'$RSYSLOG_OUT_LOG'" template="outfmt")
}
'
startup
cat > "$POLICY_FILE" <<'YAML'
interval: 1
burst: 200
severity: 0
YAML
./msleep 100
cat > "$POLICY_FILE" <<'YAML'
interval: 1
burst: 100
severity: 0
YAML
./msleep 100
cat > "$POLICY_FILE" <<'YAML'
interval: 10
burst: 0
severity: 0
YAML
./msleep 1500
./tcpflood -Tudp -p$PORT_RCVR -m $SENDMESSAGES -M "msgnum:"
./msleep 1000
wait_queueempty
if [ -f "$RSYSLOG_OUT_LOG" ]; then
content_count=$(grep -c "msgnum:" "$RSYSLOG_OUT_LOG" || true)
else
content_count=0
fi
if [ "$content_count" -ne 0 ]; then
echo "FAIL: final debounced policy expected 0 messages, got $content_count"
error_exit 1
fi
shutdown_when_empty
wait_shutdown
exit_test

View File

@ -0,0 +1,73 @@
#!/bin/bash
# Test YAML ratelimit object policyWatch/policyWatchDebounce parsing and reload.
. ${srcdir:=.}/diag.sh init
. $srcdir/diag.sh check-inotify
export PORT_RCVR="$(get_free_port)"
export POLICY_FILE="$(pwd)/${RSYSLOG_DYNNAME}.policy.yaml"
export SENDMESSAGES=20
cat > "$POLICY_FILE" <<'YAML'
interval: 1
burst: 1000
severity: 0
YAML
generate_conf
add_conf '
include(file="'${RSYSLOG_DYNNAME}'.yaml")
module(load="../plugins/imudp/.libs/imudp" batchSize="1")
input(type="imudp" port="'$PORT_RCVR'" ratelimit.name="yaml_watch" ruleset="main")
'
cat > "${RSYSLOG_DYNNAME}.yaml" <<'YAMLEOF'
ratelimits:
- name: yaml_watch
policy: "${POLICY_FILE}"
policyWatch: on
policyWatchDebounce: 200ms
templates:
- name: outfmt
type: string
string: "RECEIVED RAW: %rawmsg%\n"
rulesets:
- name: main
script: |
action(type="omfile" file="${RSYSLOG_OUT_LOG}" template="outfmt")
YAMLEOF
sed -i \
-e "s|\${POLICY_FILE}|${POLICY_FILE}|g" \
-e "s|\${RSYSLOG_OUT_LOG}|${RSYSLOG_OUT_LOG}|g" \
"${RSYSLOG_DYNNAME}.yaml"
startup
./tcpflood -Tudp -p$PORT_RCVR -m $SENDMESSAGES -M "msgnum:"
wait_file_lines "$RSYSLOG_OUT_LOG" 20 100
: > "$RSYSLOG_OUT_LOG"
cat > "$POLICY_FILE" <<'YAML'
interval: 10
burst: 0
severity: 0
YAML
./msleep 1500
./tcpflood -Tudp -p$PORT_RCVR -m $SENDMESSAGES -M "msgnum:"
./msleep 1000
wait_queueempty
content_count=$(grep -c "msgnum:" "$RSYSLOG_OUT_LOG" || true)
if [ "$content_count" -ne 0 ]; then
echo "FAIL: YAML ratelimit policyWatch expected 0 messages after update, got $content_count"
error_exit 1
fi
shutdown_when_empty
wait_shutdown
exit_test