diff --git a/Src/World Server/MainProcess.cpp b/Src/World Server/MainProcess.cpp index 1c2fee7..d8cb19b 100644 --- a/Src/World Server/MainProcess.cpp +++ b/Src/World Server/MainProcess.cpp @@ -403,8 +403,16 @@ PVOID VisibilityProcess(PVOID TS) // World Process PVOID WorldProcess( PVOID TS ) { + int adminQueueTicks = 0; while( GServer->ServerOnline ) { + // Poll roughly every 2 seconds regardless of WorldDelay, instead of + // hitting the DB on every tick. + if( ++adminQueueTicks * GServer->Config.WorldDelay >= 2000 ) + { + adminQueueTicks = 0; + GServer->PollAdminQueue( ); + } pthread_mutex_lock( &GServer->MapMutex ); for(UINT i=0;iMapList.Map.size();i++) { diff --git a/Src/World Server/ServerFunctions.cpp b/Src/World Server/ServerFunctions.cpp index 06016a7..0359bb1 100644 --- a/Src/World Server/ServerFunctions.cpp +++ b/Src/World Server/ServerFunctions.cpp @@ -66,6 +66,48 @@ bool CWorldServer::NPCAnnounce( const char msg[200], const char npc[50]) return true; } +// Poll the admin_queue table (written by the webadmin panel) for pending +// broadcast/kick actions and execute them. There's no direct socket between +// webadmin and the world server, so this DB-polling approach reuses the +// existing MYSQL connection instead of adding a second IPC channel. +void CWorldServer::PollAdminQueue( ) +{ + // QStore holds SQLMutex locked until QFree() -- copy the rows out and + // free the result set first, since NPCAnnounce/pakGMKick/QExecute below + // issue their own queries and would deadlock on the same (non-recursive) + // mutex if called while this result set is still open. + struct QueueItem { int id; char type[20]; char payload[255]; }; + QueueItem items[20]; + int count = 0; + + MYSQL_RES* result = DB->QStore("SELECT id, type, payload FROM admin_queue WHERE executed=0 ORDER BY id LIMIT 20"); + if(result != NULL) + { + MYSQL_ROW row; + while(count < 20 && (row = mysql_fetch_row(result)) != NULL) + { + items[count].id = atoi(row[0]); + strncpy_s(items[count].type, row[1], sizeof(items[count].type)-1); + strncpy_s(items[count].payload, row[2], sizeof(items[count].payload)-1); + count++; + } + } + DB->QFree( ); + + for(int i = 0; i < count; i++) + { + if(strcmp(items[i].type, "broadcast") == 0) + { + NPCAnnounce(items[i].payload, "[Admin]"); + } + else if(strcmp(items[i].type, "kick") == 0) + { + pakGMKick(NULL, items[i].payload); + } + DB->QExecute("UPDATE admin_queue SET executed=1 WHERE id=%i", items[i].id); + } +} + // NPC Whisper to a character bool CWorldServer::NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] ) { diff --git a/Src/World Server/WorldServer.h b/Src/World Server/WorldServer.h index d9fb8f2..0099ff4 100644 --- a/Src/World Server/WorldServer.h +++ b/Src/World Server/WorldServer.h @@ -342,6 +342,7 @@ class CWorldServer : public CServerSocket bool NPCMessage( CMonster* thismon, const char msg[200], const char npc[50] ); bool NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] ); bool NPCAnnounce( const char msg[200], const char npc[50] ); + void PollAdminQueue( ); UINT GetMaxPartyExp( UINT partylevel ); bool DoSkillScript( CCharacter* character, CSkills* thisskill ); diff --git a/database sql files/admin_queue.sql b/database sql files/admin_queue.sql new file mode 100644 index 0000000..f426fa2 --- /dev/null +++ b/database sql files/admin_queue.sql @@ -0,0 +1,14 @@ +-- +-- Queue table the webadmin panel writes to and the world server polls +-- (in WorldProcess, MainProcess.cpp) to execute broadcast/kick actions +-- without needing a direct socket between the two processes. +-- + +CREATE TABLE IF NOT EXISTS `admin_queue` ( + `id` int(11) NOT NULL auto_increment, + `type` varchar(20) NOT NULL, + `payload` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `executed` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci; diff --git a/docker-compose.yml b/docker-compose.yml index 3ee2949..7562978 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,6 +104,13 @@ services: DB_NAME: ${DB_NAME:-ktrose} ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-change-me-too} ADMIN_MIN_ACCESSLEVEL: ${ADMIN_MIN_ACCESSLEVEL:-900} + COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-ross} + volumes: + # Lets the panel start/stop/restart the login/char/world containers and + # stream their logs via the Docker API. Grants the container + # effectively root-equivalent access to the host -- acceptable here + # since the panel sits behind GM-only login and isn't internet-exposed. + - /var/run/docker.sock:/var/run/docker.sock:ro expose: - "8080" depends_on: diff --git a/docker/conf/webtools-nginx.conf b/docker/conf/webtools-nginx.conf index dfef35e..8d99b7a 100644 --- a/docker/conf/webtools-nginx.conf +++ b/docker/conf/webtools-nginx.conf @@ -11,6 +11,10 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Prefix /admin; + # /server//logs is an SSE stream -- buffering would delay + # lines until nginx's buffer fills instead of forwarding them live. + proxy_buffering off; + proxy_read_timeout 1h; } location / { diff --git a/webadmin/Dockerfile b/webadmin/Dockerfile index 994bd66..7686db1 100644 --- a/webadmin/Dockerfile +++ b/webadmin/Dockerfile @@ -9,4 +9,4 @@ COPY app.py ./ COPY templates ./templates EXPOSE 8080 -CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"] +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--worker-class", "gthread", "--threads", "8", "app:app"] diff --git a/webadmin/app.py b/webadmin/app.py index 8df329f..dd7c95d 100644 --- a/webadmin/app.py +++ b/webadmin/app.py @@ -1,10 +1,14 @@ import os import functools import hashlib +import json +import time +import docker +import docker.errors import pymysql import pymysql.cursors -from flask import Flask, request, session, redirect, url_for, render_template, flash +from flask import Flask, Response, request, session, redirect, url_for, render_template, flash from werkzeug.middleware.proxy_fix import ProxyFix app = Flask(__name__) @@ -30,6 +34,33 @@ 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 "--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", @@ -387,5 +418,107 @@ def kick_account(account_id): 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//", 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//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) diff --git a/webadmin/requirements.txt b/webadmin/requirements.txt index 34f3181..f49821f 100644 --- a/webadmin/requirements.txt +++ b/webadmin/requirements.txt @@ -1,3 +1,4 @@ Flask==3.0.3 PyMySQL==1.1.1 gunicorn==22.0.0 +docker==7.1.0 diff --git a/webadmin/templates/base.html b/webadmin/templates/base.html index ef5d262..bcb0f88 100644 --- a/webadmin/templates/base.html +++ b/webadmin/templates/base.html @@ -71,6 +71,21 @@ .badge.ok { background: #163a26; color: var(--ok); } .badge.off { background: #3a1620; color: var(--danger); } .row-actions a { margin-right: 8px; } + .logbox { + background: #0a0b10; + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + height: 360px; + overflow-y: auto; + font-family: "SF Mono", Consolas, Menlo, monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + } + .service-tabs { display: flex; gap: 8px; margin-bottom: 12px; } + .service-tabs button.tab { background: #33384a; } + .service-tabs button.tab.active { background: var(--accent); } @@ -80,6 +95,7 @@ {% endif %} diff --git a/webadmin/templates/server.html b/webadmin/templates/server.html new file mode 100644 index 0000000..979ded1 --- /dev/null +++ b/webadmin/templates/server.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}Server – ROSS Admin{% endblock %} +{% block content %} +

Serversteuerung

+ +
+ + + + {% for service in services %} + + + + + + + {% endfor %} + +
DienstStatusGestartet
{{ service }} + {% set st = statuses[service]['status'] %} + {{ st }} + {{ statuses[service]['started_at'] or '-' }} +
+ +
+
+ +
+
+ +
+
+
+ +
+

Live-Logs

+
+ {% for service in services %} + + {% endfor %} +
+
+
+ +
+
+

Broadcast

+
+ + +
+
+
+ +
+

Online-Spieler

+ {% if online %} + + + + {% for row in online %} + + + + + + {% endfor %} + +
AccountCharakter
{{ row['username'] }}{{ row['lastchar'] }} +
+ + +
+
+ {% else %} +

Niemand online.

+ {% endif %} +
+
+ + +{% endblock %}