commit 41175b9fc7d3ddaf0cf528e16d139a2ef0c4f002 Author: Merijntje Tak Date: Sat Jul 9 22:35:01 2022 +0200 Initial version diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5a71d37 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# 4 space indentation +[*.{py,java,r,R}] +indent_style = space +indent_size = 4 + +# 2 space indentation +[*.{js,json,y{a,}ml,html,cwl}] +indent_style = space +indent_size = 2 + +[*.{md,Rmd,rst}] +trim_trailing_whitespace = false +indent_style = space \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c60c62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +*.swp +config + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..89b883c --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +docker-nsupdate-ddns \ No newline at end of file diff --git a/.idea/docker-nsupdate-ddns.iml b/.idea/docker-nsupdate-ddns.iml new file mode 100644 index 0000000..024c4bd --- /dev/null +++ b/.idea/docker-nsupdate-ddns.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..45ca55e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..cd63a25 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..4889fbc --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +docker = "*" +python-dotenv = "*" +dnspython = "*" + +[requires] +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..e3c49e7 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,89 @@ +{ + "_meta": { + "hash": { + "sha256": "2a9a9c218218349a7c550a97bb3bc3b6113227211c48e027755bdc8baf5822a1" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d", + "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412" + ], + "version": "==2022.6.15" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "dnspython": { + "hashes": [ + "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e", + "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f" + ], + "index": "pypi", + "version": "==2.2.1" + }, + "docker": { + "hashes": [ + "sha256:7a79bb439e3df59d0a72621775d600bc8bc8b422d285824cb37103eab91d1ce0", + "sha256:d916a26b62970e7c2f554110ed6af04c7ccff8e9f81ad17d0d40c75637e227fb" + ], + "index": "pypi", + "version": "==5.0.3" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3'", + "version": "==3.3" + }, + "python-dotenv": { + "hashes": [ + "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f", + "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938" + ], + "index": "pypi", + "version": "==0.20.0" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "version": "==2.27.1" + }, + "urllib3": { + "hashes": [ + "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec", + "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6" + ], + "version": "==1.26.10" + }, + "websocket-client": { + "hashes": [ + "sha256:074e2ed575e7c822fc0940d31c3ac9bb2b1142c303eafcf3e304e6ce035522e8", + "sha256:6278a75065395418283f887de7c3beafb3aa68dada5cacbe4b214e8d26da499b" + ], + "version": "==1.3.1" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5b33ad --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# docker-nsupdate-ddns + +This script pushes container/ip information from a Docker instance to a DNS server via the DNS update mechanism desribed in RFC 2136 (nsupdate). nsupdate is implemented by ISC Bind9. + +Every REFRESH_INTERVAL seconds, it queries all the Docker containers on the local host, finds their IP and pushes it with the name to the DNS server. + +## Running + +The script takes environment variables or a config file. A sample config file is provided in `config.sample`. The file name of the config file can be passed as argument, but defaults to `config`. + +The names of the environment variables is the same as in the config file. Environment variables have precendence over the config file. + +### Environment variables +```bash +docker run -d \ + -e DOMAIN=int.mtak.nl \ + -e HOSTNAME_LABEL=nl.mtak.docker-bind-ddns.hostname \ + -e DEFAULT_NETWORK=10.100.0.192/26 \ + -e REFRESH_INTERVAL=5 \ + -e ONE_SHOT=True \ + -e NAMESERVER=10.100.0.11 \ + -e TSIG_NAME=dck1 \ + -e TSIG_KEY=SyYXDCJ4kIs3qhvI= \ + merijntjetak/docker-bind/ddns:latest +``` + +### Config file +```bash +cat <configfile +DOMAIN=int.mtak.nl +HOSTNAME_LABEL=nl.mtak.docker-bind-ddns.hostname +DEFAULT_NETWORK=10.100.0.192/26 +REFRESH_INTERVAL=5 +ONE_SHOT=True +NAMESERVER=10.100.0.11 +TSIG_NAME=dck1 +TSIG_KEY=SyYXDCJ4kIs3qhvI= +EOF + +docker run -d \ + -v `pwd`/configfile:/configfile + merijntjetak/docker-bind-ddns:latest /configfile + +``` + +### Configuration + +- `DOMAIN` - Sets the domain in which the records are created. Needs to match the Bind zone. +- `HOSTNAME_LABEL` - Docker label to override the default record name with. Use with `docker --label=nl.mtak.docker-bind-ddns.hostname=nginx` to get `nginx.int.mtak.nl` +- `DEFAULT_NETWORK` - Preferred network to find IP for, in case there are multiple networks +- `REFRESH_INTERVAL` - Interval between updates +- `ONE_SHOT` - Set to True for the script to update once and immediately quit (nice for debugging) +- `NAMESERVER` - Nameserver to push updates to +- `TSIG_NAME` - Name of the TSIG key +- `TSIG_KEY` - TSIG-KEY + +### Bind9 integration + +1. Generate a key + + `tsig-keygen clientname >/etc/bind/keys/clientname.key` + +2. Include keys in your Bind9 configuration + + `include "/etc/bind/keys/*";` + +3. Allow updates to your zone: + + ``` + zone "int.mtak.nl" { + type master; + file "/etc/bind/db/int.mtak.nl.zone"; + update-policy { + grant clientname zonesub ANY; + }; + }; + ``` + +## Design +### Requirements + +- Eventual redunancy (Bind9 zone transfers to secondary) +- Support for multiple individual Docker servers +- IPv6 support +- Detect hostname in decreasing order of priority: + - label + - Container name +- Forward-only +- Clean up DNS (DNS is stateful but the script isn't, so there might be a mismatch) + +### Todo + +- Make event-driven with [Ahab](https://github.com/instacart/ahab) +- Add tests + +### Alternatives + +CoreDNS didn't fit the requirements, because zone transfers out of a CoreDNS server do not +include the records from the coredns-dockerdiscovery plugin. + diff --git a/config.sample b/config.sample new file mode 100644 index 0000000..d7ae0a6 --- /dev/null +++ b/config.sample @@ -0,0 +1,8 @@ +DOMAIN=int.mtak.nl +HOSTNAME_LABEL=nl.mtak.docker-bind-ddns.hostname +DEFAULT_NETWORK=10.100.0.192/26 +REFRESH_INTERVAL=10 +ONE_SHOT=True +NAMESERVER=10.100.0.11 +TSIG_NAME=dck1 +TSIG_KEY=SyYhvI= diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..ff0d04c --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +__all__ = ["container", "nsupdate"] diff --git a/lib/container.py b/lib/container.py new file mode 100644 index 0000000..ee1da8d --- /dev/null +++ b/lib/container.py @@ -0,0 +1,57 @@ +import docker + +config = {} + + +def get_container_name(container): + """ + Get name of container, try in the following order: + - Check if hostname_label is set + - Fall back to container Name + """ + x = container.attrs['Name'][1:] + + if config['hostname_label'] in container.attrs['Config']['Labels']: + x = container.attrs['Config']['Labels'][config['hostname_label']] + + x = x.replace("_", "-") # Be compliant with RFC1035 + return x + + +def get_container_ip(container): + """ + Get IP of container. Try in the following order + - default_network + - First found network + - Fall back to ['NetworkSettings']['IPAddress'] + """ + x = container.attrs['NetworkSettings']['IPAddress'] + + if next(iter(container.attrs['NetworkSettings']['Networks'])): + network_name = next(iter(container.attrs['NetworkSettings']['Networks'])) + x = container.attrs['NetworkSettings']['Networks'][network_name]['IPAddress'] + + if config['default_network'] in container.attrs['NetworkSettings']['Networks']: + x = container.attrs['NetworkSettings']['Networks'][config['default_network']] + + return x + + +def generate_container_list(): + client = docker.from_env() + + container_list = client.containers.list() + ipam4 = {} + + for container in container_list: + container_name = get_container_name(container) + container_ip = get_container_ip(container) + if container_ip: + ipam4[container_name] = container_ip + + return ipam4 + + +def init(_config): + global config + config = _config diff --git a/lib/nsupdate.py b/lib/nsupdate.py new file mode 100644 index 0000000..dd5ed17 --- /dev/null +++ b/lib/nsupdate.py @@ -0,0 +1,37 @@ +import dns.update +import dns.query +import dns.tsigkeyring +import ipaddress + +config = {} + + +def add_records(records): + keyring = dns.tsigkeyring.from_text({config['tsig_name']: config['tsig_key']}) + for hostname, ip in records.items(): + print("Adding record for " + hostname + "(" + ip + ")") + + rrtype = "A" + address = ipaddress.ip_address(ip) + if isinstance(address, ipaddress.IPv4Address): + rrtype = "A" + if isinstance(address, ipaddress.IPv6Address): + rrtype = "AAAA" + + update = dns.update.Update(config['domain'], keyring=keyring) + update.add(hostname, 60, rrtype, ip) + dns.query.tcp(update, config['nameserver'], timeout=2) + + +def delete_records(records): + keyring = dns.tsigkeyring.from_text({config['tsig_name']: config['tsig_key']}) + for hostname, ip in records.items(): + print("Deleting record for " + hostname + "(" + ip + ")") + update = dns.update.Update(config['domain'], keyring=keyring) + update.delete(hostname) + dns.query.tcp(update, config['nameserver'], timeout=2) + + +def init(_config): + global config + config = _config diff --git a/main.py b/main.py new file mode 100755 index 0000000..15fc85a --- /dev/null +++ b/main.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +import os +import sys +from dotenv import load_dotenv +import time + +from lib import * + +config = {} +ipam4_old = {} + + +def main(): + get_config() + + if not config['one_shot']: + while True: + loop() + time.sleep(config['refresh_interval']) + + loop() + + +def loop(): + container.init(config) + ipam4 = container.generate_container_list() + global ipam4_old + + additions4 = determine_additions(ipam4, ipam4_old) + deletions4 = determine_deletions(ipam4, ipam4_old) + + nsupdate.init(config) + nsupdate.delete_records(deletions4) + nsupdate.add_records(additions4) + + ipam4_old = ipam4 + + +def get_config(): + config_file = sys.argv[1] if len(sys.argv) >= 2 else 'config' + load_dotenv(config_file) + + config['domain'] = os.environ.get("DOMAIN") + config['hostname_label'] = os.environ.get("HOSTNAME_LABEL") + config['default_network'] = os.environ.get("DEFAULT_NETWORK") + config['refresh_interval'] = int(os.environ.get("REFRESH_INTERVAL")) + config['one_shot'] = os.environ.get("ONE_SHOT").lower() in ['true', 'yes'] + config['nameserver'] = os.environ.get("NAMESERVER") + config['tsig_name'] = os.environ.get("TSIG_NAME") + config['tsig_key'] = os.environ.get("TSIG_KEY") + + +def determine_additions(ipam, ipam_old): + return {k: v for k, v in ipam.items() if k not in ipam_old} + + +def determine_deletions(ipam, ipam_old): + return {k: v for k, v in ipam_old.items() if k not in ipam} + pass + + +if __name__ == "__main__": + main()