aboutsummaryrefslogtreecommitdiff
path: root/rpki/gui/app/views.py
diff options
context:
space:
mode:
Diffstat (limited to 'rpki/gui/app/views.py')
-rw-r--r--rpki/gui/app/views.py1314
1 files changed, 1314 insertions, 0 deletions
diff --git a/rpki/gui/app/views.py b/rpki/gui/app/views.py
new file mode 100644
index 00000000..db4cf0c1
--- /dev/null
+++ b/rpki/gui/app/views.py
@@ -0,0 +1,1314 @@
+# Copyright (C) 2010, 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.
+
+"""
+This module contains the view functions implementing the web portal
+interface.
+
+"""
+
+__version__ = '$Id$'
+
+import os
+import os.path
+from tempfile import NamedTemporaryFile
+import cStringIO
+import csv
+import logging
+
+from django.utils.decorators import method_decorator
+from django.contrib.auth.decorators import login_required
+from django.shortcuts import get_object_or_404, render, redirect
+from django.utils.http import urlquote
+from django import http
+from django.core.urlresolvers import reverse, reverse_lazy
+from django.contrib.auth.models import User
+from django.views.generic import DetailView, ListView, DeleteView
+from django.core.paginator import Paginator, InvalidPage
+from django.forms.formsets import formset_factory, BaseFormSet
+import django.db.models
+from django.contrib import messages
+
+from rpki.irdb import Zookeeper, ChildASN, ChildNet, ROARequestPrefix
+from rpki.gui.app import models, forms, glue, range_list
+from rpki.resource_set import (resource_range_as, resource_range_ip,
+ roa_prefix_ipv4)
+from rpki import sundial
+import rpki.exceptions
+
+from rpki.gui.cacheview.models import ROA
+from rpki.gui.routeview.models import RouteOrigin
+from rpki.gui.decorators import tls_required
+
+logger = logging.getLogger(__name__)
+
+
+def superuser_required(f):
+ """Decorator which returns HttpResponseForbidden if the user does
+ not have superuser permissions.
+
+ """
+ @login_required
+ def _wrapped(request, *args, **kwargs):
+ if not request.user.is_superuser:
+ return http.HttpResponseForbidden()
+ return f(request, *args, **kwargs)
+ return _wrapped
+
+
+def handle_required(f):
+ """Decorator for view functions which require the user to be logged in and
+ a resource handle selected for the session.
+
+ """
+ @login_required
+ @tls_required
+ def wrapped_fn(request, *args, **kwargs):
+ if 'handle' not in request.session:
+ if request.user.is_superuser:
+ conf = models.Conf.objects.all()
+ else:
+ conf = models.Conf.objects.filter(confacl__user=request.user)
+
+ if conf.count() == 1:
+ request.session['handle'] = conf[0]
+ elif conf.count() == 0:
+ return render(request, 'app/conf_empty.html', {})
+ else:
+ url = '%s?next=%s' % (reverse(conf_list),
+ urlquote(request.get_full_path()))
+ return http.HttpResponseRedirect(url)
+
+ return f(request, *args, **kwargs)
+ return wrapped_fn
+
+
+@handle_required
+def generic_import(request, queryset, configure, form_class=None,
+ post_import_redirect=None):
+ """
+ Generic view function for importing XML files used in the setup
+ process.
+
+ queryset
+ queryset containing all objects of the type being imported
+
+ configure
+ method on Zookeeper to invoke with the imported XML file
+
+ form_class
+ specifies the form to use for import. If None, uses the generic
+ forms.ImportForm.
+
+ post_import_redirect
+ if None (default), the user will be redirected to the detail page for
+ the imported object. Otherwise, the user will be redirected to the
+ specified URL.
+
+ """
+ conf = request.session['handle']
+ if form_class is None:
+ form_class = forms.ImportForm
+ if request.method == 'POST':
+ form = form_class(request.POST, request.FILES)
+ if form.is_valid():
+ tmpf = NamedTemporaryFile(prefix='import', suffix='.xml',
+ delete=False)
+ tmpf.write(form.cleaned_data['xml'].read())
+ tmpf.close()
+ z = Zookeeper(handle=conf.handle)
+ handle = form.cleaned_data.get('handle')
+ # CharField uses an empty string for the empty value, rather than
+ # None. Convert to none in this case, since configure_child/parent
+ # expects it.
+ if handle == '':
+ handle = None
+ # configure_repository returns None, so can't use tuple expansion
+ # here. Unpack the tuple below if post_import_redirect is None.
+ r = configure(z, tmpf.name, handle)
+ # force rpkid run now
+ z.synchronize_ca(poke=True)
+ os.remove(tmpf.name)
+ if post_import_redirect:
+ url = post_import_redirect
+ else:
+ _, handle = r
+ url = queryset.get(issuer=conf,
+ handle=handle).get_absolute_url()
+ return http.HttpResponseRedirect(url)
+ else:
+ form = form_class()
+
+ return render(request, 'app/app_form.html', {
+ 'form': form,
+ 'form_title': 'Import ' + queryset.model._meta.verbose_name.capitalize(),
+ })
+
+
+@handle_required
+def dashboard(request):
+ conf = request.session['handle']
+
+ used_asns = range_list.RangeList()
+
+ # asns used in my roas
+ qs = models.ROARequest.objects.filter(issuer=conf)
+ roa_asns = set((obj.asn for obj in qs))
+ used_asns.extend((resource_range_as(asn, asn) for asn in roa_asns))
+
+ # asns given to my children
+ child_asns = ChildASN.objects.filter(child__in=conf.children.all())
+ used_asns.extend((resource_range_as(obj.start_as, obj.end_as) for obj in child_asns))
+
+ # my received asns
+ asns = models.ResourceRangeAS.objects.filter(cert__conf=conf)
+ my_asns = range_list.RangeList([resource_range_as(obj.min, obj.max) for obj in asns])
+
+ unused_asns = my_asns.difference(used_asns)
+
+ used_prefixes = range_list.RangeList()
+ used_prefixes_v6 = range_list.RangeList()
+
+ # prefixes used in my roas
+ for obj in models.ROARequestPrefix.objects.filter(roa_request__issuer=conf,
+ version='IPv4'):
+ used_prefixes.append(obj.as_resource_range())
+
+ for obj in models.ROARequestPrefix.objects.filter(roa_request__issuer=conf,
+ version='IPv6'):
+ used_prefixes_v6.append(obj.as_resource_range())
+
+ # prefixes given to my children
+ for obj in ChildNet.objects.filter(child__in=conf.children.all(),
+ version='IPv4'):
+ used_prefixes.append(obj.as_resource_range())
+
+ for obj in ChildNet.objects.filter(child__in=conf.children.all(),
+ version='IPv6'):
+ used_prefixes_v6.append(obj.as_resource_range())
+
+ # my received prefixes
+ prefixes = models.ResourceRangeAddressV4.objects.filter(cert__conf=conf).all()
+ prefixes_v6 = models.ResourceRangeAddressV6.objects.filter(cert__conf=conf).all()
+ my_prefixes = range_list.RangeList([obj.as_resource_range() for obj in prefixes])
+ my_prefixes_v6 = range_list.RangeList([obj.as_resource_range() for obj in prefixes_v6])
+
+ unused_prefixes = my_prefixes.difference(used_prefixes)
+ # monkey-patch each object with a boolean value indicating whether or not
+ # it is a prefix. We have to do this here because in the template there is
+ # no way to catch the MustBePrefix exception.
+ for x in unused_prefixes:
+ try:
+ x.prefixlen()
+ x.is_prefix = True
+ except rpki.exceptions.MustBePrefix:
+ x.is_prefix = False
+
+ unused_prefixes_v6 = my_prefixes_v6.difference(used_prefixes_v6)
+ for x in unused_prefixes_v6:
+ try:
+ x.prefixlen()
+ x.is_prefix = True
+ except rpki.exceptions.MustBePrefix:
+ x.is_prefix = False
+
+ clients = models.Client.objects.all() if request.user.is_superuser else None
+
+ return render(request, 'app/dashboard.html', {
+ 'conf': conf,
+ 'unused_asns': unused_asns,
+ 'unused_prefixes': unused_prefixes,
+ 'unused_prefixes_v6': unused_prefixes_v6,
+ 'asns': asns,
+ 'prefixes': prefixes,
+ 'prefixes_v6': prefixes_v6,
+ 'clients': clients,
+ })
+
+
+@login_required
+def conf_list(request, **kwargs):
+ """Allow the user to select a handle."""
+ log = request.META['wsgi.errors']
+ next_url = request.GET.get('next', reverse(dashboard))
+ if request.user.is_superuser:
+ qs = models.Conf.objects.all()
+ else:
+ qs = models.Conf.objects.filter(confacl__user=request.user)
+ return render(request, 'app/conf_list.html', {
+ 'conf_list': qs,
+ 'next_url': next_url
+ })
+
+
+@login_required
+def conf_select(request):
+ """Change the handle for the current session."""
+ if not 'handle' in request.GET:
+ return redirect(conf_list)
+ handle = request.GET['handle']
+ next_url = request.GET.get('next', reverse(dashboard))
+ if request.user.is_superuser:
+ request.session['handle'] = get_object_or_404(models.Conf, handle=handle)
+ else:
+ request.session['handle'] = get_object_or_404(
+ models.Conf, confacl__user=request.user, handle=handle
+ )
+ return http.HttpResponseRedirect(next_url)
+
+
+def serve_xml(content, basename, ext='xml'):
+ """
+ Generate a HttpResponse object with the content type set to XML.
+
+ `content` is a string.
+
+ `basename` is the prefix to specify for the XML filename.
+
+ `csv` is the type (default: xml)
+
+ """
+ resp = http.HttpResponse(content, mimetype='application/%s' % ext)
+ resp['Content-Disposition'] = 'attachment; filename=%s.%s' % (basename, ext)
+ return resp
+
+
+@handle_required
+def conf_export(request):
+ """Return the identity.xml for the current handle."""
+ conf = request.session['handle']
+ z = Zookeeper(handle=conf.handle)
+ xml = z.generate_identity()
+ return serve_xml(str(xml), '%s.identity' % conf.handle)
+
+
+@handle_required
+def export_asns(request):
+ """Export CSV file containing ASN allocations to children."""
+ conf = request.session['handle']
+ s = cStringIO.StringIO()
+ csv_writer = csv.writer(s, delimiter=' ')
+ for childasn in ChildASN.objects.filter(child__issuer=conf):
+ csv_writer.writerow([childasn.child.handle, str(childasn.as_resource_range())])
+ return serve_xml(s.getvalue(), '%s.asns' % conf.handle, ext='csv')
+
+
+@handle_required
+def import_asns(request):
+ conf = request.session['handle']
+ if request.method == 'POST':
+ form = forms.ImportCSVForm(request.POST, request.FILES)
+ if form.is_valid():
+ f = NamedTemporaryFile(prefix='asns', suffix='.csv', delete=False)
+ f.write(request.FILES['csv'].read())
+ f.close()
+ z = Zookeeper(handle=conf.handle)
+ z.load_asns(f.name)
+ z.run_rpkid_now()
+ os.unlink(f.name)
+ messages.success(request, 'Successfully imported AS delgations from CSV file.')
+ return redirect(dashboard)
+ else:
+ form = forms.ImportCSVForm()
+ return render(request, 'app/import_resource_form.html', {
+ 'form_title': 'Import CSV containing ASN delegations',
+ 'form': form,
+ 'cancel_url': reverse(dashboard)
+ })
+
+
+@handle_required
+def export_prefixes(request):
+ """Export CSV file containing ASN allocations to children."""
+ conf = request.session['handle']
+ s = cStringIO.StringIO()
+ csv_writer = csv.writer(s, delimiter=' ')
+ for childnet in ChildNet.objects.filter(child__issuer=conf):
+ csv_writer.writerow([childnet.child.handle, str(childnet.as_resource_range())])
+ return serve_xml(s.getvalue(), '%s.prefixes' % conf.handle, ext='csv')
+
+
+@handle_required
+def import_prefixes(request):
+ conf = request.session['handle']
+ if request.method == 'POST':
+ form = forms.ImportCSVForm(request.POST, request.FILES)
+ if form.is_valid():
+ f = NamedTemporaryFile(prefix='prefixes', suffix='.csv', delete=False)
+ f.write(request.FILES['csv'].read())
+ f.close()
+ z = Zookeeper(handle=conf.handle)
+ z.load_prefixes(f.name)
+ z.run_rpkid_now()
+ os.unlink(f.name)
+ messages.success(request, 'Successfully imported prefix delegations from CSV file.')
+ return redirect(dashboard)
+ else:
+ form = forms.ImportCSVForm()
+ return render(request, 'app/import_resource_form.html', {
+ 'form_title': 'Import CSV containing Prefix delegations',
+ 'form': form,
+ 'cancel_url': reverse(dashboard)
+ })
+
+
+@handle_required
+def parent_import(request):
+ conf = request.session['handle']
+ return generic_import(request, conf.parents, Zookeeper.configure_parent)
+
+
+@handle_required
+def parent_detail(request, pk):
+ return render(request, 'app/parent_detail.html', {
+ 'object': get_object_or_404(request.session['handle'].parents, pk=pk)})
+
+
+@handle_required
+def parent_delete(request, pk):
+ conf = request.session['handle']
+ obj = get_object_or_404(conf.parents, pk=pk) # confirm permission
+ log = request.META['wsgi.errors']
+ if request.method == 'POST':
+ form = forms.Empty(request.POST, request.FILES)
+ if form.is_valid():
+ z = Zookeeper(handle=conf.handle, logstream=log)
+ z.delete_parent(obj.handle)
+ z.synchronize_ca()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.Empty()
+ return render(request, 'app/object_confirm_delete.html', {
+ 'object': obj,
+ 'form': form,
+ 'parent_template': 'app/parent_detail.html'
+ })
+
+
+@handle_required
+def parent_export(request, pk):
+ """Export XML repository request for a given parent."""
+ conf = request.session['handle']
+ parent = get_object_or_404(conf.parents, pk=pk)
+ z = Zookeeper(handle=conf.handle)
+ xml = z.generate_repository_request(parent)
+ return serve_xml(str(xml), '%s.repository' % parent.handle)
+
+
+@handle_required
+def child_import(request):
+ conf = request.session['handle']
+ return generic_import(request, conf.children, Zookeeper.configure_child)
+
+
+@handle_required
+def child_add_prefix(request, pk):
+ logstream = request.META['wsgi.errors']
+ conf = request.session['handle']
+ child = get_object_or_404(conf.children, pk=pk)
+ if request.method == 'POST':
+ form = forms.AddNetForm(request.POST, child=child)
+ if form.is_valid():
+ address_range = form.cleaned_data.get('address_range')
+ r = resource_range_ip.parse_str(address_range)
+ version = 'IPv%d' % r.version
+ child.address_ranges.create(start_ip=str(r.min), end_ip=str(r.max),
+ version=version)
+ Zookeeper(handle=conf.handle, logstream=logstream).run_rpkid_now()
+ return http.HttpResponseRedirect(child.get_absolute_url())
+ else:
+ form = forms.AddNetForm(child=child)
+ return render(request, 'app/app_form.html',
+ {'object': child, 'form': form, 'form_title': 'Add Prefix'})
+
+
+@handle_required
+def child_add_asn(request, pk):
+ logstream = request.META['wsgi.errors']
+ conf = request.session['handle']
+ child = get_object_or_404(conf.children, pk=pk)
+ if request.method == 'POST':
+ form = forms.AddASNForm(request.POST, child=child)
+ if form.is_valid():
+ asns = form.cleaned_data.get('asns')
+ r = resource_range_as.parse_str(asns)
+ child.asns.create(start_as=r.min, end_as=r.max)
+ Zookeeper(handle=conf.handle, logstream=logstream).run_rpkid_now()
+ return http.HttpResponseRedirect(child.get_absolute_url())
+ else:
+ form = forms.AddASNForm(child=child)
+ return render(request, 'app/app_form.html',
+ {'object': child, 'form': form, 'form_title': 'Add ASN'})
+
+
+@handle_required
+def child_detail(request, pk):
+ child = get_object_or_404(request.session['handle'].children, pk=pk)
+ return render(request, 'app/child_detail.html', {'object': child})
+
+
+@handle_required
+def child_edit(request, pk):
+ """Edit the end validity date for a resource handle's child."""
+ log = request.META['wsgi.errors']
+ conf = request.session['handle']
+ child = get_object_or_404(conf.children.all(), pk=pk)
+ form_class = forms.ChildForm(child)
+ if request.method == 'POST':
+ form = form_class(request.POST, request.FILES)
+ if form.is_valid():
+ child.valid_until = sundial.datetime.from_datetime(form.cleaned_data.get('valid_until'))
+ child.save()
+ # remove AS & prefixes that are not selected in the form
+ models.ChildASN.objects.filter(child=child).exclude(pk__in=form.cleaned_data.get('as_ranges')).delete()
+ models.ChildNet.objects.filter(child=child).exclude(pk__in=form.cleaned_data.get('address_ranges')).delete()
+ Zookeeper(handle=conf.handle, logstream=log).run_rpkid_now()
+ return http.HttpResponseRedirect(child.get_absolute_url())
+ else:
+ form = form_class(initial={
+ 'as_ranges': child.asns.all(),
+ 'address_ranges': child.address_ranges.all()})
+
+ return render(request, 'app/app_form.html', {
+ 'object': child,
+ 'form': form,
+ 'form_title': 'Edit Child: ' + child.handle,
+ })
+
+
+@handle_required
+def child_response(request, pk):
+ """
+ Export the XML file containing the output of the configure_child
+ to send back to the client.
+
+ """
+ conf = request.session['handle']
+ child = get_object_or_404(models.Child, issuer=conf, pk=pk)
+ z = Zookeeper(handle=conf.handle)
+ xml = z.generate_parental_response(child)
+ resp = serve_xml(str(xml), child.handle)
+ return resp
+
+
+@handle_required
+def child_delete(request, pk):
+ logstream = request.META['wsgi.errors']
+ conf = request.session['handle']
+ child = get_object_or_404(conf.children, pk=pk)
+ if request.method == 'POST':
+ form = forms.Empty(request.POST)
+ if form.is_valid():
+ z = Zookeeper(handle=conf.handle, logstream=logstream)
+ z.delete_child(child.handle)
+ z.synchronize_ca()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.Empty()
+ return render(request, 'app/object_confirm_delete.html', {
+ 'object': child,
+ 'form': form,
+ 'parent_template': 'app/child_detail.html'
+ })
+
+
+@handle_required
+def roa_detail(request, pk):
+ conf = request.session['handle']
+ obj = get_object_or_404(conf.roas, pk=pk)
+ return render(request, 'app/roa_detail.html', {'object': obj})
+
+
+def get_covered_routes(rng, max_prefixlen, asn):
+ """Returns a list of routeview.models.RouteOrigin objects which would
+ change validation status if a ROA were created with the parameters to this
+ function.
+
+ A "newstatus" attribute is monkey-patched on the RouteOrigin objects which
+ can be used in the template. "status" remains the current validation
+ status of the object.
+
+ """
+
+ # find all routes that match or are completed covered by the proposed new roa
+ qs = RouteOrigin.objects.filter(
+ prefix_min__gte=rng.min,
+ prefix_max__lte=rng.max
+ )
+ routes = []
+ for route in qs:
+ status = route.status
+ # tweak the validation status due to the presence of the
+ # new ROA. Don't need to check the prefix bounds here
+ # because all the matches routes will be covered by this
+ # new ROA
+ if status == 'unknown':
+ # if the route was previously unknown (no covering
+ # ROAs), then:
+ # if the AS matches, it is valid, otherwise invalid
+ if (route.asn != 0 and route.asn == asn and route.prefixlen <= max_prefixlen):
+ route.newstatus = 'valid'
+ else:
+ route.newstatus = 'invalid'
+ routes.append(route)
+ elif status == 'invalid':
+ # if the route was previously invalid, but this new ROA
+ # matches the ASN, it is now valid
+ if route.asn != 0 and route.asn == asn and route.prefixlen <= max_prefixlen:
+ route.newstatus = 'valid'
+ routes.append(route)
+
+ return routes
+
+
+@handle_required
+def roa_create(request):
+ """Present the user with a form to create a ROA.
+
+ Doesn't use the generic create_object() form because we need to
+ create both the ROARequest and ROARequestPrefix objects.
+
+ """
+
+ conf = request.session['handle']
+ if request.method == 'POST':
+ form = forms.ROARequest(request.POST, request.FILES, conf=conf)
+ if form.is_valid():
+ asn = form.cleaned_data.get('asn')
+ rng = form._as_resource_range() # FIXME calling "private" method
+ max_prefixlen = int(form.cleaned_data.get('max_prefixlen'))
+
+ routes = get_covered_routes(rng, max_prefixlen, asn)
+
+ prefix = str(rng)
+ form = forms.ROARequestConfirm(initial={'asn': asn,
+ 'prefix': prefix,
+ 'max_prefixlen': max_prefixlen})
+ return render(request, 'app/roarequest_confirm_form.html',
+ {'form': form,
+ 'asn': asn,
+ 'prefix': prefix,
+ 'max_prefixlen': max_prefixlen,
+ 'routes': routes})
+ else:
+ # pull initial values from query parameters
+ d = {}
+ for s in ('asn', 'prefix'):
+ if s in request.GET:
+ d[s] = request.GET[s]
+ form = forms.ROARequest(initial=d)
+
+ return render(request, 'app/roarequest_form.html', {'form': form})
+
+
+class ROARequestFormSet(BaseFormSet):
+ """There is no way to pass arbitrary keyword arguments to the form
+ constructor, so we have to override BaseFormSet to allow it.
+
+ """
+ def __init__(self, *args, **kwargs):
+ self.conf = kwargs.pop('conf')
+ super(ROARequestFormSet, self).__init__(*args, **kwargs)
+
+ def _construct_forms(self):
+ self.forms = []
+ for i in xrange(self.total_form_count()):
+ self.forms.append(self._construct_form(i, conf=self.conf))
+
+
+def split_with_default(s):
+ xs = s.split(',')
+ if len(xs) == 1:
+ return xs[0], None
+ return xs
+
+
+@handle_required
+def roa_create_multi(request):
+ """version of roa_create that uses a formset to allow entry of multiple
+ roas on a single page.
+
+ ROAs can be specified in the GET query string, as such:
+
+ ?roa=prefix,asn
+
+ Mulitple ROAs may be specified:
+
+ ?roa=prefix,asn+roa=prefix2,asn2
+
+ If an IP range is specified, it will be automatically split into multiple
+ prefixes:
+
+ ?roa=1.1.1.1-2.2.2.2,42
+
+ The ASN may optionally be omitted.
+
+ """
+
+ conf = request.session['handle']
+ if request.method == 'GET':
+ init = []
+ for x in request.GET.getlist('roa'):
+ rng, asn = split_with_default(x)
+ rng = resource_range_ip.parse_str(rng)
+ if rng.can_be_prefix:
+ init.append({'asn': asn, 'prefix': str(rng)})
+ else:
+ v = []
+ rng.chop_into_prefixes(v)
+ init.extend([{'asn': asn, 'prefix': str(p)} for p in v])
+ formset = formset_factory(forms.ROARequest, formset=ROARequestFormSet,
+ can_delete=True)(initial=init, conf=conf)
+ elif request.method == 'POST':
+ formset = formset_factory(forms.ROARequest, formset=ROARequestFormSet,
+ extra=0, can_delete=True)(request.POST, request.FILES, conf=conf)
+ if formset.is_valid():
+ routes = []
+ v = []
+ # as of Django 1.4.5 we still can't use formset.cleaned_data
+ # because deleted forms are not excluded, which causes an
+ # AttributeError to be raised.
+ for form in formset:
+ if hasattr(form, 'cleaned_data') and form.cleaned_data: # exclude empty forms
+ asn = form.cleaned_data.get('asn')
+ rng = resource_range_ip.parse_str(form.cleaned_data.get('prefix'))
+ max_prefixlen = int(form.cleaned_data.get('max_prefixlen'))
+ # FIXME: This won't do the right thing in the event that a
+ # route is covered by multiple ROAs created in the form.
+ # You will see duplicate entries, each with a potentially
+ # different validation status.
+ routes.extend(get_covered_routes(rng, max_prefixlen, asn))
+ v.append({'prefix': str(rng), 'max_prefixlen': max_prefixlen,
+ 'asn': asn})
+ # if there were no rows, skip the confirmation step
+ if v:
+ formset = formset_factory(forms.ROARequestConfirm, extra=0)(initial=v)
+ return render(request, 'app/roarequest_confirm_multi_form.html',
+ {'routes': routes, 'formset': formset, 'roas': v})
+ return render(request, 'app/roarequest_multi_form.html',
+ {'formset': formset})
+
+
+@handle_required
+def roa_create_confirm(request):
+ """This function is called when the user confirms the creation of a ROA
+ request. It is responsible for updating the IRDB.
+
+ """
+ conf = request.session['handle']
+ log = request.META['wsgi.errors']
+ if request.method == 'POST':
+ form = forms.ROARequestConfirm(request.POST, request.FILES)
+ if form.is_valid():
+ asn = form.cleaned_data.get('asn')
+ prefix = form.cleaned_data.get('prefix')
+ rng = resource_range_ip.parse_str(prefix)
+ max_prefixlen = form.cleaned_data.get('max_prefixlen')
+ # Always create ROA requests with a single prefix.
+ # https://trac.rpki.net/ticket/32
+ roa = models.ROARequest.objects.create(issuer=conf, asn=asn)
+ v = 'IPv%d' % rng.version
+ roa.prefixes.create(version=v, prefix=str(rng.min),
+ prefixlen=rng.prefixlen(),
+ max_prefixlen=max_prefixlen)
+ Zookeeper(handle=conf.handle, logstream=log).run_rpkid_now()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ # What should happen when the submission form isn't valid? For now
+ # just fall through and redirect back to the ROA creation form
+ return http.HttpResponseRedirect(reverse(roa_create))
+
+
+@handle_required
+def roa_create_multi_confirm(request):
+ """This function is called when the user confirms the creation of a ROA
+ request. It is responsible for updating the IRDB.
+
+ """
+ conf = request.session['handle']
+ log = request.META['wsgi.errors']
+ if request.method == 'POST':
+ formset = formset_factory(forms.ROARequestConfirm, extra=0)(request.POST, request.FILES)
+ if formset.is_valid():
+ for cleaned_data in formset.cleaned_data:
+ asn = cleaned_data.get('asn')
+ prefix = cleaned_data.get('prefix')
+ rng = resource_range_ip.parse_str(prefix)
+ max_prefixlen = cleaned_data.get('max_prefixlen')
+ # Always create ROA requests with a single prefix.
+ # https://trac.rpki.net/ticket/32
+ roa = models.ROARequest.objects.create(issuer=conf, asn=asn)
+ v = 'IPv%d' % rng.version
+ roa.prefixes.create(version=v, prefix=str(rng.min),
+ prefixlen=rng.prefixlen(),
+ max_prefixlen=max_prefixlen)
+ Zookeeper(handle=conf.handle, logstream=log).run_rpkid_now()
+ return redirect(dashboard)
+ # What should happen when the submission form isn't valid? For now
+ # just fall through and redirect back to the ROA creation form
+ return http.HttpResponseRedirect(reverse(roa_create_multi))
+
+
+@handle_required
+def roa_delete(request, pk):
+ """Handles deletion of a single ROARequest object.
+
+ Uses a form for double confirmation, displaying how the route
+ validation status may change as a result.
+
+ """
+
+ conf = request.session['handle']
+ roa = get_object_or_404(conf.roas, pk=pk)
+ if request.method == 'POST':
+ roa.delete()
+ Zookeeper(handle=conf.handle).run_rpkid_now()
+ return redirect(reverse(dashboard))
+
+ ### Process GET ###
+
+ # note: assumes we only generate one prefix per ROA
+ roa_prefix = roa.prefixes.all()[0]
+ rng = roa_prefix.as_resource_range()
+
+ routes = []
+ for route in roa.routes:
+ # select all roas which cover this route
+ # excluding the current roa
+ # note: we can't identify the exact ROA here, because we only know what
+ # was requested to rpkid
+ roas = route.roas.exclude(
+ asid=roa.asn,
+ prefixes__prefix_min=rng.min,
+ prefixes__prefix_max=rng.max,
+ prefixes__max_length=roa_prefix.max_prefixlen
+ )
+
+ # subselect exact match
+ if route.asn != 0 and roas.filter(asid=route.asn,
+ prefixes__max_length__gte=route.prefixlen).exists():
+ route.newstatus = 'valid'
+ elif roas.exists():
+ route.newstatus = 'invalid'
+ else:
+ route.newstatus = 'unknown'
+ # we may want to ignore routes for which there is no status change,
+ # but the user may want to see that nothing has changed explicitly
+ routes.append(route)
+
+ return render(request, 'app/roarequest_confirm_delete.html',
+ {'object': roa, 'routes': routes})
+
+
+@handle_required
+def roa_clone(request, pk):
+ conf = request.session['handle']
+ roa = get_object_or_404(conf.roas, pk=pk)
+ return redirect(
+ reverse(roa_create_multi) + "?roa=" + str(roa.prefixes.all()[0].as_roa_prefix())
+ )
+
+
+@handle_required
+def roa_import(request):
+ """Import CSV containing ROA declarations."""
+ if request.method == 'POST':
+ form = forms.ImportCSVForm(request.POST, request.FILES)
+ if form.is_valid():
+ import tempfile
+ tmp = tempfile.NamedTemporaryFile(suffix='.csv', prefix='roas', delete=False)
+ tmp.write(request.FILES['csv'].read())
+ tmp.close()
+ z = Zookeeper(handle=request.session['handle'])
+ z.load_roa_requests(tmp.name)
+ z.run_rpkid_now()
+ os.unlink(tmp.name)
+ messages.success(request, 'Successfully imported ROAs.')
+ return redirect(dashboard)
+ else:
+ form = forms.ImportCSVForm()
+ return render(request, 'app/import_resource_form.html', {
+ 'form_title': 'Import ROAs from CSV',
+ 'form': form,
+ 'cancel_url': reverse(dashboard)
+ })
+
+
+@handle_required
+def roa_export(request):
+ """Export CSV containing ROA declarations."""
+ # FIXME: remove when Zookeeper can do this
+ f = cStringIO.StringIO()
+ csv_writer = csv.writer(f, delimiter=' ')
+ conf = request.session['handle']
+ # each roa prefix gets a unique group so rpkid will issue separate roas
+ for group, roapfx in enumerate(ROARequestPrefix.objects.filter(roa_request__issuer=conf)):
+ csv_writer.writerow([str(roapfx.as_roa_prefix()), roapfx.roa_request.asn, '%s-%d' % (conf.handle, group)])
+ resp = http.HttpResponse(f.getvalue(), mimetype='application/csv')
+ resp['Content-Disposition'] = 'attachment; filename=roas.csv'
+ return resp
+
+
+class GhostbusterDetailView(DetailView):
+ def get_queryset(self):
+ return self.request.session['handle'].ghostbusters
+
+
+@handle_required
+def ghostbuster_delete(request, pk):
+ conf = request.session['handle']
+ logstream = request.META['wsgi.errors']
+ obj = get_object_or_404(conf.ghostbusters, pk=pk)
+ if request.method == 'POST':
+ form = forms.Empty(request.POST, request.FILES)
+ if form.is_valid():
+ obj.delete()
+ Zookeeper(handle=conf.handle, logstream=logstream).run_rpkid_now()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.Empty(request.POST, request.FILES)
+ return render(request, 'app/object_confirm_delete.html', {
+ 'object': obj,
+ 'form': form,
+ 'parent_template': 'app/ghostbusterrequest_detail.html'
+ })
+
+
+@handle_required
+def ghostbuster_create(request):
+ conf = request.session['handle']
+ logstream = request.META['wsgi.errors']
+ if request.method == 'POST':
+ form = forms.GhostbusterRequestForm(request.POST, request.FILES,
+ conf=conf)
+ if form.is_valid():
+ obj = form.save(commit=False)
+ obj.vcard = glue.ghostbuster_to_vcard(obj)
+ obj.save()
+ Zookeeper(handle=conf.handle, logstream=logstream).run_rpkid_now()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.GhostbusterRequestForm(conf=conf)
+ return render(request, 'app/app_form.html',
+ {'form': form, 'form_title': 'New Ghostbuster Request'})
+
+
+@handle_required
+def ghostbuster_edit(request, pk):
+ conf = request.session['handle']
+ obj = get_object_or_404(conf.ghostbusters, pk=pk)
+ logstream = request.META['wsgi.errors']
+ if request.method == 'POST':
+ form = forms.GhostbusterRequestForm(request.POST, request.FILES,
+ conf=conf, instance=obj)
+ if form.is_valid():
+ obj = form.save(commit=False)
+ obj.vcard = glue.ghostbuster_to_vcard(obj)
+ obj.save()
+ Zookeeper(handle=conf.handle, logstream=logstream).run_rpkid_now()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.GhostbusterRequestForm(conf=conf, instance=obj)
+ return render(request, 'app/app_form.html',
+ {'form': form, 'form_title': 'Edit Ghostbuster Request'})
+
+
+@handle_required
+def refresh(request):
+ """
+ Query rpkid, update the db, and redirect back to the dashboard.
+
+ """
+ glue.list_received_resources(request.META['wsgi.errors'],
+ request.session['handle'])
+ return http.HttpResponseRedirect(reverse(dashboard))
+
+
+@handle_required
+def route_view(request):
+ """
+ Display a list of global routing table entries which match resources
+ listed in received certificates.
+
+ """
+ conf = request.session['handle']
+ count = request.GET.get('count', 25)
+ page = request.GET.get('page', 1)
+
+ paginator = Paginator(conf.routes, count)
+ try:
+ routes = paginator.page(page)
+ except InvalidPage:
+ # page was empty, or page number was invalid
+ routes = []
+ ts = dict((attr['name'], attr['ts']) for attr in models.Timestamp.objects.values())
+ return render(request, 'app/routes_view.html',
+ {'routes': routes, 'timestamp': ts})
+
+
+def route_detail(request, pk):
+ """Show a list of ROAs that match a given IPv4 route."""
+ route = get_object_or_404(models.RouteOrigin, pk=pk)
+ # when running rootd, viewing the 0.0.0.0/0 route will cause a fetch of all
+ # roas, so we paginate here, even though in the general case the number of
+ # objects will be small enough to fit a single page
+ count = request.GET.get('count', 25)
+ page = request.GET.get('page', 1)
+ paginator = Paginator(route.roa_prefixes.all(), count)
+ return render(request, 'app/route_detail.html', {
+ 'object': route,
+ 'roa_prefixes': paginator.page(page),
+ })
+
+
+def route_suggest(request):
+ """Handles POSTs from the route view and redirects to the ROA creation
+ page based on selected route objects. The form should contain elements of
+ the form "pk-NUM" where NUM is the RouteOrigin object id.
+
+ """
+ if request.method == 'POST':
+ routes = []
+ for pk in request.POST.iterkeys():
+ logger.debug(pk)
+ if pk.startswith("pk-"):
+ n = int(pk[3:])
+ routes.append(n)
+ qs = RouteOrigin.objects.filter(pk__in=routes)
+ s = []
+ for r in qs:
+ s.append('roa=%s/%d,%d' % (str(r.prefix_min), r.prefixlen, r.asn))
+ p = '&'.join(s)
+ return redirect(reverse(roa_create_multi) + '?' + p)
+
+
+@handle_required
+def repository_detail(request, pk):
+ conf = request.session['handle']
+ return render(request,
+ 'app/repository_detail.html',
+ {'object': get_object_or_404(conf.repositories, pk=pk)})
+
+
+@handle_required
+def repository_delete(request, pk):
+ log = request.META['wsgi.errors']
+ conf = request.session['handle']
+ # Ensure the repository being deleted belongs to the current user.
+ obj = get_object_or_404(models.Repository, issuer=conf, pk=pk)
+ if request.method == 'POST':
+ form = forms.Empty(request.POST, request.FILES)
+ if form.is_valid():
+ z = Zookeeper(handle=conf.handle, logstream=log)
+ z.delete_repository(obj.handle)
+ z.synchronize_ca()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.Empty()
+ return render(request, 'app/object_confirm_delete.html', {
+ 'object': obj,
+ 'form': form,
+ 'parent_template':
+ 'app/repository_detail.html',
+ })
+
+
+@handle_required
+def repository_import(request):
+ """Import XML response file from repository operator."""
+ return generic_import(request,
+ models.Repository.objects,
+ Zookeeper.configure_repository,
+ form_class=forms.ImportRepositoryForm,
+ post_import_redirect=reverse(dashboard))
+
+
+@superuser_required
+def client_list(request):
+ """display a list of all repository client (irdb.models.Client)"""
+
+ return render(request, 'app/client_list.html', {
+ 'object_list': models.Client.objects.all()
+ })
+
+
+@superuser_required
+def client_detail(request, pk):
+ return render(request, 'app/client_detail.html',
+ {'object': get_object_or_404(models.Client, pk=pk)})
+
+
+@superuser_required
+def client_delete(request, pk):
+ log = request.META['wsgi.errors']
+ obj = get_object_or_404(models.Client, pk=pk)
+ if request.method == 'POST':
+ form = forms.Empty(request.POST, request.FILES)
+ if form.is_valid():
+ z = Zookeeper(logstream=log)
+ z.delete_publication_client(obj.handle)
+ z.synchronize_pubd()
+ return http.HttpResponseRedirect(reverse(dashboard))
+ else:
+ form = forms.Empty()
+ return render(request, 'app/object_confirm_delete.html', {
+ 'object': obj,
+ 'form': form,
+ 'parent_template': 'app/client_detail.html'
+ })
+
+
+@superuser_required
+def client_import(request):
+ return generic_import(request, models.Client.objects,
+ Zookeeper.configure_publication_client,
+ form_class=forms.ImportClientForm,
+ post_import_redirect=reverse(dashboard))
+
+
+@superuser_required
+def client_export(request, pk):
+ """Return the XML file resulting from a configure_publication_client
+ request.
+
+ """
+ client = get_object_or_404(models.Client, pk=pk)
+ z = Zookeeper()
+ xml = z.generate_repository_response(client)
+ return serve_xml(str(xml), '%s.repo' % z.handle)
+
+
+### Routines for managing resource handles serviced by this server
+
+@superuser_required
+def resource_holder_list(request):
+ """Display a list of all the RPKI handles managed by this server."""
+ return render(request, 'app/resource_holder_list.html', {
+ 'object_list': models.Conf.objects.all()
+ })
+
+
+@superuser_required
+def resource_holder_edit(request, pk):
+ """Display a list of all the RPKI handles managed by this server."""
+ conf = get_object_or_404(models.Conf, pk=pk)
+ if request.method == 'POST':
+ form = forms.ResourceHolderForm(request.POST, request.FILES)
+ if form.is_valid():
+ models.ConfACL.objects.filter(conf=conf).delete()
+ for user in form.cleaned_data.get('users'):
+ models.ConfACL.objects.create(user=user, conf=conf)
+ return redirect(resource_holder_list)
+ else:
+ users = [acl.user for acl in models.ConfACL.objects.filter(conf=conf).all()]
+ form = forms.ResourceHolderForm(initial={
+ 'users': users
+ })
+ return render(request, 'app/app_form.html', {
+ 'form_title': "Edit Resource Holder: " + conf.handle,
+ 'form': form,
+ 'cancel_url': reverse(resource_holder_list)
+ })
+
+
+@superuser_required
+def resource_holder_delete(request, pk):
+ conf = get_object_or_404(models.Conf, pk=pk)
+ log = request.META['wsgi.errors']
+ if request.method == 'POST':
+ form = forms.Empty(request.POST)
+ if form.is_valid():
+ z = Zookeeper(handle=conf.handle, logstream=log)
+ z.delete_self()
+ z.synchronize_deleted_ca()
+ return redirect(resource_holder_list)
+ else:
+ form = forms.Empty()
+ return render(request, 'app/app_confirm_delete.html', {
+ 'form_title': 'Delete Resource Holder: ' + conf.handle,
+ 'form': form,
+ 'cancel_url': reverse(resource_holder_list)
+ })
+
+
+@superuser_required
+def resource_holder_create(request):
+ log = request.META['wsgi.errors']
+ if request.method == 'POST':
+ form = forms.ResourceHolderCreateForm(request.POST, request.FILES)
+ if form.is_valid():
+ handle = form.cleaned_data.get('handle')
+ parent = form.cleaned_data.get('parent')
+
+ zk_child = Zookeeper(handle=handle, logstream=log)
+ identity_xml = zk_child.initialize_resource_bpki()
+ if parent:
+ # FIXME etree_wrapper should allow us to deal with file objects
+ t = NamedTemporaryFile(delete=False)
+ t.close()
+
+ identity_xml.save(t.name)
+ zk_parent = Zookeeper(handle=parent.handle, logstream=log)
+ parent_response, _ = zk_parent.configure_child(t.name)
+ parent_response.save(t.name)
+ zk_parent.synchronize_ca()
+ repo_req, _ = zk_child.configure_parent(t.name)
+ repo_req.save(t.name)
+ repo_resp, _ = zk_parent.configure_publication_client(t.name)
+ repo_resp.save(t.name)
+ zk_parent.synchronize_pubd()
+ zk_child.configure_repository(t.name)
+ os.remove(t.name)
+ zk_child.synchronize_ca()
+ return redirect(resource_holder_list)
+ else:
+ form = forms.ResourceHolderCreateForm()
+ return render(request, 'app/app_form.html', {
+ 'form': form,
+ 'form_title': 'Create Resource Holder',
+ 'cancel_url': reverse(resource_holder_list)
+ })
+
+
+### views for managing user logins to the web interface
+
+@superuser_required
+def user_create(request):
+ if request.method == 'POST':
+ form = forms.UserCreateForm(request.POST, request.FILES)
+ if form.is_valid():
+ username = form.cleaned_data.get('username')
+ pw = form.cleaned_data.get('password')
+ email = form.cleaned_data.get('email')
+ user = User.objects.create_user(username, email, pw)
+ for conf in form.cleaned_data.get('resource_holders'):
+ models.ConfACL.objects.create(user=user, conf=conf)
+ return redirect(user_list)
+ else:
+ form = forms.UserCreateForm()
+
+ return render(request, 'app/app_form.html', {
+ 'form': form,
+ 'form_title': 'Create User',
+ 'cancel_url': reverse(user_list),
+ })
+
+
+@superuser_required
+def user_list(request):
+ """Display a list of all the RPKI handles managed by this server."""
+ return render(request, 'app/user_list.html', {
+ 'object_list': User.objects.all()
+ })
+
+
+@superuser_required
+def user_delete(request, pk):
+ user = get_object_or_404(User, pk=pk)
+ if request.method == 'POST':
+ form = forms.Empty(request.POST, request.FILES)
+ if form.is_valid():
+ user.delete()
+ return redirect(user_list)
+ else:
+ form = forms.Empty()
+ return render(request, 'app/app_confirm_delete.html', {
+ 'form_title': 'Delete User: ' + user.username,
+ 'form': form,
+ 'cancel_url': reverse(user_list)
+ })
+
+
+@superuser_required
+def user_edit(request, pk):
+ user = get_object_or_404(User, pk=pk)
+ if request.method == 'POST':
+ form = forms.UserEditForm(request.POST)
+ if form.is_valid():
+ pw = form.cleaned_data.get('pw')
+ if pw:
+ user.set_password(pw)
+ user.email = form.cleaned_data.get('email')
+ user.save()
+ models.ConfACL.objects.filter(user=user).delete()
+ handles = form.cleaned_data.get('resource_holders')
+ for conf in handles:
+ models.ConfACL.objects.create(user=user, conf=conf)
+ return redirect(user_list)
+ else:
+ form = forms.UserEditForm(initial={
+ 'email': user.email,
+ 'resource_holders': models.Conf.objects.filter(confacl__user=user).all()
+ })
+ return render(request, 'app/app_form.html', {
+ 'form': form,
+ 'form_title': 'Edit User: ' + user.username,
+ 'cancel_url': reverse(user_list)
+ })
+
+
+class AlertListView(ListView):
+ # this nonsense is required to decorate CBVs
+ @method_decorator(handle_required)
+ def dispatch(self, request, *args, **kwargs):
+ return super(AlertListView, self).dispatch(request, *args, **kwargs)
+
+ def get_queryset(self, **kwargs):
+ conf = self.request.session['handle']
+ return conf.alerts.all()
+
+
+class AlertDetailView(DetailView):
+ # this nonsense is required to decorate CBVs
+ @method_decorator(handle_required)
+ def dispatch(self, request, *args, **kwargs):
+ return super(AlertDetailView, self).dispatch(request, *args, **kwargs)
+
+ def get_queryset(self, **kwargs):
+ conf = self.request.session['handle']
+ return conf.alerts.all()
+
+ def get_object(self, **kwargs):
+ obj = super(AlertDetailView, self).get_object(**kwargs)
+ # mark alert as read by the user
+ obj.seen = True
+ obj.save()
+ return obj
+
+
+class AlertDeleteView(DeleteView):
+ success_url = reverse_lazy('alert-list')
+
+ # this nonsense is required to decorate CBVs
+ @method_decorator(handle_required)
+ def dispatch(self, request, *args, **kwargs):
+ return super(AlertDeleteView, self).dispatch(request, *args, **kwargs)
+
+ def get_queryset(self, **kwargs):
+ conf = self.request.session['handle']
+ return conf.alerts.all()
+
+
+@handle_required
+def alert_clear_all(request):
+ """Clear all alerts associated with the current resource holder."""
+ if request.method == 'POST':
+ form = forms.Empty(request.POST, request.FILES)
+ if form.is_valid():
+ # delete alerts
+ request.session['handle'].clear_alerts()
+ return redirect('alert-list')
+ else:
+ form = forms.Empty()
+ return render(request, 'app/alert_confirm_clear.html', {'form': form})