IpHelper   A
last analyzed

Complexity

Total Complexity 29

Size/Duplication

Total Lines 194
Duplicated Lines 0 %

Test Coverage

Coverage 94.03%

Importance

Changes 4
Bugs 2 Features 0
Metric Value
eloc 71
c 4
b 2
f 0
dl 0
loc 194
ccs 63
cts 67
cp 0.9403
rs 10
wmc 29

5 Methods

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