From fa640706f5ee68b0a595d5fa30447e65ce58a27e Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Tue, 23 Sep 2014 00:48:54 +0200 Subject: [PATCH] add related hosts functionality, to update host records based on main host network address + interface id this is esp. useful for v6 hosts, where your provider might give you a changing prefix (we assume /64), but your interface ids stay the same. but same mechanism also principally works for v4 (we assume /29), except that you usually do not get a v4 network where the network address is changing. and when it comes to dynamic addresses, many people only get 1 ipv4 address anyway. the related host could then be used for aliaseses with ifid == 0 for all aliases. --- conftest.py | 6 +- nsupdate/api/_tests/test_api.py | 24 ++- nsupdate/api/views.py | 62 +++++-- ...__add_unique_relatedhost_name_main_host.py | 155 ++++++++++++++++++ nsupdate/main/models.py | 34 ++++ requirements.d/all.txt | 1 + setup.py | 1 + 7 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 nsupdate/main/migrations/0028_auto__add_relatedhost__add_unique_relatedhost_name_main_host.py diff --git a/conftest.py b/conftest.py index 8f0aae3..2ac675a 100644 --- a/conftest.py +++ b/conftest.py @@ -17,6 +17,8 @@ TEST_HOST = FQDN('test%da' % randint(1, 1000000), TESTDOMAIN) # unit tests can TEST_SECRET = "secret" TEST_HOST2 = FQDN('test%db' % randint(1, 1000000), TESTDOMAIN) TEST_SECRET2 = "somethingelse" +RELATED_HOST_NAME = 'rh' +TEST_HOST_RELATED = FQDN(RELATED_HOST_NAME + '.' + TEST_HOST.host, TEST_HOST.domain) NAMESERVER_IP = "85.10.192.104" NAMESERVER_UPDATE_ALGORITHM = "HMAC_SHA512" # no problem, you can ONLY update the TESTDOMAIN with this secret, nothing else: @@ -62,7 +64,7 @@ def db_init(db): # note: db is a predefined fixture and required here to have t Init the database contents for testing, so we have a service domain, ... """ from django.contrib.auth import get_user_model - from nsupdate.main.models import Host, Domain, ServiceUpdater, ServiceUpdaterHostConfig + from nsupdate.main.models import Host, RelatedHost, Domain, ServiceUpdater, ServiceUpdaterHostConfig user_model = get_user_model() # create a fresh test user u = user_model.objects.create_user(USERNAME, settings.DEFAULT_FROM_EMAIL, PASSWORD) @@ -115,6 +117,8 @@ def db_init(db): # note: db is a predefined fixture and required here to have t created_by=u, ) + RelatedHost.objects.create(name=RELATED_HOST_NAME, interface_id_ipv4="0.0.0.1", interface_id_ipv6="::1", main_host=h) + def pytest_runtest_setup(item): activate('en') diff --git a/nsupdate/api/_tests/test_api.py b/nsupdate/api/_tests/test_api.py index 4fed75c..7659cb1 100644 --- a/nsupdate/api/_tests/test_api.py +++ b/nsupdate/api/_tests/test_api.py @@ -110,7 +110,7 @@ def test_nic_update_authorized_ns_unavailable(client): assert response.content == b'dnserr' -def test_nic_update_authorized_myip(client): +def test_nic_update_authorized_myip_v4(client): response = client.get(reverse('nic_update') + '?myip=4.3.2.1', HTTP_AUTHORIZATION=make_basic_auth_header(TEST_HOST, TEST_SECRET)) assert response.status_code == 200 @@ -126,6 +126,28 @@ def test_nic_update_authorized_myip(client): assert response.status_code == 200 # must be nochg (was same IP) assert response.content == b'nochg 1.2.3.4' + # now check if it updated the ipv4 related hosts also: + assert query_ns(TEST_HOST_RELATED, 'A') == '1.2.3.1' # 1.2.3.4/29 + 0.0.0.1 + + +def test_nic_update_authorized_myip_v6(client): + response = client.get(reverse('nic_update') + '?myip=2000::2', + HTTP_AUTHORIZATION=make_basic_auth_header(TEST_HOST, TEST_SECRET)) + assert response.status_code == 200 + # we don't care whether it is nochg or good, but should be the ip from myip=...: + assert response.content in [b'good 2000::2', b'nochg 2000::2'] + response = client.get(reverse('nic_update') + '?myip=2000::3', + HTTP_AUTHORIZATION=make_basic_auth_header(TEST_HOST, TEST_SECRET)) + assert response.status_code == 200 + # must be good (was different IP) + assert response.content == b'good 2000::3' + response = client.get(reverse('nic_update') + '?myip=2000::3', + HTTP_AUTHORIZATION=make_basic_auth_header(TEST_HOST, TEST_SECRET)) + assert response.status_code == 200 + # must be nochg (was same IP) + assert response.content == b'nochg 2000::3' + # now check if it updated the ipv4 related hosts also: + assert query_ns(TEST_HOST_RELATED, 'AAAA') == '2000::1' # 2000::3/64 + ::1 @pytest.mark.requires_sequential diff --git a/nsupdate/api/views.py b/nsupdate/api/views.py index 2015631..864990e 100644 --- a/nsupdate/api/views.py +++ b/nsupdate/api/views.py @@ -7,6 +7,8 @@ import logging logger = logging.getLogger(__name__) import json +from netaddr import IPAddress, IPNetwork +from netaddr.core import AddrFormatError from django.http import HttpResponse from django.conf import settings @@ -18,7 +20,7 @@ from django.utils.decorators import method_decorator from ..utils import log, ddns_client from ..main.models import Host -from ..main.dnstools import (update, delete, check_ip, put_ip_into_session, +from ..main.dnstools import (FQDN, update, delete, check_ip, put_ip_into_session, SameIpError, DnsUpdateError, NameServerNotAvailable) @@ -343,9 +345,55 @@ def _update(host, ipaddr, secure=False, logger=None): host.poke(kind, secure) try: update(fqdn, ipaddr) + except SameIpError: + msg = '%s - received no-change update, ip: %s tls: %r' % (fqdn, ipaddr, secure) + logger.warning(msg) + host.register_client_result(msg, fault=True) + return Response('nochg %s' % ipaddr) + except (DnsUpdateError, NameServerNotAvailable) as e: + msg = str(e) + msg = '%s - received update that resulted in a dns error [%s], ip: %s tls: %r' % ( + fqdn, msg, ipaddr, secure) + logger.error(msg) + host.register_server_result(msg, fault=True) + return Response('dnserr') + else: msg = '%s - received good update -> ip: %s tls: %r' % (fqdn, ipaddr, secure) logger.info(msg) host.register_client_result(msg, fault=False) + # update related hosts + for rh in host.relatedhosts.all(): + if rh.available: + # TODO: make netmask configurable in Host record + if kind == 'ipv4': + ifid, netmask = rh.interface_id_ipv4.strip(), '/29' + else: # kind == 'ipv6': + ifid, netmask = rh.interface_id_ipv6.strip(), '/64' + if not ifid: + # ifid can be just left blank if no address record of this type is wanted + continue + try: + ifid = IPAddress(ifid) + network = IPNetwork(ipaddr + netmask) + rh_ipaddr = str(IPAddress(network.network) + int(ifid)) + rh_fqdn = FQDN(rh.name + '.' + fqdn.host, fqdn.domain) + except AddrFormatError as e: + logger.warning("trouble computing address of related host %s [%s]" % (rh, e)) + else: + logger.info("updating related host %s -> %s" % (rh_fqdn, rh_ipaddr)) + try: + update(rh_fqdn, rh_ipaddr) + except SameIpError: + msg = '%s - received no-change update, ip: %s tls: %r' % (rh_fqdn, rh_ipaddr, secure) + logger.warning(msg) + host.register_client_result(msg, fault=True) + except (DnsUpdateError, NameServerNotAvailable) as e: + msg = str(e) + msg = '%s - received update that resulted in a dns error [%s], ip: %s tls: %r' % ( + rh_fqdn, msg, rh_ipaddr, secure) + logger.error(msg) + host.register_server_result(msg, fault=True) + # now check if there are other services we shall relay updates to: for hc in host.serviceupdaterhostconfigs.all(): if (kind == 'ipv4' and hc.give_ipv4 and hc.service.accept_ipv4 @@ -363,18 +411,6 @@ def _update(host, ipaddr, secure=False, logger=None): kwargs.pop('password') logger.exception("the dyndns2 updater raised an exception [%r]" % kwargs) return Response('good %s' % ipaddr) - except SameIpError: - msg = '%s - received no-change update, ip: %s tls: %r' % (fqdn, ipaddr, secure) - logger.warning(msg) - host.register_client_result(msg, fault=True) - return Response('nochg %s' % ipaddr) - except (DnsUpdateError, NameServerNotAvailable) as e: - msg = str(e) - msg = '%s - received update that resulted in a dns error [%s], ip: %s tls: %r' % ( - fqdn, msg, ipaddr, secure) - logger.error(msg) - host.register_server_result(msg, fault=True) - return Response('dnserr') def _delete(host, ipaddr, secure=False, logger=None): diff --git a/nsupdate/main/migrations/0028_auto__add_relatedhost__add_unique_relatedhost_name_main_host.py b/nsupdate/main/migrations/0028_auto__add_relatedhost__add_unique_relatedhost_name_main_host.py new file mode 100644 index 0000000..37826a3 --- /dev/null +++ b/nsupdate/main/migrations/0028_auto__add_relatedhost__add_unique_relatedhost_name_main_host.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'RelatedHost' + db.create_table(u'main_relatedhost', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('name', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('interface_id_ipv4', self.gf('django.db.models.fields.CharField')(default='', max_length=16)), + ('interface_id_ipv6', self.gf('django.db.models.fields.CharField')(default='', max_length=22)), + ('available', self.gf('django.db.models.fields.BooleanField')(default=True)), + ('main_host', self.gf('django.db.models.fields.related.ForeignKey')(related_name='relatedhosts', to=orm['main.Host'])), + )) + db.send_create_signal(u'main', ['RelatedHost']) + + # Adding unique constraint on 'RelatedHost', fields ['name', 'main_host'] + db.create_unique(u'main_relatedhost', ['name', 'main_host_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'RelatedHost', fields ['name', 'main_host'] + db.delete_unique(u'main_relatedhost', ['name', 'main_host_id']) + + # Deleting model 'RelatedHost' + db.delete_table(u'main_relatedhost') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'main.blacklistedhost': { + 'Meta': {'object_name': 'BlacklistedHost'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'blacklisted_domains'", 'to': u"orm['auth.User']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_update': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'name_re': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}) + }, + u'main.domain': { + 'Meta': {'object_name': 'Domain'}, + 'available': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'domains'", 'to': u"orm['auth.User']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_update': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'nameserver_ip': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}), + 'nameserver_update_algorithm': ('django.db.models.fields.CharField', [], {'default': "'HMAC_SHA512'", 'max_length': '16'}), + 'nameserver_update_secret': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '88'}), + 'public': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + u'main.host': { + 'Meta': {'unique_together': "(('name', 'domain'),)", 'object_name': 'Host', 'index_together': "(('name', 'domain'),)"}, + 'abuse': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'abuse_blocked': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'available': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'client_faults': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'client_result_msg': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts'", 'to': u"orm['auth.User']"}), + 'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['main.Domain']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_update': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'last_update_ipv4': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'last_update_ipv6': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'server_faults': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'server_result_msg': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'tls_update_ipv4': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'tls_update_ipv6': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'update_secret': ('django.db.models.fields.CharField', [], {'max_length': '64'}) + }, + u'main.relatedhost': { + 'Meta': {'unique_together': "(('name', 'main_host'),)", 'object_name': 'RelatedHost'}, + 'available': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'interface_id_ipv4': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '16'}), + 'interface_id_ipv6': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '22'}), + 'main_host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'relatedhosts'", 'to': u"orm['main.Host']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + u'main.serviceupdater': { + 'Meta': {'object_name': 'ServiceUpdater'}, + 'accept_ipv4': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'accept_ipv6': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'serviceupdater'", 'to': u"orm['auth.User']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_update': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'path': ('django.db.models.fields.CharField', [], {'default': "'/nic/update'", 'max_length': '255'}), + 'secure': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'server': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + u'main.serviceupdaterhostconfig': { + 'Meta': {'object_name': 'ServiceUpdaterHostConfig'}, + 'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'serviceupdaterhostconfigs'", 'to': u"orm['auth.User']"}), + 'give_ipv4': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'give_ipv6': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'serviceupdaterhostconfigs'", 'to': u"orm['main.Host']"}), + 'hostname': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'null': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_update': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['main.ServiceUpdater']"}) + } + } + + complete_apps = ['main'] \ No newline at end of file diff --git a/nsupdate/main/models.py b/nsupdate/main/models.py index 536638e..9d10cf3 100644 --- a/nsupdate/main/models.py +++ b/nsupdate/main/models.py @@ -285,6 +285,40 @@ def pre_delete_host(sender, **kwargs): pre_delete.connect(pre_delete_host, sender=Host) +class RelatedHost(models.Model): + # host addr = network_of_main_host + interface_id + name = models.CharField( + max_length=255, # RFC 2181 (and considering having multiple joined labels here later) + validators=[ + RegexValidator( + regex=r'^(([a-z0-9][a-z0-9\-]*[a-z0-9])|[a-z0-9])$', + message='Invalid host name: only "a-z", "0-9" and "-" is allowed' + ), + ], + help_text=_("The name of a host in same network as your main host.")) + interface_id_ipv4 = models.CharField( + default='', + max_length=16, # 123.123.123.123 + help_text=_("The IPv4 interface ID of this host.")) + interface_id_ipv6 = models.CharField( + default='', + max_length=22, # ::1234:5678:9abc:def0 + help_text=_("The IPv6 interface ID of this host.")) + available = models.BooleanField( + default=True, + help_text=_("Check if host is available/in use - " + "if not checked, we won't accept updates for this host")) + + main_host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name='relatedhosts') + + def __unicode__(self): + return u"%s.%s" % ( + self.name, unicode(self.main_host)) + + class Meta(object): + unique_together = (('name', 'main_host'), ) + + class ServiceUpdater(models.Model): name = models.CharField( max_length=32, diff --git a/requirements.d/all.txt b/requirements.d/all.txt index 3d984e0..9e8ffee 100644 --- a/requirements.d/all.txt +++ b/requirements.d/all.txt @@ -1,5 +1,6 @@ # packages always needed dnspython +netaddr django==1.6.6 django-bootstrap-form django-registration diff --git a/setup.py b/setup.py index 05c661f..0279bfa 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ setup( zip_safe=False, platforms='any', install_requires=install_requires + [ + 'netaddr', 'django >=1.6, <1.7', # 1.7 is not tested yet # django >= 1.5.3 also works, but needs a code change, see # https://github.com/nsupdate-info/nsupdate.info/issues/141