]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Game server api (#23129)
authorSimon <63975668+Simyon264@users.noreply.github.com>
Sun, 7 Jan 2024 01:47:29 +0000 (02:47 +0100)
committerGitHub <noreply@github.com>
Sun, 7 Jan 2024 01:47:29 +0000 (17:47 -0800)
* Stuff

* Fix RoundStatus Action

* Rename stuff

* Also needs to run on the main thread

* minor stuff

* Move to not be an EntitySystem

* Part 1 of making this less shit

* Make some more stuff run on main thread

* Actor check, logging and remove token from log.

* Better log

* Change ActionKick to use Guid instead of username

* Fix build

* Bandaid fix for test fails

Content.Server/Administration/ServerAPI.cs [new file with mode: 0644]
Content.Server/Administration/Systems/AdminSystem.cs
Content.Server/IoC/ServerContentIoC.cs
Content.Shared/CCVar/CCVars.cs

diff --git a/Content.Server/Administration/ServerAPI.cs b/Content.Server/Administration/ServerAPI.cs
new file mode 100644 (file)
index 0000000..27e9a83
--- /dev/null
@@ -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
+
+    /// <summary>
+    ///     Changes the panic bunker settings.
+    /// </summary>
+    private async Task<bool> 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;
+    }
+
+    /// <summary>
+    ///     Sets the current MOTD.
+    /// </summary>
+    private async Task<bool> 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;
+    }
+
+    /// <summary>
+    ///     Forces the next preset-
+    /// </summary>
+    private async Task<bool> 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<GameTicker>();
+
+        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;
+    }
+
+    /// <summary>
+    ///     Ends an active game rule.
+    /// </summary>
+    private async Task<bool> 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<GameTicker>();
+        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;
+    }
+
+    /// <summary>
+    ///     Adds a game rule to the current round.
+    /// </summary>
+    private async Task<bool> 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<GameTicker>();
+
+        var tsc = new TaskCompletionSource<EntityUid>();
+        _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;
+    }
+
+    /// <summary>
+    ///     Kicks a player.
+    /// </summary>
+    private async Task<bool> 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;
+    }
+
+    /// <summary>
+    ///     Round restart/end
+    /// </summary>
+    private async Task<bool> 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<GameTicker>();
+        var roundEndSystem = _entitySystemManager.GetEntitySystem<RoundEndSystem>();
+        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
+
+    /// <summary>
+    ///     Returns an array containing all presets.
+    /// </summary>
+    private async Task<bool> 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<string>();
+        foreach (var preset in _prototypeManager.EnumeratePrototypes<GamePresetPrototype>())
+        {
+            presets.Add(preset.ID);
+        }
+
+        jObject["presets"] = JsonNode.Parse(JsonSerializer.Serialize(presets));
+        await context.RespondAsync(jObject.ToString(), HttpStatusCode.OK);
+        return true;
+    }
+
+    /// <summary>
+    ///    Returns an array containing all game rules.
+    /// </summary>
+    private async Task<bool> 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<string>();
+        foreach (var gameRule in _prototypeManager.EnumeratePrototypes<EntityPrototype>())
+        {
+            if (gameRule.Abstract)
+                continue;
+
+            if (gameRule.HasComponent<GameRuleComponent>(_componentFactory))
+                gameRules.Add(gameRule.ID);
+        }
+
+        jObject["game_rules"] = JsonNode.Parse(JsonSerializer.Serialize(gameRules));
+        await context.RespondAsync(jObject.ToString(), HttpStatusCode.OK);
+        return true;
+    }
+
+
+    /// <summary>
+    ///     Handles fetching information.
+    /// </summary>
+    private async Task<bool> 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<GameTicker>();
+        var adminSystem = _entitySystemManager.GetEntitySystem<AdminSystem>();
+
+        var jObject = new JsonObject();
+
+        jObject["round_id"] = await RunOnMainThread(() => ticker.RoundId);
+
+        var players = new List<Player>();
+        var onlineAdmins = new List<Player>();
+        var onlineAdminsDeadmined = new List<Player>();
+
+        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<string>();
+        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;
+    }
+
+    /// <summary>
+    /// Async helper function which runs a task on the main thread and returns the result.
+    /// </summary>
+    private async Task<T> RunOnMainThread<T>(Func<T> func)
+    {
+        var taskCompletionSource = new TaskCompletionSource<T>();
+        _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!;
+    }
+}
index 966bff2f7157c6b3f9aed25ec9d95b22be0d79eb..70d3befbe256a84e39cde9d2e721534234a4a0a4 100644 (file)
@@ -61,7 +61,7 @@ namespace Content.Server.Administration.Systems
         public IReadOnlySet<NetUserId> RoundActivePlayers => _roundActivePlayers;
 
         private readonly HashSet<NetUserId> _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);
index 2a63ace8e30dd884a42c9c14010fd511da33d800..25bb1072a52c9ee8a19a91c27d865b2ca08a8f4b 100644 (file)
@@ -58,6 +58,7 @@ namespace Content.Server.IoC
             IoCManager.Register<PoissonDiskSampler>();
             IoCManager.Register<DiscordWebhook>();
             IoCManager.Register<ServerDbEntryManager>();
+            IoCManager.Register<ServerApi>();
         }
     }
 }
index 5a315f70557c199410df90cec1fd79af40b79931..2a1a29c044cb99d8ff0c98d9b437e71423567cf5 100644 (file)
@@ -29,6 +29,12 @@ namespace Content.Shared.CCVar
         public static readonly CVarDef<string> RulesHeader =
             CVarDef.Create("server.rules_header", "ui-rules-header", CVar.REPLICATED | CVar.SERVER);
 
+        /// <summary>
+        ///     The token used to authenticate with the admin API. Leave empty to disable the admin API. This is a secret! Do not share!
+        /// </summary>
+        public static readonly CVarDef<string> AdminApiToken =
+            CVarDef.Create("server.admin_api_token", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
         /*
          * Ambience
          */