feat: SSH keys management, backups fixes, instances/nodes/templates improvements, dev mode support
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
This commit is contained in:
@@ -197,11 +197,11 @@ async def get_log_categories():
|
||||
"""Get available log categories"""
|
||||
return {
|
||||
"categories": [
|
||||
{"value": "auth", "label": "🔐 Авторизация"},
|
||||
{"value": "proxmox", "label": "🖥️ Proxmox"},
|
||||
{"value": "ipam", "label": "🌐 IPAM"},
|
||||
{"value": "system", "label": "⚙️ Система"},
|
||||
{"value": "api", "label": "🔌 API"},
|
||||
{"value": "docker", "label": "🐳 Docker"}
|
||||
{"value": "auth", "label": "Авторизация"},
|
||||
{"value": "proxmox", "label": "Proxmox"},
|
||||
{"value": "ipam", "label": "IPAM"},
|
||||
{"value": "system", "label": "Система"},
|
||||
{"value": "api", "label": "API"},
|
||||
{"value": "docker", "label": "Docker"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -137,20 +137,59 @@ def delete_storage(
|
||||
@router.get("/api/backups/list/{server_id}")
|
||||
def list_backups(
|
||||
server_id: int,
|
||||
node: str,
|
||||
storage: str,
|
||||
node: Optional[str] = None,
|
||||
vmid: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("backup:view")),
|
||||
):
|
||||
"""List backup files stored in a given storage on a node"""
|
||||
"""List backup files stored in a given storage.
|
||||
|
||||
If ``node`` is omitted (or empty), backup files are aggregated from all
|
||||
cluster nodes that have the storage available. Duplicate ``volid`` entries
|
||||
(which appear on shared storages) are de-duplicated.
|
||||
"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
backups = client.list_backups(node, storage, vmid)
|
||||
|
||||
nodes_to_query: list[str] = []
|
||||
if node:
|
||||
nodes_to_query = [node]
|
||||
else:
|
||||
try:
|
||||
cluster_nodes = client.get_nodes() or []
|
||||
nodes_to_query = [
|
||||
n.get("node") for n in cluster_nodes
|
||||
if n.get("node") and (n.get("status") in (None, "online"))
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to enumerate cluster nodes for backup listing: {e}")
|
||||
|
||||
seen: set[str] = set()
|
||||
backups: list[dict] = []
|
||||
for n in nodes_to_query:
|
||||
try:
|
||||
items = client.list_backups(n, storage, vmid) or []
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping node {n} when listing backups: {e}")
|
||||
continue
|
||||
for item in items:
|
||||
volid = item.get("volid")
|
||||
if volid and volid in seen:
|
||||
continue
|
||||
if volid:
|
||||
seen.add(volid)
|
||||
# annotate with the node where it was discovered (helpful for
|
||||
# restore / delete actions on non-shared storages)
|
||||
item.setdefault("node", n)
|
||||
backups.append(item)
|
||||
|
||||
return JSONResponse(content={"backups": backups})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing backups: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -53,6 +53,8 @@ class LXCDeployRequest(BaseModel):
|
||||
ipam_pool_id: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
ssh_keys: Optional[str] = None
|
||||
ssh_key_ids: Optional[List[int]] = None
|
||||
owner_id: Optional[int] = None
|
||||
nameserver: Optional[str] = None
|
||||
searchdomain: Optional[str] = None
|
||||
unprivileged: bool = True
|
||||
@@ -105,7 +107,8 @@ def _update_task(task_id: int, status: str, step: str, progress: int,
|
||||
db.close()
|
||||
|
||||
|
||||
def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str):
|
||||
def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str,
|
||||
owner_id: Optional[int] = None):
|
||||
"""Blocking LXC deploy — runs in thread pool."""
|
||||
db = SessionLocal()
|
||||
server = None
|
||||
@@ -286,7 +289,7 @@ def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username:
|
||||
template_id=None,
|
||||
template_name=req.ostemplate,
|
||||
description=f"Created from CT template {req.ostemplate}",
|
||||
owner_id=user_id,
|
||||
owner_id=owner_id or user_id,
|
||||
)
|
||||
|
||||
LoggingService.log_proxmox_action(
|
||||
@@ -434,6 +437,23 @@ async def deploy_lxc_from_template(
|
||||
if not req.name or len(req.name) < 1:
|
||||
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
|
||||
|
||||
# Resolve owner / SSH keys
|
||||
is_admin = current_user.has_permission("users.manage") or current_user.is_admin
|
||||
instance_owner_id = current_user.id
|
||||
if req.owner_id and req.owner_id != current_user.id:
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
|
||||
owner = db.query(User).filter(User.id == req.owner_id).first()
|
||||
if not owner:
|
||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||
instance_owner_id = owner.id
|
||||
|
||||
if req.ssh_key_ids:
|
||||
from ..ssh_keys import resolve_ssh_keys_for_deploy
|
||||
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)
|
||||
existing = (req.ssh_keys or "").strip()
|
||||
req.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
|
||||
|
||||
# Create task record
|
||||
task = DeployTask(
|
||||
status="pending",
|
||||
@@ -456,6 +476,7 @@ async def deploy_lxc_from_template(
|
||||
req,
|
||||
current_user.id,
|
||||
current_user.username,
|
||||
instance_owner_id,
|
||||
)
|
||||
|
||||
return LXCDeployResponse(task_id=task_id, status="pending", name=req.name)
|
||||
|
||||
@@ -523,6 +523,37 @@ def delete_proxmox_server(
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/api/servers/test", status_code=status.HTTP_200_OK)
|
||||
def test_proxmox_credentials(
|
||||
payload: ProxmoxServerCreate,
|
||||
current_user: User = Depends(PermissionChecker("proxmox.view"))
|
||||
):
|
||||
"""Проверить произвольные учётные данные Proxmox без сохранения в БД."""
|
||||
try:
|
||||
if payload.use_password:
|
||||
client = ProxmoxClient(
|
||||
host=payload.ip_address,
|
||||
user=payload.api_user,
|
||||
password=payload.password,
|
||||
verify_ssl=payload.verify_ssl,
|
||||
)
|
||||
else:
|
||||
client = ProxmoxClient(
|
||||
host=payload.ip_address,
|
||||
user=payload.api_user,
|
||||
token_name=payload.api_token_name,
|
||||
token_value=payload.api_token_value,
|
||||
verify_ssl=payload.verify_ssl,
|
||||
)
|
||||
|
||||
if client.is_connected():
|
||||
return {"success": True, "status": "success", "message": "Connection successful"}
|
||||
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing Proxmox credentials for {payload.ip_address}: {e}")
|
||||
return {"success": False, "status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/test", status_code=status.HTTP_200_OK)
|
||||
def test_proxmox_connection(
|
||||
server_id: int,
|
||||
@@ -533,25 +564,25 @@ def test_proxmox_connection(
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
|
||||
try:
|
||||
# Determine auth method
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
|
||||
if client.is_connected():
|
||||
server.update_status(True)
|
||||
db.commit()
|
||||
return {"status": "success", "message": "Connection successful"}
|
||||
return {"success": True, "status": "success", "message": "Connection successful"}
|
||||
else:
|
||||
server.update_status(False, "Failed to connect")
|
||||
db.commit()
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
|
||||
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
|
||||
|
||||
except Exception as e:
|
||||
server.update_status(False, str(e))
|
||||
db.commit()
|
||||
logger.error(f"Error testing Proxmox connection to {server.name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"success": False, "status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ==================== Proxmox Resources ====================
|
||||
|
||||
@@ -152,7 +152,24 @@ def get_all_virtual_machines(
|
||||
query = query.filter(VMInstance.owner_id == current_user.id)
|
||||
|
||||
cached_vms = query.order_by(VMInstance.name).all()
|
||||
|
||||
|
||||
# ── Live metrics from Proxmox cluster/resources (CPU, RAM, disk, uptime) ──
|
||||
# One HTTP call per server returns metrics for all VMs/LXC in that cluster.
|
||||
# Map: (server_id_in_db, vmid) -> live data dict
|
||||
live_metrics: dict[tuple[int, int], dict] = {}
|
||||
for server in servers:
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
continue
|
||||
for res in client.get_cluster_resources(type_='vm'):
|
||||
vmid = res.get('vmid')
|
||||
if vmid is None:
|
||||
continue
|
||||
live_metrics[(server.id, vmid)] = res
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch live metrics from {server.name}: {e}")
|
||||
|
||||
result = []
|
||||
|
||||
# Detect cluster servers (servers with hostnames pve1, pve2, pve3 pattern)
|
||||
@@ -195,7 +212,10 @@ def get_all_virtual_machines(
|
||||
os_template = vm.template_name or vm.os_type or ("QEMU/KVM" if vm.vm_type == "qemu" else "Linux")
|
||||
if not vm.template_name and vm.vm_type == "lxc" and os_template:
|
||||
os_template = os_template.capitalize()
|
||||
|
||||
|
||||
# Live metrics (cpu/mem/disk/uptime/net) from cluster/resources
|
||||
live = live_metrics.get((vm.server_id, vm.vmid)) or {}
|
||||
|
||||
result.append({
|
||||
"server_id": vm.server_id,
|
||||
"server_name": server_name,
|
||||
@@ -204,18 +224,28 @@ def get_all_virtual_machines(
|
||||
"name": vm.name,
|
||||
"hostname": ip_hostname or f"{'vps' if vm.vm_type == 'qemu' else 'lxc'}{vm.vmid}.{server.hostname if server else 'local'}",
|
||||
"type": vm.vm_type,
|
||||
"status": vm.status or "unknown",
|
||||
"status": live.get('status') or vm.status or "unknown",
|
||||
"node": vm.node,
|
||||
"cores": vm.cores or 0,
|
||||
"memory": vm.memory or 0,
|
||||
"disk": vm.disk_size or 0,
|
||||
"disk": live.get('disk') or 0,
|
||||
"ip": ip_address,
|
||||
"ip_address": ip_address,
|
||||
"ip_hostname": ip_network_name,
|
||||
"os": os_template,
|
||||
"os_template": os_template,
|
||||
"owner": owner,
|
||||
"owner_hostname": "",
|
||||
"storage": "Storage1 (DIR)"
|
||||
"storage": "Storage1 (DIR)",
|
||||
"tags": vm.tags or "",
|
||||
# Live runtime metrics
|
||||
"cpu": live.get('cpu'),
|
||||
"mem": live.get('mem'),
|
||||
"maxmem": live.get('maxmem') or vm.memory or 0,
|
||||
"maxdisk": live.get('maxdisk') or vm.disk_size or 0,
|
||||
"uptime": live.get('uptime'),
|
||||
"netin": live.get('netin'),
|
||||
"netout": live.get('netout'),
|
||||
})
|
||||
|
||||
return JSONResponse(content=result)
|
||||
|
||||
453
backend/app/api/ssh_keys.py
Normal file
453
backend/app/api/ssh_keys.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
SSH Keys management API.
|
||||
|
||||
Each user owns a personal library of SSH keys (public + optionally encrypted
|
||||
private key). Keys can be referenced by id when deploying a VM/LXC instance.
|
||||
|
||||
* Regular users can manage only their own keys and may pick only their own
|
||||
keys when deploying.
|
||||
* Admins (`users.manage` permission) can manage SSH keys of any user and may
|
||||
pick any user's key (typically their own + the new instance owner's) when
|
||||
deploying through the admin flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_client_ip, get_current_user, PermissionChecker
|
||||
from ..db import get_db
|
||||
from ..logging_service import LoggingService
|
||||
from ..models import User, UserSSHKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== Schemas ====================
|
||||
|
||||
|
||||
class SSHKeyBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
public_key: str = Field(..., min_length=20, max_length=10000)
|
||||
private_key: Optional[str] = Field(None, max_length=20000)
|
||||
comment: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class SSHKeyCreate(SSHKeyBase):
|
||||
pass
|
||||
|
||||
|
||||
class SSHKeyUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
public_key: Optional[str] = Field(None, min_length=20, max_length=10000)
|
||||
private_key: Optional[str] = Field(None, max_length=20000)
|
||||
comment: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class SSHKeyResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
public_key: str
|
||||
fingerprint: Optional[str] = None
|
||||
comment: Optional[str] = None
|
||||
has_private_key: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ==================== Helpers ====================
|
||||
|
||||
|
||||
def _normalize_public_key(value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _compute_fingerprint(public_key: str) -> Optional[str]:
|
||||
"""SHA256 fingerprint in OpenSSH format (SHA256:base64...)."""
|
||||
try:
|
||||
parts = public_key.strip().split()
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
key_b64 = parts[1]
|
||||
raw = base64.b64decode(key_b64)
|
||||
digest = hashlib.sha256(raw).digest()
|
||||
b64 = base64.b64encode(digest).decode().rstrip("=")
|
||||
return f"SHA256:{b64}"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _to_response(key: UserSSHKey) -> dict:
|
||||
return {
|
||||
"id": key.id,
|
||||
"user_id": key.user_id,
|
||||
"name": key.name,
|
||||
"public_key": key.public_key,
|
||||
"fingerprint": key.fingerprint,
|
||||
"comment": key.comment,
|
||||
"has_private_key": bool(key.private_key),
|
||||
"created_at": key.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_target_user(db: Session, user_id: int) -> User:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
# ==================== Self-service endpoints ====================
|
||||
|
||||
|
||||
@router.get("/api/ssh-keys/my", response_model=List[SSHKeyResponse])
|
||||
async def list_my_ssh_keys(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List current user's SSH keys."""
|
||||
keys = (
|
||||
db.query(UserSSHKey)
|
||||
.filter(UserSSHKey.user_id == current_user.id)
|
||||
.order_by(UserSSHKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_to_response(k) for k in keys]
|
||||
|
||||
|
||||
@router.post("/api/ssh-keys/my", response_model=SSHKeyResponse, status_code=201)
|
||||
async def create_my_ssh_key(
|
||||
data: SSHKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add an SSH key to the current user's library."""
|
||||
return _create_key_for_user(db, request, current_user, current_user.id, data)
|
||||
|
||||
|
||||
@router.put("/api/ssh-keys/my/{key_id}", response_model=SSHKeyResponse)
|
||||
async def update_my_ssh_key(
|
||||
key_id: int,
|
||||
data: SSHKeyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _update_key(db, request, current_user, current_user.id, key_id, data)
|
||||
|
||||
|
||||
@router.delete("/api/ssh-keys/my/{key_id}")
|
||||
async def delete_my_ssh_key(
|
||||
key_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _delete_key(db, request, current_user, current_user.id, key_id)
|
||||
|
||||
|
||||
@router.get("/api/ssh-keys/my/{key_id}/private")
|
||||
async def download_my_private_key(
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return _read_private_key(db, current_user.id, key_id)
|
||||
|
||||
|
||||
# ==================== Admin endpoints (per user) ====================
|
||||
|
||||
|
||||
@router.get("/api/ssh-keys/users/{user_id}", response_model=List[SSHKeyResponse])
|
||||
async def list_user_ssh_keys(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.manage")),
|
||||
):
|
||||
"""Admin: list a user's SSH keys."""
|
||||
_ensure_target_user(db, user_id)
|
||||
keys = (
|
||||
db.query(UserSSHKey)
|
||||
.filter(UserSSHKey.user_id == user_id)
|
||||
.order_by(UserSSHKey.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [_to_response(k) for k in keys]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/ssh-keys/users/{user_id}",
|
||||
response_model=SSHKeyResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def admin_create_user_ssh_key(
|
||||
user_id: int,
|
||||
data: SSHKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.manage")),
|
||||
):
|
||||
target = _ensure_target_user(db, user_id)
|
||||
return _create_key_for_user(db, request, current_user, target.id, data)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/ssh-keys/users/{user_id}/{key_id}",
|
||||
response_model=SSHKeyResponse,
|
||||
)
|
||||
async def admin_update_user_ssh_key(
|
||||
user_id: int,
|
||||
key_id: int,
|
||||
data: SSHKeyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.manage")),
|
||||
):
|
||||
_ensure_target_user(db, user_id)
|
||||
return _update_key(db, request, current_user, user_id, key_id, data)
|
||||
|
||||
|
||||
@router.delete("/api/ssh-keys/users/{user_id}/{key_id}")
|
||||
async def admin_delete_user_ssh_key(
|
||||
user_id: int,
|
||||
key_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.manage")),
|
||||
):
|
||||
_ensure_target_user(db, user_id)
|
||||
return _delete_key(db, request, current_user, user_id, key_id)
|
||||
|
||||
|
||||
@router.get("/api/ssh-keys/users/{user_id}/{key_id}/private")
|
||||
async def admin_download_user_private_key(
|
||||
user_id: int,
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.manage")),
|
||||
):
|
||||
_ensure_target_user(db, user_id)
|
||||
return _read_private_key(db, user_id, key_id)
|
||||
|
||||
|
||||
# ==================== Internal helpers ====================
|
||||
|
||||
|
||||
def _create_key_for_user(
|
||||
db: Session,
|
||||
request: Request,
|
||||
current_user: User,
|
||||
target_user_id: int,
|
||||
data: SSHKeyCreate,
|
||||
) -> dict:
|
||||
public_key = _normalize_public_key(data.public_key)
|
||||
if not public_key.startswith(("ssh-", "ecdsa-", "sk-")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid SSH public key format",
|
||||
)
|
||||
|
||||
fingerprint = _compute_fingerprint(public_key)
|
||||
private_key = data.private_key.strip() if data.private_key else None
|
||||
if private_key and "PRIVATE KEY" not in private_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid SSH private key format (PEM expected)",
|
||||
)
|
||||
|
||||
key = UserSSHKey(
|
||||
user_id=target_user_id,
|
||||
name=data.name.strip(),
|
||||
public_key=public_key,
|
||||
private_key=private_key,
|
||||
fingerprint=fingerprint,
|
||||
comment=(data.comment or None),
|
||||
)
|
||||
db.add(key)
|
||||
db.commit()
|
||||
db.refresh(key)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="ssh_key_added",
|
||||
message=f"SSH key '{key.name}' added to user_id={target_user_id} by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="ssh_key",
|
||||
resource_id=str(key.id),
|
||||
resource_name=key.name,
|
||||
)
|
||||
|
||||
return _to_response(key)
|
||||
|
||||
|
||||
def _update_key(
|
||||
db: Session,
|
||||
request: Request,
|
||||
current_user: User,
|
||||
target_user_id: int,
|
||||
key_id: int,
|
||||
data: SSHKeyUpdate,
|
||||
) -> dict:
|
||||
key = (
|
||||
db.query(UserSSHKey)
|
||||
.filter(UserSSHKey.id == key_id, UserSSHKey.user_id == target_user_id)
|
||||
.first()
|
||||
)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
|
||||
if data.name is not None:
|
||||
key.name = data.name.strip()
|
||||
if data.public_key is not None:
|
||||
public_key = _normalize_public_key(data.public_key)
|
||||
if not public_key.startswith(("ssh-", "ecdsa-", "sk-")):
|
||||
raise HTTPException(status_code=400, detail="Invalid SSH public key format")
|
||||
key.public_key = public_key
|
||||
key.fingerprint = _compute_fingerprint(public_key)
|
||||
if data.private_key is not None:
|
||||
pk = data.private_key.strip()
|
||||
if pk:
|
||||
if "PRIVATE KEY" not in pk:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid SSH private key format (PEM expected)",
|
||||
)
|
||||
key.private_key = pk
|
||||
else:
|
||||
key.private_key = None
|
||||
if data.comment is not None:
|
||||
key.comment = data.comment or None
|
||||
|
||||
db.commit()
|
||||
db.refresh(key)
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="ssh_key_updated",
|
||||
message=f"SSH key #{key.id} ('{key.name}') updated by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="ssh_key",
|
||||
resource_id=str(key.id),
|
||||
resource_name=key.name,
|
||||
)
|
||||
|
||||
return _to_response(key)
|
||||
|
||||
|
||||
def _delete_key(
|
||||
db: Session,
|
||||
request: Request,
|
||||
current_user: User,
|
||||
target_user_id: int,
|
||||
key_id: int,
|
||||
) -> dict:
|
||||
key = (
|
||||
db.query(UserSSHKey)
|
||||
.filter(UserSSHKey.id == key_id, UserSSHKey.user_id == target_user_id)
|
||||
.first()
|
||||
)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
|
||||
name = key.name
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="ssh_key_deleted",
|
||||
message=f"SSH key '{name}' (id={key_id}) deleted by {current_user.username}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="ssh_key",
|
||||
resource_id=str(key_id),
|
||||
resource_name=name,
|
||||
)
|
||||
|
||||
return {"success": True, "message": "SSH key deleted"}
|
||||
|
||||
|
||||
def _read_private_key(db: Session, target_user_id: int, key_id: int) -> dict:
|
||||
key = (
|
||||
db.query(UserSSHKey)
|
||||
.filter(UserSSHKey.id == key_id, UserSSHKey.user_id == target_user_id)
|
||||
.first()
|
||||
)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="SSH key not found")
|
||||
if not key.private_key:
|
||||
raise HTTPException(status_code=404, detail="No private key stored for this entry")
|
||||
return {
|
||||
"id": key.id,
|
||||
"name": key.name,
|
||||
"private_key": key.private_key,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Internal API (used by deploy flow) ====================
|
||||
|
||||
|
||||
def resolve_ssh_keys_for_deploy(
|
||||
db: Session,
|
||||
requester: User,
|
||||
key_ids: List[int],
|
||||
instance_owner_id: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Resolve a list of SSH key ids into a newline-joined block of public keys.
|
||||
|
||||
Authorization rules:
|
||||
* Regular users may use only their own keys.
|
||||
* Admins (`users.manage` permission) may use their own keys and the keys
|
||||
of the designated `instance_owner_id` user.
|
||||
"""
|
||||
if not key_ids:
|
||||
return ""
|
||||
|
||||
allowed_owners = {requester.id}
|
||||
is_admin = requester.has_permission("users.manage") or requester.is_admin
|
||||
if is_admin and instance_owner_id:
|
||||
allowed_owners.add(instance_owner_id)
|
||||
|
||||
keys = (
|
||||
db.query(UserSSHKey)
|
||||
.filter(UserSSHKey.id.in_(key_ids))
|
||||
.all()
|
||||
)
|
||||
if len(keys) != len(set(key_ids)):
|
||||
raise HTTPException(status_code=404, detail="One or more SSH keys not found")
|
||||
|
||||
for k in keys:
|
||||
if k.user_id not in allowed_owners:
|
||||
logger.warning(
|
||||
f"User {requester.username} tried to use ssh key #{k.id} "
|
||||
f"belonging to user_id={k.user_id}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not allowed to use one of the selected SSH keys",
|
||||
)
|
||||
|
||||
return "\n".join(k.public_key.strip() for k in keys if k.public_key).strip()
|
||||
@@ -166,6 +166,7 @@ def list_os_templates(
|
||||
current_user: User = Depends(PermissionChecker("templates.view")),
|
||||
group_id: int = None,
|
||||
server_id: int = None,
|
||||
vm_type: Optional[str] = None,
|
||||
active_only: bool = True
|
||||
):
|
||||
"""Получить список всех OS шаблонов с информацией о группе"""
|
||||
@@ -175,6 +176,8 @@ def list_os_templates(
|
||||
query = query.filter(OSTemplate.group_id == group_id)
|
||||
if server_id:
|
||||
query = query.filter(OSTemplate.server_id == server_id)
|
||||
if vm_type in ('qemu', 'lxc'):
|
||||
query = query.filter(OSTemplate.vm_type == vm_type)
|
||||
if active_only:
|
||||
query = query.filter(OSTemplate.is_active == True)
|
||||
|
||||
@@ -358,20 +361,26 @@ def discover_proxmox_templates(
|
||||
proxmox_templates = client.get_templates(node=node_filter)
|
||||
|
||||
# Get already added templates для ЭТОГО сервера
|
||||
existing_vmids = set(
|
||||
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
)
|
||||
|
||||
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
|
||||
existing_volids = set(t.volid for t in existing if t.volid)
|
||||
|
||||
result = []
|
||||
for tpl in proxmox_templates:
|
||||
volid = tpl.get('volid')
|
||||
vmid = tpl.get('vmid')
|
||||
already = (volid is not None and volid in existing_volids) or (vmid is not None and vmid in existing_vmids)
|
||||
result.append({
|
||||
'vmid': tpl.get('vmid'),
|
||||
'name': tpl.get('name', f"Template-{tpl.get('vmid')}"),
|
||||
'vmid': vmid,
|
||||
'volid': volid,
|
||||
'name': tpl.get('name', f"Template-{vmid or volid}"),
|
||||
'node': tpl.get('node'),
|
||||
'status': tpl.get('status'),
|
||||
'vm_type': tpl.get('type', 'qemu'),
|
||||
'is_file_template': tpl.get('is_file_template', False),
|
||||
'maxmem': tpl.get('maxmem', 0),
|
||||
'maxdisk': tpl.get('maxdisk', 0),
|
||||
'already_added': tpl.get('vmid') in existing_vmids
|
||||
'already_added': already,
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
@@ -461,26 +470,31 @@ def _do_auto_import(server_id: int, username: str):
|
||||
|
||||
proxmox_templates = client.get_templates(node=node_filter)
|
||||
|
||||
existing_vmids = set(
|
||||
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
)
|
||||
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
|
||||
existing_volids = set(t.volid for t in existing if t.volid)
|
||||
|
||||
groups_cache = {g.name: g for g in db.query(OSTemplateGroup).all()}
|
||||
|
||||
templates_by_group = {}
|
||||
for tpl in proxmox_templates:
|
||||
volid = tpl.get('volid')
|
||||
vmid = tpl.get('vmid')
|
||||
if vmid in existing_vmids:
|
||||
if volid and volid in existing_volids:
|
||||
continue
|
||||
template_name = tpl.get('name', f"Template-{vmid}")
|
||||
if vmid is not None and vmid in existing_vmids:
|
||||
continue
|
||||
template_name = tpl.get('name', f"Template-{vmid or volid}")
|
||||
group_info = detect_os_group(template_name)
|
||||
group_name = group_info['name']
|
||||
if group_name not in templates_by_group:
|
||||
templates_by_group[group_name] = {'info': group_info, 'templates': []}
|
||||
templates_by_group[group_name]['templates'].append({
|
||||
'vmid': vmid,
|
||||
'volid': volid,
|
||||
'name': template_name,
|
||||
'node': tpl.get('node'),
|
||||
'vm_type': tpl.get('type', 'qemu'),
|
||||
'maxmem': tpl.get('maxmem', 0),
|
||||
'maxdisk': tpl.get('maxdisk', 0),
|
||||
})
|
||||
@@ -516,7 +530,9 @@ def _do_auto_import(server_id: int, username: str):
|
||||
group_id=group.id,
|
||||
server_id=server_id,
|
||||
name=tpl['name'],
|
||||
vmid=tpl['vmid'],
|
||||
vmid=tpl.get('vmid'),
|
||||
volid=tpl.get('volid'),
|
||||
vm_type=tpl.get('vm_type', 'qemu'),
|
||||
node=tpl.get('node'),
|
||||
source_node=tpl.get('node'),
|
||||
default_cores=2,
|
||||
@@ -529,7 +545,7 @@ def _do_auto_import(server_id: int, username: str):
|
||||
sort_order=global_sort_order
|
||||
)
|
||||
db.add(template)
|
||||
imported.append({'vmid': tpl['vmid'], 'name': tpl['name'], 'group': group_name})
|
||||
imported.append({'vmid': tpl.get('vmid'), 'volid': tpl.get('volid'), 'name': tpl['name'], 'group': group_name})
|
||||
global_sort_order += 1
|
||||
|
||||
db.commit()
|
||||
@@ -581,25 +597,30 @@ def auto_import_templates(
|
||||
|
||||
proxmox_templates = client.get_templates(node=node_filter)
|
||||
|
||||
existing_vmids = set(
|
||||
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
)
|
||||
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
|
||||
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
|
||||
existing_volids = set(t.volid for t in existing if t.volid)
|
||||
groups_cache = {g.name: g for g in db.query(OSTemplateGroup).all()}
|
||||
|
||||
templates_by_group = {}
|
||||
for tpl in proxmox_templates:
|
||||
volid = tpl.get('volid')
|
||||
vmid = tpl.get('vmid')
|
||||
if vmid in existing_vmids:
|
||||
if volid and volid in existing_volids:
|
||||
continue
|
||||
template_name = tpl.get('name', f"Template-{vmid}")
|
||||
if vmid is not None and vmid in existing_vmids:
|
||||
continue
|
||||
template_name = tpl.get('name', f"Template-{vmid or volid}")
|
||||
group_info = detect_os_group(template_name)
|
||||
group_name = group_info['name']
|
||||
if group_name not in templates_by_group:
|
||||
templates_by_group[group_name] = {'info': group_info, 'templates': []}
|
||||
templates_by_group[group_name]['templates'].append({
|
||||
'vmid': vmid,
|
||||
'volid': volid,
|
||||
'name': template_name,
|
||||
'node': tpl.get('node'),
|
||||
'vm_type': tpl.get('type', 'qemu'),
|
||||
'maxmem': tpl.get('maxmem', 0),
|
||||
'maxdisk': tpl.get('maxdisk', 0),
|
||||
})
|
||||
@@ -635,7 +656,9 @@ def auto_import_templates(
|
||||
group_id=group.id,
|
||||
server_id=server_id,
|
||||
name=tpl['name'],
|
||||
vmid=tpl['vmid'],
|
||||
vmid=tpl.get('vmid'),
|
||||
volid=tpl.get('volid'),
|
||||
vm_type=tpl.get('vm_type', 'qemu'),
|
||||
node=tpl.get('node'),
|
||||
source_node=tpl.get('node'),
|
||||
default_cores=2,
|
||||
@@ -649,7 +672,8 @@ def auto_import_templates(
|
||||
)
|
||||
db.add(template)
|
||||
imported.append({
|
||||
'vmid': tpl['vmid'],
|
||||
'vmid': tpl.get('vmid'),
|
||||
'volid': tpl.get('volid'),
|
||||
'name': tpl['name'],
|
||||
'group': group_name,
|
||||
'sort_order': global_sort_order
|
||||
@@ -706,7 +730,8 @@ def _update_deploy_task(task_id: int, status: str, step: str, progress: int,
|
||||
|
||||
|
||||
def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
|
||||
user_id: int, username: str, user_ssh_key: str):
|
||||
user_id: int, username: str, user_ssh_key: str,
|
||||
owner_id: int = None):
|
||||
"""Blocking deploy logic — runs in a thread pool."""
|
||||
db = SessionLocal()
|
||||
server = None
|
||||
@@ -920,7 +945,7 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
|
||||
template_id=template.id,
|
||||
template_name=template.name,
|
||||
description=f"Deployed from template {template.name}",
|
||||
owner_id=user_id
|
||||
owner_id=owner_id or user_id
|
||||
)
|
||||
|
||||
LoggingService.log_proxmox_action(
|
||||
@@ -999,6 +1024,25 @@ async def deploy_vm_from_template(
|
||||
if disk < template.min_disk:
|
||||
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
|
||||
|
||||
# Resolve owner / SSH keys (admin may target another user)
|
||||
is_admin = current_user.has_permission("users.manage") or current_user.is_admin
|
||||
instance_owner_id = current_user.id
|
||||
if deploy_data.owner_id and deploy_data.owner_id != current_user.id:
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
|
||||
owner = db.query(User).filter(User.id == deploy_data.owner_id).first()
|
||||
if not owner:
|
||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||
instance_owner_id = owner.id
|
||||
|
||||
if deploy_data.ssh_key_ids:
|
||||
from .ssh_keys import resolve_ssh_keys_for_deploy
|
||||
resolved = resolve_ssh_keys_for_deploy(
|
||||
db, current_user, deploy_data.ssh_key_ids, instance_owner_id
|
||||
)
|
||||
existing = (deploy_data.ssh_keys or "").strip()
|
||||
deploy_data.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
|
||||
|
||||
# Create deploy task record
|
||||
task = DeployTask(
|
||||
status='pending',
|
||||
@@ -1023,7 +1067,8 @@ async def deploy_vm_from_template(
|
||||
deploy_data,
|
||||
current_user.id,
|
||||
current_user.username,
|
||||
current_user.ssh_public_key or ''
|
||||
current_user.ssh_public_key or '',
|
||||
instance_owner_id,
|
||||
)
|
||||
|
||||
logger.info(f"[DEPLOY] Task #{task_id} queued: {deploy_data.name} by {current_user.username}")
|
||||
|
||||
Reference in New Issue
Block a user