1-- SPDX-License-Identifier: GPL-3.0-or-later
2local ffi = require('ffi')
3
4local rz_url = "https://www.internic.net/domain/root.zone"
5local rz_local_fname = "root.zone"
6local rz_ca_file = nil
7local rz_event_id = nil
8
9local rz_default_interval = 86400
10local rz_https_fail_interval = 600
11local rz_import_error_interval = 600
12local rz_cur_interval = rz_default_interval
13local rz_interval_randomizer_limit = 10
14local rz_interval_threshold = 5
15local rz_interval_min = 3600
16
17local rz_first_try = true
18
19local prefill = {}
20
21-- hack for circular dependency between timer() and fill_cache()
22local forward_references = {}
23
24local function stop_timer()
25	if rz_event_id then
26		event.cancel(rz_event_id)
27		rz_event_id = nil
28	end
29end
30
31local function timer()
32	stop_timer()
33	worker.bg_worker.cq:wrap(forward_references.fill_cache)
34end
35
36local function restart_timer(after)
37	stop_timer()
38	rz_event_id = event.after(after * sec, timer)
39end
40
41local function display_delay(time)
42	local days = math.floor(time / 86400)
43	local hours = math.floor((time % 86400) / 3600)
44	local minutes = math.floor((time % 3600) / 60)
45	local seconds = math.floor(time % 60)
46	if days > 0 then
47		return string.format("%d days %02d hours", days, hours)
48	elseif hours > 0 then
49		return string.format("%02d hours %02d minutes", hours, minutes)
50	elseif minutes > 0 then
51		return string.format("%02d minutes %02d seconds", minutes, seconds)
52	end
53	return string.format("%02d seconds", seconds)
54end
55
56-- returns: number of seconds the file is valid for
57-- 0 indicates immediate download
58local function get_file_ttl(fname)
59	local c_str = ffi.new("char[?]", #fname)
60	ffi.copy(c_str, fname)
61	local mtime = tonumber(ffi.C.kr_file_mtime(c_str))
62
63	if mtime > 0 then
64		local age = os.time() - mtime
65		return math.max(
66			rz_cur_interval - age,
67			0)
68	else
69		return 0  -- file does not exist, download now
70	end
71end
72
73local function download(url, fname)
74	local kluautil = require('kluautil')
75	local file, rcode, errmsg
76	file, errmsg = io.open(fname, 'w')
77	if not file then
78		error(string.format("[prefill] unable to open file %s (%s)",
79			fname, errmsg))
80	end
81
82	log_info(ffi.C.LOG_GRP_PREFILL, "downloading root zone to file %s ...", fname)
83	rcode, errmsg = kluautil.kr_https_fetch(url, file, rz_ca_file)
84	if rcode == nil then
85		error(string.format("[prefill] fetch of `%s` failed: %s", url, errmsg))
86	end
87
88	file:close()
89end
90
91local function import(fname)
92	local res = cache.zone_import(fname)
93	if res.code == 1 then -- no TA found, wait
94		error("[prefill] no trust anchor found for root zone, import aborted")
95	elseif res.code == 0 then
96		log_info(ffi.C.LOG_GRP_PREFILL, "root zone successfully parsed, import started")
97	else
98		error(string.format("[prefill] root zone import failed (%s)", res.msg))
99	end
100end
101
102function forward_references.fill_cache()
103	local file_ttl = get_file_ttl(rz_local_fname)
104
105	if file_ttl > rz_interval_threshold then
106		log_info(ffi.C.LOG_GRP_PREFILL, "root zone file valid for %s, reusing data from disk",
107			display_delay(file_ttl))
108	else
109		local ok, errmsg = pcall(download, rz_url, rz_local_fname)
110		if not ok then
111			rz_cur_interval = rz_https_fail_interval
112						- math.random(rz_interval_randomizer_limit)
113			log_info(ffi.C.LOG_GRP_PREFILL, "cannot download new zone (%s), "
114				.. "will retry root zone download in %s",
115				errmsg, display_delay(rz_cur_interval))
116			restart_timer(rz_cur_interval)
117			os.remove(rz_local_fname)
118			return
119		end
120		file_ttl = rz_default_interval
121	end
122	-- file is up to date, import
123	-- import/filter function gets executed after resolver/module
124	local ok, errmsg = pcall(import, rz_local_fname)
125	if not ok then
126		if rz_first_try then
127			rz_first_try = false
128			rz_cur_interval = 1
129		else
130			rz_cur_interval = rz_import_error_interval
131				- math.random(rz_interval_randomizer_limit)
132		end
133		log_info(ffi.C.LOG_GRP_PREFILL, "root zone import failed (%s), retry in %s",
134			errmsg, display_delay(rz_cur_interval))
135	else
136		-- re-download before TTL expires
137		rz_cur_interval = (file_ttl - rz_interval_threshold
138					- math.random(rz_interval_randomizer_limit))
139		log_info(ffi.C.LOG_GRP_PREFILL, "root zone refresh in %s",
140			display_delay(rz_cur_interval))
141	end
142	restart_timer(rz_cur_interval)
143end
144
145function prefill.deinit()
146	stop_timer()
147end
148
149-- process one item from configuration table
150-- right now it supports only root zone because
151-- prefill module uses global variables
152local function config_zone(zone_cfg)
153	if zone_cfg.interval then
154		zone_cfg.interval = tonumber(zone_cfg.interval)
155		if zone_cfg.interval < rz_interval_min then
156			error(string.format('[prefill] refresh interval %d s is too short, '
157				.. 'minimal interval is %d s',
158				zone_cfg.interval, rz_interval_min))
159		end
160		rz_default_interval = zone_cfg.interval
161		rz_cur_interval = zone_cfg.interval
162	end
163
164	rz_ca_file = zone_cfg.ca_file
165
166	if not zone_cfg.url or not string.match(zone_cfg.url, '^https://') then
167		error('[prefill] option url must contain a '
168			.. 'https:// URL of a zone file')
169	else
170		rz_url = zone_cfg.url
171	end
172end
173
174function prefill.config(config)
175	if config == nil then return end -- e.g. just modules = { 'prefill' }
176	local root_configured = false
177	if type(config) ~= 'table' then
178		error('[prefill] configuration must be in table '
179			.. '{owner name = {per-zone config}}')
180	end
181	for owner, zone_cfg in pairs(config) do
182		if owner ~= '.' then
183			error('[prefill] only root zone can be imported '
184				.. 'at the moment')
185		else
186			config_zone(zone_cfg)
187			root_configured = true
188		end
189	end
190	if not root_configured then
191		error('[prefill] this module version requires configuration '
192			.. 'for root zone')
193	end
194
195	restart_timer(0)  -- start now
196end
197
198return prefill
199