Files
ROSS/webadmin/app.py
T
Thomas 15e562cb04 Add server control (start/stop/restart, live logs, broadcast, kick) to webadmin
Docker SDK drives login/char/worldserver containers via docker.sock (mounted
read-only into webadmin); live logs stream over SSE with nginx buffering
disabled so lines arrive in real time. Broadcast/kick go through a new
admin_queue DB table that the world server polls every ~2s in its existing
WorldProcess tick loop, avoiding a second IPC channel to the game server.
2026-07-10 14:19:14 +02:00

525 lines
19 KiB
Python

import os
import functools
import hashlib
import json
import time
import docker
import docker.errors
import pymysql
import pymysql.cursors
from flask import Flask, Response, request, session, redirect, url_for, render_template, flash
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
app.secret_key = os.environ.get("ADMIN_SECRET_KEY", "dev-only-change-me")
# Sits behind the webtools nginx container at /admin/ (see webtools/nginx.conf);
# this reads X-Forwarded-Prefix/-Proto/-For so url_for()/redirect() produce
# correct /admin/-prefixed URLs instead of root-relative ones.
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1, x_proto=1, x_for=1, x_host=1)
def hash_password(plaintext):
# The game client MD5-hashes the password client-side before it ever
# hits the wire, so accounts.password is always an MD5 hex digest --
# match that here so admin-created/changed passwords work in-game.
return hashlib.md5(plaintext.encode("utf-8")).hexdigest()
DB_HOST = os.environ.get("DB_HOST", "db")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_USER = os.environ.get("DB_USER", "rose")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "change_me")
DB_NAME = os.environ.get("DB_NAME", "ktrose")
# Only accounts with accesslevel >= this may log in to the admin panel.
ADMIN_MIN_ACCESSLEVEL = int(os.environ.get("ADMIN_MIN_ACCESSLEVEL", "900"))
COMPOSE_PROJECT_NAME = os.environ.get("COMPOSE_PROJECT_NAME", "ross")
GAME_SERVICES = ["loginserver", "charserver", "worldserver"]
_docker_client = None
def get_docker():
global _docker_client
if _docker_client is None:
_docker_client = docker.from_env()
return _docker_client
def get_service_container(service):
# Looked up by compose labels rather than a hardcoded "<project>-<service>-1"
# name, since that naming scheme isn't guaranteed across compose versions.
containers = get_docker().containers.list(
all=True,
filters={
"label": [
f"com.docker.compose.project={COMPOSE_PROJECT_NAME}",
f"com.docker.compose.service={service}",
]
},
)
return containers[0] if containers else None
CHAR_STAT_FIELDS = [
"level", "zuly", "str", "con", "dex", "_int", "cha", "sen",
"curHp", "curMp", "statp", "skillp", "stamina", "exp", "classid",
]
# Column edited via its own <select> (populated from list_respawnzones) rather
# than a bare number input, but it's a plain int column just like the ones
# above, so the generic CHAR_STAT_FIELDS UPDATE loop handles it unchanged.
CHAR_STAT_FIELDS.append("respawnid")
def get_db():
return pymysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD,
database=DB_NAME, cursorclass=pymysql.cursors.DictCursor, autocommit=True,
)
def login_required(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
if not session.get("username"):
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
db = get_db()
try:
with db.cursor() as cur:
cur.execute(
"SELECT username, password, accesslevel FROM accounts WHERE username=%s",
(username,),
)
row = cur.fetchone()
finally:
db.close()
if (
row
and row["password"].lower() == hash_password(password)
and row["accesslevel"] >= ADMIN_MIN_ACCESSLEVEL
):
session.clear()
session["username"] = row["username"]
return redirect(request.args.get("next") or url_for("dashboard"))
flash("Login fehlgeschlagen (falsches Passwort oder unzureichendes Accesslevel).")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.route("/")
@login_required
def dashboard():
db = get_db()
try:
with db.cursor() as cur:
cur.execute("SELECT COUNT(*) AS n FROM accounts")
accounts_n = cur.fetchone()["n"]
cur.execute("SELECT COUNT(*) AS n FROM characters")
chars_n = cur.fetchone()["n"]
finally:
db.close()
return render_template("dashboard.html", accounts_n=accounts_n, chars_n=chars_n)
@app.route("/accounts")
@login_required
def accounts():
q = request.args.get("q", "").strip()
db = get_db()
try:
with db.cursor() as cur:
if q:
cur.execute(
"SELECT id, username, accesslevel, active, online, lastip "
"FROM accounts WHERE username LIKE %s ORDER BY id",
(f"%{q}%",),
)
else:
cur.execute(
"SELECT id, username, accesslevel, active, online, lastip "
"FROM accounts ORDER BY id"
)
rows = cur.fetchall()
finally:
db.close()
return render_template("accounts.html", accounts=rows, q=q)
@app.route("/accounts/new", methods=["GET", "POST"])
@login_required
def new_account():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "").strip()
accesslevel = int(request.form.get("accesslevel", "100") or "100")
if not username or not password:
flash("Username und Passwort sind Pflichtfelder.")
return render_template("new_account.html")
db = get_db()
try:
with db.cursor() as cur:
cur.execute("SELECT id FROM accounts WHERE username=%s", (username,))
if cur.fetchone():
flash(f"Account '{username}' existiert bereits.")
return render_template("new_account.html")
cur.execute(
"INSERT INTO accounts (username, password, accesslevel, lastlogin, logtime) "
"VALUES (%s, %s, %s, '', 0)",
(username, hash_password(password), accesslevel),
)
finally:
db.close()
flash(f"Account '{username}' angelegt.")
return redirect(url_for("accounts"))
return render_template("new_account.html")
@app.route("/accounts/<int:account_id>/edit", methods=["GET", "POST"])
@login_required
def edit_account(account_id):
db = get_db()
try:
if request.method == "POST":
accesslevel = int(request.form.get("accesslevel", "100") or "100")
active = 1 if request.form.get("active") else 0
new_password = request.form.get("password", "").strip()
with db.cursor() as cur:
if new_password:
cur.execute(
"UPDATE accounts SET accesslevel=%s, active=%s, password=%s WHERE id=%s",
(accesslevel, active, hash_password(new_password), account_id),
)
else:
cur.execute(
"UPDATE accounts SET accesslevel=%s, active=%s WHERE id=%s",
(accesslevel, active, account_id),
)
flash("Account aktualisiert.")
return redirect(url_for("accounts"))
with db.cursor() as cur:
cur.execute("SELECT * FROM accounts WHERE id=%s", (account_id,))
account = cur.fetchone()
cur.execute(
"SELECT id, char_name, level FROM characters WHERE account_name=%s",
(account["username"],),
)
chars = cur.fetchall()
finally:
db.close()
if not account:
flash("Account nicht gefunden.")
return redirect(url_for("accounts"))
return render_template("edit_account.html", account=account, chars=chars)
@app.route("/characters")
@login_required
def characters():
q = request.args.get("q", "").strip()
db = get_db()
try:
with db.cursor() as cur:
if q:
cur.execute(
"SELECT id, char_name, account_name, level, isgm, isdev "
"FROM characters WHERE char_name LIKE %s OR account_name LIKE %s ORDER BY id",
(f"%{q}%", f"%{q}%"),
)
else:
cur.execute(
"SELECT id, char_name, account_name, level, isgm, isdev "
"FROM characters ORDER BY id"
)
rows = cur.fetchall()
finally:
db.close()
return render_template("characters.html", chars=rows, q=q)
@app.route("/characters/new", methods=["GET", "POST"])
@login_required
def new_character():
db = get_db()
try:
with db.cursor() as cur:
cur.execute("SELECT username FROM accounts ORDER BY username")
account_names = [r["username"] for r in cur.fetchall()]
if request.method == "POST":
account_name = request.form.get("account_name", "").strip()
char_name = request.form.get("char_name", "").strip()
sex = int(request.form.get("sex", "0") or "0")
face = int(request.form.get("face", "1") or "1")
hairstyle = int(request.form.get("hairstyle", "1") or "1")
if not account_name or not char_name:
flash("Account und Charaktername sind Pflichtfelder.")
return render_template("new_character.html", account_names=account_names)
with db.cursor() as cur:
cur.execute("SELECT id FROM characters WHERE char_name=%s", (char_name,))
if cur.fetchone():
flash(f"Charakter '{char_name}' existiert bereits.")
return render_template("new_character.html", account_names=account_names)
# Mirrors the real character-creation INSERT in
# Src/Char Server/charpackets.cpp -- every other column
# relies on its table default (level 1, zero stats, etc).
cur.execute(
"INSERT INTO characters (account_name, char_name, face, hairStyle, sex) "
"VALUES (%s, %s, %s, %s, %s)",
(account_name, char_name, face, hairstyle, sex),
)
flash(f"Charakter '{char_name}' angelegt.")
return redirect(url_for("characters"))
finally:
db.close()
return render_template("new_character.html", account_names=account_names)
@app.route("/characters/<int:char_id>/edit", methods=["GET", "POST"])
@login_required
def edit_character(char_id):
db = get_db()
try:
if request.method == "POST":
with db.cursor() as cur:
cur.execute("SELECT char_name FROM characters WHERE id=%s", (char_id,))
old = cur.fetchone()
if not old:
flash("Charakter nicht gefunden.")
return redirect(url_for("characters"))
new_name = request.form.get("char_name", "").strip()
if new_name and new_name != old["char_name"]:
with db.cursor() as cur:
cur.execute(
"SELECT id FROM characters WHERE char_name=%s AND id<>%s",
(new_name, char_id),
)
if cur.fetchone():
flash(f"Name '{new_name}' ist bereits vergeben.")
return redirect(url_for("edit_character", char_id=char_id))
cur.execute(
"UPDATE characters SET char_name=%s WHERE id=%s",
(new_name, char_id),
)
# namefriend is a denormalized display-name cache in other
# players' friend lists (list_friend.idfriend references
# this character's id) -- keep it in sync so renames
# don't leave stale names in friend lists.
cur.execute(
"UPDATE list_friend SET namefriend=%s WHERE idfriend=%s",
(new_name, char_id),
)
flash(f"Charakter '{old['char_name']}' umbenannt in '{new_name}'.")
values = []
set_clauses = []
for field in CHAR_STAT_FIELDS:
raw = request.form.get(field, "").strip()
if raw == "":
continue
set_clauses.append(f"`{field}`=%s")
values.append(int(raw))
isgm = 1 if request.form.get("isgm") else 0
isdev = 1 if request.form.get("isdev") else 0
set_clauses += ["isgm=%s", "isdev=%s"]
values += [isgm, isdev]
values.append(char_id)
with db.cursor() as cur:
cur.execute(
f"UPDATE characters SET {', '.join(set_clauses)} WHERE id=%s",
values,
)
flash("Charakter aktualisiert.")
return redirect(url_for("characters"))
with db.cursor() as cur:
cur.execute("SELECT * FROM characters WHERE id=%s", (char_id,))
char = cur.fetchone()
cur.execute(
"SELECT id, map, x, y FROM list_respawnzones ORDER BY map, id"
)
respawn_zones = cur.fetchall()
finally:
db.close()
if not char:
flash("Charakter nicht gefunden.")
return redirect(url_for("characters"))
zones_by_map = {}
for z in respawn_zones:
zones_by_map.setdefault(z["map"], []).append(z)
return render_template(
"edit_character.html",
char=char,
stat_fields=[f for f in CHAR_STAT_FIELDS if f != "respawnid"],
zones_by_map=zones_by_map,
)
@app.route("/characters/<int:char_id>/delete", methods=["POST"])
@login_required
def delete_character(char_id):
db = get_db()
try:
with db.cursor() as cur:
cur.execute("SELECT char_name FROM characters WHERE id=%s", (char_id,))
row = cur.fetchone()
if row:
cur.execute("DELETE FROM characters WHERE id=%s", (char_id,))
finally:
db.close()
if row:
flash(f"Charakter '{row['char_name']}' gelöscht.")
return redirect(url_for("characters"))
@app.route("/accounts/<int:account_id>/delete", methods=["POST"])
@login_required
def delete_account(account_id):
db = get_db()
try:
with db.cursor() as cur:
cur.execute("SELECT username FROM accounts WHERE id=%s", (account_id,))
row = cur.fetchone()
if row:
cur.execute("DELETE FROM accounts WHERE id=%s", (account_id,))
finally:
db.close()
if row:
flash(f"Account '{row['username']}' gelöscht (Charaktere bleiben erhalten).")
return redirect(url_for("accounts"))
@app.route("/accounts/<int:account_id>/kick", methods=["POST"])
@login_required
def kick_account(account_id):
# Resets the DB's stale "online" flag -- useful after a crash/kill -9
# left an account stuck thinking it's still logged in and unable to
# log back in. Does not disconnect a currently-live session.
db = get_db()
try:
with db.cursor() as cur:
cur.execute("UPDATE accounts SET online=0 WHERE id=%s", (account_id,))
finally:
db.close()
flash("Online-Status zurückgesetzt.")
return redirect(url_for("edit_account", account_id=account_id))
def queue_admin_command(cmd_type, payload):
db = get_db()
try:
with db.cursor() as cur:
cur.execute(
"INSERT INTO admin_queue (type, payload) VALUES (%s, %s)",
(cmd_type, payload),
)
finally:
db.close()
@app.route("/server")
@login_required
def server_control():
statuses = {}
for service in GAME_SERVICES:
container = get_service_container(service)
if container is None:
statuses[service] = {"status": "missing", "started_at": None}
else:
statuses[service] = {
"status": container.status,
"started_at": container.attrs.get("State", {}).get("StartedAt"),
}
db = get_db()
try:
with db.cursor() as cur:
cur.execute(
"SELECT username, lastchar FROM accounts WHERE online=1 ORDER BY username"
)
online = cur.fetchall()
finally:
db.close()
return render_template(
"server.html", statuses=statuses, services=GAME_SERVICES, online=online
)
@app.route("/server/<service>/<action>", methods=["POST"])
@login_required
def server_action(service, action):
if service not in GAME_SERVICES or action not in ("start", "stop", "restart"):
flash("Ungültige Aktion.")
return redirect(url_for("server_control"))
container = get_service_container(service)
if container is None:
flash(f"Container für '{service}' nicht gefunden.")
return redirect(url_for("server_control"))
try:
if action == "start":
container.start()
else:
getattr(container, action)(timeout=15)
except docker.errors.APIError as exc:
flash(f"Aktion fehlgeschlagen: {exc}")
else:
flash(f"{service}: {action} ausgeführt.")
return redirect(url_for("server_control"))
@app.route("/server/<service>/logs")
@login_required
def server_logs(service):
if service not in GAME_SERVICES:
return "unknown service", 404
container = get_service_container(service)
if container is None:
return "container not found", 404
def stream():
for line in container.logs(stream=True, follow=True, tail=200):
text = line.decode("utf-8", errors="replace").rstrip("\n")
yield f"data: {json.dumps(text)}\n\n"
return Response(stream(), mimetype="text/event-stream")
@app.route("/server/broadcast", methods=["POST"])
@login_required
def server_broadcast():
message = request.form.get("message", "").strip()
if not message:
flash("Nachricht darf nicht leer sein.")
else:
queue_admin_command("broadcast", message[:250])
flash("Broadcast eingereiht (wird binnen ~2s vom Worldserver gesendet).")
return redirect(url_for("server_control"))
@app.route("/server/kick", methods=["POST"])
@login_required
def server_kick():
charname = request.form.get("charname", "").strip()
if not charname:
flash("Kein Charakter angegeben.")
else:
queue_admin_command("kick", charname[:64])
flash(f"Kick für '{charname}' eingereiht.")
return redirect(url_for("server_control"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=False)