diff --git a/CHANGES b/CHANGES index cd490d9..5921b75 100644 --- a/CHANGES +++ b/CHANGES @@ -11,6 +11,8 @@ added after an existing DKIM signing application to add an Ed25519 signature (Thanks to A. Schulze for the patch) - Added support for AuthservID option + - Added support for InternalHosts option (ipaddress and either dns (dnspython) + or pydns (DNS) modules are now required) 0.9.3 2018-03-02 - Fixup csl dataset processing for single item lists diff --git a/TODO b/TODO index 1b126b9..233d543 100644 --- a/TODO +++ b/TODO @@ -30,11 +30,11 @@ File dataset implemented verified 0.9.4 (Alpha) AuthservID implemented verified +InternalHosts implemented verified 0.9.5 (Beta) Diagnostics DiagnosticDirectory -InternalHosts SyslogSuccess 1.0.0 @@ -42,7 +42,6 @@ Convert dkim-milter-python config No additional features planned Plannedataset type support: -refile: db:/.db mdb: diff --git a/dkimpy_milter/__init__.py b/dkimpy_milter/__init__.py index 16c4345..d145799 100644 --- a/dkimpy_milter/__init__.py +++ b/dkimpy_milter/__init__.py @@ -69,8 +69,9 @@ class dkimMilter(Milter.Base): self.AuthservID = self.receiver if hostaddr and len(hostaddr) > 0: ipaddr = hostaddr[0] - """if iniplist(ipaddr,self.conf.internal_connect): FIXME - self.internal_connection = True""" + if milterconfig['InternalHostsObj']: + if milterconfig['InternalHostsObj'].match(ipaddr): + self.internal_connection = True else: ipaddr = '' self.connectip = ipaddr if self.internal_connection: diff --git a/dkimpy_milter/config.py b/dkimpy_milter/config.py index 465ed8e..a86ffb4 100644 --- a/dkimpy_milter/config.py +++ b/dkimpy_milter/config.py @@ -32,7 +32,8 @@ import urllib import stat import dkim import socket - +import ipaddress +from dnsplug import Session # default values defaultConfigData = { @@ -43,7 +44,9 @@ defaultConfigData = { 'Socket' : 'local:/var/run/dkimpy-milter/dkimpy-milter.sock', 'PidFile' : '/var/run/dkimpy-milter/dkimpy-milter.pid', 'UserID' : 'dkimpy-milter', - 'Canonicalization' : 'relaxed/simple' + 'Canonicalization' : 'relaxed/simple', + 'InternalHosts' : '127.0.0.1', + 'InternalHostsObj' : False } @@ -52,6 +55,167 @@ class ConfigException(Exception): '''Exception raised when there's a configuration file error.''' pass +################################# +class HostsDataset(object): + '''Hold a group of host related dataset objects''' + + def __init__(self, dataset): + self.dataset = [] + # Self.dataset will end up being a list of DataSetItem(s). + for item in dataset: + item = item.rstrip(']') + item = item.lstrip('[') + self.dataset.append(self.DatasetItem(item)) + + class DatasetItem(object): + '''Individual dataset item''' + + def __init__(self, item): + self.item = item + self.isipv4 = False + self.isipv4cidr = False + self.isipv6 = False + self.isipv6cidr = False + self.ishostname = False + self.isdomain = False + self.negative = False + if self.item[0] == '!': + self.item = item[1:] + self.negative = True + try: + self.item = ipaddress.ip_address(unicode(self.item, "utf-8")) + if isinstance(self.item, ipaddress.IPv4Address): self.isipv4 = True + elif isinstance(self.item, ipaddress.IPv6Address): self.isipv6 = True + except ValueError as e: + try: + self.item = ipaddress.ip_network(unicode(self.item, "utf-8"), strict=False) + if isinstance(self.item, ipaddress.IPv4Network): self.isipv4cidr = True + elif isinstance(self.item, ipaddress.IPv6Network): self.isipv6cidr = True + except ValueError as e2: + if self.item[0] == '.' and len(self.item.split('.')) > 2: + self.isdomain = True + elif len(self.item.split('.')) > 1: # It has a '.' in it + self.ishostname = True + else: + raise ConfigException('Unknown dataset item: {0}'.format(item)) + + def match(self, connectip): + '''Check if the connect IP is part of the dataset''' + source = ipaddress.ip_address(unicode(connectip, "utf-8")) + for item in self.dataset: + if item.isdomain or item.ishostname: + result = self.matchname(source) # Match host/domain names first + if result: + return(result) + elif item.isipv4 or item.isipv4cidr: + if isinstance(source, ipaddress.IPv4Address): # Then IPv4/6 addresses + return(self.match4(source)) # or networks depending + elif item.isipv6 or item.isipv6cidr: # on the item type and + if isinstance(source, ipaddress.IPv6Address): # connection type + return(self.match6(source)) + + def matchname(self, source): + '''Does source IP address relate to a domain/hostname in the dataset''' + match = False + matchone = False + negativeone = False + matchdomain = False + negativedomain = False + ptrlist = self.getptr(source) + for item in self.dataset: + if item.isdomain: + for ptr in ptrlist: + # Strip the leading '.' off the domain name so exact match works. + if item.item[1:] == ptr[-len(item.item)+1:]: + matchdomain = True + negativedomain = item.negative + elif item.ishostname: + for ptr in ptrlist: + if item.item == ptr: + matchone = True + negativeone = item.negative + if matchdomain and not negativedomain: + match = True + if matchone and not negativeone: + return True + if matchone and negativeone: + match = False + return(match) + + def getptr(self, source): + '''Get validated PTR name of IP address''' + results = [] + s = Session() + ptrnames = s.dns(source.reverse_pointer, 'PTR') + for name in ptrnames: + if isinstance(source, ipaddress.IPv4Address): + ips = s.dns(name, 'A') + for ip in ips: + ip = ipaddress.IPv4Address(unicode(ip, 'UTF-8')) + if ip == source: + results.append(name) + if isinstance(source, ipaddress.IPv6Address): + ips = s.dns(name, 'AAAA') + for ip in ips: + ip = ipaddress.IPv6Address(unicode(ip, 'UTF-8')) + if ip == source: + results.append(name) + return results + + def match4(self, source): + '''Is the source IP related to a IPv4 address/network in the dataset''' + match = False + matchone = False + negativeone = False + matchcidr = False + negativecidr = False + for item in self.dataset: + if item.isipv4: + if source == item.item: + matchone = True + negativeone = item.negative + elif item.isipv4cidr: + if source in item.item: + matchcidr = True + negativecidr = item.negative + if matchcidr and not negativecidr: + match = True + if matchone and not negativeone: + return True + if matchone and negativeone: + match = False + return(match) + + def match6(self, source): + '''Is the source IP realted to a IPv6 address/network in the dataset''' + match = False + matchone = False + negativeone = False + matchcidr = False + negativecidr = False + for item in self.dataset: + if item.isipv6: + if source == item.item: + matchone = True + negativeone = item.negative + elif item.isipv6cidr: + if source in item.item: + matchcidr = True + negativecidr = item.negative + if matchcidr and not negativecidr: + match = True + if matchone and not negativeone: + return True + if matchone and negativeone: + match = False + return(match) + + def dump(self): + for item in self.dataset: + print 'name: {0} ip4: {1} cidr4: {2} ip6: {3} cidr6: {4} host: {5} domain: {6} negat: {7} type: {8}'.format(item.item, + item.isipv4, item.isipv4cidr, item.isipv6, item.isipv6cidr, item.ishostname, item.isdomain, + item.negative, type(item.item)) + #################################################################### def _processConfigFile(filename = None, configdata = None, useSyslog = 1, useStderr = 0): @@ -168,6 +332,8 @@ def _readConfigFile(path, configData = None, configGlobal = {}): 'Selector' : 'str', 'SelectorEd25519': 'str', 'Canonicalization' : 'str', + 'InternalHosts' : 'dataset', + 'InternalHostsObj': 'bool', } # check to see if it's a file @@ -226,6 +392,7 @@ def _readConfigFile(path, configData = None, configGlobal = {}): fp.close() try: configData['AuthservID'] = _calculate_authserv_id(configData['AuthservID']) + configData['InternalHostsObj'] = HostsDataset(configData['InternalHosts']) except: pass diff --git a/man/dkimpy-milter.8 b/man/dkimpy-milter.8 index 978127d..1a996d9 100644 --- a/man/dkimpy-milter.8 +++ b/man/dkimpy-milter.8 @@ -156,7 +156,7 @@ values, or to a file that contains them, or a database containing the data. Some data sets require that the value contain more than one entry. How this is done depends on which data set type is used. Not all these datasets are -currently used by dkimp-milter. See +currently used by dkimpy-milter. See .B dkimpy-milter.conf(5) for details about specific options and which dataset types they use. @@ -171,16 +171,6 @@ are ignored, and the hash ("#") character denotes the start of a comment. If a value contains multiple entries, the entries should be separated by colons. .TP -.I b) -If the string begins with "refile:", then the remainder of the string is -presumed to specify a file that contains a set of patterns, one per line, -and their associated values. The pattern is taken as the start of the line -to the first whitespace, and the portion after that whitespace is taken as -the value to be used when that pattern is matched. Patterns are simple -wildcard patterns, matching all text except that the asterisk ("*") character -is considered a wildcard. If a value contains multiple entries, the entries -should be separated by colons. [Not implemented yet] -.TP .I c) If the string begins with "db:" and the program was compiled with Sleepycat DB support, then the remainder of the string is presumed to diff --git a/setup.py b/setup.py index d453a33..d03fb65 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,6 @@ setup( (os.path.join('/lib', 'systemd', 'system'), ['system/dkimpy-milter.service']),(os.path.join('/etc', 'init.d'), ['system/dkimpy-milter'])], - install_requires = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl'], + install_requires = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dns'], zip_safe = False, )