radicale/radicale/config.py

447 lines
16 KiB
Python
Raw Normal View History

# This file is part of Radicale Server - Calendar Server
2017-05-27 17:28:07 +02:00
# Copyright © 2008-2017 Guillaume Ayoub
# Copyright © 2008 Nicolas Kandel
# Copyright © 2008 Pascal Halter
2019-06-17 04:13:25 +02:00
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
#
# This library is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
"""
2020-01-12 23:32:28 +01:00
Configuration module
2020-01-12 23:32:28 +01:00
Use ``load()`` to obtain an instance of ``Configuration`` for use with
``radicale.app.Application``.
"""
2020-02-19 09:50:25 +01:00
import contextlib
import math
import os
2020-05-19 07:03:58 +02:00
import string
2021-07-26 20:56:46 +02:00
import sys
2016-10-11 18:17:01 +02:00
from collections import OrderedDict
2019-06-17 04:13:25 +02:00
from configparser import RawConfigParser
2021-07-26 20:56:46 +02:00
from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
Sequence, Tuple, TypeVar, Union)
2021-07-26 20:56:46 +02:00
from radicale import auth, rights, storage, types, web
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
DEFAULT_CONFIG_PATH: str = os.pathsep.join([
2019-06-17 04:13:25 +02:00
"?/etc/radicale/config",
"?~/.config/radicale/config"])
2021-07-26 20:56:46 +02:00
def positive_int(value: Any) -> int:
value = int(value)
if value < 0:
raise ValueError("value is negative: %d" % value)
return value
2021-07-26 20:56:46 +02:00
def positive_float(value: Any) -> float:
value = float(value)
if not math.isfinite(value):
raise ValueError("value is infinite")
if math.isnan(value):
raise ValueError("value is not a number")
if value < 0:
raise ValueError("value is negative: %f" % value)
return value
2021-07-26 20:56:46 +02:00
def logging_level(value: Any) -> str:
if value not in ("debug", "info", "warning", "error", "critical"):
2019-06-17 04:13:25 +02:00
raise ValueError("unsupported level: %r" % value)
return value
2021-07-26 20:56:46 +02:00
def filepath(value: Any) -> str:
2019-06-17 04:13:25 +02:00
if not value:
return ""
value = os.path.expanduser(value)
2021-07-26 20:56:46 +02:00
if sys.platform == "win32":
2019-06-17 04:13:25 +02:00
value = os.path.expandvars(value)
return os.path.abspath(value)
2021-07-26 20:56:46 +02:00
def list_of_ip_address(value: Any) -> List[Tuple[str, int]]:
2019-06-17 04:13:25 +02:00
def ip_address(value):
try:
2020-05-19 06:46:07 +02:00
address, port = value.rsplit(":", 1)
2020-05-19 07:03:58 +02:00
return address.strip(string.whitespace + "[]"), int(port)
2019-06-17 04:13:25 +02:00
except ValueError:
raise ValueError("malformed IP address: %r" % value)
2020-05-19 06:46:07 +02:00
return [ip_address(s) for s in value.split(",")]
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def str_or_callable(value: Any) -> Union[str, Callable]:
if callable(value):
return value
return str(value)
2021-07-26 20:56:46 +02:00
def unspecified_type(value: Any) -> Any:
2020-02-19 09:50:30 +01:00
return value
2021-07-26 20:56:46 +02:00
def _convert_to_bool(value: Any) -> bool:
2019-06-17 04:13:25 +02:00
if value.lower() not in RawConfigParser.BOOLEAN_STATES:
raise ValueError("not a boolean: %r" % value)
2019-06-17 04:13:25 +02:00
return RawConfigParser.BOOLEAN_STATES[value.lower()]
2021-07-26 20:56:46 +02:00
INTERNAL_OPTIONS: Sequence[str] = ("_allow_extra",)
# Default configuration
2021-07-26 20:56:46 +02:00
DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
2016-10-11 18:17:01 +02:00
("server", OrderedDict([
("hosts", {
2020-02-19 09:49:56 +01:00
"value": "localhost:5232",
"help": "set server hostnames including ports",
2020-10-04 05:38:58 +02:00
"aliases": ("-H", "--hosts",),
2019-06-17 04:13:25 +02:00
"type": list_of_ip_address}),
("max_connections", {
"value": "8",
"help": "maximum number of parallel connections",
"type": positive_int}),
("max_content_length", {
2018-04-29 21:20:23 +02:00
"value": "100000000",
"help": "maximum size of request body in bytes",
"type": positive_int}),
("timeout", {
2018-04-29 21:20:23 +02:00
"value": "30",
"help": "socket timeout",
"type": positive_float}),
("ssl", {
"value": "False",
"help": "use SSL connection",
2020-10-04 05:38:58 +02:00
"aliases": ("-s", "--ssl",),
2021-11-10 22:16:30 +01:00
"opposite_aliases": ("-S", "--no-ssl",),
"type": bool}),
("certificate", {
"value": "/etc/ssl/radicale.cert.pem",
"help": "set certificate file",
2020-10-04 05:38:58 +02:00
"aliases": ("-c", "--certificate",),
2019-06-17 04:13:25 +02:00
"type": filepath}),
("key", {
"value": "/etc/ssl/radicale.key.pem",
"help": "set private key file",
2020-10-04 05:38:58 +02:00
"aliases": ("-k", "--key",),
2019-06-17 04:13:25 +02:00
"type": filepath}),
("certificate_authority", {
"value": "",
"help": "set CA certificate for validating clients",
2020-10-04 05:38:58 +02:00
"aliases": ("--certificate-authority",),
"type": filepath}),
("_internal_server", {
"value": "False",
"help": "the internal server is used",
"type": bool})])),
2016-10-11 18:17:01 +02:00
("encoding", OrderedDict([
("request", {
"value": "utf-8",
"help": "encoding for responding requests",
"type": str}),
("stock", {
"value": "utf-8",
"help": "encoding for storing local collections",
"type": str})])),
2016-10-11 18:17:01 +02:00
("auth", OrderedDict([
("type", {
2017-06-02 12:43:23 +02:00
"value": "none",
"help": "authentication method",
"type": str_or_callable,
"internal": auth.INTERNAL_TYPES}),
("htpasswd_filename", {
"value": "/etc/radicale/users",
"help": "htpasswd filename",
2019-06-17 04:13:25 +02:00
"type": filepath}),
("htpasswd_encryption", {
"value": "md5",
"help": "htpasswd encryption method",
"type": str}),
("realm", {
"value": "Radicale - Password Required",
"help": "message displayed when a password is needed",
"type": str}),
("delay", {
"value": "1",
"help": "incorrect authentication delay",
"type": positive_float})])),
2016-10-11 18:17:01 +02:00
("rights", OrderedDict([
("type", {
"value": "owner_only",
"help": "rights backend",
"type": str_or_callable,
"internal": rights.INTERNAL_TYPES}),
("file", {
"value": "/etc/radicale/rights",
"help": "file for rights management from_file",
2019-06-17 04:13:25 +02:00
"type": filepath})])),
2016-10-11 18:17:01 +02:00
("storage", OrderedDict([
("type", {
"value": "multifilesystem",
"help": "storage backend",
"type": str_or_callable,
"internal": storage.INTERNAL_TYPES}),
("filesystem_folder", {
2019-06-17 04:13:25 +02:00
"value": "/var/lib/radicale/collections",
"help": "path where collections are stored",
2019-06-17 04:13:25 +02:00
"type": filepath}),
("max_sync_token_age", {
2018-09-09 14:58:43 +02:00
"value": "2592000", # 30 days
"help": "delete sync token that are older",
2019-06-17 04:13:25 +02:00
"type": positive_int}),
("hook", {
"value": "",
"help": "command that is run after changes to storage",
"type": str}),
("_filesystem_fsync", {
"value": "True",
"help": "sync all changes to filesystem during requests",
"type": bool})])),
2017-05-31 13:18:40 +02:00
("web", OrderedDict([
("type", {
"value": "internal",
2017-05-31 13:18:40 +02:00
"help": "web interface backend",
"type": str_or_callable,
"internal": web.INTERNAL_TYPES})])),
2016-10-11 18:17:01 +02:00
("logging", OrderedDict([
("level", {
"value": "warning",
"help": "threshold for the logger",
"type": logging_level}),
("mask_passwords", {
"value": "True",
"help": "mask passwords in logs",
2019-06-17 04:13:25 +02:00
"type": bool})])),
("headers", OrderedDict([
("_allow_extra", str)]))])
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def parse_compound_paths(*compound_paths: Optional[str]
) -> List[Tuple[str, bool]]:
2019-06-17 04:13:25 +02:00
"""Parse a compound path and return the individual paths.
Paths in a compound path are joined by ``os.pathsep``. If a path starts
with ``?`` the return value ``IGNORE_IF_MISSING`` is set.
When multiple ``compound_paths`` are passed, the last argument that is
not ``None`` is used.
Returns a dict of the format ``[(PATH, IGNORE_IF_MISSING), ...]``
"""
compound_path = ""
for p in compound_paths:
if p is not None:
compound_path = p
paths = []
for path in compound_path.split(os.pathsep):
ignore_if_missing = path.startswith("?")
if ignore_if_missing:
path = path[1:]
path = filepath(path)
if path:
paths.append((path, ignore_if_missing))
return paths
2021-07-26 20:56:46 +02:00
def load(paths: Optional[Iterable[Tuple[str, bool]]] = None
) -> "Configuration":
2020-01-12 23:32:28 +01:00
"""
Create instance of ``Configuration`` for use with
``radicale.app.Application``.
``paths`` a list of configuration files with the format
``[(PATH, IGNORE_IF_MISSING), ...]``.
If a configuration file is missing and IGNORE_IF_MISSING is set, the
config is set to ``Configuration.SOURCE_MISSING``.
2019-06-17 04:13:25 +02:00
2020-01-12 23:32:28 +01:00
The configuration can later be changed with ``Configuration.update()``.
2019-06-17 04:13:25 +02:00
"""
2021-07-26 20:56:46 +02:00
if paths is None:
paths = []
2019-06-17 04:13:25 +02:00
configuration = Configuration(DEFAULT_CONFIG_SCHEMA)
for path, ignore_if_missing in paths:
parser = RawConfigParser()
config_source = "config file %r" % path
try:
if not parser.read(path):
config = Configuration.SOURCE_MISSING
if not ignore_if_missing:
raise RuntimeError("No such file: %r" % path)
2019-06-17 04:13:25 +02:00
else:
config = {s: {o: parser[s][o] for o in parser.options(s)}
for s in parser.sections()}
except Exception as e:
2021-07-26 20:56:46 +02:00
raise RuntimeError("Failed to load %s: %s" % (config_source, e)
) from e
configuration.update(config, config_source)
2019-06-17 04:13:25 +02:00
return configuration
2021-07-26 20:56:46 +02:00
_Self = TypeVar("_Self", bound="Configuration")
2019-06-17 04:13:25 +02:00
class Configuration:
2021-07-26 20:56:46 +02:00
SOURCE_MISSING: ClassVar[types.CONFIG] = {}
_schema: types.CONFIG_SCHEMA
_values: types.MUTABLE_CONFIG
_configs: List[Tuple[types.CONFIG, str, bool]]
def __init__(self, schema: types.CONFIG_SCHEMA) -> None:
2019-06-17 04:13:25 +02:00
"""Initialize configuration.
``schema`` a dict that describes the configuration format.
See ``DEFAULT_CONFIG_SCHEMA``.
2020-01-13 15:51:10 +01:00
The content of ``schema`` must not change afterwards, it is kept
as an internal reference.
2019-06-17 04:13:25 +02:00
2020-01-12 23:32:28 +01:00
Use ``load()`` to create an instance for use with
``radicale.app.Application``.
2019-06-17 04:13:25 +02:00
"""
self._schema = schema
self._values = {}
self._configs = []
default = {section: {option: self._schema[section][option]["value"]
for option in self._schema[section]
if option not in INTERNAL_OPTIONS}
for section in self._schema}
self.update(default, "default config", privileged=True)
2021-07-26 20:56:46 +02:00
def update(self, config: types.CONFIG, source: Optional[str] = None,
privileged: bool = False) -> None:
2019-06-17 04:13:25 +02:00
"""Update the configuration.
``config`` a dict of the format {SECTION: {OPTION: VALUE, ...}, ...}.
2020-01-12 23:32:28 +01:00
The configuration is checked for errors according to the config schema.
2020-01-13 15:51:10 +01:00
The content of ``config`` must not change afterwards, it is kept
as an internal reference.
2019-06-17 04:13:25 +02:00
2020-01-12 23:32:28 +01:00
``source`` a description of the configuration source (used in error
messages).
2019-06-17 04:13:25 +02:00
``privileged`` allows updating sections and options starting with "_".
2019-06-17 04:13:25 +02:00
"""
2021-07-26 20:56:46 +02:00
if source is None:
source = "unspecified config"
new_values: types.MUTABLE_CONFIG = {}
2019-06-17 04:13:25 +02:00
for section in config:
if (section not in self._schema or
section.startswith("_") and not privileged):
raise ValueError(
2019-06-17 04:13:25 +02:00
"Invalid section %r in %s" % (section, source))
new_values[section] = {}
extra_type = None
2020-02-19 09:50:30 +01:00
extra_type = self._schema[section].get("_allow_extra")
if "type" in self._schema[section]:
2019-06-17 04:13:25 +02:00
if "type" in config[section]:
plugin = config[section]["type"]
2019-06-17 04:13:25 +02:00
else:
plugin = self.get(section, "type")
if plugin not in self._schema[section]["type"]["internal"]:
2020-02-19 09:50:30 +01:00
extra_type = unspecified_type
2019-06-17 04:13:25 +02:00
for option in config[section]:
type_ = extra_type
2019-06-17 04:13:25 +02:00
if option in self._schema[section]:
type_ = self._schema[section][option]["type"]
if (not type_ or option in INTERNAL_OPTIONS or
option.startswith("_") and not privileged):
2019-06-17 04:13:25 +02:00
raise RuntimeError("Invalid option %r in section %r in "
"%s" % (option, section, source))
raw_value = config[section][option]
try:
2020-01-17 12:45:01 +01:00
if type_ == bool and not isinstance(raw_value, bool):
2019-06-17 04:13:25 +02:00
raw_value = _convert_to_bool(raw_value)
new_values[section][option] = type_(raw_value)
except Exception as e:
raise RuntimeError(
"Invalid %s value for option %r in section %r in %s: "
"%r" % (type_.__name__, option, section, source,
raw_value)) from e
self._configs.append((config, source, bool(privileged)))
2019-06-17 04:13:25 +02:00
for section in new_values:
self._values[section] = self._values.get(section, {})
self._values[section].update(new_values[section])
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def get(self, section: str, option: str) -> Any:
2019-06-17 04:13:25 +02:00
"""Get the value of ``option`` in ``section``."""
2020-02-19 09:50:25 +01:00
with contextlib.suppress(KeyError):
return self._values[section][option]
raise KeyError(section, option)
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def get_raw(self, section: str, option: str) -> Any:
2019-06-17 04:13:25 +02:00
"""Get the raw value of ``option`` in ``section``."""
for config, _, _ in reversed(self._configs):
2020-02-19 09:50:25 +01:00
if option in config.get(section, {}):
return config[section][option]
raise KeyError(section, option)
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def get_source(self, section: str, option: str) -> str:
"""Get the source that provides ``option`` in ``section``."""
for config, source, _ in reversed(self._configs):
if option in config.get(section, {}):
return source
raise KeyError(section, option)
2021-07-26 20:56:46 +02:00
def sections(self) -> List[str]:
2019-06-17 04:13:25 +02:00
"""List all sections."""
2021-07-26 20:56:46 +02:00
return list(self._values.keys())
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def options(self, section: str) -> List[str]:
2019-06-17 04:13:25 +02:00
"""List all options in ``section``"""
2021-07-26 20:56:46 +02:00
return list(self._values[section].keys())
2019-06-17 04:13:25 +02:00
2021-07-26 20:56:46 +02:00
def sources(self) -> List[Tuple[str, bool]]:
"""List all config sources."""
return [(source, config is self.SOURCE_MISSING) for
config, source, _ in self._configs]
2021-07-26 20:56:46 +02:00
def copy(self: _Self, plugin_schema: Optional[types.CONFIG_SCHEMA] = None
) -> _Self:
2019-06-17 04:13:25 +02:00
"""Create a copy of the configuration
``plugin_schema`` is a optional dict that contains additional options
for usage with a plugin. See ``DEFAULT_CONFIG_SCHEMA``.
"""
if plugin_schema is None:
schema = self._schema
else:
2021-07-26 20:56:46 +02:00
new_schema = dict(self._schema)
2019-06-17 04:13:25 +02:00
for section, options in plugin_schema.items():
2021-07-26 20:56:46 +02:00
if (section not in new_schema or
"type" not in new_schema[section] or
"internal" not in new_schema[section]["type"]):
2019-06-17 04:13:25 +02:00
raise ValueError("not a plugin section: %r" % section)
2021-07-26 20:56:46 +02:00
new_section = dict(new_schema[section])
new_type = dict(new_section["type"])
new_type["internal"] = (self.get(section, "type"),)
new_section["type"] = new_type
2019-06-17 04:13:25 +02:00
for option, value in options.items():
2021-07-26 20:56:46 +02:00
if option in new_section:
raise ValueError("option already exists in %r: %r" %
(section, option))
new_section[option] = value
new_schema[section] = new_section
schema = new_schema
2020-01-12 23:32:27 +01:00
copy = type(self)(schema)
for config, source, privileged in self._configs:
copy.update(config, source, privileged)
2019-06-17 04:13:25 +02:00
return copy