Files
brass-and-sigil/server/Services/ServerPropertiesService.cs
Matt Sijbers a1331212cb Initial commit: Brass & Sigil monorepo
Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
2026-05-05 00:19:05 +01:00

150 lines
5.4 KiB
C#

using BrassAndSigil.Server.Models;
namespace BrassAndSigil.Server.Services;
/// <summary>
/// Reads and writes Minecraft's <c>server.properties</c>. Editable keys are
/// gated by an allowlist so a compromised panel can't flip security-critical
/// fields like <c>online-mode</c> arbitrarily -- only common gameplay knobs.
/// Preserves comments and key order on write; appends new keys at the end.
/// </summary>
public sealed class ServerPropertiesService
{
private readonly ServerConfig _config;
public ServerPropertiesService(ServerConfig config) => _config = config;
/// <summary>
/// Keys that may be modified via /api/server/settings. Anything outside this
/// set is silently dropped from the update payload -- admin must SSH for those.
/// </summary>
public static readonly IReadOnlySet<string> EditableKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"motd",
"difficulty",
"gamemode",
"view-distance",
"simulation-distance",
"max-players",
"pvp",
"hardcore",
"white-list",
"enforce-whitelist",
"allow-flight",
"enable-command-block",
"spawn-protection",
};
public string PropertiesPath => Path.Combine(Path.GetFullPath(_config.ServerDir), "server.properties");
public Dictionary<string, string> ReadAll()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(PropertiesPath)) return dict;
foreach (var raw in File.ReadAllLines(PropertiesPath))
{
var line = raw.TrimStart();
if (line.Length == 0 || line[0] == '#' || line[0] == '!') continue;
var idx = line.IndexOf('=');
if (idx < 0) continue;
dict[line.Substring(0, idx).Trim()] = line.Substring(idx + 1);
}
return dict;
}
/// <summary>Returns just the editable subset, with values left as raw strings.</summary>
public Dictionary<string, string> ReadEditable()
{
var all = ReadAll();
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in EditableKeys)
{
if (all.TryGetValue(key, out var v)) result[key] = v;
}
return result;
}
/// <summary>
/// Read the current <c>level-seed</c> value, or null if absent / empty.
/// </summary>
public string? GetLevelSeed()
{
if (!File.Exists(PropertiesPath)) return null;
foreach (var raw in File.ReadAllLines(PropertiesPath))
{
var line = raw.TrimStart();
if (!line.StartsWith("level-seed=", StringComparison.Ordinal)) continue;
var v = line.Substring("level-seed=".Length).Trim();
return string.IsNullOrEmpty(v) ? null : v;
}
return null;
}
/// <summary>
/// Direct write of <c>level-seed</c>. Bypasses <see cref="EditableKeys"/>
/// because the seed is set as part of the wipe flow (with confirmation),
/// not by general settings UI -- exposing it through the regular Update()
/// path would let it be flipped from any settings save. Empty string
/// clears the field, which makes Minecraft pick a random seed on next
/// world generation.
/// </summary>
public void SetLevelSeed(string seed)
{
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
var done = false;
for (int i = 0; i < lines.Count; i++)
{
var trimmed = lines[i].TrimStart();
if (trimmed.StartsWith("level-seed=", StringComparison.Ordinal))
{
lines[i] = $"level-seed={seed}";
done = true;
break;
}
}
if (!done) lines.Add($"level-seed={seed}");
File.WriteAllLines(PropertiesPath, lines);
}
/// <summary>
/// Apply updates to the file. Keys not in <see cref="EditableKeys"/> are
/// silently dropped. Lines that already exist are updated in-place to
/// preserve order and comments; new keys are appended at the end.
/// </summary>
public void Update(IDictionary<string, string> updates)
{
// Filter to allowed keys only.
var filtered = updates
.Where(kv => EditableKeys.Contains(kv.Key))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
if (filtered.Count == 0) return;
var lines = File.Exists(PropertiesPath)
? File.ReadAllLines(PropertiesPath).ToList()
: new List<string>();
var applied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < lines.Count; i++)
{
var raw = lines[i];
var trimmed = raw.TrimStart();
if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == '!') continue;
var idx = trimmed.IndexOf('=');
if (idx < 0) continue;
var key = trimmed.Substring(0, idx).Trim();
if (filtered.TryGetValue(key, out var newValue))
{
lines[i] = $"{key}={newValue}";
applied.Add(key);
}
}
foreach (var (key, value) in filtered)
{
if (!applied.Contains(key)) lines.Add($"{key}={value}");
}
File.WriteAllLines(PropertiesPath, lines);
}
}