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()
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
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}')
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)
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
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
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'