Server updates from the web panel with progress screen

- Updater checks git.mbm.wtf every 6 h (and on demand) for a newer <Version>
  on main, shows the new commits and the required mod version
- Install: download the source, build it with dotnet publish into
  data/app/current, warn players and restart (exit code 75 for Docker/systemd,
  self-relaunch on Windows). At startup a newer installed update is loaded in
  its own AssemblyLoadContext; after 3 failed starts the built-in version runs
- Panel: Update page, sidebar badge, full-screen progress overlay (download,
  build, install, restart) that survives the restart and reloads the panel
- Panel sessions survive restarts (hashed tokens in data/panel_sessions.json)
- Docker image is based on the .NET SDK so it can build updates
- Version 2.9.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:07:09 +02:00
co-authored by Claude Opus 5.5
parent 1a2431db3a
commit 1ab9a01efb
12 changed files with 1084 additions and 13 deletions
+65 -5
View File
@@ -60,6 +60,7 @@ public sealed class AdminPanel
this.dataDir = dataDir;
this.config = config;
users = new PanelUsers(Path.Combine(dataDir, "panel_users.json"));
LoadSessions();
}
// ---------------------------------------------------------------- Start
@@ -357,7 +358,7 @@ public sealed class AdminPanel
{
if (!CsrfOk(ctx)) return Forbidden("Missing header");
string token = ctx.Request.Cookies[CookieName];
if (token != null) sessions.TryRemove(token, out _);
if (token != null && sessions.TryRemove(TokenKey(token), out _)) SaveSessions();
ctx.Response.Cookies.Delete(CookieName, CookieOptions(ctx));
return Results.Json(new { ok = true });
});
@@ -527,6 +528,25 @@ public sealed class AdminPanel
};
});
// --- Updates (prüfen, installieren, Fortschritt)
app.MapGet("/api/update", (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
return Results.Json(host.Updater.Status());
});
app.MapPost("/api/update/check", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
await host.Updater.CheckAsync();
return Results.Json(host.Updater.Status());
});
app.MapPost("/api/update/install", (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
string problem = host.Updater.Install(By(user));
return problem != null ? Error(409, problem) : Ok("Update started");
});
// --- Einstellungen (server.json)
app.MapGet("/api/config", async (HttpContext ctx) =>
{
@@ -740,11 +760,14 @@ public sealed class AdminPanel
PanelUsers.User CurrentUser(HttpContext ctx)
{
string token = ctx.Request.Cookies[CookieName];
if (token == null || !sessions.TryGetValue(token, out var session)) return null;
if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(token, out _); return null; }
if (token == null) return null;
string key = TokenKey(token);
if (!sessions.TryGetValue(key, out var session)) return null;
if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(key, out _); SaveSessions(); return null; }
var user = users.Find(session.User);
if (user == null) { sessions.TryRemove(token, out _); return null; }
if (user == null) { sessions.TryRemove(key, out _); SaveSessions(); return null; }
session.Expires = DateTime.UtcNow + SessionLifetime; // gleitend verlängern
if (DateTime.UtcNow - lastSessionSave > TimeSpan.FromMinutes(5)) SaveSessions();
return user;
}
@@ -761,16 +784,53 @@ public sealed class AdminPanel
void StartSession(HttpContext ctx, string name)
{
string token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
sessions[token] = new Session { User = name, Expires = DateTime.UtcNow + SessionLifetime };
sessions[TokenKey(token)] = new Session { User = name, Expires = DateTime.UtcNow + SessionLifetime };
ctx.Response.Cookies.Append(CookieName, token, CookieOptions(ctx));
// Alte Sitzungen aufräumen
foreach (var kv in sessions) if (kv.Value.Expires < DateTime.UtcNow) sessions.TryRemove(kv.Key, out _);
SaveSessions();
}
void EndSessionsOf(string name)
{
foreach (var kv in sessions)
if (string.Equals(kv.Value.User, name, StringComparison.OrdinalIgnoreCase)) sessions.TryRemove(kv.Key, out _);
SaveSessions();
}
// Sitzungen überstehen einen Neustart (z.B. nach einem Update). Gespeichert wird nur ein Hash des Cookies.
static readonly JsonSerializerOptions SessionJson = new() { IncludeFields = true };
readonly object sessionFileLock = new();
DateTime lastSessionSave = DateTime.MinValue;
string SessionsPath => Path.Combine(dataDir, "panel_sessions.json");
static string TokenKey(string token) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token)));
void LoadSessions()
{
try
{
if (!File.Exists(SessionsPath)) return;
var saved = JsonSerializer.Deserialize<Dictionary<string, Session>>(File.ReadAllText(SessionsPath), SessionJson);
foreach (var kv in saved ?? new())
if (kv.Value?.User != null && kv.Value.Expires > DateTime.UtcNow) sessions[kv.Key] = kv.Value;
}
catch { } // kaputte Datei: alle melden sich neu an
}
void SaveSessions()
{
lock (sessionFileLock)
{
lastSessionSave = DateTime.UtcNow;
try
{
string tmp = SessionsPath + ".tmp";
File.WriteAllText(tmp, JsonSerializer.Serialize(sessions.ToDictionary(kv => kv.Key, kv => kv.Value), SessionJson));
File.Move(tmp, SessionsPath, true);
}
catch { }
}
}
static CookieOptions CookieOptions(HttpContext ctx) => new()