Passed
Push — master ( 61c905...ca26ac )
by Alexander
03:28 queued 01:32
created

IpHelper::getCidrBits()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
eloc 13
nc 9
nop 1
dl 0
loc 19
ccs 14
cts 14
cp 1
crap 6
rs 9.2222
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\NetworkUtilities;
6
7
final 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
     * Gets the IP version.
41
     *
42
     * @param string $ip the valid IPv4 or IPv6 address.
43
     * @param bool $validate enable perform IP address validation. False is best practice if the data comes from a trusted source.
44
     *
45
     * @return int {@see IPV4} or {@see IPV6}
46
     */
47 53
    public static function getIpVersion(string $ip, bool $validate = true): int
48
    {
49 53
        $ipStringLength = strlen($ip);
50 53
        if ($ipStringLength < 2) {
51 4
            throw new \InvalidArgumentException("Unrecognized address $ip", 10);
52
        }
53 49
        $preIpVersion = strpos($ip, ':') === false ? self::IPV4 : self::IPV6;
54 49
        if ($preIpVersion === self::IPV4 && $ipStringLength < 7) {
55 1
            throw new \InvalidArgumentException("Unrecognized address $ip", 11);
56
        }
57 48
        if (!$validate) {
58 9
            return $preIpVersion;
59
        }
60 39
        $rawIp = @inet_pton($ip);
61 39
        if ($rawIp !== false) {
62 34
            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
     *
90
     * @return bool whether $subnet is contained by $range
91
     *
92
     * @see https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing
93
     */
94 11
    public static function inRange(string $subnet, string $range): bool
95
    {
96 11
        [$ip, $mask] = array_pad(explode('/', $subnet), 2, null);
97 11
        [$net, $netMask] = array_pad(explode('/', $range), 2, null);
98
99 11
        assert(is_string($ip));
100 11
        assert(is_string($net));
101
102 11
        $ipVersion = self::getIpVersion($ip);
103 11
        $netVersion = self::getIpVersion($net);
104 11
        if ($ipVersion !== $netVersion) {
105 1
            return false;
106
        }
107
108 10
        $maxMask = $ipVersion === self::IPV4 ? self::IPV4_ADDRESS_LENGTH : self::IPV6_ADDRESS_LENGTH;
109 10
        $mask ??= $maxMask;
110 10
        $netMask ??= $maxMask;
111
112 10
        $binIp = self::ip2bin($ip);
113 10
        $binNet = self::ip2bin($net);
114 10
        $masked = substr($binNet, 0, (int)$netMask);
115
116 10
        return ($masked === '' || strpos($binIp, $masked) === 0) && $mask >= $netMask;
117
    }
118
119
    /**
120
     * Expands an IPv6 address to it's full notation.
121
     *
122
     * For example `2001:db8::1` will be expanded to `2001:0db8:0000:0000:0000:0000:0000:0001`
123
     *
124
     * @param string $ip the original valid IPv6 address
125
     *
126
     * @return string the expanded IPv6 address
127
     */
128 3
    public static function expandIPv6(string $ip): string
129
    {
130 3
        $ipRaw = @inet_pton($ip);
131 3
        if ($ipRaw === false) {
132 1
            if (@inet_pton('::1') === false) {
133
                throw new \RuntimeException('IPv6 is not supported by inet_pton()!');
134
            }
135 1
            throw new \InvalidArgumentException("Unrecognized address $ip");
136
        }
137 2
        $hex = unpack('H*hex', $ipRaw);
138 2
        return substr(preg_replace('/([a-f0-9]{4})/i', '$1:', $hex['hex']), 0, -1);
139
    }
140
141
    /**
142
     * Converts IP address to bits representation.
143
     *
144
     * @param string $ip the valid IPv4 or IPv6 address
145
     *
146
     * @return string bits as a string
147
     */
148 14
    public static function ip2bin(string $ip): string
149
    {
150 14
        if (self::getIpVersion($ip) === self::IPV4) {
151 7
            $ipBinary = pack('N', ip2long($ip));
152 7
        } elseif (@inet_pton('::1') === false) {
153
            throw new \RuntimeException('IPv6 is not supported by inet_pton()!');
154
        } else {
155 7
            $ipBinary = inet_pton($ip);
156
        }
157
158 14
        assert(is_string($ipBinary));
159
160 14
        $result = '';
161 14
        for ($i = 0, $iMax = strlen($ipBinary); $i < $iMax; $i += 4) {
162 14
            $data = substr($ipBinary, $i, 4);
163 14
            if (!is_string($data)) {
164
                throw new \RuntimeException('An error occurred while converting IP address to bits representation.');
165
            }
166 14
            $result .= str_pad(decbin(unpack('N', $data)[1]), 32, '0', STR_PAD_LEFT);
167
        }
168 14
        return $result;
169
    }
170
171
    /**
172
     * Gets the bits from CIDR Notation.
173
     *
174
     * @param string $ip IP or IP with CIDR Notation (`127.0.0.1`, `2001:db8:a::123/64`)
175
     *
176
     * @return int
177
     */
178 19
    public static function getCidrBits(string $ip): int
179
    {
180 19
        if (preg_match('/^(?<ip>.{2,}?)(?:\/(?<bits>-?\d+))?$/', $ip, $matches) === 0) {
181 2
            throw new \InvalidArgumentException("Unrecognized address $ip", 1);
182
        }
183 17
        $ipVersion = self::getIpVersion($matches['ip']);
184 15
        $maxBits = $ipVersion === self::IPV6 ? self::IPV6_ADDRESS_LENGTH : self::IPV4_ADDRESS_LENGTH;
185 15
        $bits = $matches['bits'] ?? null;
186 15
        if ($bits === null) {
187 3
            return $maxBits;
188
        }
189 12
        $bits = (int)$bits;
190 12
        if ($bits < 0) {
191 2
            throw new \InvalidArgumentException('The number of CIDR bits cannot be negative', 2);
192
        }
193 10
        if ($bits > $maxBits) {
194 2
            throw new \InvalidArgumentException("CIDR bits is greater than $bits", 3);
195
        }
196 8
        return $bits;
197
    }
198
}
199