first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-14 11:39:46 +02:00
commit 3da6c2832c
125 changed files with 30111 additions and 0 deletions

View File

@@ -0,0 +1,350 @@
import difflib
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from fastapi import HTTPException
from sqlalchemy.orm import Session, joinedload
from app.models.backup import Backup
from app.models.router import Router
from app.models.user import User
from app.services.file_service import compute_checksum, ensure_data_dir
from app.services.log_service import log_service
from app.services.notification_service import notification_service
from app.services.router_service import router_service
from app.services.settings_service import settings_service
class BackupService:
def _device_label(self, router: Router) -> str:
platform = 'SwitchOS' if router.device_type == 'switchos' else 'RouterOS'
return f'{platform} device {router.name}'
def _router_for_user(self, db: Session, user: User, router_id: int) -> Router:
router = db.query(Router).filter(Router.id == router_id, Router.owner_id == user.id).first()
if not router:
raise HTTPException(status_code=404, detail='Device not found')
return router
def _serialize_backup(self, backup: Backup):
file_path = Path(backup.file_path)
return {
'id': backup.id,
'router_id': backup.router_id,
'router_name': backup.router.name if backup.router else None,
'device_type': backup.router.device_type if backup.router else 'routeros',
'file_path': backup.file_path,
'file_name': backup.file_name,
'backup_type': backup.backup_type,
'checksum': backup.checksum,
'file_size': file_path.stat().st_size if file_path.exists() else None,
'created_at': backup.created_at,
}
def _build_structured_diff(self, left_lines: list[str], right_lines: list[str]):
matcher = difflib.SequenceMatcher(a=left_lines, b=right_lines)
rows = []
stats = {'added': 0, 'removed': 0, 'modified': 0, 'context': 0}
left_number = 1
right_number = 1
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
for left_text, right_text in zip(left_lines[i1:i2], right_lines[j1:j2]):
rows.append(
{
'type': 'context',
'left_number': left_number,
'right_number': right_number,
'left_text': left_text,
'right_text': right_text,
}
)
stats['context'] += 1
left_number += 1
right_number += 1
continue
if tag == 'delete':
for left_text in left_lines[i1:i2]:
rows.append(
{
'type': 'removed',
'left_number': left_number,
'right_number': None,
'left_text': left_text,
'right_text': '',
}
)
stats['removed'] += 1
left_number += 1
continue
if tag == 'insert':
for right_text in right_lines[j1:j2]:
rows.append(
{
'type': 'added',
'left_number': None,
'right_number': right_number,
'left_text': '',
'right_text': right_text,
}
)
stats['added'] += 1
right_number += 1
continue
block_left = left_lines[i1:i2]
block_right = right_lines[j1:j2]
block_size = max(len(block_left), len(block_right))
for index in range(block_size):
left_text = block_left[index] if index < len(block_left) else ''
right_text = block_right[index] if index < len(block_right) else ''
row_type = 'modified'
if left_text and not right_text:
row_type = 'removed'
stats['removed'] += 1
elif right_text and not left_text:
row_type = 'added'
stats['added'] += 1
else:
stats['modified'] += 1
rows.append(
{
'type': row_type,
'left_number': left_number if left_text else None,
'right_number': right_number if right_text else None,
'left_text': left_text,
'right_text': right_text,
}
)
if left_text:
left_number += 1
if right_text:
right_number += 1
return rows, stats
def get_backup_for_user(self, db: Session, user: User, backup_id: int) -> Backup:
backup = (
db.query(Backup)
.options(joinedload(Backup.router))
.join(Router)
.filter(Backup.id == backup_id, Router.owner_id == user.id)
.first()
)
if not backup:
raise HTTPException(status_code=404, detail='Backup not found')
return backup
def list_backups(
self,
db: Session,
user: User,
search: str | None = None,
backup_type: str | None = None,
router_id: int | None = None,
created_on: date | None = None,
sort_by: str = 'created_at',
order: str = 'desc',
):
query = db.query(Backup).options(joinedload(Backup.router)).join(Router).filter(Router.owner_id == user.id)
if search:
query = query.filter(
Backup.file_name.ilike(f'%{search}%')
| Router.name.ilike(f'%{search}%')
| Router.host.ilike(f'%{search}%')
)
if backup_type:
query = query.filter(Backup.backup_type == backup_type)
if router_id:
query = query.filter(Backup.router_id == router_id)
if created_on:
day_start = datetime.combine(created_on, time.min)
next_day = day_start + timedelta(days=1)
query = query.filter(Backup.created_at >= day_start, Backup.created_at < next_day)
sort_map = {
'created_at': Backup.created_at,
'file_name': Backup.file_name,
'backup_type': Backup.backup_type,
'router_name': Router.name,
}
sort_column = sort_map.get(sort_by, Backup.created_at)
query = query.order_by(sort_column.asc() if order == 'asc' else sort_column.desc())
return [self._serialize_backup(backup) for backup in query.all()]
def list_router_backups(self, db: Session, user: User, router_id: int):
router = self._router_for_user(db, user, router_id)
backups = (
db.query(Backup)
.options(joinedload(Backup.router))
.filter(Backup.router_id == router.id)
.order_by(Backup.created_at.desc())
.all()
)
return [self._serialize_backup(backup) for backup in backups]
def export_router(self, db: Session, user: User, router_id: int) -> Backup:
router = self._router_for_user(db, user, router_id)
if router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='Text export is available only for RouterOS devices')
settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
name = f'{router.name}_{router.id}_{stamp}.rsc'
file_path = ensure_data_dir() / name
try:
content = router_service.export(router, settings.global_ssh_key)
file_path.write_text(content, encoding='utf-8')
backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='export')
db.add(backup)
db.commit()
db.refresh(backup)
log_service.add(db, f'Export OK for device {router.name}')
notification_service.notify(settings, f'Export {router.name} OK', True)
return backup
except HTTPException:
raise
except Exception as exc:
notification_service.notify(settings, f'Export {router.name} FAIL: {exc}', False)
log_service.add(db, f'Export FAILED for device {router.name}: {exc}')
raise HTTPException(status_code=500, detail=str(exc)) from exc
def binary_backup(self, db: Session, user: User, router_id: int) -> Backup:
router = self._router_for_user(db, user, router_id)
settings = settings_service.get_or_create(db)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
base_name = f'{router.name}_{router.id}_{stamp}'
extension = '.swb' if router.device_type == 'switchos' else '.backup'
name = f'{base_name}{extension}'
file_path = ensure_data_dir() / name
try:
router_service.binary_backup(router, base_name, str(file_path), settings.global_ssh_key, settings)
checksum = compute_checksum(str(file_path))
backup = Backup(router_id=router.id, file_path=str(file_path), file_name=name, backup_type='binary', checksum=checksum)
db.add(backup)
db.commit()
db.refresh(backup)
log_service.add(db, f'Binary backup OK for {self._device_label(router)}')
notification_service.notify(settings, f'Backup {router.name} OK', True)
return backup
except HTTPException:
raise
except Exception as exc:
notification_service.notify(settings, f'Backup {router.name} FAIL: {exc}', False)
log_service.add(db, f'Binary backup FAILED for {self._device_label(router)}: {exc}')
raise HTTPException(status_code=500, detail=str(exc)) from exc
def upload_backup_to_router(self, db: Session, user: User, router_id: int, backup_id: int):
router = self._router_for_user(db, user, router_id)
if router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='Restore upload is available only for RouterOS devices')
backup = self.get_backup_for_user(db, user, backup_id)
if backup.backup_type != 'binary':
raise HTTPException(status_code=400, detail='Only binary backups can be uploaded')
if backup.router and backup.router.device_type != 'routeros':
raise HTTPException(status_code=400, detail='SwitchOS backup files cannot be restored over SSH upload')
checksum = compute_checksum(backup.file_path)
if backup.checksum and checksum != backup.checksum:
raise HTTPException(status_code=400, detail='Checksum mismatch')
settings = settings_service.get_or_create(db)
router_service.upload_backup(router, backup.file_path, settings.global_ssh_key)
log_service.add(db, f'Upload backup OK for device {router.name}')
def delete_backup(self, db: Session, user: User, backup_id: int, commit: bool = True):
backup = self.get_backup_for_user(db, user, backup_id)
path = Path(backup.file_path)
if path.exists():
path.unlink()
db.delete(backup)
if commit:
db.commit()
def diff_backups(self, db: Session, user: User, left_id: int, right_id: int):
left = self.get_backup_for_user(db, user, left_id)
right = self.get_backup_for_user(db, user, right_id)
if left.backup_type != 'export' or right.backup_type != 'export':
raise HTTPException(status_code=400, detail='Diff is supported only for export backups')
left_lines = Path(left.file_path).read_text(encoding='utf-8', errors='ignore').splitlines()
right_lines = Path(right.file_path).read_text(encoding='utf-8', errors='ignore').splitlines()
diff_lines = list(
difflib.unified_diff(left_lines, right_lines, fromfile=left.file_name, tofile=right.file_name, lineterm='')
)
diff_html = difflib.HtmlDiff(wrapcolumn=120).make_file(
left_lines,
right_lines,
fromdesc=left.file_name,
todesc=right.file_name,
context=True,
numlines=2,
)
structured_lines, stats = self._build_structured_diff(left_lines, right_lines)
return {
'left_backup_id': left_id,
'right_backup_id': right_id,
'left_file_name': left.file_name,
'right_file_name': right.file_name,
'diff_text': '\n'.join(diff_lines),
'diff_html': diff_html,
'stats': stats,
'lines': structured_lines,
}
def email_backup(self, db: Session, user: User, backup_id: int):
backup = self.get_backup_for_user(db, user, backup_id)
settings = settings_service.get_or_create(db)
platform_name = 'SwitchOS' if backup.router and backup.router.device_type == 'switchos' else 'RouterOS'
noun = 'Export' if backup.backup_type == 'export' else 'Backup'
subject = f'{platform_name} {noun}: {backup.file_name}'
body = f'Sending {backup.file_name} from device {backup.router.name}.'
notification_service.send_email(settings, subject, body, backup.file_path)
log_service.add(db, f'Email sent for backup {backup.file_name}')
def export_all(self, db: Session, user: User):
routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = []
for router in routers:
if (router.device_type or 'routeros').lower() != 'routeros':
result.append({
'router': router.name,
'status': 'skipped',
'message': 'Text export is available only for RouterOS devices',
})
continue
try:
backup = self.export_router(db, user, router.id)
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})
except Exception as exc:
result.append({'router': router.name, 'status': 'error', 'message': str(exc)})
return result
def binary_all(self, db: Session, user: User):
routers = db.query(Router).filter(Router.owner_id == user.id).all()
result = []
for router in routers:
try:
backup = self.binary_backup(db, user, router.id)
result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id})
except Exception as exc:
result.append({'router': router.name, 'status': 'error', 'message': str(exc)})
return result
def cleanup_old_backups(self, db: Session):
settings = settings_service.get_or_create(db)
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=settings.backup_retention_days)
old_backups = db.query(Backup).filter(Backup.created_at < cutoff).all()
deleted_count = 0
for backup in old_backups:
path = Path(backup.file_path)
if path.exists():
path.unlink()
db.delete(backup)
deleted_count += 1
db.commit()
log_service.add(db, f'Retention cleanup removed {deleted_count} backups older than {settings.backup_retention_days} days')
return deleted_count
backup_service = BackupService()

View File

@@ -0,0 +1,38 @@
import hashlib
import os
import shutil
from pathlib import Path
from app.core.config import settings
from app.schemas.dashboard import StorageStats
def compute_checksum(file_path: str) -> str:
sha256 = hashlib.sha256()
with open(file_path, "rb") as handle:
for chunk in iter(lambda: handle.read(4096), b""):
sha256.update(chunk)
return sha256.hexdigest()
def ensure_data_dir() -> Path:
return settings.data_path
def get_folder_size() -> int:
total = 0
for dirpath, _, filenames in os.walk(ensure_data_dir()):
for filename in filenames:
try:
total += os.path.getsize(Path(dirpath) / filename)
except OSError:
pass
return total
def get_storage_stats() -> StorageStats:
ensure_data_dir()
disk = shutil.disk_usage(ensure_data_dir())
folder_used = get_folder_size()
usage_percent = (folder_used / disk.total) * 100 if disk.total else 0
return StorageStats(total=disk.total, used=disk.used, free=disk.free, folder_used=folder_used, usage_percent=usage_percent)

View File

@@ -0,0 +1,24 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from app.models.log import OperationLog
class LogService:
def add(self, db: Session, message: str, commit: bool = True) -> None:
db.add(OperationLog(message=message))
if commit:
db.commit()
def delete_older_than(self, db: Session, days: int) -> int:
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
logs = db.query(OperationLog).filter(OperationLog.timestamp < cutoff).all()
count = len(logs)
for log in logs:
db.delete(log)
db.commit()
return count
log_service = LogService()

View File

@@ -0,0 +1,78 @@
import smtplib
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
import requests
from app.core.config import settings as app_settings
from app.models.settings import GlobalSettings
class NotificationService:
def send_pushover(self, token: str, user_key: str, message: str, title: str = "RouterOS Backup") -> bool:
response = requests.post(
"https://api.pushover.net/1/messages.json",
data={"token": token, "user": user_key, "message": message, "title": title},
timeout=15,
)
return response.ok
def send_email(self, settings: GlobalSettings, subject: str, body: str, attachment_path: str | None = None):
if not (settings.smtp_host and settings.smtp_login and settings.smtp_password):
raise ValueError("SMTP is not configured")
recipient = (settings.recipient_email or settings.smtp_login or "").strip()
if not recipient:
raise ValueError("Recipient email is empty")
msg = MIMEMultipart()
msg["From"] = settings.smtp_login
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))
if attachment_path:
attachment = Path(attachment_path)
with attachment.open("rb") as handle:
part = MIMEBase("application", "octet-stream")
part.set_payload(handle.read())
encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="{attachment.name}"')
msg.attach(part)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=app_settings.smtp_timeout_seconds) as server:
if app_settings.smtp_starttls:
server.starttls()
server.login(settings.smtp_login, settings.smtp_password)
server.sendmail(settings.smtp_login, [recipient], msg.as_string())
def notify(self, settings: GlobalSettings, message: str, success: bool):
if settings.notify_failures_only and success:
return
if settings.smtp_notifications_enabled:
try:
self.send_email(settings, "Mikrotik Backup System notification", message)
except Exception:
pass
if settings.pushover_token and settings.pushover_userkey:
try:
self.send_pushover(settings.pushover_token, settings.pushover_userkey, message)
except Exception:
pass
def send_test_email(self, settings: GlobalSettings):
self.send_email(settings, "Mikrotik Backup System test", "This is a test email from Mikrotik Backup System")
def send_test_pushover(self, settings: GlobalSettings):
if not (settings.pushover_token and settings.pushover_userkey):
raise ValueError("Pushover is not configured")
self.send_pushover(
settings.pushover_token,
settings.pushover_userkey,
"Test pushover from Mikrotik Backup System",
)
notification_service = NotificationService()

View File

@@ -0,0 +1,220 @@
from datetime import datetime
import io
from pathlib import Path
import paramiko
from sqlalchemy.orm import Session
from app.models.router import Router
from app.services.log_service import log_service
from app.services.swos_beta_service import swos_beta_service
class RouterService:
def _load_pkey(self, ssh_key_str: str):
key_str = (ssh_key_str or "").strip()
key_buffer = io.StringIO(key_str)
loaders = [
paramiko.RSAKey.from_private_key,
paramiko.Ed25519Key.from_private_key,
paramiko.ECDSAKey.from_private_key,
]
last_error = None
for loader in loaders:
key_buffer.seek(0)
try:
return loader(key_buffer)
except Exception as exc:
last_error = exc
raise ValueError("Failed to load SSH private key") from last_error
def _connect(self, router: Router, global_ssh_key: str | None = None):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
key_source = router.ssh_key.strip() if router.ssh_key and router.ssh_key.strip() else (global_ssh_key or "")
if key_source:
pkey = self._load_pkey(key_source)
client.connect(router.host, port=router.port, username=router.ssh_user, pkey=pkey, timeout=10)
else:
client.connect(
router.host,
port=router.port,
username=router.ssh_user,
password=router.ssh_password,
timeout=10,
allow_agent=False,
look_for_keys=False,
banner_timeout=10,
)
return client
def export(self, router: Router, global_ssh_key: str | None = None) -> str:
if router.device_type != 'routeros':
raise ValueError('Export tekstowy jest dostępny tylko dla RouterOS.')
client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command('/export')
output = stdout.read().decode('utf-8', errors='ignore')
client.close()
return output
def binary_backup(self, router: Router, backup_name: str, local_path: str, global_ssh_key: str | None = None, global_settings=None) -> str:
if router.device_type == 'switchos':
downloaded = swos_beta_service.download_backup_for_router(router, global_settings)
Path(local_path).write_bytes(downloaded.content)
return local_path
client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command(f'/system backup save name={backup_name}')
stdout.channel.recv_exit_status()
sftp = client.open_sftp()
remote_file = f'{backup_name}.backup'
sftp.get(remote_file, local_path)
try:
sftp.remove(remote_file)
except Exception:
pass
sftp.close()
client.close()
return local_path
def upload_backup(self, router: Router, local_backup_path: str, global_ssh_key: str | None = None):
if router.device_type != 'routeros':
raise ValueError('Przywracanie plików jest dostępne tylko dla RouterOS.')
client = self._connect(router, global_ssh_key)
sftp = client.open_sftp()
target_name = Path(local_backup_path).name
sftp.put(local_backup_path, target_name)
sftp.close()
client.close()
def _probe_routeros_connection(self, router: Router, global_ssh_key: str | None = None):
tested_at = datetime.utcnow()
try:
client = self._connect(router, global_ssh_key)
_, stdout, _ = client.exec_command('/system resource print without-paging')
resource_output = stdout.read().decode('utf-8', errors='ignore')
_, stdout, _ = client.exec_command('/system identity print')
identity_output = stdout.read().decode('utf-8', errors='ignore')
client.close()
model = 'Unknown'
uptime = 'Unknown'
hostname = 'Unknown'
version = 'Unknown'
for line in resource_output.splitlines():
if 'board-name' in line:
model = line.split(':', 1)[1].strip()
if 'uptime' in line:
uptime = line.split(':', 1)[1].strip()
if 'version' in line:
version = line.split(':', 1)[1].strip()
for line in identity_output.splitlines():
if 'name' in line:
hostname = line.split(':', 1)[1].strip()
return {
'success': True,
'tested_at': tested_at,
'model': model,
'uptime': uptime,
'hostname': hostname,
'version': version,
'error': None,
'transport': 'ssh',
'server': None,
'auth_mode': 'ssh',
'http_status': None,
'backup_available': None,
}
except Exception as exc:
return {
'success': False,
'tested_at': tested_at,
'model': 'Unknown',
'uptime': 'Unknown',
'hostname': router.name,
'version': None,
'error': str(exc),
'transport': 'ssh',
'server': None,
'auth_mode': 'ssh',
'http_status': None,
'backup_available': None,
}
def probe_connection(self, router: Router, global_ssh_key: str | None = None, global_settings=None):
if router.device_type == 'switchos':
return swos_beta_service.probe_router(router, global_settings)
return self._probe_routeros_connection(router, global_ssh_key)
def _store_connection_result(self, db: Session, router: Router, result: dict):
router.last_connection_status = result['success']
router.last_connection_tested_at = result['tested_at']
router.last_connection_error = result.get('error')
router.last_connection_hostname = result.get('hostname')
router.last_connection_model = result.get('model')
router.last_connection_version = result.get('version')
router.last_connection_uptime = result.get('uptime')
router.last_connection_transport = result.get('transport')
router.last_connection_server = result.get('server')
router.last_connection_auth_mode = result.get('auth_mode')
router.last_connection_http_status = result.get('http_status')
router.last_connection_backup_available = result.get('backup_available')
db.add(router)
db.commit()
db.refresh(router)
return result
def _device_label(self, router: Router) -> str:
platform = 'SwitchOS' if router.device_type == 'switchos' else 'RouterOS'
return f'{platform} device {router.name}'
def _build_connection_log_message(self, router: Router, result: dict) -> str:
device_label = self._device_label(router)
transport = result.get('transport') or 'unknown transport'
auth_mode = result.get('auth_mode')
http_status = result.get('http_status')
backup_available = result.get('backup_available')
hostname = result.get('hostname')
model = result.get('model')
version = result.get('version')
uptime = result.get('uptime')
server = result.get('server')
details = [f'via {transport}', f'target={router.host}:{router.port}']
if router.device_type == 'routeros':
if router.ssh_user:
details.append(f'user={router.ssh_user}')
if hostname:
details.append(f'hostname={hostname}')
if model and model != 'Unknown':
details.append(f'model={model}')
if version and version != 'Unknown':
details.append(f'version={version}')
if uptime and uptime != 'Unknown':
details.append(f'uptime={uptime}')
else:
if auth_mode:
details.append(f'auth={auth_mode}')
if http_status:
details.append(f'http={http_status}')
if server:
details.append(f'server={server}')
if backup_available is not None:
details.append(f'backup_available={"yes" if backup_available else "no"}')
if hostname:
details.append(f'hostname={hostname}')
detail_suffix = f' ({", ".join(details)})' if details else ''
if result.get('success'):
return f'Connection test OK for {device_label}{detail_suffix}'
error = result.get('error') or 'Unknown error'
return f'Connection test FAILED for {device_label}{detail_suffix}: {error}'
def test_connection(self, db: Session, router: Router, global_settings):
result = self.probe_connection(router, global_settings.global_ssh_key, global_settings)
stored_result = self._store_connection_result(db, router, result)
log_service.add(db, self._build_connection_log_message(router, stored_result))
return stored_result
router_service = RouterService()

View File

@@ -0,0 +1,249 @@
from __future__ import annotations
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from app.core.config import settings as app_settings
from app.core.cron_utils import CronValidationError, describe_cron_expression, parse_cron_expression, preview_next_runs
from app.db.session import SessionLocal
from app.models.router import Router
from app.services.backup_service import backup_service
from app.services.log_service import log_service
from app.services.router_service import router_service
from app.services.settings_service import settings_service
class SchedulerService:
def __init__(self):
self.scheduler = BackgroundScheduler(timezone=app_settings.timezone)
self.started = False
def start(self):
if self.started:
return
self.reschedule()
self.scheduler.start()
self.started = True
def shutdown(self):
if self.started:
self.scheduler.shutdown(wait=False)
self.started = False
def _parse_cron(self, expr: str):
return parse_cron_expression(expr, app_settings.timezone)
def validate_cron(self, expr: str):
return self._parse_cron(expr)
def _interval_next_runs(self, minutes: int, count: int = 3):
now = datetime.now()
return [now + timedelta(minutes=minutes * index) for index in range(1, count + 1)]
def scheduler_status(self):
with SessionLocal() as db:
settings = settings_service.get_or_create(db)
return {
'timezone': app_settings.timezone,
'running': self.started,
'jobs': [
self._describe_job(
key='auto_export',
label='settings.schedulerAutoExportLabel',
enabled=settings.enable_auto_export,
cron=settings.export_cron,
),
self._describe_job(
key='auto_binary',
label='settings.schedulerBinaryLabel',
enabled=bool(settings.binary_cron),
cron=settings.binary_cron,
),
self._describe_job(
key='retention',
label='settings.schedulerRetentionLabel',
enabled=bool(settings.retention_cron),
cron=settings.retention_cron,
),
self._describe_interval_job(
key='connection_probe',
label='settings.schedulerConnectionLabel',
minutes=settings.connection_test_interval_minutes,
),
{
'key': 'log_cleanup',
'label': 'settings.schedulerLogsLabel',
'enabled': True,
'cron': None,
'description': 'settings.schedulerLogsDescription',
'description_params': None,
'valid': True,
'next_runs': [],
'error': None,
},
],
}
def _describe_job(self, key: str, label: str, enabled: bool, cron: str | None):
cron = (cron or '').strip()
if not enabled or not cron:
return {
'key': key,
'label': label,
'enabled': False,
'cron': cron or None,
'description': 'settings.scheduleDisabledHint',
'description_params': None,
'valid': True,
'next_runs': [],
'error': None,
}
try:
next_runs = preview_next_runs(cron, app_settings.timezone, count=3)
return {
'key': key,
'label': label,
'enabled': True,
'cron': cron,
'description': 'settings.schedulerCronDescription',
'description_params': {'description': describe_cron_expression(cron)},
'valid': True,
'next_runs': next_runs,
'error': None,
}
except CronValidationError as exc:
return {
'key': key,
'label': label,
'enabled': True,
'cron': cron,
'description': 'settings.schedulerInvalidCron',
'description_params': None,
'valid': False,
'next_runs': [],
'error': str(exc),
}
def _describe_interval_job(self, key: str, label: str, minutes: int):
minutes = int(minutes or 0)
if minutes <= 0:
return {
'key': key,
'label': label,
'enabled': False,
'cron': None,
'description': 'settings.connectionTestsDisabledHint',
'description_params': None,
'valid': True,
'next_runs': [],
'error': None,
}
return {
'key': key,
'label': label,
'enabled': True,
'cron': None,
'description': 'settings.connectionTestsEverySummary',
'description_params': {'minutes': minutes},
'valid': True,
'next_runs': self._interval_next_runs(minutes),
'error': None,
}
def reschedule(self):
self.scheduler.remove_all_jobs()
with SessionLocal() as db:
settings = settings_service.get_or_create(db)
job_definitions = [
('auto_export', settings.enable_auto_export, settings.export_cron, self._run_auto_export, 'auto export'),
('auto_binary', bool(settings.binary_cron), settings.binary_cron, self._run_binary_backup, 'binary backup'),
('retention', bool(settings.retention_cron), settings.retention_cron, self._run_retention, 'retention cleanup'),
]
pending_logs: list[str] = []
for job_id, enabled, cron, callback, label in job_definitions:
cron = (cron or '').strip()
if not enabled or not cron:
continue
try:
trigger = self._parse_cron(cron)
self.scheduler.add_job(
callback,
trigger=trigger,
id=job_id,
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=300,
)
except Exception as exc:
pending_logs.append(f'Scheduler skipped invalid {label} cron ({cron}): {exc}')
if int(settings.connection_test_interval_minutes or 0) > 0:
self.scheduler.add_job(
self._run_connection_probes,
trigger='interval',
minutes=int(settings.connection_test_interval_minutes),
id='connection_probe',
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=300,
)
self.scheduler.add_job(
self._run_log_cleanup,
trigger='interval',
days=1,
id='log_cleanup',
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=300,
)
for message in pending_logs:
log_service.add(db, message, commit=False)
if pending_logs:
db.commit()
def _run_auto_export(self):
with SessionLocal() as db:
routers = db.query(Router).all()
for router in routers:
try:
backup_service.export_router(db, type('U', (), {'id': router.owner_id})(), router.id)
except Exception as exc:
log_service.add(db, f'Scheduled export failed for {router.name}: {exc}')
def _run_binary_backup(self):
with SessionLocal() as db:
routers = db.query(Router).all()
for router in routers:
try:
backup_service.binary_backup(db, type('U', (), {'id': router.owner_id})(), router.id)
except Exception as exc:
log_service.add(db, f'Scheduled binary backup failed for {router.name}: {exc}')
def _run_retention(self):
with SessionLocal() as db:
backup_service.cleanup_old_backups(db)
def _run_connection_probes(self):
with SessionLocal() as db:
settings = settings_service.get_or_create(db)
routers = db.query(Router).all()
for router in routers:
result = router_service.test_connection(db, router, settings.global_ssh_key)
if not result['success']:
log_service.add(db, f'Scheduled connection test failed for {router.name}: {result.get("error") or "Unknown error"}')
def _run_log_cleanup(self):
with SessionLocal() as db:
settings = settings_service.get_or_create(db)
deleted = log_service.delete_older_than(db, settings.log_retention_days)
log_service.add(db, f'Log retention cleanup removed {deleted} entries older than {settings.log_retention_days} days')
scheduler_service = SchedulerService()

View File

@@ -0,0 +1,32 @@
from sqlalchemy.orm import Session
from app.models.settings import GlobalSettings
from app.schemas.settings import SettingsUpdate
class SettingsService:
def get_or_create(self, db: Session) -> GlobalSettings:
settings = db.query(GlobalSettings).first()
if not settings:
settings = GlobalSettings()
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def update(self, db: Session, payload: SettingsUpdate) -> GlobalSettings:
settings = self.get_or_create(db)
data = payload.model_dump(exclude={'clear_global_ssh_key'})
for key, value in data.items():
if key == 'global_ssh_key' and value is None and not payload.clear_global_ssh_key:
continue
setattr(settings, key, value)
if payload.clear_global_ssh_key:
settings.global_ssh_key = None
db.add(settings)
db.commit()
db.refresh(settings)
return settings
settings_service = SettingsService()

View File

@@ -0,0 +1,174 @@
import re
from dataclasses import dataclass
from datetime import datetime
from urllib.parse import urlparse
import requests
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
from app.schemas.swos_beta import SwosBetaCredentials, SwosBetaProbeResponse
@dataclass
class DownloadedSwosBackup:
filename: str
content: bytes
content_type: str
auth_mode: str
base_url: str
class SwosBetaService:
timeout_seconds = 12
def probe(self, payload: SwosBetaCredentials) -> SwosBetaProbeResponse:
base_url = self._build_base_url(payload.host, payload.port)
response, auth_mode = self._request_with_fallback('GET', base_url, payload)
html = response.text if 'text' in (response.headers.get('content-type') or '').lower() else ''
title = self._extract_title(html)
backup_response, _ = self._request_with_fallback('GET', f'{base_url}/backup.swb', payload, allow_text_fallback=False)
backup_ok = backup_response.status_code == 200 and len(backup_response.content) > 0
return SwosBetaProbeResponse(
success=response.ok,
base_url=base_url,
status_code=response.status_code,
auth_mode=auth_mode,
page_title=title,
content_type=response.headers.get('content-type'),
server=response.headers.get('server'),
save_backup_visible='save backup' in html.lower(),
backup_endpoint_ok=backup_ok,
note='SwitchOS jest obsługiwany bezpośrednio w liście urządzeń.'
)
def probe_router(self, router, global_settings) -> dict:
payload = self.credentials_from_router(router, global_settings)
tested_at = datetime.utcnow()
try:
result = self.probe(payload)
return {
'success': result.success,
'tested_at': tested_at,
'model': 'SwitchOS',
'uptime': f'HTTP {result.status_code}',
'hostname': result.page_title or router.name,
'version': None,
'error': None,
'transport': 'http',
'server': result.server,
'auth_mode': result.auth_mode,
'http_status': str(result.status_code),
'backup_available': result.backup_endpoint_ok,
}
except Exception as exc:
return {
'success': False,
'tested_at': tested_at,
'model': 'SwitchOS',
'uptime': 'HTTP',
'hostname': router.name,
'version': None,
'error': str(exc),
'transport': 'http',
'server': None,
'auth_mode': None,
'http_status': None,
'backup_available': None,
}
def credentials_from_router(self, router, global_settings) -> SwosBetaCredentials:
username = (getattr(router, 'ssh_user', None) or '').strip() or (getattr(global_settings, 'default_switchos_username', None) or '').strip()
password = (getattr(router, 'ssh_password', None) or '').strip() or (getattr(global_settings, 'default_switchos_password', None) or '').strip()
if not username:
raise ValueError('Brak użytkownika SwitchOS. Ustaw dane w urządzeniu albo w ustawieniach globalnych.')
return SwosBetaCredentials(
host=router.host,
port=router.port or 80,
username=username,
password=password,
label=router.name,
)
def download_backup(self, payload: SwosBetaCredentials) -> DownloadedSwosBackup:
base_url = self._build_base_url(payload.host, payload.port)
response, auth_mode = self._request_with_fallback('GET', f'{base_url}/backup.swb', payload, allow_text_fallback=False)
if response.status_code != 200:
raise ValueError(f'Urządzenie zwróciło kod HTTP {response.status_code} dla /backup.swb.')
if not response.content:
raise ValueError('Urządzenie zwróciło pusty plik backupu.')
filename = self._build_filename(payload)
content_type = response.headers.get('content-type') or 'application/octet-stream'
return DownloadedSwosBackup(
filename=filename,
content=response.content,
content_type=content_type,
auth_mode=auth_mode,
base_url=base_url,
)
def download_backup_for_router(self, router, global_settings) -> DownloadedSwosBackup:
return self.download_backup(self.credentials_from_router(router, global_settings))
def _request_with_fallback(self, method: str, url: str, payload: SwosBetaCredentials, allow_text_fallback: bool = True):
attempts = []
auth_variants = [
('digest', HTTPDigestAuth(payload.username, payload.password)),
('basic', HTTPBasicAuth(payload.username, payload.password)),
]
if allow_text_fallback:
auth_variants.append(('none', None))
last_response = None
for label, auth in auth_variants:
try:
response = requests.request(
method,
url,
auth=auth,
timeout=self.timeout_seconds,
allow_redirects=True,
)
last_response = response
if response.status_code < 400:
return response, label
attempts.append(f'{label}:{response.status_code}')
except requests.RequestException as exc:
attempts.append(f'{label}:{exc.__class__.__name__}')
if last_response is not None:
raise ValueError(f'Nie udało się połączyć ze SwitchOS ({", ".join(attempts)}).')
raise ValueError('Nie udało się połączyć ze SwitchOS.')
def _build_base_url(self, host: str, port: int) -> str:
raw = host.strip()
parsed = urlparse(raw if '://' in raw else f'http://{raw}')
scheme = parsed.scheme or 'http'
if scheme not in {'http', 'https'}:
raise ValueError('Dozwolone są tylko adresy HTTP lub HTTPS.')
if not parsed.hostname:
raise ValueError('Nieprawidłowy adres hosta.')
resolved_port = parsed.port or port
base = f'{scheme}://{parsed.hostname}'
if resolved_port not in {80, 443} or (scheme == 'http' and resolved_port != 80) or (scheme == 'https' and resolved_port != 443):
base = f'{base}:{resolved_port}'
return base.rstrip('/')
def _extract_title(self, html: str) -> str | None:
if not html:
return None
match = re.search(r'<title>(.*?)</title>', html, flags=re.IGNORECASE | re.DOTALL)
if not match:
return None
return re.sub(r'\s+', ' ', match.group(1)).strip() or None
def _build_filename(self, payload: SwosBetaCredentials) -> str:
label = payload.label or payload.host
safe = re.sub(r'[^A-Za-z0-9._-]+', '-', label).strip('-') or 'switchos'
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
return f'{safe}-switchos-{timestamp}.swb'
swos_beta_service = SwosBetaService()