using System.Net; using System.Net.Http; 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.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!; // Needed to circumvent the "IoC has no context on this thread" error until I figure out how to do it properly [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 = default!; private ISawmill _sawmill = default!; private string _motd = default!; public void 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); // Bandaid fix for the test fails // "System.Collections.Generic.KeyNotFoundException : The given key 'server.admin_api_token' was not present in the dictionary." // TODO: Figure out why this happens try { _config.OnValueChanged(CCVars.AdminApiToken, UpdateToken, true); _config.OnValueChanged(CCVars.MOTD, UpdateMotd, true); } catch (Exception e) { _sawmill.Error("Failed to subscribe to config vars: {0}", e); } } public void Shutdown() { _config.UnsubValueChanged(CCVars.AdminApiToken, UpdateToken); _config.UnsubValueChanged(CCVars.MOTD, UpdateMotd); } private void UpdateToken(string token) { _token = token; } private void UpdateMotd(string motd) { _motd = motd; } #region Actions /// /// Changes the panic bunker settings. /// private async Task ActionPanicPunker(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url!.AbsolutePath != "/admin/actions/panic_bunker") { return false; } if (!CheckAccess(context)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } var actionSupplied = context.RequestHeaders.TryGetValue("Action", out var action); if (!actionSupplied) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } var valueSupplied = context.RequestHeaders.TryGetValue("Value", out var value); if (!valueSupplied) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } switch (action) // TODO: This looks bad, there has to be a better way to do this. { case "enabled": if (!bool.TryParse(value.ToString(), out var enabled)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerEnabled, enabled); }); break; case "disable_with_admins": if (!bool.TryParse(value.ToString(), out var disableWithAdmins)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerDisableWithAdmins, disableWithAdmins); }); break; case "enable_without_admins": if (!bool.TryParse(value.ToString(), out var enableWithoutAdmins)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerEnableWithoutAdmins, enableWithoutAdmins); }); break; case "count_deadminned_admins": if (!bool.TryParse(value.ToString(), out var countDeadminnedAdmins)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerCountDeadminnedAdmins, countDeadminnedAdmins); }); break; case "show_reason": if (!bool.TryParse(value.ToString(), out var showReason)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerShowReason, showReason); }); break; case "min_account_age_hours": if (!int.TryParse(value.ToString(), out var minAccountAgeHours)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerMinAccountAge, minAccountAgeHours * 60); }); break; case "min_overall_hours": if (!int.TryParse(value.ToString(), out var minOverallHours)) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => { _config.SetCVar(CCVars.PanicBunkerMinOverallHours, minOverallHours * 60); }); break; } _sawmill.Info($"Panic bunker setting {action} changed to {value} by {actor!.Name}({actor!.Guid})."); await context.RespondAsync("Success", HttpStatusCode.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)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } var motdSupplied = context.RequestHeaders.TryGetValue("MOTD", out var motd); if (!motdSupplied) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _sawmill.Info($"MOTD changed to \"{motd}\" by {actor!.Name}({actor!.Guid})."); _taskManager.RunOnMainThread(() => _config.SetCVar(CCVars.MOTD, motd.ToString())); // A hook in the MOTD system sends the changes to each client await context.RespondAsync("Success", HttpStatusCode.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)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } var ticker = _entitySystemManager.GetEntitySystem(); if (ticker.RunLevel != GameRunLevel.PreRoundLobby) { _sawmill.Info($"Attempted to force preset {actor!.Name}({actor!.Guid}) while the game was not in the pre-round lobby."); await context.RespondAsync("This can only be executed while the game is in the pre-round lobby.", HttpStatusCode.BadRequest); return true; } var presetSupplied = context.RequestHeaders.TryGetValue("PresetId", out var preset); if (!presetSupplied) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } var result = await RunOnMainThread(() => ticker.FindGamePreset(preset.ToString())); if (result == null) { await context.RespondAsync($"No preset exists with name {preset}.", HttpStatusCode.NotFound); return true; } _taskManager.RunOnMainThread(() => { ticker.SetGamePreset(result); }); _sawmill.Info($"Forced the game to start with preset {preset} by {actor!.Name}({actor!.Guid})."); await context.RespondAsync("Success", HttpStatusCode.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)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } var gameRuleSupplied = context.RequestHeaders.TryGetValue("GameRuleId", out var gameRule); if (!gameRuleSupplied) { _sawmill.Info($"Attempted to end game rule without supplying a game rule name by {actor!.Name}({actor!.Guid})."); await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } var ticker = _entitySystemManager.GetEntitySystem(); var gameRuleEntity = await RunOnMainThread(() => ticker .GetActiveGameRules() .FirstOrNull(rule => _entityManager.MetaQuery.GetComponent(rule).EntityPrototype?.ID == gameRule.ToString())); if (gameRuleEntity == null) // Game rule not found { _sawmill.Info($"Attempted to end game rule {gameRule} by {actor!.Name}({actor!.Guid}), but it was not found."); await context.RespondAsync("Gamerule not found or not active",HttpStatusCode.NotFound); return true; } _sawmill.Info($"Ended game rule {gameRule} by {actor!.Name}({actor!.Guid})."); _taskManager.RunOnMainThread(() => ticker.EndGameRule((EntityUid) gameRuleEntity)); await context.RespondAsync($"Ended game rule {gameRule}({gameRuleEntity})", HttpStatusCode.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)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } var gameRuleSupplied = context.RequestHeaders.TryGetValue("GameRuleId", out var gameRule); if (!gameRuleSupplied) { _sawmill.Info($"Attempted to add game rule without supplying a game rule name by {actor!.Name}({actor!.Guid})."); await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } var ticker = _entitySystemManager.GetEntitySystem(); var tsc = new TaskCompletionSource(); _taskManager.RunOnMainThread(() => { var ruleEntity = ticker.AddGameRule(gameRule.ToString()); _sawmill.Info($"Added game rule {gameRule} by {actor!.Name}({actor!.Guid})."); if (ticker.RunLevel == GameRunLevel.InRound) { ticker.StartGameRule(ruleEntity); _sawmill.Info($"Started game rule {gameRule} by {actor!.Name}({actor!.Guid})."); } tsc.TrySetResult(ruleEntity); }); var ruleEntity = await tsc.Task; await context.RespondAsync($"Added game rule {ruleEntity}", HttpStatusCode.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)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } var playerSupplied = context.RequestHeaders.TryGetValue("Guid", out var guid); if (!playerSupplied) { _sawmill.Info($"Attempted to kick player without supplying a username by {actor!.Name}({actor!.Guid})."); await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } var session = await RunOnMainThread(() => { // There is no function to get a session by GUID, so we have to iterate over all sessions and check their GUIDs. foreach (var player in _playerManager.Sessions) { if (player.UserId.UserId.ToString() == guid.ToString()) { return player; } } return null; }); if (session == null) { _sawmill.Info($"Attempted to kick player {guid} by {actor!.Name}({actor!.Guid}), but they were not found."); await context.RespondAsync("Player not found", HttpStatusCode.NotFound); return true; } var reasonSupplied = context.RequestHeaders.TryGetValue("Reason", out var reason); if (!reasonSupplied) { reason = "No reason supplied"; } reason += " (kicked by admin)"; _taskManager.RunOnMainThread(() => { _netManager.DisconnectChannel(session.Channel, reason.ToString()); }); await context.RespondAsync("Success", HttpStatusCode.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 != "/admin/actions/round") { return false; } if (!CheckAccess(context)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); return true; } // Not using body, because that's a stream and I don't want to deal with that var actionSupplied = context.RequestHeaders.TryGetValue("Action", out var action); if (!actionSupplied) { _sawmill.Info($"Attempted to {action} round without supplying an action by {actor!.Name}({actor!.Guid})."); await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } var ticker = _entitySystemManager.GetEntitySystem(); var roundEndSystem = _entitySystemManager.GetEntitySystem(); switch (action) { case "start": if (ticker.RunLevel != GameRunLevel.PreRoundLobby) { await context.RespondAsync("Round already started", HttpStatusCode.BadRequest); _sawmill.Info("Forced round start failed: round already started"); return true; } _taskManager.RunOnMainThread(() => { ticker.StartRound(); }); _sawmill.Info("Forced round start"); break; case "end": if (ticker.RunLevel != GameRunLevel.InRound) { await context.RespondAsync("Round already ended", HttpStatusCode.BadRequest); _sawmill.Info("Forced round end failed: round is not in progress"); return true; } _taskManager.RunOnMainThread(() => { roundEndSystem.EndRound(); }); _sawmill.Info("Forced round end"); break; case "restart": if (ticker.RunLevel != GameRunLevel.InRound) { await context.RespondAsync("Round already ended", HttpStatusCode.BadRequest); _sawmill.Info("Forced round restart failed: round is not in progress"); return true; } _taskManager.RunOnMainThread(() => { roundEndSystem.EndRound(); }); _sawmill.Info("Forced round restart"); break; case "restartnow": // You should restart yourself NOW!!! _taskManager.RunOnMainThread(() => { ticker.RestartRound(); }); _sawmill.Info("Forced instant round restart"); break; default: await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _sawmill.Info($"Round {action} by {actor!.Name}({actor!.Guid})."); await context.RespondAsync("Success", HttpStatusCode.OK); return true; } #endregion #region Fetching /// /// Returns an array containing all presets. /// private async Task GetForcePresets(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Get || context.Url!.AbsolutePath != "/admin/force_presets") { return false; } if (!CheckAccess(context)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } var jObject = new JsonObject(); var presets = new List(); foreach (var preset in _prototypeManager.EnumeratePrototypes()) { presets.Add(preset.ID); } jObject["presets"] = JsonNode.Parse(JsonSerializer.Serialize(presets)); await context.RespondAsync(jObject.ToString(), HttpStatusCode.OK); 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)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } var jObject = new JsonObject(); var gameRules = new List(); foreach (var gameRule in _prototypeManager.EnumeratePrototypes()) { if (gameRule.Abstract) continue; if (gameRule.HasComponent(_componentFactory)) gameRules.Add(gameRule.ID); } jObject["game_rules"] = JsonNode.Parse(JsonSerializer.Serialize(gameRules)); await context.RespondAsync(jObject.ToString(), HttpStatusCode.OK); return true; } /// /// Handles fetching information. /// private async Task InfoHandler(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Get || context.Url!.AbsolutePath != "/admin/info" || _token == string.Empty) { return false; // 404 } if (!CheckAccess(context)) { await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } if (!CheckActor(context, out var actor)) { await context.RespondAsync("An actor is required to perform this action.", HttpStatusCode.BadRequest); 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 = _entitySystemManager.GetEntitySystem(); var adminSystem = _entitySystemManager.GetEntitySystem(); var jObject = new JsonObject(); jObject["round_id"] = await RunOnMainThread(() => ticker.RoundId); var players = new List(); var onlineAdmins = new List(); var onlineAdminsDeadmined = new List(); foreach (var player in _playerManager.Sessions) { players.Add(new Player { Guid = player.UserId.UserId.ToString(), Name = player.Name }); if (await RunOnMainThread(() => _adminManager.IsAdmin(player))) { onlineAdmins.Add(new Player { Guid = player.UserId.UserId.ToString(), Name = player.Name }); } else if (await RunOnMainThread(() => _adminManager.IsAdmin(player, true))) { onlineAdminsDeadmined.Add(new Player { Guid = player.UserId.UserId.ToString(), Name = player.Name }); } } // The JsonSerializer.Serialize into JsonNode.Parse is a bit of a hack jObject["players"] = JsonNode.Parse(JsonSerializer.Serialize(players)); jObject["admins"] = JsonNode.Parse(JsonSerializer.Serialize(onlineAdmins)); jObject["deadmined"] = JsonNode.Parse(JsonSerializer.Serialize(onlineAdminsDeadmined)); var gameRules = new List(); foreach (var addedGameRule in await RunOnMainThread(() => ticker.GetActiveGameRules())) { var meta = _entityManager.MetaQuery.GetComponent(addedGameRule); gameRules.Add(meta.EntityPrototype?.ID ?? meta.EntityPrototype?.Name ?? "Unknown"); } jObject["game_rules"] = JsonNode.Parse(JsonSerializer.Serialize(gameRules)); jObject["game_preset"] = ticker.CurrentPreset?.ID; jObject["map"] = await RunOnMainThread(() => _gameMapManager.GetSelectedMap()?.MapName ?? "Unknown"); jObject["motd"] = _motd; jObject["panic_bunker"] = new JsonObject(); jObject["panic_bunker"]!["enabled"] = adminSystem.PanicBunker.Enabled; jObject["panic_bunker"]!["disable_with_admins"] = adminSystem.PanicBunker.DisableWithAdmins; jObject["panic_bunker"]!["enable_without_admins"] = adminSystem.PanicBunker.EnableWithoutAdmins; jObject["panic_bunker"]!["count_deadminned_admins"] = adminSystem.PanicBunker.CountDeadminnedAdmins; jObject["panic_bunker"]!["show_reason"] = adminSystem.PanicBunker.ShowReason; jObject["panic_bunker"]!["min_account_age_hours"] = adminSystem.PanicBunker.MinAccountAgeHours; jObject["panic_bunker"]!["min_overall_hours"] = adminSystem.PanicBunker.MinOverallHours; _sawmill.Info($"Info requested by {actor!.Name}({actor!.Guid})."); await context.RespondAsync(jObject.ToString(), HttpStatusCode.OK); return true; } #endregion private bool CheckAccess(IStatusHandlerContext context) { var auth = context.RequestHeaders.TryGetValue("Authorization", out var authToken); if (!auth) { _sawmill.Info(@"Unauthorized access attempt to admin API. No auth header"); return false; } // No auth header, no access if (authToken == _token) return true; // Invalid auth header, no access _sawmill.Info(@"Unauthorized access attempt to admin API. ""{0}""", authToken.ToString()); 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(() => { taskCompletionSource.TrySetResult(func()); }); var result = await taskCompletionSource.Task; return result; } private bool CheckActor(IStatusHandlerContext context, out Player? actor) { // We are trusting the header to be correct. // This is fine because the header is set by the SS14.Admin backend. var actorSupplied = context.RequestHeaders.TryGetValue("Actor", out var actorHeader); if (!actorSupplied) { actor = null; return false; } var stringRep = actorHeader.ToString(); actor = new Player() { // GUID_NAME format Guid = stringRep[..stringRep.IndexOf('_')], Name = stringRep[(stringRep.IndexOf('_') + 1)..] }; return true; } private sealed class Player { public string Guid { get; set; } = default!; public string Name { get; set; } = default!; } }