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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user