first commit
This commit is contained in:
547
app.py
Normal file
547
app.py
Normal 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} až {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)
|
||||
Reference in New Issue
Block a user