More SSH management.
authorRadek Czajka <rczajka@rczajka.pl>
Mon, 1 Apr 2019 09:45:14 +0000 (11:45 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Mon, 1 Apr 2019 12:20:41 +0000 (14:20 +0200)
13 files changed:
src/cas/static/css/main.css
src/ssh_keys/admin.py
src/ssh_keys/locale/pl/LC_MESSAGES/django.mo
src/ssh_keys/locale/pl/LC_MESSAGES/django.po
src/ssh_keys/migrations/0003_auto_20190401_0923.py [new file with mode: 0644]
src/ssh_keys/migrations/0004_sshkey_last_seen_at.py [new file with mode: 0644]
src/ssh_keys/migrations/0005_auto_20190401_0938.py [new file with mode: 0644]
src/ssh_keys/models.py
src/ssh_keys/templates/ssh_keys/sshkey_confirm_delete.html
src/ssh_keys/templates/ssh_keys/sshkey_list.html
src/ssh_keys/urls.py
src/ssh_keys/utils.py [new file with mode: 0644]
src/ssh_keys/views.py

index 134f3bf..1329ac0 100644 (file)
@@ -135,5 +135,10 @@ footer, #content_push {
 }
 
 code.key {
-    word-wrap: break-word;
+    white-space: nowrap;
+    width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: block;
+    color: #666;
 }
index b907416..6e75313 100644 (file)
@@ -1,5 +1,12 @@
 from django.contrib import admin
+from django.db.models import F
 from .models import SSHKey
 
 
-admin.site.register(SSHKey)
+class SSHKeyAdmin(admin.ModelAdmin):
+    fields = ['user',  'key', 'algorithm', 'bit_length', 'md5_hash', 'created_at', 'last_seen_at']
+    readonly_fields = ['algorithm', 'bit_length', 'md5_hash', 'created_at', 'last_seen_at']
+    list_display = ['comment', 'last_seen_at', 'user', 'md5_hash', 'algorithm', 'bit_length', 'created_at']
+    ordering = (F('last_seen_at').desc(nulls_last=True),)
+
+admin.site.register(SSHKey, SSHKeyAdmin)
index 2268d5a..5daa4b4 100644 (file)
Binary files a/src/ssh_keys/locale/pl/LC_MESSAGES/django.mo and b/src/ssh_keys/locale/pl/LC_MESSAGES/django.mo differ
index 29bf9f8..b2fc0b1 100644 (file)
@@ -7,8 +7,8 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2019-03-30 23:22+0100\n"
-"PO-Revision-Date: 2019-03-30 23:22+0100\n"
+"POT-Creation-Date: 2019-04-01 11:41+0200\n"
+"PO-Revision-Date: 2019-04-01 11:42+0200\n"
 "Last-Translator: \n"
 "Language-Team: \n"
 "Language: pl\n"
@@ -20,27 +20,43 @@ msgstr ""
 "%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
 "X-Generator: Poedit 2.0.6\n"
 
-#: apps.py:7 models.py:15 templates/ssh_keys/sshkey_list.html:13
+#: apps.py:7 models.py:20 templates/ssh_keys/sshkey_list.html:13
 msgid "SSH keys"
 msgstr "Klucze SSH"
 
-#: models.py:7
+#: models.py:8
 msgid "user"
 msgstr "użytkownik"
 
-#: models.py:8
+#: models.py:9
 msgid "key"
 msgstr "klucz"
 
-#: models.py:9
+#: models.py:10
 msgid "comment"
 msgstr "komentarz"
 
-#: models.py:10
+#: models.py:11
+msgid "algorithm"
+msgstr "algorytm"
+
+#: models.py:12
+msgid "bit length"
+msgstr "długość bitowa"
+
+#: models.py:13
+msgid "MD5 hash"
+msgstr "skrót MD5"
+
+#: models.py:14
 msgid "created at"
 msgstr "utworzony"
 
-#: models.py:14
+#: models.py:15
+msgid "last seen at"
+msgstr "ostatnio widziany"
+
+#: models.py:19
 msgid "SSH key"
 msgstr "klucz SSH"
 
@@ -48,7 +64,7 @@ msgstr "klucz SSH"
 msgid "Add SSH key"
 msgstr "Dodaj klucz SSH"
 
-#: templates/ssh_keys/sshkey_add.html:12 templates/ssh_keys/sshkey_list.html:27
+#: templates/ssh_keys/sshkey_add.html:12 templates/ssh_keys/sshkey_list.html:31
 msgid "Add"
 msgstr "Dodaj"
 
@@ -61,11 +77,22 @@ msgid "Are you sure you want to delete this key?"
 msgstr "Czy na pewno chcesz usunąć ten klucz?"
 
 #: templates/ssh_keys/sshkey_confirm_delete.html:14
-#: templates/ssh_keys/sshkey_list.html:18
 msgid "Added at"
 msgstr "Dodano"
 
 #: templates/ssh_keys/sshkey_confirm_delete.html:17
-#: templates/ssh_keys/sshkey_list.html:20
+#: templates/ssh_keys/sshkey_list.html:24
 msgid "Delete"
 msgstr "Usuń"
+
+#: templates/ssh_keys/sshkey_list.html:20
+msgid "Added"
+msgstr "Dodany"
+
+#: templates/ssh_keys/sshkey_list.html:21
+msgid "Last seen"
+msgstr "Ostatnio widziany"
+
+#: views.py:34
+msgid "Key already in the database."
+msgstr "Klucz jest już w bazie."
diff --git a/src/ssh_keys/migrations/0003_auto_20190401_0923.py b/src/ssh_keys/migrations/0003_auto_20190401_0923.py
new file mode 100644 (file)
index 0000000..8d7b1c5
--- /dev/null
@@ -0,0 +1,35 @@
+# Generated by Django 2.1.7 on 2019-04-01 09:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ssh_keys', '0002_auto_20190330_2220'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='sshkey',
+            name='algorithm',
+            field=models.CharField(default='', editable=False, max_length=32, verbose_name='algorithm'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='sshkey',
+            name='bit_length',
+            field=models.IntegerField(editable=False, null=True, verbose_name='bit length'),
+        ),
+        migrations.AddField(
+            model_name='sshkey',
+            name='md5_hash',
+            field=models.CharField(default='', editable=False, max_length=128, verbose_name='MD5 hash'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='sshkey',
+            name='comment',
+            field=models.CharField(editable=False, max_length=255, verbose_name='comment'),
+        ),
+    ]
diff --git a/src/ssh_keys/migrations/0004_sshkey_last_seen_at.py b/src/ssh_keys/migrations/0004_sshkey_last_seen_at.py
new file mode 100644 (file)
index 0000000..9765120
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.7 on 2019-04-01 09:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ssh_keys', '0003_auto_20190401_0923'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='sshkey',
+            name='last_seen_at',
+            field=models.DateTimeField(editable=False, null=True, verbose_name='last seen at'),
+        ),
+    ]
diff --git a/src/ssh_keys/migrations/0005_auto_20190401_0938.py b/src/ssh_keys/migrations/0005_auto_20190401_0938.py
new file mode 100644 (file)
index 0000000..b602dcf
--- /dev/null
@@ -0,0 +1,17 @@
+# Generated by Django 2.1.7 on 2019-04-01 09:38
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ssh_keys', '0004_sshkey_last_seen_at'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='sshkey',
+            unique_together={('algorithm', 'bit_length', 'md5_hash')},
+        ),
+    ]
index 18b887e..e4841ca 100644 (file)
@@ -1,22 +1,32 @@
 from django.conf import settings
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
+from .utils import get_key_details
 
 
 class SSHKey(models.Model):
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('user'))
     key = models.TextField(_('key'))
-    comment = models.CharField(_('comment'), max_length=255, blank=True)
+    comment = models.CharField(_('comment'), max_length=255, editable=False)
+    algorithm = models.CharField(_('algorithm'), max_length=32, editable=False)
+    bit_length = models.IntegerField(_('bit length'), null=True, editable=False)
+    md5_hash = models.CharField(_('MD5 hash'), max_length=128, editable=False)
     created_at = models.DateTimeField(_('created at'), auto_now_add=True)
+    last_seen_at = models.DateTimeField(_('last seen at'), null=True, editable=False)
 
     class Meta:
         ordering = ['created_at']
         verbose_name = _('SSH key')
         verbose_name_plural = _('SSH keys')
+        unique_together = [('algorithm', 'bit_length', 'md5_hash')]
 
     def __str__(self):
         return self.comment
 
     def save(self, *args, **kwargs):
-        self.comment = self.key.rsplit()[-1][:255]
+        det = get_key_details(self.key)
+        self.comment = det['comment'][:255]
+        self.algorithm = det['algo']
+        self.bit_length = det['bits']
+        self.md5_hash = det['md5']
         return super().save(*args, **kwargs)
index c1217d1..ee54777 100644 (file)
 <form method="post">
   {% csrf_token %}
   <p>
+    {{ object.comment }} ({{ object.algorithm}} {{ object.bit_length }})<br>
+    {{ object.md5_hash }}
     <code class="key">{{ object.key }}</code><br>
-    {% trans "Added at" %} {{ object.created_at }}.
-    </p>
+    {% trans "Added" %}: {{ object.created_at }}<br>
+    {% trans "Last seen" %}: {{ object.last_seen_at|default:"–" }}<br>
+  </p>
+
   <button>
     {% trans "Delete" %}
   </button>
index 9dea954..abd1b10 100644 (file)
   <h1>{% trans "SSH keys" %}</h1>
 
   {% for key in object_list %}
-<p>
-  <code class="key">{{ key.key }}</code><br>
-  {% trans "Added at" %} {{ key.created_at }}<br>
-  <a href="{% url 'ssh_keys_delete' key.id %}">
-    {% trans "Delete" %}
-  </a>
-</p>
-<hr>
+    <p>
+      {{ key.comment }} ({{ key.algorithm}} {{ key.bit_length }})<br>
+      {{ key.md5_hash }}
+      <code class="key">{{ key.key }}</code><br>
+      {% trans "Added" %}: {{ key.created_at }}<br>
+      {% trans "Last seen" %}: {{ key.last_seen_at|default:"–" }}<br>
+
+      <a href="{% url 'ssh_keys_delete' key.id %}">
+        {% trans "Delete" %}
+      </a>
+    </p>
+    <hr>
   {% endfor %}
 
   <a href="{% url 'ssh_keys_add' %}">
index deabb5c..db879cb 100644 (file)
@@ -7,4 +7,5 @@ urlpatterns = [
     path('<int:pk>/delete/', views.DeleteSSHKeyView.as_view(), name='ssh_keys_delete'),
     path('add/', views.AddSSHKeyView.as_view(), name='ssh_keys_add'),
 
+    path('seen/', views.ssh_keys_seen),
 ]
diff --git a/src/ssh_keys/utils.py b/src/ssh_keys/utils.py
new file mode 100644 (file)
index 0000000..e8756a2
--- /dev/null
@@ -0,0 +1,60 @@
+from datetime import datetime, timedelta
+import re
+import subprocess
+
+
+def get_key_details(key):
+    """
+    >>> get_key_details('ssh-dss AAAAB3NzaC1kc3MAAACBAJxrocPXtCxwgg5yvOc1NLFFz/Fql4+7sOgMOkwWO6pxpJ4bPZgzZ0B17/HGKxQaot3Nc7vzdkC3MBrDDbKrX4n9qB9yJBd0Kkr5X0K7SnBKU+7fbg+rloUdYE78LS6ap05+xlJ8dU918DnS3KqcT/YQQXaTLrt/2DUOM1qxCI1XAAAAFQCJXLN0vYx7SIYMQ0zhv9IUT5WhgQAAAIAT2new16avxvs56zU87t1QQe0qwbQEIUygWW6vqnc9Lo9aSf21sM5WAHTkEnTVyiFSI6K6Q6bD2OUMvS2oWaoariW8EFKzg7/pufmThG0oAxkloc3j8gMO2+xuw7yHzP2pd6xgosNkqivpsGT1PKo+vM6x8p9B6PvipHPqhgFHWQAAAIEAhgFE3+gfPpfDIhaPP5Adx4Hm0VO3xBgOtafvunv3kP54kvHuTaD2uLwgcdOsMedv1/tqhJddh4+9ibwhlKbxKLHrQIcGSHCIY/BoA4RnpSBlGoXEc2buLoZ9IwANCIa2mp19Q/v4wwLnTJHabdMkNCiUn8NPEiHUPjgIj1uoCgo= epsilon')
+    {'algo': 'DSA', 'bits': 1024, 'md5': 'f6:78:a7:9e:6b:41:dc:31:f4:39:12:5f:2b:0c:7a:11', 'comment': 'epsilon'}
+    """
+    process = subprocess.run(
+        ['ssh-keygen', '-lEmd5', '-f-'],
+        input=key.encode('utf-8'),
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE)
+    if process.returncode != 0:
+        raise ValueError(process.stderr.decode('utf-8'))
+
+    output = process.stdout.decode('utf-8').rstrip()
+    m = re.match(
+        r'^(?P<bits>\d+) MD5:(?P<md5>[a-f0-9:]+) (?P<comment>.*) \((?P<algo>.*)\)$',
+        output
+    )
+    return {
+        'algo':  m.group('algo'),
+        'bits': int(m.group('bits')),
+        'md5': m.group('md5'),
+        'comment': m.group('comment'),
+    }
+
+
+def parse_log_line(line, year=None, allow_future_days=7):
+    """
+    >>> sorted(parse_log_line(
+    ...     'Jan  1 2:34:56 heta sshd[4112]: Accepted publickey for localuser from 0.0.0.0 port 33980 ssh2: RSA f6:78:a7:9e:6b:41:dc:31:f4:39:12:5f:2b:0c:7a:11',
+    ...     year=2019).items())
+    [('algo', 'RSA'), ('datetime', datetime.datetime(2019, 1, 1, 2, 34, 56)), ('host', 'heta'), ('ip', '0.0.0.0'), ('md5', 'f6:78:a7:9e:6b:41:dc:31:f4:39:12:5f:2b:0c:7a:11'), ('user', 'localuser')]
+    """
+    logline_re = r'^(?P<time>\w{3}\s+\d+\s+\d?\d:\d\d:\d\d) (?P<host>\S+) .*: Accepted publickey for (?P<user>\S*) from (?P<ip>[\S]+) port .* ssh2: (?P<algo>\w+) (?P<md5>[a-f0-9:]+)$'
+
+    m = re.match(logline_re, line)
+    if m is None:
+        return None
+    dt = datetime.strptime(m['time'], '%b %d %H:%M:%S')
+    if year is not None:
+        dt = dt.replace(year=year)
+    else:
+        now = datetime.now()
+        dt = dt.replace(year=now.year)
+        if (dt - now).days > allow_future_days:
+            dt = dt.replace(year=dt.year - 1)
+
+    return {
+        'datetime': dt,
+        'host': m['host'],
+        'user': m['user'],
+        'ip': m['ip'],
+        'algo': m['algo'],
+        'md5': m['md5'],
+    }
index 684dcf6..67401e3 100644 (file)
@@ -1,6 +1,19 @@
+from datetime import datetime, timedelta
+import logging
+import re
 from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib import messages
+from django.db import IntegrityError
+from django.db.models import Q
+from django import http
+from django.shortcuts import get_object_or_404
+from django.utils.timezone import now
+from django.utils.translation import ugettext as _
+from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import ListView, CreateView, DeleteView
+from services.models import Service
 from .models import SSHKey
+from .utils import parse_log_line
 
 
 class SSHKeysView(LoginRequiredMixin, ListView):
@@ -16,7 +29,14 @@ class AddSSHKeyView(LoginRequiredMixin, CreateView):
 
     def form_valid(self, form):
         form.instance.user = self.request.user
-        return super().form_valid(form)
+        try:
+            return super().form_valid(form)
+        except ValueError as e:
+            messages.add_message(self.request, messages.ERROR, e)
+        except IntegrityError:
+            messages.add_message(self.request, messages.ERROR, _("Key already in the database."))
+        return http.HttpResponseRedirect(self.success_url)
+
 
     
 class DeleteSSHKeyView(LoginRequiredMixin, DeleteView):
@@ -24,4 +44,38 @@ class DeleteSSHKeyView(LoginRequiredMixin, DeleteView):
 
     def get_queryset(self):
         return SSHKey.objects.filter(user=self.request.user)
-    
+
+
+@csrf_exempt
+def ssh_keys_seen(request):
+    logger = logging.getLogger('django.request')
+    key = request.GET.get('key')
+    service = get_object_or_404(Service, key=key)
+    n = now()
+
+    logline_re = r'^(?P<time>\w{3}\s+\d+\s+\d\d:\d\d:\d\d).*: Accepted publickey for .* from .* port .* ssh2: (?P<algo>\w+) (?P<hash>[a-f0-9:]+)$'
+
+    last_seen = {}
+    for line in request.body.decode('latin1').split('\n'):
+        if not line.strip():
+            continue
+        data = parse_log_line(line)
+        if data is None:
+            logger.error('Unparsed: ' + line)
+            break
+        dt = data['datetime']
+        key = data['algo'], data['md5']
+        last_seen[key] = max(last_seen.get(key, dt), dt)
+
+    for key, dt in last_seen.items():
+        algo, md5 = key
+        SSHKey.objects.filter(
+            Q(last_seen_at=None) | Q(last_seen_at__lt=dt),
+            algorithm=algo,
+            md5_hash=md5
+        ).update(
+            last_seen_at=dt
+        )
+
+    return http.HttpResponse('ok')
+