switchos support
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user