🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
"""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/<job_id>")
@_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/<job_id>", 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)