Files
deploy-panel/app.py
T
2026-09-23 19:42:44 +03:00

556 lines
19 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.
import json
import os
import re
import shutil
import subprocess
import time
from datetime import datetime, timezone
import requests
from flask import Flask, jsonify, redirect, render_template_string, request
app = Flask(__name__)
CF_API_TOKEN = os.environ["CF_API_TOKEN"]
CF_ZONE_ID = os.environ["CF_ZONE_ID"]
SELF_NAME = os.environ.get("SELF_CONTAINER_NAME", "")
API_TOKEN = os.environ["PANEL_API_TOKEN"]
SERVER_IP = os.environ.get("SERVER_IP", "148.135.181.126")
ROUTER_RULE_RE = re.compile(r"^traefik\.http\.routers\.[^.]+\.rule$")
def check_auth():
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {API_TOKEN}"
def extract_hostname(labels):
for key, value in labels.items():
if ROUTER_RULE_RE.match(key) and "`" in value:
return value.split("`")[1]
return None
def human_relative(iso_ts):
if not iso_ts or iso_ts.startswith("0001-01-01"):
return None
try:
dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
except ValueError:
return None
secs = int((datetime.now(timezone.utc) - dt).total_seconds())
if secs < 60:
return f"{secs} sn önce"
mins = secs // 60
if mins < 60:
return f"{mins} dk önce"
hours = mins // 60
if hours < 24:
return f"{hours} sa önce"
days = hours // 24
return f"{days} gün önce"
def _cpu_times():
with open("/proc/stat") as f:
parts = f.readline().split()[1:]
nums = [int(x) for x in parts]
idle = nums[3] + nums[4]
total = sum(nums)
return idle, total
def server_stats():
stats = {}
idle1, total1 = _cpu_times()
time.sleep(0.3)
idle2, total2 = _cpu_times()
d_idle = idle2 - idle1
d_total = total2 - total1
stats["cpu_percent"] = round((1 - d_idle / d_total) * 100, 1) if d_total > 0 else 0.0
stats["cpu_cores"] = os.cpu_count()
with open("/proc/loadavg") as f:
stats["load"] = " / ".join(f.read().split()[:3])
meminfo = {}
with open("/proc/meminfo") as f:
for line in f:
key, _, rest = line.partition(":")
meminfo[key] = int(rest.strip().split()[0])
mem_total = meminfo.get("MemTotal", 0)
mem_available = meminfo.get("MemAvailable", 0)
mem_used = mem_total - mem_available
stats["mem_used_gb"] = round(mem_used / 1024 / 1024, 1)
stats["mem_total_gb"] = round(mem_total / 1024 / 1024, 1)
stats["mem_percent"] = round(mem_used / mem_total * 100, 1) if mem_total else 0.0
disk = shutil.disk_usage("/")
stats["disk_used_gb"] = round(disk.used / 1024**3, 1)
stats["disk_total_gb"] = round(disk.total / 1024**3, 1)
stats["disk_percent"] = round(disk.used / disk.total * 100, 1) if disk.total else 0.0
with open("/proc/uptime") as f:
up_secs = int(float(f.read().split()[0]))
days, rem = divmod(up_secs, 86400)
hours, _ = divmod(rem, 3600)
stats["uptime"] = f"{days} gün {hours} saat" if days else f"{hours} saat"
all_states = subprocess.run(
["docker", "ps", "-a", "--format", "{{.State}}"],
capture_output=True, text=True,
).stdout.split()
stats["containers_running"] = sum(1 for s in all_states if s == "running")
stats["containers_total"] = len(all_states)
return stats
def inspect_all(cid):
return json.loads(subprocess.run(
["docker", "inspect", cid], capture_output=True, text=True, check=True
).stdout)[0]
def port_summary(info):
ports = info.get("NetworkSettings", {}).get("Ports") or {}
out = []
for container_port, bindings in ports.items():
if not bindings:
continue
for b in bindings:
out.append(f"{b.get('HostPort')}{container_port}")
return ", ".join(out) if out else None
def list_all_containers():
out = subprocess.run(
["docker", "ps", "-a", "--format", "{{.ID}}"],
capture_output=True, text=True, check=True,
).stdout.split()
containers = []
for cid in out:
info = inspect_all(cid)
name = info["Name"].lstrip("/")
if name == SELF_NAME:
continue
labels = info["Config"]["Labels"] or {}
state = info["State"]
status = state["Status"]
when = human_relative(state.get("StartedAt") if status == "running" else state.get("FinishedAt"))
containers.append({
"name": name,
"hostname": extract_hostname(labels),
"status": status,
"since": when,
"image": info["Config"]["Image"],
"ports": port_summary(info),
})
containers.sort(key=lambda c: c["name"])
return containers
def docker_system_df():
out = subprocess.run(["docker", "system", "df"], capture_output=True, text=True).stdout
lines = out.strip().splitlines()
rows = []
for line in lines[1:]:
parts = re.split(r"\s{2,}", line.strip())
if len(parts) >= 4:
rows.append({
"type": parts[0], "total": parts[1], "active": parts[2],
"size": parts[3], "reclaimable": parts[4] if len(parts) > 4 else "",
})
return rows
def find_container_hostname(name):
result = subprocess.run(["docker", "inspect", name], capture_output=True, text=True)
if result.returncode != 0:
return None
info = json.loads(result.stdout)[0]
labels = info["Config"]["Labels"] or {}
return extract_hostname(labels)
def cert_issuer(hostname, timeout=5):
"""Traefik'in o hostname icin su an sundugu sertifikanin issuer'ini
dondurur (bos string = baglanti kurulamadi)."""
try:
p1 = subprocess.run(
["openssl", "s_client", "-connect", "traefik:443", "-servername", hostname],
input="", capture_output=True, text=True, timeout=timeout,
)
p2 = subprocess.run(
["openssl", "x509", "-noout", "-issuer"],
input=p1.stdout, capture_output=True, text=True, timeout=timeout,
)
return p2.stdout.strip()
except Exception:
return ""
def wait_for_real_cert(hostname, max_wait=50):
"""Traefik yeni bir hostname icin ilk ACME denemesini DNS/Cloudflare
tam hazir olmadan yapabiliyor ve kendiliginden hizli tekrar
denemiyor. Bu yuzden: gercek (Let's Encrypt) sertifika gelene kadar
bekle; birkac saniye icinde gelmezse Traefik'i bir kez yeniden
baslatarak ACME denemesini zorla tetikle."""
deadline = time.time() + max_wait
nudged = False
while time.time() < deadline:
if "Let's Encrypt" in cert_issuer(hostname):
return True
if not nudged and time.time() > deadline - max_wait + 8:
subprocess.run(["docker", "restart", "traefik"])
nudged = True
time.sleep(3)
time.sleep(2)
return "Let's Encrypt" in cert_issuer(hostname)
def ensure_dns(hostname):
existing = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records",
params={"type": "A", "name": hostname},
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
timeout=10,
).json()
if existing.get("result"):
return {"created": False}
requests.post(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records",
headers={"Authorization": f"Bearer {CF_API_TOKEN}", "Content-Type": "application/json"},
json={"type": "A", "name": hostname, "content": SERVER_IP, "ttl": 1, "proxied": True},
timeout=10,
)
return {"created": True}
def delete_dns_record(hostname):
if not hostname:
return
r = requests.get(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records",
params={"name": hostname},
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
timeout=10,
).json()
for rec in r.get("result", []):
requests.delete(
f"https://api.cloudflare.com/client/v4/zones/{CF_ZONE_ID}/dns_records/{rec['id']}",
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
timeout=10,
)
TEMPLATE = """
<!doctype html>
<html>
<head>
<title>Deploy Panel</title>
<meta charset="utf-8">
<meta http-equiv="refresh" content="20">
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2.5rem; }
h1 { color: #38bdf8; margin-bottom: 0.2rem; }
h2 { color: #38bdf8; font-size: 1rem; margin: 2rem 0 0.8rem; }
.sub { opacity: 0.55; font-size: 0.85rem; margin-bottom: 1.5rem; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: 0.7rem; border-bottom: 1px solid #334155; text-align: left; vertical-align: middle; }
th { opacity: 0.7; font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: .03em; }
a { color: #38bdf8; text-decoration: none; }
a:hover { text-decoration: underline; }
.badge { padding: 0.25rem 0.65rem; border-radius: 999px; font-size: 0.78rem; font-weight: 600; }
.running { background: #065f46; color: #d1fae5; }
.exited { background: #7f1d1d; color: #fecaca; }
.since { opacity: 0.55; font-size: 0.8rem; display: block; }
.actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
button, .btn { border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer;
font-size: 0.85rem; color: white; }
.btn-restart, .btn-start { background: #1e3a8a; }
.btn-restart:hover, .btn-start:hover { background: #1e40af; }
.btn-stop { background: #78350f; }
.btn-stop:hover { background: #92400e; }
.btn-logs { background: #334155; }
.btn-logs:hover { background: #475569; }
.btn-delete { background: #7f1d1d; }
.btn-delete:hover { background: #991b1b; }
.btn-prune { background: #4c1d95; }
.btn-prune:hover { background: #5b21b6; }
.empty { opacity: 0.6; margin-top: 1.5rem; }
form { display: inline; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem; margin-bottom: 2rem; }
.stat-card { background: #1e293b; border-radius: 10px; padding: 1rem 1.2rem; }
.stat-label { font-size: 0.75rem; opacity: 0.6; text-transform: uppercase; letter-spacing: .03em; }
.stat-value { font-size: 1.5rem; font-weight: 700; margin: 0.15rem 0 0.5rem; }
.stat-sub { font-size: 0.78rem; opacity: 0.55; }
.bar { height: 6px; border-radius: 999px; background: #334155; overflow: hidden; margin-top: 0.5rem; }
.bar-fill { height: 100%; background: #38bdf8; }
.bar-fill.warn { background: #f59e0b; }
.bar-fill.crit { background: #ef4444; }
.prune-actions { display: flex; gap: 0.6rem; margin-top: 0.8rem; }
</style>
</head>
<body>
<h1>Deploy Panel</h1>
<div class="sub">{{ containers|length }} container · 20 saniyede bir otomatik yenilenir · <a href="/">şimdi yenile</a></div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">CPU ({{ s.cpu_cores }} çekirdek)</div>
<div class="stat-value">%{{ s.cpu_percent }}</div>
<div class="bar"><div class="bar-fill {{ 'crit' if s.cpu_percent > 85 else ('warn' if s.cpu_percent > 60 else '') }}" style="width: {{ s.cpu_percent }}%"></div></div>
<div class="stat-sub">yük: {{ s.load }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Bellek</div>
<div class="stat-value">%{{ s.mem_percent }}</div>
<div class="bar"><div class="bar-fill {{ 'crit' if s.mem_percent > 85 else ('warn' if s.mem_percent > 60 else '') }}" style="width: {{ s.mem_percent }}%"></div></div>
<div class="stat-sub">{{ s.mem_used_gb }} / {{ s.mem_total_gb }} GB</div>
</div>
<div class="stat-card">
<div class="stat-label">Disk</div>
<div class="stat-value">%{{ s.disk_percent }}</div>
<div class="bar"><div class="bar-fill {{ 'crit' if s.disk_percent > 85 else ('warn' if s.disk_percent > 60 else '') }}" style="width: {{ s.disk_percent }}%"></div></div>
<div class="stat-sub">{{ s.disk_used_gb }} / {{ s.disk_total_gb }} GB</div>
</div>
<div class="stat-card">
<div class="stat-label">Sunucu</div>
<div class="stat-value" style="font-size:1.1rem;">{{ s.uptime }}</div>
<div class="stat-sub">çalışma süresi</div>
</div>
<div class="stat-card">
<div class="stat-label">Container</div>
<div class="stat-value" style="font-size:1.1rem;">{{ s.containers_running }} / {{ s.containers_total }}</div>
<div class="stat-sub">çalışan / toplam</div>
</div>
</div>
{% if containers %}
<table>
<tr><th>İsim</th><th>Adres</th><th>Portlar</th><th>Durum</th><th>İmaj</th><th></th></tr>
{% for c in containers %}
<tr>
<td>{{ c.name }}</td>
<td>{% if c.hostname %}<a href="https://{{ c.hostname }}" target="_blank">{{ c.hostname }}</a>{% else %}—{% endif %}</td>
<td>{{ c.ports or '—' }}</td>
<td>
<span class="badge {{ c.status }}">{{ c.status }}</span>
{% if c.since %}<span class="since">{{ c.since }}</span>{% endif %}
</td>
<td>{{ c.image }}</td>
<td>
<div class="actions">
<a class="btn btn-logs" href="/logs/{{ c.name }}">Loglar</a>
{% if c.status == 'running' %}
<form method="post" action="/stop/{{ c.name }}">
<button class="btn-stop" type="submit">Durdur</button>
</form>
<form method="post" action="/restart/{{ c.name }}">
<button class="btn-restart" type="submit">Yeniden Başlat</button>
</form>
{% else %}
<form method="post" action="/start/{{ c.name }}">
<button class="btn-start" type="submit">Başlat</button>
</form>
{% endif %}
<form method="post" action="/delete/{{ c.name }}"
onsubmit="return confirm('{{ c.name }} silinsin mi?{{ ' DNS kaydı da otomatik kaldırılacak.' if c.hostname else '' }}');">
<button class="btn-delete" type="submit">Sil</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">Hiç container yok.</p>
{% endif %}
<h2>Disk Kullanımı</h2>
<table>
<tr><th>Tür</th><th>Toplam</th><th>Kullanımda</th><th>Boyut</th><th>Geri kazanılabilir</th></tr>
{% for row in df %}
<tr>
<td>{{ row.type }}</td>
<td>{{ row.total }}</td>
<td>{{ row.active }}</td>
<td>{{ row.size }}</td>
<td>{{ row.reclaimable }}</td>
</tr>
{% endfor %}
</table>
<div class="prune-actions">
<form method="post" action="/images/prune" onsubmit="return confirm('Hiçbir container tarafından kullanılmayan tüm imajlar silinecek. Emin misin?');">
<button class="btn-prune" type="submit">Kullanılmayan imajları temizle</button>
</form>
<form method="post" action="/volumes/prune" onsubmit="return confirm('Hiçbir container tarafından kullanılmayan tüm volume\\'ler silinecek. Emin misin?');">
<button class="btn-prune" type="submit">Kullanılmayan volume'leri temizle</button>
</form>
</div>
</body>
</html>
"""
LOGS_TEMPLATE = """
<!doctype html>
<html>
<head>
<title>{{ name }} - Loglar</title>
<meta charset="utf-8">
<style>
body { font-family: ui-monospace, monospace; background: #0f172a; color: #e2e8f0; padding: 2rem; }
a { color: #38bdf8; }
h1 { font-family: system-ui, sans-serif; color: #38bdf8; font-size: 1.2rem; }
pre { background: #1e293b; padding: 1rem; border-radius: 8px; overflow-x: auto;
white-space: pre-wrap; word-break: break-word; font-size: 0.85rem; line-height: 1.4; }
</style>
</head>
<body>
<a href="/">&larr; panele dön</a>
<h1>{{ name }} — son {{ lines }} satır</h1>
<pre>{{ logs }}</pre>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(
TEMPLATE, containers=list_all_containers(), s=server_stats(), df=docker_system_df()
)
@app.route("/logs/<name>")
def logs_ui(name):
result = subprocess.run(
["docker", "logs", "--tail", "150", name],
capture_output=True, text=True,
)
output = (result.stdout or "") + (result.stderr or "")
return render_template_string(LOGS_TEMPLATE, name=name, lines=150, logs=output or "(log yok)")
@app.route("/start/<name>", methods=["POST"])
def start_ui(name):
subprocess.run(["docker", "start", name])
return redirect("/")
@app.route("/stop/<name>", methods=["POST"])
def stop_ui(name):
subprocess.run(["docker", "stop", name])
return redirect("/")
@app.route("/restart/<name>", methods=["POST"])
def restart_ui(name):
subprocess.run(["docker", "restart", name])
return redirect("/")
@app.route("/delete/<name>", methods=["POST"])
def delete_ui(name):
hostname = find_container_hostname(name)
subprocess.run(["docker", "rm", "-f", name])
delete_dns_record(hostname)
return redirect("/")
@app.route("/images/prune", methods=["POST"])
def prune_images():
subprocess.run(["docker", "image", "prune", "-af"])
return redirect("/")
@app.route("/volumes/prune", methods=["POST"])
def prune_volumes():
subprocess.run(["docker", "volume", "prune", "-f"])
return redirect("/")
@app.route("/api/ensure-dns", methods=["POST"])
def api_ensure_dns():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
body = request.get_json(force=True)
hostname = body.get("hostname")
if not hostname:
return jsonify({"error": "hostname required"}), 400
result = ensure_dns(hostname)
return jsonify(result), 200
@app.route("/api/deploy", methods=["POST"])
def api_deploy():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
body = request.get_json(force=True)
name = body.get("name")
hostname = body.get("hostname")
port = str(body.get("port", "80"))
if not name or not hostname:
return jsonify({"error": "name and hostname required"}), 400
current_hostname = find_container_hostname(name)
if current_hostname is not None and current_hostname != hostname:
return jsonify({
"error": "name_conflict",
"message": f"'{name}' is already deployed with a different hostname ({current_hostname}).",
}), 409
ensure_dns(hostname)
subprocess.run(["docker", "rm", "-f", name])
rule = f"Host(`{hostname}`)"
subprocess.run([
"docker", "run", "-d",
"--name", name,
"--network", "proxy",
"--restart", "always",
"-l", "traefik.enable=true",
"-l", f"traefik.http.routers.{name}.rule={rule}",
"-l", f"traefik.http.routers.{name}.entrypoints=websecure",
"-l", f"traefik.http.routers.{name}.tls.certresolver=letsencrypt",
"-l", f"traefik.http.services.{name}.loadbalancer.server.port={port}",
f"{name}:latest",
], check=True)
cert_ready = wait_for_real_cert(hostname)
return jsonify({
"status": "deployed", "url": f"https://{hostname}", "cert_ready": cert_ready,
}), 200
@app.route("/api/wait-for-cert", methods=["POST"])
def api_wait_for_cert():
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
body = request.get_json(force=True)
hostname = body.get("hostname")
if not hostname:
return jsonify({"error": "hostname required"}), 400
cert_ready = wait_for_real_cert(hostname)
return jsonify({"cert_ready": cert_ready}), 200
@app.route("/api/deploy/<name>", methods=["DELETE"])
def api_delete(name):
if not check_auth():
return jsonify({"error": "unauthorized"}), 401
hostname = find_container_hostname(name)
subprocess.run(["docker", "rm", "-f", name])
delete_dns_record(hostname)
return jsonify({"status": "deleted"}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)