├── .github └── workflows │ └── dockerhub.yml ├── .gitignore ├── Dockerfile ├── README.md ├── app.py ├── rblchecker ├── __init__.py ├── listing.py ├── probe.py └── tests │ ├── __init__.py │ └── test_probe.py └── requirements.txt /.github/workflows/dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: build our image 2 | 3 | on: 4 | push: 5 | branches: main 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout code 12 | uses: actions/checkout@v2 13 | - name: Set up QEMU 14 | uses: docker/setup-qemu-action@v1 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v1 17 | - name: login to docker hub 18 | if: github.event_name != 'pull_request' 19 | run: echo "${{ secrets.DOCKER_ACCESS_TOKEN }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin 20 | - name: build the image 21 | run: | 22 | docker buildx build --push \ 23 | --tag sbonfert/rbl-checker:latest \ 24 | --platform linux/amd64,linux/arm/v7,linux/arm64 . -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | .devcontainer 142 | .vscode 143 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 4 | ADD app.py requirements.txt /app/ 5 | ADD rblchecker/__init__.py rblchecker/listing.py rblchecker/probe.py /app/rblchecker/ 6 | RUN pip3 install -r /app/requirements.txt 7 | ENV PYTHONUNBUFFERED=1 8 | 9 | ENTRYPOINT python3 /app/app.py 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Realtime Blacklist (RBL) Checker 2 | 3 | This docker container regularly checks one or multiple email servers against a number of RBL blacklists in a customizable interval. If one of the specified server is listed on one or more blacklists, it sends a notification via Apprise. 4 | 5 | ## Features 6 | 7 | - Supports IPv4 and IPv6 8 | - Notifications via Apprise whenever a new listing is found or an existing one is removed 9 | - Runs inside a docker container, which is fully configurable by environment variables 10 | - Optional integration of healthchecks.io 11 | 12 | ## Usage 13 | 14 | The container can be configured using environment variables. These are: 15 | 16 | | Variable | Mandatory/Optional | Usage | 17 | | --------------------- | ------------------ | ----- | 18 | | RBL_HOSTS | Mandatory | The hosts to monitor, given either as IP-Address or as FQDN. Multiple hosts have to be comma-separated. If a FQDN is provided, all available IPv4 and IPv6 addresses will be checked | 19 | | RBL_APPRISE_URL | Mandatory | A string representing the notification endpoint. Format according to the [Apprise Documentation](https://github.com/caronc/apprise) | 20 | | RBL_INTERVAL | Optional | The interval in which the blacklists should be checked, given in minutes. Default: 60 | 21 | | RBL_HEALTHCHECK_URL | Optional | This URL is fetched (GET-request) after each execution to indicate, that the service was executed. This may be used in conjunction with e.g. [healthchecks.io](https://healthchecks.io). This is skipped, if the variable is not set | 22 | | RBL_DQS | Optional | Spamhaus DQS key. It is recommended to supply this value, as otherwise false-positives may occur. | 23 | 24 | ## Run as a docker container 25 | 26 | This container can be run with the following docker command: 27 | 28 | ```sh 29 | docker run -d --name rbl-checker -e RBL_HOSTS=mail.example.com,mail.example.org -e RBL_APPRISE_URL=pover://abc@xyz sbonfert/rbl-checker 30 | ``` 31 | 32 | ## docker-compose example configuration 33 | 34 | ```yaml 35 | rbl-checker: 36 | image: sbonfert/rbl-checker:latest 37 | restart: always 38 | container_name: rbl-checker 39 | environment: 40 | - RBL_HOSTS=mail.example.com,mail.example.org 41 | - RBL_APPRISE_URL=pover://abc@xyz 42 | ``` 43 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import apprise 5 | import requests 6 | 7 | from rblchecker.listing import Listing 8 | from rblchecker.probe import Probe 9 | 10 | def notify(appriseObject: apprise.Apprise, newListings: [Listing], obsoleteListings: [Listing]) -> None: 11 | message = "" 12 | if len(newListings) > 0: 13 | message += "There are new alerts:\n" 14 | for listing in newListings: 15 | message += listing.getDescription() 16 | if len(obsoleteListings): 17 | message += "The following alerts have been cleared:\n" 18 | for listing in obsoleteListings: 19 | message += listing.getDescription() 20 | appriseObject.notify(body = message, title = "RBL Alert") 21 | 22 | 23 | def Main(): 24 | print("RBLChecker started...") 25 | 26 | # Get environment variables 27 | hosts = os.environ.get('RBL_HOSTS', "") # Comma separated list 28 | appriseUrl = os.environ.get('RBL_APPRISE_URL', "") 29 | interval = os.environ.get('RBL_INTERVAL', 60) # Given in minutes 30 | # Optional: Spamhaus DQS 31 | dqsKey = os.environ.get('RBL_DQS', "") 32 | # Optional: report execution to a healthchecks server 33 | healthcheckUrl = os.environ.get('RBL_HEALTHCHECK_URL', "") 34 | 35 | # Make sure that all required variables are provided 36 | if (hosts == "" or appriseUrl == ""): 37 | print("Please supply RBL_HOSTS and RBL_APPRISE_URL as environment variables") 38 | exit(1) 39 | 40 | try: 41 | appriseObject = apprise.Apprise() 42 | appriseObject.add(appriseUrl) 43 | except: 44 | print("ERROR: Apprise initialization failed. Please double-check your configuration.") 45 | print("Exiting...") 46 | exit(1) 47 | 48 | probes = [] 49 | for host in hosts.split(","): 50 | probes.append(Probe(host, dqsKey)) 51 | 52 | while True: 53 | for probe in probes: 54 | print("Checking blacklists for " + probe.host) 55 | (newListings, obsoleteListings) = probe.check() 56 | 57 | if len(newListings) > 0 or len(obsoleteListings) > 0: 58 | notify(appriseObject, newListings, obsoleteListings) 59 | 60 | # Checking done. Report health to healthcheck server 61 | if(healthcheckUrl != ""): 62 | try: 63 | requests.get(healthcheckUrl, timeout = 10) 64 | except: 65 | print("Healthcheck server not reachable") 66 | 67 | # Go to sleep 68 | print("Sleeping for " + str(interval) + " Minutes") 69 | time.sleep(int(interval) * 60) 70 | 71 | 72 | if __name__ == '__main__': 73 | Main() 74 | -------------------------------------------------------------------------------- /rblchecker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbonfert/rbl-checker/1ffe61a06d874f192cac26fe5b129963b1f97318/rblchecker/__init__.py -------------------------------------------------------------------------------- /rblchecker/listing.py: -------------------------------------------------------------------------------- 1 | class Listing(object): 2 | 3 | def __init__(self, host:str, ip: str, bl: str): 4 | self._host = host 5 | self._ip = ip 6 | self._bl = bl 7 | self._reason = str() 8 | 9 | def __eq__(self, other): 10 | return self._host == other._host and self._ip == other._ip and self._bl == other._bl 11 | 12 | def __hash__(self): 13 | return hash(self._host) ^ hash(self._ip) ^ hash(self._bl) 14 | 15 | def addReason(self, reason: str) -> None: 16 | self._reason += reason 17 | 18 | def getDescription(self) -> str: 19 | description = 'Host ' + self._host + '('+ self._ip + ') was listed on blacklist ' + self._bl + '.\n' 20 | if self._reason == '': 21 | description += 'No reason was given.\n' 22 | else: 23 | description += 'The provided reason is: ' + self._reason + '\n' 24 | description += "\n" 25 | return description -------------------------------------------------------------------------------- /rblchecker/probe.py: -------------------------------------------------------------------------------- 1 | import dns.resolver 2 | from ipaddress import ip_address 3 | from rblchecker.listing import Listing 4 | 5 | class Probe(object): 6 | 7 | def __init__(self, host: str, dqsKey: str): 8 | self._hostToCheck = host 9 | self._dqsKey = dqsKey 10 | self._ipsToCheck = self._resolveToIPs() 11 | self._listings = [] 12 | self.initializeRBLs() 13 | 14 | @property 15 | def host(self) -> str: 16 | return self._hostToCheck 17 | 18 | def check(self) -> ([Listing], [Listing]): 19 | """Checks weather the given host is listed on any dns blacklist, returns an array of new listings, and one of obsolete ones""" 20 | 21 | currentListings = [] 22 | 23 | for ip in self._ipsToCheck: 24 | for bl in self.RBLs: 25 | try: 26 | reply = dns.resolver.resolve(self._generateLookupUrl(ip,bl), 'A') 27 | # If this point is reached, the IP address was listed 28 | # Try to find out the reason by getting the corresponding TXT record 29 | listing = Listing(self._hostToCheck, ip, bl) 30 | try: 31 | reply = dns.resolver.resolve(self._generateLookupUrl(ip,bl), 'TXT') 32 | for record in reply: 33 | listing.addReason(str(record)) 34 | except: 35 | pass 36 | 37 | currentListings.append(listing) 38 | 39 | except dns.resolver.NXDOMAIN: 40 | # Not listed 41 | pass 42 | except dns.resolver.NoAnswer: 43 | # Not listed 44 | pass 45 | except dns.resolver.NoNameservers: 46 | print("All nameservers dailed to answer the query, RBL is probably misconfigured: " + bl) 47 | except dns.resolver.Timeout: 48 | # No DNS reply received in time 49 | print("Timeout from " + bl) 50 | 51 | # Process the observed listings 52 | # Newly observed listings 53 | newListings = list(set(currentListings) - set(self._listings)) 54 | # Listings that have been removed 55 | obsoleteListings = list(set(self._listings) - set(currentListings)) 56 | 57 | self._listings = currentListings 58 | 59 | return (newListings, obsoleteListings) 60 | 61 | def _isValidIPAddress(self, IP: str) -> bool: 62 | try: 63 | ip_address(IP) 64 | return True 65 | except ValueError: 66 | return False 67 | 68 | 69 | def _resolveToIPs(self) -> [str]: 70 | """Fetches all A and AAAA DNS records for the given hostname and returns them as an array""" 71 | 72 | if self._isValidIPAddress(self._hostToCheck): 73 | return [self._hostToCheck] 74 | else: 75 | # Assume it is a hostname otherwise, resolve it 76 | try: 77 | replyv4 = dns.resolver.resolve(self._hostToCheck, 'A') 78 | except: 79 | replyv4 = [] 80 | try: 81 | replyv6 = dns.resolver.resolve(self._hostToCheck, 'AAAA') 82 | except: 83 | replyv6 = [] 84 | ipv4s = list(map(lambda reply: reply.address, replyv4)) 85 | ipv6s = list(map(lambda reply: reply.address, replyv6)) 86 | return ipv4s + ipv6s 87 | 88 | def _generateLookupUrl(self, ip: str, dnsbl: str) -> str: 89 | """Given an IP address and a dnsbl, generates a valid lookup address (RFC5782)""" 90 | 91 | return ".".join(ip_address(ip).reverse_pointer.split('.')[0:-2]) + '.' + dnsbl 92 | def initializeRBLs(self): 93 | self.RBLs = [ 94 | 'zombie.dnsbl.sorbs.net', 95 | 'bl.spamcop.net', 96 | 'dsn.rfc-ignorant.org', 97 | 'multi.surbl.org', 98 | 'blackholes.five-ten-sg.com', 99 | 'sorbs.dnsbl.net.au', 100 | 'dnsbl.sorbs.net', 101 | 'zen.spamhaus.org' if self._dqsKey == "" else "".join([self._dqsKey, ".zen.dq.spamhaus.net"]), 102 | 'db.wpbl.info', 103 | 'rmst.dnsbl.net.au', 104 | 'dnsbl.kempt.net', 105 | 'blacklist.woody.ch', 106 | 'psbl.surriel.com', 107 | 'virbl.bit.nl', 108 | 'virus.rbl.jp', 109 | 'wormrbl.imp.ch', 110 | 'spamrbl.imp.ch', 111 | 'rbl.interserver.net', 112 | 'spamlist.or.kr', 113 | 'dyna.spamrats.com', 114 | 'dnsbl.abuse.ch', 115 | 'dnsbl.inps.de', 116 | 'dnsbl.dronebl.org', 117 | 'bl.deadbeef.com', 118 | 'ricn.dnsbl.net.au', 119 | 'forbidden.icm.edu.pl', 120 | 'probes.dnsbl.net.au', 121 | 'ubl.unsubscore.com', 122 | 'b.barracudacentral.org', 123 | 'ksi.dnsbl.net.au', 124 | 'uribl.swinog.ch', 125 | 'bsb.spamlookup.net', 126 | 'dob.sibl.support-intelligence.net', 127 | 'url.rbl.jp', 128 | 'dyndns.rbl.jp', 129 | 'bogons.cymru.com', 130 | 'relays.mail-abuse.org', 131 | 'omrs.dnsbl.net.au', 132 | 'osrs.dnsbl.net.au', 133 | 'orvedb.aupads.org', 134 | 'relays.nether.net', 135 | 'relays.bl.gweep.ca', 136 | 'smtp.dnsbl.sorbs.net', 137 | 'relays.bl.kundenserver.de', 138 | 'dialups.mail-abuse.org', 139 | 'rdts.dnsbl.net.au', 140 | 'spam.dnsbl.sorbs.net', 141 | 'duinv.aupads.org', 142 | 'dynablock.sorbs.net', 143 | 'dynip.rothen.com', 144 | 'short.rbl.jp', 145 | 'korea.services.net', 146 | 'mail.people.it', 147 | 'blacklist.sci.kun.nl', 148 | 'all.spamblock.unit.liu.se' 149 | ] -------------------------------------------------------------------------------- /rblchecker/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sbonfert/rbl-checker/1ffe61a06d874f192cac26fe5b129963b1f97318/rblchecker/tests/__init__.py -------------------------------------------------------------------------------- /rblchecker/tests/test_probe.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from rblchecker.probe import Probe 4 | 5 | @pytest.mark.parametrize( 6 | 'host, expected', [ 7 | ('192.168.0.1', ['192.168.0.1']), 8 | ('1.1.1.1', ['1.1.1.1']), 9 | ('fe80::1', ['fe80::1']), 10 | ('1::', ['1::']), 11 | ('ipv4only.arpa', ['192.0.0.170', '192.0.0.171']), 12 | ('localhost', ['127.0.0.1', '::1']), 13 | ]) 14 | def test_resolveToIPs(host, expected): 15 | probe = Probe(host) 16 | assert probe._resolveToIPs().sort() == expected.sort() 17 | 18 | @pytest.mark.parametrize( 19 | 'ip, dnsbl, expected', [ 20 | ('192.0.2.99', 'bad.example.com', '99.2.0.192.bad.example.com'), 21 | ('2001:db8:1:2:3:4:567:89ab', 'ugly.example.com', 'b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.ugly.example.com'), 22 | ]) 23 | def test_generateLookupUrl(ip, dnsbl, expected): 24 | probe = Probe(ip) 25 | assert probe._generateLookupUrl(ip, dnsbl) == expected -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | apprise==1.8.0 2 | certifi==2024.7.4 3 | charset-normalizer==3.3.2 4 | click==8.1.7 5 | dnspython==2.6.1 6 | idna==3.7 7 | Markdown==3.6 8 | oauthlib==3.2.2 9 | PyYAML==6.0.1 10 | requests==2.32.3 11 | requests-oauthlib==2.0.0 12 | setuptools==70.2.0 13 | urllib3==2.2.2 14 | wheel==0.43.0 --------------------------------------------------------------------------------