Files
deploy-panel/app.py
T
2026-09-23 16:15:56 +03:00

319 lines
10 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 subprocess
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):
"""Scan ALL traefik router rule labels, not just one matching the
container's own name — compose-based containers get auto-generated
docker names that no longer match the Traefik router name."""
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 inspect_all(cid):
return json.loads(subprocess.run(
["docker", "inspect", cid], capture_output=True, text=True, check=True
).stdout)[0]
def list_projects():
out = subprocess.run(
["docker", "ps", "-a", "--filter", "label=traefik.enable=true", "--format", "{{.ID}}"],
capture_output=True, text=True, check=True,
).stdout.split()
projects = []
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"))
projects.append({
"name": name,
"hostname": extract_hostname(labels),
"status": status,
"since": when,
"image": info["Config"]["Image"],
})
projects.sort(key=lambda p: p["name"])
return projects
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 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; }
.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; }
button, .btn { border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer;
font-size: 0.85rem; color: white; }
.btn-restart { background: #1e3a8a; }
.btn-restart:hover { background: #1e40af; }
.btn-logs { background: #334155; }
.btn-logs:hover { background: #475569; }
.btn-delete { background: #7f1d1d; }
.btn-delete:hover { background: #991b1b; }
.empty { opacity: 0.6; margin-top: 1.5rem; }
form { display: inline; }
</style>
</head>
<body>
<h1>Deploy Panel</h1>
<div class="sub">{{ projects|length }} proje · 20 saniyede bir otomatik yenilenir · <a href="/">şimdi yenile</a></div>
{% if projects %}
<table>
<tr><th>Proje</th><th>Adres</th><th>Durum</th><th>İmaj</th><th></th></tr>
{% for p in projects %}
<tr>
<td>{{ p.name }}</td>
<td>{% if p.hostname %}<a href="https://{{ p.hostname }}" target="_blank">{{ p.hostname }}</a>{% else %}—{% endif %}</td>
<td>
<span class="badge {{ p.status }}">{{ p.status }}</span>
{% if p.since %}<span class="since">{{ p.since }}</span>{% endif %}
</td>
<td>{{ p.image }}</td>
<td>
<div class="actions">
<a class="btn btn-logs" href="/logs/{{ p.name }}">Loglar</a>
<form method="post" action="/restart/{{ p.name }}">
<button class="btn-restart" type="submit">Yeniden Başlat</button>
</form>
<form method="post" action="/delete/{{ p.name }}"
onsubmit="return confirm('{{ p.name }} silinsin mi? Container ve DNS kaydı kalıcı olarak kaldırılacak.');">
<button class="btn-delete" type="submit">Sil</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="empty">Henüz deploy edilmiş bir proje yok.</p>
{% endif %}
</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, projects=list_projects())
@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("/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):
projects = {p["name"]: p for p in list_projects()}
target = projects.get(name)
if target:
subprocess.run(["docker", "rm", "-f", name])
delete_dns_record(target["hostname"])
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)
return jsonify({"status": "deployed", "url": f"https://{hostname}"}), 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)