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