summaryrefslogtreecommitdiffstats
path: root/modules/prefill/prefill.lua
blob: f4a42886e1e5386cdd3ef68a6630d910c9908d71 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
-- SPDX-License-Identifier: GPL-3.0-or-later
local ffi = require('ffi')

local rz_url = "https://www.internic.net/domain/root.zone"
local rz_local_fname = "root.zone"
local rz_ca_file = nil
local rz_event_id = nil

local rz_default_interval = 86400
local rz_https_fail_interval = 600
local rz_import_error_interval = 600
local rz_cur_interval = rz_default_interval
local rz_interval_randomizer_limit = 10
local rz_interval_threshold = 5
local rz_interval_min = 3600

local rz_first_try = true

local prefill = {}

-- hack for circular dependency between timer() and fill_cache()
local forward_references = {}

local function stop_timer()
	if rz_event_id then
		event.cancel(rz_event_id)
		rz_event_id = nil
	end
end

local function timer()
	stop_timer()
	worker.bg_worker.cq:wrap(forward_references.fill_cache)
end

local function restart_timer(after)
	stop_timer()
	rz_event_id = event.after(after * sec, timer)
end

local function display_delay(time)
	local days = math.floor(time / 86400)
	local hours = math.floor((time % 86400) / 3600)
	local minutes = math.floor((time % 3600) / 60)
	local seconds = math.floor(time % 60)
	if days > 0 then
		return string.format("%d days %02d hours", days, hours)
	elseif hours > 0 then
		return string.format("%02d hours %02d minutes", hours, minutes)
	elseif minutes > 0 then
		return string.format("%02d minutes %02d seconds", minutes, seconds)
	end
	return string.format("%02d seconds", seconds)
end

-- returns: number of seconds the file is valid for
-- 0 indicates immediate download
local function get_file_ttl(fname)
	local mtime = tonumber(ffi.C.kr_file_mtime(fname))

	if mtime > 0 then
		local age = os.time() - mtime
		return math.max(
			rz_cur_interval - age,
			0)
	else
		return 0  -- file does not exist, download now
	end
end

local function download(url, fname)
	local kluautil = require('kluautil')
	local file, rcode, errmsg
	file, errmsg = io.open(fname, 'w')
	if not file then
		error(string.format("[prefil] unable to open file %s (%s)",
			fname, errmsg))
	end

	log_info(ffi.C.LOG_GRP_PREFILL, "downloading root zone to file %s ...", fname)
	rcode, errmsg = kluautil.kr_https_fetch(url, file, rz_ca_file)
	if rcode == nil then
		error(string.format("[prefil] fetch of `%s` failed: %s", url, errmsg))
	end

	file:close()
end

local function import(fname)
	local ret = ffi.C.zi_zone_import({
		zone_file = fname,
		time_src = ffi.C.ZI_STAMP_MTIM, -- the file might be slightly older
	})
	if ret == 0 then
		log_info(ffi.C.LOG_GRP_PREFILL, "zone successfully parsed, import started")
	else
		error(string.format(
			"[prefil] zone import failed: %s", ffi.C.knot_strerror(ret)
		))
	end
end

function forward_references.fill_cache()
	local file_ttl = get_file_ttl(rz_local_fname)

	if file_ttl > rz_interval_threshold then
		log_info(ffi.C.LOG_GRP_PREFILL, "root zone file valid for %s, reusing data from disk",
			display_delay(file_ttl))
	else
		local ok, errmsg = pcall(download, rz_url, rz_local_fname)
		if not ok then
			rz_cur_interval = rz_https_fail_interval
						- math.random(rz_interval_randomizer_limit)
			log_info(ffi.C.LOG_GRP_PREFILL, "cannot download new zone (%s), "
				.. "will retry root zone download in %s",
				errmsg, display_delay(rz_cur_interval))
			restart_timer(rz_cur_interval)
			os.remove(rz_local_fname)
			return
		end
		file_ttl = rz_default_interval
	end
	-- file is up to date, import
	-- import/filter function gets executed after resolver/module
	local ok, errmsg = pcall(import, rz_local_fname)
	if not ok then
		if rz_first_try then
			rz_first_try = false
			rz_cur_interval = 1
		else
			rz_cur_interval = rz_import_error_interval
				- math.random(rz_interval_randomizer_limit)
		end
		log_info(ffi.C.LOG_GRP_PREFILL, "root zone import failed (%s), retry in %s",
			errmsg, display_delay(rz_cur_interval))
	else
		-- re-download before TTL expires
		rz_cur_interval = (file_ttl - rz_interval_threshold
					- math.random(rz_interval_randomizer_limit))
		log_info(ffi.C.LOG_GRP_PREFILL, "root zone refresh in %s",
			display_delay(rz_cur_interval))
	end
	restart_timer(rz_cur_interval)
end

function prefill.deinit()
	stop_timer()
end

-- process one item from configuration table
-- right now it supports only root zone because
-- prefill module uses global variables
local function config_zone(zone_cfg)
	if zone_cfg.interval then
		zone_cfg.interval = tonumber(zone_cfg.interval)
		if zone_cfg.interval < rz_interval_min then
			error(string.format('[prefil] refresh interval %d s is too short, '
				.. 'minimal interval is %d s',
				zone_cfg.interval, rz_interval_min))
		end
		rz_default_interval = zone_cfg.interval
		rz_cur_interval = zone_cfg.interval
	end

	rz_ca_file = zone_cfg.ca_file

	if not zone_cfg.url or not string.match(zone_cfg.url, '^https://') then
		error('[prefil] option url must contain a '
			.. 'https:// URL of a zone file')
	else
		rz_url = zone_cfg.url
	end
end

function prefill.config(config)
	if config == nil then return end -- e.g. just modules = { 'prefill' }
	local root_configured = false
	if type(config) ~= 'table' then
		error('[prefil] configuration must be in table '
			.. '{owner name = {per-zone config}}')
	end
	for owner, zone_cfg in pairs(config) do
		if owner ~= '.' then
			error('[prefil] only root zone can be imported '
				.. 'at the moment')
		else
			config_zone(zone_cfg)
			root_configured = true
		end
	end
	if not root_configured then
		error('[prefil] this module version requires configuration '
			.. 'for root zone')
	end

	restart_timer(0)  -- start now
end

return prefill