Files
2026-07-13 12:32:21 +08:00

744 lines
19 KiB
Lua

local cjson = require("cjson.safe")
local utils = require("kong.dns.utils")
local stats = require("kong.dns.stats")
local mlcache = require("kong.resty.mlcache")
local resolver = require("resty.dns.resolver")
local now = ngx.now
local log = ngx.log
local ERR = ngx.ERR
local WARN = ngx.WARN
local DEBUG = ngx.DEBUG
local ALERT = ngx.ALERT
local timer_at = ngx.timer.at
local worker_id = ngx.worker.id
local pairs = pairs
local ipairs = ipairs
local tonumber = tonumber
local setmetatable = setmetatable
local math_min = math.min
local math_floor = math.floor
local string_lower = string.lower
local table_insert = table.insert
local table_isempty = require("table.isempty")
local is_srv = utils.is_srv
local parse_hosts = utils.parse_hosts
local ipv6_bracket = utils.ipv6_bracket
local search_names = utils.search_names
local parse_resolv_conf = utils.parse_resolv_conf
local get_next_round_robin_answer = utils.get_next_round_robin_answer
local get_next_weighted_round_robin_answer = utils.get_next_weighted_round_robin_answer
local req_dyn_hook_run_hook = require("kong.dynamic_hook").run_hook
-- Constants and default values
local PREFIX = "[dns_client] "
local DEFAULT_ERROR_TTL = 1 -- unit: second
local DEFAULT_STALE_TTL = 3600
-- long-lasting TTL of 10 years for hosts or static IP addresses in cache settings
local LONG_LASTING_TTL = 10 * 365 * 24 * 60 * 60
local DEFAULT_FAMILY = { "SRV", "A", "AAAA" }
local TYPE_SRV = resolver.TYPE_SRV
local TYPE_A = resolver.TYPE_A
local TYPE_AAAA = resolver.TYPE_AAAA
local TYPE_A_OR_AAAA = -1 -- used to resolve IP addresses for SRV targets
local TYPE_TO_NAME = {
[TYPE_SRV] = "SRV",
[TYPE_A] = "A",
[TYPE_AAAA] = "AAAA",
[TYPE_A_OR_AAAA] = "A/AAAA",
}
local HIT_L3 = 3 -- L1 lru, L2 shm, L3 callback, L4 stale
local HIT_LEVEL_TO_NAME = {
[1] = "hit_lru",
[2] = "hit_shm",
[3] = "miss",
[4] = "hit_stale",
}
-- client specific error
local CACHE_ONLY_ERROR_CODE = 100
local CACHE_ONLY_ERROR_MESSAGE = "cache only lookup failed"
local CACHE_ONLY_ANSWERS = {
errcode = CACHE_ONLY_ERROR_CODE,
errstr = CACHE_ONLY_ERROR_MESSAGE,
}
local EMPTY_RECORD_ERROR_CODE = 101
local EMPTY_RECORD_ERROR_MESSAGE = "empty record received"
-- APIs
local _M = {
TYPE_SRV = TYPE_SRV,
TYPE_A = TYPE_A,
TYPE_AAAA = TYPE_AAAA,
}
local _MT = { __index = _M, }
local _TRIES_MT = { __tostring = cjson.encode, }
local init_hosts do
local function insert_answer_into_cache(cache, hosts_cache, address, name, qtype)
local answers = {
ttl = LONG_LASTING_TTL,
expire = now() + LONG_LASTING_TTL,
{
name = name,
type = qtype,
address = address,
class = 1,
ttl = LONG_LASTING_TTL,
},
}
hosts_cache[name .. ":" .. qtype] = answers
hosts_cache[name .. ":" .. TYPE_A_OR_AAAA] = answers
end
-- insert hosts into cache
function init_hosts(cache, path)
local hosts = parse_hosts(path)
local hosts_cache = {}
for name, address in pairs(hosts) do
name = string_lower(name)
if address.ipv6 then
insert_answer_into_cache(cache, hosts_cache, address.ipv6, name, TYPE_AAAA)
end
if address.ipv4 then
insert_answer_into_cache(cache, hosts_cache, address.ipv4, name, TYPE_A)
end
end
return hosts, hosts_cache
end
end
-- distinguish the worker_events sources registered by different new() instances
local ipc_counter = 0
function _M.new(opts)
opts = opts or {}
local enable_ipv4, enable_ipv6, enable_srv
for _, typstr in ipairs(opts.family or DEFAULT_FAMILY) do
typstr = typstr:upper()
if typstr == "A" then
enable_ipv4 = true
elseif typstr == "AAAA" then
enable_ipv6 = true
elseif typstr == "SRV" then
enable_srv = true
else
return nil, "Invalid dns type in dns_family array: " .. typstr
end
end
log(DEBUG, PREFIX, "supported types: ", enable_srv and "srv " or "",
enable_ipv4 and "ipv4 " or "", enable_ipv6 and "ipv6 " or "")
-- parse resolv.conf
local resolv, err = parse_resolv_conf(opts.resolv_conf, opts.enable_ipv6)
if not resolv then
log(WARN, PREFIX, "Invalid resolv.conf: ", err)
resolv = { options = {} }
end
-- init the resolver options for lua-resty-dns
local nameservers = (opts.nameservers and not table_isempty(opts.nameservers))
and opts.nameservers
or resolv.nameservers
if not nameservers or table_isempty(nameservers) then
log(WARN, PREFIX, "Invalid configuration, no nameservers specified")
end
local no_random
if opts.random_resolver == nil then
no_random = not resolv.options.rotate
else
no_random = not opts.random_resolver
end
local r_opts = {
retrans = opts.retrans or resolv.options.attempts or 5,
timeout = opts.timeout or resolv.options.timeout or 2000, -- ms
no_random = no_random,
nameservers = nameservers,
}
-- init the mlcache
-- maximum timeout for the underlying r:query() operation to complete
-- socket timeout * retrans * 2 calls for send and receive + 1s extra delay
local lock_timeout = r_opts.timeout / 1000 * r_opts.retrans * 2 + 1 -- s
local resty_lock_opts = {
timeout = lock_timeout,
exptimeout = lock_timeout + 1,
}
-- TODO: convert the ipc a module constant, currently we need to use the
-- ipc_source to distinguish sources of different DNS client events.
ipc_counter = ipc_counter + 1
local ipc_source = "dns_client_mlcache#" .. ipc_counter
local ipc = {
register_listeners = function(events)
-- The DNS client library will be required in globalpatches before Kong
-- initializes worker_events.
if not kong or not kong.worker_events then
return
end
local cwid = worker_id() or -1
for _, ev in pairs(events) do
local handler = function(data, event, source, wid)
if cwid ~= wid then -- Current worker has handled this event.
ev.handler(data)
end
end
kong.worker_events.register(handler, ipc_source, ev.channel)
end
end,
-- @channel: event channel name, such as "mlcache:invalidate:dns_cache"
-- @data: mlcache's key name, such as "<qname>:<qtype>"
broadcast = function(channel, data)
if not kong or not kong.worker_events then
return
end
local ok, err = kong.worker_events.post(ipc_source, channel, data)
if not ok then
log(ERR, PREFIX, "failed to post event '", ipc_source, "', '", channel, "': ", err)
end
end,
}
local cache, err = mlcache.new("dns_cache", "kong_dns_cache", {
ipc = ipc,
neg_ttl = opts.error_ttl or DEFAULT_ERROR_TTL,
-- 10000 is a reliable and tested value from the original library.
lru_size = opts.cache_size or 10000,
shm_locks = ngx.shared.kong_locks and "kong_locks",
resty_lock_opts = resty_lock_opts,
})
if not cache then
return nil, "could not create mlcache: " .. err
end
if opts.cache_purge then
cache:purge(true)
end
-- parse hosts
local hosts, hosts_cache = init_hosts(cache, opts.hosts)
return setmetatable({
cache = cache,
stats = stats.new(),
hosts = hosts,
r_opts = r_opts,
resolv = opts._resolv or resolv,
valid_ttl = opts.valid_ttl,
error_ttl = opts.error_ttl or DEFAULT_ERROR_TTL,
stale_ttl = opts.stale_ttl or DEFAULT_STALE_TTL,
enable_srv = enable_srv,
enable_ipv4 = enable_ipv4,
enable_ipv6 = enable_ipv6,
hosts_cache = hosts_cache,
-- TODO: Make the table readonly. But if `string.buffer.encode/decode` and
-- `pl.tablex.readonly` are called on it, it will become empty table.
--
-- quickly accessible constant empty answers
EMPTY_ANSWERS = {
errcode = EMPTY_RECORD_ERROR_CODE,
errstr = EMPTY_RECORD_ERROR_MESSAGE,
ttl = opts.error_ttl or DEFAULT_ERROR_TTL,
},
}, _MT)
end
local function process_answers(self, qname, qtype, answers)
local errcode = answers.errcode
if errcode then
answers.ttl = self.error_ttl
return answers
end
local processed_answers = {}
-- 0xffffffff for maximum TTL value
local ttl = math_min(self.valid_ttl or 0xffffffff, 0xffffffff)
for _, answer in ipairs(answers) do
answer.name = string_lower(answer.name)
if self.valid_ttl then
answer.ttl = self.valid_ttl
else
ttl = math_min(ttl, answer.ttl)
end
local answer_type = answer.type
if answer_type == qtype then
-- compatible with balancer, see https://github.com/Kong/kong/pull/3088
if answer_type == TYPE_AAAA then
answer.address = ipv6_bracket(answer.address)
elseif answer_type == TYPE_SRV then
answer.target = ipv6_bracket(answer.target)
end
table_insert(processed_answers, answer)
end
end
if table_isempty(processed_answers) then
log(DEBUG, PREFIX, "processed ans:empty")
return self.EMPTY_ANSWERS
end
log(DEBUG, PREFIX, "processed ans:", #processed_answers)
processed_answers.expire = now() + ttl
processed_answers.ttl = ttl
return processed_answers
end
local function set_resolver_for_curr_try(tries, server)
if not server or not tries then
return
end
-- server is a table with { host, port? }
local server_str = #server == 1 and server[1] or server[1] .. ":" .. server[2]
tries[#tries].resolver = server_str
end
local function set_for_curr_try(tries, key, value)
local current_try = tries[#tries]
if not current_try then
return
end
current_try[key] = value
end
local function add_for_curr_try(tries, key, value)
local current_try = tries[#tries]
if not current_try then
return
end
if not current_try[key] then
current_try[key] = {}
end
table_insert(current_try[key], value)
end
local function resolve_query(self, name, qtype, tries)
local key = name .. ":" .. qtype
local stats = self.stats
stats:incr(key, "query")
-- initialize try
table_insert(tries, {
qname = name,
qtype = qtype,
cache_hit = false,
})
local r, err = resolver:new(self.r_opts)
if not r then
return nil, "failed to instantiate the resolver: " .. err
end
-- set resolver address for current try
if r.servers and r.cur then
local current = r.cur == 1 and #r.servers or r.cur
-- validate undocumented fields
if type(current) ~= "number" and type(r.servers) ~= "table" then
log(ERR, PREFIX, "cannot read resolver info")
else
local current_server = r.servers[current]
set_resolver_for_curr_try(tries, current_server)
end
end
local start = now()
local answers, err = r:query(name, { qtype = qtype })
r:destroy()
local duration = math_floor((now() - start) * 1000)
stats:set(key, "query_last_time", duration)
log(DEBUG, PREFIX, "r:query(", key, ") ans:", answers and #answers or "-",
" t:", duration, " ms")
-- network error or malformed DNS response
if not answers then
stats:incr(key, "query_fail_nameserver")
err = "DNS server error: " .. tostring(err) .. ", took " .. duration .. " ms"
-- TODO: make the error more structured, like:
-- { qname = name, qtype = qtype, error = err, } or something similar
add_for_curr_try(tries, "error", err)
return nil, err
end
answers = process_answers(self, name, qtype, answers)
stats:incr(key, answers.errstr and
"query_fail:" .. answers.errstr or
"query_succ")
-- DNS response error
if answers.errcode then
err = ("dns %s error: %s %s"):format(
answers.errcode < CACHE_ONLY_ERROR_CODE and "server" or "client",
answers.errcode, answers.errstr)
add_for_curr_try(tries, "error", err)
set_for_curr_try(tries, "errcode", answers.errcode)
end
return answers
end
-- resolve all `name`s and return first usable answers
local function resolve_query_names(self, names, qtype, tries)
local answers, err
for _, qname in ipairs(names) do
answers, err = resolve_query(self, qname, qtype, tries)
-- severe error occurred
if not answers then
return nil, err
end
if not answers.errcode then
return answers, nil, answers.ttl
end
end
-- not found in the search iteration
return answers, nil, answers.ttl
end
local function resolve_query_types(self, name, qtype, tries)
local names = search_names(name, self.resolv, self.hosts)
local answers, err, ttl
-- the specific type
if qtype ~= TYPE_A_OR_AAAA then
return resolve_query_names(self, names, qtype, tries)
end
-- query A or AAAA
if self.enable_ipv4 then
answers, err, ttl = resolve_query_names(self, names, TYPE_A, tries)
if not answers or not answers.errcode then
return answers, err, ttl
end
end
if self.enable_ipv6 then
answers, err, ttl = resolve_query_names(self, names, TYPE_AAAA, tries)
end
return answers, err, ttl
end
local function stale_update_task(premature, self, key, name, qtype)
if premature then
return
end
local tries = setmetatable({}, _TRIES_MT)
local answers = resolve_query_types(self, name, qtype, tries)
if not answers or answers.errcode then
log(DEBUG, PREFIX, "failed to update stale DNS records: ", tostring(tries))
return
end
log(DEBUG, PREFIX, "update stale DNS records: ", #answers)
self.cache:set(key, { ttl = answers.ttl }, answers)
end
local function start_stale_update_task(self, key, name, qtype)
self.stats:incr(key, "stale")
local ok, err = timer_at(0, stale_update_task, self, key, name, qtype)
if not ok then
log(ALERT, PREFIX, "failed to start a timer to update stale DNS records: ", err)
end
end
local function check_and_get_ip_answers(name)
-- TODO: use is_valid_ipv4 from kong/tools/ip.lua instead
if name:match("^%d+%.%d+%.%d+%.%d+$") then -- IPv4
return {
{ name = name, class = 1, type = TYPE_A, address = name },
}
end
if name:find(":", 1, true) then -- IPv6
return {
{ name = name, class = 1, type = TYPE_AAAA, address = ipv6_bracket(name) },
}
end
return nil
end
local function resolve_callback(self, name, qtype, cache_only, tries)
-- check if name is ip address
local answers = check_and_get_ip_answers(name)
if answers then -- domain name is IP literal
answers.ttl = LONG_LASTING_TTL
answers.expire = now() + answers.ttl
return answers, nil, answers.ttl
end
-- check if this key exists in the hosts file (it maybe evicted from cache)
local key = name .. ":" .. qtype
local answers = self.hosts_cache[key]
if answers then
return answers, nil, answers.ttl
end
-- `:peek(stale=true)` verifies if the expired key remains in L2 shm, then
-- initiates an asynchronous background updating task to refresh it.
local ttl, _, answers = self.cache:peek(key, true)
if answers and not answers.errcode and self.stale_ttl and ttl then
-- `_expire_at` means the final expiration time of stale records
if not answers._expire_at then
answers._expire_at = answers.expire + self.stale_ttl
end
-- trigger the update task by the upper caller every 60 seconds
local remaining_stale_ttl = math_min(answers._expire_at - now(), 60)
if remaining_stale_ttl > 0 then
log(DEBUG, PREFIX, "start stale update task ", key,
" remaining_stale_ttl:", remaining_stale_ttl)
-- mlcache's internal lock mechanism ensures concurrent control
start_stale_update_task(self, key, name, qtype)
answers.ttl = remaining_stale_ttl
answers.expire = remaining_stale_ttl + now()
return answers, nil, remaining_stale_ttl
end
end
if cache_only then
return CACHE_ONLY_ANSWERS, nil, -1
end
log(DEBUG, PREFIX, "cache miss, try to query ", key)
return resolve_query_types(self, name, qtype, tries)
end
function _M:resolve_all(name, qtype, cache_only, tries, has_timing)
name = string_lower(name)
tries = setmetatable(tries or {}, _TRIES_MT)
if not qtype then
qtype = ((self.enable_srv and is_srv(name)) and TYPE_SRV or TYPE_A_OR_AAAA)
end
local key = name .. ":" .. qtype
--log(DEBUG, PREFIX, "resolve_all ", key)
local stats = self.stats
stats:incr(key, "runs")
local answers, err, hit_level = self.cache:get(key, nil, resolve_callback,
self, name, qtype, cache_only,
tries)
-- check for runtime errors in the callback
if err and err:sub(1, 8) == "callback" then
log(ALERT, PREFIX, err)
end
local hit_str = hit_level and HIT_LEVEL_TO_NAME[hit_level] or "fail"
stats:incr(key, hit_str)
if #tries == 0 then
-- initialize try
table_insert(tries, {
qname = name,
qtype = qtype,
cache_hit = true,
resolver = "cache",
})
end
--log(DEBUG, PREFIX, "cache lookup ", key, " ans:", answers and #answers or "-",
-- " hlv:", hit_str)
if has_timing then
req_dyn_hook_run_hook("timing", "dns:cache_lookup",
(hit_level and hit_level < HIT_L3))
end
if answers and answers.errcode then
err = ("dns %s error: %s %s"):format(
answers.errcode < CACHE_ONLY_ERROR_CODE and "server" or "client",
answers.errcode, answers.errstr)
return nil, err, tries
end
return answers, err, tries
end
function _M:resolve(name, qtype, cache_only, tries)
return self:resolve_all(name, qtype, cache_only, tries,
ngx.ctx and ngx.ctx.has_timing)
end
function _M:resolve_address(name, port, cache_only, tries)
local has_timing = ngx.ctx and ngx.ctx.has_timing
local answers, err, tries = self:resolve_all(name, nil, cache_only, tries,
has_timing)
if answers and answers[1] and answers[1].type == TYPE_SRV then
local answer = get_next_weighted_round_robin_answer(answers)
port = answer.port ~= 0 and answer.port or port
answers, err, tries = self:resolve_all(answer.target, TYPE_A_OR_AAAA,
cache_only, tries, has_timing)
end
if not answers then
return nil, err, tries
end
return get_next_round_robin_answer(answers).address, port, tries
end
-- compatible with original DNS client library
-- These APIs will be deprecated if fully replacing the original one.
local dns_client
function _M.init(opts)
log(DEBUG, PREFIX, "(re)configuring dns client")
local client, err = _M.new(opts)
if not client then
return nil, err
end
dns_client = client
return true
end
-- New and old libraries have the same function name.
_M._resolve = _M.resolve
function _M.resolve(name, r_opts, cache_only, tries)
return dns_client:_resolve(name, r_opts and r_opts.qtype, cache_only, tries)
end
function _M.toip(name, port, cache_only, tries)
return dns_client:resolve_address(name, port, cache_only, tries)
end
-- "_ldap._tcp.example.com:33" -> "_ldap._tcp.example.com|SRV"
local function format_key(key)
local qname, qtype = key:match("^(.+):(%-?%d+)$") -- match "(qname):(qtype)"
return qtype and qname .. "|" .. (TYPE_TO_NAME[tonumber(qtype)] or qtype)
or key
end
function _M.stats()
return dns_client.stats:emit(format_key)
end
-- For testing
if package.loaded.busted then
function _M.getobj()
return dns_client
end
function _M.getcache()
return {
set = function(self, k, v, ttl)
self.cache:set(k, {ttl = ttl or 0}, v)
end,
delete = function(self, k)
self.cache:delete(k)
end,
cache = dns_client.cache,
}
end
end
return _M