1# This file is part of Radicale Server - Calendar Server
2# Copyright © 2008-2017 Guillaume Ayoub
3# Copyright © 2008 Nicolas Kandel
4# Copyright © 2008 Pascal Halter
5# Copyright © 2017-2019 Unrud <unrud@outlook.com>
6#
7# This library is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This library is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
19
20"""
21Configuration module
22
23Use ``load()`` to obtain an instance of ``Configuration`` for use with
24``radicale.app.Application``.
25
26"""
27
28import contextlib
29import math
30import os
31import string
32from collections import OrderedDict
33from configparser import RawConfigParser
34
35from radicale import auth, rights, storage, web
36
37DEFAULT_CONFIG_PATH = os.pathsep.join([
38    "?/usr/local/etc/radicale/config",
39    "?~/.config/radicale/config"])
40
41
42def positive_int(value):
43    value = int(value)
44    if value < 0:
45        raise ValueError("value is negative: %d" % value)
46    return value
47
48
49def positive_float(value):
50    value = float(value)
51    if not math.isfinite(value):
52        raise ValueError("value is infinite")
53    if math.isnan(value):
54        raise ValueError("value is not a number")
55    if value < 0:
56        raise ValueError("value is negative: %f" % value)
57    return value
58
59
60def logging_level(value):
61    if value not in ("debug", "info", "warning", "error", "critical"):
62        raise ValueError("unsupported level: %r" % value)
63    return value
64
65
66def filepath(value):
67    if not value:
68        return ""
69    value = os.path.expanduser(value)
70    if os.name == "nt":
71        value = os.path.expandvars(value)
72    return os.path.abspath(value)
73
74
75def list_of_ip_address(value):
76    def ip_address(value):
77        try:
78            address, port = value.rsplit(":", 1)
79            return address.strip(string.whitespace + "[]"), int(port)
80        except ValueError:
81            raise ValueError("malformed IP address: %r" % value)
82    return [ip_address(s) for s in value.split(",")]
83
84
85def str_or_callable(value):
86    if callable(value):
87        return value
88    return str(value)
89
90
91def unspecified_type(value):
92    return value
93
94
95def _convert_to_bool(value):
96    if value.lower() not in RawConfigParser.BOOLEAN_STATES:
97        raise ValueError("Not a boolean: %r" % value)
98    return RawConfigParser.BOOLEAN_STATES[value.lower()]
99
100
101INTERNAL_OPTIONS = ("_allow_extra",)
102# Default configuration
103DEFAULT_CONFIG_SCHEMA = OrderedDict([
104    ("server", OrderedDict([
105        ("hosts", {
106            "value": "localhost:5232",
107            "help": "set server hostnames including ports",
108            "aliases": ["-H", "--hosts"],
109            "type": list_of_ip_address}),
110        ("max_connections", {
111            "value": "8",
112            "help": "maximum number of parallel connections",
113            "type": positive_int}),
114        ("max_content_length", {
115            "value": "100000000",
116            "help": "maximum size of request body in bytes",
117            "type": positive_int}),
118        ("timeout", {
119            "value": "30",
120            "help": "socket timeout",
121            "type": positive_int}),
122        ("ssl", {
123            "value": "False",
124            "help": "use SSL connection",
125            "aliases": ["-s", "--ssl"],
126            "opposite": ["-S", "--no-ssl"],
127            "type": bool}),
128        ("certificate", {
129            "value": "/usr/local/etc/radicale/radicale.cert.pem",
130            "help": "set certificate file",
131            "aliases": ["-c", "--certificate"],
132            "type": filepath}),
133        ("key", {
134            "value": "/usr/local/etc/radicale/radicale.key.pem",
135            "help": "set private key file",
136            "aliases": ["-k", "--key"],
137            "type": filepath}),
138        ("certificate_authority", {
139            "value": "",
140            "help": "set CA certificate for validating clients",
141            "aliases": ["--certificate-authority"],
142            "type": filepath}),
143        ("_internal_server", {
144            "value": "False",
145            "help": "the internal server is used",
146            "type": bool})])),
147    ("encoding", OrderedDict([
148        ("request", {
149            "value": "utf-8",
150            "help": "encoding for responding requests",
151            "type": str}),
152        ("stock", {
153            "value": "utf-8",
154            "help": "encoding for storing local collections",
155            "type": str})])),
156    ("auth", OrderedDict([
157        ("type", {
158            "value": "none",
159            "help": "authentication method",
160            "type": str_or_callable,
161            "internal": auth.INTERNAL_TYPES}),
162        ("htpasswd_filename", {
163            "value": "/usr/local/etc/radicale/users",
164            "help": "htpasswd filename",
165            "type": filepath}),
166        ("htpasswd_encryption", {
167            "value": "md5",
168            "help": "htpasswd encryption method",
169            "type": str}),
170        ("realm", {
171            "value": "Radicale - Password Required",
172            "help": "message displayed when a password is needed",
173            "type": str}),
174        ("delay", {
175            "value": "1",
176            "help": "incorrect authentication delay",
177            "type": positive_float})])),
178    ("rights", OrderedDict([
179        ("type", {
180            "value": "owner_only",
181            "help": "rights backend",
182            "type": str_or_callable,
183            "internal": rights.INTERNAL_TYPES}),
184        ("file", {
185            "value": "/usr/local/etc/radicale/rights",
186            "help": "file for rights management from_file",
187            "type": filepath})])),
188    ("storage", OrderedDict([
189        ("type", {
190            "value": "multifilesystem",
191            "help": "storage backend",
192            "type": str_or_callable,
193            "internal": storage.INTERNAL_TYPES}),
194        ("filesystem_folder", {
195            "value": "/usr/local/share/radicale/collections",
196            "help": "path where collections are stored",
197            "type": filepath}),
198        ("max_sync_token_age", {
199            "value": "2592000",  # 30 days
200            "help": "delete sync token that are older",
201            "type": positive_int}),
202        ("hook", {
203            "value": "",
204            "help": "command that is run after changes to storage",
205            "type": str}),
206        ("_filesystem_fsync", {
207            "value": "True",
208            "help": "sync all changes to filesystem during requests",
209            "type": bool})])),
210    ("web", OrderedDict([
211        ("type", {
212            "value": "internal",
213            "help": "web interface backend",
214            "type": str_or_callable,
215            "internal": web.INTERNAL_TYPES})])),
216    ("logging", OrderedDict([
217        ("level", {
218            "value": "warning",
219            "help": "threshold for the logger",
220            "type": logging_level}),
221        ("mask_passwords", {
222            "value": "True",
223            "help": "mask passwords in logs",
224            "type": bool})])),
225    ("headers", OrderedDict([
226        ("_allow_extra", str)]))])
227
228
229def parse_compound_paths(*compound_paths):
230    """Parse a compound path and return the individual paths.
231    Paths in a compound path are joined by ``os.pathsep``. If a path starts
232    with ``?`` the return value ``IGNORE_IF_MISSING`` is set.
233
234    When multiple ``compound_paths`` are passed, the last argument that is
235    not ``None`` is used.
236
237    Returns a dict of the format ``[(PATH, IGNORE_IF_MISSING), ...]``
238
239    """
240    compound_path = ""
241    for p in compound_paths:
242        if p is not None:
243            compound_path = p
244    paths = []
245    for path in compound_path.split(os.pathsep):
246        ignore_if_missing = path.startswith("?")
247        if ignore_if_missing:
248            path = path[1:]
249        path = filepath(path)
250        if path:
251            paths.append((path, ignore_if_missing))
252    return paths
253
254
255def load(paths=()):
256    """
257    Create instance of ``Configuration`` for use with
258    ``radicale.app.Application``.
259
260    ``paths`` a list of configuration files with the format
261    ``[(PATH, IGNORE_IF_MISSING), ...]``.
262    If a configuration file is missing and IGNORE_IF_MISSING is set, the
263    config is set to ``Configuration.SOURCE_MISSING``.
264
265    The configuration can later be changed with ``Configuration.update()``.
266
267    """
268    configuration = Configuration(DEFAULT_CONFIG_SCHEMA)
269    for path, ignore_if_missing in paths:
270        parser = RawConfigParser()
271        config_source = "config file %r" % path
272        try:
273            if not parser.read(path):
274                config = Configuration.SOURCE_MISSING
275                if not ignore_if_missing:
276                    raise RuntimeError("No such file: %r" % path)
277            else:
278                config = {s: {o: parser[s][o] for o in parser.options(s)}
279                          for s in parser.sections()}
280        except Exception as e:
281            raise RuntimeError(
282                "Failed to load %s: %s" % (config_source, e)) from e
283        configuration.update(config, config_source)
284    return configuration
285
286
287class Configuration:
288    SOURCE_MISSING = {}
289
290    def __init__(self, schema):
291        """Initialize configuration.
292
293        ``schema`` a dict that describes the configuration format.
294        See ``DEFAULT_CONFIG_SCHEMA``.
295        The content of ``schema`` must not change afterwards, it is kept
296        as an internal reference.
297
298        Use ``load()`` to create an instance for use with
299        ``radicale.app.Application``.
300
301        """
302        self._schema = schema
303        self._values = {}
304        self._configs = []
305        default = {section: {option: self._schema[section][option]["value"]
306                             for option in self._schema[section]
307                             if option not in INTERNAL_OPTIONS}
308                   for section in self._schema}
309        self.update(default, "default config", privileged=True)
310
311    def update(self, config, source=None, privileged=False):
312        """Update the configuration.
313
314        ``config`` a dict of the format {SECTION: {OPTION: VALUE, ...}, ...}.
315        The configuration is checked for errors according to the config schema.
316        The content of ``config`` must not change afterwards, it is kept
317        as an internal reference.
318
319        ``source`` a description of the configuration source (used in error
320        messages).
321
322        ``privileged`` allows updating sections and options starting with "_".
323
324        """
325        source = source or "unspecified config"
326        new_values = {}
327        for section in config:
328            if (section not in self._schema or
329                    section.startswith("_") and not privileged):
330                raise ValueError(
331                    "Invalid section %r in %s" % (section, source))
332            new_values[section] = {}
333            extra_type = None
334            extra_type = self._schema[section].get("_allow_extra")
335            if "type" in self._schema[section]:
336                if "type" in config[section]:
337                    plugin = config[section]["type"]
338                else:
339                    plugin = self.get(section, "type")
340                if plugin not in self._schema[section]["type"]["internal"]:
341                    extra_type = unspecified_type
342            for option in config[section]:
343                type_ = extra_type
344                if option in self._schema[section]:
345                    type_ = self._schema[section][option]["type"]
346                if (not type_ or option in INTERNAL_OPTIONS or
347                        option.startswith("_") and not privileged):
348                    raise RuntimeError("Invalid option %r in section %r in "
349                                       "%s" % (option, section, source))
350                raw_value = config[section][option]
351                try:
352                    if type_ == bool and not isinstance(raw_value, bool):
353                        raw_value = _convert_to_bool(raw_value)
354                    new_values[section][option] = type_(raw_value)
355                except Exception as e:
356                    raise RuntimeError(
357                        "Invalid %s value for option %r in section %r in %s: "
358                        "%r" % (type_.__name__, option, section, source,
359                                raw_value)) from e
360        self._configs.append((config, source, bool(privileged)))
361        for section in new_values:
362            self._values[section] = self._values.get(section, {})
363            self._values[section].update(new_values[section])
364
365    def get(self, section, option):
366        """Get the value of ``option`` in ``section``."""
367        with contextlib.suppress(KeyError):
368            return self._values[section][option]
369        raise KeyError(section, option)
370
371    def get_raw(self, section, option):
372        """Get the raw value of ``option`` in ``section``."""
373        for config, _, _ in reversed(self._configs):
374            if option in config.get(section, {}):
375                return config[section][option]
376        raise KeyError(section, option)
377
378    def get_source(self, section, option):
379        """Get the source that provides ``option`` in ``section``."""
380        for config, source, _ in reversed(self._configs):
381            if option in config.get(section, {}):
382                return source
383        raise KeyError(section, option)
384
385    def sections(self):
386        """List all sections."""
387        return self._values.keys()
388
389    def options(self, section):
390        """List all options in ``section``"""
391        return self._values[section].keys()
392
393    def sources(self):
394        """List all config sources."""
395        return [(source, config is self.SOURCE_MISSING) for
396                config, source, _ in self._configs]
397
398    def copy(self, plugin_schema=None):
399        """Create a copy of the configuration
400
401        ``plugin_schema`` is a optional dict that contains additional options
402        for usage with a plugin. See ``DEFAULT_CONFIG_SCHEMA``.
403
404        """
405        if plugin_schema is None:
406            schema = self._schema
407        else:
408            schema = self._schema.copy()
409            for section, options in plugin_schema.items():
410                if (section not in schema or "type" not in schema[section] or
411                        "internal" not in schema[section]["type"]):
412                    raise ValueError("not a plugin section: %r" % section)
413                schema[section] = schema[section].copy()
414                schema[section]["type"] = schema[section]["type"].copy()
415                schema[section]["type"]["internal"] = [
416                    self.get(section, "type")]
417                for option, value in options.items():
418                    if option in schema[section]:
419                        raise ValueError("option already exists in %r: %r" % (
420                            section, option))
421                    schema[section][option] = value
422        copy = type(self)(schema)
423        for config, source, privileged in self._configs:
424            copy.update(config, source, privileged)
425        return copy
426