Initial version

This commit is contained in:
Merijntje Tak
2022-07-09 22:35:01 +02:00
commit 41175b9fc7
16 changed files with 587 additions and 0 deletions

25
.editorconfig Normal file
View File

@@ -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

162
.gitignore vendored Normal file
View File

@@ -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/

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
docker-nsupdate-ddns

8
.idea/docker-nsupdate-ddns.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Pipenv (docker-nsupdate-ddns)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Pipenv (docker-bind-ddns)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/../docker-nsupdate-ddns/.idea/docker-nsupdate-ddns.iml" filepath="$PROJECT_DIR$/../docker-nsupdate-ddns/.idea/docker-nsupdate-ddns.iml" />
</modules>
</component>
</project>

14
Pipfile Normal file
View File

@@ -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"

89
Pipfile.lock generated Normal file
View File

@@ -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": {}
}

100
README.md Normal file
View File

@@ -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 <<EOF >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.

8
config.sample Normal file
View File

@@ -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=

1
lib/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = ["container", "nsupdate"]

57
lib/container.py Normal file
View File

@@ -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

37
lib/nsupdate.py Normal file
View File

@@ -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

64
main.py Executable file
View File

@@ -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()