check if an IP is within a range of CIDR in Python

2016-09-07 python ip

I know there are some similar questions up here, but they mostly either want to find the range itself (which uses some libraries, like the example that stackoverflow says is a dupe of my question) and is in another language.

I have a way to convert the subnet into the beginning and the end of the range of ip's in a subnet (okay, bad wording, it's simply like1.1.1.1/16 -> (1.1.0.0 , 1.1.255.255))

I now want to check if 1.1.2.2 is within this subnet. Can I simply do a > and < to compare?

ip_range = ('1.1.0.0', '1.1.255.255')
if '1.1.2.2' >= ip_range[0] and '1.1.2.2' <= ip_range[1]:
     return True

When I tested it, it works, but I don't know if it would always work for any ipv4 ip's. I'd assume I'm just comparing ASCII order , so this should always work, but is there any exception?

Answers

Your code compares strings, not numbers. I would suggest using tuples instead:

>>> ip_range = [(1,1,0,0), (1,1,255,255)]
>>> testip = (1,1,2,2)
>>> testip > ip_range[0] and testip < ip_range[1]
True
>>> testip = (1,3,1,1)
>>> testip > ip_range[0] and testip < ip_range[1]
False

This doesn't work in general, because string comparison is in collating order, not the numerical values of the four fields. For instance, '1.1.2.2' > '1.1.128.1' -- the critical spot in the 5th character, '1' vs '2'.

If you want to compare the fields, try separating into lists:

ip_vals = [int(x) for x in ip_range.split('.')]

ip_vals is now a list of the values; you can compare the lists and get the results I think you want.

You can't really do string comparisons on a dot separated list of numbers because your test will simply fail on input say 1.1.99.99 as '9' is simply greater than '2'

>>> '1.1.99.99' < '1.1.255.255'
False

So instead you can convert the input into tuples of integers through comprehension expression

def convert_ipv4(ip):
    return tuple(int(n) for n in ip.split('.'))

Note the lack of type checking, but if your input is a proper IP address it will be fine. Since you have a 2-tuple of IP addresses, you can create a function that takes both start and end as argument, pass that tuple in through argument list, and return that with just one statement (as Python allows chaining of comparisons). Perhaps like:

def check_ipv4_in(addr, start, end):
    return convert_ipv4(start) < convert_ipv4(addr) < convert_ipv4(end)

Test it out.

>>> ip_range = ('1.1.0.0', '1.1.255.255')
>>> check_ipv4_in('1.1.99.99', *ip_range)
True

With this method you can lazily expand it to IPv6, though the conversion to and from hex (instead of int) will be needed instead.

In Python 3.3 and later, you should be using the ipaddress module.

from ipaddress import ip_network, ip_address

net = ip_network("1.1.0.0/16")
print(ip_address("1.1.2.2") in net)    # True

IPv4 & IPv6 DIY CIDR Membership and Ranges

There's the ipaddress module that provides all the functionality one should ever need. The below is not based on it - it just shows another way it could be done.

Building Blocks

def ipv4_mask(cidr):
    mask = 2**32 - 2**(32 - int(cidr))
    return (mask >> sh & 0xff for sh in (24, 16, 8, 0))

def ipv6_mask(cidr):
    mask = 2**128 - 2**(128 - int(cidr))
    return (mask >> sh & 0xff for sh in range(120, -1, -8))

def ipv4_bytes(ip):
    return (int(b) for b in ip.split('.'))

def ipv6_bytes(ip):
    words  = ip.split(':')
    filled = False
    for word in words:
        if word:
            yield int(word[:-2] or '0', 16)
            yield int(word[-2:], 16)
        elif filled:
            yield 0
            yield 0
        else:
            filled = True
            for _ in range(9 - len(words)):
                yield 0
                yield 0

All the basic functions are very simple aside from the IPv6 bytes function. The different formats for IPv6 addresses require more logic to parse than the simple IPv4 format. For instance, loopback can be represented as ::1. Or runs of 0's can be expressed with adjacent colons, like: aaaa::1111 represents aaaa:0:0:0:0:0:0:1111.

Membership Checks

To determine if an IP is within the range of IP's as defined by the IP and CIDR netmask bit specifier, it's unnecessary to calculate the beginning and end addresses if you apply the netmask as it's intended (as a mask). The two functions below are examples of how this is done for determining if an IPv4 address is a member of a CIDR notated network IP. And another showing an IPv6 test to determine if one subnet is within another.

Using the above as building blocks, we can construct custom functions for ipv4 or ipv6.:

def ipv4_cidr_member_of(ip1, ip2):
    ip2, m = ip2.split('/')
    return not any((a ^ b) & m 
                   for a, b, m in 
                   zip(ipv4_bytes(ip1), 
                       ipv4_bytes(ip2), 
                       ipv4_mask(m)))

def ipv6_cidr_subnet_of(ip1, ip2):
    ip1, m1 = ip1.split('/')
    ip2, m2 = ip2.split('/')
    return int(m1) >= int(m2) and \
           not any((a ^ b) & m
                   for a, b, m in
                   zip(ipv6_bytes(ip1),
                       ipv6_bytes(ip2),
                       ipv6_mask(m2)))

>>> ipv6_cidr_subnet_of('aaaa:bbbb:cccc:dddd:1100::/72', 
...                     'aaaa:bbbb:cccc:dddd::/64')
True
>>> ipv4_cidr_member_of('11.22.33.44', '11.22.33.0/24')
True
>>> 

With this approach, comparisons generally involve XOR-ing two IP bytes, then AND-ing with the net mask. An IPv4 algorithm can be converted to IPv6 simply by changing the functions beginning with 'ipv4_' to 'ipv6_' and vice versa. The algorithms for either IPv4 or IPv6 are the same at this level using the building blocks.

Using the building blocks, custom functions could be created for things like determining if two CIDR notated IP addresses are both on the same network, or if one is within the same network as the other - that would be similar to the ...subnet_of() function in logic.

Ranges

Keeping in mind that it's not necessary to calculate the ranges of a subnet to determine membership if you treat the mask as a true mask; if for whatever reason you want the range, the IP and netmask can be applied to get it in a similar way to the other examples above.

>>> def ipv4_cidr_range_bytes(ip):
...     ip, m = ip.split('/')
...     ip    = list(ipv4_bytes(ip))
...     m     = list(ipv4_mask(m))
...     start = [ b &  m         for b, m in zip(ip, m)]
...     end   = [(b | ~m) & 0xff for b, m in zip(ip, m)]
...     return start, end
...     
>>> ipv4_cidr_range_bytes('11.22.34.0/23')
([11, 22, 34, 0], [11, 22, 35, 255])
>>>
>>> # For IPv6, the above function could have been converted to look
>>> # just like it, but let's mix it up for fun with a single pass
>>> # over the data with zip(), then group into bytes objects with map()
>>>
>>> def ipv6_cidr_range_bytes(ip):
...     ip, m = ip.split('/')
...     s, e  = map(lambda *x: bytes(x), 
...                 *((b & m, (b | ~m) & 0xff)
...                   for b, m in zip(ipv6_bytes(ip),
...                                   ipv6_mask(m))))
...     return s, e
...     
>>> ipv6_cidr_range_bytes('aaaa:bbbb:cccc:dddd:1100::/72')
(b'\xaa\xaa\xbb\xbb\xcc\xcc\xdd\xdd\x11\x00\x00\x00\x00\x00\x00\x00', 
 b'\xaa\xaa\xbb\xbb\xcc\xcc\xdd\xdd\x11\xff\xff\xff\xff\xff\xff\xff')

Efficiency

The functions appear to be slightly faster than using ipaddress objects and methods:

>>> # Using the ipaddress module:
>>> timeit.timeit("a = ip_network('192.168.1.0/24'); "
                  "b = ip_network('192.168.1.128/30'); "
...               "b.subnet_of(a)", globals=globals(), number=10**4)
0.2772132240352221
>>>
>>> # Using this code:
>>> timeit.timeit("ipv4_cidr_subnet_of('192.168.1.128/30', '192.168.1.0/24')", 
...               globals=globals(), number=10**4)
0.07261682399985148
>>> 

Caching

If the same comparisons are repetitive in an application - the same IP's recur often, functools.lru_cache can be used to decorate the functions and possibly gain some more efficiency:

from functools import lru_cache

@lru_cache
def ipv6_cidr_member_of(ip1, ip2):
    ip1    = ipv6_bytes(ip1)
    ip2, m = ip2.split('/')
    ip2    = ipv6_bytes(ip2)
    m      = ipv6_mask(m)
    return not any((a ^ b) & m for a, b, m in zip(ip1, ip2, m))

This caches the parameters and return values, so when the same ip1 is checked for membership again in ip2, the cache quickly returns the last value calculated and the function body doesn't need to redo the operation.

>>> # Without caching:
>>> timeit.timeit("ipv6_cidr_member_of('aaaa:bbbb:cccc:dddd:11af:23af::',"
...                                   "'aaaa:bbbb:cccc:dddd::/64')",
...               globals=globals(), number=5)
0.00011115199959021993
>>> # 11.115199959021993e-05 <- the above time in sci. notation.
>>>
>>> # With caching (@lru_cach applied).
>>> timeit.timeit("ipv6_cidr_member_of('aaaa:bbbb:cccc:dddd:11af:23af::',"
...                                   "'aaaa:bbbb:cccc:dddd::/64')",
...               globals=globals(), number=5)
4.458599869394675e-05

This test just shows 5 cycles. The higher the ratio of cache hits to misses, the higher the gain in efficiency.

Related