config.py 15 KB

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