Files
pvemanager/backend/app/proxmox/mixins/cluster.py
Dilshod Rasulov 50f2ee1966
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s
fix(security): authenticate VNC/terminal WebSockets and harden SSH/config
Critical security and robustness fixes from a code audit:

- WS terminal/VNC now require a JWT (?token=), validate the session,
  vms.console permission and VM ownership before bridging to Proxmox
  (previously the LXC terminal gave unauthenticated root shell access).
  Frontend ConsolePage passes the token in both WS URLs.
- Fix SSH fallback for `pvecm delnode`: SSHClient now supports password
  auth (it previously crashed with TypeError); ProxmoxClient stores the
  password for SSH fallbacks and the host port suffix is stripped.
- Add production startup warnings for default ADMIN_PASSWORD and wildcard
  CORS_ORIGINS.
- dashboard: use tz-aware utcnow() instead of naive datetime.utcnow().
- crypto: narrow decrypt_value except to InvalidToken and log failures.
- ssh_client: guard the shared connection cache with a lock; make
  unknown-host-key rejection opt-in via SSH_REJECT_UNKNOWN_HOSTS.
- logging: send loguru console output to stderr instead of stdout.
- Sync version fallback to 1.5.1; remove one-off refactor scripts.
- Add CODE_REVIEW.md audit report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:31:26 +05:00

313 lines
14 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.
from typing import List, Dict, Optional, Union, Any
import time
import urllib3
from loguru import logger
class ClusterMixin:
def is_cluster(self) -> bool:
"""Проверить, является ли сервер частью кластера"""
if not self.proxmox:
return False
try:
# Проверяем через cluster/status - это наиболее надёжный способ
cluster_status = self.proxmox.cluster.status.get()
# В кластере будет тип 'cluster' с информацией
for item in cluster_status:
if item.get('type') == 'cluster':
# Это кластер
return True
# Также проверяем количество нод
nodes = [item for item in cluster_status if item.get('type') == 'node']
return len(nodes) > 1
except Exception as e:
# Если /cluster/status не работает, пробуем через ноды
try:
nodes = self.get_nodes()
return len(nodes) > 1
except Exception:
return False
def get_ha_resources(self) -> List[Dict]:
"""Получить список ресурсов в HA"""
if not self.proxmox:
return []
try:
resources = self.proxmox.cluster.ha.resources.get()
return resources
except Exception as e:
logger.error(f"Ошибка получения HA ресурсов: {e}")
return []
def get_ha_groups(self) -> List[Dict]:
"""Получить список HA групп"""
if not self.proxmox:
return []
try:
groups = self.proxmox.cluster.ha.groups.get()
return groups
except Exception as e:
logger.error(f"Ошибка получения HA групп: {e}")
return []
def is_in_ha(self, vmid: int, vm_type: str = 'vm') -> bool:
"""
Проверить, находится ли VM/контейнер в HA
Args:
vmid: ID виртуальной машины или контейнера
vm_type: 'vm' или 'ct'
"""
if not self.proxmox:
return False
try:
sid = f"{vm_type}:{vmid}"
resources = self.get_ha_resources()
return any(r.get('sid') == sid for r in resources)
except Exception:
return False
def add_to_ha(self, vmid: int, vm_type: str = 'vm', group: str = None,
max_restart: int = 1, max_relocate: int = 1,
state: str = 'started', comment: str = None) -> Dict:
"""
Добавить VM/контейнер в HA
Args:
vmid: ID виртуальной машины или контейнера
vm_type: 'vm' для QEMU, 'ct' для LXC
group: Имя HA группы (опционально)
max_restart: Максимальное количество перезапусков при сбое
max_relocate: Максимальное количество перемещений на другую ноду
state: Целевое состояние: 'started', 'stopped', 'enabled', 'disabled', 'ignored'
comment: Комментарий
Returns:
Dict с результатом операции
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
sid = f"{vm_type}:{vmid}"
# Проверяем, не добавлен ли уже
if self.is_in_ha(vmid, vm_type):
return {'success': False, 'error': 'Already in HA', 'already_in_ha': True}
params = {
'sid': sid,
'max_restart': max_restart,
'max_relocate': max_relocate,
'state': state
}
if group:
params['group'] = group
if comment:
params['comment'] = comment
self.proxmox.cluster.ha.resources.post(**params)
logger.info(f"Added {sid} to HA")
return {'success': True, 'sid': sid}
except Exception as e:
logger.error(f"Ошибка добавления {vmid} в HA: {e}")
return {'success': False, 'error': str(e)}
def remove_from_ha(self, vmid: int, vm_type: str = 'vm') -> Dict:
"""
Удалить VM/контейнер из HA
Args:
vmid: ID виртуальной машины или контейнера
vm_type: 'vm' для QEMU, 'ct' для LXC
Returns:
Dict с результатом операции
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
sid = f"{vm_type}:{vmid}"
# Проверяем, что ресурс в HA
if not self.is_in_ha(vmid, vm_type):
return {'success': False, 'error': 'Not in HA', 'not_in_ha': True}
self.proxmox.cluster.ha.resources(sid).delete()
logger.info(f"Removed {sid} from HA")
return {'success': True, 'sid': sid}
except Exception as e:
logger.error(f"Ошибка удаления {vmid} из HA: {e}")
return {'success': False, 'error': str(e)}
def get_ha_status(self, vmid: int, vm_type: str = 'vm') -> Optional[Dict]:
"""
Получить статус HA для VM/контейнера
Returns:
Dict со статусом HA или None если не в HA
"""
if not self.proxmox:
return None
try:
sid = f"{vm_type}:{vmid}"
resources = self.get_ha_resources()
for r in resources:
if r.get('sid') == sid:
return {
'in_ha': True,
'state': r.get('state'),
'group': r.get('group'),
'max_restart': r.get('max_restart'),
'max_relocate': r.get('max_relocate'),
'comment': r.get('comment')
}
return {'in_ha': False}
except Exception as e:
logger.error(f"Ошибка получения HA статуса {vmid}: {e}")
return None
def create_cluster(self, cluster_name: str, link0: str = None) -> Dict:
"""
Создать новый Proxmox кластер на этой ноде.
Требует аутентификации через пароль (root@pam).
Args:
cluster_name: Уникальное имя кластера (не изменяемо после создания)
link0: IP-адрес для corosync link0 (по умолчанию — IP ноды)
Returns:
Dict с UPID задачи или ошибкой
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
params = {'clustername': cluster_name}
if link0:
params['link0'] = link0
result = self.proxmox.cluster.config.post(**params)
logger.info(f"Cluster '{cluster_name}' creation started on {self.host}, UPID: {result}")
return {'success': True, 'upid': result, 'cluster_name': cluster_name}
except Exception as e:
logger.error(f"Ошибка создания кластера '{cluster_name}' на {self.host}: {e}")
return {'success': False, 'error': str(e)}
def get_cluster_join_info(self) -> Dict:
"""
Получить информацию для присоединения к кластеру (fingerprint, join token).
Вызывается на существующей кластерной ноде.
Returns:
Dict с полями: totem, nodelist, fingerprint, config_digest
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
info = self.proxmox.cluster.config.join.get()
logger.info(f"Got cluster join info from {self.host}")
return {'success': True, 'data': info}
except Exception as e:
logger.error(f"Ошибка получения join info с {self.host}: {e}")
return {'success': False, 'error': str(e)}
def join_cluster(self, cluster_host: str, rootpw: str, fingerprint: str,
link0: str = None) -> Dict:
"""
Присоединить эту ноду к существующему кластеру.
ВАЖНО: Нода должна быть пустой — /etc/pve будет перезаписан.
Args:
cluster_host: IP/hostname существующей кластерной ноды
rootpw: root-пароль кластерной ноды (нужен для join)
fingerprint: SHA-256 fingerprint кластерного сертификата
link0: Локальный IP для corosync link0 (по умолчанию — IP ноды)
Returns:
Dict с UPID задачи или ошибкой
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
params = {
'hostname': cluster_host,
'password': rootpw,
'fingerprint': fingerprint,
}
if link0:
params['link0'] = link0
result = self.proxmox.cluster.config.join.post(**params)
logger.info(f"Node {self.host} joining cluster at {cluster_host}, UPID: {result}")
return {'success': True, 'upid': result}
except Exception as e:
logger.error(f"Ошибка присоединения {self.host} к кластеру {cluster_host}: {e}")
return {'success': False, 'error': str(e)}
def delete_cluster_node(self, node_name: str) -> Dict:
"""
Удалить ноду из кластера (pvecm delnode).
Вызывается на ДРУГОЙ ноде кластера, не на удаляемой.
Удаляемая нода должна быть ВЫКЛЮЧЕНА.
Args:
node_name: Имя ноды для удаления (hostname)
Returns:
Dict с результатом
"""
if not self.proxmox:
return {'success': False, 'error': 'Not connected'}
try:
# Proxmox PVE 8 API: DELETE /nodes/{node}
self.proxmox.nodes(node_name).delete()
logger.info(f"Node '{node_name}' deleted from cluster via {self.host}")
return {'success': True, 'node': node_name}
except Exception as e:
logger.warning(f"REST API delete node failed, trying SSH fallback: {e}")
# SSH fallback через pvecm delnode
return self._delete_cluster_node_via_ssh(node_name)
def _delete_cluster_node_via_ssh(self, node_name: str) -> Dict:
"""SSH fallback для удаления ноды через pvecm delnode"""
from app.ssh_client import SSHClient
# self.host may carry an API port suffix (e.g. "1.2.3.4:8006"); SSH needs the bare host.
ssh_host = self.host.split(':')[0] if self.host else self.host
ssh = SSHClient(
hostname=ssh_host,
username='root',
password=getattr(self, '_password', None),
)
try:
if not ssh.connect():
return {'success': False, 'error': 'SSH connection failed'}
output, exit_code = ssh.execute(f"pvecm delnode {node_name}", return_exit_code=True)
ssh.close()
# "Could not kill node (error = CS_ERR_NOT_EXIST)" — ignorable per Proxmox docs
if exit_code == 0 or (output and 'CS_ERR_NOT_EXIST' in output):
logger.info(f"Node '{node_name}' removed via pvecm delnode (SSH)")
return {'success': True, 'node': node_name, 'method': 'ssh'}
else:
return {'success': False, 'error': output or 'pvecm delnode failed'}
except Exception as e:
logger.error(f"SSH pvecm delnode failed for {node_name}: {e}")
return {'success': False, 'error': str(e)}