config.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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. Configuration module
  21. Use ``load()`` to obtain an instance of ``Configuration`` for use with
  22. ``radicale.app.Application``.
  23. """
  24. import math
  25. import os
  26. from collections import OrderedDict
  27. from configparser import RawConfigParser
  28. from radicale import auth, rights, storage, web
  29. from radicale.log import logger
  30. DEFAULT_CONFIG_PATH = os.pathsep.join([
  31. "?/etc/radicale/config",
  32. "?~/.config/radicale/config"])
  33. def positive_int(value):
  34. value = int(value)
  35. if value < 0:
  36. raise ValueError("value is negative: %d" % value)
  37. return value
  38. def positive_float(value):
  39. value = float(value)
  40. if not math.isfinite(value):
  41. raise ValueError("value is infinite")
  42. if math.isnan(value):
  43. raise ValueError("value is not a number")
  44. if value < 0:
  45. raise ValueError("value is negative: %f" % value)
  46. return value
  47. def logging_level(value):
  48. if value not in ("debug", "info", "warning", "error", "critical"):
  49. raise ValueError("unsupported level: %r" % value)
  50. return value
  51. def filepath(value):
  52. if not value:
  53. return ""
  54. value = os.path.expanduser(value)
  55. if os.name == "nt":
  56. value = os.path.expandvars(value)
  57. return os.path.abspath(value)
  58. def list_of_ip_address(value):
  59. def ip_address(value):
  60. try:
  61. address, port = value.strip().rsplit(":", 1)
  62. return address.strip("[] "), int(port)
  63. except ValueError:
  64. raise ValueError("malformed IP address: %r" % value)
  65. return [ip_address(s.strip()) for s in value.split(",")]
  66. def _convert_to_bool(value):
  67. if value.lower() not in RawConfigParser.BOOLEAN_STATES:
  68. raise ValueError("Not a boolean: %r" % value)
  69. return RawConfigParser.BOOLEAN_STATES[value.lower()]
  70. # Default configuration
  71. DEFAULT_CONFIG_SCHEMA = OrderedDict([
  72. ("server", OrderedDict([
  73. ("hosts", {
  74. "value": "127.0.0.1:5232",
  75. "help": "set server hostnames including ports",
  76. "aliases": ["-H", "--hosts"],
  77. "type": list_of_ip_address}),
  78. ("max_connections", {
  79. "value": "8",
  80. "help": "maximum number of parallel connections",
  81. "type": positive_int}),
  82. ("max_content_length", {
  83. "value": "100000000",
  84. "help": "maximum size of request body in bytes",
  85. "type": positive_int}),
  86. ("timeout", {
  87. "value": "30",
  88. "help": "socket timeout",
  89. "type": positive_int}),
  90. ("ssl", {
  91. "value": "False",
  92. "help": "use SSL connection",
  93. "aliases": ["-s", "--ssl"],
  94. "opposite": ["-S", "--no-ssl"],
  95. "type": bool}),
  96. ("certificate", {
  97. "value": "/etc/ssl/radicale.cert.pem",
  98. "help": "set certificate file",
  99. "aliases": ["-c", "--certificate"],
  100. "type": filepath}),
  101. ("key", {
  102. "value": "/etc/ssl/radicale.key.pem",
  103. "help": "set private key file",
  104. "aliases": ["-k", "--key"],
  105. "type": filepath}),
  106. ("certificate_authority", {
  107. "value": "",
  108. "help": "set CA certificate for validating clients",
  109. "aliases": ["--certificate-authority"],
  110. "type": filepath}),
  111. ("protocol", {
  112. "value": "PROTOCOL_TLSv1_2",
  113. "help": "SSL protocol used",
  114. "type": str}),
  115. ("ciphers", {
  116. "value": "",
  117. "help": "available ciphers",
  118. "type": str}),
  119. ("dns_lookup", {
  120. "value": "True",
  121. "help": "use reverse DNS to resolve client address in logs",
  122. "type": bool})])),
  123. ("encoding", OrderedDict([
  124. ("request", {
  125. "value": "utf-8",
  126. "help": "encoding for responding requests",
  127. "type": str}),
  128. ("stock", {
  129. "value": "utf-8",
  130. "help": "encoding for storing local collections",
  131. "type": str})])),
  132. ("auth", OrderedDict([
  133. ("type", {
  134. "value": "none",
  135. "help": "authentication method",
  136. "type": str,
  137. "internal": auth.INTERNAL_TYPES}),
  138. ("htpasswd_filename", {
  139. "value": "/etc/radicale/users",
  140. "help": "htpasswd filename",
  141. "type": filepath}),
  142. ("htpasswd_encryption", {
  143. "value": "md5",
  144. "help": "htpasswd encryption method",
  145. "type": str}),
  146. ("realm", {
  147. "value": "Radicale - Password Required",
  148. "help": "message displayed when a password is needed",
  149. "type": str}),
  150. ("delay", {
  151. "value": "1",
  152. "help": "incorrect authentication delay",
  153. "type": positive_float})])),
  154. ("rights", OrderedDict([
  155. ("type", {
  156. "value": "owner_only",
  157. "help": "rights backend",
  158. "type": str,
  159. "internal": rights.INTERNAL_TYPES}),
  160. ("file", {
  161. "value": "/etc/radicale/rights",
  162. "help": "file for rights management from_file",
  163. "type": filepath})])),
  164. ("storage", OrderedDict([
  165. ("type", {
  166. "value": "multifilesystem",
  167. "help": "storage backend",
  168. "type": str,
  169. "internal": storage.INTERNAL_TYPES}),
  170. ("filesystem_folder", {
  171. "value": "/var/lib/radicale/collections",
  172. "help": "path where collections are stored",
  173. "type": filepath}),
  174. ("max_sync_token_age", {
  175. "value": "2592000", # 30 days
  176. "help": "delete sync token that are older",
  177. "type": positive_int}),
  178. ("hook", {
  179. "value": "",
  180. "help": "command that is run after changes to storage",
  181. "type": str})])),
  182. ("web", OrderedDict([
  183. ("type", {
  184. "value": "internal",
  185. "help": "web interface backend",
  186. "type": str,
  187. "internal": web.INTERNAL_TYPES})])),
  188. ("logging", OrderedDict([
  189. ("level", {
  190. "value": "warning",
  191. "help": "threshold for the logger",
  192. "type": logging_level}),
  193. ("mask_passwords", {
  194. "value": "True",
  195. "help": "mask passwords in logs",
  196. "type": bool})])),
  197. ("headers", OrderedDict([
  198. ("_allow_extra", True)])),
  199. ("internal", OrderedDict([
  200. ("_internal", True),
  201. ("filesystem_fsync", {
  202. "value": "True",
  203. "help": "sync all changes to filesystem during requests",
  204. "type": bool}),
  205. ("internal_server", {
  206. "value": "False",
  207. "help": "the internal server is used",
  208. "type": bool})]))])
  209. def parse_compound_paths(*compound_paths):
  210. """Parse a compound path and return the individual paths.
  211. Paths in a compound path are joined by ``os.pathsep``. If a path starts
  212. with ``?`` the return value ``IGNORE_IF_MISSING`` is set.
  213. When multiple ``compound_paths`` are passed, the last argument that is
  214. not ``None`` is used.
  215. Returns a dict of the format ``[(PATH, IGNORE_IF_MISSING), ...]``
  216. """
  217. compound_path = ""
  218. for p in compound_paths:
  219. if p is not None:
  220. compound_path = p
  221. paths = []
  222. for path in compound_path.split(os.pathsep):
  223. ignore_if_missing = path.startswith("?")
  224. if ignore_if_missing:
  225. path = path[1:]
  226. path = filepath(path)
  227. if path:
  228. paths.append((path, ignore_if_missing))
  229. return paths
  230. def load(paths=()):
  231. """
  232. Create instance of ``Configuration`` for use with
  233. ``radicale.app.Application``.
  234. ``paths`` a list of configuration files with the format
  235. ``[(PATH, IGNORE_IF_MISSING), ...]``.
  236. If a configuration file is missing and IGNORE_IF_MISSING is set, the
  237. config is set to ``Configuration.SOURCE_MISSING``.
  238. The configuration can later be changed with ``Configuration.update()``.
  239. """
  240. configuration = Configuration(DEFAULT_CONFIG_SCHEMA)
  241. for path, ignore_if_missing in paths:
  242. parser = RawConfigParser()
  243. config_source = "config file %r" % path
  244. try:
  245. if not parser.read(path):
  246. config = Configuration.SOURCE_MISSING
  247. if not ignore_if_missing:
  248. raise RuntimeError("No such file: %r" % path)
  249. else:
  250. config = {s: {o: parser[s][o] for o in parser.options(s)}
  251. for s in parser.sections()}
  252. except Exception as e:
  253. raise RuntimeError(
  254. "Failed to load %s: %s" % (config_source, e)) from e
  255. configuration.update(config, config_source)
  256. return configuration
  257. class Configuration:
  258. SOURCE_MISSING = {}
  259. def __init__(self, schema):
  260. """Initialize configuration.
  261. ``schema`` a dict that describes the configuration format.
  262. See ``DEFAULT_CONFIG_SCHEMA``.
  263. The content of ``schema`` must not change afterwards, it is kept
  264. as an internal reference.
  265. Use ``load()`` to create an instance for use with
  266. ``radicale.app.Application``.
  267. """
  268. self._schema = schema
  269. self._values = {}
  270. self._configs = []
  271. values = {}
  272. for section in schema:
  273. values[section] = {}
  274. for option in schema[section]:
  275. if option.startswith("_"):
  276. continue
  277. values[section][option] = schema[section][option]["value"]
  278. self.update(values, "default config", internal=True)
  279. def update(self, config, source=None, internal=False):
  280. """Update the configuration.
  281. ``config`` a dict of the format {SECTION: {OPTION: VALUE, ...}, ...}.
  282. The configuration is checked for errors according to the config schema.
  283. The content of ``config`` must not change afterwards, it is kept
  284. as an internal reference.
  285. ``source`` a description of the configuration source (used in error
  286. messages).
  287. ``internal`` allows updating "_internal" sections.
  288. """
  289. source = source or "unspecified config"
  290. new_values = {}
  291. for section in config:
  292. if (section not in self._schema or not internal and
  293. self._schema[section].get("_internal", False)):
  294. raise RuntimeError(
  295. "Invalid section %r in %s" % (section, source))
  296. new_values[section] = {}
  297. if "_allow_extra" in self._schema[section]:
  298. allow_extra_options = self._schema[section]["_allow_extra"]
  299. elif "type" in self._schema[section]:
  300. if "type" in config[section]:
  301. plugin_type = config[section]["type"]
  302. else:
  303. plugin_type = self.get(section, "type")
  304. allow_extra_options = plugin_type not in self._schema[section][
  305. "type"].get("internal", [])
  306. else:
  307. allow_extra_options = False
  308. for option in config[section]:
  309. if option in self._schema[section]:
  310. type_ = self._schema[section][option]["type"]
  311. elif allow_extra_options:
  312. type_ = str
  313. else:
  314. raise RuntimeError("Invalid option %r in section %r in "
  315. "%s" % (option, section, source))
  316. raw_value = config[section][option]
  317. try:
  318. if type_ == bool and not isinstance(raw_value, bool):
  319. raw_value = _convert_to_bool(raw_value)
  320. new_values[section][option] = type_(raw_value)
  321. except Exception as e:
  322. raise RuntimeError(
  323. "Invalid %s value for option %r in section %r in %s: "
  324. "%r" % (type_.__name__, option, section, source,
  325. raw_value)) from e
  326. self._configs.append((config, source, bool(internal)))
  327. for section in new_values:
  328. if section not in self._values:
  329. self._values[section] = {}
  330. for option in new_values[section]:
  331. self._values[section][option] = new_values[section][option]
  332. def get(self, section, option):
  333. """Get the value of ``option`` in ``section``."""
  334. return self._values[section][option]
  335. def get_raw(self, section, option):
  336. """Get the raw value of ``option`` in ``section``."""
  337. fconfig = self._configs[0]
  338. for config, _, _ in reversed(self._configs):
  339. if section in config and option in config[section]:
  340. fconfig = config
  341. break
  342. return fconfig[section][option]
  343. def sections(self):
  344. """List all sections."""
  345. return self._values.keys()
  346. def options(self, section):
  347. """List all options in ``section``"""
  348. return self._values[section].keys()
  349. def copy(self, plugin_schema=None):
  350. """Create a copy of the configuration
  351. ``plugin_schema`` is a optional dict that contains additional options
  352. for usage with a plugin. See ``DEFAULT_CONFIG_SCHEMA``.
  353. """
  354. if plugin_schema is None:
  355. schema = self._schema
  356. else:
  357. schema = self._schema.copy()
  358. for section, options in plugin_schema.items():
  359. if (section not in schema or "type" not in schema[section] or
  360. "internal" not in schema[section]["type"]):
  361. raise ValueError("not a plugin section: %r" % section)
  362. schema[section] = schema[section].copy()
  363. schema[section]["type"] = schema[section]["type"].copy()
  364. schema[section]["type"]["internal"] = [
  365. self.get(section, "type")]
  366. for option, value in options.items():
  367. if option in schema[section]:
  368. raise ValueError("option already exists in %r: %r" % (
  369. section, option))
  370. schema[section][option] = value
  371. copy = type(self)(schema)
  372. for config, source, internal in self._configs:
  373. copy.update(config, source, internal)
  374. return copy
  375. def log_config_sources(self):
  376. """
  377. A helper function that writes a description of all config sources
  378. to logger.
  379. Configs set to ``Configuration.SOURCE_MISSING`` are described as
  380. missing.
  381. """
  382. for config, source, _ in self._configs:
  383. if config is self.SOURCE_MISSING:
  384. logger.info("Skipped missing %s", source)
  385. else:
  386. logger.info("Loaded %s", source)