diff --git a/manager/knot_resolver_manager/constants.py b/manager/knot_resolver_manager/constants.py index 90ceed9f8..9253c2db1 100644 --- a/manager/knot_resolver_manager/constants.py +++ b/manager/knot_resolver_manager/constants.py @@ -35,6 +35,14 @@ def kres_gc_executable() -> Path: return knot_resolver.sbin_dir / "kres-cache-gc" +def kresd_user(): + return None if knot_resolver is None else knot_resolver.user + + +def kresd_group(): + return None if knot_resolver is None else knot_resolver.group + + def kresd_cache_dir(config: "KresConfig") -> Path: return config.cache.storage.to_path() diff --git a/manager/knot_resolver_manager/datamodel/cache_schema.py b/manager/knot_resolver_manager/datamodel/cache_schema.py index ac30f0d01..4ed9fc3a8 100644 --- a/manager/knot_resolver_manager/datamodel/cache_schema.py +++ b/manager/knot_resolver_manager/datamodel/cache_schema.py @@ -4,16 +4,16 @@ from knot_resolver_manager.datamodel.templates import template_from_str from knot_resolver_manager.datamodel.types import ( - Dir, DNSRecordTypeEnum, DomainName, EscapedStr, - File, IntNonNegative, IntPositive, Percent, + ReadableFile, SizeUnit, TimeUnit, + WritableDir, ) from knot_resolver_manager.utils.modeling import ConfigSchema from knot_resolver_manager.utils.modeling.base_schema import lazy_default @@ -51,7 +51,7 @@ class PrefillSchema(ConfigSchema): origin: DomainName url: EscapedStr refresh_interval: TimeUnit = TimeUnit("1d") - ca_file: Optional[File] = None + ca_file: Optional[ReadableFile] = None def _validate(self) -> None: if str(self.origin) != ".": @@ -125,7 +125,7 @@ class CacheSchema(ConfigSchema): prefetch: These options help keep the cache hot by prefetching expiring records or learning usage patterns and repetitive queries. """ - storage: Dir = lazy_default(Dir, "/var/cache/knot-resolver") + storage: WritableDir = lazy_default(WritableDir, "/var/cache/knot-resolver") size_max: SizeUnit = SizeUnit("100M") garbage_collector: Union[GarbageCollectorSchema, Literal[False]] = GarbageCollectorSchema() ttl_min: TimeUnit = TimeUnit("5s") diff --git a/manager/knot_resolver_manager/datamodel/config_schema.py b/manager/knot_resolver_manager/datamodel/config_schema.py index 353712310..c8398569e 100644 --- a/manager/knot_resolver_manager/datamodel/config_schema.py +++ b/manager/knot_resolver_manager/datamodel/config_schema.py @@ -18,7 +18,7 @@ from knot_resolver_manager.datamodel.network_schema import NetworkSchema from knot_resolver_manager.datamodel.options_schema import OptionsSchema from knot_resolver_manager.datamodel.templates import POLICY_CONFIG_TEMPLATE, WORKER_CONFIG_TEMPLATE -from knot_resolver_manager.datamodel.types import Dir, EscapedStr, IntPositive +from knot_resolver_manager.datamodel.types import EscapedStr, IntPositive, WritableDir from knot_resolver_manager.datamodel.view_schema import ViewSchema from knot_resolver_manager.datamodel.webmgmt_schema import WebmgmtSchema from knot_resolver_manager.utils.modeling import ConfigSchema @@ -114,7 +114,7 @@ class Raw(ConfigSchema): version: int = 1 nsid: Optional[EscapedStr] = None hostname: Optional[EscapedStr] = None - rundir: Dir = lazy_default(Dir, _DEFAULT_RUNDIR) + rundir: WritableDir = lazy_default(WritableDir, _DEFAULT_RUNDIR) workers: Union[Literal["auto"], IntPositive] = IntPositive(1) max_workers: IntPositive = IntPositive(_default_max_worker_count()) management: ManagementSchema = lazy_default(ManagementSchema, {"unix-socket": DEFAULT_MANAGER_API_SOCK}) @@ -135,7 +135,7 @@ class Raw(ConfigSchema): nsid: Optional[EscapedStr] hostname: EscapedStr - rundir: Dir + rundir: WritableDir workers: IntPositive max_workers: IntPositive management: ManagementSchema @@ -231,7 +231,7 @@ def render_lua_policy(self) -> str: return POLICY_CONFIG_TEMPLATE.render(cfg=self, cwd=os.getcwd()) -def get_rundir_without_validation(data: Dict[str, Any]) -> Dir: +def get_rundir_without_validation(data: Dict[str, Any]) -> WritableDir: """ Without fully parsing, try to get a rundir from a raw config data, otherwise use default. Attempts a dir validation to produce a good error message. @@ -239,4 +239,4 @@ def get_rundir_without_validation(data: Dict[str, Any]) -> Dir: Used for initial manager startup. """ - return Dir(data["rundir"] if "rundir" in data else _DEFAULT_RUNDIR, object_path="/rundir") + return WritableDir(data["rundir"] if "rundir" in data else _DEFAULT_RUNDIR, object_path="/rundir") diff --git a/manager/knot_resolver_manager/datamodel/dnssec_schema.py b/manager/knot_resolver_manager/datamodel/dnssec_schema.py index 5e274c9a9..e51500e18 100644 --- a/manager/knot_resolver_manager/datamodel/dnssec_schema.py +++ b/manager/knot_resolver_manager/datamodel/dnssec_schema.py @@ -1,6 +1,6 @@ from typing import List, Optional -from knot_resolver_manager.datamodel.types import DomainName, EscapedStr, File, IntNonNegative, TimeUnit +from knot_resolver_manager.datamodel.types import DomainName, EscapedStr, IntNonNegative, ReadableFile, TimeUnit from knot_resolver_manager.utils.modeling import ConfigSchema @@ -14,7 +14,7 @@ class TrustAnchorFileSchema(ConfigSchema): """ - file: File + file: ReadableFile read_only: bool = False diff --git a/manager/knot_resolver_manager/datamodel/forward_schema.py b/manager/knot_resolver_manager/datamodel/forward_schema.py index ee5206c27..52a05f36d 100644 --- a/manager/knot_resolver_manager/datamodel/forward_schema.py +++ b/manager/knot_resolver_manager/datamodel/forward_schema.py @@ -2,7 +2,7 @@ from typing_extensions import Literal -from knot_resolver_manager.datamodel.types import DomainName, File, IPAddressOptionalPort, ListOrItem, PinSha256 +from knot_resolver_manager.datamodel.types import DomainName, IPAddressOptionalPort, ListOrItem, PinSha256, ReadableFile from knot_resolver_manager.utils.modeling import ConfigSchema @@ -22,7 +22,7 @@ class ForwardServerSchema(ConfigSchema): transport: Optional[Literal["tls"]] = None pin_sha256: Optional[ListOrItem[PinSha256]] = None hostname: Optional[DomainName] = None - ca_file: Optional[File] = None + ca_file: Optional[ReadableFile] = None def _validate(self) -> None: if self.pin_sha256 and (self.hostname or self.ca_file): diff --git a/manager/knot_resolver_manager/datamodel/local_data_schema.py b/manager/knot_resolver_manager/datamodel/local_data_schema.py index e891601ce..fafa7ebe2 100644 --- a/manager/knot_resolver_manager/datamodel/local_data_schema.py +++ b/manager/knot_resolver_manager/datamodel/local_data_schema.py @@ -5,10 +5,10 @@ from knot_resolver_manager.datamodel.types import ( DomainName, EscapedStr, - File, IDPattern, IPAddress, ListOrItem, + ReadableFile, TimeUnit, ) from knot_resolver_manager.utils.modeling import ConfigSchema @@ -32,7 +32,7 @@ class RuleSchema(ConfigSchema): name: Optional[ListOrItem[DomainName]] = None subtree: Optional[Literal["empty", "nxdomain", "redirect"]] = None address: Optional[ListOrItem[IPAddress]] = None - file: Optional[ListOrItem[File]] = None + file: Optional[ListOrItem[ReadableFile]] = None records: Optional[EscapedStr] = None tags: Optional[List[IDPattern]] = None ttl: Optional[TimeUnit] = None @@ -64,7 +64,7 @@ class RPZSchema(ConfigSchema): tags: Tags to link with other policy rules. """ - file: File + file: ReadableFile tags: Optional[List[IDPattern]] = None @@ -87,9 +87,9 @@ class LocalDataSchema(ConfigSchema): ttl: Optional[TimeUnit] = None nodata: bool = True root_fallback_addresses: Optional[Dict[DomainName, ListOrItem[IPAddress]]] = None - root_fallback_addresses_files: Optional[List[File]] = None + root_fallback_addresses_files: Optional[List[ReadableFile]] = None addresses: Optional[Dict[DomainName, ListOrItem[IPAddress]]] = None - addresses_files: Optional[List[File]] = None + addresses_files: Optional[List[ReadableFile]] = None records: Optional[EscapedStr] = None rules: Optional[List[RuleSchema]] = None rpz: Optional[List[RPZSchema]] = None diff --git a/manager/knot_resolver_manager/datamodel/logging_schema.py b/manager/knot_resolver_manager/datamodel/logging_schema.py index d2b7b7e7a..601cd4a54 100644 --- a/manager/knot_resolver_manager/datamodel/logging_schema.py +++ b/manager/knot_resolver_manager/datamodel/logging_schema.py @@ -3,7 +3,7 @@ from typing_extensions import Literal -from knot_resolver_manager.datamodel.types import FilePath, TimeUnit +from knot_resolver_manager.datamodel.types import TimeUnit, WritableFilePath from knot_resolver_manager.utils.modeling import ConfigSchema from knot_resolver_manager.utils.modeling.base_schema import is_obj_type_valid @@ -84,7 +84,7 @@ class DnstapSchema(ConfigSchema): log_tcp_rtt: Log TCP RTT (Round-trip time). """ - unix_socket: FilePath + unix_socket: WritableFilePath log_queries: bool = True log_responses: bool = True log_tcp_rtt: bool = True diff --git a/manager/knot_resolver_manager/datamodel/lua_schema.py b/manager/knot_resolver_manager/datamodel/lua_schema.py index cf49b7124..079333ae8 100644 --- a/manager/knot_resolver_manager/datamodel/lua_schema.py +++ b/manager/knot_resolver_manager/datamodel/lua_schema.py @@ -1,6 +1,6 @@ from typing import Optional -from knot_resolver_manager.datamodel.types import File +from knot_resolver_manager.datamodel.types import ReadableFile from knot_resolver_manager.utils.modeling import ConfigSchema @@ -16,7 +16,7 @@ class LuaSchema(ConfigSchema): script_only: bool = False script: Optional[str] = None - script_file: Optional[File] = None + script_file: Optional[ReadableFile] = None def _validate(self) -> None: if self.script and self.script_file: diff --git a/manager/knot_resolver_manager/datamodel/management_schema.py b/manager/knot_resolver_manager/datamodel/management_schema.py index 09daa3ff3..44f8f3e83 100644 --- a/manager/knot_resolver_manager/datamodel/management_schema.py +++ b/manager/knot_resolver_manager/datamodel/management_schema.py @@ -1,6 +1,6 @@ from typing import Optional -from knot_resolver_manager.datamodel.types import FilePath, IPAddressPort +from knot_resolver_manager.datamodel.types import WritableFilePath, IPAddressPort from knot_resolver_manager.utils.modeling import ConfigSchema @@ -13,7 +13,7 @@ class ManagementSchema(ConfigSchema): interface: IP address and port number to listen to. """ - unix_socket: Optional[FilePath] = None + unix_socket: Optional[WritableFilePath] = None interface: Optional[IPAddressPort] = None def _validate(self) -> None: diff --git a/manager/knot_resolver_manager/datamodel/network_schema.py b/manager/knot_resolver_manager/datamodel/network_schema.py index 289104b82..b9a35090f 100644 --- a/manager/knot_resolver_manager/datamodel/network_schema.py +++ b/manager/knot_resolver_manager/datamodel/network_schema.py @@ -4,8 +4,7 @@ from knot_resolver_manager.datamodel.types import ( EscapedStr32B, - File, - FilePath, + WritableFilePath, Int0_512, Int0_65535, InterfaceOptionalPort, @@ -16,6 +15,7 @@ IPv6Address, ListOrItem, PortNumber, + ReadableFile, SizeUnit, ) from knot_resolver_manager.utils.modeling import ConfigSchema @@ -62,10 +62,10 @@ class TLSSchema(ConfigSchema): padding: EDNS(0) padding of queries and answers sent over an encrypted channel. """ - cert_file: Optional[File] = None - key_file: Optional[File] = None + cert_file: Optional[ReadableFile] = None + key_file: Optional[ReadableFile] = None sticket_secret: Optional[EscapedStr32B] = None - sticket_secret_file: Optional[File] = None + sticket_secret_file: Optional[ReadableFile] = None auto_discovery: bool = False padding: Union[bool, Int0_512] = True @@ -88,7 +88,7 @@ class Raw(ConfigSchema): """ interface: Optional[ListOrItem[InterfaceOptionalPort]] = None - unix_socket: Optional[ListOrItem[FilePath]] = None + unix_socket: Optional[ListOrItem[WritableFilePath]] = None port: Optional[PortNumber] = None kind: KindEnum = "dns" freebind: bool = False @@ -96,7 +96,7 @@ class Raw(ConfigSchema): _LAYER = Raw interface: Optional[ListOrItem[InterfaceOptionalPort]] - unix_socket: Optional[ListOrItem[FilePath]] + unix_socket: Optional[ListOrItem[WritableFilePath]] port: Optional[PortNumber] kind: KindEnum freebind: bool diff --git a/manager/knot_resolver_manager/datamodel/rpz_schema.py b/manager/knot_resolver_manager/datamodel/rpz_schema.py index 633e34a5b..bf98bd0ca 100644 --- a/manager/knot_resolver_manager/datamodel/rpz_schema.py +++ b/manager/knot_resolver_manager/datamodel/rpz_schema.py @@ -1,6 +1,6 @@ from typing import List, Optional -from knot_resolver_manager.datamodel.types import File, PolicyActionEnum, PolicyFlagEnum +from knot_resolver_manager.datamodel.types import PolicyActionEnum, PolicyFlagEnum, ReadableFile from knot_resolver_manager.utils.modeling import ConfigSchema @@ -18,7 +18,7 @@ class RPZSchema(ConfigSchema): """ action: PolicyActionEnum - file: File + file: ReadableFile watch: bool = True views: Optional[List[str]] = None options: Optional[List[PolicyFlagEnum]] = None diff --git a/manager/knot_resolver_manager/datamodel/static_hints_schema.py b/manager/knot_resolver_manager/datamodel/static_hints_schema.py index 7d39fcf40..89db49bbb 100644 --- a/manager/knot_resolver_manager/datamodel/static_hints_schema.py +++ b/manager/knot_resolver_manager/datamodel/static_hints_schema.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from knot_resolver_manager.datamodel.types import DomainName, File, IPAddress, TimeUnit +from knot_resolver_manager.datamodel.types import DomainName, IPAddress, ReadableFile, TimeUnit from knot_resolver_manager.utils.modeling import ConfigSchema @@ -22,6 +22,6 @@ class StaticHintsSchema(ConfigSchema): nodata: bool = True etc_hosts: bool = False root_hints: Optional[Dict[DomainName, List[IPAddress]]] = None - root_hints_file: Optional[File] = None + root_hints_file: Optional[ReadableFile] = None hints: Optional[Dict[DomainName, List[IPAddress]]] = None - hints_files: Optional[List[File]] = None + hints_files: Optional[List[ReadableFile]] = None diff --git a/manager/knot_resolver_manager/datamodel/types/__init__.py b/manager/knot_resolver_manager/datamodel/types/__init__.py index 350cf2133..a3d7db3e6 100644 --- a/manager/knot_resolver_manager/datamodel/types/__init__.py +++ b/manager/knot_resolver_manager/datamodel/types/__init__.py @@ -1,5 +1,5 @@ from .enums import DNSRecordTypeEnum, PolicyActionEnum, PolicyFlagEnum -from .files import AbsoluteDir, Dir, File, FilePath +from .files import AbsoluteDir, Dir, File, FilePath, ReadableFile, WritableDir, WritableFilePath from .generic_types import ListOrItem from .types import ( DomainName, @@ -60,6 +60,9 @@ "SizeUnit", "TimeUnit", "AbsoluteDir", + "ReadableFile", + "WritableDir", + "WritableFilePath", "File", "FilePath", "Dir", diff --git a/manager/knot_resolver_manager/datamodel/types/files.py b/manager/knot_resolver_manager/datamodel/types/files.py index 49b51f713..4c6e7186f 100644 --- a/manager/knot_resolver_manager/datamodel/types/files.py +++ b/manager/knot_resolver_manager/datamodel/types/files.py @@ -1,6 +1,12 @@ +import os +import stat +from enum import auto, Flag +from grp import getgrnam from pathlib import Path +from pwd import getpwnam from typing import Any, Dict, Tuple, Type, TypeVar +from knot_resolver_manager.constants import kresd_group, kresd_user from knot_resolver_manager.datamodel.globals import get_resolve_root, get_strict_validation from knot_resolver_manager.utils.modeling.base_value_type import BaseValueType @@ -133,5 +139,107 @@ def __init__( p = self._value.parent if self.strict_validation and (not p.exists() or not p.is_dir()): raise ValueError(f"path '{self._value}' does not point inside an existing directory") + if self.strict_validation and self._value.is_dir(): raise ValueError(f"path '{self._value}' points to a directory when we expected a file") + + +class _PermissionMode(Flag): + READ = auto() + WRITE = auto() + EXECUTE = auto() + + +def _kres_accessible(dest_path: Path, perm_mode: _PermissionMode) -> bool: + chflags = { + _PermissionMode.READ: [stat.S_IRUSR, stat.S_IRGRP, stat.S_IROTH], + _PermissionMode.WRITE: [stat.S_IWUSR, stat.S_IWGRP, stat.S_IWOTH], + _PermissionMode.EXECUTE: [stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH], + } + + username = kresd_user() + groupname = kresd_group() + + if username is None or groupname is None: + return True + + user_uid = getpwnam(username).pw_uid + user_gid = getgrnam(groupname).gr_gid + + dest_stat = os.stat(dest_path) + dest_uid = dest_stat.st_uid + dest_gid = dest_stat.st_gid + dest_mode = dest_stat.st_mode + + def accessible(perm: _PermissionMode) -> bool: + if user_uid == dest_uid: + return bool(dest_mode & chflags[perm][0]) + b_groups = os.getgrouplist(os.getlogin(), user_gid) + if user_gid == dest_gid or dest_gid in b_groups: + return bool(dest_mode & chflags[perm][1]) + return bool(dest_mode & chflags[perm][2]) + + # __iter__ for class enum.Flag added in python3.11 + # 'for perm in perm_mode:' failes for <=python3.11 + for perm in _PermissionMode: + if perm in perm_mode: + if not accessible(perm): + return False + return True + + +class ReadableFile(File): + """ + Path, that is enforced to be: + - an existing file + - readable by knot-resolver processes + """ + + def __init__( + self, source_value: Any, parents: Tuple["UncheckedPath", ...] = tuple(), object_path: str = "/" + ) -> None: + super().__init__(source_value, parents=parents, object_path=object_path) + + if self.strict_validation and not _kres_accessible(self._value, _PermissionMode.READ): + raise ValueError(f"{kresd_user()}:{kresd_group()} has insufficient permissions to read '{self._value}'") + + +class WritableDir(Dir): + """ + Path, that is enforced to be: + - an existing directory + - writable/executable by knot-resolver processes + """ + + def __init__( + self, source_value: Any, parents: Tuple["UncheckedPath", ...] = tuple(), object_path: str = "/" + ) -> None: + super().__init__(source_value, parents=parents, object_path=object_path) + + if self.strict_validation and not _kres_accessible( + self._value, _PermissionMode.WRITE | _PermissionMode.EXECUTE + ): + raise ValueError( + f"{kresd_user()}:{kresd_group()} has insufficient permissions to write/execute '{self._value}'" + ) + + +class WritableFilePath(FilePath): + """ + Path, that is enforced to be: + - parent of the last path segment is an existing directory + - it does not point to a dir + - writable/executable parent directory by knot-resolver processes + """ + + def __init__( + self, source_value: Any, parents: Tuple["UncheckedPath", ...] = tuple(), object_path: str = "/" + ) -> None: + super().__init__(source_value, parents=parents, object_path=object_path) + + if self.strict_validation and not _kres_accessible( + self._value.parent, _PermissionMode.WRITE | _PermissionMode.EXECUTE + ): + raise ValueError( + f"{kresd_user()}:{kresd_group()} has insufficient permissions to write/execute'{self._value.parent}'" + ) diff --git a/manager/knot_resolver_manager/datamodel/webmgmt_schema.py b/manager/knot_resolver_manager/datamodel/webmgmt_schema.py index 41cc33877..2e75c3b74 100644 --- a/manager/knot_resolver_manager/datamodel/webmgmt_schema.py +++ b/manager/knot_resolver_manager/datamodel/webmgmt_schema.py @@ -1,6 +1,6 @@ from typing import Optional -from knot_resolver_manager.datamodel.types import File, FilePath, InterfacePort +from knot_resolver_manager.datamodel.types import WritableFilePath, InterfacePort, ReadableFile from knot_resolver_manager.utils.modeling import ConfigSchema @@ -16,11 +16,11 @@ class WebmgmtSchema(ConfigSchema): key_file: Path to certificate key. """ - unix_socket: Optional[FilePath] = None + unix_socket: Optional[WritableFilePath] = None interface: Optional[InterfacePort] = None tls: bool = False - cert_file: Optional[File] = None - key_file: Optional[File] = None + cert_file: Optional[ReadableFile] = None + key_file: Optional[ReadableFile] = None def _validate(self) -> None: if bool(self.unix_socket) == bool(self.interface): diff --git a/python/knot_resolver.py.in b/python/knot_resolver.py.in index 262f7a840..e6b2accbd 100644 --- a/python/knot_resolver.py.in +++ b/python/knot_resolver.py.in @@ -8,3 +8,5 @@ etc_dir = Path("@etc_dir@") run_dir = Path("@run_dir@") lib_dir = Path("@lib_dir@") modules_dir = Path("@modules_dir@") +user = "@user@" +group = "@group@" diff --git a/python/meson.build b/python/meson.build index e209df542..c6b2a27ed 100644 --- a/python/meson.build +++ b/python/meson.build @@ -9,6 +9,8 @@ python_config.set('etc_dir', etc_dir) python_config.set('run_dir', run_dir) python_config.set('lib_dir', lib_dir) python_config.set('modules_dir', modules_dir) +python_config.set('user', user) +python_config.set('group', group) configure_file( input: 'knot_resolver.py.in', diff --git a/scripts/poe-tasks/configure b/scripts/poe-tasks/configure index d205a6dec..ea2bdeb3e 100755 --- a/scripts/poe-tasks/configure +++ b/scripts/poe-tasks/configure @@ -8,6 +8,6 @@ reconfigure='' if [ -f .build_kresd/ninja.build ]; then reconfigure='--reconfigure' fi -meson setup .build_kresd "$reconfigure" --prefix=$(realpath .install_kresd) "$@" +meson setup .build_kresd "$reconfigure" --prefix=$(realpath .install_kresd) -Duser=$USER -Dgroup=$(id -gn) "$@" build_kresd