380 lines
13 KiB
Python
380 lines
13 KiB
Python
import collections
|
|
import logging
|
|
from functools import cached_property
|
|
|
|
import routeros_api
|
|
from django.db import models
|
|
from django.db.utils import IntegrityError
|
|
from django.forms import model_to_dict
|
|
from django.forms.models import ValidationError
|
|
from routeros_api.api_structure import StringField
|
|
from routeros_api.exceptions import RouterOsApiCommunicationError
|
|
|
|
from django_proxmox_mikrotik.settings import MikrotikConfig
|
|
from lib import FactoryMixin
|
|
from lib.db import BOOLEAN_CHOICES_CHAR
|
|
from lib.router_abstract import RoutedModelAbstract, RouterAbstract
|
|
|
|
ip_8 = MikrotikConfig.IP_8
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def is_local_ip(ip):
|
|
return ip[:3] in ip_8
|
|
|
|
|
|
class MikrotikApi(FactoryMixin):
|
|
def __init__(self):
|
|
# Create fresh connection for each instance - no shared state
|
|
self.connection = None
|
|
self.api = None
|
|
self._connect()
|
|
|
|
def _connect(self):
|
|
"""Create a new connection and API instance"""
|
|
try:
|
|
self.connection = routeros_api.RouterOsApiPool(
|
|
MikrotikConfig.HOST,
|
|
username=MikrotikConfig.USER,
|
|
password=MikrotikConfig.PASS,
|
|
port=8728,
|
|
plaintext_login=True,
|
|
use_ssl=False,
|
|
ssl_verify=True,
|
|
ssl_verify_hostname=True,
|
|
ssl_context=None,
|
|
)
|
|
self.api = self.connection.get_api()
|
|
except Exception as e:
|
|
_logger.error(f"Failed to create Mikrotik connection: {e}")
|
|
self.connection = None
|
|
self.api = None
|
|
raise
|
|
|
|
def disconnect(self):
|
|
"""Safely disconnect the connection"""
|
|
if self.connection:
|
|
try:
|
|
self.connection.disconnect()
|
|
except (OSError, AttributeError, BrokenPipeError) as e:
|
|
_logger.debug(f"Error during disconnect (expected): {e}")
|
|
finally:
|
|
self.connection = None
|
|
self.api = None
|
|
|
|
@property
|
|
def _default_structure(self):
|
|
return collections.defaultdict(lambda: StringField(encoding='windows-1250'))
|
|
|
|
def resource(self, route):
|
|
if not self.api:
|
|
raise ConnectionError("No active Mikrotik connection")
|
|
return self.api.get_resource(route, self._default_structure)
|
|
|
|
|
|
class Mikrotik(RouterAbstract):
|
|
_instances = {}
|
|
|
|
class _resource:
|
|
def __init__(self, route):
|
|
self._route = route
|
|
|
|
def __getattr__(self, item):
|
|
"""Dynamic method creation for RouterOS API calls"""
|
|
def method_wrapper(*args, **kwargs):
|
|
api = None
|
|
max_retries = 3
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
api = MikrotikApi()
|
|
resource = api.resource(self._route)
|
|
method = getattr(resource, item)
|
|
result = method(*args, **kwargs)
|
|
return result
|
|
except (OSError, BrokenPipeError, ConnectionError, AttributeError) as e:
|
|
_logger.warning(f"Connection error in _resource.{item}() attempt {attempt + 1}/{max_retries}: {e}")
|
|
if attempt == max_retries - 1:
|
|
raise
|
|
finally:
|
|
if api:
|
|
api.disconnect()
|
|
|
|
return method_wrapper
|
|
|
|
""""
|
|
def call(self, *args, **kwargs):
|
|
api = MikrotikApi()
|
|
try:
|
|
resource = api.resource(self._route)
|
|
return resource.call(*args, **kwargs)
|
|
finally:
|
|
api.connection.disconnect()
|
|
"""
|
|
|
|
def __init__(self, route):
|
|
self._route = route
|
|
|
|
@staticmethod
|
|
def pool(route):
|
|
if isinstance(route, MikrotikModelMixin):
|
|
route = route.router_base
|
|
return Mikrotik._instances.setdefault(route, Mikrotik(route))
|
|
|
|
def initialize(self, route):
|
|
if isinstance(route, MikrotikModelMixin):
|
|
self._route = route.router_base
|
|
else:
|
|
self._route = route
|
|
|
|
Mikrotik._instances.setdefault(route, self)
|
|
|
|
def get(self, **kwargs):
|
|
mikrotik_kwargs = {}
|
|
additional_queries = []
|
|
for k in list(kwargs.keys()):
|
|
if '__' in k:
|
|
v = kwargs.pop(k)
|
|
field, lookup = k.split('__', 1)
|
|
|
|
if lookup == 'startswith':
|
|
# Mikrotik verwendet ~"^pattern" für startswith
|
|
mikrotik_kwargs[field] = f'^{v}'
|
|
elif lookup == 'contains':
|
|
# Mikrotik verwendet ~"pattern" für contains
|
|
mikrotik_kwargs[field] = f'{v}'
|
|
elif lookup == 'endswith':
|
|
# Mikrotik verwendet ~"pattern$" für endswith
|
|
mikrotik_kwargs[field] = f'{v}$'
|
|
else:
|
|
# Unbekannter Lookup-Typ, behalte den ursprünglichen Wert bei
|
|
kwargs[k] = v
|
|
_logger.debug(f'Getting {self._route} with transformed kwargs: {mikrotik_kwargs}')
|
|
|
|
for field, pattern in mikrotik_kwargs.items():
|
|
additional_queries.append(f'{field}~"{pattern}"')
|
|
|
|
logging.info(f'Getting {self._route}/print with kwargs: {kwargs} and additional queries: {additional_queries}')
|
|
return self._resource(self._route).call('print', queries=kwargs, additional_queries=additional_queries)
|
|
|
|
def set(self, **kwargs):
|
|
assert 'id' in kwargs, "id must be set"
|
|
if MikrotikConfig.READONLY:
|
|
_logger.warning(f'Trying to set {self._route} to {kwargs} on read-only router')
|
|
return True # Simulate success in readonly mode
|
|
else:
|
|
return self._resource(self._route).set(**kwargs)
|
|
|
|
def add(self, **kwargs):
|
|
kwargs.pop('id', None)
|
|
if MikrotikConfig.READONLY:
|
|
_logger.warning(f'Trying to add {self._route} to {kwargs} on read-only router')
|
|
return '*READONLY' # Simulate success with fake ID in readonly mode
|
|
else:
|
|
return self._resource(self._route).add(**kwargs)
|
|
|
|
def remove(self, **kwargs):
|
|
assert 'id' in kwargs, "id must be set"
|
|
if MikrotikConfig.READONLY:
|
|
_logger.warning(f'Trying to remove {self._route} with {kwargs} on read-only router')
|
|
return True # Simulate success in readonly mode
|
|
else:
|
|
return self._resource(self._route).remove(id=kwargs['id'])
|
|
|
|
|
|
class MikrotikModelMixin(RoutedModelAbstract):
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._old_values = self.to_json
|
|
|
|
internal_id = models.BigAutoField(primary_key=True)
|
|
|
|
disabled = models.CharField(max_length=10, null=True, blank=True, default='false', choices=BOOLEAN_CHOICES_CHAR)
|
|
comment = models.TextField(null=True, blank=True, default='')
|
|
|
|
"""
|
|
Those we need for configuration
|
|
"""
|
|
|
|
@property
|
|
def router_base(self):
|
|
raise NotImplemented('Not implemented')
|
|
|
|
@property
|
|
def router_object_without_id(self):
|
|
_logger.warning(f'Deprecated - use router_object instead of router_object_without_id for {self}')
|
|
return self.router_object()
|
|
|
|
"""
|
|
Common stuff - may be overwritten
|
|
"""
|
|
|
|
@property
|
|
def no_mikrotik_props(self):
|
|
"""Props, that are not send to router
|
|
"""
|
|
return ['internal_id', 'dynamic']
|
|
|
|
@property
|
|
def router(self):
|
|
return Mikrotik.pool(self)
|
|
|
|
@classmethod
|
|
def class_props(cls):
|
|
return [p for p in model_to_dict(cls()).keys() if p not in ('internal_id', 'dynamic')]
|
|
|
|
@classmethod
|
|
def get_all_as_object(cls):
|
|
all = cls().router_get_all
|
|
print(all, type(all))
|
|
return [cls.from_dict(**r) for r in all]
|
|
|
|
@classmethod
|
|
def translate_keys(cls, **kwargs):
|
|
return {k.replace('-', '_'): v for k, v in kwargs.items()}
|
|
|
|
def router_object(self, translate_keys=True):
|
|
if self.id:
|
|
args = {'id': self.id}
|
|
else:
|
|
args = {}
|
|
for k in self.unique_on_router:
|
|
# here, we take the first one set
|
|
if isinstance(k, (tuple, list)):
|
|
for k2 in k:
|
|
if v2 := getattr(self, k2, None):
|
|
args[k2] = v2
|
|
break
|
|
else:
|
|
if v := getattr(self, k, None):
|
|
args[k] = v
|
|
|
|
if not args:
|
|
raise ValueError(f"Empty args to get info from router for {self}")
|
|
|
|
data = self.router.get(**args)
|
|
if data:
|
|
return self.translate_keys(**data[0]) if translate_keys else data[0]
|
|
return None
|
|
|
|
def sync_from_router(self, data=None):
|
|
if data := data or self.router_object():
|
|
_logger.debug(f'Syncing {self} from router with {data}')
|
|
self.assign(**data)
|
|
else:
|
|
_logger.debug(f'Could not sync {self} from router')
|
|
return self
|
|
|
|
def assign(self, **kwargs):
|
|
updatefields = []
|
|
for k, v in kwargs.items():
|
|
if hasattr(self, k):
|
|
if v != getattr(self, k):
|
|
updatefields.append(k)
|
|
setattr(self, k, v)
|
|
return self
|
|
|
|
def sync_all_from_router(self):
|
|
for obj in self.router_get_all:
|
|
self.from_dict(**obj)
|
|
|
|
def delete_from_router(self):
|
|
if self.id:
|
|
return self.router.remove(id=self.id)
|
|
return True
|
|
|
|
@classmethod
|
|
def from_dict(cls, **kwargs):
|
|
self_props = cls.class_props()
|
|
args = {}
|
|
for k, v in cls.translate_keys(**kwargs).items():
|
|
if k not in self_props:
|
|
_logger.warning(f'Unknown property {k} for {cls.__class__.__name__}')
|
|
else:
|
|
args[k] = v
|
|
try:
|
|
obj = cls.objects.get(id=args['id'])
|
|
_logger.debug(f'Found {obj} from {kwargs}')
|
|
except cls.DoesNotExist:
|
|
obj = cls.objects.create(**args)
|
|
_logger.debug(f'Created {obj} from {kwargs}')
|
|
except Exception as e:
|
|
_logger.error(f'Could not create {cls.__class__.__name__} from {kwargs} - {e}')
|
|
raise e
|
|
return obj
|
|
|
|
@property
|
|
def mikrotik_send_params(self):
|
|
return {k: v for k, v in self.to_json.items() if k not in self.no_mikrotik_props}
|
|
|
|
def sync_to_router(self, created=False):
|
|
data = self.mikrotik_send_params
|
|
_logger.debug(f'Syncing {self.__dict__}')
|
|
if self.id:
|
|
_logger.debug(f'Syncing {self} to router with {data}')
|
|
return self.router_set(**data)
|
|
_logger.debug(f'Adding {self} to router with {data}')
|
|
return self.router_add(**data)
|
|
|
|
@cached_property
|
|
def router_get_all(self):
|
|
return self.router.get()
|
|
|
|
def router_get(self, **kwargs):
|
|
response = self.router.get(**kwargs)
|
|
_logger.debug(f'Got {self} from router with {response}')
|
|
return response
|
|
|
|
def router_set(self, **kwargs):
|
|
kwargs['id'] = self.id
|
|
response = self.router.set(**kwargs)
|
|
_logger.debug(f'Set {self} to router with {response}')
|
|
return response
|
|
|
|
def router_add(self, **kwargs):
|
|
if self.id:
|
|
_logger.warning(f'Trying to add {self} to router - already has id {self.id}')
|
|
return True
|
|
kwargs.pop('id', None)
|
|
try:
|
|
response = self.router.add(**kwargs)
|
|
except RouterOsApiCommunicationError as e:
|
|
_logger.error(f'Could not add {self} to router - {e}')
|
|
routerdata = self.router_object()
|
|
if routerdata:
|
|
return self.sync_from_router(data=routerdata)
|
|
raise ValidationError(f'Could not add {self} to router - {e}')
|
|
try:
|
|
new_on_router = self.router_object()
|
|
_logger.debug(f'Got {new_on_router} from router')
|
|
self.id = new_on_router['id']
|
|
_logger.debug(f'Added {self} to router with {response}')
|
|
self.save()
|
|
except (IndexError, KeyError, NotImplementedError) as e:
|
|
_logger.info(f'Could not set id for {self} - git no id {e}')
|
|
return response
|
|
|
|
|
|
def sync_from_mikrotik(classname):
|
|
_st = classname()
|
|
for i in _st.router_get_all:
|
|
i = {k.replace('-', '_'): v for k, v in i.items()}
|
|
try:
|
|
existing = classname.objects.get(id=i['id'])
|
|
for k, v in i.items():
|
|
if hasattr(existing, k):
|
|
_logger.debug(f'Updating {k} to {v} - {existing}')
|
|
setattr(existing, k, v)
|
|
existing.save()
|
|
except classname.DoesNotExist:
|
|
_logger.info(f'Creating {i["id"]}')
|
|
try:
|
|
classname.objects.create(**i)
|
|
except IntegrityError as e:
|
|
_logger.error(f'Could not create {i["id"]}, already exists')
|
|
_logger.exception(e)
|