Распаковал архив Proxmox-VPS-Panel.rar и добавил содержимое в репозиторий
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user