Files
open-webui/proxmox_openwebui_tool.py
T

682 lines
25 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.
#!/usr/bin/env python3
"""
Кастомный инструмент для Open WebUI.
Управление Proxmox VE: LXC, VM, мониторинг.
Подключение:
1. Добавьте в Open WebUI как новый инструмент
2. Укажите код из этого файла
3. Обязательно задайте переменную окружения PROXMOX_TOKEN_VALUE
"""
import json
import os
import re
import requests
import time
# ============================================================
# КОНФИГУРАЦИЯ (из переменных окружения)
# ============================================================
PROXMOX_HOST = os.getenv("PROXMOX_HOST", "https://192.168.31.2:8006")
PROXMOX_USER = os.getenv("PROXMOX_USER", "root@pam")
PROXMOX_TOKEN_NAME = os.getenv("PROXMOX_TOKEN_NAME", "openwebui-token")
PROXMOX_TOKEN_VALUE = os.getenv("PROXMOX_TOKEN_VALUE", "")
VERIFY_SSL = os.getenv("PROXMOX_VERIFY_SSL", "false").lower() in ("true", "1", "yes")
DEFAULT_NODE = os.getenv("PROXMOX_NODE", "pve")
DEFAULT_STORAGE = os.getenv("PROXMOX_STORAGE", "local")
DEFAULT_BRIDGE = os.getenv("PROXMOX_BRIDGE", "vmbr0")
DEFAULT_TEMPLATE_VMID = int(os.getenv("PROXMOX_TEMPLATE_VMID", "1000"))
if not PROXMOX_TOKEN_VALUE:
raise RuntimeError(
"PROXMOX_TOKEN_VALUE not set! "
"Export it as environment variable."
)
# ============================================================
# ВНУТРЕННИЕ ФУНКЦИИ
# ============================================================
_base_url = PROXMOX_HOST.rstrip("/") + "/api2/json"
_auth_header = {
"Authorization": f"PVEAPIToken={PROXMOX_USER}!{PROXMOX_TOKEN_NAME}={PROXMOX_TOKEN_VALUE}"
}
def _api(method, path, params=None, data=None):
url = _base_url + path
resp = requests.request(
method=method,
url=url,
headers=_auth_header,
params=params,
json=data,
verify=VERIFY_SSL,
timeout=30,
)
resp.raise_for_status()
return resp.json().get("data", {})
def _fmt(b):
if b is None:
return "N/A"
b = float(b)
for u in ["B", "KB", "MB", "GB", "TB"]:
if b < 1024:
return f"{b:.1f} {u}"
b /= 1024
return f"{b:.1f} PB"
def _pct(u, t):
if u is None or t is None or t == 0:
return "N/A"
return f"{_fmt(u)} / {_fmt(t)} ({u/t*100:.1f}%)"
def _uptime(s):
if s is None:
return "N/A"
d, r = divmod(int(s), 86400)
h, r = divmod(r, 3600)
m, s2 = divmod(r, 60)
parts = []
if d:
parts.append(f"{d}d")
if h:
parts.append(f"{h}h")
if m:
parts.append(f"{m}m")
parts.append(f"{s2}s")
return " ".join(parts)
def _wait_for_task(upid, timeout=60):
if not upid or not isinstance(upid, str) or not upid.startswith("UPID:"):
return True, "no UPID to wait"
node = DEFAULT_NODE
start = time.time()
while time.time() - start < timeout:
try:
tasks = _api("GET", f"/nodes/{node}/tasks")
for t in tasks:
if t.get("upid") == upid:
status = t.get("status", "")
if status == "stopped":
if t.get("exitstatus") == "OK":
return True, "OK"
else:
return False, str(t)
break
except Exception:
pass
time.sleep(2)
return False, "timeout waiting for task"
# ============================================================
# ИНСТРУМЕНТЫ ДЛЯ OPEN WEBUI
# ============================================================
class Tools:
"""Инструменты управления Proxmox VE."""
@staticmethod
def connection():
"""Проверить подключение к Proxmox."""
try:
v = _api("GET", "/version")
return (
"OK Proxmox VE "
+ v.get("version", "?")
+ " | node: pve | IP: 192.168.31.2"
)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def nodes():
"""Показать все узлы кластера."""
try:
items = _api("GET", "/nodes")
out = ["Nodes:"]
for n in items:
out.append(f"- {n.get('node', '?')} ({n.get('status', '?')})")
out.append(
f" CPU: {n.get('cpu', 0)*100:.1f}% | "
f"RAM: {_pct(n.get('mem'), n.get('maxmem'))}"
)
out.append(f" Uptime: {_uptime(n.get('uptime'))}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def node_status(node=DEFAULT_NODE):
"""Детальный статус узла."""
try:
s = _api("GET", f"/nodes/{node}/status")
out = [f"Node {node}:"]
ci = s.get("cpuinfo", {})
out.append(f"CPU: {ci.get('model', '?')} ({s.get('cpus', 0)} cores)")
out.append(f"Load: {s.get('cpu', 0)*100:.1f}%")
la = s.get("loadavg", [])
if la:
out.append(f"Loadavg: {' / '.join(f'{float(v):.2f}' for v in la)}")
m = s.get("memory", {})
out.append(f"RAM: {_pct(m.get('used'), m.get('total'))}")
sw = s.get("swap", {})
if sw.get("total", 0):
out.append(f"SWAP: {_pct(sw.get('used'), sw.get('total'))}")
r = s.get("rootfs", {})
if r:
out.append(f"Disk: {_pct(r.get('used'), r.get('total'))}")
out.append(f"Uptime: {_uptime(s.get('uptime'))}")
out.append(f"Kernel: {s.get('kversion', '?')}")
if s.get("pveversion"):
out.append(f"PVE: {s['pveversion']}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def cluster():
"""Показать статус кластера."""
try:
items = _api("GET", "/cluster/status")
out = ["Cluster:"]
has_cluster_info = False
for item in items:
t = item.get("type", "")
if t == "cluster":
has_cluster_info = True
out.append(
f"Cluster: {item.get('name', '?')} | "
f"Quorum: {'OK' if item.get('quorate') else 'FAIL'}"
)
elif t == "node":
out.append(
f" {item.get('name', '?')} - {item.get('status', '?')} "
f"({item.get('ip', '-')})"
)
if not has_cluster_info:
node_count = sum(1 for i in items if i.get("type") == "node")
out.append("Cluster: single-node | Quorum: OK (N/A)")
out.append(f" Nodes: {node_count}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def resources(ftype=""):
"""Показать все ресурсы кластера (VMs, LXC, storage)."""
try:
p = {}
if ftype:
p["type"] = ftype
items = _api("GET", "/cluster/resources", params=p)
out = ["Resources:"]
g = {}
for r in items:
g.setdefault(r.get("type", "?"), []).append(r)
labels = {
"node": "Nodes",
"qemu": "QEMU",
"lxc": "LXC",
"storage": "Storage",
}
for t, lst in g.items():
out.append(f"{labels.get(t, t)}: {len(lst)}")
for r in lst[:25]:
n = r.get("name") or r.get("id") or "?"
st = r.get("status", "?")
line = f" {n} - {st}"
if t in ("qemu", "lxc"):
line += f" (ID:{r.get('vmid', '?')})"
if r.get("maxmem"):
line += f" RAM:{_pct(r.get('mem'), r.get('maxmem'))}"
if r.get("cpu"):
line += f" CPU:{r.get('cpu')*100:.1f}%"
out.append(line)
out.append("")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def storage():
"""Показать все хранилища."""
try:
items = _api("GET", "/storage")
if not items:
return "No storage"
out = ["Storage:"]
for s in items:
name = s.get("storage", "?")
out.append(f"- {name} ({s.get('type', '?')})")
out.append(f" Content: {s.get('content', '?')}")
if s.get("total"):
out.append(f" Used: {_pct(s.get('used'), s.get('total'))}")
out.append(f" Free: {_fmt(s.get('avail'))}")
out.append("")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def tasks(limit=10, node=DEFAULT_NODE):
"""Последние задачи на узле."""
try:
items = _api("GET", f"/nodes/{node}/tasks", params={"limit": limit})
if not items:
return f"No tasks on {node}"
out = [f"Tasks on {node}:"]
for t in items:
start = time.ctime(t.get("starttime", 0)) if t.get("starttime") else "?"
out.append(
f"- {t.get('type', '?')} | {t.get('status', '?')} | "
f"{t.get('user', '?')} | {start}"
)
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def lxc_list(node=DEFAULT_NODE):
"""Список LXC контейнеров."""
try:
items = _api("GET", f"/nodes/{node}/lxc")
if not items:
return f"No LXC on {node}"
out = [f"LXC on {node}:"]
for c in items:
out.append(
f"- {c.get('name', '?')} (ID:{c.get('vmid', '?')}) - "
f"{c.get('status', '?')}"
)
if c.get("cpu"):
out.append(f" CPU:{c['cpu']*100:.1f}%")
if c.get("maxmem"):
out.append(f" RAM:{_pct(c.get('mem'), c.get('maxmem'))}")
if c.get("maxdisk"):
out.append(f" Disk:{_pct(c.get('disk'), c.get('maxdisk'))}")
if c.get("uptime"):
out.append(f" Uptime:{_uptime(c['uptime'])}")
out.append("")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def lxc_detail(vmid, node=DEFAULT_NODE):
"""Детальная информация о LXC."""
try:
s = _api("GET", f"/nodes/{node}/lxc/{vmid}/status/current")
c = _api("GET", f"/nodes/{node}/lxc/{vmid}/config")
name = s.get("name", c.get("hostname", "?"))
out = [f"LXC #{vmid}: {name} - {s.get('status', '?')}"]
out.append(
f"OS: {c.get('ostype', '?')} | CPU: {c.get('cores', '?')} | "
f"RAM: {c.get('memory', '?')}MB"
)
out.append(f"Disk: {c.get('rootfs', '?')} | SWAP: {c.get('swap', '?')}MB")
out.append(f"Hostname: {c.get('hostname', '?')}")
nets = [f" {k}: {v}" for k, v in c.items() if k.startswith("net")]
if nets:
out.append("Network:\n" + "\n".join(nets))
if s.get("maxmem"):
out.append(f"RAM use: {_pct(s.get('mem'), s.get('maxmem'))}")
if s.get("cpu"):
out.append(f"CPU: {s['cpu']*100:.1f}%")
if s.get("uptime"):
out.append(f"Uptime: {_uptime(s['uptime'])}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def lxc_action(vmid, action, node=DEFAULT_NODE):
"""
Управление LXC: start, stop, shutdown, restart, suspend, resume, delete.
"""
try:
acts = {
"start": "start",
"stop": "stop",
"shutdown": "shutdown",
"restart": "reboot",
"suspend": "suspend",
"resume": "resume",
"delete": "del",
}
if action not in acts:
return f"Unknown action: {action}. Allowed: {', '.join(acts.keys())}"
if action == "delete":
result = _api("DELETE", f"/nodes/{node}/lxc/{vmid}")
else:
result = _api("POST", f"/nodes/{node}/lxc/{vmid}/status/{acts[action]}")
return f"OK LXC #{vmid} {action}\n{result}"
except Exception as e:
return "ERR " + str(e)
@staticmethod
def templates(storage="local", node=DEFAULT_NODE):
"""Показать доступные LXC шаблоны."""
try:
content = _api("GET", f"/nodes/{node}/storage/{storage}/content")
tmpl = [x for x in content if x.get("content") == "vztmpl"]
if not tmpl:
return f"No templates in {storage}"
out = [f"Templates in {storage}:"]
for t in tmpl:
out.append(f" {t.get('volid', '?')} - {_fmt(t.get('size'))}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def lxc_create(
vmid,
hostname,
ostemplate,
password="",
sshkey="",
storage=DEFAULT_STORAGE,
cores=1,
memory=512,
swap=0,
disk="8G",
bridge=DEFAULT_BRIDGE,
ip="dhcp",
netmask=24,
gw="",
dns="",
domain="",
node=DEFAULT_NODE,
unpriv=True,
start_now=True,
):
"""Создать новый LXC контейнер."""
try:
if not hostname:
return "Error: hostname required"
if not ostemplate:
return "Error: ostemplate required"
if not password and not sshkey:
return "Error: password or sshkey required"
exist = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/lxc")]
exist += [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/qemu")]
if str(vmid) in exist:
return f"Error: VMID {vmid} already exists"
params = {
"vmid": vmid,
"hostname": hostname,
"ostemplate": ostemplate,
"storage": storage,
"cores": cores,
"memory": memory,
"swap": swap,
"unprivileged": 1 if unpriv else 0,
}
params["rootfs"] = disk if ":" in disk else f"{storage}:{disk}"
net = f"name=eth0,bridge={bridge}"
if ip and ip.lower() != "dhcp":
net += f",ip={ip}/{netmask}"
if gw:
net += f",gw={gw}"
else:
net += ",ip=dhcp"
params["net0"] = net
if password:
params["password"] = password
if sshkey:
params["ssh-public-keys"] = sshkey
if dns:
params["nameserver"] = dns
if domain:
params["searchdomain"] = domain
if start_now:
params["start"] = 1
result = _api("POST", f"/nodes/{node}/lxc", data=params)
out = [f"OK LXC #{vmid} creating!"]
out.append(f" Name: {hostname}")
out.append(f" Template: {ostemplate}")
out.append(f" CPU: {cores} cores, RAM: {memory}MB, Disk: {disk}")
if ip and ip.lower() != "dhcp":
out.append(f" Net: {bridge}, IP: {ip}/{netmask}")
else:
out.append(f" Net: {bridge}, IP: DHCP")
if isinstance(result, str) and result:
out.append(f"UPID: {result}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def vm_list(node=DEFAULT_NODE):
"""Список всех VM."""
try:
items = _api("GET", f"/nodes/{node}/qemu")
if not items:
return f"No VMs on {node}"
out = [f"VMs on {node}:"]
for v in items:
out.append(
f"- {v.get('name', '?')} (ID:{v.get('vmid', '?')}) - "
f"{v.get('status', '?')}"
)
if v.get("cpu"):
out.append(f" CPU:{v['cpu']*100:.1f}%")
if v.get("maxmem"):
out.append(f" RAM:{_pct(v.get('mem'), v.get('maxmem'))}")
if v.get("uptime"):
out.append(f" Uptime:{_uptime(v['uptime'])}")
out.append("")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def vm_detail(vmid, node=DEFAULT_NODE):
"""Детальная информация о VM."""
try:
s = _api("GET", f"/nodes/{node}/qemu/{vmid}/status/current")
out = [f"VM #{vmid}: {s.get('name', '?')}"]
out.append(f"Status: {s.get('status', '?')}")
if s.get("maxmem"):
out.append(f"RAM: {_pct(s.get('mem'), s.get('maxmem'))}")
if s.get("cpu"):
out.append(f"CPU: {s['cpu']*100:.1f}%")
if s.get("maxdisk") and s.get("maxdisk", 0) > 0:
out.append(f"Disk: {_pct(s.get('disk'), s.get('maxdisk'))}")
if s.get("uptime"):
out.append(f"Uptime: {_uptime(s['uptime'])}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def vm_action(vmid, action, node=DEFAULT_NODE):
"""
Управление VM: start, stop, shutdown, restart, suspend, resume, delete.
"""
try:
acts = {
"start": "start",
"stop": "stop",
"shutdown": "shutdown",
"restart": "reboot",
"suspend": "suspend",
"resume": "resume",
"delete": "del",
}
if action not in acts:
return f"Unknown action: {action}. Allowed: {', '.join(acts.keys())}"
if action == "delete":
result = _api("DELETE", f"/nodes/{node}/qemu/{vmid}")
else:
result = _api(
"POST", f"/nodes/{node}/qemu/{vmid}/status/{acts[action]}"
)
return f"OK VM #{vmid} {action}\n{result}"
except Exception as e:
return "ERR " + str(e)
@staticmethod
def vm_create(
vmid,
name,
template_vmid=DEFAULT_TEMPLATE_VMID,
memory=4096,
cores=2,
disk_size="32G",
disk_storage="local-lvm",
iso="",
bridge=DEFAULT_BRIDGE,
ip="dhcp",
netmask=24,
gw="",
ostype="l26",
agent=1,
ciuser="",
cipassword="",
sshkeys="",
node=DEFAULT_NODE,
start_now=True,
):
"""
Универсальное создание VM.
Режимы:
1. Из шаблона (по умолч.): клонирует VM #1000 (cloud-init)
2. С ISO: template_vmid=None, iso="local:iso/debian-12.iso"
Параметры cloud-init:
ciuser: имя пользователя в гостевой ОС
cipassword: пароль
sshkeys: SSH публичные ключи
"""
try:
# Режим 1: клон из шаблона
if template_vmid:
return Tools._vm_create_from_template(
vmid=vmid, name=name, template_vmid=template_vmid,
memory=memory, cores=cores, storage=disk_storage,
start_now=start_now, ciuser=ciuser, cipassword=cipassword,
sshkeys=sshkeys, node=node,
)
# Режим 2: создание с нуля (ISO)
if not name:
return "Error: name required"
if not vmid:
return "Error: vmid required"
exist_vm = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/qemu")]
exist_lxc = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/lxc")]
if str(vmid) in exist_vm + exist_lxc:
return f"Error: VMID {vmid} already exists"
params = {
"vmid": vmid, "name": name, "memory": memory, "cores": cores,
"sockets": 1, "ostype": ostype, "agent": str(agent),
}
if ":" in disk_size:
params["virtio0"] = disk_size
else:
params["virtio0"] = f"{disk_storage}:{disk_size}"
if iso:
params["ide2"] = f"{iso},media=cdrom"
params["boot"] = "order=ide2;virtio0"
net = f"name=eth0,bridge={bridge}"
if ip and ip.lower() != "dhcp":
net += f",ip={ip}/{netmask}"
if gw:
net += f",gw={gw}"
else:
net += ",ip=dhcp"
params["net0"] = net
if start_now:
params["start"] = 1
result = _api("POST", f"/nodes/{node}/qemu", data=params)
out = [f"OK VM #{vmid}: {name} creating!"]
out.append(f" CPU: {cores} cores, RAM: {memory} MB")
out.append(f" Disk: {params['virtio0']}")
if iso:
out.append(f" ISO: {iso}")
out.append(f" Net: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}")
out.append(f" QEMU Agent: {'on' if agent else 'off'}")
if isinstance(result, str) and result:
out.append(f"UPID: {result}")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)
@staticmethod
def _vm_create_from_template(
vmid, name, template_vmid, full=True,
memory=None, cores=None, storage=None,
start_now=True, ciuser="", cipassword="", sshkeys="", node=DEFAULT_NODE,
):
"""Создать VM клонированием из шаблона (внутренний метод)."""
try:
exist_vm = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/qemu")]
exist_lxc = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/lxc")]
if str(vmid) in exist_vm + exist_lxc:
return f"Error: VMID {vmid} already exists"
params = {"newid": vmid, "name": name, "full": 1 if full else 0}
if storage:
params["storage"] = storage
result = _api("POST", f"/nodes/{node}/qemu/{template_vmid}/clone", data=params)
out = [f"OK VM #{vmid}: {name} cloning from template #{template_vmid}!"]
out.append(f" Type: {'full' if full else 'linked'} clone")
if storage:
out.append(f" Storage: {storage}")
upid = result if isinstance(result, str) else ""
if upid:
out.append(f"UPID: {upid}")
out.append("Waiting for clone to finish...")
ok, msg = _wait_for_task(upid, timeout=120)
out.append(f" {'Clone done' if ok else f'WARN: {msg}'}")
config_params = {}
if memory is not None:
config_params["memory"] = memory
if cores is not None:
config_params["cores"] = cores
if ciuser:
config_params["ciuser"] = ciuser
if cipassword:
config_params["cipassword"] = cipassword
if sshkeys:
config_params["sshkeys"] = sshkeys
if config_params:
out.append("Applying settings...")
try:
_api("PUT", f"/nodes/{node}/qemu/{vmid}/config", data=config_params)
if memory is not None:
out.append(f" RAM -> {memory} MB")
if cores is not None:
out.append(f" CPU -> {cores} cores")
if ciuser:
out.append(f" Cloud-init user: {ciuser}")
if cipassword:
out.append(" Cloud-init password: (set)")
if sshkeys:
out.append(" Cloud-init SSH keys: (set)")
out.append(" Settings applied")
except Exception as e2:
out.append(f" WARN Could not apply settings: {e2}")
if start_now:
out.append("Starting VM...")
try:
_api("POST", f"/nodes/{node}/qemu/{vmid}/status/start")
out.append(f" VM #{vmid} started!")
except Exception as e3:
out.append(f" WARN Could not start: {e3}")
else:
out.append(f" VM #{vmid} created (not started)")
return "\n".join(out)
except Exception as e:
return "ERR " + str(e)