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.
This commit is contained in:
Thomas committed 2026-07-10 14:19:14 +02:00
1 parent 3554c85f2f
commit 15e562cb04
11 files changed
+331 -2

No files matched your search

+8
View File
@@ -403,8 +403,16 @@ PVOID VisibilityProcess(PVOID TS)
// World Process // World Process
PVOID WorldProcess( PVOID TS ) PVOID WorldProcess( PVOID TS )
{ {
int adminQueueTicks = 0;
while( GServer->ServerOnline ) 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 ); pthread_mutex_lock( &GServer->MapMutex );
for(UINT i=0;i<GServer->MapList.Map.size();i++) for(UINT i=0;i<GServer->MapList.Map.size();i++)
{ {
+42
View File
@@ -66,6 +66,48 @@ bool CWorldServer::NPCAnnounce( const char msg[200], const char npc[50])
return true; 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 // NPC Whisper to a character
bool CWorldServer::NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] ) bool CWorldServer::NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] )
{ {
+1
View File
@@ -342,6 +342,7 @@ class CWorldServer : public CServerSocket
bool NPCMessage( CMonster* thismon, const char msg[200], const char npc[50] ); 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 NPCWhisper( CPlayer* thisclient, CMonster* thismon, const char msg[200], const char npc[50] );
bool NPCAnnounce( const char msg[200], const char npc[50] ); bool NPCAnnounce( const char msg[200], const char npc[50] );
void PollAdminQueue( );
UINT GetMaxPartyExp( UINT partylevel ); UINT GetMaxPartyExp( UINT partylevel );
bool DoSkillScript( CCharacter* character, CSkills* thisskill ); bool DoSkillScript( CCharacter* character, CSkills* thisskill );
+14
View File
@@ -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;
+7
View File
@@ -104,6 +104,13 @@ services:
DB_NAME: ${DB_NAME:-ktrose} DB_NAME: ${DB_NAME:-ktrose}
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-change-me-too} ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-change-me-too}
ADMIN_MIN_ACCESSLEVEL: ${ADMIN_MIN_ACCESSLEVEL:-900} 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: expose:
- "8080" - "8080"
depends_on: depends_on:
+4
View File
@@ -11,6 +11,10 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /admin; proxy_set_header X-Forwarded-Prefix /admin;
# /server/<service>/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 / { location / {
+1 -1
View File
@@ -9,4 +9,4 @@ COPY app.py ./
COPY templates ./templates COPY templates ./templates
EXPOSE 8080 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"]
+134 -1
View File
@@ -1,10 +1,14 @@
import os import os
import functools import functools
import hashlib import hashlib
import json
import time
import docker
import docker.errors
import pymysql import pymysql
import pymysql.cursors 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 from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__) 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. # Only accounts with accesslevel >= this may log in to the admin panel.
ADMIN_MIN_ACCESSLEVEL = int(os.environ.get("ADMIN_MIN_ACCESSLEVEL", "900")) 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 = [ CHAR_STAT_FIELDS = [
"level", "zuly", "str", "con", "dex", "_int", "cha", "sen", "level", "zuly", "str", "con", "dex", "_int", "cha", "sen",
"curHp", "curMp", "statp", "skillp", "stamina", "exp", "classid", "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)) 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__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=False) app.run(host="0.0.0.0", port=8080, debug=False)
+1
View File
@@ -1,3 +1,4 @@
Flask==3.0.3 Flask==3.0.3
PyMySQL==1.1.1 PyMySQL==1.1.1
gunicorn==22.0.0 gunicorn==22.0.0
docker==7.1.0
+16
View File
@@ -71,6 +71,21 @@
.badge.ok { background: #163a26; color: var(--ok); } .badge.ok { background: #163a26; color: var(--ok); }
.badge.off { background: #3a1620; color: var(--danger); } .badge.off { background: #3a1620; color: var(--danger); }
.row-actions a { margin-right: 8px; } .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); }
</style> </style>
</head> </head>
<body> <body>
@@ -80,6 +95,7 @@
<nav> <nav>
<a href="{{ url_for('accounts') }}">Accounts</a> <a href="{{ url_for('accounts') }}">Accounts</a>
<a href="{{ url_for('characters') }}">Charaktere</a> <a href="{{ url_for('characters') }}">Charaktere</a>
<a href="{{ url_for('server_control') }}">Server</a>
<a href="{{ url_for('logout') }}">Logout ({{ session['username'] }})</a> <a href="{{ url_for('logout') }}">Logout ({{ session['username'] }})</a>
</nav> </nav>
{% endif %} {% endif %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Server – ROSS Admin{% endblock %}
{% block content %}
<h2>Serversteuerung</h2>
<div class="card">
<table>
<thead><tr><th>Dienst</th><th>Status</th><th>Gestartet</th><th></th></tr></thead>
<tbody>
{% for service in services %}
<tr>
<td>{{ service }}</td>
<td>
{% set st = statuses[service]['status'] %}
<span class="badge {{ 'ok' if st == 'running' else 'off' }}">{{ st }}</span>
</td>
<td>{{ statuses[service]['started_at'] or '-' }}</td>
<td class="row-actions">
<form class="inline" method="post" action="{{ url_for('server_action', service=service, action='start') }}">
<button type="submit">Start</button>
</form>
<form class="inline" method="post" action="{{ url_for('server_action', service=service, action='restart') }}">
<button type="submit" class="secondary">Restart</button>
</form>
<form class="inline" method="post" action="{{ url_for('server_action', service=service, action='stop') }}"
onsubmit="return confirm('{{ service }} wirklich stoppen?');">
<button type="submit" class="secondary" style="background:var(--danger)">Stop</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3 style="margin-top:0">Live-Logs</h3>
<div class="service-tabs">
{% for service in services %}
<button type="button" class="tab {{ 'active' if loop.first else '' }}" data-service="{{ service }}" onclick="switchLog('{{ service }}')">{{ service }}</button>
{% endfor %}
</div>
<div id="logbox" class="logbox"></div>
</div>
<div class="grid">
<div class="card">
<h3 style="margin-top:0">Broadcast</h3>
<form method="post" action="{{ url_for('server_broadcast') }}">
<label>Nachricht an alle Spieler</label>
<input type="text" name="message" maxlength="250" required>
<div style="margin-top:12px"><button type="submit">Senden</button></div>
</form>
</div>
<div class="card">
<h3 style="margin-top:0">Online-Spieler</h3>
{% if online %}
<table>
<thead><tr><th>Account</th><th>Charakter</th><th></th></tr></thead>
<tbody>
{% for row in online %}
<tr>
<td>{{ row['username'] }}</td>
<td>{{ row['lastchar'] }}</td>
<td class="row-actions">
<form class="inline" method="post" action="{{ url_for('server_kick') }}"
onsubmit="return confirm('{{ row['lastchar'] }} kicken?');">
<input type="hidden" name="charname" value="{{ row['lastchar'] }}">
<button type="submit" class="secondary" style="background:var(--danger)">Kick</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:var(--muted)">Niemand online.</p>
{% endif %}
</div>
</div>
<script>
let currentSource = null;
function switchLog(service) {
document.querySelectorAll('.service-tabs .tab').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.service === service);
});
const box = document.getElementById('logbox');
box.textContent = '';
if (currentSource) currentSource.close();
currentSource = new EventSource('{{ url_for("server_logs", service="__SERVICE__") }}'.replace('__SERVICE__', service));
currentSource.onmessage = function(ev) {
const line = JSON.parse(ev.data);
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
box.textContent += line + '\n';
if (atBottom) box.scrollTop = box.scrollHeight;
};
}
switchLog('{{ services[0] }}');
window.addEventListener('beforeunload', function() { if (currentSource) currentSource.close(); });
</script>
{% endblock %}