Web panel (like txAdmin) on the game port: login/roles/users, players, kick/ban/op, bans, live console, world reset/backups/restore, settings editor, save ZIP upload/download, start/stop/restart; in-process restart loop; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MSCO.Server;
|
||||
|
||||
/// <summary>Rollen im Web-Panel: Moderator (Spieler/Chat), Admin (alles am Server), Owner (zusätzlich Benutzer).</summary>
|
||||
public enum PanelRole { Moderator = 1, Admin = 2, Owner = 3 }
|
||||
|
||||
/// <summary>
|
||||
/// Benutzer des Web-Panels in data/panel_users.json. Passwörter nur als PBKDF2-SHA256-Hash mit eigenem Salz.
|
||||
/// </summary>
|
||||
public sealed class PanelUsers
|
||||
{
|
||||
public sealed class User
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public PanelRole Role { get; set; }
|
||||
public string Salt { get; set; }
|
||||
public string Hash { get; set; }
|
||||
public int Iterations { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime? LastLoginUtc { get; set; }
|
||||
}
|
||||
|
||||
sealed class FileData { public List<User> Users { get; set; } = new(); }
|
||||
|
||||
const int Iterations = 210_000;
|
||||
static readonly Regex ValidName = new("^[A-Za-z0-9_.-]{3,32}$", RegexOptions.Compiled);
|
||||
static readonly JsonSerializerOptions Options = new() { WriteIndented = true };
|
||||
|
||||
readonly string path;
|
||||
readonly object gate = new();
|
||||
List<User> users;
|
||||
|
||||
public PanelUsers(string path)
|
||||
{
|
||||
this.path = path;
|
||||
users = File.Exists(path) ? JsonSerializer.Deserialize<FileData>(File.ReadAllText(path), Options)?.Users ?? new() : new();
|
||||
}
|
||||
|
||||
public bool Empty { get { lock (gate) return users.Count == 0; } }
|
||||
|
||||
public List<User> All() { lock (gate) return users.ToList(); }
|
||||
|
||||
public User Find(string name)
|
||||
{
|
||||
lock (gate) return users.FirstOrDefault(u => string.Equals(u.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static string CheckName(string name) =>
|
||||
name != null && ValidName.IsMatch(name) ? null : "Username: 3-32 characters (letters, numbers, _ . -)";
|
||||
|
||||
public static string CheckPassword(string password) =>
|
||||
password != null && password.Length >= 8 && password.Length <= 200 ? null : "Password: at least 8 characters";
|
||||
|
||||
/// <summary>Neuen Benutzer anlegen. Gibt einen Fehler zurück oder null.</summary>
|
||||
public string Add(string name, string password, PanelRole role)
|
||||
{
|
||||
string error = CheckName(name) ?? CheckPassword(password);
|
||||
if (error != null) return error;
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(name) != null) return "A user with this name already exists";
|
||||
var user = new User { Name = name, Role = role, CreatedUtc = DateTime.UtcNow };
|
||||
SetPasswordHash(user, password);
|
||||
users.Add(user);
|
||||
Save();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Benutzer prüfen. Gibt ihn bei richtigem Passwort zurück, sonst null.</summary>
|
||||
public User Verify(string name, string password)
|
||||
{
|
||||
User user = Find(name ?? "");
|
||||
if (user == null || password == null)
|
||||
{
|
||||
// Gleich lange rechnen wie bei einem echten Benutzer (kein Rückschluss, ob es den Namen gibt)
|
||||
Rfc2898DeriveBytes.Pbkdf2(password ?? "", new byte[16], Iterations, HashAlgorithmName.SHA256, 32);
|
||||
return null;
|
||||
}
|
||||
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), Convert.FromBase64String(user.Salt),
|
||||
user.Iterations, HashAlgorithmName.SHA256, 32);
|
||||
if (!CryptographicOperations.FixedTimeEquals(hash, Convert.FromBase64String(user.Hash))) return null;
|
||||
lock (gate)
|
||||
{
|
||||
user.LastLoginUtc = DateTime.UtcNow;
|
||||
Save();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
public string SetPassword(string name, string password)
|
||||
{
|
||||
string error = CheckPassword(password);
|
||||
if (error != null) return error;
|
||||
lock (gate)
|
||||
{
|
||||
User user = Find(name);
|
||||
if (user == null) return "User not found";
|
||||
SetPasswordHash(user, password);
|
||||
Save();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public string SetRole(string name, PanelRole role)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
User user = Find(name);
|
||||
if (user == null) return "User not found";
|
||||
if (user.Role == PanelRole.Owner && role != PanelRole.Owner && users.Count(u => u.Role == PanelRole.Owner) == 1)
|
||||
return "There must be at least one owner";
|
||||
user.Role = role;
|
||||
Save();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public string Remove(string name)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
User user = Find(name);
|
||||
if (user == null) return "User not found";
|
||||
if (user.Role == PanelRole.Owner && users.Count(u => u.Role == PanelRole.Owner) == 1)
|
||||
return "The last owner cannot be deleted";
|
||||
users.Remove(user);
|
||||
Save();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void SetPasswordHash(User user, string password)
|
||||
{
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||
user.Salt = Convert.ToBase64String(salt);
|
||||
user.Iterations = Iterations;
|
||||
user.Hash = Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, 32));
|
||||
}
|
||||
|
||||
void Save()
|
||||
{
|
||||
string tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(new FileData { Users = users }, Options));
|
||||
if (!OperatingSystem.IsWindows())
|
||||
try { File.SetUnixFileMode(tmp, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
|
||||
File.Move(tmp, path, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user