diff --git a/circle/dashboard/forms.py b/circle/dashboard/forms.py index 4000354..ad1b0ec 100644 --- a/circle/dashboard/forms.py +++ b/circle/dashboard/forms.py @@ -40,7 +40,7 @@ from django.contrib.auth.forms import UserCreationForm as OrgUserCreationForm from django.forms.widgets import TextInput, HiddenInput from django.template import Context from django.template.loader import render_to_string -from django.utils.html import escape +from django.utils.html import escape, format_html from django.utils.translation import ugettext_lazy as _ from sizefield.widgets import FileSizeWidget from django.core.urlresolvers import reverse_lazy @@ -935,6 +935,56 @@ class VmDeployForm(OperationForm): "(blank allows scheduling automatically)."))) +class VmPortRemoveForm(OperationForm): + def __init__(self, *args, **kwargs): + choices = kwargs.pop('choices') + self.rule = kwargs.pop('default') + + super(VmPortRemoveForm, self).__init__(*args, **kwargs) + + self.fields.insert(0, 'rule', forms.ModelChoiceField( + queryset=choices, initial=self.rule, required=True, + empty_label=None, label=_('Port'))) + if self.rule: + self.fields['rule'].widget = HiddenInput() + + +class VmPortAddForm(OperationForm): + port = forms.IntegerField(required=True, label=_('Port'), + min_value=1, max_value=65535) + proto = forms.ChoiceField((('tcp', 'tcp'), ('udp', 'udp')), + required=True, label=_('Protocol')) + + def __init__(self, *args, **kwargs): + choices = kwargs.pop('choices') + self.host = kwargs.pop('default') + + super(VmPortAddForm, self).__init__(*args, **kwargs) + + self.fields.insert(0, 'host', forms.ModelChoiceField( + queryset=choices, initial=self.host, required=True, + empty_label=None, label=_('Host'))) + if self.host: + self.fields['host'].widget = HiddenInput() + + @property + def helper(self): + helper = super(VmPortAddForm, self).helper + if self.host: + helper.layout = Layout( + AnyTag( + "div", + HTML(format_html( + _("<label>Host:</label> {0}"), self.host)), + css_class="form-group", + ), + Field("host"), + Field("proto"), + Field("port"), + ) + return helper + + class CircleAuthenticationForm(AuthenticationForm): # fields: username, password diff --git a/circle/dashboard/templates/dashboard/_vm-remove-port.html b/circle/dashboard/templates/dashboard/_vm-remove-port.html new file mode 100644 index 0000000..44fe133 --- /dev/null +++ b/circle/dashboard/templates/dashboard/_vm-remove-port.html @@ -0,0 +1,23 @@ +{% extends "dashboard/operate.html" %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block formfields %} + {% if form %} + {% crispy form %} + {% endif %} + + {% if form.fields.rule.initial != None %} + {% with rule=form.fields.rule.initial %} + <dl> + <dt>{% trans "Port" %}:</dt> + <dd>{{ rule.dport }}/{{ rule.proto }}</dd> + <dt>{% trans "Host" %}:</dt> + <dd>{{ rule.host.hostname }}</dd> + <dt>{% trans "Vlan" %}:</dt> + <dd>{{ rule.host.vlan.name }}</dd> + </dl> + {% endwith %} + {% endif %} + +{% endblock %} diff --git a/circle/dashboard/templates/dashboard/vm-detail/_network-port-add.html b/circle/dashboard/templates/dashboard/vm-detail/_network-port-add.html index 7601a01..8cae3ae 100644 --- a/circle/dashboard/templates/dashboard/vm-detail/_network-port-add.html +++ b/circle/dashboard/templates/dashboard/vm-detail/_network-port-add.html @@ -1,13 +1,14 @@ {% load i18n %} <div class="vm-details-network-port-add pull-right"> - <form action="" method="POST"> + <form action="{{ op.add_port.get_url }}" method="POST"> {% csrf_token %} - <input type="hidden" name="host_pk" value="{{ i.host.pk }}"/> + <input type="hidden" name="host" value="{{ i.host.pk }}"/> <div class="input-group input-group-sm"> <span class="input-group-addon"> <i class="fa fa-plus"></i> <i class="fa fa-long-arrow-right"></i> </span> - <input type="text" class="form-control" size="5" style="width: 80px;" name="port"/> + <input type="number" class="form-control" size="5" min="1" max="65535" + style="width: 80px;" name="port" required/> <span class="input-group-addon">/</span> <select class="form-control" name="proto" style="width: 70px;"><option>tcp</option><option>udp</option></select> <div class="input-group-btn"> diff --git a/circle/dashboard/templates/dashboard/vm-detail/network.html b/circle/dashboard/templates/dashboard/vm-detail/network.html index 9e49384..d4d63b8 100644 --- a/circle/dashboard/templates/dashboard/vm-detail/network.html +++ b/circle/dashboard/templates/dashboard/vm-detail/network.html @@ -78,7 +78,7 @@ {{ l.private }}/{{ l.proto }} </td> <td> - <a href="{% url "dashboard.views.remove-port" pk=instance.pk rule=l.ipv4.pk %}" class="btn btn-link btn-xs vm-details-remove-port" data-rule="{{ l.ipv4.pk }}" title="{% trans "Remove" %}"><i class="fa fa-times"><span class="sr-only">{% trans "Remove" %}</span></i></a> + <a href="{{ op.remove_port.get_url }}?rule={{ l.ipv4.pk }}" class="btn btn-link btn-xs vm-details-remove-port" data-rule="{{ l.ipv4.pk }}" title="{% trans "Remove" %}"><i class="fa fa-times"><span class="sr-only">{% trans "Remove" %}</span></i></a> </td> </tr> {% endif %} @@ -110,7 +110,7 @@ {{ l.private }}/{{ l.proto }} </td> <td> - <a href="{% url "dashboard.views.remove-port" pk=instance.pk rule=l.ipv4.pk %}" class="btn btn-link btn-xs vm-details-remove-port" data-rule="{{ l.ipv6.pk }}" title="{% trans "Remove" %}"><i class="fa fa-times"><span class="sr-only">{% trans "Remove" %}</span></i></a> + <a href="{{ op.remove_port.get_url }}?rule={{ l.ipv4.pk }}" class="btn btn-link btn-xs vm-details-remove-port" data-rule="{{ l.ipv6.pk }}" title="{% trans "Remove" %}"><i class="fa fa-times"><span class="sr-only">{% trans "Remove" %}</span></i></a> </td> </tr> {% endif %} diff --git a/circle/dashboard/tests/test_views.py b/circle/dashboard/tests/test_views.py index b157176..7aca07e 100644 --- a/circle/dashboard/tests/test_views.py +++ b/circle/dashboard/tests/test_views.py @@ -26,7 +26,8 @@ from django.contrib.auth import authenticate from dashboard.views import VmAddInterfaceView from vm.models import Instance, InstanceTemplate, Lease, Node, Trait -from vm.operations import WakeUpOperation, AddInterfaceOperation +from vm.operations import (WakeUpOperation, AddInterfaceOperation, + AddPortOperation) from ..models import Profile from firewall.models import Vlan, Host, VlanGroup from mock import Mock, patch @@ -335,51 +336,48 @@ class VmDetailTest(LoginMixin, TestCase): self.login(c, "user2") inst = Instance.objects.get(pk=1) inst.set_level(self.u2, 'owner') - response = c.post("/dashboard/vm/1/", {'port': True, - 'proto': 'tcp', - 'port': '1337'}) + vlan = Vlan.objects.get(id=1) + vlan.set_level(self.u2, 'user') + inst.add_interface(user=self.u2, vlan=vlan) + host = Host.objects.get( + interface__in=inst.interface_set.all()) + with patch.object(AddPortOperation, 'async') as mock_method: + mock_method.side_effect = inst.add_port + response = c.post("/dashboard/vm/1/op/add_port/", { + 'proto': 'tcp', 'host': host.pk, 'port': '1337'}) self.assertEqual(response.status_code, 403) def test_unpermitted_add_port_wo_obj_levels(self): c = Client() self.login(c, "user2") - self.u2.user_permissions.add(Permission.objects.get( - name='Can configure port forwards.')) - response = c.post("/dashboard/vm/1/", {'port': True, - 'proto': 'tcp', - 'port': '1337'}) - self.assertEqual(response.status_code, 403) - - def test_unpermitted_add_port_w_bad_host(self): - c = Client() - self.login(c, "user2") inst = Instance.objects.get(pk=1) - inst.set_level(self.u2, 'owner') + vlan = Vlan.objects.get(id=1) + vlan.set_level(self.u2, 'user') + inst.add_interface(user=self.u2, vlan=vlan, system=True) + host = Host.objects.get( + interface__in=inst.interface_set.all()) self.u2.user_permissions.add(Permission.objects.get( name='Can configure port forwards.')) - response = c.post("/dashboard/vm/1/", {'proto': 'tcp', - 'host_pk': '9999', - 'port': '1337'}) + with patch.object(AddPortOperation, 'async') as mock_method: + mock_method.side_effect = inst.add_port + response = c.post("/dashboard/vm/1/op/add_port/", { + 'proto': 'tcp', 'host': host.pk, 'port': '1337'}) + assert not mock_method.called self.assertEqual(response.status_code, 403) - def test_permitted_add_port_w_unhandled_exception(self): + def test_unpermitted_add_port_w_bad_host(self): c = Client() self.login(c, "user2") inst = Instance.objects.get(pk=1) inst.set_level(self.u2, 'owner') - vlan = Vlan.objects.get(id=1) - vlan.set_level(self.u2, 'user') - inst.add_interface(user=self.u2, vlan=vlan) - host = Host.objects.get( - interface__in=inst.interface_set.all()) self.u2.user_permissions.add(Permission.objects.get( name='Can configure port forwards.')) - port_count = len(host.list_ports()) - response = c.post("/dashboard/vm/1/", {'proto': 'tcp', - 'host_pk': host.pk, - 'port': 'invalid_port'}) - self.assertEqual(response.status_code, 302) - self.assertEqual(len(host.list_ports()), port_count) + with patch.object(AddPortOperation, 'async') as mock_method: + mock_method.side_effect = inst.add_port + response = c.post("/dashboard/vm/1/op/add_port/", { + 'proto': 'tcp', 'host': '9999', 'port': '1337'}) + assert not mock_method.called + self.assertEqual(response.status_code, 200) def test_permitted_add_port(self): c = Client() @@ -394,9 +392,11 @@ class VmDetailTest(LoginMixin, TestCase): self.u2.user_permissions.add(Permission.objects.get( name='Can configure port forwards.')) port_count = len(host.list_ports()) - response = c.post("/dashboard/vm/1/", {'proto': 'tcp', - 'host_pk': host.pk, - 'port': '1337'}) + with patch.object(AddPortOperation, 'async') as mock_method: + mock_method.side_effect = inst.add_port + response = c.post("/dashboard/vm/1/op/add_port/", { + 'proto': 'tcp', 'host': host.pk, 'port': '1337'}) + assert mock_method.called self.assertEqual(response.status_code, 302) self.assertEqual(len(host.list_ports()), port_count + 1) diff --git a/circle/dashboard/urls.py b/circle/dashboard/urls.py index 4a044b5..9bbc5b2 100644 --- a/circle/dashboard/urls.py +++ b/circle/dashboard/urls.py @@ -26,7 +26,7 @@ from .views import ( InstanceActivityDetail, LeaseCreate, LeaseDelete, LeaseDetail, MyPreferencesView, NodeAddTraitView, NodeCreate, NodeDelete, NodeDetailView, NodeList, NodeStatus, - NotificationView, PortDelete, TemplateAclUpdateView, TemplateCreate, + NotificationView, TemplateAclUpdateView, TemplateCreate, TemplateDelete, TemplateDetail, TemplateList, vm_activity, VmCreate, VmDetailView, VmDetailVncTokenView, VmList, @@ -82,8 +82,6 @@ urlpatterns = patterns( name="dashboard.views.template-delete"), url(r'^template/(?P<pk>\d+)/tx/$', TransferTemplateOwnershipView.as_view(), name='dashboard.views.template-transfer-ownership'), - url(r'^vm/(?P<pk>\d+)/remove_port/(?P<rule>\d+)/$', PortDelete.as_view(), - name='dashboard.views.remove-port'), url(r'^vm/(?P<pk>\d+)/$', VmDetailView.as_view(), name='dashboard.views.detail'), url(r'^vm/(?P<pk>\d+)/vnctoken/$', VmDetailVncTokenView.as_view(), diff --git a/circle/dashboard/views/vm.py b/circle/dashboard/views/vm.py index a4f63ea..606b768 100644 --- a/circle/dashboard/views/vm.py +++ b/circle/dashboard/views/vm.py @@ -63,6 +63,7 @@ from ..forms import ( VmRenewForm, VmStateChangeForm, VmListSearchForm, VmCustomizeForm, VmDiskResizeForm, RedeployForm, VmDiskRemoveForm, VmMigrateForm, VmDeployForm, + VmPortRemoveForm, VmPortAddForm, ) from ..models import Favourite @@ -175,7 +176,6 @@ class VmDetailView(GraphMixin, CheckedDetailView): 'new_description': self.__set_description, 'new_tag': self.__add_tag, 'to_remove': self.__remove_tag, - 'port': self.__add_port, 'abort_operation': self.__abort_operation, } for k, v in options.iteritems(): @@ -271,40 +271,6 @@ class VmDetailView(GraphMixin, CheckedDetailView): return redirect(reverse_lazy("dashboard.views.detail", kwargs={'pk': self.object.pk})) - def __add_port(self, request): - object = self.get_object() - if not (object.has_level(request.user, "operator") and - request.user.has_perm('vm.config_ports')): - raise PermissionDenied() - - port = request.POST.get("port") - proto = request.POST.get("proto") - - try: - error = None - interfaces = object.interface_set.all() - host = Host.objects.get(pk=request.POST.get("host_pk"), - interface__in=interfaces) - host.add_port(proto, private=port) - except Host.DoesNotExist: - logger.error('Tried to add port to nonexistent host %d. User: %s. ' - 'Instance: %s', request.POST.get("host_pk"), - unicode(request.user), object) - raise PermissionDenied() - except ValueError: - error = _("There is a problem with your input.") - except Exception as e: - error = _("Unknown error.") - logger.error(e) - - if request.is_ajax(): - pass - else: - if error: - messages.error(request, error) - return redirect(reverse_lazy("dashboard.views.detail", - kwargs={'pk': self.get_object().pk})) - def __abort_operation(self, request): self.object = self.get_object() @@ -451,6 +417,62 @@ class VmMigrateView(FormOperationMixin, VmOperationView): return val +class VmPortRemoveView(FormOperationMixin, VmOperationView): + + template_name = 'dashboard/_vm-remove-port.html' + op = 'remove_port' + show_in_toolbar = False + with_reload = True + wait_for_result = 0.5 + icon = 'times' + effect = "danger" + form_class = VmPortRemoveForm + + def get_form_kwargs(self): + instance = self.get_op().instance + choices = Rule.portforwards().filter( + host__interface__instance=instance) + rule_pk = self.request.GET.get('rule') + if rule_pk: + try: + default = choices.get(pk=rule_pk) + except (ValueError, Rule.DoesNotExist): + raise Http404() + else: + default = None + + val = super(VmPortRemoveView, self).get_form_kwargs() + val.update({'choices': choices, 'default': default}) + return val + + +class VmPortAddView(FormOperationMixin, VmOperationView): + + op = 'add_port' + show_in_toolbar = False + with_reload = True + wait_for_result = 0.5 + icon = 'plus' + effect = "success" + form_class = VmPortAddForm + + def get_form_kwargs(self): + instance = self.get_op().instance + choices = Host.objects.filter(interface__instance=instance) + host_pk = self.request.GET.get('host') + if host_pk: + try: + default = choices.get(pk=host_pk) + except (ValueError, Host.DoesNotExist): + raise Http404() + else: + default = None + + val = super(VmPortAddView, self).get_form_kwargs() + val.update({'choices': choices, 'default': default}) + return val + + class VmSaveView(FormOperationMixin, VmOperationView): op = 'save_as_template' @@ -684,6 +706,8 @@ vm_ops = OrderedDict([ op='remove_disk', form_class=VmDiskRemoveForm, icon='times', effect="danger")), ('add_interface', VmAddInterfaceView), + ('remove_port', VmPortRemoveView), + ('add_port', VmPortAddView), ('renew', VmRenewView), ('resources_change', VmResourcesChangeView), ('password_reset', VmOperationView.factory( @@ -1167,52 +1191,6 @@ def get_disk_download_status(request, pk): ) -class PortDelete(LoginRequiredMixin, DeleteView): - model = Rule - pk_url_kwarg = 'rule' - - def get_template_names(self): - if self.request.is_ajax(): - return ['dashboard/confirm/ajax-delete.html'] - else: - return ['dashboard/confirm/base-delete.html'] - - def get_context_data(self, **kwargs): - context = super(PortDelete, self).get_context_data(**kwargs) - rule = kwargs.get('object') - instance = rule.host.interface_set.get().instance - context['title'] = _("Port delete confirmation") - context['text'] = _("Are you sure you want to close %(port)d/" - "%(proto)s on %(vm)s?" % {'port': rule.dport, - 'proto': rule.proto, - 'vm': instance}) - return context - - def delete(self, request, *args, **kwargs): - rule = Rule.objects.get(pk=kwargs.get("rule")) - instance = rule.host.interface_set.get().instance - if not instance.has_level(request.user, 'owner'): - raise PermissionDenied() - - super(PortDelete, self).delete(request, *args, **kwargs) - - success_url = self.get_success_url() - success_message = _("Port successfully removed.") - - if request.is_ajax(): - return HttpResponse( - json.dumps({'message': success_message}), - content_type="application/json", - ) - else: - messages.success(request, success_message) - return HttpResponseRedirect("%s#network" % success_url) - - def get_success_url(self): - return reverse_lazy('dashboard.views.detail', - kwargs={'pk': self.kwargs.get("pk")}) - - class ClientCheck(LoginRequiredMixin, TemplateView): def get_template_names(self): diff --git a/circle/firewall/fields.py b/circle/firewall/fields.py index 47f5539..8da33a4 100644 --- a/circle/firewall/fields.py +++ b/circle/firewall/fields.py @@ -34,6 +34,10 @@ reverse_domain_re = re.compile(r'^(%\([abcd]\)d|[a-z0-9.-])+$') ipv6_template_re = re.compile(r'^(%\([abcd]\)[dxX]|[A-Za-z0-9:-])+$') +class mac_custom(mac_unix): + word_fmt = '%.2X' + + class MACAddressFormField(forms.Field): default_error_messages = { 'invalid': _(u'Enter a valid MAC address. %s'), @@ -51,9 +55,6 @@ class MACAddressField(models.Field): description = _('MAC Address object') __metaclass__ = models.SubfieldBase - class mac_custom(mac_unix): - word_fmt = '%.2X' - def __init__(self, *args, **kwargs): kwargs['max_length'] = 17 super(MACAddressField, self).__init__(*args, **kwargs) @@ -65,7 +66,7 @@ class MACAddressField(models.Field): if isinstance(value, EUI): return value - return EUI(value, dialect=MACAddressField.mac_custom) + return EUI(value, dialect=mac_custom) def get_internal_type(self): return 'CharField' diff --git a/circle/firewall/models.py b/circle/firewall/models.py index cfa4a99..2a7e6ba 100644 --- a/circle/firewall/models.py +++ b/circle/firewall/models.py @@ -243,6 +243,13 @@ class Rule(models.Model): return retval + @classmethod + def portforwards(cls, host=None): + qs = cls.objects.filter(dport__isnull=False, direction='in') + if host is not None: + qs = qs.filter(host=host) + return qs + class Meta: verbose_name = _("rule") verbose_name_plural = _("rules") @@ -762,7 +769,7 @@ class Host(models.Model): Return a list of ports with forwarding rules set. """ retval = [] - for rule in self.rules.filter(dport__isnull=False, direction='in'): + for rule in Rule.portforwards(host=self): forward = { 'proto': rule.proto, 'private': rule.dport, diff --git a/circle/vm/models/instance.py b/circle/vm/models/instance.py index 541b7c1..a82f0e8 100644 --- a/circle/vm/models/instance.py +++ b/circle/vm/models/instance.py @@ -817,7 +817,7 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin, return acts def get_merged_activities(self, user=None): - whitelist = ("create_disk", "download_disk") + whitelist = ("create_disk", "download_disk", "add_port", "remove_port") acts = self.get_activities(user) merged_acts = [] latest = None diff --git a/circle/vm/operations.py b/circle/vm/operations.py index fc7c4d5..7fd5be4 100644 --- a/circle/vm/operations.py +++ b/circle/vm/operations.py @@ -27,7 +27,7 @@ from tarfile import TarFile, TarInfo import time from urlparse import urlsplit -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.utils import timezone from django.utils.translation import ugettext_lazy as _, ugettext_noop from django.conf import settings @@ -606,6 +606,41 @@ class RemoveInterfaceOperation(InstanceOperation): @register_operation +class RemovePortOperation(InstanceOperation): + id = 'remove_port' + name = _("close port") + description = _("Close the specified port.") + concurrency_check = False + required_perms = ('vm.config_ports', ) + + def _operation(self, activity, rule): + interface = rule.host.interface_set.get() + if interface.instance != self.instance: + raise SuspiciousOperation() + activity.readable_name = create_readable( + ugettext_noop("close %(proto)s/%(port)d on %(host)s"), + proto=rule.proto, port=rule.dport, host=rule.host) + rule.delete() + + +@register_operation +class AddPortOperation(InstanceOperation): + id = 'add_port' + name = _("open port") + description = _("Open the specified port.") + concurrency_check = False + required_perms = ('vm.config_ports', ) + + def _operation(self, activity, host, proto, port): + if host.interface_set.get().instance != self.instance: + raise SuspiciousOperation() + host.add_port(proto, private=port) + activity.readable_name = create_readable( + ugettext_noop("open %(proto)s/%(port)d on %(host)s"), + proto=proto, port=port, host=host) + + +@register_operation class RemoveDiskOperation(InstanceOperation): id = 'remove_disk' name = _("remove disk")