This commit is contained in:
Robin C. Ladiges 2022-09-04 00:02:19 +02:00
parent 47505dbdd5
commit 87cec91941
No known key found for this signature in database
GPG Key ID: B494D3DF92661B99
14 changed files with 619 additions and 2 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
/Server/**/.gitignore
/Server/**/*.env
/Server/**/*.md
/Server/**/*.sh

1
Server/JsonApi/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/test.env

View File

@ -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<ApiPacket?> Read(Context ctx, string header) {
string reqStr = header + await ApiPacket.GetRequestStr(ctx);
ApiPacket? p = null;
try { p = JsonSerializer.Deserialize<ApiPacket>(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<string> 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);
}
}

View File

@ -0,0 +1,68 @@
namespace Server.JsonApi;
using System.Text.Json;
using System.Text.Json.Nodes;
using TypesDictionary = Dictionary<string, Func<Context, Task<bool>>>;
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<JsonElement>();
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<bool> 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;
}
}

View File

@ -0,0 +1,74 @@
namespace Server.JsonApi;
public static class ApiRequestCommand {
public static async Task<bool> 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<string> commands = new List<string>();
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);
}
}
}

View File

@ -0,0 +1,21 @@
namespace Server.JsonApi;
public static class ApiRequestPermissions {
public static async Task<bool> 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);
}
}
}

View File

@ -0,0 +1,125 @@
using Shared.Packet.Packets;
using System.Dynamic;
using System.Net;
namespace Server.JsonApi;
using Mutators = Dictionary<string, Action<dynamic, Client>>;
public static class ApiRequestStatus {
public static async Task<bool> 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<string, object> output = settings;
// recursively go down the path
foreach (string key in allowedSetting.Split("/")) {
lastKey = key;
if (next == null) { break; }
output = (IDictionary<string, object>) 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);
}
}
}

View File

@ -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<IPAddress, int> Failures = new ConcurrentDictionary<IPAddress, int>();
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);
}
}

42
Server/JsonApi/Context.cs Normal file
View File

@ -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<string> Permissions {
get {
if (this.request == null) { return new List<string>(); }
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);
}
}

77
Server/JsonApi/JsonApi.cs Normal file
View File

@ -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<bool> HandleAPIRequest(
Server server,
Socket socket,
PacketHeader header,
IMemoryOwner<byte> 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;
}
}

78
Server/JsonApi/README.md Normal file
View File

@ -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
```

22
Server/JsonApi/test.sh Executable file
View File

@ -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 ""

View File

@ -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<byte> 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 {

View File

@ -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";
}
}
public class JsonApiTable
{
public bool Enabled { get; set; } = false;
public Dictionary<string, List<string>> Tokens { get; set; } = new Dictionary<string, List<string>>();
}
}