Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Auto-backup driven by config.BackupSchedule. Accepted formats:
/// - "HH:mm" single daily slot (e.g. "04:00")
/// - "HH:mm,HH:mm,..." multiple daily slots
/// - "every Nh" every N hours (>= 15 minutes)
/// - "every Nm" every N minutes
/// Wakes once a minute and fires backups when the clock matches the spec.
/// Doesn't catch up if the server was off when a slot passed -- daily/interval
/// backups don't need replay logic.
/// </summary>
public sealed class BackupScheduler : IDisposable
{
private readonly ServerConfig _config;
private readonly BackupService _backup;
private readonly Action<string> _log;
private CancellationTokenSource? _cts;
private Task? _loop;
// Tracking for "fired" state. For interval: just the last fire time. For
// daily-times: which times have fired today, reset at day rollover.
private DateTimeOffset? _lastIntervalFire;
private DateOnly _lastFireDay = DateOnly.MinValue;
private readonly HashSet<TimeOnly> _firedToday = new();
public BackupScheduler(ServerConfig config, BackupService backup, Action<string> log)
{
_config = config;
_backup = backup;
_log = log;
}
public void Start()
{
if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return;
if (Parse(_config.BackupSchedule) == default)
{
_log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled.");
return;
}
_cts?.Cancel();
_cts = new CancellationTokenSource();
_loop = Task.Run(() => RunAsync(_cts.Token));
_log($"[backup-scheduler] Schedule active: {Describe()}");
}
/// <summary>Stop the current loop and re-Start with the latest config values.</summary>
public void Reload()
{
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts?.Dispose();
_cts = null;
_loop = null;
Start();
}
/// <summary>Compute the next future scheduled fire time. Null if no schedule.</summary>
public DateTimeOffset? NextRun()
{
var (interval, times) = Parse(_config.BackupSchedule);
if (interval.HasValue)
{
var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1);
var next = baseTime + interval.Value;
// If we've never fired and we're past the implied first slot, "next" might be
// in the past -- clamp to "imminent" by using now + small buffer.
if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1);
return next.ToLocalTime();
}
if (times is not null)
{
var now = DateTime.Now;
var nowTime = TimeOnly.FromDateTime(now);
// Use Cast<TimeOnly?>().FirstOrDefault() so "no pending" is null rather than 00:00.
var pendingToday = times
.Where(t => t > nowTime && !_firedToday.Contains(t))
.OrderBy(t => t)
.Cast<TimeOnly?>()
.FirstOrDefault();
if (pendingToday.HasValue)
return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan()));
// None left today -- first slot tomorrow.
var firstTomorrow = times.Min();
return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan()));
}
return null;
}
private async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(TimeSpan.FromMinutes(1), ct); }
catch (OperationCanceledException) { break; }
var (interval, times) = Parse(_config.BackupSchedule);
if (interval is null && times is null) continue;
var nowUtc = DateTimeOffset.UtcNow;
var nowLocal = DateTime.Now;
var today = DateOnly.FromDateTime(nowLocal);
var nowTime = TimeOnly.FromDateTime(nowLocal);
bool shouldFire = false;
if (interval.HasValue)
{
shouldFire = !_lastIntervalFire.HasValue
|| (nowUtc - _lastIntervalFire.Value) >= interval.Value;
}
else if (times is not null)
{
if (today != _lastFireDay)
{
_firedToday.Clear();
_lastFireDay = today;
}
foreach (var t in times)
{
if (t <= nowTime && !_firedToday.Contains(t))
{
shouldFire = true;
_firedToday.Add(t);
break;
}
}
}
if (!shouldFire) continue;
_log("[backup-scheduler] Triggering scheduled backup.");
try
{
var result = await _backup.CreateAsync("scheduled", ct: ct);
if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB).");
else _log($"[backup-scheduler] Failed: {result.Error}");
if (interval.HasValue) _lastIntervalFire = nowUtc;
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); }
}
}
/// <summary>Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty.</summary>
private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input)
{
if (string.IsNullOrWhiteSpace(input)) return (null, null);
var s = input.Trim().ToLowerInvariant();
// every Nh / every Nm
var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$");
if (m.Success)
{
var n = int.Parse(m.Groups[1].Value);
var unit = m.Groups[2].Value;
var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n);
// Sanity floor -- anything below 15 min creates more save-lag than backups are worth.
if (span < TimeSpan.FromMinutes(15)) return (null, null);
if (span > TimeSpan.FromDays(7)) return (null, null);
return (span, null);
}
// Comma-separated HH:mm
var list = new List<TimeOnly>();
foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null);
list.Add(t);
}
return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray());
}
public string Describe()
{
var (interval, times) = Parse(_config.BackupSchedule);
if (interval.HasValue)
{
var totalMin = (int)interval.Value.TotalMinutes;
if (totalMin >= 60 && totalMin % 60 == 0)
{
var h = totalMin / 60;
return h == 1 ? "Every hour" : $"Every {h} hours";
}
return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes";
}
if (times is not null)
{
if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}";
return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm")));
}
return "Disabled";
}
public void Dispose()
{
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts?.Dispose();
}
}
+257
View File
@@ -0,0 +1,257 @@
using System.IO.Compression;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// World backups: ZIP the level-name dir into a separate (typically slower-but-bigger)
/// backup directory. Online backups via /save-all flush + /save-off while the server is
/// running mean players don't see downtime -- just a brief save lag.
/// </summary>
public sealed class BackupService
{
private readonly ServerConfig _config;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action<string> log)
{
_config = config;
_proc = proc;
_broadcast = broadcast;
_log = log;
}
public sealed record BackupInfo(string Name, long SizeBytes, DateTimeOffset CreatedAt);
public sealed record CreateResult(bool Ok, string? Name, long SizeBytes, string? Error);
public string BackupDir => ResolveBackupDir();
public List<BackupInfo> List()
{
var dir = BackupDir;
if (!Directory.Exists(dir)) return new();
return Directory.EnumerateFiles(dir, "*.zip")
.Select(p =>
{
var fi = new FileInfo(p);
return new BackupInfo(fi.Name, fi.Length, new DateTimeOffset(fi.CreationTimeUtc, TimeSpan.Zero));
})
.OrderByDescending(b => b.CreatedAt)
.ToList();
}
/// <summary>
/// Create a ZIP backup of the world dir. Online (no shutdown) when the server is
/// running.
/// <para>
/// <paramref name="flush"/> = false (default): just <c>save-off</c> + brief drain +
/// ZIP + <c>save-on</c>. Near-zero player-visible lag. Backup captures state up to
/// MC's last autosave (within ~5 min) -- fine for hourly snapshots.
/// </para>
/// <para>
/// <paramref name="flush"/> = true: also runs <c>save-all flush</c> first, which
/// synchronously serialises every loaded chunk before the ZIP. Captures state up
/// to NOW. Causes a tick spike of seconds-to-tens-of-seconds depending on world
/// size. Used only for irreversible operations (pre-wipe) where freshness matters.
/// </para>
/// </summary>
public async Task<CreateResult> CreateAsync(string? reason = null, bool flush = false, CancellationToken ct = default)
{
if (!await _gate.WaitAsync(0, ct))
return new CreateResult(false, null, 0, "Another backup is already in progress.");
var dir = ResolveBackupDir();
try
{
Directory.CreateDirectory(dir);
}
catch (Exception ex)
{
_gate.Release();
return new CreateResult(false, null, 0, $"Couldn't create backup dir '{dir}': {ex.Message}");
}
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(_config.ServerDir, levelName);
if (!Directory.Exists(worldDir))
{
_gate.Release();
return new CreateResult(false, null, 0, $"World directory not found at '{worldDir}'.");
}
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var slug = string.IsNullOrWhiteSpace(reason) ? "" : "-" + Slugify(reason);
var name = $"{levelName}-{stamp}{slug}.zip";
var path = Path.Combine(dir, name);
var serverRunning = _proc.IsRunning;
try
{
if (serverRunning)
{
if (flush)
{
// Loud path: fresh state, but pays the tick spike. Tell players.
try { await _broadcast.SayAsync("Saving world for backup (brief lag possible)...", ct); } catch { }
_log("[backup] save-all flush");
await _proc.SendInputAsync("save-all flush", ct);
await Task.Delay(2500, ct);
}
_log("[backup] save-off");
await _proc.SendInputAsync("save-off", ct);
// Brief drain so any save tasks already enqueued can finish before we
// start reading from disk for the ZIP.
await Task.Delay(500, ct);
}
_log($"[backup] Archiving {worldDir} -> {name}");
// Run on a worker thread so the request thread doesn't block on disk I/O.
await Task.Run(() =>
ZipFile.CreateFromDirectory(worldDir, path, CompressionLevel.Fastest, includeBaseDirectory: false), ct);
var size = new FileInfo(path).Length;
_log($"[backup] Created {name} ({size / (1024 * 1024)} MB).");
// No completion broadcast for silent path -- backup was invisible to players,
// no need to tell them it finished. Loud path is wipe-only and the wipe
// sequence has its own messaging.
RotateOldest(dir, _config.BackupKeep);
return new CreateResult(true, name, size, null);
}
catch (Exception ex)
{
_log($"[backup] Failed: {ex.Message}");
try { File.Delete(path); } catch { } // partial archive
return new CreateResult(false, null, 0, ex.Message);
}
finally
{
try
{
if (serverRunning && _proc.IsRunning)
{
_log("[backup] save-on");
await _proc.SendInputAsync("save-on");
}
}
catch { }
_gate.Release();
}
}
/// <summary>
/// Stop the server, move the current world out of the way as a "pre-restore" safety
/// copy, extract the chosen archive, restart.
/// </summary>
public async Task<(bool Ok, string? Error)> RestoreAsync(string backupName, CancellationToken ct = default)
{
if (!await _gate.WaitAsync(0, ct))
return (false, "Another backup operation is already in progress.");
try
{
var dir = ResolveBackupDir();
var path = Path.Combine(dir, Path.GetFileName(backupName));
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(_config.ServerDir, levelName);
if (_proc.IsRunning)
{
_log("[restore] Stopping server before restore...");
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
}
// Always preserve the current world as a pre-restore snapshot in case the
// chosen archive is corrupt or wrong.
if (Directory.Exists(worldDir))
{
var preRestore = $"{worldDir}-prerestore-{DateTime.UtcNow:yyyyMMddHHmmss}";
_log($"[restore] Moving current world to {Path.GetFileName(preRestore)}");
Directory.Move(worldDir, preRestore);
}
_log($"[restore] Extracting {backupName}");
Directory.CreateDirectory(worldDir);
await Task.Run(() => ZipFile.ExtractToDirectory(path, worldDir, overwriteFiles: true), ct);
_log("[restore] Starting server.");
_proc.Start();
return (true, null);
}
catch (Exception ex)
{
_log($"[restore] Failed: {ex.Message}");
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
return (false, ex.Message);
}
finally
{
_gate.Release();
}
}
public (bool Ok, string? Error) Delete(string backupName)
{
try
{
var path = Path.Combine(ResolveBackupDir(), Path.GetFileName(backupName));
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
File.Delete(path);
return (true, null);
}
catch (Exception ex) { return (false, ex.Message); }
}
private string ResolveBackupDir()
{
if (!string.IsNullOrWhiteSpace(_config.BackupDir))
return Path.GetFullPath(_config.BackupDir);
// Default: sibling of serverDir so it survives server-dir wipes.
var serverFull = Path.GetFullPath(_config.ServerDir);
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
return Path.Combine(parent, "backups");
}
private static void RotateOldest(string dir, int keep)
{
if (keep <= 0) return;
try
{
var zips = Directory.EnumerateFiles(dir, "*.zip")
.Select(p => new FileInfo(p))
.OrderByDescending(fi => fi.CreationTimeUtc)
.ToList();
foreach (var old in zips.Skip(keep))
{
try { old.Delete(); }
catch { /* best-effort */ }
}
}
catch { }
}
private static string? ReadLevelName(string serverDir)
{
var path = Path.Combine(serverDir, "server.properties");
if (!File.Exists(path)) return null;
foreach (var line in File.ReadAllLines(path))
{
if (line.StartsWith("level-name=", StringComparison.Ordinal))
return line.Substring("level-name=".Length).Trim();
}
return null;
}
private static string Slugify(string s)
{
var chars = s.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray();
var slug = new string(chars).ToLowerInvariant();
return slug.Length > 32 ? slug.Substring(0, 32) : slug;
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// On-demand snapshot of online players + positions, formatted for BlueMap's
/// live player overlay. Pull-based: the BlueMap web UI polls a JSON file at
/// <c>/map/maps/overworld/live/players.json</c> roughly every 2 s, and the
/// daemon intercepts that path and calls <see cref="SnapshotAsync"/> per
/// request. Closed tab = no requests = no RCON calls -- same model as
/// <c>/api/players</c>, no server-side timer to manage.
/// </summary>
public static class BlueMapPlayers
{
public static async Task<List<object>> SnapshotAsync(RconManager rcon, string serverDir, CancellationToken ct)
{
var listResp = await rcon.SendCommandAsync("list", ct);
// Format: "There are N of a max of M players online: name1, name2, ..."
var colon = listResp.IndexOf(':');
var names = colon < 0
? Array.Empty<string>()
: listResp.Substring(colon + 1)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var result = new List<object>();
foreach (var name in names)
{
ct.ThrowIfCancellationRequested();
try
{
var posResp = await rcon.SendCommandAsync($"data get entity {name} Pos", ct);
var pos = ParseDoubleTriple(posResp);
if (pos is null) continue;
var rotResp = await rcon.SendCommandAsync($"data get entity {name} Rotation", ct);
var rot = ParseFloatPair(rotResp);
result.Add(new
{
uuid = ResolveUuid(serverDir, name),
name,
foreign = false,
position = new { x = pos.Value.x, y = pos.Value.y, z = pos.Value.z },
rotation = new { pitch = rot?.pitch ?? 0, yaw = rot?.yaw ?? 0, roll = 0.0 },
});
}
catch
{
// One bad player shouldn't drop the rest of the list.
}
}
return result;
}
// ── UUID resolution ──────────────────────────────────────────────────────
// BlueMap uses the UUID for two things: marker identity across polls (so a
// changing UUID makes the marker flash on/off as it thinks the player keeps
// leaving and rejoining) and skin lookup against Mojang's profile API. We
// need the *real* Mojang UUID to satisfy both -- MC writes name→uuid pairs
// into <serverDir>/usercache.json after each successful auth. We cache that
// file's contents in memory and reload on mtime change, since usercache
// updates rarely (player join, periodic refresh).
private sealed class UserCacheEntry
{
public string? Name { get; set; }
public string? Uuid { get; set; }
}
private static readonly object _cacheLock = new();
private static Dictionary<string, string>? _cache; // case-insensitive name → uuid
private static DateTime _cacheLoadedAt = DateTime.MinValue;
private static string? _cachePath;
private static string ResolveUuid(string serverDir, string name)
{
var path = Path.Combine(Path.GetFullPath(serverDir), "usercache.json");
lock (_cacheLock)
{
if (File.Exists(path))
{
var mtime = File.GetLastWriteTimeUtc(path);
if (_cache is null || _cachePath != path || mtime > _cacheLoadedAt)
{
try
{
var json = File.ReadAllText(path);
var entries = JsonSerializer.Deserialize<UserCacheEntry[]>(json, JsonOpts.CaseInsensitive);
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (entries is not null)
foreach (var e in entries)
if (!string.IsNullOrEmpty(e.Name) && !string.IsNullOrEmpty(e.Uuid))
dict[e.Name] = e.Uuid;
_cache = dict;
_cachePath = path;
_cacheLoadedAt = mtime;
}
catch
{
// Corrupt usercache shouldn't take down the marker -- fall through.
}
}
}
if (_cache is not null && _cache.TryGetValue(name, out var uuid)) return uuid;
}
// Fallback when usercache hasn't been written yet (very early after
// first auth) or has been wiped: deterministic UUID derived from the
// name. Stable across polls so the marker doesn't flash; skin lookup
// will 404 against Mojang and BlueMap will show a default head.
return DeriveStableUuid(name).ToString();
}
private static Guid DeriveStableUuid(string name)
{
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes("brass-sigil:" + name.ToLowerInvariant()));
var guidBytes = new byte[16];
Array.Copy(bytes, guidBytes, 16);
return new Guid(guidBytes);
}
// Parse a NBT-style double triple like "[123.4d, 64.0d, -56.7d]" out of an
// RCON `data get` response.
private static (double x, double y, double z)? ParseDoubleTriple(string resp)
{
var m = Regex.Match(resp, @"\[([\-\d\.]+)d?,\s*([\-\d\.]+)d?,\s*([\-\d\.]+)d?\]");
if (!m.Success) return null;
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)) return null;
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) return null;
if (!double.TryParse(m.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var z)) return null;
return (x, y, z);
}
// Rotation comes as `[yaw, pitch]` in floats.
private static (double pitch, double yaw)? ParseFloatPair(string resp)
{
var m = Regex.Match(resp, @"\[([\-\d\.]+)f?,\s*([\-\d\.]+)f?\]");
if (!m.Success) return null;
if (!double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var yaw)) return null;
if (!double.TryParse(m.Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var pitch)) return null;
return (pitch, yaw);
}
}
+321
View File
@@ -0,0 +1,321 @@
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Wraps BlueMap CLI as an out-of-process renderer.
///
/// The CLI jar is downloaded from GitHub releases on first render (mirrors the
/// JavaInstaller pattern -- keeps the brass-sigil-server binary lean and lets
/// BlueMap update independently). BlueMap 5.20+ requires Java 25, so we also
/// auto-install Adoptium Temurin JRE 25 alongside the JRE 21 we use for MC.
///
/// Renders are kicked off manually from the panel and produce static HTML/JS/PNG
/// output served at /map/. Zero impact on the running MC server (separate JVM,
/// separate memory pool -- only competes for disk I/O during render).
/// </summary>
public sealed class BlueMapService : IDisposable
{
private const string BlueMapVersion = "5.20";
private const string BlueMapJarUrl =
"https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v"
+ BlueMapVersion + "/bluemap-" + BlueMapVersion + "-cli.jar";
private const int BlueMapJavaVersion = 25;
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
private readonly ServerConfig _config;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private Process? _renderProc;
public sealed class RenderState
{
public bool InProgress { get; set; }
public string Phase { get; set; } = "idle";
// "idle" | "extracting" | "configuring" | "rendering" | "complete" | "failed"
public string? Error { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public int? ExitCode { get; set; }
public string? LastLogLine { get; set; }
}
public RenderState State { get; private set; } = new();
public string RootDir
{
get
{
// Configured override (e.g. /mnt/md0p1/brass-sigil/bluemap) wins. Default
// is sibling of serverDir so it doesn't bloat the world dir or the
// server install -- typically ~/brass-sigil-server/bluemap.
if (!string.IsNullOrWhiteSpace(_config.BlueMapDir))
return Path.GetFullPath(_config.BlueMapDir);
var serverFull = Path.GetFullPath(_config.ServerDir);
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
return Path.Combine(parent, "bluemap");
}
}
public string CliJarPath => Path.Combine(RootDir, "cli.jar");
public string WebDir => Path.Combine(RootDir, "web");
public string ConfigDir => Path.Combine(RootDir, "config");
public BlueMapService(ServerConfig config, Action<string> log)
{
_config = config;
_log = log;
}
public bool HasRendered => Directory.Exists(WebDir) &&
File.Exists(Path.Combine(WebDir, "index.html"));
/// <summary>Kick off a render in the background. Returns false if one is already running.</summary>
public bool StartRender()
{
if (!_gate.Wait(0)) return false;
State = new RenderState { InProgress = true, Phase = "extracting", StartedAt = DateTimeOffset.UtcNow };
_ = Task.Run(RenderAsync);
return true;
}
/// <summary>
/// Cancel an in-progress render by killing the BlueMap process. State on disk
/// is preserved, so the next render resumes from where this one stopped.
/// </summary>
public bool CancelRender()
{
if (!State.InProgress) return false;
try
{
// Kill the whole process tree (the BlueMap CLI may spawn worker JVMs
// for parallel rendering). entireProcessTree=true on Linux uses
// process group; on Windows it walks the tree via Job Objects.
_renderProc?.Kill(entireProcessTree: true);
State.Phase = "cancelled";
State.Error = "Cancelled by user.";
_log("[bluemap] Render cancelled by user.");
return true;
}
catch (Exception ex)
{
_log($"[bluemap] Cancel failed: {ex.Message}");
return false;
}
}
/// <summary>
/// Delete rendered map output + render state. Used after a world wipe -- the
/// old tiles reference terrain that no longer exists. Preserves cli.jar and
/// configs so a follow-up Render still works (and skips re-download +
/// re-config). Returns true if anything was deleted.
/// </summary>
public bool ClearRenderOutput()
{
var dataDir = Path.Combine(RootDir, "data");
var mapsDir = Path.Combine(RootDir, "web", "maps");
var anyDeleted = false;
try
{
if (Directory.Exists(dataDir)) { Directory.Delete(dataDir, true); anyDeleted = true; _log("[bluemap] Cleared render state."); }
if (Directory.Exists(mapsDir)) { Directory.Delete(mapsDir, true); anyDeleted = true; _log("[bluemap] Cleared rendered tiles."); }
}
catch (Exception ex) { _log($"[bluemap] Couldn't clear output: {ex.Message}"); }
return anyDeleted;
}
/// <summary>
/// Called when brass-sigil-server itself shuts down -- kill any in-flight
/// BlueMap process so it doesn't orphan to PID 1 and keep eating CPU after
/// the daemon's gone. Render state on disk is preserved; next start can
/// resume the render exactly where this one was killed.
/// </summary>
public void Dispose()
{
try
{
if (_renderProc is { HasExited: false })
{
_renderProc.Kill(entireProcessTree: true);
_renderProc.WaitForExit(2000);
}
}
catch { }
_renderProc?.Dispose();
_gate.Dispose();
}
private async Task RenderAsync()
{
try
{
Directory.CreateDirectory(RootDir);
State.Phase = "downloading";
await DownloadJarIfMissingAsync();
// BlueMap 5.20 needs Java 25; our MC server runs Java 21. Maintain a
// separate JRE 25 install for BlueMap only.
var bluemapJava = await EnsureJava25Async();
if (bluemapJava is null) throw new InvalidOperationException("Couldn't install JRE 25 for BlueMap.");
// First run: BlueMap with no config writes default configs and exits.
// We need to (a) run it once to seed configs, (b) patch the world path,
// (c) re-run with -r to actually render.
State.Phase = "configuring";
EnsureConfig(bluemapJava);
State.Phase = "rendering";
// -r = render. --mods <modsDir> = pull textures from mod jars so Create
// blocks etc. show real colours instead of magenta/grey fallback.
var modsDir = Path.Combine(Path.GetFullPath(_config.ServerDir), "mods");
var args = new List<string> { "-jar", CliJarPath, "-r" };
if (Directory.Exists(modsDir))
{
args.Add("--mods");
args.Add(modsDir);
}
var psi = new ProcessStartInfo
{
FileName = bluemapJava,
WorkingDirectory = RootDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
_renderProc = new Process { StartInfo = psi, EnableRaisingEvents = true };
_renderProc.OutputDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
_renderProc.ErrorDataReceived += (_, e) => { if (e.Data is { } line) { State.LastLogLine = line; _log($"[bluemap] {line}"); } };
_renderProc.Start();
_renderProc.BeginOutputReadLine();
_renderProc.BeginErrorReadLine();
await _renderProc.WaitForExitAsync();
State.ExitCode = _renderProc.ExitCode;
State.FinishedAt = DateTimeOffset.UtcNow;
if (_renderProc.ExitCode == 0) State.Phase = "complete";
else { State.Phase = "failed"; State.Error = $"BlueMap exited with code {_renderProc.ExitCode}"; }
}
catch (Exception ex)
{
State.Phase = "failed";
State.Error = ex.Message;
State.FinishedAt = DateTimeOffset.UtcNow;
_log($"[bluemap] Failed: {ex.Message}");
}
finally
{
State.InProgress = false;
_renderProc?.Dispose();
_renderProc = null;
_gate.Release();
}
}
private async Task DownloadJarIfMissingAsync()
{
if (File.Exists(CliJarPath)) return;
_log($"[bluemap] Downloading BlueMap CLI v{BlueMapVersion} to {CliJarPath}");
Directory.CreateDirectory(Path.GetDirectoryName(CliJarPath)!);
using var resp = await _http.GetAsync(BlueMapJarUrl, HttpCompletionOption.ResponseHeadersRead);
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync();
await using var dst = File.Create(CliJarPath);
await src.CopyToAsync(dst);
_log($"[bluemap] Downloaded {new FileInfo(CliJarPath).Length / 1024} KB.");
}
/// <summary>Reuse JavaInstaller to drop a JRE 25 next to JRE 21 (separate dirs).</summary>
private async Task<string?> EnsureJava25Async()
{
var installer = new JavaInstaller();
var serverFull = Path.GetFullPath(_config.ServerDir);
// Look for an existing JRE 25 install first (idempotent across renders).
var existing = installer.FindBundledJava(serverFull, BlueMapJavaVersion);
if (existing is not null) return existing;
var installDir = installer.GetJavaInstallDir(serverFull, BlueMapJavaVersion);
var progress = new Progress<string>(msg => _log("[bluemap] " + msg));
return await installer.InstallJreAsync(BlueMapJavaVersion, serverFull, installDir, progress, default);
}
/// <summary>
/// Run BlueMap with no flags so it writes default configs (bluemap.conf,
/// maps/overworld.conf, etc.), then patch the overworld map's world path
/// to point at our serverDir. Idempotent -- only writes configs that don't
/// exist; existing user edits survive.
/// </summary>
private void EnsureConfig(string javaPath)
{
Directory.CreateDirectory(ConfigDir);
var bluemapConf = Path.Combine(ConfigDir, "core.conf");
var seeded = File.Exists(bluemapConf);
if (!seeded)
{
_log("[bluemap] First run -- generating default configs.");
var psi = new ProcessStartInfo
{
FileName = javaPath,
WorkingDirectory = RootDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-jar");
psi.ArgumentList.Add(CliJarPath);
using var p = Process.Start(psi)!;
p.WaitForExit(60_000);
}
// Patch the overworld map config to reference our world dir.
var serverDirAbs = Path.GetFullPath(_config.ServerDir);
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(serverDirAbs, levelName).Replace('\\', '/');
var mapsDir = Path.Combine(ConfigDir, "maps");
Directory.CreateDirectory(mapsDir);
var owConf = Path.Combine(mapsDir, "overworld.conf");
if (!File.Exists(owConf) || !File.ReadAllText(owConf).Contains(worldDir))
{
File.WriteAllText(owConf, $@"# Generated by brass-sigil-server. Edit at your own risk.
world: ""{worldDir}""
dimension: ""minecraft:overworld""
name: ""Brass and Sigil -- Overworld""
sorting: 0
sky-color: ""#7dabff""
ambient-light: 0
");
_log("[bluemap] Wrote map config: maps/overworld.conf");
}
// Tell core.conf to accept that we read its license/disclaimer (otherwise CLI exits with a notice).
if (File.Exists(bluemapConf))
{
var text = File.ReadAllText(bluemapConf);
if (!text.Contains("accept-download: true"))
{
text = text.Replace("accept-download: false", "accept-download: true");
if (!text.Contains("accept-download:"))
text += "\naccept-download: true\n";
File.WriteAllText(bluemapConf, text);
_log("[bluemap] Set accept-download: true in core.conf");
}
}
}
private static string? ReadLevelName(string serverDir)
{
var path = Path.Combine(serverDir, "server.properties");
if (!File.Exists(path)) return null;
foreach (var line in File.ReadAllLines(path))
{
if (line.StartsWith("level-name=", StringComparison.Ordinal))
return line.Substring("level-name=".Length).Trim();
}
return null;
}
}
+109
View File
@@ -0,0 +1,109 @@
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Pushes player-visible messages and overlays into Minecraft via stdin
/// commands (/say, /title, /bossbar). The boss-bar countdown is the primary
/// primitive the updater uses for restart announcements.
/// </summary>
public sealed class Broadcaster
{
private readonly ServerProcess _proc;
private const string BossBarId = "brass:announce";
public Broadcaster(ServerProcess proc) => _proc = proc;
public Task SayAsync(string message, CancellationToken ct = default)
=> _proc.SendInputAsync($"say {SingleLine(message)}", ct);
public Task ActionBarAsync(string message, CancellationToken ct = default)
=> _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(message)}\"}}", ct);
/// <summary>
/// Re-sends the action bar text once per second so it stays sticky for the
/// full duration. Action bar fades after ~2-3 s of inactivity, so the
/// re-send is mandatory. Doesn't conflict with boss-bar UI for actual
/// boss fights -- preferred over BossBarCountdownAsync for restart warnings.
/// </summary>
public async Task ActionBarCountdownAsync(
string title, int durationSeconds, CancellationToken ct = default)
{
if (durationSeconds <= 0) return;
// Silence /title's "Showing new title for X" chat broadcast for the loop --
// otherwise it spams chat once per second per online player. Restored in
// the finally block. World save typically isn't quick enough to persist
// the off state if we crash mid-flight, but worst case admins can flip
// it back manually with /gamerule sendCommandFeedback true.
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
try
{
for (int sec = durationSeconds; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
var mins = sec / 60;
var secs = sec % 60;
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
await _proc.SendInputAsync($"title @a actionbar {{\"text\":\"{EscapeJson(label)}\"}}", ct);
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
}
finally
{
// Clear the action bar AND restore feedback. Both best-effort: if MC
// is stopping these'll fail and that's fine.
try { await _proc.SendInputAsync("title @a actionbar {\"text\":\"\"}"); } catch { }
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
}
}
public Task TitleAsync(string message, CancellationToken ct = default)
=> _proc.SendInputAsync($"title @a title {{\"text\":\"{EscapeJson(message)}\"}}", ct);
/// <summary>
/// Show a draining boss bar at the top of every player's screen for
/// <paramref name="durationSeconds"/>. Updates the bar's name with a
/// "title -- Mm Ss" countdown each second. Returns when the bar is removed.
/// Honours cancellation: bar is removed cleanly even on cancel.
/// </summary>
public async Task BossBarCountdownAsync(
string title, int durationSeconds, string color = "yellow", CancellationToken ct = default)
{
if (durationSeconds <= 0) return;
// Silence /bossbar feedback for the same reason as ActionBarCountdownAsync.
await _proc.SendInputAsync("gamerule sendCommandFeedback false", ct);
await _proc.SendInputAsync($"bossbar add {BossBarId} {{\"text\":\"{EscapeJson(title)}\"}}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} color {color}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} max {durationSeconds}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} value {durationSeconds}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} players @a", ct);
try
{
for (int sec = durationSeconds; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
var mins = sec / 60;
var secs = sec % 60;
var label = mins > 0 ? $"{title} -- {mins}m {secs:00}s" : $"{title} -- {secs}s";
await _proc.SendInputAsync($"bossbar set {BossBarId} name {{\"text\":\"{EscapeJson(label)}\"}}", ct);
await _proc.SendInputAsync($"bossbar set {BossBarId} value {sec}", ct);
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
}
finally
{
// Always remove the bar -- even on cancel, so a stuck bar isn't left
// on every player's screen. Use CancellationToken.None for the cleanup.
try { await _proc.SendInputAsync($"bossbar remove {BossBarId}"); } catch { }
try { await _proc.SendInputAsync("gamerule sendCommandFeedback true"); } catch { }
}
}
private static string EscapeJson(string s) =>
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", " ");
private static string SingleLine(string s) =>
s.Replace("\r", " ").Replace("\n", " ").Trim();
}
+156
View File
@@ -0,0 +1,156 @@
using System.Formats.Tar;
using System.IO.Compression;
using System.Runtime.InteropServices;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Downloads + extracts Adoptium Temurin JRE 21 to server/java/. Used as a fallback
/// when system Java is missing or too old. Adoptium's API gives us a stable
/// platform-keyed download URL without needing API keys or auth.
/// </summary>
public sealed class JavaInstaller
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(15) };
public string GetJavaInstallDir(string serverDir) => Path.Combine(serverDir, "java");
public string GetJavaInstallDir(string serverDir, int majorVersion) =>
Path.Combine(serverDir, "java" + majorVersion);
/// <summary>If a previous install put a java executable under serverDir/java/, return its path.</summary>
public string? FindBundledJava(string serverDir)
{
var javaDir = GetJavaInstallDir(serverDir);
if (!Directory.Exists(javaDir)) return null;
var exe = OperatingSystem.IsWindows() ? "java.exe" : "java";
return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault();
}
/// <summary>Find a Java install for a specific major version (e.g. javaXX/jdk-XX*/bin/java).</summary>
public string? FindBundledJava(string serverDir, int majorVersion)
{
var javaDir = GetJavaInstallDir(serverDir, majorVersion);
if (!Directory.Exists(javaDir)) return null;
var exe = OperatingSystem.IsWindows() ? "java.exe" : "java";
return Directory.EnumerateFiles(javaDir, exe, SearchOption.AllDirectories).FirstOrDefault();
}
public Task<string?> InstallJre21Async(string serverDir, IProgress<string>? progress, CancellationToken ct)
=> InstallJreAsync(21, serverDir, GetJavaInstallDir(serverDir), progress, ct);
/// <summary>
/// Download + extract Adoptium Temurin JRE for a specific major version into
/// <paramref name="installDir"/>. Used by BlueMap to get JRE 25 alongside the
/// JRE 21 we use for Minecraft.
/// </summary>
public async Task<string?> InstallJreAsync(int majorVersion, string serverDir, string installDir,
IProgress<string>? progress, CancellationToken ct)
{
var javaDir = installDir;
Directory.CreateDirectory(javaDir);
var (url, archiveName, isZip) = PickAdoptiumDownload(majorVersion);
if (url is null)
{
progress?.Report($"[err] No supported Adoptium binary for {RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}.");
return null;
}
var archivePath = Path.Combine(javaDir, archiveName!);
progress?.Report($"Downloading Adoptium Temurin JRE 21 ({(isZip ? "zip" : "tar.gz")})...");
try
{
using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
{
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(archivePath);
await src.CopyToAsync(dst, ct);
}
}
catch (Exception ex)
{
progress?.Report($" [err] Download failed: {ex.Message}");
return null;
}
progress?.Report($" Downloaded {new FileInfo(archivePath).Length:N0} bytes");
progress?.Report("Extracting...");
try
{
if (isZip)
{
ZipFile.ExtractToDirectory(archivePath, javaDir, overwriteFiles: true);
}
else
{
await using var fs = File.OpenRead(archivePath);
await using var gzip = new GZipStream(fs, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(gzip, javaDir, overwriteFiles: true, ct);
}
}
catch (Exception ex)
{
progress?.Report($" [err] Extract failed: {ex.Message}");
return null;
}
try { File.Delete(archivePath); } catch { /* best-effort */ }
var javaExe = FindBundledJava(serverDir);
if (javaExe is null)
{
progress?.Report(" [err] Extracted, but couldn't locate bin/java in the result.");
return null;
}
// On Linux/macOS, make sure java is executable. TarFile preserves mode bits in
// most setups, but be defensive.
if (!OperatingSystem.IsWindows())
{
try
{
File.SetUnixFileMode(javaExe,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
catch { }
}
progress?.Report($"Java {majorVersion} ready: {javaExe}");
return javaExe;
}
private static (string? Url, string? ArchiveName, bool IsZip) PickAdoptiumDownload(int majorVersion)
{
// Adoptium API picks the latest GA release matching our os/arch.
// Docs: https://api.adoptium.net/q/swagger-ui/
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "x64",
Architecture.Arm64 => "aarch64",
_ => null
};
if (arch is null) return (null, null, false);
if (OperatingSystem.IsWindows())
{
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/windows/{arch}/jre/hotspot/normal/eclipse",
$"jre{majorVersion}.zip", true);
}
if (OperatingSystem.IsLinux())
{
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/linux/{arch}/jre/hotspot/normal/eclipse",
$"jre{majorVersion}.tar.gz", false);
}
if (OperatingSystem.IsMacOS())
{
return ($"https://api.adoptium.net/v3/binary/latest/{majorVersion}/ga/mac/{arch}/jre/hotspot/normal/eclipse",
$"jre{majorVersion}.tar.gz", false);
}
return (null, null, false);
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Project-wide JSON serializer options. Always case-insensitive on read so that
/// hand-edited config files / API responses don't silently fail to bind a property
/// because of a casing mismatch (e.g. "Command" vs "command", "JavaPath" vs "javaPath").
/// Use this everywhere we call JsonSerializer.Deserialize.
/// </summary>
public static class JsonOpts
{
public static readonly JsonSerializerOptions CaseInsensitive = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static readonly JsonSerializerOptions Pretty = new()
{
WriteIndented = true,
};
}
+199
View File
@@ -0,0 +1,199 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Server-side mod sync. Downloads only mods that the server needs:
/// queries Modrinth's project metadata for each mod's `server_side` field
/// and skips anything marked "unsupported" (Iris, Sodium, JEI, etc).
/// CurseForge mods can't be auto-classified without an API key, so they
/// are downloaded as-is and the server admin can manually delete unwanted ones.
/// </summary>
public sealed class ManifestSync
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(5) };
private const string PackVersionFile = "pack-version.json";
private const string ServerManifestCache = "server-pack.cache.json";
public sealed record SyncResult(int Downloaded, int Removed, int Skipped, string PackVersion);
public async Task<Manifest> FetchManifestAsync(string url, CancellationToken ct = default)
{
var json = await _http.GetStringAsync(url, ct);
var manifest = JsonSerializer.Deserialize<Manifest>(json, JsonOpts.CaseInsensitive)
?? throw new InvalidOperationException("Manifest is empty.");
manifest.Files ??= new();
return manifest;
}
public async Task<SyncResult> SyncAsync(
string manifestUrl, string serverDir, IProgress<string>? progress = null, CancellationToken ct = default)
{
progress?.Report("Fetching manifest...");
var manifest = await FetchManifestAsync(manifestUrl, ct);
progress?.Report($"Pack: {manifest.Name} v{manifest.Version}");
Directory.CreateDirectory(serverDir);
// Resolve which mods are server-side.
var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct);
// Build the filtered list of files to keep on the server.
var keepFiles = manifest.Files
.Where(f => !ShouldSkipFile(f.Path, skipSlugs))
.ToList();
var skippedCount = manifest.Files.Count - keepFiles.Count;
// Prune managed files that aren't in the keep set.
var wantedPaths = new HashSet<string>(
keepFiles.Select(f => f.Path.Replace('\\', '/')),
StringComparer.OrdinalIgnoreCase);
var toRemove = ListManagedFiles(serverDir).Where(p => !wantedPaths.Contains(p)).ToList();
foreach (var rel in toRemove)
{
var full = Path.Combine(serverDir, rel);
try { File.Delete(full); progress?.Report($" Removed: {rel}"); }
catch (Exception ex) { progress?.Report($" Could not remove {rel}: {ex.Message}"); }
}
// Download missing or hash-mismatched files.
var toDownload = new List<ManifestFile>();
foreach (var file in keepFiles)
{
ct.ThrowIfCancellationRequested();
var dest = Path.Combine(serverDir, file.Path);
if (!File.Exists(dest)) { toDownload.Add(file); continue; }
if (!string.IsNullOrEmpty(file.Sha1))
{
var actual = await ComputeSha1Async(dest, ct);
if (!string.Equals(actual, file.Sha1, StringComparison.OrdinalIgnoreCase))
toDownload.Add(file);
}
}
progress?.Report(toDownload.Count == 0 ? "Already up-to-date." : $"Downloading {toDownload.Count} files...");
for (int i = 0; i < toDownload.Count; i++)
{
var file = toDownload[i];
progress?.Report($" [{i + 1}/{toDownload.Count}] {file.Path}");
await DownloadFileAsync(file.Url, Path.Combine(serverDir, file.Path), file.Sha1, ct);
}
// Write pack-version.json marker.
var record = new
{
name = manifest.Name,
version = manifest.Version,
syncedAt = DateTime.UtcNow.ToString("o"),
includedFiles = keepFiles.Count,
skippedFiles = skippedCount
};
await File.WriteAllTextAsync(
Path.Combine(serverDir, PackVersionFile),
JsonSerializer.Serialize(record, new JsonSerializerOptions { WriteIndented = true }),
ct);
return new SyncResult(toDownload.Count, toRemove.Count, skippedCount, manifest.Version ?? "?");
}
private static bool ShouldSkipFile(string filePath, HashSet<string> skipSlugs)
{
if (skipSlugs.Count == 0) return false;
var name = Path.GetFileNameWithoutExtension(filePath).ToLowerInvariant();
// Match if any skip slug appears at the start of the filename (slug-version.jar)
return skipSlugs.Any(slug => name.StartsWith(slug + "-", StringComparison.OrdinalIgnoreCase)
|| name.Equals(slug, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
{
var skip = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var modrinthIds = new HashSet<string>();
foreach (var file in manifest.Files)
{
if (!file.Path.StartsWith("mods/", StringComparison.OrdinalIgnoreCase)) continue;
// Modrinth URL pattern: https://cdn.modrinth.com/data/{projectId}/versions/{versionId}/...
var url = file.Url;
const string prefix = "cdn.modrinth.com/data/";
var idx = url.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
if (idx < 0) continue;
var rest = url.Substring(idx + prefix.Length);
var slash = rest.IndexOf('/');
if (slash < 0) continue;
modrinthIds.Add(rest.Substring(0, slash));
}
foreach (var pid in modrinthIds)
{
ct.ThrowIfCancellationRequested();
try
{
var info = await _http.GetFromJsonAsync<JsonElement>(
$"https://api.modrinth.com/v2/project/{pid}", ct);
var slug = info.TryGetProperty("slug", out var s) ? s.GetString() : null;
var serverSide = info.TryGetProperty("server_side", out var ss) ? ss.GetString() : null;
if (!string.IsNullOrEmpty(slug) && string.Equals(serverSide, "unsupported", StringComparison.OrdinalIgnoreCase))
{
skip.Add(slug);
}
}
catch
{
// Best-effort: if we can't classify, keep the mod (safer to ship extra than missing)
}
}
return skip;
}
private static List<string> ListManagedFiles(string serverDir)
{
var roots = new[] { "mods", "config", "resourcepacks", "kubejs", "defaultconfigs" };
var result = new List<string>();
foreach (var root in roots)
{
var rootDir = Path.Combine(serverDir, root);
if (!Directory.Exists(rootDir)) continue;
foreach (var f in Directory.EnumerateFiles(rootDir, "*", SearchOption.AllDirectories))
result.Add(Path.GetRelativePath(serverDir, f).Replace('\\', '/'));
}
return result;
}
private static async Task DownloadFileAsync(string url, string destPath, string? expectedSha1, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
var tmp = destPath + ".part";
using (var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct))
{
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = File.Create(tmp);
await src.CopyToAsync(dst, ct);
}
if (!string.IsNullOrEmpty(expectedSha1))
{
var actual = await ComputeSha1Async(tmp, ct);
if (!string.Equals(actual, expectedSha1, StringComparison.OrdinalIgnoreCase))
{
File.Delete(tmp);
throw new InvalidOperationException($"Hash mismatch for {Path.GetFileName(destPath)}");
}
}
if (File.Exists(destPath)) File.Delete(destPath);
File.Move(tmp, destPath);
}
private static async Task<string> ComputeSha1Async(string path, CancellationToken ct)
{
using var sha = SHA1.Create();
await using var stream = File.OpenRead(path);
var bytes = await sha.ComputeHashAsync(stream, ct);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+112
View File
@@ -0,0 +1,112 @@
using System.Diagnostics;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Downloads NeoForge's official server installer JAR and runs it with --installServer
/// to produce run.sh/run.bat + the server library tree. Handles Java invocation and
/// streams installer output via a progress callback.
/// </summary>
public sealed class NeoForgeInstaller
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(10) };
public bool IsAlreadyInstalled(string serverDir)
{
return File.Exists(Path.Combine(serverDir, OperatingSystem.IsWindows() ? "run.bat" : "run.sh"));
}
public async Task<bool> InstallAsync(string version, string serverDir, string javaPath,
IProgress<string>? progress, CancellationToken ct)
{
Directory.CreateDirectory(serverDir);
// 1. Download installer
var installerName = $"neoforge-{version}-installer.jar";
var installerPath = Path.Combine(serverDir, installerName);
var url = $"https://maven.neoforged.net/releases/net/neoforged/neoforge/{version}/{installerName}";
if (!File.Exists(installerPath))
{
progress?.Report($"Downloading NeoForge {version} installer...");
var bytes = await _http.GetByteArrayAsync(url, ct);
await File.WriteAllBytesAsync(installerPath, bytes, ct);
progress?.Report($" Saved {bytes.Length:N0} bytes to {installerName}");
}
else
{
progress?.Report($"NeoForge installer already present, skipping download.");
}
// 2. Run installer
progress?.Report("Running NeoForge installer (java -jar ... --installServer)...");
var psi = new ProcessStartInfo
{
FileName = javaPath,
WorkingDirectory = serverDir,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-jar");
psi.ArgumentList.Add(installerName);
psi.ArgumentList.Add("--installServer");
Process? proc;
try
{
proc = Process.Start(psi);
}
catch (Exception ex)
{
progress?.Report($" [error] Could not start java: {ex.Message}");
return false;
}
if (proc is null)
{
progress?.Report(" [error] Failed to start java.");
return false;
}
var stdoutTask = StreamLines(proc.StandardOutput, line => progress?.Report($" {line}"), ct);
var stderrTask = StreamLines(proc.StandardError, line => progress?.Report($" [err] {line}"), ct);
await proc.WaitForExitAsync(ct);
await Task.WhenAll(stdoutTask, stderrTask);
if (proc.ExitCode != 0)
{
progress?.Report($" [error] NeoForge installer exited with code {proc.ExitCode}");
return false;
}
// 3. Verify run script exists
if (!IsAlreadyInstalled(serverDir))
{
progress?.Report(" [error] NeoForge installer ran but run.sh/run.bat is missing.");
return false;
}
progress?.Report($"NeoForge {version} installed.");
// 4. Clean up the installer JAR (large, no longer needed)
try { File.Delete(installerPath); } catch { /* best-effort */ }
return true;
}
private static async Task StreamLines(StreamReader reader, Action<string> onLine, CancellationToken ct)
{
try
{
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync(ct);
if (line is null) break;
onLine(line);
}
}
catch { }
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Minimal Minecraft RCON client (Source RCON protocol).
/// Used for sending console commands and reading "list" output for player counts.
/// </summary>
public sealed class RconClient : IDisposable
{
private const int SERVERDATA_AUTH = 3;
private const int SERVERDATA_EXECCOMMAND = 2;
private const int SERVERDATA_RESPONSE_VALUE = 0;
private TcpClient? _tcp;
private NetworkStream? _stream;
private int _nextRequestId = 1;
public bool Connected => _tcp?.Connected ?? false;
public async Task ConnectAsync(string host, int port, string password, CancellationToken ct = default)
{
_tcp = new TcpClient();
await _tcp.ConnectAsync(host, port, ct);
_stream = _tcp.GetStream();
var authId = NextId();
await SendPacketAsync(authId, SERVERDATA_AUTH, password, ct);
// Read auth response. Server sends an empty response value first, then the auth result.
var (id1, _, _) = await ReadPacketAsync(ct);
if (id1 == -1) throw new InvalidOperationException("RCON auth failed (bad password)");
// Auth ok if id matches what we sent
}
public async Task<string> SendCommandAsync(string command, CancellationToken ct = default)
{
if (_stream == null) throw new InvalidOperationException("Not connected");
var id = NextId();
await SendPacketAsync(id, SERVERDATA_EXECCOMMAND, command, ct);
var (_, _, body) = await ReadPacketAsync(ct);
return body;
}
private int NextId() => Interlocked.Increment(ref _nextRequestId);
private async Task SendPacketAsync(int id, int type, string body, CancellationToken ct)
{
var bodyBytes = Encoding.UTF8.GetBytes(body);
var packetSize = 4 + 4 + bodyBytes.Length + 2; // id + type + body + 2 null bytes
var buffer = new byte[4 + packetSize];
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), packetSize);
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(4, 4), id);
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(8, 4), type);
bodyBytes.CopyTo(buffer.AsSpan(12));
// last two bytes already 0 by default
await _stream!.WriteAsync(buffer, ct);
}
private async Task<(int Id, int Type, string Body)> ReadPacketAsync(CancellationToken ct)
{
var sizeBuf = new byte[4];
await ReadExactAsync(sizeBuf, ct);
var size = BinaryPrimitives.ReadInt32LittleEndian(sizeBuf);
if (size < 10 || size > 1024 * 1024) throw new InvalidOperationException($"Bad RCON packet size {size}");
var pkt = new byte[size];
await ReadExactAsync(pkt, ct);
var id = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(0, 4));
var type = BinaryPrimitives.ReadInt32LittleEndian(pkt.AsSpan(4, 4));
var body = Encoding.UTF8.GetString(pkt, 8, size - 10); // strip 2 trailing nulls
return (id, type, body);
}
private async Task ReadExactAsync(byte[] buffer, CancellationToken ct)
{
var read = 0;
while (read < buffer.Length)
{
var n = await _stream!.ReadAsync(buffer.AsMemory(read), ct);
if (n == 0) throw new EndOfStreamException();
read += n;
}
}
public void Dispose()
{
_stream?.Dispose();
_tcp?.Dispose();
}
}
+68
View File
@@ -0,0 +1,68 @@
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Thin reconnecting wrapper around <see cref="RconClient"/>. The original
/// single-connection-with-no-retry pattern caches a dead client whenever the
/// initial connect happens before MC has opened the RCON port (which is normal --
/// boot takes ~30 s). This manager lazily connects on first use, retries on
/// failure, and drops the client when a send throws so the next call reconnects.
/// </summary>
public sealed class RconManager : IDisposable
{
private readonly string _host;
private readonly int _port;
private readonly string _password;
private readonly SemaphoreSlim _lock = new(1, 1);
private RconClient? _client;
public RconManager(string host, int port, string password)
{
_host = host;
_port = port;
_password = password;
}
public async Task<string> SendCommandAsync(string command, CancellationToken ct = default)
{
var client = await EnsureConnectedAsync(ct);
try
{
return await client.SendCommandAsync(command, ct);
}
catch
{
await DropAsync();
throw;
}
}
private async Task<RconClient> EnsureConnectedAsync(CancellationToken ct)
{
await _lock.WaitAsync(ct);
try
{
if (_client is { Connected: true }) return _client;
_client?.Dispose();
_client = new RconClient();
await _client.ConnectAsync(_host, _port, _password, ct);
return _client;
}
finally
{
_lock.Release();
}
}
private async Task DropAsync()
{
await _lock.WaitAsync();
try { _client?.Dispose(); _client = null; }
finally { _lock.Release(); }
}
public void Dispose()
{
_client?.Dispose();
_lock.Dispose();
}
}
+405
View File
@@ -0,0 +1,405 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Manages the Minecraft Java subprocess: spawns it with the right JVM args,
/// captures stdout/stderr into a ring buffer (so the web UI can show recent
/// logs), broadcasts new lines via an event, handles graceful shutdown.
/// </summary>
public sealed class ServerProcess : IDisposable
{
private readonly ServerConfig _config;
private Process? _process;
private readonly ConcurrentQueue<LogLine> _logRing = new();
private const int LogRingSize = 2000;
// Process-tree containment: on Windows, the Job Object kills our subprocess
// automatically when our own process dies -- regardless of how we died.
// Created lazily on first Start() on Windows; destroyed in Dispose.
private static WindowsJobObject? _jobObject;
public event Action<LogLine>? OnLogLine;
public event Action<int>? Exited;
public bool IsRunning => _process is { HasExited: false };
public DateTime? StartedAt { get; private set; }
public int? Pid => _process?.Id;
// Memory + CPU sampling. CpuMetrics is a moving average across calls -- the
// first call after start returns null because we need two samples for a delta.
// We track the Java *descendant* of our shell, not the shell itself, because
// run.sh / run.bat is ~2 MB and useless for stats.
private TimeSpan _lastCpuTime;
private DateTime _lastCpuSampleAt;
private int _lastTrackedPid;
private Process? _trackedJava;
private readonly object _statsLock = new();
private readonly Queue<double> _cpuSamples = new();
private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling
/// <summary>
/// Returns the Java JVM descendant if found, otherwise falls back to the
/// directly-spawned shell process. Cached and re-resolved when the cached
/// pid exits (e.g., MC restart).
/// </summary>
private Process? TrackedProcess
{
get
{
if (_process is null || _process.HasExited) return null;
if (_trackedJava is { HasExited: false }) return _trackedJava;
// Try to find a 'java' descendant of our shell. If not found yet (still
// booting), fall back to the shell -- first stats will read tiny, then
// reset on next call once Java is up.
var found = FindJavaDescendant(_process.Id);
_trackedJava = found;
return found ?? _process;
}
}
public long? MemoryBytes
{
get
{
var p = TrackedProcess;
if (p is null || p.HasExited) return null;
try { p.Refresh(); return p.WorkingSet64; }
catch { return null; }
}
}
/// <summary>
/// System-wide CPU percentage (0-100) plus rolling-window peak and average.
/// Each call samples once and contributes to a 20-entry history (~60 s at 3 s
/// polling). First call after start returns null (need two samples for a delta).
/// </summary>
public (double Current, double Max, double Avg)? CpuMetrics
{
get
{
var p = TrackedProcess;
if (p is null || p.HasExited) return null;
lock (_statsLock)
{
try
{
p.Refresh();
var now = DateTime.UtcNow;
var cpuNow = p.TotalProcessorTime;
if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default)
{
_lastTrackedPid = p.Id;
_lastCpuTime = cpuNow;
_lastCpuSampleAt = now;
_cpuSamples.Clear();
return null;
}
var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds;
var elapsedCpu = (cpuNow - _lastCpuTime).TotalMilliseconds;
_lastCpuTime = cpuNow;
_lastCpuSampleAt = now;
if (elapsedReal <= 0) return (0, 0, 0);
// System-wide: divide by core count so the value is bounded 0-100
// and intuitive ("42% of total CPU capacity"). The user found the
// top-style 0-N*100 range confusing for a fleet view.
var perCore = elapsedCpu / elapsedReal * 100.0;
var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount);
_cpuSamples.Enqueue(systemWide);
while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue();
return (systemWide, _cpuSamples.Max(), _cpuSamples.Average());
}
catch { return null; }
}
}
}
/// <summary>
/// BFS the process tree starting from <paramref name="rootPid"/> looking for
/// a process named like "java". Linux: read /proc/PID/task/PID/children.
/// Windows: enumerate parents via Process objects (no extra deps).
/// </summary>
private static Process? FindJavaDescendant(int rootPid)
{
try
{
if (OperatingSystem.IsLinux())
{
return FindJavaDescendantLinux(rootPid);
}
if (OperatingSystem.IsWindows())
{
return FindJavaDescendantWindows(rootPid);
}
}
catch { }
return null;
}
private static Process? FindJavaDescendantLinux(int rootPid)
{
var visited = new HashSet<int>();
var queue = new Queue<int>();
queue.Enqueue(rootPid);
while (queue.Count > 0)
{
var pid = queue.Dequeue();
if (!visited.Add(pid)) continue;
var childrenPath = $"/proc/{pid}/task/{pid}/children";
if (!File.Exists(childrenPath)) continue;
string raw;
try { raw = File.ReadAllText(childrenPath); } catch { continue; }
foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (!int.TryParse(token, out var cpid)) continue;
Process? p = null;
try { p = Process.GetProcessById(cpid); } catch { continue; }
if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
return p;
queue.Enqueue(cpid);
}
}
return null;
}
private static Process? FindJavaDescendantWindows(int rootPid)
{
// Build a parent->children map by reading every running process's parent
// PID once, then BFS. Slower than Linux's /proc but works without WMI.
var allProcs = Process.GetProcesses();
var byParent = new Dictionary<int, List<Process>>();
foreach (var p in allProcs)
{
int parent;
try { parent = GetParentPidWindows(p); } catch { continue; }
if (parent == 0) continue;
if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List<Process>();
list.Add(p);
}
var visited = new HashSet<int>();
var queue = new Queue<int>();
queue.Enqueue(rootPid);
while (queue.Count > 0)
{
var pid = queue.Dequeue();
if (!visited.Add(pid)) continue;
if (!byParent.TryGetValue(pid, out var children)) continue;
foreach (var c in children)
{
if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
return c;
queue.Enqueue(c.Id);
}
}
return null;
}
[System.Runtime.InteropServices.DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(
IntPtr processHandle, int processInformationClass,
ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct ProcessBasicInformation
{
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr InheritedFromUniqueProcessId; // <-- parent PID
}
private static int GetParentPidWindows(Process p)
{
var info = new ProcessBasicInformation();
int rc = NtQueryInformationProcess(p.Handle, 0, ref info,
System.Runtime.InteropServices.Marshal.SizeOf<ProcessBasicInformation>(), out _);
return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32();
}
public ServerProcess(ServerConfig config) => _config = config;
public bool Start()
{
if (IsRunning) return false;
// Reset cached Java descendant + CPU sampling baseline so the next call
// re-resolves once the new run.sh -> java tree is up.
_trackedJava = null;
_lastTrackedPid = 0;
_lastCpuSampleAt = default;
// The mod loader's start script lives next to the server jar. NeoForge produces
// run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on
// the server side, so we build the equivalent JVM command ourselves.
var startScript = ResolveStartCommand(out var argList);
var startInfo = new ProcessStartInfo
{
FileName = startScript,
WorkingDirectory = Path.GetFullPath(_config.ServerDir),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var a in argList) startInfo.ArgumentList.Add(a);
// CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to
// whatever's on PATH. If we configured a specific Java (or auto-downloaded one),
// prepend its bin dir + set JAVA_HOME so the script picks up the right JVM
// instead of an older system Java.
ApplyJavaEnvironment(startInfo);
_process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false);
_process.ErrorDataReceived += (_, e) => OnLine(e.Data, isError: true);
_process.Exited += (_, _) =>
{
var code = _process?.ExitCode ?? -1;
OnLine($"=== Server exited (code {code}) ===", isError: false);
Exited?.Invoke(code);
};
_process.Start();
// Bind the Java subprocess to a Job Object on Windows so it gets killed
// automatically if we exit ungracefully (X-button, Task Manager, etc.).
if (OperatingSystem.IsWindows())
{
try
{
_jobObject ??= new WindowsJobObject();
_jobObject.AssignProcess(_process);
}
catch (Exception ex)
{
// Non-fatal -- we still try to clean up via Process.Kill in Dispose.
OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true);
}
}
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
StartedAt = DateTime.UtcNow;
return true;
}
private string ResolveStartCommand(out List<string> args)
{
args = new List<string>();
var dir = Path.GetFullPath(_config.ServerDir);
// Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args.
var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh";
var runScript = Path.Combine(dir, runShell);
if (File.Exists(runScript))
{
// Make sure the user_jvm_args.txt has the memory we want
EnsureUserJvmArgs(dir);
// Suppress the Swing server-GUI window -- we use the web panel instead.
// NeoForge's run.bat / run.sh forwards extra args to Minecraft.
args.Add("nogui");
return runScript;
}
// Fallback: invoke java directly on the server jar. This won't work for NeoForge
// (it requires the loader's run script), but it works for vanilla and Forge legacy.
args.Add($"-Xms{_config.MemoryMB}M");
args.Add($"-Xmx{_config.MemoryMB}M");
args.Add("-jar");
args.Add("server.jar");
args.Add("nogui");
return _config.JavaPath;
}
private void ApplyJavaEnvironment(ProcessStartInfo psi)
{
// Only meaningful when we have an absolute path to a specific java binary
// (i.e., not just "java" on PATH).
if (string.IsNullOrEmpty(_config.JavaPath)) return;
if (!Path.IsPathRooted(_config.JavaPath)) return;
if (!File.Exists(_config.JavaPath)) return;
var javaBinDir = Path.GetDirectoryName(_config.JavaPath);
if (string.IsNullOrEmpty(javaBinDir)) return;
var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/
var sep = OperatingSystem.IsWindows() ? ";" : ":";
var existingPath = Environment.GetEnvironmentVariable("PATH") ?? "";
psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}";
if (!string.IsNullOrEmpty(javaHome))
{
psi.Environment["JAVA_HOME"] = javaHome;
}
}
private void EnsureUserJvmArgs(string dir)
{
// NeoForge's run.bat / run.sh reads this file for JVM-level args.
// Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons
// and significantly better than G1 for heavily-modded MC. Requires Java 21+.
var path = Path.Combine(dir, "user_jvm_args.txt");
var content =
$"-Xms{_config.MemoryMB}M\n" +
$"-Xmx{_config.MemoryMB}M\n" +
"-XX:+UseZGC\n" +
"-XX:+ZGenerational\n";
try { File.WriteAllText(path, content); } catch { /* best-effort */ }
}
private void OnLine(string? data, bool isError)
{
if (string.IsNullOrEmpty(data)) return;
var line = new LogLine(DateTimeOffset.UtcNow, isError, data);
_logRing.Enqueue(line);
while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _);
OnLogLine?.Invoke(line);
}
public IReadOnlyList<LogLine> RecentLogs() => _logRing.ToArray();
public async Task SendInputAsync(string command, CancellationToken ct = default)
{
if (_process is null || _process.HasExited) return;
await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct);
await _process.StandardInput.FlushAsync(ct);
}
public async Task<bool> StopAsync(TimeSpan? graceful = null, CancellationToken ct = default)
{
if (!IsRunning) return false;
graceful ??= TimeSpan.FromSeconds(30);
try
{
await SendInputAsync("stop", ct);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(graceful.Value);
try { await _process!.WaitForExitAsync(cts.Token); return true; }
catch (OperationCanceledException)
{
_process?.Kill(entireProcessTree: true);
return false;
}
}
catch
{
try { _process?.Kill(entireProcessTree: true); } catch { }
return false;
}
}
public void Dispose()
{
try { _process?.Kill(entireProcessTree: true); } catch { }
_process?.Dispose();
}
public sealed record LogLine(DateTimeOffset At, bool IsError, string Text);
}
+149
View File
@@ -0,0 +1,149 @@
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Reads and writes Minecraft's <c>server.properties</c>. Editable keys are
/// gated by an allowlist so a compromised panel can't flip security-critical
/// fields like <c>online-mode</c> arbitrarily -- only common gameplay knobs.
/// Preserves comments and key order on write; appends new keys at the end.
/// </summary>
public sealed class ServerPropertiesService
{
private readonly ServerConfig _config;
public ServerPropertiesService(ServerConfig config) => _config = config;
/// <summary>
/// Keys that may be modified via /api/server/settings. Anything outside this
/// set is silently dropped from the update payload -- admin must SSH for those.
/// </summary>
public static readonly IReadOnlySet<string> EditableKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"motd",
"difficulty",
"gamemode",
"view-distance",
"simulation-distance",
"max-players",
"pvp",
"hardcore",
"white-list",
"enforce-whitelist",
"allow-flight",
"enable-command-block",
"spawn-protection",
};
public string PropertiesPath => Path.Combine(Path.GetFullPath(_config.ServerDir), "server.properties");
public Dictionary<string, string> ReadAll()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(PropertiesPath)) return dict;
foreach (var raw in File.ReadAllLines(PropertiesPath))
{
var line = raw.TrimStart();
if (line.Length == 0 || line[0] == '#' || line[0] == '!') continue;
var idx = line.IndexOf('=');
if (idx < 0) continue;
dict[line.Substring(0, idx).Trim()] = line.Substring(idx + 1);
}
return dict;
}
/// <summary>Returns just the editable subset, with values left as raw strings.</summary>
public Dictionary<string, string> ReadEditable()
{
var all = ReadAll();
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in EditableKeys)
{
if (all.TryGetValue(key, out var v)) result[key] = v;
}
return result;
}
/// <summary>
/// Read the current <c>level-seed</c> value, or null if absent / empty.
/// </summary>
public string? GetLevelSeed()
{
if (!File.Exists(PropertiesPath)) return null;
foreach (var raw in File.ReadAllLines(PropertiesPath))
{
var line = raw.TrimStart();
if (!line.StartsWith("level-seed=", StringComparison.Ordinal)) continue;
var v = line.Substring("level-seed=".Length).Trim();
return string.IsNullOrEmpty(v) ? null : v;
}
return null;
}
/// <summary>
/// Direct write of <c>level-seed</c>. Bypasses <see cref="EditableKeys"/>
/// because the seed is set as part of the wipe flow (with confirmation),
/// not by general settings UI -- exposing it through the regular Update()
/// path would let it be flipped from any settings save. Empty string
/// clears the field, which makes Minecraft pick a random seed on next
/// world generation.
/// </summary>
public void SetLevelSeed(string seed)
{
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
var done = false;
for (int i = 0; i < lines.Count; i++)
{
var trimmed = lines[i].TrimStart();
if (trimmed.StartsWith("level-seed=", StringComparison.Ordinal))
{
lines[i] = $"level-seed={seed}";
done = true;
break;
}
}
if (!done) lines.Add($"level-seed={seed}");
File.WriteAllLines(PropertiesPath, lines);
}
/// <summary>
/// Apply updates to the file. Keys not in <see cref="EditableKeys"/> are
/// silently dropped. Lines that already exist are updated in-place to
/// preserve order and comments; new keys are appended at the end.
/// </summary>
public void Update(IDictionary<string, string> updates)
{
// Filter to allowed keys only.
var filtered = updates
.Where(kv => EditableKeys.Contains(kv.Key))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
if (filtered.Count == 0) return;
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
var applied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < lines.Count; i++)
{
var raw = lines[i];
var trimmed = raw.TrimStart();
if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == '!') continue;
var idx = trimmed.IndexOf('=');
if (idx < 0) continue;
var key = trimmed.Substring(0, idx).Trim();
if (filtered.TryGetValue(key, out var newValue))
{
lines[i] = $"{key}={newValue}";
applied.Add(key);
}
}
foreach (var (key, value) in filtered)
{
if (!applied.Contains(key)) lines.Add($"{key}={value}");
}
File.WriteAllLines(PropertiesPath, lines);
}
}
+260
View File
@@ -0,0 +1,260 @@
using BrassAndSigil.Server.Models;
using System.Text.Json;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Drives a "fetch new manifest, drain players, swap mods, restart MC" workflow.
/// Single-flight: one update at a time, guarded by a semaphore. State is exposed
/// so the panel can poll progress; logs go through the existing OnLogLine event
/// (re-streamed via SSE) so they show up in the live console too.
/// </summary>
public sealed class UpdaterService
{
private readonly ServerConfig _config;
private readonly string _configPath;
private readonly ServerProcess _proc;
private readonly Broadcaster _broadcast;
private readonly Action<string> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private CancellationTokenSource? _cts;
public UpdateState State { get; private set; } = new();
public sealed class UpdateState
{
public bool InProgress { get; set; }
public string Phase { get; set; } = "idle";
// "idle" | "countdown" | "stopping" | "syncing" | "installing_loader" | "starting" | "complete" | "failed" | "cancelled"
public int CountdownTotal { get; set; }
public int CountdownRemaining { get; set; }
public string? CurrentVersion { get; set; }
public string? AvailableVersion { get; set; }
public string? Error { get; set; }
public DateTimeOffset? LastFinishedAt { get; set; }
}
public sealed record CheckResult(string? Current, string? Available, bool NeedsUpdate, string? Error);
public UpdaterService(ServerConfig config, string configPath,
ServerProcess proc, Broadcaster broadcast,
Action<string> log)
{
_config = config;
_configPath = configPath;
_proc = proc;
_broadcast = broadcast;
_log = log;
}
/// <summary>Lightweight read: compare local pack-version.json to remote manifest.</summary>
public async Task<CheckResult> CheckAsync(CancellationToken ct = default)
{
try
{
var sync = new ManifestSync();
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
var local = ReadLocalPackVersion(_config.ServerDir);
var current = local;
var available = manifest.Version;
var needs = !string.Equals(current, available, StringComparison.Ordinal);
State.CurrentVersion = current;
State.AvailableVersion = available;
return new CheckResult(current, available, needs, null);
}
catch (Exception ex)
{
return new CheckResult(State.CurrentVersion, State.AvailableVersion, false, ex.Message);
}
}
public bool TryCancel()
{
if (!State.InProgress || _cts is null) return false;
// Only meaningful during countdown phase -- a sync mid-flight isn't safely abortable.
if (State.Phase != "countdown") return false;
_cts.Cancel();
return true;
}
/// <summary>
/// Run the full update flow. Single-flight -- returns false if one is already running.
/// </summary>
public async Task<bool> StartAsync(int delaySeconds)
{
if (!await _gate.WaitAsync(0)) return false;
_cts = new CancellationTokenSource();
var ct = _cts.Token;
State = new UpdateState
{
InProgress = true,
Phase = "countdown",
CountdownTotal = delaySeconds,
CountdownRemaining = delaySeconds,
CurrentVersion = State.CurrentVersion,
AvailableVersion = State.AvailableVersion,
};
try
{
// ── 1. Player-facing countdown ──
if (delaySeconds > 0 && _proc.IsRunning)
{
_log($"[update] Announcing restart in {delaySeconds}s.");
await _broadcast.SayAsync($"Server will restart in {FormatDuration(delaySeconds)} for an update to v{State.AvailableVersion}.", ct);
// Run the action-bar countdown + periodic chat warnings + UI ticker
// in parallel. Action bar (instead of boss bar) avoids stacking on
// top of real boss fight UIs (Ender Dragon, raids, mod bosses).
var actionBar = _broadcast.ActionBarCountdownAsync(
"Server restart for update", delaySeconds, ct);
var warnings = WarnDuringCountdownAsync(delaySeconds, ct);
// Drive State.CountdownRemaining for the UI poller.
var ticker = TickCountdownStateAsync(delaySeconds, ct);
await Task.WhenAll(actionBar, warnings, ticker);
}
// ── 2. Stop MC ──
ct.ThrowIfCancellationRequested();
State.Phase = "stopping";
_log("[update] Stopping Minecraft for update...");
if (_proc.IsRunning)
{
await _broadcast.SayAsync("Server is restarting now.");
await _proc.StopAsync(TimeSpan.FromSeconds(30));
}
// ── 3. Sync mods from manifest ──
ct.ThrowIfCancellationRequested();
State.Phase = "syncing";
_log("[update] Syncing mods from manifest...");
var sync = new ManifestSync();
var progress = new Progress<string>(msg => _log($"[update] {msg}"));
var result = await sync.SyncAsync(_config.ManifestUrl, _config.ServerDir, progress, ct);
_log($"[update] Sync complete: {result.Downloaded} downloaded, {result.Removed} removed.");
// ── 4. Update NeoForge if loader version changed ──
ct.ThrowIfCancellationRequested();
var manifest = await sync.FetchManifestAsync(_config.ManifestUrl, ct);
if (manifest.Loader is { } loader &&
loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase) &&
LoaderVersionChanged(_config.ServerDir, loader.Version))
{
State.Phase = "installing_loader";
_log($"[update] Reinstalling NeoForge {loader.Version}...");
var nf = new NeoForgeInstaller();
var ok = await nf.InstallAsync(loader.Version, _config.ServerDir, _config.JavaPath, progress, ct);
if (!ok) throw new InvalidOperationException("NeoForge installer failed.");
}
// ── 5. Start MC ──
ct.ThrowIfCancellationRequested();
State.Phase = "starting";
_log("[update] Starting Minecraft...");
_proc.Start();
State.CurrentVersion = manifest.Version;
State.Phase = "complete";
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log("[update] Update complete.");
return true;
}
catch (OperationCanceledException)
{
State.Phase = "cancelled";
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log("[update] Update cancelled.");
// If we cancelled during countdown, MC is still running -- leave it alone.
return false;
}
catch (Exception ex)
{
State.Phase = "failed";
State.Error = ex.Message;
State.InProgress = false;
State.LastFinishedAt = DateTimeOffset.UtcNow;
_log($"[update] Failed: {ex.Message}");
// Try to bring MC back up if we stopped it but never restarted.
if (!_proc.IsRunning)
{
try { _proc.Start(); _log("[update] Restored Minecraft after failure."); }
catch (Exception startEx) { _log($"[update] Restore failed too: {startEx.Message}"); }
}
return false;
}
finally
{
_cts?.Dispose();
_cts = null;
_gate.Release();
}
}
private async Task TickCountdownStateAsync(int total, CancellationToken ct)
{
for (int sec = total; sec > 0; sec--)
{
ct.ThrowIfCancellationRequested();
State.CountdownRemaining = sec;
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
State.CountdownRemaining = 0;
}
private async Task WarnDuringCountdownAsync(int total, CancellationToken ct)
{
// Periodic chat warnings -- independent of the boss bar (visual-but-missable).
// Each milestone fires at an absolute time computed from the start, so the
// delays don't accumulate sequentially across the loop iterations.
var startUtc = DateTime.UtcNow;
var milestones = new[] { 300, 60, 30, 10 };
foreach (var m in milestones)
{
if (m >= total) continue;
var fireAt = startUtc.AddSeconds(total - m);
var wait = fireAt - DateTime.UtcNow;
if (wait > TimeSpan.Zero)
{
try { await Task.Delay(wait, ct); }
catch (OperationCanceledException) { throw; }
}
try { await _broadcast.SayAsync($"Server restart in {FormatDuration(m)}."); }
catch { /* don't bring down the whole update for one failed broadcast */ }
}
}
private static bool LoaderVersionChanged(string serverDir, string newVersion)
{
// Look at the libraries dir for an existing neoforge-<version> path.
// If absent or different version, we should re-install.
var libsRoot = Path.Combine(serverDir, "libraries", "net", "neoforged", "neoforge");
if (!Directory.Exists(libsRoot)) return true;
var versions = Directory.EnumerateDirectories(libsRoot).Select(Path.GetFileName).ToList();
return !versions.Contains(newVersion);
}
private static string? ReadLocalPackVersion(string serverDir)
{
var path = Path.Combine(serverDir, "pack-version.json");
if (!File.Exists(path)) return null;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
}
catch { return null; }
}
private static string FormatDuration(int seconds)
{
if (seconds >= 60) return $"{seconds / 60} minute{(seconds / 60 == 1 ? "" : "s")}";
return $"{seconds} second{(seconds == 1 ? "" : "s")}";
}
}
+145
View File
@@ -0,0 +1,145 @@
using System.Text.Json;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Tracks "I want to play" requests from friends, pending admin approval.
/// State is a flat JSON file in the server dir so it survives daemon restarts
/// without needing a database. Single-flight gate prevents concurrent-write
/// corruption when admin and friend act at the same time.
///
/// State machine: (none) -> pending -> approved | denied
/// "approved" means the admin clicked Approve; the actual /whitelist add
/// command goes through the existing whitelist endpoint, which removes the
/// request from the pending list.
/// </summary>
public sealed class WhitelistRequestService
{
private readonly ServerConfig _config;
private readonly object _lock = new();
public WhitelistRequestService(ServerConfig config) => _config = config;
public sealed class Request
{
public string Username { get; set; } = "";
public string? Message { get; set; }
public string Status { get; set; } = "pending"; // pending | approved | denied
public DateTimeOffset RequestedAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public string? RemoteIp { get; set; } // for admin diagnosis if needed
}
private string FilePath =>
Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist-requests.json");
private List<Request> Load()
{
try
{
if (!File.Exists(FilePath)) return new();
var text = File.ReadAllText(FilePath);
if (string.IsNullOrWhiteSpace(text)) return new();
return JsonSerializer.Deserialize<List<Request>>(text, JsonOpts.CaseInsensitive) ?? new();
}
catch { return new(); }
}
private void Save(List<Request> list)
{
var text = JsonSerializer.Serialize(list, JsonOpts.Pretty);
File.WriteAllText(FilePath, text);
}
public IReadOnlyList<Request> List() { lock (_lock) return Load(); }
public IReadOnlyList<Request> ListPending()
{
lock (_lock)
return Load().Where(r => r.Status == "pending").ToList();
}
/// <summary>Submit a new request. Idempotent on (username, status=pending) -- won't dupe.</summary>
public Request Submit(string username, string? message, string? remoteIp)
{
lock (_lock)
{
var list = Load();
// Drop any prior request for this username (case-insensitive) so the
// most recent one wins regardless of previous state. Keeps the file
// from growing if a friend re-requests after a denial.
list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
var req = new Request
{
Username = username,
Message = string.IsNullOrWhiteSpace(message) ? null : message,
Status = "pending",
RequestedAt = DateTimeOffset.UtcNow,
RemoteIp = remoteIp,
};
list.Add(req);
Save(list);
return req;
}
}
/// <summary>
/// Effective status for the launcher. If the username is in the actual
/// whitelist.json (regardless of whether they ever filed a request), returns
/// "approved" -- that's what the friend's launcher cares about. Otherwise
/// falls back to whatever request record we have, or "unknown".
/// </summary>
public string StatusFor(string username)
{
if (IsActuallyWhitelisted(username)) return "approved";
lock (_lock)
{
var match = Load().FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
return match?.Status ?? "unknown";
}
}
private bool IsActuallyWhitelisted(string username)
{
var path = Path.Combine(Path.GetFullPath(_config.ServerDir), "whitelist.json");
if (!File.Exists(path)) return false;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
return doc.RootElement.EnumerateArray().Any(e =>
e.TryGetProperty("name", out var n) &&
string.Equals(n.GetString(), username, StringComparison.OrdinalIgnoreCase));
}
catch { return false; }
}
public bool MarkApproved(string username) => SetStatus(username, "approved");
public bool MarkDenied(string username) => SetStatus(username, "denied");
/// <summary>Remove the request entirely (used after the actual /whitelist add fires).</summary>
public bool Remove(string username)
{
lock (_lock)
{
var list = Load();
var removed = list.RemoveAll(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (removed > 0) Save(list);
return removed > 0;
}
}
private bool SetStatus(string username, string status)
{
lock (_lock)
{
var list = Load();
var match = list.FirstOrDefault(r => r.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (match is null) return false;
match.Status = status;
match.ResolvedAt = DateTimeOffset.UtcNow;
Save(list);
return true;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Wraps a Windows Job Object configured with KILL_ON_JOB_CLOSE so that any process
/// assigned to it dies the moment our process does -- regardless of *how* we died
/// (X-button on the console, Task Manager End Task, parent BSOD, etc.). Without
/// this, a Java subprocess can outlive us and keep the server files locked.
///
/// On Linux, use systemd's cgroup management instead; the equivalent guarantee
/// comes for free when the tool runs as a systemd unit.
/// </summary>
public sealed class WindowsJobObject : IDisposable
{
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
public ulong OtherOperationCount;
public ulong ReadTransferCount;
public ulong WriteTransferCount;
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
private IntPtr _handle;
private bool _disposed;
public WindowsJobObject()
{
if (!OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("WindowsJobObject only works on Windows.");
_handle = CreateJobObject(IntPtr.Zero, null);
if (_handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
{
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
}
};
var size = Marshal.SizeOf(info);
var ptr = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(info, ptr, fDeleteOld: false);
if (!SetInformationJobObject(_handle, JobObjectExtendedLimitInformation, ptr, (uint)size))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
}
finally
{
Marshal.FreeHGlobal(ptr);
}
}
public void AssignProcess(Process process)
{
if (_disposed) throw new ObjectDisposedException(nameof(WindowsJobObject));
if (!AssignProcessToJobObject(_handle, process.Handle))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_handle != IntPtr.Zero)
{
CloseHandle(_handle); // Closing the last handle triggers KILL_ON_JOB_CLOSE
_handle = IntPtr.Zero;
}
GC.SuppressFinalize(this);
}
~WindowsJobObject() => Dispose();
}
+229
View File
@@ -0,0 +1,229 @@
using System.Text.RegularExpressions;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Destructive world operations -- wipe and replace. Always single-flight, always
/// stops the server first, and offers a rename-as-backup default so an accidental
/// click doesn't lose data permanently.
/// </summary>
public sealed class WorldService
{
private readonly ServerConfig _config;
private readonly ServerProcess _proc;
private readonly BackupService _backup;
private readonly Broadcaster _broadcast;
private readonly RconManager _rcon;
private readonly BlueMapService? _bluemap;
private readonly Action<string> _log;
private readonly SemaphoreSlim _lock = new(1, 1);
public WorldService(ServerConfig config, ServerProcess proc, BackupService backup,
Broadcaster broadcast, RconManager rcon, Action<string> log,
BlueMapService? bluemap = null)
{
_config = config;
_proc = proc;
_backup = backup;
_broadcast = broadcast;
_rcon = rcon;
_bluemap = bluemap;
_log = log;
}
public sealed record WipeResult(bool Ok, string? BackupName, string? SeedUsed, string? Error);
/// <summary>
/// What seed strategy a wipe should use:
/// <list type="bullet">
/// <item><c>Keep</c> -- capture the live seed via RCON before wipe and reuse it.</item>
/// <item><c>Random</c> -- clear <c>level-seed</c> so MC picks a fresh random one.</item>
/// <item><c>Custom</c> -- set <c>level-seed</c> to <see cref="WipeOptions.CustomSeed"/>.</item>
/// </list>
/// </summary>
public enum SeedMode { Keep, Random, Custom }
public sealed record WipeOptions(bool Backup, SeedMode Mode, string? CustomSeed);
/// <summary>
/// Best-effort lookup of the current world seed. Prefers RCON's <c>seed</c>
/// command (always returns the actual generated seed even when
/// server.properties has level-seed empty); falls back to the configured
/// level-seed value if RCON is unavailable (server stopped, no MC, etc.).
/// Returns null when neither is available.
/// </summary>
public async Task<string?> GetCurrentSeedAsync(CancellationToken ct = default)
{
if (_proc.IsRunning)
{
try
{
var resp = await _rcon.SendCommandAsync("seed", ct);
// Format: "Seed: [<number>]"
var m = Regex.Match(resp, @"Seed:\s*\[(-?\d+)\]");
if (m.Success) return m.Groups[1].Value;
}
catch { /* fall through to properties */ }
}
return new ServerPropertiesService(_config).GetLevelSeed();
}
// Caching world size: scanning a large world dir is O(file count). 30 s cache
// dampens that to roughly once per status poll cycle on busy panels.
private long _cachedSize;
private DateTime _cachedAt = DateTime.MinValue;
public long GetWorldSizeBytes()
{
if ((DateTime.UtcNow - _cachedAt) < TimeSpan.FromSeconds(30)) return _cachedSize;
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
var worldDir = Path.Combine(Path.GetFullPath(_config.ServerDir), levelName);
if (!Directory.Exists(worldDir)) { _cachedSize = 0; _cachedAt = DateTime.UtcNow; return 0; }
long total = 0;
try
{
foreach (var f in Directory.EnumerateFiles(worldDir, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(f).Length; } catch { }
}
}
catch { }
_cachedSize = total;
_cachedAt = DateTime.UtcNow;
return total;
}
/// <summary>
/// Stop the server, optionally archive the world via BackupService, delete the
/// world folder(s), apply the chosen seed strategy, then restart. Backups go
/// to the configured backup dir (typically a slower-but-bigger drive) rather
/// than next to the world dir.
/// </summary>
public async Task<WipeResult> WipeWorldAsync(WipeOptions options, int warningSeconds = 30, CancellationToken ct = default)
{
if (!await _lock.WaitAsync(0, ct))
return new WipeResult(false, null, null, "Another wipe is already in progress.");
try
{
var serverDir = Path.GetFullPath(_config.ServerDir);
var levelName = ReadLevelName(serverDir) ?? "world";
var primaryWorld = Path.Combine(serverDir, levelName);
var props = new ServerPropertiesService(_config);
// ── Decide what seed the new world will use, BEFORE we stop the server ──
// Keep mode needs RCON, which only works while MC is alive.
string seedToWrite; // value for level-seed= line; "" means random
string? capturedSeed = null;
switch (options.Mode)
{
case SeedMode.Keep:
capturedSeed = await GetCurrentSeedAsync(ct);
if (string.IsNullOrEmpty(capturedSeed))
{
return new WipeResult(false, null, null,
"Couldn't read current seed (RCON unreachable + level-seed empty). Stop the server, set level-seed manually, or pick Random/Custom.");
}
seedToWrite = capturedSeed;
_log($"[wipe] Keep-seed mode: captured current seed {capturedSeed} for reuse.");
break;
case SeedMode.Custom:
var custom = options.CustomSeed?.Trim() ?? "";
if (string.IsNullOrEmpty(custom))
return new WipeResult(false, null, null, "Custom seed mode selected but no seed provided.");
seedToWrite = custom;
_log($"[wipe] Custom-seed mode: new world will use seed {custom}.");
break;
default: // Random
seedToWrite = "";
_log("[wipe] Random-seed mode: clearing level-seed so MC generates a fresh seed.");
break;
}
// Loud, urgent player warning before any irreversible action -- wipe is
// destructive even with a backup (admins still need to restore manually).
if (warningSeconds > 0 && _proc.IsRunning)
{
_log($"[wipe] Announcing {warningSeconds}s wipe warning to players...");
try
{
await _broadcast.SayAsync(
$"WORLD WIPE in {warningSeconds} seconds. Disconnect now if you want to keep your current world!", ct);
await _broadcast.ActionBarCountdownAsync("WORLD WIPING", warningSeconds, ct);
await _broadcast.SayAsync("Wiping world now.");
}
catch (OperationCanceledException) { throw; }
catch { /* don't abort wipe over a broadcast error */ }
}
string? backupName = null;
if (options.Backup && Directory.Exists(primaryWorld))
{
_log("[wipe] Creating backup before wipe...");
// flush:true here -- we're about to delete the world. Capture every block
// and every move up to right now, even at the cost of a tick spike.
var br = await _backup.CreateAsync("pre-wipe", flush: true, ct: ct);
if (!br.Ok)
return new WipeResult(false, null, null, $"Backup failed: {br.Error}. Wipe aborted to preserve data.");
backupName = br.Name;
}
if (_proc.IsRunning)
{
_log("[wipe] Stopping server...");
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
}
if (Directory.Exists(primaryWorld))
{
_log($"[wipe] Deleting {primaryWorld}");
Directory.Delete(primaryWorld, recursive: true);
}
// Legacy sibling dirs (rare on modern NeoForge but cheap to handle)
foreach (var altSuffix in new[] { "_nether", "_the_end" })
{
var altDir = Path.Combine(serverDir, levelName + altSuffix);
if (!Directory.Exists(altDir)) continue;
_log($"[wipe] Deleting {altDir}");
Directory.Delete(altDir, recursive: true);
}
// Apply the seed AFTER deletion but BEFORE restart -- MC reads
// server.properties at startup to determine the new world's seed.
props.SetLevelSeed(seedToWrite);
// Map output for the now-deleted world is stale -- clear it so the next
// render starts fresh against the new terrain.
_bluemap?.ClearRenderOutput();
_log("[wipe] World wiped. Restarting server -- Minecraft will generate a fresh world.");
_proc.Start();
// For Random mode we don't know the exact new seed yet (MC picks at startup);
// return the level-seed value we wrote, which is "" for random.
return new WipeResult(true, backupName, string.IsNullOrEmpty(seedToWrite) ? null : seedToWrite, null);
}
catch (Exception ex)
{
_log($"[wipe] Failed: {ex.Message}");
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
return new WipeResult(false, null, null, ex.Message);
}
finally
{
_lock.Release();
}
}
private static string? ReadLevelName(string serverDir)
{
var path = Path.Combine(serverDir, "server.properties");
if (!File.Exists(path)) return null;
foreach (var line in File.ReadAllLines(path))
{
if (line.StartsWith("level-name=", StringComparison.Ordinal))
return line.Substring("level-name=".Length).Trim();
}
return null;
}
}