diff --git a/Content.Server/Administration/ServerAPI.cs b/Content.Server/Administration/ServerAPI.cs
new file mode 100644
index 0000000000..27e9a83f47
--- /dev/null
+++ b/Content.Server/Administration/ServerAPI.cs
@@ -0,0 +1,795 @@
+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!;
+ }
+}
diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs
index 966bff2f71..70d3befbe2 100644
--- a/Content.Server/Administration/Systems/AdminSystem.cs
+++ b/Content.Server/Administration/Systems/AdminSystem.cs
@@ -61,7 +61,7 @@ namespace Content.Server.Administration.Systems
public IReadOnlySet RoundActivePlayers => _roundActivePlayers;
private readonly HashSet _roundActivePlayers = new();
- private readonly PanicBunkerStatus _panicBunker = new();
+ public readonly PanicBunkerStatus PanicBunker = new();
public override void Initialize()
{
@@ -248,7 +248,7 @@ namespace Content.Server.Administration.Systems
private void OnPanicBunkerChanged(bool enabled)
{
- _panicBunker.Enabled = enabled;
+ PanicBunker.Enabled = enabled;
_chat.SendAdminAlert(Loc.GetString(enabled
? "admin-ui-panic-bunker-enabled-admin-alert"
: "admin-ui-panic-bunker-disabled-admin-alert"
@@ -259,52 +259,52 @@ namespace Content.Server.Administration.Systems
private void OnPanicBunkerDisableWithAdminsChanged(bool enabled)
{
- _panicBunker.DisableWithAdmins = enabled;
+ PanicBunker.DisableWithAdmins = enabled;
UpdatePanicBunker();
}
private void OnPanicBunkerEnableWithoutAdminsChanged(bool enabled)
{
- _panicBunker.EnableWithoutAdmins = enabled;
+ PanicBunker.EnableWithoutAdmins = enabled;
UpdatePanicBunker();
}
private void OnPanicBunkerCountDeadminnedAdminsChanged(bool enabled)
{
- _panicBunker.CountDeadminnedAdmins = enabled;
+ PanicBunker.CountDeadminnedAdmins = enabled;
UpdatePanicBunker();
}
private void OnShowReasonChanged(bool enabled)
{
- _panicBunker.ShowReason = enabled;
+ PanicBunker.ShowReason = enabled;
SendPanicBunkerStatusAll();
}
private void OnPanicBunkerMinAccountAgeChanged(int minutes)
{
- _panicBunker.MinAccountAgeHours = minutes / 60;
+ PanicBunker.MinAccountAgeHours = minutes / 60;
SendPanicBunkerStatusAll();
}
private void OnPanicBunkerMinOverallHoursChanged(int hours)
{
- _panicBunker.MinOverallHours = hours;
+ PanicBunker.MinOverallHours = hours;
SendPanicBunkerStatusAll();
}
private void UpdatePanicBunker()
{
- var admins = _panicBunker.CountDeadminnedAdmins
+ var admins = PanicBunker.CountDeadminnedAdmins
? _adminManager.AllAdmins
: _adminManager.ActiveAdmins;
var hasAdmins = admins.Any();
- if (hasAdmins && _panicBunker.DisableWithAdmins)
+ if (hasAdmins && PanicBunker.DisableWithAdmins)
{
_config.SetCVar(CCVars.PanicBunkerEnabled, false);
}
- else if (!hasAdmins && _panicBunker.EnableWithoutAdmins)
+ else if (!hasAdmins && PanicBunker.EnableWithoutAdmins)
{
_config.SetCVar(CCVars.PanicBunkerEnabled, true);
}
@@ -314,7 +314,7 @@ namespace Content.Server.Administration.Systems
private void SendPanicBunkerStatusAll()
{
- var ev = new PanicBunkerChangedEvent(_panicBunker);
+ var ev = new PanicBunkerChangedEvent(PanicBunker);
foreach (var admin in _adminManager.AllAdmins)
{
RaiseNetworkEvent(ev, admin);
diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs
index 2a63ace8e3..25bb1072a5 100644
--- a/Content.Server/IoC/ServerContentIoC.cs
+++ b/Content.Server/IoC/ServerContentIoC.cs
@@ -58,6 +58,7 @@ namespace Content.Server.IoC
IoCManager.Register();
IoCManager.Register();
IoCManager.Register();
+ IoCManager.Register();
}
}
}
diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs
index 5a315f7055..2a1a29c044 100644
--- a/Content.Shared/CCVar/CCVars.cs
+++ b/Content.Shared/CCVar/CCVars.cs
@@ -29,6 +29,12 @@ namespace Content.Shared.CCVar
public static readonly CVarDef RulesHeader =
CVarDef.Create("server.rules_header", "ui-rules-header", CVar.REPLICATED | CVar.SERVER);
+ ///
+ /// The token used to authenticate with the admin API. Leave empty to disable the admin API. This is a secret! Do not share!
+ ///
+ public static readonly CVarDef AdminApiToken =
+ CVarDef.Create("server.admin_api_token", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
/*
* Ambience
*/