diff --git a/NEWS b/NEWS index 13fd8f4285ce7f27259a572f3f84230049674866..b53ed68d4023fd5b5c9333cc439850efc3077c0b 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,10 @@ Bugfixes - /management/unix-socket: revert to absolute path (#926, !1664) - fix `tags` when used in /local-data/rules/*/records (!1670) +Improvements +------------ +- /local-data/rpz/*/watchdog: new configuration to enable watchdog for RPZ files (!1665) + Knot Resolver 6.0.11 (2025-02-26) ================================= diff --git a/doc/_static/config.schema.json b/doc/_static/config.schema.json index 0bedbbc4ed1b314e73bd6539b33142f0148b0452..754403734593b0883c841125a729260247674051 100644 --- a/doc/_static/config.schema.json +++ b/doc/_static/config.schema.json @@ -928,6 +928,21 @@ "type": "string", "description": "Path to the RPZ zone file." }, + "watchdog": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto" + ] + }, + { + "type": "boolean" + } + ], + "description": "Enables files watchdog for configured RPZ file. Requires the optional 'watchdog' dependency.", + "default": "auto" + }, "tags": { "type": [ "array", diff --git a/python/knot_resolver/datamodel/local_data_schema.py b/python/knot_resolver/datamodel/local_data_schema.py index ee22977849b4a01a713d12a682d2903cfc3fbe9a..478a0e2310ef6d6fd02519310666a9e64a501352 100644 --- a/python/knot_resolver/datamodel/local_data_schema.py +++ b/python/knot_resolver/datamodel/local_data_schema.py @@ -1,5 +1,6 @@ -from typing import Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional, Union +from knot_resolver.constants import WATCHDOG_LIB from knot_resolver.datamodel.types import ( DomainName, EscapedStr, @@ -54,16 +55,36 @@ class RuleSchema(ConfigSchema): class RPZSchema(ConfigSchema): - """ - Configuration or Response Policy Zone (RPZ). + class Raw(ConfigSchema): + """ + Configuration or Response Policy Zone (RPZ). - --- - file: Path to the RPZ zone file. - tags: Tags to link with other policy rules. - """ + --- + file: Path to the RPZ zone file. + watchdog: Enables files watchdog for configured RPZ file. Requires the optional 'watchdog' dependency. + tags: Tags to link with other policy rules. + """ + + file: ReadableFile + watchdog: Union[Literal["auto"], bool] = "auto" + tags: Optional[List[IDPattern]] = None + + _LAYER = Raw file: ReadableFile - tags: Optional[List[IDPattern]] = None + watchdog: bool + tags: Optional[List[IDPattern]] + + def _watchdog(self, obj: Raw) -> Any: + if obj.watchdog == "auto": + return WATCHDOG_LIB + return obj.watchdog + + def _validate(self) -> None: + if self.watchdog and not WATCHDOG_LIB: + raise ValueError( + "'watchdog' is enabled, but the required 'watchdog' dependency (optional) is not installed" + ) class LocalDataSchema(ConfigSchema): diff --git a/python/knot_resolver/manager/files/watchdog.py b/python/knot_resolver/manager/files/watchdog.py index e0abf56c7601f94256ae373dc5fe86b9633a8860..e74abec9f629a4880b7c18890bbd8feb0607e3ab 100644 --- a/python/knot_resolver/manager/files/watchdog.py +++ b/python/knot_resolver/manager/files/watchdog.py @@ -1,22 +1,27 @@ import logging from pathlib import Path from threading import Timer -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional +from urllib.parse import quote from knot_resolver.constants import WATCHDOG_LIB from knot_resolver.controller.registered_workers import command_registered_workers from knot_resolver.datamodel import KresConfig from knot_resolver.manager.config_store import ConfigStore, only_on_real_changes_update from knot_resolver.utils import compat +from knot_resolver.utils.requests import SocketDesc, request logger = logging.getLogger(__name__) +FilesToWatch = Dict[Path, Optional[str]] -def tls_cert_files_config(config: KresConfig) -> List[Any]: + +def watched_files_config(config: KresConfig) -> List[Any]: return [ config.network.tls.files_watchdog, config.network.tls.cert_file, config.network.tls.key_file, + config.local_data.rpz, ] @@ -27,72 +32,103 @@ if WATCHDOG_LIB: ) from watchdog.observers import Observer - _tls_cert_watchdog: Optional["TLSCertWatchDog"] = None - - class TLSCertEventHandler(FileSystemEventHandler): - def __init__(self, files: List[Path], cmd: str) -> None: + class FilesWatchdogEventHandler(FileSystemEventHandler): + def __init__(self, files: FilesToWatch, config: KresConfig) -> None: self._files = files - self._cmd = cmd - self._timer: Optional[Timer] = None + self._config = config + self._policy_timer: Optional[Timer] = None + self._timers: Dict[str, Timer] = {} + + def _trigger(self, cmd: Optional[str]) -> None: + def policy_reload() -> None: + management = self._config.management + socket = SocketDesc( + f'http+unix://{quote(str(management.unix_socket), safe="")}/', + 'Key "/management/unix-socket" in validated configuration', + ) + if management.interface: + socket = SocketDesc( + f"http://{management.interface.addr}:{management.interface.port}", + 'Key "/management/interface" in validated configuration', + ) + + response = request(socket, "POST", "renew") + if response.status != 200: + logger.error(f"Failed to reload policy rules: {response.body}") + logger.info("Reloading policy rules has finished") + + if not cmd: + # skipping if reload was already triggered + if self._policy_timer and self._policy_timer.is_alive(): + logger.info("Skipping reloading policy rules, it was already triggered") + return + # start a 5sec timer + logger.info("Delayed policy rules reload has started") + self._policy_timer = Timer(5, policy_reload) + self._policy_timer.start() + return - def _reload(self) -> None: def command() -> None: if compat.asyncio.is_event_loop_running(): - compat.asyncio.create_task(command_registered_workers(self._cmd)) + compat.asyncio.create_task(command_registered_workers(cmd)) else: - compat.asyncio.run(command_registered_workers(self._cmd)) - logger.info("Reloading of TLS certificate files has finished") + compat.asyncio.run(command_registered_workers(cmd)) + logger.info(f"Sending '{cmd}' command to reload watched files has finished") - # skipping if reload was already triggered - if self._timer and self._timer.is_alive(): - logger.info("Skipping TLS certificate files reloading, reload command was already triggered") + # skipping if command was already triggered + if cmd in self._timers and self._timers[cmd].is_alive(): + logger.info(f"Skipping sending '{cmd}' command, it was already triggered") return # start a 5sec timer - logger.info("Delayed reload of TLS certificate files has started") - self._timer = Timer(5, command) - self._timer.start() + logger.info(f"Delayed send of '{cmd}' command has started") + self._timers[cmd] = Timer(5, command) + self._timers[cmd].start() def on_created(self, event: FileSystemEvent) -> None: src_path = Path(str(event.src_path)) - if src_path in self._files: + if src_path in self._files.keys(): logger.info(f"Watched file '{src_path}' has been created") - self._reload() + self._trigger(self._files[src_path]) def on_deleted(self, event: FileSystemEvent) -> None: src_path = Path(str(event.src_path)) - if src_path in self._files: + if src_path in self._files.keys(): logger.warning(f"Watched file '{src_path}' has been deleted") - if self._timer: - self._timer.cancel() - for file in self._files: + cmd = self._files[src_path] + if cmd in self._timers: + self._timers[cmd].cancel() + for file in self._files.keys(): if file.parent == src_path: logger.warning(f"Watched directory '{src_path}' has been deleted") - if self._timer: - self._timer.cancel() + cmd = self._files[file] + if cmd in self._timers: + self._timers[cmd].cancel() + + def on_moved(self, event: FileSystemEvent) -> None: + src_path = Path(str(event.src_path)) + if src_path in self._files.keys(): + logger.info(f"Watched file '{src_path}' has been moved") + self._trigger(self._files[src_path]) def on_modified(self, event: FileSystemEvent) -> None: src_path = Path(str(event.src_path)) - if src_path in self._files: + if src_path in self._files.keys(): logger.info(f"Watched file '{src_path}' has been modified") - self._reload() + self._trigger(self._files[src_path]) - class TLSCertWatchDog: - def __init__(self, cert_file: Path, key_file: Path) -> None: - self._observer = Observer() - - cmd = f"net.tls('{cert_file}', '{key_file}')" + _files_watchdog: Optional["FilesWatchdog"] = None - cert_files: List[Path] = [] - cert_files.append(cert_file) - cert_files.append(key_file) + class FilesWatchdog: + def __init__(self, files_to_watch: FilesToWatch, config: KresConfig) -> None: + self._observer = Observer() - cert_dirs: List[Path] = [] - cert_dirs.append(cert_file.parent) - if cert_file.parent != key_file.parent: - cert_dirs.append(key_file.parent) + event_handler = FilesWatchdogEventHandler(files_to_watch, config) + dirs_to_watch: List[Path] = [] + for file in files_to_watch.keys(): + if file.parent not in dirs_to_watch: + dirs_to_watch.append(file.parent) - event_handler = TLSCertEventHandler(cert_files, cmd) - for d in cert_dirs: + for d in dirs_to_watch: self._observer.schedule( event_handler, str(d), @@ -108,23 +144,33 @@ if WATCHDOG_LIB: self._observer.join() -@only_on_real_changes_update(tls_cert_files_config) -async def _init_tls_cert_watchdog(config: KresConfig) -> None: +@only_on_real_changes_update(watched_files_config) +async def _init_files_watchdog(config: KresConfig) -> None: if WATCHDOG_LIB: - global _tls_cert_watchdog + global _files_watchdog - if _tls_cert_watchdog: - _tls_cert_watchdog.stop() + if _files_watchdog: + _files_watchdog.stop() + files_to_watch: FilesToWatch = {} + # network.tls if config.network.tls.files_watchdog and config.network.tls.cert_file and config.network.tls.key_file: - logger.info("Initializing TLS certificate files WatchDog") - _tls_cert_watchdog = TLSCertWatchDog( - config.network.tls.cert_file.to_path(), - config.network.tls.key_file.to_path(), - ) - _tls_cert_watchdog.start() + net_tls = f"net.tls('{config.network.tls.cert_file}', '{config.network.tls.key_file}')" + files_to_watch[config.network.tls.cert_file.to_path()] = net_tls + files_to_watch[config.network.tls.key_file.to_path()] = net_tls + + # local-data.rpz + if config.local_data.rpz: + for rpz in config.local_data.rpz: + if rpz.watchdog: + files_to_watch[rpz.file.to_path()] = None + + if files_to_watch: + logger.info("Initializing files watchdog") + _files_watchdog = FilesWatchdog(files_to_watch, config) + _files_watchdog.start() async def init_files_watchdog(config_store: ConfigStore) -> None: - # watchdog for TLS certificate files - await config_store.register_on_change_callback(_init_tls_cert_watchdog) + # register files watchdog callback + await config_store.register_on_change_callback(_init_files_watchdog) diff --git a/python/knot_resolver/manager/server.py b/python/knot_resolver/manager/server.py index 879ce80e49995f03b222dd76b380d610d9810f80..41078a7ad3c33b5c662d5707daf7a56fed3ea499 100644 --- a/python/knot_resolver/manager/server.py +++ b/python/knot_resolver/manager/server.py @@ -130,13 +130,21 @@ class Server: f"Configuration file was not found at '{self._config_path}'." " Something must have happened to it while we were running." ) - logger.error("Configuration have NOT been changed.") + logger.error("Configuration has NOT been changed.") except (DataParsingError, DataValidationError) as e: logger.error(f"Failed to parse the updated configuration file: {e}") - logger.error("Configuration have NOT been changed.") + logger.error("Configuration has NOT been changed.") except KresManagerException as e: logger.error(f"Reloading of the configuration file failed: {e}") - logger.error("Configuration have NOT been changed.") + logger.error("Configuration has NOT been changed.") + + async def _renew_config(self) -> None: + try: + await self.config_store.renew() + logger.info("Configuration successfully renewed") + except KresManagerException as e: + logger.error(f"Renewing the configuration failed: {e}") + logger.error("Configuration has NOT been renewed.") async def sigint_handler(self) -> None: logger.info("Received SIGINT, triggering graceful shutdown") @@ -325,6 +333,15 @@ class Server: await self._reload_config() return web.Response(text="Reloading...") + async def _handler_renew(self, _request: web.Request) -> web.Response: + """ + Route handler for renewing the configuration + """ + + logger.info("Renewing configuration event triggered...") + await self._renew_config() + return web.Response(text="Renewing configuration...") + async def _handler_processes(self, request: web.Request) -> web.Response: """ Route handler for listing PIDs of subprocesses @@ -359,6 +376,7 @@ class Server: web.patch(r"/v1/config{path:.*}", self._handler_config_query), web.post("/stop", self._handler_stop), web.post("/reload", self._handler_reload), + web.post("/renew", self._handler_renew), web.get("/schema", self._handler_schema), web.get("/schema/ui", self._handle_view_schema), web.get("/metrics", self._handler_metrics), diff --git a/tests/packaging/interactive/rpz_watchdog.sh b/tests/packaging/interactive/rpz_watchdog.sh new file mode 100755 index 0000000000000000000000000000000000000000..910178fabf47eca180e97ed7ee652d2fa9830d56 --- /dev/null +++ b/tests/packaging/interactive/rpz_watchdog.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +set -e + +gitroot=$(git rev-parse --show-toplevel) +rpz_file=$gitroot/example.rpz + +rpz_example=$(cat <<EOF +\$ORIGIN RPZ.EXAMPLE.ORG. +ok.example.com CNAME rpz-passthru. +EOF +) +# create example RPZ +echo "$rpz_example" >> $rpz_file + +rpz_conf=$(cat <<EOF +local-data: + rpz: + - file: $rpz_file + watchdog: false +EOF +) +# add RPZ to config +echo "$rpz_conf" >> /etc/knot-resolver/config.yaml + +function count_errors(){ + echo "$(journalctl -u knot-resolver.service | grep -c error)" +} + +function count_reloads(){ + echo "$(journalctl -u knot-resolver.service | grep -c "Reloading policy rules has finished")" +} + +# test that RPZ watchdog +# {{ + +err_count=$(count_errors) +rel_count=$(count_reloads) + +# reload config with RPZ configured without watchdog turned on +kresctl reload +sleep 1 +if [ $(count_errors) -ne $err_count ] || [ $(count_reloads) -ne $rel_count ]; then + echo "RPZ file watchdog is running (should not) or other errors occurred." + exit 1 +fi + +# configure RPZ file and turn on watchdog +kresctl config set -p /local-data/rpz/0/watchdog true +sleep 1 +if [ "$?" -ne "0" ]; then + echo "Could not turn on RPZ file watchdog." + exit 1 +fi + +# }} + +# test RPZ modification +# {{ + +# modify RPZ file, it will trigger reload +rel_count=$(count_reloads) +echo "32.1.2.0.192.rpz-client-ip CNAME rpz-passthru." >> $rpz_file + +# wait for files reload to finish +sleep 10 + +if [ $(count_errors) -ne $err_count ] || [ $(count_reloads) -eq $rel_count ]; then + echo "Could not reload modified RPZ file." + exit 1 +fi + +# }} + +# test replacement +# {{ + +rel_count=$(count_reloads) + +# copy RPZ file +cp $rpz_file $rpz_file.new + +# edit new files +echo "48.zz.101.db8.2001.rpz-client-ip CNAME rpz-passthru." >> $rpz_file.new + +# replace files +cp -f $rpz_file.new $rpz_file + +# wait for files reload to finish +sleep 10 + +if [ $(count_errors) -ne $err_count ] || [ $(count_reloads) -eq $rel_count ]; then + echo "Could not reload replaced RPZ file." + exit 1 +fi + +# }} + +# test recovery from deletion and creation +# {{ + +rel_count=$(count_reloads) + +# backup rpz file +cp $rpz_file $rpz_file.backup + +# delete RPZ file +rm $rpz_file + +# create cert files +cp -f $rpz_file.backup $rpz_file + +# wait for files reload to finish +sleep 10 + +if [ $(count_errors) -ne $err_count ] || [ $(count_reloads) -eq $rel_count ]; then + echo "Could not reload created RPZ file." + exit 1 +fi + +# }} diff --git a/tests/packaging/interactive/watchdog.sh b/tests/packaging/interactive/tls_cert_watchdog.sh similarity index 95% rename from tests/packaging/interactive/watchdog.sh rename to tests/packaging/interactive/tls_cert_watchdog.sh index ffc76e921e65010ef5fd7cd9bc69f2801891b006..104bbdd64f102acf5c8ffd0ee8ca4063e1aab4c0 100755 --- a/tests/packaging/interactive/watchdog.sh +++ b/tests/packaging/interactive/tls_cert_watchdog.sh @@ -26,7 +26,7 @@ function count_errors(){ } function count_reloads(){ - echo "$(journalctl -u knot-resolver.service | grep -c "Reloading of TLS certificate files has finished")" + echo "$(journalctl -u knot-resolver.service | grep -c "to reload watched files has finished")" } # test that files watchdog is turned off