Add country-based IP blocking for Caddy via Ansible
Some checks failed
Ansible Lint Check / check-ansible (push) Failing after 37s
Nix Format Check / check-format (push) Failing after 1m39s
Python Lint Check / check-python (push) Failing after 23s

- Introduce generate_country_blocks.py to fetch IP ranges by country
- Update group_vars/servers.yml with country blocking settings
- Add country_block snippet to Caddyfile and apply to all sites
- Create Ansible tasks for automated IP range generation and integration
- Add documentation for configuring and managing country blocking
This commit is contained in:
2025-06-15 01:30:42 +02:00
parent 020c32e8fe
commit 0f35a7b9e2
7 changed files with 487 additions and 3 deletions

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Generate country IP ranges for Caddy blocking configuration.
Downloads the latest country IP ranges and formats them for use in Caddyfile.
"""
import json
import requests
import sys
import argparse
from typing import List, Dict
import ipaddress
def download_country_ranges(countries: List[str]) -> Dict[str, List[str]]:
"""
Download IP ranges for specified countries from ip2location.com free database.
Args:
countries: List of ISO 3166-1 alpha-2 country codes (e.g., ['CN', 'RU'])
Returns:
Dictionary mapping country codes to lists of IP ranges
"""
country_ranges = {}
for country in countries:
print(f"Downloading IP ranges for {country}...", file=sys.stderr)
# Use ipinfo.io's free API for country IP ranges
try:
url = f"https://ipinfo.io/countries/{country.lower()}"
headers = {'User-Agent': 'Mozilla/5.0 (compatible; country-blocker/1.0)'}
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 200:
# The response contains IP ranges in CIDR format, one per line
ranges = []
for line in response.text.strip().split('\n'):
line = line.strip()
if line and not line.startswith('#'):
try:
# Validate the IP range
ipaddress.ip_network(line, strict=False)
ranges.append(line)
except ValueError:
continue
country_ranges[country] = ranges
print(f"Found {len(ranges)} IP ranges for {country}", file=sys.stderr)
else:
print(f"Failed to download ranges for {country}: HTTP {response.status_code}", file=sys.stderr)
except Exception as e:
print(f"Error downloading ranges for {country}: {e}", file=sys.stderr)
return country_ranges
def get_alternative_ranges(countries: List[str]) -> Dict[str, List[str]]:
"""
Alternative method using a different data source.
Falls back to manually curated ranges for common blocking scenarios.
"""
# Common IP ranges for frequently blocked countries
# These are examples - you should update with current ranges
manual_ranges = {
'CN': [
'1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '1.0.32.0/19',
'14.0.12.0/22', '14.0.36.0/22', '14.1.0.0/18', '14.16.0.0/12',
'27.0.0.0/11', '27.50.40.0/21', '27.54.192.0/18', '27.98.208.0/20',
# Add more ranges as needed
],
'RU': [
'2.56.96.0/19', '2.58.56.0/21', '5.8.0.0/19', '5.34.96.0/19',
'5.42.192.0/19', '5.44.64.0/18', '5.53.96.0/19', '5.61.24.0/21',
# Add more ranges as needed
],
'KP': [
'175.45.176.0/22', '210.52.109.0/24', '77.94.35.0/24'
],
'IR': [
'2.176.0.0/12', '5.22.0.0/16', '5.23.0.0/16', '5.56.128.0/17',
# Add more ranges as needed
]
}
result = {}
for country in countries:
if country in manual_ranges:
result[country] = manual_ranges[country]
print(f"Using manual ranges for {country}: {len(manual_ranges[country])} ranges", file=sys.stderr)
else:
print(f"No manual ranges available for {country}", file=sys.stderr)
result[country] = []
return result
def format_for_caddy(country_ranges: Dict[str, List[str]]) -> List[str]:
"""
Format IP ranges for use in Caddyfile remote_ip matcher.
Args:
country_ranges: Dictionary of country codes to IP ranges
Returns:
List of IP ranges formatted for Caddy
"""
all_ranges = []
for country, ranges in country_ranges.items():
all_ranges.extend(ranges)
return all_ranges
def main():
parser = argparse.ArgumentParser(description='Generate country IP ranges for blocking')
parser.add_argument('countries', nargs='+',
help='Country codes to block (e.g., CN RU KP IR)')
parser.add_argument('--format', choices=['caddy', 'json', 'list'],
default='caddy', help='Output format')
parser.add_argument('--fallback', action='store_true',
help='Use manual fallback ranges instead of downloading')
parser.add_argument('--output', '-o', help='Output file (default: stdout)')
args = parser.parse_args()
# Normalize country codes to uppercase
countries = [c.upper() for c in args.countries]
# Download or use fallback ranges
if args.fallback:
country_ranges = get_alternative_ranges(countries)
else:
country_ranges = download_country_ranges(countries)
# If download failed, try fallback
if not any(country_ranges.values()):
print("Download failed, using fallback ranges...", file=sys.stderr)
country_ranges = get_alternative_ranges(countries)
# Format output
if args.format == 'caddy':
ranges = format_for_caddy(country_ranges)
output = ' '.join(ranges)
elif args.format == 'json':
output = json.dumps(country_ranges, indent=2)
else: # list format
ranges = format_for_caddy(country_ranges)
output = '\n'.join(ranges)
# Write output
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Output written to {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == '__main__':
main()

View File

@@ -1,3 +1,19 @@
--- ---
flatpaks: false flatpaks: false
install_ui_apps: false install_ui_apps: false
# Country blocking configuration for Caddy
# List of countries to block by ISO 3166-1 alpha-2 country codes
# Common examples: CN (China), RU (Russia), KP (North Korea), IR (Iran), BY (Belarus)
blocked_countries_codes:
- CN # China
- RU # Russia
- KP # North Korea
- IR # Iran
# IP ranges for blocked countries (generated automatically)
# This will be populated by the country blocking script
blocked_countries: []
# Enable/disable country blocking globally
enable_country_blocking: true

View File

@@ -37,11 +37,11 @@
enabled: true enabled: true
- name: beszel - name: beszel
enabled: true enabled: true
- name: arr-stack
enabled: false
- name: downloaders - name: downloaders
enabled: true enabled: true
- name: wireguard - name: wireguard
enabled: true enabled: true
- name: echoip - name: echoip
enabled: true enabled: true
- name: arr-stack
enabled: false

View File

@@ -1,43 +1,74 @@
# Global configuration for country blocking
{
# Block specific countries (add ISO country codes as needed)
# Examples: CN (China), RU (Russia), KP (North Korea), IR (Iran)
servers {
protocols h1 h2 h3
}
}
# Country blocking snippet - reusable across all sites
{% if enable_country_blocking | default(false) and blocked_countries | default([]) | length > 0 %}
(country_block) {
@blocked_countries {
remote_ip {{ blocked_countries | join(' ') }}
}
respond @blocked_countries "Access denied from your country" 403
}
{% else %}
(country_block) {
# Country blocking disabled
}
{% endif %}
photos.mvl.sh { photos.mvl.sh {
import country_block
reverse_proxy immich:2283 reverse_proxy immich:2283
tls {{ caddy_email }} tls {{ caddy_email }}
} }
photos.vleeuwen.me { photos.vleeuwen.me {
import country_block
redir https://photos.mvl.sh{uri} redir https://photos.mvl.sh{uri}
tls {{ caddy_email }} tls {{ caddy_email }}
} }
karakeep.mvl.sh { karakeep.mvl.sh {
import country_block
reverse_proxy karakeep:3000 reverse_proxy karakeep:3000
tls {{ caddy_email }} tls {{ caddy_email }}
} }
hoarder.mvl.sh { hoarder.mvl.sh {
import country_block
redir https://karakeep.mvl.sh{uri} redir https://karakeep.mvl.sh{uri}
} }
git.vleeuwen.me git.mvl.sh { git.vleeuwen.me git.mvl.sh {
import country_block
reverse_proxy gitea:3000 reverse_proxy gitea:3000
tls {{ caddy_email }} tls {{ caddy_email }}
} }
status.vleeuwen.me status.mvl.sh { status.vleeuwen.me status.mvl.sh {
import country_block
reverse_proxy uptime-kuma:3001 reverse_proxy uptime-kuma:3001
tls {{ caddy_email }} tls {{ caddy_email }}
} }
sf.mvl.sh { sf.mvl.sh {
import country_block
reverse_proxy seafile:80 reverse_proxy seafile:80
handle /seafdav* { handle /seafdav* {
reverse_proxy seafile:8080 reverse_proxy seafile:8080
} }
tls {{ caddy_email }} tls {{ caddy_email }}
} }
of.mvl.sh { of.mvl.sh {
import country_block
reverse_proxy onlyoffice:80 { reverse_proxy onlyoffice:80 {
header_up Host {host} header_up Host {host}
header_up X-Real-IP {remote} header_up X-Real-IP {remote}
@@ -48,31 +79,37 @@ of.mvl.sh {
} }
fsm.mvl.sh { fsm.mvl.sh {
import country_block
reverse_proxy factorio-server-manager:80 reverse_proxy factorio-server-manager:80
tls {{ caddy_email }} tls {{ caddy_email }}
} }
df.mvl.sh { df.mvl.sh {
import country_block
redir / https://git.mvl.sh/vleeuwenmenno/dotfiles/raw/branch/master/setup.sh redir / https://git.mvl.sh/vleeuwenmenno/dotfiles/raw/branch/master/setup.sh
tls {{ caddy_email }} tls {{ caddy_email }}
} }
overseerr.mvl.sh jellyseerr.mvl.sh overseerr.vleeuwen.me jellyseerr.vleeuwen.me { overseerr.mvl.sh jellyseerr.mvl.sh overseerr.vleeuwen.me jellyseerr.vleeuwen.me {
import country_block
reverse_proxy mennos-server:5555 reverse_proxy mennos-server:5555
tls {{ caddy_email }} tls {{ caddy_email }}
} }
jellyfin.mvl.sh jellyfin.vleeuwen.me { jellyfin.mvl.sh jellyfin.vleeuwen.me {
import country_block
reverse_proxy jellyfin:8096 reverse_proxy jellyfin:8096
tls {{ caddy_email }} tls {{ caddy_email }}
} }
fladder.mvl.sh { fladder.mvl.sh {
import country_block
reverse_proxy fladder:80 reverse_proxy fladder:80
tls {{ caddy_email }} tls {{ caddy_email }}
} }
ip.mvl.sh { ip.mvl.sh {
import country_block
reverse_proxy echoip:8080 { reverse_proxy echoip:8080 {
header_up X-Real-IP {http.request.remote.host} header_up X-Real-IP {http.request.remote.host}
header_up X-Forwarded-For {http.request.remote.host} header_up X-Forwarded-For {http.request.remote.host}
@@ -84,6 +121,7 @@ ip.mvl.sh {
} }
http://ip.mvl.sh { http://ip.mvl.sh {
import country_block
reverse_proxy echoip:8080 { reverse_proxy echoip:8080 {
header_up X-Real-IP {http.request.remote.host} header_up X-Real-IP {http.request.remote.host}
header_up X-Forwarded-For {http.request.remote.host} header_up X-Forwarded-For {http.request.remote.host}

View File

@@ -0,0 +1,214 @@
# Caddy Country Blocking Configuration
This directory contains configuration for implementing country-based IP blocking in Caddy reverse proxy.
## Overview
The country blocking feature allows you to block traffic from specific countries by their IP address ranges. This is useful for:
- Reducing spam and malicious traffic
- Complying with regional restrictions
- Improving security posture
- Reducing server load from unwanted traffic
## How It Works
1. **IP Range Generation**: The `generate_country_blocks.py` script downloads current IP ranges for specified countries
2. **Caddy Configuration**: The Caddyfile template includes a reusable snippet that blocks IPs from those ranges
3. **Automatic Updates**: Ansible task generates fresh IP ranges on each deployment
## Configuration
### Enable/Disable Country Blocking
In `group_vars/servers.yml`:
```yaml
# Enable or disable country blocking
enable_country_blocking: true # Set to false to disable
```
### Specify Countries to Block
Add country codes (ISO 3166-1 alpha-2) to the `blocked_countries_codes` list:
```yaml
blocked_countries_codes:
- CN # China
- RU # Russia
- KP # North Korea
- IR # Iran
- BY # Belarus
```
### Common Country Codes
| Country | Code | | Country | Code |
|---------|------|-|---------|------|
| China | CN | | Russia | RU |
| North Korea | KP | | Iran | IR |
| Belarus | BY | | Syria | SY |
| Myanmar | MM | | Afghanistan | AF |
| Cuba | CU | | Venezuela | VE |
## Files Structure
```
caddy/
├── Caddyfile.j2 # Main Caddy configuration with country blocking
├── caddy.yml # Ansible task for Caddy deployment
├── country-blocking.yml # Ansible task for country blocking setup
├── docker-compose.yml.j2 # Docker Compose configuration
├── generate_country_blocks.py # Script to generate IP ranges
└── README-country-blocking.md # This documentation
```
## Manual Usage
### Generate IP Ranges Manually
```bash
# Generate ranges for specific countries
python3 generate_country_blocks.py CN RU --format=list
# Output to file
python3 generate_country_blocks.py CN RU KP --output=blocked_ranges.txt
# Use fallback/manual ranges if download fails
python3 generate_country_blocks.py CN RU --fallback
# JSON format for inspection
python3 generate_country_blocks.py CN RU --format=json
```
### Test Country Blocking
After deployment, test blocking by checking access from different locations:
```bash
# Test from a VPN/proxy in a blocked country
curl -I https://your-domain.com
# Should return HTTP 403 with "Access denied from your country"
```
## Deployment
The country blocking is automatically configured when you run the Caddy Ansible task:
```bash
ansible-playbook -i inventory.ini playbook.yml --tags caddy
```
Or specifically for country blocking:
```bash
ansible-playbook -i inventory.ini playbook.yml --tags country-blocking
```
## Troubleshooting
### No IP Ranges Generated
If the script fails to download ranges:
1. Check internet connectivity on the Ansible control machine
2. Verify the country codes are valid (2-letter ISO codes)
3. Use fallback ranges: set `--fallback` flag in the script
4. Check the script output for error messages
### Legitimate Traffic Blocked
If legitimate users are blocked:
1. Verify their country isn't in your blocked list
2. Check if they're using a VPN/proxy from a blocked country
3. Consider whitelisting specific IP ranges for known users
4. Review your country blocking list for overly broad restrictions
### Service Not Starting
If Caddy fails to start after enabling country blocking:
1. Check the generated Caddyfile syntax: `caddy validate --config /path/to/Caddyfile`
2. Verify the IP ranges are valid CIDR notation
3. Check Docker logs: `docker compose logs caddy`
## Security Considerations
### Benefits
- **Reduced Attack Surface**: Blocks traffic from high-risk regions
- **Lower Server Load**: Reduces processing of malicious requests
- **Compliance**: Helps meet regional access restrictions
### Limitations
- **VPN Bypass**: Users can circumvent blocking using VPNs
- **Legitimate Users**: May block legitimate traffic from blocked countries
- **Maintenance**: IP ranges change over time and need updates
- **False Positives**: Geolocation isn't 100% accurate
### Best Practices
1. **Whitelist Critical Services**: Don't block access to monitoring/health endpoints
2. **Regular Updates**: Update IP ranges regularly (automated with Ansible)
3. **Monitor Logs**: Watch for legitimate users being blocked
4. **Gradual Implementation**: Start with high-risk countries, expand cautiously
5. **Document Decisions**: Keep records of why specific countries are blocked
## Advanced Configuration
### Custom Block Message
Modify the response in `Caddyfile.j2`:
```caddy
respond @blocked_countries "Custom block message" 403
```
### Whitelist Specific IPs
Add to the country_block snippet:
```caddy
(country_block) {
@blocked_countries {
remote_ip {{ blocked_countries | join(' ') }}
not remote_ip 1.2.3.4 5.6.7.8/24 # Whitelist specific IPs
}
respond @blocked_countries "Access denied from your country" 403
}
```
### Log Blocked Requests
Add logging to track blocked requests:
```caddy
(country_block) {
@blocked_countries {
remote_ip {{ blocked_countries | join(' ') }}
}
log @blocked_countries {
output file /var/log/caddy/blocked_countries.log
format json
}
respond @blocked_countries "Access denied from your country" 403
}
```
## Data Sources
The script uses multiple data sources for IP ranges:
1. **Primary**: ipinfo.io free API
2. **Fallback**: Manually curated ranges for common countries
3. **Alternative**: Can be extended to use other sources like MaxMind, IP2Location
## License and Legal
- Ensure compliance with local laws regarding geographic blocking
- Some regions have regulations about access restrictions
- Consider accessibility requirements for your services
- Review terms of service for IP geolocation data providers

View File

@@ -1,6 +1,8 @@
--- ---
- name: Deploy Caddy service - name: Deploy Caddy service
block: block:
- name: Setup country blocking
ansible.builtin.include_tasks: country-blocking.yml
- name: Set Caddy directories - name: Set Caddy directories
ansible.builtin.set_fact: ansible.builtin.set_fact:
caddy_service_dir: "{{ ansible_env.HOME }}/services/caddy" caddy_service_dir: "{{ ansible_env.HOME }}/services/caddy"

View File

@@ -0,0 +1,52 @@
---
- name: Country blocking setup for Caddy
block:
- name: Ensure Python requests module is installed
ansible.builtin.pip:
name: requests
state: present
when: enable_country_blocking | default(false)
- name: Copy country blocking script
ansible.builtin.copy:
src: generate_country_blocks.py
dest: "{{ caddy_service_dir }}/generate_country_blocks.py"
mode: '0755'
when: enable_country_blocking | default(false)
- name: Generate country IP ranges
ansible.builtin.command:
cmd: "python3 {{ caddy_service_dir }}/generate_country_blocks.py {{ blocked_countries_codes | join(' ') }} --format=list"
register: country_ranges_result
when:
- enable_country_blocking | default(false)
- blocked_countries_codes | default([]) | length > 0
changed_when: false
- name: Set country IP ranges fact
ansible.builtin.set_fact:
blocked_countries: "{{ country_ranges_result.stdout.split('\n') | select('match', '^[0-9]') | list }}"
when:
- enable_country_blocking | default(false)
- country_ranges_result is defined
- country_ranges_result.stdout is defined
- name: Display blocked countries info
ansible.builtin.debug:
msg:
- "Country blocking enabled: {{ enable_country_blocking | default(false) }}"
- "Countries to block: {{ blocked_countries_codes | default([]) | join(', ') }}"
- "IP ranges found: {{ blocked_countries | default([]) | length }}"
when: enable_country_blocking | default(false)
- name: Fallback to empty list if no ranges generated
ansible.builtin.set_fact:
blocked_countries: []
when:
- enable_country_blocking | default(false)
- blocked_countries is not defined
tags:
- caddy
- security
- country-blocking