Mike.MN
Full Stack PHP Developer / Systems Administrator / DevOps
Minneapolis, Minnesota, USA|

php-blockip

Created: 2012
Language: PHP, SQL
Software: Memcached

This script works in concert with projects/dnsrbl and the Block List on projects/proxyadmin-cms. This will effectively block unwanted visitors from your site, redirecting them harmlessly to another site of your choosing (google.com). Script is self contained -- only 1 file, just add the include('blockip.php'); on top of any PHP page, and that page becomes access controlled. Useful for signup pages, contact forms, or online forums, it will keep away the worst spammers.

Features

  • Self contained only 1 file
  • Web based admin page to manually add or remove ip addresses
  • Simple remote HTTP API for adding or removing ip addresses programmatically
  • Command line interface for adding or removing ip addresses while you're logged in to the server
  • Queries external DNS RBL Realtime Blackhole List
  • Queries internal sqlite blocklist
  • Previous lookups cached in memory for maximum pageload performance. Small delay (dns+sql query) only on the first lookup.
TODO
  • IPv6 Support
  • Add IP blocks larger than /24
  • Support for more than 1 RBL list
  • Granular Multi-RBL matches 127.0.0.1, 127.0.0.2, 127.0.0.4, 127.0.0.8, etc

$config['memcache']['enabled'] = true;
$config['memcache']['host'] = 'localhost';
$config['memcache']['port'] = 11211;
$config['memcache']['keyprefix'] = 'blockip_';
$config['memcache']['blocked_expiration'] = 5; // 3600
$config['memcache']['passed_expiration'] = 5;  // 3600
$config['memcache']['splay'] = 0;

$config['myredirect'] = 'https://www.google.com';
$config['dbfile'] = '/tmp/htaccess.sqlite';
# only allow HTTP API requests from these hosts
$config['api_whitelist'] = ['1.2.3.4', '5.6.7.8'];

/*
    Cli usage:
        $ php blockip.php addip 127.0.0.1/32 "the reason in quotes"
        $ php blockip.php delip 127.0.0.1/32

    HTTP API usage:
        POST /blockip.php
            action = 
            ipcidr = 127.0.0.1/24
            reason = reason in quotes
        curl -v -d action=addip -d ipcidr=1.2.3.4/32 -d reason="the reason" http://site/blockip.php
        curl -v -d action=delip -d ipcidr=1.2.3.4/32 -d reason=null http://site/blockip.php

    PHP usage:
        include('blockip.php');

    // All in one script for IP blocking.
*/

// load classes
$memcache = new StatsCache($config);
$memcache->addServer($config['memcache']['host'], $config['memcache']['port']);
$ipblock = new ipblock($memcache, $config);

// cli program
if ( php_sapi_name() == 'cli' ) {
    try {
        $db = new PDO('sqlite:' .$config['dbfile']);
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    } catch(PDOException $e) {
        echo $e->getMessage();
    }

    // reinitialize this with the db that we just loaded. only want to load the db when it is actually needed
    $ipblock = new ipblock($memcache, $config, $db);

    // php index.php addip 127.0.0.1/32 "the reason in quotes"
    if ((!empty($argv[1]) && !empty($argv[2])) && $argv[1] == 'addip' && !empty($argv[2])) {
        $ip = $argv[2];
        $reason = $argv[3];

        try {
            // add ip and reason to sqlite db
            $ipblock->addip_sqlite($ip, $reason);

            // now clear the memcache
            $v4 = explode('/', $ip);
            $ipblock->clearIP($v4[0]);
        } catch(PDOException $e) {
            echo $e->getMessage();
        }

    }

    // php index.php delip 127.0.0.1/32
    if ((!empty($argv[1]) && !empty($argv[2])) && $argv[1] == 'delip' && !empty($argv[2])) {
        $ip = $argv[2];

        try {
            // del from sqlite db
            $ipblock->delip_sqlite($ip);

            // now clear the memcache
            $v4 = explode('/', $ip);
            $ipblock->clearIP($v4[0]);
        } catch (PDOException $e) {
            echo $e->getMessage();
        }
    }

die('Ended cli program' .PHP_EOL);
} // end of cli



// if this is an external HTTP request for the api
if (__FILE__ == $_SERVER['SCRIPT_FILENAME']) {
    // check if remote user is in the api whitelist
    $ip = $_SERVER['REMOTE_ADDR'];
    foreach ($config['api_whitelist'] AS $k => $whiteip) {
        if ($ip == $whiteip) { $ok = true; break; } else { $ok = false; }
    }
    if ($ok == false) { die('Error: Your IP is wrong'); }
    if (!$_POST) { die('Error: Wrong method'); }

    // get POST data
    $action = trim($_POST['action']);
    $ipcidr = trim($_POST['ipcidr']);
    $reason = trim($_POST['reason']);

    // all fields are required
    if (empty($action) || empty($ipcidr) || empty($reason)) { die('Error: Missing something'); }

    // check if its a valid action
    if ($action != 'addip' && $action != 'delip') { die('Error: Action not allowed'); }

    // check that submitted ipv4 is valid
    if (strpos($ipcidr, '/') === false) { die('Error: Missing cidr'); }
    list($addr, $cidr) = explode('/', $ipcidr);
    if (!$ipblock->isvalid_ipv4($addr)) { die('Error: Notvalid ipv4'); }

    // check that cidr is valid
    if ($cidr != 24 && $cidr != 32) { die('Error: Only /32 or /24 cidr'); }

    // check that reason isnt too long, this is an imaginary limit
    if (strlen($reason) > 140) { die('Error: Reason too long'); }


    // checks passed, load the db
    try {
        $db = new PDO('sqlite:' .$config['dbfile']);
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    } catch(PDOException $e) {
        echo $e->getMessage();
    }

    // reinitialize the class with the db, only load the db if required
    $ipblock = new ipblock($memcache, $config, $db);


    // if we got to this part then the data is valid
    if ($action == 'addip') {
        try {
            // add ip and reason to sqlite db
            $ipblock->addip_sqlite($ipcidr, $reason);

            // now clear the memcache
            $v4 = explode('/', $ip);
            $ipblock->clearIP($v4[0]);
        } catch (PDOException $e) {
            echo $e->getMessage();
        }

        echo "Success added ip";

    } elseif ($action == 'delip') {
        try {
            // delete ip from sqlite db
            $ipblock->delip_sqlite($ipcidr);

            // now clear the memcache
            $v4 = explode('/', $ip);
            $ipblock->clearIP($v4[0]);
        } catch (PDOException $e) {
            echo $e->getMessage();
        }

        echo "Success deleted ip";

    } else {
        die('Error: error');
    }

} // end of external request





// main program, used when you reqire('blockip.php');

// get ip address of incoming client
$ip = $_SERVER['REMOTE_ADDR'];
// XXX skips to the end for ipv6 addresses
if (strpos($ip, ':')) goto end; # yolo
// initial quick checks of the tmp keys in memcached
if ($ipblock->blocked_tmpexists($ip)) { $ipblock->myredirect($ip); }
// if key isnt already allowed
if (!$ipblock->passed_tmpexists($ip)) {
        // check rbl
        $dns = @dns_get_record($ipblock->revIP($ip) .'.rbl.example.com', DNS_A);

        // check htaccess db
        $isip = $ipblock->checkIP($ip);

        if (!empty($dns) || !empty($isip)) {
            // user is in htaccess, block
            $ipblock->blockIP($ip);
        } else {
            // user is not in rbl or htaccess, make tmp key to allow entry with skipping checks
            $ipblock->passed_mktmp($ip);
        }
} else {
    // ip was found in the passed db, user is ok, do nothing
}

// XXX skips to the end for ipv6 addresses
end:







class ipblock {
    function __construct($memcache, $config, $db=false) {
        $this->memcache = $memcache;
        $this->config = $config;
        $this->db = $db;
    }

    function myredirect() {
        header('Location: ' .$this->config['myredirect'], true, 303);
        die();
    }

    function addip_sqlite ($ipcidr, $reason) {
        if (empty($this->db)) { die('Error: forgot to load the db'); }

        $insert = 'INSERT INTO `htaccess` (ip, reason) VALUES (:ip, :reason)';
        $stmt = $this->db->prepare($insert);

        $stmt->bindParam(':ip', $ipcidr);
        $stmt->bindParam(':reason', $reason);
        return $stmt->execute();
    }
    function delip_sqlite ($ipcidr) {
        if (empty($this->db)) { die('Error: forgot to load the db'); }
        $delete = 'DELETE FROM `htaccess` WHERE ip = :ip';
        $stmt = $this->db->prepare($delete);
        $stmt->bindParam(':ip', $ipcidr);
        return $stmt->execute();
    }

    function blockIP ($ip) {
        $this->blocked_mktmp($ip);
        $this->myredirect();
    }

    function clearIP($ip) {
        $this->memcache->delete('passed_'.$ip);
        $this->memcache->delete('blocked_'.$ip);
        return true;
    }

    function checkIP($ip) {
        try {
            // this is inside checkIP function because it is infrequently used. having it here saves cpu operations.
            $db = new PDO("sqlite:" .$this->config['dbfile']);
            $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        } catch(PDOException $e) {
            echo $e->getMessage();
        }

        try {
            // check the db for the first 3 octets. TODO: fix this limitation during ipv6 rewrite
            $iparr = explode('.', $ip);
            $stmt = $db->prepare('SELECT * FROM htaccess WHERE ip LIKE :ip');
            $myip = $iparr[0] .'.' .$iparr[1] .'.' .$iparr[2] .'.%';
            $stmt->bindValue(':ip', $myip, PDO::PARAM_STR);
            $stmt->execute();
            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

            if (!empty($rows)) {
                $match = $rows[0]['ip'];
                list($addr, $cidr) = explode('/', $match);
                echo "<br />match_cidr($ip, $addr/$cidr<br />";
                return $this->match_cidr($ip, "$addr/$cidr");
            } else {
                return false;
            }
        } catch(PDOException $e) {
            echo $e->getMessage();
        }
    }
    function match_cidr($addr, $cidr) {
        $output = false;

        if (is_array($cidr)) {
            foreach ($cidr as $cidrlet) {
                if ($this->match_cidr($addr, $cidrlet)) {
                    $output = true;
                    break;
                }
            }
        } else {
            @list($ip, $mask) = explode('/', $cidr);
            if (!$mask) $mask = 32;
            $mask = pow(2,32) - pow(2, (32 - $mask));
            echo "<br />$mask<br />";
            $output = ((ip2long($addr) & $mask) == (ip2long($ip) & $mask));
            echo "<br />ip2long($addr) & $mask " .(ip2long($addr) & $mask);
            echo "<br />ip2long($ip) & $mask ". (ip2long($ip) & $mask) ."<br />";
        }
        return $output;
    }

    function passed_mktmp ($ip) {
        $this->memcache->delete('blocked'.$ip);
        return $this->memcache->set('passed_'.$ip, $ip, $this->config['memcache']['passed_expiration']);
    }
    function blocked_mktmp ($ip) {
        $this->memcache->delete('passed'.$ip);
        return $this->memcache->set('blocked_'.$ip, $ip, $this->config['memcache']['blocked_expiration']);
    }

    function passed_tmpexists ($ip) {
        return $this->memcache->get('passed_'.$ip);
    }
    function blocked_tmpexists ($ip) {
        return $this->memcache->get('blocked_'.$ip);
    }

    function revIP($ip) {
        $forward = explode('.', $ip);
        $reverse = array_reverse($forward);
        return implode('.', $reverse);
    }

    // Determine if an IP address is reserved by RFC 1918.
    function is_rfc1918($addr) {
        return match_cidr($addr, ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]);
    }

    function isvalid_ipv4 ($ip) {
        return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
    }

} // end of class ipblock