1# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*-
2# vi: set ft=python sts=4 ts=4 sw=4 noet :
3
4# This file is part of Fail2Ban.
5#
6# Fail2Ban is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# Fail2Ban is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Fail2Ban; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19
20# Author: Yaroslav Halchenko
21# Modified: Cyril Jaquier
22
23__author__ = 'Yaroslav Halchenko, Serg G. Brester (aka sebres)'
24__copyright__ = 'Copyright (c) 2007 Yaroslav Halchenko, 2015 Serg G. Brester (aka sebres)'
25__license__ = 'GPL'
26
27import os
28import re
29import sys
30from ..helpers import getLogger
31
32if sys.version_info >= (3,): # pragma: 2.x no cover
33
34	# SafeConfigParser deprecated from Python 3.2 (renamed to ConfigParser)
35	from configparser import ConfigParser as SafeConfigParser, BasicInterpolation, \
36		InterpolationMissingOptionError, NoOptionError, NoSectionError
37
38	# And interpolation of __name__ was simply removed, thus we need to
39	# decorate default interpolator to handle it
40	class BasicInterpolationWithName(BasicInterpolation):
41		"""Decorator to bring __name__ interpolation back.
42
43		Original handling of __name__ was removed because of
44		functional deficiencies: http://bugs.python.org/issue10489
45
46		commit v3.2a4-105-g61f2761
47		Author: Lukasz Langa <lukasz@langa.pl>
48		Date:	Sun Nov 21 13:41:35 2010 +0000
49
50		Issue #10489: removed broken `__name__` support from configparser
51
52		But should be fine to reincarnate for our use case
53		"""
54		def _interpolate_some(self, parser, option, accum, rest, section, map,
55							  *args, **kwargs):
56			if section and not (__name__ in map):
57				map = map.copy()		  # just to be safe
58				map['__name__'] = section
59				# try to wrap section options like %(section/option)s:
60				parser._map_section_options(section, option, rest, map)
61				return super(BasicInterpolationWithName, self)._interpolate_some(
62					parser, option, accum, rest, section, map, *args, **kwargs)
63
64else: # pragma: 3.x no cover
65	from configparser import SafeConfigParser, \
66		InterpolationMissingOptionError, NoOptionError, NoSectionError
67
68	# Interpolate missing known/option as option from default section
69	SafeConfigParser._cp_interpolate_some = SafeConfigParser._interpolate_some
70	def _interpolate_some(self, option, accum, rest, section, map, *args, **kwargs):
71		# try to wrap section options like %(section/option)s:
72		self._map_section_options(section, option, rest, map)
73		return self._cp_interpolate_some(option, accum, rest, section, map, *args, **kwargs)
74	SafeConfigParser._interpolate_some = _interpolate_some
75
76def _expandConfFilesWithLocal(filenames):
77	"""Expands config files with local extension.
78	"""
79	newFilenames = []
80	for filename in filenames:
81		newFilenames.append(filename)
82		localname = os.path.splitext(filename)[0] + '.local'
83		if localname not in filenames and os.path.isfile(localname):
84			newFilenames.append(localname)
85	return newFilenames
86
87# Gets the instance of the logger.
88logSys = getLogger(__name__)
89logLevel = 7
90
91
92__all__ = ['SafeConfigParserWithIncludes']
93
94
95class SafeConfigParserWithIncludes(SafeConfigParser):
96	"""
97	Class adds functionality to SafeConfigParser to handle included
98	other configuration files (or may be urls, whatever in the future)
99
100	File should have section [includes] and only 2 options implemented
101	are 'files_before' and 'files_after' where files are listed 1 per
102	line.
103
104	Example:
105
106[INCLUDES]
107before = 1.conf
108         3.conf
109
110after = 1.conf
111
112	It is a simple implementation, so just basic care is taken about
113	recursion. Includes preserve right order, ie new files are
114	inserted to the list of read configs before original, and their
115	includes correspondingly so the list should follow the leaves of
116	the tree.
117
118	I wasn't sure what would be the right way to implement generic (aka c++
119	template) so we could base at any *configparser class... so I will
120	leave it for the future
121
122	"""
123
124	SECTION_NAME = "INCLUDES"
125
126	SECTION_OPTNAME_CRE = re.compile(r'^([\w\-]+)/([^\s>]+)$')
127
128	SECTION_OPTSUBST_CRE = re.compile(r'%\(([\w\-]+/([^\)]+))\)s')
129
130	CONDITIONAL_RE = re.compile(r"^(\w+)(\?.+)$")
131
132	if sys.version_info >= (3,2):
133		# overload constructor only for fancy new Python3's
134		def __init__(self, share_config=None, *args, **kwargs):
135			kwargs = kwargs.copy()
136			kwargs['interpolation'] = BasicInterpolationWithName()
137			kwargs['inline_comment_prefixes'] = ";"
138			super(SafeConfigParserWithIncludes, self).__init__(
139				*args, **kwargs)
140			self._cfg_share = share_config
141
142	else:
143		def __init__(self, share_config=None, *args, **kwargs):
144			SafeConfigParser.__init__(self, *args, **kwargs)
145			self._cfg_share = share_config
146
147	def get_ex(self, section, option, raw=False, vars={}):
148		"""Get an option value for a given section.
149
150		In opposite to `get`, it differentiate session-related option name like `sec/opt`.
151		"""
152		sopt = None
153		# if option name contains section:
154		if '/' in option:
155			sopt = SafeConfigParserWithIncludes.SECTION_OPTNAME_CRE.search(option)
156		# try get value from named section/option:
157		if sopt:
158			sec = sopt.group(1)
159			opt = sopt.group(2)
160			seclwr = sec.lower()
161			if seclwr == 'known':
162				# try get value firstly from known options, hereafter from current section:
163				sopt = ('KNOWN/'+section, section)
164			else:
165				sopt = (sec,) if seclwr != 'default' else ("DEFAULT",)
166			for sec in sopt:
167				try:
168					v = self.get(sec, opt, raw=raw)
169					return v
170				except (NoSectionError, NoOptionError) as e:
171					pass
172		# get value of section/option using given section and vars (fallback):
173		v = self.get(section, option, raw=raw, vars=vars)
174		return v
175
176	def _map_section_options(self, section, option, rest, defaults):
177		"""
178		Interpolates values of the section options (name syntax `%(section/option)s`).
179
180		Fallback: try to wrap missing default options as "default/options" resp. "known/options"
181		"""
182		if '/' not in rest or '%(' not in rest: # pragma: no cover
183			return 0
184		rplcmnt = 0
185		soptrep = SafeConfigParserWithIncludes.SECTION_OPTSUBST_CRE.findall(rest)
186		if not soptrep: # pragma: no cover
187			return 0
188		for sopt, opt in soptrep:
189			if sopt not in defaults:
190				sec = sopt[:~len(opt)]
191				seclwr = sec.lower()
192				if seclwr != 'default':
193					usedef = 0
194					if seclwr == 'known':
195						# try get raw value from known options:
196						try:
197							v = self._sections['KNOWN/'+section][opt]
198						except KeyError:
199							# fallback to default:
200							usedef = 1
201					else:
202						# get raw value of opt in section:
203						try:
204							# if section not found - ignore:
205							try:
206								sec = self._sections[sec]
207							except KeyError: # pragma: no cover
208								continue
209							v = sec[opt]
210						except KeyError: # pragma: no cover
211							# fallback to default:
212							usedef = 1
213				else:
214					usedef = 1
215				if usedef:
216					try:
217						v = self._defaults[opt]
218					except KeyError: # pragma: no cover
219						continue
220				# replacement found:
221				rplcmnt = 1
222				try: # set it in map-vars (consider different python versions):
223					defaults[sopt] = v
224				except:
225					# try to set in first default map (corresponding vars):
226					try:
227						defaults._maps[0][sopt] = v
228					except: # pragma: no cover
229						# no way to update vars chain map - overwrite defaults:
230						self._defaults[sopt] = v
231		return rplcmnt
232
233	@property
234	def share_config(self):
235		return self._cfg_share
236
237	def _getSharedSCPWI(self, filename):
238		SCPWI = SafeConfigParserWithIncludes
239		# read single one, add to return list, use sharing if possible:
240		if self._cfg_share:
241			# cache/share each file as include (ex: filter.d/common could be included in each filter config):
242			hashv = 'inc:'+(filename if not isinstance(filename, list) else '\x01'.join(filename))
243			cfg, i = self._cfg_share.get(hashv, (None, None))
244			if cfg is None:
245				cfg = SCPWI(share_config=self._cfg_share)
246				i = cfg.read(filename, get_includes=False)
247				self._cfg_share[hashv] = (cfg, i)
248			elif logSys.getEffectiveLevel() <= logLevel:
249				logSys.log(logLevel, "    Shared file: %s", filename)
250		else:
251			# don't have sharing:
252			cfg = SCPWI()
253			i = cfg.read(filename, get_includes=False)
254		return (cfg, i)
255
256	def _getIncludes(self, filenames, seen=[]):
257		if not isinstance(filenames, list):
258			filenames = [ filenames ]
259		filenames = _expandConfFilesWithLocal(filenames)
260		# retrieve or cache include paths:
261		if self._cfg_share:
262			# cache/share include list:
263			hashv = 'inc-path:'+('\x01'.join(filenames))
264			fileNamesFull = self._cfg_share.get(hashv)
265			if fileNamesFull is None:
266				fileNamesFull = []
267				for filename in filenames:
268					fileNamesFull += self.__getIncludesUncached(filename, seen)
269				self._cfg_share[hashv] = fileNamesFull
270			return fileNamesFull
271		# don't have sharing:
272		fileNamesFull = []
273		for filename in filenames:
274			fileNamesFull += self.__getIncludesUncached(filename, seen)
275		return fileNamesFull
276
277	def __getIncludesUncached(self, resource, seen=[]):
278		"""
279		Given 1 config resource returns list of included files
280		(recursively) with the original one as well
281		Simple loops are taken care about
282		"""
283		SCPWI = SafeConfigParserWithIncludes
284		try:
285			parser, i = self._getSharedSCPWI(resource)
286			if not i:
287				return []
288		except UnicodeDecodeError as e:
289			logSys.error("Error decoding config file '%s': %s" % (resource, e))
290			return []
291
292		resourceDir = os.path.dirname(resource)
293
294		newFiles = [ ('before', []), ('after', []) ]
295		if SCPWI.SECTION_NAME in parser.sections():
296			for option_name, option_list in newFiles:
297				if option_name in parser.options(SCPWI.SECTION_NAME):
298					newResources = parser.get(SCPWI.SECTION_NAME, option_name)
299					for newResource in newResources.split('\n'):
300						if os.path.isabs(newResource):
301							r = newResource
302						else:
303							r = os.path.join(resourceDir, newResource)
304						if r in seen:
305							continue
306						s = seen + [resource]
307						option_list += self._getIncludes(r, s)
308		# combine lists
309		return newFiles[0][1] + [resource] + newFiles[1][1]
310
311	def get_defaults(self):
312		return self._defaults
313
314	def get_sections(self):
315		return self._sections
316
317	def options(self, section, withDefault=True):
318		"""Return a list of option names for the given section name.
319
320		Parameter `withDefault` controls the include of names from section `[DEFAULT]`
321		"""
322		try:
323			opts = self._sections[section]
324		except KeyError: # pragma: no cover
325			raise NoSectionError(section)
326		if withDefault:
327			# mix it with defaults:
328			return set(opts.keys()) | set(self._defaults)
329		# only own option names:
330		return list(opts.keys())
331
332	def read(self, filenames, get_includes=True):
333		if not isinstance(filenames, list):
334			filenames = [ filenames ]
335		# retrieve (and cache) includes:
336		fileNamesFull = []
337		if get_includes:
338			fileNamesFull += self._getIncludes(filenames)
339		else:
340			fileNamesFull = filenames
341
342		if not fileNamesFull:
343			return []
344
345		logSys.info("  Loading files: %s", fileNamesFull)
346
347		if get_includes or len(fileNamesFull) > 1:
348			# read multiple configs:
349			ret = []
350			alld = self.get_defaults()
351			alls = self.get_sections()
352			for filename in fileNamesFull:
353				# read single one, add to return list, use sharing if possible:
354				cfg, i = self._getSharedSCPWI(filename)
355				if i:
356					ret += i
357					# merge defaults and all sections to self:
358					alld.update(cfg.get_defaults())
359					for n, s in cfg.get_sections().items():
360						# conditional sections
361						cond = SafeConfigParserWithIncludes.CONDITIONAL_RE.match(n)
362						if cond:
363							n, cond = cond.groups()
364							s = s.copy()
365							try:
366								del(s['__name__'])
367							except KeyError:
368								pass
369							for k in list(s.keys()):
370								v = s.pop(k)
371								s[k + cond] = v
372						s2 = alls.get(n)
373						if isinstance(s2, dict):
374							# save previous known values, for possible using in local interpolations later:
375							self.merge_section('KNOWN/'+n,
376								dict([i for i in iter(s2.items()) if i[0] in s]), '')
377							# merge section
378							s2.update(s)
379						else:
380							alls[n] = s.copy()
381
382			return ret
383
384		# read one config :
385		if logSys.getEffectiveLevel() <= logLevel:
386			logSys.log(logLevel, "    Reading file: %s", fileNamesFull[0])
387		# read file(s) :
388		if sys.version_info >= (3,2): # pragma: no cover
389			return SafeConfigParser.read(self, fileNamesFull, encoding='utf-8')
390		else:
391			return SafeConfigParser.read(self, fileNamesFull)
392
393	def merge_section(self, section, options, pref=None):
394		alls = self.get_sections()
395		try:
396			sec = alls[section]
397		except KeyError:
398			alls[section] = sec = dict()
399		if not pref:
400			sec.update(options)
401			return
402		sk = {}
403		for k, v in options.items():
404			if not k.startswith(pref) and k != '__name__':
405				sk[pref+k] = v
406		sec.update(sk)
407
408