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