ReportedIP Agent for Linux
The agent is a single static binary that does two jobs on a Linux server: it keeps the community blacklist in the kernel firewall, and it reports the attackers it finds in that server's own logs. Optionally it also blocks those local finds itself. It replaces the hand-written sync script documented on this site, and it takes over the work that fail2ban used to do.
linux/amd64 and linux/arm64. Each server needs a licence, one is included
with Professional and three with Business; see Server licences.
Licences are added and removed under Agent Servers in your
account.
What it does
Community blacklist into the kernel
Five lists, one per exposed service (ssh, mail, web, ftp, edge), fetched as often as your plan allows and swapped into an ipset or an nftables set atomically. A list that came back empty or implausibly short is never applied, so a failed fetch leaves the previous set in place instead of opening the door.
Local attacks recognised and reported
The agent tails the logs the server already writes, counts hits per address against a threshold that belongs to that source, and reports the address once the threshold is reached. No log line, no user name and no URL ever leaves the machine.
Local finds blocked, if you want that
Off after the installation. Switched on, an address the agent caught itself is banned in a kernel set with a timeout, and a repeat offender is banned for longer each time.
Requirements
Four things, and only the last one has to come from us.
- A Linux kernel with a packet filter you can write to. Either
ipsettogether withiptables, ornftables. On a Debian or Ubuntu host that isapt install ipset iptablesorapt install nftables, on the RHEL familydnf install ipset iptablesordnf install nftables. The agent also usesip6tableswhen it is there, and without it IPv6 bans are recorded but not enforced. - systemd for the timer and the watch service. A host without systemd still runs the agent, with the sync from cron and the watcher from your own init system, and the section below says how.
- Root. It writes firewall rules and reads logs that are not world-readable, and both of those need it. There is no reduced mode that runs as a normal user.
- An API key from your account. The key is used twice during the installation, once to download the licensed build and once to register the host. One key per host is the recommendation; several hosts on one key is allowed and costs nothing extra, because a host is identified by its install ID and not by the key.
Nothing else. The binary is statically linked and built without cgo, so it brings no interpreter, no
shared library, no package repository and no cron helper of its own. For the download and the
checksum, install.sh needs curl or wget plus
sha256sum, and it refuses to install if the checksum tool is missing. The agent was
developed and measured on Debian 12 (bookworm) on arm64 with ISPConfig, nginx, Postfix, pure-ftpd
and bind.
ipset or nftables, and what auto decides
Both backends do the same job and neither is better on a clean host. What matters is which one the host already uses, because two packet filters writing the same INPUT chain is how an afternoon disappears.
backend | What happens |
|---|---|
auto (default) | ipset is preferred when both ipset and iptables are on the host, otherwise nft is used. The preference exists because CSF and the published firewall guide use that tool chain, so a host with existing rules keeps them. |
ipset | Sets in ipset, rules in the rip-blacklist chain of iptables and ip6tables. Fails with a clear message when either tool is missing, rather than falling back. |
nftables | Sets and rules in the native table inet reportedip. Fails when nft is missing. |
A version check is never used to decide this. Only a binary that exists counts, because
iptables --version on a host with the nft backend says something that looks right and
means nothing. Once a host has synced, the chosen backend is recorded, and a later change needs
reportedip-agent sync --migrate-backend instead of silently building a second set of
rules.
Check the host first
doctor is the command to run before installing. It changes nothing at all: no
file, no directory, no rule, no set. Everything it reports it read.
reportedip-agent doctor
It answers six questions, in this order:
- system: the distribution as its own
os-releasenames it, the kernel, the platform, whether systemd is really running (read from/run/systemd/system, not from the presence ofsystemctl), the time zone that logs without an offset are read in, and the free space where the state directory will live. - ssh: which unit runs sshd, and the port. The interesting case is
ssh.socket: with socket activationsshd -Tstill answers 22 while the real port is in the socket unit, so the port is taken from the listening process instead. doctor says which of the two applied, because an admin who sees a wrong port in the config looks here first. - firewall: where
ipset,iptables,ip6tablesandnftare, which backend that resolves to, and whether the local ban set carries a per-entry timeout. That last point decides whether the kernel expires a ban on its own, which is what keeps a crashed agent from leaving a permanent block behind. - panel: ISPConfig, Plesk, cPanel or DirectAdmin. Only the ISPConfig per-site log layout is known to this version. The others are named rather than guessed, because a wrong glob would have the agent read byte counters instead of access logs.
- sources: every configured source resolved to real files, with how many there are and how many are readable, the largest one with an estimated line count, and a note about files that are there and empty. On the largest panel host measured, the web globs resolve to 196 files, and whoever installs the agent there should learn that before and not after.
- mail: whether an alert could actually leave this host. Of eighteen hosts
measured, fourteen could deliver mail, two had
sendmailwith a stopped MTA, and two had no MTA at all. Without this section somebody waits for a mail that never comes.
Reading the verdict
The last block is called verdict and it maps onto the exit code, so a monitoring check
needs no output parsing.
| Exit | Verdict | What it means |
|---|---|---|
0 | everything the agent needs is here | Install. |
1 | runs on this host, with the limits above | Every limit is printed by name. A missing firewall backend means this host reports and does not block, which is a valid installation. A source that resolves to nothing is worth fixing first. |
2 | cannot run on this host | Not a Linux host, or a host that can neither block nor read a single log. Fix the cause before installing. |
Run doctor again after the installation. With a config in place it stops guessing and
reports the configured sources instead of the detected ones, and it adds the clock drift per source
that the daemon measured. A source whose log stamps every line more than a minute away from the
system clock has a counting window that never fills, and that is the one fault which looks exactly
like "the detector does not work".
Install
One command, run as root. It reads the current version from the public metadata, downloads the build
for your architecture with your key, verifies it against SHA256SUMS, installs it to
/usr/local/bin/reportedip-agent and then sets the host up.
curl -fsSL https://reportedip.com/agent/install.sh | REPORTEDIP_KEY=<YOUR-KEY> sh
The key goes into the environment because it is needed twice, once for the licensed download and once
to register the host, and pasting it twice is how a key ends up in a shell history in the wrong form.
The script is POSIX sh on purpose: it has to run on hosts where bash is not the default
shell. It stops with a named reason and installs nothing when it is not run as root, when the key is
unset, when the platform is not Linux, when the architecture is neither amd64 nor arm64, when neither
curl nor wget exists, and when sha256sum is missing.
The two fetchers are deliberately separate. The version metadata is pulled without the key, the licensed files with it, so the key is never sent to a redirect target that only serves public metadata. Of the checksum file only the line for your own architecture is checked, because the file also lists the other one.
What the installer really does
Everything below is idempotent. Running the same command again on a host that already has the agent upgrades the binary and leaves the configuration exactly as it was.
- Reads
https://reportedip.com/agent/latestwithout the key and pulls the version out of it. That file is the same JSON the agent itself polls for updates. - Downloads
reportedip-agent_linux_<arch>andSHA256SUMSwith theX-Keyheader into a temporary directory that is removed on every exit path. - Verifies the checksum and refuses to install on a mismatch.
- Installs the binary with mode 0755 to
/usr/local/bin/reportedip-agentand prints the version it just placed. - Restarts
reportedip-agent.serviceif that unit is currently active. This matters on an upgrade: the watch daemon would otherwise keep running the old code until the host reboots. The sync unit is a oneshot and picks the new binary up by itself on its next start. - Runs the host setup, unless
REPORTEDIP_NO_SETUP=1is set or/etc/reportedip-agent/config.yamlalready exists. In the second case it says so and leaves the configuration alone.
The host setup is the step that writes files, and it is the same thing as
reportedip-agent install --key. In order:
- Creates
/etc/reportedip-agentand/var/lib/reportedip-agent, the second one mode 0700, and it corrects the mode of a directory that was already there. - Detects the SSH port from
sshd -T, fromSSH_CONNECTIONand from the listeners of running sshd processes, and uses the union of the three. When nothing is found it stops and asks for--ssh-portrather than assuming 22. - Decides which of the five feed lists to configure.
sshandedgeare always on.mail,webandftpare switched on when a matching unit is active or a matching port is listening, so a host with no web server gets no web list. - Detects the log sources and writes them into the config explicitly. Detection happens once, at install time, and the result stays in the file: a service that is stopped for maintenance must never switch a source off silently.
- Writes
/etc/reportedip-agent/config.yamlwith mode 0600 andmode: log,ban.enabled: false. An existing config is never overwritten, and--keyis then ignored with a note that says to editapi_keyin the file instead. - Writes the auto-whitelist in the state directory: the address your SSH session came from, plus every interface address of the host. The session address of an earlier install run is kept, so a second run from a different session does not strip the address the first one relied on. Interface addresses are re-derived on each run, so an address you removed does not linger.
- Generates the install ID, a random UUID v4 in
/var/lib/reportedip-agent/install-id. That is the identity this server has towards the API from then on. The hostname is never used and never sent. - Warns about two things it cannot fix for you: an nftables table
inet reportedipleft behind by the documented shell script while the resolved backend is ipset, and an nginx that reads Cloudflare headers withoutset_real_ip_from, which would have the web source count Cloudflare's addresses instead of the visitor's. - Writes the three systemd units to
/etc/systemd/system, runsdaemon-reload, enablesreportedip-agent-sync.service, and enables and starts bothreportedip-agent-sync.timerandreportedip-agent.service. - Calls
verify-keywith the fresh configuration and prints your role and the reports used today against the daily limit. A 401 or 403 here means the key is wrong and is the one failure worth fixing immediately.
INPUT. The first reportedip-agent sync creates all of that, which is
why the installer ends by telling you to run it. Until then the host is exactly as it was, and an
installation you decide against costs one rm of two directories.
The environment variables of install.sh
| Variable | Default | What it is for |
|---|---|---|
REPORTEDIP_KEY | none, required | Your API key. Without it the script stops before it downloads anything. |
REPORTEDIP_VERSION | the current release | Pins a version, for a staged rollout or to reinstall a specific build. Old version directories are kept on the distribution point for exactly this. |
REPORTEDIP_PREFIX | /usr/local/bin | Where the binary goes. Change it and the systemd units, which name the absolute path, have to follow. |
REPORTEDIP_BASE | https://reportedip.com/agent | The download base. For an internal mirror that serves latest, SHA256SUMS, SHA256SUMS.sig and the two binaries. The signature check applies there as well. |
REPORTEDIP_NO_SETUP | unset | 1 places the binary and stops. Nothing is detected, no config is written and no unit is installed. This is the variable for a golden image, a container layer or a configuration management run that owns the config file itself. |
# Binary only, no host setup: for an image or for Ansible
curl -fsSL https://reportedip.com/agent/install.sh \
| REPORTEDIP_KEY=<YOUR-KEY> REPORTEDIP_NO_SETUP=1 sh
# Later, on the running host, or from your playbook:
reportedip-agent install --key "<YOUR-KEY>"
Installing by hand
The setup step is a command of its own and takes three flags. Run it when
REPORTEDIP_NO_SETUP=1 placed the binary, or when the detection guessed something you
want to correct.
reportedip-agent install --key "<YOUR-KEY>"
# The detection could not find the SSH port (socket activation,
# a non-standard unit, a port from an include file):
reportedip-agent install --key "<YOUR-KEY>" --ssh-port 2222
# The session address is not where you administer this host from
# (a jump host, a console, a serial connection):
reportedip-agent install --key "<YOUR-KEY>" --admin-ip 203.0.113.0/24
--admin-ip takes a single address or a CIDR range and replaces the guess entirely. It is
worth using whenever the SSH session is not representative, because the guessed address is what
stands between you and your own block rule on day one. The whole install run has a five minute
deadline, so a hanging nft or systemctl ends with an error instead of a
process you have to interrupt.
A host without systemd
Nothing in the agent depends on systemd. The units are a convenience, and doctor says so
rather than failing when they cannot be used. Two things have to be arranged by hand on such a host.
# 1. The feed sync, from cron. Every 15 minutes is safe on any
# plan: the agent skips a fetch its own plan interval does
# not allow yet, so nothing is requested too often.
*/15 * * * * root /usr/local/bin/reportedip-agent sync >/dev/null 2>&1
@reboot root sleep 120 && /usr/local/bin/reportedip-agent sync
# 2. The watch daemon, from your init system. It runs in the
# foreground, logs to stderr and stops on SIGTERM.
/usr/local/bin/reportedip-agent watch
The reboot entry is not optional. ipset timeouts do not survive a reboot and neither do the sets
themselves, so without a sync after boot the host comes up with no rules and no restored bans. On a
systemd host that path is covered by reportedip-agent-sync.service being enabled for
multi-user.target, which runs after every firewall service and without the random
delay. Keep log_file set on a host without journald, because stderr may go nowhere
there.
The first hour, in order
The order below is not a suggestion. Each step answers a question the next one depends on, and doing them in a different order is how a working installation looks broken.
reportedip-agent doctor. Before anything else, because it is the only command that changes nothing. Whatever it names as a limit is a limit you will otherwise rediscover as a symptom two days later.reportedip-agent sync. The first run builds the chain, creates the sets and downloads the five lists. It takes a moment: the lists are fetched one after another with a short pause between them.reportedip-agent status. Now there is something to look at. Check that every configured list has a plausible IPv4 count, that the chain saysok, that the whitelist holds the addresses you expect, and thataccountshows your role and the licence state of this host.- Fix the whitelist. The installer whitelisted the address your SSH session came from. That is a guess. Replace it with the range you actually administer from, and add your monitoring, your backup host and your office range while you are there.
- Wait, and read
statusagain. A day is enough, two is better. Inmode: logthe rules are fully built and they match, they only log instead of dropping, so the kernel log shows you exactly whatdropwould have cut off. - Switch to
mode: dropand runsync, which is what rebuilds the rules. - Only then consider
ban.enabled: true, and restart the watch service afterwards. That is the second switch, and it is about your own logs rather than the community list.
reportedip-agent doctor
reportedip-agent sync
reportedip-agent status
reportedip-agent whitelist add 203.0.113.0/24 "management"
reportedip-agent whitelist add 198.51.100.7 "monitoring"
reportedip-agent whitelist list
mode: log, and that is the point.
Nothing is dropped until you decide it should be, which gives you two days to notice the range
that is still missing from the whitelist. Whitelist first, drop second: the reverse
order works too, right up until the one time it does not.
Configuration
One file, /etc/reportedip-agent/config.yaml, owned by root and mode 0600. The agent
refuses to start when it is readable by group or others, and says so with the
chmod command to fix it, because the file holds your API key. State lives under
/var/lib/reportedip-agent: read positions, the report queue, the dedup memory, the ban
store, the health record and the update state.
The file the installer writes is already correct for the host. Most installations change two things
in their first week, the value of mode and whether local banning is on, and nothing
else ever.
window_minutes which is quietly
dropped would leave you believing a threshold you do not have. Every valid key is listed below,
and a key that is not in that list does not exist. After an edit, check the file before you rely
on it: reportedip-agent status exits 2 and names the key when the file cannot be
read.
Every key of config.yaml
Nothing here is required except api_key. Every other key has the default shown, and a
key you leave out keeps that default.
| Key | Default | What it does |
|---|---|---|
api_key | none | Your API key. The only key without a default. REPLACE_ME counts as missing. |
api_url | https://reportedip.com/wp-json/reportedip/v2 | The REST base. Has to be an absolute https URL; plain http is accepted on the loopback only, for a local proxy. |
update_url | derived from api_url | Where agent builds come from. Unset it is the scheme and host of api_url with the path /agent. Set it for a mirror, which then also has to be https. |
auto_update | true | Check for a newer release during the sync run and install it. Set it to false when this host's version is decided elsewhere, for instance by your own package build or a canary rollout. reportedip-agent update still works by hand, and status keeps reporting how far behind the host is. |
log_level | info | info or debug. Nothing else is accepted. |
log_file | /var/log/reportedip-agent.log | The agent's own log, in addition to stderr, so journald keeps everything it has today. Empty switches the file off. Only the sync run and the watch daemon write it. |
log_max_mb | 10 | Size at which the agent rotates its own file, checked on every write. Range 1 to 1024. |
log_keep | 3 | Rotated generations kept, range 0 to 20. With the defaults the worst case on disk is 40 MB. No logrotate snippet is needed, and none is in the way either. |
backend | auto | auto, ipset or nftables. See above. |
mode | log | log, drop or off. Applies to the rules, both the feed lists and the local bans. |
confidence | 90 | The feed threshold, 1 to 100. Lower means a longer list and more borderline addresses. The server names a floor for your plan, 75 from Professional and 90 on Contributor, and the higher of the two values wins. |
limit | 50000 | Maximum addresses per list request, 1 to 50000. |
lists | ssh, edge | Which feed lists are active. ssh and edge are always on and cannot be switched off. lists.ssh.ports is the only per-list option; the other lists use fixed port sets. |
ports | from the real listener | Under lists.ssh: the ports the ssh rule matches on. Written by the installer from the detected listener. An empty list makes the rule match every port. |
min_entries | ssh 1000, mail 500, web 500, ftp 500, edge 1000 | The sanity floor per list: below this many addresses the list is not swapped in. Must be at least 1, because a minimum of zero would let an empty list go live. |
whitelist_file | /etc/reportedip-agent/whitelist.conf | Your never-block, never-report list. One address or CIDR per line, # starts a comment. A broken line is skipped with a warning and never disables the protection. |
sources | one sshd entry | The log sources. A file with a sources key decides alone, so an entry you add has to be next to the ones the installer wrote, not instead of them. |
type | none | Inside a source: one of the thirteen source types. An unknown type is an error that lists the valid ones. |
path | none | Inside a source: one log file. Set path or glob, never both. With neither, the agent picks the channel itself, journald or the distribution's default file. |
glob | none | Inside a source: a pattern over many files, rescanned every ten minutes. At most five wildcard segments. |
exclude | none | Inside a source: patterns that drop files a glob caught. Shell-style matching, not regular expressions. A pattern without a slash matches the file name alone; one with a slash has to cover the full path. Every pattern that matches nothing is warned about at start. |
poll_minutes | 5 | Inside a source: only for imunify360, which polls a command instead of reading a file. Range 1 to 60. On any other type it is an error. |
thresholds | empty | Per-event-source override of the built-in table. See the next section. |
hits, window_minutes | see the table below | Inside a thresholds entry: the pair that means "this many hits inside this many minutes". |
min_hits, window_minutes | 5, 10 | The global pair for every source without its own entry in the built-in table. |
web_min_hits, web_window_minutes | 50, 120 | The same pair for web access logs, which need volume rather than a status code. |
dedup_hours | 6 | The same address is reported at most once per this window. |
queue_max | 5000 | Queued report files. Above this the oldest are dropped. |
disk_min_mb | 200 | Free mebibytes required where the state directory lives. Below it no new report is queued while detection and blocking carry on. 0 switches the boundary off. |
jail_categories | built-in table | Extra mapping from a fail2ban jail name to threat category IDs, 1 to 58. Only relevant while fail2ban is still a source. |
ban | off | The local ban block, with enabled, time_minutes, max_time_minutes, escalate and memory_hours. See Blocking. |
notify | no mail | The mail block, with email, cooldown_hours and the smtp sub-block (host, port, from, user, password, starttls). |
A complete file, with every block that a normal host uses:
# /etc/reportedip-agent/config.yaml root:root 0600
api_key: "YOUR_API_KEY"
api_url: https://reportedip.com/wp-json/reportedip/v2
log_level: info # info | debug
log_file: /var/log/reportedip-agent.log
log_max_mb: 10
log_keep: 3
auto_update: true
backend: auto # auto | ipset | nftables
mode: log # log | drop | off
confidence: 90 # the feed threshold
limit: 50000
lists: # ssh and edge are always on
ssh:
ports: [22] # written by install from the real listener
edge: {}
mail: {}
web: {}
min_entries: # below this many addresses a list is not applied
ssh: 1000
mail: 500
web: 500
ftp: 500
edge: 1000
whitelist_file: /etc/reportedip-agent/whitelist.conf
min_hits: 5 # the global detection pair
window_minutes: 10
web_min_hits: 50 # web access logs use their own
web_window_minutes: 120
dedup_hours: 6
queue_max: 5000
disk_min_mb: 200
sources: # detected at install time
- type: sshd
- type: web
glob: /var/www/*/log/access.log
- type: web-error
path: /var/log/nginx/error.log
- type: postfix
path: /var/log/mail.log
- type: dovecot
path: /var/log/mail.log
thresholds: {} # per source overrides, see below
ban: # block local finds, not only the feed
enabled: false
time_minutes: 30
max_time_minutes: 10080
escalate: 4
memory_hours: 24
notify:
email: "" # empty means no mail
cooldown_hours: 24
Detection limits, per source
A threshold is a pair: how many hits, inside how many minutes. It is the same idea as
maxretry and findtime of a fail2ban jail, and the values below were taken
from the jail configuration of the host this was measured on, so switching fail2ban off does not
change how sensitive a service is.
Three levels decide which pair applies to one event source, and the first one that has an answer
wins: an explicit entry under thresholds, then the built-in table, then the global
pair.
| Event source | Threshold | Where it comes from |
|---|---|---|
sshd | 5 in 10 min | min_hits / window_minutes |
web-error | 5 in 10 min | min_hits / window_minutes |
modsec | 5 in 10 min | min_hits / window_minutes |
exim | 5 in 10 min | min_hits / window_minutes |
web | 50 in 120 min | web_min_hits / web_window_minutes |
web-app | 20 in 60 min | built in |
postfix-sasl | 3 in 60 min | built in, measured |
postfix-reject | 5 in 60 min | built in, 5xx rejects only |
postfix-amavis | 3 in 60 min | built in |
dovecot | 10 in 60 min | built in, measured |
ftp | 20 in 60 min | built in |
named | 20 in 30 min | built in |
panel | 5 in 60 min | built in |
fail2ban, csf, imunify360 | no threshold | That source counted already, so one line is one report. |
To change one source and nothing else, name it under thresholds. The key is the event
source from the table above, not the source type from sources, and a name that is not on
that list is an error rather than a setting that never applies. Both numbers have to be at least 1.
# A recursive resolver that sees a lot of refused queries:
# fewer minutes, more hits, and nothing else changes.
thresholds:
named:
hits: 40
window_minutes: 5
# A mail server whose customers keep mistyping passwords:
# SASL a little more forgiving, Dovecot untouched.
thresholds:
postfix-sasl:
hits: 6
window_minutes: 60
Raising a threshold makes the agent quieter and lets slow attackers through. Lowering it is the direction that costs something: below about three hits in an hour on a mail or web source you will eventually report a customer with a wrong password in their mail client, and a report is public within the community. The web pair is high for exactly that reason, because a plain WordPress answers a wrong password with HTTP 200 and the detector cannot lean on the status code.
One more input is not configurable. An address with a ban history needs fewer hits than an unknown one, which is the same weighting the recidive jail did: one previous ban counts a line double, two count it triple, then five and nine. The weight is cut to the threshold of its event source, so a tenth ban does not turn a single line into a report.
Ban limits
Four values, all inside the ban block, all in the unit named in the key. They apply only
to addresses the agent found in your own logs. The community list has no ban time at all, it is
replaced wholesale every hour.
| Key | Default | Meaning, and what happens at the extremes |
|---|---|---|
enabled | false | Whether local finds are blocked at all. false is a host that reports and does not block. A manual ban add works either way. |
time_minutes | 30 | The first ban of an address. At least 1. Very short values make the escalation the only thing that matters; very long ones mean a false positive sits in the set for a long time. |
max_time_minutes | 10080 (7 days) | The cap of the escalation. Must not be below time_minutes, or the cap would shorten the first ban, and the agent refuses such a file. Lowering it later also shortens the bans that are already running: the next sync restores them cut to today's cap. |
escalate | 4 | The multiplier per repeat inside the memory window. At least 1, and 1 means every ban lasts time_minutes with no escalation at all. |
memory_hours | 24 | How long a ban counts towards the next escalation. At least 1. A record also stays for three times the length of its last ban when that is longer, which is what makes the cap reachable at all without the ban file growing from short bans. |
# Careful: a short first ban, a low cap, a long memory.
ban:
enabled: true
time_minutes: 10
max_time_minutes: 1440 # one day
escalate: 3
memory_hours: 72
# Strict: a long first ban and a hard escalation.
ban:
enabled: true
time_minutes: 60
max_time_minutes: 43200 # thirty days
escalate: 6
memory_hours: 168 # a week
Operating limits
These bound what the agent itself may consume. They are not tuning knobs for detection, and the two you might genuinely want to change are the queue size and the disk floor.
| Key | Default | Unit | Too low | Too high |
|---|---|---|---|---|
queue_max | 5000 | queued report files | Reports are dropped during an outage of the API, oldest first. | A long outage leaves thousands of small files to send afterwards. |
dedup_hours | 6 | hours | The same attacker is reported again and again and eats your daily quota. | An address that attacks again next week is reported late. |
disk_min_mb | 200 | MB free | The agent can help fill /var, which takes the whole host with it. | Reports stop on a host that is perfectly healthy. 0 removes the boundary entirely. |
log_max_mb | 10 | MB | Rotation on every other write, and a history too short to investigate anything. | Together with log_keep this is the worst case on disk. |
log_keep | 3 | generations | 0 keeps no rotated file at all. | Maximum 20, and the disk cost is the product of the two. |
cooldown_hours | 24 | hours | At least 1, and 1 means a mail per sync run for a condition that stays open. | A problem that started this morning is mailed tomorrow. |
poll_minutes | 5 | minutes | A command started too often on a busy host. | Maximum 60, and findings arrive that much later. |
limit | 50000 | addresses per list | A truncated list, so the worst addresses are there and the tail is not. | 50000 is the maximum the API serves. |
min_entries | 1000 / 500 | addresses | An implausibly short list goes live and the host is less protected than it thinks. | A genuinely small list is rejected for ever and the set stays at its previous generation. |
Below the disk floor the agent stops writing new reports to the queue, records the condition and
mails it, and keeps detecting and blocking. That order matters: a full /var is worse
than a missed report, and protecting a server by breaking it is not protection.
Mail when something is wrong
Empty notify.email is the default and means this host sends nothing. What is mailed is a
state, not an event, so a feed that fails every hour is one message and not twenty-four. The agent
tracks eight states (feed, api_key, chain, disk, source, update, queue, license), remembers when each
was last mailed so a restart does not start over, and sends an all-clear when one is over.
# A host with a local MTA: postfix, exim or msmtp. The agent
# uses "sendmail -t -i" and no password lives in a file.
notify:
email: "ops@example.org"
cooldown_hours: 24
# A host without an MTA: SMTP, and starttls stays on.
notify:
email: "ops@example.org"
cooldown_hours: 12
smtp:
host: mail.example.org
port: 587
from: agent@example.org
user: agent@example.org
password: "..."
starttls: true
With starttls: true the mail is refused rather than sent in the clear when the server
does not offer it. A mail carries the state name, the version, a generic sentence with numbers and
the command to look with. Never a log line, never an address, never a payload. Run
reportedip-agent doctor after configuring this: the mail section says
whether a mail could really leave the host, which is a different question from whether the
configuration parses.
When a change takes effect
The agent reads its configuration at start and does not watch the file, and there is no reload signal. Which command you need depends on which part you touched.
| What you changed | What makes it take effect |
|---|---|
mode, lists, min_entries, confidence, limit, backend | reportedip-agent sync. The sync run is what builds the chain and the rules, so a new mode is live at the end of it. |
thresholds, min_hits, window_minutes, web_min_hits, web_window_minutes, dedup_hours, ban, notify, log_* | systemctl restart reportedip-agent.service. These are read by the watch daemon. |
A source added to sources | systemctl restart reportedip-agent.service. |
A source removed from sources | A stop and a start, not a restart. On a live host a systemctl restart was not enough and the daemon kept tailing the removed file: systemctl stop reportedip-agent.service && systemctl start reportedip-agent.service. |
whitelist_file content | Nothing, when you use reportedip-agent whitelist add: that writes the file and the kernel set in one step and takes effect at once. Editing the file by hand needs a sync. |
api_key, api_url, auto_update, update_url | Nothing for the next sync, which is a oneshot and reads the file fresh. Restart the watch service so the reporting path picks the key up too. |
# The safe sequence after any edit
reportedip-agent status >/dev/null && echo "config parses"
reportedip-agent sync
systemctl restart reportedip-agent.service
systemctl status reportedip-agent.service --no-pager
The community feed
This is the part the Network-Level Blocking page describes as a shell script. The agent does the same thing, with the parts that are easy to get wrong when you write it yourself already handled: one conditional request per list so an unchanged list costs nothing, a size check before anything goes live, an atomic swap so there is never a window with an empty set, and port-scoped rules so a web attacker cannot lock anyone out of SSH.
The category mapping is the same as on that page. Which of the five lists are active depends on what is actually listening, and there is no combined list, because one set per service is what makes the port-scoped rule possible.
| List | Kernel set | Ports the rule matches |
|---|---|---|
ssh | rip-ssh, rip-ssh-v6 | from lists.ssh.ports |
mail | rip-mail, rip-mail-v6 | 25, 465, 587, 110, 995, 143, 993 |
web | rip-web, rip-web-v6 | 80, 443 |
ftp | rip-ftp, rip-ftp-v6 | 21 |
edge | rip-edge, rip-edge-v6 | every port, on purpose |
How often the feed is fetched depends on your plan, and the server decides it, not the agent. On a licensed server the interval is every 15 minutes with a minimum confidence of 75 from Professional upwards, and hourly with a minimum confidence of 90 on Contributor. Business and Enterprise are as Professional. The agent asks on every pass which values apply to this host and follows the answer, so an upgrade takes effect without anyone editing a file. There is no configuration key for the interval, and a key you invent for it stops the agent with exit 2.
Two more rules sit on top of that. A list that was fetched less than 15 minutes ago is not fetched
again, because that is how long the server caches one, so running sync by hand is always
allowed and never harmful. And every fetch is conditional: an unchanged list answers 304 and costs
one request and no transfer, which is why a short interval is cheap for both sides.
What the agent finds in your logs
Each source has its own detector and its own threshold, because five failed SSH logins in ten minutes
and fifty suspicious web requests in two hours are the same statement about an attacker and not the
same number. Rotation, copytruncate and a log that disappears for a while are handled,
and an unreadable file produces one warning rather than one per read.
The thirteen source types
| Type | Typical path | What it counts as a hit |
|---|---|---|
sshd | journald, else /var/log/auth.log or /var/log/secure | Failed passwords, invalid users, failed keys for a user that does not exist, exceeded attempt limits, and the pre-authentication failures that mark scanners: no identification string, a bad protocol version, garbage in the banner exchange. A refused key for a valid user is deliberately not a hit, because an admin with five keys writes four of those per successful login. A successful login clears that address's failures. |
web | /var/log/nginx/access.log, or a per-site glob | Login POSTs, counted regardless of status code, and paths no legitimate client asks for. Everything else in an access log is ignored, because this is the source with real customers behind it. |
web-error | /var/log/nginx/error.log, /var/log/apache2/error.log | Requests the web server itself refused, failed HTTP basic auth, and ModSecurity lines of critical severity when they land here instead of an audit log. Rate-limit lines are deliberately not counted: a limit fires on a real browser with twenty tabs. TLS handshake and PHP messages say something about the server, not the client. |
web-app | the same access logs | Runs after web on the same lines and covers the application-level attacks: WordPress user enumeration, plugin and core probing, Drupal login and exploit paths. A login POST is counted once, by web. |
postfix | /var/log/mail.log, /var/log/maillog | Three separate event sources out of one file: SASL authentication failures, NOQUEUE rejects that are really 5xx (a 450 is greylisting and does not count), and amavis blocks. Delivered mail is never a hit. |
dovecot | the same mail log | IMAP and POP3 login failures, one per line whatever the "N attempts" says. The remote address is read from the rip= field, never lip=, which is the server's own. An aborted login with no auth attempt is not a hit: that is a TLS scan, and no password was tried. |
exim | /var/log/exim4/mainlog, /var/log/exim/main.log | Authentication failures and rejected senders. Unverified against a live host: the patterns come from the documented log format. |
ftp | /var/log/syslog, /var/log/messages | Failed authentications of pure-ftpd, proftpd and vsftpd. All three log through syslog, and each writes the peer differently, so three patterns are used. pure-ftpd is measured: 494 of 494 lines on a production host had one single form. |
named | the same syslog | Only an incoming query that bind turned down. The big trap is the reverse direction: connection refused resolving is this host's own resolver failing to reach someone else's name server, and 90 percent of the measured lines were that. A detector without the exclusion reports other people's name servers as attackers. |
panel | /var/log/ispconfig/auth.log | Failed logins on the ISPConfig panel. The file is 0 bytes on every host measured, which is not a fault: the panel simply writes nothing there. doctor points it out after a week. |
modsec | /var/log/apache2/modsec_audit.log | One event per transaction whose verdict carries critical severity. This is the only stateful detector, because a transaction spans several lines. Nothing from the request travels in the event: not the rule ID, not the matched data, not the URI. |
csf | /var/log/lfd.log | What CSF actually blocked, never what it merely detected. No second threshold on top: lfd counted before the agent saw the line, so one block is one report. |
fail2ban | /var/log/fail2ban.log, else the unit's journal | Ban actions only. A Restore Ban is never reported, because that is what a fail2ban restart replays from its own database and not a new attack. A filter Found line is not reported either: the jail has not decided yet. |
imunify360 | polls imunify360-agent | Incidents from the CLI, mapped to categories by rule name prefix. Experimental, and unverified against a licensed installation; the decoder skips what it cannot parse rather than turning an unexpected answer into a dead source. |
Adding a source the installer did not find
A source the installer did not find is simply absent from the config, and adding it is one entry with
a path or a glob. Because a file with a sources key replaces the default list entirely,
add your entry next to the existing ones rather than in a second block.
sources:
- type: sshd
- type: web
path: /var/log/nginx/access.log
# A second web server on a different path
- type: web
path: /var/log/caddy/access.log
# Every site of a panel host, rescanned every ten minutes.
# At most five wildcard segments.
- type: web
glob: /var/www/clients/*/web*/log/access.log
exclude:
# This site reports to the API on its own, through Hive or a
# honeypot. Without the exclude the host sends every address
# twice and pays twice out of the daily quota.
- /var/log/ispconfig/httpd/honeypot.example.com/*
# Exim on a host the installer saw as a Postfix machine
- type: exim
path: /var/log/exim4/mainlog
Two paths that resolve to the same file are not a problem and need no exclude: ISPConfig publishes
every access log twice, under /var/www and under /var/log/ispconfig, and
the agent reads such a file once. Before you trust a new entry, point
reportedip-agent test at the file, then restart the watch service, then check
status: the sources section shows hits per source, and a source with a
files= count of zero resolves to nothing at all.
What leaves the machine
A report carries the address, the threat category IDs and a generated sentence. That is all. No log line, no user name, no request body, no User-Agent and no URL is ever transmitted, which means a report cannot leak your customers, your paths or your credentials even by accident.
In the current version the agent sends no hostname. A server identifies itself to the API with a random UUID generated during the installation, and that is the identity you see in your account. You give it a label there if you want to recognise it; the label stays in your account and is never derived from the machine.
Four addresses are never reported and never blocked, and this is a boundary in the code rather than a setting: private and reserved ranges, the loopback, every address of this server's own interfaces and its default gateway, and everything in your whitelist. The install-time SSH client address sits in the auto-whitelist, which counts as part of that last group.
Blocking
The agent writes two very different things into the kernel, and an admin coming from fail2ban usually conflates them for the first week. They have different sources, different lifetimes and different switches, so it is worth separating them once and properly.
Two kinds of set, two lifetimes
| Community blacklist | Local finds | |
|---|---|---|
| Sets | rip-ssh, rip-mail, rip-web, rip-ftp, rip-edge, and a -v6 set for each | rip-local and rip-local-v6 |
| Where the addresses come from | Every reporter in the community, filtered by your confidence | Only this host's own logs |
| How an entry arrives | The whole set is replaced on every feed pass | One address at a time, when a threshold is reached |
| How an entry leaves | With the next swap, when the feed no longer lists it | The kernel expires it when its own timeout runs out |
| Per-entry timeout | none | yes, that is the whole design |
| Switch | mode | ban.enabled, and mode on top |
| Needs a licence | yes, this is the paid part | no |
The feed swap never touches rip-local. Feed sets are replaced in full and carry no
timeout; local bans arrive one at a time and expire on their own. That separation is why a feed
outage cannot release your local bans, and why a local ban cannot survive as an orphan after the feed
set is rebuilt.
How a local ban begins and how it ends
A ban begins when a detector's counter for one address crosses the threshold of its event source. What happens then, in order:
- The whitelist is checked first, before anything else is even asked. A whitelisted address leaves
no trace at all, so you never find your own management range in
reportedip-agent ban listwondering whether it was blocked. The check reports which layer matched: a built-in reserved range, one of this host's own addresses, or your file. - The ban store,
/var/lib/reportedip-agent/bans.json, is asked for the ban time. It records the address, when the ban expires, how many bans fell inside the memory window, when the last one started, the source and the categories. The file is written before the kernel entry, so a crash right after the kernel write cannot lose the record that expires it. - The address goes into
rip-localwith that time as a per-entry timeout. The kernel expires it. There is no cleanup timer, no cron job and no unban queue, which is the reason a crashed or removed agent leaves no permanent block behind. - A line is written to the log with the address, the ban time, the source, how many bans that address has inside the window, the lag between the log line and the kernel entry, and the two commands to undo it.
A running ban is never shortened. When a new hit arrives for an address that is already banned,
nothing happens: it is not an escalation either, because escalation counts bans and not hits, exactly
like the recidive jail it replaces. An address that is already blocked cannot be blocked again. That
rule is also what keeps the transition period honest, when the agent sees one attack twice, once as a
raw line from auth.log and once as a ban line from fail2ban.log.
A replayed log line does not produce a ban either. A hit whose timestamp is not newer than the last ban of that address is a replay after a hard stop, not a new offence, and the reporting path has the same guard in its dedup file.
The state survives more than a restart. Every sync run compares bans.json against what
the kernel actually holds and puts back what is missing, with the time the ban has left:
- After a reboot that is all of them, because neither an ipset timeout nor the set
itself survives a reboot. This is why the sync service is enabled for
multi-user.targetand runs after every firewall service. - During normal operation it is whatever took the set away: a
firewall-cmd --reload, acsf -r, anipset flush. Entries the kernel still holds are left alone, so the gap is a part of one sync interval and not the rest of the ban. The log says explicitly when entries were missing under active bans, because that means something edited the set under the agent. - An expired record never comes back. Yesterday's attacker is no reason to cut someone off today.
- The whitelist is re-checked on every restore. It may have grown while the host was down, and an address you whitelisted yesterday must not be blocked again by a record from the day before.
Escalation, with real numbers
The first ban lasts time_minutes. Every repeat inside the memory window multiplies it by
escalate, and max_time_minutes caps it. With the defaults
(30 minutes, multiplier 4, cap one week) an address that keeps coming back goes:
| Ban | Calculation | Duration |
|---|---|---|
| 1st | time_minutes | 30 minutes |
| 2nd | 30 × 4 | 2 hours |
| 3rd | 120 × 4 | 8 hours |
| 4th | 480 × 4 | 32 hours |
| 5th | 1920 × 4 | 128 hours, five days and eight hours |
| 6th and after | would be 512 hours, capped | 7 days, the value of max_time_minutes |
Worked through concretely. An attacker hits your SSH port at 09:00 on Monday and reaches five failures inside ten minutes, so 09:07 brings a 30 minute ban that expires at 09:37. They come back at 11:00 and are banned again, this time for two hours, because the first ban is still inside the 24 hour memory. The third attempt at 14:00 costs eight hours and takes them to 22:00. The fourth, early on Tuesday, costs 32 hours. At that point the record is kept for three times the length of the last ban rather than the plain 24 hours, which is what makes the fifth step reachable at all: a long ban is worth a long memory, a 30 minute ban only the configured day, and the ban file therefore does not grow from short bans.
Two settings change the shape of that curve. escalate: 1 switches escalation off
entirely and gives every ban the same length. A memory_hours that is short relative to
time_minutes means an address that pauses for a day is a first offender again, which is
sometimes exactly right for a mail server with real customers.
Lowering max_time_minutes also applies to bans that are already running. The next sync
restores them cut to today's cap, because an operator who shortens the cap after an incident expects
the running bans to follow rather than sit in the kernel for a week.
The chain in the kernel
With the ipset backend the agent owns exactly one chain, rip-blacklist, and one jump
into it. The chain is flushed and rebuilt on every sync, so its content is always what the config
says, and the jump is inserted at position 1 of INPUT only if it is not already there.
# What a sync builds, in this order:
iptables -N rip-blacklist
iptables -F rip-blacklist
iptables -A rip-blacklist -m set --match-set rip-whitelist src -j RETURN
iptables -A rip-blacklist -m set --match-set rip-local src -j DROP
iptables -A rip-blacklist -p tcp --dport 22 \
-m set --match-set rip-ssh src -j DROP
iptables -A rip-blacklist -p tcp -m multiport --dports 25,465,587,110,995,143,993 \
-m set --match-set rip-mail src -j DROP
iptables -A rip-blacklist -p tcp -m multiport --dports 80,443 \
-m set --match-set rip-web src -j DROP
iptables -A rip-blacklist -m set --match-set rip-edge src -j DROP
iptables -I INPUT 1 -j rip-blacklist
The whitelist is the first rule and returns, it is not the last rule. A
RETURN in front of everything else means a whitelisted address leaves the chain before
any set is consulted, so it outranks every ban including one that is already in
rip-local. That is what makes whitelist add a working way out of a lockout,
with no sync and no restart. A whitelist placed at the end would be a list of exceptions that arrive
too late, and on a host where you have locked yourself out, "too late" is the entire problem.
The local ban rule carries no port match, because a host that attacked your mail
server has no business on port 22 either. The five feed rules are port-scoped, so an address on the
web list is dropped on 80 and 443 and can still reach SSH. That is deliberate: a shared address, a
carrier NAT or a compromised proxy on the web list must not lock an administrator out of the
machine. Only edge matches every port, which is what the category behind it means.
With the nftables backend the shape is the same in native form: a table inet reportedip,
a chain hooked into input at priority -10 with policy accept, the two whitelist
return rules first, then rip-local, then the feed lists with their port
matches. The whitelist sets are interval sets so they hold CIDR ranges; the local sets carry the
timeout flag, which both tools require on the set before any element may carry a timeout at all.
In mode: log the same rules are built, and the verdict is a rate-limited log line
(six per minute, prefixed rip-<list>:) instead of DROP. Everything
else is identical, which is why the log mode is a real rehearsal and not a simulation.
mode and ban.enabled are two switches
This is the single most common misunderstanding, so it gets its own paragraph.
mode decides what the rules do. ban.enabled decides whether your
own finds ever become an entry at all. Setting only mode: drop gets you the community
list enforced and nothing of your own.
mode | ban.enabled | Community list | Your own finds |
|---|---|---|---|
log | false | Matched and logged, nothing dropped | Reported to the community, not blocked locally |
log | true | Matched and logged | Recorded in rip-local and visible in ban list, logged instead of dropped |
drop | false | Dropped | Reported only. This is a perfectly sensible steady state. |
drop | true | Dropped | Dropped as well, with escalation. The full configuration. |
off | either | Nothing in the packet path: the jump is removed and the chain is left with its rules | Recorded in the store, no effect |
off is the emergency switch and it is honest about it. It removes what hooks the chain
into the packet path, up to five times to catch duplicate jumps, and fails loudly if a jump survives,
because saying "off" while still dropping traffic is worse than an error. The sets keep their
entries, so switching back needs no full download.
A manual ban ignores both switches on purpose: reportedip-agent ban add works with
ban.enabled: false, because that is a deliberate act by an operator, the same way
fail2ban-client set banip was. It still respects the whitelist, and it still refuses
when the host has no firewall backend at all.
Switching from log to drop
# 1. What would have been dropped? The rules already match in
# log mode, so ask the kernel log.
journalctl -k --since "24 hours ago" | grep -c "rip-"
journalctl -k --since "24 hours ago" | grep -o "rip-[a-z]*:" | sort | uniq -c
# 2. Is anything of yours in there? Check every source address
# against your whitelist before you switch.
reportedip-agent whitelist list
# 3. Switch, and rebuild the rules.
sed -i "s/^mode: log/mode: drop/" /etc/reportedip-agent/config.yaml
reportedip-agent sync
reportedip-agent status
# 4. Only now the second switch, if you want it.
# ban.enabled: true, then restart the daemon.
systemctl restart reportedip-agent.service
reportedip-agent ban list
If step 1 returns a number in the thousands, that is normal on an exposed host and it is the community list doing its job. What matters is not the count but whether any address in that log belongs to you. Two days in log mode is enough to find out, and the answer is far cheaper here than after the switch.
Looking at what is really in the kernel
reportedip-agent status is the summary, and it reads both the state files and the live
kernel so the two can be compared. Where they disagree, the kernel is the truth and status says so:
a record without a kernel entry is a ban that is not in effect.
# The agent's own view
reportedip-agent status
reportedip-agent ban list
# ipset backend, straight from the kernel
ipset list -t rip-ssh # header and entry count only
ipset list rip-local # entries, each with its timeout
iptables -S rip-blacklist
iptables -L INPUT -n --line-numbers | head
# nftables backend
nft list table inet reportedip
nft list set inet reportedip rip-local
In ban list the column kernel is what the kernel still has on the clock and
record is what bans.json expects. A dash under kernel next to a
running record means the ban is not in effect and the next sync will restore it. A row whose record
says none exists in the kernel only, which means somebody used ipset or
nft by hand, or the store was lost: it will still expire, but no reboot brings it back.
Lifting one ban, and removing everything
reportedip-agent whitelist add <address> takes effect
immediately and outranks every ban, including one already in the set. It writes the file and the
live kernel set in one step, and it needs no sync and no restart.
# Lift one ban and forget its history. The next detection starts
# from a first offence rather than escalating on top of a verdict
# you disagreed with.
reportedip-agent unban 203.0.113.45
# Never ban and never report this address or range, from now on.
reportedip-agent whitelist add 203.0.113.0/24 "customer office"
# Ban by hand. Works even with ban.enabled: false. A time given
# here is the time, not the first step of an escalation.
reportedip-agent ban add 203.0.113.45
reportedip-agent ban add 203.0.113.45 1440
# Stop blocking without uninstalling: no jump, chain and sets kept.
sed -i "s/^mode: .*/mode: off/" /etc/reportedip-agent/config.yaml
reportedip-agent sync
# Remove the agent from the packet path completely (ipset backend).
iptables -D INPUT -j rip-blacklist; iptables -F rip-blacklist; iptables -X rip-blacklist
ip6tables -D INPUT -j rip-blacklist; ip6tables -F rip-blacklist; ip6tables -X rip-blacklist
for s in $(ipset list -n | grep "^rip-"); do ipset destroy "$s"; done
# nftables backend
nft delete table inet reportedip
unban and whitelist add both take the sync lock, so the restore step of a
running sync cannot put back what you are lifting. Removing an entry from the whitelist file is the
one operation that is not instant: it takes effect with the next sync rebuild, and removing an
exception never needs to be immediate.
rip- set.
iptables-restore discards the whole file when it hits an unknown set, so one stale
line in /etc/iptables/rules.v4 can take your entire firewall down at the next boot.
The agent rebuilds its chain on every boot by itself and needs nothing persisted.
status warns when it finds rip- in rules.v4,
rules.v6, /etc/sysconfig/iptables, ip6tables,
/etc/nftables.conf or /etc/nftables.d.
Commands
| Command | What it does | Exit |
|---|---|---|
install [--key K] [--admin-ip IP] [--ssh-port N] | Detects the services, writes the config, the auto-whitelist and the install ID, installs and enables the units, and verifies the key. Never overwrites an existing config. Root only. | 0, 1 on a rejected key, 2 on a config problem |
sync | One feed pass: rebuild the whitelist set, fetch each list conditionally, swap what passed the size check, rebuild the chain, restore missing local bans, run the housekeeping, check for an update every six hours. This is what the timer runs and what runs at boot. | 0, 1 degraded, 0 also when another sync holds the lock |
watch | The daemon. Tails every source, counts, reports and bans if that is enabled. Stops on SIGTERM. | 1 when the queue lock is still held after a minute, 2 on a config problem |
report-queue | Sends the queued reports once. Useful after an API outage, or on a host where the daemon does not run. | 0, 1 degraded |
status | Version, backend, mode, tools, per-list counts and last success, whitelist, chain check, per-source read positions and hit counts, queue and sender, account and licence, disk, local bans, open health conditions. | 0 healthy, 1 degraded, 2 config |
doctor | What the host offers and what is missing. Changes nothing, works without a config. | 0, 1 with limits, 2 cannot run |
test <file>... [--type T] [--lines] | Runs the detectors over real log files. Reports nothing, bans nothing, writes nothing. | 0, 2 on a file or type problem |
ban list | add <ip> [minutes] | rm <ip> | Shows the local bans against what the kernel really holds, sets one by hand, lifts one. | 0, 1 on a kernel failure, 2 on a bad argument |
unban <ip> | The same as ban rm, under the word an operator types when something is on fire. | as above |
whitelist add <ip|cidr> [comment] | list | rm <ip|cidr> [--auto] | Manages the never-block, never-report list. add writes the file and the kernel set at once. list prints your file and the auto-whitelist. --auto removes an entry the installer wrote. | 0, 1 when the file changed but the set did not, 2 on a bad address |
update [--check] | Checks the distribution point and replaces this binary, then restarts the watch service. --check only reports. This is the one command that reads the config loosely, so it still works on a host whose config is newer than its binary. | 0, 1 when the check or the install failed |
housekeeping | Removes the agent's own leftovers and prints the numbers. The sync run does the same silently. | 0, 2 on a config or state problem |
version | Prints the version and nothing else. | 0 |
help | The command list, with the config and state paths. | 0 |
test, instead of fail2ban-regex
test runs the detector chain over a log file you already have, with the thresholds and
the whitelist of this host, and prints which addresses would have crossed a threshold and where in
the file. It reports nothing, blocks nothing and writes nothing, so it is safe on a production host
and it is the honest way to answer "would this have caught last week's attack".
# A configured source: the type is taken from the config
reportedip-agent test /var/log/auth.log
# Any other file: name the type yourself
reportedip-agent test --type postfix /var/log/mail.log.1
# A rotated log and its successor as one stream, in this order,
# so a window that spans the rotation counts like the daemon saw it.
# A .gz is read inflated.
reportedip-agent test /var/log/mail.log.2.gz /var/log/mail.log.1 /var/log/mail.log
# Every single hit, with line number, address and timestamp
reportedip-agent test --lines /var/log/auth.log
The summary line gives the lines read, how many matched, how many carried no usable timestamp and how many were skipped as overlong. Then one row per address with its hits, its resets, whether it would have been banned and how often, the time of the first hit, and the threshold that was applied. An address that is on your whitelist is shown with the layer that matched and marked as never banned and never reported, which makes this the fastest way to prove a whitelist entry actually works.
Two things test does not do. It does not guess a format: without --type the
file has to be one of the configured source paths, and otherwise it tells you so and lists the valid
types. And it does not apply the ban-history weight, so the daemon needs fewer hits for an address
with previous bans than this replay suggests.
Running it
- A timer in the rhythm of your plan, with jitter.
reportedip-agent-sync.timerfires a few minutes after it is activated and then repeatedly in the interval your plan provides, with a random but fixed delay, so a fleet does not reach the API in the same second. What that interval is comes from the server, see The community feed. The boot path is separate:reportedip-agent-sync.serviceis enabled formulti-user.targetand runs afternetwork-online.targetand after every firewall service, with no random delay, because a host must not sit unprotected for a quarter of an hour after a reboot. - A watch service that stops when it should.
reportedip-agent.servicerestarts on failure after ten seconds, at most five times in five minutes, and never on exit code 2. A misconfigured host stops with a readable reason instead of being retried for ever. Both units run withNoNewPrivileges,ProtectHome,PrivateTmp, a locked personality and a restricted set of address families. - Its own log, its own rotation. The agent writes to stderr, which journald picks
up, and to its own file when
log_fileis set. It rotates that file itself atlog_max_mband keepslog_keepgenerations. It does not depend on logrotate and it does not fill the journal. - One mail when something is wrong, one when it is fixed. Per condition, not per event, with a cooldown, so a broken feed produces one message and not one an hour.
- A disk-space floor. Below
disk_min_mbthe agent queues no new reports and says so, while detection and blocking keep running. housekeeping. Old queue files, expired dedup entries, stale read positions for files that no longer exist, leftover temporary and lock files, expired ban records that no longer feed an escalation, and the previous binary once it is a month old. It runs as part of every sync, and by hand when you want to see the numbers.- Self-update every six hours. A new release is verified against an Ed25519 signature with the public key compiled into the binary before it is installed, so a tampered download fails on your machine rather than being trusted because it arrived over HTTPS. The previous binary is kept for a rollback and removed by the housekeeping after thirty days.
systemctl list-timers reportedip-agent-sync.timer
systemctl status reportedip-agent.service --no-pager
journalctl -u reportedip-agent.service -n 50 --no-pager
journalctl -u reportedip-agent-sync.service -n 50 --no-pager
tail -n 100 /var/log/reportedip-agent.log
Exit codes
| Code | Meaning | What to do |
|---|---|---|
0 | Healthy | Nothing to do. |
1 | Degraded, but it will try again | Read the output. A retry may well fix it. An open error condition in the health record colours the exit code even when the run itself went through, because an exit 0 next to a recorded error is no signal for a monitoring system. |
2 | Not repeatable without a human | The config, the file permissions, a missing tool, a lock held by another process. Run doctor. systemd will not retry the watch service on a 2. |
That makes reportedip-agent status usable as a monitoring check directly, with no
wrapper script and no output parsing.
Upgrades and release notes
The agent looks for a new release every six hours, during the sync run. It verifies the download
against an Ed25519 signature with the public key built into the binary, keeps the previous one for a
rollback, and restarts the watch service itself. reportedip-agent update does the same on
demand, and update --check only reports what is published.
Running the install command again upgrades a host as well, which is what to do when a release raises the minimum version: the agent then refuses to update itself and says so, because it needs a human. Downgrades are refused in general, so a host that pulled a newer version does not go back by itself.
One upgrade case is worth knowing in advance. The config parser is strict about unknown keys, so an
older binary cannot read a config that already carries a newer block. For a customer the order is
harmless, because the agent updates itself first and a new key only appears when someone adds it.
If you ever do end up with a binary older than its config, update is the way out: it is
the one command that reads the file loosely, and it names the keys it does not know.
What the current release changed is public and needs no key:
curl -s https://reportedip.com/agent/whats-new
reportedip-agent update --check
Changes to the REST API itself, the response fields and status codes an integrator has to care about, are in the API changelog instead.
Server licences
The agent is licensed per server. Servers are their own pool and are counted separately from the domains of the Hive plugin, so installing the agent never costs you a domain.
| Plan | Servers included | More servers |
|---|---|---|
| Free | none | Not bookable |
| Contributor | none | Not bookable |
| Professional | 1 | Any number |
| Business | 3 | Any number |
| Enterprise | by contract | by contract |
A further licence costs 4.90 € a month or 49 € a year per server, incl. VAT, and gets cheaper per server as the count rises: 4.90 € for the first four, 3.90 € from the fifth, 2.90 € from the tenth, 1.90 € from the twenty-fifth and 1.40 € from the fiftieth. The Business volume multiplier raises your domain count but not your included servers, because the servers are the part that is paid for separately.
You regulate the number yourself under Agent Servers in your account. A host appears there the first time its agent calls the API, with its install ID, its version and when it was last heard from. If the account has a licence to spare, the host takes it at once. If it has none, the row says so and a button adds one. There is nothing to register in advance and nothing to paste into a file: the host already holds an API key of your account, which is a stronger claim than any verification file.
Identity is the install ID, never the hostname and never the key. Rotating the API key on a host does not create a second licence, and renaming the machine changes nothing. When you have more agents than licences, the oldest hosts keep theirs. A cancelled server licence keeps working for seven days.
What happens without a licence
Only one thing stops: the feed is no longer fetched. Everything else keeps running, and that is deliberate. A server must not become unprotected because an invoice was missed, and an unlicensed host is not a defenceless host. It simply stops receiving the community list.
| Function | Without a licence |
|---|---|
| Feed download | Stops. This is the part that is paid for. |
| The list already in the kernel | Stays. It is never flushed, and it keeps blocking. |
| Local detection | Keeps running. All thirteen sources, all thresholds. |
| Local blocking | Keeps running, escalation included, including the restore after a reboot. |
| Reports | Keep going, within the daily limit of your plan. |
| Whitelist, chain, housekeeping, log rotation | Keep running. |
| Self-update | Keeps running. An outdated agent on an unpaid host is our risk, not our leverage. |
status | Shows the licence state, the reason, the age of the list and what to do about it. |
The service is not stopped, the sets are not emptied, local bans are not lifted, and there is no advertising in the log. A free account can therefore run the agent as a pure reporter with full local protection, and that is a legitimate way to use it.
Replacing fail2ban
The agent does not sit next to fail2ban, it takes over from it. fail2ban remains available as one source among many, so a host that still runs it keeps getting its bans reported, but a host without fail2ban loses nothing at all: every attack type that used to arrive through a jail now has a detector reading the original log instead.
The comparison was measured rather than argued. Over eleven days on a production host, the agent found 187 of the 189 addresses fail2ban had banned on the same machine. The two it did not were test addresses fed in by hand. On top of that it reported 78 further addresses that were genuinely attacking and had stayed under fail2ban's thresholds, and it matched the FTP bans exactly, three out of three.
Two practical differences. Escalation for repeat offenders comes from the agent's own ban history
rather than from a jail that reads another jail's log, and
reportedip-agent test <file> takes the place of fail2ban-regex.
First check whether something already owns these names
This one cost an afternoon on a production fleet, so it comes before everything else. A hand-written
firewall script of the kind this site used to document can use exactly the names the agent
uses. Found in the field: the sets rip-whitelist, rip-ssh,
rip-mail, rip-web, rip-ftp and rip-edge, plus a
chain rip-blacklist hooked in with -A INPUT -j rip-blacklist, rebuilt every
hour by a cron job, and nearly character for character the chain the agent builds itself.
On such a host the usual order is wrong in both directions. The agent's first sync rebuilds the chain
and replaces the existing DROP rules with LOG rules, which leaves the host
unprotected until the old cron job runs again, up to an hour later. And when that job does run, it
rebuilds the chain its own way and removes the agent's rip-local rule along with it. Two
programs, one chain, and each one undoing the other every hour.
So check before the first sync, and if the names collide, do not stop at mode: log on
this host. Install the agent, which touches no rule, take the old whitelist over, disable the old
cron job, and only then run the first sync with mode: drop already set.
# 1. Does something already own these names?
ipset list -n | grep "^rip-"
iptables -S INPUT | grep rip-
iptables -S rip-blacklist 2>/dev/null | head
crontab -l | grep -iE "ipset|blacklist|reportedip"
ls -l /etc/cron.d/ /etc/cron.hourly/ 2>/dev/null
# 2. If yes, take the old whitelist over FIRST. The feed can be
# downloaded again; that list cannot.
ipset list rip-whitelist | sed -n "/^Members/,\$p" | tail -n +2 > /tmp/rip-wl
ipset list rip-whitelist-v6 | sed -n "/^Members/,\$p" | tail -n +2 >> /tmp/rip-wl
wc -l /tmp/rip-wl
while read -r a; do
[ -n "$a" ] && reportedip-agent whitelist add "$a" "imported from the old script"
done < /tmp/rip-wl
reportedip-agent whitelist list | wc -l
# 3. Disable the old job, then hand the chain over in one step.
crontab -l | grep -v blacklist | crontab - # or: rm /etc/cron.d/<job>
sed -i "s/^mode: log/mode: drop/" /etc/reportedip-agent/config.yaml
reportedip-agent sync
reportedip-agent status
reportedip-agent whitelist list before the first sync.
Running both for a while
Running both is safe and it is the sensible way to migrate. The agent never writes to
/etc/fail2ban and fail2ban knows nothing about rip- sets, so the two use
separate chains and separate state. Two rules that both drop the same address cost one packet
comparison.
The one thing not to do is report the same event twice. If you keep the fail2ban action that posts bans over HTTP and configure fail2ban as an agent source, every ban leaves the host twice and is paid for twice out of your daily quota. Pick the agent or the action.
# What did fail2ban ban, and what does the agent make of the
# same logs? Same file, same window, two verdicts.
fail2ban-client status sshd
reportedip-agent test /var/log/auth.log
# Per jail, then the agent over the file behind it
for j in $(fail2ban-client status | sed -n "s/.*Jail list:\t*//p" | tr -d " " | tr "," " "); do
echo "== $j"; fail2ban-client status "$j" | grep -E "Total banned|Currently banned"
done
reportedip-agent status | sed -n "/^sources:/,/^queue:/p"
Switching fail2ban off for good
Do this once you have compared the two for a few days and the agent's source list covers every jail you had.
# 1. Stop it and keep it stopped.
systemctl stop fail2ban
systemctl disable fail2ban
# 2. Remove the fail2ban source from the agent config, then stop
# and start the daemon. A restart is not enough for a removed
# source: the daemon keeps tailing the old file.
$EDITOR /etc/reportedip-agent/config.yaml
systemctl stop reportedip-agent.service
systemctl start reportedip-agent.service
# 3. Check what fail2ban left in the kernel. Stopping it does not
# always clean up: a jail that once used an iptables action
# leaves old-style f2b- chains behind.
nft list tables | grep f2b || echo "no f2b table"
iptables -S | grep "f2b-" || echo "no f2b chain"
ipset list -n | grep f2b || echo "no f2b set"
# 4. Remove the leftovers from the RUNNING ruleset.
iptables -D INPUT -j f2b-sshd
iptables -F f2b-sshd && iptables -X f2b-sshd
# 5. And from the SAVED one, or they come back at the next boot.
grep -n "f2b" /etc/iptables/rules.v4 /etc/iptables/rules.v6
netfilter-persistent save
# 6. Confirm the agent is happy without it.
reportedip-agent status | grep -i fail2ban
reportedip-agent status; echo "exit $?"
f2b- chains
from the running ruleset, netfilter-persistent restores them from
/etc/iptables/rules.v4 and from /etc/iptables/rules.v6 at
every boot. They come back empty, so they drop nothing and look harmless, and they will still be
there in a year confusing whoever reads the ruleset next. Check both files: on one host the
leftovers were only in the v6 file, and whoever greps rules.v4 alone walks straight
past them. Clean the running ruleset first, then save it, and verify after a reboot.
Rollback is one command and takes seconds, because the agent never touched
/etc/fail2ban: systemctl enable --now fail2ban brings every jail and its own
table back with the agent unaffected. Measured on a live host, all eleven jails were back in eight
seconds.
Resource use
Measured on a Debian 12 arm64 host with seven log files under the agent: 0.085 % of one core and 16.6 MB of resident memory. There is no interpreter to start, no database and no cache directory to warm up, which is most of the reason those numbers are small.
On disk the agent's own files are bounded by configuration rather than by hope:
log_max_mb times log_keep plus the current file for the log,
queue_max small files for the queue, and a ban store that holds only what an escalation
still needs. The state directory of a busy host stays well under a hundred megabytes, which is why
the default disk floor of 200 MB never fires on a healthy machine.
What the agent is not
It is not a malware scanner, not a web application firewall and not a replacement for Imunify360. It does not read your files, it does not inspect request bodies and it does not quarantine anything. It blocks and reports addresses, and it does that on a small, auditable surface. For protection at the level of a single request on a WordPress site, use the Hive plugin instead. The two run on the same machine without interfering with each other.
Troubleshooting
One symptom, one likely cause, one command. reportedip-agent doctor and
reportedip-agent status between them answer most of these, and they are the first two
things support will ask for.
| Symptom | Cause | Command |
|---|---|---|
| Refuses to start and names its config file | The file is readable by group or others, and it holds your API key. | chmod 0600 /etc/reportedip-agent/config.yaml && chown root:root /etc/reportedip-agent/config.yaml |
| Exit 2 with "field ... not found in type" | An unknown key in the config. The parser is strict on purpose. | Remove or correct the key the message names. The list of valid keys is above. |
| Every command exits 2 after a rollback | The binary is older than the config and cannot parse a newer block. | reportedip-agent update, the one command that reads the file loosely and names the keys it does not know. |
| The sets exist but are empty | Either the feed was refused or the list failed the size check. | reportedip-agent status says which of the two, and the per-list line carries the last error. |
status says unlicensed | The account has no licence to spare for this host. | Add one under Agent Servers. The list in the kernel keeps working meanwhile. |
| Nothing is ever reported | The source that would have fired was never detected, or its threshold is not reached. | reportedip-agent test /path/to/log, then reportedip-agent status and read the sources section. |
| Nothing is ever blocked, although reports go out | ban.enabled is still false, or mode is still log. Two switches. | reportedip-agent ban list says which of the two, in its last lines. |
| No source state at all | The watch daemon never ran. | systemctl enable --now reportedip-agent.service |
A source shows files=0 | The path or glob resolves to nothing on this host. | reportedip-agent doctor names the source and the pattern it tried. |
| A log source is listed as unreadable | The file is not readable even for root. Usually a control panel that sets mode 000 on rotation. | ls -l on the path, and fix the rotation that produced it. |
| A source has hits but never reports | Its log timestamps are more than a minute from the system clock, so the counting window never fills. | reportedip-agent doctor, the drift lines under sources. Fix the time zone of that log. |
| Your own address got banned | It was not on the whitelist. | reportedip-agent whitelist add <address>. Instant, because the whitelist rule sits before the block rule. |
A ban is in bans.json but not in the kernel | A reboot without a sync, or a firewall reload that took the set with it. | reportedip-agent sync restores it with the time it has left. |
An entry in the kernel with record none | Somebody banned by hand with ipset or nft, or the store was lost. It expires, but no reboot brings it back. | reportedip-agent ban list |
| A set exists with the wrong type, or without a timeout | A leftover from the documented shell script, or from another tool. | ipset destroy <set> and then reportedip-agent sync, which rebuilds it correctly. |
Both tool chains have rip- objects | The backend was changed without a migration. | reportedip-agent sync --migrate-backend, and status prints the exact removal commands for the other side. |
| The firewall is gone after a reboot | A saved ruleset references a rip- set, and iptables-restore discards the whole file on an unknown set. | grep -n rip- /etc/iptables/rules.v4 /etc/iptables/rules.v6 and remove those lines. The agent needs nothing persisted. |
| A removed source is still being read | A systemctl restart is not enough for a removed source. | systemctl stop reportedip-agent.service && systemctl start reportedip-agent.service |
| Reports stop but detection continues | The disk-space floor was reached. | Free space, then reportedip-agent housekeeping. |
| HTTP 429 on reports | The daily report limit of your plan. | The limits are listed under Authentication. |
| The queue keeps growing | The sender is paused after a failure, with a backoff. | reportedip-agent status shows the pause and the reason; reportedip-agent report-queue sends one pass by hand. |
| Every web hit is the same handful of addresses | nginx sees a proxy or Cloudflare and not the visitor. | Configure set_real_ip_from. reportedip-agent install warns about exactly this case. |
| "more addresses than the counter tracks at once" | A scan wider than the per-source counter. Not a fault. | Nothing. A scan that wide is a case for the community list, not for local bans. |
status says the host is behind on versions | The automatic update is not getting through, or auto_update is off. | reportedip-agent update --check, then reportedip-agent update. |
Last updated: · Maintained by the ReportedIP team