Passed
Push — master ( 2d53e1...1fde52 )
by Alexander
01:17
created

IpHelper   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 186
Duplicated Lines 0 %

Test Coverage

Coverage 92.19%

Importance

Changes 3
Bugs 1 Features 0
Metric Value
eloc 72
c 3
b 1
f 0
dl 0
loc 186
ccs 59
cts 64
cp 0.9219
rs 10
wmc 29

5 Methods

Rating   Name   Duplication   Size   Complexity  
A getCidrBits() 0 19 6
B getIpVersion() 0 21 10
A expandIPv6() 0 11 3
A inRange() 0 23 5
A ip2bin() 0 21 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\NetworkUtilities;
6
7
class IpHelper
8
{
9
    public const IPV4 = 4;
10
    public const IPV6 = 6;
11
12
    /**
13
     * IPv4 address pattern. This pattern is PHP and JavaScript compatible.
14
     * Allows to define your own IP regexp eg. `'/^'.IpHelper::IPV4_PATTERN.'/(\d+)$/'`
15
     */
16
    public const IPV4_PATTERN = '((2(5[0-5]|[0-4]\d)|1\d{2}|[1-9]?\d)\.){3}(2(5[0-5]|[0-4]\d)|1\d{2}|[1-9]?\d)';
17
    /**
18
     * IPv6 address regexp. This regexp is PHP and Javascript compatible.
19
     */
20
    public const IPV4_REGEXP = '/^' . self::IPV4_PATTERN . '$/';
21
    /**
22
     * IPv6 address pattern. This pattern is PHP and Javascript compatible.
23
     * Allows to define your own IP regexp eg. `'/^'.IpHelper::IPV6_PATTERN.'/(\d+)$/'`
24
     */
25
    public const IPV6_PATTERN = '(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:' . self::IPV4_PATTERN . ')';
26
    /**
27
     * IPv6 address regexp. This regexp is PHP and JavaScript compatible.
28
     */
29
    public const IPV6_REGEXP = '/^' . self::IPV6_PATTERN . '$/';
30
    /**
31
     * The length of IPv6 address in bits
32
     */
33
    public const IPV6_ADDRESS_LENGTH = 128;
34
    /**
35
     * The length of IPv4 address in bits
36
     */
37
    public const IPV4_ADDRESS_LENGTH = 32;
38
39
40
    /**
41
     * Gets the IP version.
42
     *
43
     * @param string $ip the valid IPv4 or IPv6 address.
44
     * @param bool $validate enable perform IP address validation. False is best practice if the data comes from a trusted source.
45
     * @return int {{IPV4}} or {{IPV6}}
46
     */
47 52
    public static function getIpVersion(string $ip, bool $validate = true): int
48
    {
49 52
        $ipStringLength = strlen($ip);
50 52
        if ($ipStringLength < 2) {
51 4
            throw new \InvalidArgumentException("Unrecognized address $ip", 10);
52
        }
53 48
        $preIpVersion = strpos($ip, ':') === false ? self::IPV4 : self::IPV6;
54 48
        if ($preIpVersion === self::IPV4 && $ipStringLength < 7) {
55 1
            throw new \InvalidArgumentException("Unrecognized address $ip", 11);
56
        }
57 47
        if (!$validate) {
58 9
            return $preIpVersion;
59
        }
60 38
        $rawIp = @inet_pton($ip);
61 38
        if ($rawIp !== false) {
62 33
            return strlen($rawIp) === self::IPV4_ADDRESS_LENGTH >> 3 ? self::IPV4 : self::IPV6;
63
        }
64 5
        if ($preIpVersion === self::IPV6 && preg_match(self::IPV6_REGEXP, $ip) === 1) {
65
            return self::IPV6;
66
        }
67 5
        throw new \InvalidArgumentException("Unrecognized address $ip", 12);
68
    }
69
70
    /**
71
     * Checks whether IP address or subnet $subnet is contained by $subnet.
72
     *
73
     * For example, the following code checks whether subnet `192.168.1.0/24` is in subnet `192.168.0.0/22`:
74
     *
75
     * ```php
76
     * IpHelper::inRange('192.168.1.0/24', '192.168.0.0/22'); // true
77
     * ```
78
     *
79
     * In case you need to check whether a single IP address `192.168.1.21` is in the subnet `192.168.1.0/24`,
80
     * you can use any of theses examples:
81
     *
82
     * ```php
83
     * IpHelper::inRange('192.168.1.21', '192.168.1.0/24'); // true
84
     * IpHelper::inRange('192.168.1.21/32', '192.168.1.0/24'); // true
85
     * ```
86
     *
87
     * @param string $subnet the valid IPv4 or IPv6 address or CIDR range, e.g.: `10.0.0.0/8` or `2001:af::/64`
88
     * @param string $range the valid IPv4 or IPv6 CIDR range, e.g. `10.0.0.0/8` or `2001:af::/64`
89
     * @return bool whether $subnet is contained by $range
90
     *
91
     * @see https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing
92
     */
93 10
    public static function inRange(string $subnet, string $range): bool
94
    {
95 10
        [$ip, $mask] = array_pad(explode('/', $subnet), 2, null);
96 10
        [$net, $netMask] = array_pad(explode('/', $range), 2, null);
97
98
        assert(is_string($ip));
99
        assert(is_string($net));
100
101 10
        $ipVersion = static::getIpVersion($ip);
102 10
        $netVersion = static::getIpVersion($net);
103 10
        if ($ipVersion !== $netVersion) {
104
            return false;
105
        }
106
107 10
        $maxMask = $ipVersion === self::IPV4 ? self::IPV4_ADDRESS_LENGTH : self::IPV6_ADDRESS_LENGTH;
108 10
        $mask ??= $maxMask;
109 10
        $netMask ??= $maxMask;
110
111 10
        $binIp = static::ip2bin($ip);
112 10
        $binNet = static::ip2bin($net);
113 10
        $masked = substr($binNet, 0, (int)$netMask);
114
115 10
        return ($masked === '' || strpos($binIp, $masked) === 0) && $mask >= $netMask;
116
    }
117
118
    /**
119
     * Expands an IPv6 address to it's full notation.
120
     *
121
     * For example `2001:db8::1` will be expanded to `2001:0db8:0000:0000:0000:0000:0000:0001`
122
     *
123
     * @param string $ip the original valid IPv6 address
124
     * @return string the expanded IPv6 address
125
     */
126 3
    public static function expandIPv6(string $ip): string
127
    {
128 3
        $ipRaw = @inet_pton($ip);
129 3
        if ($ipRaw === false) {
130 1
            if (@inet_pton('::1') === false) {
131
                throw new \RuntimeException('IPv6 is not supported by inet_pton()!');
132
            }
133 1
            throw new \InvalidArgumentException("Unrecognized address $ip");
134
        }
135 2
        $hex = unpack('H*hex', $ipRaw);
136 2
        return substr(preg_replace('/([a-f0-9]{4})/i', '$1:', $hex['hex']), 0, -1);
137
    }
138
139
    /**
140
     * Converts IP address to bits representation.
141
     *
142
     * @param string $ip the valid IPv4 or IPv6 address
143
     * @return string bits as a string
144
     */
145 14
    public static function ip2bin(string $ip): string
146
    {
147 14
        if (static::getIpVersion($ip) === self::IPV4) {
148 7
            $ipBinary = pack('N', ip2long($ip));
149 7
        } elseif (@inet_pton('::1') === false) {
150
            throw new \RuntimeException('IPv6 is not supported by inet_pton()!');
151
        } else {
152 7
            $ipBinary = inet_pton($ip);
153
        }
154
155
        assert(is_string($ipBinary));
156
157 14
        $result = '';
158 14
        for ($i = 0, $iMax = strlen($ipBinary); $i < $iMax; $i += 4) {
159 14
            $data = substr($ipBinary, $i, 4);
160 14
            if (!is_string($data)) {
161
                throw new \RuntimeException('An error occurred while converting IP address to bits representation.');
162
            }
163 14
            $result .= str_pad(decbin(unpack('N', $data)[1]), 32, '0', STR_PAD_LEFT);
164
        }
165 14
        return $result;
166
    }
167
168
    /**
169
     * Gets the bits from CIDR Notation.
170
     *
171
     * @param string $ip IP or IP with CIDR Notation (`127.0.0.1`, `2001:db8:a::123/64`)
172
     * @return int
173
     */
174 19
    public static function getCidrBits(string $ip): int
175
    {
176 19
        if (preg_match('/^(?<ip>.{2,}?)(?:\/(?<bits>-?\d+))?$/', $ip, $matches) === 0) {
177 2
            throw new \InvalidArgumentException("Unrecognized address $ip", 1);
178
        }
179 17
        $ipVersion = static::getIpVersion($matches['ip']);
180 15
        $maxBits = $ipVersion === self::IPV6 ? self::IPV6_ADDRESS_LENGTH : self::IPV4_ADDRESS_LENGTH;
181 15
        $bits = $matches['bits'] ?? null;
182 15
        if ($bits === null) {
183 3
            return $maxBits;
184
        }
185 12
        $bits = (int)$bits;
186 12
        if ($bits < 0) {
187 2
            throw new \InvalidArgumentException('The number of CIDR bits cannot be negative', 2);
188
        }
189 10
        if ($bits > $maxBits) {
190 2
            throw new \InvalidArgumentException("CIDR bits is greater than $bits", 3);
191
        }
192 8
        return $bits;
193
    }
194
}
195