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