Files
dotfiles/config/ansible/files/generate_country_blocks.py
Menno van Leeuwen 3774ea6233
Some checks failed
Ansible Lint Check / check-ansible (push) Failing after 29s
Nix Format Check / check-format (push) Failing after 1m26s
Python Lint Check / check-python (push) Failing after 22s
Expand country blocking to more high-risk countries
- Add IN, VN, BR, TR, ID, TH, BD, PK, RO to blocked list
- Update alternative IP ranges for new countries in script
- Enhance documentation with rationale, risk assessment, and best practices
- Add test script for verifying country blocking functionality
- Improve Ansible tasks for dependency installation
2025-06-15 01:53:42 +02:00

221 lines
8.7 KiB
Python

#!/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',
'36.0.0.0/10', '39.0.0.0/11', '42.0.0.0/8', '58.0.0.0/9',
'59.32.0.0/11', '60.0.0.0/8', '61.0.0.0/10', '110.0.0.0/7',
'112.0.0.0/5', '120.0.0.0/6', '124.0.0.0/7', '180.76.0.0/16'
],
'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',
'31.31.192.0/19', '37.9.64.0/18', '37.18.0.0/16', '46.17.40.0/21',
'77.88.0.0/18', '78.24.216.0/21', '79.104.0.0/14', '80.66.176.0/20',
'81.68.0.0/14', '82.138.140.0/22', '84.201.128.0/18', '85.143.192.0/18',
'91.103.0.0/17', '91.208.165.0/24', '94.181.160.0/20', '95.165.0.0/16'
],
'IN': [
'1.22.0.0/15', '14.96.0.0/11', '27.34.0.0/15', '36.255.0.0/16',
'39.32.0.0/11', '49.248.0.0/13', '58.84.0.0/15', '59.144.0.0/12',
'103.21.58.0/24', '106.51.0.0/16', '110.224.0.0/11', '117.192.0.0/10',
'122.160.0.0/12', '125.16.0.0/12', '150.129.0.0/16', '157.32.0.0/11',
'163.47.0.0/16', '180.179.0.0/16', '182.64.0.0/10', '183.82.0.0/15'
],
'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',
'5.144.128.0/17', '31.2.128.0/17', '37.156.0.0/14', '37.191.0.0/16',
'46.32.0.0/11', '78.39.192.0/18', '79.175.128.0/18', '80.191.0.0/16',
'81.91.128.0/17', '82.99.192.0/18', '85.15.0.0/16', '91.98.0.0/15'
],
'VN': [
'14.160.0.0/11', '27.64.0.0/10', '42.112.0.0/13', '45.117.0.0/16',
'103.21.148.0/22', '103.56.156.0/22', '113.160.0.0/11', '115.73.0.0/16',
'116.96.0.0/10', '118.69.0.0/16', '171.224.0.0/11', '203.113.128.0/18'
],
'BR': [
'138.0.0.0/8', '177.0.0.0/8', '179.0.0.0/8', '186.192.0.0/12',
'189.0.0.0/8', '191.0.0.0/8', '200.128.0.0/9', '201.0.0.0/8'
],
'TR': [
'31.145.0.0/16', '46.1.0.0/16', '78.160.0.0/11', '85.111.0.0/16',
'88.229.0.0/16', '94.54.0.0/15', '176.88.0.0/13', '185.2.0.0/16',
'188.119.0.0/16', '212.156.0.0/14'
],
'ID': [
'36.64.0.0/10', '43.218.0.0/15', '103.10.0.0/15', '103.56.0.0/14',
'114.4.0.0/14', '118.96.0.0/11', '125.160.0.0/11', '139.228.0.0/15',
'182.253.0.0/16', '202.43.0.0/16'
],
'TH': [
'27.130.0.0/15', '49.228.0.0/14', '58.8.0.0/14', '101.51.0.0/16',
'103.10.228.0/22', '110.164.0.0/14', '124.120.0.0/13', '171.96.0.0/13',
'182.232.0.0/14', '202.28.0.0/14'
],
'BD': [
'27.147.0.0/16', '43.245.8.0/22', '103.4.92.0/22', '103.15.200.0/22',
'114.130.0.0/16', '118.179.0.0/16', '119.40.64.0/18', '180.211.192.0/18',
'202.40.32.0/19', '203.83.160.0/19'
],
'PK': [
'39.32.0.0/11', '58.27.0.0/16', '103.11.60.0/22', '110.93.192.0/18',
'115.42.0.0/16', '119.63.128.0/20', '175.107.0.0/16', '182.176.0.0/12',
'202.47.96.0/19', '203.124.0.0/14'
],
'RO': [
'31.13.224.0/19', '37.233.0.0/16', '46.19.0.0/16', '79.114.0.0/15',
'86.35.0.0/16', '89.136.0.0/13', '94.177.0.0/16', '109.166.0.0/15',
'188.24.0.0/13', '212.146.0.0/15'
],
'BY': [
'31.130.176.0/20', '37.17.128.0/18', '46.16.104.0/21', '78.108.176.0/20',
'85.113.0.0/16', '86.57.0.0/17', '93.84.64.0/18', '178.120.0.0/13',
'178.172.160.0/19', '212.98.160.0/19'
]
}
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()