Skip to main contentSkip to footer

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.

Current version: 0.3.8 for 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

1

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.

2

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.

3

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 ipset together with iptables, or nftables. On a Debian or Ubuntu host that is apt install ipset iptables or apt install nftables, on the RHEL family dnf install ipset iptables or dnf install nftables. The agent also uses ip6tables when 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.

backendWhat 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.
ipsetSets 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.
nftablesSets 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.

bash
reportedip-agent doctor

It answers six questions, in this order:

  • system: the distribution as its own os-release names it, the kernel, the platform, whether systemd is really running (read from /run/systemd/system, not from the presence of systemctl), 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 activation sshd -T still 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, ip6tables and nft are, 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 sendmail with 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.

ExitVerdictWhat it means
0everything the agent needs is hereInstall.
1runs on this host, with the limits aboveEvery 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.
2cannot run on this hostNot 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.

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

  1. Reads https://reportedip.com/agent/latest without the key and pulls the version out of it. That file is the same JSON the agent itself polls for updates.
  2. Downloads reportedip-agent_linux_<arch> and SHA256SUMS with the X-Key header into a temporary directory that is removed on every exit path.
  3. Verifies the checksum and refuses to install on a mismatch.
  4. Installs the binary with mode 0755 to /usr/local/bin/reportedip-agent and prints the version it just placed.
  5. Restarts reportedip-agent.service if 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.
  6. Runs the host setup, unless REPORTEDIP_NO_SETUP=1 is set or /etc/reportedip-agent/config.yaml already 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-agent and /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, from SSH_CONNECTION and from the listeners of running sshd processes, and uses the union of the three. When nothing is found it stops and asks for --ssh-port rather than assuming 22.
  • Decides which of the five feed lists to configure. ssh and edge are always on. mail, web and ftp are 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.yaml with mode 0600 and mode: log, ban.enabled: false. An existing config is never overwritten, and --key is then ignored with a note that says to edit api_key in 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 reportedip left behind by the documented shell script while the resolved backend is ipset, and an nginx that reads Cloudflare headers without set_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, runs daemon-reload, enables reportedip-agent-sync.service, and enables and starts both reportedip-agent-sync.timer and reportedip-agent.service.
  • Calls verify-key with 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.
The installation touches no firewall rule. No chain, no set, no jump into 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

VariableDefaultWhat it is for
REPORTEDIP_KEYnone, requiredYour API key. Without it the script stops before it downloads anything.
REPORTEDIP_VERSIONthe current releasePins 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/binWhere the binary goes. Change it and the systemd units, which name the absolute path, have to follow.
REPORTEDIP_BASEhttps://reportedip.com/agentThe 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_SETUPunset1 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.
bash
# 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.

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

bash
# 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.

  1. 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.
  2. 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.
  3. reportedip-agent status. Now there is something to look at. Check that every configured list has a plausible IPv4 count, that the chain says ok, that the whitelist holds the addresses you expect, and that account shows your role and the licence state of this host.
  4. 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.
  5. Wait, and read status again. A day is enough, two is better. In mode: log the rules are fully built and they match, they only log instead of dropping, so the kernel log shows you exactly what drop would have cut off.
  6. Switch to mode: drop and run sync, which is what rebuilds the rules.
  7. 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.
bash
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
The installation starts in 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.

An unknown key stops the agent with exit 2. The parser runs with strict field checking, so a misspelled key is an error with a line number instead of a value that silently never applies. That is deliberate: a typo in 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.

KeyDefaultWhat it does
api_keynoneYour API key. The only key without a default. REPLACE_ME counts as missing.
api_urlhttps://reportedip.com/wp-json/reportedip/v2The REST base. Has to be an absolute https URL; plain http is accepted on the loopback only, for a local proxy.
update_urlderived from api_urlWhere 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_updatetrueCheck 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_levelinfoinfo or debug. Nothing else is accepted.
log_file/var/log/reportedip-agent.logThe 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_mb10Size at which the agent rotates its own file, checked on every write. Range 1 to 1024.
log_keep3Rotated 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.
backendautoauto, ipset or nftables. See above.
modeloglog, drop or off. Applies to the rules, both the feed lists and the local bans.
confidence90The 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.
limit50000Maximum addresses per list request, 1 to 50000.
listsssh, edgeWhich 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.
portsfrom the real listenerUnder 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_entriesssh 1000, mail 500, web 500, ftp 500, edge 1000The 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.confYour 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.
sourcesone sshd entryThe 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.
typenoneInside a source: one of the thirteen source types. An unknown type is an error that lists the valid ones.
pathnoneInside 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.
globnoneInside a source: a pattern over many files, rescanned every ten minutes. At most five wildcard segments.
excludenoneInside 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_minutes5Inside 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.
thresholdsemptyPer-event-source override of the built-in table. See the next section.
hits, window_minutessee the table belowInside a thresholds entry: the pair that means "this many hits inside this many minutes".
min_hits, window_minutes5, 10The global pair for every source without its own entry in the built-in table.
web_min_hits, web_window_minutes50, 120The same pair for web access logs, which need volume rather than a status code.
dedup_hours6The same address is reported at most once per this window.
queue_max5000Queued report files. Above this the oldest are dropped.
disk_min_mb200Free 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_categoriesbuilt-in tableExtra mapping from a fail2ban jail name to threat category IDs, 1 to 58. Only relevant while fail2ban is still a source.
banoffThe local ban block, with enabled, time_minutes, max_time_minutes, escalate and memory_hours. See Blocking.
notifyno mailThe 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:

yaml
# /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 sourceThresholdWhere it comes from
sshd5 in 10 minmin_hits / window_minutes
web-error5 in 10 minmin_hits / window_minutes
modsec5 in 10 minmin_hits / window_minutes
exim5 in 10 minmin_hits / window_minutes
web50 in 120 minweb_min_hits / web_window_minutes
web-app20 in 60 minbuilt in
postfix-sasl3 in 60 minbuilt in, measured
postfix-reject5 in 60 minbuilt in, 5xx rejects only
postfix-amavis3 in 60 minbuilt in
dovecot10 in 60 minbuilt in, measured
ftp20 in 60 minbuilt in
named20 in 30 minbuilt in
panel5 in 60 minbuilt in
fail2ban, csf, imunify360no thresholdThat 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.

yaml
# 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.

KeyDefaultMeaning, and what happens at the extremes
enabledfalseWhether local finds are blocked at all. false is a host that reports and does not block. A manual ban add works either way.
time_minutes30The 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_minutes10080 (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.
escalate4The multiplier per repeat inside the memory window. At least 1, and 1 means every ban lasts time_minutes with no escalation at all.
memory_hours24How 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.
yaml
# 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.

KeyDefaultUnitToo lowToo high
queue_max5000queued report filesReports are dropped during an outage of the API, oldest first.A long outage leaves thousands of small files to send afterwards.
dedup_hours6hoursThe same attacker is reported again and again and eats your daily quota.An address that attacks again next week is reported late.
disk_min_mb200MB freeThe 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_mb10MBRotation on every other write, and a history too short to investigate anything.Together with log_keep this is the worst case on disk.
log_keep3generations0 keeps no rotated file at all.Maximum 20, and the disk cost is the product of the two.
cooldown_hours24hoursAt 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_minutes5minutesA command started too often on a busy host.Maximum 60, and findings arrive that much later.
limit50000addresses per listA truncated list, so the worst addresses are there and the tail is not.50000 is the maximum the API serves.
min_entries1000 / 500addressesAn 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.

yaml
# 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 changedWhat makes it take effect
mode, lists, min_entries, confidence, limit, backendreportedip-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 sourcessystemctl restart reportedip-agent.service.
A source removed from sourcesA 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 contentNothing, 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_urlNothing 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.
bash
# 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.

ListKernel setPorts the rule matches
sshrip-ssh, rip-ssh-v6from lists.ssh.ports
mailrip-mail, rip-mail-v625, 465, 587, 110, 995, 143, 993
webrip-web, rip-web-v680, 443
ftprip-ftp, rip-ftp-v621
edgerip-edge, rip-edge-v6every 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

TypeTypical pathWhat it counts as a hit
sshdjournald, else /var/log/auth.log or /var/log/secureFailed 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 globLogin 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.logRequests 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-appthe same access logsRuns 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/maillogThree 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.
dovecotthe same mail logIMAP 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.logAuthentication failures and rejected senders. Unverified against a live host: the patterns come from the documented log format.
ftp/var/log/syslog, /var/log/messagesFailed 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.
namedthe same syslogOnly 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.logFailed 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.logOne 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.logWhat 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 journalBan 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.
imunify360polls imunify360-agentIncidents 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.

yaml
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 blacklistLocal finds
Setsrip-ssh, rip-mail, rip-web, rip-ftp, rip-edge, and a -v6 set for eachrip-local and rip-local-v6
Where the addresses come fromEvery reporter in the community, filtered by your confidenceOnly this host's own logs
How an entry arrivesThe whole set is replaced on every feed passOne address at a time, when a threshold is reached
How an entry leavesWith the next swap, when the feed no longer lists itThe kernel expires it when its own timeout runs out
Per-entry timeoutnoneyes, that is the whole design
Switchmodeban.enabled, and mode on top
Needs a licenceyes, this is the paid partno

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:

  1. 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 list wondering 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.
  2. 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.
  3. The address goes into rip-local with 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.
  4. 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.target and runs after every firewall service.
  • During normal operation it is whatever took the set away: a firewall-cmd --reload, a csf -r, an ipset 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:

BanCalculationDuration
1sttime_minutes30 minutes
2nd30 × 42 hours
3rd120 × 48 hours
4th480 × 432 hours
5th1920 × 4128 hours, five days and eight hours
6th and afterwould be 512 hours, capped7 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.

bash
# 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.

modeban.enabledCommunity listYour own finds
logfalseMatched and logged, nothing droppedReported to the community, not blocked locally
logtrueMatched and loggedRecorded in rip-local and visible in ban list, logged instead of dropped
dropfalseDroppedReported only. This is a perfectly sensible steady state.
droptrueDroppedDropped as well, with escalation. The full configuration.
offeitherNothing in the packet path: the jump is removed and the chain is left with its rulesRecorded 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

bash
# 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.

bash
# 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

The way out of a lockout is one command. The whitelist rule sits in front of the block rule, so 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.
bash
# 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.

Never let a saved ruleset reference a 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

CommandWhat it doesExit
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
syncOne 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
watchThe 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-queueSends the queued reports once. Useful after an API outage, or on a host where the daemon does not run.0, 1 degraded
statusVersion, 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
doctorWhat 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
housekeepingRemoves the agent's own leftovers and prints the numbers. The sync run does the same silently.0, 2 on a config or state problem
versionPrints the version and nothing else.0
helpThe 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".

bash
# 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.timer fires 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.service is enabled for multi-user.target and runs after network-online.target and 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.service restarts 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 with NoNewPrivileges, 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_file is set. It rotates that file itself at log_max_mb and keeps log_keep generations. 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_mb the 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.
bash
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

CodeMeaningWhat to do
0HealthyNothing to do.
1Degraded, but it will try againRead 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.
2Not repeatable without a humanThe 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:

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

PlanServers includedMore servers
FreenoneNot bookable
ContributornoneNot bookable
Professional1Any number
Business3Any number
Enterpriseby contractby 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.

FunctionWithout a licence
Feed downloadStops. This is the part that is paid for.
The list already in the kernelStays. It is never flushed, and it keeps blocking.
Local detectionKeeps running. All thirteen sources, all thresholds.
Local blockingKeeps running, escalation included, including the restore after a reboot.
ReportsKeep going, within the daily limit of your plan.
Whitelist, chain, housekeeping, log rotationKeep running.
Self-updateKeeps running. An outdated agent on an unpaid host is our risk, not our leverage.
statusShows 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.

bash
# 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
Step 2 is not optional. On the hosts checked, the old script's whitelist held 208 IPv4 and 61 IPv6 prefixes: search engine crawlers, Cloudflare and the operator's own machines. Without importing it, the agent would have reported Googlebot, Cloudflare and the fleet itself on its first day. A whitelist is the one piece of state a migration cannot reconstruct from anywhere else, so carry it over before you switch anything, and read it back with 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.

bash
# 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.

bash
# 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 $?"
Step 5 is the one people skip. If you only delete the 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.

SymptomCauseCommand
Refuses to start and names its config fileThe 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 rollbackThe 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 emptyEither 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 unlicensedThe 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 reportedThe 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 outban.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 allThe watch daemon never ran.systemctl enable --now reportedip-agent.service
A source shows files=0The 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 unreadableThe 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 reportsIts 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 bannedIt 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 kernelA 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 noneSomebody 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 timeoutA 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- objectsThe 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 rebootA 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 readA systemctl restart is not enough for a removed source.systemctl stop reportedip-agent.service && systemctl start reportedip-agent.service
Reports stop but detection continuesThe disk-space floor was reached.Free space, then reportedip-agent housekeeping.
HTTP 429 on reportsThe daily report limit of your plan.The limits are listed under Authentication.
The queue keeps growingThe 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 addressesnginx 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 versionsThe 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

Security Focused
GDPR Compliant
Made in Germany
Back to Docs