diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e7cbc41 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +/Server/bin/ +/Server/obj/ +/Shared/bin/ +/Shared/obj/ diff --git a/.github/pull.yml b/.github/pull.yml new file mode 100644 index 0000000..cedbee3 --- /dev/null +++ b/.github/pull.yml @@ -0,0 +1,9 @@ +version: "1" +rules: + - base: upstream + upstream: Sanae6:master + mergeMethod: merge + conflictReviewers: + - piplup55 +conflictLabel: "conflict" +label: "Pull" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 74ffcb5..9b581b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,3 +73,35 @@ jobs: platforms : linux/amd64,linux/arm/v7,linux/arm64/v8 cache-from : type=gha,scope=${{ github.workflow }} cache-to : type=gha,scope=${{ github.workflow }},mode=max + - + name: Build binary files + run: | + ./docker-build.sh all + - + name : Upload Server + uses : actions/upload-artifact@v3 + with: + name : Server + path : ./bin/Server + if-no-files-found : error + - + name : Upload Server.arm + uses : actions/upload-artifact@v3 + with: + name : Server.arm + path : ./bin/Server.arm + if-no-files-found : error + - + name : Upload Server.arm64 + uses : actions/upload-artifact@v3 + with: + name : Server.arm64 + path : ./bin/Server.arm64 + if-no-files-found : error + - + name : Upload Server.exe + uses : actions/upload-artifact@v3 + with: + name : Server.exe + path : ./bin/Server.exe + if-no-files-found : error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 8344193..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Release job - -on: - workflow_dispatch: - push: - branches: [ server ] - -jobs: - release: - runs-on: ubuntu-latest - - env: - BASE_VERSION: "1.0" - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 6.0.x - - - name: Get version info - id: version_info - run: | - echo "::set-output name=build_version::${{ env.BASE_VERSION }}.${{ github.run_number }}" - - - name: Create output dir - run: "mkdir building" - - - name: Publish Windows - run: | - dotnet publish Server/Server.csproj -r win-x64 -c Release --sc -p:PublishSingleFile=true -o building/windows-x64 - - name: Packing Windows builds - run: | - pushd building - 7z a ../building/windows-x64.zip - popd - shell: bash - - - name: Publish Linux - run: | - dotnet publish Server/Server.csproj -r linux-x64 -c Release --sc -p:PublishSingleFile=true -o building/linux-x64 - dotnet publish Server/Server.csproj -r linux-arm -c Release --sc -p:PublishSingleFile=true -o building/linux-arm - - name: Packing Linux builds - run: | - pushd building - tar -czvf ../building/linux-x64.tar.gz linux-x64 - tar -czvf ../building/linux-arm.tar.gz linux-arm - popd - shell: bash - - - name: Pushing new release - uses: ncipollo/release-action@v1 - with: - name: ${{ steps.version_info.outputs.build_version }} - artifacts: "building/*.tar.gz,building/*.zip" - tag: ${{ steps.version_info.outputs.build_version }} - allowUpdates: true - removeArtifacts: true - replacesArtifacts: true - generateReleaseNotes: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 64e0e6f..2baf885 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ riderModule.iml .idea/ settings.json .vs/ + +/cache/ +/data/ diff --git a/README.md b/README.md index e982eca..48caed9 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # Super Mario Odyssey: Online Server -The official server for the [Super Mario Odyssey: Online](https://github.com/CraftyBoss/SuperMarioOdysseyOnline) mod. +[Are you looking for the Offical Super Mario Odyssey Online Server?](https://github.com/Sanae6/SmoOnlineServer) +## Branches + - [Releases](https://github.com/TeamPiplup/SmoOnlineServer/releases) + - [upstream](https://github.com/TeamPiplup/SmoOnlineServer/tree/upstream) - upstream is the master branch + - [packet-fixes](https://github.com/TeamPiplup/SmoOnlineServer/tree/packet-fixes) - packet-fixes contains the fixes for ryujinx + - any other branch will be a testing branch you can build(although i don't recommand it) ## Windows Setup @@ -61,28 +66,3 @@ docker-compose logs --tail=20 --follow # stop server docker-compose stop ``` - -## Commands - -Run `help` to get what commands are available in the server console. -Run the `loadsettings` command in the console to update the settings without restarting. -Server address and port will require a server restart, but everything else should update when you run `loadsettings`. - -[//]: # (TODO: Document all commands, possibly rename them too.) - -## Settings - -### Server -Address: the ip address of the server, default: 0.0.0.0 # this shouldn't be changed -Port: the port of the server, default 1027 -Maxplayers: the max amount of players that can join, default: 8 -Flip: flips the player upside down, defaults: enabled: true, pov: both -Scenario: sync's scenario's for all players on the server, default: false -Banlist: banned people are unable to join the server, default: false -PersistShines/Moons: Allows the server to remember moon progress across crashes/restarts - -### Discord -Note: Token and LogChannel needs to a string puts "" around it -Token: the token of the bot you want to load into, default: null -Prefix: the bot prefix to be used, default: $ -LogChannel: logs the server console to that channel, default: null \ No newline at end of file diff --git a/Server/BanLists.cs b/Server/BanLists.cs new file mode 100644 index 0000000..dbe0d12 --- /dev/null +++ b/Server/BanLists.cs @@ -0,0 +1,361 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +using Shared; +using Shared.Packet.Packets; + +namespace Server; + +using MUCH = Func failToFind, HashSet toActUpon, List<(string arg, IEnumerable amb)> ambig)>; + +public static class BanLists { + public static bool Enabled { + get { + return Settings.Instance.BanList.Enabled; + } + private set { + Settings.Instance.BanList.Enabled = value; + } + } + + private static ISet IPs { + get { + return Settings.Instance.BanList.IpAddresses; + } + } + + private static ISet Profiles { + get { + return Settings.Instance.BanList.Players; + } + } + + private static ISet Stages { + get { + return Settings.Instance.BanList.Stages; + } + } + + + private static bool IsIPv4(string str) { + return IPAddress.TryParse(str, out IPAddress? ip) + && ip != null + && ip.AddressFamily == AddressFamily.InterNetwork; + ; + } + + + public static bool IsIPv4Banned(Client user) { + IPEndPoint? ipv4 = (IPEndPoint?) user.Socket?.RemoteEndPoint; + if (ipv4 == null) { return false; } + return IsIPv4Banned(ipv4.Address); + } + public static bool IsIPv4Banned(IPAddress ipv4) { + return IsIPv4Banned(ipv4.ToString()); + } + public static bool IsIPv4Banned(string ipv4) { + return IPs.Contains(ipv4); + } + + public static bool IsProfileBanned(Client user) { + return IsProfileBanned(user.Id); + } + public static bool IsProfileBanned(string str) { + if (!Guid.TryParse(str, out Guid id)) { return false; } + return IsProfileBanned(id); + } + public static bool IsProfileBanned(Guid id) { + return Profiles.Contains(id); + } + + public static bool IsStageBanned(string stage) { + return Stages.Contains(stage); + } + + public static bool IsClientBanned(Client user) { + return IsProfileBanned(user) || IsIPv4Banned(user); + } + + + private static void BanIPv4(Client user) { + IPEndPoint? ipv4 = (IPEndPoint?) user.Socket?.RemoteEndPoint; + if (ipv4 != null) { + BanIPv4(ipv4.Address); + } + } + private static void BanIPv4(IPAddress ipv4) { + BanIPv4(ipv4.ToString()); + } + private static void BanIPv4(string ipv4) { + IPs.Add(ipv4); + } + + private static void BanProfile(Client user) { + BanProfile(user.Id); + } + private static void BanProfile(string str) { + if (!Guid.TryParse(str, out Guid id)) { return; } + BanProfile(id); + } + private static void BanProfile(Guid id) { + Profiles.Add(id); + } + + private static void BanStage(string stage) { + Stages.Add(stage); + } + + private static void BanClient(Client user) { + BanProfile(user); + BanIPv4(user); + } + + + private static void UnbanIPv4(Client user) { + IPEndPoint? ipv4 = (IPEndPoint?) user.Socket?.RemoteEndPoint; + if (ipv4 != null) { + UnbanIPv4(ipv4.Address); + } + } + private static void UnbanIPv4(IPAddress ipv4) { + UnbanIPv4(ipv4.ToString()); + } + private static void UnbanIPv4(string ipv4) { + IPs.Remove(ipv4); + } + + private static void UnbanProfile(Client user) { + UnbanProfile(user.Id); + } + private static void UnbanProfile(string str) { + if (!Guid.TryParse(str, out Guid id)) { return; } + UnbanProfile(id); + } + private static void UnbanProfile(Guid id) { + Profiles.Remove(id); + } + + private static void UnbanStage(string stage) { + Stages.Remove(stage); + } + + + private static void Save() { + Settings.SaveSettings(true); + } + + + public static void Crash( + Client user, + bool permanent = false, + bool dispose_user = true, + int delay_ms = 0 + ) { + user.Ignored = true; + Task.Run(async () => { + if (delay_ms > 0) { + await Task.Delay(delay_ms); + } + await user.Send(new ChangeStagePacket { + Id = (permanent ? "$agogus/ban4lyfe" : "$among$us/cr4sh%"), + Stage = (permanent ? "$ejected" : "$agogusStage"), + Scenario = (sbyte) (permanent ? 69 : 21), + SubScenarioType = (byte) (permanent ? 21 : 69), + }); + if (dispose_user) { + user.Dispose(); + } + }); + } + + private static void CrashMultiple(string[] args, MUCH much) { + foreach (Client user in much(args).toActUpon) { + Crash(user, true); + } + } + + + public static string HandleBanCommand(string[] args, MUCH much) { + if (args.Length == 0) { + return "Usage: ban {list|enable|disable|player|profile|ip|stage} ..."; + } + + string cmd = args[0]; + args = args.Skip(1).ToArray(); + + switch (cmd) { + default: + return "Usage: ban {list|enable|disable|player|profile|ip|stage} ..."; + + case "list": + if (args.Length != 0) { + return "Usage: ban list"; + } + StringBuilder list = new StringBuilder(); + list.Append("BanList: " + (Enabled ? "enabled" : "disabled")); + + if (IPs.Count > 0) { + list.Append("\nBanned IPv4 addresses:\n- "); + list.Append(string.Join("\n- ", IPs)); + } + + if (Profiles.Count > 0) { + list.Append("\nBanned profile IDs:\n- "); + list.Append(string.Join("\n- ", Profiles)); + } + + if (Stages.Count > 0) { + list.Append("\nBanned stages:\n- "); + list.Append(string.Join("\n- ", Stages)); + } + + return list.ToString(); + + case "enable": + if (args.Length != 0) { + return "Usage: ban enable"; + } + Enabled = true; + Save(); + return "BanList enabled."; + + case "disable": + if (args.Length != 0) { + return "Usage: ban disable"; + } + Enabled = false; + Save(); + return "BanList disabled."; + + case "player": + if (args.Length == 0) { + return "Usage: ban player <* | !* (usernames to not ban...) | (usernames to ban...)>"; + } + + var res = much(args); + + StringBuilder sb = new StringBuilder(); + sb.Append(res.toActUpon.Count > 0 ? "Banned players: " + string.Join(", ", res.toActUpon.Select(x => $"\"{x.Name}\"")) : ""); + sb.Append(res.failToFind.Count > 0 ? "\nFailed to find matches for: " + string.Join(", ", res.failToFind.Select(x => $"\"{x.ToLower()}\"")) : ""); + if (res.ambig.Count > 0) { + res.ambig.ForEach(x => { + sb.Append($"\nAmbiguous for \"{x.arg}\": {string.Join(", ", x.amb.Select(x => $"\"{x}\""))}"); + }); + } + + foreach (Client user in res.toActUpon) { + BanClient(user); + Crash(user, true); + } + + Save(); + return sb.ToString(); + + case "profile": + if (args.Length != 1) { + return "Usage: ban profile "; + } + if (!Guid.TryParse(args[0], out Guid id)) { + return "Invalid profile ID value!"; + } + if (IsProfileBanned(id)) { + return "Profile " + id.ToString() + " is already banned."; + } + BanProfile(id); + CrashMultiple(args, much); + Save(); + return "Banned profile: " + id.ToString(); + + case "ip": + if (args.Length != 1) { + return "Usage: ban ip "; + } + if (!IsIPv4(args[0])) { + return "Invalid IPv4 address!"; + } + if (IsIPv4Banned(args[0])) { + return "IP " + args[0] + " is already banned."; + } + BanIPv4(args[0]); + CrashMultiple(args, much); + Save(); + return "Banned ip: " + args[0]; + + case "stage": + if (args.Length != 1) { + return "Usage: ban stage "; + } + string? stage = Shared.Stages.Input2Stage(args[0]); + if (stage == null) { + return "Invalid stage name!"; + } + if (IsStageBanned(stage)) { + return "Stage " + stage + " is already banned."; + } + var stages = Shared.Stages + .StagesByInput(args[0]) + .Where(s => !IsStageBanned(s)) + .ToList() + ; + foreach (string s in stages) { + BanStage(s); + } + Save(); + return "Banned stage: " + string.Join(", ", stages); + } + } + + + public static string HandleUnbanCommand(string[] args) { + if (args.Length != 2) { + return "Usage: unban {profile|ip|stage} "; + } + + string cmd = args[0]; + string val = args[1]; + + switch (cmd) { + default: + return "Usage: unban {profile|ip|stage} "; + + case "profile": + if (!Guid.TryParse(val, out Guid id)) { + return "Invalid profile ID value!"; + } + if (!IsProfileBanned(id)) { + return "Profile " + id.ToString() + " is not banned."; + } + UnbanProfile(id); + Save(); + return "Unbanned profile: " + id.ToString(); + + case "ip": + if (!IsIPv4(val)) { + return "Invalid IPv4 address!"; + } + if (!IsIPv4Banned(val)) { + return "IP " + val + " is not banned."; + } + UnbanIPv4(val); + Save(); + return "Unbanned ip: " + val; + + case "stage": + string stage = Shared.Stages.Input2Stage(val) ?? val; + if (!IsStageBanned(stage)) { + return "Stage " + stage + " is not banned."; + } + var stages = Shared.Stages + .StagesByInput(val) + .Where(IsStageBanned) + .ToList() + ; + foreach (string s in stages) { + UnbanStage(s); + } + Save(); + return "Unbanned stage: " + string.Join(", ", stages); + } + } +} diff --git a/Server/Client.cs b/Server/Client.cs index 5f3336f..4b53525 100644 --- a/Server/Client.cs +++ b/Server/Client.cs @@ -12,6 +12,7 @@ namespace Server; public class Client : IDisposable { public readonly ConcurrentDictionary Metadata = new ConcurrentDictionary(); // can be used to store any information about a player public bool Connected = false; + public bool Ignored = false; public CostumePacket? CurrentCostume = null; // required for proper client sync public string Name { get => Logger.Name; @@ -76,6 +77,17 @@ public async Task Send(Memory data, Client? sender) { await Socket!.SendAsync(data[..(Constants.HeaderSize + header.PacketSize)], SocketFlags.None); } + public void CleanMetadataOnNewConnection() { + object? tmp; + Metadata.TryRemove("time", out tmp); + Metadata.TryRemove("seeking", out tmp); + Metadata.TryRemove("lastCostumePacket", out tmp); + Metadata.TryRemove("lastCapturePacket", out tmp); + Metadata.TryRemove("lastTagPacket", out tmp); + Metadata.TryRemove("lastGamePacket", out tmp); + Metadata.TryRemove("lastPlayerPacket", out tmp); + } + public static bool operator ==(Client? left, Client? right) { return left is { } leftClient && right is { } rightClient && leftClient.Id == rightClient.Id; } diff --git a/Server/DiscordBot.cs b/Server/DiscordBot.cs index 654d2d4..53f41dd 100644 --- a/Server/DiscordBot.cs +++ b/Server/DiscordBot.cs @@ -1,15 +1,12 @@ -#define SEND_RESP_TO_BAD_REQ //should the bot send a message to people who attempt to run a command from an invalid location? (Comment out to disable) -#define LOG_BAD_REQ //should the bot log aformentioned invalid requests? -#define LOG_CHANNELS_ON_COMMAND_ATTEMPT_VERBOSE //print a message describing relevant channel ids whenever a command is attempted to be sent? - -using DSharpPlus; +using DSharpPlus; using DSharpPlus.Entities; using Microsoft.Extensions.Logging; using Shared; namespace Server; -public class DiscordBot { +public class DiscordBot +{ private DiscordClient? DiscordClient; private string? Token; private Settings.DiscordTable Config => Settings.Instance.Discord; @@ -19,9 +16,8 @@ public class DiscordBot { private DiscordChannel? LogChannel; private bool Reconnecting; - private bool warnedAboutNullLogChannel = false; //print warning message - - public DiscordBot() { + public DiscordBot() + { Token = Config.Token; Logger.AddLogHandler(Log); CommandHandler.RegisterCommand("dscrestart", _ => { @@ -30,6 +26,10 @@ public DiscordBot() { Task.Run(Reconnect); return "Restarting Discord bot"; }); + CommandHandler.RegisterCommand("printdiscinfo", _ => + { + return $"comm: \"{Config.CommandChannel}\", log: \"{Config.LogChannel}\", pref: \"{Prefix}\""; + }); if (Config.Token == null) return; if (Config.CommandChannel == null) Logger.Warn("You probably should set your CommandChannel in settings.json"); @@ -38,43 +38,75 @@ public DiscordBot() { Settings.LoadHandler += SettingsLoadHandler; } - private async Task Reconnect() { - warnedAboutNullLogChannel = false; + private async Task Reconnect() + { if (DiscordClient != null) // usually null prop works, not here though...` await DiscordClient.DisconnectAsync(); await Run(); } - private async void SettingsLoadHandler() { - try { - if (DiscordClient == null || Token != Config.Token) - await Run(); - if (Config.LogChannel != null) - LogChannel = await (DiscordClient?.GetChannelAsync(ulong.Parse(Config.LogChannel)) ?? - throw new NullReferenceException("Discord client not setup yet!")); - } catch (Exception e) { - Logger.Error($"Failed to get log channel \"{Config.LogChannel}\""); - Logger.Error(e); + private async void SettingsLoadHandler() + { + if (DiscordClient == null || Token != Config.Token) + { + await Run(); + } + + if (DiscordClient == null) + { + Logger.Error(new NullReferenceException("Discord client not setup yet!")); + return; + } + + if (Config.CommandChannel != null) + { + try + { + CommandChannel = await DiscordClient.GetChannelAsync(ulong.Parse(Config.CommandChannel)); + } + catch (Exception e) + { + Logger.Error($"Failed to get command channel \"{Config.CommandChannel}\""); + Logger.Error(e); + } + } + + if (Config.LogChannel != null) + { + try + { + LogChannel = await DiscordClient.GetChannelAsync(ulong.Parse(Config.LogChannel)); + } + catch (Exception e) + { + Logger.Error($"Failed to get log channel \"{Config.LogChannel}\""); + Logger.Error(e); + } } } private static List SplitMessage(string message, int maxSizePerElem = 2000) { List result = new List(); - for (int i = 0; i < message.Length; i += maxSizePerElem) + for (int i = 0; i < message.Length; i += maxSizePerElem) { result.Add(message.Substring(i, message.Length - i < maxSizePerElem ? message.Length - i : maxSizePerElem)); } return result; } - private async void Log(string source, string level, string text, ConsoleColor _) { - try { - if (DiscordClient != null && LogChannel != null) { + private async void Log(string source, string level, string text, ConsoleColor _) + { + try + { + if (DiscordClient != null && LogChannel != null) + { foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"{level} [{source}]"), 1994)) //room for 6 '`' await DiscordClient.SendMessageAsync(LogChannel, $"```{mesg}```"); } - } catch (Exception e) { + } + catch (Exception e) + { // don't log again, it'll just stack overflow the server! if (Reconnecting) return; // skip if reconnecting await Console.Error.WriteLineAsync("Exception in discord logger"); @@ -82,16 +114,20 @@ private async void Log(string source, string level, string text, ConsoleColor _) } } - public async Task Run() { + public async Task Run() + { Token = Config.Token; DiscordClient?.Dispose(); - if (Config.Token == null) { + if (Config.Token == null) + { DiscordClient = null; return; } - try { - DiscordClient = new DiscordClient(new DiscordConfiguration { + try + { + DiscordClient = new DiscordClient(new DiscordConfiguration + { Token = Config.Token, MinimumLogLevel = LogLevel.None }); @@ -102,17 +138,32 @@ public async Task Run() { Reconnecting = false; string mentionPrefix = $"{DiscordClient.CurrentUser.Mention}"; DiscordClient.MessageCreated += async (_, args) => { - if (args.Author.IsCurrent) return; - try { + if (args.Author.IsCurrent) return; //dont respond to commands from ourselves (prevent "sql-injection" esq attacks) + //prevent commands via dm and non-public channels + if (CommandChannel == null) + { + if (args.Channel is DiscordDmChannel) + return; //no dm'ing the bot allowed! + } + else if (args.Channel.Id != CommandChannel.Id && (LogChannel != null && args.Channel.Id != LogChannel.Id)) + return; + //run command + try + { DiscordMessage msg = args.Message; string? resp = null; - if (string.IsNullOrEmpty(Prefix)) { + if (string.IsNullOrEmpty(Prefix)) + { await msg.Channel.TriggerTypingAsync(); resp = string.Join('\n', CommandHandler.GetResult(msg.Content).ReturnStrings); - } else if (msg.Content.StartsWith(Prefix)) { + } + else if (msg.Content.StartsWith(Prefix)) + { await msg.Channel.TriggerTypingAsync(); resp = string.Join('\n', CommandHandler.GetResult(msg.Content[Prefix.Length..]).ReturnStrings); - } else if (msg.Content.StartsWith(mentionPrefix)) { + } + else if (msg.Content.StartsWith(mentionPrefix)) + { await msg.Channel.TriggerTypingAsync(); resp = string.Join('\n', CommandHandler.GetResult(msg.Content[mentionPrefix.Length..].TrimStart()).ReturnStrings); } @@ -121,7 +172,9 @@ public async Task Run() { foreach (string mesg in SplitMessage(resp)) await msg.RespondAsync(mesg); } - } catch (Exception e) { + } + catch (Exception e) + { Logger.Error(e); } }; @@ -135,9 +188,11 @@ public async Task Run() { Logger.Error(args.Exception); return Task.CompletedTask; }; - } catch (Exception e) { + } + catch (Exception e) + { Logger.Error("Exception occurred in discord runner!"); Logger.Error(e); } } -} +} \ No newline at end of file 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..603382e --- /dev/null +++ b/Server/JsonApi/ApiRequestStatus.cs @@ -0,0 +1,231 @@ +using Shared; +using Shared.Packet.Packets; +using System.Dynamic; +using System.Net; +using System.Numerics; +using System.Text.Json.Serialization; + +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)) + ; + + var has_results = false; + + // 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 + var prop = input.GetType().GetProperty(key); + if (prop == null) { + JsonApi.Logger.Warn($"Property \"{allowedSetting}\" doesn't exist on the Settings object. This is probably a misconfiguration in the settings.json"); + goto next; + } + input = prop.GetValue(input, null); + } + + if (lastKey != "") { + // copy key with the actual value + output.Remove(lastKey); + output.Add(lastKey, input); + has_results = true; + } + + next:; + } + + if (!has_results) { return null; } + return settings; + } + + + private class StatusResponse { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public dynamic? Settings { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public dynamic[]? Players { get; set; } + } + + + private static class Player { + private static Mutators Mutators = new Mutators { + ["Status/Players/ID"] = (dynamic p, Client c) => p.ID = c.Id, + ["Status/Players/Name"] = (dynamic p, Client c) => p.Name = c.Name, + ["Status/Players/Kingdom"] = (dynamic p, Client c) => p.Kingdom = Player.GetKingdom(c), + ["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/Position"] = (dynamic p, Client c) => p.Position = Position.FromVector3(Player.GetPlayerPacket(c)?.Position ?? null), + ["Status/Players/Rotation"] = (dynamic p, Client c) => p.Rotation = Rotation.FromQuaternion(Player.GetPlayerPacket(c)?.Rotation ?? null), + ["Status/Players/Tagged"] = (dynamic p, Client c) => p.Tagged = Player.GetTagged(c), + ["Status/Players/Costume"] = (dynamic p, Client c) => p.Costume = Costume.FromClient(c), + ["Status/Players/Capture"] = (dynamic p, Client c) => p.Capture = Player.GetCapture(c), + ["Status/Players/Is2D"] = (dynamic p, Client c) => p.Is2D = Player.GetGamePacket(c)?.Is2d ?? null, + ["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 static PlayerPacket? GetPlayerPacket(Client c) { + object? lastPlayerPacket = null; + c.Metadata.TryGetValue("lastPlayerPacket", out lastPlayerPacket); + if (lastPlayerPacket == null) { return null; } + return (PlayerPacket) lastPlayerPacket; + } + + + private static bool? GetTagged(Client c) { + object? lastTagPacket = null; + c.Metadata.TryGetValue("lastTagPacket", out lastTagPacket); + if (lastTagPacket == null) { return null; } + return ((TagPacket) lastTagPacket).IsIt; + } + + + private static string? GetCapture(Client c) { + object? lastCapturePacket = null; + c.Metadata.TryGetValue("lastCapturePacket", out lastCapturePacket); + if (lastCapturePacket == null) { return null; } + CapturePacket p = (CapturePacket) lastCapturePacket; + if (p.ModelName == "") { return null; } + return p.ModelName; + } + + + private static string? GetKingdom(Client c) { + string? stage = Player.GetGamePacket(c)?.Stage ?? null; + if (stage == null) { return null; } + + Stages.Stage2Alias.TryGetValue(stage, out string? alias); + if (alias == null) { return null; } + + if (Stages.Alias2Kingdom.Contains(alias)) { + return (string?) Stages.Alias2Kingdom[alias]; + } + + return null; + } + } + + + 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); + } + } + + + private class Position { + public float X { get; private set; } + public float Y { get; private set; } + public float Z { get; private set; } + + + private Position(float X, float Y, float Z) { + this.X = X; + this.Y = Y; + this.Z = Z; + } + + public static Position? FromVector3(Vector3? pos) { + if (pos == null) { return null; } + Vector3 p = (Vector3) pos; + return new Position(p.X, p.Y, p.Z); + } + } + + + private class Rotation { + public float W { get; private set; } + public float X { get; private set; } + public float Y { get; private set; } + public float Z { get; private set; } + + private Rotation(float W, float X, float Y, float Z) { + this.W = W; + this.X = X; + this.Y = Y; + this.Z = Z; + } + + public static Rotation? FromQuaternion(Quaternion? quat) { + if (quat == null) { return null; } + Quaternion q = (Quaternion) quat; + return new Rotation(q.W, q.X, q.Y, q.Z); + } + } +} 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..1f2880b --- /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 SortedSet Permissions { + get { + if (this.request == null) { return new SortedSet(); } + 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..1ffd70f --- /dev/null +++ b/Server/JsonApi/README.md @@ -0,0 +1,85 @@ +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/ID` +- `Status/Players/Name` +- `Status/Players/Kingdom` +- `Status/Players/Stage` +- `Status/Players/Scenario` +- `Status/Players/Position` +- `Status/Players/Rotation` +- `Status/Players/Tagged` +- `Status/Players/Costume` +- `Status/Players/Capture` +- `Status/Players/Is2D` +- `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..9d2b4f2 --- /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:-Status}" + +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 \ + | jq \ +; +echo "" diff --git a/Server/Program.cs b/Server/Program.cs index 7e46b1e..c9728d8 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -62,23 +62,11 @@ async Task LoadShines() await LoadShines(); server.ClientJoined += (c, _) => { - if (Settings.Instance.BanList.Enabled - && (Settings.Instance.BanList.Players.Contains(c.Id) - || Settings.Instance.BanList.IpAddresses.Contains( - ((IPEndPoint) c.Socket!.RemoteEndPoint!).Address.ToString()))) - throw new Exception($"Banned player attempted join: {c.Name}"); c.Metadata["shineSync"] = new ConcurrentBag(); c.Metadata["loadedSave"] = false; c.Metadata["scenario"] = (byte?) 0; c.Metadata["2d"] = false; c.Metadata["speedrun"] = false; - foreach (Client client in server.ClientsConnected) { - try { - c.Send((GamePacket) client.Metadata["lastGamePacket"]!, client).Wait(); - } catch { - // lol who gives a fuck - } - } }; async Task ClientSyncShineBag(Client client) { @@ -115,13 +103,41 @@ async void SyncShineBag() { float MarioSize(bool is2d) => is2d ? 180 : 160; +void flipPlayer(Client c, ref PlayerPacket pp) { + pp.Position += Vector3.UnitY * MarioSize((bool) c.Metadata["2d"]!); + pp.Rotation *= ( + Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationX(MathF.PI)) + * Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationY(MathF.PI)) + ); +}; + +void logError(Task x) { + if (x.Exception != null) { + consoleLogger.Error(x.Exception.ToString()); + } +}; + server.PacketHandler = (c, p) => { switch (p) { case GamePacket gamePacket: { + if (BanLists.Enabled && BanLists.IsStageBanned(gamePacket.Stage)) { + c.Logger.Warn($"Crashing player for entering banned stage {gamePacket.Stage}."); + BanLists.Crash(c, false, false, 500); + return false; + } c.Logger.Info($"Got game packet {gamePacket.Stage}->{gamePacket.ScenarioNum}"); + + // reset lastPlayerPacket on stage changes + object? old = null; + c.Metadata.TryGetValue("lastGamePacket", out old); + if (old != null && ((GamePacket) old).Stage != gamePacket.Stage) { + c.Metadata["lastPlayerPacket"] = null; + } + c.Metadata["scenario"] = gamePacket.ScenarioNum; c.Metadata["2d"] = gamePacket.Is2d; c.Metadata["lastGamePacket"] = gamePacket; + switch (gamePacket.Stage) { case "CapWorldHomeStage" when gamePacket.ScenarioNum == 0: c.Metadata["speedrun"] = true; @@ -145,8 +161,7 @@ async void SyncShineBag() { server.BroadcastReplace(gamePacket, c, (from, to, gp) => { gp.ScenarioNum = (byte?) to.Metadata["scenario"] ?? 200; #pragma warning disable CS4014 - to.Send(gp, from) - .ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } }); + to.Send(gp, from).ContinueWith(logError); #pragma warning restore CS4014 }); return false; @@ -156,14 +171,23 @@ async void SyncShineBag() { } case TagPacket tagPacket: { + // c.Logger.Info($"Got tag packet: {tagPacket.IsIt}"); + c.Metadata["lastTagPacket"] = tagPacket; if ((tagPacket.UpdateType & TagPacket.TagUpdate.State) != 0) c.Metadata["seeking"] = tagPacket.IsIt; if ((tagPacket.UpdateType & TagPacket.TagUpdate.Time) != 0) c.Metadata["time"] = new Time(tagPacket.Minutes, tagPacket.Seconds, DateTime.Now); break; } + case CapturePacket capturePacket: { + // c.Logger.Info($"Got capture packet: {capturePacket.ModelName}"); + c.Metadata["lastCapturePacket"] = capturePacket; + break; + } + case CostumePacket costumePacket: c.Logger.Info($"Got costume packet: {costumePacket.BodyName}, {costumePacket.CapName}"); + c.Metadata["lastCostumePacket"] = costumePacket; c.CurrentCostume = costumePacket; #pragma warning disable CS4014 ClientSyncShineBag(c); //no point logging since entire def has try/catch @@ -183,33 +207,35 @@ async void SyncShineBag() { break; } - case PlayerPacket playerPacket when Settings.Instance.Flip.Enabled - && Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Others - && Settings.Instance.Flip.Players.Contains(c.Id): { - playerPacket.Position += Vector3.UnitY * MarioSize((bool) c.Metadata["2d"]!); - playerPacket.Rotation *= Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationX(MathF.PI)) - * Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationY(MathF.PI)); + case PlayerPacket playerPacket: { + c.Metadata["lastPlayerPacket"] = playerPacket; + // flip for all + if ( Settings.Instance.Flip.Enabled + && Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Others + && Settings.Instance.Flip.Players.Contains(c.Id) + ) { + flipPlayer(c, ref playerPacket); #pragma warning disable CS4014 - server.Broadcast(playerPacket, c) - .ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } }); + server.Broadcast(playerPacket, c).ContinueWith(logError); #pragma warning restore CS4014 - return false; - } - case PlayerPacket playerPacket when Settings.Instance.Flip.Enabled - && Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Self - && !Settings.Instance.Flip.Players.Contains(c.Id): { - server.BroadcastReplace(playerPacket, c, (from, to, sp) => { - if (Settings.Instance.Flip.Players.Contains(to.Id)) { - sp.Position += Vector3.UnitY * MarioSize((bool) c.Metadata["2d"]!); - sp.Rotation *= Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationX(MathF.PI)) - * Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationY(MathF.PI)); - } + return false; + } + // flip only for specific clients + if ( Settings.Instance.Flip.Enabled + && Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Self + && !Settings.Instance.Flip.Players.Contains(c.Id) + ) { + server.BroadcastReplace(playerPacket, c, (from, to, sp) => { + if (Settings.Instance.Flip.Players.Contains(to.Id)) { + flipPlayer(c, ref sp); + } #pragma warning disable CS4014 - to.Send(sp, from) - .ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } }); + to.Send(sp, from).ContinueWith(logError); #pragma warning restore CS4014 - }); - return false; + }); + return false; + } + break; } } @@ -220,38 +246,49 @@ async void SyncShineBag() { HashSet failToFind = new(); HashSet toActUpon; List<(string arg, IEnumerable amb)> ambig = new(); - if (args[0] == "*") + if (args[0] == "*") { toActUpon = new(server.Clients.Where(c => c.Connected)); + } else { toActUpon = args[0] == "!*" ? new(server.Clients.Where(c => c.Connected)) : new(); for (int i = (args[0] == "!*" ? 1 : 0); i < args.Length; i++) { string arg = args[i]; - IEnumerable search = server.Clients.Where(c => c.Connected && - (c.Name.ToLower().StartsWith(arg.ToLower()) || (Guid.TryParse(arg, out Guid res) && res == c.Id))); - if (!search.Any()) + IEnumerable search = server.Clients.Where(c => c.Connected && ( + c.Name.ToLower().StartsWith(arg.ToLower()) + || (Guid.TryParse(arg, out Guid res) && res == c.Id) + || (IPAddress.TryParse(arg, out IPAddress? ip) && ip.Equals(((IPEndPoint) c.Socket!.RemoteEndPoint!).Address)) + )); + if (!search.Any()) { failToFind.Add(arg); //none found + } else if (search.Count() > 1) { Client? exact = search.FirstOrDefault(x => x.Name == arg); if (!ReferenceEquals(exact, null)) { //even though multiple matches, since exact match, it isn't ambiguous - if (args[0] == "!*") + if (args[0] == "!*") { toActUpon.Remove(exact); - else + } + else { toActUpon.Add(exact); + } } else { - if (!ambig.Any(x => x.arg == arg)) + if (!ambig.Any(x => x.arg == arg)) { ambig.Add((arg, search.Select(x => x.Name))); //more than one match - foreach (var rem in search.ToList()) //need copy because can't remove from list while iterating over it + } + foreach (var rem in search.ToList()) { //need copy because can't remove from list while iterating over it toActUpon.Remove(rem); + } } } else { //only one match, so autocomplete - if (args[0] == "!*") + if (args[0] == "!*") { toActUpon.Remove(search.First()); - else + } + else { toActUpon.Add(search.First()); + } } } } @@ -298,54 +335,14 @@ async void SyncShineBag() { } foreach (Client user in res.toActUpon) { - Task.Run(async () => { - await user.Send(new ChangeStagePacket { - Id = "$among$us/SubArea", - Stage = "$agogusStage", - Scenario = 21, - SubScenarioType = 69 // invalid id - }); - user.Dispose(); - }); + BanLists.Crash(user); } return sb.ToString(); }); -CommandHandler.RegisterCommand("ban", args => { - if (args.Length == 0) { - return "Usage: ban <* | !* (usernames to not ban...) | (usernames to ban...)>"; - } - - var res = MultiUserCommandHelper(args); - - StringBuilder sb = new StringBuilder(); - sb.Append(res.toActUpon.Count > 0 ? "Banned: " + string.Join(", ", res.toActUpon.Select(x => $"\"{x.Name}\"")) : ""); - sb.Append(res.failToFind.Count > 0 ? "\nFailed to find matches for: " + string.Join(", ", res.failToFind.Select(x => $"\"{x.ToLower()}\"")) : ""); - if (res.ambig.Count > 0) { - res.ambig.ForEach(x => { - sb.Append($"\nAmbiguous for \"{x.arg}\": {string.Join(", ", x.amb.Select(x => $"\"{x}\""))}"); - }); - } - - foreach (Client user in res.toActUpon) { - Task.Run(async () => { - await user.Send(new ChangeStagePacket { - Id = "$agogus/banned4lyfe", - Stage = "$ejected", - Scenario = 69, - SubScenarioType = 21 // invalid id - }); - IPEndPoint? endpoint = (IPEndPoint?) user.Socket?.RemoteEndPoint; - Settings.Instance.BanList.Players.Add(user.Id); - if (endpoint != null) Settings.Instance.BanList.IpAddresses.Add(endpoint.ToString()); - user.Dispose(); - }); - } - - Settings.SaveSettings(); - return sb.ToString(); -}); +CommandHandler.RegisterCommand("ban", args => { return BanLists.HandleBanCommand(args, (args) => MultiUserCommandHelper(args)); }); +CommandHandler.RegisterCommand("unban", args => { return BanLists.HandleUnbanCommand(args); }); CommandHandler.RegisterCommand("send", args => { const string optionUsage = "Usage: send "; @@ -651,7 +648,7 @@ await c.Send(new ShinePacket { } } } -}).ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } }); +}).ContinueWith(logError); #pragma warning restore CS4014 await server.Listen(cts.Token); diff --git a/Server/Server.cs b/Server/Server.cs index 30646d7..d05ebef 100644 --- a/Server/Server.cs +++ b/Server/Server.cs @@ -1,19 +1,20 @@ -using DSharpPlus; -using DSharpPlus.Entities; -using Microsoft.Extensions.Logging; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; using Shared; +using Shared.Packet; +using Shared.Packet.Packets; namespace Server; -public class DiscordBot { - private DiscordClient? DiscordClient; - private string? Token; - private Settings.DiscordTable Config => Settings.Instance.Discord; - private string Prefix => Config.Prefix; - private readonly Logger Logger = new Logger("Discord"); - private DiscordChannel? CommandChannel; - private DiscordChannel? LogChannel; - private bool Reconnecting; +public class Server { + public readonly List Clients = new List(); + public IEnumerable ClientsConnected => Clients.Where(client => client.Metadata.ContainsKey("lastGamePacket") && client.Connected); + public readonly Logger Logger = new Logger("Server"); + private readonly MemoryPool memoryPool = MemoryPool.Shared; + public Func? PacketHandler = null!; + public event Action ClientJoined = null!; public async Task Listen(CancellationToken? token = null) { Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); @@ -28,7 +29,15 @@ public async Task Listen(CancellationToken? token = null) { Socket socket = token.HasValue ? await serverSocket.AcceptAsync(token.Value) : await serverSocket.AcceptAsync(); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true); + if (BanLists.Enabled && BanLists.IsIPv4Banned(((IPEndPoint) socket.RemoteEndPoint!).Address!)) { + Logger.Warn($"Ignoring banned IPv4 address {socket.RemoteEndPoint}"); + continue; + } + 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 @@ -63,7 +72,7 @@ public async Task Listen(CancellationToken? token = null) { public static void FillPacket(PacketHeader header, T packet, Memory memory) where T : struct, IPacket { Span data = memory.Span; - + header.Serialize(data[..Constants.HeaderSize]); packet.Serialize(data[Constants.HeaderSize..]); } @@ -156,6 +165,8 @@ async Task Read(Memory readMem, int readSize, int readOffset) { 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 @@ -166,6 +177,11 @@ async Task Read(Memory readMem, int readSize, int readOffset) { break; } + if (client.Ignored) { + memory.Dispose(); + continue; + } + // connection initialization if (first) { first = false; @@ -173,6 +189,8 @@ async Task Read(Memory readMem, int readSize, int readOffset) { ConnectPacket connect = new ConnectPacket(); connect.Deserialize(memory.Memory.Span[packetRange]); + bool wasFirst = connect.ConnectionType == ConnectPacket.ConnectionTypes.FirstConnection; + lock (Clients) { if (Clients.Count(x => x.Connected) == Settings.Instance.Server.MaxPlayers) { client.Logger.Error($"Turned away as server is at max clients"); @@ -211,34 +229,41 @@ async Task Read(Memory readMem, int readSize, int readOffset) { List toDisconnect = Clients.FindAll(c => c.Id == header.Id && c.Connected && c.Socket != null); Clients.RemoveAll(c => c.Id == header.Id); - client.Id = header.Id; Clients.Add(client); Parallel.ForEachAsync(toDisconnect, (c, token) => c.Socket!.DisconnectAsync(false, token)); // done disconnecting and removing stale clients with the same id ClientJoined?.Invoke(client, connect); + // a new connection, not a reconnect, for an existing client + } else if (wasFirst) { + client.CleanMetadataOnNewConnection(); } } + // for all other clients that are already connected List otherConnectedPlayers = Clients.FindAll(c => c.Id != header.Id && c.Connected && c.Socket != null); await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { IMemoryOwner tempBuffer = MemoryPool.Shared.RentZero(Constants.HeaderSize + (other.CurrentCostume.HasValue ? Math.Max(connect.Size, other.CurrentCostume.Value.Size) : connect.Size)); + + // make the other client known to the (new) client PacketHeader connectHeader = new PacketHeader { - Id = other.Id, - Type = PacketType.Connect, - PacketSize = connect.Size + Id = other.Id, + Type = PacketType.Connect, + PacketSize = connect.Size, }; connectHeader.Serialize(tempBuffer.Memory.Span[..Constants.HeaderSize]); ConnectPacket connectPacket = new ConnectPacket { ConnectionType = ConnectPacket.ConnectionTypes.FirstConnection, // doesn't matter what it is - MaxPlayers = Settings.Instance.Server.MaxPlayers, - ClientName = other.Name + MaxPlayers = Settings.Instance.Server.MaxPlayers, + ClientName = other.Name, }; connectPacket.Serialize(tempBuffer.Memory.Span[Constants.HeaderSize..]); await client.Send(tempBuffer.Memory[..(Constants.HeaderSize + connect.Size)], null); + + // tell the (new) client what costume the other client has if (other.CurrentCostume.HasValue) { - connectHeader.Type = PacketType.Costume; + connectHeader.Type = PacketType.Costume; connectHeader.PacketSize = other.CurrentCostume.Value.Size; connectHeader.Serialize(tempBuffer.Memory.Span[..Constants.HeaderSize]); other.CurrentCostume.Value.Serialize(tempBuffer.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + connectHeader.PacketSize)]); @@ -246,21 +271,21 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { } tempBuffer.Dispose(); + + // make the other client reset their puppet cache for this client, if it is a new connection (after restart) + if (wasFirst) { + await SendEmptyPackets(client, other); + } }); - Logger.Info($"Client {client.Name} ({client.Id}/{socket.RemoteEndPoint}) connected."); + Logger.Info($"Client {client.Name} ({client.Id}/{remote}) connected."); + + // send missing or outdated packets from others to the new client + await ResendPackets(client); } else if (header.Id != client.Id && client.Id != Guid.Empty) { throw new Exception($"Client {client.Name} sent packet with invalid client id {header.Id} instead of {client.Id}"); } - if (header.Type == PacketType.Costume) { - CostumePacket costumePacket = new CostumePacket { - BodyName = "" - }; - costumePacket.Deserialize(memory.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + costumePacket.Size)]); - client.CurrentCostume = costumePacket; - } - try { IPacket packet = (IPacket) Activator.CreateInstance(Constants.PacketIdMap[header.Type])!; packet.Deserialize(memory.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + packet.Size)]); @@ -295,8 +320,15 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { } disconnect: - Logger.Info($"Client {socket.RemoteEndPoint} ({client.Name}/{client.Id}) disconnected from the server"); + if (client.Name != "Unknown User" && client.Id != Guid.Parse("00000000-0000-0000-0000-000000000000")) { + Logger.Info($"Client {remote} ({client.Name}/{client.Id}) disconnected from the server"); + } + else { + Logger.Info($"Client {remote} disconnected from the server"); + } + close: + bool wasConnected = client.Connected; // Clients.Remove(client) client.Connected = false; try { @@ -305,11 +337,45 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { catch { /*lol*/ } #pragma warning disable CS4014 - Task.Run(() => Broadcast(new DisconnectPacket(), client)) - .ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } }); + if (wasConnected) { + Task.Run(() => Broadcast(new DisconnectPacket(), client)) + .ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } }); + } #pragma warning restore CS4014 } + private async Task ResendPackets(Client client) { + async Task trySend(Client other, string packetType) where T : struct, IPacket { + if (! other.Metadata.ContainsKey(packetType)) { return; } + try { + await client.Send((T) other.Metadata[packetType]!, other); + } + catch { + // lol who gives a fuck + } + }; + await Parallel.ForEachAsync(this.ClientsConnected, async (other, _) => { + if (client.Id == other.Id) { return; } + await trySend(other, "lastCostumePacket"); + await trySend(other, "lastCapturePacket"); + await trySend(other, "lastTagPacket"); + await trySend(other, "lastGamePacket"); + await trySend(other, "lastPlayerPacket"); + }); + } + + private async Task SendEmptyPackets(Client client, Client other) { + await other.Send(new TagPacket { + UpdateType = TagPacket.TagUpdate.State | TagPacket.TagUpdate.Time, + IsIt = false, + Seconds = 0, + Minutes = 0, + }, client); + await other.Send(new CapturePacket { + ModelName = "", + }, client); + } + private static PacketHeader GetHeader(Span data) { //no need to error check, the client will disconnect when the packet is invalid :) PacketHeader header = new PacketHeader(); diff --git a/Server/Settings.cs b/Server/Settings.cs index fe075e5..c57101c 100644 --- a/Server/Settings.cs +++ b/Server/Settings.cs @@ -30,10 +30,10 @@ public static void LoadSettings() { LoadHandler?.Invoke(); } - public static void SaveSettings() { + public static void SaveSettings(bool silent = false) { try { File.WriteAllText("settings.json", JsonConvert.SerializeObject(Instance, Formatting.Indented, new StringEnumConverter(new CamelCaseNamingStrategy()))); - Logger.Info("Saved settings to settings.json"); + if (!silent) { Logger.Info("Saved settings to settings.json"); } } catch (Exception e) { Logger.Error($"Failed to save settings.json {e}"); @@ -43,10 +43,11 @@ public static void SaveSettings() { public ServerTable Server { get; set; } = new ServerTable(); public FlipTable Flip { get; set; } = new FlipTable(); public ScenarioTable Scenario { get; set; } = new ScenarioTable(); - public BannedPlayers BanList { get; set; } = new BannedPlayers(); + public BanListTable BanList { get; set; } = new BanListTable(); 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(); @@ -58,15 +59,16 @@ public class ScenarioTable { public bool MergeEnabled { get; set; } = false; } - public class BannedPlayers { + public class BanListTable { public bool Enabled { get; set; } = false; - public List Players { get; set; } = new List(); - public List IpAddresses { get; set; } = new List(); + public ISet Players { get; set; } = new SortedSet(); + public ISet IpAddresses { get; set; } = new SortedSet(); + public ISet Stages { get; set; } = new SortedSet(); } public class FlipTable { public bool Enabled { get; set; } = true; - public List Players { get; set; } = new List(); + public ISet Players { get; set; } = new SortedSet(); public FlipOptions Pov { get; set; } = FlipOptions.Both; } @@ -86,4 +88,10 @@ public class PersistShinesTable 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>(); + } +} diff --git a/Shared/Stages.cs b/Shared/Stages.cs index 5eb03b4..04315d5 100644 --- a/Shared/Stages.cs +++ b/Shared/Stages.cs @@ -11,7 +11,7 @@ public static class Stages { return mapName; } // exact stage value - if (Stage2Alias.ContainsKey(input)) { + if (IsStage(input)) { return input; } // force input value with a ! @@ -29,6 +29,32 @@ public static string KingdomAliasMapping() { return result; } + public static bool IsAlias(string input) { + return Alias2Stage.ContainsKey(input); + } + + public static bool IsStage(string input) { + return Stage2Alias.ContainsKey(input); + } + + public static IEnumerable StagesByInput(string input) { + if (IsAlias(input)) { + var stages = Stage2Alias + .Where(e => e.Value == input) + .Select(e => e.Key) + ; + foreach (string stage in stages) { + yield return stage; + } + } + else { + string? stage = Input2Stage(input); + if (stage != null) { + yield return stage; + } + } + } + public static readonly Dictionary Alias2Stage = new Dictionary() { { "cap", "CapWorldHomeStage" }, { "cascade", "WaterfallWorldHomeStage" }, diff --git a/docker-build.sh b/docker-build.sh new file mode 100755 index 0000000..d1b9d07 --- /dev/null +++ b/docker-build.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail + +if [[ "$#" == "0" ]] || [[ "$#" > "1" ]] || ! [[ "$1" =~ ^(all|x64|arm|arm64|win64)$ ]] ; then + echo "Usage: docker-build.sh {all|x64|arm|arm64|win64}" + exit 1 +fi + +DIR=$(dirname "$(realpath $0)") +cd "$DIR" + +declare -A archs=( + ["x64"]="linux-x64" + ["arm"]="linux-arm" + ["arm64"]="linux-arm64" + ["win64"]="win-x64" +) + +for sub in "${!archs[@]}" ; do + arch="${archs[$sub]}" + + if [[ "$1" != "all" ]] && [[ "$1" != "$sub" ]] ; then + continue + fi + + docker run \ + -u `id -u`:`id -g` \ + -v "/$DIR/"://app/ \ + -w //app/ \ + -e DOTNET_CLI_HOME=//app/cache/ \ + -e XDG_DATA_HOME=//app/cache/ \ + mcr.microsoft.com/dotnet/sdk:6.0 \ + dotnet publish \ + ./Server/Server.csproj \ + -r $arch \ + -c Release \ + -o /app/bin/$sub/ \ + --self-contained \ + -p:publishSingleFile=true \ + ; + + filename="Server" + ext="" + if [[ "$sub" == "arm" ]] ; then filename="Server.arm"; + elif [[ "$sub" == "arm64" ]] ; then filename="Server.arm64"; + elif [[ "$sub" == "win64" ]] ; then filename="Server.exe"; ext=".exe"; + fi + + mv ./bin/$sub/Server$ext ./bin/$filename + rm -rf ./bin/$sub/ +done