bf53b65706
When `webPassword` is null and the daemon starts headless (systemd, piped
SSH), no longer auto-generate a random password. Instead:
- Boot normally with the gate denying everything except /api/auth/setup
- Panel UI eagerly probes new /api/auth/state on load and renders a
first-run setup overlay (password + confirm) when needsSetup=true
- POST /api/auth/setup writes the chosen password and issues the auth
cookie in the same response, so the operator lands logged in
Interactive TTY behaviour (prompt at the console) is unchanged. The gate
middleware is now registered unconditionally so first-run mode is still
locked-down instead of wide-open.
1285 lines
61 KiB
C#
1285 lines
61 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)
|
|
{
|
|
// Non-interactive (systemd, piped SSH): don't auto-generate. The
|
|
// gate below leaves /api/auth/setup open so the operator can set
|
|
// a password on first visit to the panel; everything else stays
|
|
// locked until they do.
|
|
AnsiConsole.MarkupLine("");
|
|
AnsiConsole.MarkupLine("[bold yellow]═══ FIRST-RUN: admin password not set ═══[/]");
|
|
AnsiConsole.MarkupLine("[grey]Open the web panel in your browser and you'll be walked through[/]");
|
|
AnsiConsole.MarkupLine("[grey]setting one. Everything except /api/auth/setup is 401-gated until then.[/]");
|
|
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.
|
|
// Gate registered unconditionally so first-run mode (config.WebPassword
|
|
// == null) is still locked down to /api/auth/setup + /api/auth/state.
|
|
// Once the operator sets a password via the web panel, the same gate
|
|
// tightens automatically (config.WebPassword is updated in-place).
|
|
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; }
|
|
|
|
// Always-anonymous endpoints (login, state probe, friend whitelist requests).
|
|
if (path.StartsWithSegments("/api/auth/login")) { await next(); return; }
|
|
if (path.StartsWithSegments("/api/auth/state")) { await next(); return; }
|
|
if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; }
|
|
if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; }
|
|
|
|
// First-run mode: only /api/auth/setup is reachable until a password
|
|
// is set. The setup endpoint refuses to overwrite an existing password.
|
|
if (string.IsNullOrEmpty(config.WebPassword))
|
|
{
|
|
if (path.StartsWithSegments("/api/auth/setup")) { await next(); return; }
|
|
ctx.Response.StatusCode = 401;
|
|
await ctx.Response.WriteAsync("Not yet configured -- visit / in a browser to set an admin password.");
|
|
return;
|
|
}
|
|
|
|
// Normal auth check (cookie or X-Brass-Sigil-Auth header).
|
|
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 });
|
|
});
|
|
|
|
// Anonymous probe so the panel can decide between login vs first-run
|
|
// setup vs already-authed without triggering a 401 from a "real" call.
|
|
app.MapGet("/api/auth/state", (HttpContext ctx) =>
|
|
{
|
|
var pw = config.WebPassword;
|
|
var needsSetup = pw is null;
|
|
var authed = false;
|
|
if (!needsSetup && !string.IsNullOrEmpty(pw))
|
|
{
|
|
var cookie = ctx.Request.Cookies["brass-sigil-auth"] ?? "";
|
|
var pwBytes = System.Text.Encoding.UTF8.GetBytes(pw);
|
|
var ckBytes = System.Text.Encoding.UTF8.GetBytes(cookie);
|
|
authed = ckBytes.Length == pwBytes.Length
|
|
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ckBytes, pwBytes);
|
|
}
|
|
return Results.Json(new { needsSetup, authed });
|
|
});
|
|
|
|
// First-run-only password setup. Refuses if a password is already set,
|
|
// so an attacker can't use this endpoint to take over an existing install.
|
|
// Rate-limited under the same bucket as /api/auth/login.
|
|
app.MapPost("/api/auth/setup", async (HttpContext ctx) =>
|
|
{
|
|
if (!string.IsNullOrEmpty(config.WebPassword))
|
|
return Results.Json(new { ok = false, error = "Password already set" }, statusCode: 409);
|
|
|
|
using var sr = new StreamReader(ctx.Request.Body);
|
|
var payload = JsonSerializer.Deserialize<PasswordPayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
|
|
var pw = payload?.Password ?? "";
|
|
if (pw.Length < 8)
|
|
return Results.Json(new { ok = false, error = "Password must be at least 8 characters" }, statusCode: 400);
|
|
|
|
config.WebPassword = pw;
|
|
config.Save(configPath);
|
|
|
|
// Issue the cookie immediately so the operator is logged in after setup
|
|
// -- no second round-trip through the login form.
|
|
ctx.Response.Cookies.Append("brass-sigil-auth", pw, new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
Secure = ctx.Request.IsHttps,
|
|
SameSite = SameSiteMode.Strict,
|
|
Path = "/",
|
|
});
|
|
return Results.Json(new { ok = true });
|
|
}).RequireRateLimiting("login");
|
|
|
|
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);
|
|
}
|
|
}
|