Files
kong--kong/kong/tools/ip.lua
T
2026-07-13 12:32:21 +08:00

318 lines
8.9 KiB
Lua

local ipmatcher = require "resty.ipmatcher"
local type = type
local ipairs = ipairs
local tonumber = tonumber
local tostring = tostring
local gsub = string.gsub
local fmt = string.format
local lower = string.lower
local find = string.find
local splitn = require("kong.tools.string").splitn
local split_once = require("kong.tools.string").split_once
local _M = {}
local ipv4_prefixes = {}
for i = 0, 32 do
ipv4_prefixes[tostring(i)] = i
end
local ipv6_prefixes = {}
for i = 0, 128 do
ipv6_prefixes[tostring(i)] = i
end
local function split_cidr(cidr, prefixes)
local ip, prefix = split_once(cidr, "/")
if not ip then
return
end
return ip, prefixes[prefix]
end
local function validate(input, f1, f2, prefixes)
if type(input) ~= "string" then
return false
end
if prefixes then
local ip, prefix = split_cidr(input, prefixes)
if not ip or not prefix then
return false
end
input = ip
end
if f1(input) then
return true
end
if f2 and f2(input) then
return true
end
return false
end
function _M.is_valid_ipv4(ipv4)
return validate(ipv4, ipmatcher.parse_ipv4)
end
function _M.is_valid_ipv6(ipv6)
return validate(ipv6, ipmatcher.parse_ipv6)
end
function _M.is_valid_ip(ip)
return validate(ip, ipmatcher.parse_ipv4, ipmatcher.parse_ipv6)
end
function _M.is_valid_cidr_v4(cidr_v4)
return validate(cidr_v4, ipmatcher.parse_ipv4, nil, ipv4_prefixes)
end
function _M.is_valid_cidr_v6(cidr_v6)
return validate(cidr_v6, ipmatcher.parse_ipv6, nil, ipv6_prefixes)
end
function _M.is_valid_cidr(cidr)
return validate(cidr, _M.is_valid_cidr_v4, _M.is_valid_cidr_v6)
end
function _M.is_valid_ip_or_cidr_v4(ip_or_cidr_v4)
return validate(ip_or_cidr_v4, ipmatcher.parse_ipv4, _M.is_valid_cidr_v4)
end
function _M.is_valid_ip_or_cidr_v6(ip_or_cidr_v6)
return validate(ip_or_cidr_v6, ipmatcher.parse_ipv6, _M.is_valid_cidr_v6)
end
function _M.is_valid_ip_or_cidr(ip_or_cidr)
return validate(ip_or_cidr, _M.is_valid_ip, _M.is_valid_cidr)
end
--- checks the hostname type; ipv4, ipv6, or name.
-- Type is determined by exclusion, not by validation. So if it returns 'ipv6' then
-- it can only be an ipv6, but it is not necessarily a valid ipv6 address.
-- @param name the string to check (this may contain a portnumber)
-- @return string either; 'ipv4', 'ipv6', or 'name'
-- @usage hostname_type("123.123.123.123") --> "ipv4"
-- hostname_type("::1") --> "ipv6"
-- hostname_type("some::thing") --> "ipv6", but invalid...
function _M.hostname_type(name)
local remainder, colons = gsub(name, ":", "")
if colons > 1 then
return "ipv6"
end
if remainder:match("^[%d%.]+$") then
return "ipv4"
end
return "name"
end
--- parses, validates and normalizes an ipv4 address.
-- @param address the string containing the address (formats; ipv4, ipv4:port)
-- @return normalized address (string) + port (number or nil), or alternatively nil+error
function _M.normalize_ipv4(address)
local a,b,c,d,port
if address:find(":", 1, true) then
-- has port number
a,b,c,d,port = address:match("^(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?):(%d+)$")
else
-- without port number
a,b,c,d,port = address:match("^(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)%.(%d%d?%d?)$")
end
if not a then
return nil, "invalid ipv4 address: " .. address
end
a,b,c,d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if a < 0 or a > 255 or b < 0 or b > 255 or c < 0 or
c > 255 or d < 0 or d > 255 then
return nil, "invalid ipv4 address: " .. address
end
if port then
port = tonumber(port)
if port > 65535 then
return nil, "invalid port number"
end
end
return fmt("%d.%d.%d.%d",a,b,c,d), port
end
--- parses, validates and normalizes an ipv6 address.
-- @param address the string containing the address (formats; ipv6, [ipv6], [ipv6]:port)
-- @return normalized expanded address (string) + port (number or nil), or alternatively nil+error
function _M.normalize_ipv6(address)
local check, port = address:match("^(%b[])(.-)$")
if port == "" then
port = nil
end
if check then
check = check:sub(2, -2) -- drop the brackets
-- we have ipv6 in brackets, now get port if we got something left
if port then
port = port:match("^:(%d-)$")
if not port then
return nil, "invalid ipv6 address"
end
port = tonumber(port)
if port > 65535 then
return nil, "invalid port number"
end
end
else
-- no brackets, so full address only; no brackets, no port
check = address
port = nil
end
-- check ipv6 format and normalize
if check:sub(1,1) == ":" then
check = "0" .. check
end
if check:sub(-1,-1) == ":" then
check = check .. "0"
end
if check:find("::", 1, true) then
-- expand double colon
local _, count = gsub(check, ":", "")
local ins = ":" .. string.rep("0:", 8 - count)
check = gsub(check, "::", ins, 1) -- replace only 1 occurence!
end
local a,b,c,d,e,f,g,h = check:match("^(%x%x?%x?%x?):(%x%x?%x?%x?):(%x%x?%x?%x?):(%x%x?%x?%x?):(%x%x?%x?%x?):(%x%x?%x?%x?):(%x%x?%x?%x?):(%x%x?%x?%x?)$")
if not a then
-- not a valid IPv6 address
return nil, "invalid ipv6 address: " .. address
end
local zeros = "0000"
return lower(fmt("%s:%s:%s:%s:%s:%s:%s:%s",
zeros:sub(1, 4 - #a) .. a,
zeros:sub(1, 4 - #b) .. b,
zeros:sub(1, 4 - #c) .. c,
zeros:sub(1, 4 - #d) .. d,
zeros:sub(1, 4 - #e) .. e,
zeros:sub(1, 4 - #f) .. f,
zeros:sub(1, 4 - #g) .. g,
zeros:sub(1, 4 - #h) .. h)), port
end
--- parses and validates a hostname.
-- @param address the string containing the hostname (formats; name, name:port)
-- @return hostname (string) + port (number or nil), or alternatively nil+error
function _M.check_hostname(address)
local name = address
local port = address:match(":(%d+)$")
if port then
name = name:sub(1, -(#port+2))
port = tonumber(port)
if port > 65535 then
return nil, "invalid port number"
end
end
local match = name:match("^[%d%a%-%.%_]+$")
if match == nil then
return nil, "invalid hostname: " .. address
end
-- Reject prefix/trailing dashes and dots in each segment
-- notes:
-- - punycode allows prefixed dash, if the characters before the dash are escaped
-- - FQDN can end in dots
local t, count = splitn(name, ".")
for index, segment in ipairs(t) do
if segment:match("-$") or segment:match("^%.") or segment:match("%.$") or
(segment == "" and index ~= count) then
return nil, "invalid hostname: " .. address
end
end
return name, port
end
local verify_types = {
ipv4 = _M.normalize_ipv4,
ipv6 = _M.normalize_ipv6,
name = _M.check_hostname,
}
--- verifies and normalizes ip adresses and hostnames. Supports ipv4, ipv4:port, ipv6, [ipv6]:port, name, name:port.
-- Returned ipv4 addresses will have no leading zero's, ipv6 will be fully expanded without brackets.
-- Note: a name will not be normalized!
-- @param address string containing the address
-- @return table with the following fields: `host` (string; normalized address, or name), `type` (string; 'ipv4', 'ipv6', 'name'), and `port` (number or nil), or alternatively nil+error on invalid input
function _M.normalize_ip(address)
local atype = _M.hostname_type(address)
local addr, port = verify_types[atype](address)
if not addr then
return nil, port
end
return {
type = atype,
host = addr,
port = port,
}
end
--- Formats an ip address or hostname with an (optional) port for use in urls.
-- Supports ipv4, ipv6 and names.
--
-- Explicitly accepts 'nil+error' as input, to pass through any errors from the normalizing and name checking functions.
-- @param p1 address to format, either string with name/ip, table returned from `normalize_ip`, or from the `socket.url` library.
-- @param p2 port (optional) if p1 is a table, then this port will be inserted if no port-field is in the table
-- @return formatted address or nil+error
-- @usage
-- local addr, err = format_ip(normalize_ip("001.002.003.004:123")) --> "1.2.3.4:123"
-- local addr, err = format_ip(normalize_ip("::1")) --> "[0000:0000:0000:0000:0000:0000:0000:0001]"
-- local addr, err = format_ip("::1", 80)) --> "[::1]:80"
-- local addr, err = format_ip(check_hostname("//bad .. name\\")) --> nil, "invalid hostname: ... "
function _M.format_host(p1, p2)
local t = type(p1)
if t == "nil" then
return p1, p2 -- just pass through any errors passed in
end
local host, port, typ
if t == "table" then
port = p1.port or p2
host = p1.host
typ = p1.type or _M.hostname_type(host)
elseif t == "string" then
port = p2
host = p1
typ = _M.hostname_type(host)
else
return nil, "cannot format type '" .. t .. "'"
end
if typ == "ipv6" and not find(host, "[", nil, true) then
return "[" .. _M.normalize_ipv6(host) .. "]" .. (port and ":" .. port or "")
else
return host .. (port and ":" .. port or "")
end
end
return _M