diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7fe50f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +/Server/bin/ +/Server/obj/ +/Shared/bin/ +/Shared/obj/ + +/Server/**/.gitignore +/Server/**/*.env +/Server/**/*.md +/Server/**/*.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 74ffcb5..1c25164 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,11 +27,11 @@ jobs: echo "IMAGE=$IMAGE" >>$GITHUB_ENV - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - id: meta name: Docker meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ env.IMAGE }} @@ -47,22 +47,22 @@ jobs: org.opencontainers.image.licenses=UNLICENSED - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 with: platforms: amd64,arm64,arm - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to GHCR - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry : ghcr.io username : ${{ github.repository_owner }} password : ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: pull : true push : true @@ -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@v4 + with: + name : Server + path : ./bin/Server + if-no-files-found : error + - + name : Upload Server.arm + uses : actions/upload-artifact@v4 + with: + name : Server.arm + path : ./bin/Server.arm + if-no-files-found : error + - + name : Upload Server.arm64 + uses : actions/upload-artifact@v4 + with: + name : Server.arm64 + path : ./bin/Server.arm64 + if-no-files-found : error + - + name : Upload Server.exe + uses : actions/upload-artifact@v4 + with: + name : Server.exe + path : ./bin/Server.exe + if-no-files-found : error diff --git a/.github/workflows/test-pr.yml b/.github/workflows/test-pr.yml index d858bed..68c042e 100644 --- a/.github/workflows/test-pr.yml +++ b/.github/workflows/test-pr.yml @@ -15,18 +15,18 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 with: platforms: amd64,arm64,arm - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v6 with: pull : true push : false 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/Dockerfile b/Dockerfile index 1179791..45a31b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ################################################################################ ################################################################## build ### -FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:6.0 as build +FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:6.0 AS build WORKDIR /app/ @@ -34,7 +34,7 @@ RUN dotnet publish \ ################################################################################ ################################################################ runtime ### -FROM mcr.microsoft.com/dotnet/runtime:6.0 as runtime +FROM mcr.microsoft.com/dotnet/runtime:6.0 AS runtime # Copy application binary from build stage COPY --from=build /app/out/ /app/ diff --git a/Server/BanLists.cs b/Server/BanLists.cs new file mode 100644 index 0000000..de0e8af --- /dev/null +++ b/Server/BanLists.cs @@ -0,0 +1,407 @@ +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 ISet GameModes { + get { + return Settings.Instance.BanList.GameModes; + } + } + + + 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 IsGameModeBanned(GameMode gameMode) { + return GameModes.Contains((sbyte)gameMode); + } + + 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 BanGameMode(GameMode gameMode) { + GameModes.Add((sbyte)gameMode); + } + + 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 UnbanGameMode(GameMode gameMode) { + GameModes.Remove((sbyte)gameMode); + } + + + private static void Save() { + Settings.SaveSettings(true); + } + + + public static void Crash( + Client user, + int delay_ms = 0 + ) { + user.Ignored = true; + Task.Run(async () => { + if (delay_ms > 0) { + await Task.Delay(delay_ms); + } + bool permanent = user.Banned; + 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), + }); + }); + } + + private static void CrashMultiple(string[] args, MUCH much) { + foreach (Client user in much(args).toActUpon) { + user.Banned = true; + Crash(user); + } + } + + + 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)); + } + + if (GameModes.Count > 0) { + list.Append("\nBanned gamemodes:\n- "); + list.Append(string.Join("\n- ", GameModes.Select(x => (GameMode)x))); + } + + 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) { + user.Banned = true; + BanClient(user); + Crash(user); + } + + 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); + + case "gamemode": + if (args.Length != 1) { + return "Usage: ban gamemode "; + } + if (!GameMode.TryParse(args[0], out GameMode gameMode)) { + return "Invalid gamemode value!"; + } + if (IsGameModeBanned(gameMode)) { + return "Gamemode " + gameMode + " is already banned."; + } + BanGameMode(gameMode); + Save(); + return "Banned gamemode: " + gameMode; + } + } + + + 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); + + case "gamemode": + if (!GameMode.TryParse(val, out GameMode gameMode)) { + return "Invalid gamemode value!"; + } + if (!IsGameModeBanned(gameMode)) { + return "Gamemode " + gameMode + " is not banned."; + } + UnbanGameMode(gameMode); + Save(); + return "Unbanned gamemode: " + gameMode; + } + } +} diff --git a/Server/Client.cs b/Server/Client.cs index 5f3336f..6e35910 100644 --- a/Server/Client.cs +++ b/Server/Client.cs @@ -12,6 +12,8 @@ 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 bool Banned = false; public CostumePacket? CurrentCostume = null; // required for proper client sync public string Name { get => Logger.Name; @@ -40,8 +42,9 @@ public Client(Client other, Socket socket) { } public void Dispose() { - if (Socket?.Connected is true) + if (Socket?.Connected is true) { Socket.Disconnect(false); + } } @@ -50,9 +53,14 @@ public async Task Send(T packet, Client? sender = null) where T : struct, IPa PacketAttribute packetAttribute = Constants.PacketMap[typeof(T)]; try { + // don't send most packets to ignored players + if (Ignored && packetAttribute.Type != PacketType.Init && packetAttribute.Type != PacketType.ChangeStage) { + memory.Dispose(); + return; + } Server.FillPacket(new PacketHeader { - Id = sender?.Id ?? Id, - Type = packetAttribute.Type, + Id = sender?.Id ?? Id, + Type = packetAttribute.Type, PacketSize = packet.Size }, packet, memory.Memory); } @@ -68,14 +76,52 @@ public async Task Send(T packet, Client? sender = null) where T : struct, IPa public async Task Send(Memory data, Client? sender) { PacketHeader header = new PacketHeader(); header.Deserialize(data.Span); - if (!Connected && header.Type is not PacketType.Connect) { + + if (!Connected && !Ignored && header.Type != PacketType.Connect) { Server.Logger.Error($"Didn't send {header.Type} to {Id} because they weren't connected yet"); return; } + // don't send most packets to ignored players + if (Ignored && header.Type != PacketType.Init && header.Type != PacketType.ChangeStage) { + return; + } + await Socket!.SendAsync(data[..(Constants.HeaderSize + header.PacketSize)], SocketFlags.None); } + public void CleanMetadataOnNewConnection() { + object? tmp; + Metadata.TryRemove("gameMode", out tmp); + Metadata.TryRemove("time", out tmp); + Metadata.TryRemove("seeking", out tmp); + Metadata.TryRemove("lastCostumePacket", out tmp); + Metadata.TryRemove("lastCapturePacket", out tmp); + Metadata.TryRemove("lastGamePacket", out tmp); + Metadata.TryRemove("lastPlayerPacket", out tmp); + } + + public TagPacket? GetTagPacket() { + var gmode = (GameMode?) (this.Metadata.ContainsKey("gameMode") ? this.Metadata["gameMode"] : null); + if (gmode == null) { return null; } + if ( gmode != GameMode.Legacy + && gmode != GameMode.HideAndSeek + && gmode != GameMode.Sardines + ) { return null; } + + var time = (Time?) (this.Metadata.ContainsKey("time") ? this.Metadata["time"] : null); + var seek = (bool?) (this.Metadata.ContainsKey("seeking") ? this.Metadata["seeking"] : null); + if (time == null && seek == null) { return null; } + + return new TagPacket { + GameMode = (GameMode) gmode, + UpdateType = (seek != null ? TagPacket.TagUpdate.State : 0) | (time != null ? TagPacket.TagUpdate.Time: 0), + IsIt = seek ?? false, + Seconds = (byte) (time?.Seconds ?? 0), + Minutes = (ushort) (time?.Minutes ?? 0), + }; + } + public static bool operator ==(Client? left, Client? right) { return left is { } leftClient && right is { } rightClient && leftClient.Id == rightClient.Id; } 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..0c7934b --- /dev/null +++ b/Server/JsonApi/ApiRequest.cs @@ -0,0 +1,62 @@ +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 string? GetStringData() { + if (this.Data is JsonValue) { + JsonElement val = this.Data.GetValue(); + JsonValueKind kind = val.ValueKind; + if (kind == JsonValueKind.String) { return val.GetString(); } + } + 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..f20e47e --- /dev/null +++ b/Server/JsonApi/ApiRequestCommand.cs @@ -0,0 +1,69 @@ +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!.GetStringData()!; + 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) { + string? command = ctx.request!.GetStringData(); + + if (command == null) { + JsonApi.Logger.Warn($"[Commands] Invalid request. Data is 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..76e8772 --- /dev/null +++ b/Server/JsonApi/ApiRequestStatus.cs @@ -0,0 +1,282 @@ +using Shared; +using Shared.Packet.Packets; +using System.Net; +using System.Numerics; +using System.Text.Json.Nodes; +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 = ApiRequestStatus.Player.GetPlayers(ctx), + }; + await ctx.Send(resp); + return true; + } + + + private static JsonNode? GetSettings(Context ctx) + { + // output object + JsonObject settings = new JsonObject(); + + // all permissions for Settings + var allowedSettings = ctx.Permissions + .Where(str => str.StartsWith("Status/Settings/")) + .Select(str => str.Substring(16)) + ; + + bool has_results = false; + + // copy all allowed Settings + foreach (string allowedSetting in allowedSettings) { + string lastKey = ""; + JsonNode? next = settings; + object input = Settings.Instance; + JsonObject output = settings!; + + // recursively go down the path + foreach (string key in allowedSetting.Split("/")) { + lastKey = key; + + if (next == null) { break; } + output = (JsonObject) next!; + + // create the sublayer + if (!output.ContainsKey(key)) { output.Add(key, new JsonObject()); } + + // traverse down the output object + output.TryGetPropertyValue(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 continue2; + } else { + input = prop.GetValue(input, null)!; + } + } + + if (lastKey != "") { + // copy key with the actual value + output.Remove(lastKey); + output.Add(lastKey, JsonValue.Create(input)); + has_results = true; + } + + continue2:; + } + + if (!has_results) { return null; } + return settings; + } + + + private class StatusResponse { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonNode? Settings { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Player[]? Players { get; set; } + } + + + public class Player { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ID { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public GameMode? GameMode { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Kingdom { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Stage { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Scenario { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PlayerPosition? Position { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PlayerRotation? Rotation { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Tagged { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PlayerCostume? Costume { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Capture { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Is2D { get; private set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IPv4 { get; private set; } + + + private static Mutators Mutators = new Mutators { + ["Status/Players/ID"] = (Player p, Client c) => p.ID = c.Id, + ["Status/Players/Name"] = (Player p, Client c) => p.Name = c.Name, + ["Status/Players/GameMode"] = (Player p, Client c) => p.GameMode = Player.GetGameMode(c), + ["Status/Players/Kingdom"] = (Player p, Client c) => p.Kingdom = Player.GetKingdom(c), + ["Status/Players/Stage"] = (Player p, Client c) => p.Stage = Player.GetGamePacket(c)?.Stage ?? null, + ["Status/Players/Scenario"] = (Player p, Client c) => p.Scenario = Player.GetGamePacket(c)?.ScenarioNum ?? null, + ["Status/Players/Position"] = (Player p, Client c) => p.Position = PlayerPosition.FromVector3(Player.GetPlayerPacket(c)?.Position ?? null), + ["Status/Players/Rotation"] = (Player p, Client c) => p.Rotation = PlayerRotation.FromQuaternion(Player.GetPlayerPacket(c)?.Rotation ?? null), + ["Status/Players/Tagged"] = (Player p, Client c) => p.Tagged = Player.GetTagged(c), + ["Status/Players/Costume"] = (Player p, Client c) => p.Costume = PlayerCostume.FromClient(c), + ["Status/Players/Capture"] = (Player p, Client c) => p.Capture = Player.GetCapture(c), + ["Status/Players/Is2D"] = (Player p, Client c) => p.Is2D = Player.GetGamePacket(c)?.Is2d ?? null, + ["Status/Players/IPv4"] = (Player p, Client c) => p.IPv4 = (c.Socket?.RemoteEndPoint as IPEndPoint)?.Address.ToString(), + }; + + + public static Player[]? GetPlayers(Context ctx) { + if (!ctx.HasPermission("Status/Players")) { return null; } + return ctx.server.ClientsConnected.Select((Client c) => Player.FromClient(ctx, c)).ToArray(); + } + + + private static Player FromClient(Context ctx, Client c) { + Player player = new Player(); + 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 GameMode? GetGameMode(Client c) { + object? gamemode = null; + c.Metadata.TryGetValue("gameMode", out gamemode); + return (GameMode?) gamemode; + } + + + private static bool? GetTagged(Client c) { + object? seeking = null; + c.Metadata.TryGetValue("seeking", out seeking); + return (bool?) seeking; + } + + + 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; + } + } + + + public class PlayerCostume { + public string Cap { get; private set; } + public string Body { get; private set; } + + + private PlayerCostume(CostumePacket p) { + this.Cap = p.CapName; + this.Body = p.BodyName; + } + + + public static PlayerCostume? FromClient(Client c) { + if (c.CurrentCostume == null) { return null; } + CostumePacket p = (CostumePacket) c.CurrentCostume!; + return new PlayerCostume(p); + } + } + + + public class PlayerPosition { + public float X { get; private set; } + public float Y { get; private set; } + public float Z { get; private set; } + + + private PlayerPosition(float X, float Y, float Z) { + this.X = X; + this.Y = Y; + this.Z = Z; + } + + + public static PlayerPosition? FromVector3(Vector3? pos) { + if (pos == null) { return null; } + Vector3 p = (Vector3) pos; + return new PlayerPosition(p.X, p.Y, p.Z); + } + } + + + public class PlayerRotation { + 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 PlayerRotation(float W, float X, float Y, float Z) { + this.W = W; + this.X = X; + this.Y = Y; + this.Z = Z; + } + + + public static PlayerRotation? FromQuaternion(Quaternion? quat) { + if (quat == null) { return null; } + Quaternion q = (Quaternion) quat; + return new PlayerRotation(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..9210bec --- /dev/null +++ b/Server/JsonApi/README.md @@ -0,0 +1,83 @@ +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). + +--- + +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/GameMode` +- `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 7b 22 4f 75 74 70 75 74 22 3a 5b 22 53 65 6e 74 |{"Output":["Sent| +00000010 20 70 6c 61 79 65 72 73 20 74 6f 20 50 65 61 63 | players to Peac| +00000020 68 57 6f 72 6c 64 48 6f 6d 65 53 74 61 67 65 3a |hWorldHomeStage:| +00000030 2d 31 22 5d 7d |-1"]}| +00000035 +``` diff --git a/Server/JsonApi/test.sh b/Server/JsonApi/test.sh new file mode 100755 index 0000000..29c2920 --- /dev/null +++ b/Server/JsonApi/test.sh @@ -0,0 +1,35 @@ +#!/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 + +# backwards compatible way to dynamically trim binary before the first '{' character +function trim_start () { + local IFS + local LC_ALL + local c + while IFS= LC_ALL=C read -rd '' -n1 c ; do + [ "$c" == "{" ] && echo -n "$c" && break + done + while IFS= LC_ALL=C read -rd '' -n1 c ; do + echo -n "$c" + done +} + +echo -n "{\"API_JSON_REQUEST\":{\"Token\":\"${TOKEN}\",\"Type\":\"$TYPE\"$DATA}}" \ + | timeout 5.0 nc $HOST $PORT \ + | trim_start \ + | jq \ +; +echo "" diff --git a/Server/Program.cs b/Server/Program.cs index 7e46b1e..1488638 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -62,31 +62,19 @@ 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 - } - } + c.Metadata["disableShineSync"] = false; }; async Task ClientSyncShineBag(Client client) { if (!Settings.Instance.Shines.Enabled) return; try { - if ((bool?) client.Metadata["speedrun"] ?? false) return; + if ((bool?) client.Metadata["disableShineSync"] ?? false) return; ConcurrentBag clientBag = (ConcurrentBag) (client.Metadata["shineSync"] ??= new ConcurrentBag()); - foreach (int shine in shineBag.Except(clientBag).ToArray()) { + foreach (int shine in shineBag.Except(clientBag).Except(Settings.Instance.Shines.Excluded).ToArray()) { if (!client.Connected) return; await client.Send(new ShinePacket { ShineId = shine @@ -115,29 +103,73 @@ 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: { + // crash ignored player + if (c.Ignored) { + c.Logger.Info($"Crashing ignored player after entering stage {gamePacket.Stage}."); + BanLists.Crash(c, 500); + return false; + } + + // crash player entering a banned stage + if (BanLists.Enabled && BanLists.IsStageBanned(gamePacket.Stage)) { + c.Logger.Warn($"Crashing player for entering banned stage {gamePacket.Stage}."); + BanLists.Crash(c, 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; - ((ConcurrentBag) (c.Metadata["shineSync"] ??= new ConcurrentBag())).Clear(); - shineBag.Clear(); - c.Logger.Info("Entered Cap on new save, preventing moon sync until Cascade"); + case "CapWorldHomeStage" when gamePacket.ScenarioNum == 1: + case "CapWorldTowerStage" when gamePacket.ScenarioNum == 1: + if (!((bool?) c.Metadata["disableShineSync"] ?? false)) { + c.Metadata["disableShineSync"] = true; + ((ConcurrentBag) (c.Metadata["shineSync"] ??= new ConcurrentBag())).Clear(); + c.Logger.Info("Entered Cap on new save, preventing moon sync until Cascade"); + if (Settings.Instance.Shines.ClearOnNewSaves) { + shineBag.Clear(); + c.Logger.Info("Cleared shine bags"); + Task.Run(PersistShines); + } + } break; - case "WaterfallWorldHomeStage": - bool wasSpeedrun = (bool) c.Metadata["speedrun"]!; - c.Metadata["speedrun"] = false; - if (wasSpeedrun) + default: + if ((bool?) c.Metadata["disableShineSync"] ?? false) { Task.Run(async () => { - c.Logger.Info("Entered Cascade with moon sync disabled, enabling moon sync"); - await Task.Delay(15000); + c.Logger.Info("Entered Cascade or later with moon sync disabled, enabling moon sync again"); + await Task.Delay(2000); + c.Metadata["disableShineSync"] = false; await ClientSyncShineBag(c); }); + } break; } @@ -145,8 +177,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; @@ -155,24 +186,62 @@ async void SyncShineBag() { break; } + // ignore all other packets from ignored players + case IPacket pack when c.Ignored: { + return false; + } + case TagPacket 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); + if (BanLists.Enabled && BanLists.IsGameModeBanned(tagPacket.GameMode)) { + c.Logger.Warn($"Crashing player for entering banned gamemode {tagPacket.GameMode}."); + BanLists.Crash(c, 500); + return false; + } + + if ( (tagPacket.GameMode == GameMode.Legacy && tagPacket.UpdateType == TagPacket.TagUpdate.Both) + || tagPacket.GameMode == GameMode.HideAndSeek + || tagPacket.GameMode == GameMode.Sardines + ) { + // c.Logger.Info($"Got tag packet: {tagPacket.GameMode} {tagPacket.UpdateType} {tagPacket.IsIt} {tagPacket.Minutes}:{tagPacket.Seconds}"); + 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); + } + } else { + // c.Logger.Info($"Got tag packet: {tagPacket.GameMode} {(byte) tagPacket.UpdateType}"); + c.Metadata["seeking"] = null; + c.Metadata["time"] = null; + } + c.Metadata["gameMode"] = tagPacket.GameMode; + break; } - case CostumePacket costumePacket: + 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 #pragma warning restore CS4014 c.Metadata["loadedSave"] = true; break; + } case ShinePacket shinePacket: { if (!Settings.Instance.Shines.Enabled) return false; + if (Settings.Instance.Shines.Excluded.Contains(shinePacket.ShineId)) { + c.Logger.Info($"Got moon {shinePacket.ShineId} (excluded)"); + return false; + } if (c.Metadata["loadedSave"] is false) break; ConcurrentBag playerBag = (ConcurrentBag)c.Metadata["shineSync"]!; shineBag.Add(shinePacket.ShineId); @@ -183,33 +252,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 +291,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 +380,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 "; @@ -440,14 +482,20 @@ await c.Send(new ChangeStagePacket { if (!byte.TryParse(args[3], out byte seconds) || seconds >= 60) return $"Invalid time for seconds {args[3]} (range: 0-59)"; TagPacket tagPacket = new TagPacket { + GameMode = GameMode.Legacy, UpdateType = TagPacket.TagUpdate.Time, - Minutes = minutes, - Seconds = seconds + Minutes = minutes, + Seconds = seconds, }; - if (args[1] == "*") - server.Broadcast(tagPacket); - else - client?.Send(tagPacket); + if (args[1] == "*") { + Parallel.ForEachAsync(server.Clients, async (client, _) => { + await server.Broadcast(tagPacket, client); + await client.Send(tagPacket); + }); + } else if (client != null) { + server.Broadcast(tagPacket, client); + client.Send(tagPacket); + } return $"Set time for {(args[1] == "*" ? "everyone" : args[1])} to {minutes}:{seconds}"; } case "seeking" when args.Length == 3: { @@ -455,13 +503,19 @@ await c.Send(new ChangeStagePacket { Client? client = server.Clients.FirstOrDefault(x => x.Name == args[1]); if (!bool.TryParse(args[2], out bool seeking)) return $"Usage: tag seeking {args[1]} "; TagPacket tagPacket = new TagPacket { + GameMode = GameMode.Legacy, UpdateType = TagPacket.TagUpdate.State, - IsIt = seeking + IsIt = seeking, }; - if (args[1] == "*") - server.Broadcast(tagPacket); - else - client?.Send(tagPacket); + if (args[1] == "*") { + Parallel.ForEachAsync(server.Clients, async (client, _) => { + await server.Broadcast(tagPacket, client); + await client.Send(tagPacket); + }); + } else if (client != null) { + server.Broadcast(tagPacket, client); + client.Send(tagPacket); + } return $"Set {(args[1] == "*" ? "everyone" : args[1])} to {(seeking ? "seeker" : "hider")}"; } case "start" when args.Length > 2: { @@ -475,17 +529,24 @@ await c.Send(new ChangeStagePacket { int realTime = 1000 * time; await Task.Delay(realTime); await Task.WhenAll( - Parallel.ForEachAsync(seekers, async (seeker, _) => - await server.Broadcast(new TagPacket { + Parallel.ForEachAsync(seekers, async (seeker, _) => { + TagPacket packet = new TagPacket { + GameMode = GameMode.Legacy, UpdateType = TagPacket.TagUpdate.State, - IsIt = true - }, seeker)), - Parallel.ForEachAsync(server.Clients.Except(seekers), async (hider, _) => - await server.Broadcast(new TagPacket { + IsIt = true, + }; + await server.Broadcast(packet, seeker); + await seeker.Send(packet); + }), + Parallel.ForEachAsync(server.Clients.Except(seekers), async (hider, _) => { + TagPacket packet = new TagPacket { + GameMode = GameMode.Legacy, UpdateType = TagPacket.TagUpdate.State, - IsIt = false - }, hider) - ) + IsIt = false, + }; + await server.Broadcast(packet, hider); + await hider.Send(packet); + }) ); consoleLogger.Info($"Started game with seekers {string.Join(", ", seekerNames)}"); }); @@ -562,17 +623,19 @@ await server.Broadcast(new TagPacket { }); CommandHandler.RegisterCommand("shine", args => { - const string optionUsage = "Valid options: list, clear, sync, send, set"; + const string optionUsage = "Valid options: list, clear, sync, send, set, include, exclude"; if (args.Length < 1) return optionUsage; switch (args[0]) { case "list" when args.Length == 1: - return $"Shines: {string.Join(", ", shineBag)}"; + return $"Shines: {string.Join(", ", shineBag)}" + ( + Settings.Instance.Shines.Excluded.Count() > 0 + ? "\nExcluded Shines: " + string.Join(", ", Settings.Instance.Shines.Excluded) + : "" + ); case "clear" when args.Length == 1: shineBag.Clear(); - Task.Run(async () => { - await PersistShines(); - }); + Task.Run(PersistShines); foreach (ConcurrentBag playerBag in server.Clients.Select(serverClient => (ConcurrentBag)serverClient.Metadata["shineSync"]!)) playerBag?.Clear(); @@ -604,6 +667,21 @@ await c.Send(new ShinePacket { return optionUsage; } + case "exclude" when args.Length == 2: + case "include" when args.Length == 2: { + if (int.TryParse(args[1], out int sid)) { + if (args[0] == "exclude") { + Settings.Instance.Shines.Excluded.Add(sid); + Settings.SaveSettings(); + return $"Exclude shine {sid} from syncing."; + } else { + Settings.Instance.Shines.Excluded.Remove(sid); + Settings.SaveSettings(); + return $"No longer exclude shine {sid} from syncing."; + } + } + return optionUsage; + } default: return optionUsage; } @@ -651,7 +729,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 cfb7091..625cb2b 100644 --- a/Server/Server.cs +++ b/Server/Server.cs @@ -29,8 +29,11 @@ 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); - Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + if (! Settings.Instance.JsonApi.Enabled) { + Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}"); + } + // start sub thread to handle client try { #pragma warning disable CS4014 Task.Run(() => HandleSocket(socket)) @@ -64,7 +67,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..]); } @@ -73,27 +76,29 @@ public static void FillPacket(PacketHeader header, T packet, Memory mem public delegate void PacketReplacer(Client from, Client to, T value); // replacer must send public void BroadcastReplace(T packet, Client sender, PacketReplacer packetReplacer) where T : struct, IPacket { - foreach (Client client in Clients.Where(client => client.Connected && sender.Id != client.Id)) packetReplacer(sender, client, packet); + foreach (Client client in Clients.Where(c => c.Connected && !c.Ignored && sender.Id != c.Id)) { + packetReplacer(sender, client, packet); + } } public async Task Broadcast(T packet, Client sender) where T : struct, IPacket { IMemoryOwner memory = MemoryPool.Shared.RentZero(Constants.HeaderSize + packet.Size); PacketHeader header = new PacketHeader { - Id = sender?.Id ?? Guid.Empty, - Type = Constants.PacketMap[typeof(T)].Type, - PacketSize = packet.Size + Id = sender?.Id ?? Guid.Empty, + Type = Constants.PacketMap[typeof(T)].Type, + PacketSize = packet.Size, }; FillPacket(header, packet, memory.Memory); await Broadcast(memory, sender); } public Task Broadcast(T packet) where T : struct, IPacket { - return Task.WhenAll(Clients.Where(c => c.Connected).Select(async client => { + return Task.WhenAll(Clients.Where(c => c.Connected && !c.Ignored).Select(async client => { IMemoryOwner memory = MemoryPool.Shared.RentZero(Constants.HeaderSize + packet.Size); PacketHeader header = new PacketHeader { - Id = client.Id, - Type = Constants.PacketMap[typeof(T)].Type, - PacketSize = packet.Size + Id = client.Id, + Type = Constants.PacketMap[typeof(T)].Type, + PacketSize = packet.Size, }; FillPacket(header, packet, memory.Memory); await client.Send(memory.Memory, client); @@ -107,7 +112,7 @@ public Task Broadcast(T packet) where T : struct, IPacket { /// Memory owner to dispose once done /// Optional sender to not broadcast data to public async Task Broadcast(IMemoryOwner data, Client? sender = null) { - await Task.WhenAll(Clients.Where(c => c.Connected && c != sender).Select(client => client.Send(data.Memory, sender))); + await Task.WhenAll(Clients.Where(c => c.Connected && !c.Ignored && c != sender).Select(client => client.Send(data.Memory, sender))); data.Dispose(); } @@ -117,7 +122,7 @@ public async Task Broadcast(IMemoryOwner data, Client? sender = null) { /// Memory to send to the clients /// Optional sender to not broadcast data to public async void Broadcast(Memory data, Client? sender = null) { - await Task.WhenAll(Clients.Where(c => c.Connected && c != sender).Select(client => client.Send(data, sender))); + await Task.WhenAll(Clients.Where(c => c.Connected && !c.Ignored && c != sender).Select(client => client.Send(data, sender))); } public Client? FindExistingClient(Guid id) { @@ -129,9 +134,7 @@ private async void HandleSocket(Socket socket) { Client client = new Client(socket) {Server = this}; var remote = socket.RemoteEndPoint; IMemoryOwner memory = null!; - await client.Send(new InitPacket { - MaxPlayers = Settings.Instance.Server.MaxPlayers - }); + bool first = true; try { while (true) { @@ -154,63 +157,112 @@ async Task Read(Memory readMem, int readSize, int readOffset) { return true; } - if (!await Read(memory.Memory[..Constants.HeaderSize], Constants.HeaderSize, 0)) + 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 memory = memoryPool.Rent(Constants.HeaderSize + header.PacketSize); memTemp.Memory.Span[..Constants.HeaderSize].CopyTo(memory.Memory.Span[..Constants.HeaderSize]); memTemp.Dispose(); - if (!await Read(memory.Memory, header.PacketSize, Constants.HeaderSize)) + if (!await Read(memory.Memory, header.PacketSize, Constants.HeaderSize)) { break; + } } // connection initialization if (first) { - first = false; - if (header.Type != PacketType.Connect) throw new Exception($"First packet was not init, instead it was {header.Type}"); + first = false; // only do this once + + // first client packet has to be the client init + if (header.Type != PacketType.Connect) { + throw new Exception($"First packet was not init, instead it was {header.Type} ({remote})"); + } ConnectPacket connect = new ConnectPacket(); connect.Deserialize(memory.Memory.Span[packetRange]); + + client.Id = header.Id; + client.Name = connect.ClientName; + + // is the IPv4 address banned? + if (BanLists.Enabled && BanLists.IsIPv4Banned(((IPEndPoint) socket.RemoteEndPoint!).Address!)) { + Logger.Warn($"Ignoring banned IPv4 address for {client.Name} ({client.Id}/{remote})"); + client.Ignored = true; + client.Banned = true; + } + // is the profile ID banned? + else if (BanLists.Enabled && BanLists.IsProfileBanned(client.Id)) { + client.Logger.Warn($"Ignoring banned profile ID for {client.Name} ({client.Id}/{remote})"); + client.Ignored = true; + client.Banned = true; + } + // is the server full? + else if (Clients.Count(x => x.Connected) >= Settings.Instance.Server.MaxPlayers) { + client.Logger.Error($"Ignoring player {client.Name} ({client.Id}/{remote}) as server reached max players of {Settings.Instance.Server.MaxPlayers}"); + client.Ignored = true; + } + + // send server init (required to crash ignored players later) + await client.Send(new InitPacket { + MaxPlayers = (client.Ignored ? (ushort) 1 : Settings.Instance.Server.MaxPlayers), + }); + + // don't init or announce an ignored client to other players any further + if (client.Ignored) { + memory.Dispose(); + continue; + } + + bool wasFirst = connect.ConnectionType == ConnectPacket.ConnectionTypes.FirstConnection; + + // add client to the set of connected players lock (Clients) { - if (Clients.Count(x => x.Connected) == Settings.Instance.Server.MaxPlayers) { - client.Logger.Error($"Turned away as server is at max clients"); + // is the server full? (check again, to prevent race conditions) + if (Clients.Count(x => x.Connected) >= Settings.Instance.Server.MaxPlayers) { + client.Logger.Error($"Ignoring player {client.Name} ({client.Id}/{remote}) as server reached max players of {Settings.Instance.Server.MaxPlayers}"); + client.Ignored = true; memory.Dispose(); - goto disconnect; + continue; } - bool firstConn = true; + // detect and handle reconnections + bool isClientNew = true; switch (connect.ConnectionType) { case ConnectPacket.ConnectionTypes.FirstConnection: case ConnectPacket.ConnectionTypes.Reconnecting: { - client.Id = header.Id; - if (FindExistingClient(header.Id) is { } oldClient) { - firstConn = false; + if (FindExistingClient(client.Id) is { } oldClient) { + isClientNew = false; client = new Client(oldClient, socket); + client.Name = connect.ClientName; Clients.Remove(oldClient); Clients.Add(client); if (oldClient.Connected) { oldClient.Logger.Info($"Disconnecting already connected client {oldClient.Socket?.RemoteEndPoint} for {client.Socket?.RemoteEndPoint}"); oldClient.Dispose(); } - } else { + } + else { connect.ConnectionType = ConnectPacket.ConnectionTypes.FirstConnection; } break; } - default: - throw new Exception($"Invalid connection type {connect.ConnectionType}"); + default: { + throw new Exception($"Invalid connection type {connect.ConnectionType} for {client.Name} ({client.Id}/{remote})"); + } } - client.Name = connect.ClientName; client.Connected = true; - if (firstConn) { + + if (isClientNew) { // do any cleanup required when it comes to new clients - List toDisconnect = Clients.FindAll(c => c.Id == header.Id && c.Connected && c.Socket != null); - Clients.RemoveAll(c => c.Id == header.Id); + List toDisconnect = Clients.FindAll(c => c.Id == client.Id && c.Connected && c.Socket != null); + Clients.RemoveAll(c => c.Id == client.Id); Clients.Add(client); @@ -219,26 +271,35 @@ async Task Read(Memory readMem, int readSize, int readOffset) { ClientJoined?.Invoke(client, connect); } + // a known client reconnects, but with a new first connection (e.g. after a restart) + else if (wasFirst) { + client.CleanMetadataOnNewConnection(); + } } - List otherConnectedPlayers = Clients.FindAll(c => c.Id != header.Id && c.Connected && c.Socket != null); + // for all other clients that are already connected + List otherConnectedPlayers = Clients.FindAll(c => c.Id != client.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,17 +307,30 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { } tempBuffer.Dispose(); + + // make the other client reset their puppet cache for this new client, if it is a new connection (after restart) + if (wasFirst) { + await SendEmptyPackets(client, other); + } }); Logger.Info($"Client {client.Name} ({client.Id}/{remote}) connected."); - } else if (header.Id != client.Id && client.Id != Guid.Empty) { + + // 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}"); } try { + // parse the packet IPacket packet = (IPacket) Activator.CreateInstance(Constants.PacketIdMap[header.Type])!; packet.Deserialize(memory.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + packet.Size)]); + + // process the packet if (PacketHandler?.Invoke(client, packet) is false) { + // don't broadcast the packet to everyone memory.Dispose(); continue; } @@ -264,7 +338,9 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { catch (Exception e) { client.Logger.Error($"Packet handler warning: {e}"); } + #pragma warning disable CS4014 + // broadcast the packet to everyone Broadcast(memory, client) .ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } }); #pragma warning restore CS4014 @@ -273,7 +349,8 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { catch (Exception e) { if (e is SocketException {SocketErrorCode: SocketError.ConnectionReset}) { client.Logger.Info($"Disconnected from the server: Connection reset"); - } else { + } + else { client.Logger.Error($"Disconnecting due to exception: {e}"); if (socket.Connected) { #pragma warning disable CS4014 @@ -286,7 +363,7 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { memory?.Dispose(); } - disconnect: + // client disconnected 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"); } @@ -294,8 +371,8 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { Logger.Info($"Client {remote} disconnected from the server"); } + close: bool wasConnected = client.Connected; - // Clients.Remove(client) client.Connected = false; try { client.Dispose(); @@ -310,6 +387,43 @@ await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => { #pragma warning restore CS4014 } + private async Task ResendPackets(Client client) { + async Task trySendPack(Client other, T? packet) where T : struct, IPacket { + if (packet == null) { return; } + try { + await client.Send((T) packet, other); + } + catch { + // lol who gives a fuck + } + }; + async Task trySendMeta(Client other, string packetType) where T : struct, IPacket { + if (!other.Metadata.ContainsKey(packetType)) { return; } + await trySendPack(other, (T) other.Metadata[packetType]!); + }; + await Parallel.ForEachAsync(this.ClientsConnected, async (other, _) => { + if (client.Id == other.Id) { return; } + await trySendMeta(other, "lastCostumePacket"); + await trySendMeta(other, "lastCapturePacket"); + await trySendPack(other, other.GetTagPacket()); + await trySendMeta(other, "lastGamePacket"); + await trySendMeta(other, "lastPlayerPacket"); + }); + } + + private async Task SendEmptyPackets(Client client, Client other) { + await other.Send(new TagPacket { + GameMode = GameMode.Legacy, + UpdateType = TagPacket.TagUpdate.Both, + 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..5b9ef16 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,17 @@ 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 ISet GameModes { 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; } @@ -79,6 +82,8 @@ public class DiscordTable { public class ShineTable { public bool Enabled { get; set; } = true; + public ISet Excluded { get; set; } = new SortedSet { 496 }; + public bool ClearOnNewSaves { get; set; } = false; } public class PersistShinesTable @@ -86,4 +91,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/Packet/Packets/TagPacket.cs b/Shared/Packet/Packets/TagPacket.cs index 23f3cfc..91fa898 100644 --- a/Shared/Packet/Packets/TagPacket.cs +++ b/Shared/Packet/Packets/TagPacket.cs @@ -4,30 +4,67 @@ namespace Shared.Packet.Packets; [Packet(PacketType.Tag)] public struct TagPacket : IPacket { + public GameMode GameMode; public TagUpdate UpdateType; public bool IsIt; public byte Seconds; public ushort Minutes; - public short Size => 6; + public short Size => 5; public void Serialize(Span data) { - MemoryMarshal.Write(data, ref UpdateType); + byte both = (byte)((byte) UpdateType | ((byte) GameMode << 4)); + MemoryMarshal.Write(data, ref both); MemoryMarshal.Write(data[1..], ref IsIt); MemoryMarshal.Write(data[2..], ref Seconds); - MemoryMarshal.Write(data[4..], ref Minutes); + MemoryMarshal.Write(data[3..], ref Minutes); } public void Deserialize(ReadOnlySpan data) { - UpdateType = MemoryMarshal.Read(data); - IsIt = MemoryMarshal.Read(data[1..]); - Seconds = MemoryMarshal.Read(data[2..]); - Minutes = MemoryMarshal.Read(data[4..]); + byte both = MemoryMarshal.Read(data); + GameMode = (GameMode) (sbyte) (((((both & (byte) 0xf0) >> 4) + 1) % 16) - 1); + UpdateType = (TagUpdate) (byte) (both & (byte) 0x0f); + IsIt = MemoryMarshal.Read(data[1..]); + Seconds = MemoryMarshal.Read(data[2..]); + Minutes = MemoryMarshal.Read(data[3..]); } [Flags] public enum TagUpdate : byte { - Time = 1, - State = 2 + None = 0, + Time = 1, + State = 2, + Both = 3, + Unknown04 = 4, + Unknown05 = 5, + Unknown06 = 6, + Unknown07 = 7, + Unknown08 = 8, + Unknown09 = 9, + Unknown10 = 10, + Unknown11 = 11, + Unknown12 = 12, + Unknown13 = 13, + Unknown14 = 14, + All = 15, } -} \ No newline at end of file +} + +public enum GameMode : sbyte { + None = -1, + Legacy = 0, + HideAndSeek = 1, + Sardines = 2, + FreezeTag = 3, + Unknown04 = 4, + Unknown05 = 5, + Unknown06 = 6, + Unknown07 = 7, + Unknown08 = 8, + Unknown09 = 9, + Unknown10 = 10, + Unknown11 = 11, + Unknown12 = 12, + Unknown13 = 13, + Reserved = 14, // extension possibility for more future game modes with an extra added byte +} 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