From 102b423896bb13e8a80d15ec2b0e85715e8051bd Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Wed, 15 Apr 2020 00:56:20 +0200 Subject: [PATCH] Initial commit --- .gitignore | 204 ++++++++++++++++++ .idea/.gitignore | 8 + .idea/inspectionProfiles/Project_Default.xml | 54 +++++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/netbox_ddns.iml | 26 +++ LICENSE.txt | 177 +++++++++++++++ README.md | 7 + netbox_ddns/__init__.py | 28 +++ netbox_ddns/admin.py | 114 ++++++++++ netbox_ddns/background_tasks.py | 175 +++++++++++++++ netbox_ddns/migrations/0001_initial.py | 60 ++++++ netbox_ddns/migrations/__init__.py | 0 netbox_ddns/models.py | 190 ++++++++++++++++ netbox_ddns/navigation.py | 1 + netbox_ddns/signals.py | 37 ++++ netbox_ddns/template_content.py | 1 + netbox_ddns/urls.py | 1 + netbox_ddns/validators.py | 23 ++ netbox_ddns/views.py | 0 pyproject.toml | 3 + setup.cfg | 26 +++ setup.py | 6 + 24 files changed, 1162 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/netbox_ddns.iml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 netbox_ddns/__init__.py create mode 100644 netbox_ddns/admin.py create mode 100644 netbox_ddns/background_tasks.py create mode 100644 netbox_ddns/migrations/0001_initial.py create mode 100644 netbox_ddns/migrations/__init__.py create mode 100644 netbox_ddns/models.py create mode 100644 netbox_ddns/navigation.py create mode 100644 netbox_ddns/signals.py create mode 100644 netbox_ddns/template_content.py create mode 100644 netbox_ddns/urls.py create mode 100644 netbox_ddns/validators.py create mode 100644 netbox_ddns/views.py create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97e1675 --- /dev/null +++ b/.gitignore @@ -0,0 +1,204 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +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/ + +# 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 +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__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/ + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..5cfd77b --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,54 @@ + + + + \ 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..a04b7b5 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..403ce74 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/netbox_ddns.iml b/.idea/netbox_ddns.iml new file mode 100644 index 0000000..5777f4f --- /dev/null +++ b/.idea/netbox_ddns.iml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da2f25 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# vCenter integration plugin for NetBox + +This plugin shows live data from vCenter clusters in NetBox, making it easier for administrators to make sure that reality reflects what is documented in NetBox. + +## Compatibility + +This plugin in compatible with [NetBox](https://netbox.readthedocs.org/) 2.8 and later. diff --git a/netbox_ddns/__init__.py b/netbox_ddns/__init__.py new file mode 100644 index 0000000..31ad930 --- /dev/null +++ b/netbox_ddns/__init__.py @@ -0,0 +1,28 @@ +VERSION = '0.1' + +try: + from extras.plugins import PluginConfig +except ImportError: + # Dummy for when importing outside of netbox + class PluginConfig: + pass + + +class NetBoxDDNSConfig(PluginConfig): + name = 'netbox_ddns' + verbose_name = 'Dynamic DNS' + version = VERSION + author = 'Sander Steffann' + author_email = 'sander@steffann.nl' + description = 'Dynamic DNS Connector for NetBox' + base_url = 'ddns' + required_settings = [] + default_settings = {} + + def ready(self): + super().ready() + + from . import signals + + +config = NetBoxDDNSConfig diff --git a/netbox_ddns/admin.py b/netbox_ddns/admin.py new file mode 100644 index 0000000..fa7f133 --- /dev/null +++ b/netbox_ddns/admin.py @@ -0,0 +1,114 @@ +import logging + +from django.contrib import admin, messages +from django.contrib.admin.filters import SimpleListFilter +from django.contrib.admin.options import ModelAdmin +from django.db.models import QuerySet +from django.http.request import HttpRequest +from django.utils.translation import gettext_lazy as _ + +from ipam.models import IPAddress +from netbox.admin import admin_site +from .background_tasks import update_dns +from .models import ReverseZone, Server, Zone + +logger = logging.getLogger('netbox_ddns') + + +class IPFamilyFilter(SimpleListFilter): + title = _('Prefix family') + parameter_name = 'family' + + def lookups(self, request: HttpRequest, model_admin: ModelAdmin): + return ( + ('ipv4', _('IPv4')), + ('ipv6', _('IPv6')), + ) + + def queryset(self, request: HttpRequest, queryset: QuerySet): + if self.value() == 'ipv4': + return queryset.filter(prefix__family=4) + if self.value() == 'ipv6': + return queryset.filter(prefix__family=6) + + +class ZoneInlineAdmin(admin.TabularInline): + model = Zone + + +class ReverseZoneInlineAdmin(admin.TabularInline): + model = ReverseZone + + +@admin.register(Server, site=admin_site) +class ServerAdmin(admin.ModelAdmin): + list_display = ('server', 'tsig_key_name', 'tsig_algorithm') + inlines = [ + ZoneInlineAdmin, + ReverseZoneInlineAdmin, + ] + + +@admin.register(Zone, site=admin_site) +class ZoneAdmin(admin.ModelAdmin): + list_display = ('name', 'server') + actions = [ + 'update_all_records' + ] + + def update_all_records(self, request: HttpRequest, queryset: QuerySet): + for zone in queryset: + counter = 0 + + # Find all more-specific zones + more_specifics = Zone.objects.filter(name__endswith=zone.name).exclude(pk=zone.pk) + + # Find all IPAddress objects in this zone but not in the more-specifics + addresses = IPAddress.objects.filter(dns_name__endswith=zone.name) + for more_specific in more_specifics: + addresses = addresses.exclude(dns_name__endswith=more_specific.name) + + for address in addresses: + if address.dns_name: + update_dns.delay( + new_address=address.address.ip, + new_dns_name=address.dns_name, + skip_reverse=True + ) + counter += 1 + + messages.info(request, _("Updating {count} forward records in {name}").format(count=counter, + name=zone.name)) + + +@admin.register(ReverseZone, site=admin_site) +class ReverseZoneAdmin(admin.ModelAdmin): + list_display = ('prefix', 'name', 'server') + list_filter = [IPFamilyFilter] + actions = [ + 'update_all_records' + ] + + def update_all_records(self, request: HttpRequest, queryset: QuerySet): + for zone in queryset: + counter = 0 + + # Find all more-specific zones + more_specifics = ReverseZone.objects.filter(prefix__net_contained=zone.prefix).exclude(pk=zone.pk) + + # Find all IPAddress objects in this zone but not in the more-specifics + addresses = IPAddress.objects.filter(address__net_contained=zone.prefix) + for more_specific in more_specifics: + addresses = addresses.exclude(address__net_contained=more_specific.prefix) + + for address in addresses: + if address.dns_name: + update_dns.delay( + new_address=address.address.ip, + new_dns_name=address.dns_name, + skip_forward=True + ) + counter += 1 + + messages.info(request, _("Updating {count} reverse records in {name}").format(count=counter, + name=zone.name)) diff --git a/netbox_ddns/background_tasks.py b/netbox_ddns/background_tasks.py new file mode 100644 index 0000000..36b302c --- /dev/null +++ b/netbox_ddns/background_tasks.py @@ -0,0 +1,175 @@ +import logging +from typing import Optional + +import dns.query +import dns.rdatatype +import dns.resolver +from django.db.models.functions import Length +from django_rq import job +from netaddr.ip import IPAddress + +from netbox_ddns.models import ReverseZone, Zone + +logger = logging.getLogger('netbox_ddns') + + +def get_zone(dns_name: str) -> Optional[Zone]: + # Generate all possible zones + zones = [] + parts = dns_name.lower().split('.') + for i in range(len(parts)): + zones.append('.'.join(parts[-i - 1:])) + + # Find the zone, if any + return Zone.objects.filter(name__in=zones).order_by(Length('name').desc()).first() + + +def get_soa(dns_name: str) -> str: + parts = dns_name.rstrip('.').split('.') + for i in range(len(parts)): + zone_name = '.'.join(parts[i:]) + + try: + dns.resolver.query(zone_name + '.', dns.rdatatype.SOA) + return zone_name + except dns.resolver.NoAnswer: + # The name exists, but has no SOA. Continue one level further up + continue + except dns.resolver.NXDOMAIN as e: + # Look for a SOA record in the authority section + for query, response in e.responses().items(): + for rrset in response.authority: + if rrset.rdtype == dns.rdatatype.SOA: + return rrset.name.to_text(omit_final_dot=True) + + +def get_reverse_zone(address: IPAddress) -> Optional[ReverseZone]: + # Find the zone, if any + zones = list(ReverseZone.objects.filter(prefix__net_contains=address)) + if not zones: + return None + + zones.sort(key=lambda zone: zone.prefix.prefixlen) + return zones[-1] + + +def update_status(status: list, operation: str, response) -> None: + rcode = response.rcode() + + if rcode == dns.rcode.NOERROR: + message = f"{operation} successful" + logger.info(message) + else: + message = f"{operation} failed: {dns.rcode.to_text(rcode)}" + logger.error(message) + + status.append(message) + + +@job +def update_dns(old_address: IPAddress = None, new_address: IPAddress = None, + old_dns_name: str = '', new_dns_name: str = '', + skip_forward=False, skip_reverse=False): + status = [] + + # Only delete old records when they are provided and not the same as the new records + if old_dns_name and old_address and (old_dns_name != new_dns_name or old_address != new_address): + # Delete old forward record + if not skip_forward: + zone = get_zone(old_dns_name) + if zone: + logger.debug(f"Found zone {zone.name} for {old_dns_name}") + + # Check the SOA, we don't want to write to a parent zone if it has delegated authority + soa = get_soa(old_dns_name) + if soa == zone.name: + update = zone.server.create_update(zone.name) + update.delete( + old_dns_name + '.', + 'a' if old_address.version == 4 else 'aaaa', + str(old_address) + ) + response = dns.query.udp(update, zone.server.address) + update_status(status, f'Deleting {old_dns_name} {old_address}', response) + else: + logger.debug(f"Can't update zone {zone.name} for {old_dns_name}, " + f"it has delegated authority for {soa}") + else: + logger.debug(f"No zone found for {old_dns_name}") + + # Delete old reverse record + if not skip_reverse: + zone = get_reverse_zone(old_address) + if zone: + record_name = zone.record_name(old_address) + logger.debug(f"Found zone {zone.name} for {record_name}") + + # Check the SOA, we don't want to write to a parent zone if it has delegated authority + soa = get_soa(record_name) + if soa == zone.name: + update = zone.server.create_update(zone.name) + update.delete( + record_name + '.', + 'ptr', + old_dns_name + '.' + ) + response = dns.query.udp(update, zone.server.address) + update_status(status, f'Deleting {record_name} {old_dns_name}', response) + else: + logger.debug(f"Can't update zone {zone.name} for {record_name}, " + f"it has delegated authority for {soa}") + else: + logger.debug(f"No zone found for {old_address}") + + # Always try to add, just in case a previous update failed + if new_dns_name and new_address: + # Add new forward record + if not skip_forward: + zone = get_zone(new_dns_name) + if zone: + logger.debug(f"Found zone {zone.name} for {new_dns_name}") + + # Check the SOA, we don't want to write to a parent zone if it has delegated authority + soa = get_soa(new_dns_name) + if soa == zone.name: + update = zone.server.create_update(zone.name) + update.add( + new_dns_name + '.', + 300, + 'a' if new_address.version == 4 else 'aaaa', + str(new_address) + ) + response = dns.query.udp(update, zone.server.address) + update_status(status, f'Adding {new_dns_name} {new_address}', response) + else: + logger.debug(f"Can't update zone {zone.name} for {old_dns_name}, " + f"it has delegated authority for {soa}") + else: + logger.debug(f"No zone found for {new_dns_name}") + + # Add new reverse record + if not skip_reverse: + zone = get_reverse_zone(new_address) + if zone: + record_name = zone.record_name(new_address) + logger.debug(f"Found zone {zone.name} for {record_name}") + + # Check the SOA, we don't want to write to a parent zone if it has delegated authority + soa = get_soa(record_name) + if soa == zone.name: + update = zone.server.create_update(zone.name) + update.add( + record_name + '.', + 300, + 'ptr', + new_dns_name + '.' + ) + response = dns.query.udp(update, zone.server.address) + update_status(status, f'Adding {record_name} {old_dns_name}', response) + else: + logger.debug(f"Can't update zone {zone.name} for {record_name}, " + f"it has delegated authority for {soa}") + else: + logger.debug(f"No zone found for {new_address}") + + return ', '.join(status) diff --git a/netbox_ddns/migrations/0001_initial.py b/netbox_ddns/migrations/0001_initial.py new file mode 100644 index 0000000..3048ab3 --- /dev/null +++ b/netbox_ddns/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 3.0.5 on 2020-04-14 22:52 + +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields +import netbox_ddns.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Server', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('server', models.CharField(max_length=255, validators=[netbox_ddns.validators.HostnameAddressValidator()])), + ('tsig_key_name', models.CharField(max_length=255, validators=[netbox_ddns.validators.HostnameValidator()])), + ('tsig_algorithm', models.CharField(max_length=32)), + ('tsig_key', models.CharField(max_length=512, validators=[netbox_ddns.validators.validate_base64])), + ], + options={ + 'verbose_name': 'dynamic DNS Server', + 'verbose_name_plural': 'dynamic DNS Servers', + 'ordering': ('server', 'tsig_key_name'), + 'unique_together': {('server', 'tsig_key_name')}, + }, + ), + migrations.CreateModel( + name='Zone', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255, unique=True, validators=[netbox_ddns.validators.HostnameValidator()])), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_ddns.Server')), + ], + options={ + 'verbose_name': 'zone', + 'verbose_name_plural': 'zones', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='ReverseZone', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('prefix', ipam.fields.IPNetworkField(unique=True)), + ('name', models.CharField(blank=True, max_length=255)), + ('server', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_ddns.Server')), + ], + options={ + 'verbose_name': 'reverse zone', + 'verbose_name_plural': 'reverse zones', + 'ordering': ('prefix',), + }, + ), + ] diff --git a/netbox_ddns/migrations/__init__.py b/netbox_ddns/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_ddns/models.py b/netbox_ddns/models.py new file mode 100644 index 0000000..ad992e2 --- /dev/null +++ b/netbox_ddns/models.py @@ -0,0 +1,190 @@ +import logging +import socket +from typing import Optional + +import dns.tsigkeyring +import dns.update +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ +from dns.tsig import HMAC_MD5, HMAC_SHA1, HMAC_SHA224, HMAC_SHA256, HMAC_SHA384, HMAC_SHA512 +from netaddr.ip import IPAddress + +from ipam.fields import IPNetworkField +from .validators import HostnameAddressValidator, HostnameValidator, validate_base64 + +logger = logging.getLogger('netbox_ddns') + +TSIG_ALGORITHM_CHOICES = ( + (str(HMAC_MD5), 'HMAC MD5'), + (str(HMAC_SHA1), 'HMAC SHA1'), + (str(HMAC_SHA224), 'HMAC SHA224'), + (str(HMAC_SHA256), 'HMAC SHA256'), + (str(HMAC_SHA384), 'HMAC SHA384'), + (str(HMAC_SHA512), 'HMAC SHA512'), +) + + +class Server(models.Model): + server = models.CharField( + verbose_name=_('DDNS Server'), + max_length=255, + validators=[HostnameAddressValidator()], + ) + tsig_key_name = models.CharField( + verbose_name=_('TSIG Key Name'), + max_length=255, + validators=[HostnameValidator()], + ) + tsig_algorithm = models.CharField( + verbose_name=_('TSIG Algorithm'), + max_length=32, # Longest is 24 chars for historic reasons, new ones are shorter, so 32 is more than enough + choices=TSIG_ALGORITHM_CHOICES, + ) + tsig_key = models.CharField( + verbose_name=_('TSIG Key'), + max_length=512, + validators=[validate_base64], + help_text=_('in base64 notation'), + ) + + class Meta: + unique_together = ( + ('server', 'tsig_key_name'), + ) + ordering = ('server', 'tsig_key_name') + verbose_name = _('dynamic DNS Server') + verbose_name_plural = _('dynamic DNS Servers') + + def __str__(self): + return f'{self.server} ({self.tsig_key_name})' + + def clean(self): + # Remove trailing dots from domain-style fields + self.server = self.server.rstrip('.').lower() + self.tsig_key_name = self.tsig_key_name.rstrip('.').lower() + + @property + def address(self) -> Optional[str]: + addrinfo = socket.getaddrinfo(self.server, 'domain', proto=socket.IPPROTO_UDP) + for family, _, _, _, sockaddr in addrinfo: + if family in (socket.AF_INET, socket.AF_INET6) and sockaddr[0]: + return sockaddr[0] + + def create_update(self, zone: str) -> dns.update.Update: + return dns.update.Update( + zone=zone.rstrip('.') + '.', + keyring=dns.tsigkeyring.from_text({ + self.tsig_key_name: self.tsig_key + }), + keyname=self.tsig_key_name, + keyalgorithm=self.tsig_algorithm + ) + + +class Zone(models.Model): + name = models.CharField( + verbose_name=_('zone name'), + max_length=255, + validators=[HostnameValidator()], + unique=True, + ) + server = models.ForeignKey( + to=Server, + verbose_name=_('DDNS Server'), + on_delete=models.PROTECT, + ) + + class Meta: + ordering = ('name',) + verbose_name = _('zone') + verbose_name_plural = _('zones') + + def __str__(self): + return self.name + + def clean(self): + # Remove trailing dots from domain-style fields + self.name = self.name.rstrip('.').lower() + + def get_updater(self): + return self.server.create_update(self.name) + + +class ReverseZone(models.Model): + prefix = IPNetworkField( + verbose_name=_('prefix'), + unique=True, + ) + name = models.CharField( + verbose_name=_('reverse zone name'), + max_length=255, + blank=True, + help_text=_("RFC 2317 style reverse DNS, required when the prefix doesn't map to a reverse zone"), + ) + server = models.ForeignKey( + to=Server, + verbose_name=_('DDNS Server'), + on_delete=models.PROTECT, + ) + + class Meta: + ordering = ('prefix',) + verbose_name = _('reverse zone') + verbose_name_plural = _('reverse zones') + + def __str__(self): + return f'for {self.prefix}' + + def record_name(self, address: IPAddress): + record_name = self.name + if self.prefix.version == 4: + for pos, octet in enumerate(address.words): + if (pos + 1) * 8 <= self.prefix.prefixlen: + continue + + record_name = f'{octet}.{record_name}' + else: + nibbles = f'{address.value:032x}' + for pos, nibble in enumerate(nibbles): + if (pos + 1) * 4 <= self.prefix.prefixlen: + continue + + record_name = f'{nibble}.{record_name}' + + return record_name + + def clean(self): + # Remove trailing dots from domain-style fields + self.name = self.name.rstrip('.') + + if self.prefix.version == 4: + if self.prefix.prefixlen not in [0, 8, 16, 24] and not self.name: + raise ValidationError({ + 'name': _('Required when prefix length is not 0, 8, 16 or 24'), + }) + elif not self.name: + # Generate it for the user + self.name = 'in-addr.arpa' + for pos, octet in enumerate(self.prefix.ip.words): + if pos * 8 >= self.prefix.prefixlen: + break + + self.name = f'{octet}.{self.name}' + else: + if self.prefix.prefixlen % 4 != 0 and not self.name: + raise ValidationError({ + 'name': _('Required when prefix length is not a nibble boundary'), + }) + elif not self.name: + # Generate it for the user + self.name = 'ip6.arpa' + nibbles = f'{self.prefix.ip.value:032x}' + for pos, nibble in enumerate(nibbles): + if pos * 4 >= self.prefix.prefixlen: + break + + self.name = f'{nibble}.{self.name}' + + # Store zone names in lowercase + self.name = self.name.lower() diff --git a/netbox_ddns/navigation.py b/netbox_ddns/navigation.py new file mode 100644 index 0000000..eecb905 --- /dev/null +++ b/netbox_ddns/navigation.py @@ -0,0 +1 @@ +menu_items = () diff --git a/netbox_ddns/signals.py b/netbox_ddns/signals.py new file mode 100644 index 0000000..77ee622 --- /dev/null +++ b/netbox_ddns/signals.py @@ -0,0 +1,37 @@ +import logging + +from django.db.models.signals import post_delete, post_save, pre_save +from django.dispatch import receiver + +from ipam.models import IPAddress +from netbox_ddns.background_tasks import update_dns + +logger = logging.getLogger('netbox_ddns') + + +@receiver(pre_save, sender=IPAddress) +def store_original_ipaddress(instance: IPAddress, **_kwargs): + instance.before_save = IPAddress.objects.filter(pk=instance.pk).first() + + +@receiver(post_save, sender=IPAddress) +def trigger_ddns_update(instance: IPAddress, **_kwargs): + old_address = instance.before_save.address if instance.before_save else None + old_dns_name = instance.before_save.dns_name if instance.before_save else '' + + if instance.address != old_address or instance.dns_name != old_dns_name: + # IP address or DNS name has changed + update_dns.delay( + old_address=old_address.ip if old_address else None, + new_address=instance.address.ip, + old_dns_name=old_dns_name.rstrip('.'), + new_dns_name=instance.dns_name.rstrip('.'), + ) + + +@receiver(post_delete, sender=IPAddress) +def trigger_ddns_delete(instance: IPAddress, **_kwargs): + update_dns.delay( + old_address=instance.address.ip, + old_dns_name=instance.dns_name.rstrip('.'), + ) diff --git a/netbox_ddns/template_content.py b/netbox_ddns/template_content.py new file mode 100644 index 0000000..75e3a92 --- /dev/null +++ b/netbox_ddns/template_content.py @@ -0,0 +1 @@ +template_extensions = [] diff --git a/netbox_ddns/urls.py b/netbox_ddns/urls.py new file mode 100644 index 0000000..637600f --- /dev/null +++ b/netbox_ddns/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/netbox_ddns/validators.py b/netbox_ddns/validators.py new file mode 100644 index 0000000..c8c282f --- /dev/null +++ b/netbox_ddns/validators.py @@ -0,0 +1,23 @@ +import base64 +import binascii + +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator, URLValidator +from django.utils.translation import gettext_lazy as _ + + +class HostnameValidator(RegexValidator): + regex = r'^(' + URLValidator.hostname_re + r'((' + URLValidator.domain_re + URLValidator.tld_re + r')|\.?))$' + message = _('Enter a valid hostname.') + + +class HostnameAddressValidator(RegexValidator): + regex = '^(' + HostnameValidator.regex + '|' + URLValidator.ipv6_re + '|' + URLValidator.host_re + ')$' + message = _('Enter a valid hostname or IP address.') + + +def validate_base64(value): + try: + base64.b64decode(value, validate=True) + except binascii.Error: + raise ValidationError('Invalid base64 string') diff --git a/netbox_ddns/views.py b/netbox_ddns/views.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7959e22 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools >= 41.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..584002d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,26 @@ +[metadata] +name = netbox-ddns +version = attr: netbox_ddns.VERSION +description = Dynamic DNS Connector for NetBox +long_description = file: README.md +author = Sander Steffann +author_email = sander@steffann.nl +url = https://github.com/sjm-steffann/netbox-ddns +license = Apache 2.0 +license_file = LICENSE.txt +classifiers = + Development Status :: 2 - Pre-Alpha + Framework :: Django + Framework :: Django :: 3.0 + License :: OSI Approved :: Apache Software License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + +[options] +zip_safe = False +include_package_data = True +packages = find: +python_requires = >=3.6 +install_requires = + setuptools + dnspython diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..df26131 --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +import setuptools + +if __name__ == "__main__": + setuptools.setup()