Commit 99cbd1bf authored by Baptiste Jonglez's avatar Baptiste Jonglez Committed by jocelyn
Browse files

Implement a Radius/LDAP-based DSL module

This implements the model, admin, and LDAP export.  Still no user
interface.
parent 0227df9a
Loading
Loading
Loading
Loading
+0 −0

Empty file added.

coin/dsl_ldap/admin.py

0 → 100644
+62 −0
Original line number Diff line number Diff line
# -*- 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

Empty file added.

+159 −0
Original line number Diff line number Diff line
# -*- 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

coin/dsl_ldap/tests.py

0 → 100644
+6 −0
Original line number Diff line number Diff line
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.test import TestCase

# Create your tests here.
Loading