diff options
Diffstat (limited to 'rpki/gui/cacheview')
18 files changed, 1306 insertions, 0 deletions
diff --git a/rpki/gui/cacheview/__init__.py b/rpki/gui/cacheview/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/rpki/gui/cacheview/__init__.py diff --git a/rpki/gui/cacheview/forms.py b/rpki/gui/cacheview/forms.py new file mode 100644 index 00000000..28b8ff24 --- /dev/null +++ b/rpki/gui/cacheview/forms.py @@ -0,0 +1,51 @@ +# Copyright (C) 2011 SPARTA, Inc. dba Cobham Analytic Solutions +# Copyright (C) 2013 SPARTA, Inc. a Parsons Company +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND SPARTA DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL SPARTA BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +__version__ = '$Id$' + +from django import forms + +from rpki.gui.cacheview.misc import parse_ipaddr +from rpki.exceptions import BadIPResource +from rpki.resource_set import resource_range_as + + +class SearchForm(forms.Form): + asn = forms.CharField(required=False, help_text='AS or range', label='AS') + addr = forms.CharField(required=False, max_length=40, help_text='range/CIDR', label='IP Address') + + def clean(self): + asn = self.cleaned_data.get('asn') + addr = self.cleaned_data.get('addr') + if (asn and addr) or ((not asn) and (not addr)): + raise forms.ValidationError, 'Please specify either an AS or IP range, not both' + + if asn: + try: + resource_range_as.parse_str(asn) + except ValueError: + raise forms.ValidationError, 'invalid AS range' + + if addr: + #try: + parse_ipaddr(addr) + #except BadIPResource: + # raise forms.ValidationError, 'invalid IP address range/prefix' + + return self.cleaned_data + + +class SearchForm2(forms.Form): + resource = forms.CharField(required=True) diff --git a/rpki/gui/cacheview/misc.py b/rpki/gui/cacheview/misc.py new file mode 100644 index 00000000..9a69645c --- /dev/null +++ b/rpki/gui/cacheview/misc.py @@ -0,0 +1,31 @@ +# Copyright (C) 2011 SPARTA, Inc. dba Cobham Analytic Solutions +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND SPARTA DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL SPARTA BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +from rpki.resource_set import resource_range_ipv4, resource_range_ipv6 +from rpki.exceptions import BadIPResource + +def parse_ipaddr(s): + # resource_set functions only accept str + if isinstance(s, unicode): + s = s.encode() + s = s.strip() + r = resource_range_ipv4.parse_str(s) + try: + r = resource_range_ipv4.parse_str(s) + return 4, r + except BadIPResource: + r = resource_range_ipv6.parse_str(s) + return 6, r + +# vim:sw=4 ts=8 expandtab diff --git a/rpki/gui/cacheview/models.py b/rpki/gui/cacheview/models.py new file mode 100644 index 00000000..c3ee8421 --- /dev/null +++ b/rpki/gui/cacheview/models.py @@ -0,0 +1,237 @@ +# Copyright (C) 2011 SPARTA, Inc. dba Cobham Analytic Solutions +# Copyright (C) 2012 SPARTA, Inc. a Parsons Company +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND SPARTA DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL SPARTA BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +__version__ = '$Id$' + +from datetime import datetime +import time + +from django.db import models +from django.core.urlresolvers import reverse + +import rpki.resource_set +import rpki.gui.models + + +class TelephoneField(models.CharField): + def __init__(self, *args, **kwargs): + kwargs['max_length'] = 255 + models.CharField.__init__(self, *args, **kwargs) + + +class AddressRange(rpki.gui.models.PrefixV4): + @models.permalink + def get_absolute_url(self): + return ('rpki.gui.cacheview.views.addressrange_detail', [str(self.pk)]) + + +class AddressRangeV6(rpki.gui.models.PrefixV6): + @models.permalink + def get_absolute_url(self): + return ('rpki.gui.cacheview.views.addressrange_detail_v6', + [str(self.pk)]) + + +class ASRange(rpki.gui.models.ASN): + @models.permalink + def get_absolute_url(self): + return ('rpki.gui.cacheview.views.asrange_detail', [str(self.pk)]) + +kinds = list(enumerate(('good', 'warn', 'bad'))) +kinds_dict = dict((v, k) for k, v in kinds) + + +class ValidationLabel(models.Model): + """ + Represents a specific error condition defined in the rcynic XML + output file. + """ + label = models.CharField(max_length=79, db_index=True, unique=True) + status = models.CharField(max_length=255) + kind = models.PositiveSmallIntegerField(choices=kinds) + + def __unicode__(self): + return self.label + + +class RepositoryObject(models.Model): + """ + Represents a globally unique RPKI repository object, specified by its URI. + """ + uri = models.URLField(unique=True, db_index=True) + +generations = list(enumerate(('current', 'backup'))) +generations_dict = dict((val, key) for (key, val) in generations) + + +class ValidationStatus(models.Model): + timestamp = models.DateTimeField() + generation = models.PositiveSmallIntegerField(choices=generations, null=True) + status = models.ForeignKey(ValidationLabel) + repo = models.ForeignKey(RepositoryObject, related_name='statuses') + + +class SignedObject(models.Model): + """ + Abstract class to hold common metadata for all signed objects. + The signing certificate is ommitted here in order to give a proper + value for the 'related_name' attribute. + """ + repo = models.ForeignKey(RepositoryObject, related_name='cert', unique=True) + + # on-disk file modification time + mtime = models.PositiveIntegerField(default=0) + + # SubjectName + name = models.CharField(max_length=255) + + # value from the SKI extension + keyid = models.CharField(max_length=60, db_index=True) + + # validity period from EE cert which signed object + not_before = models.DateTimeField() + not_after = models.DateTimeField() + + def mtime_as_datetime(self): + """ + convert the local timestamp to UTC and convert to a datetime object + """ + return datetime.utcfromtimestamp(self.mtime + time.timezone) + + def status_id(self): + """ + Returns a HTML class selector for the current object based on its validation status. + The selector is chosen based on the current generation only. If there is any bad status, + return bad, else if there are any warn status, return warn, else return good. + """ + for x in reversed(kinds): + if self.repo.statuses.filter(generation=generations_dict['current'], status__kind=x[0]): + return x[1] + return None # should not happen + + def __unicode__(self): + return u'%s' % self.name + + +class Cert(SignedObject): + """ + Object representing a resource certificate. + """ + addresses = models.ManyToManyField(AddressRange, related_name='certs') + addresses_v6 = models.ManyToManyField(AddressRangeV6, related_name='certs') + asns = models.ManyToManyField(ASRange, related_name='certs') + issuer = models.ForeignKey('self', related_name='children', null=True) + sia = models.CharField(max_length=255) + + def get_absolute_url(self): + return reverse('cert-detail', args=[str(self.pk)]) + + def get_cert_chain(self): + """Return a list containing the complete certificate chain for this + certificate.""" + cert = self + x = [cert] + while cert != cert.issuer: + cert = cert.issuer + x.append(cert) + x.reverse() + return x + cert_chain = property(get_cert_chain) + + +class ROAPrefix(models.Model): + "Abstract base class for ROA mixin." + + max_length = models.PositiveSmallIntegerField() + + class Meta: + abstract = True + + def as_roa_prefix(self): + "Return value as a rpki.resource_set.roa_prefix_ip object." + rng = self.as_resource_range() + return self.roa_cls(rng.min, rng.prefixlen(), self.max_length) + + def __unicode__(self): + p = self.as_resource_range() + if p.prefixlen() == self.max_length: + return str(p) + return '%s-%s' % (str(p), self.max_length) + + +# ROAPrefix is declared first, so subclass picks up __unicode__ from it. +class ROAPrefixV4(ROAPrefix, rpki.gui.models.PrefixV4): + "One v4 prefix in a ROA." + + roa_cls = rpki.resource_set.roa_prefix_ipv4 + + @property + def routes(self): + """return all routes covered by this roa prefix""" + return RouteOrigin.objects.filter(prefix_min__gte=self.prefix_min, + prefix_max__lte=self.prefix_max) + + class Meta: + ordering = ('prefix_min',) + + +# ROAPrefix is declared first, so subclass picks up __unicode__ from it. +class ROAPrefixV6(ROAPrefix, rpki.gui.models.PrefixV6): + "One v6 prefix in a ROA." + + roa_cls = rpki.resource_set.roa_prefix_ipv6 + + class Meta: + ordering = ('prefix_min',) + + +class ROA(SignedObject): + asid = models.PositiveIntegerField() + prefixes = models.ManyToManyField(ROAPrefixV4, related_name='roas') + prefixes_v6 = models.ManyToManyField(ROAPrefixV6, related_name='roas') + issuer = models.ForeignKey('Cert', related_name='roas') + + def get_absolute_url(self): + return reverse('roa-detail', args=[str(self.pk)]) + + class Meta: + ordering = ('asid',) + + def __unicode__(self): + return u'ROA for AS%d' % self.asid + + +class Ghostbuster(SignedObject): + full_name = models.CharField(max_length=40) + email_address = models.EmailField(blank=True, null=True) + organization = models.CharField(blank=True, null=True, max_length=255) + telephone = TelephoneField(blank=True, null=True) + issuer = models.ForeignKey('Cert', related_name='ghostbusters') + + def get_absolute_url(self): + # note that ghostbuster-detail is different from gbr-detail! sigh + return reverse('ghostbuster-detail', args=[str(self.pk)]) + + def __unicode__(self): + if self.full_name: + return self.full_name + if self.organization: + return self.organization + if self.email_address: + return self.email_address + return self.telephone + + +from rpki.gui.routeview.models import RouteOrigin diff --git a/rpki/gui/cacheview/templates/cacheview/addressrange_detail.html b/rpki/gui/cacheview/templates/cacheview/addressrange_detail.html new file mode 100644 index 00000000..76edc1ba --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/addressrange_detail.html @@ -0,0 +1,18 @@ +{% extends "cacheview/cacheview_base.html" %} + +{% block content %} +<h1>{% block title %}IP Range Detail{% endblock %}</h1> + +<p> +IP Range: {{ object }} +</p> + +<p>Covered by the following resource certs:</p> + +<ul> +{% for cert in object.certs.all %} +<li><a href="{{ cert.get_absolute_url }}">{{ cert }}</a></li> +{% endfor %} +</ul> + +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/cacheview_base.html b/rpki/gui/cacheview/templates/cacheview/cacheview_base.html new file mode 100644 index 00000000..ec71d740 --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/cacheview_base.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load url from future %} + +{% block sidebar %} +<form method='post' action='{% url 'res-search' %}'> + {% csrf_token %} + <input type='text' id='id_resource' name='resource' placeholder='prefix or AS'> + <button type='submit'>Search</button> +</form> +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/cert_detail.html b/rpki/gui/cacheview/templates/cacheview/cert_detail.html new file mode 100644 index 00000000..256e7780 --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/cert_detail.html @@ -0,0 +1,105 @@ +{% extends "cacheview/signedobject_detail.html" %} + +{% block title %} +Resource Certificate Detail +{% endblock %} + +{% block detail %} + +<h2>RFC3779 Resources</h2> + +<table class='table table-striped'> + <thead> + <tr><th>AS Ranges</th><th>IP Ranges</th></tr> + </thead> + <tbody> + <tr> + <td style='text-align:left;vertical-align:top'> + <ul class='compact'> + {% for asn in object.asns.all %} + <li><a href="{{ asn.get_absolute_url }}">{{ asn }}</a></li> + {% endfor %} + </ul> + </td> + <td style='text-align:left;vertical-align:top'> + <ul class='compact'> + {% for rng in object.addresses.all %} + <li><a href="{{ rng.get_absolute_url }}">{{ rng }}</a></li> + {% endfor %} + </ul> + </td> + </tr> + </tbody> +</table> + +<div class='section'> +<h2>Issued Objects</h2> +<ul> + +{% if object.ghostbusters.all %} + <li> +<h3>Ghostbusters</h3> + +<table class='table table-striped'> + <thead> + <tr><th>Name</th><th>Expires</th></tr> + </thead> + <tbody> + +{% for g in object.ghostbusters.all %} + <tr class='{{ g.status_id }}'> + <td><a href="{{ g.get_absolute_url }}">{{ g }}</a></td> + <td>{{ g.not_after }}</td> + </tr> + </tbody> +{% endfor %} + +</table> +{% endif %} + +{% if object.roas.all %} + <li> +<h3>ROAs</h3> +<table class='table table-striped'> + <thead> + <tr><th>#</th><th>Prefix</th><th>AS</th><th>Expires</th></tr> + </thead> + <tbody> + {% for roa in object.roas.all %} + {% for pfx in roa.prefixes.all %} + <tr class='{{ roa.status_id }}'> + <td><a href="{{ roa.get_absolute_url }}">#</a></td> + <td>{{ pfx }}</td> + <td>{{ roa.asid }}</td> + <td>{{ roa.not_after }}</td> + </tr> + {% endfor %} + {% endfor %} + </tbody> +</table> +{% endif %} + +{% if object.children.all %} +<li> +<h3>Children</h3> +<table class='table table-striped'> + <thead> + <tr><th>Name</th><th>Expires</th></tr> + </thead> + <tbody> + + {% for child in object.children.all %} + <tr class='{{ child.status_id }}'> + <td><a href="{{ child.get_absolute_url }}">{{ child.name }}</a></td> + <td>{{ child.not_after }}</td> + </tr> + {% endfor %} + </tbody> +</table> +{% endif %} + +</ul> + +</div><!--issued objects--> + +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/ghostbuster_detail.html b/rpki/gui/cacheview/templates/cacheview/ghostbuster_detail.html new file mode 100644 index 00000000..4215f757 --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/ghostbuster_detail.html @@ -0,0 +1,13 @@ +{% extends "cacheview/signedobject_detail.html" %} + +{% block title %}Ghostbuster Detail{% endblock %} + +{% block detail %} +<p> +<table class='table'> + <tr><td>Full Name</td><td>{{ object.full_name }}</td></tr> + <tr><td>Organization</td><td>{{ object.organization }}</td></tr> + <tr><td>Email</td><td>{{ object.email_address }}</td></tr> + <tr><td>Telephone</td><td>{{ object.telephone }}</td></tr> +</table> +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/global_summary.html b/rpki/gui/cacheview/templates/cacheview/global_summary.html new file mode 100644 index 00000000..0dbd0ffc --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/global_summary.html @@ -0,0 +1,26 @@ +{% extends "cacheview/cacheview_base.html" %} + +{% block content %} +<div class='page-header'> + <h1>Browse Global RPKI</h1> +</div> + +<table class="table table-striped"> + <thead> + <tr> + <th>Name</th> + <th>Expires</th> + <th>URI</th> + </tr> + </thead> + <tbody> + {% for r in roots %} + <tr> + <td><a href="{{ r.get_absolute_url }}">{{ r.name }}</a></td> + <td>{{ r.not_after }}</td> + <td>{{ r.repo.uri }}</td> + </tr> + {% endfor %} + </tbody> +</table> +{% endblock content %} diff --git a/rpki/gui/cacheview/templates/cacheview/query_result.html b/rpki/gui/cacheview/templates/cacheview/query_result.html new file mode 100644 index 00000000..0694c531 --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/query_result.html @@ -0,0 +1,21 @@ +{% extends "cacheview/cacheview_base.html" %} + +{% block content %} + +<h1>{% block title %}Query Results{% endblock %}</h1> + +<table> + <tr><th>Prefix</th><th>AS</th><th>Valid</th><th>Until</th></tr> + {% for object in object_list %} + <tr class='{{ object.1.status.kind_as_str }}'> + <td>{{ object.0 }}</td> + <td>{{ object.1.asid }}</td> + <td><a href="{{ object.1.get_absolute_url }}">{{ object.1.ok }}</a></td> + <td>{{ object.1.not_after }}</td> + </tr> + {% endfor %} +</table> + +<p><a href="{% url rpki.gui.cacheview.views.query_view %}">new query</a></p> + +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/roa_detail.html b/rpki/gui/cacheview/templates/cacheview/roa_detail.html new file mode 100644 index 00000000..39cc547b --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/roa_detail.html @@ -0,0 +1,18 @@ +{% extends "cacheview/signedobject_detail.html" %} + +{% block title %}ROA Detail{% endblock %} + +{% block detail %} +<p> +<table> + <tr><td>AS</td><td>{{ object.asid }}</td></tr> +</table> + +<h2>Prefixes</h2> + +<ul> +{% for pfx in object.prefixes.all %} +<li>{{ pfx }} +{% endfor %} +</ul> +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/search_form.html b/rpki/gui/cacheview/templates/cacheview/search_form.html new file mode 100644 index 00000000..1141615d --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/search_form.html @@ -0,0 +1,17 @@ +{% extends "cacheview/cacheview_base.html" %} + +{% block title %} +{{ search_type }} Search +{% endblock %} + +{% block content %} + +<h1>{{search_type}} Search</h1> + +<form method='post' action='{{ request.url }}'> + {% csrf_token %} + {{ form.as_p }} + <input type='submit' name='Search'> +</form> + +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/search_result.html b/rpki/gui/cacheview/templates/cacheview/search_result.html new file mode 100644 index 00000000..7cbf852e --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/search_result.html @@ -0,0 +1,42 @@ +{% extends "cacheview/cacheview_base.html" %} + +{% block content %} + +<div class='page-header'> + <h1>Search Results <small>{{ resource }}</small></h1> +</div> + +<h2>Matching Resource Certificates</h2> +{% if certs %} +<ul> +{% for cert in certs %} +<li><a href="{{ cert.get_absolute_url }}">{{ cert }}</a> +{% endfor %} +</ul> +{% else %} +<p>none</p> +{% endif %} + +<h2>Matching ROAs</h2> +{% if roas %} +<table class='table table-striped'> + <thead> + <tr> + <th>#</th><th>Prefix</th><th>AS</th> + </tr> + </thead> + <tbody> +{% for roa in roas %} +<tr> + <td><a href="{{ roa.get_absolute_url }}">#</a></td> + <td>{{ roa.prefixes.all.0 }}</td> + <td>{{ roa.asid }}</td> +</tr> +{% endfor %} +</tbody> +</table> +{% else %} +<p>none</p> +{% endif %} + +{% endblock %} diff --git a/rpki/gui/cacheview/templates/cacheview/signedobject_detail.html b/rpki/gui/cacheview/templates/cacheview/signedobject_detail.html new file mode 100644 index 00000000..22ae3d27 --- /dev/null +++ b/rpki/gui/cacheview/templates/cacheview/signedobject_detail.html @@ -0,0 +1,58 @@ +{% extends "cacheview/cacheview_base.html" %} + +{% block content %} +<div class='page-header'> +<h1>{% block title %}Signed Object Detail{% endblock %}</h1> +</div> + +<h2>Cert Info</h2> +<table class='table table-striped'> + <tr><td>Subject Name</td><td>{{ object.name }}</td></tr> + <tr><td>SKI</td><td>{{ object.keyid }}</td></tr> + {% if object.sia %} + <tr><td>SIA</td><td>{{ object.sia }}</td></tr> + {% endif %} + <tr><td>Not Before</td><td>{{ object.not_before }}</td></tr> + <tr><td>Not After</td><td>{{ object.not_after }}</td></tr> +</table> + +<h2>Metadata</h2> + +<table class='table table-striped'> + <tr><td>URI</td><td>{{ object.repo.uri }}</td></tr> + <tr><td>Last Modified</td><td>{{ object.mtime_as_datetime|date:"DATETIME_FORMAT" }}</td></tr> +</table> + +<h2>Validation Status</h2> +<table class='table table-striped'> + <thead> + <tr><th>Timestamp</th><th>Generation</th><th>Status</th></tr> + </thead> + <tbody> + {% for status in object.repo.statuses.all %} + <tr class="{{ status.status.get_kind_display }}"><td>{{ status.timestamp }}</td><td>{{ status.get_generation_display }}</td><td>{{ status.status.status }}</td></tr> + {% endfor %} + </tbody> +</table> + +<h2>X.509 Certificate Chain</h2> + +<table class='table table-striped'> + <thead> + <tr><th>Depth</th><th>Name</th></tr> + </thead> + <tbody> + +{% for cert in chain %} +<tr class='{{ cert.1.status_id }}'> + <td>{{ cert.0 }}</td> + <td><a href="{{ cert.1.get_absolute_url }}">{{ cert.1.name }}</a></td> +</tr> +{% endfor %} +</tbody> + +</table> + +{% block detail %}{% endblock %} + +{% endblock %} diff --git a/rpki/gui/cacheview/tests.py b/rpki/gui/cacheview/tests.py new file mode 100644 index 00000000..2247054b --- /dev/null +++ b/rpki/gui/cacheview/tests.py @@ -0,0 +1,23 @@ +""" +This file demonstrates two different styles of tests (one doctest and one +unittest). These will both pass when you run "manage.py test". + +Replace these with more appropriate tests for your application. +""" + +from django.test import TestCase + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.failUnlessEqual(1 + 1, 2) + +__test__ = {"doctest": """ +Another way to test that 1 + 1 is equal to 2. + +>>> 1 + 1 == 2 +True +"""} + diff --git a/rpki/gui/cacheview/urls.py b/rpki/gui/cacheview/urls.py new file mode 100644 index 00000000..cc03a587 --- /dev/null +++ b/rpki/gui/cacheview/urls.py @@ -0,0 +1,32 @@ +# Copyright (C) 2011 SPARTA, Inc. dba Cobham Analytic Solutions +# Copyright (C) 2013 SPARTA, Inc. a Parsons Company +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND SPARTA DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL SPARTA BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +__version__ = '$Id$' + +from django.conf.urls import patterns, url +from rpki.gui.cacheview.views import (CertDetailView, RoaDetailView, + GhostbusterDetailView) + +urlpatterns = patterns('', + url(r'^search$', 'rpki.gui.cacheview.views.search_view', + name='res-search'), + url(r'^cert/(?P<pk>[^/]+)$', CertDetailView.as_view(), name='cert-detail'), + url(r'^gbr/(?P<pk>[^/]+)$', GhostbusterDetailView.as_view(), + name='ghostbuster-detail'), + url(r'^roa/(?P<pk>[^/]+)$', RoaDetailView.as_view(), name='roa-detail'), + (r'^$', 'rpki.gui.cacheview.views.global_summary'), +) + +# vim:sw=4 ts=8 expandtab diff --git a/rpki/gui/cacheview/util.py b/rpki/gui/cacheview/util.py new file mode 100644 index 00000000..0d3d7ae3 --- /dev/null +++ b/rpki/gui/cacheview/util.py @@ -0,0 +1,432 @@ +# Copyright (C) 2011 SPARTA, Inc. dba Cobham +# Copyright (C) 2012, 2013 SPARTA, Inc. a Parsons Company +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND SPARTA DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL SPARTA BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +__version__ = '$Id$' +__all__ = ('import_rcynic_xml') + +default_logfile = '/var/rcynic/data/rcynic.xml' +default_root = '/var/rcynic/data' +object_accepted = None # set by import_rcynic_xml() + +import time +import vobject +import logging +import os +import stat +from socket import getfqdn +from cStringIO import StringIO + +from django.db import transaction +import django.db.models + +import rpki +import rpki.gui.app.timestamp +from rpki.gui.app.models import Conf, Alert +from rpki.gui.cacheview import models +from rpki.rcynic import rcynic_xml_iterator, label_iterator +from rpki.sundial import datetime +from rpki.irdb.zookeeper import Zookeeper + +logger = logging.getLogger(__name__) + + +def rcynic_cert(cert, obj): + obj.sia = cert.sia_directory_uri + + # object must be saved for the related manager methods below to work + obj.save() + + # for the root cert, we can't set inst.issuer = inst until + # after inst.save() has been called. + if obj.issuer is None: + obj.issuer = obj + obj.save() + + # resources can change when a cert is updated + obj.asns.clear() + obj.addresses.clear() + + if cert.resources.asn.inherit: + # FIXME: what happens when the parent's resources change and the child + # cert is not reissued? + obj.asns.add(*obj.issuer.asns.all()) + else: + for asr in cert.resources.asn: + logger.debug('processing %s' % asr) + + attrs = {'min': asr.min, 'max': asr.max} + q = models.ASRange.objects.filter(**attrs) + if not q: + obj.asns.create(**attrs) + else: + obj.asns.add(q[0]) + + # obj.issuer is None the first time we process the root cert in the + # hierarchy, so we need to guard against dereference + for cls, addr_obj, addrset, parentset in ( + models.AddressRange, obj.addresses, cert.resources.v4, + obj.issuer.addresses.all() if obj.issuer else [] + ), ( + models.AddressRangeV6, obj.addresses_v6, cert.resources.v6, + obj.issuer.addresses_v6.all() if obj.issuer else [] + ): + if addrset.inherit: + addr_obj.add(*parentset) + else: + for rng in addrset: + logger.debug('processing %s' % rng) + + attrs = {'prefix_min': rng.min, 'prefix_max': rng.max} + q = cls.objects.filter(**attrs) + if not q: + addr_obj.create(**attrs) + else: + addr_obj.add(q[0]) + + +def rcynic_roa(roa, obj): + obj.asid = roa.asID + # object must be saved for the related manager methods below to work + obj.save() + obj.prefixes.clear() + obj.prefixes_v6.clear() + for pfxset in roa.prefix_sets: + if pfxset.__class__.__name__ == 'roa_prefix_set_ipv6': + roa_cls = models.ROAPrefixV6 + prefix_obj = obj.prefixes_v6 + else: + roa_cls = models.ROAPrefixV4 + prefix_obj = obj.prefixes + + for pfx in pfxset: + attrs = {'prefix_min': pfx.min(), + 'prefix_max': pfx.max(), + 'max_length': pfx.max_prefixlen} + q = roa_cls.objects.filter(**attrs) + if not q: + prefix_obj.create(**attrs) + else: + prefix_obj.add(q[0]) + + +def rcynic_gbr(gbr, obj): + vcard = vobject.readOne(gbr.vcard) + obj.full_name = vcard.fn.value if hasattr(vcard, 'fn') else None + obj.email_address = vcard.email.value if hasattr(vcard, 'email') else None + obj.telephone = vcard.tel.value if hasattr(vcard, 'tel') else None + obj.organization = vcard.org.value[0] if hasattr(vcard, 'org') else None + obj.save() + +LABEL_CACHE = {} + +# dict keeping mapping of uri to (handle, old status, new status) for objects +# published by the local rpkid +uris = {} + +dispatch = { + 'rcynic_certificate': rcynic_cert, + 'rcynic_roa': rcynic_roa, + 'rcynic_ghostbuster': rcynic_gbr +} + +model_class = { + 'rcynic_certificate': models.Cert, + 'rcynic_roa': models.ROA, + 'rcynic_ghostbuster': models.Ghostbuster +} + + +def save_status(repo, vs): + timestamp = datetime.fromXMLtime(vs.timestamp).to_sql() + status = LABEL_CACHE[vs.status] + g = models.generations_dict[vs.generation] if vs.generation else None + repo.statuses.create(generation=g, timestamp=timestamp, status=status) + + # if this object is in our interest set, update with the current validation + # status + if repo.uri in uris: + x, y, z, q = uris[repo.uri] + valid = z or (status is object_accepted) # don't clobber previous True value + uris[repo.uri] = x, y, valid, repo + + if status is not object_accepted: + return + + cls = model_class[vs.file_class.__name__] + # find the instance of the signedobject subclass that is associated with + # this repo instance (may be empty when not accepted) + inst_qs = cls.objects.filter(repo=repo) + + logger.debug('processing %s' % vs.filename) + + if not inst_qs: + inst = cls(repo=repo) + logger.debug('object not found in db, creating new object cls=%s id=%s' % ( + cls, + id(inst) + )) + else: + inst = inst_qs[0] + + try: + # determine if the object is changed/new + mtime = os.stat(vs.filename)[stat.ST_MTIME] + except OSError as e: + logger.error('unable to stat %s: %s %s' % ( + vs.filename, type(e), e)) + # treat as if missing from rcynic.xml + # use inst_qs rather than deleting inst so that we don't raise an + # exception for newly created objects (inst_qs will be empty) + inst_qs.delete() + return + + if mtime != inst.mtime: + inst.mtime = mtime + try: + obj = vs.obj # causes object to be lazily loaded + except Exception, e: + logger.warning('Caught %s while processing %s: %s' % ( + type(e), vs.filename, e)) + return + + inst.not_before = obj.notBefore.to_sql() + inst.not_after = obj.notAfter.to_sql() + inst.name = obj.subject + inst.keyid = obj.ski + + # look up signing cert + if obj.issuer == obj.subject: + # self-signed cert (TA) + assert(isinstance(inst, models.Cert)) + inst.issuer = None + else: + # if an object has moved in the repository, the entry for + # the old location will still be in the database, but + # without any object_accepted in its validtion status + qs = models.Cert.objects.filter( + keyid=obj.aki, + name=obj.issuer, + repo__statuses__status=object_accepted + ) + ncerts = len(qs) + if ncerts == 0: + logger.warning('unable to find signing cert with ski=%s (%s)' % (obj.aki, obj.issuer)) + return + else: + if ncerts > 1: + # multiple matching certs, all of which are valid + logger.warning('Found multiple certs matching ski=%s sn=%s' % (obj.aki, obj.issuer)) + for c in qs: + logger.warning(c.repo.uri) + # just use the first match + inst.issuer = qs[0] + + try: + # do object-specific tasks + dispatch[vs.file_class.__name__](obj, inst) + except: + logger.error('caught exception while processing rcynic_object:\n' + 'vs=' + repr(vs) + '\nobj=' + repr(obj)) + # .show() writes to stdout + obj.show() + raise + + logger.debug('object saved id=%s' % id(inst)) + else: + logger.debug('object is unchanged') + + +@transaction.commit_on_success +def process_cache(root, xml_file): + + last_uri = None + repo = None + + logger.info('clearing validation statuses') + models.ValidationStatus.objects.all().delete() + + logger.info('updating validation status') + for vs in rcynic_xml_iterator(root, xml_file): + if vs.uri != last_uri: + repo, created = models.RepositoryObject.objects.get_or_create(uri=vs.uri) + last_uri = vs.uri + save_status(repo, vs) + + # garbage collection + # remove all objects which have no ValidationStatus references, which + # means they did not appear in the last XML output + logger.info('performing garbage collection') + + # Delete all objects that have zero validation status elements. + models.RepositoryObject.objects.annotate(num_statuses=django.db.models.Count('statuses')).filter(num_statuses=0).delete() + + # Delete all SignedObject instances that were not accepted. There may + # exist rows for objects that were previously accepted. + # See https://trac.rpki.net/ticket/588#comment:30 + # + # We have to do this here rather than in save_status() because the + # <validation_status/> elements are not guaranteed to be consecutive for a + # given URI. see https://trac.rpki.net/ticket/625#comment:5 + models.SignedObject.objects.exclude(repo__statuses__status=object_accepted).delete() + + # ROAPrefixV* objects are M2M so they are not automatically deleted when + # their ROA object disappears + models.ROAPrefixV4.objects.annotate(num_roas=django.db.models.Count('roas')).filter(num_roas=0).delete() + models.ROAPrefixV6.objects.annotate(num_roas=django.db.models.Count('roas')).filter(num_roas=0).delete() + logger.info('done with garbage collection') + + +@transaction.commit_on_success +def process_labels(xml_file): + logger.info('updating labels...') + + for label, kind, desc in label_iterator(xml_file): + logger.debug('label=%s kind=%s desc=%s' % (label, kind, desc)) + if kind: + q = models.ValidationLabel.objects.filter(label=label) + if not q: + obj = models.ValidationLabel(label=label) + else: + obj = q[0] + + obj.kind = models.kinds_dict[kind] + obj.status = desc + obj.save() + + LABEL_CACHE[label] = obj + + +def fetch_published_objects(): + """Query rpkid for all objects published by local users, and look up the + current validation status of each object. The validation status is used + later to send alerts for objects which have transitioned to invalid. + + """ + logger.info('querying for published objects') + + handles = [conf.handle for conf in Conf.objects.all()] + req = [rpki.left_right.list_published_objects_elt.make_pdu(action='list', self_handle=h, tag=h) for h in handles] + z = Zookeeper() + pdus = z.call_rpkid(*req) + for pdu in pdus: + if isinstance(pdu, rpki.left_right.list_published_objects_elt): + # Look up the object in the rcynic cache + qs = models.RepositoryObject.objects.filter(uri=pdu.uri) + if qs: + # get the current validity state + valid = qs[0].statuses.filter(status=object_accepted).exists() + uris[pdu.uri] = (pdu.self_handle, valid, False, None) + logger.debug('adding ' + pdu.uri) + else: + # this object is not in the cache. it was either published + # recently, or disappared previously. if it disappeared + # previously, it has already been alerted. in either case, we + # omit the uri from the list since we are interested only in + # objects which were valid and are no longer valid + pass + elif isinstance(pdu, rpki.left_right.report_error_elt): + logging.error('rpkid reported an error: %s' % pdu.error_code) + + +class Handle(object): + def __init__(self): + self.invalid = [] + self.missing = [] + + def add_invalid(self, v): + self.invalid.append(v) + + def add_missing(self, v): + self.missing.append(v) + + +def notify_invalid(): + """Send email alerts to the addresses registered in ghostbuster records for + any invalid objects that were published by users of this system. + + """ + + logger.info('sending notifications for invalid objects') + + # group invalid objects by user + notify = {} + for uri, v in uris.iteritems(): + handle, old_status, new_status, obj = v + + if obj is None: + # object went missing + n = notify.get(handle, Handle()) + n.add_missing(uri) + # only select valid->invalid + elif old_status and not new_status: + n = notify.get(handle, Handle()) + n.add_invalid(obj) + + for handle, v in notify.iteritems(): + conf = Conf.objects.get(handle) + + msg = StringIO() + msg.write('This is an alert about problems with objects published by ' + 'the resource handle %s.\n\n' % handle) + + if v.invalid: + msg.write('The following objects were previously valid, but are ' + 'now invalid:\n') + + for o in v.invalid: + msg.write('\n') + msg.write(o.repo.uri) + msg.write('\n') + for s in o.statuses.all(): + msg.write('\t') + msg.write(s.status.label) + msg.write(': ') + msg.write(s.status.status) + msg.write('\n') + + if v.missing: + msg.write('The following objects were previously valid but are no ' + 'longer in the cache:\n') + + for o in v.missing: + msg.write(o) + msg.write('\n') + + msg.write("""-- +You are receiving this email because your address is published in a Ghostbuster +record, or is the default email address for this resource holder account on +%s.""" % getfqdn()) + + from_email = 'root@' + getfqdn() + subj = 'invalid RPKI object alert for resource handle %s' % conf.handle + conf.send_alert(subj, msg.getvalue(), from_email, severity=Alert.ERROR) + + +def import_rcynic_xml(root=default_root, logfile=default_logfile): + """Load the contents of rcynic.xml into the rpki.gui.cacheview database.""" + + global object_accepted + + start = time.time() + process_labels(logfile) + object_accepted = LABEL_CACHE['object_accepted'] + fetch_published_objects() + process_cache(root, logfile) + notify_invalid() + + rpki.gui.app.timestamp.update('rcynic_import') + + stop = time.time() + logger.info('elapsed time %d seconds.' % (stop - start)) diff --git a/rpki/gui/cacheview/views.py b/rpki/gui/cacheview/views.py new file mode 100644 index 00000000..94870eb2 --- /dev/null +++ b/rpki/gui/cacheview/views.py @@ -0,0 +1,172 @@ +# Copyright (C) 2011 SPARTA, Inc. dba Cobham Analytic Solutions +# Copyright (C) 2013 SPARTA, Inc. a Parsons Company +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND SPARTA DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL SPARTA BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +# OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +__version__ = '$Id$' + +from django.views.generic import DetailView +from django.shortcuts import render +from django.db.models import F + +from rpki.gui.cacheview import models, forms, misc +from rpki.resource_set import resource_range_as, resource_range_ip +from rpki.POW import IPAddress +from rpki.exceptions import BadIPResource + + +def cert_chain(obj): + """ + returns an iterator covering all certs from the root cert down to the EE. + """ + chain = [obj] + while obj != obj.issuer: + obj = obj.issuer + chain.append(obj) + return zip(range(len(chain)), reversed(chain)) + + +class SignedObjectDetailView(DetailView): + def get_context_data(self, **kwargs): + context = super(SignedObjectDetailView, + self).get_context_data(**kwargs) + context['chain'] = cert_chain(self.object) + return context + + +class RoaDetailView(SignedObjectDetailView): + model = models.ROA + + +class CertDetailView(SignedObjectDetailView): + model = models.Cert + + +class GhostbusterDetailView(SignedObjectDetailView): + model = models.Ghostbuster + + +def search_view(request): + certs = None + roas = None + + if request.method == 'POST': + form = forms.SearchForm2(request.POST, request.FILES) + if form.is_valid(): + resource = form.cleaned_data.get('resource') + # try to determine the type of input given + try: + r = resource_range_as.parse_str(resource) + certs = models.Cert.objects.filter(asns__min__gte=r.min, + asns__max__lte=r.max) + roas = models.ROA.objects.filter(asid__gte=r.min, + asid__lte=r.max) + except: + try: + r = resource_range_ip.parse_str(resource) + if r.version == 4: + certs = models.Cert.objects.filter( + addresses__prefix_min__lte=r.min, + addresses__prefix_max__gte=r.max) + roas = models.ROA.objects.filter( + prefixes__prefix_min__lte=r.min, + prefixes__prefix_max__gte=r.max) + else: + certs = models.Cert.objects.filter( + addresses_v6__prefix_min__lte=r.min, + addresses_v6__prefix_max__gte=r.max) + roas = models.ROA.objects.filter( + prefixes_v6__prefix_min__lte=r.min, + prefixes_v6__prefix_max__gte=r.max) + except BadIPResource: + pass + + return render(request, 'cacheview/search_result.html', + {'resource': resource, 'certs': certs, 'roas': roas}) + + +def cmp_prefix(x, y): + r = cmp(x[0].family, y[0].family) + if r == 0: + r = cmp(x[2], y[2]) # integer address + if r == 0: + r = cmp(x[0].bits, y[0].bits) + if r == 0: + r = cmp(x[0].max_length, y[0].max_length) + if r == 0: + r = cmp(x[1].asid, y[1].asid) + return r + + +#def cmp_prefix(x,y): +# for attr in ('family', 'prefix', 'bits', 'max_length'): +# r = cmp(getattr(x[0], attr), getattr(y[0], attr)) +# if r: +# return r +# return cmp(x[1].asid, y[1].asid) + + +def query_view(request): + """ + Allow the user to search for an AS or prefix, and show all published ROA + information. + """ + + if request.method == 'POST': + form = forms.SearchForm(request.POST, request.FILES) + if form.is_valid(): + certs = None + roas = None + + addr = form.cleaned_data.get('addr') + asn = form.cleaned_data.get('asn') + + if addr: + family, r = misc.parse_ipaddr(addr) + prefixes = models.ROAPrefix.objects.filter(family=family, prefix=str(r.min)) + + prefix_list = [] + for pfx in prefixes: + for roa in pfx.roas.all(): + prefix_list.append((pfx, roa)) + elif asn: + r = resource_range_as.parse_str(asn) + roas = models.ROA.objects.filter(asid__gte=r.min, asid__lte=r.max) + + # display the results sorted by prefix + prefix_list = [] + for roa in roas: + for pfx in roa.prefixes.all(): + addr = IPAddress(pfx.prefix.encode()) + prefix_list.append((pfx, roa, addr)) + prefix_list.sort(cmp=cmp_prefix) + + return render('cacheview/query_result.html', + {'object_list': prefix_list}, request) + else: + form = forms.SearchForm() + + return render('cacheview/search_form.html', { + 'form': form, 'search_type': 'ROA '}, request) + + +def global_summary(request): + """Display a table summarizing the state of the global RPKI.""" + + roots = models.Cert.objects.filter(issuer=F('pk')) # self-signed + + return render(request, 'cacheview/global_summary.html', { + 'roots': roots + }) + +# vim:sw=4 ts=8 expandtab |