using System.Diagnostics; using System.IO.Compression; using System.Reflection; using System.Runtime.Loader; using System.Text.Json; using System.Text.RegularExpressions; namespace MSCO.Server; /// /// Server-Updates aus dem Git-Repository (git.mbm.wtf). Prüft regelmäßig, ob auf "main" eine neuere Version /// (<Version> in MSCO.Server.csproj) liegt. Installieren im Panel: Quellcode laden, mit dem .NET SDK bauen /// (dotnet publish) und nach DATA/app/current legen, dann neu starten. Beim Start lädt das Programm von dort die neuere /// Version (siehe RunInstalled) - so klappt es auch in Docker, wo /app zum Image gehört. Ein neueres Image /// (docker compose up --build) gewinnt wieder gegen ein älteres installiertes Update. /// public sealed class Updater { /// Exit-Code "neu starten für das Update" - Docker (restart: unless-stopped) und systemd starten neu. public const int RestartExitCode = 75; const int MaxStartAttempts = 3; static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6); static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(15); const int MaxLogLines = 400; /// Quelle der Updates (für Forks/Tests per MSCO_UPDATE_SOURCE änderbar). public static string Source => (Environment.GetEnvironmentVariable("MSCO_UPDATE_SOURCE") ?? "https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server").TrimEnd('/'); public static string Branch => Environment.GetEnvironmentVariable("MSCO_UPDATE_BRANCH") ?? "main"; static string Api { get { var u = new Uri(Source); return $"{u.Scheme}://{u.Authority}/api/v1/repos{u.AbsolutePath.TrimEnd('/')}"; } } static readonly HttpClient Http = CreateHttp(); static HttpClient CreateHttp() { var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; http.DefaultRequestHeaders.UserAgent.ParseAdd("MSCO-Server/" + CurrentVersion); return http; } // ---------------------------------------------------------------- eigene Version static string Informational => typeof(Updater).Assembly.GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; /// Version dieses Programms (aus MSCO.Server.csproj), z.B. "2.9.0". public static string CurrentVersion => Informational.Split('+')[0]; /// Git-Commit dieses Builds (nur bei per Update gebauten Versionen bekannt). public static string CurrentCommit { get { int plus = Informational.IndexOf('+'); return plus < 0 ? null : Informational[(plus + 1)..]; } } public static bool IsNewer(string candidate, string than) => Version.TryParse(candidate, out var a) && Version.TryParse(than, out var b) && a > b; /// <Version> aus einer .csproj-Datei. public static string ParseCsprojVersion(string csproj) { var m = Regex.Match(csproj ?? "", @"\s*([0-9][0-9.]*)\s*"); return m.Success ? m.Groups[1].Value : null; } /// Mod-Version (Protokoll) aus GameServer.cs. public static string ParseModVersion(string source) { var m = Regex.Match(source ?? "", @"ModVersion\s*=\s*""([^""]+)"""); return m.Success ? m.Groups[1].Value : null; } // ---------------------------------------------------------------- Zustand fürs Panel public enum Step { Idle, Downloading, Building, Installing, Restarting, Failed } public sealed class Commit { public string Sha, Title, Date; } sealed class Latest { public string Version, Commit, Date, Message, ModVersion; public List Changes = new(); } readonly ServerHost host; readonly string dataDir; readonly object gate = new(); readonly List log = new(); Latest latest; DateTime? lastCheckUtc; string checkError; Step step = Step.Idle; int progress; string message, error, target, startedBy; DateTime stepSinceUtc = DateTime.UtcNow; bool notified; (bool ok, string reason)? sdk; public Updater(ServerHost host, string dataDir) { this.host = host; this.dataDir = dataDir; } string AppDir => Path.Combine(dataDir, "app"); string WorkDir => Path.Combine(dataDir, "update"); /// Hintergrund: kurz nach dem Start und dann alle 6 Stunden prüfen; neu installierte Version als gut markieren. public void StartBackground() { _ = Task.Run(async () => { await Task.Delay(TimeSpan.FromSeconds(60)); MarkHealthy(); // eine Minute ohne Absturz: das installierte Update läuft while (true) { try { await CheckAsync(); } catch { } await Task.Delay(CheckInterval); } }); } bool firstCheckStarted; public object Status() { // Noch nie geprüft (gerade erst gestartet): jetzt im Hintergrund prüfen, damit das Panel den Punkt bald zeigt if (lastCheckUtc == null && !firstCheckStarted) { firstCheckStarted = true; _ = Task.Run(async () => { try { await CheckAsync(); } catch { } }); } lock (gate) { var sdkInfo = sdk; return new { current = CurrentVersion, currentCommit = CurrentCommit, modVersion = GameServer.ModVersion, source = Source, branch = Branch, latest = latest?.Version, latestCommit = latest?.Commit, latestDate = latest?.Date, latestMessage = latest?.Message, latestModVersion = latest?.ModVersion, available = latest != null && IsNewer(latest.Version, CurrentVersion), changes = latest?.Changes.Select(c => new { sha = c.Sha, title = c.Title, date = c.Date }), lastCheck = lastCheckUtc?.ToString("o"), checkError, canInstall = sdkInfo?.ok, cannotInstall = sdkInfo is { ok: false } ? sdkInfo.Value.reason : null, supervised = Supervised, step = step.ToString().ToLowerInvariant(), progress, message, error, target, startedBy, stepSince = stepSinceUtc.ToString("o"), log = log.ToArray() }; } } public bool Busy { get { lock (gate) return step is Step.Downloading or Step.Building or Step.Installing or Step.Restarting; } } void SetStep(Step s, int percent, string text) { lock (gate) { if (s != step) stepSinceUtc = DateTime.UtcNow; step = s; progress = percent; message = text; } AddLog("== " + text); } void SetProgress(int percent, string text = null) { lock (gate) { progress = Math.Max(progress, Math.Clamp(percent, 0, 100)); if (text != null) message = text; } } void AddLog(string line) { lock (gate) { log.Add(line); if (log.Count > MaxLogLines) log.RemoveRange(0, log.Count - MaxLogLines); } } // ---------------------------------------------------------------- Prüfen /// Neueste Version auf dem Branch abfragen. public async Task CheckAsync() { CheckSdk(); try { using var branchDoc = JsonDocument.Parse(await Http.GetStringAsync($"{Api}/branches/{Branch}")); var commit = branchDoc.RootElement.GetProperty("commit"); string sha = commit.GetProperty("id").GetString(); string msg = commit.TryGetProperty("message", out var m) ? m.GetString() : ""; string date = commit.TryGetProperty("timestamp", out var t) ? t.GetString() : null; string version = ParseCsprojVersion(await Http.GetStringAsync($"{Source}/raw/commit/{sha}/MSCO.Server.csproj")) ?? throw new Exception("No in MSCO.Server.csproj"); string modVersion = null; try { modVersion = ParseModVersion(await Http.GetStringAsync($"{Source}/raw/commit/{sha}/GameServer.cs")); } catch { } var found = new Latest { Version = version, Commit = sha, Date = date, ModVersion = modVersion, Message = (msg ?? "").Split('\n')[0].Trim() }; if (IsNewer(version, CurrentVersion)) { try { found.Changes = await ChangesAsync(sha); } catch { } } bool newlyAvailable; lock (gate) { newlyAvailable = IsNewer(version, CurrentVersion) && (latest == null || latest.Version != version || !notified); latest = found; lastCheckUtc = DateTime.UtcNow; checkError = null; if (newlyAvailable) notified = true; } if (newlyAvailable) GameServer.Log($"Update available: MSCO server {version} (you have {CurrentVersion}) - install it in the web panel (Update)" + (modVersion != null && modVersion != GameServer.ModVersion ? $". Players then need MSCO mod {modVersion}." : "")); } catch (Exception e) { lock (gate) { lastCheckUtc = DateTime.UtcNow; checkError = "Could not check for updates: " + Short(e); } } } /// Commits seit der eigenen Version (bzw. die letzten, wenn der eigene Commit unbekannt ist). async Task> ChangesAsync(string sha) { using var doc = JsonDocument.Parse(await Http.GetStringAsync( $"{Api}/commits?sha={sha}&limit=30&stat=false&verification=false&files=false")); var list = new List(); string own = CurrentCommit; foreach (var c in doc.RootElement.EnumerateArray()) { string id = c.GetProperty("sha").GetString(); if (own != null && id.StartsWith(own, StringComparison.OrdinalIgnoreCase)) break; var inner = c.GetProperty("commit"); string text = inner.GetProperty("message").GetString() ?? ""; string date = inner.TryGetProperty("author", out var a) && a.TryGetProperty("date", out var d) ? d.GetString() : null; list.Add(new Commit { Sha = id[..Math.Min(8, id.Length)], Title = text.Split('\n')[0].Trim(), Date = date }); if (list.Count >= (own != null ? 30 : 10)) break; } return list; } /// Ist ein .NET SDK zum Bauen da? (Docker-Image ab 2.9 enthält es.) void CheckSdk() { if (sdk != null) return; try { var psi = new ProcessStartInfo(DotnetPath(), "--list-sdks") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; using var p = Process.Start(psi); string output = p.StandardOutput.ReadToEnd(); p.WaitForExit(15000); int need = Environment.Version.Major; bool ok = output.Split('\n').Any(l => int.TryParse(l.Split('.')[0].Trim(), out int major) && major >= need); lock (gate) sdk = ok ? (true, null) : (false, $"No .NET {need} SDK found - it is needed to build the update. " + "With Docker: pull the new version once (git pull && docker compose up -d --build), its image contains the SDK."); } catch (Exception e) { lock (gate) sdk = (false, "The dotnet command was not found (" + Short(e) + ") - install the .NET SDK to update from the panel."); } } static string DotnetPath() { string self = Environment.ProcessPath; string name = Path.GetFileNameWithoutExtension(self ?? ""); return name.Equals("dotnet", StringComparison.OrdinalIgnoreCase) ? self : "dotnet"; } // ---------------------------------------------------------------- Installieren /// Update im Hintergrund laden, bauen, installieren und neu starten. null = gestartet, sonst Fehlertext. public string Install(string by) { lock (gate) { if (step is Step.Downloading or Step.Building or Step.Installing or Step.Restarting) return "An update is already running"; if (latest == null || !IsNewer(latest.Version, CurrentVersion)) return "No update available - check again first"; if (sdk is { ok: false }) return sdk.Value.reason; log.Clear(); error = null; target = latest.Version; startedBy = by; step = Step.Downloading; progress = 0; stepSinceUtc = DateTime.UtcNow; } GameServer.Log($"Server update to {target} started by {by}"); _ = Task.Run(InstallAsync); return null; } async Task InstallAsync() { try { // 1) Quellcode des neuesten Commits laden SetStep(Step.Downloading, 2, "Checking the newest version"); await CheckAsync(); Latest want; lock (gate) want = latest; if (want == null || !IsNewer(want.Version, CurrentVersion)) throw new Exception(checkError ?? "No update available anymore"); lock (gate) target = want.Version; Directory.CreateDirectory(WorkDir); string zip = Path.Combine(WorkDir, "source.zip"), src = Path.Combine(WorkDir, "src"); SetStep(Step.Downloading, 5, $"Downloading MSCO server {want.Version} ({want.Commit[..8]})"); await DownloadAsync($"{Source}/archive/{want.Commit}.zip", zip); if (Directory.Exists(src)) Directory.Delete(src, true); ZipFile.ExtractToDirectory(zip, src); string csproj = Directory.GetFiles(src, "MSCO.Server.csproj", SearchOption.AllDirectories) .OrderBy(p => p.Length).FirstOrDefault() ?? throw new Exception("MSCO.Server.csproj not found in the download"); SetProgress(25, "Download complete"); // 2) Bauen string build = Path.Combine(AppDir, "build"); if (Directory.Exists(build)) Directory.Delete(build, true); SetStep(Step.Building, 27, "Building (dotnet publish) - this takes a minute"); await BuildAsync(csproj, build, want.Commit); string dll = Path.Combine(build, "MSCO.Server.dll"); if (!File.Exists(dll)) throw new Exception("The build did not produce MSCO.Server.dll"); // 3) Installieren: build -> current (die bisherige bleibt als previous) SetStep(Step.Installing, 92, "Installing"); WriteInfo(build, new InstallInfo { Version = want.Version, Commit = want.Commit, InstalledUtc = DateTime.UtcNow, By = startedBy }); string current = Path.Combine(AppDir, "current"), previous = Path.Combine(AppDir, "previous"); if (Directory.Exists(previous)) Directory.Delete(previous, true); if (Directory.Exists(current)) Directory.Move(current, previous); Directory.Move(build, current); try { Directory.Delete(src, true); File.Delete(zip); } catch { } AddLog("Installed to " + current); // 4) Neu starten - die Spieler verbinden sich danach automatisch wieder SetStep(Step.Restarting, 96, $"Restarting with version {want.Version}"); var server = host.Server; if (server != null && host.State == HostState.Running) { // im Server-Thread: Spieler zählen und vorwarnen var warn = server.Invoke(() => server.JoinedCount > 0 && server.PanelSay("Server update - restarting in 5 seconds, you reconnect automatically", "Server") != null); if (await Task.WhenAny(warn, Task.Delay(3000)) == warn && warn.Result) await Task.Delay(5000); } await Task.Delay(1500); // Panel: "Neustart" noch anzeigen lassen GameServer.Log($"Update {want.Version} installed - restarting"); host.ExitForUpdate(); } catch (Exception e) { lock (gate) { step = Step.Failed; error = Short(e); message = "Update failed"; stepSinceUtc = DateTime.UtcNow; } AddLog("FAILED: " + Short(e)); GameServer.Log("Server update failed: " + Short(e) + " - the server keeps running the old version"); } } async Task DownloadAsync(string url, string file) { using var res = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); res.EnsureSuccessStatusCode(); long? total = res.Content.Headers.ContentLength; await using var input = await res.Content.ReadAsStreamAsync(); await using var output = File.Create(file); var buffer = new byte[81920]; long done = 0; int read; while ((read = await input.ReadAsync(buffer)) > 0) { await output.WriteAsync(buffer.AsMemory(0, read)); done += read; string mb = (done / 1048576.0).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture); if (total > 0) SetProgress(5 + (int)(18 * done / total.Value), $"Downloading … {mb} MB"); else SetProgress(Math.Min(22, 5 + (int)(done / 100_000)), $"Downloading … {mb} MB"); } AddLog($"Downloaded {done / 1024} KB"); } async Task BuildAsync(string csproj, string output, string commit) { var psi = new ProcessStartInfo(DotnetPath()) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = Path.GetDirectoryName(csproj) }; foreach (string a in new[] { "publish", csproj, "-c", "Release", "-o", output, "--nologo", "--disable-build-servers", "-p:SourceRevisionId=" + commit, "-p:UseAppHost=" + (OperatingSystem.IsWindows() ? "true" : "false") }) psi.ArgumentList.Add(a); // Alles Schreibbare in den Datenordner (in Docker ist HOME nur /tmp) psi.Environment["DOTNET_CLI_HOME"] = Path.Combine(WorkDir, "home"); psi.Environment["NUGET_PACKAGES"] = Path.Combine(WorkDir, "nuget"); psi.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; psi.Environment["DOTNET_NOLOGO"] = "1"; psi.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; psi.Environment["MSBUILDDISABLENODEREUSE"] = "1"; using var p = new Process { StartInfo = psi }; void OnLine(string line) { if (string.IsNullOrWhiteSpace(line)) return; AddLog(line.TrimEnd()); if (line.Contains("Restored ") || line.Contains("All projects are up-to-date")) SetProgress(45, "Building - packages restored"); else if (line.Contains(" -> ") && line.Contains(".dll")) SetProgress(80, "Building - compiled"); else if (line.Contains(" -> ")) SetProgress(90, "Building - published"); } p.OutputDataReceived += (_, e) => { if (e.Data != null) OnLine(e.Data); }; p.ErrorDataReceived += (_, e) => { if (e.Data != null) OnLine(e.Data); }; p.Start(); p.BeginOutputReadLine(); p.BeginErrorReadLine(); // Fortschritt langsam weiterlaufen lassen, solange das Bauen dauert var started = DateTime.UtcNow; while (!p.HasExited) { await Task.Delay(1000); if (DateTime.UtcNow - started > BuildTimeout) { try { p.Kill(true); } catch { } throw new Exception("The build took longer than " + BuildTimeout.TotalMinutes + " minutes"); } lock (gate) if (progress < 88) progress++; } p.WaitForExit(); if (p.ExitCode != 0) { string lastError; lock (gate) lastError = log.LastOrDefault(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)) ?? "see the log"; throw new Exception($"The build failed (exit code {p.ExitCode}): {lastError}"); } SetProgress(91, "Build complete"); } static string Short(Exception e) { while (e.InnerException != null && e is AggregateException or TargetInvocationException) e = e.InnerException; return e.Message; } // ---------------------------------------------------------------- Neu starten /// Läuft das Programm unter Docker/systemd (die starten nach dem Beenden neu)? public static bool Supervised => Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true" || Environment.GetEnvironmentVariable("INVOCATION_ID") != null // systemd || Environment.GetEnvironmentVariable("MSCO_SUPERVISED") == "1"; /// Nach dem Beenden für ein Update: ohne Docker/systemd startet sich das Programm selbst neu. public static void Relaunch(string[] args) { if (Supervised) { GameServer.Log("Exiting for the update - Docker/systemd starts the server again"); return; } try { string self = Environment.ProcessPath; var all = new List(); if (Path.GetFileNameWithoutExtension(self).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) all.Add(Environment.GetCommandLineArgs()[0]); // die gestartete MSCO.Server.dll all.AddRange(args); var psi = new ProcessStartInfo(self) { Arguments = string.Join(" ", all.Select(Quote)), WorkingDirectory = Environment.CurrentDirectory, UseShellExecute = OperatingSystem.IsWindows() // Windows: neues Konsolenfenster }; Process.Start(psi); GameServer.Log("Started the updated server"); } catch (Exception e) { GameServer.Log("Could not restart automatically (" + e.Message + ") - please start the server again"); } } static string Quote(string a) => a.Length > 0 && a.IndexOfAny(new[] { ' ', '"', '\t' }) < 0 ? a : "\"" + a.Replace("\"", "\\\"") + "\""; // ---------------------------------------------------------------- installierte Version starten sealed class InstallInfo { public string Version { get; set; } public string Commit { get; set; } public DateTime InstalledUtc { get; set; } public string By { get; set; } public int Attempts { get; set; } public bool Verified { get; set; } } static InstallInfo ReadInfo(string dir) { try { return JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(dir, "msco-update.json"))); } catch { return null; } } static void WriteInfo(string dir, InstallInfo info) => File.WriteAllText(Path.Combine(dir, "msco-update.json"), JsonSerializer.Serialize(info, new JsonSerializerOptions { WriteIndented = true })); /// Läuft gerade eine installierte Update-Version? Dann nach einer Minute als funktionierend merken. static void MarkHealthy() { string dir = Environment.GetEnvironmentVariable("MSCO_UPDATE_DIR"); if (dir == null) return; var info = ReadInfo(dir); if (info == null || info.Verified) return; info.Verified = true; info.Attempts = 0; try { WriteInfo(dir, info); } catch { } } /// /// Ganz am Anfang von Program: liegt in DATA/app/current eine neuere Version, diese statt der eigenen ausführen /// (im selben Prozess, eigener AssemblyLoadContext - Signale, Konsole und Docker bleiben gleich). Gibt den Exit-Code /// zurück oder null, wenn die eigene Version laufen soll. /// public static int? RunInstalled(string[] args, string dataDir) { if (Environment.GetEnvironmentVariable("MSCO_UPDATE_DIR") != null) return null; // wir SIND die installierte Version if (Environment.GetEnvironmentVariable("MSCO_NO_UPDATES") == "1") return null; string dir = Path.Combine(dataDir, "app", "current"); var info = ReadInfo(dir); if (info == null) return null; string dll = Path.Combine(dir, "MSCO.Server.dll"); if (!File.Exists(dll)) return null; if (!IsNewer(info.Version, CurrentVersion)) { // Das Programm selbst ist gleich neu oder neuer (z.B. neues Docker-Image) - altes Update aufräumen Console.WriteLine($"Installed update {info.Version} is not newer than this version ({CurrentVersion}) - removing it"); try { Directory.Delete(Path.Combine(dataDir, "app"), true); } catch { } return null; } if (!info.Verified && info.Attempts >= MaxStartAttempts) { Console.WriteLine($"The installed update {info.Version} failed to start {info.Attempts} times - running the built-in version {CurrentVersion}"); try { string failed = Path.Combine(dataDir, "app", "failed"); if (Directory.Exists(failed)) Directory.Delete(failed, true); Directory.Move(dir, failed); } catch { } return null; } if (!info.Verified) { info.Attempts++; try { WriteInfo(dir, info); } catch { } } MethodInfo entry; try { var context = new UpdateLoadContext(dll); entry = context.LoadFromAssemblyPath(dll).EntryPoint ?? throw new Exception("no entry point"); } catch (Exception e) { Console.WriteLine($"Could not load the installed update {info.Version}: {e.Message} - running the built-in version {CurrentVersion}"); return null; } Console.WriteLine($"Starting installed update {info.Version} (built-in version {CurrentVersion})"); Environment.SetEnvironmentVariable("MSCO_UPDATE_DIR", dir); try { object result = entry.Invoke(null, entry.GetParameters().Length == 0 ? null : new object[] { args }); if (result is Task task) return task.GetAwaiter().GetResult(); if (result is Task plain) { plain.GetAwaiter().GetResult(); return 0; } return result is int code ? code : 0; } catch (TargetInvocationException e) when (e.InnerException != null) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); throw; } } /// Lädt die installierte Version samt ihren Abhängigkeiten (z.B. SharpCompress) aus ihrem Ordner. sealed class UpdateLoadContext : AssemblyLoadContext { readonly AssemblyDependencyResolver resolver; public UpdateLoadContext(string mainAssembly) : base("msco-update") => resolver = new AssemblyDependencyResolver(mainAssembly); protected override Assembly Load(AssemblyName name) { string path = resolver.ResolveAssemblyToPath(name); return path != null ? LoadFromAssemblyPath(path) : null; // Framework (ASP.NET ...) aus dem Standard-Kontext } protected override IntPtr LoadUnmanagedDll(string name) { string path = resolver.ResolveUnmanagedDllToPath(name); return path != null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero; } } }