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.Serialization; 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.Events; using Content.Shared.Administration.Managers; using Content.Shared.CCVar; 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; public sealed class ServerApi : IPostInjectInit { [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IConfigurationManager _config = default!; [Dependency] private readonly ISharedPlayerManager _playerManager = default!; // Players [Dependency] private readonly ISharedAdminManager _adminManager = default!; // Admins [Dependency] private readonly IGameMapManager _gameMapManager = default!; // Map name [Dependency] private readonly IServerNetManager _netManager = default!; // Kick [Dependency] private readonly IPrototypeManager _prototypeManager = default!; // Game rules [Dependency] private readonly IComponentFactory _componentFactory = default!; [Dependency] private readonly ITaskManager _taskManager = default!; // game explodes when calling stuff from the non-game thread [Dependency] private readonly EntityManager _entityManager = default!; [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; private string _token = string.Empty; private ISawmill _sawmill = default!; public static Dictionary PanicPunkerCvarNames = new() { { "Enabled", "game.panic_bunker.enabled" }, { "DisableWithAdmins", "game.panic_bunker.disable_with_admins" }, { "EnableWithoutAdmins", "game.panic_bunker.enable_without_admins" }, { "CountDeadminnedAdmins", "game.panic_bunker.count_deadminned_admins" }, { "ShowReason", "game.panic_bunker.show_reason" }, { "MinAccountAgeHours", "game.panic_bunker.min_account_age" }, { "MinOverallHours", "game.panic_bunker.min_overall_hours" }, { "CustomReason", "game.panic_bunker.custom_reason" } }; void IPostInjectInit.PostInject() { _sawmill = Logger.GetSawmill("serverApi"); // Get _statusHost.AddHandler(InfoHandler); _statusHost.AddHandler(GetGameRules); _statusHost.AddHandler(GetForcePresets); // Post _statusHost.AddHandler(ActionRoundStatus); _statusHost.AddHandler(ActionKick); _statusHost.AddHandler(ActionAddGameRule); _statusHost.AddHandler(ActionEndGameRule); _statusHost.AddHandler(ActionForcePreset); _statusHost.AddHandler(ActionForceMotd); _statusHost.AddHandler(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) { if (context.RequestMethod != HttpMethod.Patch || context.Url.AbsolutePath != "/admin/actions/panic_bunker") { return false; } if (!CheckAccess(context)) return true; var body = await ReadJson>(context); var (success, actor) = await CheckActor(context); if (!success) return true; foreach (var panicPunkerActions in body!.Select(x => new { Action = x.Key, Value = x.Value.ToString() })) { if (panicPunkerActions.Action == null || panicPunkerActions.Value == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "Action and value are required to perform this action.", Exception = new ExceptionData() { Message = "Action and value are required to perform this action.", ErrorType = ErrorTypes.ActionNotSpecified } }, HttpStatusCode.BadRequest); return true; } if (!PanicPunkerCvarNames.TryGetValue(panicPunkerActions.Action, out var cvarName)) { await context.RespondJsonAsync(new BaseResponse() { Message = $"Cannot set: Action {panicPunkerActions.Action} does not exist.", Exception = new ExceptionData() { Message = $"Cannot set: Action {panicPunkerActions.Action} does not exist.", ErrorType = ErrorTypes.ActionNotSupported } }, HttpStatusCode.BadRequest); return true; } // Since the CVar can be of different types, we need to parse it to the correct type // First, I try to parse it as a bool, if it fails, I try to parse it as an int // And as a last resort, I do nothing and put it as a string if (bool.TryParse(panicPunkerActions.Value, out var boolValue)) { await RunOnMainThread(() => _config.SetCVar(cvarName, boolValue)); } else if (int.TryParse(panicPunkerActions.Value, out var intValue)) { await RunOnMainThread(() => _config.SetCVar(cvarName, intValue)); } else { await RunOnMainThread(() => _config.SetCVar(cvarName, panicPunkerActions.Value)); } _sawmill.Info($"Panic bunker property {panicPunkerActions} changed to {panicPunkerActions.Value} by {actor!.Name} ({actor.Guid})."); } await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); return true; } /// /// Sets the current MOTD. /// private async Task ActionForceMotd(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/set_motd") { return false; } if (!CheckAccess(context)) return true; var motd = await ReadJson(context); var (success, actor) = await CheckActor(context); if (!success) return true; if (motd!.Motd == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "A motd is required to perform this action.", Exception = new ExceptionData() { Message = "A motd is required to perform this action.", ErrorType = ErrorTypes.MotdNotSpecified } }, HttpStatusCode.BadRequest); return true; } _sawmill.Info($"MOTD changed to \"{motd.Motd}\" by {actor!.Name} ({actor.Guid})."); await RunOnMainThread(() => _config.SetCVar(CCVars.MOTD, motd.Motd)); // A hook in the MOTD system sends the changes to each client await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); return true; } /// /// Forces the next preset- /// private async Task ActionForcePreset(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/force_preset") { return false; } if (!CheckAccess(context)) return true; var body = await ReadJson(context); var (success, actor) = await CheckActor(context); if (!success) return true; var ticker = await RunOnMainThread(() => _entitySystemManager.GetEntitySystem()); if (ticker.RunLevel != GameRunLevel.PreRoundLobby) { await context.RespondJsonAsync(new BaseResponse() { Message = "Round already started", Exception = new ExceptionData() { Message = "Round already started", ErrorType = ErrorTypes.RoundAlreadyStarted } }, HttpStatusCode.Conflict); return true; } if (body!.PresetId == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "A preset is required to perform this action.", Exception = new ExceptionData() { Message = "A preset is required to perform this action.", ErrorType = ErrorTypes.PresetNotSpecified } }, HttpStatusCode.BadRequest); return true; } var result = await RunOnMainThread(() => ticker.FindGamePreset(body.PresetId)); if (result == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "Preset not found", Exception = new ExceptionData() { Message = "Preset not found", ErrorType = ErrorTypes.PresetNotSpecified } }, HttpStatusCode.UnprocessableContent); return true; } await RunOnMainThread(() => { ticker.SetGamePreset(result); }); _sawmill.Info($"Forced the game to start with preset {body.PresetId} by {actor!.Name}({actor.Guid})."); await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); return true; } /// /// Ends an active game rule. /// private async Task ActionEndGameRule(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/end_game_rule") { return false; } if (!CheckAccess(context)) return true; var body = await ReadJson(context); var (success, actor) = await CheckActor(context); if (!success) return true; if (body!.GameRuleId == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "A game rule is required to perform this action.", Exception = new ExceptionData() { Message = "A game rule is required to perform this action.", ErrorType = ErrorTypes.GuidNotSpecified } }, HttpStatusCode.BadRequest); return true; } var ticker = await RunOnMainThread(() => _entitySystemManager.GetEntitySystem()); var gameRuleEntity = await RunOnMainThread(() => ticker .GetActiveGameRules() .FirstOrNull(rule => _entityManager.MetaQuery.GetComponent(rule).EntityPrototype?.ID == body.GameRuleId)); if (gameRuleEntity == null) // Game rule not found { await context.RespondJsonAsync(new BaseResponse() { Message = "Game rule not found or not active", Exception = new ExceptionData() { Message = "Game rule not found or not active", ErrorType = ErrorTypes.GameRuleNotFound } }, HttpStatusCode.Conflict); return true; } _sawmill.Info($"Ended game rule {body.GameRuleId} by {actor!.Name} ({actor.Guid})."); await RunOnMainThread(() => ticker.EndGameRule((EntityUid) gameRuleEntity)); await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); return true; } /// /// Adds a game rule to the current round. /// private async Task ActionAddGameRule(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/add_game_rule") { return false; } if (!CheckAccess(context)) return true; var body = await ReadJson(context); var (success, actor) = await CheckActor(context); if (!success) return true; if (body!.GameRuleId == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "A game rule is required to perform this action.", Exception = new ExceptionData() { Message = "A game rule is required to perform this action.", ErrorType = ErrorTypes.GuidNotSpecified } }, HttpStatusCode.BadRequest); return true; } var ruleEntity = await RunOnMainThread(() => { var ticker = _entitySystemManager.GetEntitySystem(); // See if prototype exists try { _prototypeManager.Index(body.GameRuleId); } catch (KeyNotFoundException e) { return null; } var ruleEntity = ticker.AddGameRule(body.GameRuleId); _sawmill.Info($"Added game rule {body.GameRuleId} by {actor!.Name} ({actor.Guid})."); if (ticker.RunLevel == GameRunLevel.InRound) { ticker.StartGameRule(ruleEntity); _sawmill.Info($"Started game rule {body.GameRuleId} by {actor.Name} ({actor.Guid})."); } return ruleEntity; }); if (ruleEntity == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "Game rule not found", Exception = new ExceptionData() { Message = "Game rule not found", ErrorType = ErrorTypes.GameRuleNotFound } }, HttpStatusCode.UnprocessableContent); return true; } await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); return true; } /// /// Kicks a player. /// private async Task ActionKick(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url.AbsolutePath != "/admin/actions/kick") { return false; } if (!CheckAccess(context)) return true; var body = await ReadJson(context); var (success, actor) = await CheckActor(context); if (!success) return true; if (body == null) { _sawmill.Info($"Attempted to kick player without supplying a body by {actor!.Name}({actor.Guid})."); await context.RespondJsonAsync(new BaseResponse() { Message = "A body is required to perform this action.", Exception = new ExceptionData() { Message = "A body is required to perform this action.", ErrorType = ErrorTypes.BodyUnableToParse } }, HttpStatusCode.BadRequest); return true; } if (body.Guid == null) { _sawmill.Info($"Attempted to kick player without supplying a username by {actor!.Name}({actor.Guid})."); await context.RespondJsonAsync(new BaseResponse() { Message = "A player is required to perform this action.", Exception = new ExceptionData() { Message = "A player is required to perform this action.", ErrorType = ErrorTypes.GuidNotSpecified } }, HttpStatusCode.BadRequest); return true; } var session = await RunOnMainThread(() => { _playerManager.TryGetSessionById(new NetUserId(new Guid(body.Guid)), out var player); return player; }); if (session == null) { _sawmill.Info($"Attempted to kick player {body.Guid} by {actor!.Name} ({actor.Guid}), but they were not found."); await context.RespondJsonAsync(new BaseResponse() { Message = "Player not found", Exception = new ExceptionData() { Message = "Player not found", ErrorType = ErrorTypes.PlayerNotFound } }, HttpStatusCode.UnprocessableContent); return true; } var reason = body.Reason ?? "No reason supplied"; reason += " (kicked by admin)"; await RunOnMainThread(() => { _netManager.DisconnectChannel(session.Channel, reason); }); await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); _sawmill.Info("Kicked player {0} ({1}) for {2} by {3}({4})", session.Name, session.UserId.UserId.ToString(), reason, actor!.Name, actor.Guid); return true; } /// /// Round restart/end /// private async Task ActionRoundStatus(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || !context.Url.AbsolutePath.StartsWith("/admin/actions/round/")) { return false; } // Make sure paths like /admin/actions/round/lol/start don't work if (context.Url.AbsolutePath.Split('/').Length != 5) { return false; } if (!CheckAccess(context)) return true; var (success, actor) = await CheckActor(context); if (!success) return true; var (ticker, roundEndSystem) = await RunOnMainThread(() => { var ticker = _entitySystemManager.GetEntitySystem(); var roundEndSystem = _entitySystemManager.GetEntitySystem(); return (ticker, roundEndSystem); }); // Action is the last part of the URL path (e.g. /admin/actions/round/start -> start) var action = context.Url.AbsolutePath.Split('/').Last(); switch (action) { case "start": if (ticker.RunLevel != GameRunLevel.PreRoundLobby) { await context.RespondJsonAsync(new BaseResponse() { Message = "Round already started", Exception = new ExceptionData() { Message = "Round already started", ErrorType = ErrorTypes.RoundAlreadyStarted } }, HttpStatusCode.Conflict); _sawmill.Debug("Forced round start failed: round already started"); return true; } await RunOnMainThread(() => { ticker.StartRound(); }); _sawmill.Info("Forced round start"); break; case "end": if (ticker.RunLevel != GameRunLevel.InRound) { await context.RespondJsonAsync(new BaseResponse() { Message = "Round already ended", Exception = new ExceptionData() { Message = "Round already ended", ErrorType = ErrorTypes.RoundAlreadyEnded } }, HttpStatusCode.Conflict); _sawmill.Debug("Forced round end failed: round is not in progress"); return true; } await RunOnMainThread(() => { roundEndSystem.EndRound(); }); _sawmill.Info("Forced round end"); break; case "restart": if (ticker.RunLevel != GameRunLevel.InRound) { await context.RespondJsonAsync(new BaseResponse() { Message = "Round not in progress", Exception = new ExceptionData() { Message = "Round not in progress", ErrorType = ErrorTypes.RoundNotInProgress } }, HttpStatusCode.Conflict); _sawmill.Debug("Forced round restart failed: round is not in progress"); return true; } await RunOnMainThread(() => { roundEndSystem.EndRound(); }); _sawmill.Info("Forced round restart"); break; case "restartnow": // You should restart yourself NOW!!! await RunOnMainThread(() => { ticker.RestartRound(); }); _sawmill.Info("Forced instant round restart"); break; default: return false; } _sawmill.Info($"Round {action} by {actor!.Name} ({actor.Guid})."); await context.RespondJsonAsync(new BaseResponse() { Message = "OK" }); return true; } #endregion #region Fetching /// /// Returns an array containing all available presets. /// private async Task GetForcePresets(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Get || context.Url.AbsolutePath != "/admin/force_presets") { return false; } if (!CheckAccess(context)) return true; var presets = new List<(string id, string desc)>(); foreach (var preset in _prototypeManager.EnumeratePrototypes()) { presets.Add((preset.ID, preset.Description)); } await context.RespondJsonAsync(new PresetResponse() { Presets = presets }); return true; } /// /// Returns an array containing all game rules. /// private async Task GetGameRules(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Get || context.Url.AbsolutePath != "/admin/game_rules") { return false; } if (!CheckAccess(context)) return true; 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 }); return true; } /// /// Handles fetching information. /// private async Task InfoHandler(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Get || context.Url.AbsolutePath != "/admin/info") { return false; } if (!CheckAccess(context)) return true; var (success, actor) = await CheckActor(context); if (!success) return true; /* Information to display Round number Connected players Active admins Active game rules Active game preset Active map MOTD Panic bunker status */ var (ticker, adminSystem) = await RunOnMainThread(() => { var ticker = _entitySystemManager.GetEntitySystem(); var adminSystem = _entitySystemManager.GetEntitySystem(); return (ticker, adminSystem); }); var players = new List(); await RunOnMainThread(async () => { foreach (var player in _playerManager.Sessions) { var isAdmin = _adminManager.IsAdmin(player); var isDeadmined = _adminManager.IsAdmin(player, true) && !isAdmin; players.Add(new Actor() { Guid = player.UserId.UserId.ToString(), Name = player.Name, IsAdmin = isAdmin, IsDeadmined = isDeadmined }); } }); var gameRules = await RunOnMainThread(() => { 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"); } return gameRules; }); _sawmill.Info($"Info requested by {actor!.Name} ({actor.Guid})."); await context.RespondJsonAsync(new InfoResponse() { Players = players, RoundId = ticker.RoundId, Map = await RunOnMainThread(() => _gameMapManager.GetSelectedMap()?.MapName ?? "Unknown"), PanicBunker = adminSystem.PanicBunker, GamePreset = ticker.CurrentPreset?.ID, GameRules = gameRules, MOTD = _config.GetCVar(CCVars.MOTD) }); return true; } #endregion private bool CheckAccess(IStatusHandlerContext context) { var auth = context.RequestHeaders.TryGetValue("Authorization", out var authToken); if (!auth) { context.RespondJsonAsync(new BaseResponse() { Message = "An authorization header is required to perform this action.", Exception = new ExceptionData() { Message = "An authorization header is required to perform this action.", ErrorType = ErrorTypes.MissingAuthentication } }); return false; } if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(authToken.ToString()), Encoding.UTF8.GetBytes(_token))) return true; context.RespondJsonAsync(new BaseResponse() { Message = "Invalid authorization header.", Exception = new ExceptionData() { Message = "Invalid authorization header.", ErrorType = ErrorTypes.InvalidAuthentication } }); // Invalid auth header, no access _sawmill.Info("Unauthorized access attempt to admin API."); return false; } /// /// Async helper function which runs a task on the main thread and returns the result. /// private async Task RunOnMainThread(Func func) { var taskCompletionSource = new TaskCompletionSource(); _taskManager.RunOnMainThread(() => { try { taskCompletionSource.TrySetResult(func()); } catch (Exception e) { taskCompletionSource.TrySetException(e); } }); var result = await taskCompletionSource.Task; return result; } /// /// Runs an action on the main thread. This does not return any value and is meant to be used for void functions. Use for functions that return a value. /// private async Task RunOnMainThread(Action action) { var taskCompletionSource = new TaskCompletionSource(); _taskManager.RunOnMainThread(() => { try { action(); taskCompletionSource.TrySetResult(true); } catch (Exception e) { taskCompletionSource.TrySetException(e); } }); await taskCompletionSource.Task; } private async Task<(bool, Actor? actor)> 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) { var actionData = JsonSerializer.Deserialize(actor); if (actionData == null) { await context.RespondJsonAsync(new BaseResponse() { Message = "Unable to parse actor.", Exception = new ExceptionData() { Message = "Unable to parse actor.", ErrorType = ErrorTypes.BodyUnableToParse } }, HttpStatusCode.BadRequest); return (false, null); } // Check if the actor is valid, like if all the required fields are present if (string.IsNullOrWhiteSpace(actionData.Guid) || string.IsNullOrWhiteSpace(actionData.Name)) { await context.RespondJsonAsync(new BaseResponse() { Message = "Invalid actor supplied.", Exception = new ExceptionData() { Message = "Invalid actor supplied.", ErrorType = ErrorTypes.InvalidActor } }, HttpStatusCode.BadRequest); return (false, null); } // See if the parsed GUID is a valid GUID if (!Guid.TryParse(actionData.Guid, out _)) { await context.RespondJsonAsync(new BaseResponse() { Message = "Invalid GUID supplied.", Exception = new ExceptionData() { Message = "Invalid GUID supplied.", ErrorType = ErrorTypes.InvalidActor } }, HttpStatusCode.BadRequest); return (false, null); } return (true, actionData); } await context.RespondJsonAsync(new BaseResponse() { Message = "An actor is required to perform this action.", Exception = new ExceptionData() { Message = "An actor is required to perform this action.", ErrorType = ErrorTypes.MissingActor } }, HttpStatusCode.BadRequest); return (false, null); } /// /// Helper function to read JSON encoded data from the request body. /// private async Task ReadJson(IStatusHandlerContext context) { try { var json = await context.RequestBodyJsonAsync(); return json; } catch (Exception e) { await context.RespondJsonAsync(new BaseResponse() { Message = "Unable to parse request body.", Exception = new ExceptionData() { Message = e.Message, ErrorType = ErrorTypes.BodyUnableToParse, StackTrace = e.StackTrace } }, HttpStatusCode.BadRequest); return default; } } #region From Client private record Actor { public string? Guid { get; init; } public string? Name { get; init; } public bool IsAdmin { get; init; } = false; public bool IsDeadmined { get; init; } = false; } private record KickActionBody { public string? Guid { get; init; } public string? Reason { get; init; } } private record GameRuleActionBody { public string? GameRuleId { get; init; } } private record PresetActionBody { public string? PresetId { get; init; } } private record MotdActionBody { public string? Motd { get; init; } } #endregion #region Responses private record BaseResponse { public string? Message { get; init; } = "OK"; public ExceptionData? Exception { get; init; } = null; } private record ExceptionData { public string Message { get; init; } = string.Empty; public ErrorTypes ErrorType { get; init; } = ErrorTypes.None; public string? StackTrace { get; init; } = null; } private enum ErrorTypes { BodyUnableToParse = -2, None = -1, MissingAuthentication = 0, InvalidAuthentication = 1, MissingActor = 2, InvalidActor = 3, RoundNotInProgress = 4, RoundAlreadyStarted = 5, RoundAlreadyEnded = 6, ActionNotSpecified = 7, ActionNotSupported = 8, GuidNotSpecified = 9, PlayerNotFound = 10, GameRuleNotFound = 11, PresetNotSpecified = 12, MotdNotSpecified = 13 } #endregion #region Misc /// /// Record used to send the response for the info endpoint. /// private record InfoResponse { public int RoundId { get; init; } = 0; public List Players { get; init; } = new(); public List GameRules { get; init; } = new(); public string? GamePreset { get; init; } = null; public string? Map { get; init; } = null; public string? MOTD { get; init; } = null; public PanicBunkerStatus PanicBunker { get; init; } = new(); } private record PresetResponse : BaseResponse { public List<(string id, string desc)> Presets { get; init; } = new(); } private record GameruleResponse : BaseResponse { public List GameRules { get; init; } = new(); } #endregion }