diff --git a/api/serializers.py b/api/serializers.py index bff1bd9c..c8cdffd9 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -222,6 +222,13 @@ class SrvSerializer(NamespacedHMSerializer): fields = ('service', 'protocole', 'extension', 'ttl', 'priority', 'weight', 'port', 'target', 'api_url') +class SshFpSerializer(NamespacedHMSerializer): + """Serialize `machines.models.SSHFP` objects. + """ + class Meta: + model = machines.SshFp + field = ('machine', 'pub_key_entry', 'algo', 'comment', 'api_url') + class InterfaceSerializer(NamespacedHMSerializer): """Serialize `machines.models.Interface` objects. @@ -679,6 +686,26 @@ class SRVRecordSerializer(SrvSerializer): fields = ('service', 'protocole', 'ttl', 'priority', 'weight', 'port', 'target') +class SSHFPRecordSerializer(SshFpSerializer): + """Serialize `machines.models.SshFp` objects with the data needed to + generate a SSHFP DNS record. + """ + class Meta(SshFpSerializer.Meta): + fields = ('algo_id', 'hash') + + +class SSHFPInterfaceSerializer(serializers.ModelSerializer): + """Serialize `machines.models.Domain` objects with the data needed to + generate a CNAME DNS record. + """ + hostname = serializers.CharField(source='domain.name', read_only=True) + sshfp = SSHFPRecordSerializer(source='machine.sshfp_set', many=True, read_only=True) + + class Meta: + model = machines.Interface + fields = ('hostname', 'sshfp') + + class ARecordSerializer(serializers.ModelSerializer): """Serialize `machines.models.Interface` objects with the data needed to generate a A DNS record. @@ -729,12 +756,13 @@ class DNSZonesSerializer(serializers.ModelSerializer): a_records = ARecordSerializer(many=True, source='get_associated_a_records') aaaa_records = AAAARecordSerializer(many=True, source='get_associated_aaaa_records') cname_records = CNAMERecordSerializer(many=True, source='get_associated_cname_records') + sshfp_records = SSHFPInterfaceSerializer(many=True, source='get_associated_sshfp_records') class Meta: model = machines.Extension fields = ('name', 'soa', 'ns_records', 'originv4', 'originv6', 'mx_records', 'txt_records', 'srv_records', 'a_records', - 'aaaa_records', 'cname_records') + 'aaaa_records', 'cname_records', 'sshfp_records') # MAILING diff --git a/api/urls.py b/api/urls.py index 2947850e..67302789 100644 --- a/api/urls.py +++ b/api/urls.py @@ -54,6 +54,7 @@ router.register_viewset(r'machines/ns', views.NsViewSet) router.register_viewset(r'machines/txt', views.TxtViewSet) router.register_viewset(r'machines/dname', views.DNameViewSet) router.register_viewset(r'machines/srv', views.SrvViewSet) +router.register_viewset(r'machines/sshfp', views.SshFpViewSet) router.register_viewset(r'machines/interface', views.InterfaceViewSet) router.register_viewset(r'machines/ipv6list', views.Ipv6ListViewSet) router.register_viewset(r'machines/domain', views.DomainViewSet) diff --git a/api/views.py b/api/views.py index 45e083cc..7b01b0c3 100644 --- a/api/views.py +++ b/api/views.py @@ -177,6 +177,13 @@ class SrvViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.SrvSerializer +class SshFpViewSet(viewsets.ReadOnlyModelViewSet): + """Exposes list and details of `machines.models.SshFp` objects. + """ + queryset = machines.SshFp.objects.all() + serializer_class = serializers.SshFpSerializer + + class InterfaceViewSet(viewsets.ReadOnlyModelViewSet): """Exposes list and details of `machines.models.Interface` objects. """ diff --git a/machines/admin.py b/machines/admin.py index eb765748..26d7a6a3 100644 --- a/machines/admin.py +++ b/machines/admin.py @@ -39,11 +39,12 @@ from .models import ( Txt, DName, Srv, + SshFp, Nas, Service, OuverturePort, Ipv6List, - OuverturePortList + OuverturePortList, ) @@ -106,6 +107,11 @@ class SrvAdmin(VersionAdmin): pass +class SshFpAdmin(VersionAdmin): + """ Admin view of a SSHFP object """ + pass + + class NasAdmin(VersionAdmin): """ Admin view of a Nas object """ pass @@ -151,6 +157,7 @@ admin.site.register(Ns, NsAdmin) admin.site.register(Txt, TxtAdmin) admin.site.register(DName, DNameAdmin) admin.site.register(Srv, SrvAdmin) +admin.site.register(SshFp, SshFpAdmin) admin.site.register(IpList, IpListAdmin) admin.site.register(Interface, InterfaceAdmin) admin.site.register(Domain, DomainAdmin) diff --git a/machines/forms.py b/machines/forms.py index 36cd64f8..23c2aa39 100644 --- a/machines/forms.py +++ b/machines/forms.py @@ -56,6 +56,7 @@ from .models import ( Service, Vlan, Srv, + SshFp, Nas, IpType, OuverturePortList, @@ -595,3 +596,18 @@ class EditOuverturePortListForm(FormRevMixin, ModelForm): prefix=prefix, **kwargs ) + + +class SshFpForm(FormRevMixin, ModelForm): + """Edits a SSHFP record.""" + class Meta: + model = SshFp + exclude = ('machine',) + + def __init__(self, *args, **kwargs): + prefix = kwargs.pop('prefix', self.Meta.model.__name__) + super(SshFpForm, self).__init__( + *args, + prefix=prefix, + **kwargs + ) diff --git a/machines/migrations/0088_dname.py b/machines/migrations/0084_dname.py similarity index 100% rename from machines/migrations/0088_dname.py rename to machines/migrations/0084_dname.py diff --git a/machines/migrations/0085_sshfingerprint.py b/machines/migrations/0085_sshfingerprint.py new file mode 100644 index 00000000..47f11d07 --- /dev/null +++ b/machines/migrations/0085_sshfingerprint.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-29 11:39 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import re2o.mixins + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0084_dname'), + ] + + operations = [ + migrations.CreateModel( + name='SshFp', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pub_key_entry', models.TextField(help_text='SSH public key', max_length=2048)), + ('algo', models.CharField(choices=[('ssh-rsa', 'ssh-rsa'), ('ssh-ed25519', 'ssh-ed25519'), ('ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp256'), ('ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp384'), ('ecdsa-sha2-nistp521', 'ecdsa-sha2-nistp521')], max_length=32)), + ('comment', models.CharField(blank=True, help_text='Comment', max_length=255, null=True)), + ('machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='machines.Machine')), + ], + options={ + 'verbose_name': 'SSHFP record', + 'verbose_name_plural': 'SSHFP records', + 'permissions': (('view_sshfp', 'Can see an SSHFP record'),), + }, + bases=(re2o.mixins.RevMixin, re2o.mixins.AclMixin, models.Model), + ), + ] diff --git a/machines/models.py b/machines/models.py index 590e3997..7be76e74 100644 --- a/machines/models.py +++ b/machines/models.py @@ -32,6 +32,8 @@ import re from ipaddress import IPv6Address from itertools import chain from netaddr import mac_bare, EUI, IPSet, IPRange, IPNetwork, IPAddress +import hashlib +import base64 from django.db import models from django.db.models.signals import post_save, post_delete @@ -563,6 +565,12 @@ class Extension(RevMixin, AclMixin, models.Model): entry += "@ IN AAAA " + str(self.origin_v6) return entry + def get_associated_sshfp_records(self): + from re2o.utils import all_active_assigned_interfaces + return (all_active_assigned_interfaces() + .filter(type__ip_type__extension=self) + .filter(machine__id__in=SshFp.objects.values('machine'))) + def get_associated_a_records(self): from re2o.utils import all_active_assigned_interfaces return (all_active_assigned_interfaces() @@ -755,6 +763,73 @@ class Srv(RevMixin, AclMixin, models.Model): str(self.port) + ' ' + str(self.target) + '.' +class SshFp(RevMixin, AclMixin, models.Model): + """A fingerprint of an SSH public key""" + + ALGO = ( + ("ssh-rsa", "ssh-rsa"), + ("ssh-ed25519", "ssh-ed25519"), + ("ecdsa-sha2-nistp256", "ecdsa-sha2-nistp256"), + ("ecdsa-sha2-nistp384", "ecdsa-sha2-nistp384"), + ("ecdsa-sha2-nistp521", "ecdsa-sha2-nistp521"), + ) + + machine = models.ForeignKey('Machine', on_delete=models.CASCADE) + pub_key_entry = models.TextField( + help_text="SSH public key", + max_length=2048 + ) + algo = models.CharField( + choices=ALGO, + max_length=32 + ) + comment = models.CharField( + help_text="Comment", + max_length=255, + null=True, + blank=True + ) + + @cached_property + def algo_id(self): + """Return the id of the algorithm for this key""" + if "ecdsa" in self.algo: + return 3 + elif "rsa" in self.algo: + return 1 + else: + return 2 + + @cached_property + def hash(self): + """Return the hashess for the pub key with correct id + cf RFC, 1 is sha1 , 2 sha256""" + return { + "1" : hashlib.sha1(base64.b64decode(self.pub_key_entry)).hexdigest(), + "2" : hashlib.sha256(base64.b64decode(self.pub_key_entry)).hexdigest(), + } + + class Meta: + permissions = ( + ("view_sshfp", "Can see an SSHFP record"), + ) + verbose_name = "SSHFP record" + verbose_name_plural = "SSHFP records" + + def can_view(self, user_request, *_args, **_kwargs): + return self.machine.can_view(user_request, *_args, **_kwargs) + + def can_edit(self, user_request, *args, **kwargs): + return self.machine.can_edit(user_request, *args, **kwargs) + + def can_delete(self, user_request, *args, **kwargs): + return self.machine.can_delete(user_request, *args, **kwargs) + + def __str__(self): + return str(self.algo) + ' ' + str(self.comment) + + + class Interface(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): """ Une interface. Objet clef de l'application machine : - une address mac unique. Possibilité de la rendre unique avec le diff --git a/machines/templates/machines/aff_machines.html b/machines/templates/machines/aff_machines.html index 0acababc..ba736f10 100644 --- a/machines/templates/machines/aff_machines.html +++ b/machines/templates/machines/aff_machines.html @@ -119,6 +119,13 @@ with this program; if not, write to the Free Software Foundation, Inc., {% acl_end %} + {% can_create SshFp interface.machine.id %} +
  • + + Manage the SSH fingerprints + +
  • + {% acl_end %} {% can_create OuverturePortList %}
  • diff --git a/machines/templates/machines/aff_sshfp.html b/machines/templates/machines/aff_sshfp.html new file mode 100644 index 00000000..4409b8de --- /dev/null +++ b/machines/templates/machines/aff_sshfp.html @@ -0,0 +1,54 @@ +{% comment %} +Re2o est un logiciel d'administration développé initiallement au rezometz. Il +se veut agnostique au réseau considéré, de manière à être installable en +quelques clics. + +Copyright © 2018 Gabriel Détraz + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +{% endcomment %} + +{% load acl %} +{% load logs_extra %} + +
    + + + + + + + + + + {% for sshfp in sshfp_list %} + + + + + + + {% endfor %} +
    SSH public keyAlgorithm usedComment
    {{ sshfp.pub_key_entry }}{{ sshfp.algo }}{{ sshfp.comment }} + {% can_edit sshfp %} + {% include 'buttons/edit.html' with href='machines:edit-sshfp' id=sshfp.id %} + {% acl_end %} + {% can_delete sshfp %} + {% include 'buttons/suppr.html' with href='machines:del-sshfp' id=sshfp.id %} + {% acl_end %} + {% history_button sshfp %} +
    +
    + diff --git a/machines/templates/machines/index_sshfp.html b/machines/templates/machines/index_sshfp.html new file mode 100644 index 00000000..2c8d1581 --- /dev/null +++ b/machines/templates/machines/index_sshfp.html @@ -0,0 +1,38 @@ +{% extends "machines/sidebar.html" %} +{% comment %} +Re2o est un logiciel d'administration développé initiallement au rezometz. Il +se veut agnostique au réseau considéré, de manière à être installable en +quelques clics. + +Copyright © 2018 Gabriel Détraz + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +{% endcomment %} + +{% load bootstrap3 %} +{% load acl %} + +{% block title %}Machines{% endblock %} + +{% block content %} +

    SSH fingerprints

    +{% can_create SshFp machine_id %} +
    + Add an SSH fingerprint + +{% acl_end %} +{% include "machines/aff_sshfp.html" with sshfp_list=sshfp_list %} +{% endblock %} + diff --git a/machines/templates/machines/machine.html b/machines/templates/machines/machine.html index 0c5a478a..7ec4212a 100644 --- a/machines/templates/machines/machine.html +++ b/machines/templates/machines/machine.html @@ -69,6 +69,9 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if serviceform %} {% bootstrap_form_errors serviceform %} {% endif %} +{% if sshfpform %} + {% bootstrap_form_errors sshfpform %} +{% endif %} {% if vlanform %} {% bootstrap_form_errors vlanform %} {% endif %} @@ -133,6 +136,10 @@ with this program; if not, write to the Free Software Foundation, Inc.,

    Enregistrement SRV

    {% massive_bootstrap_form srvform 'target' %} {% endif %} + {% if sshfpform %} +

    SSHFP record

    + {% bootstrap_form sshfpform %} + {% endif %} {% if aliasform %}

    Alias

    {% bootstrap_form aliasform %} diff --git a/machines/urls.py b/machines/urls.py index bf3d63d8..ce0a7a78 100644 --- a/machines/urls.py +++ b/machines/urls.py @@ -82,6 +82,18 @@ urlpatterns = [ url(r'^add_srv/$', views.add_srv, name='add-srv'), url(r'^edit_srv/(?P[0-9]+)$', views.edit_srv, name='edit-srv'), url(r'^del_srv/$', views.del_srv, name='del-srv'), + url(r'^new_sshfp/(?P[0-9]+)$', + views.new_sshfp, + name='new-sshfp'), + url(r'^edit_sshfp/(?P[0-9]+)$', + views.edit_sshfp, + name='edit-sshfp'), + url(r'^del_sshfp/(?P[0-9]+)$', + views.del_sshfp, + name='del-sshfp'), + url(r'^index_sshfp/(?P[0-9]+)$', + views.index_sshfp, + name='index-sshfp'), url(r'^index_extension/$', views.index_extension, name='index-extension'), url(r'^add_alias/(?P[0-9]+)$', views.add_alias, diff --git a/machines/views.py b/machines/views.py index 71484952..398b9250 100644 --- a/machines/views.py +++ b/machines/views.py @@ -54,6 +54,7 @@ from re2o.utils import ( from re2o.acl import ( can_create, can_edit, + can_view, can_delete, can_view_all, can_delete_set, @@ -102,13 +103,14 @@ from .forms import ( DelVlanForm, ServiceForm, DelServiceForm, + SshFpForm, NasForm, DelNasForm, SrvForm, DelSrvForm, Ipv6ListForm, EditOuverturePortListForm, - EditOuverturePortConfigForm + EditOuverturePortConfigForm, ) from .models import ( IpType, @@ -127,6 +129,7 @@ from .models import ( Txt, DName, Srv, + SshFp, OuverturePortList, OuverturePort, Ipv6List, @@ -460,6 +463,72 @@ def del_ipv6list(request, ipv6list, **_kwargs): ) +@login_required +@can_create(SshFp) +@can_edit(Machine) +def new_sshfp(request, machine, **_kwargs): + """Creates an SSHFP record associated with a machine""" + sshfp_instance = SshFp(machine=machine) + sshfp = SshFpForm( + request.POST or None, + instance=sshfp_instance + ) + if sshfp.is_valid(): + sshfp.save() + messages.success(request, "The SSHFP record was added") + return redirect(reverse( + 'machines:index-sshfp', + kwargs={'machineid': str(machine.id)} + )) + return form( + {'sshfpform': sshfp, 'action_name': 'Create'}, + 'machines/machine.html', + request + ) + + +@login_required +@can_edit(SshFp) +def edit_sshfp(request, sshfp_instance, **_kwargs): + """Edits an SSHFP record""" + sshfp = SshFpForm( + request.POST or None, + instance=sshfp_instance + ) + if sshfp.is_valid(): + if sshfp.changed_data: + sshfp.save() + messages.success(request, "The SSHFP record was edited") + return redirect(reverse( + 'machines:index-sshfp', + kwargs={'machineid': str(sshfp_instance.machine.id)} + )) + return form( + {'sshfpform': sshfp, 'action_name': 'Edit'}, + 'machines/machine.html', + request + ) + + +@login_required +@can_delete(SshFp) +def del_sshfp(request, sshfp, **_kwargs): + """Deletes an SSHFP record""" + if request.method == "POST": + machineid = sshfp.machine.id + sshfp.delete() + messages.success(request, "The SSHFP record was deleted") + return redirect(reverse( + 'machines:index-sshfp', + kwargs={'machineid': str(machineid)} + )) + return form( + {'objet': sshfp, 'objet_name': 'sshfp'}, + 'machines/delete.html', + request + ) + + @login_required @can_create(IpType) def add_iptype(request): @@ -1388,7 +1457,20 @@ def index_alias(request, interface, interfaceid): @login_required -@can_edit(Interface) +@can_view(Machine) +def index_sshfp(request, machine, machineid): + """View used to display the list of existing SSHFP records associated + with a machine""" + sshfp_list = SshFp.objects.filter(machine=machine) + return render( + request, + 'machines/index_sshfp.html', + {'sshfp_list': sshfp_list, 'machine_id': machineid} + ) + + +@login_required +@can_view_all(Interface) def index_ipv6(request, interface, interfaceid): """ View used to display the list of existing IPv6 of an interface """ ipv6_list = Ipv6List.objects.filter(interface=interface) diff --git a/static/css/base.css b/static/css/base.css index 2b44e95c..2dc17770 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -113,4 +113,19 @@ footer a { .modal-dialog { width: 1000px } + +/* For tables with long text in cells */ + +.table.long_text{ + table-layout: fixed; + width: 100%; +} + +td.long_text{ + word-wrap: break-word; + width: 40%; +} + +th.long_text{ + width: 60%; }