Main Configuration
File: /usr/local/etc/badips.conf
Global Settings
| Parameter |
Description |
Default |
log_level |
Logging verbosity: debug, info, warn, error |
debug |
block_duration |
How long to block IPs (seconds) |
691200 (8 days) |
never_block_cidrs |
Comma-separated IPv4 CIDRs that are never blocked (highest priority) |
Required |
never_block_cidrs_v6 |
Comma-separated IPv6 CIDRs that are never blocked (highest priority) |
::1/128,fe80::/10,fc00::/7 |
always_block_cidrs |
Comma-separated IPv4 CIDRs that are always blocked (static firewall rules) |
224.0.0.0/4,240.0.0.0/4 |
always_block_cidrs_v6 |
Comma-separated IPv6 CIDRs that are always blocked (static firewall rules) |
(empty) |
auto_mode |
Auto-discover services (1=enabled, 0=disabled) |
1 |
cleanup_every_seconds |
How often to clean expired blocks |
3600 |
sleep_time |
Seconds between log scans |
10 |
initial_journal_lookback |
Initial journal history to scan (seconds) |
200000 |
central_db_batch_size |
Max batch size for PostgreSQL inserts before timeout |
20 |
central_db_queue_timeout |
Max seconds to wait before processing queued IPs (regardless of batch size) |
5 |
public_blocklist_urls |
Comma-separated list of URLs to fetch blocklists from (HTTP/HTTPS/file://) ⚠️ DEPRECATED in v3.5+ - Use PublicBlocklistPlugins instead |
Optional |
public_blocklist_refresh |
How often to refresh public blocklists (seconds) ⚠️ DEPRECATED in v3.5+ - Use PublicBlocklistPlugins instead |
900 (15 minutes) |
heartbeat |
Status logging interval (seconds) |
10 |
graceful_shutdown_timeout |
Max seconds to wait for threads to finish during shutdown |
10 |
Example Configuration
# Bad IPs Configuration
# Generated during installation on Tue Dec 9 03:50:28 PM CST 2025
[global]
# Logging
log_level = debug
# How long to block an IP (seconds)
block_duration = 691200 # 8 days
# Network filtering (IPv4)
never_block_cidrs = 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16,23.116.91.64/29
always_block_cidrs = 224.0.0.0/4,240.0.0.0/4
# Network filtering (IPv6)
never_block_cidrs_v6 = ::1/128,fe80::/10,fc00::/7
always_block_cidrs_v6 =
# Performance tuning
auto_mode = 1
# Cleanup intervals
cleanup_every_seconds = 3600
# Initial lookback -> how far to initally look back at journal
# Files are always read in entirety on initial loading
initial_journal_lookback = 200000
# Sleep time: number of seconds between looking at journalct or log files
sleep_time = 10
# central_db_batch_size: the max batch size to insert into central database of new IPs blocked
# As new IPs are found, after they have been blocked, each IP is added to a queue (sync_to_central_db_queue)
# Once the queue has at least central_db_batch_size in it, then that many IPs will be saved to the central database
# The lower the number, the quicker IPs will be saved the the database and can then be used by other systems
# The higher the number, the less frequent there are inserts to the database
# See central_db_queue_timeout to see max wait time.
central_db_batch_size = 20
# central_db_queue_timeout: the timeout to wait for
to be in sync_to_central_db_queue
# After seconds, no matter how many IPs are in sync_to_central_db_queue, they will be removed and processed
# The lower the numer, the faster a low count of items will be processed
# The higher the number, the less stress on the database with low count inserts
central_db_queue_timeout = 5
# ⚠️ DEPRECATED in v3.5+ - Use [PublicBlocklistPlugins:Name] sections instead
# public_blocklist_urls (comma separated list)
# List of sites where a static text list can be retrieved of IPs to block
# Public lists are cached to a local file (see public_blocklist_refresh)
# You can use a local file, too, doing something like: file:///block/these/annoying_ips.txt
# That way you can generate your own list of IPs and IP+Subnets
# public_blocklist_urls = https://www.spamhaus.org/drop/drop.txt, https://feodotracker.abuse.ch/downloads/ipblocklist.txt
# public_blocklist_refresh (seconds):
# How often to refresh above list. Honors ETag and not-before headers.
# If you are kinda doing some debug and constantly stopping and starting, then the cached file will be used until
# it is seconds old
# public_blocklist_refresh = 900
# heartbeat (seconds):
# How often to produce a log entry with some cursory info
heartbeat = 10
# graceful_shutdown_timeout (seconds):
# How long to give each thread an opportunity to be cleared before bypassing the queue and shutting down
# 10 seconds is plenty of time for each thread
graceful_shutdown_timeout = 10
⚠️ Critical: Always configure never_block_cidrs with your management networks to prevent lockouts!
IP Filtering Precedence
Bad IPs uses three nftables sets with the following precedence order (highest to lowest):
- never_block (static) - IPs/CIDRs that are always allowed, regardless of detections
- always_block (static) - IPs/CIDRs that are always blocked at the firewall level
- badipv4 (dynamic with timeout) - IPs dynamically blocked based on detections
Use cases for always_block:
- Known bad actor networks or botnets
- Geographic blocking (countries you don't serve)
- Competitor reconnaissance IPs
- Cloud scanner networks (Shodan, Censys, etc.)
Note: IPs in always_block are still logged to the database for reporting purposes. Different servers can have different always_block configurations based on their role (e.g., block on DNS/mail but not web).
Logging Configuration
File: /usr/local/etc/badips/log4perl.conf
Bad IPs uses Log::Log4perl for flexible logging configuration.
Default Configuration
# Root logger
log4perl.rootLogger = INFO, SYS
# File appender
log4perl.appender.SYS = Log::Log4perl::Appender::File
log4perl.appender.SYS.filename = /var/log/bad_ips/bad_ips.log
log4perl.appender.SYS.mode = append
log4perl.appender.SYS.layout = Log::Log4perl::Layout::PatternLayout
log4perl.appender.SYS.layout.ConversionPattern = %d|%P|%p|%l|%X{THREAD}|%m%n
Log Levels
- DEBUG - Detailed diagnostic information (verbose)
- INFO - General informational messages (default)
- WARN - Warning messages
- ERROR - Error messages only
Changing Log Level
To enable debug logging, edit /usr/local/etc/badips/log4perl.conf:
# Change INFO to DEBUG
log4perl.rootLogger = DEBUG, SYS
Then reload the service:
sudo systemctl reload bad_ips
Reload Timing
Log configuration changes are applied gradually:
- Main thread: ~5 seconds after reload signal
- Worker threads: ~5 seconds + thread reload time
- A log message indicates when threads will be reloaded
- Watch logs with:
journalctl -u bad_ips -f
Note: The 5-second interval is hardcoded in /usr/local/sbin/bad_ips.
If you need immediate changes, modify the script and run sudo systemctl restart bad_ips.
Log Format
The default log format includes:
- %d - Timestamp
- %P - Process ID
- %p - Log level (DEBUG, INFO, etc.)
- %l - Location (file:line)
- %X{THREAD} - Thread name (main, nft_blocker, etc.)
- %m - Log message
Log Rotation
Logs are automatically rotated by logrotate:
- Rotation: Daily
- Retention: 14 days
- Location:
/var/log/bad_ips/bad_ips.log
Database Configuration
File: /usr/local/etc/badips.d/database.conf
Permissions: 600 (contains passwords)
Database Parameters
| Parameter |
Description |
Example |
db_host |
Database hostname or IP |
10.10.0.116 |
db_port |
Database port |
5432 |
db_name |
Database name |
bad_ips |
db_user |
Database username |
bad_ips_admin |
db_password |
Database password |
secret |
db_ssl_mode |
SSL mode: disable, require, verify-ca, verify-full |
disable |
Example Configuration
[global]
db_host = 10.10.0.116
db_port = 5432
db_name = bad_ips
db_user = bad_ips_admin
db_password = your_secure_password_here
db_ssl_mode = require
Database Schema
Bad IPs uses the jailed_ips table with PostgreSQL's native inet type for IPv4/IPv6 support:
CREATE TABLE jailed_ips (
id SERIAL PRIMARY KEY,
ip inet NOT NULL,
originating_server VARCHAR(255) NOT NULL,
originating_service VARCHAR(255),
detector_name VARCHAR(255),
pattern_matched TEXT,
matched_log_line TEXT,
first_blocked_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
block_count INTEGER DEFAULT 1,
UNIQUE(ip, originating_server)
);
CREATE INDEX idx_jailed_ips_expires ON jailed_ips(expires_at);
CREATE INDEX idx_jailed_ips_ip ON jailed_ips(ip);
Detector Configuration
Directory: /usr/local/etc/badips.d/
Naming: NN-service.conf (e.g., 10-sshd.conf)
Detector Format
[detector:name]
units = service1.service, service2.service
pattern1 = regex pattern to match
pattern2 = another pattern
pattern3 = yet another pattern
Pre-configured Detectors
SSH (10-sshd.conf)
[detector:sshd]
units = ssh.service, sshd.service
pattern1 = Failed password for invalid user
pattern2 = Failed password for root
pattern3 = Connection closed by authenticating user
Postfix Mail (20-postfix.conf)
[detector:postfix]
units = postfix@-.service, postfix.service
pattern1 = Relay access denied
pattern2 = Illegal address syntax from
pattern3 = SASL LOGIN authentication failed
pattern4 = SSL_accept error from
pattern5 = non-SMTP command from unknown
Dovecot IMAP/POP3 (30-dovecot.conf)
[detector:dovecot]
units = dovecot.service
pattern1 = Aborted login
pattern2 = Disconnected.*rip=
pattern3 = Auth failed
pattern4 = Invalid user
Nginx Web (40-nginx.conf)
[detector:nginx]
units = nginx.service
pattern1 = 404.*GET /wp-admin
pattern2 = 404.*GET /wp-login
pattern3 = 400 Bad Request
BIND DNS (70-bind.conf)
[detector:bind]
units = named.service, bind9.service
pattern1 = query failed \(REFUSED\)
pattern2 = rate limiting
pattern3 = query failed \(SERVFAIL\)
Pattern Matching
Pattern Syntax
Patterns are Perl-compatible regular expressions (PCRE).
Common Patterns
| Pattern |
Matches |
Failed password |
Literal string "Failed password" |
Failed password.*root |
"Failed password" followed by "root" |
404.*wp-admin |
404 errors accessing wp-admin |
Disconnected.*rip= |
Disconnection messages with IP |
IP Extraction
Bad IPs automatically extracts IPv4 addresses from matched log lines. No special configuration needed.
Testing Patterns
bad_ips --dry-run
Runs Bad IPs in detection-only mode. IPs are identified but not blocked.
Public Blocklist Plugins
Bad IPs supports extensible public blocklist plugins that fetch and process IP blocklists from external sources. A Spamhaus plugin is included by default, and you can develop custom plugins for any blocklist source.
Configuration
Plugins are configured in /usr/local/etc/badips.conf using the [PublicBlocklistPlugins:PluginName] section format:
[PublicBlocklistPlugins:Spamhaus]
urls = https://www.spamhaus.org/drop/drop.txt, https://www.spamhaus.org/drop/edrop.txt
fetch_interval = 3600
use_cache = 1
cache_path = /var/cache/badips/
active = 1
[PublicBlocklistPlugins:Feodotracker]
urls = https://feodotracker.abuse.ch/downloads/ipblocklist.txt
fetch_interval = 7200
use_cache = 1
cache_path = /var/cache/badips/
active = 0
⚠️ Configuration Requirements:
- A configuration section
[PublicBlocklistPlugins:<plugin_name>] must exist for each plugin
- The
<plugin_name> must exactly match the package name and module file name
- The only required parameter is
active
- If
active does not exist or is set to 0, the plugin is not loaded
- All other parameters are optional and plugin-specific
Built-in Plugins
The Spamhaus plugin is included with Bad IPs and provides access to the Spamhaus DROP and EDROP lists. Additional plugins can be found in the template configuration or developed custom.
Developing Custom Plugins
You can develop your own blocklist plugins to integrate any external IP list source. Custom plugins must follow the BadIPs plugin architecture:
Package Structure
⚠️ Important: Plugin package names must follow the format: BadIPs::PublicBlocklistPlugins::<PluginName>
For example, a custom plugin named "MyBlocklist" would be:
- Package name:
BadIPs::PublicBlocklistPlugins::MyBlocklist
- File location:
/usr/local/lib/site_perl/BadIPs/PublicBlocklistPlugins/MyBlocklist.pm
- Configuration section:
[PublicBlocklistPlugins:MyBlocklist]
Required Methods
1. Constructor: new()
The new() method must instantiate your plugin class and will receive the following arguments:
| Argument |
Type |
Description |
conf |
HashRef |
Complete configuration hash including your plugin's [PublicBlocklistPlugins:Name] section |
reload_check |
CodeRef |
Function reference to check if reload has been requested. Returns boolean. |
shutdown_check |
CodeRef |
Function reference to check if shutdown has been requested. Returns boolean. |
enqueue_ip |
CodeRef |
Function reference to enqueue discovered IPs for blocking. Accepts an IP item hashref. |
log |
Object |
Log::Log4perl logger object for your plugin |
2. Run Method: run()
The run() method is where your plugin's main logic executes. This method:
- Takes no arguments
- Runs in its own thread
- Should loop continuously, checking
shutdown_check and reload_check
- Calls
enqueue_ip to submit discovered IPs for blocking
Example Plugin Skeleton
package BadIPs::PublicBlocklistPlugins::MyBlocklist;
use strict;
use warnings;
use LWP::UserAgent;
sub new {
my ($class, %args) = @_;
my $self = {
conf => $args{conf},
reload_check => $args{reload_check},
shutdown_check => $args{shutdown_check},
enqueue_ip => $args{enqueue_ip},
log => $args{log},
};
bless $self, $class;
return $self;
}
sub run {
my ($self) = @_;
my $log = $self->{log};
my $plugin_conf = $self->{conf}{PublicBlocklistPlugins}{MyBlocklist};
$log->info("MyBlocklist plugin started");
while (!$self->{shutdown_check}->()) {
# Check for reload request
if ($self->{reload_check}->()) {
$log->info("Reload requested, restarting...");
return;
}
# Fetch blocklist
my $ua = LWP::UserAgent->new;
my $response = $ua->get($plugin_conf->{url});
if ($response->is_success) {
my @ips = split /\n/, $response->content;
foreach my $ip (@ips) {
next if $ip =~ /^#/; # Skip comments
$ip =~ s/\s.*//; # Remove trailing comments
# Enqueue IP for blocking
$self->{enqueue_ip}->({
ip => $ip,
originating_service => 'MyBlocklist',
detector_name => 'MyBlocklist',
pattern_matched => 'Public blocklist',
});
}
$log->info("Processed blocklist, found " . scalar(@ips) . " IPs");
} else {
$log->error("Failed to fetch blocklist: " . $response->status_line);
}
# Sleep before next fetch
sleep($plugin_conf->{fetch_interval} || 3600);
}
$log->info("MyBlocklist plugin shutting down");
return 1;
}
1;
Plugin Configuration
After creating your plugin, add its configuration to /usr/local/etc/badips.conf. The section name must match your plugin name, and active = 1 is the only required parameter:
[PublicBlocklistPlugins:MyBlocklist]
# Required parameter - plugin will not load without this
active = 1
# Optional plugin-specific parameters
url = https://example.com/blocklist.txt
fetch_interval = 3600
💡 Notes:
- The section name
[PublicBlocklistPlugins:MyBlocklist] must exactly match your module file name (MyBlocklist.pm)
active = 1 is the only required parameter. If missing or set to 0, the plugin will not load.
- All other parameters are optional and defined by your plugin
- Access parameters via
$self->{conf}{PublicBlocklistPlugins}{MyBlocklist}{param_name}
Best Practices
- Respect fetch intervals: Don't hammer external services
- Handle errors gracefully: Log errors and continue operation
- Check shutdown frequently: Allow clean service stops
- Use caching: Implement local caching to reduce bandwidth
- Validate IPs: Ensure fetched data contains valid IP addresses/CIDRs
- Honor reload: Exit run() when reload_check returns true
Service Management
Common Commands
# Check status
systemctl status bad_ips
# Reload configuration (no restart needed)
systemctl reload bad_ips
# Restart service
systemctl restart bad_ips
# View logs
journalctl -u bad_ips.service -f
# Test configuration
bad_ips --test-config
# View blocked IPs
sudo nft list set inet filter badipv4
💡 Tip: After making configuration changes, use systemctl reload bad_ips instead of restart to apply changes without clearing blocked IPs.