예제 #1
0
class AbstractTopology(OrgMixin, TimeStampedEditableModel):

    label = models.CharField(_('label'), max_length=64)
    parser = models.CharField(
        _('format'),
        choices=PARSERS,
        max_length=128,
        help_text=_('Select topology format'),
    )
    strategy = models.CharField(_('strategy'),
                                max_length=16,
                                choices=STRATEGIES,
                                default='fetch',
                                db_index=True)
    # fetch strategy
    url = models.URLField(
        _('url'),
        blank=True,
        help_text=_('Topology data will be fetched from this URL'
                    ' (FETCH strategy)'),
    )
    # receive strategy
    key = KeyField(
        unique=False,
        db_index=False,
        help_text=_('key needed to update topology from nodes '),
        verbose_name=_('key'),
        blank=True,
    )
    # receive strategy
    expiration_time = models.PositiveIntegerField(
        _('expiration time'),
        default=0,
        help_text=
        _('"Expiration Time" in seconds: setting this to 0 will immediately mark missing links '
          'as down; a value higher than 0 will delay marking missing links as down until the '
          '"modified" field of a link is older than "Expiration Time"'),
    )
    published = models.BooleanField(
        _('published'),
        default=True,
        help_text=_('Unpublished topologies won\'t be updated or '
                    'shown in the visualizer'),
    )

    # the following fields will be filled automatically
    protocol = models.CharField(_('protocol'), max_length=64, blank=True)
    version = models.CharField(_('version'), max_length=24, blank=True)
    revision = models.CharField(_('revision'), max_length=64, blank=True)
    metric = models.CharField(_('metric'), max_length=24, blank=True)

    status = {'added': 'up', 'removed': 'down', 'changed': 'up'}
    action = {'added': 'add', 'changed': 'change', 'removed': 'remove'}

    class Meta:
        verbose_name_plural = _('topologies')
        abstract = True

    def __str__(self):
        return '{0} - {1}'.format(self.label, self.get_parser_display())

    def get_absolute_url(self):
        return reverse('topology_detail', args=[self.pk])

    def clean(self):
        if self.strategy == 'fetch' and not self.url:
            raise ValidationError({
                'url':
                [_('an url must be specified when using FETCH strategy')]
            })
        elif self.strategy == 'receive' and not self.key:
            raise ValidationError({
                'key':
                [_('a key must be specified when using RECEIVE strategy')]
            })

    @cached_property
    def parser_class(self):
        return import_string(self.parser)

    @property
    def link_model(self):
        return self.link_set.model

    @property
    def node_model(self):
        return self.node_set.model

    @property
    def snapshot_model(self):
        return self.snapshot_set.model

    def get_topology_data(self, data=None):
        """
        gets latest topology data
        """
        # if data is ``None`` it will be fetched from ``self.url``
        latest = self.parser_class(data=data, url=self.url, timeout=TIMEOUT)
        # update topology attributes if needed
        changed = False
        for attr in ['protocol', 'version', 'metric']:
            latest_attr = getattr(latest, attr)
            if getattr(self, attr) != latest_attr:
                setattr(self, attr, latest_attr)
                changed = True
        if changed:
            self.save()
        return latest

    def diff(self, data=None):
        """shortcut to netdiff.diff"""
        # if we get an instance of ``self.parser_class`` it means
        # ``self.get_topology_data`` has already been executed by ``receive``
        if isinstance(data, self.parser_class):
            latest = data
        else:
            latest = self.get_topology_data(data)
        current = NetJsonParser(
            self.json(dict=True, omit_down=True, original=True))
        return diff(current, latest)

    def get_nodes_queryset(self):
        return self.node_set.all()

    def get_links_queryset(self):
        return self.link_set.select_related('source', 'target')

    def json(self, dict=False, omit_down=False, original=False, **kwargs):
        """returns a dict that represents a NetJSON NetworkGraph object"""
        nodes = []
        links = []
        links_queryset = self.get_links_queryset()
        # needed to detect links coming back online
        if omit_down:
            links_queryset = links_queryset.filter(status='up')
        # populate graph
        for link in links_queryset:
            links.append(link.json(dict=True, original=original))
        for node in self.get_nodes_queryset():
            nodes.append(node.json(dict=True, original=original))
        netjson = OrderedDict((
            ('type', 'NetworkGraph'),
            ('protocol', self.protocol),
            ('version', self.version),
            ('metric', self.metric),
            ('label', self.label),
            ('id', str(self.id)),
            ('parser', self.parser),
            ('created', self.created),
            ('modified', self.modified),
            ('nodes', nodes),
            ('links', links),
        ))
        if dict:
            return netjson
        return json.dumps(netjson, cls=JSONEncoder, **kwargs)

    def _create_node(self, **kwargs):
        options = dict(organization=self.organization, topology=self)
        options.update(kwargs)
        node = self.node_model(**options)
        return node

    def _create_link(self, **kwargs):
        options = dict(organization=self.organization, topology=self)
        options.update(kwargs)
        link = self.link_model(**options)
        return link

    def _update_added_items(self, items):
        Link, Node = self.link_model, self.node_model

        for node_dict in items.get('nodes', []):
            # if node exists, update its properties
            node = Node.get_from_address(node_dict['id'], topology=self)
            if node:
                self._update_node_properties(node, node_dict, section='added')
                continue
            # if node doesn't exist create new
            addresses = [node_dict['id']]
            addresses += node_dict.get('local_addresses', [])
            label = node_dict.get('label', '')
            properties = node_dict.get('properties', {})
            node = self._create_node(label=label,
                                     addresses=addresses,
                                     properties=properties)
            node.full_clean()
            node.save()

        for link_dict in items.get('links', []):
            link = Link.get_from_nodes(link_dict['source'],
                                       link_dict['target'],
                                       topology=self)
            # if link exists, update its properties
            if link:
                self._update_link_properties(link, link_dict, section='added')
                continue
            # if link does not exist create new
            source = Node.get_from_address(link_dict['source'], self)
            target = Node.get_from_address(link_dict['target'], self)
            link = self._create_link(
                source=source,
                target=target,
                cost=link_dict['cost'],
                cost_text=link_dict['cost_text'],
                properties=link_dict['properties'],
                topology=self,
            )
            link.full_clean()
            link.save()

    def _update_node_properties(self, node, node_dict, section):
        changed = False
        if node.label != node_dict.get('label'):
            changed = True
            node.label = node_dict.get('label')
        local_addresses = node_dict.get('local_addresses')
        if node.addresses != local_addresses:
            changed = True
            node.addresses = [node_dict['id']]
            if local_addresses:
                node.addresses += local_addresses
        if node.properties != node_dict.get('properties'):
            changed = True
            node.properties = node_dict.get('properties')
        # perform writes only if needed
        if changed:
            with log_failure(self.action[section], node):
                node.full_clean()
                node.save()

    def _update_link_properties(self, link, link_dict, section):
        changed = False
        # if status of link is changed
        if self.link_status_changed(link, self.status[section]):
            link.status = self.status[section]
            changed = True
        if link.cost != link_dict.get('cost'):
            link.cost = link_dict.get('cost')
            changed = True
        if link.cost_text != link_dict.get('cost_text'):
            link.cost_text = link_dict.get('cost_text')
            changed = True
        if link.properties != link_dict.get('properties'):
            link.properties = link_dict.get('properties')
            changed = True
        # perform writes only if needed
        if changed:
            with log_failure(self.action[section], link):
                link.full_clean()
                link.save()

    def _update_changed_items(self, items, section='changed'):
        Link, Node = self.link_model, self.node_model
        for node_dict in items.get('nodes', []):
            node = Node.get_from_address(node_dict['id'], topology=self)
            if node:
                self._update_node_properties(node, node_dict, section=section)

        for link_dict in items.get('links', []):
            link = Link.get_from_nodes(link_dict['source'],
                                       link_dict['target'],
                                       topology=self)
            if link:
                self._update_link_properties(link, link_dict, section=section)

    def update(self, data=None):
        """
        Updates topology
        Removed nodes are not deleted or modified
        Links are not deleted straightaway but set as "down"
        """
        diff = self.diff(data)

        if diff['added']:
            self._update_added_items(diff['added'])
        if diff['changed']:
            self._update_changed_items(diff['changed'])
        if diff['removed']:
            Link = self.link_model
            for link_dict in diff['removed'].get('links', []):
                link = Link.get_from_nodes(link_dict['source'],
                                           link_dict['target'],
                                           topology=self)
                if link:
                    self._update_link_properties(link,
                                                 link_dict,
                                                 section='removed')

    def save_snapshot(self, **kwargs):
        """
        Saves the snapshot of topology
        """
        Snapshot = self.snapshot_model
        date = datetime.now().date()
        options = dict(organization=self.organization,
                       topology=self,
                       date=date)
        options.update(kwargs)
        try:
            s = Snapshot.objects.get(**options)
        except Snapshot.DoesNotExist:
            s = Snapshot(**options)
        s.data = self.json()
        s.save()

    def link_status_changed(self, link, status):
        """
        determines if link status has changed,
        takes in consideration also ``strategy`` and ``expiration_time``
        """
        status_changed = link.status != status
        # if status has not changed return ``False`` immediately
        if not status_changed:
            return False
        # if using fetch strategy or
        # using receive strategy and link is coming back up or
        # receive strategy and ``expiration_time == 0``
        elif self.strategy == 'fetch' or status == 'up' or self.expiration_time == 0:
            return True
        # if using receive strategy and expiration_time of link has expired
        elif link.modified < (now() - timedelta(seconds=self.expiration_time)):
            return True
        # if using receive strategy and expiration_time of link has not expired
        return False

    def receive(self, data):
        """
        Receive topology data (RECEIVE strategy)
        expiration_time at 0 means:
          "if a link is missing, mark it as down immediately"
        expiration_time > 0 means:
          "if a link is missing, wait expiration_time seconds before marking it as down"
        """
        if self.expiration_time > 0:
            data = self.get_topology_data(data)
            Link = self.link_model
            netjson = data.json(dict=True)
            # update last modified date of all received links
            for link_dict in netjson['links']:
                link = Link.get_from_nodes(link_dict['source'],
                                           link_dict['target'],
                                           topology=self)
                if link:
                    link.save()
        self.update(data)

    @classmethod
    def update_all(cls, label=None):
        """
        - updates topologies
        - logs failures
        - calls delete_expired_links()
        """
        queryset = cls.objects.filter(published=True, strategy='fetch')
        if label:
            queryset = queryset.filter(label__icontains=label)
        for topology in queryset:
            print_info('Updating topology {0}'.format(topology))
            with log_failure('update', topology):
                topology.update()
        cls().link_model.delete_expired_links()
        cls().node_model.delete_expired_nodes()

    @classmethod
    def save_snapshot_all(cls, label=None):
        """
        - save snapshots of topoogies
        - logs failures
        """
        queryset = cls.objects.filter(published=True)
        if label:
            queryset = queryset.filter(label__icontains=label)
        for topology in queryset:
            print_info('Saving topology {0}'.format(topology))
            with log_failure('save_snapshot', topology):
                topology.save_snapshot()
예제 #2
0
class AbstractVpn(ShareableOrgMixin, BaseConfig):
    """
    Abstract VPN model
    """

    host = models.CharField(max_length=64,
                            help_text=_('VPN server hostname or ip address'))
    ca = models.ForeignKey(
        get_model_name('django_x509', 'Ca'),
        verbose_name=_('Certification Authority'),
        on_delete=models.CASCADE,
    )
    cert = models.ForeignKey(
        get_model_name('django_x509', 'Cert'),
        verbose_name=_('x509 Certificate'),
        help_text=_('leave blank to create automatically'),
        blank=True,
        null=True,
        on_delete=models.CASCADE,
    )
    key = KeyField(db_index=True)
    backend = models.CharField(
        _('VPN backend'),
        choices=app_settings.VPN_BACKENDS,
        max_length=128,
        help_text=_('Select VPN configuration backend'),
    )
    notes = models.TextField(blank=True)
    # diffie hellman parameters are required
    # in some VPN solutions (eg: OpenVPN)
    dh = models.TextField(blank=True)
    # placeholder DH used as default
    # (a new one is generated in the background
    # because it can take some time)
    _placeholder_dh = (
        '-----BEGIN DH PARAMETERS-----\n'
        'MIIBCAKCAQEA1eYGbpFmXaXNhkoWbx+hrGKh8XMaiGSH45QsnMx/AOPtVfRQTTs0\n'
        '0rXgllizgqGP7Ug04+ULK5mxY1xGcm/Sh8s21I4t/HFJzElMmhRVy4B1r3bETzHi\n'
        '7DCUsK2EPi0csofnD5upwu5T6RbBAq0/HTWR/AoW2em5JS1ZhX4JV32nH33EWkl1\n'
        'PzhjVKENl9RQ/DKd+T2edUJU0r1miBqw0Xulf/LVYvwOimcp0WmYtkBJOgf9xEEP\n'
        '3Hd2KG4Ib/vR7v2Z1fdyUgB8dMAElZ2+tK5PM9E9lJmll0fsfrKtcYpgL2mk24vO\n'
        'BbOcwKkB+eBE/B9jqmbG5YYhDo9fQGmNEwIBAg==\n'
        '-----END DH PARAMETERS-----\n')

    __vpn__ = True

    class Meta:
        verbose_name = _('VPN server')
        verbose_name_plural = _('VPN servers')
        abstract = True

    def clean(self, *args, **kwargs):
        """
        * ensure certificate matches CA
        """
        super().clean(*args, **kwargs)
        # certificate must be related to CA
        if self.cert and self.cert.ca.pk != self.ca.pk:
            msg = _('The selected certificate must match the selected CA.')
            raise ValidationError({'cert': msg})
        self._validate_org_relation('ca')
        self._validate_org_relation('cert')

    def save(self, *args, **kwargs):
        """
        Calls _auto_create_cert() if cert is not set
        """
        if not self.cert:
            self.cert = self._auto_create_cert()
        if not self.dh:
            self.dh = self._placeholder_dh
        is_adding = self._state.adding
        super().save(*args, **kwargs)
        if is_adding and self.dh == self._placeholder_dh:
            transaction.on_commit(lambda: create_vpn_dh.delay(self.id))

    @classmethod
    def dhparam(cls, length):
        """
        Returns an automatically generated set of DH parameters in PEM
        """
        return subprocess.check_output(  # pragma: nocover
            'openssl dhparam {0} 2> /dev/null'.format(length),
            shell=True).decode('utf-8')

    def _auto_create_cert(self):
        """
        Automatically generates server x509 certificate
        """
        common_name = slugify(self.name)
        server_extensions = [{
            'name': 'nsCertType',
            'value': 'server',
            'critical': False
        }]
        cert_model = self.__class__.cert.field.related_model
        cert = cert_model(
            name=self.name,
            ca=self.ca,
            key_length=self.ca.key_length,
            digest=self.ca.digest,
            country_code=self.ca.country_code,
            state=self.ca.state,
            city=self.ca.city,
            organization_name=self.ca.organization_name,
            email=self.ca.email,
            common_name=common_name,
            extensions=server_extensions,
        )
        cert = self._auto_create_cert_extra(cert)
        cert.save()
        return cert

    def get_context(self):
        """
        prepares context for netjsonconfig VPN backend
        """
        try:
            c = {'ca': self.ca.certificate}
        except ObjectDoesNotExist:
            c = {}
        if self.cert:
            c.update({
                'cert': self.cert.certificate,
                'key': self.cert.private_key
            })
        if self.dh:
            c.update({'dh': self.dh})
        c.update(super().get_context())
        return c

    def get_system_context(self):
        return self.get_context()

    def _get_auto_context_keys(self):
        """
        returns a dictionary which indicates the names of
        the configuration variables needed to access:
            * path to CA file
            * CA certificate in PEM format
            * path to cert file
            * cert in PEM format
            * path to key file
            * key in PEM format
        """
        pk = self.pk.hex
        return {
            'ca_path': 'ca_path_{0}'.format(pk),
            'ca_contents': 'ca_contents_{0}'.format(pk),
            'cert_path': 'cert_path_{0}'.format(pk),
            'cert_contents': 'cert_contents_{0}'.format(pk),
            'key_path': 'key_path_{0}'.format(pk),
            'key_contents': 'key_contents_{0}'.format(pk),
        }

    def auto_client(self, auto_cert=True):
        """
        calls backend ``auto_client`` method and returns a configuration
        dictionary that is suitable to be used as a template
        if ``auto_cert`` is ``False`` the resulting configuration
        won't include autogenerated key and certificate details
        """
        config = {}
        backend = self.backend_class
        if hasattr(backend, 'auto_client'):
            context_keys = self._get_auto_context_keys()
            # add curly brackets for netjsonconfig context evaluation
            for key in context_keys.keys():
                context_keys[key] = '{{%s}}' % context_keys[key]
            # do not include cert and key if auto_cert is False
            if not auto_cert:
                for key in [
                        'cert_path', 'cert_contents', 'key_path',
                        'key_contents'
                ]:
                    del context_keys[key]
            conifg_dict_key = self.backend_class.__name__.lower()
            auto = backend.auto_client(host=self.host,
                                       server=self.config[conifg_dict_key][0],
                                       **context_keys)
            config.update(auto)
        return config

    def _auto_create_cert_extra(self, cert):
        """
        sets the organization on the created client certificate
        """
        cert.organization = self.organization
        return cert
예제 #3
0
class AbstractOrganizationRadiusSettings(UUIDModel):
    organization = models.OneToOneField(
        swapper.get_model_name('openwisp_users', 'Organization'),
        verbose_name=_('organization'),
        related_name='radius_settings',
        on_delete=models.CASCADE,
    )
    token = KeyField(max_length=32)
    sms_verification = models.BooleanField(
        default=app_settings.SMS_DEFAULT_VERIFICATION,
        help_text=_('whether users who sign up should '
                    'be required to verify their mobile '
                    'phone number via SMS'),
    )
    sms_sender = models.CharField(
        _('Sender'),
        max_length=128,
        blank=True,
        null=True,
        help_text=
        _('alpha numeric identifier used as sender for SMS sent by this organization'
          ),
    )
    sms_meta_data = JSONField(
        null=True,
        blank=True,
        help_text=_(
            'Additional configuration for SMS backend in JSON format (optional)'
        ),
    )
    freeradius_allowed_hosts = models.TextField(
        null=True,
        blank=True,
        help_text=_GET_IP_LIST_HELP_TEXT,
    )
    allowed_mobile_prefixes = models.TextField(
        null=True,
        blank=True,
        help_text=_GET_MOBILE_PREFIX_HELP_TEXT,
    )
    first_name = models.CharField(
        verbose_name=_('first name'),
        help_text=_GET_OPTIONAL_FIELDS_HELP_TEXT,
        max_length=12,
        null=True,
        blank=True,
        choices=OPTIONAL_FIELD_CHOICES,
    )
    last_name = models.CharField(
        verbose_name=_('last name'),
        help_text=_GET_OPTIONAL_FIELDS_HELP_TEXT,
        max_length=12,
        null=True,
        blank=True,
        choices=OPTIONAL_FIELD_CHOICES,
    )
    location = models.CharField(
        verbose_name=_('location'),
        help_text=_GET_OPTIONAL_FIELDS_HELP_TEXT,
        max_length=12,
        null=True,
        blank=True,
        choices=OPTIONAL_FIELD_CHOICES,
    )
    birth_date = models.CharField(
        verbose_name=_('birth date'),
        help_text=_GET_OPTIONAL_FIELDS_HELP_TEXT,
        max_length=12,
        null=True,
        blank=True,
        choices=OPTIONAL_FIELD_CHOICES,
    )
    registration_enabled = models.BooleanField(
        null=True,
        blank=True,
        default=True,
        help_text=_REGISTRATION_ENABLED_HELP_TEXT,
    )

    class Meta:
        verbose_name = _('Organization radius settings')
        verbose_name_plural = verbose_name
        abstract = True

    def __str__(self):
        return self.organization.name

    @property
    def freeradius_allowed_hosts_list(self):
        addresses = []
        if self.freeradius_allowed_hosts:
            addresses = self.freeradius_allowed_hosts.split(',')
        return addresses

    @property
    def allowed_mobile_prefixes_list(self):
        mobile_prefixes = []
        if self.allowed_mobile_prefixes:
            mobile_prefixes = self.allowed_mobile_prefixes.split(',')
        return mobile_prefixes

    def clean(self):
        if self.sms_verification and not self.sms_sender:
            raise ValidationError({
                'sms_sender':
                _('if SMS verification is enabled this field is required.')
            })
        self._clean_freeradius_allowed_hosts()
        self._clean_allowed_mobile_prefixes()
        self._clean_optional_fields()

    def _clean_freeradius_allowed_hosts(self):
        allowed_hosts_set = set(self.freeradius_allowed_hosts_list)
        settings_allowed_hosts_set = set(app_settings.FREERADIUS_ALLOWED_HOSTS)
        if not allowed_hosts_set and not settings_allowed_hosts_set:
            raise ValidationError({
                'freeradius_allowed_hosts':
                _('Cannot be empty when the settings value for '
                  '`OPENWISP_RADIUS_FREERADIUS_ALLOWED_HOSTS` is not provided.'
                  )
            })
        elif allowed_hosts_set:
            if allowed_hosts_set == settings_allowed_hosts_set:
                self.freeradius_allowed_hosts = None
            else:
                try:
                    for ip_address in allowed_hosts_set:
                        ipaddress.ip_network(ip_address)
                except ValueError:
                    raise ValidationError({
                        'freeradius_allowed_hosts':
                        _('Invalid input. Please enter valid ip addresses '
                          'or subnets separated by comma. (no spaces)')
                    })

    def _clean_allowed_mobile_prefixes(self):
        valid_country_codes = phonenumbers.COUNTRY_CODE_TO_REGION_CODE.keys()
        allowed_mobile_prefixes_set = set(self.allowed_mobile_prefixes_list)
        settings_allowed_mobile_prefixes_set = set(
            app_settings.ALLOWED_MOBILE_PREFIXES)
        for code in self.allowed_mobile_prefixes_list:
            if not code or code[0] != '+' or int(
                    code[1:]) not in valid_country_codes:
                raise ValidationError({
                    'allowed_mobile_prefixes':
                    _('Invalid input. Please enter valid mobile '
                      'prefixes separated by comma. (no spaces)')
                })

        if allowed_mobile_prefixes_set == settings_allowed_mobile_prefixes_set:
            self.allowed_mobile_prefixes = None

    def _clean_optional_fields(self):
        global_settings = app_settings.OPTIONAL_REGISTRATION_FIELDS
        for field in ['first_name', 'last_name', 'location', 'birth_date']:
            if getattr(self, field) == global_settings.get(field):
                setattr(self, field, None)

    def save_cache(self, *args, **kwargs):
        cache.set(self.organization.pk, self.token)
        cache.set(f'ip-{self.organization.pk}',
                  self.freeradius_allowed_hosts_list)

    def delete_cache(self, *args, **kwargs):
        cache.delete(self.organization.pk)
        cache.delete(f'ip-{self.organization.pk}')
예제 #4
0
class AbstractDevice(OrgMixin, BaseModel):
    """
    Base device model
    Stores information related to the
    physical properties of a network device
    """

    _changed_checked_fields = ['name', 'group_id', 'management_ip']

    name = models.CharField(
        max_length=64,
        unique=False,
        validators=[device_name_validator],
        db_index=True,
        help_text=_('must be either a valid hostname or mac address'),
    )
    mac_address = models.CharField(
        max_length=17,
        db_index=True,
        unique=False,
        validators=[mac_address_validator],
        help_text=_('primary mac address'),
    )
    key = KeyField(
        unique=True,
        blank=True,
        default=None,
        db_index=True,
        help_text=_('unique device key'),
    )
    model = models.CharField(
        max_length=64,
        blank=True,
        db_index=True,
        help_text=_('device model and manufacturer'),
    )
    os = models.CharField(
        _('operating system'),
        blank=True,
        db_index=True,
        max_length=128,
        help_text=_('operating system identifier'),
    )
    system = models.CharField(
        _('SOC / CPU'),
        blank=True,
        db_index=True,
        max_length=128,
        help_text=_('system on chip or CPU info'),
    )
    notes = models.TextField(blank=True, help_text=_('internal notes'))
    group = models.ForeignKey(
        get_model_name('config', 'DeviceGroup'),
        verbose_name=_('group'),
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
    )
    # these fields are filled automatically
    # with data received from devices
    last_ip = models.GenericIPAddressField(
        blank=True,
        null=True,
        db_index=True,
        help_text=_(
            'indicates the IP address logged from '
            'the last request coming from the device'
        ),
    )
    management_ip = models.GenericIPAddressField(
        blank=True,
        null=True,
        db_index=True,
        help_text=_(
            'IP address used by OpenWISP to reach the device when performing '
            'any type of push operation or active check. The value of this field is '
            'generally sent by the device and hence does not need to be changed, '
            'but can be changed or cleared manually if needed.'
        ),
    )
    hardware_id = models.CharField(**(app_settings.HARDWARE_ID_OPTIONS))

    class Meta:
        unique_together = (
            ('mac_address', 'organization'),
            ('hardware_id', 'organization'),
        )
        abstract = True
        verbose_name = app_settings.DEVICE_VERBOSE_NAME[0]
        verbose_name_plural = app_settings.DEVICE_VERBOSE_NAME[1]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._set_initial_values_for_changed_checked_fields()

    def _set_initial_values_for_changed_checked_fields(self):
        for field in self._changed_checked_fields:
            if self._is_deferred(field):
                setattr(self, f'_initial_{field}', models.DEFERRED)
            else:
                setattr(self, f'_initial_{field}', getattr(self, field))

    def __str__(self):
        return (
            self.hardware_id
            if (app_settings.HARDWARE_ID_ENABLED and app_settings.HARDWARE_ID_AS_NAME)
            else self.name
        )

    def _has_config(self):
        return hasattr(self, 'config')

    def _get_config_attr(self, attr):
        """
        gets property or calls method of related config object
        without rasing an exception if config is not set
        """
        if not self._has_config():
            return None
        attr = getattr(self.config, attr)
        return attr() if callable(attr) else attr

    def _get_config(self):
        if self._has_config():
            return self.config
        else:
            return self.get_config_model()(device=self)

    def get_context(self):
        config = self._get_config()
        return config.get_context()

    def get_system_context(self):
        config = self._get_config()
        return config.get_system_context()

    def generate_key(self, shared_secret):
        if app_settings.CONSISTENT_REGISTRATION:
            keybase = (
                self.hardware_id
                if app_settings.HARDWARE_ID_ENABLED
                else self.mac_address
            )
            hash_key = md5('{}+{}'.format(keybase, shared_secret).encode('utf-8'))
            return hash_key.hexdigest()
        else:
            return KeyField.default_callable()

    def _validate_unique_name(self):
        if app_settings.DEVICE_NAME_UNIQUE:
            if (
                hasattr(self, 'organization')
                and self._meta.model.objects.filter(
                    ~Q(id=self.id),
                    organization=self.organization,
                    name__iexact=self.name,
                ).exists()
            ):
                raise ValidationError(
                    _('Device with this Name and Organization already exists.')
                )

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        self._validate_unique_name()
        self._validate_org_relation('group', field_error='group')

    def save(self, *args, **kwargs):
        if not self.key:
            try:
                shared_secret = self.organization.config_settings.shared_secret
            except ObjectDoesNotExist:
                # should not happen, but if organization config settings
                # is not defined the default key will default to being random
                self.key = KeyField.default_callable()
            else:
                self.key = self.generate_key(shared_secret)
        state_adding = self._state.adding
        super().save(*args, **kwargs)
        if state_adding and self.group and self.group.templates.exists():
            self.create_default_config()
        # The value of "self._state.adding" will always be "False"
        # after performing the save operation. Hence, the actual value
        # is stored in the "state_adding" variable.
        if not state_adding:
            self._check_changed_fields()

    def _check_changed_fields(self):
        self._get_initial_values_for_checked_fields()
        # Execute method for checked for each field in self._changed_checked_fields
        for field in self._changed_checked_fields:
            getattr(self, f'_check_{field}_changed')()

    def _is_deferred(self, field):
        """
        Return a boolean whether the field is deferred.
        """
        return field in self.get_deferred_fields()

    def _get_initial_values_for_checked_fields(self):
        # Refresh values from database only when the checked field
        # was initially deferred, but is no longer deferred now.
        # Store the present value of such fields because they will
        # be overwritten fetching values from database
        # NOTE: Initial value of a field will only remain deferred
        # if the current value of the field is still deferred. This
        present_values = dict()
        for field in self._changed_checked_fields:
            if getattr(
                self, f'_initial_{field}'
            ) == models.DEFERRED and not self._is_deferred(field):
                present_values[field] = getattr(self, field)
        # Skip fetching values from database if all of the checked fields are
        # still deferred, or were not deferred from the begining.
        if not present_values:
            return
        self.refresh_from_db(fields=present_values.keys())
        for field in self._changed_checked_fields:
            setattr(self, f'_initial_{field}', field)
            setattr(self, field, present_values[field])

    def _check_name_changed(self):
        if self._initial_name == models.DEFERRED:
            return

        if self._initial_name != self.name:
            device_name_changed.send(
                sender=self.__class__,
                instance=self,
            )

            if self._has_config():
                self.config.set_status_modified()

    def _check_group_id_changed(self):
        if self._initial_group_id == models.DEFERRED:
            return

        if self._initial_group_id != self.group_id:
            self._send_device_group_changed_signal(
                self, self.group_id, self._initial_group_id
            )

    def _check_management_ip_changed(self):
        if self._initial_management_ip == models.DEFERRED:
            return
        if self.management_ip != self._initial_management_ip:
            management_ip_changed.send(
                sender=self.__class__,
                management_ip=self.management_ip,
                old_management_ip=self._initial_management_ip,
                instance=self,
            )

        self._initial_management_ip = self.management_ip

    @classmethod
    def _send_device_group_changed_signal(cls, instance, group_id, old_group_id):
        """
        Emits ``device_group_changed`` signal.
        Called also by ``change_device_group`` admin action method.
        """
        device_group_changed.send(
            sender=cls,
            instance=instance,
            group_id=group_id,
            old_group_id=old_group_id,
        )

    @property
    def backend(self):
        """
        Used as a shortcut for display purposes
        (eg: admin site)
        """
        return self._get_config_attr('get_backend_display')

    @property
    def status(self):
        """
        Used as a shortcut for display purposes
        (eg: admin site)
        """
        return self._get_config_attr('get_status_display')

    def get_default_templates(self):
        """
        calls `get_default_templates` of related
        config object (or new config instance)
        """
        if self._has_config():
            config = self.config
        else:
            config = self.get_temp_config_instance()
        return config.get_default_templates()

    @classmethod
    def get_config_model(cls):
        return cls._meta.get_field('config').related_model

    def get_temp_config_instance(self, **options):
        config = self.get_config_model()(**options)
        config.device = self
        return config

    def can_be_updated(self):
        """
        returns True if the device can and should be updated
        can be overridden with custom logic if needed
        """
        return self.config.status != 'applied'

    def create_default_config(self, **options):
        """
        creates a new config instance to apply group templates
        if group has templates.
        """
        if not (self.group and self.group.templates.exists()):
            return
        config = self.get_temp_config_instance(
            backend=app_settings.DEFAULT_BACKEND, **options
        )
        config.save()

    @classmethod
    def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id):
        """
        This method is used to manage group templates for devices.
        """
        Device = load_model('config', 'Device')
        DeviceGroup = load_model('config', 'DeviceGroup')
        Template = load_model('config', 'Template')
        if type(device_ids) is not list:
            device_ids = [device_ids]
            old_group_ids = [old_group_ids]
        for device_id, old_group_id in zip(device_ids, old_group_ids):
            device = Device.objects.get(pk=device_id)
            if not hasattr(device, 'config'):
                device.create_default_config()
            config_created = hasattr(device, 'config')
            if not config_created:
                # device has no config (device group has no templates)
                return
            group_templates = Template.objects.none()
            if group_id:
                group = DeviceGroup.objects.get(pk=group_id)
                group_templates = group.templates.all()
            old_group_templates = Template.objects.none()
            if old_group_id:
                old_group = DeviceGroup.objects.get(pk=old_group_id)
                old_group_templates = old_group.templates.all()
            device.config.manage_group_templates(group_templates, old_group_templates)
예제 #5
0
class AbstractVpn(ShareableOrgMixin, BaseConfig):
    """
    Abstract VPN model
    """

    host = models.CharField(max_length=64,
                            help_text=_('VPN server hostname or ip address'))
    ca = models.ForeignKey(
        get_model_name('django_x509', 'Ca'),
        verbose_name=_('Certification Authority'),
        on_delete=models.CASCADE,
    )
    cert = models.ForeignKey(
        get_model_name('django_x509', 'Cert'),
        verbose_name=_('x509 Certificate'),
        help_text=_('leave blank to create automatically'),
        blank=True,
        null=True,
        on_delete=models.CASCADE,
    )
    key = KeyField(db_index=True)
    backend = models.CharField(
        _('VPN backend'),
        choices=app_settings.VPN_BACKENDS,
        max_length=128,
        help_text=_('Select VPN configuration backend'),
    )
    notes = models.TextField(blank=True)
    # diffie hellman parameters are required
    # in some VPN solutions (eg: OpenVPN)
    dh = models.TextField(blank=True)

    __vpn__ = True

    class Meta:
        verbose_name = _('VPN server')
        verbose_name_plural = _('VPN servers')
        abstract = True

    def clean(self, *args, **kwargs):
        """
        * ensure certificate matches CA
        """
        super().clean(*args, **kwargs)
        # certificate must be related to CA
        if self.cert and self.cert.ca.pk != self.ca.pk:
            msg = _('The selected certificate must match the selected CA.')
            raise ValidationError({'cert': msg})
        self._validate_org_relation('ca')
        self._validate_org_relation('cert')

    def save(self, *args, **kwargs):
        """
        Calls _auto_create_cert() if cert is not set
        """
        if not self.cert:
            self.cert = self._auto_create_cert()
        if not self.dh:
            self.dh = self.dhparam(2048)
        super().save(*args, **kwargs)

    @classmethod
    def dhparam(cls, length):
        """
        Returns an automatically generated set of DH parameters in PEM
        """
        return subprocess.check_output(  # pragma: nocover
            'openssl dhparam {0} 2> /dev/null'.format(length),
            shell=True).decode('utf-8')

    def _auto_create_cert(self):
        """
        Automatically generates server x509 certificate
        """
        common_name = slugify(self.name)
        server_extensions = [{
            'name': 'nsCertType',
            'value': 'server',
            'critical': False
        }]
        cert_model = self.__class__.cert.field.related_model
        cert = cert_model(
            name=self.name,
            ca=self.ca,
            key_length=self.ca.key_length,
            digest=self.ca.digest,
            country_code=self.ca.country_code,
            state=self.ca.state,
            city=self.ca.city,
            organization_name=self.ca.organization_name,
            email=self.ca.email,
            common_name=common_name,
            extensions=server_extensions,
        )
        cert = self._auto_create_cert_extra(cert)
        cert.save()
        return cert

    def get_context(self):
        """
        prepares context for netjsonconfig VPN backend
        """
        try:
            c = {'ca': self.ca.certificate}
        except ObjectDoesNotExist:
            c = {}
        if self.cert:
            c.update({
                'cert': self.cert.certificate,
                'key': self.cert.private_key
            })
        if self.dh:
            c.update({'dh': self.dh})
        c.update(super().get_context())
        return c

    def _get_auto_context_keys(self):
        """
        returns a dictionary which indicates the names of
        the configuration variables needed to access:
            * path to CA file
            * CA certificate in PEM format
            * path to cert file
            * cert in PEM format
            * path to key file
            * key in PEM format
        """
        pk = self.pk.hex
        return {
            'ca_path': 'ca_path_{0}'.format(pk),
            'ca_contents': 'ca_contents_{0}'.format(pk),
            'cert_path': 'cert_path_{0}'.format(pk),
            'cert_contents': 'cert_contents_{0}'.format(pk),
            'key_path': 'key_path_{0}'.format(pk),
            'key_contents': 'key_contents_{0}'.format(pk),
        }

    def auto_client(self, auto_cert=True):
        """
        calls backend ``auto_client`` method and returns a configuration
        dictionary that is suitable to be used as a template
        if ``auto_cert`` is ``False`` the resulting configuration
        won't include autogenerated key and certificate details
        """
        config = {}
        backend = self.backend_class
        if hasattr(backend, 'auto_client'):
            context_keys = self._get_auto_context_keys()
            # add curly brackets for netjsonconfig context evaluation
            for key in context_keys.keys():
                context_keys[key] = '{{%s}}' % context_keys[key]
            # do not include cert and key if auto_cert is False
            if not auto_cert:
                for key in [
                        'cert_path', 'cert_contents', 'key_path',
                        'key_contents'
                ]:
                    del context_keys[key]
            conifg_dict_key = self.backend_class.__name__.lower()
            auto = backend.auto_client(host=self.host,
                                       server=self.config[conifg_dict_key][0],
                                       **context_keys)
            config.update(auto)
        return config

    def _auto_create_cert_extra(self, cert):
        """
        sets the organization on the created client certificate
        """
        cert.organization = self.organization
        return cert
예제 #6
0
class AbstractVpn(ShareableOrgMixinUniqueName, BaseConfig):
    """
    Abstract VPN model
    """

    host = models.CharField(max_length=64,
                            help_text=_('VPN server hostname or ip address'))
    ca = models.ForeignKey(
        get_model_name('django_x509', 'Ca'),
        verbose_name=_('Certification Authority'),
        on_delete=models.CASCADE,
        blank=True,
        null=True,
    )
    cert = models.ForeignKey(
        get_model_name('django_x509', 'Cert'),
        verbose_name=_('x509 Certificate'),
        help_text=_('leave blank to create automatically'),
        blank=True,
        null=True,
        on_delete=models.CASCADE,
    )
    key = KeyField(db_index=True)
    backend = models.CharField(
        _('VPN backend'),
        choices=app_settings.VPN_BACKENDS,
        max_length=128,
        help_text=_('Select VPN configuration backend'),
    )
    notes = models.TextField(blank=True)
    # optional, needed for VPNs which do not support automatic IP allocation
    subnet = models.ForeignKey(
        get_model_name('openwisp_ipam', 'Subnet'),
        verbose_name=_('Subnet'),
        help_text=_('Subnet IP addresses used by VPN clients, if applicable'),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )
    ip = models.ForeignKey(
        get_model_name('openwisp_ipam', 'IpAddress'),
        verbose_name=_('Internal IP'),
        help_text=_(
            'Internal IP address of the VPN server interface, if applicable'),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )
    # optional, helpful for updating WireGuard and VXLAN server configuration
    webhook_endpoint = models.CharField(
        verbose_name=_('Webhook Endpoint'),
        help_text=_(
            'Webhook to trigger for updating server configuration '
            '(e.g. https://openwisp2.mydomain.com:8081/trigger-update)'),
        max_length=128,
        blank=True,
        null=True,
    )
    auth_token = models.CharField(
        verbose_name=_('Webhook AuthToken'),
        help_text=_('Authentication token for triggering "Webhook Endpoint"'),
        max_length=128,
        blank=True,
        null=True,
    )
    # diffie hellman parameters are required
    # in some VPN solutions (eg: OpenVPN)
    dh = models.TextField(blank=True)
    # placeholder DH used as default
    # (a new one is generated in the background
    # because it can take some time)
    _placeholder_dh = (
        '-----BEGIN DH PARAMETERS-----\n'
        'MIIBCAKCAQEA1eYGbpFmXaXNhkoWbx+hrGKh8XMaiGSH45QsnMx/AOPtVfRQTTs0\n'
        '0rXgllizgqGP7Ug04+ULK5mxY1xGcm/Sh8s21I4t/HFJzElMmhRVy4B1r3bETzHi\n'
        '7DCUsK2EPi0csofnD5upwu5T6RbBAq0/HTWR/AoW2em5JS1ZhX4JV32nH33EWkl1\n'
        'PzhjVKENl9RQ/DKd+T2edUJU0r1miBqw0Xulf/LVYvwOimcp0WmYtkBJOgf9xEEP\n'
        '3Hd2KG4Ib/vR7v2Z1fdyUgB8dMAElZ2+tK5PM9E9lJmll0fsfrKtcYpgL2mk24vO\n'
        'BbOcwKkB+eBE/B9jqmbG5YYhDo9fQGmNEwIBAg==\n'
        '-----END DH PARAMETERS-----\n')
    # needed for wireguard
    public_key = models.CharField(blank=True, max_length=44)
    private_key = models.CharField(blank=True, max_length=44)

    __vpn__ = True

    # cache wireguard / vxlan peers for 7 days (generation is expensive)
    _PEER_CACHE_TIMEOUT = 60 * 60 * 24 * 7

    class Meta:
        verbose_name = _('VPN server')
        verbose_name_plural = _('VPN servers')
        unique_together = ('organization', 'name')
        abstract = True

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # for internal usage
        self._send_vpn_modified_after_save = False

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        self._validate_backend()
        self._validate_certs()
        self._validate_keys()
        self._validate_org_relation('ca')
        self._validate_org_relation('cert')
        self._validate_org_relation('subnet')
        self._validate_subnet_ip()

    def _validate_backend(self):
        if self._state.adding:
            return
        if ('backend' not in self.get_deferred_fields()
                and self._meta.model.objects.only('backend').get(
                    id=self.id).backend != self.backend
                and self.vpnclient_set.exists()):
            raise ValidationError({
                'backend':
                _('Backend cannot be changed because the VPN is currently in use.'
                  )
            })

    def _validate_certs(self):
        if not self._is_backend_type('openvpn'):
            self.ca = None
            self.cert = None
            return

        if not self.ca:
            raise ValidationError(
                {'ca': _('CA is required with this VPN backend')})
        # certificate must be related to CA
        if self.cert and self.cert.ca.pk != self.ca.pk:
            msg = _('The selected certificate must match the selected CA.')
            raise ValidationError({'cert': msg})

    def _validate_keys(self):
        if not self._is_backend_type('wireguard'):
            self.public_key = ''
            self.private_key = ''

    def _validate_subnet_ip(self):
        if self._is_backend_type('openvpn'):
            self.subnet = None
            self.ip = None
        elif self._is_backend_type('wireguard'):
            if not self.subnet:
                raise ValidationError(
                    {'subnet': _('Subnet is required for this VPN backend.')})
            if self.ip and self.ip.subnet != self.subnet:
                raise ValidationError(
                    {'ip': _('VPN IP address must be within the VPN subnet')})

    def save(self, *args, **kwargs):
        """
        Calls _auto_create_cert() if cert is not set
        """
        created = self._state.adding
        if not created:
            self._check_changes()
        create_dh = False
        if not self.cert and self.ca:
            self.cert = self._auto_create_cert()
        if self._is_backend_type('openvpn') and not self.dh:
            self.dh = self._placeholder_dh
            create_dh = True
        if self._is_backend_type('wireguard'):
            self._generate_wireguard_keys()
        if self.subnet and not self.ip:
            self.ip = self._auto_create_ip()
        super().save(*args, **kwargs)
        if create_dh:
            transaction.on_commit(lambda: create_vpn_dh.delay(self.id))
        if not created and self._send_vpn_modified_after_save:
            self._send_vpn_modified_signal()
            self._send_vpn_modified_after_save = False
        self.update_vpn_server_configuration()

    def _check_changes(self):
        attrs = [
            'config',
            'host',
            'ca',
            'cert',
            'key',
            'backend',
            'subnet',
            'ip',
            'dh',
            'public_key',
            'private_key',
        ]
        current = self._meta.model.objects.only(*attrs).get(pk=self.pk)
        for attr in attrs:
            if getattr(self, attr) == getattr(current, attr):
                continue
            self._send_vpn_modified_after_save = True
            break

    def _send_vpn_modified_signal(self):
        vpn_server_modified.send(sender=self.__class__, instance=self)

    @classmethod
    def dhparam(cls, length):
        """
        Returns an automatically generated set of DH parameters in PEM
        """
        return subprocess.check_output(  # pragma: nocover
            'openssl dhparam {0} 2> /dev/null'.format(length),
            shell=True).decode('utf-8')

    def update_vpn_server_configuration(instance, **kwargs):
        if not instance._is_backend_type('wireguard'):
            return
        if instance.webhook_endpoint and instance.auth_token:
            transaction.on_commit(lambda: trigger_vpn_server_endpoint.delay(
                endpoint=instance.webhook_endpoint,
                auth_token=instance.auth_token,
                vpn_id=instance.pk,
            ))
        else:
            logger.info(
                f'Cannot update configuration of {instance.name} VPN server, '
                'webhook endpoint and authentication token are empty.')

    def _auto_create_cert(self):
        """
        Automatically generates server x509 certificate
        """
        common_name = slugify(self.name)
        server_extensions = [{
            'name': 'nsCertType',
            'value': 'server',
            'critical': False
        }]
        cert_model = self.__class__.cert.field.related_model
        cert = cert_model(
            name=self.name,
            ca=self.ca,
            key_length=self.ca.key_length,
            digest=self.ca.digest,
            country_code=self.ca.country_code,
            state=self.ca.state,
            city=self.ca.city,
            organization_name=self.ca.organization_name,
            email=self.ca.email,
            common_name=common_name,
            extensions=server_extensions,
        )
        cert = self._auto_create_cert_extra(cert)
        cert.save()
        return cert

    def _auto_create_ip(self):
        """
        Automatically generates host IP address
        """
        return self.subnet.request_ip()

    def get_context(self):
        """
        prepares context for netjsonconfig VPN backend
        """
        c = collections.OrderedDict()
        if self.ca:
            try:
                c['ca'] = self.ca.certificate
            except ObjectDoesNotExist:
                pass
        if self.cert:
            c['cert'] = self.cert.certificate
            c['key'] = self.cert.private_key
        if self.dh:
            c['dh'] = self.dh
        if self.private_key:
            c['private_key'] = self.private_key
        if self.public_key:
            c['public_key'] = self.public_key
        if self.subnet:
            c['subnet'] = str(self.subnet.subnet)
            c['subnet_prefixlen'] = str(self.subnet.subnet.prefixlen)
        if self.ip:
            c['ip_address'] = self.ip.ip_address
        c.update(sorted(super().get_context().items()))
        return c

    def get_vpn_server_context(self):
        context = {}
        context_keys = self._get_auto_context_keys()
        if self.host:
            context[context_keys['vpn_host']] = self.host
        if self._is_backend_type('wireguard'):
            context[
                context_keys['vpn_port']] = self.config['wireguard'][0]['port']
        if self.ca:
            ca = self.ca
            # CA
            ca_filename = 'ca-{0}-{1}.pem'.format(
                ca.pk, ca.common_name.replace(' ', '_'))
            ca_path = '{0}/{1}'.format(app_settings.CERT_PATH, ca_filename)
            context.update({
                context_keys['ca_path']: ca_path,
                context_keys['ca_contents']: ca.certificate,
            })
        if self.public_key:
            context[context_keys['public_key']] = self.public_key
        if self.ip:
            context[context_keys['server_ip_address']] = self.ip.ip_address
            context[context_keys[
                'server_ip_network']] = f'{self.ip.ip_address}/{self.subnet.subnet.max_prefixlen}'
            context[context_keys['vpn_subnet']] = str(self.subnet.subnet)
        return context

    def get_system_context(self):
        return self.get_context()

    def _is_backend_type(self, backend_type):
        """
        returns true if the backend path used converted to lowercase
        contains ``backend_type``.
        Checking for the exact path may not be the best choices
        given backends can be extended and customized.
        By using this method, customizations will just have
        to maintain the naming consistent.
        """
        return backend_type.lower() in self.backend.lower()

    def _get_auto_context_keys(self):
        """
        returns a dictionary which indicates the names of
        the configuration variables needed to access:
            * path to CA file
            * CA certificate in PEM format
            * path to cert file
            * cert in PEM format
            * path to key file
            * key in PEM format
        WireGuard:
            * public key
            * ip address
        VXLAN:
            * vni (VXLAN Network Identifier)
        """
        pk = self.pk.hex
        context_keys = {
            'vpn_host': 'vpn_host_{}'.format(pk),
            'vpn_port': 'vpn_port_{}'.format(pk),
        }
        if self._is_backend_type('openvpn'):
            context_keys.update({
                'ca_path': 'ca_path_{0}'.format(pk),
                'ca_contents': 'ca_contents_{0}'.format(pk),
                'cert_path': 'cert_path_{0}'.format(pk),
                'cert_contents': 'cert_contents_{0}'.format(pk),
                'key_path': 'key_path_{0}'.format(pk),
                'key_contents': 'key_contents_{0}'.format(pk),
            })
        if self._is_backend_type('wireguard'):
            context_keys.update({
                'public_key': 'public_key_{}'.format(pk),
                'ip_address': 'ip_address_{}'.format(pk),
                'vpn_subnet': 'vpn_subnet_{}'.format(pk),
                'private_key': 'pvt_key_{}'.format(pk),
            })
        if self._is_backend_type('vxlan'):
            context_keys.update({'vni': 'vni_{}'.format(pk)})
        if self.ip:
            context_keys.update({
                'server_ip_address':
                'server_ip_address_{}'.format(pk),
                'server_ip_network':
                'server_ip_network_{}'.format(pk),
            })
        return context_keys

    def auto_client(self, auto_cert=True, template_backend_class=None):
        """
        calls backend ``auto_client`` method and returns a configuration
        dictionary that is suitable to be used as a template
        if ``auto_cert`` is ``False`` the resulting configuration
        won't include autogenerated key and certificate details
        """
        config = {}
        backend = self.backend_class
        if hasattr(backend, 'auto_client'):
            context_keys = self._get_auto_context_keys()
            # add curly brackets for netjsonconfig context evaluation
            for key in context_keys.keys():
                context_keys[key] = '{{%s}}' % context_keys[key]
            # do not include cert and key if auto_cert is False
            if not auto_cert:
                for key in [
                        'cert_path', 'cert_contents', 'key_path',
                        'key_contents'
                ]:
                    del context_keys[key]
            config_dict_key = self.backend_class.__name__.lower()
            vpn_host = context_keys.pop('vpn_host', self.host)
            if self._is_backend_type('wireguard') and template_backend_class:
                vpn_auto_client = '{}wireguard_auto_client'.format(
                    'vxlan_' if self._is_backend_type('vxlan') else '')
                auto = getattr(template_backend_class, vpn_auto_client)(
                    host=vpn_host,
                    server=self.config['wireguard'][0],
                    **context_keys,
                )
            else:
                del context_keys['vpn_port']
                auto = backend.auto_client(
                    host=self.host,
                    server=self.config[config_dict_key][0],
                    **context_keys,
                )
            config.update(auto)
        return config

    def _auto_create_cert_extra(self, cert):
        """
        sets the organization on the created client certificate
        """
        cert.organization = self.organization
        return cert

    def _generate_wireguard_keys(self):
        """
        generates wireguard private and public keys
        and set the respctive attributes
        """
        if not self.private_key or not self.public_key:
            self.private_key, self.public_key = crypto.generate_wireguard_keys(
            )

    def get_config(self):
        config = super().get_config()
        if self._is_backend_type('wireguard'):
            self._add_wireguard(config)
        if self._is_backend_type('vxlan'):
            self._add_vxlan(config)
        return config

    def _invalidate_peer_cache(self, update=False):
        """
        Invalidates peer cache, if update=True is passed,
        the peer cache will be regenerated
        """
        if self._is_backend_type('wireguard'):
            self._get_wireguard_peers.invalidate(self)
            if update:
                self._get_wireguard_peers()
        if self._is_backend_type('vxlan'):
            self._get_vxlan_peers.invalidate(self)
            if update:
                self._get_vxlan_peers()
        # Send signal for peers changed
        vpn_peers_changed.send(sender=self.__class__, instance=self)

    def _get_peer_queryset(self):
        """
        returns an iterator to iterate over tunnel peers
        used to generate the list of peers of a tunnel (WireGuard/VXLAN)
        """
        return (self.vpnclient_set.select_related(
            'config', 'ip').filter(auto_cert=True).only(
                'id',
                'vpn_id',
                'vni',
                'public_key',
                'config__device_id',
                'config__status',
                'ip__ip_address',
            ).iterator())

    def _add_wireguard(self, config):
        """
        Adds wireguard peers and private key to the generated
        configuration without the need of manual intervention.
        Modifies the config data structure as a side effect.
        """
        try:
            config['wireguard'][0].setdefault('peers', [])
        except (KeyError, IndexError):
            # this error will be handled by
            # schema validation in subsequent steps
            return config
        # private key is added to the config automatically
        config['wireguard'][0]['private_key'] = self.private_key
        # peers are also added automatically (and cached)
        config['wireguard'][0]['peers'] = self._get_wireguard_peers()
        # internal IP address of wireguard interface
        config['wireguard'][0][
            'address'] = '{{ ip_address }}/{{ subnet_prefixlen }}'

    @cache_memoize(_PEER_CACHE_TIMEOUT, args_rewrite=_peer_cache_key)
    def _get_wireguard_peers(self):
        """
        Returns list of wireguard peers, the result is cached.
        """
        peers = []
        for vpnclient in self._get_peer_queryset():
            if vpnclient.ip:
                ip_address = ipaddress.ip_address(vpnclient.ip.ip_address)
                peers.append({
                    'public_key':
                    vpnclient.public_key,
                    'allowed_ips':
                    f'{ip_address}/{ip_address.max_prefixlen}',
                })
        return peers

    def _add_vxlan(self, config):
        """
        Adds VXLAN peers to the generated configuration
        without the need of manual intervention.
        Modifies the config data structure as a side effect.
        """
        peers = self._get_vxlan_peers()
        # add peer list to conifg as a JSON file
        config.setdefault('files', [])
        config['files'].append({
            'mode':
            '0644',
            'path':
            'vxlan.json',
            'contents':
            json.dumps(peers, indent=4, sort_keys=True),
        })

    @cache_memoize(_PEER_CACHE_TIMEOUT, args_rewrite=_peer_cache_key)
    def _get_vxlan_peers(self):
        """
        Returns list of vxlan peers, the result is cached.
        """
        peers = []
        for vpnclient in self._get_peer_queryset():
            if vpnclient.ip:
                peers.append({
                    'vni': vpnclient.vni,
                    'remote': vpnclient.ip.ip_address
                })
        return peers
예제 #7
0
class AbstractDevice(OrgMixin, BaseModel):
    """
    Base device model
    Stores information related to the
    physical properties of a network device
    """

    name = models.CharField(
        max_length=64,
        unique=False,
        validators=[device_name_validator],
        db_index=True,
        help_text=_('must be either a valid hostname or mac address'),
    )
    mac_address = models.CharField(
        max_length=17,
        db_index=True,
        unique=False,
        validators=[mac_address_validator],
        help_text=_('primary mac address'),
    )
    key = KeyField(
        unique=True,
        blank=True,
        default=None,
        db_index=True,
        help_text=_('unique device key'),
    )
    model = models.CharField(
        max_length=64,
        blank=True,
        db_index=True,
        help_text=_('device model and manufacturer'),
    )
    os = models.CharField(
        _('operating system'),
        blank=True,
        db_index=True,
        max_length=128,
        help_text=_('operating system identifier'),
    )
    system = models.CharField(
        _('SOC / CPU'),
        blank=True,
        db_index=True,
        max_length=128,
        help_text=_('system on chip or CPU info'),
    )
    notes = models.TextField(blank=True, help_text=_('internal notes'))
    # these fields are filled automatically
    # with data received from devices
    last_ip = models.GenericIPAddressField(
        blank=True,
        null=True,
        db_index=True,
        help_text=_('indicates the IP address logged from '
                    'the last request coming from the device'),
    )
    management_ip = models.GenericIPAddressField(
        blank=True,
        null=True,
        db_index=True,
        help_text=_('ip address of the management interface, if available'),
    )
    hardware_id = models.CharField(**(app_settings.HARDWARE_ID_OPTIONS))

    class Meta:
        unique_together = (
            ('name', 'organization'),
            ('mac_address', 'organization'),
            ('hardware_id', 'organization'),
        )
        abstract = True
        verbose_name = app_settings.DEVICE_VERBOSE_NAME[0]
        verbose_name_plural = app_settings.DEVICE_VERBOSE_NAME[1]

    def __str__(self):
        return (self.hardware_id if
                (app_settings.HARDWARE_ID_ENABLED
                 and app_settings.HARDWARE_ID_AS_NAME) else self.name)

    def clean(self):
        """
        modifies related config status if name
        attribute is changed (queries the database)
        """
        super().clean()
        if self._state.adding:
            return
        current = self.__class__.objects.get(pk=self.pk)
        if self.name != current.name and self._has_config():
            self.config.set_status_modified()

    def _has_config(self):
        return hasattr(self, 'config')

    def _get_config_attr(self, attr):
        """
        gets property or calls method of related config object
        without rasing an exception if config is not set
        """
        if not self._has_config():
            return None
        attr = getattr(self.config, attr)
        return attr() if callable(attr) else attr

    def _get_config(self):
        if self._has_config():
            return self.config
        else:
            return self.get_config_model()(device=self)

    def get_context(self):
        config = self._get_config()
        return config.get_context()

    def get_system_context(self):
        config = self._get_config()
        return config.get_system_context()

    def generate_key(self, shared_secret):
        if app_settings.CONSISTENT_REGISTRATION:
            keybase = (self.hardware_id if app_settings.HARDWARE_ID_ENABLED
                       else self.mac_address)
            hash_key = md5('{}+{}'.format(keybase,
                                          shared_secret).encode('utf-8'))
            return hash_key.hexdigest()
        else:
            return KeyField.default_callable()

    def save(self, *args, **kwargs):
        if not self.key:
            try:
                shared_secret = self.organization.config_settings.shared_secret
            except ObjectDoesNotExist:
                # should not happen, but if organization config settings
                # is not defined the default key will default to being random
                self.key = KeyField.default_callable()
            else:
                self.key = self.generate_key(shared_secret)
        super().save(*args, **kwargs)

    @property
    def backend(self):
        """
        Used as a shortcut for display purposes
        (eg: admin site)
        """
        return self._get_config_attr('get_backend_display')

    @property
    def status(self):
        """
        Used as a shortcut for display purposes
        (eg: admin site)
        """
        return self._get_config_attr('get_status_display')

    def get_default_templates(self):
        """
        calls `get_default_templates` of related
        config object (or new config instance)
        """
        if self._has_config():
            config = self.config
        else:
            config = self.get_temp_config_instance()
        return config.get_default_templates()

    @classmethod
    def get_config_model(cls):
        return cls._meta.get_field('config').related_model

    def get_temp_config_instance(self, **options):
        config = self.get_config_model()(**options)
        config.device = self
        return config

    def can_be_updated(self):
        """
        returns True if the device can and should be updated
        can be overridden with custom logic if needed
        """
        return self.config.status != 'applied'