summaryrefslogtreecommitdiffstats
path: root/nselib/ipOps.lua
diff options
context:
space:
mode:
Diffstat (limited to 'nselib/ipOps.lua')
-rw-r--r--nselib/ipOps.lua888
1 files changed, 888 insertions, 0 deletions
diff --git a/nselib/ipOps.lua b/nselib/ipOps.lua
new file mode 100644
index 0000000..aa45dac
--- /dev/null
+++ b/nselib/ipOps.lua
@@ -0,0 +1,888 @@
+---
+-- Utility functions for manipulating and comparing IP addresses.
+--
+-- @copyright Same as Nmap--See https://nmap.org/book/man-legal.html
+
+local math = require "math"
+local stdnse = require "stdnse"
+local string = require "string"
+local stringaux = require "stringaux"
+local table = require "table"
+local type = type
+local ipairs = ipairs
+local tonumber = tonumber
+local unittest = require "unittest"
+
+
+_ENV = stdnse.module("ipOps", stdnse.seeall)
+
+local pack = string.pack
+local unpack = string.unpack
+
+---
+-- Checks to see if the supplied IP address is part of a non-routable
+-- address space.
+--
+-- The non-Internet-routable address spaces known to this function are
+-- * IPv4 Loopback (RFC3330)
+-- * IPv4 Private Use (RFC1918)
+-- * IPv4 Link Local (RFC3330)
+-- * IPv4 IETF Protocol Assignments (RFC 5736)
+-- * IPv4 TEST-NET-1, TEST-NET-2, TEST-NET-3 (RFC 5737)
+-- * IPv4 Network Interconnect Device Benchmark Testing (RFC 2544)
+-- * IPv4 Reserved for Future Use (RFC 1112, Section 4)
+-- * IPv4 Multicast Local Network Control Block (RFC 3171, Section 3)
+-- * IPv6 Unspecified and Loopback (RFC3513)
+-- * IPv6 Site-Local (RFC3513, deprecated in RFC3879)
+-- * IPv6 Unique Local Unicast (RFC4193)
+-- * IPv6 Link Local Unicast (RFC4291)
+-- @param ip String representing an IPv4 or IPv6 address. Shortened notation
+-- is permitted.
+-- @usage
+-- local is_private = ipOps.isPrivate( "192.168.1.1" )
+-- @return True or false (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error or
+-- String non-routable address containing the supplied IP address.
+isPrivate = function( ip )
+ local err
+
+ ip, err = expand_ip( ip )
+ if err then return nil, err end
+
+ if ip:match( ":" ) then
+
+ local is_private
+ local ipv6_private = { "::/127", "FC00::/7", "FE80::/10", "FEC0::/10" }
+
+ for _, range in ipairs( ipv6_private ) do
+ is_private, err = ip_in_range( ip, range )
+ if is_private == true then
+ return true, range
+ end
+ if err then
+ return nil, err
+ end
+ end
+
+ elseif ip:sub(1,3) == '10.' then
+
+ return true, '10/8'
+
+ elseif ip:sub(1,4) == '127.' then
+
+ return true, '127/8'
+
+ elseif ip:sub(1,8) == '169.254.' then
+
+ return true, '169.254/16'
+
+ elseif ip:sub(1,4) == '172.' then
+
+ local p, e = ip_in_range(ip, '172.16/12')
+ if p == true then
+ return true, '172.16/12'
+ else
+ return p, e
+ end
+
+ elseif ip:sub(1,4) == '192.' then
+
+ if ip:sub(5,8) == '168.' then
+ return true, '192.168/16'
+ elseif ip:match('^192%.[0][0]?[0]?%.[0][0]?[0]?%.') then
+ return true, '192.0.0/24'
+ elseif ip:match('^192%.[0][0]?[0]?%.[0]?[0]?2') then
+ return true, '192.0.2/24'
+ end
+
+ elseif ip:sub(1,4) == '198.' then
+
+ if ip:match('^198%.[0]?18%.') or ip:match('^198%.[0]?19%.') then
+ return true, '198.18/15'
+ elseif ip:match('^198%.[0]?51%.100%.') then
+ return true, '198.51.100/24'
+ end
+
+ elseif ip:match('^203%.[0][0]?[0]?%.113%.') then
+
+ return true, '203.0.113/24'
+
+ elseif ip:match('^224%.[0][0]?[0]?%.[0][0]?[0]?%.') then
+
+ return true, '224.0.0/24'
+
+ elseif ip:match('^24[0-9]%.') or ip:match('^25[0-5]%.') then
+
+ return true, '240.0.0/4'
+
+ end
+
+ return false, nil
+
+end
+
+
+
+---
+-- Converts the supplied IPv4 address into a DWORD value.
+--
+-- For example, the address a.b.c.d becomes (((a*256+b)*256+c)*256+d).
+--
+-- Note: IPv6 addresses are not supported. Currently, numbers in NSE are
+-- limited to 10^14, and consequently not all IPv6 addresses can be
+-- represented. Consider using <code>ip_to_str</code> for IPv6 addresses.
+-- @param ip String representing an IPv4 address. Shortened notation is
+-- permitted.
+-- @usage
+-- local dword = ipOps.todword( "73.150.2.210" )
+-- @return Number corresponding to the supplied IP address (or <code>nil</code>
+-- in case of an error).
+-- @return String error message in case of an error.
+todword = function( ip )
+
+ if type( ip ) ~= "string" or ip:match( ":" ) then
+ return nil, "Error in ipOps.todword: Expected IPv4 address."
+ end
+
+ local n, ret, err = {}
+ n, err = get_parts_as_number( ip )
+ if err then return nil, err end
+
+ ret = (((n[1]*256+n[2]))*256+n[3])*256+n[4]
+
+ return ret
+
+end
+
+---
+-- Converts the supplied IPv4 address from a DWORD value into a dotted string.
+--
+-- For example, the address (((a*256+b)*256+c)*256+d) becomes a.b.c.d.
+--
+--@param ip DWORD representing an IPv4 address.
+--@return The string representing the address.
+fromdword = function( ip )
+ if type( ip ) ~= "number" then
+ stdnse.debug1("Error in ipOps.fromdword: Expected 32-bit number.")
+ return nil
+ end
+ return string.format("%d.%d.%d.%d", unpack("BBBB", pack(">I4", ip)))
+end
+
+---
+-- Separates the supplied IP address into its constituent parts and
+-- returns them as a table of numbers.
+--
+-- For example, the address 139.104.32.123 becomes { 139, 104, 32, 123 }.
+-- @usage
+-- local a, b, c, d;
+-- local t, err = ipOps.get_parts_as_number( "139.104.32.123" )
+-- if t then a, b, c, d = table.unpack( t ) end
+-- @param ip String representing an IPv4 or IPv6 address. Shortened notation
+-- is permitted.
+-- @return Array of numbers for each part of the supplied IP address (or
+-- <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+get_parts_as_number = function( ip )
+ local err
+
+ ip, err = expand_ip( ip )
+ if err then return nil, err end
+
+ local pattern, base
+ if ip:match( ":" ) then
+ pattern = "%x+"
+ base = 16
+ else
+ pattern = "%d+"
+ base = 10
+ end
+ local t = {}
+ for part in string.gmatch(ip, pattern) do
+ t[#t+1] = tonumber( part, base )
+ end
+
+ return t
+
+end
+
+
+
+---
+-- Compares two IP addresses.
+--
+-- When comparing addresses from different families,
+-- IPv4 addresses will sort before IPv6 addresses.
+-- @param left String representing an IPv4 or IPv6 address. Shortened
+-- notation is permitted.
+-- @param op A comparison operator which may be one of the following strings:
+-- <code>"eq"</code>, <code>"ge"</code>, <code>"le"</code>,
+-- <code>"gt"</code> or <code>"lt"</code> (respectively ==, >=, <=,
+-- >, <).
+-- @param right String representing an IPv4 or IPv6 address. Shortened
+-- notation is permitted.
+-- @usage
+-- if ipOps.compare_ip( "2001::DEAD:0:0:0", "eq", "2001:0:0:0:DEAD::" ) then
+-- ...
+-- end
+-- @return True or false (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+compare_ip = function( left, op, right )
+
+ if type( left ) ~= "string" or type( right ) ~= "string" then
+ return nil, "Error in ipOps.compare_ip: Expected IP address as a string."
+ end
+
+ local err ={}
+ left, err[#err+1] = ip_to_str( left )
+ right, err[#err+1] = ip_to_str( right )
+ if #err > 0 then
+ return nil, table.concat( err, " " )
+ end
+
+ -- by prepending the length, IPv4 (length 4) sorts before IPv6 (length 16)
+ left = pack("s1", left)
+ right = pack("s1", right)
+
+ if ( op == "eq" ) then
+ return ( left == right )
+ elseif ( op == "ne" ) then
+ return ( left ~= right )
+ elseif ( op == "le" ) then
+ return ( left <= right )
+ elseif ( op == "ge" ) then
+ return ( left >= right )
+ elseif ( op == "lt" ) then
+ return ( left < right )
+ elseif ( op == "gt" ) then
+ return ( left > right )
+ end
+
+ return nil, "Error in ipOps.compare_ip: Invalid Operator."
+end
+
+--- Sorts a table of IP addresses
+--
+-- An in-place sort using <code>table.sort</code> to sort by IP address.
+-- Sorting non-IP addresses is likely to result in a bad sort and possibly an infinite loop.
+--
+-- @param ips The table of IP addresses to sort
+-- @param op The comparison operation to use. Default: "lt" (ascending)
+ip_sort = function (ips, op)
+ op = op or "lt"
+ return table.sort(ips, function(a, b) return compare_ip(a, op, b) end)
+end
+
+---
+-- Checks whether the supplied IP address is within the supplied range of IP
+-- addresses.
+--
+-- The address and the range must both belong to the same address family.
+-- @param ip String representing an IPv4 or IPv6 address. Shortened
+-- notation is permitted.
+-- @param range String representing a range of IPv4 or IPv6 addresses in
+-- first-last or CIDR notation (e.g.
+-- <code>"192.168.1.1 - 192.168.255.255"</code> or
+-- <code>"2001:0A00::/23"</code>).
+-- @usage
+-- if ipOps.ip_in_range( "192.168.1.1", "192/8" ) then ... end
+-- @return True or false (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+ip_in_range = function( ip, range )
+
+ local first, last, err = get_ips_from_range( range )
+ if err then return nil, err end
+ ip, err = expand_ip( ip )
+ if err then return nil, err end
+ if ( ip:match( ":" ) and not first:match( ":" ) ) or ( not ip:match( ":" ) and first:match( ":" ) ) then
+ return nil, "Error in ipOps.ip_in_range: IP address is of a different address family to Range."
+ end
+
+ err = {}
+ local ip_ge_first, ip_le_last
+ ip_ge_first, err[#err+1] = compare_ip( ip, "ge", first )
+ ip_le_last, err[#err+1] = compare_ip( ip, "le", last )
+ if #err > 0 then
+ return nil, table.concat( err, " " )
+ end
+
+ if ip_ge_first and ip_le_last then
+ return true
+ else
+ return false
+ end
+
+end
+
+
+
+---
+-- Expands an IP address supplied in shortened notation.
+-- Serves also to check the well-formedness of an IP address.
+--
+-- Note: IPv4in6 notated addresses will be returned in pure IPv6 notation unless
+-- the IPv4 portion is shortened and does not contain a dot, in which case the
+-- address will be treated as IPv6.
+-- @param ip String representing an IPv4 or IPv6 address in shortened or full notation.
+-- @param family String representing the address family to expand to. Only
+-- affects IPv4 addresses when "inet6" is provided, causing the function to
+-- return an IPv4-mapped IPv6 address.
+-- @usage
+-- local ip = ipOps.expand_ip( "2001::" )
+-- @return String representing a fully expanded IPv4 or IPv6 address (or
+-- <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+expand_ip = function( ip, family )
+ local err
+
+ if type( ip ) ~= "string" or ip == "" then
+ return nil, "Error in ipOps.expand_ip: Expected IP address as a string."
+ end
+
+ local err4 = "Error in ipOps.expand_ip: An address assumed to be IPv4 was malformed."
+
+ if not ip:match( ":" ) then
+ -- ipv4: missing octets should be "0" appended
+ if ip:match( "[^%.0-9]" ) then
+ return nil, err4
+ end
+ local octets = {}
+ for octet in string.gmatch( ip, "%d+" ) do
+ if tonumber( octet, 10 ) > 255 then return nil, err4 end
+ octets[#octets+1] = octet
+ end
+ if #octets > 4 then return nil, err4 end
+ while #octets < 4 do
+ octets[#octets+1] = "0"
+ end
+ if family == "inet6" then
+ return ( table.concat( { 0,0,0,0,0,"ffff",
+ stdnse.tohex( 256*octets[1]+octets[2] ),
+ stdnse.tohex( 256*octets[3]+octets[4] )
+ }, ":" ) )
+ else
+ return ( table.concat( octets, "." ) )
+ end
+ end
+
+ if family ~= nil and family ~= "inet6" then
+ return nil, "Error in ipOps.expand_ip: Cannot convert IPv6 address to IPv4"
+ end
+
+ if ip:match( "[^%.:%x]" ) then
+ return nil, ( err4:gsub( "IPv4", "IPv6" ) )
+ end
+
+ -- preserve ::
+ ip = string.gsub(ip, "::", ":z:")
+
+ -- get a table of each hexadectet
+ local hexadectets = {}
+ for hdt in string.gmatch( ip, "[%.z%x]+" ) do
+ hexadectets[#hexadectets+1] = hdt
+ end
+
+ -- deal with IPv4in6 (last hexadectet only)
+ local t = {}
+ if hexadectets[#hexadectets]:match( "[%.]+" ) then
+ hexadectets[#hexadectets], err = expand_ip( hexadectets[#hexadectets] )
+ if err then return nil, ( err:gsub( "IPv4", "IPv4in6" ) ) end
+ t = stringaux.strsplit( "[%.]+", hexadectets[#hexadectets] )
+ for i, v in ipairs( t ) do
+ t[i] = tonumber( v, 10 )
+ end
+ hexadectets[#hexadectets] = stdnse.tohex( 256*t[1]+t[2] )
+ hexadectets[#hexadectets+1] = stdnse.tohex( 256*t[3]+t[4] )
+ end
+
+ -- deal with :: and check for invalid address
+ local z_done = false
+ for index, value in ipairs( hexadectets ) do
+ if value:match( "[%.]+" ) then
+ -- shouldn't have dots at this point
+ return nil, ( err4:gsub( "IPv4", "IPv6" ) )
+ elseif value == "z" and z_done then
+ -- can't have more than one ::
+ return nil, ( err4:gsub( "IPv4", "IPv6" ) )
+ elseif value == "z" and not z_done then
+ z_done = true
+ hexadectets[index] = "0"
+ local bound = 8 - #hexadectets
+ for i = 1, bound, 1 do
+ table.insert( hexadectets, index+i, "0" )
+ end
+ elseif tonumber( value, 16 ) > 65535 then
+ -- more than FFFF!
+ return nil, ( err4:gsub( "IPv4", "IPv6" ) )
+ end
+ end
+
+ -- make sure we have exactly 8 hexadectets
+ if #hexadectets > 8 then return nil, ( err4:gsub( "IPv4", "IPv6" ) ) end
+ while #hexadectets < 8 do
+ hexadectets[#hexadectets+1] = "0"
+ end
+
+ return ( table.concat( hexadectets, ":" ) )
+
+end
+
+
+
+---
+-- Returns the first and last IP addresses in the supplied range of addresses.
+-- @param range String representing a range of IPv4 or IPv6 addresses in either
+-- CIDR or first-last notation.
+-- @usage
+-- first, last = ipOps.get_ips_from_range( "192.168.0.0/16" )
+-- @return String representing the first address in the supplied range (or
+-- <code>nil</code> in case of an error).
+-- @return String representing the last address in the supplied range (or
+-- <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+get_ips_from_range = function( range )
+
+ if type( range ) ~= "string" then
+ return nil, nil, "Error in ipOps.get_ips_from_range: Expected a range as a string."
+ end
+
+ local ip, prefix = range:match("^%s*([%x:.]+)/(%d+)%s*$")
+ if ip then
+ return get_first_last_ip(ip, prefix)
+ end
+ local first, last = range:match("^%s*([%x:.]+)%s*%-%s*([%x:.]+)%s*$")
+ if not first then
+ return nil, nil, "Error in ipOps.get_ips_from_range: The range supplied could not be interpreted."
+ end
+
+ local err
+ first, err = expand_ip(first)
+ if not err then
+ last, err = expand_ip(last)
+ end
+ if not err then
+ local af = function (ip) return ip:find(":") and 6 or 4 end
+ if af(first) ~= af(last) then
+ err = "Error in ipOps.get_ips_from_range: First IP address is of a different address family to last IP address."
+ end
+ end
+ if err then
+ return nil, nil, err
+ end
+ return first, last
+end
+
+---
+-- Calculates the first and last IP addresses of a range of addresses,
+-- given an IP address in the range and a prefix length for that range
+-- @param ip String representing an IPv4 or IPv6 address. Shortened notation
+-- is permitted.
+-- @param prefix Number or a string representing a decimal number corresponding
+-- to a prefix length.
+-- @usage
+-- first, last = ipOps.get_first_last_ip( "192.0.0.0", 26)
+-- @return String representing the first IP address of the range denoted by
+-- the supplied parameters (or <code>nil</code> in case of an error).
+-- @return String representing the last IP address of the range denoted by
+-- the supplied parameters (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+get_first_last_ip = function(ip, prefix)
+ local err
+ ip, err = ip_to_bin(ip)
+ if err then return nil, nil, err end
+
+ prefix = tonumber(prefix)
+ if not prefix or prefix ~= math.floor(prefix) or prefix < 0 or prefix > #ip then
+ return nil, nil, "Error in ipOps.get_first_last_ip: Invalid prefix."
+ end
+
+ local net = ip:sub(1, prefix)
+ local first = bin_to_ip(net .. ("0"):rep(#ip - prefix))
+ local last = bin_to_ip(net .. ("1"):rep(#ip - prefix))
+ return first, last
+end
+
+---
+-- Calculates the first IP address of a range of addresses given an IP address in
+-- the range and prefix length for that range.
+-- @param ip String representing an IPv4 or IPv6 address. Shortened notation
+-- is permitted.
+-- @param prefix Number or a string representing a decimal number corresponding
+-- to a prefix length.
+-- @usage
+-- first = ipOps.get_first_ip( "192.0.0.0", 26 )
+-- @return String representing the first IP address of the range denoted by the
+-- supplied parameters (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+get_first_ip = function(ip, prefix)
+ local first, last, err = get_first_last_ip(ip, prefix)
+ return first, err
+end
+
+---
+-- Calculates the last IP address of a range of addresses given an IP address in
+-- the range and prefix length for that range.
+-- @param ip String representing an IPv4 or IPv6 address. Shortened notation
+-- is permitted.
+-- @param prefix Number or a string representing a decimal number corresponding
+-- to a prefix length.
+-- @usage
+-- last = ipOps.get_last_ip( "192.0.0.0", 26 )
+-- @return String representing the last IP address of the range denoted by the
+-- supplied parameters (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+get_last_ip = function(ip, prefix)
+ local first, last, err = get_first_last_ip(ip, prefix)
+ return last, err
+end
+
+---
+-- Converts an IP address into an opaque string (big-endian)
+-- @param ip String representing an IPv4 or IPv6 address.
+-- @param family (optional) Address family to convert to. "ipv6" converts IPv4
+-- addresses to IPv4-mapped IPv6.
+-- @usage
+-- opaque = ipOps.ip_to_str( "192.168.3.4" )
+-- @return 4- or 16-byte string representing IP address (or <code>nil</code>
+-- in case of an error).
+-- @return String error message in case of an error
+ip_to_str = function( ip, family )
+ local err
+
+ ip, err = expand_ip( ip, family )
+ if err then return nil, err end
+
+ local t = {}
+
+ if not ip:match( ":" ) then
+ -- ipv4 string
+ for octet in string.gmatch( ip, "%d+" ) do
+ t[#t+1] = pack("B", octet)
+ end
+ else
+ -- ipv6 string
+ for hdt in string.gmatch( ip, "%x+" ) do
+ t[#t+1] = pack( ">I2", tonumber(hdt, 16) )
+ end
+ end
+
+
+ return table.concat( t )
+end
+
+---
+-- Converts an opaque string (big-endian) into an IP address
+--
+-- @param ip Opaque string representing an IP address. If length 4, then IPv4
+-- is assumed. If length 16, then IPv6 is assumed.
+-- @return IP address in readable notation (or <code>nil</code> in case of an
+-- error)
+-- @return String error message in case of an error
+str_to_ip = function (ip)
+ if #ip == 4 then
+ return ("%d.%d.%d.%d"):format(unpack("BBBB", ip))
+ elseif #ip == 16 then
+ local full = ("%x:%x:%x:%x:%x:%x:%x:%x"):format(unpack((">I2"):rep(8), ip))
+ full = full:gsub(":[:0]+", "::", 1) -- Collapse the first (should be longest?) series of :0:
+ full = full:gsub("^0::", "::", 1) -- handle special case of ::1
+ return full
+ else
+ return nil, "Invalid length"
+ end
+end
+
+---
+-- Converts an IP address into a string representing the address as binary
+-- digits.
+-- @param ip String representing an IPv4 or IPv6 address. Shortened notation
+-- is permitted.
+-- @usage
+-- bit_string = ipOps.ip_to_bin( "2001::" )
+-- @return String representing the supplied IP address as 32 or 128 binary
+-- digits (or <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+ip_to_bin = function( ip )
+ local err
+
+ ip, err = expand_ip( ip )
+ if err then return nil, err end
+
+ local t, mask = {}
+
+ if not ip:match( ":" ) then
+ -- ipv4 string
+ for octet in string.gmatch( ip, "%d+" ) do
+ t[#t+1] = stdnse.tohex( tonumber(octet) )
+ end
+ mask = "00"
+ else
+ -- ipv6 string
+ for hdt in string.gmatch( ip, "%x+" ) do
+ t[#t+1] = hdt
+ end
+ mask = "0000"
+ end
+
+ -- padding
+ for i, v in ipairs( t ) do
+ t[i] = mask:sub( 1, # mask - # v ) .. v
+ end
+
+ return hex_to_bin( table.concat( t ) )
+
+end
+
+
+
+---
+-- Converts a string of binary digits into an IP address.
+-- @param binstring String representing an IP address as 32 or 128 binary
+-- digits.
+-- @usage
+-- ip = ipOps.bin_to_ip( "01111111000000000000000000000001" )
+-- @return String representing an IP address (or <code>nil</code> in
+-- case of an error).
+-- @return String error message in case of an error.
+bin_to_ip = function( binstring )
+
+ if type( binstring ) ~= "string" or binstring:match( "[^01]+" ) then
+ return nil, "Error in ipOps.bin_to_ip: Expected string of binary digits."
+ end
+
+ local af
+ if # binstring == 32 then
+ af = 4
+ elseif # binstring == 128 then
+ af = 6
+ else
+ return nil, "Error in ipOps.bin_to_ip: Expected exactly 32 or 128 binary digits."
+ end
+
+ local t = {}
+ if af == 6 then
+ local pattern = string.rep( "[01]", 16 )
+ for chunk in string.gmatch( binstring, pattern ) do
+ t[#t+1] = stdnse.tohex( tonumber( chunk, 2 ) )
+ end
+ return table.concat( t, ":" )
+ end
+
+ if af == 4 then
+ local pattern = string.rep( "[01]", 8 )
+ for chunk in string.gmatch( binstring, pattern ) do
+ t[#t+1] = tonumber( chunk, 2 ) .. ""
+ end
+ return table.concat( t, "." )
+ end
+
+end
+
+
+
+local bin_lookup = {
+ ["0"]="0000",
+ ["1"]="0001",
+ ["2"]="0010",
+ ["3"]="0011",
+ ["4"]="0100",
+ ["5"]="0101",
+ ["6"]="0110",
+ ["7"]="0111",
+ ["8"]="1000",
+ ["9"]="1001",
+ ["a"]="1010",
+ ["b"]="1011",
+ ["c"]="1100",
+ ["d"]="1101",
+ ["e"]="1110",
+ ["f"]="1111",
+}
+setmetatable(bin_lookup, {
+ __index = function()
+ error("Error in ipOps.hex_to_bin: Expected string representing a hexadecimal number.")
+ end
+ })
+---
+-- Converts a string of hexadecimal digits into the corresponding string of
+-- binary digits.
+--
+-- Each hex digit results in four bits.
+-- @param hex String representing a hexadecimal number.
+-- @usage
+-- bin_string = ipOps.hex_to_bin( "F00D" )
+-- @return String representing the supplied number in binary digits (or
+-- <code>nil</code> in case of an error).
+-- @return String error message in case of an error.
+hex_to_bin = function( hex )
+ if type( hex ) ~= "string" then
+ return nil, "Error in ipOps.hex_to_bin: Expected string"
+ end
+
+ local status, result = pcall( string.gsub, string.lower(hex), ".", bin_lookup)
+ if status then
+ return result
+ end
+ return status, result
+end
+
+---
+-- Convert a CIDR subnet mask to dotted decimal notation.
+--
+-- @param subnet CIDR string representing the subnet mask.
+-- @usage
+-- local netmask = ipOps.cidr_to_subnet( "/16" )
+-- @return Dotted decimal representation of the suppliet subnet mask (e.g. "255.255.0.0")
+cidr_to_subnet = function( subnet )
+ local bits = subnet:match("/(%d%d)$")
+ if not bits then return nil end
+ return fromdword((0xFFFFFFFF >> tonumber(bits)) ~ 0xFFFFFFFF)
+end
+
+---
+-- Convert a dotted decimal subnet mask to CIDR notation.
+--
+-- @param subnet Dotted decimal string representing the subnet mask.
+-- @usage
+-- local cidr = ipOps.subnet_to_cidr( "255.255.0.0" )
+-- @return CIDR representation of the supplied subnet mask (e.g. "/16").
+subnet_to_cidr = function( subnet )
+ local dword, err = todword(subnet)
+ if not dword then return nil, err end
+ return "/" .. tostring(32 - (math.tointeger(math.log((dword ~ 0xFFFFFFFF) + 1, 2))))
+end
+
+--Ignore the rest if we are not testing.
+if not unittest.testing() then
+ return _ENV
+end
+
+test_suite = unittest.TestSuite:new()
+test_suite:add_test(unittest.is_true(isPrivate("192.168.123.123")), "192.168.123.123 is private")
+test_suite:add_test(unittest.is_false(isPrivate("1.1.1.1")), "1.1.1.1 is not private")
+test_suite:add_test(unittest.equal(todword("65.66.67.68"),0x41424344), "todword")
+test_suite:add_test(unittest.equal(todword("127.0.0.1"),0x7f000001), "todword")
+test_suite:add_test(unittest.equal(fromdword(0xffffffff),"255.255.255.255"), "fromdword")
+test_suite:add_test(unittest.equal(fromdword(0x7f000001),"127.0.0.1"), "fromdword")
+test_suite:add_test(unittest.equal(str_to_ip("\x01\x02\x03\x04"),"1.2.3.4"), "str_to_ip (ipv4)")
+test_suite:add_test(unittest.equal(str_to_ip("\0\x01\xbe\xef\0\0\0\0\0\0\x02\x03\0\0\0\x01"),"1:beef::203:0:1"), "str_to_ip (ipv6)")
+test_suite:add_test(unittest.equal(str_to_ip(("\0"):rep(15) .. "\x01"),"::1"), "str_to_ip (ipv6)")
+test_suite:add_test(function()
+ local parts, err = get_parts_as_number("8.255.0.1")
+ if parts == nil then return false, err end
+ if parts[1] == 8 and parts[2] == 255 and parts[3] == 0 and parts[4] == 1 then
+ return true
+ end
+ return false, string.format("Expected {8, 255, 0, 1}, got {%d, %d, %d, %d}", table.unpack(parts))
+end, "get_parts_as_number")
+
+do
+ local low_ip4 = "192.168.1.10"
+ local high_ip4 = "192.168.10.1"
+ local low_ip6 = "2001::DEAD:0:0:9"
+ local high_ip6 = "2001::DEAF:0:0:9"
+ for _, op in ipairs({
+ {low_ip4, "eq", low_ip4, unittest.is_true, "IPv4"},
+ {low_ip6, "eq", low_ip6, unittest.is_true, "IPv6"},
+ {high_ip4, "eq", low_ip4, unittest.is_false, "IPv4"},
+ {high_ip6, "eq", low_ip6, unittest.is_false, "IPv6"},
+ {low_ip4, "eq", low_ip6, unittest.is_false, "mixed"},
+ {low_ip4, "ne", low_ip4, unittest.is_false, "IPv4"},
+ {low_ip6, "ne", low_ip6, unittest.is_false, "IPv6"},
+ {high_ip4, "ne", low_ip4, unittest.is_true, "IPv4"},
+ {high_ip6, "ne", low_ip6, unittest.is_true, "IPv6"},
+ {low_ip4, "ne", low_ip6, unittest.is_true, "mixed"},
+ {low_ip4, "ge", low_ip4, unittest.is_true, "IPv4, equal"},
+ {low_ip6, "ge", low_ip6, unittest.is_true, "IPv6, equal"},
+ {high_ip4, "ge", low_ip4, unittest.is_true, "IPv4"},
+ {high_ip6, "ge", low_ip6, unittest.is_true, "IPv6"},
+ {low_ip4, "ge", high_ip4, unittest.is_false, "IPv4"},
+ {low_ip6, "ge", high_ip6, unittest.is_false, "IPv6"},
+ {low_ip6, "ge", low_ip4, unittest.is_true, "mixed"},
+ {low_ip4, "ge", low_ip6, unittest.is_false, "mixed"},
+ {low_ip4, "le", low_ip4, unittest.is_true, "IPv4, equal"},
+ {low_ip6, "le", low_ip6, unittest.is_true, "IPv6, equal"},
+ {high_ip4, "le", low_ip4, unittest.is_false, "IPv4"},
+ {high_ip6, "le", low_ip6, unittest.is_false, "IPv6"},
+ {low_ip4, "le", high_ip4, unittest.is_true, "IPv4"},
+ {low_ip6, "le", high_ip6, unittest.is_true, "IPv6"},
+ {low_ip6, "le", low_ip4, unittest.is_false, "mixed"},
+ {low_ip4, "le", low_ip6, unittest.is_true, "mixed"},
+ {low_ip4, "gt", low_ip4, unittest.is_false, "IPv4, equal"},
+ {low_ip6, "gt", low_ip6, unittest.is_false, "IPv6, equal"},
+ {high_ip4, "gt", low_ip4, unittest.is_true, "IPv4"},
+ {high_ip6, "gt", low_ip6, unittest.is_true, "IPv6"},
+ {low_ip4, "gt", high_ip4, unittest.is_false, "IPv4"},
+ {low_ip6, "gt", high_ip6, unittest.is_false, "IPv6"},
+ {low_ip6, "gt", low_ip4, unittest.is_true, "mixed"},
+ {low_ip4, "gt", low_ip6, unittest.is_false, "mixed"},
+ {low_ip4, "lt", low_ip4, unittest.is_false, "IPv4, equal"},
+ {low_ip6, "lt", low_ip6, unittest.is_false, "IPv6, equal"},
+ {high_ip4, "lt", low_ip4, unittest.is_false, "IPv4"},
+ {high_ip6, "lt", low_ip6, unittest.is_false, "IPv6"},
+ {low_ip4, "lt", high_ip4, unittest.is_true, "IPv4"},
+ {low_ip6, "lt", high_ip6, unittest.is_true, "IPv6"},
+ {low_ip6, "lt", low_ip4, unittest.is_false, "mixed"},
+ {low_ip4, "lt", low_ip6, unittest.is_true, "mixed"},
+ }) do
+ test_suite:add_test(op[4](compare_ip(op[1], op[2], op[3])),
+ string.format("compare_ip(%s, %s, %s) (%s)", op[1], op[2], op[3], op[5]))
+ end
+end
+
+do
+ for _, h in ipairs({
+ {"a", "1010"},
+ {"aA", "10101010"},
+ {"12", "00010010"},
+ {"54321", "01010100001100100001"},
+ {"123error", false},
+ {"", ""},
+ {"bad 123", false},
+ }) do
+ test_suite:add_test(unittest.equal(hex_to_bin(h[1]), h[2]))
+ end
+end
+
+do
+ for _, op in ipairs({
+ {"192.168.13.1", "192/8", unittest.is_true, "IPv4 CIDR"},
+ {"193.168.13.1", "192/8", unittest.is_false, "IPv4 CIDR"},
+ {"192.168.13.0", "192.168.13.128/24", unittest.is_true, "IPv4 CIDR"},
+ {"193.168.13.0", "192.168.13.128/24", unittest.is_false, "IPv4 CIDR"},
+ {"2001:db8::9", "2001:db8/32", unittest.is_true, "IPv6 CIDR"},
+ {"2001:db7::9", "2001:db8/32", unittest.is_false, "IPv6 CIDR"},
+ {"2001:db8::9", "2001:db8::1:0/32", unittest.is_true, "IPv6 CIDR"},
+ {"2001:db7::9", "2001:db8::1:0/32", unittest.is_false, "IPv6 CIDR"},
+ {"192.168.13.1", "192.168.10.33-192.168.80.80", unittest.is_true, "IPv4 range"},
+ {"193.168.13.1", "192.168.1.1 - 192.168.5.0", unittest.is_false, "IPv4 range"},
+ {"2001:db8::9", "2001:db8::1-2001:db8:1::1", unittest.is_true, "IPv6 range"},
+ {"2001:db8::9", "2001:db8:10::1-2001:db8:11::1", unittest.is_false, "IPv6 range"},
+ {"193.168.1.1", "192.168.1.1 - 2001:db8::1", unittest.is_nil, "mixed"},
+ {"2001:db8::1", "192.168.1.1 - 2001:db8::1", unittest.is_nil, "mixed"},
+ }) do
+ test_suite:add_test(op[3](ip_in_range(op[1], op[2])),
+ string.format("ip_in_range(%s, %s) (%s)", op[1], op[2], op[4]))
+ end
+end
+
+do
+ for _, op in ipairs({
+ {"192.168", nil, "192.168.0.0", "IPv4 trunc"},
+ {"192.0.2.3", nil, "192.0.2.3", "IPv4"},
+ {"192.168", "inet6", "0:0:0:0:0:ffff:c0a8:0", "IPv4 trunc to IPv6"},
+ {"2001:db8::9", nil, "2001:db8:0:0:0:0:0:9", "IPv6"},
+ {"::ffff:192.0.2.128", "inet6", "0:0:0:0:0:ffff:c000:280", "IPv4-mapped to IPv6"},
+ -- TODO: Perhaps we should support extracting IPv4 from IPv4-mapped addresses?
+ --{"::ffff:192.0.2.128", "inet4", "192.0.2.128", "IPv4-mapped to IPv4"},
+ --{"::ffff:c000:0280", "inet4", "192.0.2.128", "IPv4-mapped to IPv4"},
+ }) do
+ test_suite:add_test(unittest.equal(expand_ip(op[1], op[2]), op[3]),
+ string.format("expand_ip(%s, %s) (%s)", op[1], op[2], op[4]))
+ end
+ test_suite:add_test(unittest.is_nil(expand_ip("2001:db8::1", "ipv4")),
+ "IPv6 to IPv4")
+end
+test_suite:add_test(unittest.equal(cidr_to_subnet("/16"), "255.255.0.0"), "cidr_to_subnet")
+test_suite:add_test(unittest.equal(subnet_to_cidr("255.255.0.0"), "/16"), "subnet_to_cidr")
+
+return _ENV;