Passed
Push — master ( 58d5a3...886a7d )
by Alexander
01:33
created

IpHelper   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 186
Duplicated Lines 0 %

Test Coverage

Coverage 92.54%

Importance

Changes 2
Bugs 1 Features 0
Metric Value
eloc 72
c 2
b 1
f 0
dl 0
loc 186
ccs 62
cts 67
cp 0.9254
rs 10
wmc 29

5 Methods

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