diff --git a/containers/cron/cron_tasks b/containers/cron/cron_tasks index cae64b3..ac7a568 100644 --- a/containers/cron/cron_tasks +++ b/containers/cron/cron_tasks @@ -1,5 +1,6 @@ */5 * * * * root sleep 0 ; /usr/bin/curl -s http://routerfleet:8001/cron/generate_backup_schedule/ >> /var/log/cron.log 2>&1 * * * * * root sleep 5 ; /usr/bin/curl -s http://routerfleet:8001/cron/create_backup_tasks/ >> /var/log/cron.log 2>&1 +* * * * * root sleep 10; /usr/bin/curl -s http://routerfleet:8001/cron/update_router_information/ >> /var/log/cron.log 2>&1 */10 * * * * root sleep 20; /usr/bin/curl -s http://routerfleet:8001/cron/housekeeping/ >> /var/log/cron.log 2>&1 * * * * * root sleep 40; /usr/bin/curl -s http://routerfleet:8001/cron/perform_backup_tasks/ >> /var/log/cron.log 2>&1 * * * * * root sleep 50; /usr/bin/curl -s http://routerfleet:8001/cron/check_updates/ >> /var/log/cron.log 2>&1 diff --git a/import_tool/migrations/0006_alter_importtask_router_type.py b/import_tool/migrations/0006_alter_importtask_router_type.py new file mode 100644 index 0000000..c6aeb01 --- /dev/null +++ b/import_tool/migrations/0006_alter_importtask_router_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-23 12:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('import_tool', '0005_importtask_import_id_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='importtask', + name='router_type', + field=models.CharField(choices=[('monitoring', 'Monitoring Only'), ('routeros', 'Mikrotik (RouterOS)'), ('routeros-branded', 'Mikrotik (Branded)'), ('openwrt', 'OpenWRT')], max_length=100), + ), + ] diff --git a/router_manager/admin.py b/router_manager/admin.py index 9354b7b..ffcbe0e 100644 --- a/router_manager/admin.py +++ b/router_manager/admin.py @@ -1,5 +1,12 @@ from django.contrib import admin -from .models import Router, SSHKey, RouterStatus, BackupSchedule +from .models import Router, SSHKey, RouterStatus, BackupSchedule, RouterInformation + + +class RouterInformationAdmin(admin.ModelAdmin): + list_display = ('router', 'model_name', 'model_version', 'serial_number', 'os_version', 'firmware_version', 'architecture') + search_fields = ('router__name', 'model_name', 'model_version', 'serial_number', 'os_version', 'firmware_version', 'architecture') + list_filter = ('router__name', 'model_name', 'model_version', 'serial_number', 'os_version', 'firmware_version', 'architecture') +admin.site.register(RouterInformation, RouterInformationAdmin) class RouterAdmin(admin.ModelAdmin): diff --git a/router_manager/migrations/0017_alter_router_router_type_routerinformation.py b/router_manager/migrations/0017_alter_router_router_type_routerinformation.py new file mode 100644 index 0000000..4c68529 --- /dev/null +++ b/router_manager/migrations/0017_alter_router_router_type_routerinformation.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2 on 2025-04-23 12:24 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('router_manager', '0016_router_port'), + ] + + operations = [ + migrations.AlterField( + model_name='router', + name='router_type', + field=models.CharField(choices=[('monitoring', 'Monitoring Only'), ('routeros', 'Mikrotik (RouterOS)'), ('routeros-branded', 'Mikrotik (Branded)'), ('openwrt', 'OpenWRT')], max_length=100), + ), + migrations.CreateModel( + name='RouterInformation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('success', models.BooleanField(default=False)), + ('error', models.BooleanField(default=False)), + ('error_message', models.TextField(blank=True, null=True)), + ('retry_count', models.IntegerField(default=0)), + ('next_retry', models.DateTimeField(blank=True, null=True)), + ('last_retrieval', models.DateTimeField(blank=True, null=True)), + ('host_id', models.CharField(blank=True, max_length=100, null=True)), + ('model_name', models.CharField(blank=True, max_length=100, null=True)), + ('model_version', models.CharField(blank=True, max_length=100, null=True)), + ('serial_number', models.CharField(blank=True, max_length=100, null=True)), + ('os_version', models.CharField(blank=True, max_length=100, null=True)), + ('firmware_version', models.CharField(blank=True, max_length=100, null=True)), + ('architecture', models.CharField(blank=True, max_length=100, null=True)), + ('json_data', models.TextField(blank=True, null=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('router', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='router_manager.router')), + ], + ), + ] diff --git a/router_manager/migrations/0018_routerinformation_cpu.py b/router_manager/migrations/0018_routerinformation_cpu.py new file mode 100644 index 0000000..1478758 --- /dev/null +++ b/router_manager/migrations/0018_routerinformation_cpu.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-04-24 17:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('router_manager', '0017_alter_router_router_type_routerinformation'), + ] + + operations = [ + migrations.AddField( + model_name='routerinformation', + name='cpu', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/router_manager/migrations/0019_remove_routerinformation_host_id.py b/router_manager/migrations/0019_remove_routerinformation_host_id.py new file mode 100644 index 0000000..a75cab9 --- /dev/null +++ b/router_manager/migrations/0019_remove_routerinformation_host_id.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2 on 2025-04-24 17:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('router_manager', '0018_routerinformation_cpu'), + ] + + operations = [ + migrations.RemoveField( + model_name='routerinformation', + name='host_id', + ), + ] diff --git a/router_manager/models.py b/router_manager/models.py index d757925..8154877 100644 --- a/router_manager/models.py +++ b/router_manager/models.py @@ -80,3 +80,31 @@ class BackupSchedule(models.Model): created = models.DateTimeField(auto_now_add=True) uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) + +class RouterInformation(models.Model): + router = models.OneToOneField(Router, on_delete=models.CASCADE) + + success = models.BooleanField(default=False) + error = models.BooleanField(default=False) + error_message = models.TextField(blank=True, null=True) + retry_count = models.IntegerField(default=0) + next_retry = models.DateTimeField(blank=True, null=True) + last_retrieval = models.DateTimeField(blank=True, null=True) + + model_name = models.CharField(max_length=100, null=True, blank=True) + model_version = models.CharField(max_length=100, null=True, blank=True) + serial_number = models.CharField(max_length=100, null=True, blank=True) + + os_version = models.CharField(max_length=100, null=True, blank=True) + firmware_version = models.CharField(max_length=100, null=True, blank=True) + architecture = models.CharField(max_length=100, null=True, blank=True) + cpu = models.CharField(max_length=100, null=True, blank=True) + + json_data = models.TextField(null=True, blank=True) + + updated = models.DateTimeField(auto_now=True) + created = models.DateTimeField(auto_now_add=True) + uuid = models.UUIDField(unique=True, editable=False, default=uuid.uuid4) + + def __str__(self): + return str(self.router) diff --git a/router_manager/views.py b/router_manager/views.py index 178f31e..8d590ca 100644 --- a/router_manager/views.py +++ b/router_manager/views.py @@ -8,9 +8,11 @@ from django.utils import timezone from backup.models import BackupProfile from backup_data.models import RouterBackup from routerfleet_tools.models import WebadminSettings +from routerlib.router_functions import update_router_information from user_manager.models import UserAcl from .forms import RouterForm, RouterGroupForm, SSHKeyForm -from .models import Router, RouterGroup, RouterStatus, SSHKey, BackupSchedule +from .models import Router, RouterGroup, RouterInformation, RouterStatus, SSHKey, BackupSchedule +from django.conf import settings @login_required @@ -263,3 +265,28 @@ def view_create_instant_backup_multiple_routers(request): return JsonResponse({'results': results}) return JsonResponse({'error': 'Invalid request method.'}, status=405) + + +def view_cron_update_router_information(request): + data = {'status': 'success'} + refresh_interval = 24 #hours + + router_list = Router.objects.filter(enabled=True).exclude(router_type='monitoring').exclude(routerstatus__status_online=False) + router = router_list.filter(routerinformation__isnull=True).first() + if not router: + router = router_list.filter(routerinformation__next_retry__lt=timezone.now()).first() + if not router: + router = router_list.filter(routerinformation__last_retrieval__isnull=True).first() + if not router: + router = router_list.filter(routerinformation__last_retrieval__lt=timezone.now() - timezone.timedelta(hours=refresh_interval)).first() + + if router: + router_information, created = RouterInformation.objects.get_or_create(router=router) + success, error_message = update_router_information(router_information) + if not success: + data['status'] = 'error' + data['message'] = 'Failed to update router' + else: + data['message'] = 'No routers need update' + + return JsonResponse(data) diff --git a/routerfleet/urls.py b/routerfleet/urls.py index 5108059..5b38b94 100644 --- a/routerfleet/urls.py +++ b/routerfleet/urls.py @@ -6,7 +6,7 @@ from dashboard.views import view_dashboard, view_status,backup_statistics_data,r from integration_manager.views import view_wireguard_webadmin_launcher, view_manage_wireguard_integration, view_launch_wireguard_webadmin from user_manager.views import view_manage_user, view_user_list from accounts.views import view_login, view_logout, view_create_first_user -from router_manager.views import view_create_instant_backup_multiple_routers, view_router_list, view_manage_router, view_router_group_list, view_ssh_key_list, view_manage_router_group, view_manage_sshkey, view_router_details, view_create_instant_backup_task, view_router_availability +from router_manager.views import view_create_instant_backup_multiple_routers, view_router_list, view_manage_router, view_router_group_list, view_ssh_key_list, view_manage_router_group, view_manage_sshkey, view_router_details, view_create_instant_backup_task, view_router_availability, view_cron_update_router_information from backup.views import view_backup_profile_list, view_manage_backup_profile, view_backup_list, view_backup_details, view_debug_run_backups, view_compare_backups, view_backup_download, view_backup_delete from monitoring.views import view_export_router_list, view_update_router_status, view_router_config_timestamp, view_router_last_status_change from backup_data.views import view_generate_backup_schedule, view_create_backup_tasks, view_perform_backup_tasks, view_housekeeping @@ -58,6 +58,7 @@ urlpatterns = [ path('cron/perform_backup_tasks/', view_perform_backup_tasks, name='perform_backup_tasks'), path('cron/housekeeping/', view_housekeeping, name='housekeeping'), path('cron/check_updates/', cron_check_updates, name='check_updates'), + path('cron/update_router_information/', view_cron_update_router_information, name='update_router_information'), path('cron/concatenate_notifications/', view_cron_concatenate_notifications, name='concatenate_notifications'), path('cron/send_messages/', view_cron_send_messages, name='send_messages'), path('cron/daily_reports/', view_cron_daily_reports, name='daily_reports'), diff --git a/routerlib/router_functions.py b/routerlib/router_functions.py new file mode 100644 index 0000000..20765e9 --- /dev/null +++ b/routerlib/router_functions.py @@ -0,0 +1,161 @@ +import json +import datetime +from django.utils import timezone +from router_manager.models import RouterInformation, Router +from routerlib.functions import connect_to_ssh + + +def _parse_routeros_key_value_output(output: str) -> dict: + """ + Parse lines like "key: value" into a dict. + Skip blank lines or lines starting with '[' (the prompt). + """ + data = {} + # Normalize and split + for raw_line in output.replace('\r', '').splitlines(): + line = raw_line.strip() + # skip empty or prompt lines + if not line or line.startswith('['): + continue + if ':' not in line: + continue + key, val = line.split(':', 1) + data[key.strip()] = val.strip() + return data + + +def get_router_information(router_information: RouterInformation): + """ + Connect to the router, retrieve info, and store it in RouterInformation. + """ + router = router_information.router + field_max_length = 100 + success = False + error_message = '' + + try: + ssh = connect_to_ssh(router.address, router.port, router.username, router.password, router.ssh_key) + json_data = {} + + if router.router_type in ('routeros', 'routeros-branded'): + for cmd in ['/system resource print', '/system routerboard print']: + stdin, stdout, stderr = ssh.exec_command(cmd) + raw = stdout.read().decode('utf-8', errors='ignore') + parsed = _parse_routeros_key_value_output(raw) + json_data[cmd] = parsed + + rb = json_data['/system routerboard print'] + sr = json_data['/system resource print'] + + if sr: + router_information.model_name = sr.get('board-name', '')[:field_max_length] + router_information.os_version = sr.get('version', '')[:field_max_length] + router_information.architecture = sr.get('architecture-name', '')[:field_max_length] + router_information.cpu = sr.get('cpu', '')[:field_max_length] + success = True + if rb: + router_information.model_version = rb.get('model', '')[:field_max_length] + router_information.serial_number = rb.get('serial-number', '')[:field_max_length] + router_information.firmware_version = rb.get('current-firmware', '')[:field_max_length] + success = True + if not success: + return False, 'Failed to retrieve router information' + + elif router.router_type == 'openwrt': + stdin, stdout, stderr = ssh.exec_command('cat /etc/os-release') + osrel = {} + for line in stdout.read().decode('utf-8').splitlines(): + if '=' in line: + k, v = line.split('=', 1) + osrel[k] = v.strip().strip('"') + json_data['cat /etc/os-release'] = osrel + + # hostname + stdin, stdout, stderr = ssh.exec_command('uci get system.@system[0].hostname') + hostname = stdout.read().decode('utf-8').strip() + json_data['uci get system.@system[0].hostname'] = hostname + + # architecture + stdin, stdout, stderr = ssh.exec_command('uname -m') + arch = stdout.read().decode('utf-8').strip() + json_data['uname -m'] = arch + + # fallback serial (MAC of eth0) + stdin, stdout, stderr = ssh.exec_command('cat /sys/class/net/eth0/address') + mac = stdout.read().decode('utf-8').strip() + json_data['cat /sys/class/net/eth0/address'] = mac + + if osrel: + router_information.model_name = osrel.get('OPENWRT_DEVICE_MODEL', '')[:field_max_length] + router_information.model_version = osrel.get('VERSION_ID', '')[:field_max_length] + router_information.serial_number = mac[:field_max_length] + router_information.os_version = osrel.get('VERSION', '')[:field_max_length] + router_information.firmware_version = osrel.get('OPENWRT_RELEASE', '')[:field_max_length] + router_information.architecture = arch[:field_max_length] + success = True + if not success: + return False, 'Failed to retrieve router information' + else: + return False, f"Router type not supported: {router.get_router_type_display()}" + + if success: + router_information.success = True + router_information.error = False + router_information.retry_count = 0 + router_information.next_retry = None + router_information.error_message = '' + router_information.last_retrieval = timezone.now() + router_information.json_data = json.dumps(json_data) + router_information.save() + + except Exception as e: + success = False + error_message = str(e) + + finally: + try: + ssh.close() + except: + pass + + return success, error_message + + +def update_router_information(router_information: RouterInformation): + max_retry = 3 + retry_minutes = 5 + + success = False + error_message = '' + + if router_information.retry_count > max_retry: + router_information.error = True + router_information.success = False + router_information.next_retry = None + router_information.retry_count = 0 + router_information.last_retrieval = timezone.now() + if router_information.error_message: + router_information.error_message += f"\nMax retries reached for {router_information.router.name}" + else: + router_information.error_message = f"Max retries reached for {router_information.router.name}" + router_information.save() + return False, router_information.error_message + try: + success, error_message = get_router_information(router_information) + except Exception as e: + success = False + error_message = f"Failed to update router information for {router_information.router.name}. Exception: {e}" + + if not success: + router_information.error = True + router_information.success = False + router_information.next_retry = timezone.now() + datetime.timedelta(minutes=retry_minutes) + router_information.retry_count += 1 + router_information.last_retrieval = timezone.now() + if error_message: + router_information.error_message = error_message + else: + router_information.error_message = f"Failed to update router information for {router_information.router.name}" + router_information.save() + + return success, error_message