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 { public override async Task 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("Admin password (min 8 chars):").Secret()); if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; } pw2 = AnsiConsole.Prompt(new TextPrompt("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 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(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/.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 .png from // /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(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(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( 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(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() }); 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(), 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( 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( 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( 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(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(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(), 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>( 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( 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( 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( 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( 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( 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( 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; } /// /// 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. /// private static async Task 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(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(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(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()); 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() : 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(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); } }