Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions.
382 lines
15 KiB
PHP
382 lines
15 KiB
PHP
<?php
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class Argus_GeoIP_RIR {
|
|
|
|
const SOURCES = array(
|
|
'arin' => 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
|
|
'ripencc' => 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
|
|
'apnic' => 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
|
|
'lacnic' => 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
|
|
'afrinic' => 'https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest',
|
|
);
|
|
|
|
const TIME_BUDGET_SECONDS = 40;
|
|
|
|
const CURSOR_OPTION = 'argus_wpd_geoip_cursor';
|
|
const BUILD_PATH_OPTION = 'argus_wpd_geoip_build_path';
|
|
const LAST_REFRESH_OPTION = 'argus_wpd_geoip_last_refresh';
|
|
const RANGE_COUNT_OPTION = 'argus_wpd_geoip_range_count';
|
|
const LAST_ERROR_OPTION = 'argus_wpd_geoip_last_error';
|
|
const CORRUPT_OPTION = 'argus_wpd_geoip_corrupt_detected';
|
|
|
|
const HEADER_MAGIC = 'AWG1';
|
|
const HEADER_LEN = 16;
|
|
const FORMAT_VERSION = 1;
|
|
|
|
protected static $file_cache = null;
|
|
|
|
public static function init() {
|
|
add_filter( 'argus_wpd_resolve_country', array( __CLASS__, 'resolve' ), 10, 2 );
|
|
}
|
|
|
|
public static function resolve( $result, $ip ) {
|
|
if ( null !== $result ) {
|
|
return $result;
|
|
}
|
|
if ( ! Argus_Settings::get( 'geoip_rir_enabled', true ) ) {
|
|
return null;
|
|
}
|
|
$code = self::lookup_ipv4( $ip );
|
|
if ( ! $code ) {
|
|
return null;
|
|
}
|
|
return array( 'label' => self::country_name( $code ), 'code' => $code );
|
|
}
|
|
|
|
public static function lookup_ipv4( $ip ) {
|
|
$n = self::ipv4_to_uint( $ip );
|
|
if ( false === $n ) {
|
|
return null;
|
|
}
|
|
|
|
if ( null === self::$file_cache ) {
|
|
self::$file_cache = self::read_and_verify();
|
|
}
|
|
if ( '' === self::$file_cache ) {
|
|
return null;
|
|
}
|
|
|
|
$count = (int) ( strlen( self::$file_cache ) / 10 );
|
|
$lo = 0;
|
|
$hi = $count - 1;
|
|
|
|
while ( $lo <= $hi ) {
|
|
$mid = intdiv( $lo + $hi, 2 );
|
|
$rec = unpack( 'Nstart/Nend/a2cc', substr( self::$file_cache, $mid * 10, 10 ) );
|
|
if ( $n < $rec['start'] ) {
|
|
$hi = $mid - 1;
|
|
} elseif ( $n > $rec['end'] ) {
|
|
$lo = $mid + 1;
|
|
} else {
|
|
return $rec['cc'];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
protected static function read_and_verify() {
|
|
$path = self::compiled_file();
|
|
if ( ! file_exists( $path ) ) {
|
|
return '';
|
|
}
|
|
$raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $raw || '' === $raw ) {
|
|
return '';
|
|
}
|
|
|
|
if ( strlen( $raw ) < self::HEADER_LEN || self::HEADER_MAGIC !== substr( $raw, 0, 4 ) ) {
|
|
return $raw;
|
|
}
|
|
|
|
$header = unpack( 'a4magic/Cversion/Ncount/Ncrc', substr( $raw, 0, self::HEADER_LEN ) );
|
|
$records = substr( $raw, self::HEADER_LEN );
|
|
|
|
if ( (int) $header['count'] !== (int) ( strlen( $records ) / 10 ) || (int) $header['crc'] !== crc32( $records ) ) {
|
|
|
|
if ( ! get_option( self::CORRUPT_OPTION ) ) {
|
|
update_option( self::CORRUPT_OPTION, true, false );
|
|
Argus_Events::record( 'geoip_corrupt', 'medium', 'GeoIP database file failed its integrity check and was ignored until the next scheduled refresh.', array() );
|
|
}
|
|
return '';
|
|
}
|
|
|
|
if ( get_option( self::CORRUPT_OPTION ) ) {
|
|
delete_option( self::CORRUPT_OPTION );
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
public static function run_daily_refresh() {
|
|
$started = microtime( true );
|
|
$sources = array_keys( self::SOURCES );
|
|
$cursor = (int) get_option( self::CURSOR_OPTION, 0 );
|
|
|
|
$build_path = get_option( self::BUILD_PATH_OPTION, '' );
|
|
if ( 0 === $cursor || '' === $build_path || ! file_exists( $build_path ) ) {
|
|
|
|
foreach ( glob( trailingslashit( self::data_dir() ) . 'geoip-build-*.raw' ) ?: array() as $stray ) {
|
|
@unlink( $stray ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
}
|
|
$build_path = trailingslashit( self::data_dir() ) . 'geoip-build-' . wp_generate_password( 8, false ) . '.raw';
|
|
update_option( self::BUILD_PATH_OPTION, $build_path, false );
|
|
$cursor = 0;
|
|
}
|
|
|
|
$handle = fopen( $build_path, 'ab' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( ! $handle ) {
|
|
self::record_outcome( false, __( 'Could not open a working file in uploads to build the GeoIP database.', 'argus-wordpress-defence' ) );
|
|
return;
|
|
}
|
|
|
|
$any_failure = false;
|
|
while ( $cursor < count( $sources ) ) {
|
|
if ( microtime( true ) - $started > self::TIME_BUDGET_SECONDS ) {
|
|
break;
|
|
}
|
|
$key = $sources[ $cursor ];
|
|
if ( ! self::fetch_and_append( $key, self::SOURCES[ $key ], $handle ) ) {
|
|
$any_failure = true;
|
|
}
|
|
$cursor++;
|
|
update_option( self::CURSOR_OPTION, $cursor, false );
|
|
}
|
|
|
|
fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
if ( $cursor < count( $sources ) ) {
|
|
return;
|
|
}
|
|
|
|
$swap_result = self::compile_and_swap( $build_path, $any_failure );
|
|
@unlink( $build_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
|
|
update_option( self::BUILD_PATH_OPTION, '', false );
|
|
update_option( self::CURSOR_OPTION, 0, false );
|
|
update_option( self::RANGE_COUNT_OPTION, $swap_result['count'], false );
|
|
|
|
if ( $swap_result['swapped'] ) {
|
|
|
|
update_option( self::LAST_REFRESH_OPTION, current_time( 'mysql', true ), false );
|
|
}
|
|
|
|
if ( $any_failure && ! $swap_result['swapped'] ) {
|
|
|
|
self::record_outcome(
|
|
false,
|
|
sprintf(
|
|
|
|
__( 'One or more RIR sources could not be reached this cycle. Kept the existing %d-range database rather than replacing it with an incomplete one; will retry on the next scheduled refresh.', 'argus-wordpress-defence' ),
|
|
$swap_result['count']
|
|
)
|
|
);
|
|
} else {
|
|
self::record_outcome(
|
|
! $any_failure,
|
|
$any_failure
|
|
? sprintf(
|
|
|
|
__( 'GeoIP database refreshed with %d IPv4 ranges, but one or more RIR sources could not be reached this cycle -- those ranges are missing until a future refresh succeeds.', 'argus-wordpress-defence' ),
|
|
$swap_result['count']
|
|
)
|
|
: sprintf(
|
|
|
|
__( 'GeoIP database refreshed: %d IPv4 ranges loaded from 5 RIR sources.', 'argus-wordpress-defence' ),
|
|
$swap_result['count']
|
|
)
|
|
);
|
|
}
|
|
|
|
self::$file_cache = null;
|
|
}
|
|
|
|
protected static function fetch_and_append( $source_key, $url, $handle ) {
|
|
|
|
$tmp = tempnam( sys_get_temp_dir(), 'argus-geoip-' . $source_key . '-' );
|
|
if ( ! $tmp ) {
|
|
return false;
|
|
}
|
|
|
|
$response = wp_remote_get( $url, array( 'timeout' => 30, 'stream' => true, 'filename' => $tmp ) );
|
|
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
|
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
return false;
|
|
}
|
|
|
|
$fh = fopen( $tmp, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( ! $fh ) {
|
|
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
return false;
|
|
}
|
|
|
|
while ( false !== ( $line = fgets( $fh ) ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition
|
|
$line = trim( $line );
|
|
if ( '' === $line || '#' === $line[0] ) {
|
|
continue;
|
|
}
|
|
|
|
$parts = explode( '|', $line );
|
|
if ( count( $parts ) < 7 ) {
|
|
continue;
|
|
}
|
|
if ( 'ipv4' !== $parts[2] ) {
|
|
continue;
|
|
}
|
|
if ( ! in_array( $parts[6], array( 'allocated', 'assigned' ), true ) ) {
|
|
continue;
|
|
}
|
|
$cc = strtoupper( $parts[1] );
|
|
if ( 2 !== strlen( $cc ) || ! ctype_alpha( $cc ) ) {
|
|
continue;
|
|
}
|
|
$start = self::ipv4_to_uint( $parts[3] );
|
|
$count = (int) $parts[4];
|
|
if ( false === $start || $count <= 0 ) {
|
|
continue;
|
|
}
|
|
$end = min( $start + $count - 1, 4294967295 );
|
|
|
|
fwrite( $handle, pack( 'NNa2', $start, $end, $cc ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
}
|
|
|
|
fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
|
|
return true;
|
|
}
|
|
|
|
protected static function compile_and_swap( $build_path, $any_failure = false ) {
|
|
$data = file_get_contents( $build_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( false === $data || '' === $data ) {
|
|
return array( 'count' => self::count_from_file( self::compiled_file() ), 'swapped' => false );
|
|
}
|
|
|
|
$existing_file = self::compiled_file();
|
|
if ( $any_failure && file_exists( $existing_file ) ) {
|
|
return array( 'count' => self::count_from_file( $existing_file ), 'swapped' => false );
|
|
}
|
|
|
|
$records = str_split( $data, 10 );
|
|
usort(
|
|
$records,
|
|
function ( $a, $b ) {
|
|
return substr_compare( $a, $b, 0, 4 );
|
|
}
|
|
);
|
|
|
|
$record_bytes = implode( '', $records );
|
|
|
|
$header = pack( 'a4CNNa3', self::HEADER_MAGIC, self::FORMAT_VERSION, count( $records ), crc32( $record_bytes ), '' );
|
|
|
|
$tmp_final = trailingslashit( self::data_dir() ) . 'geoip-v4.bin.tmp';
|
|
file_put_contents( $tmp_final, $header . $record_bytes ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
rename( $tmp_final, $existing_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
|
|
delete_option( self::CORRUPT_OPTION );
|
|
|
|
return array( 'count' => count( $records ), 'swapped' => true );
|
|
}
|
|
|
|
protected static function count_from_file( $path ) {
|
|
if ( ! file_exists( $path ) ) {
|
|
return 0;
|
|
}
|
|
$head = file_get_contents( $path, false, null, 0, self::HEADER_LEN ); // phpcs:ignore WordPress.WP.AlternativeFunctions
|
|
if ( is_string( $head ) && strlen( $head ) === self::HEADER_LEN && self::HEADER_MAGIC === substr( $head, 0, 4 ) ) {
|
|
$header = unpack( 'a4magic/Cversion/Ncount', $head );
|
|
return (int) $header['count'];
|
|
}
|
|
return (int) ( filesize( $path ) / 10 );
|
|
}
|
|
|
|
protected static function record_outcome( $success, $message ) {
|
|
update_option( self::LAST_ERROR_OPTION, $success ? '' : $message, false );
|
|
Argus_Events::record( 'geoip_refresh', $success ? 'info' : 'medium', $message, array( 'success' => $success ) );
|
|
}
|
|
|
|
protected static function data_dir() {
|
|
$uploads = wp_get_upload_dir();
|
|
$dir = trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data';
|
|
if ( ! is_dir( $dir ) ) {
|
|
wp_mkdir_p( $dir );
|
|
}
|
|
return $dir;
|
|
}
|
|
|
|
protected static function compiled_file() {
|
|
return trailingslashit( self::data_dir() ) . 'geoip-v4.bin';
|
|
}
|
|
|
|
public static function ipv4_to_uint( $ip ) {
|
|
if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
|
|
return false;
|
|
}
|
|
$parts = explode( '.', $ip );
|
|
if ( 4 !== count( $parts ) ) {
|
|
return false;
|
|
}
|
|
return ( (int) $parts[0] << 24 ) + ( (int) $parts[1] << 16 ) + ( (int) $parts[2] << 8 ) + (int) $parts[3];
|
|
}
|
|
|
|
public static function status() {
|
|
return array(
|
|
'last_refresh' => get_option( self::LAST_REFRESH_OPTION, '' ) ?: null,
|
|
'range_count' => (int) get_option( self::RANGE_COUNT_OPTION, 0 ),
|
|
'last_error' => get_option( self::LAST_ERROR_OPTION, '' ),
|
|
'cursor' => (int) get_option( self::CURSOR_OPTION, 0 ),
|
|
);
|
|
}
|
|
|
|
public static function country_name( $code ) {
|
|
static $names = null;
|
|
if ( null === $names ) {
|
|
$names = array(
|
|
'AL' => 'Albania', 'AD' => 'Andorra', 'AT' => 'Austria', 'BY' => 'Belarus', 'BE' => 'Belgium',
|
|
'BA' => 'Bosnia and Herzegovina', 'BG' => 'Bulgaria', 'HR' => 'Croatia', 'CY' => 'Cyprus', 'CZ' => 'Czech Republic',
|
|
'DK' => 'Denmark', 'EE' => 'Estonia', 'FI' => 'Finland', 'FR' => 'France', 'DE' => 'Germany',
|
|
'GR' => 'Greece', 'HU' => 'Hungary', 'IS' => 'Iceland', 'IE' => 'Ireland', 'IT' => 'Italy',
|
|
'XK' => 'Kosovo', 'LV' => 'Latvia', 'LI' => 'Liechtenstein', 'LT' => 'Lithuania', 'LU' => 'Luxembourg',
|
|
'MT' => 'Malta', 'MD' => 'Moldova', 'MC' => 'Monaco', 'ME' => 'Montenegro', 'NL' => 'Netherlands',
|
|
'MK' => 'North Macedonia', 'NO' => 'Norway', 'PL' => 'Poland', 'PT' => 'Portugal', 'RO' => 'Romania',
|
|
'RU' => 'Russia', 'SM' => 'San Marino', 'RS' => 'Serbia', 'SK' => 'Slovakia', 'SI' => 'Slovenia',
|
|
'ES' => 'Spain', 'SE' => 'Sweden', 'CH' => 'Switzerland', 'UA' => 'Ukraine', 'GB' => 'United Kingdom',
|
|
'VA' => 'Vatican City', 'AF' => 'Afghanistan', 'AM' => 'Armenia', 'AZ' => 'Azerbaijan', 'BH' => 'Bahrain',
|
|
'BD' => 'Bangladesh', 'BT' => 'Bhutan', 'BN' => 'Brunei', 'KH' => 'Cambodia', 'CN' => 'China',
|
|
'GE' => 'Georgia', 'IN' => 'India', 'ID' => 'Indonesia', 'IR' => 'Iran', 'IQ' => 'Iraq',
|
|
'IL' => 'Israel', 'JP' => 'Japan', 'JO' => 'Jordan', 'KZ' => 'Kazakhstan', 'KW' => 'Kuwait',
|
|
'KG' => 'Kyrgyzstan', 'LA' => 'Laos', 'LB' => 'Lebanon', 'MY' => 'Malaysia', 'MV' => 'Maldives',
|
|
'MN' => 'Mongolia', 'MM' => 'Myanmar', 'NP' => 'Nepal', 'KP' => 'North Korea', 'OM' => 'Oman',
|
|
'PK' => 'Pakistan', 'PS' => 'Palestine', 'PH' => 'Philippines', 'QA' => 'Qatar', 'SA' => 'Saudi Arabia',
|
|
'SG' => 'Singapore', 'KR' => 'South Korea', 'LK' => 'Sri Lanka', 'SY' => 'Syria', 'TW' => 'Taiwan',
|
|
'TJ' => 'Tajikistan', 'TH' => 'Thailand', 'TL' => 'Timor-Leste', 'TR' => 'Turkey', 'TM' => 'Turkmenistan',
|
|
'AE' => 'United Arab Emirates', 'UZ' => 'Uzbekistan', 'VN' => 'Vietnam', 'YE' => 'Yemen', 'AG' => 'Antigua and Barbuda',
|
|
'AR' => 'Argentina', 'BS' => 'Bahamas', 'BB' => 'Barbados', 'BZ' => 'Belize', 'BO' => 'Bolivia',
|
|
'BR' => 'Brazil', 'CA' => 'Canada', 'CL' => 'Chile', 'CO' => 'Colombia', 'CR' => 'Costa Rica',
|
|
'CU' => 'Cuba', 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'EC' => 'Ecuador', 'SV' => 'El Salvador',
|
|
'GD' => 'Grenada', 'GT' => 'Guatemala', 'GY' => 'Guyana', 'HT' => 'Haiti', 'HN' => 'Honduras',
|
|
'JM' => 'Jamaica', 'MX' => 'Mexico', 'NI' => 'Nicaragua', 'PA' => 'Panama', 'PY' => 'Paraguay',
|
|
'PE' => 'Peru', 'KN' => 'Saint Kitts and Nevis', 'LC' => 'Saint Lucia', 'VC' => 'Saint Vincent and the Grenadines', 'SR' => 'Suriname',
|
|
'TT' => 'Trinidad and Tobago', 'US' => 'United States', 'UY' => 'Uruguay', 'VE' => 'Venezuela', 'DZ' => 'Algeria',
|
|
'AO' => 'Angola', 'BJ' => 'Benin', 'BW' => 'Botswana', 'BF' => 'Burkina Faso', 'BI' => 'Burundi',
|
|
'CM' => 'Cameroon', 'CV' => 'Cape Verde', 'CF' => 'Central African Republic', 'TD' => 'Chad', 'KM' => 'Comoros',
|
|
'CG' => 'Republic of the Congo', 'CD' => 'Democratic Republic of the Congo', 'CI' => 'Ivory Coast', 'DJ' => 'Djibouti', 'EG' => 'Egypt',
|
|
'GQ' => 'Equatorial Guinea', 'ER' => 'Eritrea', 'ET' => 'Ethiopia', 'GA' => 'Gabon', 'GM' => 'Gambia',
|
|
'GH' => 'Ghana', 'GN' => 'Guinea', 'GW' => 'Guinea-Bissau', 'KE' => 'Kenya', 'LS' => 'Lesotho',
|
|
'LR' => 'Liberia', 'LY' => 'Libya', 'MG' => 'Madagascar', 'MW' => 'Malawi', 'ML' => 'Mali',
|
|
'MR' => 'Mauritania', 'MU' => 'Mauritius', 'MA' => 'Morocco', 'MZ' => 'Mozambique', 'NA' => 'Namibia',
|
|
'NE' => 'Niger', 'NG' => 'Nigeria', 'RW' => 'Rwanda', 'ST' => 'Sao Tome and Principe', 'SN' => 'Senegal',
|
|
'SL' => 'Sierra Leone', 'SO' => 'Somalia', 'ZA' => 'South Africa', 'SS' => 'South Sudan', 'SD' => 'Sudan',
|
|
'SZ' => 'Eswatini', 'TZ' => 'Tanzania', 'TG' => 'Togo', 'TN' => 'Tunisia', 'UG' => 'Uganda',
|
|
'ZM' => 'Zambia', 'ZW' => 'Zimbabwe', 'AU' => 'Australia', 'FJ' => 'Fiji', 'KI' => 'Kiribati',
|
|
'MH' => 'Marshall Islands', 'FM' => 'Micronesia', 'NR' => 'Nauru', 'NZ' => 'New Zealand', 'PW' => 'Palau',
|
|
'PG' => 'Papua New Guinea', 'WS' => 'Samoa', 'SB' => 'Solomon Islands', 'TO' => 'Tonga', 'TV' => 'Tuvalu',
|
|
'VU' => 'Vanuatu',
|
|
);
|
|
}
|
|
return $names[ $code ] ?? $code;
|
|
}
|
|
}
|