Browse Source

Implement a Radius/LDAP-based DSL module

This implements the model, admin, and LDAP export.  Still no user
interface.
Baptiste Jonglez 10 years ago
parent
commit
2277b34f06

+ 0 - 0
coin/dsl_ldap/__init__.py


+ 62 - 0
coin/dsl_ldap/admin.py

@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.contrib import admin
+from polymorphic.admin import PolymorphicChildModelAdmin
+
+from .models import RadiusGroup, DSLConfiguration
+from coin.configuration.admin import ConfigurationAdminFormMixin
+from coin.utils import delete_selected
+
+
+class DSLConfigurationInline(admin.StackedInline):
+    model = DSLConfiguration
+    fields = ('offersubscription', 'phone_number', 'activated', 'radius_group', 'login', 'password')
+    readonly_fields = ['configuration_ptr', 'login']
+
+
+class DSLConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAdmin):
+    base_model = DSLConfiguration
+    list_display = ('offersubscription', 'activated', 'full_login',
+                    'radius_group')
+    list_filter = ('activated', 'radius_group')
+    search_fields = ('login',
+                     # TODO: searching on member directly doesn't work
+                     'offersubscription__member__first_name',
+                     'offersubscription__member__last_name',
+                     'offersubscription__member__email')
+    actions = (delete_selected, "activate", "deactivate")
+    fields = ('offersubscription', 'phone_number', 'activated', 'radius_group',
+              'login', 'password')
+    inline = DSLConfigurationInline
+
+    def get_readonly_fields(self, request, obj=None):
+        if obj and obj.login != "":
+            return ['login',]
+        else:
+            return []
+
+    def set_activation(self, request, queryset, value):
+        count = 0
+        # We must update each object individually, because we want to run
+        # the save() method to update the backend.
+        for vpn in queryset:
+            if vpn.activated != value:
+                vpn.activated = value
+                vpn.full_clean()
+                vpn.save()
+                count += 1
+        action = "activated" if value else "deactivated"
+        msg = "{} DSL line(s) {}.".format(count, action)
+        self.message_user(request, msg)
+
+    def activate(self, request, queryset):
+        self.set_activation(request, queryset, True)
+    activate.short_description = "Activate selected DSL lines"
+
+    def deactivate(self, request, queryset):
+        self.set_activation(request, queryset, False)
+    deactivate.short_description = "Deactivate selected DSL lines"
+
+admin.site.register(RadiusGroup,)
+admin.site.register(DSLConfiguration, DSLConfigurationAdmin)

+ 0 - 0
coin/dsl_ldap/migrations/__init__.py


+ 159 - 0
coin/dsl_ldap/models.py

@@ -0,0 +1,159 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django.conf import settings
+
+import ldapdb.models
+from ldapdb.models.fields import CharField, ListField
+
+from coin.mixins import CoinLdapSyncMixin
+from coin.configuration.models import Configuration
+from coin import validation, utils
+
+# Implementationo of a simple Radius backend for DSL, stored in LDAP.
+
+# Settings
+# - LDAP branch
+
+class RadiusGroup(models.Model):
+    """This implements a notion of "Radius group", which is mostly useful to
+    support multiple backhaul operators (i.e. each object provides
+    settings for a specific backhaul operator).
+
+    For now, it only changes the realm of the login stored in LDAP, but it
+    might also be used in the future to implement different Radius profile
+    for each group (different L2TP server, etc).  One possible usage could
+    be to load-balance users (possibly on the same backhaul
+    infrastracture) to different L2TP servers by placing them in different
+    Radius groups.
+    """
+    name = models.CharField(max_length=256, verbose_name=_("group name"))
+    realm = models.CharField(max_length=50,
+                             verbose_name=_("radius realm"),
+                             help_text=_('Example: "fdn.nerim"'))
+    suffix = models.CharField(max_length=50, blank=True,
+                              verbose_name=_("suffix"),
+                              help_text=_('Optional suffix added to the login, as a kind of "sub-realm".  Example: "%gnd"'))
+    comment = models.CharField(max_length=256, blank=True,
+                               verbose_name=_("comment"))
+
+    def __unicode__(self):
+        return "{} ({}@{})".format(self.name, self.suffix, self.realm)
+
+    class Meta:
+        verbose_name = _("radius group")
+        verbose_name_plural = _("radius groups")
+
+
+class DSLConfiguration(CoinLdapSyncMixin, Configuration):
+    """TODO: make sure that the (full) login never changes, because it's a
+    LDAP primary key.  Or at least delete the old object and create a new
+    one when the login changes.
+    """
+    url_namespace = "dsl_ldap"
+    phone_number = models.CharField(max_length=20,
+                                    verbose_name=_('phone number'),
+                                    help_text=_("Phone number associated to the DSL line"))
+    activated = models.BooleanField(default=False, verbose_name=_('activated'))
+    radius_group = models.ForeignKey(RadiusGroup, verbose_name=_("radius group"),
+                                     help_text=_("Group (i.e. backhaul) to use"))
+    # TODO: introduce forbidden characters (and read RFC2865)
+    login = models.CharField(max_length=50, unique=True, blank=True,
+                             verbose_name=_("login"),
+                             help_text=_("Leave empty for automatic generation"))
+    password = models.CharField(max_length=256, blank=True,
+                                verbose_name=_("password"),
+                                help_text=_("Will be stored in cleartext!  Automatically generated if empty"))
+
+    def full_login(self):
+        """Login with realm"""
+        return "{}{}@{}".format(self.login, self.radius_group.suffix,
+                                self.radius_group.realm)
+
+    def clean(self):
+        # Generate DSL login, of the form "login-dslX".
+        if not self.login:
+            username = self.offersubscription.member.username
+            dsl_lines = DSLConfiguration.objects.filter(offersubscription__member__username=username)
+            # This is the list of existing DSL logins for this user.
+            logins = [dsl.login for dsl in dsl_lines]
+            # 100 DSL lines ought to be enough for anybody.
+            for login in ["{}-dsl{}".format(username, k) for k in range(1, 101)]:
+                if login not in logins:
+                    self.login = login
+                    break
+            # We may have failed.
+            if not self.login:
+                ValidationError("Impossible de générer un login DSL")
+        # Generate password: 8 lowercase letters.  We don't really care
+        # about security, since 1/ we store it in cleartext 2/ to
+        # bruteforce it, you must have a DSL line on the same backhaul
+        # infrastructure.  On the other hand, it must be easy to copy it
+        # by hand to configure a modem.
+        if not self.password:
+            self.password = utils.generate_weak_password(8)
+
+    # This method is part of the general configuration interface.
+    def subnet_event(self):
+        self.sync_to_ldap(False)
+
+    def get_subnets(self, version):
+        subnets = self.ip_subnet.all()
+        return [subnet for subnet in subnets if subnet.inet.version == version]
+
+    def sync_to_ldap(self, creation, *args, **kwargs):
+        if creation:
+            config = LdapDSLConfig()
+        else:
+            config = LdapDSLConfig.objects.get(pk=self.full_login())
+        config.login = self.full_login()
+        config.cleartext_password = self.password
+        config.password = utils.ldap_hash(self.password)
+        config.active = 'yes' if self.activated else 'no'
+        v4_subnets = self.get_subnets(4)
+        v6_subnets = self.get_subnets(6)
+        # Instead of having an explicit IPv4 endpoint in the model, we
+        # simply take the first IPv4 subnet (which we assume is a /32)
+        if len(v4_subnets) > 0:
+            config.ipv4_endpoint = str(v4_subnets[0].inet.ip)
+        else:
+            config.ipv4_endpoint = None
+        config.ranges_v4 = [str(s) for s in v4_subnets]
+        if len(v6_subnets) > 0:
+            config.ranges_v6 = [str(s) for s in v6_subnets]
+        else:
+            # This field is multi-valued, but mandatory... Hack hack hack.
+            config.ranges_v6 = ["fd20:fd79:5eb6:5cc5::/64"]
+        config.save()
+
+    def delete_from_ldap(self):
+        LdapDSLConfig.objects.get(pk=self.full_login()).delete()
+
+    def __unicode__(self):
+        return self.full_login()
+
+    class Meta:
+        verbose_name = _("DSL line")
+        verbose_name_plural = _("DSL lines")
+
+
+class LdapDSLConfig(ldapdb.models.Model):
+    base_dn = settings.DSL_CONF_BASE_DN # "ou=radius,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
+    object_classes = [b'top', b'radiusObjectProfile', b'radiusprofile', b'ipHost']
+
+    login = CharField(db_column=b'cn', primary_key=True, max_length=255)
+    password = CharField(db_column=b'userPassword', max_length=255)
+    cleartext_password = CharField(db_column=b'description', max_length=255)
+    active = CharField(db_column=b'dialupAccess', max_length=3)
+    ipv4_endpoint = CharField(db_column=b'radiusFramedIPAddress', max_length=16)
+    ranges_v4 = ListField(db_column=b'radiusFramedRoute')
+    # This field is multi-valued, but mandatory...
+    ranges_v6 = ListField(db_column=b'ipHostNumber')
+
+    def __unicode__(self):
+        return self.login
+
+    class Meta:
+        managed = False  # Indique à South de ne pas gérer le model LdapUser

+ 6 - 0
coin/dsl_ldap/tests.py

@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.test import TestCase
+
+# Create your tests here.

+ 6 - 0
coin/dsl_ldap/views.py

@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.shortcuts import render
+
+# Create your views here.

+ 1 - 0
coin/settings.py

@@ -226,6 +226,7 @@ LDAP_ACTIVATE = False
 # Not setting them results in NameError
 LDAP_USER_BASE_DN = None
 VPN_CONF_BASE_DN = None
+DSL_CONF_BASE_DN = None
 
 # Membership configuration
 # Default cotisation in €, per year

+ 10 - 0
coin/utils.py

@@ -2,6 +2,7 @@
 from __future__ import unicode_literals
 
 import os
+import random
 import hashlib
 import binascii
 import base64
@@ -149,6 +150,15 @@ def respects_language(fun):
             return fun(*args, **kwargs)
     return _inner
 
+
+def generate_weak_password(length):
+    """Generates a weak password of the given length.  It only contains
+    lowercase letters in the [a-z] range.  Don't use this if you have
+    serious security requirements...
+    """
+    return "".join(["%c" % random.randrange(0x61, 0x7B) for i in range(length)])
+
+
 if __name__ == '__main__':
     # ldap_hash expects an unicode string
     print(ldap_hash(sys.argv[1].decode("utf-8")))