Module:IP: Difference between revisions
Jump to navigation
Jump to search
(use require('strict') instead of require('Module:No globals')) |
m (1 revision imported) |
(No difference)
|
Latest revision as of 03:03, 8 February 2024
Documentation for this module may be created at Module:IP/doc
-- IP library -- This library contains classes for working with IP addresses and IP ranges. -- Load modules require('strict') local bit32 = require('bit32') local libraryUtil = require('libraryUtil') local checkType = libraryUtil.checkType local checkTypeMulti = libraryUtil.checkTypeMulti local makeCheckSelfFunction = libraryUtil.makeCheckSelfFunction -- Constants local V4 = 'IPv4' local V6 = 'IPv6' -------------------------------------------------------------------------------- -- Helper functions -------------------------------------------------------------------------------- local function makeValidationFunction(className, isObjectFunc) -- Make a function for validating a specific object. return function (methodName, argIdx, arg) if not isObjectFunc(arg) then error(string.format( "bad argument #%d to '%s' (not a valid %s object)", argIdx, methodName, className ), 3) end end end -------------------------------------------------------------------------------- -- Collection class -- This is a table used to hold items. -------------------------------------------------------------------------------- local Collection = {} Collection.__index = Collection function Collection:add(item) if item ~= nil then self.n = self.n + 1 self[self.n] = item end end function Collection:join(sep) return table.concat(self, sep) end function Collection:remove(pos) if self.n > 0 and (pos == nil or (0 < pos and pos <= self.n)) then self.n = self.n - 1 return table.remove(self, pos) end end function Collection:sort(comp) table.sort(self, comp) end function Collection:deobjectify() -- Turns the collection into a plain array without any special properties -- or methods. self.n = nil setmetatable(self, nil) end function Collection.new() return setmetatable({n = 0}, Collection) end -------------------------------------------------------------------------------- -- RawIP class -- Numeric representation of an IPv4 or IPv6 address. Used internally. -- A RawIP object is constructed by adding data to a Collection object and -- then giving it a new metatable. This is to avoid the memory overhead of -- copying the data to a new table. -------------------------------------------------------------------------------- local RawIP = {} RawIP.__index = RawIP -- Constructors function RawIP.newFromIPv4(ipStr) -- Return a RawIP object if ipStr is a valid IPv4 string. Otherwise, -- return nil. -- This representation is for compatibility with IPv6 addresses. local octets = Collection.new() local s = ipStr:match('^%s*(.-)%s*$') .. '.' for item in s:gmatch('(.-)%.') do octets:add(item) end if octets.n == 4 then for i, s in ipairs(octets) do if s:match('^%d+$') then local num = tonumber(s) if 0 <= num and num <= 255 then if num > 0 and s:match('^0') then -- A redundant leading zero is for an IP in octal. return nil end octets[i] = num else return nil end else return nil end end local parts = Collection.new() for i = 1, 3, 2 do parts:add(octets[i] * 256 + octets[i+1]) end return setmetatable(parts, RawIP) end return nil end function RawIP.newFromIPv6(ipStr) -- Return a RawIP object if ipStr is a valid IPv6 string. Otherwise, -- return nil. ipStr = ipStr:match('^%s*(.-)%s*$') local _, n = ipStr:gsub(':', ':') if n < 7 then ipStr = ipStr:gsub('::', string.rep(':', 9 - n)) end local parts = Collection.new() for item in (ipStr .. ':'):gmatch('(.-):') do parts:add(item) end if parts.n == 8 then for i, s in ipairs(parts) do if s == '' then parts[i] = 0 else if s:match('^%x+$') then local num = tonumber(s, 16) if num and 0 <= num and num <= 65535 then parts[i] = num else return nil end else return nil end end end return setmetatable(parts, RawIP) end return nil end function RawIP.newFromIP(ipStr) -- Return a new RawIP object from either an IPv4 string or an IPv6 -- string. If ipStr is not a valid IPv4 or IPv6 string, then return -- nil. return RawIP.newFromIPv4(ipStr) or RawIP.newFromIPv6(ipStr) end -- Methods function RawIP:getVersion() -- Return a string with the version of the IP protocol we are using. return self.n == 2 and V4 or V6 end function RawIP:isIPv4() -- Return true if this is an IPv4 representation, and false otherwise. return self.n == 2 end function RawIP:isIPv6() -- Return true if this is an IPv6 representation, and false otherwise. return self.n == 8 end function RawIP:getBitLength() -- Return the bit length of the IP address. return self.n * 16 end function RawIP:getAdjacent(previous) -- Return a RawIP object for an adjacent IP address. If previous is true -- then the previous IP is returned; otherwise the next IP is returned. -- Will wraparound: -- next 255.255.255.255 → 0.0.0.0 -- ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff → :: -- previous 0.0.0.0 → 255.255.255.255 -- :: → ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff local result = Collection.new() result.n = self.n local carry = previous and 0xffff or 1 for i = self.n, 1, -1 do local sum = self[i] + carry if sum >= 0x10000 then carry = previous and 0x10000 or 1 sum = sum - 0x10000 else carry = previous and 0xffff or 0 end result[i] = sum end return setmetatable(result, RawIP) end function RawIP:getPrefix(bitLength) -- Return a RawIP object for the prefix of the current IP Address with a -- bit length of bitLength. local result = Collection.new() result.n = self.n for i = 1, self.n do if bitLength > 0 then if bitLength >= 16 then result[i] = self[i] bitLength = bitLength - 16 else result[i] = bit32.replace(self[i], 0, 0, 16 - bitLength) bitLength = 0 end else result[i] = 0 end end return setmetatable(result, RawIP) end function RawIP:getHighestHost(bitLength) -- Return a RawIP object for the highest IP with the prefix of length -- bitLength. In other words, the network (the most-significant bits) -- is the same as the current IP's, but the host bits (the -- least-significant bits) are all set to 1. local bits = self.n * 16 local width if bitLength <= 0 then width = bits elseif bitLength >= bits then width = 0 else width = bits - bitLength end local result = Collection.new() result.n = self.n for i = self.n, 1, -1 do if width > 0 then if width >= 16 then result[i] = 0xffff width = width - 16 else result[i] = bit32.replace(self[i], 0xffff, 0, width) width = 0 end else result[i] = self[i] end end return setmetatable(result, RawIP) end function RawIP:_makeIPv6String() -- Return an IPv6 string representation of the object. Behavior is -- undefined if the current object is IPv4. local z1, z2 -- indices of run of zeroes to be displayed as "::" local zstart, zcount for i = 1, 9 do -- Find left-most occurrence of longest run of two or more zeroes. if i < 9 and self[i] == 0 then if zstart then zcount = zcount + 1 else zstart = i zcount = 1 end else if zcount and zcount > 1 then if not z1 or zcount > z2 - z1 + 1 then z1 = zstart z2 = zstart + zcount - 1 end end zstart = nil zcount = nil end end local parts = Collection.new() for i = 1, 8 do if z1 and z1 <= i and i <= z2 then if i == z1 then if z1 == 1 or z2 == 8 then if z1 == 1 and z2 == 8 then return '::' end parts:add(':') else parts:add('') end end else parts:add(string.format('%x', self[i])) end end return parts:join(':') end function RawIP:_makeIPv4String() -- Return an IPv4 string representation of the object. Behavior is -- undefined if the current object is IPv6. local parts = Collection.new() for i = 1, 2 do local w = self[i] parts:add(math.floor(w / 256)) parts:add(w % 256) end return parts:join('.') end function RawIP:__tostring() -- Return a string equivalent to given IP address (IPv4 or IPv6). if self.n == 2 then return self:_makeIPv4String() else return self:_makeIPv6String() end end function RawIP:__lt(obj) if self.n == obj.n then for i = 1, self.n do if self[i] ~= obj[i] then return self[i] < obj[i] end end return false end return self.n < obj.n end function RawIP:__eq(obj) if self.n == obj.n then for i = 1, self.n do if self[i] ~= obj[i] then return false end end return true end return false end -------------------------------------------------------------------------------- -- Initialize private methods available to IPAddress and Subnet -------------------------------------------------------------------------------- -- Both IPAddress and Subnet need access to each others' private constructor -- functions. IPAddress must be able to make Subnet objects from CIDR strings -- and from RawIP objects, and Subnet must be able to make IPAddress objects -- from IP strings and from RawIP objects. These constructors must all be -- private to ensure correct error levels and to stop other modules from having -- to worry about RawIP objects. Because they are private, they must be -- initialized here. local makeIPAddress, makeIPAddressFromRaw, makeSubnet, makeSubnetFromRaw -- Objects need to be able to validate other objects that they are passed -- as input, so initialize those functions here as well. local validateCollection, validateIPAddress, validateSubnet -------------------------------------------------------------------------------- -- IPAddress class -- Represents a single IPv4 or IPv6 address. -------------------------------------------------------------------------------- local IPAddress = {} do -- dataKey is a unique key to access objects' internal data. This is needed -- to access the RawIP objects contained in other IPAddress objects so that -- they can be compared with the current object's RawIP object. This data -- is not available to other classes or other modules. local dataKey = {} -- Private static methods local function isIPAddressObject(val) return type(val) == 'table' and val[dataKey] ~= nil end validateIPAddress = makeValidationFunction('IPAddress', isIPAddressObject) -- Metamethods that don't need upvalues local function ipEquals(ip1, ip2) return ip1[dataKey].rawIP == ip2[dataKey].rawIP end local function ipLessThan(ip1, ip2) return ip1[dataKey].rawIP < ip2[dataKey].rawIP end local function concatIP(ip, val) return tostring(ip) .. tostring(val) end local function ipToString(ip) return ip:getIP() end -- Constructors makeIPAddressFromRaw = function (rawIP) -- Constructs a new IPAddress object from a rawIP object. This function -- is for internal use; it is called by IPAddress.new and from other -- IPAddress methods, and should be available to the Subnet class, but -- should not be available to other modules. assert(type(rawIP) == 'table', 'rawIP was type ' .. type(rawIP) .. '; expected type table') -- Set up structure local obj = {} local data = {} data.rawIP = rawIP -- A function to check whether methods are called with a valid self -- parameter. local checkSelf = makeCheckSelfFunction( 'IP', 'ipAddress', obj, 'IPAddress object' ) -- Public methods function obj:getIP() checkSelf(self, 'getIP') return tostring(data.rawIP) end function obj:getVersion() checkSelf(self, 'getVersion') return data.rawIP:getVersion() end function obj:isIPv4() checkSelf(self, 'isIPv4') return data.rawIP:isIPv4() end function obj:isIPv6() checkSelf(self, 'isIPv6') return data.rawIP:isIPv6() end function obj:isInCollection(collection) checkSelf(self, 'isInCollection') validateCollection('isInCollection', 1, collection) return collection:containsIP(self) end function obj:isInSubnet(subnet) checkSelf(self, 'isInSubnet') local tp = type(subnet) if tp == 'string' then subnet = makeSubnet(subnet) elseif tp == 'table' then validateSubnet('isInSubnet', 1, subnet) else checkTypeMulti('isInSubnet', 1, subnet, {'string', 'table'}) end return subnet:containsIP(self) end function obj:getSubnet(bitLength) checkSelf(self, 'getSubnet') checkType('getSubnet', 1, bitLength, 'number') if bitLength < 0 or bitLength > data.rawIP:getBitLength() or bitLength ~= math.floor(bitLength) then error(string.format( "bad argument #1 to 'getSubnet' (must be an integer between 0 and %d)", data.rawIP:getBitLength() ), 2) end return makeSubnetFromRaw(data.rawIP, bitLength) end function obj:getNextIP() checkSelf(self, 'getNextIP') return makeIPAddressFromRaw(data.rawIP:getAdjacent()) end function obj:getPreviousIP() checkSelf(self, 'getPreviousIP') return makeIPAddressFromRaw(data.rawIP:getAdjacent(true)) end -- Metamethods return setmetatable(obj, { __eq = ipEquals, __lt = ipLessThan, __concat = concatIP, __tostring = ipToString, __index = function (self, key) -- If any code knows the unique data key, allow it to access -- the data table. if key == dataKey then return data end end, __metatable = false, -- don't allow access to the metatable }) end makeIPAddress = function (ip) local rawIP = RawIP.newFromIP(ip) if not rawIP then error(string.format("'%s' is an invalid IP address", ip), 3) end return makeIPAddressFromRaw(rawIP) end function IPAddress.new(ip) checkType('IPAddress.new', 1, ip, 'string') return makeIPAddress(ip) end end -------------------------------------------------------------------------------- -- Subnet class -- Represents a block of IPv4 or IPv6 addresses. -------------------------------------------------------------------------------- local Subnet = {} do -- uniqueKey is a unique, private key used to test whether a given object -- is a Subnet object. local uniqueKey = {} -- Metatable local mt = { __index = function (self, key) if key == uniqueKey then return true end end, __eq = function (self, obj) return self:getCIDR() == obj:getCIDR() end, __concat = function (self, obj) return tostring(self) .. tostring(obj) end, __tostring = function (self) return self:getCIDR() end, __metatable = false } -- Private static methods local function isSubnetObject(val) -- Return true if val is a Subnet object, and false otherwise. return type(val) == 'table' and val[uniqueKey] ~= nil end -- Function to validate subnet objects. -- Params: -- methodName (string) - the name of the method being validated -- argIdx (number) - the position of the argument in the argument list -- arg - the argument to be validated validateSubnet = makeValidationFunction('Subnet', isSubnetObject) -- Constructors makeSubnetFromRaw = function (rawIP, bitLength) -- Set up structure local obj = setmetatable({}, mt) local data = { rawIP = rawIP, bitLength = bitLength, } -- A function to check whether methods are called with a valid self -- parameter. local checkSelf = makeCheckSelfFunction( 'IP', 'subnet', obj, 'Subnet object' ) -- Public methods function obj:getPrefix() checkSelf(self, 'getPrefix') if not data.prefix then data.prefix = makeIPAddressFromRaw( data.rawIP:getPrefix(data.bitLength) ) end return data.prefix end function obj:getHighestIP() checkSelf(self, 'getHighestIP') if not data.highestIP then data.highestIP = makeIPAddressFromRaw( data.rawIP:getHighestHost(data.bitLength) ) end return data.highestIP end function obj:getBitLength() checkSelf(self, 'getBitLength') return data.bitLength end function obj:getCIDR() checkSelf(self, 'getCIDR') return string.format( '%s/%d', tostring(self:getPrefix()), self:getBitLength() ) end function obj:getVersion() checkSelf(self, 'getVersion') return data.rawIP:getVersion() end function obj:isIPv4() checkSelf(self, 'isIPv4') return data.rawIP:isIPv4() end function obj:isIPv6() checkSelf(self, 'isIPv6') return data.rawIP:isIPv6() end function obj:containsIP(ip) checkSelf(self, 'containsIP') local tp = type(ip) if tp == 'string' then ip = makeIPAddress(ip) elseif tp == 'table' then validateIPAddress('containsIP', 1, ip) else checkTypeMulti('containsIP', 1, ip, {'string', 'table'}) end if self:getVersion() == ip:getVersion() then return self:getPrefix() <= ip and ip <= self:getHighestIP() end return false end function obj:overlapsCollection(collection) checkSelf(self, 'overlapsCollection') validateCollection('overlapsCollection', 1, collection) return collection:overlapsSubnet(self) end function obj:overlapsSubnet(subnet) checkSelf(self, 'overlapsSubnet') local tp = type(subnet) if tp == 'string' then subnet = makeSubnet(subnet) elseif tp == 'table' then validateSubnet('overlapsSubnet', 1, subnet) else checkTypeMulti('overlapsSubnet', 1, subnet, {'string', 'table'}) end if self:getVersion() == subnet:getVersion() then return ( subnet:getHighestIP() >= self:getPrefix() and subnet:getPrefix() <= self:getHighestIP() ) end return false end function obj:walk() checkSelf(self, 'walk') local started local current = self:getPrefix() local highest = self:getHighestIP() return function () if not started then started = true return current end if current < highest then current = current:getNextIP() return current end end end return obj end makeSubnet = function (cidr) -- Return a Subnet object from a CIDR string. If the CIDR string is -- invalid, throw an error. local lhs, rhs = cidr:match('^%s*(.-)/(%d+)%s*$') if lhs then local bits = lhs:find(':', 1, true) and 128 or 32 local n = tonumber(rhs) if n and n <= bits and (n == 0 or not rhs:find('^0')) then -- The right-hand side is a number between 0 and 32 (for IPv4) -- or 0 and 128 (for IPv6) and doesn't have any leading zeroes. local base = RawIP.newFromIP(lhs) if base then -- The left-hand side is a valid IP address. local prefix = base:getPrefix(n) if base == prefix then -- The left-hand side is the lowest IP in the subnet. return makeSubnetFromRaw(prefix, n) end end end end error(string.format("'%s' is an invalid CIDR string", cidr), 3) end function Subnet.new(cidr) checkType('Subnet.new', 1, cidr, 'string') return makeSubnet(cidr) end end -------------------------------------------------------------------------------- -- Ranges class -- Holds a list of IPAdress pairs representing contiguous IP ranges. -------------------------------------------------------------------------------- local Ranges = Collection.new() Ranges.__index = Ranges function Ranges.new() return setmetatable({}, Ranges) end function Ranges:add(ip1, ip2) validateIPAddress('add', 1, ip1) if ip2 ~= nil then validateIPAddress('add', 2, ip2) if ip1 > ip2 then error('The first IP must be less than or equal to the second', 2) end end Collection.add(self, {ip1, ip2 or ip1}) end function Ranges:merge() self:sort( function (lhs, rhs) -- Sort by second value, then first. if lhs[2] == rhs[2] then return lhs[1] < rhs[1] end return lhs[2] < rhs[2] end ) local pos = self.n while pos > 1 do for i = pos - 1, 1, -1 do local ip1 = self[i][2] local ip2 = ip1:getNextIP() if ip2 < ip1 then ip2 = ip1 -- don't wrap around end if self[pos][1] > ip2 then break end ip1 = self[i][1] ip2 = self[pos][1] self[i] = {ip1 > ip2 and ip2 or ip1, self[pos][2]} self:remove(pos) pos = pos - 1 if pos <= 1 then break end end pos = pos - 1 end end -------------------------------------------------------------------------------- -- IPCollection class -- Holds a list of IP addresses/subnets. Used internally. -- Each address/subnet has the same version (either IPv4 or IPv6). -------------------------------------------------------------------------------- local IPCollection = {} IPCollection.__index = IPCollection function IPCollection.new(version) assert( version == V4 or version == V6, 'IPCollection.new called with an invalid version' ) local obj = { version = version, -- V4 or V6 addresses = Collection.new(), -- valid IP addresses subnets = Collection.new(), -- valid subnets omitted = Collection.new(), -- not-quite valid strings } return obj end function IPCollection:getVersion() -- Return a string with the IP version of addresses in this collection. return self.version end function IPCollection:_store(hit, stripColons) local maker, location if hit:find('/', 1, true) then maker = Subnet.new location = self.subnets else maker = IPAddress.new location = self.addresses end local success, obj = pcall(maker, hit) if success then location:add(obj) else if stripColons then local colons, hit = hit:match('^(:*)(.*)') if colons ~= '' then self:_store(hit) return end end self.omitted:add(hit) end end function IPCollection:_assertVersion(version, msg) if self.version ~= version then error(msg, 3) end end function IPCollection:addIP(ip) local tp = type(ip) if tp == 'string' then ip = makeIPAddress(ip) elseif tp == 'table' then validateIPAddress('addIP', 1, ip) else checkTypeMulti('addIP', 1, ip, {'string', 'table'}) end self:_assertVersion(ip:getVersion(), 'addIP called with incorrect IP version') self.addresses:add(ip) return self end function IPCollection:addSubnet(subnet) local tp = type(subnet) if tp == 'string' then subnet = makeSubnet(subnet) elseif tp == 'table' then validateSubnet('addSubnet', 1, subnet) else checkTypeMulti('addSubnet', 1, subnet, {'string', 'table'}) end self:_assertVersion(subnet:getVersion(), 'addSubnet called with incorrect subnet version') self.subnets:add(subnet) return self end function IPCollection:containsIP(ip) -- Return true, obj if ip is in this collection, -- where obj is the first IPAddress or Subnet with the ip. -- Otherwise, return false. local tp = type(ip) if tp == 'string' then ip = makeIPAddress(ip) elseif tp == 'table' then validateIPAddress('containsIP', 1, ip) else checkTypeMulti('containsIP', 1, ip, {'string', 'table'}) end if self:getVersion() == ip:getVersion() then for _, item in ipairs(self.addresses) do if item == ip then return true, item end end for _, item in ipairs(self.subnets) do if item:containsIP(ip) then return true, item end end end return false end function IPCollection:getRanges() -- Return a sorted table of IP pairs equivalent to the collection. -- Each IP pair is a table representing a contiguous range of -- IP addresses from pair[1] to pair[2] inclusive (IPAddress objects). local ranges = Ranges.new() for _, item in ipairs(self.addresses) do ranges:add(item) end for _, item in ipairs(self.subnets) do ranges:add(item:getPrefix(), item:getHighestIP()) end ranges:merge() ranges:deobjectify() return ranges end function IPCollection:overlapsSubnet(subnet) -- Return true, obj if subnet overlaps this collection, -- where obj is the first IPAddress or Subnet overlapping the subnet. -- Otherwise, return false. local tp = type(subnet) if tp == 'string' then subnet = makeSubnet(subnet) elseif tp == 'table' then validateSubnet('overlapsSubnet', 1, subnet) else checkTypeMulti('overlapsSubnet', 1, subnet, {'string', 'table'}) end if self:getVersion() == subnet:getVersion() then for _, item in ipairs(self.addresses) do if subnet:containsIP(item) then return true, item end end for _, item in ipairs(self.subnets) do if subnet:overlapsSubnet(item) then return true, item end end end return false end -------------------------------------------------------------------------------- -- IPv4Collection class -- Holds a list of IPv4 addresses/subnets. -------------------------------------------------------------------------------- local IPv4Collection = setmetatable({}, IPCollection) IPv4Collection.__index = IPv4Collection function IPv4Collection.new() return setmetatable(IPCollection.new(V4), IPv4Collection) end function IPv4Collection:addFromString(text) -- Extract any IPv4 addresses or CIDR subnets from given text. checkType('addFromString', 1, text, 'string') text = text:gsub('[:!"#&\'()+,%-;<=>?[%]_{|}]', ' ') for hit in text:gmatch('%S+') do if hit:match('^%d+%.%d+[%.%d/]+$') then local _, n = hit:gsub('%.', '.') if n >= 3 then self:_store(hit) end end end return self end -------------------------------------------------------------------------------- -- IPv6Collection class -- Holds a list of IPv6 addresses/subnets. -------------------------------------------------------------------------------- local IPv6Collection = setmetatable({}, IPCollection) IPv6Collection.__index = IPv6Collection do -- Private static methods local function isCollectionObject(val) -- Return true if val is probably derived from an IPCollection object, -- otherwise return false. if type(val) == 'table' then local mt = getmetatable(val) if mt == IPv4Collection or mt == IPv6Collection then return true end end return false end validateCollection = makeValidationFunction('IPCollection', isCollectionObject) function IPv6Collection.new() return setmetatable(IPCollection.new(V6), IPv6Collection) end function IPv6Collection:addFromString(text) -- Extract any IPv6 addresses or CIDR subnets from given text. -- Want to accept all valid IPv6 despite the fact that addresses used -- are unlikely to start with ':'. -- Also want to be able to parse arbitrary wikitext which might use -- colons for indenting. -- Therefore, if an address at the start of a line is valid, use it; -- otherwise strip any leading colons and try again. checkType('addFromString', 1, text, 'string') for line in string.gmatch(text .. '\n', '[\t ]*(.-)[\t\r ]*\n') do line = line:gsub('[!"#&\'()+,%-;<=>?[%]_{|}]', ' ') for position, hit in line:gmatch('()(%S+)') do local ip = hit:match('^([:%x]+)/?%d*$') if ip then local _, n = ip:gsub(':', ':') if n >= 2 then self:_store(hit, position == 1) end end end end return self end end return { IPAddress = IPAddress, Subnet = Subnet, IPv4Collection = IPv4Collection, IPv6Collection = IPv6Collection, }