Распаковал архив Proxmox-VPS-Panel.rar и добавил содержимое в репозиторий

This commit is contained in:
root
2026-07-24 15:10:47 +00:00
parent 0372e2c5f1
commit ac74a700ce
37 changed files with 2353 additions and 0 deletions
View File
+25
View File
@@ -0,0 +1,25 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Общие
database_url: str = "sqlite:///./panel.db"
secret_key: str = "change-me-please-super-secret"
access_token_expire_minutes: int = 60 * 12
# Proxmox
pve_host: str = "https://proxmox.local:8006"
pve_token_name: str = "root@pam!panel"
pve_token_value: str = ""
pve_verify_ssl: bool = False
pve_node: str = "pve"
# Пул VMID, из которого будут выделяться номера новым машинам
vmid_range_start: int = 9000
vmid_range_end: int = 9999
class Config:
env_file = ".env"
settings = Settings()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+35
View File
@@ -0,0 +1,35 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from . import models
from .database import get_db
from .security import decode_access_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> models.User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не удалось подтвердить учётные данные",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
user_id = payload.get("sub")
if user_id is None:
raise credentials_exception
user = db.query(models.User).filter(models.User.id == int(user_id)).first()
if user is None or not user.is_active:
raise credentials_exception
return user
def require_admin(user: models.User = Depends(get_current_user)) -> models.User:
if user.role != models.Role.admin:
raise HTTPException(status_code=403, detail="Требуются права администратора")
return user
+29
View File
@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from . import models
from .database import engine
from .routers import auth, templates, instances, admin, console
models.Base.metadata.create_all(bind=engine)
app = FastAPI(title="Proxmox VPS Panel", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # в проде укажите конкретный домен фронтенда
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(templates.router)
app.include_router(instances.router)
app.include_router(admin.router)
app.include_router(console.router)
@app.get("/health")
def health():
return {"status": "ok"}
+81
View File
@@ -0,0 +1,81 @@
import enum
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Boolean, DateTime, ForeignKey, Enum,
)
from sqlalchemy.orm import relationship
from .database import Base
class Role(str, enum.Enum):
admin = "admin"
client = "client"
class GuestType(str, enum.Enum):
vm = "vm"
lxc = "lxc"
class InstanceStatus(str, enum.Enum):
creating = "creating"
running = "running"
stopped = "stopped"
error = "error"
deleting = "deleting"
deleted = "deleted"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
role = Column(Enum(Role), default=Role.client, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
instances = relationship("Instance", back_populates="owner")
class Template(Base):
"""Шаблон VPS: связывает тарифный план с исходным Proxmox-шаблоном (VM template / CT template)."""
__tablename__ = "templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(String, default="")
guest_type = Column(Enum(GuestType), nullable=False)
# VMID шаблона в Proxmox, с которого делаем клон (для VM) или CT template volid (для LXC, напр. local:vztmpl/....tar.zst)
source_vmid = Column(Integer, nullable=True) # для VM-шаблонов (клонирование)
source_template = Column(String, nullable=True) # для LXC (путь к .tar.zst шаблону)
cores = Column(Integer, default=1)
memory_mb = Column(Integer, default=1024)
disk_gb = Column(Integer, default=10)
is_active = Column(Boolean, default=True)
instances = relationship("Instance", back_populates="template")
class Instance(Base):
__tablename__ = "instances"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
vmid = Column(Integer, unique=True, index=True, nullable=False)
node = Column(String, nullable=False)
guest_type = Column(Enum(GuestType), nullable=False)
status = Column(Enum(InstanceStatus), default=InstanceStatus.creating)
root_password = Column(String, nullable=True)
ciuser = Column(String, nullable=True) # хранится только для LXC при создании, чтобы показать один раз
owner_id = Column(Integer, ForeignKey("users.id"))
template_id = Column(Integer, ForeignKey("templates.id"))
created_at = Column(DateTime, default=datetime.utcnow)
owner = relationship("User", back_populates="instances")
template = relationship("Template", back_populates="instances")
+319
View File
@@ -0,0 +1,319 @@
import random
import string
import time
from proxmoxer import ProxmoxAPI
from .config import settings
def _client() -> ProxmoxAPI:
"""Создаёт клиент Proxmox API, аутентифицированный по API-токену."""
host = settings.pve_host.replace("https://", "").replace("http://", "").split(":")[0]
return ProxmoxAPI(
host,
user=settings.pve_token_name.split("!")[0],
token_name=settings.pve_token_name.split("!")[1],
token_value=settings.pve_token_value,
verify_ssl=settings.pve_verify_ssl,
)
def gen_password(length: int = 14) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(random.choice(alphabet) for _ in range(length))
def get_next_vmid() -> int:
"""Берёт свободный VMID у самого Proxmox (гарантированно не занят)."""
px = _client()
return int(px.cluster.nextid.get())
def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, storage: str = None) -> None:
node = node or settings.pve_node
px = _client()
upid = px.nodes(node).qemu(source_vmid).clone.post(
newid=new_vmid,
name=name,
full=1,
)
_wait_task(px, node, upid)
def resize_vm(vmid: int, cores: int, memory_mb: int, node: str = None) -> None:
node = node or settings.pve_node
px = _client()
px.nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb)
def create_lxc(
new_vmid: int,
name: str,
template_volid: str,
cores: int,
memory_mb: int,
disk_gb: int,
storage: str = "local-lvm",
node: str = None,
) -> str:
"""Создаёт LXC-контейнер из шаблона. Возвращает сгенерированный root-пароль."""
node = node or settings.pve_node
px = _client()
password = gen_password()
upid = px.nodes(node).lxc.post(
vmid=new_vmid,
hostname=name,
ostemplate=template_volid,
cores=cores,
memory=memory_mb,
swap=memory_mb,
rootfs=f"{storage}:{disk_gb}",
password=password,
net0="name=eth0,bridge=vmbr0,ip=dhcp",
unprivileged=1,
)
_wait_task(px, node, upid)
return password
def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200) -> None:
"""Ждёт завершения асинхронной задачи Proxmox (клонирование, создание и т.п.)."""
start = time.time()
while time.time() - start < timeout:
status = px.nodes(node).tasks(upid).status.get()
if status.get("status") == "stopped":
if status.get("exitstatus") != "OK":
raise RuntimeError(f"Задача Proxmox завершилась с ошибкой: {status}")
return
time.sleep(2)
raise TimeoutError("Превышено время ожидания задачи Proxmox")
def list_vm_templates(node=None):
node = node or settings.pve_node
px = _client()
result = []
for vm in px.nodes(node).qemu.get():
if vm.get("template") == 1:
cfg = px.nodes(node).qemu(vm["vmid"]).config.get()
disk = 10
for k in ("scsi0","virtio0","ide0","sata0"):
v = cfg.get(k,"")
if ",size=" in v:
import re as _re
m = _re.search(r"size=(\d+)G", v)
if m: disk = int(m.group(1)); break
result.append({
"vmid": vm["vmid"], "name": vm.get("name",""),
"cores": int(cfg.get("cores",1)), "memory_mb": int(cfg.get("memory",1024)),
"disk_gb": disk,
})
return result
def list_lxc_templates(node=None):
node = node or settings.pve_node
px = _client()
result = []
for st in px.nodes(node).storage.get():
if "vztmpl" in st.get("content",""):
try:
for item in px.nodes(node).storage(st["storage"]).content.get():
if item.get("content") == "vztmpl":
name = item["volid"].split("/")[-1]
for ext in (".tar.zst",".tar.gz",".tar.xz"): name = name.replace(ext,"")
result.append({"volid": item["volid"], "name": name, "storage": st["storage"], "size_mb": round(item.get("size",0)/(1024**2),1)})
except: pass
return result
def resize_disk(vmid: int, disk_gb: int, node: str = None) -> None:
node = node or settings.pve_node
px = _client()
config = px.nodes(node).qemu(vmid).config.get()
for key in ("scsi0","virtio0","ide0","sata0"):
val = config.get(key,"")
if val:
import re as _re
m = _re.search(r"size=(\d+)G", val)
current = int(m.group(1)) if m else 0
if disk_gb > current:
px.nodes(node).qemu(vmid).resize.put(disk=key, size=f"+{disk_gb - current}G")
break
def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = None) -> None:
node = node or settings.pve_node
_client().nodes(node).qemu(vmid).config.put(ciuser=ciuser, cipassword=cipassword, ipconfig0="ip=dhcp")
def get_instance_ip(vmid: int, node: str = None) -> str:
"""Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации."""
node = node or settings.pve_node
px = _client()
# Пробуем через гостевой агент (QEMU Guest Agent)
try:
ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
for iface in ifaces:
if iface.get("name") != "lo" and iface.get("ip-addresses"):
for addr in iface["ip-addresses"]:
if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."):
return addr["ip-address"]
except Exception:
pass
# Пробуем через LXC (если контейнер)
try:
config = px.nodes(node).lxc(vmid).config.get()
# Просто пробуем разные пути для LXC
except Exception:
pass
# Пробуем через DHCP-лиз Proxmox
try:
config = px.nodes(node).qemu(vmid).config.get()
net = config.get("net0", "")
if "dhcp" in net.lower():
return "dhcp (агент не установлен)"
except Exception:
pass
return "неизвестен"
def get_instance_ip(vmid: int, node: str = None) -> str:
"""Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации."""
node = node or settings.pve_node
px = _client()
# Пробуем через гостевой агент (QEMU Guest Agent)
try:
ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
for iface in ifaces:
if iface.get("name") != "lo" and iface.get("ip-addresses"):
for addr in iface["ip-addresses"]:
if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."):
return addr["ip-address"]
except Exception:
pass
# Пробуем через LXC (если контейнер)
try:
config = px.nodes(node).lxc(vmid).config.get()
# Просто пробуем разные пути для LXC
except Exception:
pass
# Пробуем через DHCP-лиз Proxmox
try:
config = px.nodes(node).qemu(vmid).config.get()
net = config.get("net0", "")
if "dhcp" in net.lower():
return "dhcp (агент не установлен)"
except Exception:
pass
return "неизвестен"
def get_instance_ip(vmid: int, node: str = None) -> str:
"""Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации."""
node = node or settings.pve_node
px = _client()
# Пробуем через гостевой агент (QEMU Guest Agent)
try:
ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
for iface in ifaces:
if iface.get("name") != "lo" and iface.get("ip-addresses"):
for addr in iface["ip-addresses"]:
if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."):
return addr["ip-address"]
except Exception:
pass
# Пробуем через LXC (если контейнер)
try:
config = px.nodes(node).lxc(vmid).config.get()
# Просто пробуем разные пути для LXC
except Exception:
pass
# Пробуем через DHCP-лиз Proxmox
try:
config = px.nodes(node).qemu(vmid).config.get()
net = config.get("net0", "")
if "dhcp" in net.lower():
return "dhcp (агент не установлен)"
except Exception:
pass
return "неизвестен"
def get_live_stats(vmid: int, node: str = None) -> dict:
"""Возвращает live-показатели: cpu%, ram_used, ram_total, uptime."""
node = node or settings.pve_node
px = _client()
try:
status = px.nodes(node).qemu(vmid).status.current.get()
return {
"cpu": round(status.get("cpu", 0) * 100, 1),
"mem_used": status.get("mem", 0),
"mem_total": status.get("maxmem", 0),
"uptime": status.get("uptime", 0),
"status": status.get("status", "unknown"),
}
except Exception:
try:
status = px.nodes(node).lxc(vmid).status.current.get()
return {
"cpu": round(status.get("cpu", 0) * 100, 1),
"mem_used": status.get("mem", 0),
"mem_total": status.get("maxmem", 0),
"uptime": status.get("uptime", 0),
"status": status.get("status", "unknown"),
}
except Exception:
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"}
def get_live_stats_lxc(vmid: int, node: str = None) -> dict:
"""Live-показатели LXC контейнера."""
return get_live_stats(vmid, node)
def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> None:
"""action: start | stop | shutdown | reboot"""
node = node or settings.pve_node
px = _client()
endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
if action == "start":
endpoint.status.start.post()
elif action == "stop":
endpoint.status.stop.post()
elif action == "shutdown":
endpoint.status.shutdown.post()
elif action == "reboot":
endpoint.status.reboot.post()
else:
raise ValueError(f"Неизвестное действие: {action}")
def delete_guest(guest_type: str, vmid: int, node: str = None) -> None:
node = node or settings.pve_node
px = _client()
if guest_type == "vm":
px.nodes(node).qemu(vmid).delete()
else:
px.nodes(node).lxc(vmid).delete()
def get_status(guest_type: str, vmid: int, node: str = None) -> dict:
node = node or settings.pve_node
px = _client()
if guest_type == "vm":
return px.nodes(node).qemu(vmid).status.current.get()
return px.nodes(node).lxc(vmid).status.current.get()
def get_vnc_ticket(guest_type: str, vmid: int, node: str = None) -> dict:
"""Запрашивает у Proxmox тикет для VNC/websocket-консоли."""
node = node or settings.pve_node
px = _client()
endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
result = endpoint.vncproxy.post(websocket=1)
return {
"ticket": result["ticket"],
"port": result["port"],
"node": node,
"vmid": vmid,
"guest_type": guest_type,
"pve_host": settings.pve_host,
}
View File
+25
View File
@@ -0,0 +1,25 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
from ..deps import require_admin
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=List[schemas.UserOut])
def list_users(db: Session = Depends(get_db), _=Depends(require_admin)):
return db.query(models.User).order_by(models.User.created_at.desc()).all()
@router.post("/users/{user_id}/toggle-active")
def toggle_active(user_id: int, db: Session = Depends(get_db), _=Depends(require_admin)):
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
user.is_active = not user.is_active
db.commit()
return {"ok": True, "is_active": user.is_active}
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
from ..security import hash_password, verify_password, create_access_token
from ..deps import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=schemas.UserOut)
def register(payload: schemas.UserCreate, db: Session = Depends(get_db)):
existing = db.query(models.User).filter(models.User.email == payload.email).first()
if existing:
raise HTTPException(status_code=400, detail="Пользователь с таким email уже существует")
# первый зарегистрированный пользователь становится администратором
is_first_user = db.query(models.User).count() == 0
user = models.User(
email=payload.email,
password_hash=hash_password(payload.password),
role=models.Role.admin if is_first_user else models.Role.client,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=schemas.Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(status_code=401, detail="Неверный email или пароль")
token = create_access_token({"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer"}
@router.get("/me", response_model=schemas.UserOut)
def me(current_user: models.User = Depends(get_current_user)):
return current_user
+69
View File
@@ -0,0 +1,69 @@
import asyncio
import websockets
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from ..config import settings
router = APIRouter(prefix="/console", tags=["console"])
@router.websocket("/ws")
async def console_ws(
websocket: WebSocket,
node: str = Query(...),
vmid: int = Query(...),
guest_type: str = Query(...),
port: int = Query(...),
ticket: str = Query(...),
):
"""
Проксирует бинарный VNC-поток между браузером клиента и websocket-эндпоинтом
Proxmox (nodes/{node}/(qemu|lxc)/{vmid}/vncwebsocket). Тикет и порт берутся
из ответа /instances/{id}/console (см. instances.py -> pve.get_vnc_ticket).
Примечание: в зависимости от версии Proxmox и способа аутентификации
(API-токен vs cookie-тикет) может понадобиться донастройка заголовков —
см. README, раздел "Консоль VNC".
"""
await websocket.accept()
guest_path = "qemu" if guest_type == "vm" else "lxc"
pve_host = settings.pve_host.replace("http://", "").replace("https://", "")
upstream_url = (
f"wss://{pve_host}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket"
f"?port={port}&vncticket={ticket}"
)
headers = {
"Authorization": f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}"
}
try:
async with websockets.connect(
upstream_url,
extra_headers=headers,
subprotocols=["binary"],
ssl=None if settings.pve_verify_ssl else False,
) as upstream:
async def client_to_upstream():
try:
while True:
data = await websocket.receive_bytes()
await upstream.send(data)
except WebSocketDisconnect:
pass
async def upstream_to_client():
try:
async for message in upstream:
if isinstance(message, str):
message = message.encode()
await websocket.send_bytes(message)
except websockets.ConnectionClosed:
pass
await asyncio.gather(client_to_upstream(), upstream_to_client())
except Exception as exc: # noqa: BLE001
await websocket.close(code=1011, reason=str(exc)[:120])
+231
View File
@@ -0,0 +1,231 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session, joinedload
from .. import models, schemas, proxmox_client as pve
from ..config import settings
from ..database import get_db
from ..deps import get_current_user
router = APIRouter(prefix="/instances", tags=["instances"])
@router.get("", response_model=List[schemas.InstanceOut])
def list_my_instances(db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
query = db.query(models.Instance).options(joinedload(models.Instance.template))
query = query.filter(models.Instance.status != models.InstanceStatus.deleted)
if user.role != models.Role.admin:
query = query.filter(models.Instance.owner_id == user.id)
return query.order_by(models.Instance.created_at.desc()).all()
def _provision(instance_id: int, template_id: int, node: str):
"""Фоновое создание VPS через Proxmox API."""
from ..database import SessionLocal
db = SessionLocal()
try:
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
return
template = db.query(models.Template).filter(models.Template.id == template_id).first()
if not template:
instance.status = models.InstanceStatus.error
instance.root_password = "ERROR: шаблон не найден"
db.commit()
return
try:
if template.guest_type == models.GuestType.vm:
clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or 'vm'
pve.clone_vm(template.source_vmid, instance.vmid, clean_name, node)
pve.resize_vm(instance.vmid, template.cores, template.memory_mb, node)
pve.resize_disk(instance.vmid, template.disk_gb, node)
if instance.ciuser and instance.root_password:
pve.configure_cloud_init(instance.vmid, instance.ciuser, instance.root_password, node)
pve.guest_action("vm", instance.vmid, "start", node)
else:
clean_name = ''.join(c for c in instance.name.strip() if c.isalnum() or c == '-').lower() or 'lxc'
password = pve.create_lxc(
new_vmid=instance.vmid, name=clean_name,
template_volid=template.source_template,
cores=template.cores, memory_mb=template.memory_mb,
disk_gb=template.disk_gb, node=node,
)
instance.root_password = password
pve.guest_action("lxc", instance.vmid, "start", node)
instance.status = models.InstanceStatus.running
except Exception as exc:
instance.status = models.InstanceStatus.error
instance.root_password = f"ERROR: {exc}"
db.commit()
finally:
db.close()
@router.post("", response_model=schemas.InstanceOut)
def create_instance(
payload: schemas.InstanceCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: models.User = Depends(get_current_user),
):
template = db.query(models.Template).filter(
models.Template.id == payload.template_id, models.Template.is_active == True
).first()
if not template:
raise HTTPException(status_code=404, detail="Шаблон не найден")
for _ in range(100):
try:
vmid = pve.get_next_vmid()
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}")
exists = db.query(models.Instance).filter(
models.Instance.vmid == vmid,
models.Instance.status != models.InstanceStatus.deleted,
).first()
if not exists:
break
else:
raise HTTPException(status_code=409, detail="Не удалось найти свободный VMID")
clean_name = ''.join(c for c in payload.name.strip() if c.isalnum() or c == '-').lower() or 'vps'
# Proxmox переиспользует освободившиеся vmid — вычищаем мёртвые записи,
# иначе INSERT упадёт с duplicate key по ix_instances_vmid
stale = db.query(models.Instance).filter(
models.Instance.vmid == vmid,
models.Instance.status.in_([
models.InstanceStatus.error,
models.InstanceStatus.deleted,
models.InstanceStatus.deleting,
]),
).all()
for s in stale:
db.delete(s)
if stale:
db.commit()
instance = models.Instance(
name=clean_name, vmid=vmid, node=settings.pve_node,
guest_type=template.guest_type, status=models.InstanceStatus.creating,
owner_id=user.id, template_id=template.id,
ciuser=payload.ciuser or None,
root_password=payload.cipassword or None,
)
db.add(instance)
db.commit()
db.refresh(instance)
background_tasks.add_task(_provision, instance.id, template.id, settings.pve_node)
return instance
@router.get("/{instance_id}", response_model=schemas.InstanceOut)
def get_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
return instance
@router.get("/{instance_id}/status")
def get_instance_status(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
return pve.get_status(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Proxmox недоступен: {exc}")
@router.get("/{instance_id}/ip")
def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
ip = pve.get_instance_ip(instance.vmid, instance.node)
return {"ip": ip}
except Exception as exc:
return {"ip": f"ошибка: {exc}"}
@router.post("/{instance_id}/action")
def instance_action(instance_id: int, payload: schemas.InstanceAction, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
pve.guest_action(instance.guest_type.value, instance.vmid, payload.action, instance.node)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Ошибка: {exc}")
if payload.action == "start":
instance.status = models.InstanceStatus.running
elif payload.action in ("stop", "shutdown"):
instance.status = models.InstanceStatus.stopped
db.commit()
return {"ok": True}
@router.get("/{instance_id}/ip")
def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает IP-адрес инстанса."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
if user.role != models.Role.admin and instance.owner_id != user.id:
raise HTTPException(status_code=403, detail="Нет доступа")
try:
ip = pve.get_instance_ip(instance.vmid, instance.node)
return {"ip": ip}
except Exception as exc:
return {"ip": f"ошибка: {exc}"}
@router.get("/{instance_id}/ip")
def get_instance_ip(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Возвращает IP-адрес инстанса."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
if user.role != models.Role.admin and instance.owner_id != user.id:
raise HTTPException(status_code=403, detail="Нет доступа")
try:
ip = pve.get_instance_ip(instance.vmid, instance.node)
return {"ip": ip}
except Exception as exc:
return {"ip": f"ошибка: {exc}"}
@router.get("/{instance_id}/live")
def get_instance_live(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
"""Live CPU/RAM/Memory показатели."""
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
return pve.get_live_stats(instance.vmid, instance.node)
except Exception as exc:
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "error", "error": str(exc)}
@router.delete("/{instance_id}")
def delete_instance(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
pve.delete_guest(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Ошибка удаления: {exc}")
instance.status = models.InstanceStatus.deleted
db.commit()
return {"ok": True}
@router.get("/{instance_id}/console")
def instance_console(instance_id: int, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)):
instance = db.query(models.Instance).filter(models.Instance.id == instance_id).first()
if not instance:
raise HTTPException(status_code=404, detail="VPS не найден")
try:
ticket = pve.get_vnc_ticket(instance.guest_type.value, instance.vmid, instance.node)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Консоль недоступна: {exc}")
ticket["instance_id"] = instance.id
return ticket
+47
View File
@@ -0,0 +1,47 @@
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas, proxmox_client as pve
from ..database import get_db
from ..deps import get_current_user, require_admin
router = APIRouter(prefix="/templates", tags=["templates"])
@router.get("", response_model=List[schemas.TemplateOut])
def list_templates(db: Session = Depends(get_db), _=Depends(get_current_user)):
return db.query(models.Template).filter(models.Template.is_active == True).all() # noqa: E712
@router.post("", response_model=schemas.TemplateOut)
def create_template(
payload: schemas.TemplateCreate,
db: Session = Depends(get_db),
_=Depends(require_admin),
):
tpl = models.Template(**payload.model_dump())
db.add(tpl)
db.commit()
db.refresh(tpl)
return tpl
@router.get("/from-proxmox")
def list_proxmox_templates(_=Depends(require_admin)):
try: vm_tpls = pve.list_vm_templates()
except: vm_tpls = []
try: lxc_tpls = pve.list_lxc_templates()
except: lxc_tpls = []
return {"vm_templates": vm_tpls, "lxc_templates": lxc_tpls}
@router.delete("/{template_id}")
def deactivate_template(
template_id: int, db: Session = Depends(get_db), _=Depends(require_admin)
):
tpl = db.query(models.Template).filter(models.Template.id == template_id).first()
if tpl:
tpl.is_active = False
db.commit()
return {"ok": True}
+91
View File
@@ -0,0 +1,91 @@
from datetime import datetime
from typing import Optional, Optional
from pydantic import BaseModel, EmailStr
from .models import Role, GuestType, InstanceStatus
# ---------- Auth ----------
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
email: EmailStr
role: Role
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# ---------- Templates ----------
class TemplateCreate(BaseModel):
name: str
description: str = ""
guest_type: GuestType
source_vmid: Optional[int] = None
source_template: Optional[str] = None
cores: int = 1
memory_mb: int = 1024
disk_gb: int = 10
class TemplateOut(BaseModel):
id: int
name: str
description: str
guest_type: GuestType
cores: int
memory_mb: int
disk_gb: int
is_active: bool
class Config:
from_attributes = True
# ---------- Instances ----------
class InstanceCreate(BaseModel):
ciuser: str = ""
cipassword: str = ""
name: str
template_id: int
class InstanceOut(BaseModel):
id: int
name: str
vmid: int
node: str
guest_type: GuestType
status: InstanceStatus
template_id: int
template: Optional["TemplateOut"] = None
owner_id: int
created_at: datetime
class Config:
from_attributes = True
class InstanceCreatedOut(InstanceOut):
root_password: Optional[str] = None
class InstanceAction(BaseModel):
action: str # start | stop | reboot | shutdown
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from .config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict, expires_minutes: Optional[int] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(
minutes=expires_minutes or settings.access_token_expire_minutes
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except JWTError:
return None