This commit is contained in:
Holger Sielaff
2025-08-27 09:55:55 +02:00
commit 90c0ff61ed
107 changed files with 8535 additions and 0 deletions

View File

View File

@@ -0,0 +1,16 @@
"""
ASGI config for django_proxmox_mikrotik project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_proxmox_mikrotik.settings')
application = get_asgi_application()

View File

@@ -0,0 +1,79 @@
import json
import logging
import os
def env_true(value):
return value.lower() in ('yes', 'y', '1', 'on', 'true', 't')
def env_false(value):
return value.lower() in ('no', 'n', '0', 'off', 'false', 'f')
PROXMOX_READONLY = env_true(os.environ.get('PROXMOX_READONLY', '0'))
MIKROTIK_READONLY = env_true(os.environ.get('MIKROTIK_READONLY', '0'))
class ProxmoxConfig:
HOST = os.environ.get('PROXMOX_HOST')
USER = os.environ.get('PROXMOX_USER')
PASS = os.environ.get('PROXMOX_PASS')
NODE = os.environ.get('PROXMOX_NODE')
READONLY = PROXMOX_READONLY
MAX_MEM = int(os.environ.get('PROXMOX_MAX_MEM', 8192))
MAX_DISK = int(os.environ.get('PROXMOX_MAX_DISK', 100))
MAX_CORES = int(os.environ.get('PROXMOX_MAX_CORES', 8))
CREATE_LXC_TIMEOUT = int(os.environ.get('PROXMOX_CREATE_LXC_TIMEOUT', 600))
DEFAULT_STORAGE = os.environ.get('PROXMOX_DEFAULT_STORAGE', 'local')
class MikrotikConfig:
HOST = os.environ.get('MIKROTIK_HOST')
USER = os.environ.get('MIKROTIK_USER')
PASS = os.environ.get('MIKROTIK_PASS')
IP_8 = os.environ.get('MIKROTIK_IP_8', '192,172')
READONLY = MIKROTIK_READONLY
ROOT_NETWORKS = os.environ.get('MIKROTIK_ROOT_NETWORKS', '192.168.1.0,192.168.107.0').split(',')
class DatabaseConfig:
HOST = os.environ.get('DATABASE_HOST', 'localhost') or 'localhost'
ENGINE = os.environ.get('DATABASE_ENGINE', 'sqlite3') or 'sqlite3'
USER = os.environ.get('DATABASE_USER', '') or ''
PASSWORD = os.environ.get('DATABASE_PASSWORD', '') or ''
NAME = os.environ.get('DATABASE_NAME', '')
PORT = os.environ.get('DATABASE_PORT', 5432) or 5432
class AuthLDAPConfig:
HOST = os.environ.get('AUTH_LDAP_HOST')
BIND_DN = os.environ.get('AUTH_LDAP_BIND_DN')
BIND_PASSWORD = os.environ.get('AUTH_LDAP_BIND_PASSWORD')
USER_BASE = os.environ.get('AUTH_LDAP_USER_BASE')
USER_FILTER = os.environ.get('AUTH_LDAP_USER_FILTER')
GROUP_SEARCH_BASE = os.environ.get('AUTH_LDAP_GROUP_SEARCH_BASE')
GROUP_SEARCH_FILTER = os.environ.get('AUTH_LDAP_GROUP_SEARCH_FILTER')
USER_ATTR_MAP = json.loads(os.environ.get('AUTH_LDAP_USER_ATTR_MAP'))
USER_FLAGS_BY_GROUP = json.loads(os.environ.get('AUTH_LDAP_USER_FLAGS_BY_GROUP'))
FIND_GROUP_PERMS = os.environ.get('AUTH_LDAP_FIND_GROUP_PERMS')
CACHE_GROUPS = os.environ.get('AUTH_LDAP_CACHE_GROUPS')
GROUP_CACHE_TIMEOUT = os.environ.get('AUTH_LDAP_GROUP_CACHE_TIMEOUT')
_missing = []
for cfg in ['Proxmox', 'Mikrotik', 'Database', ('AuthLDAP', 'AUTH_LDAP')]:
if isinstance(cfg, tuple):
cfg, mapname = cfg
else:
mapname = cfg.upper()
cls = globals()[cfg + 'Config']
for k, v in cls.__dict__.items():
if k.startswith(mapname + '_'):
if v is None:
_missing.append(f'{cfg}.{k}')
if _missing:
raise Exception(f'Missing environment variables: \n{"\n".join(_missing)}\n')
logging.debug(f'ProxmoxConfig: {ProxmoxConfig.__dict__}')
logging.debug(f'MikrotikConfig: {MikrotikConfig.__dict__}')
logging.debug(f'DatabaseConfig: {DatabaseConfig.__dict__}')
logging.debug(f'AuthLDAPConfig: {AuthLDAPConfig.__dict__}')

View File

@@ -0,0 +1,181 @@
"""
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import json
import os
from pathlib import Path
import ldap
from django_auth_ldap.config import GroupOfNamesType, LDAPSearch
from dotenv import dotenv_values, load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env', override=False)
import logging
LOGLEVEL = getattr(logging,os.environ.get('LOG_LEVEL', 'DEBUG').upper())
logging.basicConfig(level=LOGLEVEL)
from django_proxmox_mikrotik.configs import (
env_true,
env_false,
ProxmoxConfig,
DatabaseConfig,
AuthLDAPConfig,
MikrotikConfig,
)
LOGIN_URL = '/frontend/login/'
# fallback
DatabaseConfig.NAME = DatabaseConfig.NAME or BASE_DIR / 'db.sqlite3'
SECRET_KEY = 'django-insecure-o$tw_(450z^cl%mq(h1&=jltu51mfnmiown&l^dinx+z-!nzem'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '127.0.0.1,localhost').strip().replace(' ', '').split(',')
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.admin',
'django_middleware_global_request',
'proxmox',
'tasklogger',
'mikrotik',
'manager',
'frontend',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'frontend.middleware.FrontendSessionMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_middleware_global_request.middleware.GlobalRequestMiddleware',
]
ROOT_URLCONF = 'django_proxmox_mikrotik.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates']
,
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'django_proxmox_mikrotik.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.' + DatabaseConfig.ENGINE,
'NAME': DatabaseConfig.NAME,
'PASSWORD': DatabaseConfig.PASSWORD,
'USER': DatabaseConfig.USER,
'HOST': DatabaseConfig.HOST,
'PORT': DatabaseConfig.PORT,
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = str(BASE_DIR / 'static')
STATICFILES_DIRS = [
BASE_DIR / "frontend" / "static",
]
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_LDAP_SERVER_URI = AuthLDAPConfig.HOST
AUTH_LDAP_BIND_DN = AuthLDAPConfig.BIND_DN
AUTH_LDAP_BIND_PASSWORD = AuthLDAPConfig.BIND_PASSWORD
AUTH_LDAP_USER_SEARCH = LDAPSearch(
AuthLDAPConfig.USER_BASE,
ldap.SCOPE_SUBTREE,
AuthLDAPConfig.USER_FILTER,
)
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
AuthLDAPConfig.GROUP_SEARCH_BASE,
ldap.SCOPE_SUBTREE,
AuthLDAPConfig.GROUP_SEARCH_FILTER,
)
AUTH_LDAP_USER_ATTR_MAP = AuthLDAPConfig.USER_ATTR_MAP
AUTH_LDAP_USER_FLAGS_BY_GROUP = AuthLDAPConfig.USER_FLAGS_BY_GROUP
AUTH_LDAP_FIND_GROUP_PERMS = bool(AuthLDAPConfig.FIND_GROUP_PERMS)
AUTH_LDAP_CACHE_GROUPS = bool(AuthLDAPConfig.CACHE_GROUPS)
AUTH_LDAP_GROUP_CACHE_TIMEOUT = AuthLDAPConfig.GROUP_CACHE_TIMEOUT
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr="cn")
AUTHENTICATION_BACKENDS = [
'django_auth_ldap.backend.LDAPBackend',
'django.contrib.auth.backends.ModelBackend',
]
SESSION_COOKIE_NAME_FRONTEND = 'django_pm_mk_frontend_sessionid'

View File

@@ -0,0 +1,31 @@
from django.contrib import admin
"""
URL configuration for django_proxmox_mikrotik project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.urls import path, include
from django.shortcuts import redirect
urlpatterns = [
path('admin/', admin.site.urls),
]
# django_proxmox_mikrotik/urls.py
urlpatterns += [
path('', lambda request: redirect('frontend/'), name='home'),
path('frontend/', include('frontend.urls', namespace='frontend')),
path('manager/', include('manager.urls', namespace='manager')),
path('tasklogger/', include('tasklogger.urls', namespace='tasklogger')),
]

View File

@@ -0,0 +1,16 @@
"""
WSGI config for django_proxmox_mikrotik project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_proxmox_mikrotik.settings')
application = get_wsgi_application()

0
frontend/__init__.py Normal file
View File

24
frontend/admin.py Normal file
View File

@@ -0,0 +1,24 @@
# frontend/admin.py
from django.contrib import admin
from .models import UserProfile, FAQ
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'ldap_uid', 'user_group')
search_fields = ('user__username', 'ldap_uid')
list_filter = ('user__groups',)
@admin.register(FAQ)
class FAQAdmin(admin.ModelAdmin):
list_display = ('title', 'order', 'created_at', 'updated_at')
list_filter = ('created_at',)
search_fields = ('title', 'content')
list_editable = ('order',)
ordering = ('order', 'title')
fieldsets = (
(None, {
'fields': ('title', 'content', 'order')
}),
)

9
frontend/apps.py Normal file
View File

@@ -0,0 +1,9 @@
# frontend/apps.py
from django.apps import AppConfig
class FrontendConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'frontend'
def ready(self):
import frontend.signals

352
frontend/forms.py Normal file
View File

@@ -0,0 +1,352 @@
# frontend/forms.py
import re
from functools import cached_property
from django import forms
from django.core.exceptions import ValidationError
from django_proxmox_mikrotik.settings import ProxmoxConfig
from manager.models import CloneContainer, DevContainer
from mikrotik.models import DNSStatic, IPAddress, IPDHCPLease
from proxmox.models import Lxc
from .models import FAQ
def fk_to_hidden_input(formobj: forms.Form, instance, fieldname: str):
initial_value = str(getattr(instance, fieldname)) if instance else ''
formobj.fields[fieldname] = forms.CharField(
label=fieldname.title(),
initial=initial_value,
widget=forms.TextInput(
attrs={'readonly': 'readonly', 'style': 'background-color: #eee;', 'class': 'form-control'}),
required=False
)
class DevContainerForm(forms.ModelForm):
disksize = forms.IntegerField(
required=False,
min_value=1,
max_value=100,
help_text="Festplattengröße (GB) - kann nicht verkleinert werden"
)
cores = forms.IntegerField(
required=False,
min_value=1,
help_text="Anzahl der CPU-Kerne"
)
memory = forms.CharField(
required=False,
help_text="Arbeitsspeicher in MB"
)
class Meta:
model = DevContainer
fields = ['lxc', 'lease', 'dns']
@cached_property
def dns_obj(self):
return DNSStatic.objects.get(pk=self.cleaned_data['dns'])
@cached_property
def lease_obj(self):
return IPDHCPLease.objects.get(pk=self.cleaned_data['lease'])
@cached_property
def lxc_obj(self):
return Lxc.objects.get(pk=self.cleaned_data['lxc'])
def _clean_dns(self):
if self.dns_obj.address != self.lease_obj.address:
raise ValidationError("DNS has not the same address as Lease. Please check your input.")
return self.cleaned_data['dns']
def _clean_lease(self):
"""Brauchts eigentlich nicht"""
if self.lease_obj.address != self.dns_obj.address:
raise ValidationError("Lease has not the same address as DNS. Please check your input.")
return self.cleaned_data['lease']
def _clean_lxc(self):
if self.lxc_obj.hwaddr != self.lease_obj.mac_address:
raise ValidationError("LXC has not the same MAC address as Lease. Please check your input.")
return self.cleaned_data['lxc']
def clean(self):
super().clean()
self.cleaned_data['dns'] = self._clean_dns()
self.cleaned_data['lease'] = self._clean_lease()
self.cleaned_data['lxc'] = self._clean_lxc()
return self.cleaned_data
def clean_disksize(self):
if self.cleaned_data['disksize'] and int(self.cleaned_data['disksize']) > int(ProxmoxConfig.MAX_DISK):
raise ValidationError(
f"Disksize > {ProxmoxConfig.MAX_DISK}GB not allowed. Please check your input.")
return self.cleaned_data['disksize']
def clean_memory(self):
if self.cleaned_data['memory'] and int(self.cleaned_data['memory']) > ProxmoxConfig.MAX_MEM:
raise ValidationError(f"Memory > {ProxmoxConfig.MAX_MEM}MB not allowed. Please check your input.")
return self.cleaned_data['memory']
def clean_cores(self):
if self.cleaned_data['cores'] and int(self.cleaned_data['cores']) > ProxmoxConfig.MAX_CORES:
raise ValidationError(f"Cores > {ProxmoxConfig.MAX_CORES} not allowed. Please check your input.")
return self.cleaned_data['cores']
def __init__(self, *args, user_profile=None, **kwargs):
super().__init__(*args, **kwargs)
self.user_profile = user_profile
instance = kwargs.get('instance')
# Für externe Benutzer: IP-Adressen filtern
if user_profile and user_profile.is_external():
ldap_uid = user_profile.ldap_uid
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {ldap_uid} ')
networks = [ip.network for ip in ip_addresses]
# Lease-Feld einschränken
self.fields['lease'].queryset = self.fields['lease'].queryset.filter(
address__regex=r'^(' + '|'.join([re.escape(net) for net in networks]) + r')'
)
self.fields['dns'].queryset = self.fields['dns'].queryset.filter(address=instance.lease.address)
if instance and hasattr(instance, 'lxc') and instance.lxc:
self.fields['disksize'].initial = instance.lxc.disksize
self.fields['disksize'].min_value = instance.lxc.disksize
self.fields['cores'].initial = instance.lxc.cores
self.fields['memory'].initial = instance.lxc.memory
fk_to_hidden_input(self, instance, 'lxc')
fk_to_hidden_input(self, instance, 'lease')
fk_to_hidden_input(self, instance, 'dns')
def save(self, commit=True):
instance = super().save(commit=False)
if hasattr(instance, 'lxc') and instance.lxc:
lxc = instance.lxc
if 'disksize' in self.cleaned_data and self.cleaned_data['disksize']:
lxc.disksize = self.cleaned_data['disksize']
if 'cores' in self.cleaned_data and self.cleaned_data['cores']:
lxc.cores = self.cleaned_data['cores']
if 'memory' in self.cleaned_data and self.cleaned_data['memory']:
lxc.memory = self.cleaned_data['memory']
lxc.save()
if commit:
instance.save()
return instance
class CloneContainerForm(forms.ModelForm):
class Meta:
model = CloneContainer
fields = ['hostname', 'network', 'cores', 'memory', 'disksize', 'as_regexp', 'vm', 'template']
def __init__(self, *args, user_profile=None, vm=None, template=None, hostname=None, **kwargs):
super().__init__(*args, **kwargs)
self.user_profile = user_profile
if vm:
self.fields['vm'].initial = vm[0].pk
self.fields['template'].disabled = True
elif template:
self.fields['template'].initial = template[0].pk
self.fields['vm'].disabled = True
if hostname:
self.fields['hostname'].initial = hostname
# Für externe Benutzer: Netzwerke filtern
if user_profile and user_profile.is_external():
ldap_uid = user_profile.ldap_uid
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {ldap_uid} ')
self.fields['network'].queryset = ip_addresses
def clean_hostname(self):
hostname = self.cleaned_data.get('hostname')
if not hostname:
raise ValidationError("Der Hostname ist erforderlich.")
# Prüfe, ob der Name bereits als LXC-Hostname existiert
if Lxc.objects.filter(hostname=hostname).exists():
raise ValidationError(f"Ein LXC mit dem Hostnamen '{hostname}' existiert bereits.")
# Prüfe, ob der Name bereits als DNS-Name existiert
from mikrotik.models import DNSStatic
if DNSStatic.objects.filter(name=hostname).exists():
raise ValidationError(f"Ein DNS-Eintrag mit dem Namen '{hostname}' existiert bereits.")
# Prüfe auf Regex-Übereinstimmung in DNS-Einträgen
dns_with_regex = DNSStatic.objects.exclude(regexp='')
for dns in dns_with_regex:
if dns.regexp and re.search(hostname + '$', dns.regexp):
raise ValidationError(
f"Der Name '{hostname}' entspricht dem regulären Ausdruck '{dns.regexp}' eines existierenden DNS-Eintrags.")
# Prüfe, ob der Name bereits als CloneContainer-Name existiert
existing_clone_query = CloneContainer.objects.filter(hostname=hostname, status__in=('pending', 'running'))
if self.instance.pk:
existing_clone_query = existing_clone_query.exclude(pk=self.instance.pk)
if existing_clone_query.exists():
raise ValidationError(f"Ein CloneContainer mit dem Namen '{hostname}' existiert bereits.")
return hostname
def clean_disksize(self):
disksize = self.cleaned_data.get('disksize')
if disksize and disksize > 100:
raise ValidationError("Die Festplattengröße darf maximal 100 GB betragen.")
return disksize
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get('template') and not cleaned_data.get('vm'):
raise ValidationError("Es muss entweder ein Template oder eine VM ausgewählt werden.")
return cleaned_data
class DNSStaticForm(forms.ModelForm):
# Optional: Container selection for automatic IP assignment
container = forms.ModelChoiceField(
queryset=DevContainer.objects.all(),
required=False,
empty_label="Select container (optional)",
help_text="Select a container to automatically use its IP address"
)
# Manual IP input
address = forms.GenericIPAddressField(
required=False,
help_text="IP address for the DNS entry"
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add CSS classes
for field in self.fields:
if isinstance(self.fields[field].widget, forms.widgets.TextInput):
self.fields[field].widget.attrs.update({'class': 'form-control'})
elif isinstance(self.fields[field].widget, forms.widgets.Select):
self.fields[field].widget.attrs.update({'class': 'form-select'})
# Make container field use Select2
self.fields['container'].widget.attrs.update({
'class': 'form-select container-select',
'data-placeholder': 'Search containers...'
})
def clean(self):
cleaned_data = super().clean()
container = cleaned_data.get('container')
address = cleaned_data.get('address')
name = cleaned_data.get('name')
regexp = cleaned_data.get('regexp')
# Either container or manual address must be provided
if not container and not address:
raise ValidationError("Either select a container or enter an IP address manually")
# Either name or regexp must be provided (but not both)
if not name and not regexp:
raise ValidationError("Either DNS name or regexp must be provided")
if name and regexp:
raise ValidationError("Provide either DNS name or regexp, not both")
# If container is selected, use its IP address
if container:
try:
cleaned_data['address'] = container.lease.address
except AttributeError:
raise ValidationError("Selected container has no IP lease")
# Check for existing DNS entries
if name:
existing = DNSStatic.objects.filter(name=name)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise ValidationError(f"DNS name '{name}' already exists")
if regexp:
existing = DNSStatic.objects.filter(regexp=regexp)
if self.instance.pk:
existing = existing.exclude(pk=self.instance.pk)
if existing.exists():
raise ValidationError(f"DNS regexp '{regexp}' already exists")
return cleaned_data
class Meta:
model = DNSStatic
fields = ['name', 'regexp', 'address', 'comment', 'container']
widgets = {
'name': forms.TextInput(attrs={'placeholder': 'e.g. server.example.com'}),
'regexp': forms.TextInput(attrs={'placeholder': 'e.g. .*\\.test\\.com'}),
'comment': forms.TextInput(attrs={'placeholder': 'Optional description'}),
}
help_texts = {
'name': 'Specific DNS name (e.g. server.example.com)',
'regexp': 'DNS regexp pattern (e.g. .*\\.test\\.com)',
'comment': 'Optional description for this DNS entry'
}
class DNSSearchForm(forms.Form):
search = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Search DNS entries...',
'id': 'dns-search'
})
)
entry_type = forms.ChoiceField(
choices=[
('', 'All types'),
('name', 'Names only'),
('regexp', 'Regexps only'),
],
required=False,
widget=forms.Select(attrs={'class': 'form-select'})
)
class FAQForm(forms.ModelForm):
class Meta:
model = FAQ
fields = ['title', 'content', 'order']
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Enter FAQ question/title...'
}),
'content': forms.Textarea(attrs={
'class': 'form-control',
'rows': 10,
'placeholder': 'Enter FAQ answer/content using Markdown syntax...\n\n# Heading\n## Subheading\n\n**Bold text**\n*Italic text*\n\n- List item 1\n- List item 2\n\n```python\ncode block\n```\n\n[Link text](http://example.com)',
'style': 'font-family: monospace;'
}),
'order': forms.NumberInput(attrs={
'class': 'form-control',
'min': 0
})
}
help_texts = {
'title': 'The FAQ question or title',
'content': 'The detailed answer or content (supports Markdown formatting)',
'order': 'Display order (lower numbers appear first)'
}

15
frontend/middleware.py Normal file
View File

@@ -0,0 +1,15 @@
from django.conf import settings
class FrontendSessionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.default_session_cookie_name = 'sessionid'
def __call__(self, request):
if request.path.startswith('/frontend/'):
settings.SESSION_COOKIE_NAME = settings.SESSION_COOKIE_NAME_FRONTEND
else:
settings.SESSION_COOKIE_NAME = self.default_session_cookie_name
response = self.get_response(request)
return response

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2025-07-10 09:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ldap_uid', models.CharField(max_length=100, unique=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-07-18 11:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='settings',
field=models.JSONField(default={'dashboard_view': 'list'}),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-07-21 11:03
import frontend.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('frontend', '0002_userprofile_settings'),
]
operations = [
migrations.AlterField(
model_name='userprofile',
name='settings',
field=models.JSONField(default=frontend.models.default_settings),
),
]

View File

54
frontend/models.py Normal file
View File

@@ -0,0 +1,54 @@
# frontend/models.py
from django.db import models
from django.contrib.auth.models import User
from lib.db import BaseModel, SearchableMixin
from django.db.models import Q
def default_settings():
return {
'dashboard_view': 'list',
}
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
ldap_uid = models.CharField(max_length=100, unique=True)
settings = models.JSONField(default=default_settings)
def __str__(self):
return f"{self.user.username} - {self.ldap_uid}"
@property
def user_group(self):
if self.user.groups.filter(name='root').exists():
return 'root'
elif self.user.groups.filter(name='intern').exists():
return 'intern'
elif self.user.groups.filter(name='extern').exists():
return 'extern'
return None
def is_external(self):
return self.user_group == 'extern'
def is_internal(self):
return self.user_group in ['intern', 'root']
def is_root(self):
return self.user_group == 'root'
class FAQ(BaseModel, SearchableMixin):
title = models.CharField(max_length=200, help_text='FAQ Question/Title')
content = models.TextField(help_text='FAQ Answer/Content')
order = models.IntegerField(default=0, help_text='Order for display (lower numbers first)')
class Meta:
ordering = ['order', 'title']
verbose_name = 'FAQ'
verbose_name_plural = 'FAQs'
def __str__(self):
return self.title
@classmethod
def term_filter(cls, search_string):
return Q(title__icontains=search_string) | Q(content__icontains=search_string)

22
frontend/permissions.py Normal file
View File

@@ -0,0 +1,22 @@
# frontend/permissions.py
from mikrotik.models import IPAddress
def user_can_access_container(user_profile, container):
"""Prüft, ob der Benutzer Zugriff auf den Container hat."""
if user_profile.is_internal():
return True
if user_profile.user.is_superuser:
return True
# Für externe Benutzer: Nur Container im eigenen Netzwerkbereich
ldap_uid = user_profile.ldap_uid
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {ldap_uid} ')
# Prüfen, ob Container-Netzwerk mit einer der IP-Adressen übereinstimmt
networks = [ip.network for ip in ip_addresses]
if hasattr(container, 'lease') and container.lease:
container_network = '.'.join(container.lease.address.split('.')[:3])
return any(network.startswith(container_network) for network in networks)
return False

23
frontend/signals.py Normal file
View File

@@ -0,0 +1,23 @@
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
from lib.ldap import Ldap
from lib.decorators import skip_signal
@receiver(post_save, sender=User)
@skip_signal()
def post_save_user_profile(sender, instance: User, created, **kwargs):
with Ldap() as ldap:
try:
ldap.set_user_groups(instance, save_instance=True)
except Exception as e:
logging.exception(e)
return
try:
if created or not UserProfile.objects.filter(ldap_uid=instance.username).exists():
UserProfile.objects.create(user=instance,ldap_uid=instance.username)
except Exception as e:
logging.exception("WTF???", str(e))

View File

@@ -0,0 +1,117 @@
/**
* Live Status Monitor for Container Creation
* Provides real-time updates similar to Proxmox task viewer
*/
class LiveStatusMonitor {
constructor(taskId, logContainer) {
this.taskId = taskId;
this.logContainer = logContainer;
this.pollInterval = 1000; // Poll every second
this.init();
}
init() {
console.log(`Initializing live status for task: ${this.taskId}`);
// Initial request with error handling
$.get(`/manager/task/${this.taskId}/status/`)
.done((data) => {
console.log('Initial status data received:', data);
this.updateDisplay(data);
// Start polling only after successful initial request
this.startPolling();
})
.fail((xhr, status, error) => {
console.error('Failed to get initial status:', error, xhr.responseText);
this.showError(`Failed to connect to live status: ${error}`);
// Still try to start polling in case it's a temporary issue
setTimeout(() => {
this.startPolling();
}, 2000);
});
}
startPolling() {
setInterval(() => {
$.get(`/manager/task/${this.taskId}/status/`)
.done((data) => {
this.updateDisplay(data);
})
.fail((xhr, status, error) => {
console.error('Polling error:', error);
// Don't show error for every polling failure
});
}, this.pollInterval);
}
showError(message) {
const logContent = this.logContainer.querySelector('.live-log-content');
if (logContent) {
logContent.innerHTML = `<div class="error" style="color: red; padding: 10px;">${message}</div>`;
}
}
updateDisplay(data) {
const logContent = this.logContainer.querySelector('.live-log-content');
if (!logContent) return;
const logs = data.logs || {};
const steps = logs.steps || [];
if (steps.length === 0) {
logContent.innerHTML = '<div class="loading">Waiting for status updates...</div>';
return;
}
// Track existing entries to highlight new ones
const existingCount = logContent.querySelectorAll('.log-entry').length;
logContent.innerHTML = '';
steps.forEach((step, index) => {
const entry = this.createLogEntry(step);
if (index >= existingCount) {
entry.classList.add('new');
}
logContent.appendChild(entry);
});
// Auto-scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
}
createLogEntry(step) {
const entry = document.createElement('div');
entry.className = 'log-entry';
const timestamp = new Date(step.timestamp).toLocaleTimeString();
const message = step.message || '';
entry.innerHTML = `
<div class="log-timestamp">${timestamp}</div>
<div class="log-message">${this.escapeHtml(message)}</div>
`;
return entry;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
let liveStatus = null;
function initLiveStatus(taskId, containerId) {
const container = document.getElementById(containerId);
if (container && taskId) {
liveStatus = new LiveStatusMonitor(taskId, container);
}
}

View File

@@ -0,0 +1,338 @@
/**
* Proxmox Task Management - Generelle Funktionen für Task-Überwachung
*/
try {
console.log('Proxmox Task Manager will start with ', taskMonitorOptions);
}catch(e){
console.log('Proxmox Task Manager will start without options');
const taskMonitorOptions = {};
}
class ProxmoxTaskManager {
constructor(options = taskMonitorOptions || {}) {
this.checkInterval = options.checkInterval || 2000; // 1 second default
this.maxTimeout = options.maxTimeout || 50000; // 30 seconds default
this.taskStatusUrl = options.taskStatusUrl || '/api/task-status/';
this.activeMonitors = new Map(); // Track active monitoring processes
}
/**
* Startet die Überwachung einer Proxmox Task
* @param {string} taskId - Die Proxmox Task ID
* @param {Object} callbacks - Callback-Funktionen für verschiedene Events
* @param {function} callbacks.onProgress - Wird bei jedem Status-Update aufgerufen
* @param {function} callbacks.onSuccess - Wird bei erfolgreichem Abschluss aufgerufen
* @param {function} callbacks.onError - Wird bei Fehlern aufgerufen
* @param {function} callbacks.onTimeout - Wird bei Timeout aufgerufen
* @param {Object} context - Zusätzlicher Kontext, der an Callbacks weitergegeben wird
*/
monitorTask(taskId, callbacks = {}, context = {}) {
// Stoppe eventuell bereits laufende Überwachung für diese Task
this.stopMonitoring(taskId);
const monitor = {
taskId: taskId,
callbacks: callbacks,
context: context,
interval: null,
timeout: null,
startTime: Date.now()
};
// Starte Intervall für Status-Checks
monitor.interval = setInterval(() => {
this._checkTaskStatus(monitor);
}, this.checkInterval);
// Safety timeout
monitor.timeout = setTimeout(() => {
this._handleTimeout(monitor);
}, this.maxTimeout);
// Speichere Monitor für spätere Referenz
this.activeMonitors.set(taskId, monitor);
// Ersten Check sofort ausführen
this._checkTaskStatus(monitor);
return taskId; // Return taskId for reference
}
/**
* Stoppt die Überwachung einer Task
* @param {string} taskId - Die Task ID
*/
stopMonitoring(taskId) {
const monitor = this.activeMonitors.get(taskId);
if (monitor) {
if (monitor.interval) clearInterval(monitor.interval);
if (monitor.timeout) clearTimeout(monitor.timeout);
this.activeMonitors.delete(taskId);
}
}
/**
* Stoppt alle aktiven Überwachungen
*/
stopAllMonitoring() {
for (const [taskId] of this.activeMonitors) {
this.stopMonitoring(taskId);
}
}
/**
* Prüft den Status einer Task
* @private
*/
_checkTaskStatus(monitor) {
$.get(this.taskStatusUrl, { 'task_id': monitor.taskId })
.done((data) => {
if (data.status === 'success') {
const taskStatus = data.task_status;
const progress = data.progress || 0;
// Progress callback
if (monitor.callbacks.onProgress) {
monitor.callbacks.onProgress(taskStatus, progress, data, monitor.context);
}
// Check if task is completed
if (taskStatus === 'OK' || taskStatus === 'completed') {
this._handleSuccess(monitor, data);
} else if (taskStatus === 'stopped' || taskStatus === 'error') {
this._handleError(monitor, data);
}
// If still running, continue monitoring
} else {
this._handleError(monitor, data);
}
})
.fail((xhr) => {
let errorMsg = 'Network error';
try {
const response = JSON.parse(xhr.responseText);
errorMsg = response.message || errorMsg;
} catch (e) {}
this._handleError(monitor, { message: errorMsg });
});
}
/**
* Behandelt erfolgreichen Task-Abschluss
* @private
*/
_handleSuccess(monitor, data) {
this.stopMonitoring(monitor.taskId);
if (monitor.callbacks.onSuccess) {
monitor.callbacks.onSuccess(data, monitor.context);
}
}
/**
* Behandelt Task-Fehler
* @private
*/
_handleError(monitor, data) {
this.stopMonitoring(monitor.taskId);
if (monitor.callbacks.onError) {
monitor.callbacks.onError(data, monitor.context);
}
}
/**
* Behandelt Timeout
* @private
*/
_handleTimeout(monitor) {
this.stopMonitoring(monitor.taskId);
if (monitor.callbacks.onTimeout) {
monitor.callbacks.onTimeout(monitor.context);
}
}
/**
* Gibt die Anzahl aktiver Überwachungen zurück
*/
getActiveMonitorCount() {
return this.activeMonitors.size;
}
/**
* Gibt alle aktiven Task IDs zurück
*/
getActiveTaskIds() {
return Array.from(this.activeMonitors.keys());
}
}
/**
* Standard Proxmox Task Manager Instanz
* Kann global verwendet werden
*/
const proxmoxTaskManager = new ProxmoxTaskManager();
/**
* Hilfsfunktion für einfache Task-Überwachung mit Standard-Callbacks
* @param {string} taskId - Die Task ID
* @param {Object} element - DOM Element das visuell aktualisiert werden soll
* @param {string} action - Aktion die ausgeführt wird (für Logging)
* @param {function} onComplete - Optional: Custom completion callback
*/
function monitorProxmoxTask(taskId, element, action, onComplete = null) {
const $element = $(element);
return proxmoxTaskManager.monitorTask(taskId, {
onProgress: (status, progress, data, context) => {
console.log(`Task ${taskId} progress: ${status} (${progress}%)`);
// Update tooltip with progress
if (progress > 0) {
$element.attr('title', `${action}: ${progress}%`);
}
},
onSuccess: (data, context) => {
console.log(`Task ${taskId} completed successfully`);
$element.removeClass('pending error');
if (onComplete) {
onComplete(true, data, context);
} else {
// Standard success handling
$element.attr('title', `${action} completed successfully`);
}
},
onError: (data, context) => {
console.error(`Task ${taskId} failed:`, data.message);
$element.removeClass('pending').addClass('error');
$element.attr('title', `${action} failed: ${data.message}`);
if (onComplete) {
onComplete(false, data, context);
}
},
onTimeout: (context) => {
console.warn(`Task ${taskId} monitoring timed out`);
$element.removeClass('pending').addClass('error');
$element.attr('title', `${action} timed out`);
if (onComplete) {
onComplete(false, { message: 'Timeout' }, context);
}
}
}, {
element: element,
action: action,
taskId: taskId
});
}
/**
* Erweiterte Task-Überwachung mit Live-Log-Container (für Container-Erstellung etc.)
* @param {string} taskId - Die Task ID
* @param {string} logContainerId - ID des Log-Containers
* @param {function} onComplete - Callback bei Completion
*/
function monitorProxmoxTaskWithLiveLog(taskId, logContainerId, onComplete = null) {
const logContainer = document.getElementById(logContainerId);
const logContent = logContainer?.querySelector('.live-log-content');
if (!logContainer || !logContent) {
console.error('Log container not found:', logContainerId);
return;
}
// Clear existing content
logContent.innerHTML = '<div class="loading">Monitoring task...</div>';
return proxmoxTaskManager.monitorTask(taskId, {
onProgress: (status, progress, data, context) => {
const timestamp = new Date().toLocaleTimeString();
const progressText = progress > 0 ? ` (${progress}%)` : '';
const logEntry = document.createElement('div');
logEntry.className = 'log-entry new';
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-level info">INFO</span>
<span class="log-message">Task ${status}${progressText}</span>
`;
// Remove loading message if present
const loading = logContent.querySelector('.loading');
if (loading) loading.remove();
logContent.appendChild(logEntry);
logContent.scrollTop = logContent.scrollHeight;
// Remove 'new' class after animation
setTimeout(() => logEntry.classList.remove('new'), 500);
},
onSuccess: (data, context) => {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = 'log-entry new';
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-level success">✓</span>
<span class="log-message">Task completed successfully</span>
`;
logContent.appendChild(logEntry);
logContent.scrollTop = logContent.scrollHeight;
if (onComplete) {
setTimeout(() => onComplete(true, data, context), 1000);
}
},
onError: (data, context) => {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = 'log-entry new';
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-level error">✗</span>
<span class="log-message">Task failed: ${data.message}</span>
`;
logContent.appendChild(logEntry);
logContent.scrollTop = logContent.scrollHeight;
if (onComplete) {
setTimeout(() => onComplete(false, data, context), 1000);
}
},
onTimeout: (context) => {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = 'log-entry new';
logEntry.innerHTML = `
<span class="log-timestamp">${timestamp}</span>
<span class="log-level warning">⚠</span>
<span class="log-message">Task monitoring timed out</span>
`;
logContent.appendChild(logEntry);
logContent.scrollTop = logContent.scrollHeight;
if (onComplete) {
setTimeout(() => onComplete(false, { message: 'Timeout' }, context), 1000);
}
}
}, {
taskId: taskId,
logContainerId: logContainerId
});
}
// Cleanup bei Seitenwechsel
$(window).on('beforeunload', function() {
proxmoxTaskManager.stopAllMonitoring();
});

View File

@@ -0,0 +1,99 @@
<!-- frontend/templates/frontend/base.html -->
{% load static %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Container Management{% block title %}{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<style>
body {
padding-top: 70px;
}
.form-control, select, input, textarea {
width: 100%;
max-width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
border: 1px solid #ced4da;
border-radius: 0.25rem;
}
@media (max-width: 767.98px) {
select, input, textarea {
font-size: 16px;
}
}
i.bi {
margin-right:.5em;
}
.btn-group i.bi{
margin: initial;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container">
<a class="navbar-brand" href="{% url 'frontend:dashboard' %}">Container Management</a>
{% if user.is_authenticated %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'frontend:dashboard' %}">Dev Container</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'frontend:dns_list' %}">DNS Management</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'frontend:faq_list' %}">FAQ</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'tasklogger:task_list' %}">Tasks</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<span class="nav-link">{{ user.username }} ({{ user.profile.user_group }})</span>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'frontend:logout' %}?next={{ request.path }}">Logout</a>
</li>
<li class="nav-item">
<a href="{% url 'manager:sync_all' %}"><i class="btn bi bi-arrow-repeat"></i> </a>
</li>
</ul>
</div>
{% endif %}
</div>
</nav>
<div class="container mt-5">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show"
onclick="$(this).fadeOut(function(){$(this).slideUp();})" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,48 @@
<!-- frontend/templates/frontend/container_detail.html -->
{% extends 'frontend/base.html' %}
{% block title %} - Container Details{% endblock %}
{% block content %}
{% url 'frontend:edit_container' as editurl %}
{% url 'frontend:delete_container' as deleteurl %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Container: {{ container.name }}</h2>
<div>
<a href="{{ editurl }}" class="btn btn-warning">Edit</a>
<a href="{{ deleteurl }}" class="btn btn-danger">Delete</a>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5>Container details</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Name:</strong> {{ container.name }}</p>
<p><strong>IP-Adresse:</strong> {{ container.address }}</p>
<p><strong>Hostname:</strong> {{ container.hostname }}</p>
<p><strong>MAC-Adresse:</strong> {{ container.hwaddr }}</p>
</div>
<div class="col-md-6">
<p><strong>Status:</strong> {{ container.status }}</p>
<p><strong>Speicher:</strong> {{ container.lxc.memory }} MB</p>
<p><strong>CPU-Kerne:</strong> {{ container.lxc.cores }}</p>
<p><strong>Festplatte:</strong> {{ container.lxc.disksize }} GB</p>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5>DNS-Settings</h5>
</div>
<div class="card-body">
<p><strong>DNS-Name:</strong> {{ container.dns.name|default:"--" }}</p>
<p><strong>DNS-Regex:</strong> {{ container.dns.regexp|default:"--" }}</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,342 @@
{% extends 'frontend/base.html' %}
{% load static %}
{% block title %} - Create Dev Container{% endblock %}
{% block extra_head %}
{{ block.super }}
<style>
.select2-container {
width: 100% !important;
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<!-- Rest of your existing content -->
<div class="col-12 col-md-10 col-lg-8">
<div class="card">
<div class="card-header">
<h4 class="card-title mb-0">Create Dev Container</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<input type="hidden" name="task_uuid" value="{{ task_uuid }}">
<div class="row mb-4">
<div class="col-md-10">
<label for="{{ form.hostname.id_for_label }}"
class="form-label fw-bold">{{ form.hostname.label }}</label>
{{ form.hostname }}
{% if form.hostname.help_text %}
<div class="form-text small text-muted mt-1">{{ form.hostname.help_text }}</div>
{% endif %}
{% if form.hostname.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.hostname.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-2" title="{{ form.as_regexp.help_text }}">
<label for="{{ form.as_regexp.id_for_label }}"
class="form-label fw-bold">{{ form.as_regexp.label }}</label>
{{ form.as_regexp }}
</div>
</div>
<div class="row mb-4">
<div class="col-md-{% if request.GET.clone_lxc %}12{% else %}6{% endif %}">
<label for="{{ form.vm.id_for_label }}"
class="form-label fw-bold">{{ form.vm.label }}</label>
{{ form.vm }}
{% if form.vm.help_text %}
<div class="form-text small text-muted mt-1">{{ form.vm.help_text }}</div>
{% endif %}
{% if form.vm.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.vm.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if not request.GET.clone_lxc %}
<div class="col-md-1 text-center">
<label class="form-label fw-bold">or</label>
</div>
<div class="col-md-5">
<label for="{{ form.template.id_for_label }}"
class="form-label fw-bold">{{ form.template.label }}</label>
{{ form.template }}
{% if form.template.help_text %}
<div class="form-text small text-muted mt-1">{{ form.template.help_text }}</div>
{% endif %}
{% if form.template.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.template.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Network-Feld -->
<div class="mb-4">
<label for="{{ form.network.id_for_label }}"
class="form-label fw-bold">{{ form.network.label }}</label>
{{ form.network }}
{% if form.network.help_text %}
<div class="form-text small text-muted mt-1">{{ form.network.help_text }}</div>
{% endif %}
{% if form.network.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.network.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Dritte Zeile: cores, memory und disksize -->
<div class="row mb-4">
<div class="col-md-4">
<label for="{{ form.cores.id_for_label }}"
class="form-label fw-bold">{{ form.cores.label }}</label>
{{ form.cores }}
{% if form.cores.help_text %}
<div class="form-text small text-muted mt-1">{{ form.cores.help_text }}</div>
{% endif %}
{% if form.cores.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.cores.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.memory.id_for_label }}"
class="form-label fw-bold">{{ form.memory.label }}</label>
{{ form.memory }}
{% if form.memory.help_text %}
<div class="form-text small text-muted mt-1">{{ form.memory.help_text }}</div>
{% endif %}
{% if form.memory.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.memory.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.disksize.id_for_label }}"
class="form-label fw-bold">{{ form.disksize.label }}</label>
{{ form.disksize }}
{% if form.disksize.help_text %}
<div class="form-text small text-muted mt-1">{{ form.disksize.help_text }}</div>
{% endif %}
{% if form.disksize.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.disksize.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Beschreibung (falls vorhanden) -->
{% if form.description %}
<div class="mb-4">
<label for="{{ form.description.id_for_label }}"
class="form-label fw-bold">{{ form.description.label }}</label>
{{ form.description }}
{% if form.description.help_text %}
<div class="form-text small text-muted mt-1">{{ form.description.help_text }}</div>
{% endif %}
{% if form.description.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in form.description.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% for field in form %}
{% if field.name != 'name' and field.name != 'hostname' and field.name != 'as_regexp' and field.name != 'template' and field.name != 'vm' and field.name != 'network' and field.name != 'cores' and field.name != 'memory' and field.name != 'disksize' and field.name != 'description' %}
<div class="mb-4">
<label for="{{ field.id_for_label }}"
class="form-label fw-bold">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<div class="form-text small text-muted mt-1">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block mt-1">
{% for error in field.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
<a href="{% url 'frontend:dashboard' %}"
class="btn btn-outline-secondary me-md-2 mb-2 mb-md-0 w-100 w-md-auto">Cancel</a>
<button type="submit" class="btn btn-outline-primary w-100 w-md-auto" id="create-btn">Create
Container
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Task Progress Overlay -->
<div id="task-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9999; color: white;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1e1e1e; padding: 30px; border-radius: 10px; min-width: 500px; max-width: 700px;">
<h4>Creating Container...</h4>
<div class="task-info">
<small>Task UUID: <span id="task-uuid">{{ task_uuid }}</span></small>
</div>
<div id="task-logs" style="background: #000; padding: 15px; border-radius: 5px; margin: 15px 0; max-height: 400px; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 12px;">
<div class="log-entry">Initializing...</div>
</div>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- jQuery und Select2 JS -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
let taskMonitorInterval = null;
$(document).ready(function () {
// Select2 für alle select-Elemente aktivieren
$('#id_network').select2({
width: '100%'
});
$('#id_vm').select2({
width: '100%'
});
$('#id_template').children('option').each(function () {
$(this).text($(this).text().split('/')[1]);
});
$('#id_template').select2({
width: '100%'
});
// Form submission with async task monitoring
$('form').on('submit', function (e) {
e.preventDefault();
$('#create-btn').prop('disabled', true).text('Creating Container...');
// Submit form via AJAX
$.ajax({
url: $(this).attr('action'),
method: 'POST',
data: $(this).serialize(),
success: function(response) {
if (response.status === 'task_started') {
// Show overlay and start monitoring
$('#task-uuid').text(response.task_uuid);
$('#task-overlay').show();
startTaskMonitoring(response.task_uuid);
} else {
alert('Error: ' + response.message);
$('#create-btn').prop('disabled', false).text('Create Container');
}
},
error: function(xhr) {
let errorMsg = 'Network error';
try {
const response = JSON.parse(xhr.responseText);
errorMsg = response.message || errorMsg;
} catch (e) {}
alert('Error: ' + errorMsg);
$('#create-btn').prop('disabled', false).text('Create Container');
}
});
});
});
function startTaskMonitoring(taskUuid) {
let entryCount = 0;
taskMonitorInterval = setInterval(function() {
$.get('{% url "tasklogger:task_status" %}', {'task_uuid': taskUuid})
.done(function(data) {
if (data.status === 'success') {
const task = data.task;
const entries = task.entries || [];
// Update logs (only new entries)
if (entries.length > entryCount) {
const newEntries = entries.slice(entryCount);
newEntries.forEach(function(entry) {
const logEntry = $('<div class="log-entry"></div>');
const timestamp = new Date(entry.created_at).toLocaleTimeString();
let message = '';
if (typeof entry.message === 'object') {
message = JSON.stringify(entry.message, null, 2);
} else {
message = entry.message || 'No message';
}
logEntry.html('<span style="color: #666;">' + timestamp + '</span> ' + message);
$('#task-logs').append(logEntry);
});
// Scroll to bottom
$('#task-logs').scrollTop($('#task-logs')[0].scrollHeight);
entryCount = entries.length;
}
// Check if task completed
if (task.status === 'completed') {
clearInterval(taskMonitorInterval);
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-success').css('width', '100%');
setTimeout(function() {
window.location.href = "{% url 'frontend:dashboard' %}";
}, 2000);
} else if (task.status === 'error') {
clearInterval(taskMonitorInterval);
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-danger').css('width', '100%');
// Add close button
$('#task-overlay .progress').after('<button class="btn btn-danger mt-3" onclick="window.location.href=\'{% url "frontend:dashboard" %}\'">Close</button>');
}
}
})
.fail(function() {
console.error('Failed to get task status');
});
}, 1000); // Check every second
}
</script>
{% endblock %}

View File

@@ -0,0 +1,387 @@
{% extends 'frontend/base.html' %}
{% block title %} - Dashboard{% endblock %}
{% block content %}
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>
<i class="bi bi-pc-display-horizontal"></i> Dev Container
</h3>
{% if request.user.is_staff %}
<div role="group">
<a href="{% url 'frontend:create_container' %}"
class="btn btn-primary"
><i class="bi bi-plus-circle"></i> Create Dev Container</a>
{% if default_template %}
<a href="{% url 'frontend:create_container' %}?clone_template={{ default_template.pk }}"
class="btn btn-secondary"
><i class="bi bi-plus-circle"></i> Create from default Template</a>
{% endif %}
</div>
{% endif %}
</div>
{% url 'frontend:dashboard' as search_action_url %}
{% include 'frontend/includes/pagination_snippet.html' %}
<div id="cardView" class="row">
{% for container in page_obj %}
<div class="col-12 col-md-6 col-lg-4 mb-3 container-item" data-crow="{{ container.internal_id }}">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<!-- LXC Status -->
<div class="me-2" title="LXC Status: {{ container.lxc.status }}">
<span class="status-circle status-lxc {{ container.lxc.status }} lxc-status-{{ container.internal_id }}"></span>
</div>
<div class="me-3" title="Network Status: {{ container.lease.status }}">
<span class="status-circle status-lease {{ container.lxc.status }} lease_status_{{ container.internal_id }}"></span>
</div>
<h5 class="mb-0">{{ container.vmid }} {{ container.name }}</h5>
</div>
</div>
<div class="card-body">
<div class="mb-2"><strong
style="width:25%;display: inline-block">Hostname:</strong> {{ container.hostname }}
</div>
<div class="mb-2"><strong
style="width:25%;display: inline-block">IP-Address:</strong> {{ container.address }}
</div>
<div class="mb-2"><strong style="width: 25%;display: inline-block;">Root
Disk:</strong> {{ container.lxc.disksize }} GB
</div>
<div class="mb-2"><strong
style="width: 25%;display: inline-block;">Memory:</strong> {{ container.lxc.memory }}
MB
</div>
<div class="mb-2"><strong
style="width: 25%;display: inline-block;">Cores:</strong> {{ container.lxc.cores }}
</div>
</div>
<div class="card-footer">
<div class="btn-group w-100" role="group">
<button onclick="openProxmoxConsole('{{ container.lxc.proxmox_console_url }}', '{{ container.lxc.vmid }}')"
class="btn btn-secondary terminal terminal-{{ container.pk }}"
title="Open Proxmox Console">
<i class="bi bi-terminal"></i>
</button>
<a href="{% url 'frontend:container_detail' container.pk %}"
class="btn btn-info" title="Details">
<i class="bi bi-info-circle"></i>
</a>
<a href="{% url 'frontend:edit_container' container.pk %}"
class="btn btn-warning" title="Edit">
<i class="bi bi-gear"></i>
</a>
<a href="{% url 'frontend:create_container' %}?clone_lxc={{ container.pk }}"
class="btn btn-secondary btn-sm" title="Clone">
<i class="bi bi-copy"></i>
</a>
<a href="{% url 'frontend:delete_container' container.pk %}"
class="btn btn-danger" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="alert alert-info text-center">
{% if request.GET.search %}
No Container for "{{ request.GET.search }}".
<div class="mt-3 text-center">
<a href="{% url 'frontend:dashboard' %}" class="btn btn-sm btn-secondary mt-2"><i
class="bi bi-trash">&nbsp;Clear Search</i></a>
</div>
{% else %}
No Containers found
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div id="listView" class="row d-none">
<div class="col-12">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Stati</th>
<th>Name</th>
<th>Hostname</th>
<th>IP-Address</th>
<th>Root Disk</th>
<th>Memory</th>
<th>Cores</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for container in page_obj %}
<tr class="container-item" data-crow="{{ container.internal_id }}">
<td>
<div class="d-flex align-items-center">
<!-- LXC Status -->
<div class="me-2" title="LXC Status: {{ container.lxc.status }}">
<span class="status-circle {{ container.lxc.status }} lxc_status_{{ container.internal_id }}"></span>
</div>
<!-- Network Status -->
<div title="Network Status: {{ container.status }}">
<span class="status-circle {{ container.lease.status }} lease_status_{{ container.internal_id }}"></span>
</div>
</div>
</td>
<td>{{ container.vmid }} {{ container.name }}</td>
<td>{{ container.hostname }}</td>
<td>{{ container.address }}</td>
<td>{{ container.lxc.disksize }} GB</td>
<td>{{ container.lxc.memory }} MB</td>
<td>{{ container.lxc.cores }}</td>
<td>
<div class="btn-group" role="group">
<button onclick="openProxmoxConsole('{{ container.lxc.proxmox_console_url }}', '{{ container.lxc.vmid }}')"
class="btn btn-sm btn-secondary terminal terminal-{{ container.pk }}"
title="Open Proxmox Console">
<i class="bi bi-terminal"></i>
</button>
<a href="{% url 'frontend:container_detail' container.pk %}"
class="btn btn-info btn-sm" title="Details">
<i class="bi bi-info-circle"></i>
</a>
<a href="{% url 'frontend:edit_container' container.pk %}"
class="btn btn-warning btn-sm" title="Edit">
<i class="bi bi-gear"></i>
</a>
<a href="{% url 'frontend:create_container' %}?clone_lxc={{ container.lxc.pk }}"
class="btn btn-secondary btn-sm" title="Clone">
<i class="bi bi-copy"></i>
</a>
<a href="{% url 'frontend:delete_container' container.pk %}"
class="btn btn-danger btn-sm" title="Delete">
<i class="bi bi-trash"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-center">
{% if request.GET.search %}
No Container for "{{ request.GET.search }}".
<div class="mt-3 text-center">
<a href="{% url 'frontend:dashboard' %}"
class="btn btn-sm btn-secondary mt-2"><i class="bi bi-trash">&nbsp;Clear
Search</i></a>
</div>
{% else %}
No Containers found
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include 'frontend/includes/pagination_snippet.html' %}
<style>
.status-circle {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: darkred;
}
.status-low,
.status-low td,
.status-low div {
background-color: #f9aea8 !important;
}
.status-circle.active,
.status-circle.running,
.status-circle.bound {
background-color: darkgreen;
}
.status-circle.pending {
background-color: orange;
animation: pulse 1.5s infinite;
}
.status-circle.error {
background-color: red;
animation: blink 1s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0.3;
}
100% {
opacity: 1;
}
}
.btn.terminal {
background-color: #444444;
border-color: #444444;
color: white;
}
.terminal-none {
display: none;
}
.search-form {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
padding-bottom: 0;
margin-bottom: 20px;
}
</style>
<script>
var respd = false;
document.addEventListener('DOMContentLoaded', function () {
const cardViewBtn = document.getElementById('cardViewBtn');
const listViewBtn = document.getElementById('listViewBtn');
const cardView = document.getElementById('cardView');
const listView = document.getElementById('listView');
let viewMode = localStorage.getItem('containerViewMode') || '{{ user_profile.settings.dashboard_view }}';
'{{page_ids }}'.split(',').forEach(function (id) {
if (!id) {
console.warn('Current Container called but id is empty - {{ page_ids }}');
return;
}
$.get("{% url 'frontend:current_container_details' %}", {'ids': id}, function (container) {
container = container[0]
if (container.is_low) {
$('[data-crow="' + id + '"]').addClass(
'status-low'
).attr(
'title',
'Low Warning: Disk: ' + container.disk_percent + '%, Memory: ' + container.mem_percent + '%'
);
}
let lxc_status = $('.lxc_status_' + id);
let lease_status = $('.lease_status_' + id);
lxc_status.removeClass('stopped,running)').addClass(container.lxc_status).css('cursor', 'pointer');
/*
if(container.lxc_status === 'running'){
$('.terminal-' + id).removeClass('terminal-none')
}else{
$('.terminal-' + id).addClass('terminal-none')
}
*/
lxc_status.attr('title', 'LXC Status: ' + container.lxc_status);
let stopurl = "{% url 'frontend:stop_lxc' 0 %}";
let starturl = "{% url 'frontend:start_lxc' 0 %}";
lxc_status.click(function () {
if (container.lxc_status === 'stopped') {
performContainerAction(id, 'start', starturl, lxc_status);
} else {
performContainerAction(id, 'stop', stopurl, lxc_status);
}
});
lease_status.removeClass('bound,waiting)').addClass(container.lease_status);
lease_status.attr('title', 'Network Status: ' + container.lease_status);
});
});
function checkScreenSizeAndAdjustView() {
const isMobileOrTablet = window.innerWidth < 768;
if (isMobileOrTablet && viewMode === 'list') {
cardView.classList.remove('d-none');
listView.classList.add('d-none');
cardViewBtn.classList.add('active');
listViewBtn.classList.remove('active');
} else {
if (viewMode === 'list') {
cardView.classList.add('d-none');
listView.classList.remove('d-none');
cardViewBtn.classList.remove('active');
listViewBtn.classList.add('active');
} else {
cardView.classList.remove('d-none');
listView.classList.add('d-none');
cardViewBtn.classList.add('active');
listViewBtn.classList.remove('active');
}
}
}
checkScreenSizeAndAdjustView();
window.addEventListener('resize', checkScreenSizeAndAdjustView);
function chView(vm) {
viewMode = vm;
localStorage.setItem('containerViewMode', viewMode);
checkScreenSizeAndAdjustView();
/*
$.post('{ % url 'manager:set_view_mode' % }', {'mode': viewMode}, function (data) {
console.log(data);
});
*/
}
cardViewBtn.addEventListener('click', function () {
chView('card');
});
listViewBtn.addEventListener('click', function () {
chView('list');
});
});
function openProxmoxConsole(url, vmid) {
if (!localStorage.getItem('pm-warning-confirmed')) {
if (!confirm('You have to be logged in in the Proxmox to open a Console here.\n\n')) {
return;
}
localStorage.setItem('pm-warning-confirmed', 'true');
}
window.open(
url,
'proxmox-console-' + vmid,
'width=800,height=600,scrollbars=yes,resizable=yes,status=yes,toolbar=yes,menubar=no,location=yes'
);
}
function performContainerAction(containerId, action, url, statusElement) {
// Simply redirect to the URL (synchronous operation with form-blocking)
window.location.href = url + '?id=' + containerId;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,138 @@
<!-- frontend/templates/frontend/delete_container.html -->
{% extends 'frontend/base.html' %}
{% block title %} - Container löschen{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h4>Container löschen</h4>
</div>
<div class="card-body">
<p>Sind Sie sicher, dass Sie den Container <strong>{{ container.name }}</strong> löschen möchten?</p>
<p class="text-danger">Diese Aktion kann nicht rückgängig gemacht werden!</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="task_uuid" value="{{ task_uuid }}" />
<button type="submit" class="btn btn-danger" id="delete-btn">Ja, löschen</button>
<a href="{% url 'frontend:container_detail' container.internal_id %}" class="btn btn-secondary">Abbrechen</a>
</form>
</div>
</div>
<!-- Task Progress Overlay -->
<div id="task-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 9999; color: white;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1e1e1e; padding: 30px; border-radius: 10px; min-width: 500px; max-width: 700px;">
<h4>Deleting Container...</h4>
<div class="task-info">
<small>Task UUID: <span id="task-uuid"></span></small>
</div>
<div id="task-logs" style="background: #000; padding: 15px; border-radius: 5px; margin: 15px 0; max-height: 400px; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 12px;">
<div class="log-entry">Initializing...</div>
</div>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let taskMonitorInterval = null;
$(document).ready(function () {
// Form submission with async task monitoring
$('form').on('submit', function (e) {
e.preventDefault();
$('#delete-btn').prop('disabled', true).text('Lösche Container...');
// Submit form via AJAX
$.ajax({
url: $(this).attr('action'),
method: 'POST',
data: $(this).serialize(),
success: function(response) {
if (response.status === 'task_started') {
// Show overlay and start monitoring
$('#task-uuid').text(response.task_uuid);
$('#task-overlay').show();
startTaskMonitoring(response.task_uuid);
} else {
alert('Error: ' + response.message);
$('#delete-btn').prop('disabled', false).text('Ja, löschen');
}
},
error: function(xhr) {
let errorMsg = 'Network error';
try {
const response = JSON.parse(xhr.responseText);
errorMsg = response.message || errorMsg;
} catch (e) {}
alert('Error: ' + errorMsg);
$('#delete-btn').prop('disabled', false).text('Ja, löschen');
}
});
});
});
function startTaskMonitoring(taskUuid) {
let entryCount = 0;
taskMonitorInterval = setInterval(function() {
$.get('{% url "tasklogger:task_status" %}', {'task_uuid': taskUuid})
.done(function(data) {
if (data.status === 'success') {
const task = data.task;
const entries = task.entries || [];
// Update logs (only new entries)
if (entries.length > entryCount) {
const newEntries = entries.slice(entryCount);
newEntries.forEach(function(entry) {
const logEntry = $('<div class="log-entry"></div>');
const timestamp = new Date(entry.created_at).toLocaleTimeString();
let message = '';
if (typeof entry.message === 'object') {
message = JSON.stringify(entry.message, null, 2);
} else {
message = entry.message || 'No message';
}
logEntry.html('<span style="color: #666;">' + timestamp + '</span> ' + message);
$('#task-logs').append(logEntry);
});
// Scroll to bottom
$('#task-logs').scrollTop($('#task-logs')[0].scrollHeight);
entryCount = entries.length;
}
// Check if task completed
if (task.status === 'completed') {
clearInterval(taskMonitorInterval);
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-success').css('width', '100%');
setTimeout(function() {
window.location.href = "{% url 'frontend:dashboard' %}";
}, 2000);
} else if (task.status === 'error') {
clearInterval(taskMonitorInterval);
$('.progress-bar').removeClass('progress-bar-animated').addClass('bg-danger').css('width', '100%');
// Add close button
$('#task-overlay .progress').after('<button class="btn btn-danger mt-3" onclick="window.location.href=\'{% url "frontend:dashboard" %}\'">Close</button>');
}
}
})
.fail(function() {
console.error('Failed to get task status');
});
}, 1000); // Check every second
}
</script>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends 'frontend/base.html' %}
{% block title %} - Delete DNS Entry{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-trash"></i>
Delete DNS Entry
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<strong>Warning!</strong> This action cannot be undone.
</div>
<p>Are you sure you want to delete the following DNS entry?</p>
<div class="card bg-light">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Type:</dt>
<dd class="col-sm-9">
{% if dns_entry.name %}
<span class="badge bg-primary">DNS Name</span>
{% else %}
<span class="badge bg-info">RegExp Pattern</span>
{% endif %}
</dd>
<dt class="col-sm-3">
{% if dns_entry.name %}Name:{% else %}Pattern:{% endif %}
</dt>
<dd class="col-sm-9">
{% if dns_entry.name %}
<strong>{{ dns_entry.name }}</strong>
{% else %}
<code>{{ dns_entry.regexp }}</code>
{% endif %}
</dd>
<dt class="col-sm-3">IP Address:</dt>
<dd class="col-sm-9">
<span class="badge bg-secondary">{{ dns_entry.address }}</span>
</dd>
{% if dns_entry.comment %}
<dt class="col-sm-3">Comment:</dt>
<dd class="col-sm-9">{{ dns_entry.comment }}</dd>
{% endif %}
</dl>
</div>
</div>
<form method="post" class="mt-4">
{% csrf_token %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'frontend:dns_list' %}" class="btn btn-outline-secondary me-md-2">
<i class="bi bi-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Delete DNS Entry
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,266 @@
{% extends 'frontend/base.html' %}
{% load static %}
{% block title %} - {{ title }}{% endblock %}
{% block extra_head %}
{{ block.super }}
<!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<style>
.form-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.form-section h5 {
color: #495057;
border-bottom: 2px solid #dee2e6;
padding-bottom: 10px;
margin-bottom: 15px;
}
.exclusive-fields {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 15px 0;
}
.select2-container {
width: 100% !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-book"></i>
{{ title }}
</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<!-- IP Address Section -->
<div class="form-section">
<h5><i class="bi bi-router"></i> IP Address</h5>
<div class="mb-3">
<label for="{{ form.container.id_for_label }}" class="form-label fw-bold">
{{ form.container.label }}
</label>
{{ form.container }}
{% if form.container.help_text %}
<div class="form-text">{{ form.container.help_text }}</div>
{% endif %}
{% if form.container.errors %}
<div class="invalid-feedback d-block">
{% for error in form.container.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="text-center my-3">
<strong>OR</strong>
</div>
<div class="mb-3">
<label for="{{ form.address.id_for_label }}" class="form-label fw-bold">
{{ form.address.label }}
</label>
{{ form.address }}
{% if form.address.help_text %}
<div class="form-text">{{ form.address.help_text }}</div>
{% endif %}
{% if form.address.errors %}
<div class="invalid-feedback d-block">
{% for error in form.address.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- DNS Configuration Section -->
<div class="form-section">
<h5><i class="bi bi-dns"></i> DNS Configuration</h5>
<div class="exclusive-fields">
<strong>Choose ONE of the following:</strong>
</div>
<div class="mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label fw-bold">
{{ form.name.label }}
</label>
{{ form.name }}
{% if form.name.help_text %}
<div class="form-text">{{ form.name.help_text }}</div>
{% endif %}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{% for error in form.name.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="text-center my-3">
<strong>OR</strong>
</div>
<div class="mb-3">
<label for="{{ form.regexp.id_for_label }}" class="form-label fw-bold">
{{ form.regexp.label }}
</label>
{{ form.regexp }}
{% if form.regexp.help_text %}
<div class="form-text">{{ form.regexp.help_text }}</div>
{% endif %}
{% if form.regexp.errors %}
<div class="invalid-feedback d-block">
{% for error in form.regexp.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Comment Section -->
<div class="form-section">
<h5><i class="bi bi-chat-text"></i> Additional Information</h5>
<div class="mb-3">
<label for="{{ form.comment.id_for_label }}" class="form-label fw-bold">
{{ form.comment.label }}
</label>
{{ form.comment }}
{% if form.comment.help_text %}
<div class="form-text">{{ form.comment.help_text }}</div>
{% endif %}
{% if form.comment.errors %}
<div class="invalid-feedback d-block">
{% for error in form.comment.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<!-- Form Validation Errors -->
{% if form.non_field_errors %}
<div class="alert alert-danger">
<strong>Please correct the following errors:</strong>
<ul class="mb-0 mt-2">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{% url 'frontend:dns_list' %}" class="btn btn-outline-secondary me-md-2">
<i class="bi bi-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {{ submit_text }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<!-- jQuery and Select2 JS -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
// Initialize Select2 for container selection
$('.container-select').select2({
placeholder: 'Search containers...',
allowClear: true,
ajax: {
url: '{% url "frontend:dns_container_api" %}',
dataType: 'json',
delay: 250,
data: function (params) {
return {
search: params.term,
page: params.page || 1
};
},
processResults: function (data) {
return {
results: data.results
};
},
cache: true
},
minimumInputLength: 0,
});
// Auto-fill IP address when container is selected
$('.container-select').on('select2:select', function (e) {
var data = e.params.data;
if (data.address) {
$('#id_address').val(data.address);
}
});
// Clear IP address when container selection is cleared
$('.container-select').on('select2:clear', function (e) {
$('#id_address').val('');
});
// Mutual exclusion for name/regexp fields
$('#id_name').on('input', function() {
if ($(this).val()) {
$('#id_regexp').prop('disabled', true);
} else {
$('#id_regexp').prop('disabled', false);
}
});
$('#id_regexp').on('input', function() {
if ($(this).val()) {
$('#id_name').prop('disabled', true);
} else {
$('#id_name').prop('disabled', false);
}
});
// Initialize mutual exclusion on page load
if ($('#id_name').val()) {
$('#id_regexp').prop('disabled', true);
}
if ($('#id_regexp').val()) {
$('#id_name').prop('disabled', true);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,144 @@
{% extends 'frontend/base.html' %}
{% load static %}
{% block title %} - DNS Management{% endblock %}
{% block extra_head %}
{{ block.super }}
<style>
.dns-type-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.dns-name { background-color: #e3f2fd; color: #1565c0; }
.dns-regexp { background-color: #f3e5f5; color: #7b1fa2; }
.search-form {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
</style>
{% endblock %}
{% block content %}
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>
<i class="bi bi-book"></i> DNS Management
</h3>
<a href="{% url 'frontend:dns_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add DNS Entry
</a>
</div>
{% include 'frontend/includes/dns_pagination_snippet.html' %}
<!-- DNS Entries Table -->
<div class="card">
<div class="card-body">
{% if page_obj.object_list %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Type</th>
<th>DNS Name/Pattern</th>
<th>IP Address</th>
<th>Comment</th>
<th>Created</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
{% for dns in page_obj %}
<tr>
<td>
{% if dns.name %}
<span class="badge dns-name dns-type-badge">Name</span>
{% else %}
<span class="badge dns-regexp dns-type-badge">RegExp</span>
{% endif %}
</td>
<td>
{% if dns.name %}
<strong>{{ dns.name }}</strong>
{% else %}
<code>{{ dns.regexp }}</code>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dns.address }}</span>
</td>
<td>
<small class="text-muted">{{ dns.comment|default:"-" }}</small>
</td>
<td>
<small class="text-muted">
{% if dns.created_at %}
{{ dns.created_at|date:"M d, Y" }}
{% else %}
-
{% endif %}
</small>
</td>
<td>
{% if dns.pk not in dev_container_dns %}
<div class="btn-group" role="group">
<a href="{% url 'frontend:dns_edit' dns.id %}"
class="btn btn-sm btn-outline-warning"
title="Edit DNS Entry">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'frontend:dns_delete' dns.id %}"
class="btn btn-sm btn-outline-danger"
title="Delete DNS Entry">
<i class="bi bi-trash"></i>
</a>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-globe" style="font-size: 3rem; color: #6c757d;"></i>
<h4 class="mt-3 text-muted">No DNS Entries Found</h4>
<p class="text-muted">
{% if request.GET.search %}
No DNS entries match your search criteria.
{% else %}
Start by creating your first DNS entry.
{% endif %}
</p>
{% if not request.GET.search %}
<a href="{% url 'frontend:dns_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First DNS Entry
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% include 'frontend/includes/dns_pagination_snippet.html' %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Auto-submit search form on type filter change
document.getElementById('entryTypeSelect').addEventListener('change', function() {
this.form.submit();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,69 @@
<!-- frontend/templates/frontend/edit_container.html -->
{% extends 'frontend/base.html' %}
{% load static %}
{% block title %} - Edit Container{% endblock %}
{% block extra_head %}
{{ block.super }}
{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<div class="row">
<div class="col-12 col-md-10">
<h4>Edit {{ container.name }}</h4>
</div>
<div class="col-12 col-md-2 text-end">
<a href="{% url 'frontend:create_container' %}?clone_lxc={{ container.lxc.pk }}"
class="btn btn-secondary">
<i class="bi bi-copy">&nbsp;Clone</i>
</a>
</div>
</div>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<input type="hidden" name="task_uuid" value="{{ task_uuid }}"/>
<div class="row">
{% for field in form %}
<div class="col-{% if field.name in 'lxcdnslease' %}12 mb-3{% else %}4{% endif %}">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
<input type="hidden" value="{{ field.value }}" name="{{ field.name }}"/>
{{ field }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary" id="save-btn">Speichern</button>
<a href="{% url 'frontend:container_detail' container.internal_id %}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function () {
// Form blocking - disable submit button on form submission
$('form').on('submit', function () {
$('#save-btn').prop('disabled', true).text('Aktualisiere Container...');
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends 'frontend/base.html' %}
{% load markdown_filters %}
{% block title %} - Delete FAQ Entry{% endblock %}
{% block content %}
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>
<i class="bi bi-trash"></i> Delete FAQ Entry
</h3>
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to FAQ List
</a>
</div>
<div class="card">
<div class="card-body">
<div class="alert alert-warning">
<h5><i class="bi bi-exclamation-triangle"></i> Confirm Deletion</h5>
<p>Are you sure you want to delete this FAQ entry? This action cannot be undone.</p>
</div>
<div class="mb-4">
<h5 class="text-muted">FAQ Entry Details:</h5>
<div class="border rounded p-3 bg-light">
<h6><strong>Title:</strong></h6>
<p>{{ faq.title }}</p>
<h6><strong>Content:</strong></h6>
<div style="max-height: 200px; overflow-y: auto;">{{ faq.content|markdown }}</div>
<h6><strong>Order:</strong></h6>
<p>{{ faq.order }}</p>
</div>
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Delete FAQ Entry
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends 'frontend/base.html' %}
{% block title %} - {{ title }}{% endblock %}
{% block content %}
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>
<i class="bi bi-question-circle"></i> {{ title }}
</h3>
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to FAQ List
</a>
</div>
<div class="card">
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="{{ form.title.id_for_label }}" class="form-label fw-bold">
{{ form.title.label }}
</label>
{{ form.title }}
{% if form.title.help_text %}
<div class="form-text">{{ form.title.help_text }}</div>
{% endif %}
{% if form.title.errors %}
<div class="invalid-feedback d-block">
{% for error in form.title.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.content.id_for_label }}" class="form-label fw-bold">
{{ form.content.label }}
</label>
{{ form.content }}
{% if form.content.help_text %}
<div class="form-text">{{ form.content.help_text }}</div>
{% endif %}
{% if form.content.errors %}
<div class="invalid-feedback d-block">
{% for error in form.content.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.order.id_for_label }}" class="form-label fw-bold">
{{ form.order.label }}
</label>
{{ form.order }}
{% if form.order.help_text %}
<div class="form-text">{{ form.order.help_text }}</div>
{% endif %}
{% if form.order.errors %}
<div class="invalid-feedback d-block">
{% for error in form.order.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if form.non_field_errors %}
<div class="alert alert-danger">
{% for error in form.non_field_errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<div class="d-flex justify-content-between">
<a href="{% url 'frontend:faq_list' %}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {{ submit_text }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends 'frontend/base.html' %}
{% load markdown_filters %}
{% block title %} - FAQ{% endblock %}
{% block extra_head %}
{{ block.super }}
<style>
.faq-item {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 1rem;
background: #fff;
}
.faq-header {
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
cursor: pointer;
transition: background-color 0.2s;
}
.faq-header:hover {
background: #e9ecef;
}
.faq-content {
padding: 1rem;
}
.faq-content h1, .faq-content h2, .faq-content h3,
.faq-content h4, .faq-content h5, .faq-content h6 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.faq-content p {
margin-bottom: 0.75rem;
}
.faq-content code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
.faq-content pre {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 1rem;
overflow-x: auto;
}
.faq-content blockquote {
border-left: 4px solid #007bff;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
color: #6c757d;
}
.faq-content ul, .faq-content ol {
padding-left: 2rem;
}
.faq-category-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: #007bff;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3>
<i class="bi bi-question-circle"></i> FAQ - Frequently Asked Questions
</h3>
<a href="{% url 'frontend:faq_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add FAQ Entry
</a>
</div>
<!-- FAQ Items -->
<div class="row">
<form action="{% url 'frontend:faq_list' %}" method="GET" class="col-12">
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Search FAQ Entries" name="search" value="{{ request.GET.search }}">
<button class="btn btn-outline-secondary" type="submit">
<i class="bi bi-search"></i>
</button>
</div>
</form>
</div>
{% if faqs %}
<div class="faq-container">
{% for faq in faqs %}
<div class="faq-item">
<div class="faq-header">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center flex-grow-1" onclick="toggleFAQ({{ faq.pk }})" style="cursor: pointer;">
<h5 class="mb-0 me-2">{{ faq.title }}</h5>
</div>
<div class="d-flex align-items-center">
<div class="btn-group me-2" role="group">
<a href="{% url 'frontend:faq_edit' faq.pk %}"
class="btn btn-sm btn-outline-warning"
title="Edit FAQ">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'frontend:faq_delete' faq.pk %}"
class="btn btn-sm btn-outline-danger"
title="Delete FAQ">
<i class="bi bi-trash"></i>
</a>
</div>
<i class="bi bi-chevron-down" id="icon-{{ faq.pk }}" onclick="toggleFAQ({{ faq.pk }})" style="cursor: pointer;"></i>
</div>
</div>
</div>
<div class="faq-content collapse" id="faq-{{ faq.pk }}">
{{ faq.content|markdown }}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-question-circle" style="font-size: 3rem; color: #6c757d;"></i>
<h4 class="mt-3 text-muted">No FAQ Entries Available</h4>
<p class="text-muted">
FAQ entries will appear here once they are added by an administrator.
</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleFAQ(faqId) {
const content = document.getElementById('faq-' + faqId);
const icon = document.getElementById('icon-' + faqId);
if (content.classList.contains('show')) {
content.classList.remove('show');
icon.classList.remove('bi-chevron-up');
icon.classList.add('bi-chevron-down');
} else {
content.classList.add('show');
icon.classList.remove('bi-chevron-down');
icon.classList.add('bi-chevron-up');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,81 @@
<!-- DNS Pagination -->
<nav aria-label="DNS-Paginierung" class="mt-4 search-form">
<ul class="pagination justify-content-left">
<div class="d-flex align-items-center mb-3 me-3 flex-wrap" style="width: 100%">
<form action="{% url 'frontend:dns_list' %}" method="get" class="me-3" style="flex: 1;">
<div class="input-group">
<input type="text" class="form-control" placeholder="Search DNS entries..." name="search"
value="{{ request.GET.search }}"/>
<button type="submit" class="btn btn-outline-secondary" title="Search"><i class="bi bi-search"></i>
</button>
{% if request.GET.search %}
<a href="{% url 'frontend:dns_list' %}{% if request.GET.entry_type %}?entry_type={{ request.GET.entry_type }}{% endif %}"
class="btn btn-outline-secondary" title="Clear Search"><i class="bi bi-trash"></i></a>
{% endif %}
<a class="btn btn-outline-secondary" style="font-weight: bold;color:darkred">{{ dns_entries.count }}</a>
</div>
</form>
<form id="filterForm" action="{% url 'frontend:dns_list' %}" method="GET" class="d-flex align-items-center">
<select name="entry_type" class="form-select me-2" id="entryTypeSelect" onchange="this.form.submit()">
<option value="" {% if not request.GET.entry_type %}selected{% endif %}>All Types</option>
<option value="name" {% if request.GET.entry_type == 'name' %}selected{% endif %}>Names only</option>
<option value="regexp" {% if request.GET.entry_type == 'regexp' %}selected{% endif %}>RegExp only</option>
</select>
{% if request.GET.search %}
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% endif %}
{% if request.GET.page %}
<input type="hidden" name="page" value="{{ request.GET.page }}">
{% endif %}
</form>
</div>
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page=1{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
aria-label="Prev">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&laquo;&laquo;</span></li>
<li class="page-item disabled"><span class="page-link">&laquo;</span></li>
{% endif %}
{% for i in page_obj.paginator.page_range %}
{% if page_obj.number == i %}
<li class="page-item active"><span class="page-link">{{ i }}</span></li>
{% elif i > page_obj.number|add:'-3' and i < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ i }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.entry_type %}&entry_type={{ request.GET.entry_type }}{% endif %}"
aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</span></li>
<li class="page-item disabled"><span class="page-link">&raquo;&raquo;</span></li>
{% endif %}
</ul>
</nav>

View File

@@ -0,0 +1,95 @@
<!-- Pagination -->
<nav aria-label="Container-Paginierung" class="mt-4 search-form">
<ul class="pagination justify-content-left">
<div class="d-flex align-items-center mb-3 me-3 flex-wrap" style="width: 100%">
<!-- Suchfeld -->
<form action="{{ search_action_url }}" method="get" class="me-3" style="flex: 1;">
<div class="input-group">
<input type="text" class="form-control" placeholder="Search" name="search"
value="{{ request.GET.search }}"/>
<button type="submit" class="btn btn-outline-secondary" title="Search"><i class="bi bi-search"></i>
</button>
{% if request.GET.search %}<a href="{% url 'frontend:dashboard' %}{% if request.GET.lxc_status %}?lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}{% if request.GET.lxc_status %}&{% else %}?{% endif %}network_status={{ request.GET.network_status }}{% endif %}"
class="btn btn-outline-secondary" title="Clear Search"><i class="bi bi-trash"></i></a>{% endif %}
<a class="btn btn-outline-secondary" style="font-weight: bold;color:darkred">{{ containers.count }}</a>
</div>
</form>
<!-- Filter als Select-Boxen mit auto-submit -->
<form id="filterForm" action="{{ search_action_url }}" method="GET" class="d-flex align-items-center">
<select name="lxc_status" class="form-select me-2" id="lxcStatusSelect" onchange="this.form.submit()">
<option value="" {% if not request.GET.lxc_status %}selected{% endif %}>All LXC</option>
<option value="running" {% if request.GET.lxc_status == 'running' %}selected{% endif %}>Running Lxc</option>
<option value="stopped" {% if request.GET.lxc_status == 'stopped' %}selected{% endif %}>Stopped Lxc</option>
</select>
<select name="network_status" class="form-select" id="networkStatusSelect"
onchange="this.form.submit()">
<option value="" {% if not request.GET.network_status %}selected{% endif %}>All Net</option>
<option value="bound" {% if request.GET.network_status == 'bound' %}selected{% endif %}> Bound </option>
<option value="waiting" {% if request.GET.network_status == 'waiting' %}selected{% endif %}>Waiting</option>
</select>
{% if request.GET.search %}
<input type="hidden" name="search" value="{{ request.GET.search }}">{% endif %}
{% if request.GET.page %}<input type="hidden" name="page" value="{{ request.GET.page }}">{% endif %}
</form>
</div>
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link"
href="?page=1{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
aria-label="Prev">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&laquo;&laquo;</span></li>
<li class="page-item disabled"><span class="page-link">&laquo;</span></li>
{% endif %}
{% for i in page_obj.paginator.page_range %}
{% if page_obj.number == i %}
<li class="page-item active"><span class="page-link">{{ i }}</span></li>
{% elif i > page_obj.number|add:'-3' and i < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ i }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}">{{ i }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.lxc_status %}&lxc_status={{ request.GET.lxc_status }}{% endif %}{% if request.GET.network_status %}&network_status={{ request.GET.network_status }}{% endif %}"
aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</span></li>
<li class="page-item disabled"><span class="page-link">&raquo;&raquo;</span></li>
{% endif %}
<div class="d-flex align-items-center mb-3 flex-wrap ms-2">
<!-- Ansichtsumschalter (Client-seitig per JS) -->
<div class="btn-group me-3" role="group">
<button type="button" id="cardViewBtn" class="btn btn-outline-secondary active"><i
class="bi bi-grid-3x3-gap"></i></button>
<button type="button" id="listViewBtn" class="btn btn-outline-secondary"><i class="bi bi-list-ul"></i>
</button>
</div>
</div>
</ul>
</nav>

View File

@@ -0,0 +1,30 @@
<!-- frontend/templates/frontend/login.html -->
{% extends 'frontend/base.html' %}
{% block title %} - Login{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h4>Anmelden</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Anmelden</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,142 @@
{% extends 'frontend/base.html' %}
{% load static %}
{% block title %} - Console: {{ container.name }}{% endblock %}
{% block extra_head %}
{{ block.super }}
<style>
.console-container {
height: 80vh;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
background: #000;
}
.console-header {
background: #333;
color: #fff;
padding: 10px 15px;
border-bottom: 1px solid #444;
display: flex;
justify-content: between;
align-items: center;
}
.console-info {
font-size: 14px;
}
.console-controls {
margin-left: auto;
}
.console-iframe {
width: 100%;
height: calc(100% - 50px);
border: none;
background: #000;
}
.console-status {
color: #28a745;
font-weight: bold;
}
.console-warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
<i class="bi bi-terminal"></i>
Console: {{ container.name }}
<small class="text-muted">({{ container.address }})</small>
</h2>
<a href="{% url 'frontend:dashboard' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
</div>
<div class="console-warning">
<h5><i class="bi bi-exclamation-triangle text-warning"></i> Console Access</h5>
<p class="mb-0">
This opens a direct console connection to the LXC container <strong>{{ container.name }}</strong> (VM-ID: {{ container.lxc.vmid }}).
The console connection is handled through the Proxmox web interface.
</p>
</div>
<div class="console-container">
<div class="console-header">
<div class="console-info">
<span class="console-status"></span>
{{ container.hostname }} | VM-ID: {{ container.lxc.vmid }} | IP: {{ container.address }}
</div>
<div class="console-controls">
<button class="btn btn-sm btn-outline-light" onclick="reloadConsole()">
<i class="bi bi-arrow-clockwise"></i> Reload
</button>
<button class="btn btn-sm btn-outline-light" onclick="openInNewTab()">
<i class="bi bi-box-arrow-up-right"></i> New Tab
</button>
</div>
</div>
<iframe
id="console-iframe"
class="console-iframe"
src="https://{{ proxmox_host }}:8006/?console=lxc&vmid={{ container.lxc.vmid }}&node={{ container.lxc.node }}"
title="LXC Console for {{ container.name }}">
</iframe>
</div>
<div class="mt-3">
<div class="alert alert-info">
<h6><i class="bi bi-info-circle"></i> Console Help</h6>
<ul class="mb-0">
<li>This console provides direct terminal access to your LXC container</li>
<li>You may need to press Enter to activate the console</li>
<li>Use Ctrl+Alt+Del to send reset signal (if supported)</li>
<li>If the console doesn't load, try refreshing or opening in a new tab</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function reloadConsole() {
const iframe = document.getElementById('console-iframe');
iframe.src = iframe.src;
}
function openInNewTab() {
const iframe = document.getElementById('console-iframe');
window.open(iframe.src, '_blank');
}
// Auto-reload console every 30 seconds to keep session alive
setInterval(function() {
const iframe = document.getElementById('console-iframe');
// Ping the iframe to keep session alive without full reload
try {
iframe.contentWindow.postMessage('ping', '*');
} catch (e) {
// Cross-origin, ignore
}
}, 30000);
</script>
{% endblock %}

View File

@@ -0,0 +1,163 @@
{% extends 'frontend/base.html' %}
{% load static %}
{% block title %} - Console: {{ container.name }}{% endblock %}
{% block extra_head %}
{{ block.super }}
<style>
.console-launch {
text-align: center;
padding: 40px 20px;
}
.console-icon {
font-size: 64px;
color: #28a745;
margin-bottom: 20px;
}
.console-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.btn-console {
background: linear-gradient(45deg, #28a745, #20c997);
border: none;
color: white;
padding: 15px 30px;
font-size: 18px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
transition: all 0.3s ease;
}
.btn-console:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4);
color: white;
}
.console-warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-header text-center">
<h4 class="mb-0">
<i class="bi bi-terminal"></i>
LXC Console Access
</h4>
</div>
<div class="card-body">
<div class="console-launch">
<div class="console-icon">
<i class="bi bi-terminal-fill"></i>
</div>
<h5>{{ container.name }}</h5>
<p class="text-muted">VM-ID: {{ container.lxc.vmid }} | IP: {{ container.address }}</p>
<div class="console-info">
<h6><i class="bi bi-info-circle text-info"></i> Console Information</h6>
<div class="row text-start">
<div class="col-sm-4"><strong>Hostname:</strong></div>
<div class="col-sm-8">{{ container.hostname }}</div>
<div class="col-sm-4"><strong>Node:</strong></div>
<div class="col-sm-8">{{ container.lxc.node }}</div>
<div class="col-sm-4"><strong>Status:</strong></div>
<div class="col-sm-8">
<span class="badge bg-{{ container.lxc.status|yesno:'success,danger' }}">
{{ container.lxc.status|title }}
</span>
</div>
</div>
</div>
<button id="openConsole" class="btn btn-console">
<i class="bi bi-box-arrow-up-right"></i>
Open Console in New Window
</button>
<div class="console-warning mt-3">
<h6><i class="bi bi-exclamation-triangle text-warning"></i> Important Notes</h6>
<ul class="text-start mb-0">
<li>The console will open in the Proxmox web interface</li>
<li>You will need to login to Proxmox if not already authenticated</li>
<li>Please allow popups for this site if blocked</li>
<li>Navigate to the container console once in Proxmox</li>
<li>Alternatively, you can manually access: <code>https://{{ proxmox_host }}:8006</code></li>
</ul>
</div>
</div>
</div>
<div class="card-footer text-center">
<a href="{% url 'frontend:dashboard' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Dashboard
</a>
<a href="{% url 'frontend:container_detail' container.pk %}" class="btn btn-info ms-2">
<i class="bi bi-info-circle"></i> Container Details
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.getElementById('openConsole').addEventListener('click', function() {
const consoleUrl = '{{ console_url|escapejs }}';
// Open console in popup window
const popup = window.open(
consoleUrl,
'lxc-console-{{ container.lxc.vmid }}',
'width=1024,height=768,scrollbars=yes,resizable=yes,status=yes,toolbar=no,menubar=no'
);
if (!popup) {
alert('Popup blocked! Please allow popups for this site and try again.');
return;
}
// Focus the popup
popup.focus();
// Show success message
this.innerHTML = '<i class="bi bi-check-circle"></i> Console Opened';
this.disabled = true;
this.classList.remove('btn-console');
this.classList.add('btn', 'btn-success');
// Re-enable button after 3 seconds
setTimeout(() => {
this.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Open Console in New Window';
this.disabled = false;
this.classList.remove('btn', 'btn-success');
this.classList.add('btn-console');
}, 3000);
});
// Auto-open console if requested
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('autoopen') === 'true') {
document.getElementById('openConsole').click();
}
</script>
{% endblock %}

View File

@@ -0,0 +1 @@
# frontend/templatetags/__init__.py

View File

@@ -0,0 +1,29 @@
import markdown
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(name='markdown')
def markdown_format(text):
"""
Convert markdown text to HTML
Usage: {{ content|markdown }}
"""
if not text:
return ""
# Configure markdown with useful extensions
md = markdown.Markdown(extensions=[
'extra', # Tables, fenced code blocks, etc.
'codehilite', # Syntax highlighting
'toc', # Table of contents
'nl2br' # Convert newlines to <br>
], extension_configs={
'codehilite': {
'css_class': 'highlight',
'use_pygments': True
}
})
return mark_safe(md.convert(text))

32
frontend/urls.py Normal file
View File

@@ -0,0 +1,32 @@
# frontend/urls.py
from django.urls import path
from . import views
app_name = 'frontend'
urlpatterns = [
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
path('', views.dashboard, name='dashboard'),
path('current-containers/', views.container_details, name='current_container_details'),
path('container/<int:container_id>/', views.container_detail, name='container_detail'),
path('container/<int:container_id>/edit/', views.edit_container, name='edit_container'),
path('container/<int:container_id>/delete/', views.delete_container, name='delete_container'),
path('container/<int:id>/stop/', views.stop_lxc, name='stop_lxc'),
path('container/<int:id>/start/', views.start_lxc, name='start_lxc'),
path('container/create/', views.create_container, name='create_container'),
# DNS Management
path('dns/', views.dns_list, name='dns_list'),
path('dns/create/', views.dns_create, name='dns_create'),
path('dns/<str:dns_id>/edit/', views.dns_edit, name='dns_edit'),
path('dns/<str:dns_id>/delete/', views.dns_delete, name='dns_delete'),
path('api/containers/', views.dns_container_api, name='dns_container_api'),
# FAQ
path('faq/', views.faq_list, name='faq_list'),
path('faq/raw/', views.faq_raw, name='faq_raw'),
path('faq/raw/<int:id>/', views.faq_raw, name='faq_raw_single'),
path('faq/create/', views.faq_create, name='faq_create'),
path('faq/<int:faq_id>/edit/', views.faq_edit, name='faq_edit'),
path('faq/<int:faq_id>/delete/', views.faq_delete, name='faq_delete'),
]

637
frontend/views.py Normal file
View File

@@ -0,0 +1,637 @@
import json
import threading
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.forms import model_to_dict
from django.http import HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django_proxmox_mikrotik.settings import ProxmoxConfig
from lib.decorators import readonly
from lib.proxmox import Proxmox
from lib.task_decorator import (
create_container_with_task,
delete_container_with_task,
resize_container_disk_with_task,
start_container_with_task,
stop_container_with_task,
update_container_config_sync,
)
from lib.utils import paginator
from manager.models import DevContainer
from mikrotik.models import DNSStatic
from proxmox.models import Lxc, LxcTemplate
from tasklogger.models import TaskFactory
from .forms import CloneContainerForm, DNSSearchForm, DNSStaticForm, DevContainerForm, FAQForm
from .models import FAQ, UserProfile
from .permissions import user_can_access_container
# Oben in den Imports hinzufügen:
def login_view(request):
TaskFactory.reset_current_task(request=request)
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
if next_url := request.GET.get('next'):
return redirect(next_url)
else:
return redirect('frontend:dashboard')
else:
messages.error(request, 'Ungültige Anmeldedaten')
return render(request, 'frontend/login.html')
def logout_view(request):
TaskFactory.reset_current_task(request=request)
logout(request)
if next_url := request.GET.get('next'):
return redirect('/frontend/login?next=' + next_url)
return redirect('frontend:login')
@login_required
def dashboard(request):
TaskFactory.reset_current_task(request=request)
try:
user_profile = request.user.profile
except UserProfile.DoesNotExist:
user_profile = UserProfile.objects.create(
user=request.user,
ldap_uid=request.user.username # Annahme: LDAP-UID = Username
)
searchdomain = Q()
if s := request.GET.get('search', ''):
searchdomain &= DevContainer.term_filter(s)
if user_profile.is_internal():
from mikrotik.models import IPAddress
ip_addresses = IPAddress.objects.filter(comment__icontains=f' {user_profile.ldap_uid} ')
networks = [ip.network for ip in ip_addresses]
leasefilter = Q()
for nw in networks:
leasefilter |= Q(lease__address__startswith=nw)
searchdomain &= leasefilter
if user_profile.is_external():
searchdomain &= Q(lease__address__startswith='172.2')
if lxc_status := request.GET.get('lxc_status', ''):
searchdomain &= Q(lxc__status=lxc_status)
if network_status := request.GET.get('network_status', ''):
searchdomain &= Q(lease__status=network_status)
# Paginierung hinzufügen
containers = DevContainer.objects.filter(searchdomain)
page_obj = paginator(containers, request=request)
default_template = LxcTemplate.objects.filter(is_default_template=True).first()
return render(request, 'frontend/dashboard.html', {
'containers': containers,
'page_obj': page_obj,
'user_profile': user_profile,
'page_vmids': ','.join(map(str, page_obj.object_list.values_list('lxc__vmid', flat=True))),
'page_ids': ','.join(map(str, page_obj.object_list.values_list('internal_id', flat=True))),
'proxmox_host': ProxmoxConfig.HOST,
'default_template': default_template,
})
class ContainerStatus:
low_limit = 80
def __init__(self, **kwargs):
self.cpus = kwargs.get('cpus', 0)
self.cpu = kwargs.get('cpu', 0)
self.maxmem = kwargs.get('maxmem', 256)
self.mem = kwargs.get('mem', 256)
self.maxdisk = kwargs.get('maxdisk', 6)
self.disk = kwargs.get('disk', 6)
self.maxswap = kwargs.get('maxswap', 0)
self.swap = kwargs.get('swap', 0)
self.status = kwargs.get('status')
self.vmid = kwargs.get('vmid')
self.lease_status = kwargs.get('lease_status', 'waiting')
self.lxc_status = kwargs.get('lxc_status', 'stopped')
def __hash__(self):
return int(self.vmid)
def __getattr__(self, item):
if item.endswith('percent'):
act = int(getattr(self, item[:-8]) or 0)
soll = int(getattr(self, 'max' + item[:-8]) or 1)
return round(act / soll * 100, 2)
raise AttributeError(f'{item} not found')
@property
def cpu_percent(self):
return round((self.cpu or 0) * 100, 2)
@property
def is_low(self):
if not self.cpu_percent and not self.mem_percent and not self.disk_percent:
return False
return self.cpu_percent > self.low_limit or self.mem_percent > self.low_limit or self.disk_percent > self.low_limit
@property
def to_json(self):
return {
'cpus': self.cpus,
'cpu': self.cpu,
'maxmem': self.maxmem,
'mem': self.mem,
'maxdisk': self.maxdisk,
'disk': self.disk,
'maxswap': self.maxswap,
'swap': self.swap,
'is_low': self.is_low,
'cpu_percent': self.cpu_percent,
'mem_percent': self.mem_percent,
'disk_percent': self.disk_percent,
'swap_percent': self.swap_percent,
'vmid': self.vmid,
'lease_status': self.lease_status,
'lxc_status': self.lxc_status,
}
@readonly
def container_details(request):
ids = filter(None, request.GET.get('ids', '').split(','))
ret = []
if ids:
with Proxmox() as pm:
for id in ids:
container = DevContainer.objects.get(internal_id=id)
ret.append(ContainerStatus(**(container.statuscache or {})).to_json)
return HttpResponse(json.dumps(ret), content_type='application/json')
@login_required
def start_lxc(request, id=None):
if request.GET.get('id'):
id = request.GET.get('id')
container = get_object_or_404(DevContainer, internal_id=id)
try:
# Create task and execute synchronously
task = TaskFactory(request=request)
success = start_container_with_task(str(task.uuid), container.lxc.vmid, request=request)
if success:
messages.success(request, f'Container {container.name} wurde gestartet!')
else:
messages.error(request, f'Fehler beim Starten von Container {container.name}')
return redirect('frontend:dashboard')
except Exception as e:
messages.error(request, f'Fehler beim Starten des Containers: {str(e)}')
return redirect('frontend:dashboard')
@login_required
def stop_lxc(request, id=None):
if request.GET.get('id'):
id = request.GET.get('id')
container = get_object_or_404(DevContainer, internal_id=id)
try:
# Create task and execute synchronously
task = TaskFactory(request=request)
success = stop_container_with_task(str(task.uuid), container.lxc.vmid, request=request)
if success:
messages.success(request, f'Container {container.name} wurde gestoppt!')
else:
messages.error(request, f'Fehler beim Stoppen von Container {container.name}')
return redirect('frontend:dashboard')
except Exception as e:
messages.error(request, f'Fehler beim Stoppen des Containers: {str(e)}')
return redirect('frontend:dashboard')
@login_required
def container_detail(request, container_id):
container = get_object_or_404(DevContainer, internal_id=container_id)
user_profile = request.user.profile
if False and not user_can_access_container(user_profile, container):
return HttpResponseForbidden("Sie haben keine Berechtigung, diesen Container zu sehen.")
return render(request, 'frontend/container_details.html', {
'container': container
})
@login_required
def edit_container(request, container_id):
container = get_object_or_404(DevContainer, internal_id=container_id)
user_profile = request.user.profile
task = TaskFactory(request=request)
# Berechtigungsprüfung
if not user_can_access_container(user_profile, container):
return HttpResponseForbidden("Sie haben keine Berechtigung, diesen Container zu bearbeiten.")
if request.method == 'POST':
form = DevContainerForm(request.POST, instance=container, user_profile=user_profile)
if form.is_valid():
# Check if resource changes require updates
old_disksize = container.lxc.disksize
old_cores = container.lxc.cores
old_memory = container.lxc.memory
new_disksize = form.cleaned_data.get('disksize')
new_cores = form.cleaned_data.get('cores')
new_memory = form.cleaned_data.get('memory')
try:
# Save form first (updates local database)
form.save()
# Handle disk resize (synchronously with TaskLogger)
if new_disksize and new_disksize != old_disksize:
success = resize_container_disk_with_task(str(task.uuid), container.lxc.vmid, new_disksize, request=request)
if not success:
messages.error(request, 'Fehler beim Vergrößern der Festplatte. Siehe Task-Log für Details.')
return redirect('frontend:dashboard')
# Handle memory/cores changes (synchronously)
config_updates = {}
if new_cores and new_cores != old_cores:
config_updates['cores'] = new_cores
if new_memory and new_memory != old_memory:
config_updates['memory'] = new_memory
if config_updates:
success = update_container_config_sync(container.lxc.vmid, task=task, **config_updates)
if not success:
messages.error(request, 'Fehler beim Aktualisieren der Container-Konfiguration')
return redirect('frontend:dashboard')
messages.success(request, 'Container wurde erfolgreich aktualisiert')
return redirect('frontend:dashboard')
except Exception as e:
messages.error(request, f'Fehler beim Aktualisieren des Containers: {str(e)}')
return redirect('frontend:dashboard')
finally:
TaskFactory.reset_current_task(request=request)
else:
TaskFactory.reset_current_task(request=request)
task = TaskFactory(request=request)
form = DevContainerForm(instance=container, user_profile=user_profile)
return render(request, 'frontend/edit_container.html', {
'form': form,
'container': container,
'task_uuid': str(task.uuid),
})
@login_required
def delete_container(request, container_id):
container = get_object_or_404(DevContainer, internal_id=container_id)
user_profile = request.user.profile
# Berechtigungsprüfung
if not user_can_access_container(user_profile, container):
return HttpResponseForbidden("Sie haben keine Berechtigung, diesen Container zu löschen.")
if request.method == 'GET':
TaskFactory.reset_current_task(request=request)
task = TaskFactory(request=request)
if request.method == 'POST':
try:
# Execute container deletion in background thread
def _delete_container_async():
delete_container_with_task(str(task.uuid), container, request=request)
thread = threading.Thread(target=_delete_container_async)
thread.daemon = True
thread.start()
# Return JSON response for async handling with task_uuid
return HttpResponse(json.dumps({
'status': 'task_started',
'task_uuid': str(task.uuid),
'message': 'Container deletion initiated'
}), content_type='application/json')
except Exception as e:
return HttpResponse(json.dumps({
'status': 'error',
'message': f'Fehler beim Löschen des Containers: {str(e)}'
}), content_type='application/json', status=500)
finally:
TaskFactory.reset_current_task(request=request)
return render(request, 'frontend/delete_container.html', {
'container': container,
'task_id': str(task.uuid),
})
def create_container(request):
user_profile = request.user.profile
if request.method == 'POST':
# Get task_uuid from form
task_uuid = request.POST.get('task_uuid')
form = CloneContainerForm(request.POST, user_profile=user_profile)
if form.is_valid():
container = form.save()
container.is_active = True
container.save()
# Start container creation asynchronously with TaskLogger
try:
# Get the task created when form was loaded
# Create new task if not found
task = TaskFactory(request=request)
task_uuid = str(task.uuid)
# Execute container creation in background thread
def _create_container_async():
create_container_with_task(task_uuid, container, request=request)
thread = threading.Thread(target=_create_container_async)
thread.daemon = True
thread.start()
# Return JSON response for async handling with task_uuid
return HttpResponse(json.dumps({
'status': 'task_started',
'task_uuid': task_uuid,
'message': 'Container creation initiated'
}), content_type='application/json')
except Exception as e:
return HttpResponse(json.dumps({
'status': 'error',
'message': f'Fehler beim Erstellen des Containers: {str(e)}'
}), content_type='application/json', status=500)
else:
if cloneid := request.GET.get('clone_lxc'):
lxc = Lxc.objects.filter(pk=cloneid)
else:
lxc = None
if template_id := request.GET.get('clone_template'):
template = LxcTemplate.objects.filter(pk=template_id)
else:
template = None
form = CloneContainerForm(
user_profile=user_profile,
vm=lxc,
template=template,
hostname=request.GET.get('clone_hostname'),
)
# Create new task when form is loaded and put UUID in form
task = TaskFactory(request=request)
task_uuid = str(task.uuid)
return render(request, 'frontend/create_container.html', {
'form': form,
'task_uuid': task_uuid
})
# DNS Management Views
@login_required
def dns_list(request):
"""List all DNS entries with search functionality"""
search_form = DNSSearchForm(request.GET or None)
dev_container_dns = list(DevContainer.objects.all().values_list('dns_id', flat=True))
dns_entries = DNSStatic.objects.all().order_by('name', 'regexp', 'address')
# Apply search filters
if search_form.is_valid():
search_query = search_form.cleaned_data.get('search')
entry_type = search_form.cleaned_data.get('entry_type')
if search_query:
dns_entries = dns_entries.filter(
Q(name__icontains=search_query) |
Q(regexp__icontains=search_query) |
Q(address__icontains=search_query) |
Q(comment__icontains=search_query)
)
if entry_type == 'name':
dns_entries = dns_entries.exclude(name__isnull=True).exclude(name='')
elif entry_type == 'regexp':
dns_entries = dns_entries.exclude(regexp__isnull=True).exclude(regexp='')
# Pagination
page_obj = paginator(dns_entries, request=request)
return render(request, 'frontend/dns_list.html', {
'dns_entries': dns_entries,
'page_obj': page_obj,
'search_form': search_form,
'dev_container_dns': dev_container_dns,
})
@login_required
def dns_create(request):
"""Create a new DNS entry"""
if request.method == 'POST':
form = DNSStaticForm(request.POST)
if form.is_valid():
# Remove container field before saving (it's just for UI)
dns_entry = form.save(commit=False)
dns_entry.save()
messages.success(request, f'DNS entry "{dns_entry}" created successfully')
return redirect('frontend:dns_list')
else:
form = DNSStaticForm()
return render(request, 'frontend/dns_form.html', {
'form': form,
'title': 'Create DNS Entry',
'submit_text': 'Create DNS Entry'
})
@login_required
def dns_edit(request, dns_id):
"""Edit an existing DNS entry"""
dns_entry = get_object_or_404(DNSStatic, id=dns_id)
if request.method == 'POST':
form = DNSStaticForm(request.POST, instance=dns_entry)
if form.is_valid():
dns_entry = form.save()
messages.success(request, f'DNS entry "{dns_entry}" updated successfully')
return redirect('frontend:dns_list')
else:
form = DNSStaticForm(instance=dns_entry)
return render(request, 'frontend/dns_form.html', {
'form': form,
'dns_entry': dns_entry,
'title': 'Edit DNS Entry',
'submit_text': 'Update DNS Entry'
})
@login_required
def dns_delete(request, dns_id):
"""Delete a DNS entry"""
dns_entry = get_object_or_404(DNSStatic, id=dns_id)
if request.method == 'POST':
dns_name = str(dns_entry)
dns_entry.delete()
messages.success(request, f'DNS entry "{dns_name}" deleted successfully')
return redirect('frontend:dns_list')
return render(request, 'frontend/dns_delete.html', {
'dns_entry': dns_entry,
})
@login_required
def dns_container_api(request):
"""API endpoint for container selection in DNS forms"""
search = request.GET.get('search', '')
containers = DevContainer.objects.all()
if search:
containers = containers.filter(
Q(lxc__hostname__icontains=search) |
Q(lxc__name__icontains=search) |
Q(lease__address__icontains=search)
)
results = []
for container in containers:
try:
results.append({
'id': container.pk,
'text': f"{container.name} ({container.address})",
'address': container.address,
})
except:
continue
return HttpResponse(json.dumps({
'results': results
}), content_type='application/json')
@login_required
def faq_list(request):
"""Simple FAQ list view with accordion display"""
term = request.GET.get('search', '')
faqs = FAQ.term_search(term).order_by('order', 'title')
return render(request, 'frontend/faq_list.html', {
'faqs': faqs,
})
def faq_raw(request, id=None):
response_type = 'text/markdown' if not request.GET.get('type', '') == 'json' else 'applicaton/json'
indent = int(request.GET.get('indent', 0)) or None
if id:
faq = get_object_or_404(FAQ, pk=id)
if response_type == 'text/markdown':
content = f"# {faq.title}\n\n{faq.content}"
else:
content = json.dumps(model_to_dict(faq), indent=indent)
else:
term = request.GET.get('search', '')
faqs = FAQ.term_search(term).order_by('order', 'title')
result = []
if response_type == 'text/markdown':
for faq in faqs:
result.append(f"# {faq.title}\n{faq.content}\n\n")
content = '\n-----------------------------------------------\n\n'.join(result)
else:
for faq in faqs:
result.append(model_to_dict(faq))
content = json.dumps(result, default=str, indent=indent)
return HttpResponse(content, content_type=response_type)
@login_required
def faq_create(request):
"""Create new FAQ entry"""
if request.method == 'POST':
form = FAQForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'FAQ entry created successfully!')
return redirect('frontend:faq_list')
else:
form = FAQForm()
return render(request, 'frontend/faq_form.html', {
'form': form,
'title': 'Create FAQ Entry',
'submit_text': 'Create FAQ'
})
@login_required
def faq_edit(request, faq_id):
"""Edit existing FAQ entry"""
faq = get_object_or_404(FAQ, pk=faq_id)
if request.method == 'POST':
form = FAQForm(request.POST, instance=faq)
if form.is_valid():
form.save()
messages.success(request, 'FAQ entry updated successfully!')
return redirect('frontend:faq_list')
else:
form = FAQForm(instance=faq)
return render(request, 'frontend/faq_form.html', {
'form': form,
'faq': faq,
'title': 'Edit FAQ Entry',
'submit_text': 'Update FAQ'
})
@login_required
def faq_delete(request, faq_id):
"""Delete FAQ entry"""
faq = get_object_or_404(FAQ, pk=faq_id)
if request.method == 'POST':
faq.delete()
messages.success(request, 'FAQ entry deleted successfully!')
return redirect('frontend:faq_list')
return render(request, 'frontend/faq_delete.html', {
'faq': faq,
})

24
lib/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
def ip_in_net(ip, network):
ipaddr = int(''.join(['%02x' % int(x) for x in ip.split('.')]), 16)
netaddr, bits = network.split('/')
netmask = int(''.join(['%02x' % int(x) for x in netaddr.split('.')]), 16)
mask = (0xffffffff << (32 - int(bits))) & 0xffffffff
return (ipaddr & mask) == (netmask & mask)
def human_size(num, suffix=''):
for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
if abs(num) < 1024.0:
return "%3.1f %s%s" % (num, unit, suffix)
num /= 1024.0
return "%.1f%s%s" % (num, 'Yi', suffix)
class FactoryMixin:
_instance = None
@classmethod
def factory(cls, *args, **kwargs):
if not cls._instance:
cls._instance = cls(*args, **kwargs)
return cls._instance

95
lib/db.py Normal file
View File

@@ -0,0 +1,95 @@
import logging
from django.db import models
from django.db.models.query import QuerySet
from django.forms import model_to_dict
BOOLEAN_CHOICES = (
(False, 'No'),
(True, 'Yes'),
)
BOOLEAN_CHOICES_CHAR = (
('false', 'No'),
('true', 'Yes'),
)
JOB_STATUS_CHOICES = (
('pending', 'Pending'),
('running', 'Running'),
('success', 'Success'),
('error', 'Error'),
)
class TaskAwareQuerySet(QuerySet):
def delete(self, task=None):
try:
if task:
task.add_entry(f'Deleting {self.model.__name__}s via Queryset')
except Exception as e:
logging.error(f'Failed to add task entry for {self.model.__name__}s: {e}')
finally:
return super().delete()
class TaskAwareModelMixin(models.Model):
class Meta:
abstract = True
objects = TaskAwareQuerySet.as_manager()
def delete(self, task=None, using=None, keep_parents=False):
try:
if task:
task.add_entry(f'Deleting {self.__class__.__name__} {self}')
except Exception as e:
logging.error(f'Failed to add task entry for {self.__class__.__name__} {self}: {e}')
finally:
super().delete(using=using, keep_parents=keep_parents)
class DateAwareMixin(models.Model):
class Meta:
abstract = True
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class BaseModel(DateAwareMixin):
_old_values = {}
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)
@property
def to_json(self):
return model_to_dict(self)
def write(self, throw_on_error=False, **kwargs):
logging.debug(f'Writing {self} to DB with {kwargs}')
for field, value in kwargs.items():
if hasattr(self, field):
logging.debug(f'Setting {field} to {value} for {self}')
setattr(self, field, value)
else:
if throw_on_error:
raise AttributeError(f'Could not find {field} in {self.__class__.__name__}')
logging.warning(f'Could not find {field} in {self.__class__.__name__}')
if not self._state.adding:
self.save(update_fields=kwargs.keys())
else:
logging.warning(f'Trying to write {self} to DB - object is not yet saved')
return self
class SearchableMixin:
@classmethod
def term_filter(cls, search_string):
raise NotImplemented('"{cls.__class__.__name__}.search_by_term" not implemented')
@classmethod
def term_search(cls, search_string):
if search_string:
return cls.objects.filter(cls.term_filter(search_string))
return cls.objects.all()

83
lib/decorators.py Normal file
View File

@@ -0,0 +1,83 @@
import logging
from functools import wraps
from django.db.models import Model
from django_proxmox_mikrotik.configs import MIKROTIK_READONLY, MikrotikConfig, PROXMOX_READONLY, ProxmoxConfig
def readonly(func):
"""Decorator to temporarily enable READONLY for both Proxmox and Mikrotik"""
@wraps(func)
def wrapper(*args, **kwargs):
pm_initial = ProxmoxConfig.READONLY
mk_initial = MikrotikConfig.READONLY
ProxmoxConfig.READONLY = True
MikrotikConfig.READONLY = True
logging.debug(f"READONLY: Setting ProxmoxConfig.READONLY from {pm_initial} to {ProxmoxConfig.READONLY} "
f"and MikrotikConfig.READONLY from {mk_initial} to {MikrotikConfig.READONLY} for {func.__name__}")
try:
return func(*args, **kwargs)
finally:
logging.debug(f"READONLY: Resetting ProxmoxConfig.READONLY from {pm_initial} to {ProxmoxConfig.READONLY} "
f"and MikrotikConfig.READONLY from {mk_initial} to {MikrotikConfig.READONLY} for {func.__name__}")
ProxmoxConfig.READONLY = PROXMOX_READONLY
MikrotikConfig.READONLY = MIKROTIK_READONLY
return wrapper
def force_write(func):
"""Decorator to temporarily disable READONLY for both Proxmox and Mikrotik"""
@wraps(func)
def wrapper(*args, **kwargs):
pm_initial = ProxmoxConfig.READONLY
mk_initial = MikrotikConfig.READONLY
ProxmoxConfig.READONLY = False
MikrotikConfig.READONLY = False
logging.debug(f"FORCE WRITE: Setting ProxmoxConfig.READONLY from {pm_initial} to {ProxmoxConfig.READONLY} "
f"and MikrotikConfig.READONLY from {mk_initial} to {MikrotikConfig.READONLY} for {func.__name__}")
try:
return func(*args, **kwargs)
finally:
logging.debug(f"FORCE WRITE: Resetting ProxmoxConfig.READONLY from {pm_initial} to {ProxmoxConfig.READONLY} "
f"and MikrotikConfig.READONLY from {mk_initial} to {MikrotikConfig.READONLY} for {func.__name__}")
ProxmoxConfig.READONLY = PROXMOX_READONLY
MikrotikConfig.READONLY = MIKROTIK_READONLY
return wrapper
def skip_signal(signaltype='post_save', **kwargs):
"""This should be used as decorator for signal handlers to prevent recursion.
Mostly used for post_save and post_delete.
The signaltype is just for logging."""
def _decorator(signal_handler):
@wraps(signal_handler)
def _wrapper(sender, instance: Model, **kwargs):
if getattr(instance, '_skip_signal', False):
logging.debug(
f'Skip signal handler for {signaltype} : {signal_handler.__name__} - {sender.__name__} - {instance.__class__.__name__}')
return
instance._skip_signal = True
try:
return signal_handler(sender, instance, **kwargs)
finally:
try:
del instance._skip_signal
except AttributeError:
logging.debug(
f'{instance.__class__.__name__} instance has no attribute "_skip_signal" - could not delete it.')
except Exception as e:
logging.exception('WTF????', str(e))
return _wrapper
return _decorator

94
lib/ldap.py Normal file
View File

@@ -0,0 +1,94 @@
import logging
import re
import ldap
from django.contrib.auth.models import Group, User
from django_proxmox_mikrotik.configs import AuthLDAPConfig
groupmember_re = re.compile('^uid=([^,]+),')
class Ldap:
possible_groups = ['root', 'intern', 'extern']
def __init__(self):
self.initialize()
def initialize(self):
self.conn = ldap.initialize(AuthLDAPConfig.HOST)
self.conn.simple_bind_s(AuthLDAPConfig.BIND_DN, AuthLDAPConfig.BIND_PASSWORD)
def __enter__(self):
self.initialize()
return self
def __exit__(self, *args):
self.conn.unbind_s()
def search(self, base, filterstr='(objectClass=*)', attrlist=None):
logging.debug(f'LDAP to {base} with filter {filterstr} - {attrlist if attrlist else "all attributes"}')
return self.conn.search_s(base, ldap.SCOPE_SUBTREE, filterstr, attrlist)
def __getattr__(self, item):
if hasattr(ldap, item):
return getattr(ldap, item)
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{item}'")
def get_groups(self, filterstr='(objectClass=*)', attrlist=None):
return self.search(AuthLDAPConfig.GROUP_SEARCH_BASE, filterstr, attrlist)
def get_users(self, filterstr='(objectClass=inetOrgPerson', attrlist=None):
return self.search(AuthLDAPConfig.USER_BASE, filterstr, attrlist)
def get_user(self, username):
if userdata := self.get_users(f'(uid={username})'):
return {k: (v[0].decode('utf-8') if v else None) for k, v in userdata[0][1].items()}
return {}
def get_user_groups(self, username, attrlist=None):
filterstr = (f'(&'
f'(objectClass=groupOfNames)'
f'(member=uid={username},{AuthLDAPConfig.USER_BASE})'
f')')
grps = self.get_groups(filterstr, attrlist)
return [data.get('ou')[0].decode('utf-8') for dn, data in grps]
def get_group_members(self, groupname, attrlist=None):
if found := self.search(f'ou={groupname},{AuthLDAPConfig.GROUP_SEARCH_BASE}',
'(objectClass=groupOfNames)', attrlist=['member']):
return [groupmember_re.sub(r'\1', m.decode('utf-8')) for m in found[0][1].get('member')]
return []
def create_initial_groups(self):
return [
Group.objects.get_or_create(name=name)[0] for name in self.possible_groups
]
def set_user_groups(self, userinstance: User, save_instance=True):
"""This does NOT save the user instance!"""
root_group, intern_group, extern_group = self.create_initial_groups()
try:
ldap_user = self.get_user(userinstance.username)
if ldap_user:
logging.debug(f'LDAP-User found: {ldap_user}')
groups = self.get_user_groups(userinstance.username)
if 'root' in groups and (userinstance.is_superuser is False or userinstance.is_staff is False):
logging.debug(f'LDAP-User is root: {ldap_user}')
userinstance.groups.add(root_group)
userinstance.is_superuser = True
userinstance.is_staff = True
elif 'intern' in groups and userinstance.is_staff is False:
logging.debug(f'LDAP-User is intern: {ldap_user}')
userinstance.groups.add(intern_group)
userinstance.is_staff = True
elif 'extern' in groups:
logging.debug(f'LDAP-User is extern: {ldap_user}')
userinstance.groups.add(extern_group)
else:
raise Exception(f'LDAP-User not found: {userinstance.username}')
except Exception as e:
logging.error(f"LDAP-Fehler: {e}")
raise e
if save_instance:
userinstance.save()

13
lib/messages.py Normal file
View File

@@ -0,0 +1,13 @@
from django_middleware_global_request import get_request
from django.contrib import messages
import logging
def __getattr__(name):
def wrapper(*args, **kwargs):
request = get_request()
getattr(logging, name, logging.info)(*args)
return getattr(messages, name)(request, *args, **kwargs)
if hasattr(messages, name):
return wrapper
raise AttributeError(f"'{__name__}' object has no attribute '{name}'")

379
lib/mikrotik.py Normal file
View File

@@ -0,0 +1,379 @@
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)

214
lib/proxmox.py Normal file
View File

@@ -0,0 +1,214 @@
import json
import logging
import time
import proxmoxer
from django_proxmox_mikrotik.settings import ProxmoxConfig
def get_comma_separated_values(value):
_vlist = [v.strip().split('=', 1) for v in value.split(',') if '=' in v] if value else []
return {k: v for k, v in _vlist}
class PMDict(dict):
def __getattr__(self, name):
if name in self:
return self[name]
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
class PMCollection(list):
def append(self, item):
assert isinstance(item, dict)
if not isinstance(item, PMDict):
item = PMDict(**item)
super().append(item)
def __getattr__(self, item):
if self and hasattr(self[0], item):
for i in self:
yield getattr(i, item)
raise AttributeError(f"'{self.__class__.__name__}' object (or its content[0]) has no attribute '{item}'")
class Proxmox:
def __init__(self, node=ProxmoxConfig.NODE):
self.initialize(node)
def initialize(self, node=ProxmoxConfig.NODE, ):
self.api = proxmoxer.ProxmoxAPI(
ProxmoxConfig.HOST,
user=ProxmoxConfig.USER,
password=ProxmoxConfig.PASS,
verify_ssl=False
)
self.nodepath = f'nodes/{node}'
return self
def __enter__(self):
return self.initialize()
def __exit__(self, *args):
"""Actually, this is a no-op, just for the with statement :)"""
return
def nodes(self, route=''):
return self.api(f'{self.nodepath}/{route.lstrip("/")}')
def lxc(self, route=''):
return self.nodes(f'lxc/{route.lstrip("/")}')
def storage(self, route=''):
return self.nodes(f'storage/{route.lstrip("/")}')
def qemu(self, route=''):
return self.nodes(f'qemu/{route.lstrip("/")}')
def cluster(self, route=''):
return self.api(f'cluster/{route.lstrip("/")}')
@property
def next_vmid(self):
return int(self.cluster_get('nextid'))
def __getattr__(self, name):
"""This makes a 'magic' trick
We can call the proxmox api like this:
* proxmox.lxc_115_config_get()
* proxmox.lxc_115_get('config')
* proxmox.lxc_get('115/config')
* proxmox.lxc('115/config').get()
* ...
seems handy at the moment ...
The first in *args will always be taken as route!
"""
if "_" in name:
nameparts = name.split("_")
action = nameparts.pop()
if action not in ["get", "post", "put", "delete"]:
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}' - 1")
if hasattr(self, nameparts[0]):
base_method = getattr(self, nameparts.pop(0))
else:
base_method = self.nodes
route_part = "/".join(nameparts)
def wrapper(*args, **kwargs):
if args:
route = str(args[0]).rstrip('/')
args = args[1:]
else:
route = ''
if route_part:
route = f'{route_part}/{route}'
if ProxmoxConfig.READONLY and action != 'get':
logging.warning(f'PROXMOX_READONLY is set - not calling {route} '
f'with method {base_method.__name__}.{action}'
f'({args}, {kwargs})')
# Return appropriate mock response based on action
if action == 'post':
return 'UPID:READONLY:00000000:00000000:00000000:vzcreate:readonly:root@pam:'
elif action == 'put':
return None
elif action == 'delete':
return None
return {}
logging.debug(f'Calling {base_method.__name__}.{action}({route}, {args}, {kwargs})')
return getattr(base_method(route), action)(*args, **kwargs)
return wrapper
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
@classmethod
def get_task_status(cls, taskhash, sleeptime=10, pm=None):
cls = pm or cls()
logging.debug(f"Getting task status for {taskhash}")
maxtime = ProxmoxConfig.CREATE_LXC_TIMEOUT
response = cls.nodes_get(f'tasks/{taskhash}/status')
logging.debug(f"Status response for {taskhash}: {response}")
while True:
response = cls.nodes_get(f'tasks/{taskhash}/status')
logging.debug(f"Status response for {taskhash}: {response}")
yield response
logging.debug(f"Status response for {taskhash}: {response}")
status = response['status']
if status == 'stopped':
if not response.get('exitstatus') == 'OK':
raise ValueError(f"Exitstatus is {response.get('exitstatus')}")
break
time.sleep(sleeptime)
maxtime -= sleeptime
if maxtime <= 0:
raise TimeoutError("Took to long")
def get_all_lxc(self, *filterargs, as_dict=True, **filterkwargs):
logging.debug(f"Getting all LXC with filter {filterargs} and {filterkwargs}")
from proxmox.models import Lxc
lxc_filter = {}
_raw = self.lxc_get()
logging.debug(f"All LXC: {_raw}")
if not _raw:
return []
all = _raw
comps = {}
for key in list(_raw[0].keys()):
if key in filterkwargs:
comps[key] = filterkwargs.pop(key)
for key, comp in comps.items():
all = filter(lambda x: (
logging.debug(f'{key} of lxc is {x[key]}, must be {comp}' if key in x else f"{key} not in {x}")
or key not in x
or x[key] == comp
), all)
if not all:
logging.debug(f"No LXC found with filter {filterargs} and {filterkwargs}")
logging.debug(f"All LXC: {json.dumps(all, indent=2, default=str)}")
return []
if filterargs:
for prop, c, v in filterargs:
invert = False
if c.startswith('!'):
invert = True
if c.endswith('='):
comparer = lambda x: x != v if invert else x == v
elif c.endswith('in'):
comparer = lambda x: x not in v if invert else x in v
elif c.endswith('startswith'):
comparer = lambda x: v.startswith(v) if invert else v.startswith(v)
elif c.endswith('endswith'):
comparer = lambda x: v.endswith(v) if invert else v.endswith(v)
lxc_filter[prop] = comparer
if filterkwargs:
for k, v in filterkwargs.items():
lxc_filter[k] = lambda x: x == v
def filter_out(lxc_):
if not lxc_filter:
return True
for prop, comparer in lxc_filter.items():
if not prop in lxc_:
continue
if not comparer(lxc_.get(prop)):
logging.debug(f"Filter out {lxc_} because {prop} is {lxc_.get(prop)}")
return False
return True
ret = []
for lxc in filter(filter_out, all):
lxc_config = self.lxc_get(f'{lxc["vmid"]}/config')
_lx_data = lxc | lxc_config
if as_dict:
ret.append(PMDict(**_lx_data))
# yield PMDict(**_lx_data)
else:
ret.append(Lxc().from_proxmox(**_lx_data))
# yield Lxc().from_proxmox(**_lx_data)
if not ret:
logging.warning(f"Found no LXC with filter {filterargs} and {filterkwargs}")
return ret

342
lib/router_abstract.py Normal file
View File

@@ -0,0 +1,342 @@
import json
import logging
from copy import copy
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.forms import model_to_dict
from lib import FactoryMixin
class RouterObjectCollection(set, FactoryMixin):
_objectclass = None
def _check_member_class(self, member):
if self._objectclass:
if not isinstance(member, self._objectclass):
raise ValueError(
f'Only {self._objectclass.__class__.__name__} can be added to {self.__class__.__name__}')
else:
if not isinstance(member, RouterObjectAbstract):
raise ValueError(
f'Only {RouterObjectAbstract.__class__.__name__} can be added to {self.__class__.__name__}')
self._objectclass = member.__class__
def add(self, *args, **kwargs):
"""Override add() to check if object is of correct type
we need a __hash__() function for this"""
self._check_member_class(args[0])
return super().add(*args, **kwargs)
def filter(self, *args, **kwargs):
"""This returns a new object filtered by some criteria
"""
_new = self.__class__()
for obj in self:
if obj.filtered(*args, **kwargs):
_new.add(obj)
return _new
def all(self):
"""This returns all objects - a copy"""
return self.copy()
def first(self, *args, **kwargs):
"""This returns the first object
from a copy of self"""
if args or kwargs:
objects = self.filter(*args, **kwargs)
else:
objects = self.all()
return objects[0] if objects else None
def remove(self, *args, **kwargs):
"""Override remove() to not throw KeyError (like discard())"""
try:
super().remove(*args, **kwargs)
except KeyError:
pass
return self
def values_list(self, keyset: list, flat=False):
if flat:
_ret = []
else:
_ret = self.__class__()
for obj in self:
new_obj = obj.__class__(**{k: v for k, v in copy(obj).items() if k in keyset})
if flat:
valuesdict = {}
for k, v in new_obj.items():
valuesdict.setdefault(k, []).append(v)
return list(valuesdict.items())
else:
_ret.add(new_obj)
return _ret
class RoutedModelAbstract(models.Model):
class Meta:
abstract = True
internal_id = models.BigAutoField(primary_key=True)
@property
def router_object(self):
return self._router_object
@router_object.setter
def router_object(self, value):
self._router_object = value
def __init__(self, *args, **kwargs):
self._router_object = kwargs.pop('router_object', None)
super().__init__(*args, **kwargs)
self._old_values = self.to_json
@property
def to_json(self):
return model_to_dict(self)
@property
def unique_on_router(self):
raise NotImplemented(f'{self.__class__.__name__} must implement unique_on_router')
class RouterObjectAbstract(dict, FactoryMixin):
_router_instance = None
_model_class = None
_model_instance = None
def __init__(self, router=None, model=None, **kwargs):
if router:
self.set_router(router)
if model:
self.set_model(model)
super().__init__(**kwargs)
class DoesNotExistsOnRouter(ObjectDoesNotExist):
pass
@property
def model_class(self):
return self._model_class
@property
def get_from_db(self):
raise NotImplemented(f"{self.__class__.__name__} must implement get_from_db")
@model_class.setter
def model_class(self, model):
if isinstance(model, RoutedModelAbstract):
self._model_class = model.__class__
elif issubclass(model, RoutedModelAbstract):
self._model_class = model
else:
raise ValueError(
f"model must be of type {RoutedModelAbstract.__class__.__name__}, not {model.__class__.__name__}")
@property
def model_instance(self):
return self._model_instance
@model_instance.setter
def model_instance(self, model):
if self._model_class and not issubclass(model.__class__, self._model_class):
raise ValueError(
f'Model {model.__class__.__name__} must be of type {self._model_class.__class__.__name__}')
if not isinstance(model, RoutedModelAbstract):
raise ValueError(
f"model must be of type {RoutedModelAbstract.__class__.__name__}, not {model.__class__.__name__}")
self._model_instance = model
self._model_class = model.__class_
model.router_object = self
def set_model(self, model):
if isinstance(model, RoutedModelAbstract):
self.model_instance = model
else:
self.model_class = model.__class__
if not self._model_instance:
logging.debug(f'Creating new unsaved {self._model_class.__name__} from {self}')
self._model_instance = self.model_class(**({'router_object': self} | self))
return self
def set_router(self, router):
assert isinstance(router,
RouterAbstract), f"router must be of type {RouterAbstract.__class__.__name__}, not {router.__class__.__name__}"
self._router_instance = router
return self
def to_db_object(self, raise_on_keyerror=False) -> models.Model:
"""This returns a dict representation of the object"""
if raise_on_keyerror:
_data = {k: self.get(k, '') for k in model_to_dict(self._model_class).keys()}
else:
_errors = []
_data = {}
for k in model_to_dict(self._model_class):
try:
_data[k] = self[k]
except KeyError as e:
_errors.append(str(e))
if _errors:
raise KeyError(f'Could not convert {self.__class__.__name__} to DB object - missing keys: {_errors}')
return self._model_class(**_data)
@classmethod
def from_db_object(cls, db_object):
return cls(**model_to_dict(db_object))
@property
def router(self):
return self._router_instance
def __hash__(self):
return len(self.to_json)
def _filter_or(self, **kwargs):
for k, v in kwargs.items():
if self.get(k) == v:
return True
return False
def _filter_and(self, **kwargs):
for k, v in kwargs.items():
if self.get(k) != v:
return False
return True
def filtered(self, mode='or', raise_on_failure=False, **kwargs):
"""This returns objects filtered by some criteria
Return self if criterias match, else None
"""
assert mode in ('or', 'and'), f"mode must be 'or' or 'and', not {mode}"
if getattr(self, f'_filter_{mode}')(**kwargs):
return self
if raise_on_failure:
raise self.DoesNotExists(f'Object {self} does not match criteria {kwargs}')
return None
def __getattr__(self, name):
"""This makes a 'magic' trick"""
if name in self:
return self[name]
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
def to_json(self, **dump_params):
"""This returns a dict representation of the object"""
if default_fun := dump_params.pop('default', None):
pass
else:
default_fun = lambda o: str(o)
return json.loads(json.dumps(self, default=default_fun, **dump_params))
@classmethod
def router_get(cls, *args, **kwargs):
"""This returns a RouterObjectCollection of objects"""
raise NotImplemented(f"{cls.__name__} must implement router_get()")
def router_post(self):
"""This adds an object to the router"""
raise NotImplemented(f"{self.__class__.__name__} must implement router_post()")
def router_put(self, **kwargs):
"""This removes an object from the router"""
raise NotImplemented(f"{self.__class__.__name__} must implement router_put()")
def router_delete(self):
"""This removes an object from the router"""
raise NotImplemented(f"{self.__class__.__name__} must implement router_delete()")
class DNSStaticAbstract(RoutedModelAbstract):
"""The DNSStatic Object"""
class Meta:
abstract = True
@property
def get_name(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_name')
@property
def get_regex(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_name')
@property
def get_ip4(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_ip4')
@property
def get_ip6(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_ip6')
class LeaseAbstract(RoutedModelAbstract):
"""The IP Lease Object"""
class Meta:
abstract = True
@property
def get_mac(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_mac')
@property
def get_ip4(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_ip4')
@property
def get_ip6(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_ip6')
@property
def get_status(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_status')
class IPAddressAbstract(RoutedModelAbstract):
"""The Address Pool"""
class Meta:
abstract = True
@property
def get_address(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_address')
@property
def get_network(self):
raise NotImplemented(f'{self.__class__.__name__} must implement get_network')
class RouterAbstract:
def __init__(self, *args, **kwargs):
self.initialize(*args, **kwargs)
@property
def api(self):
raise NotImplemented(f"{self.__class__.__name__} must implement api or init with api parameter")
def initialize(self, *args, **kwargs):
"""This initializes the connection to the router"""
raise NotImplemented(f"{self.__class__.__name__} must implement initialize()")
def get(self, **kwargs):
raise NotImplemented(f"{self.__class__.__name__} must implement get()")
def add(self, **kwargs):
raise NotImplemented(f"{self.__class__.__name__} must implement post()")
def set(self, **kwargs):
raise NotImplemented(f"{self.__class__.__name__} must implement put()")
def remove(self, **kwargs):
raise NotImplemented(f"{self.__class__.__name__} must implement delete()")

111
lib/task_decorator.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Einfache Proxmox Task-Funktionen mit TaskLogger-Integration
"""
import logging
from lib.proxmox import Proxmox
from tasklogger.models import TaskFactory
def start_container_with_task(task_uuid: str, vmid: int, request=None) -> bool:
"""Container starten mit TaskLogger-Monitoring"""
task = TaskFactory(task_uuid=task_uuid, request=request)
task.add_entry(f"Starting container {vmid}...")
def _start_container():
with Proxmox() as pm:
return pm.lxc_post(f'{vmid}/status/start')
# Wrap the proxmox function - this handles UPID monitoring synchronously
task.wrap_proxmox_function(_start_container)
task.add_entry(f"Container {vmid} started successfully")
task.unset_as_current()
return True
def stop_container_with_task(task_uuid: str, vmid: int, request=None) -> bool:
"""Container stoppen mit TaskLogger-Monitoring"""
task = TaskFactory(task_uuid=task_uuid, request=request)
task.add_entry(f"Stopping container {vmid}...")
def _stop_container():
with Proxmox() as pm:
return pm.lxc_post(f'{vmid}/status/stop')
# Wrap the proxmox function - this handles UPID monitoring synchronously
task.wrap_proxmox_function(_stop_container)
task.add_entry(f"Container {vmid} stopped successfully")
task.unset_as_current()
return True
def resize_container_disk_with_task(task_uuid: str, vmid: int, disk_size: int, request=None) -> bool:
"""Container-Disk vergrößern mit TaskLogger-Monitoring"""
task = TaskFactory(task_uuid=task_uuid, request=request)
task.add_entry(f"Resizing disk for container {vmid} to {disk_size}GB...")
def _resize_container_disk():
with Proxmox() as pm:
return pm.lxc_put(f'{vmid}/resize', disk='rootfs', size=f'{disk_size}G')
# Wrap the proxmox function - this handles UPID monitoring synchronously
task.wrap_proxmox_function(_resize_container_disk)
task.add_entry(f"Container {vmid} disk resized to {disk_size}GB successfully")
task.unset_as_current()
return True
def create_container_with_task(task_uuid: str, clone_container, request=None) -> bool:
"""Container erstellen mit TaskLogger-Monitoring"""
try:
# CloneContainer.execute() uses the tasklogger directly now
clone_container.execute(task_uuid_override=task_uuid, request=request)
return True
except Exception as e:
logging.exception(f"Container creation failed: {e}")
return False
def delete_container_with_task(task_uuid: str, container, request=None) -> bool:
"""Container löschen mit TaskLogger-Monitoring"""
task = TaskFactory(task_uuid=task_uuid, request=request)
task.add_entry(f"Deleting container {container.name}...")
try:
# Delete Proxmox LXC (if needed)
task.add_entry(f"Deleting Proxmox container {container.lxc.vmid}...")
# Wrap the proxmox function - this handles UPID monitoring synchronously
task.wrap_proxmox_function(container.delete, task=task)
task.add_entry("Container deleted successfully!")
task.status = 'completed'
task.save()
return True
except Exception as e:
task.add_entry(f"Error deleting container: {str(e)}")
task.status = 'error'
task.save()
logging.exception(f"Container deletion failed: {e}")
return False
finally:
task.unset_as_current()
def update_container_config_sync(vmid: int, task=None,**config_params) -> bool:
"""Synchrone Container-Config-Updates (Memory, Cores) - kein Task-Monitoring nötig"""
try:
with Proxmox() as pm:
result = pm.lxc_put(f'{vmid}/config', **config_params)
logging.info(f"Updated container {vmid} config: {config_params}")
if task:
task.add_entry(f"Updated container {vmid} config: {config_params}")
return True
except Exception as e:
logging.error(f"Failed to update container {vmid} config: {e}")
if task:
task.add_entry(f"Failed to update container {vmid} config: {e}")
return False

44
lib/utils.py Normal file
View File

@@ -0,0 +1,44 @@
import time
import logging
import functools
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django_middleware_global_request import get_request
from django_proxmox_mikrotik import settings
def measure_time(func):
"""Decorator, der die Ausführungszeit einer Funktion misst und mit logging.debug ausgibt."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
execution_time = end_time - start_time
logging.debug(f"Funktion '{func.__name__}' wurde in {execution_time:.4f} Sekunden ausgeführt")
return result
return wrapper
def paginator(queryset, page: int = 1, per_page: int = 18, request=None):
request = request or get_request()
per_page = request.GET.get('per_page',
request.POST.get('per_page', getattr(settings, 'PAGINATOR_PER_PAGE', per_page)))
paginator = Paginator(queryset, per_page)
page = request.GET.get('page', request.POST.get('page', page))
try:
return paginator.page(page)
except PageNotAnInteger:
return paginator.page(1)
except EmptyPage:
return paginator.page(paginator.num_pages)
class PaginatedModel:
def __init__(self, model):
self.model = model
def paginate(self, domainfilter, page: int = 1, per_page: int = 18, request=None):
queryset = self.model.objects.filter(domainfilter)
return paginator(queryset, page, per_page, request)

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_proxmox_mikrotik.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

0
manager/__init__.py Normal file
View File

262
manager/admin.py Normal file
View File

@@ -0,0 +1,262 @@
import logging
from django import forms
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db.models import Q
from lib.decorators import readonly
from manager.models import CloneContainer, DevContainer
from mikrotik.models import DNSStatic
from proxmox.models import Lxc
@readonly
def resync_all(*args, **kwargs):
from proxmox.models import Lxc
from lib.proxmox import Proxmox
from mikrotik.models import DNSStatic, IPDHCPLease
from manager.models import DevContainer
from mikrotik.admin import sync_ipaddress_from_mikrotik, sync_ipdhcplease_from_mikrotik, sync_dns_static_from_mikrotik
from proxmox.admin import sync_all_lxc_templates_from_proxmox, sync_all_lxc_from_proxmox
fulls = []
pm = Proxmox()
lxcs = {}
leases = {}
dnse = {}
sync_all_lxc_templates_from_proxmox()
sync_all_lxc_from_proxmox()
sync_ipaddress_from_mikrotik()
sync_ipdhcplease_from_mikrotik()
sync_dns_static_from_mikrotik()
for lxc in Lxc.objects.all():
lxcs[lxc.hwaddr.upper()] = lxc
for lease in IPDHCPLease.objects.all():
leases[lease.mac_address.upper()] = lease
for dns in DNSStatic.objects.all():
dnse[dns.address.upper()] = dns
container = {c.hwaddr.upper():c for c in DevContainer.objects.all()}
for hwaddr, lxc in lxcs.items():
if hwaddr not in leases:
logging.warning(f'LXC {lxc} has no DHCP lease')
continue
lease = leases[hwaddr]
if lease.address not in dnse:
logging.warning(f'DHCP lease {lease} for {lxc} has no DNS entry')
continue
dns = dnse[lease.address]
if hwaddr in container:
container[hwaddr].dns = dns
container[hwaddr].lease = lease
container[hwaddr].save()
elif lxc:
DevContainer.objects.create(
dns=dns,
lease=lease,
lxc=lxc,
)
# Now remove the non lxc devcontainers
DevContainer.objects.filter(lxc__isnull=True).delete()
def shell_baseimport():
from proxmox.models import Lxc
from lib.proxmox import Proxmox
from mikrotik.models import DNSStatic, IPDHCPLease, IPAddress
from manager.models import DevContainer
from lib.mikrotik import MikrotikModelMixin
fulls = []
for empt in (DevContainer, Lxc, IPAddress, IPDHCPLease, DNSStatic):
if empt.objects.count() != 0:
fulls.append(empt)
if fulls:
msg = []
queries = []
for f in fulls:
logging.error(f'{f.__name__} is not empty.')
queries.append(f'DELETE FROM {f._meta.db_table};')
msg.append(
f"\n\nPlease delete all objects and try again.\n"
f"\nThis can only be done with a raw query\n"
)
msg.append('\n'.join(queries))
logging.error('\n'.join(msg) + '\n\n')
raise ValidationError("Some tables not empty - see above output")
pm = Proxmox()
lxcs = {}
for lxc in pm.get_all_lxc(as_dict=False):
lxc.save()
lxcs[lxc.hwaddr.upper()] = lxc
addresses = IPAddress.get_all_as_object()
for a in addresses:
a.save()
leases = {}
for lease in IPDHCPLease.get_all_as_object():
if isinstance(lease, MikrotikModelMixin):
lease.save()
if lease.mac_address in lxcs:
leases[lease.address.upper()] = lease
else:
logging.warning(f'IPDHCPLease {lease} is not a MikrotikModelMixin')
dnse = {}
for dns in DNSStatic.get_all_as_object():
if isinstance(dns, MikrotikModelMixin):
dns.save()
if dns.address in leases:
dnse[dns.address.upper()] = dns
else:
logging.warning(f'DNSStatic {dns} is not a MikrotikModelMixin')
for _a, dns in dnse.items():
lease = leases[_a]
lxc = lxcs[lease.mac_address.upper()]
DevContainer.objects.get_or_create(
lxc=lxc,
dns=dns,
lease=lease,
)
class DevContainerAdminForm(forms.ModelForm):
disksize = forms.IntegerField(
required=False,
min_value=1,
max_value=100,
help_text="Disk Size of rootfs - can not be shrinked"
)
cores = forms.IntegerField(
required=False,
min_value=1,
help_text="Number of Cores"
)
memory = forms.CharField(
required=False,
help_text="Memory in MB"
)
class Meta:
model = DevContainer
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
instance = kwargs.get('instance')
if instance and hasattr(instance, 'lxc') and instance.lxc:
self.fields['disksize'].initial = instance.lxc.disksize
self.fields['disksize'].min_value = instance.lxc.disksize
self.fields['cores'].initial = instance.lxc.cores
self.fields['memory'].initial = instance.lxc.memory
def save(self, commit=True):
instance = super().save(commit=False)
if hasattr(instance, 'lxc') and instance.lxc:
lxc = instance.lxc
if 'disksize' in self.cleaned_data and self.cleaned_data['disksize']:
lxc.disksize = self.cleaned_data['disksize']
if 'cores' in self.cleaned_data and self.cleaned_data['cores']:
lxc.cores = self.cleaned_data['cores']
if 'memory' in self.cleaned_data and self.cleaned_data['memory']:
lxc.memory = self.cleaned_data['memory']
lxc.save()
if commit:
instance.save()
return instance
def clone_selected_containers(modeladmin, request, queryset):
for container in queryset:
container.execute()
class CloneContainerAdminForm(forms.ModelForm):
class Meta:
model = CloneContainer
fields = '__all__'
def clean_name(self):
name = self.cleaned_data.get('name')
if not name:
return name
dns_domain = Q(name=name) | Q(name__endswith=name) | Q(name__endswith=name.replace('.', r'\.'))
if DNSStatic.objects.filter(dns_domain).exists():
raise ValidationError(f"Ein DNS-Eintrag mit dem Namen '{name}' existiert bereits.")
if Lxc.objects.filter(hostname=name).exists():
raise ValidationError(f"A LXC with name or regex '{name}' exists.")
# Prüfe, ob der Name bereits als CloneContainer-Name existiert
# Ausschluss der aktuellen Instanz bei Updates
existing_clone_query = CloneContainer.objects.filter(name=name)
if self.instance.pk:
existing_clone_query = existing_clone_query.exclude(pk=self.instance.pk)
if existing_clone_query.exists():
raise ValidationError(f"A CloneContainer with name '{name}' exists.")
return name
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get('template') and not cleaned_data.get('vm'):
raise ValidationError("Please select a template or a VM.")
return cleaned_data
@admin.register(CloneContainer)
class CloneContainerAdmin(admin.ModelAdmin):
autocomplete_fields = ['vm', 'network']
list_display = ('hostname', 'cloned_from', 'network', 'memory', 'cores', 'disksize', 'status',)
search_fields = ('hostname', 'vm__name', 'vm__vmid',)
actions = [clone_selected_containers]
form = CloneContainerAdminForm
def cloned_from(self, obj):
return obj.vm
cloned_from.short_description = 'Cloned from'
cloned_from.admin_order_field = 'vm__name'
@admin.register(DevContainer)
class DevContainerAdmin(admin.ModelAdmin):
form = DevContainerAdminForm
autocomplete_fields = ['dns', 'lxc', 'lease']
# list_display = ('name', 'address', 'hostname_or_regexp', 'lxc__disksize', 'hwaddr', 'status',)
list_filter = (
'lxc__status',
'lease__status',
('lxc', admin.EmptyFieldListFilter),
('lease', admin.EmptyFieldListFilter),
('dns', admin.EmptyFieldListFilter),
)
search_fields = ('dns__name', 'dns__address', 'lease__address', 'dns__regexp')
actions = [resync_all]
def hostname_or_regexp(self, obj):
return obj.hostname
hostname_or_regexp.short_description = 'Hostname or Regexp'
hostname_or_regexp.admin_order_field = 'dns__name'

6
manager/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ManagerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'manager'

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.2.4 on 2025-07-07 11:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('mikrotik', '0001_initial'),
('proxmox', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CloneContainer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Will be used as hostname', max_length=150)),
('cpus', models.IntegerField(default=1)),
('memory', models.IntegerField(default=1024)),
('disksize', models.IntegerField(default=10, help_text='in GB')),
('as_regexp', models.BooleanField(choices=[(False, 'No'), (True, 'Yes')], default=True, help_text='Add a ".*<hostname>" instead of a hostname')),
('is_active', models.BooleanField(choices=[(False, 'No'), (True, 'Yes')], default=False)),
('status', models.CharField(blank=True, choices=[('pending', 'Pending'), ('running', 'Running'), ('success', 'Success'), ('error', 'Error')], default='pending', max_length=150, null=True)),
('network', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='clone_network', to='mikrotik.ipaddress')),
('template', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='clone_template', to='proxmox.lxctemplate')),
('vm', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='clone_lxc', to='proxmox.lxc')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='DevContainer',
fields=[
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
('dns', models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, related_name='devcontainer_dns', to='mikrotik.dnsstatic')),
('lease', models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, related_name='devcontainer_lease', to='mikrotik.ipdhcplease')),
('lxc', models.OneToOneField(on_delete=django.db.models.deletion.RESTRICT, related_name='devcontainer_lxc', to='proxmox.lxc')),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.2.4 on 2025-07-09 06:31
import manager.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='clonecontainer',
name='hostname',
field=models.CharField(default=0, help_text='Will be used as hostname', max_length=150),
preserve_default=False,
),
migrations.AlterField(
model_name='clonecontainer',
name='as_regexp',
field=models.BooleanField(choices=[(False, 'No'), (True, 'Yes')], default=True, help_text='Add a ".*<hostname>.replace(".",r"\\.")$" instead of a hostname'),
),
migrations.AlterField(
model_name='clonecontainer',
name='cpus',
field=models.IntegerField(default=1, validators=[manager.models.MinValueValidatorExtended(1), manager.models.MaxValueValidatorExtended(8)]),
),
migrations.AlterField(
model_name='clonecontainer',
name='disksize',
field=models.IntegerField(default=10, help_text='in GB', validators=[manager.models.MinValueValidatorExtended(10), manager.models.MaxValueValidatorExtended(100)]),
),
migrations.AlterField(
model_name='clonecontainer',
name='memory',
field=models.IntegerField(default=1024, help_text='in MB', validators=[manager.models.MinValueValidatorExtended(256), manager.models.MaxValueValidatorExtended(8192)]),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.4 on 2025-07-09 08:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0002_clonecontainer_hostname_and_more'),
]
operations = [
migrations.RenameField(
model_name='clonecontainer',
old_name='cpus',
new_name='cores',
),
migrations.RemoveField(
model_name='clonecontainer',
name='name',
),
migrations.AddField(
model_name='clonecontainer',
name='node',
field=models.CharField(default='proxmox', editable=False, max_length=150),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-10 09:30
import django.db.models.deletion
import manager.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0003_rename_cpus_clonecontainer_cores_and_more'),
('proxmox', '0005_alter_lxc_lxc'),
]
operations = [
migrations.AlterField(
model_name='clonecontainer',
name='memory',
field=models.IntegerField(default=1024, help_text='in MB', validators=[manager.models.MinValueValidatorExtended(256), manager.models.MaxValueValidatorExtended(8092)]),
),
migrations.AlterField(
model_name='clonecontainer',
name='template',
field=models.ForeignKey(blank=True, help_text='If set, will use this template instead of a VM', null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='clone_template', to='proxmox.lxctemplate'),
),
migrations.AlterField(
model_name='clonecontainer',
name='vm',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='clone_lxc', to='proxmox.lxc'),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.4 on 2025-07-14 12:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0004_alter_clonecontainer_memory_and_more'),
('proxmox', '0005_alter_lxc_lxc'),
]
operations = [
migrations.AlterField(
model_name='clonecontainer',
name='vm',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='clone_lxc', to='proxmox.lxc', verbose_name='LXC Container'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-14 13:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0005_alter_clonecontainer_vm'),
('mikrotik', '0002_alter_dnsstatic_comment_alter_ipaddress_comment_and_more'),
('proxmox', '0005_alter_lxc_lxc'),
]
operations = [
migrations.AlterField(
model_name='devcontainer',
name='dns',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='devcontainer_dns', to='mikrotik.dnsstatic'),
),
migrations.AlterField(
model_name='devcontainer',
name='lease',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='devcontainer_lease', to='mikrotik.ipdhcplease'),
),
migrations.AlterField(
model_name='devcontainer',
name='lxc',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='devcontainer_lxc', to='proxmox.lxc'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.4 on 2025-07-21 11:03
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0006_alter_devcontainer_dns_alter_devcontainer_lease_and_more'),
]
operations = [
migrations.CreateModel(
name='TaskStatusLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('task_id', models.CharField(help_text='The task ID', max_length=150)),
('task_result', models.JSONField(help_text='The task result')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='devcontainer',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='devcontainer',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2025-07-23 11:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0007_taskstatuslog_devcontainer_created_at_and_more'),
('mikrotik', '0002_alter_dnsstatic_comment_alter_ipaddress_comment_and_more'),
('proxmox', '0007_lxctemplate_net0_alter_lxc_net0'),
]
operations = [
migrations.AlterField(
model_name='devcontainer',
name='dns',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devcontainer_dns', to='mikrotik.dnsstatic'),
),
migrations.AlterField(
model_name='devcontainer',
name='lease',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devcontainer_lease', to='mikrotik.ipdhcplease'),
),
migrations.AlterField(
model_name='devcontainer',
name='lxc',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devcontainer_lxc', to='proxmox.lxc'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated manually for task_id field
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0008_alter_devcontainer_dns_alter_devcontainer_lease_and_more'),
]
operations = [
migrations.AddField(
model_name='clonecontainer',
name='task_id',
field=models.CharField(blank=True, help_text='UUID for tracking live status', max_length=36, null=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated manually for task_id default
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('manager', '0009_add_task_id'),
]
operations = [
migrations.AlterField(
model_name='clonecontainer',
name='task_id',
field=models.CharField(default=uuid.uuid4, help_text='UUID for tracking live status', max_length=36),
),
]

View File

454
manager/models.py Normal file
View File

@@ -0,0 +1,454 @@
import json
import logging
import time
import uuid
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from django_proxmox_mikrotik.settings import ProxmoxConfig
from lib.db import (
BOOLEAN_CHOICES,
BaseModel,
DateAwareMixin,
JOB_STATUS_CHOICES,
SearchableMixin,
TaskAwareModelMixin
)
from lib.proxmox import Proxmox
from mikrotik.models import DNSStatic, IPAddress, IPDHCPLease
class MinValueValidatorExtended(MinValueValidator):
message = 'Ensure this value is greater than or equal to %(limit_value)s. You gave %(value)s.'
class MaxValueValidatorExtended(MaxValueValidator):
message = 'Ensure this value is less than or equal to %(limit_value)s. You gave %(value)s.'
def minmaxvalidators(min_value, max_value):
return (
MinValueValidatorExtended(min_value),
MaxValueValidatorExtended(max_value),
)
class CloneAbstract(models.Model):
class Meta:
abstract = True
hostname = models.CharField(max_length=150, help_text='Will be used as hostname')
network = models.ForeignKey(IPAddress, on_delete=models.RESTRICT, related_name='clone_network')
cores = models.IntegerField(default=1, validators=minmaxvalidators(1, ProxmoxConfig.MAX_CORES))
memory = models.IntegerField(default=1024, validators=minmaxvalidators(256, ProxmoxConfig.MAX_MEM),
help_text='in MB')
disksize = models.IntegerField(default=10, help_text='in GB',
validators=minmaxvalidators(10, ProxmoxConfig.MAX_DISK))
as_regexp = models.BooleanField(default=True, choices=BOOLEAN_CHOICES,
help_text=r'Add a ".*<hostname>.replace(".",r"\.")$" instead of a hostname')
is_active = models.BooleanField(default=False, choices=BOOLEAN_CHOICES)
status = models.CharField(max_length=150, null=True, blank=True, default='pending', choices=JOB_STATUS_CHOICES)
task_id = models.CharField(max_length=36, default=uuid.uuid4, help_text='UUID for tracking live status')
# Lxcs have no Node - FIXME
node = models.CharField(max_length=150, default=ProxmoxConfig.NODE, editable=False)
def __str__(self):
return f"{self.hostname} from {self.vm} "
@property
def regexp(self):
return f".*{self.hostname.replace('.', r'\.')}" if self.as_regexp else None
@property
def next_vmid(self):
raise NotImplemented('Not implemented')
@property
def proxmox_data(self):
raise NotImplemented('Not implemented')
def execute(self):
raise NotImplemented('Not implemented')
class CloneContainer(CloneAbstract):
_old_active = None
vm = models.ForeignKey('proxmox.Lxc', on_delete=models.CASCADE,
related_name='clone_lxc', null=True, blank=True,
verbose_name='LXC Container')
template = models.ForeignKey('proxmox.LxcTemplate', on_delete=models.RESTRICT, related_name='clone_template',
null=True, blank=True,
help_text='If set, will use this template instead of a VM')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._old_active = self.is_active
def execute(self, task_uuid_override=None, *args, **kwargs):
from proxmox.models import Lxc
from tasklogger.models import TaskFactory
request = kwargs.pop('request', None)
# Use override task_uuid if provided (from form), otherwise create new task
task = TaskFactory(task_uuid=task_uuid_override, request=request)
task.add_entry(f"Creating container '{self.hostname}'")
try:
data = self.proxmox_data
pm = Proxmox()
route = data.pop('route')
vmdata = data.pop('vm')
dnsdata = data.pop('dns')
leasedata = data.pop('lease')
task.add_entry(f"Prepared container data: VM-ID {vmdata['newid']}")
vmparams = {
'hostname': vmdata['hostname'],
'description': vmdata['description'],
'cores': vmdata['cores'],
'memory': vmdata['memory'],
}
if vmdata['template']:
vmparams |= {
'vmid': vmdata['newid'],
'ostemplate': self.template.volid,
'net0': vmdata['net0'],
'features': 'nesting=1',
'rootfs': f'local-lvm:{vmdata["disksize"]}',
}
task.add_entry(f"Creating new container from template {self.template.volid}")
else:
vmparams |= {
'newid': vmdata['newid'],
}
task.add_entry(f"Cloning container {self.vm.vmid}")
success = False
lxc = None
lease = None
dns = None
self.status = 'running'
super().save()
# Step 1: Create/Clone Container (wrapper nur um lxc_post)
task.add_entry("Starting Proxmox container creation...")
def _lxc_post():
return pm.lxc_post(route, **vmparams)
# Wrap the proxmox function - this handles UPID monitoring synchronously
task.wrap_proxmox_function(_lxc_post)
task.add_entry("Container creation completed")
success = True
if success:
task.add_entry("Waiting for container to be ready...")
time.sleep(3)
# Step 2: Get container info and update settings
task.add_entry("Retrieving container information...")
new_vm_data = pm.get_all_lxc(as_dict=True, vmid=vmdata['newid'])
if new_vm_data:
new_vm_data = new_vm_data[0]
lxc = Lxc().from_proxmox(**new_vm_data)
lxc.save()
task.add_entry(f"Container retrieved: {lxc.name} ({lxc.vmid})")
# Update container resources if needed
changed = False
changes = []
if self.disksize > lxc.disksize:
lxc.disksize = self.disksize
changes.append(f"disk: {lxc.disksize}GB")
changed = True
if self.memory != lxc.memory:
lxc.memory = self.memory
changes.append(f"memory: {lxc.memory}MB")
changed = True
if self.cores != lxc.cores:
lxc.cores = self.cores
changes.append(f"cores: {lxc.cores}")
changed = True
if changed:
lxc.save()
task.add_entry(f"Updated container resources: {', '.join(changes)}")
# Step 3: Create DHCP Lease (Mikrotik query)
task.add_entry("Creating Mikrotik DHCP lease...")
leaseargs = {
'mac_address': lxc.hwaddr,
'address': self.network.get_next_ip,
'comment': f'Container - {self.hostname}',
'dynamic': False,
}
lease = IPDHCPLease.objects.create(**leaseargs)
task.add_entry(f"DHCP lease created: {lease.address}{lease.mac_address}")
# Step 4: Create DNS Entry (Mikrotik query)
task.add_entry("Creating Mikrotik DNS entry...")
dnsargs = {}
if self.as_regexp:
dnsargs['regexp'] = self.regexp
task.add_entry(f"Using DNS regexp: {self.regexp}")
else:
dnsargs['name'] = self.hostname
task.add_entry(f"Using DNS hostname: {self.hostname}")
dns = DNSStatic.objects.create(address=lease.address, **dnsargs)
task.add_entry(f"DNS entry created: {dns.name or dns.regexp}{dns.address}")
# Step 5: Create DevContainer link
task.add_entry("Creating container management entry...")
devcontainer = DevContainer.objects.create(
lxc=lxc,
lease=lease,
dns=dns,
)
task.add_entry("Container management entry created")
# Step 6: Start Container
task.add_entry("Starting container...")
lxc.start()
task.add_entry("Container started successfully!")
self.status = 'success'
task.status = 'completed'
task.save()
else:
error_msg = f"Error retrieving container data for VM {vmdata['newid']}"
task.add_entry(error_msg)
self.status = 'error'
else:
self.status = 'error'
task.add_entry("Container creation failed")
except Exception as e:
task.add_entry(f"Exception during container creation: {str(e)}")
self.status = 'error'
logging.exception(e)
# Cleanup on error
task.add_entry("Cleaning up failed resources...")
cleanup_errors = []
try:
if isinstance(lease, IPDHCPLease):
lease.delete(task=task)
task.add_entry("Cleaned up DHCP lease")
except Exception as a:
cleanup_errors.append(f"DHCP lease: {a}")
try:
if isinstance(dns, DNSStatic):
dns.delete(task=task)
task.add_entry("Cleaned up DNS entry")
except Exception as s:
cleanup_errors.append(f"DNS entry: {s}")
try:
if isinstance(lxc, Lxc):
lxc.delete(task=task)
task.add_entry("Cleaned up container")
except Exception as l:
cleanup_errors.append(f"Container: {l}")
if cleanup_errors:
task.add_entry(f"Cleanup errors: {', '.join(cleanup_errors)}")
else:
task.add_entry("Cleanup completed")
task.status = 'error'
task.save()
raise e
finally:
# Keep task logs for debugging, cleanup CloneContainer
task.unset_as_current()
self.delete()
@property
def proxmox_data(self):
data = {
# https://pve.proxmox.com/pve-docs/api-viewer/#/nodes/{node}/lxc/{vmid}/clone
# if template, the just a post to lxc - empty route
'route': f'{self.vm.vmid}/clone' if self.vm else '',
'vm': {
'hostname': self.hostname,
'cores': self.cores,
'cpus': self.cores,
'memory': self.memory,
'net0': self.vm.net0 if self.vm else self.template.net0,
'disksize': self.disksize,
'description': f'Container - {self.hostname} ',
'newid': Proxmox().next_vmid,
'template': self.template.volid if self.template else None,
},
'lease': {
'comment': f'Container - {self.hostname}'
},
'dns': {
# this is changed if regexp
'name': self.hostname,
'comment': f'Container - {self.hostname}'
},
}
if regexp := self.regexp:
data['dns'].pop('name', None)
data['dns']['regexp'] = regexp
return data
def clean(self):
super().clean()
assert self.vm or self.template, "Either vm or template must be set"
"""
class CloneVM(CloneAbstract):
vm = models.ForeignKey(VM, on_delete=models.CASCADE, related_name='clone_lxc')
"""
class DevContainer(BaseModel, SearchableMixin, TaskAwareModelMixin):
lxc = models.OneToOneField('proxmox.Lxc', on_delete=models.SET_NULL, related_name='devcontainer_lxc', null=True)
lease = models.OneToOneField(IPDHCPLease, on_delete=models.SET_NULL, related_name='devcontainer_lease', null=True)
dns = models.OneToOneField(DNSStatic, on_delete=models.SET_NULL, related_name='devcontainer_dns', null=True)
statuscache_data = models.JSONField(default=dict, editable=False)
@property
def statuscache(self):
if self.statuscache_data and self.statuscache_data['last_sync'] > time.time() - 300:
logging.debug(f"Return cached status for {self.name} - {self.statuscache_data['last_sync']}")
return self.statuscache_data
else:
return self.sync_statuscache()
def sync_statuscache(self):
logging.debug(f"No cached status for {self.name} - sync from proxmox")
try:
self.lxc.sync_from_proxmox()
except Exception as e:
logging.error(e)
try:
with Proxmox() as pm:
status_data = pm.lxc_get(f"{self.lxc.vmid}/status/current")
except Exception as e:
logging.error(e)
return None
try:
self.lease.sync_from_router()
except Exception as e:
logging.error(e)
try:
status_data['lease_status'] = self.lease.status
status_data['last_sync'] = time.time()
self.statuscache_data = status_data
super().save(update_fields=['statuscache_data'])
return status_data
except Exception as e:
logging.error(e)
return None
def set_statuscache_value(self, key, value):
cachedata = self.statuscache
cachedata[key]= value
with open(self.status_cachefile, 'w') as f:
json.dump(cachedata, f)
return self
@classmethod
def term_filter(cls, search_string):
q = (
# Q(lxc__comment__icontains=search_string) |
Q(lxc__hostname__icontains=search_string) |
Q(lease__comment__icontains=search_string) |
Q(dns__name__icontains=search_string) |
Q(dns__regexp__icontains=search_string) |
Q(lease__address__icontains=search_string)
)
if str(search_string).isdigit():
q |= Q(lxc__vmid=search_string)
return q
@property
def name(self):
try:
return self.lxc.name
except Exception as e:
logging.error(e)
return f"{self.internal_id} - {self.lease} - {self.dns} - {self.lxc}"
@property
def hostname(self):
try:
return self.dns.name or self.dns.regexp
except Exception as e:
logging.error(e)
return f"{self.pk} hostname"
@property
def hwaddr(self):
try:
return self.lxc.hwaddr
except Exception as e:
logging.error(e)
return f"{self.pk} hwaddr"
@property
def address(self):
try:
return self.lease.address
except Exception as e:
logging.error(e)
return f"{self.pk} address"
@property
def status(self):
try:
return self.lease.status
except Exception as e:
logging.error(e)
return f"{self.pk} status"
def save(self, *args, **kwargs):
if self.dns.address != self.lease.address:
raise ValueError(f"{self.dns.address} != {self.lease.address}")
if self.lease.mac_address.upper() != self.lxc.hwaddr.upper():
logging.error(f"{self.lease.mac_address} != {self.lxc.hwaddr} - try to change it")
self.lease.mac_address = self.lxc.hwaddr
self.lease.save()
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} ({self.address})"
@receiver(models.signals.pre_delete, sender=DevContainer)
def before_delete_devcontainer(sender, instance, **kwargs):
from tasklogger.models import TaskFactory
task = TaskFactory()
if instance.dns:
instance.dns.delete(task=task)
if instance.lease:
instance.lease.delete(task=task)
if instance.lxc:
instance.lxc.delete(task=task)

3
manager/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
manager/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from manager import views
app_name = 'manager'
urlpatterns = [
path('resync-all/', views.sync_all, name='sync_all'),
path('test/mikrotik/', views.test_mikrotik, name='test_mikrotik'),
]

46
manager/views.py Normal file
View File

@@ -0,0 +1,46 @@
import json
import logging
from django.contrib import messages
from django.db.transaction import atomic
from django.forms import model_to_dict
from django.shortcuts import HttpResponse, redirect
from django_auth_ldap.backend import LDAPBackend
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django_proxmox_mikrotik.configs import MikrotikConfig
from lib.decorators import force_write
from manager.models import CloneContainer
@atomic
@force_write
def test_mikrotik(request):
from mikrotik.models import DNSStatic
dns = DNSStatic.objects.create(name='test.test1', address='192.168.1.254')
logging.debug(model_to_dict(dns))
response = dns.sync_to_router()
return HttpResponse(json.dumps([model_to_dict(dns), response], indent=4, default=lambda x: str(x)),
content_type="application/json")
def sync_all(request):
"""TODO - just get the user and check on superuser
settings via groups does not work at the moment"""
from manager.admin import resync_all
backend = LDAPBackend()
user = backend.authenticate(request, request.user.username, request.POST.get('password'))
if user and user.is_superuser:
resync_all()
messages.success(request, 'Sync all initiated')
else:
messages.error(request, 'Not authorized')
return redirect('frontend:dashboard')

0
mikrotik/__init__.py Normal file
View File

56
mikrotik/admin.py Normal file
View File

@@ -0,0 +1,56 @@
from django.contrib import admin
from lib.decorators import readonly
from lib.mikrotik import sync_from_mikrotik as _sync_from_mikrotik
from mikrotik.models import DNSStatic, IPAddress, IPDHCPLease
@readonly
def sync_dns_static_from_mikrotik(*args, **kwargs):
_sync_from_mikrotik(DNSStatic)
@readonly
def sync_ipaddress_from_mikrotik(*args, **kwargs):
_sync_from_mikrotik(IPAddress)
@readonly
def sync_ipdhcplease_from_mikrotik(*args, **kwargs):
_sync_from_mikrotik(IPDHCPLease)
def sync_to_mikrotik(_, request, queryset):
for i in queryset:
i.sync_to_router()
class MikrotikModelMixinAdmin(admin.ModelAdmin):
def change_view(self, request, object_id, form_url='', extra_context=None):
self.get_object(request, object_id).sync_from_router()
return super().change_view(
request, object_id, form_url, extra_context=extra_context or {'show_save_and_continue': False}
)
@admin.register(DNSStatic)
class DNSStaticAdmin(admin.ModelAdmin):
actions = [sync_dns_static_from_mikrotik, sync_to_mikrotik]
list_display = ('name', 'regexp', 'address','disabled', 'comment', 'id')
list_filter = ('disabled',('id', admin.EmptyFieldListFilter), ('name', admin.EmptyFieldListFilter), ('address', admin.EmptyFieldListFilter), ('regexp', admin.EmptyFieldListFilter),)
search_fields = ('name', 'address','regexp')
@admin.register(IPAddress)
class IPAddressAdmin(admin.ModelAdmin):
actions = [sync_ipaddress_from_mikrotik, sync_to_mikrotik]
list_display = ('address','network', 'disabled', 'comment', 'id')
list_filter = ('disabled',('id', admin.EmptyFieldListFilter), ('address', admin.EmptyFieldListFilter), ('network', admin.EmptyFieldListFilter),)
search_fields = ('address', 'network', 'comment')
@admin.register(IPDHCPLease)
class IPDHCPLeaseAdmin(admin.ModelAdmin):
actions = [sync_ipdhcplease_from_mikrotik, sync_to_mikrotik]
list_display = ('address', 'mac_address', 'hostname', 'disabled', 'dynamic', 'comment', 'status', 'id')
list_filter = ('disabled', 'dynamic', 'status', ('id', admin.EmptyFieldListFilter), ('address', admin.EmptyFieldListFilter), ('mac_address', admin.EmptyFieldListFilter), ('hostname', admin.EmptyFieldListFilter), ('comment', admin.EmptyFieldListFilter),)
search_fields = ('address', 'mac_address', 'hostname')

6
mikrotik/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MikrotikConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'mikrotik'

View File

@@ -0,0 +1,83 @@
# Generated by Django 5.2.4 on 2025-07-07 11:19
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DNSStatic',
fields=[
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
('disabled', models.CharField(blank=True, choices=[('false', 'No'), ('true', 'Yes')], default='false', max_length=10, null=True)),
('comment', models.CharField(blank=True, default='', max_length=150, null=True)),
('id', models.CharField(blank=True, default='', max_length=150, null=True)),
('name', models.CharField(blank=True, max_length=150, null=True, unique=True)),
('address', models.CharField(blank=True, default='', max_length=150, null=True)),
('ttl', models.CharField(blank=True, default='', max_length=150, null=True)),
('dynamic', models.CharField(blank=True, default='', editable=False, max_length=150, null=True)),
('regexp', models.CharField(blank=True, max_length=150, null=True, unique=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='IPAddress',
fields=[
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
('disabled', models.CharField(blank=True, choices=[('false', 'No'), ('true', 'Yes')], default='false', max_length=10, null=True)),
('comment', models.CharField(blank=True, default='', max_length=150, null=True)),
('id', models.CharField(blank=True, default='', max_length=150, null=True)),
('address', models.CharField(blank=True, default='', max_length=150, null=True)),
('network', models.CharField(blank=True, default='', max_length=150, null=True)),
('interface', models.CharField(blank=True, default='', max_length=150, null=True)),
('actual_interface', models.CharField(blank=True, default='', max_length=150, null=True)),
('invalid', models.CharField(blank=True, default='', max_length=150, null=True)),
('dynamic', models.CharField(blank=True, default='', editable=False, max_length=150, null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='IPDHCPLease',
fields=[
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
('disabled', models.CharField(blank=True, choices=[('false', 'No'), ('true', 'Yes')], default='false', max_length=10, null=True)),
('comment', models.CharField(blank=True, default='', max_length=150, null=True)),
('id', models.CharField(blank=True, default='', max_length=150, null=True)),
('address', models.CharField(blank=True, default='', max_length=150, null=True)),
('mac_address', models.CharField(blank=True, default='', max_length=150, null=True)),
('client_id', models.CharField(blank=True, default='', max_length=150, null=True)),
('hostname', models.CharField(blank=True, default='', max_length=150, null=True)),
('valid_until', models.CharField(blank=True, default='', max_length=150, null=True)),
('dynamic', models.CharField(blank=True, default='', editable=False, max_length=150, null=True)),
('blocked', models.CharField(blank=True, default='', max_length=150, null=True)),
('active_client_id', models.CharField(blank=True, default='', max_length=150, null=True)),
('active_mac_address', models.CharField(blank=True, default='', max_length=150, null=True)),
('expires_after', models.CharField(blank=True, default='', max_length=150, null=True)),
('age', models.CharField(blank=True, default='', max_length=150, null=True)),
('active_server', models.CharField(blank=True, default='', max_length=150, null=True)),
('active_address', models.CharField(blank=True, default='', max_length=150, null=True)),
('host_name', models.CharField(blank=True, default='', max_length=150, null=True)),
('radius', models.CharField(blank=True, default='', max_length=150, null=True)),
('last_seen', models.CharField(blank=True, default='', max_length=150, null=True)),
('dhcp_option', models.CharField(blank=True, default='', max_length=150, null=True)),
('status', models.CharField(blank=True, default='', max_length=150, null=True)),
('server', models.CharField(blank=True, default='', max_length=150, null=True)),
('address_lists', models.CharField(blank=True, default='', max_length=150, null=True)),
('always_broadcast', models.CharField(blank=True, default='', max_length=150, null=True)),
('lease_time', models.CharField(blank=True, default='', max_length=150, null=True)),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-07-08 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mikrotik', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='dnsstatic',
name='comment',
field=models.TextField(blank=True, default='', null=True),
),
migrations.AlterField(
model_name='ipaddress',
name='comment',
field=models.TextField(blank=True, default='', null=True),
),
migrations.AlterField(
model_name='ipdhcplease',
name='comment',
field=models.TextField(blank=True, default='', null=True),
),
]

View File

215
mikrotik/models.py Normal file
View File

@@ -0,0 +1,215 @@
import logging
from functools import cached_property
from django.db import models
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from lib.db import TaskAwareModelMixin
from lib.decorators import skip_signal
from lib.mikrotik import MikrotikModelMixin, is_local_ip
class DNSStatic(MikrotikModelMixin, TaskAwareModelMixin):
id = models.CharField(max_length=150, null=True, blank=True, default='')
name = models.CharField(max_length=150, null=True, blank=True, unique=True, )
address = models.CharField(max_length=150, null=True, blank=True, default='')
ttl = models.CharField(max_length=150, null=True, blank=True, default='')
dynamic = models.CharField(max_length=150, null=True, blank=True, default='', editable=False)
regexp = models.CharField(max_length=150, null=True, blank=True, unique=True, )
# lease = models.ForeignKey('IPDHCPLease', on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='dns_statics', name)
@property
def unique_on_router(self):
return ['address', ('name', 'regexp')]
@property
def get_self_params(self):
return {f:getattr(self, f) for f in ['address', 'name', 'regexp', 'comment'] if getattr(self, f, None)}
def __str__(self):
return f'{self.address} - {self.name or self.regexp}'
@property
def no_mikrotik_props(self):
return super().no_mikrotik_props + ['ttl']
@property
def router_base(self):
return '/ip/dns/static'
@cached_property
def router_list(self):
ret = []
for r in self.router_get_all:
if is_local_ip(r['address']):
ret.append(r)
self.response = ret
return self.response
class IPAddress(MikrotikModelMixin, TaskAwareModelMixin):
id = models.CharField(max_length=150, null=True, blank=True, default='')
address = models.CharField(max_length=150, null=True, blank=True, default='')
network = models.CharField(max_length=150, null=True, blank=True, default='')
interface = models.CharField(max_length=150, null=True, blank=True, default='')
actual_interface = models.CharField(max_length=150, null=True, blank=True, default='')
invalid = models.CharField(max_length=150, null=True, blank=True, default='')
dynamic = models.CharField(max_length=150, null=True, blank=True, default='', editable=False)
@property
def unique_on_router(self):
return ['address', 'network', 'interface']
def __str__(self):
return f'{self.address} - {self.comment}'
@property
def router_base(self):
return '/ip/address'
@property
def no_mikrotik_props(self):
return super().no_mikrotik_props + [
'actual_interface',
'invalid',
]
@property
def get_next_ip(self):
# sync_from_mikrotik(IPDHCPLease)
net = '.'.join(self.network.split('.')[:-1]) + '.'
used = sorted(IPDHCPLease.objects.filter(address__startswith=net).values_list('address', flat=True),
reverse=True)
next32 = 1
if used:
next32 += int(used[0].split('.')[-1])
return f'{net}{next32}'
@cached_property
def router_list(self):
""" { "id": "*XXX", "address": "X.X.X.X/XX",
"network": "X.X.X.X", "interface": "<>",
"actual-interface": "", "invalid": "false",
"dynamic": "true", "disabled": "false" } """
ret = []
for r in self.router_get_all:
if all([
r['invalid'] == 'false',
r['disabled'] == 'false',
'container' in r.get('comment', '').lower(),
is_local_ip(r['address']),
r['address'].endswith('.1/24'),
]):
ret.append(r)
return ret
class IPDHCPLease(MikrotikModelMixin, TaskAwareModelMixin):
id = models.CharField(max_length=150, null=True, blank=True, default='')
address = models.CharField(max_length=150, null=True, blank=True, default='')
mac_address = models.CharField(max_length=150, null=True, blank=True, default='')
client_id = models.CharField(max_length=150, null=True, blank=True, default='')
hostname = models.CharField(max_length=150, null=True, blank=True, default='')
valid_until = models.CharField(max_length=150, null=True, blank=True, default='')
dynamic = models.CharField(max_length=150, null=True, blank=True, default='', editable=False)
blocked = models.CharField(max_length=150, null=True, blank=True, default='')
active_client_id = models.CharField(max_length=150, null=True, blank=True, default='')
active_mac_address = models.CharField(max_length=150, null=True, blank=True, default='')
expires_after = models.CharField(max_length=150, null=True, blank=True, default='')
age = models.CharField(max_length=150, null=True, blank=True, default='')
active_server = models.CharField(max_length=150, null=True, blank=True, default='')
active_address = models.CharField(max_length=150, null=True, blank=True, default='')
host_name = models.CharField(max_length=150, null=True, blank=True, default='')
radius = models.CharField(max_length=150, null=True, blank=True, default='')
last_seen = models.CharField(max_length=150, null=True, blank=True, default='')
dhcp_option = models.CharField(max_length=150, null=True, blank=True, default='')
status = models.CharField(max_length=150, null=True, blank=True, default='')
server = models.CharField(max_length=150, null=True, blank=True, default='')
address_lists = models.CharField(max_length=150, null=True, blank=True, default='')
always_broadcast = models.CharField(max_length=150, null=True, blank=True, default='')
lease_time = models.CharField(max_length=150, null=True, blank=True, default='')
@property
def unique_on_router(self):
return ['address', 'nac_address']
@property
def mikrotik_send_params(self):
return {
'address': self.address,
'mac-address': self.mac_address,
'comment': self.comment,
}
def save(self, *args, **kwargs):
self.mac_address = self.mac_address.upper() if self.mac_address else ''
super().save(*args, **kwargs)
@property
def network_24(self):
return '.'.join(self.address.split('.')[:-1]) + '.'
def __str__(self):
return f'{self.address} - {self.mac_address} - {self.status}'
@property
def router_base(self):
return '/ip/dhcp-server/lease'
@cached_property
def router_list(self):
return self.router_get_all
@property
def no_mikrotik_props(self):
return super().no_mikrotik_props + [
'active_client_id',
'active_mac_address',
'expires_after',
'age',
'active_server',
'active_address',
'hostname',
'host_name',
'radius',
'last_seen',
'status',
'lease_time',
'server',
]
for cl in [IPAddress, IPDHCPLease, DNSStatic]:
@receiver(pre_save, sender=cl)
def send_before_save(sender, instance: MikrotikModelMixin, **kwargs):
if instance._state.adding:
logging.info(f'Created {instance} via pre_save event - do nothing')
return instance
try:
response = instance.sync_to_router()
logging.debug(f'Update {instance} to router with {response}')
except Exception as e:
logging.error(f'Error while updating {instance} to router: {e}')
return instance
@receiver(post_save, sender=cl)
@skip_signal(signaltype='post_save')
def send_after_save(sender, instance: MikrotikModelMixin, created, **kwargs):
if created:
logging.info(f'Created {instance} via post_save event - sync once')
instance.sync_to_router()
return instance
# @skip_signal(signaltype='pre_delete')
@receiver(pre_delete, sender=cl)
def send_before_delete(sender, instance, **kwargs):
try:
response = instance.delete_from_router()
logging.debug(f'Deleted {instance} from router with {response}')
except Exception as e:
logging.error(f'Error while deleting {instance} from router: {e}')
return instance

3
mikrotik/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
mikrotik/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

0
proxmox/__init__.py Normal file
View File

79
proxmox/admin.py Normal file
View File

@@ -0,0 +1,79 @@
import logging
from django.contrib import admin
from django.db.models import Q
from lib.decorators import readonly
from lib.proxmox import Proxmox
from proxmox.models import Lxc, LxcTemplate
@readonly
def sync_all_lxc_templates_from_proxmox(*args, **kwargs):
pm = Proxmox()
for storage in pm.storage_get(enabled=1):
logging.debug(f'Syncing Templates from storage {storage["storage"]}')
storage_name = storage['storage']
for tmpl in pm.storage(f'{storage_name}/content').get(content='vztmpl'):
try:
logging.debug(f'Updating {tmpl["volid"]}')
template = LxcTemplate.objects.get(volid=tmpl['volid'])
except LxcTemplate.DoesNotExist:
logging.debug(f'Fail - Creating {tmpl["volid"]}')
template = LxcTemplate.objects.create(volid=tmpl['volid'])
template.write(**tmpl)
@readonly
def sync_all_lxc_from_proxmox(*args, **kwargs):
from lib.proxmox import Proxmox
pm = Proxmox()
existing_vms = []
for lxc_data in pm.get_all_lxc(as_dict=True, **kwargs):
vmid = lxc_data.pop('vmid')
try:
lx: Lxc = Lxc.objects.get(vmid=vmid)
logging.info(f'Updating {vmid}')
except Lxc.DoesNotExist:
logging.info(f'Creating {vmid}')
lx = Lxc.objects.create(**{'vmid': vmid})
lx.from_proxmox(**lxc_data)
existing_vms.append(vmid)
to_delete = Lxc.objects.filter(~Q(vmid__in=existing_vms))
if to_delete:
logging.info(f'Deleting {[d.vmid for d in to_delete]}')
to_delete.delete()
sync_all_lxc_from_proxmox.short_description = 'Sync all LXC from Proxmox'
@admin.register(Lxc)
class LxcAdmin(admin.ModelAdmin):
actions = [sync_all_lxc_from_proxmox, 'sync_selected_from_proxmox']
search_fields = ('name', 'vmid', 'hostname', 'hwaddr')
list_display = ('name', 'vmid', 'hwaddr', 'disksize', 'memory', 'cpus', 'status')
list_filter = ('status', 'disksize',)
def get_readonly_fields(self, request, obj=None):
if obj:
return [k.name for k in obj._meta.fields if
k.name not in ['name', 'cores', 'hwaddr', 'size', 'cpus', 'memory', 'description', 'hostname']]
return self.readonly_fields
@admin.action(description='Sync selected from proxmox')
@readonly
def sync_selected_from_proxmox(self, request, queryset):
for lx in queryset:
lx.sync_from_proxmox()
@admin.register(LxcTemplate)
class LxcTemplateAdmin(admin.ModelAdmin):
actions = [sync_all_lxc_templates_from_proxmox]
search_fields = ('volid',)
list_display = ('volid', 'human_size', 'content')

6
proxmox/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ProxmoxConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'proxmox'

View File

@@ -0,0 +1,80 @@
# Generated by Django 5.2.4 on 2025-07-07 11:19
import django.core.validators
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Lxc',
fields=[
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
('vmid', models.IntegerField(blank=True, null=True, unique=True)),
('name', models.CharField(blank=True, default='', max_length=150, null=True, verbose_name='Container Name')),
('hostname', models.CharField(blank=True, default='', max_length=150, null=True)),
('hwaddr', models.CharField(blank=True, default=uuid.uuid4, max_length=150, null=True, unique=True)),
('size', models.CharField(blank=True, max_length=4, null=True)),
('cores', models.IntegerField(blank=True, default=1, null=True)),
('memory', models.IntegerField(default=512, help_text='in MB', validators=[django.core.validators.MinValueValidator(128)])),
('disksize', models.IntegerField(default=12, help_text='in GB', validators=[django.core.validators.MinValueValidator(8)])),
('swap', models.IntegerField(blank=True, null=True)),
('description', models.TextField(blank=True, default='', null=True)),
('cpus', models.IntegerField(blank=True, default=1, null=True, validators=[django.core.validators.MinValueValidator(1)])),
('uptime', models.IntegerField(blank=True, null=True)),
('maxswap', models.IntegerField(blank=True, null=True)),
('cpu', models.IntegerField(blank=True, null=True)),
('disk', models.IntegerField(blank=True, null=True)),
('netout', models.IntegerField(blank=True, null=True)),
('diskwrite', models.IntegerField(blank=True, null=True)),
('diskread', models.IntegerField(blank=True, null=True)),
('pid', models.IntegerField(blank=True, null=True)),
('maxdisk', models.IntegerField(blank=True, null=True)),
('mem', models.IntegerField(blank=True, null=True)),
('maxmem', models.IntegerField(blank=True, null=True)),
('netin', models.IntegerField(blank=True, null=True)),
('status', models.CharField(blank=True, default='', max_length=150, null=True)),
('type', models.CharField(blank=True, default='', max_length=150, null=True)),
('onboot', models.CharField(blank=True, default='', max_length=150, null=True)),
('nameserver', models.CharField(blank=True, default='', max_length=150, null=True)),
('digest', models.CharField(blank=True, default='', max_length=150, null=True)),
('rootfs', models.CharField(blank=True, default='', max_length=150, null=True)),
('arch', models.CharField(blank=True, default='', max_length=150, null=True)),
('ostype', models.CharField(blank=True, default='', max_length=150, null=True)),
('net0', models.CharField(blank=True, default='', max_length=150, null=True)),
('features', models.CharField(blank=True, default='', max_length=250, null=True)),
('snaptime', models.CharField(blank=True, default='', max_length=150, null=True)),
('parent', models.CharField(blank=True, default='', max_length=150, null=True)),
('tags', models.CharField(blank=True, default='', max_length=250, null=True)),
('console', models.CharField(blank=True, default='', max_length=150, null=True)),
('tty', models.CharField(blank=True, default='', max_length=150, null=True)),
('searchdomain', models.CharField(blank=True, default='', max_length=150, null=True)),
('unprivileged', models.CharField(blank=True, default='', max_length=10, null=True)),
('lxc', models.CharField(blank=True, default='', max_length=10, null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='LxcTemplate',
fields=[
('internal_id', models.BigAutoField(primary_key=True, serialize=False)),
('volid', models.CharField(max_length=150, unique=True)),
('ctime', models.IntegerField(default=0)),
('size', models.IntegerField(default=0)),
('format', models.CharField(max_length=10)),
('content', models.CharField(default='tgz', max_length=10)),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.4 on 2025-07-08 11:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('proxmox', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='lxc',
name='disk',
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='diskread',
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='diskwrite',
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='netout',
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='uptime',
field=models.CharField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-07-08 11:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('proxmox', '0002_alter_lxc_disk_alter_lxc_diskread_and_more'),
]
operations = [
migrations.AlterField(
model_name='lxc',
name='disksize',
field=models.IntegerField(default=12, help_text='in GB'),
),
migrations.AlterField(
model_name='lxc',
name='memory',
field=models.IntegerField(default=512, help_text='in MB'),
),
]

View File

@@ -0,0 +1,74 @@
# Generated by Django 5.2.4 on 2025-07-08 11:29
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('proxmox', '0003_alter_lxc_disksize_alter_lxc_memory'),
]
operations = [
migrations.AlterField(
model_name='lxc',
name='cores',
field=models.BigIntegerField(blank=True, default=1, null=True),
),
migrations.AlterField(
model_name='lxc',
name='cpu',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='cpus',
field=models.BigIntegerField(blank=True, default=1, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AlterField(
model_name='lxc',
name='disksize',
field=models.BigIntegerField(default=12, help_text='in GB'),
),
migrations.AlterField(
model_name='lxc',
name='maxdisk',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='maxmem',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='maxswap',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='mem',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='memory',
field=models.BigIntegerField(default=512, help_text='in MB'),
),
migrations.AlterField(
model_name='lxc',
name='netin',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='pid',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='lxc',
name='swap',
field=models.BigIntegerField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-07-08 14:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('proxmox', '0004_alter_lxc_cores_alter_lxc_cpu_alter_lxc_cpus_and_more'),
]
operations = [
migrations.AlterField(
model_name='lxc',
name='lxc',
field=models.CharField(blank=True, default='', max_length=150, null=True),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.4 on 2025-07-21 11:03
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('proxmox', '0005_alter_lxc_lxc'),
]
operations = [
migrations.AddField(
model_name='lxc',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='lxc',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='lxctemplate',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='lxctemplate',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-07-22 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('proxmox', '0006_lxc_created_at_lxc_updated_at_lxctemplate_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='lxctemplate',
name='net0',
field=models.CharField(blank=True, default='name=eth0,bridge=vmbr0,firewall=0,ip=dhcp', max_length=150, null=True),
),
migrations.AlterField(
model_name='lxc',
name='net0',
field=models.CharField(blank=True, default='name=eth0,bridge=vmbr0,firewall=0,ip=dhcp', max_length=150, null=True),
),
]

View File

354
proxmox/models.py Normal file
View File

@@ -0,0 +1,354 @@
import logging
import re
from uuid import uuid4
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django_proxmox_mikrotik.configs import ProxmoxConfig
from lib import human_size
from lib.db import BaseModel, TaskAwareModelMixin
from lib.decorators import skip_signal
from lib.proxmox import Proxmox, get_comma_separated_values
from manager.models import DevContainer
no_int_re = re.compile(r'[^\d]')
class ProxmoxAbstractModel(BaseModel, TaskAwareModelMixin):
class Meta:
abstract = True
@property
def change_map(self) -> dict:
raise NotImplemented('@property change_map" not implemented')
@property
def comma_separated_values(self) -> list:
"""A list of fields that are comma separated values (X=1,Y=2,...
"""
return []
@property
def no_csv_overwrite(self) -> list:
"""Maybe a value within a csv options
has the same name than a non-csv option.
Ommit those"""
return []
@property
def non_proxmox_values(self) -> list:
"""A list of fields that are not in proxmox, but in db
Ao so not sync directly (maybe as csv)"""
return []
def to_proxmox(self):
raise NotImplemented('Not implemented')
def sync_from_proxmox(self):
raise NotImplemented('Not implemented')
@property
def csv_field_map(self):
return {}
@property
def int_fields(self):
return []
def from_proxmox(self, **kwargs):
"""Sets the values in DB
Extracts csv Values into self, if given"""
logging.debug(f"Got {kwargs} from proxmox")
kwkeys = list(kwargs.keys())
params = {}
for k in kwkeys:
logging.info(f"Process {k} with value {kwargs[k]}")
v = kwargs[k]
if not hasattr(self, k):
continue
if k in self.comma_separated_values:
_vals = get_comma_separated_values(v)
logging.debug(f"Got {_vals}")
for _k, _v in _vals.items():
if _k in self.no_csv_overwrite:
logging.debug(f"{_k} is in no_csv_overwrite - omitting")
continue
csvkey, selfkey, csvfun = self.csv_field_map.get(k, (_k, _k, lambda x: x))
if hasattr(self, selfkey):
if csvfun:
_v = csvfun(_v)
elif selfkey in self.int_fields:
_v = int(no_int_re.sub('', _v) or 0)
logging.debug(f"Update {selfkey} to {_v}")
params[selfkey] = _v
else:
logging.info(f"{selfkey} not found in {type(self)}")
else:
if isinstance(getattr(self, k), models.IntegerField):
v = no_int_re.sub('', _v) or 0
params[k] = v
logging.debug(f"No CSValues for {self}")
return self.write(**params)
class Lxc(ProxmoxAbstractModel):
@property
def int_fields(self):
return ['cores', 'memory', 'disksize']
@property
def comma_separated_values(self):
return ['net0', 'rootfs', 'features']
@property
def no_csv_overwrite(self):
return ['name']
@property
def non_proxmox_values(self):
return ['vmid', 'hwaddr', 'disksize']
@property
def csv_field_map(self):
return {
'rootfs': ('size', 'disksize', lambda x: int(no_int_re.sub('', x) or 0)),
}
@property
def proxmox_console_url(self):
return (
f"https://{ProxmoxConfig.HOST}:8006/"
f"?console=lxc&xtermjs=1&vmid={self.vmid}"
f"&vmname={self.hostname}&node={ProxmoxConfig.NODE}&cmd="
)
def delete(self, task=None):
with Proxmox() as pm:
try:
if task:
task.wrap_proxmox_function(self.stop)
else:
self.stop()
except Exception as e:
if 'running' in str(e):
logging.info(f"Could not stop {self.vmid} - {e}")
else:
raise
try:
if task:
task.wrap_proxmox_function(pm.lxc_delete, self.vmid, force=1, purge=1)
else:
result = pm.lxc_delete(self.vmid, force=1, purge=1)
logging.info(f"Deleted {self.vmid} from proxmox - {result}")
except Exception as e:
logging.error(f"Could not delete {self.vmid} from proxmox", e)
finally:
super().delete()
vmid = models.IntegerField(null=True, blank=True, unique=True)
name = models.CharField(max_length=150, null=True, blank=True, default='', verbose_name='Container Name')
hostname = models.CharField(max_length=150, null=True, blank=True, default='', )
# This one is from net0
hwaddr = models.CharField(max_length=150, null=True, blank=True, default=uuid4, unique=True)
# this comes from rootfs
size = models.CharField(max_length=4, null=True, blank=True)
cores = models.BigIntegerField(null=True, blank=True, default=1)
memory = models.BigIntegerField(default=512, help_text='in MB', ) # validators=[MinValueValidator(128)])
disksize = models.BigIntegerField(default=12, help_text='in GB', ) # validators=[MinValueValidator(8)])
swap = models.BigIntegerField(null=True, blank=True)
description = models.TextField(null=True, blank=True, default='')
cpus = models.BigIntegerField(null=True, blank=True, validators=[MinValueValidator(1)], default=1)
uptime = models.CharField(null=True, blank=True)
maxswap = models.BigIntegerField(null=True, blank=True)
cpu = models.BigIntegerField(null=True, blank=True)
disk = models.CharField(null=True, blank=True)
netout = models.CharField(null=True, blank=True)
diskwrite = models.CharField(null=True, blank=True)
diskread = models.CharField(null=True, blank=True)
pid = models.BigIntegerField(null=True, blank=True)
maxdisk = models.BigIntegerField(null=True, blank=True)
mem = models.BigIntegerField(null=True, blank=True)
maxmem = models.BigIntegerField(null=True, blank=True)
netin = models.BigIntegerField(null=True, blank=True)
status = models.CharField(max_length=150, null=True, blank=True, default='')
type = models.CharField(max_length=150, null=True, blank=True, default='')
onboot = models.CharField(max_length=150, null=True, blank=True, default='')
nameserver = models.CharField(max_length=150, null=True, blank=True, default='')
digest = models.CharField(max_length=150, null=True, blank=True, default='')
rootfs = models.CharField(max_length=150, null=True, blank=True, default='')
arch = models.CharField(max_length=150, null=True, blank=True, default='')
ostype = models.CharField(max_length=150, null=True, blank=True, default='')
net0 = models.CharField(max_length=150, null=True, blank=True, default='name=eth0,bridge=vmbr0,firewall=0,ip=dhcp')
features = models.CharField(max_length=250, null=True, blank=True, default='')
snaptime = models.CharField(max_length=150, null=True, blank=True, default='')
parent = models.CharField(max_length=150, null=True, blank=True, default='')
tags = models.CharField(max_length=250, null=True, blank=True, default='')
console = models.CharField(max_length=150, null=True, blank=True, default='')
tty = models.CharField(max_length=150, null=True, blank=True, default='')
searchdomain = models.CharField(max_length=150, null=True, blank=True, default='')
unprivileged = models.CharField(max_length=10, null=True, blank=True, default='')
lxc = models.CharField(max_length=150, null=True, blank=True, default='')
def __str__(self):
return f'{self.name} ({self.vmid})'
def sync_from_proxmox(self):
pm = Proxmox()
try:
data = pm.lxc_get(f'{self.vmid}/config')
logging.debug(f"Got raw data '{data}' from proxmox")
if not data:
logging.warning(f'Could not find {self.vmid} in proxmox - deleting from local database!')
return self.delete()
self.from_proxmox(**data)
super().save()
except Exception as e:
logging.error(f"Could not get config for {self.vmid} - {e}")
return self
@property
def _ch_disksize(self):
if self._old_values['disksize'] == self.disksize:
return False
if self.disksize > 100:
logging.warning(f'disksize is > 100')
return False
if self.disksize < self._old_values['disksize']:
logging.warning(f"Can not shrink disksize")
return False
return True
def change_disksize(self):
"""Just to disable some magick at the moment"""
if self._ch_disksize:
route = f'{self.vmid}/resize'
args = {
'disk': 'rootfs',
'size': f'{self.disksize}G',
}
with Proxmox() as pm:
try:
result = pm.lxc_put(route, **args)
logging.info(f"Changed disksize for container {self.vmid} to {self.disksize}G - {result}")
logs = pm.get_task_status(taskhash=result)
logging.debug(f"Tasklog for {self.vmid} is {logs}")
except Exception as e:
logging.error(f"Could not change disksize for container {self.vmid} to {self.disksize}G - {e}")
return self
def change_memory(self):
"""Just to disable some magick at the moment"""
if self._old_values['memory'] != self.memory:
route = f'{self.vmid}/config'
args = {
'memory': self.memory,
}
with Proxmox() as pm:
try:
result = pm.lxc_put(route, **args)
logging.info(f"Changed memory for container {self.vmid} to {self.memory}MB - {result}")
except Exception as e:
self.memory = self._old_values['memory']
super().save(update_fields=['memory'])
logging.error(f"Could not change memory for container {self.vmid} to {self.memory}MB - {e}")
return self
def change_cores(self):
if self._old_values['cores'] != self.cores:
logging.debug(f"Changing cores for {self.vmid} to {self.cores}")
route = f'{self.vmid}/config'
args = {
'cores': self.cores,
}
with Proxmox() as pm:
try:
result = pm.lxc_put(route, **args)
logging.info(f"Changed cores for container {self.vmid} to {self.cores} - {result}")
return result
except Exception as e:
self.cores = self._old_values['cores']
super().save(update_fields=['cores'])
logging.error(f"Could not change cores for container {self.vmid} to {self.cores} - {e}")
return None
def start(self):
startresult = self._start_stop_actions('start')
if startresult:
self.status = 'running'
return startresult
def stop(self):
stopresult = self._start_stop_actions('stop')
if stopresult:
self.status = 'stopped'
return stopresult
def reboot(self):
rebootresult = self._start_stop_actions('reboot')
if rebootresult:
self.status = 'running'
return rebootresult
def shutdown(self):
shresult = self._start_stop_actions('shutdown')
if shresult:
self.status = 'stopped'
return shresult
def _start_stop_actions(self, action):
assert action in ('start', 'stop', 'shutdown', 'reboot')
with Proxmox() as pm:
try:
result = pm.lxc_post(f'{self.vmid}/status/{action}')
logging.info(f"{action}ed {self.vmid} - {result}")
return result
except Exception as e:
logging.error(f"Could not {action} {self.vmid} - {e}")
return False
def save(self, *args, **kwargs):
logging.debug(f"Saving {self}")
super().save(*args, **kwargs)
def to_proxmox(self):
self.change_disksize()
self.change_memory()
self.change_cores()
@receiver(pre_save, sender=Lxc)
@skip_signal()
def pre_save_lxc(sender, instance: Lxc, **kwargs):
instance.hwaddr = str(instance.hwaddr or uuid4()).upper()
if instance._state.adding:
logging.info(f'Created {instance} via post_save event - do nothing, must be done via CloneContainer')
return
instance.change_disksize()
instance.change_memory()
instance.change_cores()
class LxcTemplate(ProxmoxAbstractModel, TaskAwareModelMixin):
volid = models.CharField(max_length=150, unique=True)
ctime = models.IntegerField(default=0)
size = models.IntegerField(default=0)
format = models.CharField(max_length=10)
content = models.CharField(max_length=10, default='tgz')
net0 = models.CharField(max_length=150, null=True, blank=True, default='name=eth0,bridge=vmbr0,firewall=0,ip=dhcp')
is_default_template = models.BooleanField(default=False,
help_text='If true, this template will be used when creating new containers as default, or preselected')
def __str__(self):
return self.volid.split('/')[-1]
def __repr__(self):
return self.volid
@property
def human_size(self):
return human_size(self.size)

3
proxmox/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
proxmox/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

9
requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
RouterOS-api
proxmoxer
python-dotenv
psycopg2
django-middleware-global-request
django-auth-ldap
python-ldap
requests
markdown

43
src/deploy.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Django Proxmox Mikrotik Deploy Script
set -e
PROJECT_PATH="/usr/share/django-proxmox-mikrotik"
VENV_PATH="$PROJECT_PATH/venv"
echo "Starting Django Proxmox Mikrotik deployment..."
# Activate virtual environment
source $VENV_PATH/bin/activate
# Change to project directory
cd $PROJECT_PATH
# Install/update dependencies
echo "Installing dependencies..."
pip install -r requirements.txt
# Run database migrations
echo "Running database migrations..."
python manage.py migrate
# Collect static files
echo "Collecting static files..."
python manage.py collectstatic --noinput
# Create static directory if it doesn't exist
mkdir -p /usr/share/django-proxmox-mikrotik/static
# Set proper permissions
echo "Setting permissions..."
chown -R www-data:www-data $PROJECT_PATH
chmod -R 755 $PROJECT_PATH
# Restart services
echo "Restarting services..."
systemctl restart django-proxmox-mikrotik
systemctl restart nginx
echo "Deployment completed successfully!"

View File

@@ -0,0 +1,77 @@
server {
listen 80;
server_name localhost;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Static files
location /static/ {
alias /usr/share/django-proxmox-mikrotik/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Media files
location /media/ {
alias /usr/share/django-proxmox-mikrotik/media/;
expires 30d;
add_header Cache-Control "public";
}
# API endpoints for live status updates
location /manager/task/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Extended timeouts for long-running tasks
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Disable caching for API responses
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# AJAX endpoints
location /frontend/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Standard timeouts for API calls
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Disable caching for API responses
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Main application
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeout settings
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Logs
access_log /var/log/nginx/django-proxmox-mikrotik_access.log;
error_log /var/log/nginx/django-proxmox-mikrotik_error.log;
}

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Django Proxmox Mikrotik Application
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/usr/share/django-proxmox-mikrotik
ExecStart=/usr/share/django-proxmox-mikrotik/venv/bin/python manage.py runserver 0.0.0.0:8000
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

0
tasklogger/__init__.py Normal file
View File

3
tasklogger/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
tasklogger/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TaskloggerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tasklogger'

Some files were not shown because too many files have changed in this diff Show More