diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index a8b38f6..1d01bfa 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,14 +1,12 @@ from fastapi import APIRouter -from app.api.routes import auth, backups, dashboard, health, logs, routers, settings, swos_beta +from app.api.routes import auth, backups, dashboard, health, logs, routers, settings api_router = APIRouter() -api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) -api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) -api_router.include_router(routers.router, prefix="/routers", tags=["routers"]) -api_router.include_router(backups.router, prefix="/backups", tags=["backups"]) -api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) -api_router.include_router(logs.router, prefix="/logs", tags=["logs"]) -api_router.include_router(health.router, tags=["health"]) - -api_router.include_router(swos_beta.router, prefix='/swos-beta', tags=['swos-beta']) +api_router.include_router(auth.router, prefix='/auth', tags=['auth']) +api_router.include_router(dashboard.router, prefix='/dashboard', tags=['dashboard']) +api_router.include_router(routers.router, prefix='/routers', tags=['routers']) +api_router.include_router(backups.router, prefix='/backups', tags=['backups']) +api_router.include_router(settings.router, prefix='/settings', tags=['settings']) +api_router.include_router(logs.router, prefix='/logs', tags=['logs']) +api_router.include_router(health.router, tags=['health']) diff --git a/backend/app/api/routes/routers.py b/backend/app/api/routes/routers.py index 95421e7..fa58cfc 100644 --- a/backend/app/api/routes/routers.py +++ b/backend/app/api/routes/routers.py @@ -13,73 +13,108 @@ from app.services.settings_service import settings_service router = APIRouter() -def serialize_router(router: Router, global_ssh_key: str | None = None) -> RouterResponse: +def serialize_router(router: Router, global_settings) -> RouterResponse: has_router_key = bool((router.ssh_key or '').strip()) - has_global_key = bool((global_ssh_key or '').strip()) + has_global_key = bool((global_settings.global_ssh_key or '').strip()) + router_user = (router.ssh_user or '').strip() or None + router_password = (router.ssh_password or '').strip() or None + default_swos_user = (global_settings.default_switchos_username or '').strip() or None + default_swos_password = (global_settings.default_switchos_password or '').strip() or None + effective_username = router_user + uses_global_switchos_credentials = False + has_effective_password = bool(router_password) + + if router.device_type == 'switchos': + effective_username = router_user or default_swos_user + uses_global_switchos_credentials = bool( + (not router_user and default_swos_user) or (not router_password and default_swos_password) + ) + has_effective_password = bool(router_password or default_swos_password) + payload = RouterResponse.model_validate(router, from_attributes=True).model_dump() - payload['uses_global_ssh_key'] = has_global_key and not has_router_key - payload['has_effective_ssh_key'] = has_router_key or has_global_key + payload['effective_username'] = effective_username + payload['uses_global_ssh_key'] = router.device_type == 'routeros' and has_global_key and not has_router_key + payload['has_effective_ssh_key'] = router.device_type == 'routeros' and (has_router_key or has_global_key) + payload['uses_global_switchos_credentials'] = uses_global_switchos_credentials + payload['has_effective_password'] = has_effective_password + payload['supports_export'] = router.device_type == 'routeros' + payload['supports_restore_upload'] = router.device_type == 'routeros' return RouterResponse.model_validate(payload) -@router.get("", response_model=list[RouterResponse]) +@router.get('', response_model=list[RouterResponse]) def list_routers(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - settings = settings_service.get_or_create(db) + global_settings = settings_service.get_or_create(db) routers = db.query(Router).filter(Router.owner_id == current_user.id).order_by(Router.created_at.desc()).all() - return [serialize_router(router, settings.global_ssh_key) for router in routers] + return [serialize_router(router, global_settings) for router in routers] -@router.post("", response_model=RouterResponse) +@router.post('', response_model=RouterResponse) def create_router(payload: RouterCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): - router = Router(**payload.model_dump(), owner_id=current_user.id) + router_data = payload.model_dump() + if router_data.get('device_type') == 'switchos' and router_data.get('ssh_user') is None: + router_data['ssh_user'] = '' + router = Router(**router_data, owner_id=current_user.id) db.add(router) db.commit() db.refresh(router) - settings = settings_service.get_or_create(db) - return serialize_router(router, settings.global_ssh_key) + global_settings = settings_service.get_or_create(db) + return serialize_router(router, global_settings) -@router.get("/{router_id}", response_model=RouterResponse) +@router.get('/{router_id}', response_model=RouterResponse) def get_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") - settings = settings_service.get_or_create(db) - return serialize_router(router, settings.global_ssh_key) + raise HTTPException(status_code=404, detail='Device not found') + global_settings = settings_service.get_or_create(db) + return serialize_router(router, global_settings) -@router.put("/{router_id}", response_model=RouterResponse) +@router.put('/{router_id}', response_model=RouterResponse) def update_router(router_id: int, payload: RouterUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") - for key, value in payload.model_dump(exclude_unset=True).items(): + raise HTTPException(status_code=404, detail='Device not found') + changes = payload.model_dump(exclude_unset=True) + target_device_type = changes.get('device_type', router.device_type) + if target_device_type == 'switchos': + changes['ssh_key'] = None + if 'port' not in changes: + changes['port'] = 80 + if changes.get('ssh_user') is None: + changes['ssh_user'] = '' + elif target_device_type == 'routeros' and 'port' not in changes and router.device_type != 'routeros': + changes['port'] = 22 + if not changes.get('ssh_user'): + changes['ssh_user'] = router.ssh_user or 'admin' + for key, value in changes.items(): setattr(router, key, value) db.add(router) db.commit() db.refresh(router) - settings = settings_service.get_or_create(db) - return serialize_router(router, settings.global_ssh_key) + global_settings = settings_service.get_or_create(db) + return serialize_router(router, global_settings) -@router.delete("/{router_id}") +@router.delete('/{router_id}') def delete_router(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") + raise HTTPException(status_code=404, detail='Device not found') for backup in list(router.backups): path = Path(backup.file_path) if path.exists(): path.unlink() db.delete(router) db.commit() - return {"message": "Router deleted"} + return {'message': 'Device deleted'} -@router.get("/{router_id}/test-connection", response_model=RouterTestConnection) +@router.get('/{router_id}/test-connection', response_model=RouterTestConnection) def test_connection(router_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): router = db.query(Router).filter(Router.id == router_id, Router.owner_id == current_user.id).first() if not router: - raise HTTPException(status_code=404, detail="Router not found") - settings = settings_service.get_or_create(db) - return router_service.test_connection(db, router, settings.global_ssh_key) + raise HTTPException(status_code=404, detail='Device not found') + global_settings = settings_service.get_or_create(db) + return router_service.test_connection(db, router, global_settings) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 99ca8a3..0cf1d15 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -23,6 +23,9 @@ def serialize_settings(settings: GlobalSettings) -> SettingsResponse: payload = SettingsResponse.model_validate(settings, from_attributes=True).model_dump() payload['global_ssh_key'] = None payload['has_global_ssh_key'] = bool((settings.global_ssh_key or '').strip()) + payload['has_default_switchos_credentials'] = bool( + (settings.default_switchos_username or '').strip() or (settings.default_switchos_password or '').strip() + ) return SettingsResponse.model_validate(payload) diff --git a/backend/app/db/session.py b/backend/app/db/session.py index ea29271..843b738 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -42,10 +42,13 @@ def _run_lightweight_migrations() -> None: tables = set(inspect(engine).get_table_names()) if 'global_settings' in tables: _ensure_column('global_settings', 'connection_test_interval_minutes', 'INTEGER DEFAULT 0') + _ensure_column('global_settings', 'default_switchos_username', 'VARCHAR(120)') + _ensure_column('global_settings', 'default_switchos_password', 'VARCHAR(255)') if 'users' in tables: _ensure_column('users', 'preferred_language', "VARCHAR(8) DEFAULT 'pl' NOT NULL") _ensure_column('users', 'preferred_font', "VARCHAR(32) DEFAULT 'default' NOT NULL") if 'routers' in tables: + _ensure_column('routers', 'device_type', "VARCHAR(32) DEFAULT 'routeros' NOT NULL") _ensure_column('routers', 'last_connection_status', 'BOOLEAN') _ensure_column('routers', 'last_connection_tested_at', 'DATETIME') _ensure_column('routers', 'last_connection_error', 'TEXT') @@ -53,6 +56,11 @@ def _run_lightweight_migrations() -> None: _ensure_column('routers', 'last_connection_model', 'VARCHAR(255)') _ensure_column('routers', 'last_connection_version', 'VARCHAR(255)') _ensure_column('routers', 'last_connection_uptime', 'VARCHAR(255)') + _ensure_column('routers', 'last_connection_transport', 'VARCHAR(32)') + _ensure_column('routers', 'last_connection_server', 'VARCHAR(255)') + _ensure_column('routers', 'last_connection_auth_mode', 'VARCHAR(64)') + _ensure_column('routers', 'last_connection_http_status', 'VARCHAR(32)') + _ensure_column('routers', 'last_connection_backup_available', 'BOOLEAN') def init_db(): diff --git a/backend/app/models/router.py b/backend/app/models/router.py index ab44dc2..d998e5b 100644 --- a/backend/app/models/router.py +++ b/backend/app/models/router.py @@ -11,6 +11,7 @@ class Router(Base): id = Column(Integer, primary_key=True, index=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) name = Column(String(120), nullable=False) + device_type = Column(String(32), nullable=False, default="routeros") host = Column(String(255), nullable=False) port = Column(Integer, nullable=False, default=22) ssh_user = Column(String(120), nullable=False, default="admin") @@ -23,6 +24,11 @@ class Router(Base): last_connection_model = Column(String(255), nullable=True) last_connection_version = Column(String(255), nullable=True) last_connection_uptime = Column(String(255), nullable=True) + last_connection_transport = Column(String(32), nullable=True) + last_connection_server = Column(String(255), nullable=True) + last_connection_auth_mode = Column(String(64), nullable=True) + last_connection_http_status = Column(String(32), nullable=True) + last_connection_backup_available = Column(Boolean, nullable=True) created_at = Column(DateTime, server_default=func.now(), nullable=False) backups = relationship("Backup", back_populates="router", cascade="all, delete-orphan") diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 9a96dab..29e71fe 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -15,6 +15,8 @@ class GlobalSettings(Base): enable_auto_export = Column(Boolean, default=False) connection_test_interval_minutes = Column(Integer, default=0) global_ssh_key = Column(Text, nullable=True) + default_switchos_username = Column(String(120), nullable=True) + default_switchos_password = Column(String(255), nullable=True) pushover_token = Column(String(255), nullable=True) pushover_userkey = Column(String(255), nullable=True) notify_failures_only = Column(Boolean, default=True) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index 7240f75..85368ca 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -8,6 +8,7 @@ class BackupResponse(BaseModel): id: int router_id: int router_name: str | None = None + device_type: str = "routeros" file_path: str file_name: str backup_type: str diff --git a/backend/app/schemas/router.py b/backend/app/schemas/router.py index 4ded433..66c1809 100644 --- a/backend/app/schemas/router.py +++ b/backend/app/schemas/router.py @@ -1,16 +1,19 @@ import re from datetime import datetime +from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator ALLOWED_NAME_REGEX = re.compile(r"^[A-Za-z0-9_-]+$") +DeviceType = Literal["routeros", "switchos"] class RouterBase(BaseModel): name: str = Field(min_length=1, max_length=120) + device_type: DeviceType = "routeros" host: str = Field(min_length=1, max_length=255) - port: int = Field(default=22, ge=1, le=65535) - ssh_user: str = Field(default="admin", min_length=1, max_length=120) + port: int | None = Field(default=None, ge=1, le=65535) + ssh_user: str | None = Field(default=None, max_length=120) ssh_key: str | None = None ssh_password: str | None = None @@ -21,6 +24,23 @@ class RouterBase(BaseModel): raise ValueError("Only letters, digits, dashes and underscores are allowed") return value + @field_validator("host", "ssh_user", "ssh_key", "ssh_password", mode="before") + @classmethod + def normalize_text(cls, value: str | None) -> str | None: + normalized = (value or "").strip() + return normalized or None + + @model_validator(mode="after") + def apply_device_defaults(self): + if self.device_type == "routeros": + self.port = self.port or 22 + self.ssh_user = self.ssh_user or "admin" + return self + + self.port = self.port or 80 + self.ssh_key = None + return self + class RouterCreate(RouterBase): pass @@ -28,18 +48,30 @@ class RouterCreate(RouterBase): class RouterUpdate(BaseModel): name: str | None = None + device_type: DeviceType | None = None host: str | None = None port: int | None = Field(default=None, ge=1, le=65535) ssh_user: str | None = None ssh_key: str | None = None ssh_password: str | None = None + @field_validator("name", "host", "ssh_user", "ssh_key", "ssh_password", mode="before") + @classmethod + def normalize_text(cls, value: str | None) -> str | None: + normalized = (value or "").strip() + return normalized or None + class RouterResponse(RouterBase): id: int owner_id: int + effective_username: str | None = None uses_global_ssh_key: bool = False has_effective_ssh_key: bool = False + uses_global_switchos_credentials: bool = False + has_effective_password: bool = False + supports_export: bool = False + supports_restore_upload: bool = False last_connection_status: bool | None = None last_connection_tested_at: datetime | None = None last_connection_error: str | None = None @@ -47,6 +79,11 @@ class RouterResponse(RouterBase): last_connection_model: str | None = None last_connection_version: str | None = None last_connection_uptime: str | None = None + last_connection_transport: str | None = None + last_connection_server: str | None = None + last_connection_auth_mode: str | None = None + last_connection_http_status: str | None = None + last_connection_backup_available: bool | None = None created_at: datetime | None = None model_config = {"from_attributes": True} @@ -60,3 +97,8 @@ class RouterTestConnection(BaseModel): hostname: str version: str | None = None error: str | None = None + transport: str | None = None + server: str | None = None + auth_mode: str | None = None + http_status: str | None = None + backup_available: bool | None = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 36348b5..a2a9908 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -15,6 +15,8 @@ class SettingsBase(BaseModel): enable_auto_export: bool = False connection_test_interval_minutes: int = Field(default=0, ge=0, le=1440) global_ssh_key: str | None = None + default_switchos_username: str | None = None + default_switchos_password: str | None = None pushover_token: str | None = None pushover_userkey: str | None = None notify_failures_only: bool = True @@ -30,9 +32,9 @@ class SettingsBase(BaseModel): def normalize_cron(cls, value: str | None) -> str: return (value or '').strip() - @field_validator('global_ssh_key', mode='before') + @field_validator('global_ssh_key', 'default_switchos_username', 'default_switchos_password', mode='before') @classmethod - def normalize_key(cls, value: str | None) -> str | None: + def normalize_secret_text(cls, value: str | None) -> str | None: normalized = (value or '').strip() return normalized or None @@ -55,6 +57,7 @@ class SettingsUpdate(SettingsBase): class SettingsResponse(SettingsBase): id: int has_global_ssh_key: bool = False + has_default_switchos_credentials: bool = False model_config = {'from_attributes': True} diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 377f695..745ce7e 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -19,7 +19,7 @@ class BackupService: 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='Router not found') + raise HTTPException(status_code=404, detail='Device not found') return router def _serialize_backup(self, backup: Backup): @@ -28,6 +28,7 @@ class BackupService: '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, @@ -179,6 +180,8 @@ class BackupService: 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' @@ -190,12 +193,14 @@ class BackupService: db.add(backup) db.commit() db.refresh(backup) - log_service.add(db, f'Export OK for router {router.name}') + 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 router {router.name}: {exc}') + 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: @@ -203,34 +208,41 @@ class BackupService: 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}' - name = f'{base_name}.backup' + 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) + 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 router {router.name}') + log_service.add(db, f'Binary backup OK for device {router.name}') 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 router {router.name}: {exc}') + log_service.add(db, f'Binary backup FAILED for device {router.name}: {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 router {router.name}') + 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) @@ -274,9 +286,10 @@ class BackupService: 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'RouterOS {noun}: {backup.file_name}' - body = f'Sending {backup.file_name} from router {backup.router.name}.' + 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}') @@ -284,6 +297,9 @@ class BackupService: routers = db.query(Router).filter(Router.owner_id == user.id).all() result = [] for router in routers: + if router.device_type != 'routeros': + result.append({'router': router.name, 'status': 'skipped', 'message': 'SwitchOS devices do not support text export'}) + continue try: backup = self.export_router(db, user, router.id) result.append({'router': router.name, 'status': 'ok', 'backup_id': backup.id}) diff --git a/backend/app/services/router_service.py b/backend/app/services/router_service.py index 36eb450..80f1bc9 100644 --- a/backend/app/services/router_service.py +++ b/backend/app/services/router_service.py @@ -6,6 +6,7 @@ import paramiko from sqlalchemy.orm import Session from app.models.router import Router +from app.services.swos_beta_service import swos_beta_service class RouterService: @@ -47,18 +48,25 @@ class RouterService: 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") + _, 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) -> str: + 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, _ = 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" + remote_file = f'{backup_name}.backup' sftp.get(remote_file, local_path) try: sftp.remove(remote_file) @@ -69,6 +77,8 @@ class RouterService: 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 @@ -76,64 +86,84 @@ class RouterService: sftp.close() client.close() - def probe_connection(self, router: Router, global_ssh_key: str | None = None): + 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") + _, 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" + 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() + 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() + 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, + '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), + '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_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 test_connection(self, db: Session, router: Router, global_ssh_key: str | None = None): - result = self.probe_connection(router, global_ssh_key) + def test_connection(self, db: Session, router: Router, global_settings): + result = self.probe_connection(router, global_settings.global_ssh_key, global_settings) return self._store_connection_result(db, router, result) diff --git a/backend/app/services/swos_beta_service.py b/backend/app/services/swos_beta_service.py index 18cf704..97273c3 100644 --- a/backend/app/services/swos_beta_service.py +++ b/backend/app/services/swos_beta_service.py @@ -1,7 +1,6 @@ import re from dataclasses import dataclass from datetime import datetime -from pathlib import Path from urllib.parse import urlparse import requests @@ -41,7 +40,55 @@ class SwosBetaService: server=response.headers.get('server'), save_backup_visible='save backup' in html.lower(), backup_endpoint_ok=backup_ok, - note='Moduł działa osobno i nie zapisuje kopii do głównego repozytorium.' + 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: @@ -62,6 +109,9 @@ class SwosBetaService: 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 = [ @@ -89,8 +139,8 @@ class SwosBetaService: attempts.append(f'{label}:{exc.__class__.__name__}') if last_response is not None: - raise ValueError(f'Nie udało się połączyć ze SwOS ({", ".join(attempts)}).') - raise ValueError('Nie udało się połączyć ze SwOS.') + 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() @@ -118,7 +168,7 @@ class SwosBetaService: 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}-swos-{timestamp}.swb' + return f'{safe}-switchos-{timestamp}.swb' swos_beta_service = SwosBetaService() diff --git a/backend/tests/test_swos_beta.py b/backend/tests/test_swos_beta.py index 4f858e8..dd09bb4 100644 --- a/backend/tests/test_swos_beta.py +++ b/backend/tests/test_swos_beta.py @@ -1,74 +1,151 @@ +from pathlib import Path + from fastapi.testclient import TestClient from app.main import app -from app.schemas.swos_beta import SwosBetaProbeResponse -def _login(client: TestClient) -> str: +def _login(client: TestClient) -> tuple[str, dict[str, str]]: response = client.post('/api/auth/login', data={'username': 'admin', 'password': 'admin'}) - return response.json()['access_token'] + token = response.json()['access_token'] + return token, {'Authorization': f'Bearer {token}'} -def test_swos_probe_endpoint(monkeypatch, tmp_path): - monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_probe.db"}') - monkeypatch.setenv('DATA_DIR', str(tmp_path / 'data')) - monkeypatch.setenv('SECRET_KEY', 'test-secret') - monkeypatch.setenv('DEFAULT_ADMIN_USERNAME', 'admin') - monkeypatch.setenv('DEFAULT_ADMIN_PASSWORD', 'admin') - - from app.api.routes import swos_beta - - monkeypatch.setattr( - swos_beta.swos_beta_service, - 'probe', - lambda payload: SwosBetaProbeResponse( - success=True, - base_url='http://192.168.88.1', - status_code=200, - auth_mode='digest', - page_title='SwOS', - content_type='text/html', - server='MikroTik', - save_backup_visible=True, - backup_endpoint_ok=True, - note='beta', - ), - ) +def test_switchos_list_marks_global_credentials_usage(monkeypatch, tmp_path): + from app.api.routes import settings as settings_route with TestClient(app) as client: - token = _login(client) - response = client.post( - '/api/swos-beta/probe', - json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''}, - headers={'Authorization': f'Bearer {token}'}, + _, headers = _login(client) + settings_response = client.put( + '/api/settings', + json={ + 'backup_retention_days': 7, + 'log_retention_days': 7, + 'export_cron': '', + 'binary_cron': '', + 'retention_cron': '', + 'enable_auto_export': False, + 'connection_test_interval_minutes': 0, + 'global_ssh_key': None, + 'default_switchos_username': 'sw-admin', + 'default_switchos_password': 'sw-pass', + 'pushover_token': None, + 'pushover_userkey': None, + 'notify_failures_only': True, + 'smtp_host': None, + 'smtp_port': 587, + 'smtp_login': None, + 'smtp_password': None, + 'smtp_notifications_enabled': False, + 'recipient_email': None, + 'clear_global_ssh_key': False, + }, + headers=headers, ) - assert response.status_code == 200 - assert response.json()['backup_endpoint_ok'] is True + assert settings_response.status_code == 200 + assert settings_response.json()['has_default_switchos_credentials'] is True + + create_response = client.post( + '/api/routers', + json={ + 'name': 'switch01', + 'device_type': 'switchos', + 'host': '192.168.88.2', + 'port': 80, + 'ssh_user': '', + 'ssh_password': '', + 'ssh_key': None, + }, + headers=headers, + ) + assert create_response.status_code == 200 + + list_response = client.get('/api/routers', headers=headers) + assert list_response.status_code == 200 + payload = next(item for item in list_response.json() if item['name'] == 'switch01') + assert payload['device_type'] == 'switchos' + assert payload['uses_global_switchos_credentials'] is True + assert payload['effective_username'] == 'sw-admin' -def test_swos_download_endpoint(monkeypatch, tmp_path): - monkeypatch.setenv('DATABASE_URL', f'sqlite:///{tmp_path / "swos_download.db"}') - monkeypatch.setenv('DATA_DIR', str(tmp_path / 'data')) - monkeypatch.setenv('SECRET_KEY', 'test-secret') - monkeypatch.setenv('DEFAULT_ADMIN_USERNAME', 'admin') - monkeypatch.setenv('DEFAULT_ADMIN_PASSWORD', 'admin') - - from app.api.routes import swos_beta - - class FakeBackup: - filename = 'switch.swb' - content = b'binary-data' - content_type = 'application/octet-stream' - - monkeypatch.setattr(swos_beta.swos_beta_service, 'download_backup', lambda payload: FakeBackup()) +def test_switchos_connection_probe_is_exposed_in_device_route(monkeypatch): + from app.api.routes import routers as routers_route with TestClient(app) as client: - token = _login(client) - response = client.post( - '/api/swos-beta/download', - json={'host': '192.168.88.1', 'port': 80, 'username': 'admin', 'password': ''}, - headers={'Authorization': f'Bearer {token}'}, + _, headers = _login(client) + create_response = client.post( + '/api/routers', + json={ + 'name': 'switch02', + 'device_type': 'switchos', + 'host': '192.168.88.3', + 'port': 80, + 'ssh_user': 'admin', + 'ssh_password': 'secret', + 'ssh_key': None, + }, + headers=headers, ) + device_id = create_response.json()['id'] + + monkeypatch.setattr( + routers_route.router_service, + 'test_connection', + lambda db, router, global_settings: { + 'success': True, + 'tested_at': '2026-04-13T10:00:00', + 'model': 'SwitchOS', + 'uptime': 'HTTP 200', + 'hostname': 'MikroTik SwitchOS', + 'version': None, + 'error': None, + 'transport': 'http', + 'server': 'MikroTik', + 'auth_mode': 'digest', + 'http_status': '200', + 'backup_available': True, + }, + ) + + response = client.get(f'/api/routers/{device_id}/test-connection', headers=headers) assert response.status_code == 200 - assert response.content == b'binary-data' - assert 'attachment; filename="switch.swb"' == response.headers['content-disposition'] + assert response.json()['transport'] == 'http' + assert response.json()['backup_available'] is True + + +def test_switchos_binary_backup_is_saved_as_swb(monkeypatch, tmp_path): + from app.services import backup_service as backup_service_module + from app.services import router_service as router_service_module + + data_dir = tmp_path / 'data' + data_dir.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(backup_service_module, 'ensure_data_dir', lambda: data_dir) + + def fake_binary_backup(router, backup_name, local_path, global_ssh_key=None, global_settings=None): + Path(local_path).write_bytes(b'switchos-binary') + return local_path + + monkeypatch.setattr(router_service_module.router_service, 'binary_backup', fake_binary_backup) + + with TestClient(app) as client: + _, headers = _login(client) + create_response = client.post( + '/api/routers', + json={ + 'name': 'switch03', + 'device_type': 'switchos', + 'host': '192.168.88.4', + 'port': 80, + 'ssh_user': 'admin', + 'ssh_password': 'secret', + 'ssh_key': None, + }, + headers=headers, + ) + device_id = create_response.json()['id'] + + backup_response = client.post(f'/api/backups/router/{device_id}/binary', headers=headers) + assert backup_response.status_code == 200 + assert backup_response.json()['backup_type'] == 'binary' + assert backup_response.json()['file_name'].endswith('.swb') diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 3a65368..9a6d2ef 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -56,10 +56,9 @@ export class AppComponent { { label: 'nav.routers', link: '/routers', icon: 'pi pi-server', exact: false }, { label: 'nav.files', link: '/files', icon: 'pi pi-folder-open', exact: false }, { label: 'nav.diffConfigs', link: '/diff-configs', icon: 'pi pi-code', exact: false }, - { label: 'nav.settings', link: '/settings', icon: 'pi pi-cog', exact: false }, { label: 'nav.logs', link: '/logs', icon: 'pi pi-history', exact: false }, - { label: 'nav.switchosBeta', link: '/switchos-beta', icon: 'pi pi-sitemap', exact: false }, - { label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', exact: false } + { label: 'nav.changePassword', link: '/change-password', icon: 'pi pi-lock', exact: false }, + { label: 'nav.settings', link: '/settings', icon: 'pi pi-cog', exact: false } ]; get currentPageTitle(): string { @@ -133,10 +132,6 @@ export class AppComponent { this.pageLabel = 'logs.title'; return; } - if (url.startsWith('/switchos-beta')) { - this.pageLabel = 'switchosBeta.title'; - return; - } if (url.startsWith('/change-password')) { this.pageLabel = 'auth.changePassword'; return; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index f427a65..b516eda 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -11,7 +11,6 @@ import { LogsPageComponent } from './features/logs/logs-page.component'; import { RouterDetailPageComponent } from './features/routers/router-detail-page.component'; import { RoutersPageComponent } from './features/routers/routers-page.component'; import { SettingsPageComponent } from './features/settings/settings-page.component'; -import { SwosBetaPageComponent } from './features/swos-beta/swos-beta-page.component'; export const routes: Routes = [ { path: 'login', component: LoginPageComponent }, @@ -24,6 +23,5 @@ export const routes: Routes = [ { path: 'diff-configs', canActivate: [authGuard], component: DiffConfigsPageComponent }, { path: 'settings', canActivate: [authGuard], component: SettingsPageComponent }, { path: 'logs', canActivate: [authGuard], component: LogsPageComponent }, - { path: 'switchos-beta', canActivate: [authGuard], component: SwosBetaPageComponent }, { path: '**', redirectTo: '' } ]; diff --git a/frontend/src/app/features/files/files-page.component.html b/frontend/src/app/features/files/files-page.component.html index 93bf98f..62942d9 100644 --- a/frontend/src/app/features/files/files-page.component.html +++ b/frontend/src/app/features/files/files-page.component.html @@ -104,7 +104,7 @@