Files
Empostor/src/Empostor.Server/Http/AdminController.cs
T
FangkuaiYa 2fa1a2e060 Merge branch 'main' into anticheat
# Conflicts:
#	marketplace/Empostor.Plugins.PlayerLog.dll
2026-10-02 10:13:44 +08:00

842 lines
28 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Empostor.Api.Admin;
using Empostor.Api.Config;
using Empostor.Api.Games;
using Empostor.Api.Games.Managers;
using Empostor.Api.Net;
using Empostor.Api.Net.Manager;
using Empostor.Server.Http.Admin;
using Empostor.Server.Service.Api;
using Empostor.Server.Service.Shared;
using Empostor.Server.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Empostor.Server.Http
{
[ApiController]
public sealed class AdminController : ControllerBase
{
private static readonly DateTime StartTime = DateTime.UtcNow;
private static Dictionary<string, string>? _adminStrings;
private static readonly object _stringsLock = new();
private const int LoginLockoutThreshold = 5;
private static readonly TimeSpan LoginLockoutDuration = TimeSpan.FromMinutes(15);
private readonly ILogger<AdminController> _logger;
private readonly IGameManager _gameManager;
private readonly IClientManager _clientManager;
private readonly BanStore _bans;
private readonly AdminConfig _config;
private readonly ReportStore _reportStore;
private readonly HplpStore _hplpStore;
private readonly IOptions<ServerConfig> _serverConfig;
private readonly IOptions<HttpServerConfig> _httpServerConfig;
private readonly IpGeolocationService _ipGeo;
private readonly AdminExtensionRegistry _extensions;
private readonly AdminThemeRegistry _themes;
private readonly string _passwordHash;
private static readonly ConcurrentDictionary<string, (int Count, DateTime FirstAttempt)> _loginFailures = new();
public AdminController(
ILogger<AdminController> logger,
IGameManager gameManager,
IClientManager clientManager,
BanStore bans,
IOptions<AdminConfig> config,
ReportStore reportStore,
HplpStore hplpStore,
IOptions<ServerConfig> serverConfig,
IOptions<HttpServerConfig> httpServerConfig,
IpGeolocationService ipGeo,
AdminExtensionRegistry extensions,
AdminThemeRegistry themes)
{
_logger = logger;
_gameManager = gameManager;
_clientManager = clientManager;
_bans = bans;
_config = config.Value;
_reportStore = reportStore;
_hplpStore = hplpStore;
_serverConfig = serverConfig;
_httpServerConfig = httpServerConfig;
_ipGeo = ipGeo;
_extensions = extensions;
_themes = themes;
_passwordHash = ComputeHash(_config.Password);
if (string.IsNullOrEmpty(_config.Password)
|| _config.Password == "CHANGE-ME"
|| _config.Password == "admin123")
{
_logger.LogWarning(
"AdminSECURITY: Default or empty admin password detected! Set a strong password in config.json -> Admin.Password to protect the admin panel.");
}
}
private static Dictionary<string, string> LoadStrings()
{
var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages", "AdminStrings.json");
if (!System.IO.File.Exists(path))
{
return new Dictionary<string, string>();
}
var json = System.IO.File.ReadAllText(path);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json)
?? new Dictionary<string, string>();
}
private bool IsAuthenticated()
=> Request.Cookies.TryGetValue("empostor_admin", out var v)
&& ConstantTimeEquals(v, _passwordHash);
[HttpGet("/admin")]
public IActionResult Panel()
=> Content(IsAuthenticated() ? AdminHtml : LoginHtml, "text/html; charset=utf-8");
[HttpPost("/admin/login")]
public IActionResult Login([FromForm] string password)
{
var ip = GetClientIp()?.ToString() ?? IPAddress.Loopback.ToString();
// Rate-limit check: block IPs that have failed too many times recently
if (_loginFailures.TryGetValue(ip, out var entry))
{
if (entry.Count >= LoginLockoutThreshold
&& DateTime.UtcNow - entry.FirstAttempt < LoginLockoutDuration)
{
var remaining = (int)(LoginLockoutDuration - (DateTime.UtcNow - entry.FirstAttempt)).TotalMinutes;
_logger.LogWarning("AdminRate-limited login attempt from {Ip} (locked out for ~{Min}m more)", ip, Math.Max(1, remaining));
return Content(LoginHtml.Replace("<!--ERR-->",
$"<p style='color:var(--r);margin-top:8px'>Too many failed attempts. Try again in {Math.Max(1, remaining)} minute(s).</p>"),
"text/html; charset=utf-8");
}
// Reset if lockout window has passed
if (DateTime.UtcNow - entry.FirstAttempt >= LoginLockoutDuration)
{
_loginFailures.TryRemove(ip, out _);
}
}
var submittedHash = ComputeHash(password);
if (!ConstantTimeEquals(submittedHash, _passwordHash))
{
_loginFailures.AddOrUpdate(ip,
_ => (1, DateTime.UtcNow),
(_, e) => (e.Count + 1, e.FirstAttempt));
_logger.LogWarning("AdminFailed login from {Ip} (attempt {Count})", ip,
_loginFailures.TryGetValue(ip, out var updated) ? updated.Count : 1);
return Content(LoginHtml.Replace("<!--ERR-->",
"<p style='color:var(--r);margin-top:8px'>Incorrect password.</p>"),
"text/html; charset=utf-8");
}
// Successful login — clear rate-limit entry for this IP
_loginFailures.TryRemove(ip, out _);
Response.Cookies.Append("empostor_admin", _passwordHash, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromHours(8),
});
return Redirect("/admin");
}
[HttpPost("/admin/logout")]
public IActionResult Logout() { Response.Cookies.Delete("empostor_admin"); return Redirect("/admin"); }
[HttpGet("/api/admin/strings")]
public IActionResult GetStrings()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var strings = LoadStrings();
lock (_stringsLock) { _adminStrings = strings; }
return Ok(strings);
}
[HttpPost("/api/admin/strings/reload")]
public IActionResult ReloadStrings()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var strings = LoadStrings();
lock (_stringsLock) { _adminStrings = strings; }
return Ok(strings);
}
[HttpGet("/api/admin/ext")]
public IActionResult GetExtensions()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(_extensions.Extensions.Select(e => new
{
id = e.Id,
title = e.Title,
icon = e.Icon,
section = e.Section,
autoRefresh = e.AutoRefresh,
}));
}
[HttpGet("/api/admin/ext/{id}")]
public IActionResult GetExtension(string id)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var builder = _extensions.Build(id);
if (builder == null)
{
return NotFound(Err("Extension not found"));
}
return Ok(new
{
id = builder.ExtensionId,
title = builder.Title,
icon = builder.Icon,
section = builder.Section,
widgets = builder.Widgets,
});
}
[HttpPost("/api/admin/ext/{id}/{actionName}")]
public async Task<IActionResult> InvokeExtension(string id, string actionName)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var builder = _extensions.Build(id);
if (builder == null)
{
return NotFound(Err("Extension not found"));
}
if (!builder.Actions.TryGetValue(actionName, out var handler))
{
return NotFound(Err("Action not found"));
}
string? value = null;
JsonElement? payload = null;
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
payload = doc.RootElement.Clone();
if (payload.Value.ValueKind == JsonValueKind.Object
&& payload.Value.TryGetProperty("value", out var raw))
{
value = raw.ValueKind switch
{
JsonValueKind.String => raw.GetString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => raw.GetRawText(),
_ => null,
};
}
}
catch (JsonException)
{
// No/invalid body; the action still runs without a value.
}
var context = new AdminActionContext(builder.ExtensionId, actionName, value, payload, HttpContext.RequestServices);
try
{
var result = await handler(context);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Admin extension action '{Id}/{Action}' failed", id, actionName);
return Ok(AdminActionResult.Fail($"Action failed: {ex.Message}"));
}
}
[HttpGet("/api/admin/themes")]
public IActionResult GetThemes()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
// Pick up themes dropped into Pages/themes without requiring a restart.
_themes.Rescan();
return Ok(new
{
themes = _themes.Themes,
@default = _config.Theme,
defaultMode = _config.ThemeMode,
});
}
[HttpGet("/api/admin/theme/{id}/css")]
public IActionResult GetThemeCss(string id)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (!_themes.Exists(id))
{
return NotFound();
}
return Content(_themes.BuildCss(id), "text/css; charset=utf-8");
}
[HttpGet("/api/admin/status")]
public IActionResult Status()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var up = DateTime.UtcNow - StartTime;
var games = _gameManager.Games.ToList();
var (ib, fb) = _bans.Stats();
return Ok(new
{
uptime = Fmt(up),
uptimeSeconds = (long)up.TotalSeconds,
startTime = StartTime.ToString("yyyy-MM-dd HH:mm:ss") + " UTC",
totalGames = games.Count,
totalPlayers = _clientManager.Clients.Count(),
publicGames = games.Count(g => g.IsPublic),
activeGames = games.Count(g => g.GameState == GameStates.Started),
bannedIps = ib,
bannedFriendCodes = fb,
runtime = RuntimeInformation.FrameworkDescription,
os = RuntimeInformation.OSDescription,
pid = Environment.ProcessId,
});
}
[HttpGet("/api/admin/games")]
public IActionResult GetGames([FromQuery] string? state = null)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var games = _gameManager.Games.AsEnumerable();
if (!string.IsNullOrWhiteSpace(state)
&& Enum.TryParse<GameStates>(state, ignoreCase: true, out var filterState))
{
games = games.Where(g => g.GameState == filterState);
}
return Ok(games.Select(Snap));
}
[HttpGet("/api/admin/clients")]
public async Task<IActionResult> GetClients()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var clients = _clientManager.Clients.ToList();
var snaps = new List<object>(clients.Count);
foreach (var c in clients)
{
var ip = c.Connection?.EndPoint?.Address;
var location = await _ipGeo.GetLocationAsync(ip);
snaps.Add(CSnap(c, location));
}
return Ok(snaps);
}
[HttpGet("/api/admin/bans")]
public IActionResult GetBans()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new { ips = _bans.AllIpBans(), friendCodes = _bans.AllFriendCodeBans() });
}
[HttpPost("/api/admin/broadcast")]
public async Task<IActionResult> Broadcast([FromBody] BroadcastReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (string.IsNullOrWhiteSpace(req.Message))
{
return BadRequest(Err("Message required"));
}
var sent = 0;
foreach (var g in _gameManager.Games)
{
var host = g.Host?.Character;
if (host != null) { await host.SendChatAsync($"[Server] {req.Message}"); sent++; }
}
return Ok(new { sent });
}
[HttpPost("/api/admin/message")]
public async Task<IActionResult> GameMessage([FromBody] GameMsgReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var game = FindGame(req.GameCode);
if (game == null)
{
return NotFound(Err($"Game '{req.GameCode}' not found"));
}
var host = game.Host?.Character;
if (host == null)
{
return BadRequest(Err("No host character"));
}
await host.SendChatAsync($"Admin{req.Message}");
return Ok(new { ok = true });
}
[HttpPost("/api/admin/kick")]
public async Task<IActionResult> Kick([FromBody] ClientIdReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var c = FindClient(req.ClientId);
if (c == null)
{
return NotFound(Err($"Client {req.ClientId} not found"));
}
await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Kicked by admin");
if (c.Player != null)
{
await c.Player.KickAsync();
}
return Ok(new { kicked = true, name = c.Name });
}
[HttpPost("/api/admin/ban/ip")]
public async Task<IActionResult> BanIp([FromBody] BanIpReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (!IPAddress.TryParse(req.Ip, out var ip))
{
return BadRequest(Err("Invalid IP"));
}
var entry = _bans.BanIp(ip, req.Reason ?? "Banned by admin", BanStore.ParseDuration(req.Duration));
var kicked = 0;
foreach (var c in _clientManager.Clients.ToList())
{
var cip = c.Connection?.EndPoint?.Address;
if (cip != null && Norm(cip) == Norm(ip))
{
await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Banned by admin");
if (c.Player != null)
{
await c.Player.BanAsync();
}
kicked++;
}
}
return Ok(new { banned = entry, disconnected = kicked });
}
[HttpPost("/api/admin/ban/fc")]
public async Task<IActionResult> BanFc([FromBody] BanFcReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (string.IsNullOrWhiteSpace(req.FriendCode))
{
return BadRequest(Err("FriendCode required"));
}
var entry = _bans.BanFriendCode(req.FriendCode, req.Reason ?? "Banned by admin", BanStore.ParseDuration(req.Duration));
var kicked = 0;
foreach (var c in _clientManager.Clients.ToList())
{
if (c.FriendCode == req.FriendCode)
{
await c.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Banned by admin");
if (c.Player != null)
{
await c.Player.BanAsync();
}
kicked++;
}
}
return Ok(new { banned = entry, disconnected = kicked });
}
[HttpPost("/api/admin/unban/ip")]
public IActionResult UnbanIp([FromBody] UnbanReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (!IPAddress.TryParse(req.Value, out var ip))
{
return BadRequest(Err("Invalid IP"));
}
return Ok(new { removed = _bans.UnbanIp(ip) });
}
[HttpPost("/api/admin/unban/fc")]
public IActionResult UnbanFc([FromBody] UnbanReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new { removed = _bans.UnbanFriendCode(req.Value) });
}
[HttpPost("/api/admin/game/end")]
public async Task<IActionResult> EndGame([FromBody] GameCodeReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var g = FindGame(req.GameCode);
if (g == null)
{
return NotFound(Err($"Game '{req.GameCode}' not found"));
}
var players = g.Players.ToList();
foreach (var p in players)
{
await p.Client.DisconnectAsync(DisconnectReason.Custom, req.Reason ?? "Game ended by admin");
await p.KickAsync();
}
return Ok(new { ended = req.GameCode, playersKicked = players.Count });
}
[HttpPost("/api/admin/game/public")]
public async Task<IActionResult> SetPublic([FromBody] GamePublicReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
var g = FindGame(req.GameCode);
if (g == null)
{
return NotFound(Err($"Game '{req.GameCode}' not found"));
}
await g.SetPrivacyAsync(req.IsPublic);
return Ok(new { gameCode = req.GameCode, isPublic = req.IsPublic });
}
[HttpGet("/api/admin/reports")]
public IActionResult GetReports()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(_reportStore.GetRecent(200).Select(r => new
{
time = r.Time.ToString("yyyy-MM-dd HH:mm:ss"),
gameCode = r.GameCode,
reporterName = r.ReporterName,
reporterFc = r.ReporterFriendCode ?? "—",
reportedName = r.ReportedName ?? "—",
reportedFc = r.ReportedFriendCode ?? "—",
reason = r.Reason.ToString(),
outcome = r.Outcome.ToString(),
}));
}
[HttpGet("/api/admin/hplp")]
public IActionResult GetHplp()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new
{
enabled = _hplpStore.Enabled,
regionId = _hplpStore.RegionId,
regionName = _hplpStore.RegionName,
publicUrl = _hplpStore.PublicUrl,
});
}
[HttpPost("/api/admin/hplp")]
public async Task<IActionResult> UpdateHplp([FromBody] HplpSettingsReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (req.Enabled.HasValue)
{
_hplpStore.Enabled = req.Enabled.Value;
}
if (req.RegionId != null)
{
_hplpStore.RegionId = req.RegionId;
}
if (req.RegionName != null)
{
_hplpStore.RegionName = req.RegionName;
}
if (req.PublicUrl != null)
{
_hplpStore.PublicUrl = req.PublicUrl;
}
await _hplpStore.SaveAsync();
return Ok(new
{
enabled = _hplpStore.Enabled,
regionId = _hplpStore.RegionId,
regionName = _hplpStore.RegionName,
publicUrl = _hplpStore.PublicUrl,
});
}
private IGame? FindGame(string code)
{
try { return _gameManager.Find(new GameCode(code.ToUpperInvariant())); }
catch { return null; }
}
private IClient? FindClient(int id) => _clientManager.Clients.FirstOrDefault(c => c.Id == id);
private static object Snap(IGame g) => new
{
code = GameCodeParser.IntToGameName(g.Code),
state = g.GameState.ToString(),
isPublic = g.IsPublic,
playerCount = g.PlayerCount,
maxPlayers = g.Options.MaxPlayers,
map = g.Options.Map.ToString(),
impostors = g.Options.NumImpostors,
host = g.Host?.Client.Name ?? "—",
hostFc = g.Host?.Client.FriendCode ?? "—",
players = g.Players.Select(p => new
{
id = p.Client.Id,
name = p.Client.Name,
friendCode = p.Client.FriendCode ?? "—",
isHost = p.IsHost,
platform = p.Client.PlatformSpecificData?.Platform.ToString() ?? "Unknown",
ip = p.Client.Connection?.EndPoint?.Address?.ToString() ?? "—",
}).ToList(),
};
private static object CSnap(IClient c, string location = "")
{
var reactor = c.GetReactorMods();
var level = c.Player?.Character?.PlayerInfo?.PlayerLevel;
return new
{
id = c.Id,
name = c.Name,
friendCode = c.FriendCode ?? "—",
gameVersion = c.GameVersion.ToString(),
platform = c.PlatformSpecificData?.Platform.ToString() ?? "Unknown",
platformName = c.PlatformSpecificData?.PlatformName ?? string.Empty,
language = LanguageHelper.GetDisplayName(c.Language),
level = level is null or 0 or uint.MaxValue ? (int?)null : (int)level.Value,
inGame = c.Player != null,
gameCode = c.Player != null ? GameCodeParser.IntToGameName(c.Player.Game.Code) : "—",
ip = c.Connection?.EndPoint?.Address?.ToString() ?? "—",
location = location,
reactor = reactor == null ? null : new
{
protocolVersion = reactor.ProtocolVersion,
mods = System.Linq.Enumerable.Select(reactor.Mods, m => new
{
id = m.Id,
version = m.Version,
required = m.RequiredOnAllClients,
}).ToArray(),
},
};
}
private static string Norm(IPAddress ip)
=> ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4().ToString() : ip.ToString();
private IPAddress? GetClientIp()
{
// CDN / reverse proxy headers first so we get the real client IP,
// not the CDN/nginx IP. Using Connection.RemoteIpAddress directly
// would rate-limit or ban the proxy instead of the actual user.
return RealIpResolver.Resolve(HttpContext);
}
private static object Err(string msg) => new { error = msg };
private static string Fmt(TimeSpan t)
{
if (t.TotalDays >= 1)
{
return $"{(int)t.TotalDays}d {t.Hours}h {t.Minutes}m";
}
if (t.TotalHours >= 1)
{
return $"{t.Hours}h {t.Minutes}m {t.Seconds}s";
}
return $"{t.Minutes}m {t.Seconds}s";
}
public sealed record BroadcastReq(string Message);
public sealed record GameMsgReq(string GameCode, string Message);
public sealed record ClientIdReq(int ClientId, string? Reason = null);
public sealed record BanIpReq(string Ip, string? Reason, string? Duration = null);
public sealed record BanFcReq(string FriendCode, string? Reason, string? Duration = null);
public sealed record UnbanReq(string Value);
public sealed record GameCodeReq(string GameCode, string? Reason = null);
public sealed record GamePublicReq(string GameCode, bool IsPublic);
public sealed record HplpSettingsReq(bool? Enabled, string? RegionId, string? RegionName, string? PublicUrl);
// HTML pages are auto-generated to BaseDirectory/Pages/ on first access.
// Server operators can customize them by editing the files on disk.
private static string? _loginHtml;
private static string? _adminHtml;
private string LoginHtml => _loginHtml ??= GetPage("login.html", AdminTemplateDefaults.LoginHtml);
private string AdminHtml => _adminHtml ??= GetPage("admin.html", AdminTemplateDefaults.AdminHtml);
private static string GetPage(string fileName, string defaultContent)
{
try
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Pages");
var fullPath = Path.Combine(dir, fileName);
if (!System.IO.File.Exists(fullPath))
{
Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(fullPath, defaultContent);
}
return System.IO.File.ReadAllText(fullPath);
}
catch
{
return defaultContent;
}
}
internal static string ComputeHash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes);
}
internal static bool ConstantTimeEquals(string a, string b)
{
if (a.Length != b.Length)
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(a),
Encoding.UTF8.GetBytes(b));
}
}
}