first commit

This commit is contained in:
root
2026-04-12 21:50:55 +02:00
parent de90bbb333
commit ec0e8a3206
1447 changed files with 238414 additions and 0 deletions

547
app.py Normal file
View File

@@ -0,0 +1,547 @@
import os
import re
import time
import ipaddress
from functools import wraps
import requests
import urllib3
from flask import (
Flask,
render_template,
request,
jsonify,
session,
redirect,
url_for,
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "change-me-now")
PROXMOX_BASE_URL = os.getenv("PROXMOX_BASE_URL", "").rstrip("/")
PROXMOX_TOKEN_ID = os.getenv("PROXMOX_TOKEN_ID", "")
PROXMOX_TOKEN_SECRET = os.getenv("PROXMOX_TOKEN_SECRET", "")
PROXMOX_VERIFY_SSL = os.getenv("PROXMOX_VERIFY_SSL", "false").lower() == "true"
APP_USERNAME = os.getenv("APP_USERNAME", "admin")
APP_PASSWORD = os.getenv("APP_PASSWORD", "admin")
DEFAULT_NODE = os.getenv("DEFAULT_NODE", "")
DEFAULT_STORAGE = os.getenv("DEFAULT_STORAGE", "")
DEFAULT_BRIDGE = os.getenv("DEFAULT_BRIDGE", "vmbr0")
DEFAULT_SEARCH_DOMAIN = os.getenv("DEFAULT_SEARCH_DOMAIN", "local")
ALLOWED_SUBNET = os.getenv("ALLOWED_SUBNET", "192.168.50.0/24")
DEFAULT_GATEWAY = os.getenv("DEFAULT_GATEWAY", "")
DEFAULT_DNS = os.getenv("DEFAULT_DNS", "")
def proxmox_headers():
return {
"Authorization": f"PVEAPIToken={PROXMOX_TOKEN_ID}={PROXMOX_TOKEN_SECRET}"
}
def proxmox_get(path, params=None):
url = f"{PROXMOX_BASE_URL}/api2/json{path}"
response = requests.get(
url,
headers=proxmox_headers(),
params=params,
verify=PROXMOX_VERIFY_SSL,
timeout=60,
)
response.raise_for_status()
return response.json()
def proxmox_post(path, data=None):
url = f"{PROXMOX_BASE_URL}/api2/json{path}"
response = requests.post(
url,
headers=proxmox_headers(),
data=data,
verify=PROXMOX_VERIFY_SSL,
timeout=120,
)
response.raise_for_status()
return response.json()
def login_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("logged_in"):
if request.path.startswith("/api/"):
return jsonify({"success": False, "error": "Nepřihlášený uživatel."}), 401
return redirect(url_for("login"))
return fn(*args, **kwargs)
return wrapper
def validate_hostname(hostname: str):
hostname = hostname.strip().lower()
if len(hostname) < 2 or len(hostname) > 63:
raise ValueError("Hostname musí mít 2 až 63 znaků.")
if hostname.startswith("-") or hostname.endswith("-"):
raise ValueError("Hostname nesmí začínat ani končit pomlčkou.")
if not re.fullmatch(r"[a-z0-9-]+", hostname):
raise ValueError("Hostname může obsahovat jen malá písmena, čísla a pomlčku.")
return hostname
def validate_vmid(vmid):
try:
vmid = int(vmid)
except (TypeError, ValueError):
raise ValueError("VMID musí být číslo.")
if vmid < 100 or vmid > 999999999:
raise ValueError("VMID je mimo povolený rozsah.")
return vmid
def validate_int_field(value, field_name, min_value, max_value):
try:
value = int(value)
except (TypeError, ValueError):
raise ValueError(f"{field_name} musí být číslo.")
if value < min_value or value > max_value:
raise ValueError(f"{field_name} musí být v rozsahu {min_value}{max_value}.")
return value
def validate_vlan(vlan):
if vlan in (None, "", "null"):
return None
try:
vlan = int(vlan)
except (TypeError, ValueError):
raise ValueError("VLAN musí být číslo.")
if vlan < 1 or vlan > 4094:
raise ValueError("VLAN musí být v rozsahu 1 až 4094.")
return vlan
def validate_ip_config(ip_mode, ip_last_octet, gateway):
network = ipaddress.ip_network(ALLOWED_SUBNET, strict=False)
if ip_mode == "dhcp":
return "dhcp", None
if ip_mode != "static":
raise ValueError("Neplatný IP režim.")
try:
last_octet = int(ip_last_octet)
except (TypeError, ValueError):
raise ValueError("Poslední oktet IP adresy musí být číslo.")
if last_octet < 1 or last_octet > 254:
raise ValueError("Poslední oktet IP adresy musí být v rozsahu 1 až 254.")
base = str(network.network_address).split(".")
ip_addr = f"{base[0]}.{base[1]}.{base[2]}.{last_octet}"
try:
iface = ipaddress.ip_interface(f"{ip_addr}/{network.prefixlen}")
except ValueError:
raise ValueError("Neplatná IP adresa.")
if iface.ip not in network:
raise ValueError("IP adresa není v povoleném subnetu.")
if gateway:
try:
gw = ipaddress.ip_address(gateway)
except ValueError:
raise ValueError("Neplatná gateway.")
if gw not in network:
raise ValueError("Gateway není v povoleném subnetu.")
else:
if DEFAULT_GATEWAY:
try:
gw = ipaddress.ip_address(DEFAULT_GATEWAY)
except ValueError:
raise ValueError("DEFAULT_GATEWAY v .env není platná IP adresa.")
if gw not in network:
raise ValueError("DEFAULT_GATEWAY není v povoleném subnetu.")
else:
gw = list(network.hosts())[0]
return f"{iface.ip}/{network.prefixlen}", str(gw)
def build_net0(bridge, ip_mode, ip_last_octet=None, gateway=None, vlan=None):
if not bridge:
raise ValueError("Bridge je povinný.")
parts = [f"name=eth0", f"bridge={bridge}"]
if vlan is not None:
parts.append(f"tag={vlan}")
ip_value, gw = validate_ip_config(ip_mode, ip_last_octet, gateway)
if ip_value == "dhcp":
parts.append("ip=dhcp")
else:
parts.append(f"ip={ip_value}")
parts.append(f"gw={gw}")
return ",".join(parts)
def parse_upid_node(upid: str):
# typický formát: UPID:node:...
parts = upid.split(":")
if len(parts) > 1:
return parts[1]
return DEFAULT_NODE
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if username == APP_USERNAME and password == APP_PASSWORD:
session["logged_in"] = True
return redirect(url_for("index"))
return render_template("login.html", error="Neplatné přihlašovací údaje.")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/")
@login_required
def index():
subnet_prefix = ".".join(str(ipaddress.ip_network(ALLOWED_SUBNET, strict=False).network_address).split(".")[:3])
return render_template(
"index.html",
default_node=DEFAULT_NODE,
default_storage=DEFAULT_STORAGE,
default_bridge=DEFAULT_BRIDGE,
default_search_domain=DEFAULT_SEARCH_DOMAIN,
subnet_prefix=f"{subnet_prefix}.",
default_subnet_prefix=f"{subnet_prefix}.", # ← přidat
allowed_subnet=ALLOWED_SUBNET,
default_gateway=DEFAULT_GATEWAY,
default_dns=DEFAULT_DNS,
)
@app.route("/api/nodes")
@login_required
def api_nodes():
try:
payload = proxmox_get("/nodes")
nodes = []
for item in payload.get("data", []):
node = item.get("node")
if node:
nodes.append(node)
return jsonify({"success": True, "nodes": nodes})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/api/storage/<node>")
@login_required
def api_storage(node):
try:
payload = proxmox_get(f"/nodes/{node}/storage")
storages = []
for item in payload.get("data", []):
storage = item.get("storage")
if storage and item.get("enabled", 1):
storages.append(storage)
return jsonify({"success": True, "storage": storages})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/api/templates/<node>/<storage>")
@login_required
def api_templates(node, storage):
try:
template_storage = os.getenv("LXC_TEMPLATE_STORAGE", "local").strip() or "local"
payload = proxmox_get(f"/nodes/{node}/storage/{template_storage}/content")
templates = []
for item in payload.get("data", []):
if item.get("content") == "vztmpl":
volid = item.get("volid")
if volid:
templates.append(volid)
return jsonify({"success": True, "templates": sorted(templates)})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/api/next-vmid")
@login_required
def api_next_vmid():
try:
payload = proxmox_get("/cluster/nextid")
return jsonify({"success": True, "vmid": payload.get("data")})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/api/create-lxc", methods=["POST"])
@login_required
def api_create_lxc():
try:
data = request.get_json(force=True)
node = data.get("node", "").strip()
hostname = validate_hostname(data.get("hostname", ""))
vmid = validate_vmid(data.get("vmid"))
cores = validate_int_field(data.get("cores"), "CPU", 1, 64)
memory = validate_int_field(data.get("memory"), "RAM", 128, 262144)
disk = validate_int_field(data.get("disk"), "Disk", 1, 2048)
swap = validate_int_field(data.get("swap", 512), "Swap", 0, 65536)
storage = data.get("storage", "").strip()
template = data.get("template", "").strip()
bridge = data.get("bridge", DEFAULT_BRIDGE).strip()
searchdomain = data.get("searchdomain", DEFAULT_SEARCH_DOMAIN).strip()
nameserver = data.get("nameserver", "").strip() or os.getenv("DEFAULT_DNS", "")
password = data.get("password", "")
start_on_boot = 1 if data.get("start_on_boot", True) else 0
unprivileged = 1 if data.get("unprivileged", True) else 0
vlan = validate_vlan(data.get("vlan"))
if not node:
raise ValueError("Node je povinný.")
if not storage:
raise ValueError("Storage je povinné.")
if not template:
raise ValueError("LXC template je povinná.")
if not password:
raise ValueError("Root heslo je povinné.")
ip_mode = data.get("ip_mode", "dhcp")
ip_last_octet = data.get("ip_last_octet")
gateway = data.get("gateway", "").strip() or None
net0 = build_net0(
bridge=bridge,
ip_mode=ip_mode,
ip_last_octet=ip_last_octet,
gateway=gateway,
vlan=vlan,
)
enable_nesting = bool(data.get("enable_nesting", False))
payload = {
"vmid": vmid,
"hostname": hostname,
"ostemplate": template,
"rootfs": f"{storage}:{disk}",
"cores": cores,
"memory": memory,
"swap": swap,
"password": password,
"net0": net0,
"onboot": start_on_boot,
"unprivileged": unprivileged,
}
if enable_nesting:
if not unprivileged:
raise ValueError("Nesting je povolen jen pro unprivileged kontejnery.")
payload["features"] = "nesting=1"
if searchdomain:
payload["searchdomain"] = searchdomain
if nameserver:
payload["nameserver"] = nameserver
result = proxmox_post(f"/nodes/{node}/lxc", data=payload)
upid = result.get("data")
return jsonify({
"success": True,
"upid": upid,
"node": node,
"vmid": vmid,
"hostname": hostname,
})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/api/task-status")
@login_required
def api_task_status():
try:
node = request.args.get("node", "").strip()
upid = request.args.get("upid", "").strip()
if not upid:
raise ValueError("Chybí UPID tasku.")
if not node:
node = parse_upid_node(upid)
status_payload = proxmox_get(f"/nodes/{node}/tasks/{upid}/status")
log_payload = proxmox_get(f"/nodes/{node}/tasks/{upid}/log", params={"start": 0, "limit": 50})
status_data = status_payload.get("data", {})
log_data = log_payload.get("data", [])
exitstatus = status_data.get("exitstatus")
running = status_data.get("status") == "running"
if running:
progress = 50
elif exitstatus == "OK":
progress = 100
else:
progress = 100
logs = [entry.get("t", "") for entry in log_data if entry.get("t")]
return jsonify({
"success": True,
"running": running,
"status": status_data.get("status"),
"exitstatus": exitstatus,
"starttime": status_data.get("starttime"),
"endtime": status_data.get("endtime"),
"progress": progress,
"logs": logs[-10:],
})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/api/container-details/<node>/<int:vmid>")
@login_required
def api_container_details(node, vmid):
try:
config_payload = proxmox_get(f"/nodes/{node}/lxc/{vmid}/config")
status_payload = proxmox_get(f"/nodes/{node}/lxc/{vmid}/status/current")
config = config_payload.get("data", {})
status = status_payload.get("data", {})
return jsonify({
"success": True,
"details": {
"vmid": vmid,
"node": node,
"hostname": config.get("hostname"),
"ostemplate": config.get("ostemplate"),
"cores": config.get("cores"),
"memory": config.get("memory"),
"swap": config.get("swap"),
"rootfs": config.get("rootfs"),
"net0": config.get("net0"),
"searchdomain": config.get("searchdomain"),
"nameserver": config.get("nameserver"),
"status": status.get("status"),
"uptime": status.get("uptime"),
"cpus": status.get("cpus"),
"maxmem": status.get("maxmem"),
"maxdisk": status.get("maxdisk"),
"ip_mode_subnet": ALLOWED_SUBNET,
}
})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
@app.route("/health")
def health():
return jsonify({"success": True, "status": "ok"})
@app.route("/api/start-lxc", methods=["POST"])
@login_required
def api_start_lxc():
try:
data = request.get_json(force=True)
node = data.get("node", "").strip()
vmid = validate_vmid(data.get("vmid"))
if not node:
raise ValueError("Node je povinný.")
result = proxmox_post(f"/nodes/{node}/lxc/{vmid}/status/start", data={})
upid = result.get("data")
return jsonify({
"success": True,
"upid": upid,
"node": node,
"vmid": vmid,
})
except requests.exceptions.HTTPError as e:
body = e.response.text if e.response is not None else str(e)
return jsonify({"success": False, "error": f"Proxmox HTTP {e.response.status_code}: {body}"}), 500
except ValueError as e:
return jsonify({"success": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"success": False, "error": f"Neočekávaná chyba: {str(e)}"}), 500
if __name__ == "__main__":
host = os.getenv("APP_HOST", "0.0.0.0")
port = int(os.getenv("APP_PORT", "8000"))
debug = os.getenv("FLASK_DEBUG", "false").lower() == "true"
app.run(host=host, port=port, debug=debug)