From 87cec919416493f5aa901eab3af729f76a8dfdaf Mon Sep 17 00:00:00 2001 From: "Robin C. Ladiges" Date: Sun, 4 Sep 2022 00:02:19 +0200 Subject: [PATCH] JSON API --- .dockerignore | 4 + Server/JsonApi/.gitignore | 1 + Server/JsonApi/ApiPacket.cs | 47 +++++++++ Server/JsonApi/ApiRequest.cs | 68 +++++++++++++ Server/JsonApi/ApiRequestCommand.cs | 74 ++++++++++++++ Server/JsonApi/ApiRequestPermissions.cs | 21 ++++ Server/JsonApi/ApiRequestStatus.cs | 125 ++++++++++++++++++++++++ Server/JsonApi/BlockClients.cs | 46 +++++++++ Server/JsonApi/Context.cs | 42 ++++++++ Server/JsonApi/JsonApi.cs | 77 +++++++++++++++ Server/JsonApi/README.md | 78 +++++++++++++++ Server/JsonApi/test.sh | 22 +++++ Server/Server.cs | 7 +- Server/Settings.cs | 9 +- 14 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Server/JsonApi/.gitignore create mode 100644 Server/JsonApi/ApiPacket.cs create mode 100644 Server/JsonApi/ApiRequest.cs create mode 100644 Server/JsonApi/ApiRequestCommand.cs create mode 100644 Server/JsonApi/ApiRequestPermissions.cs create mode 100644 Server/JsonApi/ApiRequestStatus.cs create mode 100644 Server/JsonApi/BlockClients.cs create mode 100644 Server/JsonApi/Context.cs create mode 100644 Server/JsonApi/JsonApi.cs create mode 100644 Server/JsonApi/README.md create mode 100755 Server/JsonApi/test.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..de5adf2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +/Server/**/.gitignore +/Server/**/*.env +/Server/**/*.md +/Server/**/*.sh diff --git a/Server/JsonApi/.gitignore b/Server/JsonApi/.gitignore new file mode 100644 index 0000000..10165ac --- /dev/null +++ b/Server/JsonApi/.gitignore @@ -0,0 +1 @@ +/test.env diff --git a/Server/JsonApi/ApiPacket.cs b/Server/JsonApi/ApiPacket.cs new file mode 100644 index 0000000..6893086 --- /dev/null +++ b/Server/JsonApi/ApiPacket.cs @@ -0,0 +1,47 @@ +using System.Net.Sockets; + +using System.Text; +using System.Text.Json; + +using Shared; + +namespace Server.JsonApi; + +public class ApiPacket { + public const ushort MAX_PACKET_SIZE = 512; // in bytes (including 20 byte header) + + + public ApiRequest? API_JSON_REQUEST { get; set; } + + + public static async Task Read(Context ctx, string header) { + string reqStr = header + await ApiPacket.GetRequestStr(ctx); + + ApiPacket? p = null; + try { p = JsonSerializer.Deserialize(reqStr); } + catch { + JsonApi.Logger.Warn($"Invalid packet deserialize from {ctx.socket.RemoteEndPoint}: {reqStr}."); + return null; + } + + if (p == null) { + JsonApi.Logger.Warn($"Invalid packet from {ctx.socket.RemoteEndPoint}: {reqStr}."); + return null; + } + + if (p.API_JSON_REQUEST == null) { + JsonApi.Logger.Warn($"Invalid request from {ctx.socket.RemoteEndPoint}: {reqStr}."); + return null; + } + + return p; + } + + + private static async Task GetRequestStr(Context ctx) { + byte[] buffer = new byte[ApiPacket.MAX_PACKET_SIZE - Constants.HeaderSize]; + int size = await ctx.socket.ReceiveAsync(buffer, SocketFlags.None); + return Encoding.UTF8.GetString(buffer, 0, size); + } +} + diff --git a/Server/JsonApi/ApiRequest.cs b/Server/JsonApi/ApiRequest.cs new file mode 100644 index 0000000..da2692b --- /dev/null +++ b/Server/JsonApi/ApiRequest.cs @@ -0,0 +1,68 @@ +namespace Server.JsonApi; + +using System.Text.Json; +using System.Text.Json.Nodes; + +using TypesDictionary = Dictionary>>; + +public class ApiRequest { + public string? Token { get; set; } + public string? Type { get; set; } + public JsonNode? Data { get; set; } + + + private static TypesDictionary Types = new TypesDictionary() { + ["Status"] = async (Context ctx) => await ApiRequestStatus.Send(ctx), + ["Command"] = async (Context ctx) => await ApiRequestCommand.Send(ctx), + ["Permissions"] = async (Context ctx) => await ApiRequestPermissions.Send(ctx), + }; + + + public dynamic? GetData() { + if (this.Data == null) { return null; } + if (this.Data is JsonArray) { return this.Data.AsArray(); } // TODO: better way? + if (this.Data is JsonObject) { return this.Data.AsObject(); } // TODO: better way? + if (this.Data is JsonValue) { + JsonElement val = this.Data.GetValue(); + JsonValueKind kind = val.ValueKind; + if (kind == JsonValueKind.String) { return val.GetString(); } + if (kind == JsonValueKind.Number) { return val.GetInt64(); } // TODO: floats + if (kind == JsonValueKind.False) { return false; } + if (kind == JsonValueKind.True) { return true; } + } + return null; + } + + + public async Task Process(Context ctx) { + if (this.Type != null) { + return await ApiRequest.Types[this.Type](ctx); + } + return false; + } + + + public bool IsValid(Context ctx) { + if (this.Token == null) { + JsonApi.Logger.Warn($"Invalid request missing Token from {ctx.socket.RemoteEndPoint}."); + return false; + } + + if (this.Type == null) { + JsonApi.Logger.Warn($"Invalid request missing Type from {ctx.socket.RemoteEndPoint}."); + return false; + } + + if (!ApiRequest.Types.ContainsKey(this.Type)) { + JsonApi.Logger.Warn($"Invalid Type \"{this.Type}\" from {ctx.socket.RemoteEndPoint}."); + return false; + } + + if (!Settings.Instance.JsonApi.Tokens.ContainsKey(this.Token)) { + JsonApi.Logger.Warn($"Invalid Token from {ctx.socket.RemoteEndPoint}."); + return false; + } + + return true; + } +} diff --git a/Server/JsonApi/ApiRequestCommand.cs b/Server/JsonApi/ApiRequestCommand.cs new file mode 100644 index 0000000..9078496 --- /dev/null +++ b/Server/JsonApi/ApiRequestCommand.cs @@ -0,0 +1,74 @@ +namespace Server.JsonApi; + +public static class ApiRequestCommand { + public static async Task Send(Context ctx) { + if (!ctx.HasPermission("Commands")) { + await Response.Send(ctx, "Error: Missing Commands permission."); + return true; + } + + if (!ApiRequestCommand.IsValid(ctx)) { + return false; + } + + string input = ctx.request!.GetData()!; + string command = input.Split(" ")[0]; + + // help doesn't need permissions and is invidualized to the token + if (command == "help") { + List commands = new List(); + commands.Add("help"); + commands.AddRange( + ctx.Permissions + .Where(str => str.StartsWith("Commands/")) + .Select(str => str.Substring(9)) + .Where(cmd => CommandHandler.Handlers.ContainsKey(cmd)) + ); + string commandsStr = string.Join(", ", commands); + + await Response.Send(ctx, $"Valid commands: {commandsStr}"); + return true; + } + + // no permissions + if (! ctx.HasPermission($"Commands/{command}")) { + await Response.Send(ctx, $"Error: Missing Commands/{command} permission."); + return true; + } + + // execute command + JsonApi.Logger.Info($"[Commands] " + input); + await Response.Send(ctx, CommandHandler.GetResult(input)); + return true; + } + + + private static bool IsValid(Context ctx) { + var command = ctx.request!.GetData(); + + if (command == null) { + JsonApi.Logger.Warn($"[Commands] Invalid request Data is \"null\" or missing and not a \"System.String\" from {ctx.socket.RemoteEndPoint}."); + return false; + } + + if (command.GetType() != typeof(string)) { + JsonApi.Logger.Warn($"[Commands] Invalid request Data is \"{command.GetType()}\" and not a \"System.String\" from {ctx.socket.RemoteEndPoint}."); + return false; + } + + return true; + } + + + private class Response { + public string[]? Output { get; set; } + + + public static async Task Send(Context ctx, CommandHandler.Response response) + { + Response resp = new Response(); + resp.Output = response.ReturnStrings; + await ctx.Send(resp); + } + } +} diff --git a/Server/JsonApi/ApiRequestPermissions.cs b/Server/JsonApi/ApiRequestPermissions.cs new file mode 100644 index 0000000..98c0144 --- /dev/null +++ b/Server/JsonApi/ApiRequestPermissions.cs @@ -0,0 +1,21 @@ +namespace Server.JsonApi; + +public static class ApiRequestPermissions { + public static async Task Send(Context ctx) { + await Response.Send(ctx); + return true; + } + + + private class Response { + public string[]? Permissions { get; set; } + + + public static async Task Send(Context ctx) + { + Response resp = new Response(); + resp.Permissions = ctx.Permissions.ToArray(); + await ctx.Send(resp); + } + } +} diff --git a/Server/JsonApi/ApiRequestStatus.cs b/Server/JsonApi/ApiRequestStatus.cs new file mode 100644 index 0000000..bbe5e62 --- /dev/null +++ b/Server/JsonApi/ApiRequestStatus.cs @@ -0,0 +1,125 @@ +using Shared.Packet.Packets; +using System.Dynamic; +using System.Net; + +namespace Server.JsonApi; + +using Mutators = Dictionary>; + +public static class ApiRequestStatus { + public static async Task Send(Context ctx) { + StatusResponse resp = new StatusResponse { + Settings = ApiRequestStatus.GetSettings(ctx), + Players = Player.GetPlayers(ctx), + }; + await ctx.Send(resp); + return true; + } + + + private static dynamic GetSettings(Context ctx) + { + // output object + dynamic settings = new ExpandoObject(); + + // all permissions for Settings + var allowedSettings = ctx.Permissions + .Where(str => str.StartsWith("Status/Settings/")) + .Select(str => str.Substring(16)) + ; + + // copy all allowed Settings + foreach (string allowedSetting in allowedSettings) { + string lastKey = ""; + dynamic? next = settings; + dynamic input = Settings.Instance; + IDictionary output = settings; + + // recursively go down the path + foreach (string key in allowedSetting.Split("/")) { + lastKey = key; + + if (next == null) { break; } + output = (IDictionary) next; + + // create the sublayer + if (!output.ContainsKey(key)) { output.Add(key, new ExpandoObject()); } + + // traverse down the output object + output.TryGetValue(key, out next); + + // traverse down the Settings object + input = input.GetType().GetProperty(key).GetValue(input, null); + } + + if (lastKey != "") { + // copy key with the actual value + output.Remove(lastKey); + output.Add(lastKey, input); + } + } + + return settings; + } + + + private class StatusResponse { + public dynamic? Settings { get; set; } + public dynamic[]? Players { get; set; } + } + + + private static class Player { + private static Mutators Mutators = new Mutators { + ["Status/Players/Name"] = (dynamic p, Client c) => p.Name = c.Name, + ["Status/Players/Stage"] = (dynamic p, Client c) => p.Stage = Player.GetGamePacket(c)?.Stage ?? null, + ["Status/Players/Scenario"] = (dynamic p, Client c) => p.Scenario = Player.GetGamePacket(c)?.ScenarioNum ?? null, + ["Status/Players/Costume"] = (dynamic p, Client c) => p.Costume = Costume.FromClient(c), + ["Status/Players/IPv4"] = (dynamic p, Client c) => p.IPv4 = (c.Socket?.RemoteEndPoint as IPEndPoint)?.Address.ToString(), + }; + + + public static dynamic[]? GetPlayers(Context ctx) { + if (!ctx.HasPermission("Status/Players")) { return null; } + return ctx.server.ClientsConnected.Select((Client c) => Player.FromClient(ctx, c)).ToArray(); + } + + + private static dynamic FromClient(Context ctx, Client c) { + dynamic player = new ExpandoObject(); + foreach (var (perm, mutate) in Mutators) { + if (ctx.HasPermission(perm)) { + mutate(player, c); + } + } + return player; + } + + + private static GamePacket? GetGamePacket(Client c) { + object? lastGamePacket = null; + c.Metadata.TryGetValue("lastGamePacket", out lastGamePacket); + if (lastGamePacket == null) { return null; } + return (GamePacket) lastGamePacket; + } + } + + + private class Costume { + public string Cap { get; private set; } + public string Body { get; private set; } + + + private Costume(CostumePacket p) { + this.Cap = p.CapName; + this.Body = p.BodyName; + } + + + public static Costume? FromClient(Client c) { + if (c.CurrentCostume == null) { return null; } + CostumePacket p = (CostumePacket) c.CurrentCostume!; + return new Costume(p); + } + } +} diff --git a/Server/JsonApi/BlockClients.cs b/Server/JsonApi/BlockClients.cs new file mode 100644 index 0000000..4cd74d0 --- /dev/null +++ b/Server/JsonApi/BlockClients.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; + +namespace Server.JsonApi; + +public static class BlockClients +{ + private const int MAX_TRIES = 5; + + + private static ConcurrentDictionary Failures = new ConcurrentDictionary(); + + + public static bool IsBlocked(Context ctx) { + if (ctx.socket.RemoteEndPoint == null) { return true; } + + IPAddress ip = (ctx.socket.RemoteEndPoint as IPEndPoint)!.Address; + + int failures = BlockClients.Failures.GetValueOrDefault(ip, 0); + return failures >= BlockClients.MAX_TRIES; + } + + + public static void Fail(Context ctx) { + if (ctx.socket.RemoteEndPoint == null) { return; } + + IPAddress ip = (ctx.socket.RemoteEndPoint as IPEndPoint)!.Address; + + int failures = 1; + BlockClients.Failures.AddOrUpdate(ip, 1, (k, v) => failures = v + 1); + + if (failures == BlockClients.MAX_TRIES) { + JsonApi.Logger.Warn($"Block client {ctx.socket.RemoteEndPoint} because of too many failed requests."); + } + } + + + public static void Redeem(Context ctx) { + if (ctx.socket.RemoteEndPoint == null) { return; } + + IPAddress ip = (ctx.socket.RemoteEndPoint as IPEndPoint)!.Address; + + BlockClients.Failures.Remove(ip, out int val); + } +} diff --git a/Server/JsonApi/Context.cs b/Server/JsonApi/Context.cs new file mode 100644 index 0000000..58692d4 --- /dev/null +++ b/Server/JsonApi/Context.cs @@ -0,0 +1,42 @@ +using Server; +using Shared; +using System.Net.Sockets; +using System.Text.Json; + +namespace Server.JsonApi; + +public class Context { + public Server server; + public Socket socket; + public ApiRequest? request; + public Logger? logger; + + + public Context( + Server server, + Socket socket + ) { + this.server = server; + this.socket = socket; + } + + + public bool HasPermission(string perm) { + if (this.request == null) { return false; } + return Settings.Instance.JsonApi.Tokens[this.request!.Token!].Contains(perm); + } + + + public List Permissions { + get { + if (this.request == null) { return new List(); } + return Settings.Instance.JsonApi.Tokens[this.request!.Token!]; + } + } + + + public async Task Send(object data) { + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(data); + await this.socket.SendAsync(bytes, SocketFlags.None); + } +} diff --git a/Server/JsonApi/JsonApi.cs b/Server/JsonApi/JsonApi.cs new file mode 100644 index 0000000..4434756 --- /dev/null +++ b/Server/JsonApi/JsonApi.cs @@ -0,0 +1,77 @@ +using System.Buffers; +using System.Net.Sockets; +using System.Text; + +using Server; + +using Shared; +using Shared.Packet; + +namespace Server.JsonApi; + + +public static class JsonApi { + public const ushort PACKET_TYPE = 0x5453; // ascii "ST" (0x53 0x54) from preamble, but swapped because of endianness + public const string PREAMBLE = "{\"API_JSON_REQUEST\":"; + + + public static readonly Logger Logger = new Logger("JsonApi"); + + + public static async Task HandleAPIRequest( + Server server, + Socket socket, + PacketHeader header, + IMemoryOwner memory + ) { + // check if it is enabled + if (!Settings.Instance.JsonApi.Enabled) { + return false; + } + + // check packet type + if ((ushort) header.Type != JsonApi.PACKET_TYPE) { + server.Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + return false; + } + + // check entire header length + string headerStr = Encoding.UTF8.GetString(memory.Memory.Span[..Constants.HeaderSize].ToArray()); + if (headerStr != JsonApi.PREAMBLE) { + server.Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + return false; + } + + Context ctx = new Context(server, socket); + + // not if there were too many failed attempts in the past + if (BlockClients.IsBlocked(ctx)) { + JsonApi.Logger.Info($"Rejected blocked client {socket.RemoteEndPoint}."); + return true; + } + + // receive & parse JSON + ApiPacket? p = await ApiPacket.Read(ctx, headerStr); + if (p == null) { + BlockClients.Fail(ctx); + return true; + } + + // verify basic request structure & token + ApiRequest req = p.API_JSON_REQUEST!; + ctx.request = req; + if (!req.IsValid(ctx)) { + BlockClients.Fail(ctx); + return true; + } + + // process request + if (!await req.Process(ctx)) { + BlockClients.Fail(ctx); + return true; + } + + BlockClients.Redeem(ctx); + return true; + } +} diff --git a/Server/JsonApi/README.md b/Server/JsonApi/README.md new file mode 100644 index 0000000..48f1a7f --- /dev/null +++ b/Server/JsonApi/README.md @@ -0,0 +1,78 @@ +The API runs on the same port as the normal game server. This is easier to deploy instead of a dedicated port, but has some limitations. + +To use the API the client sends only one texual JSON object to the server and might get a JSON object back (if the request is valid). + +The first 20 bytes of the request JSON are constant `{"API_JSON_REQUEST":`, +to fill up and exactly match a complete normal game packet header (to identify and separate it from other server traffic). + +A complete request can have a size of up to 512 characters (arbitrary limit that could be increased if needed). + +The first 22 bytes of binary data returned from the server (`InitPacket`) need to be ignored to parse the rest as valid JSON. + +--- + +Every request to the server needs to be authorized by containing a secret token. +The token and its permissions are configured in the `settings.json`. +There can be several tokens with different permission sets. + +IP addresses that provide invalid requests or token values, are automatically blocked after 5 such requests until the next server restart. +(This is mainly there to prevent agains brute force attacks that try to guess the token). + +--- + +Currently available `Type` of requests: +- `Permissions`: lists all permissions the token in use has (this request is always possible and doesn't require an extra permission). +- `Status`: outputs all Settings, Players and Player properties the token has explicit permissions for. +- `Command`: passes an command to the CommandHandler and returns its output. Every command needs to be permitted individually. + +Specific settings and commands aren't hardcoded, but the API should automatically work for future extensions on both. +The server operator only needs to add the new permissions for the new commands or settings that they want to whitelist to the `settings.json`. + +The possible player status permissions are hardcoded though: +- `Status/Players` +- `Status/Players/Name` +- `Status/Players/Stage` +- `Status/Players/Scenario` +- `Status/Players/Costume` +- `Status/Players/IPv4` + +--- + +Example for the `settings.json`: +```json +"JsonApi": { + "Enabled": true, + "Tokens": { + "SECRET_TOKEN_12345": [ + "Status/Settings/Server/MaxPlayers", + "Status/Settings/Scenario/MergeEnabled", + "Status/Settings/Shines/Enabled", + "Status/Settings/PersistShines/Enabled", + "Status/Players", + "Status/Players/Name", + "Status/Players/Stage", + "Status/Players/Costume", + "Commands", + "Commands/list", + "Commands/sendall" + ] + } +} +``` + +--- + +Example request (e.g. with `./test.sh Command sendall mush`): +```json +{"API_JSON_REQUEST":{"Token":"SECRET_TOKEN_12345","Type":"Command","Data":"sendall mush"}} +``` + +Example `hexdump -C` response: +``` +00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| +00000010 01 00 02 00 04 00 7b 22 4f 75 74 70 75 74 22 3a |......{"Output":| +00000020 5b 22 53 65 6e 74 20 70 6c 61 79 65 72 73 20 74 |["Sent players t| +00000030 6f 20 50 65 61 63 68 57 6f 72 6c 64 48 6f 6d 65 |o PeachWorldHome| +00000040 53 74 61 67 65 3a 2d 31 22 5d 7d |Stage:-1"]}| +0000004b +``` diff --git a/Server/JsonApi/test.sh b/Server/JsonApi/test.sh new file mode 100755 index 0000000..3656fd1 --- /dev/null +++ b/Server/JsonApi/test.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +TOKEN="SECRET_TOKEN_12345" +HOST="localhost" +PORT="1027" + +DIR=`dirname "$0"` +[ -f "$DIR/test.env" ] && source "$DIR/test.env" + +TYPE="$1" + +DATA="" +if [ $# -gt 1 ] ; then + DATA=",\"Data\":\"${@:2}\"" +fi + +echo -n "{\"API_JSON_REQUEST\":{\"Token\":\"${TOKEN}\",\"Type\":\"$TYPE\"$DATA}}" \ + | timeout 5.0 nc $HOST $PORT \ + | tail -c+23 \ +; + +echo "" diff --git a/Server/Server.cs b/Server/Server.cs index 72afa87..9082189 100644 --- a/Server/Server.cs +++ b/Server/Server.cs @@ -29,7 +29,9 @@ public class Server { Socket socket = token.HasValue ? await serverSocket.AcceptAsync(token.Value) : await serverSocket.AcceptAsync(); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); - Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + if (! Settings.Instance.JsonApi.Enabled) { + Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + } try { #pragma warning disable CS4014 @@ -157,6 +159,8 @@ public class Server { if (!await Read(memory.Memory[..Constants.HeaderSize], Constants.HeaderSize, 0)) break; PacketHeader header = GetHeader(memory.Memory.Span[..Constants.HeaderSize]); + if (first && await JsonApi.JsonApi.HandleAPIRequest(this, socket, header, memory)) { goto close; } + Range packetRange = Constants.HeaderSize..(Constants.HeaderSize + header.PacketSize); if (header.PacketSize > 0) { IMemoryOwner memTemp = memory; // header to copy to new memory @@ -294,6 +298,7 @@ public class Server { Logger.Info($"Client {remote} disconnected from the server"); } + close: // Clients.Remove(client) client.Connected = false; try { diff --git a/Server/Settings.cs b/Server/Settings.cs index fe075e5..b90e443 100644 --- a/Server/Settings.cs +++ b/Server/Settings.cs @@ -47,6 +47,7 @@ public class Settings { public DiscordTable Discord { get; set; } = new DiscordTable(); public ShineTable Shines { get; set; } = new ShineTable(); public PersistShinesTable PersistShines { get; set; } = new PersistShinesTable(); + public JsonApiTable JsonApi { get; set; } = new JsonApiTable(); public class ServerTable { public string Address { get; set; } = IPAddress.Any.ToString(); @@ -86,4 +87,10 @@ public class Settings { public bool Enabled { get; set; } = false; public string Filename { get; set; } = "./moons.json"; } -} \ No newline at end of file + + public class JsonApiTable + { + public bool Enabled { get; set; } = false; + public Dictionary> Tokens { get; set; } = new Dictionary>(); + } +}