"""Blueprint: Panel Admina VPS — zarządzanie serwerem z podstronami. Dashboard / Usługi (systemd) / Procesy / Dysk / Logi / Terminal (SSE). """ import os import re import shutil import subprocess import threading import time import queue as qmod from functools import wraps from datetime import datetime, timezone from flask import Blueprint, render_template, request, session, redirect, flash, jsonify, Response, send_file admin_vps = Blueprint("admin_vps", __name__) LOG_DIR = "/var/log/pagan-pkgs" SYSTEM_LOG_DIR = "/var/log" def _admin_required(f): @wraps(f) def wrap(*a, **kw): if not session.get("is_admin"): flash("Wymagane uprawnienia admina", "error") return redirect("/") return f(*a, **kw) return wrap # ── Pomocnicze ──────────────────────────────────────────────────────────────── def _sh(cmd, timeout=15): try: p = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) return p.stdout.strip(), p.returncode except Exception as e: return str(e), -1 def _human(n): for unit in ["B", "KB", "MB", "GB", "TB"]: if n < 1024 or unit == "TB": return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" n /= 1024 return f"{n:.1f} TB" def _uptime(): try: with open("/proc/uptime") as f: s = float(f.read().split()[0]) d = int(s // 86400); h = int((s % 86400) // 3600); m = int((s % 3600) // 60) return f"{d}d {h}h {m}m" except Exception: return "?" def _meminfo(): out = {} try: with open("/proc/meminfo") as f: for line in f: if ":" in line: k, v = line.split(":", 1) out[k.strip()] = int(v.split()[0]) except Exception: pass return out def _cpu_pct(): """Procent CPU (średnia z 2 odczytów /proc/stat).""" try: def _read(): with open("/proc/stat") as f: vals = f.readline().split()[1:] idle = int(vals[3]) + int(vals[4]) total = sum(int(x) for x in vals) return idle, total i1, t1 = _read() time.sleep(0.3) i2, t2 = _read() return round(100.0 * (1 - (i2 - i1) / (t2 - t1)), 1) except Exception: return 0.0 # ── Dashboard (strona + statystyki JSON) ───────────────────────────────────── @admin_vps.route("/admin") @_admin_required def admin_index(): return render_template("admin/dashboard.html", nav="index") @admin_vps.route("/admin/vps/stats") @_admin_required def vps_stats(): mem = _meminfo() ram_t = mem.get("MemTotal", 0) // 1024 ram_a = mem.get("MemAvailable", 0) // 1024 ram_u = ram_t - ram_a du = shutil.disk_usage("/") return jsonify({ "hostname": os.uname().nodename, "os": f"{os.uname().sysname} {os.uname().release}", "arch": os.uname().machine, "uptime": _uptime(), "cpu_cores": os.cpu_count() or "?", "cpu_pct": _cpu_pct(), "load": open("/proc/loadavg").read().split()[:3] if os.path.exists("/proc/loadavg") else ["?"], "ram_total": ram_t, "ram_used": ram_u, "ram_avail": ram_a, "ram_pct": round(ram_u / ram_t * 100, 1) if ram_t else 0, "swap_total": mem.get("SwapTotal", 0) // 1024, "swap_used": (mem.get("SwapTotal", 0) - mem.get("SwapFree", 0)) // 1024, "disk_total": du.total // 1073741824, "disk_used": du.used // 1073741824, "disk_free": du.free // 1073741824, "disk_pct": round(du.used / du.total * 100, 1) if du.total else 0, "procs": _sh("ps --no-headers -eo pid 2>/dev/null | wc -l")[0] or "?", "time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), }) # ── Usługi systemd ──────────────────────────────────────────────────────────── @admin_vps.route("/admin/services") @_admin_required def services(): return render_template("admin/services.html", nav="services") @admin_vps.route("/admin/services/api") @_admin_required def services_api(): out, _ = _sh("systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null | head -300") svcs = [] for line in out.splitlines(): parts = line.split() if len(parts) < 4: continue name, load, active, sub = parts[0], parts[1], parts[2], parts[3] svcs.append({"name": name, "load": load, "active": active, "sub": sub}) return jsonify({"services": svcs, "count": len(svcs)}) @admin_vps.route("/admin/services/action", methods=["POST"]) @_admin_required def services_action(): name = request.form.get("name", "") action = request.form.get("action", "") if not name or action not in ("start", "stop", "restart", "enable", "disable", "reload", "status"): return jsonify({"ok": False, "error": "nieprawidłowe parametry"}) allowed = re.fullmatch(r"[A-Za-z0-9@._-]+", name or "") if not allowed: return jsonify({"ok": False, "error": "niedozwolona nazwa"}) if action == "status": o, rc = _sh(f"systemctl --no-pager status {name} 2>&1 | head -20", timeout=20) return jsonify({"ok": True, "output": o, "rc": rc}) o, rc = _sh(f"systemctl {action} {name} 2>&1", timeout=60) o2, rc2 = _sh(f"systemctl is-active {name} 2>&1", timeout=10) return jsonify({"ok": rc == 0, "output": o, "rc": rc, "active": o2.strip(), "is_active_rc": rc2}) # ── Procesy ─────────────────────────────────────────────────────────────────── @admin_vps.route("/admin/processes") @_admin_required def processes(): return render_template("admin/processes.html", nav="processes") @admin_vps.route("/admin/processes/api") @_admin_required def processes_api(): q = request.args.get("q", "").strip() cmd = "ps -eo pid,ppid,user,%cpu,%mem,etime,comm,args --no-headers --sort=-%cpu 2>/dev/null | head -200" if q and re.fullmatch(r"[A-Za-z0-9_.@ +-]+", q): cmd = f"ps -eo pid,ppid,user,%cpu,%mem,etime,comm,args --no-headers 2>/dev/null | grep -i -- '{q}' | head -200" out, _ = _sh(cmd, timeout=15) procs = [] for line in out.splitlines(): m = re.match(r"^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$", line) if not m: continue procs.append({"pid": m.group(1), "ppid": m.group(2), "user": m.group(3), "cpu": m.group(4), "mem": m.group(5), "time": m.group(6), "comm": m.group(7), "args": m.group(8)[:120]}) return jsonify({"processes": procs, "count": len(procs), "q": q}) @admin_vps.route("/admin/processes/kill", methods=["POST"]) @_admin_required def processes_kill(): pid = request.form.get("pid", "").strip() if not re.fullmatch(r"\d+", pid or ""): return jsonify({"ok": False, "error": "zły PID"}) o, rc = _sh(f"kill -9 {pid} 2>&1", timeout=10) return jsonify({"ok": rc == 0, "output": o, "rc": rc}) # ── Dysk ────────────────────────────────────────────────────────────────────── @admin_vps.route("/admin/disk") @_admin_required def disk(): return render_template("admin/disk.html", nav="disk") @admin_vps.route("/admin/disk/api") @_admin_required def disk_api(): df, _ = _sh("df -h -x tmpfs -x devtmpfs 2>/dev/null | head -30", timeout=15) mounts = [] for line in df.splitlines(): parts = line.split() if len(parts) < 6: continue mounts.append({"fs": parts[0], "size": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4], "mnt": parts[5]}) top, _ = _sh("du -xhd1 / 2>/dev/null | sort -rh | head -12", timeout=30) top_dirs = [] for line in top.splitlines(): parts = line.split("\t") if len(parts) == 2: top_dirs.append({"size": parts[0], "path": parts[1]}) return jsonify({"mounts": mounts, "top_dirs": top_dirs}) # ── Logi ────────────────────────────────────────────────────────────────────── @admin_vps.route("/admin/logs") @_admin_required def logs(): return render_template("admin/logs.html", nav="logs") @admin_vps.route("/admin/logs/api") @_admin_required def logs_api(): src = request.args.get("src", "build") if src == "build": d = LOG_DIR else: d = SYSTEM_LOG_DIR files = [] if os.path.isdir(d): for fn in sorted(os.listdir(d), key=lambda x: os.path.getmtime(os.path.join(d, x)), reverse=True)[:200]: fp = os.path.join(d, fn) if not os.path.isfile(fp): continue try: sz = os.path.getsize(fp) mtime = datetime.fromtimestamp(os.path.getmtime(fp)).strftime("%Y-%m-%d %H:%M:%S") except Exception: continue files.append({"name": fn, "size": _human(sz), "mtime": mtime}) return jsonify({"files": files, "src": src, "dir": d}) @admin_vps.route("/admin/logs/view") @_admin_required def logs_view(): src = request.args.get("src", "build") name = request.args.get("name", "") tail = request.args.get("tail", "200") base = LOG_DIR if src == "build" else SYSTEM_LOG_DIR if not _safe_log_name(name): return jsonify({"ok": False, "error": "niedozwolona nazwa"}) fp = os.path.join(base, name) if not os.path.isfile(fp): return jsonify({"ok": False, "error": "brak pliku"}) try: t = tail if re.fullmatch(r"\d+", tail or "") else "200" out, _ = _sh(f"tail -n {t} {shlex_quote(fp)} 2>&1", timeout=15) return jsonify({"ok": True, "name": name, "content": out}) except Exception as e: return jsonify({"ok": False, "error": str(e)}) def _safe_log_name(name): """Bezpieczna nazwa pliku logu – bez ścieżek i plików ukrytych.""" return bool(name) and not ("/" in name or name.startswith(".")) \ and re.fullmatch(r"[A-Za-z0-9_.@+ -]+", name or "") is not None def _newest_log(base): """Najnowszy zwykły plik w katalogu logów (jak na liście) albo None.""" best = None if os.path.isdir(base): for fn in os.listdir(base): fp = os.path.join(base, fn) if not os.path.isfile(fp) or not _safe_log_name(fn): continue try: mt = os.path.getmtime(fp) except Exception: continue if best is None or mt > best[1]: best = (fn, mt) return best[0] if best else None @admin_vps.route("/admin/logs/download") @_admin_required def logs_download(): """Pobiera plik logu (wybrany albo najnowszy w źródle).""" src = request.args.get("src", "build") name = request.args.get("name", "") base = LOG_DIR if src == "build" else SYSTEM_LOG_DIR if name: if not _safe_log_name(name): return jsonify({"error": "niedozwolona nazwa"}), 400 fp = os.path.join(base, name) else: newest = _newest_log(base) if not newest: return jsonify({"error": "brak logów do pobrania"}), 404 fp = os.path.join(base, newest) if not os.path.isfile(fp): return jsonify({"error": "brak pliku"}), 404 return send_file(fp, as_attachment=True, download_name=os.path.basename(fp)) @admin_vps.route("/admin/logs/clear", methods=["POST"]) @_admin_required def logs_clear(): """Czyści logi (opróżnia pliki). - wybrany plik: zawsze, - cały katalog: tylko dla buildów (LOG_DIR) – /var/log (systemowe) omijamy, żeby nie uszkodzić np. dziennika systemd. """ src = request.args.get("src", "build") name = request.form.get("name") or request.args.get("name", "") if name: base = LOG_DIR if src == "build" else SYSTEM_LOG_DIR if not _safe_log_name(name): return jsonify({"ok": False, "error": "niedozwolona nazwa"}), 400 fp = os.path.join(base, name) if not os.path.isfile(fp): return jsonify({"ok": False, "error": "brak pliku"}), 404 try: open(fp, "w").close() except Exception as e: return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": True, "cleared": [name]}) # bez nazwy: tylko buildy if src != "build": return jsonify({"ok": False, "error": "Logi systemowe: najpierw kliknij konkretny plik do wyczyszczenia"}), 400 cleared = [] if os.path.isdir(LOG_DIR): for fn in os.listdir(LOG_DIR): if not _safe_log_name(fn): continue fp = os.path.join(LOG_DIR, fn) if not os.path.isfile(fp): continue try: open(fp, "w").close() cleared.append(fn) except Exception: pass return jsonify({"ok": True, "cleared": cleared}) def shlex_quote(s): return "'" + s.replace("'", "'\\''") + "'" # ── Terminal (SSE command runner) ───────────────────────────────────────────── _TERM_JOBS = {} _TERM_LOCK = threading.Lock() @admin_vps.route("/admin/terminal") @_admin_required def terminal(): return render_template("admin/terminal.html", nav="terminal") @admin_vps.route("/admin/terminal/exec", methods=["POST"]) @_admin_required def terminal_exec(): data = request.get_json(silent=True) or {} cmd = (data.get("cmd") or "").strip() cwd = (data.get("cwd") or "/root").strip() if not cmd: return jsonify({"ok": False, "error": "pusta komenda"}) if len(cmd) > 4000: return jsonify({"ok": False, "error": "komenda za długa"}) # Whitelist znaków — blokujemy ew. wstrzyknięcia przez niepotrzebne sekwencje if "\x00" in cmd or re.search(r"[\r\x1b]", cmd): return jsonify({"ok": False, "error": "niedozwolone znaki w komendzie"}) job_id = f"j{int(time.time()*1000)}" q = qmod.Queue() try: proc = subprocess.Popen( ["/bin/bash", "-c", cmd], cwd=cwd if os.path.isdir(cwd) else "/root", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", bufsize=1, ) except Exception as e: return jsonify({"ok": False, "error": str(e)}) def _pump(): try: for line in proc.stdout: q.put(line) except Exception: pass try: proc.wait() except Exception: pass q.put(None) with _TERM_LOCK: _TERM_JOBS[job_id] = {"proc": proc, "q": q, "start": time.time()} threading.Thread(target=_pump, daemon=True).start() return jsonify({"ok": True, "job": job_id}) @admin_vps.route("/admin/terminal/stream/") @_admin_required def terminal_stream(job_id): with _TERM_LOCK: job = _TERM_JOBS.get(job_id) if not job: return jsonify({"ok": False, "error": "brak zadania"}) q = job["q"] proc = job["proc"] def gen(): yield "data: " + json_dumps({"type": "start"}) + "\n\n" while True: try: line = q.get(timeout=25) except qmod.Empty: yield ": ping\n\n" continue if line is None: rc = proc.poll() with _TERM_LOCK: _TERM_JOBS.pop(job_id, None) yield "data: " + json_dumps({"type": "end", "rc": rc}) + "\n\n" return yield "data: " + json_dumps({"type": "out", "data": line}) + "\n\n" return Response(stream_with_context(gen()), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) @admin_vps.route("/admin/terminal/kill/", methods=["POST"]) @_admin_required def terminal_kill(job_id): with _TERM_LOCK: job = _TERM_JOBS.get(job_id) if job: _TERM_JOBS.pop(job_id, None) if job: try: job["proc"].kill() except Exception: pass return jsonify({"ok": True}) return jsonify({"ok": False, "error": "brak zadania"}) def json_dumps(obj): import json return json.dumps(obj, ensure_ascii=False) def stream_with_context(gen): from flask import stream_with_context as _swc return _swc(gen)