Files
brass-and-sigil/server/Commands/RunCommand.cs
T
Matt Sijbers a1331212cb 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.
2026-05-05 00:19:05 +01:00

1226 lines
58 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using BrassAndSigil.Server.Models;
using BrassAndSigil.Server.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileProviders.Embedded;
using Microsoft.Extensions.Hosting;
using Spectre.Console;
using Spectre.Console.Cli;
namespace BrassAndSigil.Server.Commands;
public sealed class RunCommand : AsyncCommand<BaseCommandSettings>
{
public override async Task<int> ExecuteAsync(CommandContext context, BaseCommandSettings settings)
{
var configPath = settings.ConfigPath;
var config = ServerConfig.Load(configPath);
AnsiConsole.MarkupLine("[bold yellow]Brass & Sigil Server[/]");
AnsiConsole.MarkupLine($"[grey]config: {configPath}[/]");
AnsiConsole.MarkupLine("");
// ────────── First-run admin setup ──────────
// Fail-secure: if WebPassword is null (key missing or never set), force a
// password to be configured before doing anything else. Empty string ("")
// is the explicit "no auth" opt-out and is rejected on non-localhost binds.
// Done here, BEFORE the heavy install + MC start so the prompt isn't
// drowned out by streaming log output.
if (config.WebPassword is null)
{
if (Console.IsInputRedirected)
{
var generated = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18))
.Replace("+", "-").Replace("/", "_").Replace("=", "");
config.WebPassword = generated;
config.Save(configPath);
AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold yellow]═══════════ ADMIN PASSWORD GENERATED ═══════════[/]");
AnsiConsole.MarkupLine($"[bold]Password:[/] [yellow]{generated}[/]");
AnsiConsole.MarkupLine($"[grey]Saved to {configPath}. Use the [/][bold]Change password[/][grey] button in the panel to set your own.[/]");
AnsiConsole.MarkupLine("[bold yellow]════════════════════════════════════════════════[/]");
AnsiConsole.MarkupLine("");
}
else
{
AnsiConsole.MarkupLine("[bold]First-run admin setup[/]");
AnsiConsole.MarkupLine("[grey]The web panel needs an admin password before continuing.[/]");
AnsiConsole.MarkupLine("[grey](Set [/][yellow]webPassword[/][grey] to empty string in config to disable -- local dev only.)[/]");
string pw1, pw2;
while (true)
{
pw1 = AnsiConsole.Prompt(new TextPrompt<string>("Admin password (min 8 chars):").Secret());
if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; }
pw2 = AnsiConsole.Prompt(new TextPrompt<string>("Confirm:").Secret());
if (pw1 != pw2) { AnsiConsole.MarkupLine("[red]Doesn't match.[/]"); continue; }
break;
}
config.WebPassword = pw1;
config.Save(configPath);
AnsiConsole.MarkupLine("[green]✓[/] Admin password saved.");
AnsiConsole.MarkupLine("");
}
}
else if (!IsLocalhostBind(config) && config.WebPassword.Length == 0)
{
AnsiConsole.MarkupLine("[red]Refusing to bind on a non-localhost address with webPassword disabled (\"\").[/]");
AnsiConsole.MarkupLine("[red]Either set a real password or change [yellow]webHost[/] to [yellow]localhost[/].[/]");
return 1;
}
// ────────── Auto-setup phase: bring the server install up to spec ──────────
var ok = await EnsureInstalledAsync(config, configPath);
if (!ok)
{
AnsiConsole.MarkupLine("[red]Auto-setup failed; cannot run server.[/]");
return 1;
}
// ────────── Process + daemon phase ──────────
var serverProc = new ServerProcess(config);
// RCON connects lazily on first use AND reconnects after failure -- MC takes
// ~30 s to open the RCON port, so eager-connect-once would cache a dead client.
var rcon = new RconManager("127.0.0.1", config.RconPort, config.RconPassword);
Action<string> srvLog = msg => AnsiConsole.MarkupLine($"[grey]{msg.EscapeMarkup()}[/]");
var whitelistRequests = new WhitelistRequestService(config);
var broadcaster = new Broadcaster(serverProc);
var updater = new UpdaterService(config, configPath, serverProc, broadcaster, srvLog);
var backup = new BackupService(config, serverProc, broadcaster, srvLog);
var bluemap = new BlueMapService(config, srvLog);
var world = new WorldService(config, serverProc, backup, broadcaster, rcon, srvLog, bluemap);
var scheduler = new BackupScheduler(config, backup, srvLog);
scheduler.Start();
try
{
serverProc.Start();
AnsiConsole.MarkupLine($"[green]✓[/] Started Minecraft (PID {serverProc.Pid})");
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]✗ Failed to start Minecraft:[/] {ex.Message.EscapeMarkup()}");
return 1;
}
serverProc.OnLogLine += line =>
{
var ts = line.At.ToLocalTime().ToString("HH:mm:ss");
var color = line.IsError ? "red" : "grey";
AnsiConsole.MarkupLine($"[{color}][[{ts}]] {line.Text.EscapeMarkup()}[/]");
};
// Build Kestrel host
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(opts =>
{
if (IsLocalhostBind(config))
opts.ListenLocalhost(config.WebPort);
else
opts.ListenAnyIP(config.WebPort);
});
builder.Logging.ClearProviders();
// Per-IP rate limit on the login endpoint: 10 attempts / 60 s sliding window.
// Without this, a weak password is bruteforce-trivial. Keyed on remote IP so
// a single brute-forcer can't exhaust everyone's quota.
builder.Services.AddRateLimiter(rl =>
{
rl.RejectionStatusCode = 429;
rl.AddPolicy("login", ctx =>
System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
// Public friend-side whitelist request: 5/hour per IP. Keeps spam manageable
// without making it annoying for friends who legitimately want to retry.
rl.AddPolicy("whitelist-request", ctx =>
System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromHours(1),
QueueLimit = 0,
}));
// Status check is the launcher's polling endpoint -- needs a looser limit
// so an open launcher polling status doesn't get throttled. 30/hour gives
// ~one check every 2 minutes, far more than the launcher actually needs.
rl.AddPolicy("whitelist-status", ctx =>
System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromHours(1),
QueueLimit = 0,
}));
});
// Trust X-Forwarded-For from loopback (a local reverse proxy like Caddy or nginx).
// Without this, the rate limiter would partition by Caddy's 127.0.0.1 connection
// and a single brute-forcer could exhaust everyone's quota.
builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
o.KnownProxies.Add(System.Net.IPAddress.Loopback);
o.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback);
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseRateLimiter();
// Static UI: prefer sidecar wwwroot/ for hot edits, fall back to embedded.
var sidecar = Path.Combine(AppContext.BaseDirectory, "wwwroot");
IFileProvider uiProvider = Directory.Exists(sidecar)
? new PhysicalFileProvider(sidecar)
: new ManifestEmbeddedFileProvider(typeof(RunCommand).Assembly, "wwwroot");
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = uiProvider });
app.UseStaticFiles(new StaticFileOptions { FileProvider = uiProvider });
// BlueMap output at /map/ -- register middleware unconditionally. We
// pre-create the directory so PhysicalFileProvider's constructor is happy
// even before the first render.
Directory.CreateDirectory(bluemap.WebDir);
var bluemapFp = new PhysicalFileProvider(Path.GetFullPath(bluemap.WebDir));
// (First-run admin setup happens early in ExecuteAsync, before the heavy
// install + MC start, so the interactive prompt isn't drowned by log output.)
// Auth: enforced whenever a password is set, regardless of bind. Accepts either
// X-Brass-Sigil-Auth header (curl / scripts) or the brass-sigil-auth cookie
// (browsers -- works for SSE too since EventSource can't set custom headers).
// /api/auth/login is allowed through without auth (chicken-and-egg) but is
// separately rate-limited to throttle brute-force attempts.
//
// MUST be registered BEFORE the /map static-files handlers below -- otherwise
// those handlers short-circuit the pipeline and serve map tiles to anyone.
// The UI shell at / is intentionally above this (login page must be reachable
// unauthenticated); all sensitive operations go through /api/* which is gated.
if (!string.IsNullOrEmpty(config.WebPassword))
{
app.Use(async (ctx, next) =>
{
var path = ctx.Request.Path;
// Only gate /api/* and /map/* -- everything else is the public UI shell.
var gated = path.StartsWithSegments("/api") || path.StartsWithSegments("/map");
if (!gated) { await next(); return; }
if (path.StartsWithSegments("/api/auth/login")) { await next(); return; }
// Friend-side whitelist request endpoints are public on purpose so the
// launcher can hit them without holding the admin password. Rate-limited
// separately to throttle abuse.
if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; }
if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; }
var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword);
var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString();
if (string.IsNullOrEmpty(auth))
auth = ctx.Request.Cookies["brass-sigil-auth"] ?? "";
var authBytes = System.Text.Encoding.UTF8.GetBytes(auth);
var ok = authBytes.Length == pwBytes.Length
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes);
if (!ok)
{
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsync("Unauthorized");
return;
}
await next();
});
}
// /map/.../live/players.json is generated dynamically per-request from RCON
// -- pull-based, so RCON only fires while a browser tab is actually polling.
// No server-side timer, no idle-state to manage. Same model as /api/players.
// Must be registered BEFORE the /map static-files handler so it short-circuits
// even if a stale players.json exists on disk from an earlier daemon version.
const string PlayersJsonPath = "/map/maps/overworld/live/players.json";
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path != PlayersJsonPath) { await next(); return; }
ctx.Response.ContentType = "application/json";
try
{
var players = await BlueMapPlayers.SnapshotAsync(rcon, config.ServerDir, ctx.RequestAborted);
await ctx.Response.WriteAsync(JsonSerializer.Serialize(new { players }), ctx.RequestAborted);
}
catch (OperationCanceledException) { /* client gone */ }
catch
{
// Don't fail the request -- empty list is fine if RCON's having a moment.
await ctx.Response.WriteAsync("{\"players\":[]}");
}
});
// /map/assets/playerheads/<uuid>.png -- BlueMap's web UI requests these for
// player markers. The CLI distribution doesn't write them (only the in-server
// mod does), so on a CLI-only setup every marker falls back to assets/steve.png.
// We lazily fetch the head from crafatar.com on first request and cache to
// disk; subsequent requests hit static-files directly (zero outbound traffic).
// To force a skin refresh, delete the matching <uuid>.png from
// <bluemapDir>/web/assets/playerheads/.
const string PlayerHeadPrefix = "/map/assets/playerheads/";
var headHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
headHttp.DefaultRequestHeaders.UserAgent.ParseAdd("brass-sigil-server/1.0");
app.Use(async (ctx, next) =>
{
var p = ctx.Request.Path.Value;
if (p is null
|| !p.StartsWith(PlayerHeadPrefix, StringComparison.Ordinal)
|| !p.EndsWith(".png", StringComparison.Ordinal))
{ await next(); return; }
var uuidStr = p.Substring(PlayerHeadPrefix.Length, p.Length - PlayerHeadPrefix.Length - 4);
if (!Guid.TryParse(uuidStr, out _)) { await next(); return; }
var cachePath = Path.Combine(bluemap.WebDir, "assets", "playerheads", uuidStr + ".png");
if (!File.Exists(cachePath))
{
try
{
var bytes = await headHttp.GetByteArrayAsync(
$"https://crafatar.com/avatars/{uuidStr}?size=32&overlay",
ctx.RequestAborted);
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
await File.WriteAllBytesAsync(cachePath, bytes, ctx.RequestAborted);
}
catch (OperationCanceledException) { return; }
catch
{
// Fall through to static-files which 404s; BlueMap shows steve.png.
// Better UX than a 5xx -- the rest of the map keeps rendering.
}
}
await next();
});
// BlueMap stores `.json` and `.prbm` files pre-compressed on disk as
// `.json.gz` / `.prbm.gz`, but its web client requests them WITHOUT the
// `.gz` suffix -- it expects the server to transparently serve the gzipped
// variant with Content-Encoding: gzip. This middleware does that rewrite
// BEFORE UseStaticFiles, so the existing OnPrepareResponse below handles
// the headers as it would for a direct .gz request.
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.StartsWithSegments("/map", out var rest))
{
var sub = rest.Value?.TrimStart('/');
if (!string.IsNullOrEmpty(sub))
{
var literal = bluemapFp.GetFileInfo(sub);
if (!literal.Exists)
{
var gz = bluemapFp.GetFileInfo(sub + ".gz");
if (gz.Exists)
{
ctx.Request.Path = "/map/" + sub + ".gz";
}
}
}
}
await next();
});
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = bluemapFp,
RequestPath = "/map",
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = bluemapFp,
RequestPath = "/map",
ServeUnknownFileTypes = true,
DefaultContentType = "application/octet-stream",
OnPrepareResponse = ctx =>
{
// Anything ending in .gz needs Content-Encoding: gzip and a content
// type derived from the extension BEFORE .gz (so .json.gz is JSON,
// .prbm.gz is binary).
var name = ctx.File.Name;
if (!name.EndsWith(".gz", StringComparison.Ordinal)) return;
ctx.Context.Response.Headers["Content-Encoding"] = "gzip";
if (name.EndsWith(".json.gz", StringComparison.Ordinal)) ctx.Context.Response.Headers["Content-Type"] = "application/json";
else if (name.EndsWith(".prbm.gz", StringComparison.Ordinal)) ctx.Context.Response.Headers["Content-Type"] = "application/octet-stream";
},
});
// ──────────────── API endpoints ────────────────
// ── auth endpoints ──
// /api/auth/login sets an HttpOnly cookie so JS can never read the value;
// this defeats cookie exfiltration via XSS. Rate-limited per IP.
app.MapPost("/api/auth/login", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<PasswordPayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var supplied = payload?.Password ?? "";
var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword ?? "");
var supBytes = System.Text.Encoding.UTF8.GetBytes(supplied);
var ok = pwBytes.Length > 0
&& supBytes.Length == pwBytes.Length
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(supBytes, pwBytes);
if (!ok)
{
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsync("Wrong password");
return;
}
ctx.Response.Cookies.Append("brass-sigil-auth", config.WebPassword!, new CookieOptions
{
HttpOnly = true,
Secure = ctx.Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/",
});
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}).AllowAnonymous().RequireRateLimiting("login");
app.MapPost("/api/auth/logout", (HttpContext ctx) =>
{
ctx.Response.Cookies.Delete("brass-sigil-auth");
return Results.Json(new { ok = true });
});
app.MapPost("/api/auth/change-password", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<ChangePasswordPayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var current = payload?.Current ?? "";
var next = payload?.Next ?? "";
var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword ?? "");
var curBytes = System.Text.Encoding.UTF8.GetBytes(current);
var ok = pwBytes.Length > 0
&& curBytes.Length == pwBytes.Length
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(curBytes, pwBytes);
if (!ok) return Results.Json(new { ok = false, error = "Current password is wrong" }, statusCode: 401);
if (next.Length < 8) return Results.Json(new { ok = false, error = "New password must be at least 8 characters" }, statusCode: 400);
config.WebPassword = next;
config.Save(configPath);
// Re-issue the cookie with the new password so the current session stays logged in.
ctx.Response.Cookies.Append("brass-sigil-auth", next, new CookieOptions
{
HttpOnly = true,
Secure = ctx.Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/",
});
return Results.Json(new { ok = true });
});
app.MapGet("/api/status", () => Results.Json(new
{
running = serverProc.IsRunning,
pid = serverProc.Pid,
startedAt = serverProc.StartedAt,
uptime = serverProc.StartedAt is { } s ? (DateTime.UtcNow - s).TotalSeconds : (double?)null,
packVersion = ReadPackVersion(config.ServerDir),
memoryBytes = serverProc.MemoryBytes,
memoryMaxMB = config.MemoryMB,
cpu = serverProc.CpuMetrics is { } m
? new { current = m.Current, max = m.Max, avg = m.Avg }
: null,
worldSizeBytes = world.GetWorldSizeBytes(),
}));
app.MapGet("/api/logs", (int? since) =>
{
var logs = serverProc.RecentLogs();
if (since.HasValue) logs = logs.Skip(since.Value).ToArray();
return Results.Json(new
{
total = serverProc.RecentLogs().Count,
lines = logs.Select(l => new { t = l.At, e = l.IsError, m = l.Text })
});
});
// Server-Sent Events: instant push of new log lines + a replay of the recent buffer
// on connect. Browsers (EventSource) reconnect automatically if the stream drops.
app.MapGet("/api/logs/stream", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx response buffering
await ctx.Response.Body.FlushAsync(ct);
var channel = System.Threading.Channels.Channel.CreateUnbounded<ServerProcess.LogLine>(
new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
void OnLine(ServerProcess.LogLine line) => channel.Writer.TryWrite(line);
serverProc.OnLogLine += OnLine;
try
{
// Replay the ring buffer first so reconnecting clients get the recent context.
foreach (var l in serverProc.RecentLogs())
{
await WriteSseLogAsync(ctx.Response, l, ct);
}
// Then live-stream new lines. Heartbeat every 25 s so the connection isn't
// killed by intermediaries (nginx, browser idle timeouts).
using var heartbeatTimer = new PeriodicTimer(TimeSpan.FromSeconds(25));
var heartbeatTask = Task.Run(async () =>
{
try { while (await heartbeatTimer.WaitForNextTickAsync(ct)) await ctx.Response.WriteAsync(": ping\n\n", ct); }
catch { }
}, ct);
while (!ct.IsCancellationRequested)
{
var line = await channel.Reader.ReadAsync(ct);
await WriteSseLogAsync(ctx.Response, line, ct);
}
}
catch (OperationCanceledException) { /* client disconnected */ }
finally
{
serverProc.OnLogLine -= OnLine;
channel.Writer.TryComplete();
}
});
app.MapPost("/api/command", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var body = await sr.ReadToEndAsync();
var payload = JsonSerializer.Deserialize<CommandPayload>(body, JsonOpts.CaseInsensitive);
var cmd = payload?.Command?.Trim();
if (string.IsNullOrEmpty(cmd)) return Results.BadRequest("empty command");
await serverProc.SendInputAsync(cmd);
return Results.Json(new { ok = true });
});
app.MapGet("/api/whitelist", () =>
{
var path = Path.Combine(Path.GetFullPath(config.ServerDir), "whitelist.json");
if (!File.Exists(path)) return Results.Json(new { players = Array.Empty<string>() });
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var names = doc.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("name", out var n) ? n.GetString() : null)
.Where(n => !string.IsNullOrWhiteSpace(n))
.ToArray();
return Results.Json(new { players = names });
}
catch (Exception ex)
{
return Results.Json(new { players = Array.Empty<string>(), error = ex.Message });
}
});
// ── Public whitelist request flow (friend-side, no auth) ──
// Friends post their MC username; admin sees the queue in the panel and
// approves/denies. The /whitelist add command itself still goes through the
// normal admin-gated endpoint below.
app.MapPost("/api/whitelist/request", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<WhitelistRequestPayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Username?.Trim();
if (string.IsNullOrEmpty(name) || name.Length > 16
|| !System.Text.RegularExpressions.Regex.IsMatch(name, @"^[A-Za-z0-9_]{3,16}$"))
{
return Results.BadRequest(new { ok = false, error = "Username must be 3-16 letters/digits/underscore." });
}
var ip = ctx.Connection.RemoteIpAddress?.ToString();
var req = whitelistRequests.Submit(name, payload?.Message, ip);
return Results.Json(new { ok = true, status = req.Status });
}).AllowAnonymous().RequireRateLimiting("whitelist-request");
// Same per-IP throttle as the request endpoint -- prevents trivial enumeration
// while still letting the launcher's polling work fine.
app.MapGet("/api/whitelist/status", (string username) =>
{
var name = (username ?? "").Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." });
var status = whitelistRequests.StatusFor(name);
return Results.Json(new { ok = true, status });
}).AllowAnonymous().RequireRateLimiting("whitelist-status");
// ── Admin-side: list/approve/deny (auth required via global middleware) ──
app.MapGet("/api/whitelist/requests", () =>
Results.Json(new { requests = whitelistRequests.ListPending() }));
app.MapPost("/api/whitelist/requests/approve", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<NamePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Name?.Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." });
// Mark approved + actually add to MC's whitelist via stdin command.
whitelistRequests.MarkApproved(name);
await serverProc.SendInputAsync($"whitelist add {name}");
// Remove from the pending file once it's actually in the whitelist.
whitelistRequests.Remove(name);
return Results.Json(new { ok = true });
});
app.MapPost("/api/whitelist/requests/deny", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<NamePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Name?.Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." });
whitelistRequests.MarkDenied(name);
return Results.Json(new { ok = true });
});
app.MapPost("/api/whitelist/add", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<NamePayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Name?.Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest("empty name");
await serverProc.SendInputAsync($"whitelist add {name}");
return Results.Json(new { ok = true });
});
app.MapPost("/api/whitelist/remove", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<NamePayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Name?.Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest("empty name");
await serverProc.SendInputAsync($"whitelist remove {name}");
return Results.Json(new { ok = true });
});
app.MapGet("/api/players", async () =>
{
try
{
var resp = await rcon.SendCommandAsync("list");
var (count, names) = ParsePlayerList(resp);
return Results.Json(new { online = count, players = names });
}
catch (Exception ex)
{
return Results.Json(new { online = -1, players = Array.Empty<string>(), error = ex.Message });
}
});
app.MapPost("/api/server/stop", async () =>
{
await serverProc.StopAsync();
return Results.Json(new { ok = true });
});
app.MapPost("/api/server/start", () =>
{
var started = serverProc.Start();
return Results.Json(new { ok = started });
});
// ── Server settings (server.properties bridge) ──
app.MapGet("/api/server/settings", () =>
{
var svc = new ServerPropertiesService(config);
return Results.Json(new
{
values = svc.ReadEditable(),
editableKeys = ServerPropertiesService.EditableKeys.ToArray(),
running = serverProc.IsRunning,
});
});
app.MapPost("/api/server/settings", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<Dictionary<string, string>>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
if (payload is null || payload.Count == 0)
return Results.BadRequest(new { ok = false, error = "Empty payload." });
var svc = new ServerPropertiesService(config);
svc.Update(payload);
return Results.Json(new { ok = true, restartRequired = serverProc.IsRunning });
});
// ── BlueMap (manual render) ──
app.MapGet("/api/map/status", () => Results.Json(new
{
inProgress = bluemap.State.InProgress,
phase = bluemap.State.Phase,
error = bluemap.State.Error,
startedAt = bluemap.State.StartedAt,
finishedAt = bluemap.State.FinishedAt,
exitCode = bluemap.State.ExitCode,
lastLogLine = bluemap.State.LastLogLine,
hasOutput = bluemap.HasRendered,
}));
app.MapPost("/api/map/render", () =>
{
var started = bluemap.StartRender();
return Results.Json(new { ok = started, error = started ? null : "Render already in progress." });
});
app.MapPost("/api/map/cancel", () =>
{
var cancelled = bluemap.CancelRender();
return Results.Json(new { ok = cancelled, error = cancelled ? null : "No render in progress." });
});
// Convenience: stop+start as one call so the panel can do it from a button.
app.MapPost("/api/server/restart", async () =>
{
if (serverProc.IsRunning) await serverProc.StopAsync();
// Brief delay so OS has time to release file handles before relaunch.
await Task.Delay(1500);
var ok = serverProc.Start();
return Results.Json(new { ok });
});
// ── Updater ──
app.MapGet("/api/update/status", async () =>
{
// Quick check (refreshes State.CurrentVersion / AvailableVersion as side-effect).
var check = await updater.CheckAsync();
return Results.Json(new
{
inProgress = updater.State.InProgress,
phase = updater.State.Phase,
countdownTotal = updater.State.CountdownTotal,
countdownRemaining = updater.State.CountdownRemaining,
current = check.Current,
available = check.Available,
needsUpdate = check.NeedsUpdate,
error = updater.State.Error ?? check.Error,
});
});
app.MapPost("/api/update/start", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<UpdateStartPayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var delay = Math.Clamp(payload?.DelaySeconds ?? 300, 0, 3600);
// Run the update in the background -- don't block the HTTP response on the
// 5-minute countdown. The panel polls /api/update/status for progress.
_ = Task.Run(() => updater.StartAsync(delay));
return Results.Accepted("/api/update/status", new { ok = true, delaySeconds = delay });
});
app.MapPost("/api/update/cancel", () =>
{
var cancelled = updater.TryCancel();
return Results.Json(new { ok = cancelled });
});
// On-demand seed fetch -- not part of /api/status polling because the
// seed only changes on wipe, no need to re-query RCON every poll.
app.MapGet("/api/world/seed", async (CancellationToken ct) =>
{
var seed = await world.GetCurrentSeedAsync(ct);
return Results.Json(new { seed });
});
app.MapPost("/api/world/wipe", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<WipePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
if (!string.Equals(payload?.Confirm, "WIPE", StringComparison.Ordinal))
return Results.BadRequest(new { ok = false, error = "Type WIPE to confirm." });
var doBackup = payload!.Backup ?? true;
var seedMode = (payload.SeedMode ?? "random").ToLowerInvariant() switch
{
"keep" => WorldService.SeedMode.Keep,
"custom" => WorldService.SeedMode.Custom,
_ => WorldService.SeedMode.Random,
};
var opts = new WorldService.WipeOptions(doBackup, seedMode, payload.CustomSeed);
var result = await world.WipeWorldAsync(opts);
return Results.Json(new { ok = result.Ok, backupName = result.BackupName, seedUsed = result.SeedUsed, error = result.Error });
});
// ── Backups ──
app.MapGet("/api/backup/list", () =>
{
var list = backup.List();
return Results.Json(new
{
dir = backup.BackupDir,
backups = list,
schedule = config.BackupSchedule,
description = scheduler.Describe(),
keep = config.BackupKeep,
nextRun = scheduler.NextRun(),
});
});
app.MapPost("/api/backup/schedule", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<BackupSchedulePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
// Empty string clears the schedule (disables). Otherwise the BackupScheduler
// accepts: "HH:mm", comma-separated "HH:mm,HH:mm,...", or "every Nh"/"every Nm".
var newSchedule = payload?.Schedule;
if (!string.IsNullOrWhiteSpace(newSchedule))
{
// Probe by parsing -- the scheduler's Describe will return "Disabled" for invalid.
var trial = newSchedule.Trim().ToLowerInvariant();
bool ok;
if (System.Text.RegularExpressions.Regex.IsMatch(trial,
@"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$"))
{
ok = true;
}
else
{
ok = trial.Split(',', StringSplitOptions.RemoveEmptyEntries)
.All(tok => TimeOnly.TryParse(tok.Trim(), out _));
}
if (!ok)
return Results.BadRequest(new { ok = false, error = "Schedule must be HH:mm, HH:mm,HH:mm, 'every Nh', or 'every Nm' (or empty to disable)." });
}
if (payload?.Keep is { } k && k < 1)
return Results.BadRequest(new { ok = false, error = "keep must be >= 1." });
if (newSchedule != null) config.BackupSchedule = string.IsNullOrWhiteSpace(newSchedule) ? null : newSchedule;
if (payload?.Keep is { } kk) config.BackupKeep = kk;
config.Save(configPath);
scheduler.Reload();
return Results.Json(new { ok = true, schedule = config.BackupSchedule, keep = config.BackupKeep, nextRun = scheduler.NextRun() });
});
app.MapPost("/api/backup/create", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<BackupCreatePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var result = await backup.CreateAsync(payload?.Reason);
return Results.Json(new { ok = result.Ok, name = result.Name, sizeBytes = result.SizeBytes, error = result.Error });
});
app.MapPost("/api/backup/restore", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<BackupNamePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Name?.Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing backup name." });
var (ok, error) = await backup.RestoreAsync(name);
return Results.Json(new { ok, error });
});
app.MapPost("/api/backup/delete", async (HttpContext ctx) =>
{
using var sr = new StreamReader(ctx.Request.Body);
var payload = JsonSerializer.Deserialize<BackupNamePayload>(
await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
var name = payload?.Name?.Trim();
if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing backup name." });
var (ok, error) = backup.Delete(name);
return Results.Json(new { ok, error });
});
AnsiConsole.MarkupLine($"[green]✓[/] Web UI: [blue]http://{config.WebHost}:{config.WebPort}/[/]");
// Intercept Ctrl+C / SIGTERM so the Java subprocess is stopped cleanly
// before we exit. The Windows Job Object is a last-resort safety net for
// the cases this can't catch (Task Manager kill, BSOD).
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // we'll exit ourselves after stopping MC
AnsiConsole.MarkupLine("[yellow]Shutting down...[/]");
// 60 s headroom matches the systemd unit's TimeoutStopSec -- populated
// worlds with terrain mods + DH LOD writes + C2ME chunk flushes can take
// 30-50 s to finish saving cleanly. Tight timeout = risk of force-kill
// mid-flush. If MC's done sooner, this returns sooner.
try { bluemap.Dispose(); } catch { } // kill any in-flight render before MC stop
try { serverProc.StopAsync(TimeSpan.FromSeconds(60)).GetAwaiter().GetResult(); } catch { }
try { app.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); } catch { }
};
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
{
try { bluemap.Dispose(); } catch { }
try { serverProc.StopAsync(TimeSpan.FromSeconds(10)).GetAwaiter().GetResult(); } catch { }
};
// Auto-open browser for Windows interactive launches (double-click experience).
if (OperatingSystem.IsWindows() && !Console.IsInputRedirected)
{
_ = Task.Run(async () =>
{
await Task.Delay(2500);
try { Process.Start(new ProcessStartInfo { FileName = $"http://{config.WebHost}:{config.WebPort}/", UseShellExecute = true }); }
catch { /* user can browse manually */ }
});
}
await app.RunAsync();
await serverProc.StopAsync();
scheduler.Dispose();
bluemap.Dispose();
rcon.Dispose();
return 0;
}
/// <summary>
/// Brings the install up to spec before launching: validates Java, accepts EULA,
/// syncs mods from the manifest, and runs the NeoForge server installer if needed.
/// Idempotent and quick when nothing needs doing.
/// </summary>
private static async Task<bool> EnsureInstalledAsync(ServerConfig config, string configPath)
{
var serverDir = Path.GetFullPath(config.ServerDir);
Directory.CreateDirectory(serverDir);
// 0. Java version pre-flight -- MC 1.21.1 / NeoForge 21.1.x require Java 21+.
// We try in order: configured javaPath -> bundled at server/java/ -> system PATH.
// If none yield Java 21+, auto-download Adoptium Temurin JRE 21 to server/java/.
var javaInstaller = new JavaInstaller();
var resolvedJava = await ResolveOrInstallJavaAsync(config, configPath, javaInstaller, serverDir);
if (resolvedJava is null) return false;
config.JavaPath = resolvedJava.Value.Path;
AnsiConsole.MarkupLine($"[green]✓[/] Java {resolvedJava.Value.Major} ({resolvedJava.Value.Vendor}) -- {config.JavaPath}");
// 1. EULA
var eulaPath = Path.Combine(serverDir, "eula.txt");
var eulaAccepted = File.Exists(eulaPath) && File.ReadAllText(eulaPath).Contains("eula=true");
if (!eulaAccepted && !config.AcceptEula)
{
AnsiConsole.MarkupLine("[bold]The Minecraft EULA has not been accepted yet.[/]");
AnsiConsole.MarkupLine("Read it: [blue]https://aka.ms/MinecraftEULA[/]");
if (Console.IsInputRedirected)
{
AnsiConsole.MarkupLine("[red]Re-run with --accept-eula or set acceptEula:true in server-config.json.[/]");
return false;
}
if (!AnsiConsole.Confirm("Do you accept the Minecraft EULA?", false))
{
AnsiConsole.MarkupLine("[red]EULA not accepted, exiting.[/]");
return false;
}
config.AcceptEula = true;
config.Save(configPath);
}
if (!File.Exists(eulaPath))
{
await File.WriteAllTextAsync(eulaPath,
"# Generated by brass-sigil-server\n" +
"# By setting eula=true you accept https://aka.ms/MinecraftEULA\n" +
"eula=true\n");
}
// 2. server.properties (only if missing)
var propsPath = Path.Combine(serverDir, "server.properties");
if (!File.Exists(propsPath))
{
if (string.IsNullOrEmpty(config.RconPassword))
{
config.RconPassword = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant();
config.Save(configPath);
}
await File.WriteAllTextAsync(propsPath,
$"motd=Brass & Sigil\n" +
$"gamemode=survival\n" +
$"difficulty=normal\n" +
$"online-mode=true\n" +
$"white-list=true\n" +
$"enforce-whitelist=true\n" +
$"max-players=20\n" +
$"view-distance=12\n" +
$"simulation-distance=10\n" +
$"enable-rcon=true\n" +
$"rcon.port={config.RconPort}\n" +
$"rcon.password={config.RconPassword}\n" +
$"broadcast-rcon-to-ops=false\n");
AnsiConsole.MarkupLine("[grey]Wrote default server.properties[/]");
}
// server-icon.png -- extract from embedded resource if missing. Survives wipes
// (lives at server root, not inside world/) and persists across pack updates.
var iconPath = Path.Combine(serverDir, "server-icon.png");
if (!File.Exists(iconPath))
{
try
{
using var stream = typeof(RunCommand).Assembly
.GetManifestResourceStream("BrassAndSigil.Server.Assets.server-icon.png");
if (stream is not null)
{
await using var fs = File.Create(iconPath);
await stream.CopyToAsync(fs);
AnsiConsole.MarkupLine("[grey]Placed default server-icon.png[/]");
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[grey]Couldn't write server-icon.png: {ex.Message.EscapeMarkup()}[/]");
}
}
// 3. Fetch manifest + sync mods
AnsiConsole.MarkupLine("[bold]Checking modpack...[/]");
var sync = new ManifestSync();
Manifest manifest;
try
{
manifest = await sync.FetchManifestAsync(config.ManifestUrl);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Could not fetch manifest:[/] {ex.Message.EscapeMarkup()}");
return false;
}
var localPackVer = ReadLocalPackVersion(serverDir);
var needsSync = !string.Equals(localPackVer, manifest.Version, StringComparison.Ordinal);
if (needsSync)
{
AnsiConsole.MarkupLine(localPackVer is null
? $"[yellow]No pack installed yet. Syncing v{manifest.Version}...[/]"
: $"[yellow]Pack v{localPackVer} -> v{manifest.Version}, syncing...[/]");
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
try
{
var result = await sync.SyncAsync(config.ManifestUrl, serverDir, progress);
AnsiConsole.MarkupLine($"[green]✓[/] Pack synced: {result.Downloaded} downloaded, {result.Removed} removed, {result.Skipped} client-only skipped");
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Sync failed:[/] {ex.Message.EscapeMarkup()}");
return false;
}
}
else
{
AnsiConsole.MarkupLine($"[green]✓[/] Pack already at v{localPackVer}");
}
// 4. Loader install (NeoForge only for now)
var loader = manifest.Loader;
if (loader is not null && loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase))
{
var nfInstaller = new NeoForgeInstaller();
if (!nfInstaller.IsAlreadyInstalled(serverDir))
{
AnsiConsole.MarkupLine($"[yellow]Installing NeoForge {loader.Version}...[/]");
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
var success = await nfInstaller.InstallAsync(loader.Version, serverDir, config.JavaPath, progress, default);
if (!success)
{
AnsiConsole.MarkupLine("[red]NeoForge install failed. Make sure 'java' is on PATH (or set javaPath in config).[/]");
return false;
}
AnsiConsole.MarkupLine("[green]✓[/] NeoForge installed");
}
else
{
AnsiConsole.MarkupLine("[green]✓[/] NeoForge already installed");
}
}
return true;
}
private static async Task<(string Path, int Major, string Vendor)?> ResolveOrInstallJavaAsync(
ServerConfig config, string configPath, JavaInstaller installer, string serverDir)
{
// 1. Try the configured javaPath as-is.
var info = await GetJavaInfoAsync(config.JavaPath);
if (info is { Major: >= 21 }) return (config.JavaPath, info.Value.Major, info.Value.Vendor);
// 2. Try a previously bundled Java at server/java/.
var bundled = installer.FindBundledJava(serverDir);
if (bundled is not null)
{
var bundledInfo = await GetJavaInfoAsync(bundled);
if (bundledInfo is { Major: >= 21 })
{
config.JavaPath = bundled;
config.Save(configPath);
return (bundled, bundledInfo.Value.Major, bundledInfo.Value.Vendor);
}
}
// 3. Last resort: download Adoptium Temurin JRE 21.
AnsiConsole.MarkupLine(info is null
? $"[yellow]No Java found at '[bold]{config.JavaPath}[/]'. Auto-installing Java 21...[/]"
: $"[yellow]Found Java {info.Value.Major} ({info.Value.Vendor}) but need 21+. Auto-installing...[/]");
var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($" [grey]{msg.EscapeMarkup()}[/]"));
var installed = await installer.InstallJre21Async(serverDir, progress, default);
if (installed is null)
{
AnsiConsole.MarkupLine("[red]Java auto-install failed.[/] Install Java 21 manually from " +
"[blue]https://adoptium.net/temurin/releases/?version=21[/] and set " +
$"[yellow]javaPath[/] in {configPath}.");
return null;
}
var newInfo = await GetJavaInfoAsync(installed);
if (newInfo is null || newInfo.Value.Major < 21)
{
AnsiConsole.MarkupLine("[red]Auto-installed Java didn't pass the version check.[/]");
return null;
}
config.JavaPath = installed;
config.Save(configPath);
return (installed, newInfo.Value.Major, newInfo.Value.Vendor);
}
private static async Task<(int Major, string Vendor)?> GetJavaInfoAsync(string javaPath)
{
try
{
var psi = new ProcessStartInfo
{
FileName = javaPath,
Arguments = "-version",
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var proc = Process.Start(psi);
if (proc is null) return null;
var stderr = await proc.StandardError.ReadToEndAsync();
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
var output = stderr + "\n" + stdout;
// First line is typically: openjdk version "21.0.7" 2025-... LTS
// or: java version "1.8.0_261"
var verMatch = System.Text.RegularExpressions.Regex.Match(output, @"version\s+""([0-9._]+)");
if (!verMatch.Success) return null;
var verStr = verMatch.Groups[1].Value;
int major;
if (verStr.StartsWith("1."))
{
// Old format e.g. 1.8.0 → Java 8
var parts = verStr.Split('.');
major = parts.Length > 1 && int.TryParse(parts[1], out var m) ? m : 0;
}
else
{
var parts = verStr.Split('.');
major = int.TryParse(parts[0], out var m) ? m : 0;
}
string vendor = "OpenJDK";
if (output.Contains("Microsoft", StringComparison.OrdinalIgnoreCase)) vendor = "Microsoft";
else if (output.Contains("Temurin", StringComparison.OrdinalIgnoreCase)) vendor = "Temurin";
else if (output.Contains("Zulu", StringComparison.OrdinalIgnoreCase)) vendor = "Azul Zulu";
else if (output.Contains("Oracle", StringComparison.OrdinalIgnoreCase)) vendor = "Oracle";
else if (output.Contains("GraalVM", StringComparison.OrdinalIgnoreCase)) vendor = "GraalVM";
return (major, vendor);
}
catch
{
return null;
}
}
private static string? TrySuggestJava21OnWindows()
{
if (!OperatingSystem.IsWindows()) return null;
var roots = new[]
{
@"C:\Program Files\Java",
@"C:\Program Files\Eclipse Adoptium",
@"C:\Program Files\Microsoft",
@"C:\Program Files\Zulu",
@"C:\Program Files\BellSoft",
};
foreach (var root in roots)
{
if (!Directory.Exists(root)) continue;
try
{
foreach (var dir in Directory.EnumerateDirectories(root))
{
var name = Path.GetFileName(dir);
if (name.Contains("21", StringComparison.Ordinal))
{
var javaExe = Path.Combine(dir, "bin", "java.exe");
if (File.Exists(javaExe)) return javaExe;
}
}
}
catch { }
}
return null;
}
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 bool IsLocalhostBind(ServerConfig cfg) =>
cfg.WebHost is "localhost" or "127.0.0.1" or "::1";
private static (int Count, string[] Names) ParsePlayerList(string rconResp)
{
var colon = rconResp.IndexOf(':');
if (colon < 0) return (0, Array.Empty<string>());
var head = rconResp.Substring(0, colon);
var tail = rconResp.Substring(colon + 1).Trim();
int count = 0;
foreach (var p in head.Split(' '))
if (int.TryParse(p, out var n)) { count = n; break; }
var names = string.IsNullOrWhiteSpace(tail)
? Array.Empty<string>()
: tail.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return (count, names);
}
private static object? ReadPackVersion(string serverDir)
{
var path = Path.Combine(serverDir, "pack-version.json");
if (!File.Exists(path)) return null;
try { return JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(path)); }
catch { return null; }
}
private sealed class CommandPayload { public string? Command { get; set; } }
private sealed class NamePayload { public string? Name { get; set; } }
private sealed class PasswordPayload { public string? Password { get; set; } }
private sealed class ChangePasswordPayload { public string? Current { get; set; } public string? Next { get; set; } }
private sealed class UpdateStartPayload { public int? DelaySeconds { get; set; } }
private sealed class WipePayload {
public string? Confirm { get; set; }
public bool? Backup { get; set; }
public string? SeedMode { get; set; } // "keep" | "random" | "custom"
public string? CustomSeed { get; set; } // only used when SeedMode == "custom"
}
private sealed class BackupCreatePayload { public string? Reason { get; set; } }
private sealed class BackupNamePayload { public string? Name { get; set; } }
private sealed class BackupSchedulePayload { public string? Schedule { get; set; } public int? Keep { get; set; } }
private sealed class WhitelistRequestPayload { public string? Username { get; set; } public string? Message { get; set; } }
private static async Task WriteSseLogAsync(HttpResponse resp, ServerProcess.LogLine line, CancellationToken ct)
{
var json = JsonSerializer.Serialize(new { t = line.At, e = line.IsError, m = line.Text });
await resp.WriteAsync($"event: log\ndata: {json}\n\n", ct);
await resp.Body.FlushAsync(ct);
}
}