Files
pvemanager/backend/app/api/ssh_keys.py
markmorado 09186e23b9
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Security audit: tenant isolation, injection fixes, hardening and performance
Security
- Enforce guest ownership and server assignment across Proxmox endpoints
  (VM/LXC actions, firewall, backups, tasks, bulk operations, deploy,
  LXC/ISO creation, golden template) and WebSocket channels
- Keep the panel's PVE root ticket server-side for consoles; single-use
  WebSocket tickets instead of the JWT in URLs; re-check access on open
  WebSocket bridges
- Validate identifiers that reach Proxmox API paths and node shell
  commands; reject backup archives / foreign volumes in create requests
- Close privilege escalation in user management, TOTP replay, reset-link
  poisoning, X-Forwarded-For spoofing, stored XSS (group icons, SVG logos)
- SSH host key pinning (TOFU), encrypted 2FA seeds and notification
  secrets, password changes end other sessions, redact secrets in logs
- Quotas count in-flight creations; 2FA failures count toward lockout

Fixes
- Consoles on non-default API ports, large App Store/script files,
  interrupted tasks after restart, impersonation lost on token refresh,
  HTTPExceptions swallowed into 500s, WebSocket client reconnects

Performance and process
- Fewer DB writes and queries on hot paths, lazy-loaded locales and
  heavy pages, off-loop blocking work, indexes (migrations 44-46)
- Deploy waits for backend tests and frontend build; frontend checks
  workflow; API bound to loopback without the SSL profile
2026-10-23 00:04:16 +05:00

505 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
value = (value or "").strip()
# Keys are written verbatim into authorized_keys / cloud-init sshkeys: an
# embedded newline would add an extra, unvalidated entry (e.g. with
# command=/from= options) next to the one shown in the UI.
if "\n" in value or "\r" in value:
raise HTTPException(status_code=400, detail="SSH public key must be a single line")
return value
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("user: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("user: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("user: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("user: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("user: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("user: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()
def build_deploy_ssh_keys(
db: Session,
requester: User,
instance_owner_id: int,
key_ids: Optional[List[int]] = None,
raw_keys: Optional[str] = None,
) -> str:
"""Собрать итоговый блок public-ключей для cloud-init / LXC.
Объединяет вставленные вручную ключи, выбранные из библиотеки ключи
(с проверкой прав) и профильный ключ **владельца** инстанса.
Профильный ключ берётся именно у владельца, а не у того, кто нажал
«Создать»: иначе VM, созданная админом для пользователя, получала ключ
админа, а ключ самого пользователя на неё не попадал.
"""
parts: List[str] = []
if raw_keys:
parts.append(raw_keys)
if key_ids:
parts.append(
resolve_ssh_keys_for_deploy(db, requester, key_ids, instance_owner_id)
)
owner = (
requester
if instance_owner_id == requester.id
else db.query(User).filter(User.id == instance_owner_id).first()
)
if owner and owner.ssh_public_key:
parts.append(owner.ssh_public_key)
seen: set = set()
uniq: List[str] = []
for block in parts:
for line in (block or "").splitlines():
line = line.strip()
if line and line not in seen:
seen.add(line)
uniq.append(line)
return "\n".join(uniq)