using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; using Content.Server.Administration.Systems; using Content.Server.GameTicking; using Content.Server.GameTicking.Presets; using Content.Server.GameTicking.Rules.Components; using Content.Server.Maps; using Content.Server.RoundEnd; using Content.Shared.Administration.Managers; using Content.Shared.CCVar; using Content.Shared.GameTicking.Components; using Content.Shared.Prototypes; using Robust.Server.ServerStatus; using Robust.Shared.Asynchronous; using Robust.Shared.Configuration; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Utility; namespace Content.Server.Administration; /// /// Exposes various admin-related APIs via the game server's . /// public sealed partial class ServerApi : IPostInjectInit { private const string SS14TokenScheme = "SS14Token"; private static readonly HashSet PanicBunkerCVars = [ CCVars.PanicBunkerEnabled.Name, CCVars.PanicBunkerDisableWithAdmins.Name, CCVars.PanicBunkerEnableWithoutAdmins.Name, CCVars.PanicBunkerCountDeadminnedAdmins.Name, CCVars.PanicBunkerShowReason.Name, CCVars.PanicBunkerMinAccountAge.Name, CCVars.PanicBunkerMinOverallHours.Name, CCVars.PanicBunkerCustomReason.Name, ]; [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IConfigurationManager _config = default!; [Dependency] private readonly ISharedPlayerManager _playerManager = default!; [Dependency] private readonly ISharedAdminManager _adminManager = default!; [Dependency] private readonly IGameMapManager _gameMapManager = default!; [Dependency] private readonly IServerNetManager _netManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IComponentFactory _componentFactory = default!; [Dependency] private readonly ITaskManager _taskManager = default!; [Dependency] private readonly EntityManager _entityManager = default!; [Dependency] private readonly ILogManager _logManager = default!; [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; [Dependency] private readonly ILocalizationManager _loc = default!; private string _token = string.Empty; private ISawmill _sawmill = default!; void IPostInjectInit.PostInject() { _sawmill = _logManager.GetSawmill("serverApi"); // Get RegisterActorHandler(HttpMethod.Get, "/admin/info", InfoHandler); RegisterHandler(HttpMethod.Get, "/admin/game_rules", GetGameRules); RegisterHandler(HttpMethod.Get, "/admin/presets", GetPresets); // Post RegisterActorHandler(HttpMethod.Post, "/admin/actions/round/start", ActionRoundStart); RegisterActorHandler(HttpMethod.Post, "/admin/actions/round/end", ActionRoundEnd); RegisterActorHandler(HttpMethod.Post, "/admin/actions/round/restartnow", ActionRoundRestartNow); RegisterActorHandler(HttpMethod.Post, "/admin/actions/kick", ActionKick); RegisterActorHandler(HttpMethod.Post, "/admin/actions/add_game_rule", ActionAddGameRule); RegisterActorHandler(HttpMethod.Post, "/admin/actions/end_game_rule", ActionEndGameRule); RegisterActorHandler(HttpMethod.Post, "/admin/actions/force_preset", ActionForcePreset); RegisterActorHandler(HttpMethod.Post, "/admin/actions/set_motd", ActionForceMotd); RegisterActorHandler(HttpMethod.Patch, "/admin/actions/panic_bunker", ActionPanicPunker); } public void Initialize() { _config.OnValueChanged(CCVars.AdminApiToken, UpdateToken, true); } public void Shutdown() { _config.UnsubValueChanged(CCVars.AdminApiToken, UpdateToken); } private void UpdateToken(string token) { _token = token; } #region Actions /// /// Changes the panic bunker settings. /// private async Task ActionPanicPunker(IStatusHandlerContext context, Actor actor) { var request = await ReadJson(context); if (request == null) return; var toSet = new Dictionary(); foreach (var (cVar, value) in request) { if (!PanicBunkerCVars.Contains(cVar)) { await RespondBadRequest(context, $"Invalid panic bunker CVar: '{cVar}'"); return; } if (value == null) { await RespondBadRequest(context, $"Value is null: '{cVar}'"); return; } if (value is not JsonValue jsonValue) { await RespondBadRequest(context, $"Value is not valid: '{cVar}'"); return; } object castValue; var cVarType = _config.GetCVarType(cVar); if (cVarType == typeof(bool)) { if (!jsonValue.TryGetValue(out bool b)) { await RespondBadRequest(context, $"CVar '{cVar}' must be of type bool."); return; } castValue = b; } else if (cVarType == typeof(int)) { if (!jsonValue.TryGetValue(out int i)) { await RespondBadRequest(context, $"CVar '{cVar}' must be of type int."); return; } castValue = i; } else if (cVarType == typeof(string)) { if (!jsonValue.TryGetValue(out string? s)) { await RespondBadRequest(context, $"CVar '{cVar}' must be of type string."); return; } castValue = s; } else { throw new NotSupportedException("Unsupported CVar type"); } toSet[cVar] = castValue; } await RunOnMainThread(() => { foreach (var (cVar, value) in toSet) { _config.SetCVar(cVar, value); _sawmill.Info( $"Panic bunker property '{cVar}' changed to '{value}' by {FormatLogActor(actor)}."); } }); await RespondOk(context); } /// /// Sets the current MOTD. /// private async Task ActionForceMotd(IStatusHandlerContext context, Actor actor) { var motd = await ReadJson(context); if (motd == null) return; _sawmill.Info($"MOTD changed to \"{motd.Motd}\" by {FormatLogActor(actor)}."); await RunOnMainThread(() => _config.SetCVar(CCVars.MOTD, motd.Motd)); // A hook in the MOTD system sends the changes to each client await RespondOk(context); } /// /// Forces the next preset- /// private async Task ActionForcePreset(IStatusHandlerContext context, Actor actor) { var body = await ReadJson(context); if (body == null) return; await RunOnMainThread(async () => { var ticker = _entitySystemManager.GetEntitySystem(); if (ticker.RunLevel != GameRunLevel.PreRoundLobby) { await RespondError( context, ErrorCode.InvalidRoundState, HttpStatusCode.Conflict, "Game must be in pre-round lobby"); return; } var preset = ticker.FindGamePreset(body.PresetId); if (preset == null) { await RespondError( context, ErrorCode.GameRuleNotFound, HttpStatusCode.UnprocessableContent, $"Game rule '{body.PresetId}' doesn't exist"); return; } ticker.SetGamePreset(preset); _sawmill.Info($"Forced the game to start with preset {body.PresetId} by {FormatLogActor(actor)}."); await RespondOk(context); }); } /// /// Ends an active game rule. /// private async Task ActionEndGameRule(IStatusHandlerContext context, Actor actor) { var body = await ReadJson(context); if (body == null) return; await RunOnMainThread(async () => { var ticker = _entitySystemManager.GetEntitySystem(); var gameRule = ticker .GetActiveGameRules() .FirstOrNull(rule => _entityManager.MetaQuery.GetComponent(rule).EntityPrototype?.ID == body.GameRuleId); if (gameRule == null) { await RespondError(context, ErrorCode.GameRuleNotFound, HttpStatusCode.UnprocessableContent, $"Game rule '{body.GameRuleId}' not found or not active"); return; } _sawmill.Info($"Ended game rule {body.GameRuleId} by {FormatLogActor(actor)}."); ticker.EndGameRule(gameRule.Value); await RespondOk(context); }); } /// /// Adds a game rule to the current round. /// private async Task ActionAddGameRule(IStatusHandlerContext context, Actor actor) { var body = await ReadJson(context); if (body == null) return; await RunOnMainThread(async () => { var ticker = _entitySystemManager.GetEntitySystem(); if (!_prototypeManager.HasIndex(body.GameRuleId)) { await RespondError(context, ErrorCode.GameRuleNotFound, HttpStatusCode.UnprocessableContent, $"Game rule '{body.GameRuleId}' not found or not active"); return; } var ruleEntity = ticker.AddGameRule(body.GameRuleId); _sawmill.Info($"Added game rule {body.GameRuleId} by {FormatLogActor(actor)}."); if (ticker.RunLevel == GameRunLevel.InRound) { ticker.StartGameRule(ruleEntity); _sawmill.Info($"Started game rule {body.GameRuleId} by {FormatLogActor(actor)}."); } await RespondOk(context); }); } /// /// Kicks a player. /// private async Task ActionKick(IStatusHandlerContext context, Actor actor) { var body = await ReadJson(context); if (body == null) return; await RunOnMainThread(async () => { if (!_playerManager.TryGetSessionById(new NetUserId(body.Guid), out var player)) { await RespondError( context, ErrorCode.PlayerNotFound, HttpStatusCode.UnprocessableContent, "Player not found"); return; } var reason = body.Reason ?? "No reason supplied"; reason += " (kicked by admin)"; _netManager.DisconnectChannel(player.Channel, reason); await RespondOk(context); _sawmill.Info($"Kicked player {player.Name} ({player.UserId}) for {reason} by {FormatLogActor(actor)}"); }); } private async Task ActionRoundStart(IStatusHandlerContext context, Actor actor) { await RunOnMainThread(async () => { var ticker = _entitySystemManager.GetEntitySystem(); if (ticker.RunLevel != GameRunLevel.PreRoundLobby) { await RespondError( context, ErrorCode.InvalidRoundState, HttpStatusCode.Conflict, "Round already started"); return; } ticker.StartRound(); _sawmill.Info($"Forced round start by {FormatLogActor(actor)}"); await RespondOk(context); }); } private async Task ActionRoundEnd(IStatusHandlerContext context, Actor actor) { await RunOnMainThread(async () => { var roundEndSystem = _entitySystemManager.GetEntitySystem(); var ticker = _entitySystemManager.GetEntitySystem(); if (ticker.RunLevel != GameRunLevel.InRound) { await RespondError( context, ErrorCode.InvalidRoundState, HttpStatusCode.Conflict, "Round is not active"); return; } roundEndSystem.EndRound(); _sawmill.Info($"Forced round end by {FormatLogActor(actor)}"); await RespondOk(context); }); } private async Task ActionRoundRestartNow(IStatusHandlerContext context, Actor actor) { await RunOnMainThread(async () => { var ticker = _entitySystemManager.GetEntitySystem(); ticker.RestartRound(); _sawmill.Info($"Forced instant round restart by {FormatLogActor(actor)}"); await RespondOk(context); }); } #endregion #region Fetching /// /// Returns an array containing all available presets. /// private async Task GetPresets(IStatusHandlerContext context) { var presets = await RunOnMainThread(() => { var presets = new List(); foreach (var preset in _prototypeManager.EnumeratePrototypes()) { presets.Add(new PresetResponse.Preset { Id = preset.ID, ModeTitle = _loc.GetString(preset.ModeTitle), Description = _loc.GetString(preset.Description) }); } return presets; }); await context.RespondJsonAsync(new PresetResponse { Presets = presets }); } /// /// Returns an array containing all game rules. /// private async Task GetGameRules(IStatusHandlerContext context) { var gameRules = new List(); foreach (var gameRule in _prototypeManager.EnumeratePrototypes()) { if (gameRule.Abstract) continue; if (gameRule.HasComponent(_componentFactory)) gameRules.Add(gameRule.ID); } await context.RespondJsonAsync(new GameruleResponse { GameRules = gameRules }); } /// /// Handles fetching information. /// private async Task InfoHandler(IStatusHandlerContext context, Actor actor) { /* Information to display Round number Connected players Active admins Active game rules Active game preset Active map MOTD Panic bunker status */ var info = await RunOnMainThread(() => { var ticker = _entitySystemManager.GetEntitySystem(); var adminSystem = _entitySystemManager.GetEntitySystem(); var players = new List(); foreach (var player in _playerManager.Sessions) { var adminData = _adminManager.GetAdminData(player, true); players.Add(new InfoResponse.Player { UserId = player.UserId.UserId, Name = player.Name, IsAdmin = adminData != null, IsDeadminned = !adminData?.Active ?? false }); } InfoResponse.MapInfo? mapInfo = null; if (_gameMapManager.GetSelectedMap() is { } mapPrototype) { mapInfo = new InfoResponse.MapInfo { Id = mapPrototype.ID, Name = mapPrototype.MapName }; } var gameRules = new List(); foreach (var addedGameRule in ticker.GetActiveGameRules()) { var meta = _entityManager.MetaQuery.GetComponent(addedGameRule); gameRules.Add(meta.EntityPrototype?.ID ?? meta.EntityPrototype?.Name ?? "Unknown"); } var panicBunkerCVars = PanicBunkerCVars.ToDictionary(c => c, c => _config.GetCVar(c)); return new InfoResponse { Players = players, RoundId = ticker.RoundId, Map = mapInfo, PanicBunker = panicBunkerCVars, GamePreset = ticker.CurrentPreset?.ID, GameRules = gameRules, MOTD = _config.GetCVar(CCVars.MOTD) }; }); await context.RespondJsonAsync(info); } #endregion private async Task CheckAccess(IStatusHandlerContext context) { var auth = context.RequestHeaders.TryGetValue("Authorization", out var authToken); if (!auth) { await RespondError( context, ErrorCode.AuthenticationNeeded, HttpStatusCode.Unauthorized, "Authorization is required"); return false; } var authHeaderValue = authToken.ToString(); var spaceIndex = authHeaderValue.IndexOf(' '); if (spaceIndex == -1) { await RespondBadRequest(context, "Invalid Authorization header value"); return false; } var authScheme = authHeaderValue[..spaceIndex]; var authValue = authHeaderValue[spaceIndex..].Trim(); if (authScheme != SS14TokenScheme) { await RespondBadRequest(context, "Invalid Authorization scheme"); return false; } if (_token == "") { _sawmill.Debug("No authorization token set for admin API"); } else if (CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(authValue), Encoding.UTF8.GetBytes(_token))) { return true; } await RespondError( context, ErrorCode.AuthenticationInvalid, HttpStatusCode.Unauthorized, "Authorization is invalid"); // Invalid auth header, no access _sawmill.Info($"Unauthorized access attempt to admin API from {context.RemoteEndPoint}"); return false; } private async Task CheckActor(IStatusHandlerContext context) { // The actor is JSON encoded in the header var actor = context.RequestHeaders.TryGetValue("Actor", out var actorHeader) ? actorHeader.ToString() : null; if (actor == null) { await RespondBadRequest(context, "Actor must be supplied"); return null; } Actor? actorData; try { actorData = JsonSerializer.Deserialize(actor); if (actorData == null) { await RespondBadRequest(context, "Actor is null"); return null; } } catch (JsonException exception) { await RespondBadRequest(context, "Actor field JSON is invalid", ExceptionData.FromException(exception)); return null; } return actorData; } #region From Client private sealed class Actor { public required Guid Guid { get; init; } public required string Name { get; init; } } private sealed class KickActionBody { public required Guid Guid { get; init; } public string? Reason { get; init; } } private sealed class GameRuleActionBody { public required string GameRuleId { get; init; } } private sealed class PresetActionBody { public required string PresetId { get; init; } } private sealed class MotdActionBody { public required string Motd { get; init; } } #endregion #region Responses private record BaseResponse( string Message, ErrorCode ErrorCode = ErrorCode.None, ExceptionData? Exception = null); private record ExceptionData(string Message, string? StackTrace = null) { public static ExceptionData FromException(Exception e) { return new ExceptionData(e.Message, e.StackTrace); } } private enum ErrorCode { None = 0, AuthenticationNeeded = 1, AuthenticationInvalid = 2, InvalidRoundState = 3, PlayerNotFound = 4, GameRuleNotFound = 5, BadRequest = 6, } #endregion #region Misc /// /// Record used to send the response for the info endpoint. /// private sealed class InfoResponse { public required int RoundId { get; init; } public required List Players { get; init; } public required List GameRules { get; init; } public required string? GamePreset { get; init; } public required MapInfo? Map { get; init; } public required string? MOTD { get; init; } public required Dictionary PanicBunker { get; init; } public sealed class Player { public required Guid UserId { get; init; } public required string Name { get; init; } public required bool IsAdmin { get; init; } public required bool IsDeadminned { get; init; } } public sealed class MapInfo { public required string Id { get; init; } public required string Name { get; init; } } } private sealed class PresetResponse { public required List Presets { get; init; } public sealed class Preset { public required string Id { get; init; } public required string Description { get; init; } public required string ModeTitle { get; init; } } } private sealed class GameruleResponse { public required List GameRules { get; init; } } #endregion }