<?php
/**
 * CIDR class helps validate an ip address against a CIDR ip range.
 *
 * This class has not been optimized, but works comparing an ip address
 * with a wild card address or a CIDR ip range.
 *
 * @package     CIDR
 * @version     1.0
 * @author      Frank Forte
 * @copyright   Copyright (c) 2015 Frank Forte
 * @license     BSD-3 License https://opensource.org/licenses/BSD-3-Clause
 */
class CIDR{

	/**
	 * compare an IPv4 or IPv6 address with a CIDR address or range
	 *
	 * @param  string  $address a valid IPv6 address
	 * @param  string  $subnet a valid IPv6 subnet[/mask]
	 * @return boolean	whether $address is within the ip range made up  of the subnet and mask
	 */
	public static function match($ip, $cidr){

		// make sure we compare ip addresses as case insensitive
		$ip = strtolower($ip);
		$cidr = strtolower($cidr);

		// comparing exact ip?
		if($ip == $cidr){ return true; }


		if(strpos($cidr,'/') !== false){
			list($subnet, $mask) = explode('/', $cidr);
		} else {
			$subnet = $cidr;
			$mask = '';
		}

		// validate ips and shorten them
		if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {

			$ipVersion = 'v4';
			// shorten ip
			$ip = preg_replace( '/^(.*\.|.*:)?0+([1-9])/','$1$2',$ip );

		} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {

			$ipVersion = 'v6';
			// shorten ip
			$ip = self::IPv6_compress($ip);

		} else {

			return false; // invalid ip
		}

		// shorten cidr and subnet
		if(strpos($subnet,':') === false)
		{
			$subnet = preg_replace( '/^(.*\.|.*:)?0+([1-9])/','$1$2',$subnet );
		}
		else
		{
			$pos = strpos($subnet,'*');
			if($pos !== false)
			{
				$subnet = substr($subnet,0,$pos);
				$i = 0;
				$subnet = explode(':',$subnet);
				$size = $j = sizeof($subnet);
				while($j < 8){
					$subnet[] = '0';
					$j++;
					$i++;
				}
				$subnet = implode(':',$subnet);
				if(substr($subnet,-1) == ':'){
					$subnet .= '0';
				}
			}

			$subnet = self::IPv6_compress($subnet);

			if($pos !== false)
			{
				$subnet = explode(':',$subnet);
				$j = sizeof($subnet);
				while($j > $size)
				{
					array_pop($subnet);
					$j--;
				}
				$subnet = implode(':',$subnet).'*';
			}
		}

		// shortened cidr
		$cidr = ($mask ? $subnet.'/'.$mask : $subnet);

		// if $cidr is ipv6, convert $ip to ipv6 for easier comparison
		if(strpos($subnet,':') !== false && $ipVersion == 'v4') {

			$v6bits = array('0000','0000','0000','0000','0000','0000',$ip);

			$ip4parts = explode('.', $v6bits[count($v6bits)-1]);
			$ip6trans = sprintf("%02x%02x:%02x%02x", $ip4parts[0], $ip4parts[1], $ip4parts[2], $ip4parts[3]);
			$v6bits[count($v6bits)-1] = $ip6trans;

			$ip = implode(':', $v6bits);
			// shorten ip
			$ip = self::IPv6_compress($ip);

			$ipVersion = 'v6';
		}

		if($ip == $cidr)
		{
			return true;
		}

		// wildcard matching (easier since we already shortened or "canonicalized" ip and cidr above)
		$pos = strpos($cidr,'*');
		if($pos !== false)
		{
			if(substr($ip,0,$pos) == substr($cidr,0,$pos))
			{
				return true;
			}
			else
			{
				return false;
			}
		}

		switch ($ipVersion) {
			case 'v4':
				return self::IPv4Match($ip, $subnet, $mask);
				break;
			case 'v6':
				return self::IPv6Match($ip, $subnet, $mask);
				break;
		}
	}



	/**
	 * Check IPv6 address is within an IP range
	 *
	 * @param  string  $address a valid IPv6 address
	 * @param  string  $subnet a valid IPv6 subnet
	 * @param  string  $mask a valid IPv6 subnet mask
	 * @return boolean	whether $address is within the ip range made up  of the subnet and mask
	 */
	private static function IPv6Match($ip, $subnet, $mask)
	{
		$subnet = inet_pton($subnet);
		$ip = inet_pton($ip);

		// thanks to MW on http://stackoverflow.com/questions/7951061/matching-ipv6-address-to-a-cidr-subnet
		$binMask = str_repeat("f", $mask / 4);
		switch ($mask % 4) {
			case 0:
				break;
			case 1:
				$binMask .= "8";
				break;
			case 2:
				$binMask .= "c";
				break;
			case 3:
				$binMask .= "e";
				break;
		}
		$binMask = str_pad($binMask, 32, '0');
		$binMask = pack("H*", $binMask);


		return ($ip & $binMask) == $subnet;
	}

	/**
	 * Check IPv4 address is within an IP range
	 *
	 * @param  string  $address a valid IPv4 address
	 * @param  string  $subnet a valid IPv4 subnet
	 * @param  string  $mask a valid IPv4 subnet mask
	 * @return boolean	whether $address is within the ip rage made up  of the subnet and mask
	 */
	private static function IPv4Match($address, $subnet, $mask)
	{
		// credit goes to Sam on http://stackoverflow.com/questions/594112/matching-an-ip-to-a-cidr-mask-in-php5
		if ((ip2long($address) & ~((1 << (32 - $mask)) - 1)) == ip2long($subnet)) {
			return true;
		}

		return false;
	}

	/**
	 * Compress an IPv6 Address
	 *
	 * @param  string  $ip a valid IPv6 address or CIDR
	 * @return string  IPv6 ip address or CIDR in short form notation
	 */
	public static function IPv6_compress($ip)
	{
		$bits = explode('/',$ip); // in case this is a CIDR range

		// want to expand and re-compress in case we have "::" in different spots.
		$bits[0] = self::IPv6_expand($bits[0]);

		$bits[0] = inet_ntop(inet_pton($bits[0]));
		return strtolower(implode('/',$bits));
	}

	/**
	 * Expand an IPv6 Address
	 *
	 * @param  string  $ip a valid IPv6 address
	 * @return string  IPv6 ip address in long form notation
	 */
	public static function IPv6_expand($ip){

		$bits = explode('/',$ip); // in case this is a CIDR range

		// add missing components
		if (strpos($bits[0], '::') !== false) {
			$part = explode('::', $bits[0]);
			$part[0] = explode(':', $part[0]);
			$part[1] = explode(':', $part[1]);
			$missing = array();
			for ($i = 0; $i < (8 - (count($part[0]) + count($part[1]))); $i++)
				array_push($missing, '0000');
			$missing = array_merge($part[0], $missing);
			$part = array_merge($missing, $part[1]);
		} else {
			$part = explode(":", $bits[0]);
		}

		// Pad components to 4 characters
		foreach ($part as &$p) {
			while (strlen($p) < 4) $p = '0' . $p;
		}
		unset($p);

		$bits[0] = implode(':', $part);

		// if it is the incorrect length, something went wrong.
		if (strlen($bits[0]) != 39) {
			return false;
		}
		return strtolower(implode('/',$bits));
	}
}
?>