Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+405
View File
@@ -0,0 +1,405 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Manages the Minecraft Java subprocess: spawns it with the right JVM args,
/// captures stdout/stderr into a ring buffer (so the web UI can show recent
/// logs), broadcasts new lines via an event, handles graceful shutdown.
/// </summary>
public sealed class ServerProcess : IDisposable
{
private readonly ServerConfig _config;
private Process? _process;
private readonly ConcurrentQueue<LogLine> _logRing = new();
private const int LogRingSize = 2000;
// Process-tree containment: on Windows, the Job Object kills our subprocess
// automatically when our own process dies -- regardless of how we died.
// Created lazily on first Start() on Windows; destroyed in Dispose.
private static WindowsJobObject? _jobObject;
public event Action<LogLine>? OnLogLine;
public event Action<int>? Exited;
public bool IsRunning => _process is { HasExited: false };
public DateTime? StartedAt { get; private set; }
public int? Pid => _process?.Id;
// Memory + CPU sampling. CpuMetrics is a moving average across calls -- the
// first call after start returns null because we need two samples for a delta.
// We track the Java *descendant* of our shell, not the shell itself, because
// run.sh / run.bat is ~2 MB and useless for stats.
private TimeSpan _lastCpuTime;
private DateTime _lastCpuSampleAt;
private int _lastTrackedPid;
private Process? _trackedJava;
private readonly object _statsLock = new();
private readonly Queue<double> _cpuSamples = new();
private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling
/// <summary>
/// Returns the Java JVM descendant if found, otherwise falls back to the
/// directly-spawned shell process. Cached and re-resolved when the cached
/// pid exits (e.g., MC restart).
/// </summary>
private Process? TrackedProcess
{
get
{
if (_process is null || _process.HasExited) return null;
if (_trackedJava is { HasExited: false }) return _trackedJava;
// Try to find a 'java' descendant of our shell. If not found yet (still
// booting), fall back to the shell -- first stats will read tiny, then
// reset on next call once Java is up.
var found = FindJavaDescendant(_process.Id);
_trackedJava = found;
return found ?? _process;
}
}
public long? MemoryBytes
{
get
{
var p = TrackedProcess;
if (p is null || p.HasExited) return null;
try { p.Refresh(); return p.WorkingSet64; }
catch { return null; }
}
}
/// <summary>
/// System-wide CPU percentage (0-100) plus rolling-window peak and average.
/// Each call samples once and contributes to a 20-entry history (~60 s at 3 s
/// polling). First call after start returns null (need two samples for a delta).
/// </summary>
public (double Current, double Max, double Avg)? CpuMetrics
{
get
{
var p = TrackedProcess;
if (p is null || p.HasExited) return null;
lock (_statsLock)
{
try
{
p.Refresh();
var now = DateTime.UtcNow;
var cpuNow = p.TotalProcessorTime;
if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default)
{
_lastTrackedPid = p.Id;
_lastCpuTime = cpuNow;
_lastCpuSampleAt = now;
_cpuSamples.Clear();
return null;
}
var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds;
var elapsedCpu = (cpuNow - _lastCpuTime).TotalMilliseconds;
_lastCpuTime = cpuNow;
_lastCpuSampleAt = now;
if (elapsedReal <= 0) return (0, 0, 0);
// System-wide: divide by core count so the value is bounded 0-100
// and intuitive ("42% of total CPU capacity"). The user found the
// top-style 0-N*100 range confusing for a fleet view.
var perCore = elapsedCpu / elapsedReal * 100.0;
var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount);
_cpuSamples.Enqueue(systemWide);
while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue();
return (systemWide, _cpuSamples.Max(), _cpuSamples.Average());
}
catch { return null; }
}
}
}
/// <summary>
/// BFS the process tree starting from <paramref name="rootPid"/> looking for
/// a process named like "java". Linux: read /proc/PID/task/PID/children.
/// Windows: enumerate parents via Process objects (no extra deps).
/// </summary>
private static Process? FindJavaDescendant(int rootPid)
{
try
{
if (OperatingSystem.IsLinux())
{
return FindJavaDescendantLinux(rootPid);
}
if (OperatingSystem.IsWindows())
{
return FindJavaDescendantWindows(rootPid);
}
}
catch { }
return null;
}
private static Process? FindJavaDescendantLinux(int rootPid)
{
var visited = new HashSet<int>();
var queue = new Queue<int>();
queue.Enqueue(rootPid);
while (queue.Count > 0)
{
var pid = queue.Dequeue();
if (!visited.Add(pid)) continue;
var childrenPath = $"/proc/{pid}/task/{pid}/children";
if (!File.Exists(childrenPath)) continue;
string raw;
try { raw = File.ReadAllText(childrenPath); } catch { continue; }
foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
if (!int.TryParse(token, out var cpid)) continue;
Process? p = null;
try { p = Process.GetProcessById(cpid); } catch { continue; }
if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
return p;
queue.Enqueue(cpid);
}
}
return null;
}
private static Process? FindJavaDescendantWindows(int rootPid)
{
// Build a parent->children map by reading every running process's parent
// PID once, then BFS. Slower than Linux's /proc but works without WMI.
var allProcs = Process.GetProcesses();
var byParent = new Dictionary<int, List<Process>>();
foreach (var p in allProcs)
{
int parent;
try { parent = GetParentPidWindows(p); } catch { continue; }
if (parent == 0) continue;
if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List<Process>();
list.Add(p);
}
var visited = new HashSet<int>();
var queue = new Queue<int>();
queue.Enqueue(rootPid);
while (queue.Count > 0)
{
var pid = queue.Dequeue();
if (!visited.Add(pid)) continue;
if (!byParent.TryGetValue(pid, out var children)) continue;
foreach (var c in children)
{
if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
return c;
queue.Enqueue(c.Id);
}
}
return null;
}
[System.Runtime.InteropServices.DllImport("ntdll.dll")]
private static extern int NtQueryInformationProcess(
IntPtr processHandle, int processInformationClass,
ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct ProcessBasicInformation
{
public IntPtr Reserved1;
public IntPtr PebBaseAddress;
public IntPtr Reserved2_0;
public IntPtr Reserved2_1;
public IntPtr UniqueProcessId;
public IntPtr InheritedFromUniqueProcessId; // <-- parent PID
}
private static int GetParentPidWindows(Process p)
{
var info = new ProcessBasicInformation();
int rc = NtQueryInformationProcess(p.Handle, 0, ref info,
System.Runtime.InteropServices.Marshal.SizeOf<ProcessBasicInformation>(), out _);
return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32();
}
public ServerProcess(ServerConfig config) => _config = config;
public bool Start()
{
if (IsRunning) return false;
// Reset cached Java descendant + CPU sampling baseline so the next call
// re-resolves once the new run.sh -> java tree is up.
_trackedJava = null;
_lastTrackedPid = 0;
_lastCpuSampleAt = default;
// The mod loader's start script lives next to the server jar. NeoForge produces
// run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on
// the server side, so we build the equivalent JVM command ourselves.
var startScript = ResolveStartCommand(out var argList);
var startInfo = new ProcessStartInfo
{
FileName = startScript,
WorkingDirectory = Path.GetFullPath(_config.ServerDir),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
foreach (var a in argList) startInfo.ArgumentList.Add(a);
// CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to
// whatever's on PATH. If we configured a specific Java (or auto-downloaded one),
// prepend its bin dir + set JAVA_HOME so the script picks up the right JVM
// instead of an older system Java.
ApplyJavaEnvironment(startInfo);
_process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false);
_process.ErrorDataReceived += (_, e) => OnLine(e.Data, isError: true);
_process.Exited += (_, _) =>
{
var code = _process?.ExitCode ?? -1;
OnLine($"=== Server exited (code {code}) ===", isError: false);
Exited?.Invoke(code);
};
_process.Start();
// Bind the Java subprocess to a Job Object on Windows so it gets killed
// automatically if we exit ungracefully (X-button, Task Manager, etc.).
if (OperatingSystem.IsWindows())
{
try
{
_jobObject ??= new WindowsJobObject();
_jobObject.AssignProcess(_process);
}
catch (Exception ex)
{
// Non-fatal -- we still try to clean up via Process.Kill in Dispose.
OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true);
}
}
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
StartedAt = DateTime.UtcNow;
return true;
}
private string ResolveStartCommand(out List<string> args)
{
args = new List<string>();
var dir = Path.GetFullPath(_config.ServerDir);
// Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args.
var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh";
var runScript = Path.Combine(dir, runShell);
if (File.Exists(runScript))
{
// Make sure the user_jvm_args.txt has the memory we want
EnsureUserJvmArgs(dir);
// Suppress the Swing server-GUI window -- we use the web panel instead.
// NeoForge's run.bat / run.sh forwards extra args to Minecraft.
args.Add("nogui");
return runScript;
}
// Fallback: invoke java directly on the server jar. This won't work for NeoForge
// (it requires the loader's run script), but it works for vanilla and Forge legacy.
args.Add($"-Xms{_config.MemoryMB}M");
args.Add($"-Xmx{_config.MemoryMB}M");
args.Add("-jar");
args.Add("server.jar");
args.Add("nogui");
return _config.JavaPath;
}
private void ApplyJavaEnvironment(ProcessStartInfo psi)
{
// Only meaningful when we have an absolute path to a specific java binary
// (i.e., not just "java" on PATH).
if (string.IsNullOrEmpty(_config.JavaPath)) return;
if (!Path.IsPathRooted(_config.JavaPath)) return;
if (!File.Exists(_config.JavaPath)) return;
var javaBinDir = Path.GetDirectoryName(_config.JavaPath);
if (string.IsNullOrEmpty(javaBinDir)) return;
var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/
var sep = OperatingSystem.IsWindows() ? ";" : ":";
var existingPath = Environment.GetEnvironmentVariable("PATH") ?? "";
psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}";
if (!string.IsNullOrEmpty(javaHome))
{
psi.Environment["JAVA_HOME"] = javaHome;
}
}
private void EnsureUserJvmArgs(string dir)
{
// NeoForge's run.bat / run.sh reads this file for JVM-level args.
// Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons
// and significantly better than G1 for heavily-modded MC. Requires Java 21+.
var path = Path.Combine(dir, "user_jvm_args.txt");
var content =
$"-Xms{_config.MemoryMB}M\n" +
$"-Xmx{_config.MemoryMB}M\n" +
"-XX:+UseZGC\n" +
"-XX:+ZGenerational\n";
try { File.WriteAllText(path, content); } catch { /* best-effort */ }
}
private void OnLine(string? data, bool isError)
{
if (string.IsNullOrEmpty(data)) return;
var line = new LogLine(DateTimeOffset.UtcNow, isError, data);
_logRing.Enqueue(line);
while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _);
OnLogLine?.Invoke(line);
}
public IReadOnlyList<LogLine> RecentLogs() => _logRing.ToArray();
public async Task SendInputAsync(string command, CancellationToken ct = default)
{
if (_process is null || _process.HasExited) return;
await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct);
await _process.StandardInput.FlushAsync(ct);
}
public async Task<bool> StopAsync(TimeSpan? graceful = null, CancellationToken ct = default)
{
if (!IsRunning) return false;
graceful ??= TimeSpan.FromSeconds(30);
try
{
await SendInputAsync("stop", ct);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(graceful.Value);
try { await _process!.WaitForExitAsync(cts.Token); return true; }
catch (OperationCanceledException)
{
_process?.Kill(entireProcessTree: true);
return false;
}
}
catch
{
try { _process?.Kill(entireProcessTree: true); } catch { }
return false;
}
}
public void Dispose()
{
try { _process?.Kill(entireProcessTree: true); } catch { }
_process?.Dispose();
}
public sealed record LogLine(DateTimeOffset At, bool IsError, string Text);
}