From 62d3bc04803fe76de0c697f9ac125327578f6374 Mon Sep 17 00:00:00 2001 From: linmeideli <2558527373@qq.com> Date: Sun, 17 May 2026 09:55:31 +0800 Subject: [PATCH] Push everything except readme --- .dockerignore | 9 + .gitignore | 434 +- CONTRIBUTING.md | 91 + Dockerfile | 47 + README.md | 0 build.cake | 165 + docs/Admin-panel.md | 173 + docs/Building-from-source.md | 29 + docs/Chat-filtering.md | 82 + docs/Discord-webhook.md | 55 + docs/FAQ.md | 23 + docs/Hello-page.md | 41 + docs/Http-server.md | 82 + docs/Player-statistics.md | 64 + docs/Plugin-marketplace.md | 50 + docs/README.md | 7 + docs/Running-the-server.md | 37 + docs/Server-configuration.md | 224 + docs/TROUBLESHOOTING.md | 63 + docs/Upgrading.md | 12 + docs/Writing-a-plugin.md | 332 + docs/images/client.jpg | Bin 0 -> 51524 bytes docs/images/logo_100.png | Bin 0 -> 3847 bytes marketplace/Boot.Chat.dll | Bin 0 -> 9728 bytes marketplace/Boot.Codes.dll | Bin 0 -> 27136 bytes marketplace/Impostor.Plugins.FixedCode.dll | Bin 0 -> 11264 bytes .../Impostor.Plugins.FriendCodeValidator.dll | Bin 0 -> 147968 bytes marketplace/Impostor.Plugins.QqVerify.dll | Bin 0 -> 14336 bytes marketplace/Impostor.Plugins.Titles.dll | Bin 0 -> 14336 bytes marketplace/Impostor.Plugins.Welcome.dll | Bin 0 -> 15872 bytes marketplace/Serilog.Sinks.File.dll | Bin 0 -> 31232 bytes marketplace/plugins.json | 106 + src/.editorconfig | 239 + src/.gitattributes | 63 + src/.gitignore | 114 + src/Directory.Build.props | 15 + .../Extensions.cs | 30 + .../Generators/BaseGenerator.cs | 22 + .../Generators/EnumGenerator.cs | 39 + .../Generators/MapDataGenerator.cs | 210 + .../Impostor.Api.Innersloth.Generator.csproj | 36 + .../Properties/launchSettings.json | 9 + .../SourceGenerator.cs | 100 + .../Vector2Converter.cs | 42 + .../packages.lock.json | 155 + src/Impostor.Api/CheatCategory.cs | 34 + src/Impostor.Api/CheatContext.cs | 20 + src/Impostor.Api/Commands/CommandContext.cs | 33 + src/Impostor.Api/Commands/ICommand.cs | 17 + src/Impostor.Api/Config/AdminConfig.cs | 12 + src/Impostor.Api/Config/AntiCheatConfig.cs | 31 + src/Impostor.Api/Config/ChatFilterConfig.cs | 19 + src/Impostor.Api/Config/CheatingHostMode.cs | 29 + .../Config/CompatibilityConfig.cs | 13 + src/Impostor.Api/Config/DebugConfig.cs | 11 + src/Impostor.Api/Config/DisconnectMessages.cs | 36 + .../Config/DiscordWebhookConfig.cs | 21 + src/Impostor.Api/Config/HttpServerConfig.cs | 27 + src/Impostor.Api/Config/PlayerStatsConfig.cs | 11 + src/Impostor.Api/Config/ServerConfig.cs | 30 + src/Impostor.Api/Config/TimeoutConfig.cs | 11 + .../Attributes/EventListenerAttribute.cs | 34 + .../Events/Client/IClientConnectedEvent.cs | 12 + .../Events/Client/IClientConnectionEvent.cs | 12 + .../Events/Client/IClientEvent.cs | 9 + src/Impostor.Api/Events/EventPriority.cs | 12 + .../Events/Game/IGameAlterEvent.cs | 7 + .../Events/Game/IGameCreatedEvent.cs | 24 + .../Events/Game/IGameCreationEvent.cs | 25 + .../Events/Game/IGameDestroyedEvent.cs | 11 + .../Events/Game/IGameEndedEvent.cs | 9 + src/Impostor.Api/Events/Game/IGameEvent.cs | 12 + .../Events/Game/IGameHostChangedEvent.cs | 11 + .../Events/Game/IGameOptionsChangedEvent.cs | 49 + .../Events/Game/IGamePlayerJoinedEvent.cs | 9 + .../Events/Game/IGamePlayerJoiningEvent.cs | 15 + .../Events/Game/IGamePlayerLeftEvent.cs | 11 + .../Events/Game/IGameStartedEvent.cs | 9 + .../Events/Game/IGameStartingEvent.cs | 11 + .../Events/Game/Meeting/IMeetingEndedEvent.cs | 17 + .../Events/Game/Meeting/IMeetingEvent.cs | 9 + .../Game/Meeting/IMeetingStartedEvent.cs | 6 + .../Events/Game/Player/IPlayerChatEvent.cs | 17 + .../Game/Player/IPlayerCheckMurderEvent.cs | 31 + .../Game/Player/IPlayerCompletedTaskEvent.cs | 9 + .../Game/Player/IPlayerDestroyedEvent.cs | 6 + .../Game/Player/IPlayerEnterVentEvent.cs | 15 + .../Events/Game/Player/IPlayerEvent.cs | 19 + .../Events/Game/Player/IPlayerExileEvent.cs | 9 + .../Game/Player/IPlayerExitVentEvent.cs | 15 + .../Game/Player/IPlayerMovementEvent.cs | 6 + .../Events/Game/Player/IPlayerMurderEvent.cs | 30 + .../Events/Game/Player/IPlayerReadyEvent.cs | 8 + .../Events/Game/Player/IPlayerReportEvent.cs | 14 + .../Player/IPlayerSetStartCounterEvent.cs | 10 + .../Events/Game/Player/IPlayerSpawnedEvent.cs | 6 + .../Game/Player/IPlayerStartMeetingEvent.cs | 12 + .../Events/Game/Player/IPlayerVentEvent.cs | 15 + .../Events/Game/Player/IPlayerVotedEvent.cs | 26 + src/Impostor.Api/Events/IEvent.cs | 6 + src/Impostor.Api/Events/IEventCancelable.cs | 10 + src/Impostor.Api/Events/IEventListener.cs | 6 + .../Events/IManualEventListener.cs | 13 + .../Events/Managers/IEventManager.cs | 35 + src/Impostor.Api/Events/MultiDisposable.cs | 31 + .../Exceptions/ImpostorConfigException.cs | 19 + .../Exceptions/ImpostorException.cs | 19 + .../Exceptions/ImpostorProtocolException.cs | 19 + .../Extensions/DictionaryExtensions.cs | 19 + .../Extensions/MurderResultFlagsExtensions.cs | 11 + .../Extensions/SpanReaderExtensions.cs | 69 + .../Extensions/SystemTypesExtensions.cs | 12 + .../Games/Extensions/GameExtensions.cs | 41 + .../Games/Extensions/GameManagerExtensions.cs | 14 + src/Impostor.Api/Games/GameCode.cs | 76 + src/Impostor.Api/Games/GameJoinError.cs | 58 + src/Impostor.Api/Games/GameJoinResult.cs | 48 + src/Impostor.Api/Games/IGame.cs | 144 + src/Impostor.Api/Games/IGameCodeFactory.cs | 7 + .../Games/Managers/IGameManager.cs | 23 + src/Impostor.Api/GlobalUsings.cs | 1 + src/Impostor.Api/Http/IListingFilter.cs | 18 + src/Impostor.Api/Impostor.Api.csproj | 41 + .../Impostor.Api.csproj.DotSettings | 8 + src/Impostor.Api/Innersloth/AlterGameTags.cs | 7 + src/Impostor.Api/Innersloth/ChatNoteType.cs | 7 + src/Impostor.Api/Innersloth/CrossplayFlags.cs | 17 + .../Innersloth/Customization/PlayerOutfit.cs | 125 + .../Customization/PlayerOutfitType.cs | 10 + .../Innersloth/Data/SpawnableObjects.json | 15 + .../Innersloth/Data/enums/ColorType.json | 20 + .../Data/enums/DisconnectReasons.json | 48 + .../Innersloth/Data/enums/GameKeywords.json | 22 + .../Innersloth/Data/enums/GameOverReason.json | 11 + .../Innersloth/Data/enums/Platforms.json | 13 + .../Innersloth/Data/enums/RoleTypes.json | 15 + .../Innersloth/Data/enums/RpcCalls.json | 63 + .../Innersloth/Data/enums/RulesPresets.json | 6 + .../Data/enums/SanctionReasons.json | 23 + .../Data/enums/SpecialGameModes.json | 4 + .../Innersloth/Data/enums/StringNames.json | 1627 +++++ .../Innersloth/Data/enums/SupportedLangs.json | 18 + .../Innersloth/Data/enums/SystemTypes.json | 61 + .../Innersloth/Data/enums/TaskTypes.json | 84 + src/Impostor.Api/Innersloth/Data/info.json | 5 + .../Innersloth/Data/maps/Airship/doors.json | 149 + .../Innersloth/Data/maps/Airship/spawn.json | 15 + .../Innersloth/Data/maps/Airship/systems.json | 13 + .../Innersloth/Data/maps/Airship/tasks.json | 1131 ++++ .../Innersloth/Data/maps/Airship/vents.json | 122 + .../Innersloth/Data/maps/April/doors.json | 93 + .../Innersloth/Data/maps/April/spawn.json | 15 + .../Innersloth/Data/maps/April/systems.json | 11 + .../Innersloth/Data/maps/April/tasks.json | 613 ++ .../Innersloth/Data/maps/April/vents.json | 142 + .../Innersloth/Data/maps/Fungle/doors.json | 58 + .../Innersloth/Data/maps/Fungle/spawn.json | 15 + .../Innersloth/Data/maps/Fungle/systems.json | 8 + .../Innersloth/Data/maps/Fungle/tasks.json | 520 ++ .../Innersloth/Data/maps/Fungle/vents.json | 102 + .../Innersloth/Data/maps/Mira/doors.json | 1 + .../Innersloth/Data/maps/Mira/spawn.json | 15 + .../Innersloth/Data/maps/Mira/systems.json | 9 + .../Innersloth/Data/maps/Mira/tasks.json | 520 ++ .../Innersloth/Data/maps/Mira/vents.json | 112 + .../Innersloth/Data/maps/Polus/doors.json | 114 + .../Innersloth/Data/maps/Polus/spawn.json | 15 + .../Innersloth/Data/maps/Polus/systems.json | 10 + .../Innersloth/Data/maps/Polus/tasks.json | 851 +++ .../Innersloth/Data/maps/Polus/vents.json | 122 + .../Innersloth/Data/maps/Skeld/doors.json | 93 + .../Innersloth/Data/maps/Skeld/spawn.json | 15 + .../Innersloth/Data/maps/Skeld/systems.json | 11 + .../Innersloth/Data/maps/Skeld/tasks.json | 613 ++ .../Innersloth/Data/maps/Skeld/vents.json | 142 + src/Impostor.Api/Innersloth/DeathReason.cs | 9 + src/Impostor.Api/Innersloth/GameCodeParser.cs | 115 + .../Innersloth/GameFilterOptions.cs | 41 + src/Impostor.Api/Innersloth/GameModes.cs | 11 + .../GameOptions/GameOptionsFactory.cs | 76 + .../Innersloth/GameOptions/GameTags.cs | 14 + .../GameOptions/HideNSeekGameOptions.cs | 195 + .../Innersloth/GameOptions/IGameOptions.cs | 67 + .../GameOptions/LegacyGameOptionsData.cs | 269 + .../GameOptions/NormalGameOptions.cs | 242 + .../RoleOptions/DetectiveRoleOptions.cs | 29 + .../RoleOptions/EngineerRoleOptions.cs | 33 + .../RoleOptions/GuardianAngelRoleOptions.cs | 37 + .../GameOptions/RoleOptions/IRoleOptions.cs | 8 + .../RoleOptions/LegacyRoleOptionsData.cs | 73 + .../RoleOptions/NoisemakerRoleOptions.cs | 33 + .../RoleOptions/PhantomRoleOptions.cs | 33 + .../RoleOptions/RoleOptionsCollection.cs | 58 + .../GameOptions/RoleOptions/RoleRate.cs | 15 + .../RoleOptions/ScientistRoleOptions.cs | 33 + .../RoleOptions/ShapeshifterRoleOptions.cs | 37 + .../RoleOptions/TrackerRoleOptions.cs | 37 + .../RoleOptions/ViperRoleOptions.cs | 29 + src/Impostor.Api/Innersloth/GameStates.cs | 11 + src/Impostor.Api/Innersloth/GameVersion.cs | 103 + src/Impostor.Api/Innersloth/KillDistances.cs | 12 + src/Impostor.Api/Innersloth/MapFlags.cs | 15 + src/Impostor.Api/Innersloth/MapTypes.cs | 12 + src/Impostor.Api/Innersloth/Maps/DoorData.cs | 19 + src/Impostor.Api/Innersloth/Maps/MapData.cs | 35 + src/Impostor.Api/Innersloth/Maps/TaskData.cs | 23 + src/Impostor.Api/Innersloth/Maps/VentData.cs | 36 + .../Innersloth/MurderResultFlags.cs | 13 + .../Innersloth/PlatformSpecificData.cs | 49 + src/Impostor.Api/Innersloth/QuickChatModes.cs | 8 + src/Impostor.Api/Innersloth/ReportOutcome.cs | 11 + src/Impostor.Api/Innersloth/ReportReasons.cs | 10 + .../Innersloth/SystemTypeHelpers.cs | 28 + src/Impostor.Api/Innersloth/TaskBarUpdate.cs | 9 + src/Impostor.Api/Innersloth/TaskCategories.cs | 9 + src/Impostor.Api/Innersloth/TextBox.cs | 22 + src/Impostor.Api/Languages/LanguageService.cs | 610 ++ src/Impostor.Api/Languages/LanguageString.cs | 44 + src/Impostor.Api/Net/Custom/ICustomMessage.cs | 7 + .../Net/Custom/ICustomMessageManager.cs | 18 + .../Net/Custom/ICustomRootMessage.cs | 9 + src/Impostor.Api/Net/Custom/ICustomRpc.cs | 10 + src/Impostor.Api/Net/IClient.cs | 107 + src/Impostor.Api/Net/IClientPlayer.cs | 43 + src/Impostor.Api/Net/IConnection.cs | 6 + src/Impostor.Api/Net/IHazelConnection.cs | 46 + src/Impostor.Api/Net/Inner/IGameNet.cs | 22 + src/Impostor.Api/Net/Inner/IInnerNetObject.cs | 13 + .../IInnerCustomNetworkTransform.cs | 20 + .../Objects/Components/IInnerPlayerPhysics.cs | 6 + .../Objects/GameManager/IInnerGameManager.cs | 5 + .../GameManager/IInnerHideAndSeekManager.cs | 8 + .../GameManager/IInnerNormalGameManager.cs | 5 + .../Logic/HideAndSeek/ILogicGameFlowHnS.cs | 8 + .../Net/Inner/Objects/IInnerGameData.cs | 6 + .../Net/Inner/Objects/IInnerLobbyBehaviour.cs | 6 + .../Net/Inner/Objects/IInnerMeetingHud.cs | 59 + .../Net/Inner/Objects/IInnerPlayerControl.cs | 179 + .../Net/Inner/Objects/IInnerPlayerInfo.cs | 47 + .../Net/Inner/Objects/IInnerVoteBanSystem.cs | 6 + .../Net/Inner/Objects/ITaskInfo.cs | 20 + .../Objects/ShipStatus/IInnerAirshipStatus.cs | 6 + .../ShipStatus/IInnerDleksShipStatus.cs | 6 + .../ShipStatus/IInnerMiraShipStatus.cs | 6 + .../ShipStatus/IInnerPolusShipStatus.cs | 6 + .../Objects/ShipStatus/IInnerShipStatus.cs | 6 + .../ShipStatus/IInnerSkeldShipStatus.cs | 6 + src/Impostor.Api/Net/LimboStates.cs | 13 + .../Net/Manager/IClientManager.cs | 9 + .../Net/Manager/ICompatibilityManager.cs | 102 + .../Net/MessageReaderExtensions.cs | 31 + .../Net/MessageWriterExtensions.cs | 38 + .../Net/Messages/C2S/HandshakeC2S.cs | 107 + .../Net/Messages/C2S/Message00HostGameC2S.cs | 24 + .../Net/Messages/C2S/Message01JoinGameC2S.cs | 19 + .../Messages/C2S/Message04RemovePlayerC2S.cs | 18 + .../Net/Messages/C2S/Message08EndGameC2S.cs | 19 + .../Net/Messages/C2S/Message10AlterGameC2S.cs | 21 + .../Messages/C2S/Message11KickPlayerC2S.cs | 18 + .../Messages/C2S/Message16GetGameListC2S.cs | 28 + .../Messages/C2S/Message21SetActivePodType.cs | 17 + .../C2S/Message22QueryPlatformIdsC2S.cs | 18 + src/Impostor.Api/Net/Messages/MessageFlags.cs | 48 + .../Net/Messages/Rpcs/Rpc00PlayAnimation.cs | 17 + .../Net/Messages/Rpcs/Rpc01CompleteTask.cs | 15 + .../Net/Messages/Rpcs/Rpc03SetInfected.cs | 17 + .../Net/Messages/Rpcs/Rpc04Exiled.cs | 13 + .../Net/Messages/Rpcs/Rpc05CheckName.cs | 15 + .../Net/Messages/Rpcs/Rpc06SetName.cs | 17 + .../Net/Messages/Rpcs/Rpc07CheckColor.cs | 17 + .../Net/Messages/Rpcs/Rpc08SetColor.cs | 19 + .../Net/Messages/Rpcs/Rpc11ReportDeadBody.cs | 15 + .../Net/Messages/Rpcs/Rpc12MurderPlayer.cs | 21 + .../Net/Messages/Rpcs/Rpc13SendChat.cs | 15 + .../Net/Messages/Rpcs/Rpc14StartMeeting.cs | 15 + .../Net/Messages/Rpcs/Rpc15SetScanner.cs | 17 + .../Net/Messages/Rpcs/Rpc16SendChatNote.cs | 19 + .../Net/Messages/Rpcs/Rpc18SetStartCounter.cs | 17 + .../Net/Messages/Rpcs/Rpc19EnterVent.cs | 15 + .../Net/Messages/Rpcs/Rpc20ExitVent.cs | 15 + .../Net/Messages/Rpcs/Rpc21SnapTo.cs | 19 + .../Net/Messages/Rpcs/Rpc22Close.cs | 13 + .../Net/Messages/Rpcs/Rpc23VotingComplete.cs | 25 + .../Net/Messages/Rpcs/Rpc24CastVote.cs | 17 + .../Net/Messages/Rpcs/Rpc25ClearVote.cs | 13 + .../Net/Messages/Rpcs/Rpc26AddVote.cs | 17 + .../Messages/Rpcs/Rpc27CloseDoorsOfType.cs | 17 + .../Net/Messages/Rpcs/Rpc29SetTasks.cs | 17 + .../Net/Messages/Rpcs/Rpc30UpdateGameData.cs | 18 + .../Net/Messages/Rpcs/Rpc31ClimbLadder.cs | 17 + .../Net/Messages/Rpcs/Rpc32UsePlatform.cs | 13 + .../Net/Messages/Rpcs/Rpc34BootFromVent.cs | 15 + .../Net/Messages/Rpcs/Rpc35UpdateSystem.cs | 27 + .../Net/Messages/Rpcs/Rpc38SetLevel.cs | 15 + .../Net/Messages/Rpcs/Rpc39SetHatStr.cs | 17 + .../Net/Messages/Rpcs/Rpc40SetSkinStr.cs | 17 + .../Net/Messages/Rpcs/Rpc41SetPetStr.cs | 17 + .../Net/Messages/Rpcs/Rpc42SetVisorStr.cs | 17 + .../Net/Messages/Rpcs/Rpc43SetNamePlateStr.cs | 17 + .../Net/Messages/Rpcs/Rpc44SetRole.cs | 19 + .../Net/Messages/Rpcs/Rpc45ProtectPlayer.cs | 21 + .../Net/Messages/Rpcs/Rpc46Shapeshift.cs | 20 + .../Net/Messages/Rpcs/Rpc47CheckMurder.cs | 18 + .../Net/Messages/Rpcs/Rpc48CheckProtect.cs | 18 + .../Net/Messages/Rpcs/Rpc49Pet.cs | 19 + .../Net/Messages/Rpcs/Rpc50CancelPet.cs | 13 + .../Net/Messages/Rpcs/Rpc51CheckZipline.cs | 15 + .../Net/Messages/Rpcs/Rpc52UseZipline.cs | 15 + .../Net/Messages/Rpcs/Rpc53TriggerSpores.cs | 15 + .../Net/Messages/Rpcs/Rpc54CheckSpore.cs | 15 + .../Net/Messages/Rpcs/Rpc55CheckShapeshift.cs | 20 + .../Messages/Rpcs/Rpc56RejectShapeshift.cs | 13 + .../Net/Messages/Rpcs/Rpc62CheckVanish.cs | 15 + .../Net/Messages/Rpcs/Rpc63StartVanish.cs | 13 + .../Net/Messages/Rpcs/Rpc64CheckAppear.cs | 15 + .../Net/Messages/Rpcs/Rpc65StartAppear.cs | 15 + .../Net/Messages/S2C/Message00HostGameS2C.cs | 19 + .../Net/Messages/S2C/Message01JoinGameS2C.cs | 58 + .../Messages/S2C/Message04RemovePlayerS2C.cs | 30 + .../Messages/S2C/Message07JoinedGameS2C.cs | 40 + .../Net/Messages/S2C/Message10AlterGameS2C.cs | 27 + .../Messages/S2C/Message11KickPlayerS2C.cs | 26 + .../Messages/S2C/Message12WaitForHostS2C.cs | 25 + .../Net/Messages/S2C/Message13RedirectS2C.cs | 26 + .../Messages/S2C/Message16GetGameListS2C.cs | 44 + .../S2C/Message22QueryPlatformIdsS2C.cs | 27 + .../S2C/Message24EndGameHostMigrationS2C.cs | 21 + .../Net/Messages/S2C/MessageDisconnect.cs | 53 + src/Impostor.Api/Plugins/DependencyType.cs | 9 + src/Impostor.Api/Plugins/IPlugin.cs | 14 + .../Plugins/IPluginHttpStartup.cs | 8 + src/Impostor.Api/Plugins/IPluginStartup.cs | 14 + .../Plugins/ImpostorDependencyAttribute.cs | 18 + .../Plugins/ImpostorPluginAttribute.cs | 30 + src/Impostor.Api/Plugins/PluginBase.cs | 44 + .../Plugins/PluginConfigLoader.cs | 30 + src/Impostor.Api/Properties/AssemblyInfo.cs | 3 + .../Service/Admin/Verify/IVerifyStore.cs | 11 + src/Impostor.Api/Unity/Mathf.cs | 61 + src/Impostor.Api/Unity/RuntimePlatform.cs | 42 + src/Impostor.Api/Utils/IDateTimeProvider.cs | 9 + src/Impostor.Api/Utils/IServerEnvironment.cs | 7 + src/Impostor.Api/Utils/IpUtils.cs | 41 + src/Impostor.Api/packages.lock.json | 144 + src/Impostor.Plugins.Example/ExamplePlugin.cs | 46 + .../ExamplePluginStartup.cs | 23 + .../Handlers/ClientEventListener.cs | 22 + .../Handlers/GameEventListener.cs | 102 + .../Handlers/MeetingEventListener.cs | 40 + .../Handlers/PlayerEventListener.cs | 141 + .../Impostor.Plugins.Example.csproj | 15 + .../packages.lock.json | 137 + .../FixedCodeConfig.cs | 26 + .../FixedCodeListener.cs | 86 + .../FixedCodePlugin.cs | 27 + .../FixedCodeStartup.cs | 25 + .../Impostor.Plugins.FixedCode.csproj | 10 + .../packages.lock.json | 137 + .../FriendCodeValidationListener.cs | 5414 +++++++++++++++++ .../FriendCodeValidatorPlugin.cs | 24 + .../FriendCodeValidatorStartup.cs | 17 + ...mpostor.Plugins.FriendCodeValidator.csproj | 10 + .../packages.lock.json | 137 + .../Impostor.Plugins.QqVerify.csproj | 10 + .../QqVerifyCommand.cs | 56 + .../QqVerifyConfig.cs | 9 + .../QqVerifyPlugin.cs | 24 + .../QqVerifyStartup.cs | 25 + .../QqVerifyStore.cs | 74 + .../packages.lock.json | 140 + .../Impostor.Plugins.Titles.csproj | 10 + .../Service/FriendCodeTitleListener.cs | 59 + .../Service/TitleEventListener.cs | 56 + src/Impostor.Plugins.Titles/TitleStore.cs | 28 + src/Impostor.Plugins.Titles/TitlesConfig.cs | 22 + src/Impostor.Plugins.Titles/TitlesPlugin.cs | 21 + .../TitlesPluginStartup.cs | 23 + .../packages.lock.json | 137 + .../Impostor.Plugins.Welcome.csproj | 10 + .../Service/WelcomeEventListener.cs | 52 + .../Service/WelcomeMessageService.cs | 97 + src/Impostor.Plugins.Welcome/WelcomePlugin.cs | 28 + .../WelcomePluginStartup.cs | 17 + .../packages.lock.json | 137 + .../Commands/CommandBootstrapper.cs | 45 + .../Commands/CommandService.cs | 68 + .../Commands/Commands/HelpCommand.cs | 56 + .../Commands/Commands/NoteCommand.cs | 41 + .../Commands/Commands/SetColorCommand.cs | 37 + .../Commands/Commands/StatCommand.cs | 69 + .../Events/Client/ClientConnectedEvent.cs | 18 + .../Events/Client/ClientConnectionEvent.cs | 18 + src/Impostor.Server/Events/EventHandler.cs | 24 + src/Impostor.Server/Events/EventManager.cs | 162 + .../Events/Game/GameAlterEvent.cs | 18 + .../Events/Game/GameCreatedEvent.cs | 19 + .../Events/Game/GameCreationEvent.cs | 46 + .../Events/Game/GameDestroyedEvent.cs | 15 + .../Events/Game/GameEndedEvent.cs | 18 + .../Events/Game/GameHostChangedEvent.cs | 22 + .../Events/Game/GameOptionsChangedEvent.cs | 19 + .../Events/Game/GamePlayerJoinedEvent.cs | 19 + .../Events/Game/GamePlayerJoiningEvent.cs | 21 + .../Events/Game/GamePlayerLeftEvent.cs | 22 + .../Events/Game/GameStartedEvent.cs | 15 + .../Events/Game/GameStartingEvent.cs | 15 + .../Events/Game/Meeting/MeetingEndedEvent.cs | 25 + .../Game/Meeting/MeetingStartedEvent.cs | 19 + .../Events/Game/Player/PlayerChatEvent.cs | 30 + .../Game/Player/PlayerCheckMurderEvent.cs | 31 + .../Game/Player/PlayerCompletedTaskEvent.cs | 26 + .../Game/Player/PlayerDestroyedEvent.cs | 23 + .../Game/Player/PlayerEnterVentEvent.cs | 27 + .../Events/Game/Player/PlayerExileEvent.cs | 23 + .../Events/Game/Player/PlayerExitVentEvent.cs | 27 + .../Events/Game/Player/PlayerMovementEvent.cs | 47 + .../Events/Game/Player/PlayerMurderEvent.cs | 29 + .../Events/Game/Player/PlayerReadyEvent.cs | 23 + .../Events/Game/Player/PlayerReportEvent.cs | 37 + .../Game/Player/PlayerSetStartCounterEvent.cs | 26 + .../Events/Game/Player/PlayerSpawnedEvent.cs | 23 + .../Game/Player/PlayerStartMeetingEvent.cs | 26 + .../Events/Game/Player/PlayerVentEvent.cs | 27 + .../Events/Game/Player/PlayerVotedEvent.cs | 29 + .../Register/IRegisteredEventListener.cs | 15 + .../InvokedRegisteredEventListener.cs | 27 + .../Register/ManualRegisteredEventListener.cs | 30 + .../Register/RegisteredEventListener.cs | 166 + .../Events/Register/TemporaryEventRegister.cs | 59 + .../WrappedRegisteredEventListener.cs | 27 + .../Extensions/IGameOptionsExtensions.cs | 24 + .../Extensions/IHazelConnectionExtensions.cs | 30 + .../Extensions/ServiceProviderExtensions.cs | 22 + .../Extensions/TypeExtensions.cs | 67 + src/Impostor.Server/GlobalUsings.cs | 2 + src/Impostor.Server/Http/AdminController.cs | 2343 +++++++ src/Impostor.Server/Http/GamesController.cs | 267 + src/Impostor.Server/Http/HelloController.cs | 93 + src/Impostor.Server/Http/ListingManager.cs | 124 + .../Http/MarketplaceController.cs | 144 + src/Impostor.Server/Http/TokenController.cs | 291 + src/Impostor.Server/Http/VerifyController.cs | 53 + src/Impostor.Server/Impostor.Server.csproj | 37 + .../Impostor.Server.csproj.DotSettings | 3 + src/Impostor.Server/Languages/de.json | 18 + src/Impostor.Server/Languages/en.json | 18 + src/Impostor.Server/Languages/es.json | 18 + src/Impostor.Server/Languages/fil.json | 18 + src/Impostor.Server/Languages/fr.json | 18 + src/Impostor.Server/Languages/ga.json | 18 + src/Impostor.Server/Languages/it.json | 18 + src/Impostor.Server/Languages/ja.json | 18 + src/Impostor.Server/Languages/ko.json | 18 + src/Impostor.Server/Languages/nl.json | 18 + src/Impostor.Server/Languages/pt.json | 18 + src/Impostor.Server/Languages/pt_BR.json | 18 + src/Impostor.Server/Languages/ru.json | 18 + src/Impostor.Server/Languages/zh_CN.json | 18 + src/Impostor.Server/Languages/zh_TW.json | 18 + src/Impostor.Server/Net/Client.cs | 484 ++ src/Impostor.Server/Net/ClientBase.cs | 83 + .../Net/Custom/CustomMessageManager.cs | 54 + .../Net/Factories/ClientFactory.cs | 24 + .../Net/Factories/IClientFactory.cs | 9 + src/Impostor.Server/Net/GameCodeFactory.cs | 12 + .../Net/Hazel/HazelConnection.cs | 80 + src/Impostor.Server/Net/Inner/GameDataTag.cs | 15 + src/Impostor.Server/Net/Inner/GameObject.cs | 29 + .../Net/Inner/InnerNetObject.Anticheat.cs | 145 + .../Net/Inner/InnerNetObject.cs | 62 + .../InnerCustomNetworkTransform.Api.cs | 27 + .../Components/InnerCustomNetworkTransform.cs | 164 + .../Components/InnerPlayerPhysics.Api.cs | 8 + .../Objects/Components/InnerPlayerPhysics.cs | 144 + .../Objects/Components/InnerVoteBanSystem.cs | 85 + .../Objects/GameManager/InnerGameManager.cs | 104 + .../InnerHideAndSeekManager.Api.cs | 9 + .../GameManager/InnerHideAndSeekManager.cs | 38 + .../GameManager/InnerNormalGameManager.cs | 21 + .../GameManager/Logic/GameLogicComponent.cs | 23 + .../Logic/HideAndSeek/LogicGameFlowHnS.cs | 20 + .../Logic/HideAndSeek/LogicHnSDangerLevel.cs | 5 + .../Logic/HideAndSeek/LogicHnSDeathPopup.cs | 5 + .../Logic/HideAndSeek/LogicHnSMusic.cs | 5 + .../Logic/HideAndSeek/LogicMinigameHnS.cs | 5 + .../Logic/HideAndSeek/LogicOptionsHnS.cs | 11 + .../Logic/HideAndSeek/LogicPingsHnS.cs | 5 + .../HideAndSeek/LogicRoleSelectionHnS.cs | 5 + .../Logic/HideAndSeek/LogicUsablesHnS.cs | 5 + .../GameManager/Logic/LogicGameFlow.cs | 5 + .../GameManager/Logic/LogicMinigame.cs | 5 + .../Objects/GameManager/Logic/LogicOptions.cs | 34 + .../GameManager/Logic/LogicRoleSelection.cs | 5 + .../Objects/GameManager/Logic/LogicUsables.cs | 5 + .../Logic/Normal/LogicGameFlowNormal.cs | 5 + .../Logic/Normal/LogicOptionsNormal.cs | 11 + .../Logic/Normal/LogicRoleSelectionNormal.cs | 5 + .../Logic/Normal/LogicUsablesBasic.cs | 5 + .../Net/Inner/Objects/InnerGameData.cs | 57 + .../Net/Inner/Objects/InnerLobbyBehaviour.cs | 27 + .../Net/Inner/Objects/InnerMeetingHud.Api.cs | 13 + .../Objects/InnerMeetingHud.PlayerVoteArea.cs | 83 + .../Objects/InnerMeetingHud.VoterState.cs | 30 + .../Net/Inner/Objects/InnerMeetingHud.cs | 298 + .../Inner/Objects/InnerPlayerControl.Api.cs | 267 + .../Net/Inner/Objects/InnerPlayerControl.cs | 1293 ++++ .../Net/Inner/Objects/InnerPlayerInfo.Api.cs | 10 + .../Net/Inner/Objects/InnerPlayerInfo.cs | 261 + .../Objects/ShipStatus/InnerAirshipStatus.cs | 47 + .../ShipStatus/InnerDleksShipStatus.cs | 27 + .../ShipStatus/InnerFungleShipStatus.cs | 25 + .../Objects/ShipStatus/InnerMiraShipStatus.cs | 25 + .../ShipStatus/InnerPolusShipStatus.cs | 46 + .../Objects/ShipStatus/InnerShipStatus.cs | 132 + .../ShipStatus/InnerSkeldShipStatus.cs | 27 + .../Net/Inner/Objects/Systems/IActivatable.cs | 7 + .../Net/Inner/Objects/Systems/ISystemType.cs | 9 + .../Systems/ShipStatus/AutoDoorsSystemType.cs | 43 + .../Systems/ShipStatus/DoorsSystemType.cs | 38 + .../Systems/ShipStatus/ElectricalDoors.cs | 29 + .../ShipStatus/HeliSabotageSystemType.cs | 52 + .../ShipStatus/HudOverrideSystemType.cs | 19 + .../Systems/ShipStatus/LifeSuppSystemType.cs | 44 + .../Systems/ShipStatus/MedScanSystem.cs | 32 + .../ShipStatus/MovingPlatformBehaviour.cs | 21 + .../MushroomMixupSabotageSystemType.cs | 76 + .../Systems/ShipStatus/ReactorSystemType.cs | 38 + .../Systems/ShipStatus/SabotageSystemType.cs | 26 + .../ShipStatus/SecurityCameraSystemType.cs | 19 + .../Systems/ShipStatus/SwitchSystem.cs | 27 + .../Net/Inner/Objects/TaskInfo.cs | 68 + src/Impostor.Server/Net/Inner/SpawnFlags.cs | 11 + .../Net/Manager/ClientManager.Api.cs | 11 + .../Net/Manager/ClientManager.cs | 183 + .../Net/Manager/CompatibilityManager.cs | 216 + .../Net/Manager/GameManager.cs | 146 + src/Impostor.Server/Net/Matchmaker.cs | 84 + src/Impostor.Server/Net/MatchmakerService.cs | 65 + .../Net/Messages/MessageWriterProvider.cs | 12 + .../Net/State/ClientPlayer.Api.cs | 18 + src/Impostor.Server/Net/State/ClientPlayer.cs | 92 + src/Impostor.Server/Net/State/Game.Api.cs | 101 + src/Impostor.Server/Net/State/Game.Data.cs | 561 ++ .../Net/State/Game.Incoming.cs | 232 + .../Net/State/Game.Outgoing.cs | 140 + src/Impostor.Server/Net/State/Game.State.cs | 125 + src/Impostor.Server/Net/State/Game.cs | 157 + src/Impostor.Server/Net/State/GameNet.Api.cs | 21 + src/Impostor.Server/Net/State/GameNet.cs | 20 + src/Impostor.Server/Pages/AdminStrings.json | 214 + src/Impostor.Server/Pages/index.html | 20 + .../Plugins/AssemblyInformation.cs | 38 + .../Plugins/DependencyInformation.cs | 18 + .../Plugins/IAssemblyInformation.cs | 14 + .../Plugins/LoadedAssemblyInformation.cs | 25 + src/Impostor.Server/Plugins/PluginConfig.cs | 11 + .../Plugins/PluginInformation.cs | 53 + src/Impostor.Server/Plugins/PluginLoader.cs | 264 + .../Plugins/PluginLoaderException.cs | 20 + .../Plugins/PluginLoaderService.cs | 64 + src/Impostor.Server/Program.cs | 246 + .../Properties/launchSettings.json | 12 + src/Impostor.Server/RealDateTimeProvider.cs | 10 + .../Recorder/ClientRecorder.cs | 89 + .../Recorder/PacketRecorder.cs | 228 + .../Recorder/PacketSerializationContext.cs | 49 + ...tSerializationContextPooledObjectPolicy.cs | 18 + .../Recorder/RecordedPacketType.cs | 10 + .../Recorder/ServerReplayVersion.cs | 18 + .../Admin/Ban/BanEnforcementListener.cs | 40 + .../Service/Admin/Ban/BanStore.cs | 150 + .../Service/Admin/Chat/ChatFilterListener.cs | 101 + .../Service/Admin/Chat/ChatFilterStore.cs | 75 + .../Admin/Reactor/ReactorHandshakeListener.cs | 254 + .../Admin/Reactor/ReactorModExtensions.cs | 20 + .../Service/Admin/Report/ReportStore.cs | 52 + .../Service/Api/DiscordWebhookListener.cs | 171 + .../Service/Auth/AuthCacheService.cs | 119 + .../Service/Stat/PlayerLogListener.cs | 95 + .../Service/Stat/PlayerLogStore.cs | 76 + .../Service/Stat/PlayerStatsListener.cs | 105 + .../Service/Stat/PlayerStatsStore.cs | 181 + src/Impostor.Server/Utils/DotnetUtils.cs | 20 + .../Utils/RealDateTimeProvider.cs | 10 + .../Utils/ServerEnvironment.cs | 11 + src/Impostor.Server/config.json | 63 + src/Impostor.Server/icon.ico | Bin 0 -> 3693 bytes src/Impostor.Server/packages.lock.json | 543 ++ src/Impostor.sln | 98 + src/NextFast.Hazel-New/.gitignore | 7 + src/NextFast.Hazel-New/Directory.Build.props | 15 + src/NextFast.Hazel-New/Hazel.sln | 37 + src/NextFast.Hazel-New/LICENSE | 22 + .../Next.Hazel.Abstractions/IMessageReader.cs | 75 + .../Next.Hazel.Abstractions/IMessageWriter.cs | 137 + .../IMessageWriterProvider.cs | 15 + .../Next.Hazel.Abstractions/MessageType.cs | 29 + .../Next.Hazel.Abstractions.csproj | 13 + src/NextFast.Hazel-New/Next.Hazel/ByteSpan.cs | 154 + .../Next.Hazel/ByteSpanExtensions.cs | 126 + .../Next.Hazel/Connection.cs | 240 + .../Next.Hazel/ConnectionListener.cs | 94 + .../Next.Hazel/ConnectionState.cs | 22 + .../Next.Hazel/ConnectionStatistics.cs | 455 ++ .../Next.Hazel/Crypto/AesGcm.cs | 325 + .../Next.Hazel/Crypto/Const.cs | 67 + .../Next.Hazel/Crypto/CryptoProvider.cs | 26 + .../Next.Hazel/Crypto/DefaultAes.cs | 49 + .../Next.Hazel/Crypto/IAes.cs | 22 + .../Next.Hazel/Crypto/Sha256Stream.cs | 77 + .../Next.Hazel/Crypto/SpanCryptoExtensions.cs | 33 + .../Next.Hazel/Crypto/X25519.cs | 810 +++ .../Next.Hazel/DataReceivedEventArgs.cs | 16 + .../Next.Hazel/DisconnectedEventArgs.cs | 23 + .../Next.Hazel/Dtls/AesGcmRecordProtection.cs | 147 + .../Next.Hazel/Dtls/ConnectionId.cs | 48 + .../Next.Hazel/Dtls/DtlsConnectionListener.cs | 1405 +++++ .../Next.Hazel/Dtls/Handshake/Certificate.cs | 66 + .../Next.Hazel/Dtls/Handshake/ClientHello.cs | 220 + .../Dtls/Handshake/Constant/CipherSuite.cs | 10 + .../Handshake/Constant/CompressionMethod.cs | 9 + .../Dtls/Handshake/Constant/ECCurveType.cs | 9 + .../Dtls/Handshake/Constant/ExtensionType.cs | 9 + .../Dtls/Handshake/Constant/Finished.cs | 9 + .../Dtls/Handshake/Constant/HandshakeType.cs | 19 + .../Dtls/Handshake/Constant/HashAlgorithm.cs | 10 + .../Dtls/Handshake/Constant/NamedCurve.cs | 11 + .../Dtls/Handshake/Constant/Random.cs | 12 + .../Handshake/Constant/SignatureAlgorithm.cs | 11 + .../Next.Hazel/Dtls/Handshake/Handshake.cs | 48 + .../Dtls/Handshake/HazelDtlsSessionInfo.cs | 58 + .../Dtls/Handshake/HelloVerifyRequest.cs | 97 + .../Next.Hazel/Dtls/Handshake/ServerHello.cs | 76 + .../Next.Hazel/Dtls/IHandshakeCipherSuite.cs | 63 + .../Next.Hazel/Dtls/IRecordProtection.cs | 81 + .../Next.Hazel/Dtls/NullRecordProtection.cs | 55 + .../Next.Hazel/Dtls/PrfSha256.cs | 80 + .../Next.Hazel/Dtls/Record.cs | 106 + .../Next.Hazel/Dtls/X25519EcdheRsaSha256.cs | 168 + .../Next.Hazel/DtlsHelper.cs | 59 + .../DefaultMessageWriterProvider.cs | 11 + .../Extensions/ServiceProviderExtensions.cs | 40 + .../Next.Hazel/Hazel.nuspec | 19 + .../Next.Hazel/HazelException.cs | 18 + src/NextFast.Hazel-New/Next.Hazel/IPMode.cs | 22 + .../Next.Hazel/IRecyclable.cs | 23 + .../Next.Hazel/MessageReader.cs | 274 + .../Next.Hazel/MessageReaderPolicy.cs | 26 + .../Next.Hazel/MessageWriter.cs | 321 + .../Next.Hazel/NetworkConnection.cs | 110 + .../Next.Hazel/NetworkConnectionListener.cs | 20 + .../Next.Hazel/NewConnectionEventArgs.cs | 23 + .../Next.Hazel/Next.Hazel.csproj | 30 + .../Next.Hazel/ObjectPoolCustom.cs | 103 + .../Next.Hazel/UPnP/ILogger.cs | 52 + .../Next.Hazel/UPnP/NetUtility.cs | 96 + .../Next.Hazel/UPnP/UPnPHelper.cs | 352 ++ .../Next.Hazel/Udp/SendOptionInternal.cs | 32 + .../Next.Hazel/Udp/UdpBroadcastListener.cs | 155 + .../Next.Hazel/Udp/UdpBroadcaster.cs | 81 + .../Next.Hazel/Udp/UdpClientConnection.cs | 244 + .../Next.Hazel/Udp/UdpConnection.KeepAlive.cs | 158 + .../Next.Hazel/Udp/UdpConnection.Reliable.cs | 473 ++ .../Next.Hazel/Udp/UdpConnection.cs | 316 + .../Next.Hazel/Udp/UdpConnectionListener.cs | 236 + .../Next.Hazel/Udp/UdpConnectionRateLimit.cs | 74 + .../Next.Hazel/Udp/UdpServerConnection.cs | 97 + src/NextFast.Hazel-New/README.md | 3 + src/NextFast.Hazel-New/build.cake | 29 + src/ProjectRules.ruleset | 26 + src/stylecop.json | 11 + 671 files changed, 50735 insertions(+), 428 deletions(-) create mode 100644 .dockerignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.cake create mode 100644 docs/Admin-panel.md create mode 100644 docs/Building-from-source.md create mode 100644 docs/Chat-filtering.md create mode 100644 docs/Discord-webhook.md create mode 100644 docs/FAQ.md create mode 100644 docs/Hello-page.md create mode 100644 docs/Http-server.md create mode 100644 docs/Player-statistics.md create mode 100644 docs/Plugin-marketplace.md create mode 100644 docs/README.md create mode 100644 docs/Running-the-server.md create mode 100644 docs/Server-configuration.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/Upgrading.md create mode 100644 docs/Writing-a-plugin.md create mode 100644 docs/images/client.jpg create mode 100644 docs/images/logo_100.png create mode 100644 marketplace/Boot.Chat.dll create mode 100644 marketplace/Boot.Codes.dll create mode 100644 marketplace/Impostor.Plugins.FixedCode.dll create mode 100644 marketplace/Impostor.Plugins.FriendCodeValidator.dll create mode 100644 marketplace/Impostor.Plugins.QqVerify.dll create mode 100644 marketplace/Impostor.Plugins.Titles.dll create mode 100644 marketplace/Impostor.Plugins.Welcome.dll create mode 100644 marketplace/Serilog.Sinks.File.dll create mode 100644 marketplace/plugins.json create mode 100644 src/.editorconfig create mode 100644 src/.gitattributes create mode 100644 src/.gitignore create mode 100644 src/Directory.Build.props create mode 100644 src/Impostor.Api.Innersloth.Generator/Extensions.cs create mode 100644 src/Impostor.Api.Innersloth.Generator/Generators/BaseGenerator.cs create mode 100644 src/Impostor.Api.Innersloth.Generator/Generators/EnumGenerator.cs create mode 100644 src/Impostor.Api.Innersloth.Generator/Generators/MapDataGenerator.cs create mode 100644 src/Impostor.Api.Innersloth.Generator/Impostor.Api.Innersloth.Generator.csproj create mode 100644 src/Impostor.Api.Innersloth.Generator/Properties/launchSettings.json create mode 100644 src/Impostor.Api.Innersloth.Generator/SourceGenerator.cs create mode 100644 src/Impostor.Api.Innersloth.Generator/Vector2Converter.cs create mode 100644 src/Impostor.Api.Innersloth.Generator/packages.lock.json create mode 100644 src/Impostor.Api/CheatCategory.cs create mode 100644 src/Impostor.Api/CheatContext.cs create mode 100644 src/Impostor.Api/Commands/CommandContext.cs create mode 100644 src/Impostor.Api/Commands/ICommand.cs create mode 100644 src/Impostor.Api/Config/AdminConfig.cs create mode 100644 src/Impostor.Api/Config/AntiCheatConfig.cs create mode 100644 src/Impostor.Api/Config/ChatFilterConfig.cs create mode 100644 src/Impostor.Api/Config/CheatingHostMode.cs create mode 100644 src/Impostor.Api/Config/CompatibilityConfig.cs create mode 100644 src/Impostor.Api/Config/DebugConfig.cs create mode 100644 src/Impostor.Api/Config/DisconnectMessages.cs create mode 100644 src/Impostor.Api/Config/DiscordWebhookConfig.cs create mode 100644 src/Impostor.Api/Config/HttpServerConfig.cs create mode 100644 src/Impostor.Api/Config/PlayerStatsConfig.cs create mode 100644 src/Impostor.Api/Config/ServerConfig.cs create mode 100644 src/Impostor.Api/Config/TimeoutConfig.cs create mode 100644 src/Impostor.Api/Events/Attributes/EventListenerAttribute.cs create mode 100644 src/Impostor.Api/Events/Client/IClientConnectedEvent.cs create mode 100644 src/Impostor.Api/Events/Client/IClientConnectionEvent.cs create mode 100644 src/Impostor.Api/Events/Client/IClientEvent.cs create mode 100644 src/Impostor.Api/Events/EventPriority.cs create mode 100644 src/Impostor.Api/Events/Game/IGameAlterEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameCreatedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameCreationEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameDestroyedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameEndedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameHostChangedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameOptionsChangedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGamePlayerJoinedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGamePlayerJoiningEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGamePlayerLeftEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameStartedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/IGameStartingEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Meeting/IMeetingEndedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Meeting/IMeetingEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Meeting/IMeetingStartedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerChatEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerCheckMurderEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerCompletedTaskEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerDestroyedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerEnterVentEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerExileEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerExitVentEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerMovementEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerMurderEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerReadyEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerReportEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerSetStartCounterEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerSpawnedEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerStartMeetingEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerVentEvent.cs create mode 100644 src/Impostor.Api/Events/Game/Player/IPlayerVotedEvent.cs create mode 100644 src/Impostor.Api/Events/IEvent.cs create mode 100644 src/Impostor.Api/Events/IEventCancelable.cs create mode 100644 src/Impostor.Api/Events/IEventListener.cs create mode 100644 src/Impostor.Api/Events/IManualEventListener.cs create mode 100644 src/Impostor.Api/Events/Managers/IEventManager.cs create mode 100644 src/Impostor.Api/Events/MultiDisposable.cs create mode 100644 src/Impostor.Api/Exceptions/ImpostorConfigException.cs create mode 100644 src/Impostor.Api/Exceptions/ImpostorException.cs create mode 100644 src/Impostor.Api/Exceptions/ImpostorProtocolException.cs create mode 100644 src/Impostor.Api/Extensions/DictionaryExtensions.cs create mode 100644 src/Impostor.Api/Extensions/MurderResultFlagsExtensions.cs create mode 100644 src/Impostor.Api/Extensions/SpanReaderExtensions.cs create mode 100644 src/Impostor.Api/Extensions/SystemTypesExtensions.cs create mode 100644 src/Impostor.Api/Games/Extensions/GameExtensions.cs create mode 100644 src/Impostor.Api/Games/Extensions/GameManagerExtensions.cs create mode 100644 src/Impostor.Api/Games/GameCode.cs create mode 100644 src/Impostor.Api/Games/GameJoinError.cs create mode 100644 src/Impostor.Api/Games/GameJoinResult.cs create mode 100644 src/Impostor.Api/Games/IGame.cs create mode 100644 src/Impostor.Api/Games/IGameCodeFactory.cs create mode 100644 src/Impostor.Api/Games/Managers/IGameManager.cs create mode 100644 src/Impostor.Api/GlobalUsings.cs create mode 100644 src/Impostor.Api/Http/IListingFilter.cs create mode 100644 src/Impostor.Api/Impostor.Api.csproj create mode 100644 src/Impostor.Api/Impostor.Api.csproj.DotSettings create mode 100644 src/Impostor.Api/Innersloth/AlterGameTags.cs create mode 100644 src/Impostor.Api/Innersloth/ChatNoteType.cs create mode 100644 src/Impostor.Api/Innersloth/CrossplayFlags.cs create mode 100644 src/Impostor.Api/Innersloth/Customization/PlayerOutfit.cs create mode 100644 src/Impostor.Api/Innersloth/Customization/PlayerOutfitType.cs create mode 100644 src/Impostor.Api/Innersloth/Data/SpawnableObjects.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/ColorType.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/DisconnectReasons.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/GameKeywords.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/GameOverReason.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/Platforms.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/RoleTypes.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/RpcCalls.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/RulesPresets.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/SanctionReasons.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/SpecialGameModes.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/StringNames.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/SupportedLangs.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/SystemTypes.json create mode 100644 src/Impostor.Api/Innersloth/Data/enums/TaskTypes.json create mode 100644 src/Impostor.Api/Innersloth/Data/info.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Airship/doors.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Airship/spawn.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Airship/systems.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Airship/tasks.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Airship/vents.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/April/doors.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/April/spawn.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/April/systems.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/April/tasks.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/April/vents.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Fungle/doors.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Fungle/spawn.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Fungle/systems.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Fungle/tasks.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Fungle/vents.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Mira/doors.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Mira/spawn.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Mira/systems.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Mira/tasks.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Mira/vents.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Polus/doors.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Polus/spawn.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Polus/systems.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Polus/tasks.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Polus/vents.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Skeld/doors.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Skeld/spawn.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Skeld/systems.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Skeld/tasks.json create mode 100644 src/Impostor.Api/Innersloth/Data/maps/Skeld/vents.json create mode 100644 src/Impostor.Api/Innersloth/DeathReason.cs create mode 100644 src/Impostor.Api/Innersloth/GameCodeParser.cs create mode 100644 src/Impostor.Api/Innersloth/GameFilterOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameModes.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/GameOptionsFactory.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/GameTags.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/HideNSeekGameOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/IGameOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/LegacyGameOptionsData.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/NormalGameOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/DetectiveRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/EngineerRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/GuardianAngelRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/IRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/LegacyRoleOptionsData.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/NoisemakerRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/PhantomRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/RoleOptionsCollection.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/RoleRate.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/ScientistRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/ShapeshifterRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/TrackerRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameOptions/RoleOptions/ViperRoleOptions.cs create mode 100644 src/Impostor.Api/Innersloth/GameStates.cs create mode 100644 src/Impostor.Api/Innersloth/GameVersion.cs create mode 100644 src/Impostor.Api/Innersloth/KillDistances.cs create mode 100644 src/Impostor.Api/Innersloth/MapFlags.cs create mode 100644 src/Impostor.Api/Innersloth/MapTypes.cs create mode 100644 src/Impostor.Api/Innersloth/Maps/DoorData.cs create mode 100644 src/Impostor.Api/Innersloth/Maps/MapData.cs create mode 100644 src/Impostor.Api/Innersloth/Maps/TaskData.cs create mode 100644 src/Impostor.Api/Innersloth/Maps/VentData.cs create mode 100644 src/Impostor.Api/Innersloth/MurderResultFlags.cs create mode 100644 src/Impostor.Api/Innersloth/PlatformSpecificData.cs create mode 100644 src/Impostor.Api/Innersloth/QuickChatModes.cs create mode 100644 src/Impostor.Api/Innersloth/ReportOutcome.cs create mode 100644 src/Impostor.Api/Innersloth/ReportReasons.cs create mode 100644 src/Impostor.Api/Innersloth/SystemTypeHelpers.cs create mode 100644 src/Impostor.Api/Innersloth/TaskBarUpdate.cs create mode 100644 src/Impostor.Api/Innersloth/TaskCategories.cs create mode 100644 src/Impostor.Api/Innersloth/TextBox.cs create mode 100644 src/Impostor.Api/Languages/LanguageService.cs create mode 100644 src/Impostor.Api/Languages/LanguageString.cs create mode 100644 src/Impostor.Api/Net/Custom/ICustomMessage.cs create mode 100644 src/Impostor.Api/Net/Custom/ICustomMessageManager.cs create mode 100644 src/Impostor.Api/Net/Custom/ICustomRootMessage.cs create mode 100644 src/Impostor.Api/Net/Custom/ICustomRpc.cs create mode 100644 src/Impostor.Api/Net/IClient.cs create mode 100644 src/Impostor.Api/Net/IClientPlayer.cs create mode 100644 src/Impostor.Api/Net/IConnection.cs create mode 100644 src/Impostor.Api/Net/IHazelConnection.cs create mode 100644 src/Impostor.Api/Net/Inner/IGameNet.cs create mode 100644 src/Impostor.Api/Net/Inner/IInnerNetObject.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/Components/IInnerCustomNetworkTransform.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/Components/IInnerPlayerPhysics.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/GameManager/IInnerGameManager.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/GameManager/IInnerHideAndSeekManager.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/GameManager/IInnerNormalGameManager.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/GameManager/Logic/HideAndSeek/ILogicGameFlowHnS.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/IInnerGameData.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/IInnerLobbyBehaviour.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/IInnerMeetingHud.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/IInnerPlayerControl.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/IInnerPlayerInfo.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/IInnerVoteBanSystem.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ITaskInfo.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ShipStatus/IInnerAirshipStatus.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ShipStatus/IInnerDleksShipStatus.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ShipStatus/IInnerMiraShipStatus.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ShipStatus/IInnerPolusShipStatus.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ShipStatus/IInnerShipStatus.cs create mode 100644 src/Impostor.Api/Net/Inner/Objects/ShipStatus/IInnerSkeldShipStatus.cs create mode 100644 src/Impostor.Api/Net/LimboStates.cs create mode 100644 src/Impostor.Api/Net/Manager/IClientManager.cs create mode 100644 src/Impostor.Api/Net/Manager/ICompatibilityManager.cs create mode 100644 src/Impostor.Api/Net/MessageReaderExtensions.cs create mode 100644 src/Impostor.Api/Net/MessageWriterExtensions.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/HandshakeC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message00HostGameC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message01JoinGameC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message04RemovePlayerC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message08EndGameC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message10AlterGameC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message11KickPlayerC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message16GetGameListC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message21SetActivePodType.cs create mode 100644 src/Impostor.Api/Net/Messages/C2S/Message22QueryPlatformIdsC2S.cs create mode 100644 src/Impostor.Api/Net/Messages/MessageFlags.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc00PlayAnimation.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc01CompleteTask.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc03SetInfected.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc04Exiled.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc05CheckName.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc06SetName.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc07CheckColor.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc08SetColor.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc11ReportDeadBody.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc12MurderPlayer.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc13SendChat.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc14StartMeeting.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc15SetScanner.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc16SendChatNote.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc18SetStartCounter.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc19EnterVent.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc20ExitVent.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc21SnapTo.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc22Close.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc23VotingComplete.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc24CastVote.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc25ClearVote.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc26AddVote.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc27CloseDoorsOfType.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc29SetTasks.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc30UpdateGameData.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc31ClimbLadder.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc32UsePlatform.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc34BootFromVent.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc35UpdateSystem.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc38SetLevel.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc39SetHatStr.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc40SetSkinStr.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc41SetPetStr.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc42SetVisorStr.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc43SetNamePlateStr.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc44SetRole.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc45ProtectPlayer.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc46Shapeshift.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc47CheckMurder.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc48CheckProtect.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc49Pet.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc50CancelPet.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc51CheckZipline.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc52UseZipline.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc53TriggerSpores.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc54CheckSpore.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc55CheckShapeshift.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc56RejectShapeshift.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc62CheckVanish.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc63StartVanish.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc64CheckAppear.cs create mode 100644 src/Impostor.Api/Net/Messages/Rpcs/Rpc65StartAppear.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message00HostGameS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message01JoinGameS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message04RemovePlayerS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message10AlterGameS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message11KickPlayerS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message12WaitForHostS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message13RedirectS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message16GetGameListS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message22QueryPlatformIdsS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/Message24EndGameHostMigrationS2C.cs create mode 100644 src/Impostor.Api/Net/Messages/S2C/MessageDisconnect.cs create mode 100644 src/Impostor.Api/Plugins/DependencyType.cs create mode 100644 src/Impostor.Api/Plugins/IPlugin.cs create mode 100644 src/Impostor.Api/Plugins/IPluginHttpStartup.cs create mode 100644 src/Impostor.Api/Plugins/IPluginStartup.cs create mode 100644 src/Impostor.Api/Plugins/ImpostorDependencyAttribute.cs create mode 100644 src/Impostor.Api/Plugins/ImpostorPluginAttribute.cs create mode 100644 src/Impostor.Api/Plugins/PluginBase.cs create mode 100644 src/Impostor.Api/Plugins/PluginConfigLoader.cs create mode 100644 src/Impostor.Api/Properties/AssemblyInfo.cs create mode 100644 src/Impostor.Api/Service/Admin/Verify/IVerifyStore.cs create mode 100644 src/Impostor.Api/Unity/Mathf.cs create mode 100644 src/Impostor.Api/Unity/RuntimePlatform.cs create mode 100644 src/Impostor.Api/Utils/IDateTimeProvider.cs create mode 100644 src/Impostor.Api/Utils/IServerEnvironment.cs create mode 100644 src/Impostor.Api/Utils/IpUtils.cs create mode 100644 src/Impostor.Api/packages.lock.json create mode 100644 src/Impostor.Plugins.Example/ExamplePlugin.cs create mode 100644 src/Impostor.Plugins.Example/ExamplePluginStartup.cs create mode 100644 src/Impostor.Plugins.Example/Handlers/ClientEventListener.cs create mode 100644 src/Impostor.Plugins.Example/Handlers/GameEventListener.cs create mode 100644 src/Impostor.Plugins.Example/Handlers/MeetingEventListener.cs create mode 100644 src/Impostor.Plugins.Example/Handlers/PlayerEventListener.cs create mode 100644 src/Impostor.Plugins.Example/Impostor.Plugins.Example.csproj create mode 100644 src/Impostor.Plugins.Example/packages.lock.json create mode 100644 src/Impostor.Plugins.FixedCode/FixedCodeConfig.cs create mode 100644 src/Impostor.Plugins.FixedCode/FixedCodeListener.cs create mode 100644 src/Impostor.Plugins.FixedCode/FixedCodePlugin.cs create mode 100644 src/Impostor.Plugins.FixedCode/FixedCodeStartup.cs create mode 100644 src/Impostor.Plugins.FixedCode/Impostor.Plugins.FixedCode.csproj create mode 100644 src/Impostor.Plugins.FixedCode/packages.lock.json create mode 100644 src/Impostor.Plugins.FriendCodeValidator/FriendCodeValidationListener.cs create mode 100644 src/Impostor.Plugins.FriendCodeValidator/FriendCodeValidatorPlugin.cs create mode 100644 src/Impostor.Plugins.FriendCodeValidator/FriendCodeValidatorStartup.cs create mode 100644 src/Impostor.Plugins.FriendCodeValidator/Impostor.Plugins.FriendCodeValidator.csproj create mode 100644 src/Impostor.Plugins.FriendCodeValidator/packages.lock.json create mode 100644 src/Impostor.Plugins.QqVerify/Impostor.Plugins.QqVerify.csproj create mode 100644 src/Impostor.Plugins.QqVerify/QqVerifyCommand.cs create mode 100644 src/Impostor.Plugins.QqVerify/QqVerifyConfig.cs create mode 100644 src/Impostor.Plugins.QqVerify/QqVerifyPlugin.cs create mode 100644 src/Impostor.Plugins.QqVerify/QqVerifyStartup.cs create mode 100644 src/Impostor.Plugins.QqVerify/QqVerifyStore.cs create mode 100644 src/Impostor.Plugins.QqVerify/packages.lock.json create mode 100644 src/Impostor.Plugins.Titles/Impostor.Plugins.Titles.csproj create mode 100644 src/Impostor.Plugins.Titles/Service/FriendCodeTitleListener.cs create mode 100644 src/Impostor.Plugins.Titles/Service/TitleEventListener.cs create mode 100644 src/Impostor.Plugins.Titles/TitleStore.cs create mode 100644 src/Impostor.Plugins.Titles/TitlesConfig.cs create mode 100644 src/Impostor.Plugins.Titles/TitlesPlugin.cs create mode 100644 src/Impostor.Plugins.Titles/TitlesPluginStartup.cs create mode 100644 src/Impostor.Plugins.Titles/packages.lock.json create mode 100644 src/Impostor.Plugins.Welcome/Impostor.Plugins.Welcome.csproj create mode 100644 src/Impostor.Plugins.Welcome/Service/WelcomeEventListener.cs create mode 100644 src/Impostor.Plugins.Welcome/Service/WelcomeMessageService.cs create mode 100644 src/Impostor.Plugins.Welcome/WelcomePlugin.cs create mode 100644 src/Impostor.Plugins.Welcome/WelcomePluginStartup.cs create mode 100644 src/Impostor.Plugins.Welcome/packages.lock.json create mode 100644 src/Impostor.Server/Commands/CommandBootstrapper.cs create mode 100644 src/Impostor.Server/Commands/CommandService.cs create mode 100644 src/Impostor.Server/Commands/Commands/HelpCommand.cs create mode 100644 src/Impostor.Server/Commands/Commands/NoteCommand.cs create mode 100644 src/Impostor.Server/Commands/Commands/SetColorCommand.cs create mode 100644 src/Impostor.Server/Commands/Commands/StatCommand.cs create mode 100644 src/Impostor.Server/Events/Client/ClientConnectedEvent.cs create mode 100644 src/Impostor.Server/Events/Client/ClientConnectionEvent.cs create mode 100644 src/Impostor.Server/Events/EventHandler.cs create mode 100644 src/Impostor.Server/Events/EventManager.cs create mode 100644 src/Impostor.Server/Events/Game/GameAlterEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameCreatedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameCreationEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameDestroyedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameEndedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameHostChangedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameOptionsChangedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GamePlayerJoinedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GamePlayerJoiningEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GamePlayerLeftEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameStartedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/GameStartingEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Meeting/MeetingEndedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Meeting/MeetingStartedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerChatEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerCheckMurderEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerCompletedTaskEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerDestroyedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerEnterVentEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerExileEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerExitVentEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerMovementEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerMurderEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerReadyEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerReportEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerSetStartCounterEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerSpawnedEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerStartMeetingEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerVentEvent.cs create mode 100644 src/Impostor.Server/Events/Game/Player/PlayerVotedEvent.cs create mode 100644 src/Impostor.Server/Events/Register/IRegisteredEventListener.cs create mode 100644 src/Impostor.Server/Events/Register/InvokedRegisteredEventListener.cs create mode 100644 src/Impostor.Server/Events/Register/ManualRegisteredEventListener.cs create mode 100644 src/Impostor.Server/Events/Register/RegisteredEventListener.cs create mode 100644 src/Impostor.Server/Events/Register/TemporaryEventRegister.cs create mode 100644 src/Impostor.Server/Events/Register/WrappedRegisteredEventListener.cs create mode 100644 src/Impostor.Server/Extensions/IGameOptionsExtensions.cs create mode 100644 src/Impostor.Server/Extensions/IHazelConnectionExtensions.cs create mode 100644 src/Impostor.Server/Extensions/ServiceProviderExtensions.cs create mode 100644 src/Impostor.Server/Extensions/TypeExtensions.cs create mode 100644 src/Impostor.Server/GlobalUsings.cs create mode 100644 src/Impostor.Server/Http/AdminController.cs create mode 100644 src/Impostor.Server/Http/GamesController.cs create mode 100644 src/Impostor.Server/Http/HelloController.cs create mode 100644 src/Impostor.Server/Http/ListingManager.cs create mode 100644 src/Impostor.Server/Http/MarketplaceController.cs create mode 100644 src/Impostor.Server/Http/TokenController.cs create mode 100644 src/Impostor.Server/Http/VerifyController.cs create mode 100644 src/Impostor.Server/Impostor.Server.csproj create mode 100644 src/Impostor.Server/Impostor.Server.csproj.DotSettings create mode 100644 src/Impostor.Server/Languages/de.json create mode 100644 src/Impostor.Server/Languages/en.json create mode 100644 src/Impostor.Server/Languages/es.json create mode 100644 src/Impostor.Server/Languages/fil.json create mode 100644 src/Impostor.Server/Languages/fr.json create mode 100644 src/Impostor.Server/Languages/ga.json create mode 100644 src/Impostor.Server/Languages/it.json create mode 100644 src/Impostor.Server/Languages/ja.json create mode 100644 src/Impostor.Server/Languages/ko.json create mode 100644 src/Impostor.Server/Languages/nl.json create mode 100644 src/Impostor.Server/Languages/pt.json create mode 100644 src/Impostor.Server/Languages/pt_BR.json create mode 100644 src/Impostor.Server/Languages/ru.json create mode 100644 src/Impostor.Server/Languages/zh_CN.json create mode 100644 src/Impostor.Server/Languages/zh_TW.json create mode 100644 src/Impostor.Server/Net/Client.cs create mode 100644 src/Impostor.Server/Net/ClientBase.cs create mode 100644 src/Impostor.Server/Net/Custom/CustomMessageManager.cs create mode 100644 src/Impostor.Server/Net/Factories/ClientFactory.cs create mode 100644 src/Impostor.Server/Net/Factories/IClientFactory.cs create mode 100644 src/Impostor.Server/Net/GameCodeFactory.cs create mode 100644 src/Impostor.Server/Net/Hazel/HazelConnection.cs create mode 100644 src/Impostor.Server/Net/Inner/GameDataTag.cs create mode 100644 src/Impostor.Server/Net/Inner/GameObject.cs create mode 100644 src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs create mode 100644 src/Impostor.Server/Net/Inner/InnerNetObject.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.Api.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.Api.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Components/InnerVoteBanSystem.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/InnerGameManager.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.Api.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/InnerNormalGameManager.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/GameLogicComponent.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicGameFlowHnS.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDangerLevel.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDeathPopup.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSMusic.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicMinigameHnS.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicOptionsHnS.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicPingsHnS.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicRoleSelectionHnS.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicUsablesHnS.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicGameFlow.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicMinigame.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicOptions.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicRoleSelection.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicUsables.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicGameFlowNormal.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicOptionsNormal.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicRoleSelectionNormal.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicUsablesBasic.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerGameData.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerLobbyBehaviour.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.Api.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.PlayerVoteArea.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.VoterState.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.Api.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerAirshipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerDleksShipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerFungleShipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerMiraShipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerPolusShipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerSkeldShipStatus.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/IActivatable.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ISystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/AutoDoorsSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/DoorsSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ElectricalDoors.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HeliSabotageSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HudOverrideSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/LifeSuppSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MedScanSystem.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MovingPlatformBehaviour.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MushroomMixupSabotageSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ReactorSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SabotageSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SecurityCameraSystemType.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SwitchSystem.cs create mode 100644 src/Impostor.Server/Net/Inner/Objects/TaskInfo.cs create mode 100644 src/Impostor.Server/Net/Inner/SpawnFlags.cs create mode 100644 src/Impostor.Server/Net/Manager/ClientManager.Api.cs create mode 100644 src/Impostor.Server/Net/Manager/ClientManager.cs create mode 100644 src/Impostor.Server/Net/Manager/CompatibilityManager.cs create mode 100644 src/Impostor.Server/Net/Manager/GameManager.cs create mode 100644 src/Impostor.Server/Net/Matchmaker.cs create mode 100644 src/Impostor.Server/Net/MatchmakerService.cs create mode 100644 src/Impostor.Server/Net/Messages/MessageWriterProvider.cs create mode 100644 src/Impostor.Server/Net/State/ClientPlayer.Api.cs create mode 100644 src/Impostor.Server/Net/State/ClientPlayer.cs create mode 100644 src/Impostor.Server/Net/State/Game.Api.cs create mode 100644 src/Impostor.Server/Net/State/Game.Data.cs create mode 100644 src/Impostor.Server/Net/State/Game.Incoming.cs create mode 100644 src/Impostor.Server/Net/State/Game.Outgoing.cs create mode 100644 src/Impostor.Server/Net/State/Game.State.cs create mode 100644 src/Impostor.Server/Net/State/Game.cs create mode 100644 src/Impostor.Server/Net/State/GameNet.Api.cs create mode 100644 src/Impostor.Server/Net/State/GameNet.cs create mode 100644 src/Impostor.Server/Pages/AdminStrings.json create mode 100644 src/Impostor.Server/Pages/index.html create mode 100644 src/Impostor.Server/Plugins/AssemblyInformation.cs create mode 100644 src/Impostor.Server/Plugins/DependencyInformation.cs create mode 100644 src/Impostor.Server/Plugins/IAssemblyInformation.cs create mode 100644 src/Impostor.Server/Plugins/LoadedAssemblyInformation.cs create mode 100644 src/Impostor.Server/Plugins/PluginConfig.cs create mode 100644 src/Impostor.Server/Plugins/PluginInformation.cs create mode 100644 src/Impostor.Server/Plugins/PluginLoader.cs create mode 100644 src/Impostor.Server/Plugins/PluginLoaderException.cs create mode 100644 src/Impostor.Server/Plugins/PluginLoaderService.cs create mode 100644 src/Impostor.Server/Program.cs create mode 100644 src/Impostor.Server/Properties/launchSettings.json create mode 100644 src/Impostor.Server/RealDateTimeProvider.cs create mode 100644 src/Impostor.Server/Recorder/ClientRecorder.cs create mode 100644 src/Impostor.Server/Recorder/PacketRecorder.cs create mode 100644 src/Impostor.Server/Recorder/PacketSerializationContext.cs create mode 100644 src/Impostor.Server/Recorder/PacketSerializationContextPooledObjectPolicy.cs create mode 100644 src/Impostor.Server/Recorder/RecordedPacketType.cs create mode 100644 src/Impostor.Server/Recorder/ServerReplayVersion.cs create mode 100644 src/Impostor.Server/Service/Admin/Ban/BanEnforcementListener.cs create mode 100644 src/Impostor.Server/Service/Admin/Ban/BanStore.cs create mode 100644 src/Impostor.Server/Service/Admin/Chat/ChatFilterListener.cs create mode 100644 src/Impostor.Server/Service/Admin/Chat/ChatFilterStore.cs create mode 100644 src/Impostor.Server/Service/Admin/Reactor/ReactorHandshakeListener.cs create mode 100644 src/Impostor.Server/Service/Admin/Reactor/ReactorModExtensions.cs create mode 100644 src/Impostor.Server/Service/Admin/Report/ReportStore.cs create mode 100644 src/Impostor.Server/Service/Api/DiscordWebhookListener.cs create mode 100644 src/Impostor.Server/Service/Auth/AuthCacheService.cs create mode 100644 src/Impostor.Server/Service/Stat/PlayerLogListener.cs create mode 100644 src/Impostor.Server/Service/Stat/PlayerLogStore.cs create mode 100644 src/Impostor.Server/Service/Stat/PlayerStatsListener.cs create mode 100644 src/Impostor.Server/Service/Stat/PlayerStatsStore.cs create mode 100644 src/Impostor.Server/Utils/DotnetUtils.cs create mode 100644 src/Impostor.Server/Utils/RealDateTimeProvider.cs create mode 100644 src/Impostor.Server/Utils/ServerEnvironment.cs create mode 100644 src/Impostor.Server/config.json create mode 100644 src/Impostor.Server/icon.ico create mode 100644 src/Impostor.Server/packages.lock.json create mode 100644 src/Impostor.sln create mode 100644 src/NextFast.Hazel-New/.gitignore create mode 100644 src/NextFast.Hazel-New/Directory.Build.props create mode 100644 src/NextFast.Hazel-New/Hazel.sln create mode 100644 src/NextFast.Hazel-New/LICENSE create mode 100644 src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageReader.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriter.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriterProvider.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel.Abstractions/MessageType.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel.Abstractions/Next.Hazel.Abstractions.csproj create mode 100644 src/NextFast.Hazel-New/Next.Hazel/ByteSpan.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/ByteSpanExtensions.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Connection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/ConnectionListener.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/ConnectionState.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/ConnectionStatistics.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/AesGcm.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/Const.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/CryptoProvider.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/DefaultAes.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/IAes.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/Sha256Stream.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/SpanCryptoExtensions.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Crypto/X25519.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/DataReceivedEventArgs.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/DisconnectedEventArgs.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/AesGcmRecordProtection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/ConnectionId.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/DtlsConnectionListener.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Certificate.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ClientHello.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CipherSuite.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CompressionMethod.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ECCurveType.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ExtensionType.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Finished.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HandshakeType.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HashAlgorithm.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/NamedCurve.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Random.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/SignatureAlgorithm.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Handshake.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HazelDtlsSessionInfo.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HelloVerifyRequest.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ServerHello.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/IHandshakeCipherSuite.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/IRecordProtection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/NullRecordProtection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/PrfSha256.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/Record.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Dtls/X25519EcdheRsaSha256.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/DtlsHelper.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Extensions/DefaultMessageWriterProvider.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Extensions/ServiceProviderExtensions.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Hazel.nuspec create mode 100644 src/NextFast.Hazel-New/Next.Hazel/HazelException.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/IPMode.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/IRecyclable.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/MessageReader.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/MessageReaderPolicy.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/MessageWriter.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/NetworkConnection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/NetworkConnectionListener.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/NewConnectionEventArgs.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Next.Hazel.csproj create mode 100644 src/NextFast.Hazel-New/Next.Hazel/ObjectPoolCustom.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/UPnP/ILogger.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/UPnP/NetUtility.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/UPnP/UPnPHelper.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/SendOptionInternal.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcastListener.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcaster.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpClientConnection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.KeepAlive.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.Reliable.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionRateLimit.cs create mode 100644 src/NextFast.Hazel-New/Next.Hazel/Udp/UdpServerConnection.cs create mode 100644 src/NextFast.Hazel-New/README.md create mode 100644 src/NextFast.Hazel-New/build.cake create mode 100644 src/ProjectRules.ruleset create mode 100644 src/stylecop.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9163c62 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.dockerignore +.env +.gitignore +.vs +.vscode +**/.git +**/.idea +**/bin +**/obj \ No newline at end of file diff --git a/.gitignore b/.gitignore index d5a18de..02a1e37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,429 +1,7 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +/.vscode +/.claude +/build +/.vs +/tools -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates -*.env - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* -*.trx - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Approval Tests result files -*.received.* - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ -.artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.idb -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -**/.paket/paket.exe -paket-files/ - -# FAKE - F# Make -**/.fake/ - -# CodeRush personal settings -**/.cr/personal - -# Python Tools for Visual Studio (PTVS) -**/__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -**/.mfractor/ - -# Local History for Visual Studio -**/.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp +.local/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..addde9d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contributing to Empostor + +Thank you for your interest in contributing! We welcome bug reports, feature suggestions, and pull requests. + +--- + +## Getting Started + +### Prerequisites + +- [.NET 8 SDK](https://dotnet.microsoft.com/download) +- An Among Us installation (for testing) +- Git + +### Build + +```bash +git clone https://github.com/your-org/Empostor.git +cd Empostor +dotnet build src/Impostor.sln +``` + +### Run locally + +```bash +dotnet run --project src/Core/Impostor.Server +``` + +--- + +## Reporting Issues + +- Search [existing issues](../../issues) before opening a new one. +- Include your Empostor version, .NET version, and steps to reproduce. +- For security vulnerabilities, **do not open a public issue** — contact us on Discord directly. + +--- + +## Pull Requests + +- **One feature / fix per PR** — keeps review focused and history clean. +- Branch from `master` and name your branch meaningfully (e.g. `fix/language-fallback`, `feat/report-dashboard`). +- Write clear commit messages. +- Ensure the project compiles without errors or StyleCop warnings before submitting. +- If your PR adds a user-facing feature, update or add the relevant doc in `docs/`. +- Do **not** commit `bin/`, `obj/`, or `packages.lock.json` files. + +### PR checklist + +- [ ] Code compiles and existing tests pass +- [ ] No `bin/` or `obj/` files committed +- [ ] StyleCop warnings resolved +- [ ] Relevant docs updated (if applicable) +- [ ] Single logical change per PR + +--- + +## Code Style + +- Follow the existing patterns in the codebase. +- The `.editorconfig` and `stylecop.json` files enforce formatting — let your IDE apply them. +- Keep variable names concise but descriptive; no abbreviations unless they are widely understood (`ctx`, `cfg`, `id`). +- Avoid unnecessary blank lines or trailing spaces. + +--- + +## Writing a Plugin + +Plugins implement `PluginBase` + `IPluginStartup` and reference `Impostor.Api`. + +```csharp +[ImpostorPlugin("com.example.myplugin", "My Plugin", "Author", "1.0.0")] +public sealed class MyPlugin : PluginBase +{ + public override ValueTask EnableAsync() + { + // load config: var cfg = LoadConfig(); + return default; + } +} +``` + +See **[docs/Writing-a-plugin.md](docs/Writing-a-plugin.md)** for the full guide. + +Custom commands can be registered by injecting `CommandService` and calling `Register(new MyCommand())`. The `ICommand` interface lives in `Impostor.Api.Commands` so plugins can use it without referencing `Impostor.Server`. + +--- + +## Questions + +Join us on [Discord](https://discord.gg/5fPmpxxnrc) — we're happy to help. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15c5273 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build + +# See for all possible platforms +# https://github.com/containerd/containerd/blob/master/platforms/platforms.go#L17 +ARG TARGETARCH + +ARG VERSIONSUFFIX="docker" + +WORKDIR /source + +# Copy csproj and restore. +COPY src/Impostor.Server/Impostor.Server.csproj ./src/Impostor.Server/Impostor.Server.csproj +COPY src/Impostor.Api.Innersloth.Generator/Impostor.Api.Innersloth.Generator.csproj ./src/Impostor.Api.Innersloth.Generator/Impostor.Api.Innersloth.Generator.csproj +COPY src/Impostor.Api/Impostor.Api.csproj ./src/Impostor.Api/Impostor.Api.csproj +COPY src/Directory.Build.props ./src/Directory.Build.props + +RUN case "$TARGETARCH" in \ + amd64) NETCORE_PLATFORM='linux-x64';; \ + arm64) NETCORE_PLATFORM='linux-arm64';; \ + arm) NETCORE_PLATFORM='linux-arm';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac && \ + dotnet restore -r "$NETCORE_PLATFORM" ./src/Impostor.Server/Impostor.Server.csproj && \ + dotnet restore -r "$NETCORE_PLATFORM" ./src/Impostor.Api.Innersloth.Generator/Impostor.Api.Innersloth.Generator.csproj && \ + dotnet restore -r "$NETCORE_PLATFORM" ./src/Impostor.Api/Impostor.Api.csproj + +# Copy everything else. +COPY src/. ./src/ +RUN case "$TARGETARCH" in \ + amd64) NETCORE_PLATFORM='linux-x64';; \ + arm64) NETCORE_PLATFORM='linux-arm64';; \ + arm) NETCORE_PLATFORM='linux-arm';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac && \ + [ $VERSIONSUFFIX = "none" ] && VERSIONSUFFIX=; \ + dotnet publish -c release -o /app -r "$NETCORE_PLATFORM" -p:VersionSuffix="$VERSIONSUFFIX" --no-restore ./src/Impostor.Server/Impostor.Server.csproj + +# Final image. +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build /app ./ + +# Override ASPNETCORE_URLS to stop warning. +ENV ASPNETCORE_URLS= + +EXPOSE 22023/tcp 22023/udp +ENTRYPOINT ["./Impostor.Server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/build.cake b/build.cake new file mode 100644 index 0000000..0b00dd8 --- /dev/null +++ b/build.cake @@ -0,0 +1,165 @@ +#addin "nuget:?package=SharpZipLib&Version=1.3.3" +#addin "nuget:?package=Cake.Compression&Version=0.3.0" +#addin "nuget:?package=Cake.FileHelpers&Version=5.0.0" + +var workflow = BuildSystem.GitHubActions.Environment.Workflow; +var buildId = workflow.RunNumber; +var tag = workflow.RefType == GitHubActionsRefType.Tag ? workflow.RefName : null; + +var buildVersion = FindRegexMatchGroupInFile("./src/Directory.Build.props", @"\(.*?)\<\/VersionPrefix\>", 1, System.Text.RegularExpressions.RegexOptions.None).Value; +var buildDir = MakeAbsolute(Directory("./build")); + +var target = Argument("target", "Test"); +var configuration = Argument("configuration", "Release"); + +var msbuildSettings = new DotNetMSBuildSettings(); + +if (tag != null) +{ + if (tag[1..] != buildVersion) throw new Exception("Tag version has to be the same as VersionPrefix in Directory.Build.props"); + msbuildSettings.Version = buildVersion; +} +else if (buildId != 0) +{ + buildId += 500; + msbuildSettings.VersionSuffix = "ci." + buildId; + buildVersion += "-ci." + buildId; +} +else +{ + buildVersion += "-dev"; +} + +////////////////////////////////////////////////////////////////////// +// UTILS +////////////////////////////////////////////////////////////////////// + +// Remove unnecessary files for packaging. +private void ImpostorPublish(string name, string project, string runtime, bool isServer = false) { + var projBuildDir = buildDir.Combine(name + "_" + runtime); + var projBuildName = name + "_" + buildVersion + "_" + runtime; + + DotNetPublish(project, new DotNetPublishSettings { + Configuration = configuration, + NoRestore = true, + Runtime = runtime, + SelfContained = false, + PublishSingleFile = true, + PublishTrimmed = false, + OutputDirectory = projBuildDir, + MSBuildSettings = msbuildSettings + }); + + if (isServer) { + CreateDirectory(projBuildDir.Combine("plugins")); + CreateDirectory(projBuildDir.Combine("libraries")); + + if (runtime == "win-x64") { + FileWriteText(projBuildDir.CombineWithFilePath("run.bat"), "@echo off\r\nImpostor.Server.exe\r\npause"); + } + } + + if (runtime == "win-x64") { + Zip(projBuildDir, buildDir.CombineWithFilePath(projBuildName + ".zip")); + } else { + GZipCompress(projBuildDir, buildDir.CombineWithFilePath(projBuildName + ".tar.gz")); + } + + if (BuildSystem.GitHubActions.IsRunningOnGitHubActions) { + BuildSystem.GitHubActions.Commands.UploadArtifact(projBuildDir, projBuildName); + } +} + +private void ImpostorPublishNF(string name, string project) { + var runtime = "win-x64"; + var projBuildDir = buildDir.Combine(name + "_" + runtime); + var projBuildZip = buildDir.CombineWithFilePath(name + "_" + buildVersion + "_" + runtime + ".zip"); + + DotNetPublish(project, new DotNetPublishSettings { + Configuration = configuration, + NoRestore = true, + Framework = "net472", + OutputDirectory = projBuildDir, + MSBuildSettings = msbuildSettings + }); + + Zip(projBuildDir, projBuildZip); +} + +////////////////////////////////////////////////////////////////////// +// TASKS +////////////////////////////////////////////////////////////////////// + +Task("Clean") + .Does(() => { + if (DirectoryExists(buildDir)) { + DeleteDirectory(buildDir, new DeleteDirectorySettings { + Recursive = true + }); + } + }); + +Task("Restore") + .Does(() => { + DotNetRestore("./src/Impostor.sln"); + }); + +Task("Replay") + .Does(() => { + // D:\Projects\GitHub\Impostor\Impostor\src\Impostor.Tools.ServerReplay\sessions + DotNetRun( + "./src/Impostor.Tools.ServerReplay/Impostor.Tools.ServerReplay.csproj", + "./src/Impostor.Tools.ServerReplay/sessions", new DotNetRunSettings { + Configuration = configuration, + NoRestore = true, + }); + }); + +Task("Build") + .IsDependentOn("Clean") + .IsDependentOn("Restore") + .IsDependentOn("Replay") + .Does(() => { + // Tests. + DotNetBuild("./src/Impostor.Tests/Impostor.Tests.csproj", new DotNetBuildSettings { + Configuration = configuration, + }); + + // Server. + ImpostorPublish("Impostor-Server", "./src/Impostor.Server/Impostor.Server.csproj", "win-x64", true); + ImpostorPublish("Impostor-Server", "./src/Impostor.Server/Impostor.Server.csproj", "osx-x64", true); + ImpostorPublish("Impostor-Server", "./src/Impostor.Server/Impostor.Server.csproj", "linux-x64", true); + ImpostorPublish("Impostor-Server", "./src/Impostor.Server/Impostor.Server.csproj", "linux-arm", true); + ImpostorPublish("Impostor-Server", "./src/Impostor.Server/Impostor.Server.csproj", "linux-arm64", true); + + // API. + DotNetPack("./src/Impostor.Api/Impostor.Api.csproj", new DotNetPackSettings { + Configuration = configuration, + OutputDirectory = buildDir, + IncludeSource = true, + IncludeSymbols = true, + MSBuildSettings = msbuildSettings + }); + + if (BuildSystem.GitHubActions.IsRunningOnGitHubActions) { + foreach (var file in GetFiles(buildDir + "/*.{nupkg,snupkg}")) + { + BuildSystem.GitHubActions.Commands.UploadArtifact(file, "Impostor.Api"); + } + } + }); + +Task("Test") + .IsDependentOn("Build") + .Does(() => { + DotNetTest("./src/Impostor.Tests/Impostor.Tests.csproj", new DotNetTestSettings { + Configuration = configuration, + NoBuild = true + }); + }); + +////////////////////////////////////////////////////////////////////// +// EXECUTION +////////////////////////////////////////////////////////////////////// + +RunTarget(target); diff --git a/docs/Admin-panel.md b/docs/Admin-panel.md new file mode 100644 index 0000000..3afbfd3 --- /dev/null +++ b/docs/Admin-panel.md @@ -0,0 +1,173 @@ +# Admin Panel + +Empostor includes a built-in web-based admin panel available at `http://your-server:22023/admin`. + +## Authentication + +The panel is protected by a password set in `config.json`: + +```json +"Admin": { + "Password": "changeme", + "MarketplaceUrl": "https://raw.githubusercontent.com/your-org/your-repo/main/marketplace/plugins.json" +} +``` + +On first visit the login page is shown. After a successful login a session cookie valid for 8 hours is set. Clicking **Sign out** clears it immediately. + +All API endpoints under `/api/admin/` return `401 Unauthorized` if the cookie is absent or incorrect. + +--- + +## Overview + +The main dashboard shows at a glance: + +- Total and public game count +- Active games (in progress) +- Connected player count +- Total ban count (IP + friend code) +- Server uptime + +A live game list is displayed below the stat cards, refreshing every 3 seconds. + +--- + +## Games + +Lists all active rooms with: + +- Room code +- State (NotStarted / Starting / Started / Ended) +- Visibility (Public / Private) +- Map name +- Player count vs. max players +- Host name and friend code +- All players in the room (hover for friend code and IP) + +--- + +## Clients + +Lists all connected clients with: + +- Client ID +- Name +- Friend code +- IP address +- Among Us client version +- Platform +- Whether they are in a game, and which room code +- Reactor mod count badge (if the client runs Reactor) + +Clicking **Detail** on any client row opens a side panel showing: + +- Full player identity (name, friend code, PUID, IP, client version, language, platform) +- Current game code +- Reactor protocol version and full mod list (mod ID, version, required flag) + +--- + +## Broadcast + +Sends a chat message to every active game on behalf of the host of that game. The message is prefixed with `[Server]`. + +--- + +## Kick + +Removes a player from the server without banning. The player can reconnect immediately. + +Players can be kicked by entering a Client ID, or by using the **Quick Kick** table which lists all connected clients with a one-click button. + +--- + +## Ban + +### Ban by IP + +Bans an IP address and immediately disconnects all clients connected from that address. The reason is saved to `bans.json` and persists across server restarts. + +### Ban by Friend Code + +Bans a specific friend code and immediately disconnects any client currently connected with it. + +Both ban types can be removed from the **Ban List** tab at any time. + +--- + +## Ban List + +Shows all active IP bans and friend code bans with: + +- Banned value (IP or friend code) +- Reason +- Timestamp + +Each entry has an **Unban** button that takes effect immediately. + +--- + +## Message + +Sends a targeted chat message to a specific game room by code. Useful for communicating with a single room without broadcasting server-wide. + +--- + +## End Game + +Force-terminates a game by kicking all players in it. The room is destroyed immediately. A confirmation dialog is shown before the action is executed. + +The tab also lists all active games with a one-click **End** button per row. + +--- + +## Privacy + +Toggles a game between Public and Private. This affects whether the room appears in the public lobby list. + +--- + +## Plugin Marketplace + +Fetches the plugin list from the URL configured in `Admin.MarketplaceUrl` (a raw GitHub JSON file). For each plugin, the latest version, supported Impostor version, and description are shown. + +Clicking **Install** downloads the `.dll` to the `plugins/` folder. The server must be restarted to load the new plugin. + +If the plugin was built for a different Impostor version, a warning is displayed but installation is not blocked. + +--- + +## Updates + +Queries the GitHub Releases API for the latest Empostor release and compares it with the running version. If a newer version is available, a link to the release page is shown. The server does not update itself automatically. + +The GitHub repository checked is `Empostor/Empostor` by default. If you maintain a fork, this is hardcoded in `MarketplaceController.cs`. + +--- + +## Reports + +Shows the last 200 player reports submitted in-game (via the Among Us report button): + +| Column | Description | +|--------|-------------| +| Time | UTC timestamp of the report | +| Game | Room code | +| Reporter | Name and friend code of the reporting player | +| Reported | Name and friend code of the reported player | +| Reason | `InappropriateName`, `InappropriateChat`, `Cheating_Hacking`, `Harassment_Misconduct` | +| Outcome | `NotReportedUnknown`, `NotReportedNoAccount`, `Reported`, etc. | + +Reports are held in memory and cleared on server restart. Plugins can listen to `IPlayerReportEvent` to persist reports or trigger further actions. + +--- + +## Server Info + +Displays runtime details: + +- Server start time and uptime +- Process ID +- .NET runtime version and OS +- Current ban and auth session counts diff --git a/docs/Building-from-source.md b/docs/Building-from-source.md new file mode 100644 index 0000000..e9b8c0f --- /dev/null +++ b/docs/Building-from-source.md @@ -0,0 +1,29 @@ +# Building from source + +The solution contains the Impostor server and its dependencies, like Hazel and the plugin API. The server is built using [.NET 8](https://dotnet.microsoft.com/download/dotnet/8.0). + +## Cloning Impostor + +You need to clone Impostor using git: + +```bash +git clone https://github.com/Impostor/Impostor.git +``` + +## Building the server + +### Dependencies + +- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) +- [Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/vs/) (Optional, only if you want the full IDE experience) + +### Build using the CLI + +```bash +cd src/Impostor.Server/ +dotnet build +# Or if you want a single file, ready for production: (change linux-x64 to win-x64 if you use Windows) +dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true +``` + +To setup the server, please look at [Running the server](Running-the-server.md). diff --git a/docs/Chat-filtering.md b/docs/Chat-filtering.md new file mode 100644 index 0000000..cc2864f --- /dev/null +++ b/docs/Chat-filtering.md @@ -0,0 +1,82 @@ +# Chat Filtering + +Empostor can filter in-game chat messages by blocking specific words and limiting message spam. This helps server administrators maintain a healthier chat environment. + +This feature is **disabled by default**. Enable it in `config.json` or from the admin panel to activate filtering. + +## Admin Panel + +A **Chat Filter** tab is available in the admin panel under the Monitor section. It allows you to manage all settings at runtime without editing `config.json` or restarting the server: + +- **Enable/Disable** filtering with a checkbox +- **Add/Remove blocked words** — type a word and click Add, or click the × on any tag to remove it +- **Spam settings** — adjust the message threshold and time window +- **Block/log toggle** — choose whether to block messages or only log warnings + +Settings changed via the admin panel take effect immediately but are runtime-only — they are lost on server restart. For permanent settings, edit `config.json`. + +## Configuration + +Add the `ChatFilter` section to your `config.json`: + +```json +{ + "ChatFilter": { + "Enabled": false, + "BlockedWords": [], + "BlockMessage": true, + "SpamThreshold": 5, + "SpamWindowSeconds": 10 + } +} +``` + +| Key | Default | Description | +| :--- | :--- | :--- | +| **Enabled** | `false` | Whether chat filtering is enabled. | +| **BlockedWords** | `[]` | A list of words to block. Matching is **case-insensitive** and uses substring matching (e.g., blocking `"bad"` also blocks `"badword"`). | +| **BlockMessage** | `true` | Whether to actually block filtered messages. When `true`, the message is cancelled and never reaches other players. When `false`, the message is allowed through but a warning is logged. | +| **SpamThreshold** | `5` | Number of messages allowed within the spam window before rate limiting kicks in. | +| **SpamWindowSeconds** | `10` | The sliding time window in seconds for spam detection. | + +## How Filtering Works + +### Word Filter + +When a player sends a chat message, it is checked against the `BlockedWords` list: + +1. The message text is compared against each blocked word (case-insensitive substring match). +2. If a match is found and `BlockMessage` is `true`, the message is blocked — other players will not see it. +3. If `BlockMessage` is `false`, the message goes through but a warning is logged. + +### Spam Filter + +Each player has a per-player sliding window to detect rapid messaging: + +1. Every chat message timestamp is recorded per player. +2. If a player sends `SpamThreshold` or more messages within the last `SpamWindowSeconds` seconds, the message is handled according to `BlockMessage`. +3. Old timestamps outside the window are automatically discarded. + +Both filters run independently — a message can be blocked by either the word filter or the spam filter. + +## Example + +To block profanity and prevent players from sending more than 3 messages in 5 seconds: + +```json +{ + "ChatFilter": { + "Enabled": true, + "BlockedWords": ["badword1", "badword2"], + "BlockMessage": true, + "SpamThreshold": 3, + "SpamWindowSeconds": 5 + } +} +``` + +## Troubleshooting + +- **Messages are not being blocked**: Ensure `Enabled` is `true` and `BlockedWords` contains entries. Remember that matching is substring-based — short words may match unexpectedly. +- **False positives**: Use specific, longer words in your `BlockedWords` list. Substring matching means `"ass"` would match `"pass"` and `"classic"`. +- **Legitimate players get spam-blocked**: Increase `SpamThreshold` or decrease `SpamWindowSeconds`. The defaults (5 messages / 10 seconds) are generous for normal conversation. diff --git a/docs/Discord-webhook.md b/docs/Discord-webhook.md new file mode 100644 index 0000000..3e5f9f7 --- /dev/null +++ b/docs/Discord-webhook.md @@ -0,0 +1,55 @@ +# Discord Webhook Integration + +Empostor can send game events to a Discord channel via [webhooks](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). Each event is sent as a rich embed with relevant details. + +This feature is **disabled by default**. Enable it in `config.json` and provide a webhook URL to activate it. + +## Configuration + +Add the `DiscordWebhook` section to your `config.json`: + +```json +{ + "DiscordWebhook": { + "Enabled": false, + "WebhookUrl": "", + "NotifyOnGameCreated": true, + "NotifyOnBan": true, + "NotifyOnReport": true, + "NotifyOnPlayerJoin": false, + "NotifyOnGameEnded": false + } +} +``` + +| Key | Default | Description | +| :--- | :--- | :--- | +| **Enabled** | `false` | Whether the Discord webhook integration is enabled. | +| **WebhookUrl** | `""` | The Discord webhook URL to POST messages to. Create one in Discord under Channel Settings → Integrations → Webhooks. | +| **NotifyOnGameCreated** | `true` | Send a notification when a new game lobby is created. | +| **NotifyOnBan** | `true` | Send a notification when a player is banned from the server. | +| **NotifyOnReport** | `true` | Send a notification when a player reports a dead body in-game. | +| **NotifyOnPlayerJoin** | `false` | Send a notification when a player joins a game lobby. | +| **NotifyOnGameEnded** | `false` | Send a notification when a game ends. | + +## Events + +Each notification is sent as a Discord embed with a colored sidebar: + +| Event | Embed Color | Fields | +| :--- | :--- | :--- | +| Game Created | Green | Game code, host name, host friend code | +| Player Banned | Red | Player name, friend code, game code | +| Player Reported | Yellow | Reporter, reported player, friend codes, game code, reason | +| Player Joined | Green | Player name, friend code, game code, player count | +| Game Ended | Purple | Game code, result, player count | + +## Rate Limiting + +To avoid hitting Discord's rate limits, duplicate events for the same game within 2 seconds are suppressed. + +## Troubleshooting + +- **No messages appear in Discord**: Verify `WebhookUrl` is correct and the webhook is active in your Discord channel. Check the server logs for `[Discord]` warnings. +- **Webhook returns HTTP errors**: Discord may rate-limit or the webhook URL may be invalid. The listener logs the HTTP status code on failure. +- **Events trigger but some fields are blank**: Friendly names and friend codes are read from client data — some fields may be empty (`—`) if the client hasn't fully connected yet. diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..b78e10b --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,23 @@ +# Frequently Answered Questions + +## What is this? + +The Empostor project is a reverse engineered and open source server for the game Among Us. The game itself is developed by [Innersloth](http://www.innersloth.com/) while this project is maintained by the community. This project was built out of frustration for the lack of server availability in certain regions and the inability for the core developer, [AeonLucid](https://github.com/AeonLucid), to join a public game. As of this time, it has not been officially endorsed by the studio. + +## Can this be used with the mobile version of the game? + +Yes, Empostor can be used with both the Android and iOS versions of the game. You can add a custom server via a link, you can do this using the [site](https://impostor.github.io/Impostor) + +## Can this be used with the console version of the game? + +Sadly not, as these don't allow adding custom servers. + +## Can this be used with Among Us 3D/VR? + +No, it's an entirely different game with a completely different network stack. So we'd need to develop another server completely from scratch. We're currently not interested in doing this. + +## How can I get started? + +See [Setting up your Server](Running-the-server.md) for more information on running the server and [Client Setup](https://impostor.github.io/Impostor/) for helping your friends join in! + +If you're having issues with the server software, check the [Troubleshooting page](TROUBLESHOOTING.md). diff --git a/docs/Hello-page.md b/docs/Hello-page.md new file mode 100644 index 0000000..87cc895 --- /dev/null +++ b/docs/Hello-page.md @@ -0,0 +1,41 @@ +# Hello Page (Home Page) + +Empostor serves a customisable home page at `http://your-server:22023/`. + +## How it works + +On startup, the server checks for a `Pages/` directory next to the executable. If it does not exist it is created automatically, and a default `index.html` is written into it. + +``` +Impostor.Server (binary) +Pages/ + index.html ← served at GET / +``` + +Every request to `/` reads the file from disk at that moment, so edits take effect without restarting the server. + +## Customising the page + +Edit `Pages/index.html` with any HTML content you want. Common uses: + +- Server rules and information +- Discord invite link +- Region file download instructions +- Link to the admin panel + +If the file is deleted, a plain fallback response is returned until the server is restarted and regenerates the default file. + +## Default page + +The generated default page contains: + +- A status indicator showing the server is online +- A link to the admin panel (`/admin`) +- A link to the [region file generator](https://impostor.github.io/Impostor) +- The server start time + +## Notes + +- The page is served as `text/html; charset=utf-8`. +- There is no template engine. The file is served as-is. +- For HTTPS access, configure a reverse proxy. See [Http-server.md](Http-server.md). diff --git a/docs/Http-server.md b/docs/Http-server.md new file mode 100644 index 0000000..6ef02be --- /dev/null +++ b/docs/Http-server.md @@ -0,0 +1,82 @@ +# HTTP Server + +Since Impostor 1.9.0 a HTTP service is included for matchmaking, which is required for clients to connect to the Empostor server. + +Starting from Among Us 16.0.5, you need to set up HTTPS for players to connect to your server. +Unfortunately Empostor doesn't handle SSL certificates, so you need to set up a reverse proxy. + +## Use a reverse proxy + +A reverse proxy allows you to forward HTTP requests from users to multiple services. If you already have one, you should configure it to add Empostor. + +If you have never set up a reverse proxy before, we recommend you to set up [Caddy](https://caddyserver.com/). It is easy to set up and comes with support for requesting SSL certificates out of the box. + +To prevent people from connecting directly to Empostor, we recommend changing the "ListenIp" in the "HttpServer" section to "127.0.0.1". This makes sure people can't connect to your HTTP server other than via your reverse proxy. Keep the "ListenIp" in the "Server" section at "0.0.0.0" though, running the normal game traffic through a proxy is not supported. + +### Caddy + +To install Caddy, follow the [official installation guide](https://caddyserver.com/docs/install). Then use the following lines as your `Caddyfile` configuration file: + +``` +example.com # replace example.com with your domain name + +reverse_proxy :22023 +``` + +Now run `caddy run` in the folder with this Caddyfile and it should set up a server for you with a free SSL certificate. +If this works, you should set up [Caddy to run in the background](https://caddyserver.com/docs/running) + +### Nginx + +Nginx is an alternative to Caddy that is a bit harder to set up. If you already use Nginx, you can use our snippet to add Empostor: + +
Nginx configuration + +```nginx +server { + listen 443 ssl http2; + server_name example.com; # replace example.com with your domain name + + # Assuming you're using Certbot, replace example.com with your domain name + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + + location / { + proxy_pass http://localhost:22023; # Change the port to your HttpServer's ListenPort + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +# Redirect all traffic to HTTPS +server { + listen 80 default_server; + location / { + return 307 https://$host$request_uri; + } +} +``` + +
+ +## Stop exposing Empostor's HTTP server to the internet directly + +To only allow connections to Empostor's HTTP servers via the reverse proxy, we recommend to set the ListenIp of the HTTP server to 127.0.0.1: + +```json +{ + // NOTE: merge this snippet into the rest of your configuration + "HttpServer": { + "ListenIp": "127.0.0.1" + } +} +``` + +Or if you're configuring Empostor with environment variables: + +```sh +IMPOSTOR_HttpServer__ListenIp=127.0.0.1 +``` + +For more information on server configuration, see [Server-configuration.md](Server Configuration). diff --git a/docs/Player-statistics.md b/docs/Player-statistics.md new file mode 100644 index 0000000..8266b12 --- /dev/null +++ b/docs/Player-statistics.md @@ -0,0 +1,64 @@ +# Player Statistics + +Empostor can track per-player game statistics including wins, kills, tasks completed, and more. Statistics are viewable through the admin panel and via the `/stat` in-game command. + +This feature is **disabled by default**. Enable it in `config.json` to begin tracking. + +## Configuration + +Add the `PlayerStats` section to your `config.json`: + +```json +{ + "PlayerStats": { + "Enabled": false, + "PersistToFile": true + } +} +``` + +| Key | Default | Description | +| :--- | :--- | :--- | +| **Enabled** | `false` | Whether player statistics tracking is enabled. | +| **PersistToFile** | `true` | Whether to save statistics to `player_stats.json` on disk. If disabled, stats are kept in memory only and lost on server restart. | + +## Tracked Statistics + +The following metrics are recorded per player (keyed by Friend Code): + +| Stat | Description | +| :--- | :--- | +| Games Played | Total number of games the player participated in | +| Wins | Number of games won as a crewmate | +| Losses | Number of games lost as a crewmate | +| Impostor Wins | Number of games won as an impostor | +| Kills | Total kills performed | +| Deaths | Total times killed | +| Tasks Completed | Total tasks completed | +| Times Exiled | Total times voted out | + +## Admin Panel + +When enabled, a **Statistics** tab appears in the admin panel under the Monitor section. It displays a ranked table sorted by games played, with columns for all tracked stats. Use the **Refresh** button to reload data and **Reset All** to clear all statistics (requires confirmation). + +## In-Game Command + +Players can check their own stats by typing `/stat` (or `/stats`, `/mystats`) in the in-game chat. The response is sent privately and is localized based on the player's language. + +## Localization + +The `/stat` command response supports all languages configured in Empostor's language system. If no translation exists for the player's language, English is used as a fallback. + +## API Endpoints + +The admin panel uses these API endpoints: + +| Method | Path | Description | +| :--- | :--- | :--- | +| `GET` | `/api/admin/player/stats` | Returns all player statistics sorted by games played. Response: `{"enabled": true, "players": [...]}` | +| `GET` | `/api/admin/player/stats/{friendCode}` | Returns statistics for a single player by Friend Code. | +| `POST` | `/api/admin/player/stats/reset` | Clears all player statistics. Requires admin authentication. | + +## File Persistence + +When `PersistToFile` is enabled, statistics are saved to `player_stats.json` in the server directory. The file is updated after each game ends, following the same pattern as the ban and report stores. diff --git a/docs/Plugin-marketplace.md b/docs/Plugin-marketplace.md new file mode 100644 index 0000000..282d9a6 --- /dev/null +++ b/docs/Plugin-marketplace.md @@ -0,0 +1,50 @@ +# Plugin Marketplace Format + +The admin panel plugin marketplace reads `marketplace/plugins.json` at startup. + +## JSON Schema + +```jsonc +[ + { + // Unique plugin identifier (reverse-domain style recommended) + "id": "cn.hayashiume.welcome", + + // Display name shown in the marketplace + "name": "Welcome Messages", + + // Short description (shown as subtitle) + "description": "Sends a localised welcome message when players join a room.", + + // Author attribution + "author": "BunchHanpiDev & HayashiUme", + + // Version history — latest version should be LAST in the array + "versions": [ + { + // Semantic version of this plugin release + "version": "1.0.0", + + // Minimum Empostor / Impostor version required + "impostor_version": "2.0.0", + + // Direct download URL for the .dll file + "download_url": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/Impostor.Plugins.Welcome.dll" + } + ] + } +] +``` + +## Versioning Rules + +- **`versions` array**: Each entry represents one downloadable release. List older versions first; the **last entry** is treated as the latest and is selected by default in the admin panel UI. +- **Single-version plugins**: An array with one entry is valid. The version dropdown is hidden and the sole version is used directly. +- **Multi-version plugins**: When two or more entries exist, the admin panel shows a ` + + + + + +"""; + + private const string AdminHtml = """ + + + + + + + Empostor Admin + + + + +
+
+

Empostor Admin

+
+
+
+ + +
+
+
+
Games
+
—
+
+
+
+
Active
+
—
+
+
+
Players
+
—
+
+
+
Bans
+
—
+
+
+
+
Uptime
+
—
+
+
+

Active Games

+ + + + + + + + + + + + + + + + +
CodeStateVisibilityMapPlayersHost
Loading...
+
+
+

All Games

+ + + + + + + + + + + + + +
CodeStateVisibilityMapPlayersHost / FCMembers
+
+
+

Clients

+ + + + + + + + + + + + + + +
IDNameFriend CodeIPVersionPlatformModsIn Game
+
+
+
+

Broadcast to All Games

+
+ +
+
+
+
+
+

Kick by Client ID

+
+ +
+
+
+

Quick Kick

+ + + + + + + + + + + +
IDNameFCGame
+
+
+
+
+
+

Ban IP

+
+
+ +
+
+
+

Ban Friend Code

+
+
+ +
+
+
+
+
+
+
+

Banned IPs

+
+
+
+

Banned Friend Codes

+
+
+
+
+
+
+

Message to Game

+
+
+
+
+ +
+
+
+
+
+

Force End Game

+
+ +
+
+
+

Active Games

+ + + + + + + + + + +
CodeStatePlayers
+
+
+
+
+

Set Game Privacy

+
+
+
+
+ +
+
+
+
+
+

Plugin Marketplace

+
+
+
Loading...
+
+
+
+
+

Server Update Check

+
+
Click Check to query GitHub.
+
+ +
+
+
+
+

Player Reports

+ +
+ + + + + + + + + + + + + + + + +
TimeGameReporterReportedReasonOutcome
Loading...
+
+
+

Server Info

+
+
+
+
+

Player Logs

+ + + + +
+ + + + + + + + + + + + + +
TimeTypePlayerGameDetail
Loading...
+
+
+
+

Player Statistics

+ + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+

Chat Filter

+ +
+
+ +

Blocked Words

+
+ + +
+
+ No blocked words added. +
+

Spam Protection

+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + + + + + + + + + +"""; + } +} diff --git a/src/Impostor.Server/Http/GamesController.cs b/src/Impostor.Server/Http/GamesController.cs new file mode 100644 index 0000000..de8c17d --- /dev/null +++ b/src/Impostor.Server/Http/GamesController.cs @@ -0,0 +1,267 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Impostor.Api.Config; +using Impostor.Api.Games; +using Impostor.Api.Games.Managers; +using Impostor.Server.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Http; + +[Route("/api/games")] +[ApiController] +public sealed class GamesController : ControllerBase +{ + private readonly IGameManager _gameManager; + private readonly ListingManager _listingManager; + private readonly HostServer _hostServer; + + public GamesController(IGameManager gameManager, ListingManager listingManager, IOptions serverConfig) + { + _gameManager = gameManager; + _listingManager = listingManager; + var config = serverConfig.Value; + _hostServer = HostServer.From(IPAddress.Parse(config.ResolvePublicIp()), config.PublicPort); + } + + /// + /// Legacy game list endpoint (Among Us < 16.0.0). + /// + [HttpGet] + public IActionResult Index(int mapId, GameKeywords lang, int numImpostors, + [FromHeader] AuthenticationHeaderValue authorization) + { + if (authorization?.Scheme != "Bearer" || authorization.Parameter == null) + { + return BadRequest(); + } + + // Parse client version from token if available; fall back to known recent version + GameVersion clientVersion; + try + { + using var doc = JsonDocument.Parse(Convert.FromBase64String(authorization.Parameter)); + var root = doc.RootElement; + var ver = root.TryGetProperty("Content", out var c) + && c.TryGetProperty("ClientVersion", out var cv) + ? cv.GetInt32() : 0; + clientVersion = ver > 0 ? new GameVersion(ver) : new GameVersion(2021, 6, 30); + } + catch + { + clientVersion = new GameVersion(2021, 6, 30); + } + + var listings = _listingManager.FindListings(HttpContext, mapId, numImpostors, lang, clientVersion); + return Ok(listings.Select(GameListing.From)); + } + + /// + /// Legacy: get server hosting a specific game. + /// + [HttpPost] + public IActionResult Post(int gameId) + { + var code = new GameCode(gameId); + var game = _gameManager.Find(code); + if (game == null) + { + return NotFound(new MatchmakerResponse(new MatchmakerError(DisconnectReason.GameNotFound))); + } + + return Ok(HostServer.From(game.PublicIp)); + } + + /// + /// Legacy: get server address to host a new game. + /// + [HttpPut] + public IActionResult Put() => Ok(_hostServer); + + /// + /// Get a specific game by code. + /// + [HttpGet("{gameId}")] + public IActionResult Show([FromRoute] int gameId) + { + var code = new GameCode(gameId); + var game = _gameManager.Find(code); + if (game == null) + { + return NotFound(new FindGameByCodeResponse(new MatchmakerError(DisconnectReason.GameNotFound))); + } + + return Ok(new FindGameByCodeResponse(GameListing.From(game))); + } + + /// + /// Filtered lobby list (Among Us 16.0.0+). + /// No support filter conditions on the latest version. + /// + [HttpGet("filtered")] + public IActionResult ShowFilteredLobbies() + { + var listings = _gameManager.Games + .Where(g => g.IsPublic) + .Select(GameListing.From) + .ToList(); + + return Ok(new + { + games = listings, + metadata = new + { + allGamesCount = _gameManager.Games.Count(), + matchingGamesCount = listings.Count, + }, + }); + } + + /// + /// JSON summary of all active games, consumed by the admin panel. + /// + [HttpGet("publicgames")] + public IActionResult Summary() + { + var games = _gameManager.Games.Select(g => new + { + code = GameCodeParser.IntToGameName(g.Code), + codeInt = (int)g.Code, + state = g.GameState.ToString(), + isPublic = g.IsPublic, + playerCount = g.PlayerCount, + maxPlayers = g.Options.MaxPlayers, + map = g.Options.Map.ToString(), + impostors = g.Options.NumImpostors, + host = g.Host?.Client.Name ?? "UnknwonName", + hostFriendCode = g.Host?.Client.FriendCode ?? "UnknownHostFriendCode", + players = g.Players.Select(p => new + { + name = p.Client.Name, + friendCode = p.Client.FriendCode ?? "UnknownFriendCode", + isHost = p.IsHost, + platform = p.Client.PlatformSpecificData?.PlatformName ?? "UnknownPlatform", + }).ToList(), + }).ToList(); + + return Ok(new + { + totalGames = games.Count, + totalPlayers = games.Sum(g => g.playerCount), + games, + }); + } + + private static uint ConvertAddressToNumber(IPAddress address) + { +#pragma warning disable CS0618 + return (uint)address.Address; +#pragma warning restore CS0618 + } + + private class HostServer + { + [JsonPropertyName("Ip")] public required long Ip { get; init; } + + [JsonPropertyName("Port")] public required ushort Port { get; init; } + + public static HostServer From(IPAddress ip, ushort port) => + new() { Ip = ConvertAddressToNumber(ip), Port = port }; + + public static HostServer From(IPEndPoint ep) => + From(ep.Address, (ushort)ep.Port); + } + + private class MatchmakerResponse + { + [SetsRequiredMembers] + public MatchmakerResponse(MatchmakerError error) { Errors = new[] { error }; } + + [JsonPropertyName("Errors")] + public required MatchmakerError[] Errors { get; init; } + } + + private class MatchmakerError + { + [SetsRequiredMembers] + public MatchmakerError(DisconnectReason reason) { Reason = reason; } + + [JsonPropertyName("Reason")] + public required DisconnectReason Reason { get; init; } + } + + private class FindGameByCodeResponse + { + [SetsRequiredMembers] + public FindGameByCodeResponse(MatchmakerError e) => (Errors, Game) = (new[] { e }, null); + + [SetsRequiredMembers] + public FindGameByCodeResponse(GameListing g) => (Errors, Game) = (null, g); + + [JsonPropertyName("Errors")] public required MatchmakerError[]? Errors { get; init; } + + [JsonPropertyName("Game")] public required GameListing? Game { get; init; } + } + + private class GameListing + { + [JsonPropertyName("IP")] public required uint Ip { get; init; } + + [JsonPropertyName("Port")] public required ushort Port { get; init; } + + [JsonPropertyName("GameId")] public required int GameId { get; init; } + + [JsonPropertyName("PlayerCount")] public required int PlayerCount { get; init; } + + [JsonPropertyName("HostName")] public required string HostName { get; init; } + + [JsonPropertyName("TrueHostName")] public required string TrueHostName { get; init; } + + [JsonPropertyName("HostPlatformName")] public required string HostPlatformName { get; init; } + + [JsonPropertyName("Platform")] public required Platforms Platform { get; init; } + + [JsonPropertyName("QuickChat")] public required QuickChatModes QuickChat { get; init; } + + [JsonPropertyName("Age")] public required int Age { get; init; } + + [JsonPropertyName("MaxPlayers")] public required int MaxPlayers { get; init; } + + [JsonPropertyName("NumImpostors")] public required int NumImpostors { get; init; } + + [JsonPropertyName("MapId")] public required MapTypes MapId { get; init; } + + [JsonPropertyName("Language")] public required GameKeywords Language { get; init; } + + [JsonPropertyName("Options")] public required string Options { get; init; } + + public static GameListing From(IGame game) + { + var platform = game.Host?.Client.PlatformSpecificData; + return new GameListing + { + Ip = ConvertAddressToNumber(game.PublicIp.Address), + Port = (ushort)game.PublicIp.Port, + GameId = game.Code, + PlayerCount = game.PlayerCount, + HostName = game.DisplayName ?? game.Host?.Client.Name ?? "Unknown host", + TrueHostName = game.DisplayName ?? game.Host?.Client.Name ?? "Unknown host", + HostPlatformName = platform?.PlatformName ?? string.Empty, + Platform = platform?.Platform ?? Platforms.Unknown, + QuickChat = game.Host?.Client.ChatMode ?? QuickChatModes.QuickChatOnly, + Age = 0, + MaxPlayers = game.Options.MaxPlayers, + NumImpostors = game.Options.NumImpostors, + MapId = game.Options.Map, + Language = game.Options.Keywords, + Options = game.Options.ToBase64String(), + }; + } + } +} diff --git a/src/Impostor.Server/Http/HelloController.cs b/src/Impostor.Server/Http/HelloController.cs new file mode 100644 index 0000000..96a4b49 --- /dev/null +++ b/src/Impostor.Server/Http/HelloController.cs @@ -0,0 +1,93 @@ +using System.IO; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Http; + +[Route("/")] +public sealed class HelloController : ControllerBase +{ + private static readonly string PagesDir = Path.Combine(Directory.GetCurrentDirectory(), "Pages"); + private static readonly string IndexFile = Path.Combine(PagesDir, "index.html"); + private static bool _initialized = false; + private static readonly object _initLock = new(); + + private readonly ILogger _logger; + + public HelloController(ILogger logger) + { + _logger = logger; + EnsurePagesDirectory(); + } + + [HttpGet] + public IActionResult GetIndex() + { + if (!System.IO.File.Exists(IndexFile)) + { + _logger.LogWarning("[HelloController] Pages/index.html not found, serving fallback."); + return Content(FallbackHtml(), "text/html; charset=utf-8"); + } + + var html = System.IO.File.ReadAllText(IndexFile); + return Content(html, "text/html; charset=utf-8"); + } + + private void EnsurePagesDirectory() + { + if (_initialized) + { + return; + } + + lock (_initLock) + { + if (_initialized) + { + return; + } + + _initialized = true; + + if (!Directory.Exists(PagesDir)) + { + Directory.CreateDirectory(PagesDir); + _logger.LogInformation("[HelloController] Created Pages/ directory at {Path}", PagesDir); + } + + if (!System.IO.File.Exists(IndexFile)) + { + System.IO.File.WriteAllText(IndexFile, DefaultIndexHtml()); + _logger.LogInformation("[HelloController] Written default Pages/index.html"); + } + } + } + + private static string DefaultIndexHtml() => $""" + + + + + +Empostor Server + + +
+
+

Empostor is running

+

Your private Among Us server is online and accepting connections.

+ +
+
Started 2026-05-15 16:16 UTC  ·  Edit this page at Pages/index.html
+ + +"""; + + private static string FallbackHtml() => + "

Empostor is running

" + + "

Pages/index.html was deleted. Restart the server to regenerate it.

" + + "

Admin Panel

"; +} diff --git a/src/Impostor.Server/Http/ListingManager.cs b/src/Impostor.Server/Http/ListingManager.cs new file mode 100644 index 0000000..41a2474 --- /dev/null +++ b/src/Impostor.Server/Http/ListingManager.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Linq; +using Impostor.Api.Config; +using Impostor.Api.Games; +using Impostor.Api.Games.Managers; +using Impostor.Api.Http; +using Impostor.Api.Net.Manager; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Http; + +/// +/// Perform game listing filtering. +/// +public sealed class ListingManager +{ + private readonly IGameManager _gameManager; + private readonly IEnumerable _listingFilters; + private readonly ICompatibilityManager _compatibilityManager; + private readonly CompatibilityConfig _compatibilityConfig; + + public ListingManager(IGameManager gameManager, IEnumerable listingFilters, ICompatibilityManager compatibilityManager, IOptions compatibilityConfig) + { + _gameManager = gameManager; + _listingFilters = listingFilters; + _compatibilityManager = compatibilityManager; + _compatibilityConfig = compatibilityConfig.Value; + } + + /// + /// Find listings that match the requested settings. + /// + /// The context of this http request. + /// The selected maps. + /// The amount of impostors. 0 is any. + /// Chat language of the game. + /// Game version of the client. + /// Maximum amount of games to return. + /// Listings that match the required criteria. + public IEnumerable FindListings(HttpContext ctx, int map, int impostorCount, GameKeywords language, GameVersion gameVersion, int maxListings = 10) + { + var resultCount = 0; + + var filters = _listingFilters.Select(f => f.GetFilter(ctx)).ToArray(); + + var compatibleGames = new List(); + + // We want to add 2 types of games + // 1. Desireable games that the player wants to play (right language, right map, desired impostor amount) + // 2. Failing that, display compatible games the player could join (public games with spots available) + + // .Where filters out games that can't be joined. + foreach (var game in this._gameManager.Games) + { + if (!game.IsPublic || game.GameState != GameStates.NotStarted || game.PlayerCount >= game.Options.MaxPlayers) + { + continue; + } + + if (!_compatibilityConfig.AllowVersionMixing && + game.Host != null && + _compatibilityManager.CanJoinGame(game.Host.Client.GameVersion, gameVersion) != GameJoinError.None) + { + continue; + } + + if (!filters.All(filter => filter(game))) + { + continue; + } + + if (IsGameDesired(game, map, impostorCount, language)) + { + // Add to result immediately. + yield return game; + + // Break out if we have enough. + if (++resultCount == maxListings) + { + yield break; + } + } + else + { + // Add to result to add afterwards. Adding is pointless if we already have enough compatible games to fill the list + if (compatibleGames.Count < (maxListings - resultCount)) + { + compatibleGames.Add(game); + } + } + } + + foreach (var game in compatibleGames) + { + yield return game; + + if (++resultCount == maxListings) + { + yield break; + } + } + } + + private static bool IsGameDesired(IGame game, int map, int impostorCount, GameKeywords language) + { + if ((map & (1 << (int)game.Options.Map)) == 0) + { + return false; + } + + if (language != game.Options.Keywords) + { + return false; + } + + if (impostorCount != 0 && game.Options.NumImpostors != impostorCount) + { + return false; + } + + return true; + } +} diff --git a/src/Impostor.Server/Http/MarketplaceController.cs b/src/Impostor.Server/Http/MarketplaceController.cs new file mode 100644 index 0000000..07909e2 --- /dev/null +++ b/src/Impostor.Server/Http/MarketplaceController.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Impostor.Api.Config; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Http +{ + [ApiController] + public sealed class MarketplaceController : ControllerBase + { + private static readonly string PluginsDir = + Path.Combine(Directory.GetCurrentDirectory(), "plugins"); + + private const string EmpostorRepo = "Empostor/Empostor"; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _http; + private readonly AdminConfig _config; + + public MarketplaceController( + ILogger logger, + IHttpClientFactory http, + IOptions config) + { + _logger = logger; + _http = http; + _config = config.Value; + } + + private bool IsAuthenticated() + => Request.Cookies.TryGetValue("impostor_admin", out var v) && v == _config.Password; + + [HttpGet("/api/admin/marketplace/plugins")] + public async Task ListPlugins() + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var url = _config.MarketplaceUrl; + if (string.IsNullOrWhiteSpace(url)) + { + return BadRequest(new { error = "MarketplaceUrl is not configured." }); + } + + try + { + using var client = _http.CreateClient(); + client.DefaultRequestHeaders.Add("User-Agent", "Empostor-Marketplace/1.0"); + var json = await client.GetStringAsync(url); + return Content(json, "application/json"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Marketplace] Failed to fetch {Url}", url); + return StatusCode(502, new { error = "Failed to fetch marketplace: " + ex.Message }); + } + } + + [HttpPost("/api/admin/marketplace/install")] + public async Task InstallPlugin([FromBody] InstallRequest req) + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + if (string.IsNullOrWhiteSpace(req.DownloadUrl)) + { + return BadRequest(new { error = "DownloadUrl is required" }); + } + + if (!req.DownloadUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { error = "Only HTTPS download URLs are allowed" }); + } + + try + { + Directory.CreateDirectory(PluginsDir); + + using var client = _http.CreateClient(); + client.DefaultRequestHeaders.Add("User-Agent", "Empostor-Marketplace/1.0"); + client.Timeout = TimeSpan.FromSeconds(60); + + var fileName = Path.GetFileName(new Uri(req.DownloadUrl).LocalPath); + if (!fileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + fileName += ".dll"; + } + + var bytes = await client.GetByteArrayAsync(req.DownloadUrl); + await System.IO.File.WriteAllBytesAsync(Path.Combine(PluginsDir, fileName), bytes); + + _logger.LogInformation("[Marketplace] Installed {File} from {Url}", fileName, req.DownloadUrl); + return Ok(new { installed = fileName, restartRequired = true }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Marketplace] Install failed: {Url}", req.DownloadUrl); + return StatusCode(500, new { error = ex.Message }); + } + } + + [HttpGet("/api/admin/update/check")] + public async Task CheckUpdate() + { + if (!IsAuthenticated()) + { + return Unauthorized(); + } + + var current = Utils.DotnetUtils.Version; + try + { + using var client = _http.CreateClient(); + client.DefaultRequestHeaders.Add("User-Agent", "Empostor-UpdateCheck/1.0"); + var json = await client.GetStringAsync( + $"https://api.github.com/repos/{EmpostorRepo}/releases/latest"); + using var doc = JsonDocument.Parse(json); + var tag = doc.RootElement.GetProperty("tag_name").GetString() ?? string.Empty; + var url = doc.RootElement.GetProperty("html_url").GetString() ?? string.Empty; + var name = doc.RootElement.GetProperty("name").GetString() ?? tag; + var latest = tag.TrimStart('v'); + var isCurrent = string.Equals( + current.Split('+')[0], latest, StringComparison.OrdinalIgnoreCase); + return Ok(new { currentVersion = current, latestVersion = latest, latestTag = tag, latestName = name, releaseUrl = url, upToDate = isCurrent }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Update] GitHub check failed"); + return StatusCode(502, new { error = ex.Message }); + } + } + + public sealed record InstallRequest(string DownloadUrl); + } +} diff --git a/src/Impostor.Server/Http/TokenController.cs b/src/Impostor.Server/Http/TokenController.cs new file mode 100644 index 0000000..c13ef1d --- /dev/null +++ b/src/Impostor.Server/Http/TokenController.cs @@ -0,0 +1,291 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Impostor.Server.Service.Auth; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Http; + +[Route("/api/user")] +[ApiController] +public sealed class TokenController : ControllerBase +{ + private static readonly string CacheFileDir = + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data"); + + private static readonly string CacheFilePath = + Path.Combine(CacheFileDir, "PuidToFriendCode.txt"); + + private static readonly object FileLock = new object(); + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly AuthCacheService _authCache; + + public TokenController( + ILogger logger, + IHttpClientFactory httpClientFactory, + AuthCacheService authCache) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _authCache = authCache; + } + + [HttpPost] + public async Task GetToken( + [FromBody] TokenRequest request, + [FromHeader] string authorization) + { + try + { + if (string.IsNullOrEmpty(authorization) || !authorization.StartsWith("Bearer ")) + { + _logger.LogWarning("[TokenController] Missing or invalid Authorization header"); + return Unauthorized(new { error = "Missing or invalid authorization" }); + } + + var eosToken = authorization["Bearer ".Length..]; + var productUserId = ExtractProductUserIdFromJwt(eosToken); + if (string.IsNullOrEmpty(productUserId)) + { + _logger.LogWarning("[TokenController] Could not extract PUID from token"); + return Unauthorized(new { error = "Invalid token content" }); + } + + var friendCode = await GetFriendCodeAsync(eosToken, productUserId); + var matchmakerToken = GenerateMatchmakerToken(productUserId); + var clientIp = GetClientIp(); + + _authCache.Store(productUserId, matchmakerToken, friendCode, clientIp); + + _logger.LogInformation( + "[TokenController] Authenticated: PUID={Puid} FriendCode={FC} IP={Ip}", + productUserId, friendCode, clientIp); + + var response = new TokenResponse + { + Content = new TokenContent + { + ProductUserId = productUserId, + FriendCode = friendCode, + }, + Hash = matchmakerToken, + }; + + return Ok(Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(response))); + } + catch (Exception ex) + { + _logger.LogError(ex, "[TokenController] Unexpected error"); + return StatusCode(500, new { error = "Internal server error" }); + } + } + + private string? ExtractProductUserIdFromJwt(string eosToken) + { + var handler = new JwtSecurityTokenHandler(); + if (!handler.CanReadToken(eosToken)) + { + return null; + } + + var jwt = handler.ReadJwtToken(eosToken); + return jwt.Claims.FirstOrDefault(c => c.Type == "sub" || c.Type == "puid")?.Value; + } + + private IPAddress? GetClientIp() + { + var xRealIp = HttpContext.Request.Headers["X-Real-IP"].FirstOrDefault(); + if (!string.IsNullOrEmpty(xRealIp) && IPAddress.TryParse(xRealIp, out var realIp)) + { + return Normalize(realIp); + } + + var xForwardedFor = HttpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrEmpty(xForwardedFor)) + { + var first = xForwardedFor.Split(',')[0].Trim(); + if (IPAddress.TryParse(first, out var fwdIp)) + { + return Normalize(fwdIp); + } + } + + return Normalize(HttpContext.Connection.RemoteIpAddress); + } + + private static IPAddress? Normalize(IPAddress? ip) + => ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip; + + private async Task GetFriendCodeAsync(string eosToken, string productUserId) + { + var cached = TryGetFriendCodeFromCache(productUserId); + if (cached != null) + { + _logger.LogInformation( + "[TokenController] FriendCode found in cache: PUID={Puid} FC={FC}", + productUserId, cached); + return cached; + } + + var friendCode = await FetchFromInnerslothAsync(eosToken, productUserId); + if (string.IsNullOrEmpty(friendCode)) + { + friendCode = GenerateFallbackFriendCode(productUserId); + _logger.LogWarning( + "[TokenController] FriendCode fetch failed for PUID={Puid}, using fallback: {FC}", + productUserId, friendCode); + } + + SaveFriendCodeToCache(productUserId, friendCode); + + return friendCode; + } + + private static string? TryGetFriendCodeFromCache(string productUserId) + { + lock (FileLock) + { + if (!System.IO.File.Exists(CacheFilePath)) + { + return null; + } + + foreach (var line in System.IO.File.ReadLines(CacheFilePath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var parts = line.Split('=', 2); + if (parts.Length == 2 && parts[0] == productUserId) + { + return parts[1]; + } + } + + return null; + } + } + + private static void SaveFriendCodeToCache(string productUserId, string friendCode) + { + lock (FileLock) + { + Directory.CreateDirectory(CacheFileDir); + + if (System.IO.File.Exists(CacheFilePath)) + { + foreach (var line in System.IO.File.ReadLines(CacheFilePath)) + { + if (line.StartsWith(productUserId + "=", StringComparison.Ordinal)) + { + return; + } + } + } + + System.IO.File.AppendAllText(CacheFilePath, $"{productUserId}={friendCode}{Environment.NewLine}"); + } + } + + private async Task FetchFromInnerslothAsync(string eosToken, string productUserId) + { + try + { + using var client = _httpClientFactory.CreateClient("innersloth"); + var req = new HttpRequestMessage(HttpMethod.Get, + "https://backend.innersloth.com/api/user/username"); + req.Headers.Add("Authorization", "Bearer " + eosToken); + req.Headers.TryAddWithoutValidation("Accept", "application/vnd.api+json"); + + var response = await client.SendAsync(req); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "[TokenController] Innersloth API returned {Status} for PUID={Puid}", + response.StatusCode, productUserId); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var attrs = root.TryGetProperty("data", out var data) + && data.TryGetProperty("attributes", out var a) ? a : root; + var username = attrs.TryGetProperty("username", out var u) ? u.GetString() : null; + var discriminator = attrs.TryGetProperty("discriminator", out var d) ? d.GetString() : null; + + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(discriminator)) + { + var fc = $"{username}#{discriminator}"; + _logger.LogInformation( + "[TokenController] FriendCode fetched: PUID={Puid} FC={FC}", + productUserId, fc); + return fc; + } + + _logger.LogWarning( + "[TokenController] Missing username/discriminator in Innersloth response for PUID={Puid}", + productUserId); + } + catch (Exception ex) + { + _logger.LogError(ex, "[TokenController] Exception calling Innersloth API for PUID={Puid}", productUserId); + } + + return null; + } + + private static string GenerateMatchmakerToken(string productUserId) + { + var salt = RandomNumberGenerator.GetBytes(16); + var input = $"{productUserId}:{DateTime.UtcNow.Ticks}:{Convert.ToBase64String(salt)}"; + return Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(input))); + } + + private static string GenerateFallbackFriendCode(string productUserId) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(productUserId)); + var disc = BitConverter.ToUInt16(hash, 0) % 10000; + return $"Player#{disc:D4}"; + } + + public sealed class TokenRequest + { + [JsonPropertyName("Puid")] + public required string Puid { get; init; } + } + + public sealed class TokenResponse + { + [JsonPropertyName("Content")] + public required TokenContent Content { get; init; } + + [JsonPropertyName("Hash")] + public required string Hash { get; init; } + } + + public sealed class TokenContent + { + [JsonPropertyName("Puid")] + public required string ProductUserId { get; init; } + + [JsonPropertyName("FriendCode")] + public string? FriendCode { get; init; } + + [JsonPropertyName("ExpiresAt")] + public DateTime ExpiresAt { get; init; } = new DateTime(2099, 12, 31); + } +} diff --git a/src/Impostor.Server/Http/VerifyController.cs b/src/Impostor.Server/Http/VerifyController.cs new file mode 100644 index 0000000..af7fbb5 --- /dev/null +++ b/src/Impostor.Server/Http/VerifyController.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using Impostor.Api.Service.Admin.Verify; +using Microsoft.AspNetCore.Mvc; + +namespace Impostor.Server.Http; + +[Route("/api/verify")] +[ApiController] +public sealed class VerifyController : ControllerBase +{ + private readonly IVerifyStore _store; + + public VerifyController(IVerifyStore store) + { + _store = store; + } + + /// + /// Called by the QQ bot when a user sends /验证 <好友代码> in a group. + /// Validates the FriendCode ↔ QQ pairing and removes the pending entry on success. + /// + [HttpPost("confirm")] + public IActionResult Confirm([FromBody] VerifyConfirmRequest req) + { + if (!_store.ValidateSecret(req.Secret)) + { + return Unauthorized(new { ok = false, error = "Invalid bot secret." }); + } + + if (string.IsNullOrWhiteSpace(req.FriendCode) || string.IsNullOrWhiteSpace(req.QqNumber)) + { + return BadRequest(new { ok = false, error = "friendCode and qqNumber are required." }); + } + + var ok = _store.TryConfirm(req.FriendCode, req.QqNumber); + if (ok) + { + return Ok(new { ok = true }); + } + + return Ok(new + { + ok = false, + error = "no_pending_or_expired", + message = "未找到待验证记录或已过期。请先在游戏内输入 /verify <你的QQ号>。", + }); + } + + public sealed record VerifyConfirmRequest( + [property: JsonPropertyName("friendCode")] string FriendCode, + [property: JsonPropertyName("qqNumber")] string QqNumber, + [property: JsonPropertyName("secret")] string Secret); +} diff --git a/src/Impostor.Server/Impostor.Server.csproj b/src/Impostor.Server/Impostor.Server.csproj new file mode 100644 index 0000000..3bcdd72 --- /dev/null +++ b/src/Impostor.Server/Impostor.Server.csproj @@ -0,0 +1,37 @@ + + + Exe + win-x64;linux-x64;linux-arm;linux-arm64;osx-x64 + true + icon.ico + ../ProjectRules.ruleset + enable + false + true + Impostor.Server + Impostor.Server + Impostor.Server + Copyright © AeonLucid 2020 + AnyCPU + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/Impostor.Server/Impostor.Server.csproj.DotSettings b/src/Impostor.Server/Impostor.Server.csproj.DotSettings new file mode 100644 index 0000000..5c07b47 --- /dev/null +++ b/src/Impostor.Server/Impostor.Server.csproj.DotSettings @@ -0,0 +1,3 @@ + + True + True \ No newline at end of file diff --git a/src/Impostor.Server/Languages/de.json b/src/Impostor.Server/Languages/de.json new file mode 100644 index 0000000..b4d0a21 --- /dev/null +++ b/src/Impostor.Server/Languages/de.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Unbekannter Befehl /{0}. Tippe /help für eine Liste.", + "command.error": "Fehler beim Ausführen von /{0}.", + "command.usage": "Verwendung: /{0}", + "command.help.list": "=== Befehle ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Unbekannter Befehl: /{0}", + "command.help.aliases": "Aliase: {0}", + "command.note.host_only": "Nur der Host kann /note verwenden.", + "command.note.cleared": "Notiz gelöscht.", + "command.note.set": "Notiz gesetzt: {0}", + "command.color.invalid": "Ungültige Farb-ID. Gültiger Bereich: 0–17.", + "command.color.set": "Farbe geändert zu {0} ({1}).", + "command.title.too_long": "Titel zu lang. Maximal 12 Zeichen.", + "command.title.cleared": "Titel entfernt.", + "command.title.set": "Titel gesetzt: {0}", + "welcome.join": "Willkommen, {0}! Freundescode: {1} | Raum: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/en.json b/src/Impostor.Server/Languages/en.json new file mode 100644 index 0000000..468a65a --- /dev/null +++ b/src/Impostor.Server/Languages/en.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Unknown command /{0}. Type /help for a list.", + "command.error": "An error occurred while executing /{0}.", + "command.usage": "Usage: /{0}", + "command.help.list": "=== Commands ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Unknown command: /{0}", + "command.help.aliases": "Aliases: {0}", + "command.note.host_only": "Only the host can use /note.", + "command.note.cleared": "Note cleared.", + "command.note.set": "Note set: {0}", + "command.color.invalid": "Invalid color ID. Valid range: 0–17.", + "command.color.set": "Color changed to {0} ({1}).", + "command.title.too_long": "Title too long. Max 12 characters.", + "command.title.cleared": "Title removed.", + "command.title.set": "Title set: {0}", + "welcome.join": "Welcome, {0}! Friend code: {1} | Room: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/es.json b/src/Impostor.Server/Languages/es.json new file mode 100644 index 0000000..032fd15 --- /dev/null +++ b/src/Impostor.Server/Languages/es.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Comando desconocido /{0}. Escribe /help para ver la lista.", + "command.error": "Error al ejecutar /{0}.", + "command.usage": "Uso: /{0}", + "command.help.list": "=== Comandos ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Comando desconocido: /{0}", + "command.help.aliases": "Alias: {0}", + "command.note.host_only": "Solo el anfitrión puede usar /note.", + "command.note.cleared": "Nota eliminada.", + "command.note.set": "Nota establecida: {0}", + "command.color.invalid": "ID de color inválido. Rango válido: 0–17.", + "command.color.set": "Color cambiado a {0} ({1}).", + "command.title.too_long": "Título demasiado largo. Máximo 12 caracteres.", + "command.title.cleared": "Título eliminado.", + "command.title.set": "Título establecido: {0}", + "welcome.join": "Bienvenido, {0}! Código de amigo: {1} | Sala: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/fil.json b/src/Impostor.Server/Languages/fil.json new file mode 100644 index 0000000..b0791dc --- /dev/null +++ b/src/Impostor.Server/Languages/fil.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Hindi kilalang command /{0}. I-type ang /help para sa listahan.", + "command.error": "May error sa pagpapatakbo ng /{0}.", + "command.usage": "Paggamit: /{0}", + "command.help.list": "=== Mga Command ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Hindi kilalang command: /{0}", + "command.help.aliases": "Mga Alias: {0}", + "command.note.host_only": "Ang host lang ang maaaring gumamit ng /note.", + "command.note.cleared": "Natanggal ang tala.", + "command.note.set": "Naitakda ang tala: {0}", + "command.color.invalid": "Di-wastong color ID. Valid na hanay: 0–17.", + "command.color.set": "Binago ang kulay sa {0} ({1}).", + "command.title.too_long": "Masyadong mahaba ang pamagat. Max 12 karakter.", + "command.title.cleared": "Natanggal ang pamagat.", + "command.title.set": "Naitakda ang pamagat: {0}", + "welcome.join": "Maligayang pagdating, {0}! Friend code: {1} | Silid: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/fr.json b/src/Impostor.Server/Languages/fr.json new file mode 100644 index 0000000..98ed475 --- /dev/null +++ b/src/Impostor.Server/Languages/fr.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Commande inconnue /{0}. Tapez /help pour la liste.", + "command.error": "Erreur lors de l'exécution de /{0}.", + "command.usage": "Utilisation : /{0}", + "command.help.list": "=== Commandes ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Commande inconnue : /{0}", + "command.help.aliases": "Alias : {0}", + "command.note.host_only": "Seul l'hôte peut utiliser /note.", + "command.note.cleared": "Note effacée.", + "command.note.set": "Note définie : {0}", + "command.color.invalid": "ID de couleur invalide. Plage valide : 0–17.", + "command.color.set": "Couleur changée en {0} ({1}).", + "command.title.too_long": "Titre trop long. Maximum 12 caractères.", + "command.title.cleared": "Titre supprimé.", + "command.title.set": "Titre défini : {0}", + "welcome.join": "Bienvenue, {0} ! Code ami : {1} | Salle : {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/ga.json b/src/Impostor.Server/Languages/ga.json new file mode 100644 index 0000000..61c1a1e --- /dev/null +++ b/src/Impostor.Server/Languages/ga.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Ordú anaithnid /{0}. Clóscríobh /help le haghaidh liosta.", + "command.error": "Earráid agus /{0} á rith.", + "command.usage": "Úsáid: /{0}", + "command.help.list": "=== Orduithe ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Ordú anaithnid: /{0}", + "command.help.aliases": "Ailíasanna: {0}", + "command.note.host_only": "Ní féidir ach leis an óstach /note a úsáid.", + "command.note.cleared": "Nóta glanadh.", + "command.note.set": "Nóta socraithe: {0}", + "command.color.invalid": "ID datha neamhbhailí. Raon bailí: 0–17.", + "command.color.set": "Dath athraithe go {0} ({1}).", + "command.title.too_long": "Teideal ró-fhada. Uasmhéid 12 carachtar.", + "command.title.cleared": "Teideal bainte.", + "command.title.set": "Teideal socraithe: {0}", + "welcome.join": "Fáilte, {0}! Cód cara: {1} | Seomra: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/it.json b/src/Impostor.Server/Languages/it.json new file mode 100644 index 0000000..cab171e --- /dev/null +++ b/src/Impostor.Server/Languages/it.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Comando sconosciuto /{0}. Digita /help per la lista.", + "command.error": "Errore durante l'esecuzione di /{0}.", + "command.usage": "Utilizzo: /{0}", + "command.help.list": "=== Comandi ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Comando sconosciuto: /{0}", + "command.help.aliases": "Alias: {0}", + "command.note.host_only": "Solo l'host può usare /note.", + "command.note.cleared": "Nota rimossa.", + "command.note.set": "Nota impostata: {0}", + "command.color.invalid": "ID colore non valido. Intervallo: 0–17.", + "command.color.set": "Colore cambiato in {0} ({1}).", + "command.title.too_long": "Titolo troppo lungo. Max 12 caratteri.", + "command.title.cleared": "Titolo rimosso.", + "command.title.set": "Titolo impostato: {0}", + "welcome.join": "Benvenuto, {0}! Codice amico: {1} | Stanza: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/ja.json b/src/Impostor.Server/Languages/ja.json new file mode 100644 index 0000000..5e2069d --- /dev/null +++ b/src/Impostor.Server/Languages/ja.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "不明なコマンド /{0}。/help でリストを確認してください。", + "command.error": "/{0} の実行中にエラーが発生しました。", + "command.usage": "使い方:/{0}", + "command.help.list": "=== コマンド一覧 ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "不明なコマンド:/{0}", + "command.help.aliases": "エイリアス:{0}", + "command.note.host_only": "/note はホストのみ使用できます。", + "command.note.cleared": "ノートを削除しました。", + "command.note.set": "ノートを設定しました:{0}", + "command.color.invalid": "無効なカラーID。有効範囲:0–17。", + "command.color.set": "カラーを {0}({1})に変更しました。", + "command.title.too_long": "称号が長すぎます。最大12文字。", + "command.title.cleared": "称号を削除しました。", + "command.title.set": "称号を設定しました:{0}", + "welcome.join": "ようこそ、{0}!フレンドコード:{1} | ルーム:{2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/ko.json b/src/Impostor.Server/Languages/ko.json new file mode 100644 index 0000000..f2bffc5 --- /dev/null +++ b/src/Impostor.Server/Languages/ko.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "알 수 없는 명령어 /{0}. /help를 입력하여 목록을 확인하세요.", + "command.error": "/{0} 실행 중 오류가 발생했습니다.", + "command.usage": "사용법: /{0}", + "command.help.list": "=== 명령어 목록 ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "알 수 없는 명령어: /{0}", + "command.help.aliases": "별칭: {0}", + "command.note.host_only": "/note는 방장만 사용할 수 있습니다.", + "command.note.cleared": "메모가 삭제되었습니다.", + "command.note.set": "메모 설정됨: {0}", + "command.color.invalid": "잘못된 색상 ID. 유효 범위: 0–17.", + "command.color.set": "색상이 {0}({1})으로 변경되었습니다.", + "command.title.too_long": "칭호가 너무 깁니다. 최대 12자.", + "command.title.cleared": "칭호가 제거되었습니다.", + "command.title.set": "칭호 설정됨: {0}", + "welcome.join": "환영합니다, {0}! 친구 코드: {1} | 방: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/nl.json b/src/Impostor.Server/Languages/nl.json new file mode 100644 index 0000000..1b276c9 --- /dev/null +++ b/src/Impostor.Server/Languages/nl.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Onbekend commando /{0}. Typ /help voor een lijst.", + "command.error": "Fout bij uitvoeren van /{0}.", + "command.usage": "Gebruik: /{0}", + "command.help.list": "=== Commando's ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Onbekend commando: /{0}", + "command.help.aliases": "Aliassen: {0}", + "command.note.host_only": "Alleen de host kan /note gebruiken.", + "command.note.cleared": "Notitie verwijderd.", + "command.note.set": "Notitie ingesteld: {0}", + "command.color.invalid": "Ongeldig kleur-ID. Geldig bereik: 0–17.", + "command.color.set": "Kleur gewijzigd naar {0} ({1}).", + "command.title.too_long": "Titel te lang. Maximaal 12 tekens.", + "command.title.cleared": "Titel verwijderd.", + "command.title.set": "Titel ingesteld: {0}", + "welcome.join": "Welkom, {0}! Vriendcode: {1} | Kamer: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/pt.json b/src/Impostor.Server/Languages/pt.json new file mode 100644 index 0000000..fe84b8b --- /dev/null +++ b/src/Impostor.Server/Languages/pt.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Comando desconhecido /{0}. Digite /help para ver a lista.", + "command.error": "Ocorreu um erro ao executar /{0}.", + "command.usage": "Uso: /{0}", + "command.help.list": "=== Comandos ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Comando desconhecido: /{0}", + "command.help.aliases": "Aliases: {0}", + "command.note.host_only": "Apenas o anfitrião pode usar /note.", + "command.note.cleared": "Nota removida.", + "command.note.set": "Nota definida: {0}", + "command.color.invalid": "ID de cor inválido. Intervalo válido: 0–17.", + "command.color.set": "Cor alterada para {0} ({1}).", + "command.title.too_long": "Título muito longo. Máximo 12 caracteres.", + "command.title.cleared": "Título removido.", + "command.title.set": "Título definido: {0}", + "welcome.join": "Bem-vindo, {0}! Código de amigo: {1} | Sala: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/pt_BR.json b/src/Impostor.Server/Languages/pt_BR.json new file mode 100644 index 0000000..929f259 --- /dev/null +++ b/src/Impostor.Server/Languages/pt_BR.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Comando desconhecido /{0}. Digite /help para ver a lista.", + "command.error": "Ocorreu um erro ao executar /{0}.", + "command.usage": "Uso: /{0}", + "command.help.list": "=== Comandos ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Comando desconhecido: /{0}", + "command.help.aliases": "Apelidos: {0}", + "command.note.host_only": "Somente o anfitrião pode usar /note.", + "command.note.cleared": "Nota removida.", + "command.note.set": "Nota definida: {0}", + "command.color.invalid": "ID de cor inválido. Intervalo válido: 0–17.", + "command.color.set": "Cor alterada para {0} ({1}).", + "command.title.too_long": "Título muito longo. Máximo 12 caracteres.", + "command.title.cleared": "Título removido.", + "command.title.set": "Título definido: {0}", + "welcome.join": "Bem-vindo, {0}! Código de amigo: {1} | Sala: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/ru.json b/src/Impostor.Server/Languages/ru.json new file mode 100644 index 0000000..bb23389 --- /dev/null +++ b/src/Impostor.Server/Languages/ru.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "Неизвестная команда /{0}. Введите /help для списка.", + "command.error": "Ошибка при выполнении /{0}.", + "command.usage": "Использование: /{0}", + "command.help.list": "=== Команды ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "Неизвестная команда: /{0}", + "command.help.aliases": "Псевдонимы: {0}", + "command.note.host_only": "Только хост может использовать /note.", + "command.note.cleared": "Заметка удалена.", + "command.note.set": "Заметка установлена: {0}", + "command.color.invalid": "Неверный ID цвета. Допустимый диапазон: 0–17.", + "command.color.set": "Цвет изменён на {0} ({1}).", + "command.title.too_long": "Титул слишком длинный. Максимум 12 символов.", + "command.title.cleared": "Титул удалён.", + "command.title.set": "Титул установлен: {0}", + "welcome.join": "Добро пожаловать, {0}! Код друга: {1} | Комната: {2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/zh_CN.json b/src/Impostor.Server/Languages/zh_CN.json new file mode 100644 index 0000000..bec2c96 --- /dev/null +++ b/src/Impostor.Server/Languages/zh_CN.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "未知指令 /{0}。输入 /help 查看列表。", + "command.error": "执行 /{0} 时发生错误。", + "command.usage": "用法:/{0}", + "command.help.list": "=== 指令列表 ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "未知指令:/{0}", + "command.help.aliases": "别名:{0}", + "command.note.host_only": "只有房主可以使用 /note。", + "command.note.cleared": "备注已清除。", + "command.note.set": "备注已设置:{0}", + "command.color.invalid": "无效颜色 ID,范围:0–17。", + "command.color.set": "颜色已更改为 {0}({1})。", + "command.title.too_long": "头衔过长,最多 12 个字符。", + "command.title.cleared": "头衔已移除。", + "command.title.set": "头衔已设置:{0}", + "welcome.join": "欢迎,{0}!好友码:{1} | 房间:{2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Languages/zh_TW.json b/src/Impostor.Server/Languages/zh_TW.json new file mode 100644 index 0000000..85cfec3 --- /dev/null +++ b/src/Impostor.Server/Languages/zh_TW.json @@ -0,0 +1,18 @@ +{ + "command.unknown": "未知指令 /{0}。輸入 /help 查看列表。", + "command.error": "執行 /{0} 時發生錯誤。", + "command.usage": "用法:/{0}", + "command.help.list": "=== 指令列表 ===", + "command.help.entry": "/{0} — {1}", + "command.help.unknown": "未知指令:/{0}", + "command.help.aliases": "別名:{0}", + "command.note.host_only": "只有房主可以使用 /note。", + "command.note.cleared": "備註已清除。", + "command.note.set": "備註已設置:{0}", + "command.color.invalid": "無效顏色 ID,範圍:0–17。", + "command.color.set": "顏色已更改為 {0}({1})。", + "command.title.too_long": "頭銜過長,最多 12 個字符。", + "command.title.cleared": "頭銜已移除。", + "command.title.set": "頭銜已設置:{0}", + "welcome.join": "歡迎,{0}!好友碼:{1} | 房間:{2}" +} \ No newline at end of file diff --git a/src/Impostor.Server/Net/Client.cs b/src/Impostor.Server/Net/Client.cs new file mode 100644 index 0000000..3e5fdbf --- /dev/null +++ b/src/Impostor.Server/Net/Client.cs @@ -0,0 +1,484 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Config; +using Impostor.Api.Events.Managers; +using Impostor.Api.Games; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Messages; +using Impostor.Api.Net.Messages.C2S; +using Impostor.Api.Net.Messages.S2C; +using Impostor.Server.Events.Player; +using Impostor.Server.Net.Manager; +using Impostor.Server.Service.Admin.Report; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Next.Hazel; + +namespace Impostor.Server.Net +{ + internal class Client : ClientBase + { + private readonly ILogger _logger; + private readonly AntiCheatConfig _antiCheatConfig; + private readonly ClientManager _clientManager; + private readonly GameManager _gameManager; + private readonly ICustomMessageManager _customMessageManager; + private readonly IEventManager _eventManager; + private readonly ReportStore _reportStore; + + public Client(ILogger logger, IOptions antiCheatOptions, ClientManager clientManager, GameManager gameManager, ICustomMessageManager customMessageManager, IEventManager eventManager, ReportStore reportStore, string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection) + : base(name, gameVersion, language, chatMode, platformSpecificData, connection) + { + _logger = logger; + _antiCheatConfig = antiCheatOptions.Value; + _clientManager = clientManager; + _gameManager = gameManager; + _customMessageManager = customMessageManager; + _eventManager = eventManager; + _reportStore = reportStore; + } + + public override async ValueTask ReportCheatAsync(CheatContext context, CheatCategory category, string message) + { + if (!_antiCheatConfig.Enabled) + { + return false; + } + + if (Player != null && Player.IsHost) + { + var isHostCheatingAllowed = _antiCheatConfig.AllowCheatingHosts switch { + CheatingHostMode.Always => true, + CheatingHostMode.IfRequested => GameVersion.HasDisableServerAuthorityFlag, + CheatingHostMode.Never => false, + _ => false, + }; + + if (isHostCheatingAllowed) + { + return false; + } + } + + bool LogUnknownCategory(CheatCategory category) + { + _logger.LogWarning("Unknown cheat category {Category} was used when reporting", category); + return true; + } + + var isCategoryEnabled = category switch + { + CheatCategory.ProtocolExtension => _antiCheatConfig.ForbidProtocolExtensions, + CheatCategory.GameFlow => _antiCheatConfig.EnableGameFlowChecks, + CheatCategory.InvalidObject => _antiCheatConfig.EnableInvalidObjectChecks, + CheatCategory.MustBeHost => _antiCheatConfig.EnableMustBeHostChecks, + CheatCategory.ColorLimits => _antiCheatConfig.EnableColorLimitChecks, + CheatCategory.NameLimits => _antiCheatConfig.EnableNameLimitChecks, + CheatCategory.Ownership => _antiCheatConfig.EnableOwnershipChecks, + CheatCategory.Role => _antiCheatConfig.EnableRoleChecks, + CheatCategory.Target => _antiCheatConfig.EnableTargetChecks, + CheatCategory.Other => true, + _ => LogUnknownCategory(category), + }; + + if (!isCategoryEnabled) + { + return false; + } + + var supportCode = Random.Shared.Next(0, 999_999).ToString("000-000"); + + _logger.LogWarning("Client {Name} ({Id}) was caught cheating: [{SupportCode}] [{Context}-{Category}] {Message}", Name, Id, supportCode, context.Name, category, message); + + if (Player is { } player) + { + if (_antiCheatConfig.BanIpFromGame) + { + player.Game.BanIp(Connection.EndPoint.Address); + } + + await player.Game.HandleRemovePlayer(Id, DisconnectReason.Hacking); + } + + var disconnectMessage = + $""" + You have been caught cheating and were {(_antiCheatConfig.BanIpFromGame ? "banned" : "kicked")} from the lobby. + For questions, contact your server admin and share the following code: {supportCode}. + """; + + await DisconnectAsync(DisconnectReason.Custom, disconnectMessage); + + return true; + } + + public override async ValueTask HandleMessageAsync(IMessageReader reader, MessageType messageType) + { + var flag = reader.Tag; + + _logger.LogTrace("[{0}] Server got {1}.", Id, MessageFlags.FlagToString(flag)); + + switch (flag) + { + case MessageFlags.HostGame: + { + // Read game settings. + Message00HostGameC2S.Deserialize(reader, out var gameOptions, out _, out var gameFilterOptions); + + // Create game. + var game = await _gameManager.CreateAsync(this, gameOptions, gameFilterOptions); + + if (game == null) + { + await DisconnectAsync(DisconnectReason.GameNotFound); + return; + } + + // Code in the packet below will be used in JoinGame. + using (var writer = MessageWriter.Get(MessageType.Reliable)) + { + Message00HostGameS2C.Serialize(writer, game.Code); + await Connection.SendAsync(writer); + } + + break; + } + + case MessageFlags.JoinGame: + { + Message01JoinGameC2S.Deserialize(reader, out var gameCode); + + var game = _gameManager.Find(gameCode); + if (game == null) + { + await DisconnectAsync(DisconnectReason.GameNotFound); + return; + } + + var result = await game.AddClientAsync(this); + + switch (result.Error) + { + case GameJoinError.None: + break; + case GameJoinError.InvalidClient: + await DisconnectAsync(DisconnectReason.Custom, "Client is in an invalid state."); + break; + case GameJoinError.Banned: + await DisconnectAsync(DisconnectReason.Banned); + break; + case GameJoinError.GameFull: + await DisconnectAsync(DisconnectReason.GameFull); + break; + case GameJoinError.InvalidLimbo: + await DisconnectAsync(DisconnectReason.Custom, "Invalid limbo state while joining."); + break; + case GameJoinError.GameStarted: + await DisconnectAsync(DisconnectReason.GameStarted); + break; + case GameJoinError.GameDestroyed: + await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.Destroyed); + break; + case GameJoinError.ClientOutdated: + await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.ClientOutdated); + break; + case GameJoinError.ClientTooNew: + await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.ClientTooNew); + break; + case GameJoinError.Custom: + await DisconnectAsync(DisconnectReason.Custom, result.Message); + break; + default: + await DisconnectAsync(DisconnectReason.Custom, "Unknown error."); + break; + } + + break; + } + + case MessageFlags.StartGame: + { + if (!IsPacketAllowed(reader, true)) + { + return; + } + + await Player!.Game.HandleStartGame(reader); + break; + } + + // No idea how this flag is triggered. + case MessageFlags.RemoveGame: + break; + + case MessageFlags.RemovePlayer: + { + if (!IsPacketAllowed(reader, true)) + { + return; + } + + Message04RemovePlayerC2S.Deserialize( + reader, + out var playerId, + out var reason); + + await Player!.Game.HandleRemovePlayer(playerId, (DisconnectReason)reason); + break; + } + + case MessageFlags.GameData: + case MessageFlags.GameDataTo: + { + if (!IsPacketAllowed(reader, false)) + { + return; + } + + var toPlayer = flag == MessageFlags.GameDataTo; + + var position = reader.Position; + var verified = await Player!.Game.HandleGameDataAsync(reader, Player, toPlayer); + reader.Seek(position); + + if (verified && Player != null) + { + // Broadcast packet to all other players. + using (var writer = MessageWriter.Get(messageType)) + { + if (toPlayer) + { + var target = reader.ReadPackedInt32(); + reader.CopyTo(writer); + await Player.Game.SendToAsync(writer, target); + } + else + { + reader.CopyTo(writer); + await Player.Game.SendToAllExceptAsync(writer, Id); + } + } + } + + break; + } + + case MessageFlags.EndGame: + { + if (!IsPacketAllowed(reader, true)) + { + return; + } + + Message08EndGameC2S.Deserialize( + reader, + out var gameOverReason); + + await Player!.Game.HandleEndGame(reader, gameOverReason); + break; + } + + case MessageFlags.AlterGame: + { + if (!IsPacketAllowed(reader, true)) + { + return; + } + + Message10AlterGameC2S.Deserialize( + reader, + out var gameTag, + out var value); + + if (gameTag != AlterGameTags.ChangePrivacy) + { + return; + } + + await Player!.Game.HandleAlterGame(reader, Player, value); + break; + } + + case MessageFlags.KickPlayer: + { + if (!IsPacketAllowed(reader, true)) + { + return; + } + + Message11KickPlayerC2S.Deserialize( + reader, + out var playerId, + out var isBan); + + await Player!.Game.HandleKickPlayer(playerId, isBan); + break; + } + + case MessageFlags.GetGameListV2: + { + await DisconnectAsync(DisconnectReason.Custom, DisconnectMessages.UdpMatchmakingUnsupported); + return; + } + + case MessageFlags.SetActivePodType: + { + Message21SetActivePodType.Deserialize(reader, out _); + break; + } + + case MessageFlags.QueryPlatformIds: + { + Message22QueryPlatformIdsC2S.Deserialize(reader, out var gameCode); + await OnQueryPlatformIds(gameCode); + break; + } + + case MessageFlags.ReportPlayer: + { + await OnReportPlayerAsync(reader); + break; + } + + default: + if (_customMessageManager.TryGet(flag, out var customRootMessage)) + { + await customRootMessage.HandleMessageAsync(this, reader, messageType); + break; + } + + _logger.LogWarning("Server received unknown flag {0}.", flag); + break; + } + +#if DEBUG + if (flag != MessageFlags.GameData && + flag != MessageFlags.GameDataTo && + flag != MessageFlags.EndGame && + reader.Position < reader.Length) + { + _logger.LogWarning( + "Server did not consume all bytes from {0} ({1} < {2}).", + flag, + reader.Position, + reader.Length); + } +#endif + } + + public override async ValueTask HandleDisconnectAsync(string reason) + { + try + { + if (Player != null) + { + // The client never sends over the real disconnect reason so we always assume ExitGame + var isRemote = reason == "The remote sent a disconnect request"; + await Player.Game.HandleRemovePlayer(Id, isRemote ? DisconnectReason.ExitGame : DisconnectReason.Error); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception caught in client disconnection."); + } + + _logger.LogInformation("Client {0} disconnecting, reason: {1}", Id, reason); + _clientManager.Remove(this); + await _gameManager.OnClientDisconnectAsync(this); + } + + private bool IsPacketAllowed(IMessageReader message, bool hostOnly) + { + if (Player == null) + { + return false; + } + + var game = Player.Game; + + // GameCode must match code of the current game assigned to the player. + if (message.ReadInt32() != game.Code) + { + return false; + } + + // Some packets should only be sent by the host of the game. + if (hostOnly) + { + if (game.HostId == Id) + { + return true; + } + + _logger.LogWarning("[{0}] Client sent packet only allowed by the host ({1}).", Id, game.HostId); + return false; + } + + return true; + } + + /// + /// Triggered when a client reports another player. + /// + private async ValueTask OnReportPlayerAsync(IMessageReader reader) + { + var gameCode = reader.ReadInt32(); + var reportedClientId = reader.ReadPackedInt32(); + var reason = (ReportReasons)reader.ReadByte(); + var reportedClient = _clientManager.Clients.FirstOrDefault(c => c.Id == reportedClientId); + + _logger.LogWarning( + "[Report] {Reporter} ({ReporterFriendCode}) reported {Reported} ({ReporterFriendCode}) Reason={Reason} Game={Game}", + Name, string.IsNullOrEmpty(FriendCode) ? "unknown" : FriendCode, + reportedClient?.Name ?? "unknown", reportedClient?.FriendCode ?? "unknown", + reason, gameCode); + + var outcome = ReportOutcome.NotReportedUnknown; + if (Player != null && Player.Character != null) + { + var reportEvent = new PlayerReportEvent(Player.Game, Player, Player.Character, reportedClient, reason); + await _eventManager.CallAsync(reportEvent); + outcome = reportEvent.Outcome; + } + + _reportStore.Add(new ReportEntry + { + GameCode = gameCode.ToString(), + ReporterName = Name, + ReporterFriendCode = FriendCode, + ReportedName = reportedClient?.Name, + ReportedFriendCode = reportedClient?.FriendCode, + Reason = reason, + Outcome = outcome, + }); + + using var message = MessageWriter.Get(MessageType.Reliable); + message.StartMessage(MessageFlags.ReportPlayer); + message.WritePacked(reportedClientId); + message.Write((int)reason); + message.Write((byte)outcome); + message.Write(reportedClient?.Name ?? string.Empty); + message.EndMessage(); + await Connection.SendAsync(message); + } + + /// + /// Triggered when the connected client requests the PlatformSpecificData. + /// + /// + /// The GameCode of the game whose platform id's are checked. + /// + private ValueTask OnQueryPlatformIds(GameCode code) + { + using var message = MessageWriter.Get(MessageType.Reliable); + + var playerSpecificData = _gameManager.Find(code)?.Players.Select(p => p.Client.PlatformSpecificData) ?? Enumerable.Empty(); + + Message22QueryPlatformIdsS2C.Serialize(message, code, playerSpecificData); + + return Connection.SendAsync(message); + } + } +} diff --git a/src/Impostor.Server/Net/ClientBase.cs b/src/Impostor.Server/Net/ClientBase.cs new file mode 100644 index 0000000..6cc35d3 --- /dev/null +++ b/src/Impostor.Server/Net/ClientBase.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Innersloth.Customization; +using Impostor.Api.Net; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net +{ + internal abstract class ClientBase : IClient + { + protected ClientBase(string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, IHazelConnection connection) + { + Name = name; + GameVersion = gameVersion; + Language = language; + ChatMode = chatMode; + PlatformSpecificData = platformSpecificData; + Connection = connection; + Items = new ConcurrentDictionary(); + } + + public int Id { get; set; } + + public string Name { get; } + + public Language Language { get; } + + public QuickChatModes ChatMode { get; } + + public PlatformSpecificData PlatformSpecificData { get; } + + /// + public string? FriendCode { get; set; } + + public GameVersion GameVersion { get; } + + public IHazelConnection Connection { get; } + + public IDictionary Items { get; } + + public ClientPlayer? Player { get; set; } + + public ColorType? PreviousColor { get; set; } = null; + + IClientPlayer? IClient.Player => Player; + + public virtual ValueTask ReportCheatAsync(CheatContext context, CheatCategory category, string message) + { + return new ValueTask(false); + } + + public ValueTask ReportCheatAsync(CheatContext context, string message) + { + return ReportCheatAsync(context, CheatCategory.Other, message); + } + + public abstract ValueTask HandleMessageAsync(IMessageReader message, MessageType messageType); + + public abstract ValueTask HandleDisconnectAsync(string reason); + + public async ValueTask DisconnectAsync(DisconnectReason reason, string? message = null) + { + await Connection.CustomDisconnectAsync(reason, message); + } + + public bool Equals(IClient? other) + { + return other != null && Id == other.Id; + } + + public override bool Equals(object? obj) + { + return Equals(obj as ClientBase); + } + + public override int GetHashCode() + { + return Id; + } + } +} diff --git a/src/Impostor.Server/Net/Custom/CustomMessageManager.cs b/src/Impostor.Server/Net/Custom/CustomMessageManager.cs new file mode 100644 index 0000000..42b3c7b --- /dev/null +++ b/src/Impostor.Server/Net/Custom/CustomMessageManager.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Impostor.Api; +using Impostor.Api.Net.Custom; + +namespace Impostor.Server.Net.Custom +{ + public class CustomMessageManager : ICustomMessageManager + where T : ICustomMessage + { + private readonly ConcurrentDictionary _messages = new ConcurrentDictionary(); + + public bool TryGet(byte id, [MaybeNullWhen(false)] out T message) + { + return _messages.TryGetValue(id, out message); + } + + public IDisposable Register(T message) + { + if (!_messages.TryAdd(message.Id, message)) + { + throw new ImpostorException($"Message with id: {message.Id} was already registered"); + } + + return new UnregisterDisposable(this, message); + } + + private class UnregisterDisposable : IDisposable + { + private readonly CustomMessageManager _manager; + private readonly T _message; + + private bool _disposed; + + public UnregisterDisposable(CustomMessageManager manager, T message) + { + _manager = manager; + _message = message; + } + + public void Dispose() + { + if (_disposed) + { + throw new ObjectDisposedException("Tried to dispose already disposed object"); + } + + _manager._messages.TryRemove(_message.Id, out _); + _disposed = true; + } + } + } +} diff --git a/src/Impostor.Server/Net/Factories/ClientFactory.cs b/src/Impostor.Server/Net/Factories/ClientFactory.cs new file mode 100644 index 0000000..188eb18 --- /dev/null +++ b/src/Impostor.Server/Net/Factories/ClientFactory.cs @@ -0,0 +1,24 @@ +using System; +using Impostor.Api.Net; +using Microsoft.Extensions.DependencyInjection; + +namespace Impostor.Server.Net.Factories +{ + internal class ClientFactory : IClientFactory + where TClient : ClientBase + { + private readonly IServiceProvider _serviceProvider; + + public ClientFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public ClientBase Create(IHazelConnection connection, string name, GameVersion clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData) + { + var client = ActivatorUtilities.CreateInstance(_serviceProvider, name, clientVersion, language, chatMode, platformSpecificData, connection); + connection.Client = client; + return client; + } + } +} diff --git a/src/Impostor.Server/Net/Factories/IClientFactory.cs b/src/Impostor.Server/Net/Factories/IClientFactory.cs new file mode 100644 index 0000000..f5c0639 --- /dev/null +++ b/src/Impostor.Server/Net/Factories/IClientFactory.cs @@ -0,0 +1,9 @@ +using Impostor.Api.Net; + +namespace Impostor.Server.Net.Factories +{ + internal interface IClientFactory + { + ClientBase Create(IHazelConnection connection, string name, GameVersion clientVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData); + } +} diff --git a/src/Impostor.Server/Net/GameCodeFactory.cs b/src/Impostor.Server/Net/GameCodeFactory.cs new file mode 100644 index 0000000..f8b0de4 --- /dev/null +++ b/src/Impostor.Server/Net/GameCodeFactory.cs @@ -0,0 +1,12 @@ +using Impostor.Api.Games; + +namespace Impostor.Server.Net +{ + public class GameCodeFactory : IGameCodeFactory + { + public GameCode Create() + { + return GameCode.Create(); + } + } +} diff --git a/src/Impostor.Server/Net/Hazel/HazelConnection.cs b/src/Impostor.Server/Net/Hazel/HazelConnection.cs new file mode 100644 index 0000000..54ea060 --- /dev/null +++ b/src/Impostor.Server/Net/Hazel/HazelConnection.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Threading.Tasks; +using Impostor.Api.Net; +using Next.Hazel; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Hazel +{ + internal class HazelConnection : IHazelConnection + { + private readonly ILogger _logger; + + public HazelConnection(Connection innerConnection, ILogger logger) + { + _logger = logger; + InnerConnection = innerConnection; + innerConnection.DataReceived = ConnectionOnDataReceived; + innerConnection.Disconnected = ConnectionOnDisconnected; + } + + public Connection InnerConnection { get; } + + public IPEndPoint EndPoint => InnerConnection.EndPoint; + + public bool IsConnected => InnerConnection.State == ConnectionState.Connected; + + public IClient? Client { get; set; } + + public float AveragePing => InnerConnection is NetworkConnection networkConnection ? networkConnection.AveragePingMs : 0; + + public ValueTask SendAsync(IMessageWriter writer) + { + return InnerConnection.SendAsync(writer); + } + + public ValueTask DisconnectAsync(string? reason, IMessageWriter? writer = null) + { + return InnerConnection.Disconnect(reason, writer as MessageWriter); + } + + public void DisposeInnerConnection() + { + InnerConnection.Dispose(); + } + + private async ValueTask ConnectionOnDisconnected(DisconnectedEventArgs e) + { + if (Client != null) + { + await Client.HandleDisconnectAsync(e.Reason); + } + } + + private async ValueTask ConnectionOnDataReceived(DataReceivedEventArgs e) + { + if (Client == null) + { + return; + } + + while (true) + { + if (e.Message.Position >= e.Message.Length) + { + break; + } + + using (var message = e.Message.ReadMessage()) + { + await Client.HandleMessageAsync(message, e.Type); + } + + if (!IsConnected) + { + break; + } + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/GameDataTag.cs b/src/Impostor.Server/Net/Inner/GameDataTag.cs new file mode 100644 index 0000000..300ce81 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/GameDataTag.cs @@ -0,0 +1,15 @@ +namespace Impostor.Server.Net.Inner +{ + public static class GameDataTag + { + public const byte DataFlag = 1; + public const byte RpcFlag = 2; + public const byte SpawnFlag = 4; + public const byte DespawnFlag = 5; + public const byte SceneChangeFlag = 6; + public const byte ReadyFlag = 7; + public const byte ChangeSettingsFlag = 8; + public const byte ConsoleDeclareClientPlatformFlag = 205; + public const byte PS4RoomRequest = 206; + } +} diff --git a/src/Impostor.Server/Net/Inner/GameObject.cs b/src/Impostor.Server/Net/Inner/GameObject.cs new file mode 100644 index 0000000..7fd4f01 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/GameObject.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner +{ + internal class GameObject + { + public GameObject() + { + Components = new List(); + } + + protected List Components { get; } + + public List GetComponentsInChildren() + { + var result = new List(); + + foreach (var component in Components) + { + if (component is T c) + { + result.Add(c); + } + } + + return result; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs b/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs new file mode 100644 index 0000000..10e995c --- /dev/null +++ b/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs @@ -0,0 +1,145 @@ +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Net; +using Impostor.Server.Net.Inner.Objects; + +namespace Impostor.Server.Net.Inner +{ + internal abstract partial class InnerNetObject + { + protected async ValueTask ValidateOwnership(CheatContext context, IClientPlayer sender) + { + if (!sender.IsOwner(this)) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Ownership, $"Failed ownership check on {GetType().Name}")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateHost(CheatContext context, IClientPlayer sender) + { + if (!sender.IsHost) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.MustBeHost, "Failed host check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateTarget(CheatContext context, IClientPlayer sender, IClientPlayer? target) + { + if (target == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Target, "Failed target check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateBroadcast(CheatContext context, IClientPlayer sender, IClientPlayer? target) + { + if (target != null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Target, "Failed broadcast check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateCmd(CheatContext context, IClientPlayer sender, IClientPlayer? target) + { + if (target == null || !target.IsHost) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Target, "Failed cmd check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateImpostor(CheatContext context, IClientPlayer sender, InnerPlayerInfo? playerInfo, bool value = true) + { + if (playerInfo == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.InvalidObject, "Couldn't check if Impostor, playerInfo not set")) + { + return false; + } + } + else if (playerInfo.IsImpostor != value) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Role, "Failed impostor check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateCanVent(CheatContext context, IClientPlayer sender, InnerPlayerInfo? playerInfo, bool value = true) + { + if (playerInfo == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.InvalidObject, "Couldn't check if can vent, playerInfo not set")) + { + return false; + } + } + else if (playerInfo.CanVent != value) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Role, "Failed can vent check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask ValidateRole(CheatContext context, IClientPlayer sender, InnerPlayerInfo? playerInfo, RoleTypes role) + { + if (playerInfo == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.InvalidObject, "Couldn't check role, playerInfo not set")) + { + return false; + } + } + else if (playerInfo.RoleType != role) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Role, $"Failed role = {role} check")) + { + return false; + } + } + + return true; + } + + protected async ValueTask UnregisteredCall(CheatContext context, IClientPlayer sender) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent unregistered call")) + { + return false; + } + + return true; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/InnerNetObject.cs b/src/Impostor.Server/Net/Inner/InnerNetObject.cs new file mode 100644 index 0000000..1fde006 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/InnerNetObject.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using Impostor.Api.Games; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner +{ + internal abstract partial class InnerNetObject : GameObject, IInnerNetObject + { + private const int HostInheritId = -2; + + private readonly ICustomMessageManager _customMessageManager; + + protected InnerNetObject(ICustomMessageManager customMessageManager, Game game) + { + _customMessageManager = customMessageManager; + Game = game; + } + + public uint NetId { get; internal set; } + + public int OwnerId { get; internal set; } + + public Game Game { get; } + + IGame IInnerNetObject.Game => Game; + + public SpawnFlags SpawnFlags { get; internal set; } + + public bool IsOwnedBy(IClientPlayer player) + { + return OwnerId == player.Client.Id || + (OwnerId == HostInheritId && player.IsHost); + } + + public abstract ValueTask SerializeAsync(IMessageWriter writer, bool initialState); + + public abstract ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState); + + public virtual async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + return await HandleCustomRpcAsync(sender, target, call, reader) ?? await UnregisteredCall(call, sender); + } + + protected async ValueTask HandleCustomRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + if (_customMessageManager.TryGet((byte)call, out var customRpc)) + { + return await customRpc.HandleRpcAsync(this, sender, target, reader); + } + + return null; + } + + internal virtual ValueTask OnSpawnAsync() + { + return default; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.Api.cs b/src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.Api.cs new file mode 100644 index 0000000..3e90de3 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.Api.cs @@ -0,0 +1,27 @@ +using System.Numerics; +using System.Threading.Tasks; +using Impostor.Api.Net; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects.Components; + +namespace Impostor.Server.Net.Inner.Objects.Components +{ + internal partial class InnerCustomNetworkTransform : IInnerCustomNetworkTransform + { + public async ValueTask SnapToAsync(Vector2 position) + { + var minSid = (ushort)(_lastSequenceId + 5U); + + // Snap in the server. + await SnapToAsync(Game.GetClientPlayer(OwnerId)!, position, minSid); + + // Broadcast to all clients. + using (var writer = Game.StartRpc(NetId, RpcCalls.SnapTo)) + { + writer.Write(position); + writer.Write(_lastSequenceId); + await Game.FinishRpcAsync(writer); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.cs b/src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.cs new file mode 100644 index 0000000..f7b145d --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Components/InnerCustomNetworkTransform.cs @@ -0,0 +1,164 @@ +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Events.Managers; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Events.Player; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ObjectPool; + +namespace Impostor.Server.Net.Inner.Objects.Components +{ + internal partial class InnerCustomNetworkTransform : InnerNetObject + { + private static readonly Vector2 ColliderOffset = new Vector2(0f, -0.4f); + + private readonly ILogger _logger; + private readonly InnerPlayerControl _playerControl; + private readonly IEventManager _eventManager; + private readonly ObjectPool _pool; + + private ushort _lastSequenceId; + + public InnerCustomNetworkTransform(ICustomMessageManager customMessageManager, Game game, ILogger logger, InnerPlayerControl playerControl, IEventManager eventManager, ObjectPool pool) : base(customMessageManager, game) + { + _logger = logger; + _playerControl = playerControl; + _eventManager = eventManager; + _pool = pool; + } + + private enum AirshipSpawnState : byte + { + PreSpawn, + SelectingSpawn, + Spawned, + } + + public Vector2 Position { get; private set; } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + if (initialState) + { + writer.Write(_lastSequenceId); + writer.Write(Position); + return new ValueTask(true); + } + + writer.Write(_lastSequenceId); + + // Impostor doesn't keep a memory of positions, so just send the last one + writer.WritePacked(1); + writer.Write(Position); + return new ValueTask(true); + } + + public override async ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + var sequenceId = reader.ReadUInt16(); + + if (initialState) + { + _lastSequenceId = sequenceId; + await SetPositionAsync(sender, reader.ReadVector2()); + } + else + { + if (!await ValidateOwnership(CheatContext.Deserialize, sender) || !await ValidateBroadcast(CheatContext.Deserialize, sender, target)) + { + return; + } + + var positions = reader.ReadPackedInt32(); + + for (var i = 0; i < positions; i++) + { + var position = reader.ReadVector2(); + var newSid = (ushort)(sequenceId + i); + if (SidGreaterThan(newSid, _lastSequenceId)) + { + _lastSequenceId = newSid; + await SetPositionAsync(sender, position); + } + } + } + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + if (call == RpcCalls.SnapTo) + { + if (!await ValidateOwnership(call, sender)) + { + return false; + } + + Rpc21SnapTo.Deserialize(reader, out var position, out var minSid); + + if (Game.GameNet.ShipStatus is { } shipStatus) + { + var vents = shipStatus.Data.Vents.Values; + + var vent = vents.SingleOrDefault(x => Approximately(x.Position, position + ColliderOffset)); + + if (vent != null) + { + if (!await ValidateCanVent(call, sender, _playerControl.PlayerInfo)) + { + return false; + } + + await _eventManager.CallAsync(new PlayerVentEvent(Game, sender, _playerControl, vent)); + } + } + + await SnapToAsync(sender, position, minSid); + return true; + } + + return await base.HandleRpcAsync(sender, target, call, reader); + } + + internal async ValueTask SetPositionAsync(IClientPlayer sender, Vector2 position) + { + Position = position; + + var playerMovementEvent = _pool.Get(); + playerMovementEvent.Reset(Game, sender, _playerControl); + await _eventManager.CallAsync(playerMovementEvent); + _pool.Return(playerMovementEvent); + } + + private static bool SidGreaterThan(ushort newSid, ushort prevSid) + { + var num = (ushort)(prevSid + (uint)short.MaxValue); + + return (int)prevSid < (int)num + ? newSid > prevSid && newSid <= num + : newSid > prevSid || newSid <= num; + } + + private static bool Approximately(Vector2 a, Vector2 b, float tolerance = 0.1f) + { + var abs = Vector2.Abs(a - b); + return abs.X <= tolerance && abs.Y <= tolerance; + } + + private ValueTask SnapToAsync(IClientPlayer sender, Vector2 position, ushort minSid) + { + if (!SidGreaterThan(minSid, _lastSequenceId)) + { + return default; + } + + _lastSequenceId = minSid; + return SetPositionAsync(sender, position); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.Api.cs b/src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.Api.cs new file mode 100644 index 0000000..6af54a0 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.Api.cs @@ -0,0 +1,8 @@ +using Impostor.Api.Net.Inner.Objects.Components; + +namespace Impostor.Server.Net.Inner.Objects.Components +{ + internal partial class InnerPlayerPhysics : IInnerPlayerPhysics + { + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.cs b/src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.cs new file mode 100644 index 0000000..11ec2d2 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Components/InnerPlayerPhysics.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Events.Managers; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Events.Player; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects.Components +{ + internal partial class InnerPlayerPhysics : InnerNetObject + { + private readonly ILogger _logger; + private readonly InnerPlayerControl _playerControl; + private readonly IEventManager _eventManager; + + public InnerPlayerPhysics(ICustomMessageManager customMessageManager, Game game, ILogger logger, InnerPlayerControl playerControl, IEventManager eventManager) : base(customMessageManager, game) + { + _logger = logger; + _playerControl = playerControl; + _eventManager = eventManager; + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public override ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + throw new NotImplementedException(); + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + switch (call) + { + case RpcCalls.EnterVent: + case RpcCalls.ExitVent: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateCanVent(call, sender, _playerControl.PlayerInfo)) + { + return false; + } + + int ventId; + + switch (call) + { + case RpcCalls.EnterVent: + Rpc19EnterVent.Deserialize(reader, out ventId); + break; + case RpcCalls.ExitVent: + Rpc20ExitVent.Deserialize(reader, out ventId); + break; + default: + throw new ArgumentOutOfRangeException(nameof(call), call, null); + } + + if (Game.GameNet.ShipStatus == null) + { + if (await sender.Client.ReportCheatAsync(call, CheatCategory.ProtocolExtension, "Client interacted with vent on unknown map")) + { + return false; + } + + break; + } + + if (!Game.GameNet.ShipStatus.Data.Vents.TryGetValue(ventId, out var vent)) + { + if (await sender.Client.ReportCheatAsync(call, CheatCategory.ProtocolExtension, "Client interacted with nonexistent vent")) + { + return false; + } + + break; + } + + switch (call) + { + case RpcCalls.EnterVent: + await _eventManager.CallAsync(new PlayerEnterVentEvent(Game, sender, _playerControl, vent)); + break; + case RpcCalls.ExitVent: + await _eventManager.CallAsync(new PlayerExitVentEvent(Game, sender, _playerControl, vent)); + break; + } + + break; + } + + case RpcCalls.BootFromVent: + { + Rpc34BootFromVent.Deserialize(reader, out var ventId); + break; + } + + case RpcCalls.ClimbLadder: + { + if (!await ValidateOwnership(call, sender)) + { + return false; + } + + Rpc31ClimbLadder.Deserialize(reader, out var ladderId, out var lastClimbLadderSid); + break; + } + + case RpcCalls.Pet: + { + if (!await ValidateOwnership(call, sender)) + { + return false; + } + + Rpc49Pet.Deserialize(reader, out var position, out var petPosition); + break; + } + + case RpcCalls.CancelPet: + { + if (!await ValidateOwnership(call, sender)) + { + return false; + } + + Rpc50CancelPet.Deserialize(reader); + break; + } + + default: + return await base.HandleRpcAsync(sender, target, call, reader); + } + + return true; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Components/InnerVoteBanSystem.cs b/src/Impostor.Server/Net/Inner/Objects/Components/InnerVoteBanSystem.cs new file mode 100644 index 0000000..8b20d73 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Components/InnerVoteBanSystem.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects.Components +{ + internal class InnerVoteBanSystem : InnerNetObject, IInnerVoteBanSystem + { + private readonly ILogger _logger; + private readonly Dictionary _votes; + + public InnerVoteBanSystem(ICustomMessageManager customMessageManager, Game game, ILogger logger) : base(customMessageManager, game) + { + _logger = logger; + _votes = new Dictionary(); + Components.Add(this); + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public override async ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + if (!await ValidateHost(CheatContext.Deserialize, sender)) + { + return; + } + + var votes = _votes; + var unknown = reader.ReadByte(); + if (unknown != 0) + { + for (var i = 0; i < unknown; i++) + { + var v4 = reader.ReadInt32(); + if (v4 == 0) + { + break; + } + + if (!votes.TryGetValue(v4, out var v12)) + { + v12 = new int[3]; + votes[v4] = v12; + } + + for (var j = 0; j < 3; j++) + { + v12[j] = reader.ReadPackedInt32(); + } + } + } + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + if (call == RpcCalls.AddVote) + { + Rpc26AddVote.Deserialize(reader, out var clientId, out var targetClientId); + + if (clientId != sender.Client.Id) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.AddVote, CheatCategory.Ownership, $"Client sent {nameof(RpcCalls.AddVote)} as other client")) + { + return false; + } + } + + return true; + } + + return await base.HandleRpcAsync(sender, target, call, reader); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerGameManager.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerGameManager.cs new file mode 100644 index 0000000..f51b080 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerGameManager.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects.GameManager; +using Impostor.Server.Net.Inner.Objects.GameManager.Logic; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects.GameManager; + +internal abstract class InnerGameManager : InnerNetObject, IInnerGameManager +{ + private readonly ILogger _logger; + private readonly List _logicComponents = new(); + + public InnerGameManager(ICustomMessageManager customMessageManager, Game game, ILogger logger) : base(customMessageManager, game) + { + _logger = logger; + + Components.Add(this); + } + + public LogicGameFlow LogicFlow { get; protected init; } = null!; + + public LogicMinigame LogicMinigame { get; protected init; } = null!; + + public LogicRoleSelection LogicRoleSelection { get; protected init; } = null!; + + public LogicUsables LogicUsables { get; protected init; } = null!; + + public LogicOptions LogicOptions { get; protected init; } = null!; + + protected T AddGameLogic(T logic) + where T : GameLogicComponent + { + _logicComponents.Add(logic); + return logic; + } + + /// + /// Finds the tag of the registered . + /// + /// Instance to search for. + /// Intance type to search for. + /// Tag of the registered , or -1 if not found. + internal int GetGameLogicTag(T logic) + where T : GameLogicComponent + { + return _logicComponents.IndexOf(logic); + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + // Let all logic components process this RPC. If at least one component handles it, return true. + var result = false; + foreach (var logicComponent in _logicComponents) + { + result |= await logicComponent.HandleRpcAsync(call, reader); + } + + // If no component accepted it, try and find a custom RPC that can deal with it. + if (!result) + { + result |= await base.HandleRpcAsync(sender, target, call, reader); + } + + return result; + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new System.NotImplementedException(); + } + + public override async ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + while (reader.Position < reader.Length) + { + var innerReader = reader.ReadMessage(); + var tag = (int)innerReader.Tag; + if (tag < 0 || tag > this._logicComponents.Count) + { + _logger.LogError("Out of bounds in DeserializeAsync of InnerGameManager"); + continue; + } + + var component = this._logicComponents[tag]; + + await component.DeserializeAsync(innerReader, initialState); + + if (innerReader.Position < innerReader.Length) + { + _logger.LogWarning( + "Server did not consume all bytes from {0} component {1} ({2} < {3}).", + nameof(InnerGameManager), + component.GetType().FullName, + innerReader.Position, + innerReader.Length); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.Api.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.Api.cs new file mode 100644 index 0000000..308588e --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.Api.cs @@ -0,0 +1,9 @@ +using Impostor.Api.Net.Inner.Objects.GameManager; +using Impostor.Api.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +namespace Impostor.Server.Net.Inner.Objects.GameManager; + +internal partial class InnerHideAndSeekManager +{ + ILogicGameFlowHnS IInnerHideAndSeekManager.LogicFlowHnS => LogicFlowHnS; +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.cs new file mode 100644 index 0000000..09c9a5a --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerHideAndSeekManager.cs @@ -0,0 +1,38 @@ +using Impostor.Api.Events.Managers; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.GameManager; +using Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects.GameManager; + +internal partial class InnerHideAndSeekManager : InnerGameManager, IInnerHideAndSeekManager +{ + public InnerHideAndSeekManager(ICustomMessageManager customMessageManager, Game game, ILogger logger, IEventManager eventManager) : base(customMessageManager, game, logger) + { + LogicMusic = AddGameLogic(new LogicHnSMusic()); + LogicMinigame = AddGameLogic(new LogicMinigameHnS()); + LogicFlowHnS = new LogicGameFlowHnS(); + LogicFlow = AddGameLogic(LogicFlowHnS); + LogicUsables = AddGameLogic(new LogicUsablesHnS()); + LogicRoleSelection = AddGameLogic(new LogicRoleSelectionHnS()); + LogicOptionsHnS = new LogicOptionsHnS(game, eventManager); + LogicOptions = AddGameLogic(this.LogicOptionsHnS); + LogicDangerLevel = AddGameLogic(new LogicHnSDangerLevel()); + LogicPing = AddGameLogic(new LogicPingsHnS()); + LogicDeathPopup = AddGameLogic(new LogicHnSDeathPopup()); + } + + public LogicHnSMusic LogicMusic { get; private set; } + + public LogicGameFlowHnS LogicFlowHnS { get; private set; } + + public LogicOptionsHnS LogicOptionsHnS { get; private set; } + + public LogicHnSDangerLevel LogicDangerLevel { get; private set; } + + public LogicPingsHnS LogicPing { get; private set; } + + public LogicHnSDeathPopup LogicDeathPopup { get; private set; } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerNormalGameManager.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerNormalGameManager.cs new file mode 100644 index 0000000..e00bac2 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/InnerNormalGameManager.cs @@ -0,0 +1,21 @@ +using Impostor.Api.Events.Managers; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.GameManager; +using Impostor.Server.Net.Inner.Objects.GameManager.Logic; +using Impostor.Server.Net.Inner.Objects.GameManager.Logic.Normal; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects.GameManager; + +internal class InnerNormalGameManager : InnerGameManager, IInnerNormalGameManager +{ + public InnerNormalGameManager(ICustomMessageManager customMessageManager, Game game, ILogger logger, IEventManager eventManager) : base(customMessageManager, game, logger) + { + LogicFlow = AddGameLogic(new LogicGameFlowNormal()); + LogicMinigame = AddGameLogic(new LogicMinigame()); + LogicRoleSelection = AddGameLogic(new LogicRoleSelectionNormal()); + LogicUsables = AddGameLogic(new LogicUsablesBasic()); + LogicOptions = AddGameLogic(new LogicOptionsNormal(game, eventManager)); + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/GameLogicComponent.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/GameLogicComponent.cs new file mode 100644 index 0000000..8ee662f --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/GameLogicComponent.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; +using Impostor.Api.Net.Inner; + +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic; + +internal abstract class GameLogicComponent +{ + public virtual ValueTask HandleRpcAsync(RpcCalls callId, IMessageReader reader) + { + return ValueTask.FromResult(false); + } + + public virtual ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public virtual ValueTask DeserializeAsync(IMessageReader reader, bool initialState) + { + return default; + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicGameFlowHnS.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicGameFlowHnS.cs new file mode 100644 index 0000000..c26bf52 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicGameFlowHnS.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Impostor.Api.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicGameFlowHnS : LogicGameFlow, ILogicGameFlowHnS +{ + public float CurrentFinalHideTime { get; private set; } + + public float CurrentHideTime { get; private set; } + + public override ValueTask DeserializeAsync(IMessageReader reader, bool initialState) + { + var num = reader.ReadSingle(); + + CurrentFinalHideTime = reader.ReadSingle(); + CurrentHideTime = num; + return default; + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDangerLevel.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDangerLevel.cs new file mode 100644 index 0000000..ba4fc85 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDangerLevel.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicHnSDangerLevel : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDeathPopup.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDeathPopup.cs new file mode 100644 index 0000000..b624c96 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSDeathPopup.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicHnSDeathPopup : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSMusic.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSMusic.cs new file mode 100644 index 0000000..e0f0a33 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicHnSMusic.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicHnSMusic : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicMinigameHnS.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicMinigameHnS.cs new file mode 100644 index 0000000..1e44fad --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicMinigameHnS.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicMinigameHnS : LogicMinigame +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicOptionsHnS.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicOptionsHnS.cs new file mode 100644 index 0000000..2d588d9 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicOptionsHnS.cs @@ -0,0 +1,11 @@ +using Impostor.Api.Events.Managers; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicOptionsHnS : LogicOptions +{ + public LogicOptionsHnS(Game game, IEventManager eventManager) : base(game, eventManager) + { + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicPingsHnS.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicPingsHnS.cs new file mode 100644 index 0000000..ff60e30 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicPingsHnS.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicPingsHnS : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicRoleSelectionHnS.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicRoleSelectionHnS.cs new file mode 100644 index 0000000..2acd488 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicRoleSelectionHnS.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicRoleSelectionHnS : LogicRoleSelection +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicUsablesHnS.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicUsablesHnS.cs new file mode 100644 index 0000000..3022cdc --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/HideAndSeek/LogicUsablesHnS.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.HideAndSeek; + +internal class LogicUsablesHnS : LogicUsables +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicGameFlow.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicGameFlow.cs new file mode 100644 index 0000000..2b408e8 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicGameFlow.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic; + +internal abstract class LogicGameFlow : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicMinigame.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicMinigame.cs new file mode 100644 index 0000000..7fd1e3b --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicMinigame.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic; + +internal class LogicMinigame : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicOptions.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicOptions.cs new file mode 100644 index 0000000..2622e52 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicOptions.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Impostor.Api.Events.Managers; +using Impostor.Api.Innersloth.GameOptions; +using Impostor.Server.Events; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic; + +internal abstract class LogicOptions : GameLogicComponent +{ + private readonly Game _game; + private readonly IEventManager _eventManager; + + protected LogicOptions(Game game, IEventManager eventManager) + { + _game = game; + _eventManager = eventManager; + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + GameOptionsFactory.Serialize(writer, _game.Options); + return ValueTask.FromResult(true); + } + + public override async ValueTask DeserializeAsync(IMessageReader reader, bool initialState) + { + GameOptionsFactory.DeserializeInto(reader, _game.Options); + await _eventManager.CallAsync(new GameOptionsChangedEvent( + _game, + Api.Events.IGameOptionsChangedEvent.ChangeReason.Host + )); + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicRoleSelection.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicRoleSelection.cs new file mode 100644 index 0000000..5674c73 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicRoleSelection.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic; + +internal abstract class LogicRoleSelection : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicUsables.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicUsables.cs new file mode 100644 index 0000000..1a60f09 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/LogicUsables.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic; + +internal abstract class LogicUsables : GameLogicComponent +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicGameFlowNormal.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicGameFlowNormal.cs new file mode 100644 index 0000000..2be3c7f --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicGameFlowNormal.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.Normal; + +internal class LogicGameFlowNormal : LogicGameFlow +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicOptionsNormal.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicOptionsNormal.cs new file mode 100644 index 0000000..88c0f22 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicOptionsNormal.cs @@ -0,0 +1,11 @@ +using Impostor.Api.Events.Managers; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.Normal; + +internal class LogicOptionsNormal : LogicOptions +{ + public LogicOptionsNormal(Game game, IEventManager eventManager) : base(game, eventManager) + { + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicRoleSelectionNormal.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicRoleSelectionNormal.cs new file mode 100644 index 0000000..855f757 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicRoleSelectionNormal.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.Normal; + +internal class LogicRoleSelectionNormal : LogicRoleSelection +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicUsablesBasic.cs b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicUsablesBasic.cs new file mode 100644 index 0000000..2d159a7 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/GameManager/Logic/Normal/LogicUsablesBasic.cs @@ -0,0 +1,5 @@ +namespace Impostor.Server.Net.Inner.Objects.GameManager.Logic.Normal; + +internal class LogicUsablesBasic : LogicUsables +{ +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerGameData.cs b/src/Impostor.Server/Net/Inner/Objects/InnerGameData.cs new file mode 100644 index 0000000..b1796f1 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerGameData.cs @@ -0,0 +1,57 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using Impostor.Api.Net.Inner.Objects; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerGameData : IInnerGameData + { + private readonly ConcurrentDictionary _allPlayers = new(); + private readonly ConcurrentDictionary _allPlayersByClientId = new(); + + public int PlayerCount => _allPlayers.Count; + + public IReadOnlyDictionary Players => _allPlayers; + + internal IReadOnlyDictionary PlayersByClientId => _allPlayersByClientId; + + public InnerPlayerInfo? GetPlayerById(byte id) + { + if (id == byte.MaxValue) + { + return null; + } + + return _allPlayers.TryGetValue(id, out var player) ? player : null; + } + + internal bool AddPlayer(InnerPlayerInfo playerInfo) + { + return _allPlayers.TryAdd(playerInfo.PlayerId, playerInfo) && + _allPlayersByClientId.TryAdd(playerInfo.ClientId, playerInfo); + } + + internal void RemovePlayer(byte playerId) + { + _allPlayers.TryRemove(playerId, out var player); + + if (player != null) + { + _allPlayersByClientId.TryRemove(player.ClientId, out _); + } + } + + internal byte GetNextAvailablePlayerId() + { + for (byte i = 0; i < byte.MaxValue; i++) + { + if (!Players.ContainsKey(i)) + { + return i; + } + } + + return byte.MaxValue; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerLobbyBehaviour.cs b/src/Impostor.Server/Net/Inner/Objects/InnerLobbyBehaviour.cs new file mode 100644 index 0000000..b64d577 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerLobbyBehaviour.cs @@ -0,0 +1,27 @@ +using System; +using System.Threading.Tasks; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal class InnerLobbyBehaviour : InnerNetObject, IInnerLobbyBehaviour + { + public InnerLobbyBehaviour(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game) + { + Components.Add(this); + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public override ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.Api.cs b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.Api.cs new file mode 100644 index 0000000..8b1528d --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.Api.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using Impostor.Api.Net.Inner.Objects; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerMeetingHud : IInnerMeetingHud + { + IReadOnlyCollection IInnerMeetingHud.PlayerStates => Array.AsReadOnly(_playerStates); + + IInnerPlayerInfo? IInnerMeetingHud.Reporter => Reporter; + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.PlayerVoteArea.cs b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.PlayerVoteArea.cs new file mode 100644 index 0000000..2e9f176 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.PlayerVoteArea.cs @@ -0,0 +1,83 @@ +using Impostor.Api.Events.Player; +using Impostor.Api.Net.Inner.Objects; +using TVoteType = Impostor.Api.Events.Player.VoteType; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerMeetingHud + { + public class PlayerVoteArea : IInnerMeetingHud.IPlayerVoteArea + { + private byte _votedForId; + + public PlayerVoteArea(InnerMeetingHud parent, InnerPlayerInfo targetPlayer, bool isDead) + { + Parent = parent; + TargetPlayer = targetPlayer; + + VotedForId = (byte)(isDead ? TVoteType.Dead : TVoteType.HasNotVoted); + } + + public InnerMeetingHud Parent { get; } + + public InnerPlayerInfo TargetPlayer { get; } + + public bool IsDead => VoteType == TVoteType.Dead; + + public bool DidVote => VoteType != TVoteType.HasNotVoted; + + public bool DidReport { get; private set; } + + public byte VotedForId + { + get => _votedForId; + + private set + { + _votedForId = value; + + switch ((VoteType)value) + { + case TVoteType.Dead: + VoteType = TVoteType.Dead; + break; + + case TVoteType.HasNotVoted: + case TVoteType.Missed: + case TVoteType.Skipped: + VoteType = (VoteType)value; + break; + + default: + VoteType = TVoteType.Player; + VotedFor = Parent.Game.GameNet.GameData!.GetPlayerById(value)?.Controller; + break; + } + } + } + + public VoteType? VoteType { get; private set; } + + public IInnerPlayerControl? VotedFor { get; private set; } + + IInnerPlayerInfo IInnerMeetingHud.IPlayerVoteArea.TargetPlayer => TargetPlayer; + + internal void Deserialize(IMessageReader reader, bool updateVote) + { + var votedForId = reader.ReadByte(); + + if (updateVote) + { + VotedForId = votedForId; + } + + DidReport = reader.ReadBoolean(); + } + + internal void SetVotedFor(byte votedFor) + { + VotedForId = votedFor; + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.VoterState.cs b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.VoterState.cs new file mode 100644 index 0000000..934ea2c --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.VoterState.cs @@ -0,0 +1,30 @@ +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerMeetingHud + { + public class VoterState + { + public VoterState(byte voterId, byte votedForId) + { + VoterId = voterId; + VotedForId = votedForId; + } + + public byte VoterId { get; } + + public byte VotedForId { get; } + + public static VoterState Deserialize(IMessageReader reader) + { + return new VoterState(reader.Tag, reader.ReadByte()); + } + + public void Serialize(IMessageWriter writer) + { + writer.StartMessage(this.VoterId); + writer.Write(this.VotedForId); + writer.EndMessage(); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs new file mode 100644 index 0000000..fba672d --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Events.Managers; +using Impostor.Api.Events.Player; +using Impostor.Api.Innersloth.GameOptions; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Events.Meeting; +using Impostor.Server.Events.Player; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerMeetingHud : InnerNetObject + { + private readonly ILogger _logger; + private readonly IEventManager _eventManager; + + private readonly CancellationTokenSource _timerToken; + + [AllowNull] + private PlayerVoteArea[] _playerStates; + + public InnerMeetingHud(ICustomMessageManager customMessageManager, Game game, ILogger logger, IEventManager eventManager) : base(customMessageManager, game) + { + _logger = logger; + _eventManager = eventManager; + _playerStates = null; + + Components.Add(this); + + _timerToken = new CancellationTokenSource(); + _ = Task.Run(async () => + { + try + { + const float AnimationTime = 0.25f + 0.5f + 0.4f + 3f + 0.75f + 5f; + if (Game.Options.GameMode is GameModes.Normal or GameModes.NormalFools) + { + var options = (NormalGameOptions)Game.Options; + await Task.Delay(TimeSpan.FromSeconds(AnimationTime + options.DiscussionTime + options.VotingTime), _timerToken.Token); + } + else + { + throw new NotImplementedException(); + } + } + catch (TaskCanceledException) + { + return; + } + + await HandleVotingCompleteAsync(); + }); + } + + public InnerPlayerInfo? Reporter { get; private set; } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public override async ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + if (!await ValidateHost(CheatContext.Deserialize, sender) || !await ValidateBroadcast(CheatContext.Deserialize, sender, target)) + { + return; + } + + if (initialState) + { + PopulateButtons(); + } + + var length = reader.ReadPackedUInt32(); + + for (var i = 0; i < length; i++) + { + var inner = reader.ReadMessage(); + var playerVoteArea = _playerStates.SingleOrDefault(x => x.TargetPlayer.PlayerId == inner.Tag); + + if (playerVoteArea != null) + { + var clientPlayer = Game.Players.SingleOrDefault(x => x.Character?.PlayerId == playerVoteArea.TargetPlayer.PlayerId); + var updateVote = !playerVoteArea.DidVote && (clientPlayer?.IsHost ?? false) && playerVoteArea.VoteType != VoteType.Missed; + + playerVoteArea.Deserialize(inner, updateVote); + + if (updateVote) + { + await HandleVoteAsync(playerVoteArea); + await CheckForEndVotingAsync(); + } + + if (initialState && playerVoteArea.DidReport) + { + Reporter = playerVoteArea.TargetPlayer; + } + } + } + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + switch (call) + { + case RpcCalls.CloseMeeting: + { + if (!await ValidateHost(call, sender)) + { + return false; + } + + Rpc22Close.Deserialize(reader); + break; + } + + case RpcCalls.VotingComplete: + { + if (!await ValidateHost(call, sender)) + { + return false; + } + + Rpc23VotingComplete.Deserialize(reader, out var states, out var playerId, out var tie); + foreach (var messageReader in states) + { + messageReader.Dispose(); + } + + // This would be a nice place to implement an anti cheat. + // But for whatever reason host sends VotingComplete before sending his vote. + // Also every client executes his own VotingComplete after other client players CastVote rpc, like wtf. + break; + } + + case RpcCalls.CastVote: + { + Rpc24CastVote.Deserialize(reader, out var playerId, out var suspectPlayerId); + return await HandleCastVoteAsync(sender, target, playerId, suspectPlayerId); + } + + case RpcCalls.ClearVote: + { + if (!await ValidateHost(call, sender)) + { + return false; + } + + Rpc25ClearVote.Deserialize(reader); + break; + } + + default: + return await base.HandleRpcAsync(sender, target, call, reader); + } + + return true; + } + + private void PopulateButtons() + { + _playerStates = Game.GameNet.GameData!.Players.Values + .OrderBy(x => x.Controller?.NetId) // The host player hold MeetingHud players list sorted by NetId + .Select(x => new PlayerVoteArea(this, x, x.Disconnected || x.IsDead)) + .ToArray(); + } + + private async ValueTask HandleVoteAsync(PlayerVoteArea playerState) + { + if (playerState.DidVote && !playerState.IsDead) + { + var player = playerState.TargetPlayer.Controller!; + await _eventManager.CallAsync(new PlayerVotedEvent(Game, Game.GetClientPlayer(player!.OwnerId)!, player, playerState.VoteType!.Value, playerState.VotedFor)); + } + } + + private async ValueTask HandleCastVoteAsync(ClientPlayer sender, ClientPlayer? target, byte playerId, byte suspectPlayerId) + { + if (sender.IsHost) + { + if (!await ValidateBroadcast(RpcCalls.CastVote, sender, target)) + { + return false; + } + } + else + { + if (!await ValidateCmd(RpcCalls.CastVote, sender, target)) + { + return false; + } + } + + if (playerId != sender.Character!.PlayerId) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CastVote, CheatCategory.Ownership, $"Client sent {nameof(RpcCalls.CastVote)} to an unowned {nameof(InnerPlayerControl)}")) + { + return false; + } + } + + if (!sender.IsHost) + { + var playerVoteArea = _playerStates.Single(x => x.TargetPlayer.PlayerId == playerId); + playerVoteArea.SetVotedFor(suspectPlayerId); + await HandleVoteAsync(playerVoteArea); + await CheckForEndVotingAsync(); + } + + return true; + } + + private async ValueTask CheckForEndVotingAsync() + { + if (_playerStates.All(ps => ps.IsDead || ps.DidVote)) + { + await HandleVotingCompleteAsync(); + } + } + + private KeyValuePair MaxPair(Dictionary self, out bool tie) + { + tie = true; + var result = new KeyValuePair(byte.MaxValue, int.MinValue); + foreach (var keyValuePair in self) + { + if (keyValuePair.Value > result.Value) + { + result = keyValuePair; + tie = false; + } + else if (keyValuePair.Value == result.Value) + { + tie = true; + } + } + + return result; + } + + private Dictionary CalculateVotes() + { + var players = new Dictionary(); + foreach (var playerVoteArea in _playerStates) + { + if (!playerVoteArea.IsDead && playerVoteArea.DidVote && playerVoteArea.VoteType != VoteType.Missed) + { + if (players.TryGetValue(playerVoteArea.VotedForId, out var current)) + { + players[playerVoteArea.VotedForId] = current + 1; + } + else + { + players[playerVoteArea.VotedForId] = 1; + } + } + } + + return players; + } + + private async ValueTask HandleVotingCompleteAsync() + { + _timerToken.Cancel(); + + foreach (var playerVoteArea in _playerStates) + { + if (!playerVoteArea.DidVote) + { + playerVoteArea.SetVotedFor((byte)VoteType.Missed); + await HandleVoteAsync(playerVoteArea); + } + } + + var self = this.CalculateVotes(); + var max = MaxPair(self, out var tie); + var exiled = tie ? null : Game.GameNet.GameData!.GetPlayerById(max.Key)?.Controller; + + if (exiled != null && exiled.PlayerInfo != null) + { + exiled.PlayerInfo.LastDeathReason = DeathReason.Exile; + await _eventManager.CallAsync(new PlayerExileEvent(Game, Game.GetClientPlayer(exiled!.OwnerId)!, exiled)); + } + + await _eventManager.CallAsync(new MeetingEndedEvent(Game, this, exiled, tie)); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs new file mode 100644 index 0000000..6b94a9e --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs @@ -0,0 +1,267 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Innersloth.Customization; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Api.Net.Inner.Objects.Components; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Events.Player; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerPlayerControl : IInnerPlayerControl + { + IInnerPlayerPhysics IInnerPlayerControl.Physics => Physics; + + IInnerCustomNetworkTransform IInnerPlayerControl.NetworkTransform => NetworkTransform; + + IInnerPlayerInfo? IInnerPlayerControl.PlayerInfo => PlayerInfo; + + public async ValueTask SetNameAsync(string name) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set name, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.PlayerName = name; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetName); + Rpc06SetName.Serialize(writer, PlayerInfo.NetId, name); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SetColorAsync(ColorType color) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set color, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.Color = color; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetColor); + Rpc08SetColor.Serialize(writer, PlayerInfo.NetId, color); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SetHatAsync(string hatId) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set hat, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.HatId = hatId; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetHatStr); + Rpc39SetHatStr.Serialize(writer, hatId, PlayerInfo.GetNextRpcSequenceId(RpcCalls.SetHatStr)); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SetPetAsync(string petId) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set pet, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.PetId = petId; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetPetStr); + Rpc41SetPetStr.Serialize(writer, petId, PlayerInfo.GetNextRpcSequenceId(RpcCalls.SetPetStr)); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SetSkinAsync(string skinId) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set skin, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.SkinId = skinId; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetSkinStr); + Rpc40SetSkinStr.Serialize(writer, skinId, PlayerInfo.GetNextRpcSequenceId(RpcCalls.SetSkinStr)); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SetVisorAsync(string visorId) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set visor, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.VisorId = visorId; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetVisorStr); + Rpc42SetVisorStr.Serialize(writer, visorId, PlayerInfo.GetNextRpcSequenceId(RpcCalls.SetVisorStr)); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SetNamePlateAsync(string nameplateId) + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set nameplate, PlayerInfo is null"); + } + + PlayerInfo.CurrentOutfit.NamePlateId = nameplateId; + + using var writer = Game.StartRpc(NetId, RpcCalls.SetNamePlateStr); + Rpc43SetNamePlateStr.Serialize(writer, nameplateId, PlayerInfo.GetNextRpcSequenceId(RpcCalls.SetNamePlateStr)); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SendChatAsync(string text) + { + using var writer = Game.StartRpc(NetId, RpcCalls.SendChat); + writer.Write(text); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask SendChatToPlayerAsync(string text, IInnerPlayerControl? player = null) + { + if (player == null) + { + player = this; + } + + using var writer = Game.StartRpc(NetId, RpcCalls.SendChat); + writer.Write(text); + await Game.FinishRpcAsync(writer, player.OwnerId); + } + + private bool ValidateMurderPlayer(IInnerPlayerControl target, MurderResultFlags result, [NotNullWhen(false)] out string? invalidReason) + { + if (PlayerInfo == null) + { + invalidReason = "Tried to murder a player, but the murderer didn't have a playerinfo"; + } + else if (!PlayerInfo.IsImpostor) + { + invalidReason = "Tried to murder a player, but murderer was not an impostor."; + } + else if (PlayerInfo.IsDead) + { + invalidReason = "Tried to murder a player, but murderer was not alive."; + } + else if (target.PlayerInfo == null) + { + invalidReason = "Tried to murder a player, but the murderer didn't have a playerinfo"; + } + else if (target.PlayerInfo.IsImpostor) + { + invalidReason = "Tried to murder a player, but target is an impostor"; + } + else if (target.PlayerInfo.IsDead) + { + invalidReason = "Tried to murder a player, but target was not alive."; + } + else + { + invalidReason = null; + return true; + } + + return false; + } + + public async ValueTask MurderPlayerAsync(IInnerPlayerControl target, MurderResultFlags result) + { + if (!ValidateMurderPlayer(target, result, out var reason)) + { + throw new ImpostorProtocolException(reason); + } + + await ForceMurderPlayerAsync(target, result); + } + + public async ValueTask ForceMurderPlayerAsync(IInnerPlayerControl target, MurderResultFlags result) + { + if (!result.IsFailed()) + { + ((InnerPlayerControl)target).Die(DeathReason.Kill); + } + + using var writer = Game.StartRpc(NetId, RpcCalls.MurderPlayer); + Rpc12MurderPlayer.Serialize(writer, target, result); + await Game.FinishRpcAsync(writer); + + await _eventManager.CallAsync(new PlayerMurderEvent(Game, Game.GetClientPlayer(OwnerId)!, this, target, result)); + } + + public async ValueTask MurderPlayerAsync(IInnerPlayerControl target) + { + await MurderPlayerAsync(target, MurderResultFlags.Succeeded); + } + + public async ValueTask ProtectPlayerAsync(IInnerPlayerControl target) + { + if (target.PlayerInfo == null) + { + throw new ImpostorProtocolException("Tried to exile a player, but target didn't have a playerinfo"); + } + else if (target.PlayerInfo.IsDead) + { + throw new ImpostorProtocolException("Tried to protect a player that is dead"); + } + + await ForceProtectPlayerAsync(target); + } + + public async ValueTask ForceProtectPlayerAsync(IInnerPlayerControl target) + { + ((InnerPlayerControl)target).Protect(this); + + using var writer = Game.StartRpc(NetId, RpcCalls.ProtectPlayer); + Rpc45ProtectPlayer.Serialize(writer, target, PlayerInfo?.CurrentOutfit.Color ?? ColorType.Red); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask ExileAsync() + { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Tried to exile a player, but target didn't have a playerinfo"); + } + else if (PlayerInfo.IsDead) + { + throw new ImpostorProtocolException("Tried to exile a player, but target was not alive."); + } + + await ForceExileAsync(); + } + + public async ValueTask ForceExileAsync() + { + // Update player. + Die(DeathReason.Exile); + + // Send RPC. + using var writer = Game.StartRpc(NetId, RpcCalls.Exiled); + Rpc04Exiled.Serialize(writer); + await Game.FinishRpcAsync(writer); + + // Notify plugins. + await _eventManager.CallAsync(new PlayerExileEvent(Game, Game.GetClientPlayer(OwnerId)!, this)); + } + + public async ValueTask StartVanishAsync() + { + using var writer = Game.StartRpc(NetId, RpcCalls.StartVanish); + Rpc63StartVanish.Serialize(writer); + await Game.FinishRpcAsync(writer); + } + + public async ValueTask StartAppearAsync(bool shouldAnimate) + { + using var writer = Game.StartRpc(NetId, RpcCalls.StartAppear); + Rpc65StartAppear.Serialize(writer, shouldAnimate); + await Game.FinishRpcAsync(writer); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs new file mode 100644 index 0000000..2e30e25 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs @@ -0,0 +1,1293 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Events.Managers; +using Impostor.Api.Innersloth.Customization; +using Impostor.Api.Innersloth.GameOptions; +using Impostor.Api.Innersloth.GameOptions.RoleOptions; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Api.Utils; +using Impostor.Server.Events.Player; +using Impostor.Server.Net.Inner.Objects.Components; +using Impostor.Server.Net.State; +using Microsoft.Extensions.DependencyInjection; +using Impostor.Server.Commands; +using Microsoft.Extensions.Logging; +using Impostor.Api.Commands; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerPlayerControl : InnerNetObject + { + private static readonly byte ColorsCount = (byte)Enum.GetValues().Length; + private readonly Game _game; + private readonly ILogger _logger; + private readonly IEventManager _eventManager; + private readonly IDateTimeProvider _dateTimeProvider; + private readonly CommandService _commandService; + + public InnerPlayerControl(ICustomMessageManager customMessageManager, Game game, ILogger logger, IServiceProvider serviceProvider, IEventManager eventManager, IDateTimeProvider dateTimeProvider) : base(customMessageManager, game) + { + _game = game; + _logger = logger; + _eventManager = eventManager; + _dateTimeProvider = dateTimeProvider; + _commandService = serviceProvider.GetRequiredService(); + Physics = ActivatorUtilities.CreateInstance(serviceProvider, this, _eventManager, game); + NetworkTransform = ActivatorUtilities.CreateInstance(serviceProvider, this, game); + Components.Add(this); + Components.Add(Physics); + Components.Add(NetworkTransform); + PlayerId = byte.MaxValue; + } + + public bool IsNew { get; private set; } + + public byte PlayerId { get; private set; } + + public InnerPlayerPhysics Physics { get; } + + public InnerCustomNetworkTransform NetworkTransform { get; } + + public InnerPlayerInfo? PlayerInfo { get; internal set; } + + internal System.Collections.Concurrent.ConcurrentQueue RequestedPlayerName { get; } = new(); + + internal System.Collections.Concurrent.ConcurrentQueue RequestedColorId { get; } = new(); + + internal IInnerPlayerControl? IsMurdering { get; set; } = null; + + internal DateTimeOffset? ProtectedOn { get; set; } + + internal IInnerPlayerControl? ProtectedBy { get; set; } + + internal bool IsProtected + { + get + { + if (Game.Options is NormalGameOptions normalGameOptions && ProtectedOn != null) + { + var guardianAngelOptions = (GuardianAngelRoleOptions)normalGameOptions.RoleOptions.Roles[RoleTypes.GuardianAngel].RoleOptions; + var protectionExpiresAt = ProtectedOn.Value.AddSeconds(guardianAngelOptions.ProtectionDurationSeconds); + return protectionExpiresAt >= _dateTimeProvider.UtcNow; + } + + return false; + } + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public override async ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + if (!await ValidateHost(CheatContext.Deserialize, sender)) + { + return; + } + + if (initialState) + { + IsNew = reader.ReadBoolean(); + } + + PlayerId = reader.ReadByte(); + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + switch (call) + { + case RpcCalls.PlayAnimation: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc00PlayAnimation.Deserialize(reader, out var task); + break; + } + + case RpcCalls.CompleteTask: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc01CompleteTask.Deserialize(reader, out var taskId); + await HandleCompleteTask(sender, taskId); + break; + } + + case RpcCalls.SyncSettings: + { + if (!await ValidateHost(call, sender)) + { + return false; + } + + break; + } + + case RpcCalls.CheckName: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc05CheckName.Deserialize(reader, out var name); + return await HandleCheckName(sender, name); + } + + case RpcCalls.SetName: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc06SetName.Deserialize(reader, out var _, out var name); + return await HandleSetName(sender, name); + } + + case RpcCalls.CheckColor: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc07CheckColor.Deserialize(reader, out var color); + return await HandleCheckColor(sender, color); + } + + case RpcCalls.SetColor: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc08SetColor.Deserialize(reader, out var _, out var color); + return await HandleSetColor(sender, color); + } + + case RpcCalls.SetHatStr: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc39SetHatStr.Deserialize(reader, out var hat, out var nextRpcSequenceId); + return await HandleSetHat(sender, hat, nextRpcSequenceId); + } + + case RpcCalls.SetSkinStr: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc40SetSkinStr.Deserialize(reader, out var skin, out var nextRpcSequenceId); + return await HandleSetSkin(sender, skin, nextRpcSequenceId); + } + + case RpcCalls.SetVisorStr: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc42SetVisorStr.Deserialize(reader, out var visor, out var nextRpcSequenceId); + return await HandleSetVisor(sender, visor, nextRpcSequenceId); + } + + case RpcCalls.SetNamePlateStr: + { + if (!await ValidateOwnership(call, sender)) + { + return false; + } + + Rpc43SetNamePlateStr.Deserialize(reader, out var namePlate, out var nextRpcSequenceId); + return await HandleSetNamePlate(sender, namePlate, nextRpcSequenceId); + } + + case RpcCalls.SetLevel: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc38SetLevel.Deserialize(reader, out var level); + return await HandleSetLevel(sender, level); + } + + case RpcCalls.ReportDeadBody: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc11ReportDeadBody.Deserialize(reader, out var targetId); + break; + } + + case RpcCalls.MurderPlayer: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc12MurderPlayer.Deserialize(reader, Game, out var murdered, out var result); + return await HandleMurderPlayer(sender, (InnerPlayerControl?)murdered, result); + } + + case RpcCalls.SendChat: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc13SendChat.Deserialize(reader, out var message); + return await HandleSendChat(sender, message); + } + + case RpcCalls.StartMeeting: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc14StartMeeting.Deserialize(reader, out var targetId); + await HandleStartMeeting(targetId); + break; + } + + case RpcCalls.SetScanner: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc15SetScanner.Deserialize(reader, out var on, out var scannerCount); + break; + } + + case RpcCalls.SendChatNote: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc16SendChatNote.Deserialize(reader, out var playerId, out var chatNoteType); + break; + } + + case RpcCalls.SetPetStr: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc41SetPetStr.Deserialize(reader, out var pet, out var nextRpcSequenceId); + return await HandleSetPet(sender, pet, nextRpcSequenceId); + } + + case RpcCalls.SetStartCounter: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc18SetStartCounter.Deserialize(reader, out var sequenceId, out var startCounter); + return await HandleSetStartCounter(sender, sequenceId, startCounter); + } + + case RpcCalls.UsePlatform: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc32UsePlatform.Deserialize(reader); + break; + } + + case RpcCalls.SendQuickChat: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + break; + } + + case RpcCalls.SetRole: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc44SetRole.Deserialize(reader, out var role, out var _); + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetRole, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + if (role is RoleTypes.ImpostorGhost or RoleTypes.CrewmateGhost or RoleTypes.GuardianAngel) + { + PlayerInfo.RoleWhenAlive = PlayerInfo.RoleType; + PlayerInfo.IsDead = true; + } + + PlayerInfo.RoleType = role; + } + + if (Game.GameState == GameStates.Starting && Game.Players.All(clientPlayer => clientPlayer.Character?.PlayerInfo?.RoleType != null)) + { + await Game.StartedAsync(); + } + + break; + } + + case RpcCalls.ProtectPlayer: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc45ProtectPlayer.Deserialize(reader, Game, out var protectTarget, out _); + return await HandleProtectPlayer(sender, protectTarget); + } + + case RpcCalls.Shapeshift: + { + if (!await ValidateHost(call, sender) || + !await ValidateRole(call, sender, PlayerInfo, RoleTypes.Shapeshifter) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc46Shapeshift.Deserialize(reader, Game, out _, out _); + break; + } + + case RpcCalls.CheckMurder: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateImpostor(call, sender, PlayerInfo) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc47CheckMurder.Deserialize(reader, Game, out var murdered); + return await HandleCheckMurder(sender, (InnerPlayerControl?)murdered); + } + + case RpcCalls.CheckProtect: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateRole(call, sender, PlayerInfo, RoleTypes.GuardianAngel) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc48CheckProtect.Deserialize(reader, Game, out _); + break; + } + + case RpcCalls.CheckZipline: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc51CheckZipline.Deserialize(reader, out var fromTop); + break; + } + + case RpcCalls.UseZipline: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc52UseZipline.Deserialize(reader, out var fromTop); + break; + } + + case RpcCalls.TriggerSpores: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc53TriggerSpores.Deserialize(reader, out var mushroomId); + break; + } + + case RpcCalls.CheckSpore: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc54CheckSpore.Deserialize(reader, out var mushroomId); + break; + } + + case RpcCalls.CheckShapeshift: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateRole(call, sender, PlayerInfo, RoleTypes.Shapeshifter) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc55CheckShapeshift.Deserialize(reader, Game, out var playerControl, out _); + break; + } + + case RpcCalls.RejectShapeshift: + { + if (!await ValidateHost(call, sender)) + { + return false; + } + + Rpc56RejectShapeshift.Deserialize(reader); + break; + } + + case RpcCalls.CheckVanish: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateRole(call, sender, PlayerInfo, RoleTypes.Phantom) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc62CheckVanish.Deserialize(reader, out var maxDuration); + return await HandleCheckVanish(sender, maxDuration); + } + + case RpcCalls.StartVanish: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc63StartVanish.Deserialize(reader); + return await HandleStartVanish(sender); + } + + case RpcCalls.CheckAppear: + { + if (!await ValidateOwnership(call, sender) || + !await ValidateRole(call, sender, PlayerInfo, RoleTypes.Phantom) || + !await ValidateCmd(call, sender, target)) + { + return false; + } + + Rpc64CheckAppear.Deserialize(reader, out var shouldAnimate); + return await HandleCheckAppear(sender, shouldAnimate); + } + + case RpcCalls.StartAppear: + { + if (!await ValidateHost(call, sender) || + !await ValidateBroadcast(call, sender, target)) + { + return false; + } + + Rpc65StartAppear.Deserialize(reader, out var shouldAnimate); + return await HandleStartAppear(sender, shouldAnimate); + } + + default: + return await base.HandleRpcAsync(sender, target, call, reader); + } + + return true; + } + + internal void Die(DeathReason reason) + { + if (PlayerInfo == null) + { + if (!Game.IsHostAuthoritive) + { + _logger.LogWarning("Tried to kill player that didn't have a PlayerInfo set, this shouldn't happen"); + } + } + else + { + PlayerInfo.IsDead = true; + PlayerInfo.LastDeathReason = reason; + } + } + + internal void Protect(InnerPlayerControl guardianAngel) + { + ProtectedOn = _dateTimeProvider.UtcNow; + ProtectedBy = guardianAngel; + } + + private async ValueTask HandleCompleteTask(ClientPlayer sender, uint taskId) + { + TaskInfo? task = null; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CompleteTask, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return; + } + } + else + { + task = PlayerInfo.Tasks.ElementAtOrDefault((int)taskId); + } + + if (task != null) + { + task.Complete = true; + await _eventManager.CallAsync(new PlayerCompletedTaskEvent(Game, sender, this, task)); + } + else + { + _logger.LogWarning($"Client sent {nameof(RpcCalls.CompleteTask)} with a taskIndex that is not in their {nameof(InnerPlayerInfo)}"); + } + } + + private async ValueTask HandleCheckName(ClientPlayer sender, string name) + { + if (Game.GameState == GameStates.Started) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckName, CheatCategory.GameFlow, "Client tried to set a name midgame")) + { + return false; + } + } + + if (name.Length > 10) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckName, CheatCategory.NameLimits, "Client sent name exceeding 10 characters")) + { + return false; + } + } + + if (string.IsNullOrWhiteSpace(name) || !name.All(TextBox.IsCharAllowed)) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckName, CheatCategory.NameLimits, "Client sent name containing illegal characters")) + { + return false; + } + } + + if (sender.Client.Name != name) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckName, CheatCategory.NameLimits, "Client sent name not matching his name from handshake")) + { + return false; + } + } + + RequestedPlayerName.Enqueue(name); + return true; + } + + private async ValueTask HandleSetName(ClientPlayer sender, string name) + { + if (Game.GameState == GameStates.Started) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.GameFlow, "Client tried to set a name midgame")) + { + return false; + } + } + + if (sender.IsOwner(this)) + { + if (Game.Players.Any(x => x.Character != null && + x.Character != this && + x.Character.PlayerInfo != null && + x.Character.PlayerInfo.PlayerName == name)) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.NameLimits, "Client sent name that is already used")) + { + return false; + } + } + + if (sender.Client.Name != name) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.NameLimits, "Client sent name not matching his name from handshake")) + { + return false; + } + } + } + else + { + if (RequestedPlayerName.TryDequeue(out var expected)) + { + var requested = expected; + if (Game.Players.Any(x => x.Character != null && + x.Character != this && + x.Character.PlayerInfo != null && + x.Character.PlayerInfo.PlayerName == expected)) + { + var i = 1; + while (true) + { + var text = expected + " " + i; + if (Game.Players.All(x => x.Character == null || + x.Character == this || + x.Character.PlayerInfo == null || + x.Character.PlayerInfo.PlayerName != text)) + { + expected = text; + break; + } + + i++; + } + } + + if (name != expected) + { + if (await sender.Client.ReportCheatAsync( + RpcCalls.SetName, + CheatCategory.NameLimits, + $"Client sent SetName with incorrect name, got '{name}', requested '{requested}', expected '{expected}'")) + { + await SetNameAsync(expected); + return false; + } + } + } + else + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.NameLimits, $"Client sent {nameof(RpcCalls.SetName)} for a player that didn't request it")) + { + return false; + } + } + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.PlayerName = name; + } + + return true; + } + + private async ValueTask HandleCheckColor(ClientPlayer sender, ColorType color) + { + if (Game.GameState == GameStates.Started) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckColor, CheatCategory.GameFlow, "Client tried to ask for a color midgame")) + { + return false; + } + } + + if ((byte)color >= ColorsCount) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckColor, CheatCategory.ProtocolExtension, "Client sent unknown color")) + { + return false; + } + } + + RequestedColorId.Enqueue(color); + return true; + } + + private async ValueTask HandleSetColor(ClientPlayer sender, ColorType color) + { + if (Game.GameState == GameStates.Started) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.GameFlow, "Client tried to set a color midgame")) + { + return false; + } + } + + if ((byte)color >= ColorsCount) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.ProtocolExtension, "Client sent unknown color")) + { + return false; + } + } + + if (sender.IsOwner(this)) + { + if (Game.IsColorUsed(color, this)) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.ColorLimits, "Client sent a color that is already used")) + { + return false; + } + } + } + else + { + if (RequestedColorId.TryDequeue(out var requested)) + { + if (Game.IsColorUsed(color, this)) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.ColorLimits, "Client selected a color that is already used")) + { + return false; + } + } + + var startFrom = requested; + if (requested > color) + { + for (var c = requested; (byte)c < ColorsCount; c++) + { + if (!Game.IsColorUsed(c, this)) + { + if (await sender.Client.ReportCheatAsync( + RpcCalls.SetColor, + CheatCategory.ColorLimits, + $"Client skipped color {c} that could be used, but wrapped instead. Player requested {requested} but was given {color}")) + { + await SetColorAsync(c); + return false; + } + } + } + + startFrom = ColorType.Red; + } + + for (var c = startFrom; c < color; c++) + { + if (!Game.IsColorUsed(c, this)) + { + if (await sender.Client.ReportCheatAsync( + RpcCalls.SetColor, + CheatCategory.ColorLimits, + $"Client skipped color {c} that could be used. Player requested {requested} but was given {color}")) + { + await SetColorAsync(c); + return false; + } + } + } + } + else + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.GameFlow, "Client sent SetColor for a player that didn't request it")) + { + return false; + } + } + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.Color = color; + } + + if (Game.TryGetPlayer(OwnerId, out var clientPlayer)) + { + clientPlayer.Client.PreviousColor = color; + } + else + { + _logger.LogWarning("Tried to record color, but couldn't get player with id {PlayerId}", OwnerId); + } + + return true; + } + + private async ValueTask HandleSetHat(ClientPlayer sender, string hat, byte nextRpcSequenceId) + { + if (Game.GameState == GameStates.Started && + await sender.Client.ReportCheatAsync(RpcCalls.SetHatStr, CheatCategory.GameFlow, "Client tried to change hat while not in lobby")) + { + return false; + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetHatStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.HatId = hat; + PlayerInfo.CurrentOutfit.HatSequenceId = nextRpcSequenceId; + } + + return true; + } + + private async ValueTask HandleSetSkin(ClientPlayer sender, string skin, byte nextRpcSequenceId) + { + if (Game.GameState == GameStates.Started && + await sender.Client.ReportCheatAsync(RpcCalls.SetSkinStr, CheatCategory.GameFlow, "Client tried to change skin while not in lobby")) + { + return false; + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetSkinStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.SkinId = skin; + PlayerInfo.CurrentOutfit.SkinSequenceId = nextRpcSequenceId; + } + + return true; + } + + private async ValueTask HandleSetVisor(ClientPlayer sender, string visor, byte nextRpcSequenceId) + { + if (Game.GameState == GameStates.Started && + await sender.Client.ReportCheatAsync(RpcCalls.SetVisorStr, CheatCategory.GameFlow, "Client tried to change visor while not in lobby")) + { + return false; + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetVisorStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.VisorId = visor; + PlayerInfo.CurrentOutfit.VisorSequenceId = nextRpcSequenceId; + } + + return true; + } + + private async ValueTask HandleSetNamePlate(ClientPlayer sender, string namePlate, byte nextRpcSequenceId) + { + if (Game.GameState == GameStates.Started && + await sender.Client.ReportCheatAsync(RpcCalls.SetNamePlateStr, CheatCategory.GameFlow, "Client tried to change skin while not in lobby")) + { + return false; + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetNamePlateStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.NamePlateId = namePlate; + PlayerInfo.CurrentOutfit.NamePlateSequenceId = nextRpcSequenceId; + } + + return true; + } + + private async ValueTask HandleSetLevel(ClientPlayer sender, uint level) + { + if (Game.GameState == GameStates.Started && + await sender.Client.ReportCheatAsync(RpcCalls.SetLevel, CheatCategory.GameFlow, "Client tried to set level while not in game")) + { + return false; + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetLevel, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.PlayerLevel = level; + } + + return true; + } + + private async ValueTask HandleCheckMurder(ClientPlayer sender, InnerPlayerControl? target) + { + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckMurder, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else if (!PlayerInfo.CanMurder(Game, _dateTimeProvider)) + { + if (IsMurdering == target) + { + return _game.IsHostAuthoritive; + } + else if (!_game.IsHostAuthoritive) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckMurder, CheatCategory.GameFlow, "Client tried to murder too fast")) + { + return false; + } + } + + PlayerInfo.LastMurder = _dateTimeProvider.UtcNow - TimeSpan.FromMilliseconds(sender.Client.Connection.AveragePing); + IsMurdering = target; + } + + if (_game.IsHostAuthoritive) + { + return true; + } + + if (target == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckMurder, CheatCategory.GameFlow, "Client tried to murder a nonexisting target")) + { + return false; + } + } + else + { + var result = target.IsProtected ? MurderResultFlags.FailedProtected : MurderResultFlags.Succeeded; + if (!ValidateMurderPlayer(target, result, out var invalidReason)) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckMurder, CheatCategory.GameFlow, invalidReason)) + { + return false; + } + } + + var evt = new PlayerCheckMurderEvent(Game, sender, this, target, result); + await _eventManager.CallAsync(evt); + if (!evt.IsCancelled) + { + target.ProtectedOn = null; + await ForceMurderPlayerAsync(target, evt.Result); + } + } + + return false; + } + + private async ValueTask HandleMurderPlayer(ClientPlayer sender, InnerPlayerControl? target, MurderResultFlags result) + { + if (!_game.IsHostAuthoritive) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.MurderPlayer, CheatCategory.GameFlow, "Client tried to murder directly")) + { + return false; + } + } + + if (target == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.MurderPlayer, CheatCategory.GameFlow, "Client tried to murder invalid target")) + { + return false; + } + } + + if (sender.Character != this && target != IsMurdering) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.MurderPlayer, CheatCategory.GameFlow, "Host tried to murder incorrect target")) + { + return false; + } + } + + if (target != null && target.PlayerInfo != null && !target.PlayerInfo.IsDead) + { + if ((result & MurderResultFlags.Succeeded) != 0 && target.IsProtected) + { + result = (result & ~MurderResultFlags.Succeeded) | MurderResultFlags.FailedProtected; + } + + if (!result.IsFailed()) + { + target.Die(DeathReason.Kill); + } + else if ((result & MurderResultFlags.FailedProtected) != 0) + { + target.ProtectedOn = null; + } + + await _eventManager.CallAsync(new PlayerMurderEvent(Game, sender, this, target, result)); + } + + IsMurdering = null; + return true; + } + + private async ValueTask HandleProtectPlayer(ClientPlayer sender, IInnerPlayerControl? target) + { + if (target == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckProtect, CheatCategory.Target, "Client tried to protect invalid target")) + { + return false; + } + + return true; + } + + if (!await ValidateRole(RpcCalls.ProtectPlayer, sender, PlayerInfo, RoleTypes.GuardianAngel)) + { + return false; + } + + ((InnerPlayerControl)target).Protect(this); + return true; + } + + private async ValueTask HandleSendChat(ClientPlayer sender, string message) + { + if (message.StartsWith("/")) + { + var ctx = new CommandContext + { + Name = message.Length > 1 ? message[1..].Split(' ')[0].ToLowerInvariant() : string.Empty, + RawArgs = message.Length > 1 && message.Contains(' ') ? message[(message.IndexOf(' ') + 1)..] : string.Empty, + Args = message.Length > 1 && message.Contains(' ') + ? message[(message.IndexOf(' ') + 1)..].Split(' ', System.StringSplitOptions.RemoveEmptyEntries) + : System.Array.Empty(), + Sender = sender, + PlayerControl = this, + Game = Game, + Lang = _commandService.Lang, + }; + await _commandService.TryHandleAsync(ctx); + return false; + } + + var @event = new PlayerChatEvent(Game, sender, this, message); + if (Game.IsHostAuthoritive && !sender.IsHost && message.StartsWith("/cmd")) + { + @event.SendToAllPlayers = false; + } + + await _eventManager.CallAsync(@event); + if (@event.IsCancelled) + { + return false; + } + else if (@event.SendToAllPlayers == false) + { + if (Game.Host != null) + { + await SendChatToPlayerAsync(message, Game.Host.Character); + } + + return false; + } + else + { + return true; + } + } + + private async ValueTask HandleStartMeeting(byte targetId) + { + var deadPlayer = Game.GameNet.GameData!.GetPlayerById(targetId)?.Controller; + await _eventManager.CallAsync(new PlayerStartMeetingEvent(Game, Game.GetClientPlayer(this.OwnerId)!, this, deadPlayer)); + } + + private async ValueTask HandleSetPet(ClientPlayer sender, string pet, byte nextRpcSequenceId) + { + if (Game.GameState == GameStates.Started && + await sender.Client.ReportCheatAsync(RpcCalls.SetPetStr, CheatCategory.GameFlow, "Client tried to change pet while not in lobby")) + { + return false; + } + + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetPetStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.PetId = pet; + PlayerInfo.CurrentOutfit.PetSequenceId = nextRpcSequenceId; + } + + return true; + } + + private async ValueTask HandleSetStartCounter(ClientPlayer sender, int sequenceId, sbyte startCounter) + { + if (!sender.IsHost && startCounter != -1) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetStartCounter, CheatCategory.MustBeHost, "Client tried to set start counter as a non-host")) + { + return false; + } + } + + if (startCounter != -1) + { + await _eventManager.CallAsync(new PlayerSetStartCounterEvent(Game, sender, this, (byte)startCounter)); + } + + return true; + } + + private async ValueTask HandleCheckVanish(ClientPlayer sender, float maxDuration) + { + if (_game.IsHostAuthoritive) + { + return true; + } + else + { + await StartVanishAsync(); + return false; + } + } + + private async ValueTask HandleStartVanish(ClientPlayer sender) + { + if (!_game.IsHostAuthoritive) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.StartVanish, CheatCategory.GameFlow, "Client tried to send StartVanish directly")) + { + return false; + } + } + + return true; + } + + private async ValueTask HandleCheckAppear(ClientPlayer sender, bool shouldAnimate) + { + if (_game.IsHostAuthoritive) + { + return true; + } + else + { + await StartAppearAsync(shouldAnimate); + return false; + } + } + + private async ValueTask HandleStartAppear(ClientPlayer sender, bool shouldAnimate) + { + if (!_game.IsHostAuthoritive) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.StartAppear, CheatCategory.GameFlow, "Client tried to send StartAppear directly")) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.Api.cs b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.Api.cs new file mode 100644 index 0000000..b8b3cd8 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.Api.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Impostor.Api.Net.Inner.Objects; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerPlayerInfo : InnerNetObject, IInnerPlayerInfo + { + IEnumerable IInnerPlayerInfo.Tasks => Tasks; + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.cs b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.cs new file mode 100644 index 0000000..5829a23 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerInfo.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Impostor.Api.Events.Managers; +using Impostor.Api.Games; +using Impostor.Api.Innersloth.Customization; +using Impostor.Api.Innersloth.GameOptions; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Api.Utils; +using Impostor.Server.Net.State; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal partial class InnerPlayerInfo + { + private readonly IEventManager _eventManager; + private readonly ILogger _logger; + + public InnerPlayerInfo(ICustomMessageManager customMessageManager, IEventManager eventManager, Game game, ILogger logger) : base(customMessageManager, game) + { + Components.Add(this); + _eventManager = eventManager; + _logger = logger; + } + + public InnerPlayerControl? Controller { get; internal set; } + + public byte PlayerId { get; internal set; } + + public int ClientId { get; internal set; } + + public string PlayerName => CurrentOutfit.PlayerName; + + public Dictionary Outfits { get; } = new() + { + [PlayerOutfitType.Default] = new PlayerOutfit(), + }; + + public PlayerOutfitType CurrentOutfitType { get; set; } = PlayerOutfitType.Default; + + public PlayerOutfit CurrentOutfit => Outfits[CurrentOutfitType]; + + public RoleTypes? RoleType { get; internal set; } + + public RoleTypes? RoleWhenAlive { get; internal set; } + + public bool Disconnected { get; internal set; } + + public bool IsImpostor => RoleType is RoleTypes.Impostor or RoleTypes.Shapeshifter or RoleTypes.ImpostorGhost or RoleTypes.Phantom; + + public bool CanVent => RoleType is RoleTypes.Impostor or RoleTypes.Shapeshifter or RoleTypes.Phantom or RoleTypes.Engineer; + + public bool IsDead { get; internal set; } + + public DeathReason LastDeathReason { get; internal set; } + + public List Tasks { get; internal set; } = new List(0); + + public DateTimeOffset LastMurder { get; set; } + + public uint PlayerLevel { get; internal set; } + + public bool CanMurder(IGame game, IDateTimeProvider dateTimeProvider) + { + if (!IsImpostor) + { + return false; + } + + // In 2021.11.9 the Guardian Angel was created, which was unfortunately implemented in such a way that + // it is hard to determine accurately whether a kill was prevented or not. When a kill was prevented, + // the impostor has a cooldown of half the usual duration. As a workaround we always assume the kill was + // prevented and the impostor only has half of its cooldown. + // FIXME when the base game improved their implementation. + if (game.Options.GameMode is GameModes.Normal or GameModes.NormalFools) + { + var options = (NormalGameOptions)game.Options; + return dateTimeProvider.UtcNow.Subtract(LastMurder).TotalSeconds >= options.KillCooldown / 2; + } + else + { + return true; + } + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + writer.Write(PlayerId); + writer.WritePacked(ClientId); + + writer.Write((byte)Outfits.Count); + foreach (var outfit in Outfits) + { + writer.Write((byte)outfit.Key); + outfit.Value.Serialize(writer); + } + + writer.WritePacked(PlayerLevel); + + var flag = 0; + if (Disconnected) + { + flag = (byte)(flag | 1u); + } + + if (IsDead) + { + flag = (byte)(flag | 4u); + } + + writer.Write((byte)flag); + + writer.Write((ushort)(RoleType ?? 0)); + writer.Write(RoleWhenAlive.HasValue); + if (RoleWhenAlive.HasValue) + { + writer.Write((ushort)RoleWhenAlive.Value); + } + + writer.Write((byte)Tasks.Count); + for (var i = 0; i < Tasks.Count; i++) + { + Tasks[i].Serialize(writer); + } + + var owner = Game.Players.FirstOrDefault(p => p.Client.Id == ClientId); + writer.Write(owner?.Client.FriendCode ?? string.Empty); + writer.Write(string.Empty); + return new ValueTask(true); + } + + public override ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + PlayerId = reader.ReadByte(); + ClientId = reader.ReadPackedInt32(); + + Outfits.Clear(); + var b = reader.ReadByte(); + for (var i = 0; i < b; i++) + { + var key = (PlayerOutfitType)reader.ReadByte(); + Outfits[key] = new PlayerOutfit(); + Outfits[key].Deserialize(reader); + } + + PlayerLevel = reader.ReadPackedUInt32(); + + var flag = reader.ReadByte(); + Disconnected = (flag & 1) != 0; + IsDead = (flag & 4) != 0; + + // Ignore the RoleType here and only trust the SetRole RPC, as + // RoleType is not nullable in vanilla, while Impostor checks game + // starts based on assigned roles. + _ = (RoleTypes)reader.ReadUInt16(); + + if (reader.ReadBoolean()) + { + RoleWhenAlive = (RoleTypes)reader.ReadUInt16(); + } + + var taskCount = reader.ReadByte(); + + if (Tasks.Count < taskCount) + { + taskCount = (byte)Tasks.Count; + } + + for (var i = 0; i < taskCount; i++) + { + Tasks[i].Deserialize(reader); + } + + // Impostor doesn't expose fields that aren't properly validated + reader.ReadString(); // FriendCode + reader.ReadString(); // PUID + + return ValueTask.CompletedTask; + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + switch (call) + { + case RpcCalls.SetTasks: + Rpc29SetTasks.Deserialize(reader, out var taskTypeIds); + SetTasks(taskTypeIds); + break; + + default: + return await base.HandleRpcAsync(sender, target, call, reader); + } + + return true; + } + + private void SetTasks(ReadOnlyMemory taskTypeIds) + { + if (Disconnected) + { + return; + } + + Tasks = new List(taskTypeIds.Length); + + var taskId = 0u; + foreach (var taskTypeId in taskTypeIds.Span) + { + var mapTasks = Game.GameNet!.ShipStatus?.Data.Tasks; + var taskType = (mapTasks != null && mapTasks.ContainsKey(taskTypeId)) ? mapTasks[taskTypeId] : null; + Tasks.Add(new TaskInfo(this, _eventManager, taskId++, taskType)); + } + } + + public byte GetNextRpcSequenceId(RpcCalls rpc) + { + var defaultOutfit = this.Outfits[PlayerOutfitType.Default]; + + byte nextRpcSequenceId = 0; + const byte SequenceIdAddition = 5; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (rpc) + { + case RpcCalls.SetHatStr: + defaultOutfit.HatSequenceId += SequenceIdAddition; + nextRpcSequenceId = defaultOutfit.HatSequenceId; + break; + case RpcCalls.SetSkinStr: + defaultOutfit.SkinSequenceId += SequenceIdAddition; + nextRpcSequenceId = defaultOutfit.SkinSequenceId; + break; + case RpcCalls.SetPetStr: + defaultOutfit.PetSequenceId += SequenceIdAddition; + nextRpcSequenceId = defaultOutfit.PetSequenceId; + break; + case RpcCalls.SetVisorStr: + defaultOutfit.VisorSequenceId += SequenceIdAddition; + nextRpcSequenceId = defaultOutfit.VisorSequenceId; + break; + case RpcCalls.SetNamePlateStr: + defaultOutfit.NamePlateSequenceId += SequenceIdAddition; + nextRpcSequenceId = defaultOutfit.NamePlateSequenceId; + break; + default: + _logger.LogError( + "{NextRpcSequenceIdName} was called for an RPC it doesn\'t know how to handle: {Rpc}", + nameof(GetNextRpcSequenceId), + rpc); + break; + } + + return nextRpcSequenceId; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerAirshipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerAirshipStatus.cs new file mode 100644 index 0000000..9c9f542 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerAirshipStatus.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Numerics; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.ShipStatus; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal class InnerAirshipStatus : InnerShipStatus, IInnerAirshipStatus + { + public InnerAirshipStatus(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game, MapTypes.Airship) + { + } + + public Vector2 PreSpawnLocation { get; } = new Vector2(-25f, 40f); + + public Vector2[] SpawnLocations { get; } = + { + new Vector2(-0.7f, 8.5f), // Brig + new Vector2(-0.7f, -1.0f), // Engine + new Vector2(15.5f, 0.0f), // MainHall + new Vector2(-7.0f, -11.5f), // Kitchen + new Vector2(20.0f, 10.5f), // Records + new Vector2(33.5f, -1.5f), // CargoBay + }; + + public override Vector2 GetSpawnLocation(InnerPlayerControl player, int numPlayers, bool initialSpawn) + { + return new Vector2(-25, 40); + } + + protected override void AddSystems(Dictionary systems) + { + base.AddSystems(systems); + + systems.Add(SystemTypes.Doors, new DoorsSystemType(Doors)); + systems.Add(SystemTypes.Comms, new HudOverrideSystemType()); + systems.Add(SystemTypes.GapRoom, new MovingPlatformBehaviour()); + systems.Add(SystemTypes.Reactor, new HeliSabotageSystemType()); + systems.Add(SystemTypes.Decontamination, new ElectricalDoors(Doors)); + systems.Add(SystemTypes.Decontamination2, new AutoDoorsSystemType(Doors)); + systems.Add(SystemTypes.Security, new SecurityCameraSystemType()); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerDleksShipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerDleksShipStatus.cs new file mode 100644 index 0000000..ab08b3c --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerDleksShipStatus.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.ShipStatus; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal class InnerDleksShipStatus : InnerShipStatus, IInnerDleksShipStatus + { + public InnerDleksShipStatus(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game, MapTypes.Dleks) + { + } + + protected override void AddSystems(Dictionary systems) + { + base.AddSystems(systems); + + systems.Add(SystemTypes.Doors, new AutoDoorsSystemType(Doors)); + systems.Add(SystemTypes.Comms, new HudOverrideSystemType()); + systems.Add(SystemTypes.Security, new SecurityCameraSystemType()); + systems.Add(SystemTypes.Reactor, new ReactorSystemType()); + systems.Add(SystemTypes.LifeSupp, new LifeSuppSystemType()); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerFungleShipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerFungleShipStatus.cs new file mode 100644 index 0000000..a71ff36 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerFungleShipStatus.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Impostor.Api.Net.Custom; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal class InnerFungleShipStatus : InnerShipStatus + { + public InnerFungleShipStatus(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game, MapTypes.Fungle) + { + } + + protected override void AddSystems(Dictionary systems) + { + base.AddSystems(systems); + + systems.Add(SystemTypes.Comms, new HudOverrideSystemType()); + systems.Add(SystemTypes.Reactor, new ReactorSystemType()); + systems.Add(SystemTypes.Doors, new DoorsSystemType(Doors)); + systems.Add(SystemTypes.MushroomMixupSabotage, new MushroomMixupSabotageSystemType()); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerMiraShipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerMiraShipStatus.cs new file mode 100644 index 0000000..129a9f5 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerMiraShipStatus.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.ShipStatus; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal class InnerMiraShipStatus : InnerShipStatus, IInnerMiraShipStatus + { + public InnerMiraShipStatus(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game, MapTypes.MiraHQ) + { + } + + protected override void AddSystems(Dictionary systems) + { + base.AddSystems(systems); + + systems.Add(SystemTypes.Comms, new HudOverrideSystemType()); + systems.Add(SystemTypes.Reactor, new ReactorSystemType()); + systems.Add(SystemTypes.LifeSupp, new LifeSuppSystemType()); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerPolusShipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerPolusShipStatus.cs new file mode 100644 index 0000000..330a6c9 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerPolusShipStatus.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Numerics; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.ShipStatus; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal class InnerPolusShipStatus : InnerShipStatus, IInnerPolusShipStatus + { + public InnerPolusShipStatus(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game, MapTypes.Polus) + { + } + + public override Vector2 GetSpawnLocation(InnerPlayerControl player, int numPlayers, bool initialSpawn) + { + if (initialSpawn) + { + return base.GetSpawnLocation(player, numPlayers, initialSpawn); + } + + var halfPlayers = numPlayers / 2; // floored intentionally + var spawnId = player.PlayerId % 15; + if (player.PlayerId < halfPlayers) + { + return Data.MeetingSpawnCenter + (new Vector2(0.6f, 0) * spawnId); + } + else + { + return Data.MeetingSpawnCenter2 + (new Vector2(0.6f, 0) * (spawnId - halfPlayers)); + } + } + + protected override void AddSystems(Dictionary systems) + { + base.AddSystems(systems); + + systems.Add(SystemTypes.Doors, new DoorsSystemType(Doors)); + systems.Add(SystemTypes.Comms, new HudOverrideSystemType()); + systems.Add(SystemTypes.Security, new SecurityCameraSystemType()); + systems.Add(SystemTypes.Laboratory, new ReactorSystemType()); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs new file mode 100644 index 0000000..6fbe552 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Innersloth.Maps; +using Impostor.Api.Net; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects.ShipStatus; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal abstract class InnerShipStatus : InnerNetObject, IInnerShipStatus + { + private readonly Dictionary _systems = new Dictionary(); + + protected InnerShipStatus(ICustomMessageManager customMessageManager, Game game, MapTypes mapType) : base(customMessageManager, game) + { + Components.Add(this); + + MapType = mapType; + Data = MapData.Maps[mapType]; + Doors = new Dictionary(Data.Doors.Count); + } + + public MapTypes MapType { get; } + + public MapData Data { get; } + + public Dictionary Doors { get; } + + internal override ValueTask OnSpawnAsync() + { + for (var i = 0; i < Doors.Count; i++) + { + Doors.Add(i, false); + } + + AddSystems(_systems); + _systems.Add(SystemTypes.Sabotage, new SabotageSystemType(_systems.Values.OfType().ToArray())); + + return base.OnSpawnAsync(); + } + + public override ValueTask SerializeAsync(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public override async ValueTask DeserializeAsync(IClientPlayer sender, IClientPlayer? target, IMessageReader reader, bool initialState) + { + if (!await ValidateHost(CheatContext.Deserialize, sender) || !await ValidateBroadcast(CheatContext.Deserialize, sender, target)) + { + return; + } + + while (reader.Position < reader.Length) + { + var messageReader = reader.ReadMessage(); + var type = (SystemTypes)messageReader.Tag; + if (_systems.TryGetValue(type, out var value)) + { + value.Deserialize(messageReader, initialState); + } + } + } + + public override async ValueTask HandleRpcAsync(ClientPlayer sender, ClientPlayer? target, RpcCalls call, IMessageReader reader) + { + switch (call) + { + case RpcCalls.CloseDoorsOfType: + { + if (!await ValidateCmd(call, sender, target) || + !await ValidateImpostor(call, sender, sender.Character!.PlayerInfo)) + { + return false; + } + + Rpc27CloseDoorsOfType.Deserialize(reader, out var systemType); + break; + } + + case RpcCalls.UpdateSystem: + { + if (!await ValidateCmd(call, sender, target)) + { + return false; + } + + // TODO: properly deserialize this RPC + // Rpc35UpdateSystem.Deserialize(reader, Game, out var systemType, out var playerControl, out var sequenceId, out var state, out var ventId); + break; + } + + default: + return await base.HandleRpcAsync(sender, target, call, reader); + } + + return true; + } + + public virtual Vector2 GetSpawnLocation(InnerPlayerControl player, int numPlayers, bool initialSpawn) + { + var vector = new Vector2(0, 1); + vector = Rotate(vector, (player.PlayerId - 1) * (360f / numPlayers)); + vector *= Data.SpawnRadius; + return (initialSpawn ? Data.InitialSpawnCenter : Data.MeetingSpawnCenter) + vector + new Vector2(0f, 0.3636f); + } + + protected virtual void AddSystems(Dictionary systems) + { + systems.Add(SystemTypes.Electrical, new SwitchSystem()); + systems.Add(SystemTypes.MedBay, new MedScanSystem()); + } + + private static Vector2 Rotate(Vector2 self, float degrees) + { + var f = 0.017453292f * degrees; + var cos = MathF.Cos(f); + var sin = MathF.Sin(f); + + return new Vector2((self.X * cos) - (sin * self.Y), (self.X * sin) + (cos * self.Y)); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerSkeldShipStatus.cs b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerSkeldShipStatus.cs new file mode 100644 index 0000000..a9c6bee --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/ShipStatus/InnerSkeldShipStatus.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Inner.Objects.ShipStatus; +using Impostor.Server.Net.Inner.Objects.Systems; +using Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; +using Impostor.Server.Net.State; + +namespace Impostor.Server.Net.Inner.Objects.ShipStatus +{ + internal class InnerSkeldShipStatus : InnerShipStatus, IInnerSkeldShipStatus + { + public InnerSkeldShipStatus(ICustomMessageManager customMessageManager, Game game) : base(customMessageManager, game, MapTypes.Skeld) + { + } + + protected override void AddSystems(Dictionary systems) + { + base.AddSystems(systems); + + systems.Add(SystemTypes.Doors, new AutoDoorsSystemType(Doors)); + systems.Add(SystemTypes.Comms, new HudOverrideSystemType()); + systems.Add(SystemTypes.Security, new SecurityCameraSystemType()); + systems.Add(SystemTypes.Reactor, new ReactorSystemType()); + systems.Add(SystemTypes.LifeSupp, new LifeSuppSystemType()); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/IActivatable.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/IActivatable.cs new file mode 100644 index 0000000..66a164c --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/IActivatable.cs @@ -0,0 +1,7 @@ +namespace Impostor.Server.Net.Inner.Objects.Systems +{ + public interface IActivatable + { + bool IsActive { get; } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ISystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ISystemType.cs new file mode 100644 index 0000000..d1d309b --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ISystemType.cs @@ -0,0 +1,9 @@ +namespace Impostor.Server.Net.Inner.Objects.Systems +{ + public interface ISystemType + { + void Serialize(IMessageWriter writer, bool initialState); + + void Deserialize(IMessageReader reader, bool initialState); + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/AutoDoorsSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/AutoDoorsSystemType.cs new file mode 100644 index 0000000..953e373 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/AutoDoorsSystemType.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class AutoDoorsSystemType : ISystemType + { + private readonly Dictionary _doors; + + public AutoDoorsSystemType(Dictionary doors) + { + _doors = doors; + } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + if (initialState) + { + for (var i = 0; i < _doors.Count; i++) + { + _doors[i] = reader.ReadBoolean(); + } + } + else + { + var num = reader.ReadPackedUInt32(); + + for (var i = 0; i < _doors.Count; i++) + { + if ((num & 1 << i) != 0) + { + _doors[i] = reader.ReadBoolean(); + } + } + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/DoorsSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/DoorsSystemType.cs new file mode 100644 index 0000000..0fb0d0d --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/DoorsSystemType.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class DoorsSystemType : ISystemType + { + private readonly Dictionary _timers = new Dictionary(); + private readonly Dictionary _doors; + + public DoorsSystemType(Dictionary doors) + { + _doors = doors; + } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + var num = reader.ReadByte(); + for (var i = 0; i < num; i++) + { + var systemType = (SystemTypes)reader.ReadByte(); + var value = reader.ReadSingle(); + + _timers[systemType] = value; + } + + for (var j = 0; j < _doors.Count; j++) + { + _doors[j] = reader.ReadBoolean(); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ElectricalDoors.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ElectricalDoors.cs new file mode 100644 index 0000000..e806c19 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ElectricalDoors.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class ElectricalDoors : ISystemType + { + private readonly Dictionary _doors; + + public ElectricalDoors(Dictionary doors) + { + _doors = doors; + } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + var num = reader.ReadUInt32(); + for (var i = 0; i < _doors.Count; i++) + { + _doors[i] = (num & (ulong)(1L << (i & 31))) > 0UL; + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HeliSabotageSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HeliSabotageSystemType.cs new file mode 100644 index 0000000..74da1a6 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HeliSabotageSystemType.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class HeliSabotageSystemType : ISystemType, IActivatable + { + public HeliSabotageSystemType() + { + Countdown = 10000f; + ActiveConsoles = new HashSet>(); + CompletedConsoles = new HashSet(); + } + + public float Countdown { get; private set; } + + public float Timer { get; private set; } + + public HashSet> ActiveConsoles { get; } + + public HashSet CompletedConsoles { get; } + + public bool IsActive => Countdown < 10000.0; + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + Countdown = reader.ReadSingle(); + Timer = reader.ReadSingle(); + ActiveConsoles.Clear(); // TODO: Thread safety + CompletedConsoles.Clear(); // TODO: Thread safety + + var activeCount = reader.ReadPackedUInt32(); + + for (var i = 0; i < activeCount; i++) + { + ActiveConsoles.Add(new Tuple(reader.ReadByte(), reader.ReadByte())); + } + + var completedCount = reader.ReadPackedUInt32(); + + for (var i = 0; i < completedCount; i++) + { + CompletedConsoles.Add(reader.ReadByte()); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HudOverrideSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HudOverrideSystemType.cs new file mode 100644 index 0000000..1dfb130 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/HudOverrideSystemType.cs @@ -0,0 +1,19 @@ +using System; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class HudOverrideSystemType : ISystemType, IActivatable + { + public bool IsActive { get; private set; } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + IsActive = reader.ReadBoolean(); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/LifeSuppSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/LifeSuppSystemType.cs new file mode 100644 index 0000000..8e69203 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/LifeSuppSystemType.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class LifeSuppSystemType : ISystemType, IActivatable + { + public LifeSuppSystemType() + { + Countdown = 10000f; + CompletedConsoles = new HashSet(); + } + + public float Countdown { get; private set; } + + public HashSet CompletedConsoles { get; } + + public bool IsActive => Countdown < 10000.0; + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + Countdown = reader.ReadSingle(); + + if (reader.Position >= reader.Length) + { + return; + } + + CompletedConsoles.Clear(); // TODO: Thread safety + + var num = reader.ReadPackedInt32(); + + for (var i = 0; i < num; i++) + { + CompletedConsoles.Add(reader.ReadPackedInt32()); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MedScanSystem.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MedScanSystem.cs new file mode 100644 index 0000000..bd67ed4 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MedScanSystem.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class MedScanSystem : ISystemType + { + public MedScanSystem() + { + UsersList = new List(); + } + + public List UsersList { get; } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + UsersList.Clear(); + + var num = reader.ReadPackedInt32(); + + for (var i = 0; i < num; i++) + { + UsersList.Add(reader.ReadByte()); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MovingPlatformBehaviour.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MovingPlatformBehaviour.cs new file mode 100644 index 0000000..debb619 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MovingPlatformBehaviour.cs @@ -0,0 +1,21 @@ +using System; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class MovingPlatformBehaviour : ISystemType, IActivatable + { + public bool IsActive { get; private set; } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + var sid = reader.ReadByte(); + var targetId = reader.ReadUInt32(); + var isLeft = reader.ReadBoolean(); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MushroomMixupSabotageSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MushroomMixupSabotageSystemType.cs new file mode 100644 index 0000000..0055608 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/MushroomMixupSabotageSystemType.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus; + +public class MushroomMixupSabotageSystemType : ISystemType +{ + private readonly Dictionary _currentMixups = new(); + private State _currentState; + private float _currentSecondsUntilHeal; + + private enum State + { + Inactive, + JustTriggered, + IdleButMixedUp, + } + + public void Serialize(IMessageWriter writer, bool initialState) + { + writer.Write((byte)_currentState); + writer.Write(_currentSecondsUntilHeal); + + writer.WritePacked(_currentMixups.Count); + foreach (var (playerId, outfit) in _currentMixups) + { + writer.Write(playerId); + outfit.Serialize(writer); + } + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + _currentState = (State)reader.ReadByte(); + _currentSecondsUntilHeal = reader.ReadSingle(); + + _currentMixups.Clear(); + var num = reader.ReadByte(); + for (var i = 0; i < num; i++) + { + var playerId = reader.ReadByte(); + var outfit = CondensedOutfit.Deserialize(reader); + + _currentMixups.Add(playerId, outfit); + } + } + + private readonly record struct CondensedOutfit( + byte HatIndex, + byte VisorIndex, + byte SkinIndex, + byte PetIndex, + byte ColorPlayerId + ) + { + public void Serialize(IMessageWriter writer) + { + writer.Write(ColorPlayerId); + writer.Write(HatIndex); + writer.Write(VisorIndex); + writer.Write(SkinIndex); + writer.Write(PetIndex); + } + + public static CondensedOutfit Deserialize(IMessageReader reader) + { + return new CondensedOutfit + { + ColorPlayerId = reader.ReadByte(), + HatIndex = reader.ReadByte(), + VisorIndex = reader.ReadByte(), + SkinIndex = reader.ReadByte(), + PetIndex = reader.ReadByte(), + }; + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ReactorSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ReactorSystemType.cs new file mode 100644 index 0000000..de6aa21 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/ReactorSystemType.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class ReactorSystemType : ISystemType, IActivatable + { + public ReactorSystemType() + { + Countdown = 10000f; + UserConsolePairs = new HashSet>(); + } + + public float Countdown { get; private set; } + + public HashSet> UserConsolePairs { get; } + + public bool IsActive => Countdown < 10000.0; + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + Countdown = reader.ReadSingle(); + UserConsolePairs.Clear(); // TODO: Thread safety + + var count = reader.ReadPackedInt32(); + + for (var i = 0; i < count; i++) + { + UserConsolePairs.Add(new Tuple(reader.ReadByte(), reader.ReadByte())); + } + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SabotageSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SabotageSystemType.cs new file mode 100644 index 0000000..c40d652 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SabotageSystemType.cs @@ -0,0 +1,26 @@ +using System; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class SabotageSystemType : ISystemType + { + private readonly IActivatable[] _specials; + + public SabotageSystemType(IActivatable[] specials) + { + _specials = specials; + } + + public float Timer { get; set; } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + Timer = reader.ReadSingle(); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SecurityCameraSystemType.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SecurityCameraSystemType.cs new file mode 100644 index 0000000..33096a5 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SecurityCameraSystemType.cs @@ -0,0 +1,19 @@ +using System; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class SecurityCameraSystemType : ISystemType + { + public byte InUse { get; internal set; } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + InUse = reader.ReadByte(); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SwitchSystem.cs b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SwitchSystem.cs new file mode 100644 index 0000000..d21c4b1 --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/Systems/ShipStatus/SwitchSystem.cs @@ -0,0 +1,27 @@ +using System; + +namespace Impostor.Server.Net.Inner.Objects.Systems.ShipStatus +{ + public class SwitchSystem : ISystemType, IActivatable + { + public byte ExpectedSwitches { get; set; } + + public byte ActualSwitches { get; set; } + + public byte Value { get; set; } = byte.MaxValue; + + public bool IsActive { get; } + + public void Serialize(IMessageWriter writer, bool initialState) + { + throw new NotImplementedException(); + } + + public void Deserialize(IMessageReader reader, bool initialState) + { + ExpectedSwitches = reader.ReadByte(); + ActualSwitches = reader.ReadByte(); + Value = reader.ReadByte(); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/Objects/TaskInfo.cs b/src/Impostor.Server/Net/Inner/Objects/TaskInfo.cs new file mode 100644 index 0000000..c04c27d --- /dev/null +++ b/src/Impostor.Server/Net/Inner/Objects/TaskInfo.cs @@ -0,0 +1,68 @@ +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Events.Managers; +using Impostor.Api.Innersloth.Maps; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Api.Net.Messages.Rpcs; +using Impostor.Server.Events.Player; + +namespace Impostor.Server.Net.Inner.Objects +{ + internal class TaskInfo : ITaskInfo + { + private readonly InnerPlayerInfo _playerInfo; + private readonly IEventManager _eventManager; + + public TaskInfo(InnerPlayerInfo playerInfo, IEventManager eventManager, uint id, TaskData? task) + { + _playerInfo = playerInfo; + _eventManager = eventManager; + Id = id; + Task = task; + } + + public uint Id { get; internal set; } + + public TaskData? Task { get; internal set; } + + public bool Complete { get; internal set; } + + public void Serialize(IMessageWriter writer) + { + writer.WritePacked(Id); + writer.Write(Complete); + } + + public void Deserialize(IMessageReader reader) + { + Id = reader.ReadPackedUInt32(); + Complete = reader.ReadBoolean(); + } + + public async ValueTask CompleteAsync() + { + if (_playerInfo.Controller == null) + { + throw new ImpostorException("Can't complete a task that doesn't have a player assigned"); + } + + var player = _playerInfo.Controller; + + if (Complete) + { + throw new ImpostorException("Can't complete a task that is already completed"); + } + + Complete = true; + + // Send RPC. + using var writer = player.Game.StartRpc(player.NetId, RpcCalls.CompleteTask); + Rpc01CompleteTask.Serialize(writer, Id); + await player.Game.FinishRpcAsync(writer); + + // Notify plugins. + await _eventManager.CallAsync(new PlayerCompletedTaskEvent(player.Game, player.Game.GetClientPlayer(player.OwnerId)!, player, this)); + } + } +} diff --git a/src/Impostor.Server/Net/Inner/SpawnFlags.cs b/src/Impostor.Server/Net/Inner/SpawnFlags.cs new file mode 100644 index 0000000..28c2a1a --- /dev/null +++ b/src/Impostor.Server/Net/Inner/SpawnFlags.cs @@ -0,0 +1,11 @@ +using System; + +namespace Impostor.Server.Net.Inner +{ + [Flags] + public enum SpawnFlags : byte + { + None = 0, + IsClientCharacter = 1, + } +} diff --git a/src/Impostor.Server/Net/Manager/ClientManager.Api.cs b/src/Impostor.Server/Net/Manager/ClientManager.Api.cs new file mode 100644 index 0000000..1ff970d --- /dev/null +++ b/src/Impostor.Server/Net/Manager/ClientManager.Api.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Impostor.Api.Net; +using Impostor.Api.Net.Manager; + +namespace Impostor.Server.Net.Manager +{ + internal partial class ClientManager : IClientManager + { + IEnumerable IClientManager.Clients => _clients.Values; + } +} diff --git a/src/Impostor.Server/Net/Manager/ClientManager.cs b/src/Impostor.Server/Net/Manager/ClientManager.cs new file mode 100644 index 0000000..98933c9 --- /dev/null +++ b/src/Impostor.Server/Net/Manager/ClientManager.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Impostor.Api.Config; +using Impostor.Api.Events.Managers; +using Impostor.Api.Net; +using Impostor.Api.Net.Manager; +using Impostor.Server.Events.Client; +using Impostor.Server.Net.Factories; +using Impostor.Server.Service.Auth; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Next.Hazel; + +namespace Impostor.Server.Net.Manager +{ + internal partial class ClientManager + { + private readonly ILogger _logger; + private readonly IEventManager _eventManager; + private readonly ConcurrentDictionary _clients; + private readonly ICompatibilityManager _compatibilityManager; + private readonly CompatibilityConfig _compatibilityConfig; + private readonly IClientFactory _clientFactory; + private readonly AuthCacheService _authCache; + private int _idLast; + + public ClientManager( + ILogger logger, + IEventManager eventManager, + IClientFactory clientFactory, + ICompatibilityManager compatibilityManager, + IOptions compatibilityConfig, + AuthCacheService authCache) + { + _logger = logger; + _eventManager = eventManager; + _clientFactory = clientFactory; + _clients = new ConcurrentDictionary(); + _compatibilityManager = compatibilityManager; + _compatibilityConfig = compatibilityConfig.Value; + _authCache = authCache; + + if (_compatibilityConfig.AllowFutureGameVersions || _compatibilityConfig.AllowHostAuthority || _compatibilityConfig.AllowVersionMixing) + { + _logger.LogWarning("One or more compatibility options were enabled, please mention these when seeking support:"); + if (_compatibilityConfig.AllowFutureGameVersions) + { + _logger.LogWarning("AllowFutureGameVersions"); + } + + if (_compatibilityConfig.AllowHostAuthority) + { + _logger.LogWarning("AllowHostAuthority"); + } + + if (_compatibilityConfig.AllowVersionMixing) + { + _logger.LogWarning("AllowVersionMixing"); + } + } + } + + public IEnumerable Clients => _clients.Values; + + public int NextId() + { + var clientId = Interlocked.Increment(ref _idLast); + if (clientId < 1) { _idLast = 0; clientId = Interlocked.Increment(ref _idLast); } + return clientId; + } + + public async ValueTask RegisterConnectionAsync( + IHazelConnection connection, + string name, + GameVersion clientVersion, + Language language, + QuickChatModes chatMode, + PlatformSpecificData? platformSpecificData, + string? matchmakerToken = null) + { + var versionCompare = _compatibilityManager.CanConnectToServer(clientVersion); + if (versionCompare == ICompatibilityManager.VersionCompareResult.ServerTooOld + && _compatibilityConfig.AllowFutureGameVersions && platformSpecificData != null) + { + _logger.LogWarning("Client connected using future version: {v}", clientVersion); + } + else if (versionCompare != ICompatibilityManager.VersionCompareResult.Compatible + || platformSpecificData == null) + { + _logger.LogInformation("Client connected using unsupported version: {v}", clientVersion); + using var packet = MessageWriter.Get(MessageType.Reliable); + var msg = versionCompare switch + { + ICompatibilityManager.VersionCompareResult.ClientTooOld => DisconnectMessages.VersionClientTooOld, + ICompatibilityManager.VersionCompareResult.ServerTooOld => DisconnectMessages.VersionServerTooOld, + _ => DisconnectMessages.VersionUnsupported, + }; + await connection.CustomDisconnectAsync(DisconnectReason.Custom, msg); + return; + } + + if (clientVersion.HasDisableServerAuthorityFlag) + { + if (!_compatibilityConfig.AllowHostAuthority) + { + await connection.CustomDisconnectAsync(DisconnectReason.Custom, DisconnectMessages.HostAuthorityUnsupported); + return; + } + + _logger.LogInformation("Player {Name} connected with host authority.", name); + } + + if (name.Length > 10) { await connection.CustomDisconnectAsync(DisconnectReason.Custom, DisconnectMessages.UsernameLength); return; } + if (string.IsNullOrWhiteSpace(name)) { await connection.CustomDisconnectAsync(DisconnectReason.Custom, DisconnectMessages.UsernameIllegalCharacters); return; } + + string? friendCode = null; + var clientIp = connection.EndPoint?.Address; + + var authInfo = _authCache.FindByToken(matchmakerToken); + + if (authInfo != null) + { + friendCode = authInfo.FriendCode; + _logger.LogInformation( + "[Auth] {Name}: matched via matchmakerToken → FriendCode={FC} PUID={Puid}", + name, friendCode, authInfo.ProductUserId); + } + else if (clientIp != null) + { + var ipAuth = _authCache.FindByIp(clientIp); + if (ipAuth != null) + { + friendCode = ipAuth.FriendCode; + _logger.LogInformation( + "[Auth] {Name}: matched via IP={Ip} → FriendCode={FC}", + name, NormalizeIp(clientIp), friendCode); + } + else + { + _logger.LogWarning( + "[Auth] {Name}: no auth info found (token={Token}, IP={Ip}) — FriendCode will be null", + name, + string.IsNullOrEmpty(matchmakerToken) ? "(none)" : matchmakerToken[..Math.Min(8, matchmakerToken.Length)] + "...", + NormalizeIp(clientIp)); + } + } + + var client = _clientFactory.Create(connection, name, clientVersion, language, chatMode, platformSpecificData); + client.FriendCode = string.IsNullOrEmpty(friendCode) ? null : friendCode; + + var id = NextId(); + client.Id = id; + _logger.LogTrace("Client connected: Id={Id} Name={Name} FriendCode={FC}", id, name, client.FriendCode ?? "(none)"); + _clients.TryAdd(id, client); + await _eventManager.CallAsync(new ClientConnectedEvent(connection, client)); + } + + public void Remove(IClient client) + { + _logger.LogTrace("Client {Id} disconnected.", client.Id); + _clients.TryRemove(client.Id, out _); + } + + public bool Validate(IClient client) + => client.Id != 0 + && _clients.TryGetValue(client.Id, out var c) + && ReferenceEquals(client, c); + + private static string NormalizeIp(IPAddress? addr) + { + if (addr == null) + { + return "(unknown)"; + } + + return addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4().ToString() : addr.ToString(); + } + } +} diff --git a/src/Impostor.Server/Net/Manager/CompatibilityManager.cs b/src/Impostor.Server/Net/Manager/CompatibilityManager.cs new file mode 100644 index 0000000..e9e9b89 --- /dev/null +++ b/src/Impostor.Server/Net/Manager/CompatibilityManager.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Impostor.Api.Games; +using Impostor.Api.Net.Manager; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.Manager; + +using CompatibilityGroup = ICompatibilityManager.CompatibilityGroup; +using VersionCompareResult = ICompatibilityManager.VersionCompareResult; + +internal class CompatibilityManager : ICompatibilityManager +{ + private static readonly CompatibilityGroup[] DefaultSupportedVersions = + { + new[] + { + new GameVersion(2024, 3, 1), // 2024.6.18 + new GameVersion(2024, 4, 1), // 2024.8.13 + new GameVersion(2024, 4, 2), // 2024.9.4 + new GameVersion(2024, 8, 10), // 2024.10.29 + }, + new[] + { + new GameVersion(2024, 8, 11), // 16.0.0 (2025-03-25) + }, + new[] + { + new GameVersion(2025, 4, 15), // 16.0.5 (2025-05-20) & 16.1.0 (2025-06-10) + }, + new[] + { + new GameVersion(2025, 7, 15), // 17.0.0 (2025-09-09) + new GameVersion(2025, 9, 12), // 17.0.1 (2025-10-14) + new GameVersion(2025, 10, 9), // 17.1 (2025-11-18) + new GameVersion(2025, 11, 6), // 17.1.1 (2025-12-03, mobile only) + new GameVersion(2025, 12, 8), // 17.1.2 (2025-12-11, mobile only) + new GameVersion(2025, 11, 5), // 17.2 (2026-02-18, not a typo, Innersloth went backwards) + new GameVersion(2026, 1, 22), // 17.2 (2026-02-19, build 6686, hotfix) + new GameVersion(2026, 2, 2), // 17.2.2 (2026-03-17, build 6768) + new GameVersion(2026, 1, 12), // 17.3 (2026-03-31, build 6803) + new GameVersion(2026, 3, 17), // 17.3.1 (2026-04-08, build 6841, mobile only) + }, + }; + + private readonly List _compatibilityGroups = new(); + private readonly Dictionary _supportMap = new(); + private readonly ILogger _logger; + private GameVersion _lowestVersionSupported = new(int.MaxValue); + private GameVersion _highestVersionSupported = new(0); + + public CompatibilityManager(ILogger logger) : this(logger, DefaultSupportedVersions) + { + } + + internal CompatibilityManager(ILogger logger, IEnumerable defaultSupportedVersions) + { + _logger = logger; + + foreach (var compatibilityGroup in defaultSupportedVersions) + { + AddCompatibilityGroup(compatibilityGroup); + } + } + + public IEnumerable CompatibilityGroups => _compatibilityGroups; + + private CompatibilityGroup? TryGetCompatibilityGroup(GameVersion clientVersion) + { + // Innersloth servers allow disabling server authority by incrementing the version revision by 25. + // We should allow crossplay between client versions with this flag set and those without. + clientVersion = clientVersion.Normalize(); + + if (_supportMap.TryGetValue(clientVersion, out var compatibilityGroup)) + { + return compatibilityGroup; + } + + return null; + } + + private CompatibilityGroup GetCompatibilityGroupOrDefault(GameVersion clientVersion) + { + // If the compatibility group is not defined, we assume it is not compatible with anything else than itself + return TryGetCompatibilityGroup(clientVersion) ?? new CompatibilityGroup(new[] { clientVersion.Normalize() }); + } + + public VersionCompareResult CanConnectToServer(GameVersion clientVersion) + { + if (this.TryGetCompatibilityGroup(clientVersion) != null) + { + return VersionCompareResult.Compatible; + } + + if (clientVersion < _lowestVersionSupported) + { + return VersionCompareResult.ClientTooOld; + } + + if (clientVersion > _highestVersionSupported) + { + return VersionCompareResult.ServerTooOld; + } + + return VersionCompareResult.Unknown; + } + + public GameJoinError CanJoinGame(GameVersion hostVersion, GameVersion clientVersion) + { + if (hostVersion == clientVersion) + { + // Optimize a common case: a player on version X should always be able to join version X + return GameJoinError.None; + } + + var hostCompatGroup = GetCompatibilityGroupOrDefault(hostVersion); + var playerCompatGroup = GetCompatibilityGroupOrDefault(clientVersion); + + if (hostCompatGroup != playerCompatGroup) + { + return clientVersion < hostVersion + ? GameJoinError.ClientOutdated + : GameJoinError.ClientTooNew; + } + + return GameJoinError.None; + } + + void ICompatibilityManager.AddCompatibilityGroup(CompatibilityGroup compatibilityGroup) + { + _logger.LogWarning($"{nameof(AddCompatibilityGroup)} was called by a plugin, this can create unexpected issues. Please proceed carefully"); + + AddCompatibilityGroup(compatibilityGroup); + } + + void ICompatibilityManager.AddSupportedVersion(CompatibilityGroup compatibilityGroup, GameVersion gameVersion) + { + _logger.LogWarning($"{nameof(AddSupportedVersion)} was called by a plugin, this can create unexpected issues. Please proceed carefully"); + + if (compatibilityGroup.GameVersions.Contains(gameVersion)) + { + return; + } + + AddSupportedVersion(compatibilityGroup, gameVersion, true); + } + + private void AddCompatibilityGroup(CompatibilityGroup compatibilityGroup) + { + foreach (var gameVersion in compatibilityGroup.GameVersions) + { + if (_supportMap.ContainsKey(gameVersion)) + { + throw new InvalidOperationException($"Can't add this compatibility group because one if its versions ({gameVersion}) is already added"); + } + } + + _compatibilityGroups.Add(compatibilityGroup); + + foreach (var gameVersion in compatibilityGroup.GameVersions) + { + AddSupportedVersion(compatibilityGroup, gameVersion, false); + } + } + + private void AddSupportedVersion(CompatibilityGroup compatibilityGroup, GameVersion gameVersion, bool addToGroup) + { + if (!_compatibilityGroups.Contains(compatibilityGroup)) + { + throw new InvalidOperationException("You have to add the compatibility group first"); + } + + if (_supportMap.ContainsKey(gameVersion)) + { + throw new InvalidOperationException("Can't add this game version because it's already in another compatibility group"); + } + + if (addToGroup) + { + compatibilityGroup.Add(gameVersion); + } + + _supportMap.Add(gameVersion, compatibilityGroup); + + if (gameVersion < _lowestVersionSupported) + { + _lowestVersionSupported = gameVersion; + } + + if (gameVersion > _highestVersionSupported) + { + _highestVersionSupported = gameVersion; + } + } + + public bool RemoveSupportedVersion(GameVersion removedVersion) + { + if (_supportMap.Remove(removedVersion, out var compatibilityGroup)) + { + if (!compatibilityGroup.Remove(removedVersion)) + { + throw new InvalidOperationException("Removed the version from the support map but it was missing from it's compatibility group"); + } + + if (!compatibilityGroup.GameVersions.Any()) + { + _compatibilityGroups.Remove(compatibilityGroup); + } + + return true; + } + + return false; + } +} diff --git a/src/Impostor.Server/Net/Manager/GameManager.cs b/src/Impostor.Server/Net/Manager/GameManager.cs new file mode 100644 index 0000000..745db78 --- /dev/null +++ b/src/Impostor.Server/Net/Manager/GameManager.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Config; +using Impostor.Api.Events.Managers; +using Impostor.Api.Games; +using Impostor.Api.Games.Managers; +using Impostor.Api.Innersloth.GameOptions; +using Impostor.Api.Net; +using Impostor.Api.Net.Manager; +using Impostor.Server.Events; +using Impostor.Server.Net.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Net.Manager +{ + internal class GameManager : IGameManager + { + private readonly ILogger _logger; + private readonly IPEndPoint _publicIp; + private readonly ConcurrentDictionary _games; + private readonly CompatibilityConfig _compatibilityConfig; + private readonly IServiceProvider _serviceProvider; + private readonly IEventManager _eventManager; + private readonly IGameCodeFactory _gameCodeFactory; + private readonly ICompatibilityManager _compatibilityManager; + private readonly ConcurrentDictionary _gamesCreatedBy; + + public GameManager( + ILogger logger, + IOptions config, + IServiceProvider serviceProvider, + IEventManager eventManager, + IGameCodeFactory gameCodeFactory, + IOptions compatibilityConfig, + ICompatibilityManager compatibilityManager) + { + _logger = logger; + _serviceProvider = serviceProvider; + _eventManager = eventManager; + _gameCodeFactory = gameCodeFactory; + _publicIp = new IPEndPoint(IPAddress.Parse(config.Value.ResolvePublicIp()), config.Value.PublicPort); + _games = new ConcurrentDictionary(); + _compatibilityConfig = compatibilityConfig.Value; + _compatibilityManager = compatibilityManager; + _gamesCreatedBy = new ConcurrentDictionary(); + } + + IEnumerable IGameManager.Games => _games.Select(kv => kv.Value); + + IGame? IGameManager.Find(GameCode code) => Find(code); + + public Game? Find(GameCode code) + { + _games.TryGetValue(code, out var game); + return game; + } + + public async ValueTask RemoveAsync(GameCode gameCode) + { + if (_games.TryGetValue(gameCode, out var game) && game.PlayerCount > 0) + { + foreach (var player in game.Players.ToArray()) + { + await player.KickAsync(); + } + } + + if (!_games.TryRemove(gameCode, out game)) + { + return; + } + + _logger.LogDebug("Remove game with code {0} ({1}).", GameCodeParser.IntToGameName(gameCode), gameCode); + await _eventManager.CallAsync(new GameDestroyedEvent(game)); + } + + public async ValueTask CreateAsync(IClient? owner, IGameOptions options, GameFilterOptions filterOptions) + { + if (owner != null && !_gamesCreatedBy.TryAdd(owner, null)) + { + _logger.LogWarning("Connection {Name}({ClientId}) has tried to create a second game, blocked", owner.Name, owner.Id); + return null; + } + + var @event = new GameCreationEvent(this, owner); + await _eventManager.CallAsync(@event); + if (@event.IsCancelled) + { + return null; + } + + var (success, game) = await TryCreateAsync(options, filterOptions, owner, @event.GameCode); + for (var i = 0; i < 10 && !success; i++) + { + (success, game) = await TryCreateAsync(options, filterOptions, owner); + } + + if (owner != null) + { + _gamesCreatedBy[owner] = game; + } + + if (!success || game == null) + { + throw new ImpostorException("Could not create new game"); + } + + return game; + } + + public ValueTask CreateAsync(IGameOptions options, GameFilterOptions filterOptions) + { + return CreateAsync(null, options, filterOptions); + } + + private async ValueTask<(bool Success, Game? Game)> TryCreateAsync(IGameOptions options, GameFilterOptions filterOptions, IClient? owner, GameCode? desiredGameCode = null) + { + var gameCode = desiredGameCode ?? _gameCodeFactory.Create(); + var game = ActivatorUtilities.CreateInstance(_serviceProvider, _publicIp, gameCode, options, filterOptions); + if (!_games.TryAdd(gameCode, game)) + { + return (false, null); + } + + _logger.LogDebug("Created game with code {0}.", game.Code); + await _eventManager.CallAsync(new GameCreatedEvent(game, owner)); + return (true, game); + } + + internal async ValueTask OnClientDisconnectAsync(IClient client) + { + if (_gamesCreatedBy.TryRemove(client, out var game) && game is { PlayerCount: 0, GameState: not GameStates.Destroyed }) + { + _logger.LogWarning("Client {Name}({ClientId}) left empty game open when disconnecting", client.Name, client.Id); + await RemoveAsync(game.Code); + } + } + } +} diff --git a/src/Impostor.Server/Net/Matchmaker.cs b/src/Impostor.Server/Net/Matchmaker.cs new file mode 100644 index 0000000..a98fd84 --- /dev/null +++ b/src/Impostor.Server/Net/Matchmaker.cs @@ -0,0 +1,84 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using Impostor.Api.Events.Managers; +using Impostor.Api.Net.Messages.C2S; +using Next.Hazel; +using Next.Hazel.Udp; +using Impostor.Server.Events.Client; +using Impostor.Server.Net.Hazel; +using Impostor.Server.Net.Manager; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ObjectPool; + +namespace Impostor.Server.Net +{ + internal class Matchmaker + { + private readonly IEventManager _eventManager; + private readonly ClientManager _clientManager; + private readonly ObjectPool _readerPool; + private readonly ILogger _connectionLogger; + private readonly ILogger _logger; + + private UdpConnectionListener? _gameListener; + + public Matchmaker( + IEventManager eventManager, + ClientManager clientManager, + ObjectPool readerPool, + ILogger connectionLogger, + ILogger logger) + { + _eventManager = eventManager; + _clientManager = clientManager; + _readerPool = readerPool; + _connectionLogger = connectionLogger; + _logger = logger; + } + + public async ValueTask StartAsync(IPEndPoint ipEndPoint) + { + var mode = ipEndPoint.AddressFamily switch + { + AddressFamily.InterNetwork => IPMode.IPv4, + AddressFamily.InterNetworkV6 => IPMode.IPv6, + _ => throw new InvalidOperationException(), + }; + + _gameListener = new UdpConnectionListener(ipEndPoint, _readerPool, mode) + { + NewConnection = OnNewConnection, + }; + + await _gameListener.StartAsync(); + _logger.LogInformation("[Matchmaker] UDP listener started on {EP}", ipEndPoint); + } + + public async ValueTask StopAsync() + { + if (_gameListener != null) + { + await _gameListener.DisposeAsync(); + } + } + + private async ValueTask OnNewConnection(NewConnectionEventArgs e) + { + HandshakeC2S.Deserialize( + e.HandshakeData, + out var clientVersion, + out var name, + out var language, + out var chatMode, + out var platformSpecificData, + out var matchmakerToken); + + var connection = new HazelConnection(e.Connection, _connectionLogger); + await _eventManager.CallAsync(new ClientConnectionEvent(connection, e.HandshakeData)); + await _clientManager.RegisterConnectionAsync( + connection, name, clientVersion, language, chatMode, platformSpecificData, matchmakerToken); + } + } +} diff --git a/src/Impostor.Server/Net/MatchmakerService.cs b/src/Impostor.Server/Net/MatchmakerService.cs new file mode 100644 index 0000000..07ea94c --- /dev/null +++ b/src/Impostor.Server/Net/MatchmakerService.cs @@ -0,0 +1,65 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Impostor.Api.Config; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Net +{ + internal class MatchmakerService : IHostedService + { + private readonly ILogger _logger; + private readonly ServerConfig _serverConfig; + private readonly HttpServerConfig _httpServerConfig; + private readonly Matchmaker _matchmaker; + + public MatchmakerService( + ILogger logger, + IOptions serverConfig, + IOptions httpServerConfig, + Matchmaker matchmaker) + { + _logger = logger; + _serverConfig = serverConfig.Value; + _httpServerConfig = httpServerConfig.Value; + _matchmaker = matchmaker; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var endpoint = new IPEndPoint(IPAddress.Parse(_serverConfig.ResolveListenIp()), _serverConfig.ListenPort); + + await _matchmaker.StartAsync(endpoint); + + _logger.LogInformation( + "Matchmaker is listening on {0}:{1}, the public server ip is {2}:{3}.", + endpoint.Address, + endpoint.Port, + _serverConfig.ResolvePublicIp(), + _serverConfig.PublicPort); + + var runningOutsideContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == null; + if (_serverConfig.PublicIp == "127.0.0.1") + { + // NOTE: If this warning annoys you, set your PublicIp to "localhost" + _logger.LogError("Your PublicIp is set to the default value of 127.0.0.1."); + _logger.LogError("To allow people on other devices to connect to your server, change this value to your Public IP address"); + _logger.LogError("For more info on how to do this see https://github.com/Impostor/Impostor/blob/master/docs/Server-configuration.md"); + } + else if (_httpServerConfig.ListenIp == "0.0.0.0" && runningOutsideContainer) + { + _logger.LogWarning("Since Among Us 16.0.5 it is required to support HTTPS for players to connect, we recommend setting up a reverse proxy:"); + _logger.LogWarning("See https://github.com/Impostor/Impostor/blob/master/docs/Http-server.md for instructions"); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogWarning("Matchmaker is shutting down!"); + await _matchmaker.StopAsync(); + } + } +} diff --git a/src/Impostor.Server/Net/Messages/MessageWriterProvider.cs b/src/Impostor.Server/Net/Messages/MessageWriterProvider.cs new file mode 100644 index 0000000..34130f7 --- /dev/null +++ b/src/Impostor.Server/Net/Messages/MessageWriterProvider.cs @@ -0,0 +1,12 @@ +using Next.Hazel; + +namespace Impostor.Server.Net.Messages +{ + public class MessageWriterProvider : IMessageWriterProvider + { + public IMessageWriter Get(MessageType sendOption = MessageType.Unreliable) + { + return MessageWriter.Get(sendOption); + } + } +} diff --git a/src/Impostor.Server/Net/State/ClientPlayer.Api.cs b/src/Impostor.Server/Net/State/ClientPlayer.Api.cs new file mode 100644 index 0000000..2e2467e --- /dev/null +++ b/src/Impostor.Server/Net/State/ClientPlayer.Api.cs @@ -0,0 +1,18 @@ +using Impostor.Api.Games; +using Impostor.Api.Net; +using Impostor.Api.Net.Inner.Objects; + +namespace Impostor.Server.Net.State +{ + internal partial class ClientPlayer + { + /// + IClient IClientPlayer.Client => Client; + + /// + IGame IClientPlayer.Game => Game; + + /// + IInnerPlayerControl? IClientPlayer.Character => Character; + } +} diff --git a/src/Impostor.Server/Net/State/ClientPlayer.cs b/src/Impostor.Server/Net/State/ClientPlayer.cs new file mode 100644 index 0000000..5072061 --- /dev/null +++ b/src/Impostor.Server/Net/State/ClientPlayer.cs @@ -0,0 +1,92 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Impostor.Api.Net; +using Impostor.Api.Net.Inner; +using Impostor.Api.Unity; +using Impostor.Server.Net.Inner.Objects; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.State +{ + internal partial class ClientPlayer : IClientPlayer, IAsyncDisposable + { + private readonly ILogger _logger; + private readonly Timer _spawnTimeout; + private readonly int _spawnTimeoutTime; + + public ClientPlayer(ILogger logger, ClientBase client, Game game, int timeOutTime) + { + _logger = logger; + _spawnTimeout = new Timer(RunSpawnTimeout!, null, -1, -1); + _spawnTimeoutTime = timeOutTime; + Game = game; + Client = client; + Limbo = LimboStates.PreSpawn; + } + + public ClientBase Client { get; } + + public Game Game { get; } + + public LimboStates Limbo { get; set; } + + public InnerPlayerControl? Character { get; internal set; } + + public bool IsHost => Game?.Host == this; + + public string? Scene { get; internal set; } + + public RuntimePlatform? Platform { get; internal set; } + + public void InitializeSpawnTimeout() + { + _spawnTimeout.Change(_spawnTimeoutTime, -1); + } + + public void DisableSpawnTimeout() + { + _spawnTimeout.Change(-1, -1); + } + + public bool IsOwner(IInnerNetObject netObject) + { + return Client.Id == netObject.OwnerId; + } + + public ValueTask KickAsync() + { + return Game.HandleKickPlayer(Client.Id, false); + } + + public ValueTask BanAsync() + { + return Game.HandleKickPlayer(Client.Id, true); + } + + public ValueTask DisposeAsync() + { + return _spawnTimeout.DisposeAsync(); + } + + private async void RunSpawnTimeout(object state) + { + try + { + if (Character == null) + { + _logger.LogInformation("{0} - Player {1} spawn timed out, kicking.", Game.Code, Client.Id); + await KickAsync(); + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception caught while kicking player for spawn timeout."); + } + finally + { + await _spawnTimeout.DisposeAsync(); + } + } + } +} diff --git a/src/Impostor.Server/Net/State/Game.Api.cs b/src/Impostor.Server/Net/State/Game.Api.cs new file mode 100644 index 0000000..eb51f9b --- /dev/null +++ b/src/Impostor.Server/Net/State/Game.Api.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Games; +using Impostor.Api.Net; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Messages; +using Next.Hazel; +using Impostor.Server.Events; +using Impostor.Server.Net.Inner; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.State +{ + internal partial class Game : IGame + { + private bool _alreadyCallingOptionsChangedEvent = false; + + IClientPlayer? IGame.Host => Host; + + IGameNet IGame.GameNet => GameNet; + + public void BanIp(IPAddress ipAddress) + { + _bannedIps.Add(ipAddress); + } + + public async ValueTask SyncSettingsAsync() + { + if (Host?.Character == null) + { + throw new ImpostorException("Attempted to change settings when the host was not spawned."); + } + + if (GameNet.GameManager == null) + { + throw new ImpostorException("Attempted to change options when the game manager was not spawned."); + } + + var gameOptionsTag = GameNet.GameManager.GetGameLogicTag(GameNet.GameManager.LogicOptions); + if (gameOptionsTag == -1) + { + throw new ImpostorException("Attempted to change options when the LogicOptions was not spawned."); + } + + // Someone will probably forget to do this, so we include it here. + // If this is not done, the host will overwrite changes later with the defaults. + Options.IsDefaults = false; + + using var writer = MessageWriter.Get(MessageType.Reliable); + + writer.StartMessage(MessageFlags.GameData); + Code.Serialize(writer); + + writer.StartMessage(GameDataTag.DataFlag); + writer.WritePacked(GameNet.GameManager.NetId); + + writer.StartMessage((byte)gameOptionsTag); + await GameNet.GameManager.LogicOptions.SerializeAsync(writer, false); + + writer.EndMessage(); + writer.EndMessage(); + writer.EndMessage(); + + await SendToAllAsync(writer); + + // Prevent bad plugins from causing a server crash by recursing into this function + if (_alreadyCallingOptionsChangedEvent) + { + _logger.LogError("Plugin called SyncSettingsAsync while processing a GameOptionsChangedEvent, aborting to prevent recursion"); + } + else + { + try + { + _alreadyCallingOptionsChangedEvent = true; + await _eventManager.CallAsync(new GameOptionsChangedEvent( + this, + Api.Events.IGameOptionsChangedEvent.ChangeReason.Api + )); + } + finally + { + _alreadyCallingOptionsChangedEvent = false; + } + } + } + + public async ValueTask SetPrivacyAsync(bool isPublic) + { + IsPublic = isPublic; + + using (var writer = MessageWriter.Get(MessageType.Reliable)) + { + WriteAlterGameMessage(writer, false, IsPublic); + + await SendToAllAsync(writer); + } + } + } +} diff --git a/src/Impostor.Server/Net/State/Game.Data.cs b/src/Impostor.Server/Net/State/Game.Data.cs new file mode 100644 index 0000000..e4c38fc --- /dev/null +++ b/src/Impostor.Server/Net/State/Game.Data.cs @@ -0,0 +1,561 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Net.Inner; +using Impostor.Api.Unity; +using Impostor.Server.Events.Game.Player; +using Impostor.Server.Events.Meeting; +using Impostor.Server.Events.Player; +using Impostor.Server.Net.Inner; +using Impostor.Server.Net.Inner.Objects; +using Impostor.Server.Net.Inner.Objects.Components; +using Impostor.Server.Net.Inner.Objects.GameManager; +using Impostor.Server.Net.Inner.Objects.ShipStatus; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.State +{ + internal partial class Game + { + /// + /// Used for global object, spawned by the host. + /// + private const int InvalidClient = -2; + + /// + /// Used internally to set the OwnerId to the current ClientId. + /// i.e: ownerId = ownerId == -3 ? this.ClientId : ownerId; + /// + private const int CurrentClient = -3; + + /// + /// Used to list objects that are managed by the game server. + /// + private const int ServerOwned = -4; + + /// + /// The first NetId that is considered as a server owned Network ID that the client will not allocate by default. + /// + private const int MinServerNetId = 100000; + + private static readonly Dictionary SpawnableObjects = new() + { + [0] = typeof(InnerSkeldShipStatus), + [1] = typeof(InnerMeetingHud), + [2] = typeof(InnerLobbyBehaviour), + [4] = typeof(InnerPlayerControl), + [5] = typeof(InnerMiraShipStatus), + [6] = typeof(InnerPolusShipStatus), + [7] = typeof(InnerDleksShipStatus), + [8] = typeof(InnerAirshipStatus), + [9] = typeof(InnerHideAndSeekManager), + [10] = typeof(InnerNormalGameManager), + [11] = typeof(InnerPlayerInfo), + [12] = typeof(InnerVoteBanSystem), + [13] = typeof(InnerFungleShipStatus), + }; + + private static readonly Dictionary SpawnableObjectIds = SpawnableObjects.ToDictionary((i) => i.Value, (i) => i.Key); + + private readonly List _allObjects = new List(); + + private readonly Dictionary _allObjectsFast = new Dictionary(); + + private uint _nextNetId = MinServerNetId; + + public T? FindObjectByNetId(uint netId) + where T : IInnerNetObject + { + if (_allObjectsFast.TryGetValue(netId, out var obj)) + { + return (T)(IInnerNetObject)obj; + } + + return default; + } + + public async ValueTask HandleGameDataAsync(IMessageReader parent, ClientPlayer sender, bool toPlayer) + { + // Find target player. + ClientPlayer? target = null; + + if (toPlayer) + { + var targetId = parent.ReadPackedInt32(); + if (!TryGetPlayer(targetId, out target)) + { + _logger.LogWarning("Player {0} tried to send GameData to unknown player {1}.", sender.Client.Id, targetId); + return false; + } + + _logger.LogTrace("Received GameData for target {0}.", targetId); + } + + // Parse GameData messages. + while (parent.Position < parent.Length) + { + using var reader = parent.ReadMessage(); + + switch (reader.Tag) + { + case GameDataTag.DataFlag: + { + var netId = reader.ReadPackedUInt32(); + if (_allObjectsFast.TryGetValue(netId, out var obj)) + { + await obj.DeserializeAsync(sender, target, reader, false); + } + else + { + _logger.LogWarning("Received DataFlag for unregistered NetId {0}.", netId); + } + + break; + } + + case GameDataTag.RpcFlag: + { + var netId = reader.ReadPackedUInt32(); + if (_allObjectsFast.TryGetValue(netId, out var obj)) + { + if (!await obj.HandleRpcAsync(sender, target, (RpcCalls)reader.ReadByte(), reader)) + { + parent.RemoveMessage(reader); + continue; + } + } + else + { + _logger.LogWarning("Received RpcFlag for unregistered NetId {0}.", netId); + } + + break; + } + + case GameDataTag.SpawnFlag: + { + // Only the host is allowed to spawn objects. + if (!sender.IsHost) + { + if (await sender.Client.ReportCheatAsync(new CheatContext(nameof(GameDataTag.SpawnFlag)), CheatCategory.MustBeHost, "Tried to send SpawnFlag as non-host.")) + { + return false; + } + } + + var objectId = reader.ReadPackedUInt32(); + if (SpawnableObjects.TryGetValue(objectId, out var spawnableObjectType)) + { + var innerNetObject = (InnerNetObject)ActivatorUtilities.CreateInstance(_serviceProvider, spawnableObjectType, this); + var ownerClientId = reader.ReadPackedInt32(); + + innerNetObject.SpawnFlags = (SpawnFlags)reader.ReadByte(); + + var components = innerNetObject.GetComponentsInChildren(); + var componentsCount = reader.ReadPackedInt32(); + + if (componentsCount != components.Count) + { + _logger.LogError( + "Children didn't match for spawnable {0}, name {1} ({2} != {3})", + objectId, + innerNetObject.GetType().Name, + componentsCount, + components.Count); + continue; + } + + _logger.LogDebug( + "Spawning {0} components, SpawnFlags {1}", + innerNetObject.GetType().Name, + innerNetObject.SpawnFlags); + + for (var i = 0; i < componentsCount; i++) + { + var obj = components[i]; + + obj.NetId = reader.ReadPackedUInt32(); + obj.OwnerId = ownerClientId; + + _logger.LogDebug( + "- {0}, NetId {1}, OwnerId {2}", + obj.GetType().Name, + obj.NetId, + obj.OwnerId); + + if (!AddNetObject(obj)) + { + _logger.LogTrace("Failed to AddNetObject, it already exists."); + + obj.NetId = uint.MaxValue; + break; + } + + using var readerSub = reader.ReadMessage(); + if (readerSub.Length > 0) + { + await obj.DeserializeAsync(sender, target, readerSub, true); + } + + await OnSpawnAsync(sender, obj); + } + + continue; + } + + _logger.LogWarning("Couldn't find spawnable object {0}.", objectId); + break; + } + + // Only the host is allowed to despawn objects. + case GameDataTag.DespawnFlag: + { + var netId = reader.ReadPackedUInt32(); + if (_allObjectsFast.TryGetValue(netId, out var obj)) + { + if (sender.Client.Id != obj.OwnerId && !sender.IsHost) + { + _logger.LogWarning( + "Player {0} ({1}) tried to send DespawnFlag for {2} but was denied.", + sender.Client.Name, + sender.Client.Id, + netId); + return false; + } + + RemoveNetObject(obj); + await OnDestroyAsync(obj); + _logger.LogDebug("Destroyed InnerNetObject {0} ({1}), OwnerId {2}", obj.GetType().Name, netId, obj.OwnerId); + } + else + { + _logger.LogDebug( + "Player {0} ({1}) sent DespawnFlag for unregistered NetId {2}.", + sender.Client.Name, + sender.Client.Id, + netId); + } + + break; + } + + case GameDataTag.SceneChangeFlag: + { + // Sender is only allowed to change his own scene. + var clientId = reader.ReadPackedInt32(); + var scene = reader.ReadString(); + + if (clientId != sender.Client.Id) + { + _logger.LogWarning( + "Player {0} ({1}) tried to send SceneChangeFlag for another player.", + sender.Client.Name, + sender.Client.Id); + return false; + } + + // According to game assembly, sender is only allowed to send OnlineGame. + if (scene != "OnlineGame") + { + _logger.LogWarning( + "Player {PlayerName} ({ClientId}) tried to send SceneChangeFlag with disallowed scene \"{Scene}\".", + sender.Client.Name, + sender.Client.Id, + scene); + return false; + } + + sender.Scene = scene; + + _logger.LogTrace("> Scene {0} to {1}", clientId, sender.Scene); + + await SyncServerObjectsAsync(sender); + await SpawnPlayerInfoAsync(sender); + + break; + } + + case GameDataTag.ReadyFlag: + { + var clientId = reader.ReadPackedInt32(); + + if (clientId != sender.Client.Id) + { + _logger.LogWarning( + "Player {0} ({1}) tried to send ReadyFlag for another player.", + sender.Client.Name, + sender.Client.Id); + return false; + } + + _logger.LogTrace("> IsReady {0}", clientId); + break; + } + + case GameDataTag.ConsoleDeclareClientPlatformFlag: + { + var clientId = reader.ReadPackedInt32(); + var platform = (RuntimePlatform)reader.ReadPackedInt32(); + + if (clientId != sender.Client.Id) + { + if (await sender.Client.ReportCheatAsync(new CheatContext(nameof(GameDataTag.ConsoleDeclareClientPlatformFlag)), CheatCategory.Ownership, "Client sent info with wrong client id")) + { + return false; + } + } + + sender.Platform = platform; + + break; + } + + default: + { + _logger.LogWarning("Bad GameData tag {0}", reader.Tag); + break; + } + } + + if (sender.Client.Player == null) + { + // Disconnect handler was probably invoked, cancel the rest. + return false; + } + } + + return true; + } + + private async ValueTask OnSpawnAsync(ClientPlayer sender, InnerNetObject netObj) + { + switch (netObj) + { + case InnerGameManager innerGameManager: + { + GameNet.GameManager = innerGameManager; + break; + } + + case InnerLobbyBehaviour lobby: + { + GameNet.LobbyBehaviour = lobby; + break; + } + + case InnerPlayerInfo playerInfo: + { + if (!GameNet.GameData.AddPlayer(playerInfo)) + { + _logger.LogWarning( + "Could not add PlayerInfo for playerId {PlayerId} with NetId {newId}, already have NetId {oldNetId}", + playerInfo.PlayerId, + playerInfo.NetId, + GameNet.GameData.GetPlayerById(playerInfo.PlayerId)?.NetId); + } + + break; + } + + case InnerVoteBanSystem voteBan: + { + GameNet.VoteBan = voteBan; + break; + } + + case InnerShipStatus shipStatus: + { + GameNet.ShipStatus = shipStatus; + break; + } + + case InnerPlayerControl control: + { + // Hook up InnerPlayerControl <-> IClientPlayer. + if (TryGetPlayer(control.OwnerId, out var player)) + { + player.Character = control; + player.DisableSpawnTimeout(); + } + else + { + await sender.Client.ReportCheatAsync(new CheatContext(nameof(GameDataTag.SpawnFlag)), CheatCategory.GameFlow, "Failed to find player that spawned the InnerPlayerControl"); + } + + // Hook up InnerPlayerControl <-> InnerPlayerControl.PlayerInfo. + var playerInfo = GameNet.GameData.GetPlayerById(control.PlayerId); + + if (playerInfo != null) + { + playerInfo.Controller = control; + control.PlayerInfo = playerInfo; + } + + if (player != null) + { + await _eventManager.CallAsync(new PlayerSpawnedEvent(this, player, control)); + await Task.Delay(TimeSpan.FromMilliseconds(1500)); + + // From Nmpostor + await _eventManager.CallAsync(new PlayerReadyEvent(this, player, control)); + } + + break; + } + + case InnerMeetingHud meetingHud: + { + foreach (var player in _players.Values) + { + if (GameNet.ShipStatus != null) + { + await player.Character!.NetworkTransform.SetPositionAsync(player, GameNet.ShipStatus.GetSpawnLocation(player.Character, PlayerCount, false)); + } + } + + await _eventManager.CallAsync(new MeetingStartedEvent(this, meetingHud)); + break; + } + } + + await netObj.OnSpawnAsync(); + } + + private async ValueTask OnDestroyAsync(InnerNetObject netObj) + { + switch (netObj) + { + case InnerLobbyBehaviour: + { + GameNet.LobbyBehaviour = null; + break; + } + + case InnerVoteBanSystem: + { + GameNet.VoteBan = null; + break; + } + + case InnerShipStatus: + { + GameNet.ShipStatus = null; + break; + } + + case InnerPlayerInfo playerInfo: + { + if (GameState != GameStates.Started && GameState != GameStates.Starting) + { + GameNet.GameData.RemovePlayer(playerInfo.PlayerId); + } + + break; + } + + case InnerPlayerControl control: + { + // Remove InnerPlayerControl <-> IClientPlayer. + if (TryGetPlayer(control.OwnerId, out var player)) + { + player.Character = null; + await _eventManager.CallAsync(new PlayerDestroyedEvent(this, player, control)); + } + + break; + } + } + } + + private async ValueTask SyncServerObjectsAsync(ClientPlayer sender) + { + foreach (var obj in _allObjectsFast.Values) + { + if (obj.OwnerId == ServerOwned) + { + _logger.LogTrace("Syncing {Type} {NetId}", obj.GetType(), obj.NetId); + await SendObjectSpawnAsync(obj, sender.Client.Id); + } + } + } + + private async ValueTask SpawnPlayerInfoAsync(ClientPlayer sender) + { + // Hosts spawn PlayerInfo objects if they requested authority + if (IsHostAuthoritive) + { + return; + } + + // Only spawn a new PlayerInfo if one has not yet been spawned + if (GameNet.GameData.PlayersByClientId.ContainsKey(sender.Client.Id)) + { + return; + } + + var playerInfo = (InnerPlayerInfo)ActivatorUtilities.CreateInstance(_serviceProvider, typeof(InnerPlayerInfo), this); + playerInfo.SpawnFlags = SpawnFlags.None; + playerInfo.NetId = _nextNetId++; + playerInfo.OwnerId = ServerOwned; + playerInfo.ClientId = sender.Client.Id; + playerInfo.PlayerId = GameNet.GameData.GetNextAvailablePlayerId(); + + // If player played a previous game, restore their color + var prevColor = sender.Client.PreviousColor; + if (prevColor.HasValue) + { + _logger.LogTrace("Color restored to {Color}", prevColor.Value); + playerInfo.CurrentOutfit.Color = prevColor.Value; + } + + if (!AddNetObject(playerInfo)) + { + _logger.LogError("Couldn't spawn PlayerInfo for {Name} ({ClientId})", sender.Client.Name, sender.Client.Id); + playerInfo.NetId = uint.MaxValue; + return; + } + + _logger.LogTrace("Spawning PlayerInfo (netId {Netid})", playerInfo.NetId); + await OnSpawnAsync(sender, playerInfo); + await SendObjectSpawnAsync(playerInfo); + } + + private async ValueTask DespawnPlayerInfoAsync(InnerPlayerInfo playerInfo) + { + if (playerInfo.OwnerId == ServerOwned) + { + _logger.LogDebug("Despawning PlayerInfo {nid}", playerInfo.NetId); + GameNet.GameData.RemovePlayer(playerInfo.PlayerId); + RemoveNetObject(playerInfo); + + await SendObjectDespawnAsync(playerInfo); + } + } + + private bool AddNetObject(InnerNetObject obj) + { + if (_allObjectsFast.ContainsKey(obj.NetId)) + { + return false; + } + + _allObjects.Add(obj); + _allObjectsFast.Add(obj.NetId, obj); + return true; + } + + private void RemoveNetObject(InnerNetObject obj) + { + var index = _allObjects.IndexOf(obj); + if (index > -1) + { + _allObjects.RemoveAt(index); + } + + _allObjectsFast.Remove(obj.NetId); + } + } +} diff --git a/src/Impostor.Server/Net/State/Game.Incoming.cs b/src/Impostor.Server/Net/State/Game.Incoming.cs new file mode 100644 index 0000000..595f4a1 --- /dev/null +++ b/src/Impostor.Server/Net/State/Game.Incoming.cs @@ -0,0 +1,232 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Impostor.Api.Games; +using Impostor.Api.Net; +using Next.Hazel; +using Impostor.Server.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.State +{ + internal partial class Game + { + private readonly SemaphoreSlim _clientAddLock = new SemaphoreSlim(1, 1); + + public async ValueTask HandleStartGame(IMessageReader message) + { + GameState = GameStates.Starting; + using var packet = MessageWriter.Get(MessageType.Reliable); + message.CopyTo(packet); + await SendToAllAsync(packet); + await _eventManager.CallAsync(new GameStartingEvent(this)); + } + + public async ValueTask HandleEndGame(IMessageReader message, GameOverReason gameOverReason) + { + GameState = GameStates.Ended; + using (var packet = MessageWriter.Get(MessageType.Reliable)) + { + message.CopyTo(packet); + await SendToAllAsync(packet); + } + + foreach (var player in _players) + { + player.Value.Limbo = LimboStates.PreSpawn; + } + + foreach (var playerInfo in GameNet.GameData.Players.Values.ToArray()) + { + await DespawnPlayerInfoAsync(playerInfo); + } + + await _eventManager.CallAsync(new GameEndedEvent(this, gameOverReason)); + } + + public async ValueTask HandleAlterGame(IMessageReader message, IClientPlayer sender, bool isPublic) + { + IsPublic = isPublic; + using var packet = MessageWriter.Get(MessageType.Reliable); + message.CopyTo(packet); + await SendToAllExceptAsync(packet, sender.Client.Id); + await _eventManager.CallAsync(new GameAlterEvent(this, isPublic)); + } + + public async ValueTask HandleRemovePlayer(int playerId, DisconnectReason reason) + { + await PlayerRemove(playerId); + if (GameState == GameStates.Destroyed) + { + return; + } + + using var packet = MessageWriter.Get(MessageType.Reliable); + WriteRemovePlayerMessage(packet, false, playerId, reason); + await SendToAllExceptAsync(packet, playerId); + } + + public async ValueTask HandleKickPlayer(int playerId, bool isBan) + { + _logger.LogInformation("{0} - Player {1} has left.", Code, playerId); + using (var kickMsg = MessageWriter.Get(MessageType.Reliable)) + { + WriteKickPlayerMessage(kickMsg, false, playerId, isBan); + await SendToAllAsync(kickMsg); + } + + await PlayerRemove(playerId, isBan); + if (GameState == GameStates.Destroyed) + { + return; + } + + using (var removeMsg = MessageWriter.Get(MessageType.Reliable)) + { + WriteRemovePlayerMessage( + removeMsg, + false, + playerId, + isBan ? DisconnectReason.Banned : DisconnectReason.Kicked); + await SendToAllExceptAsync(removeMsg, playerId); + } + } + + public async ValueTask AddClientAsync(ClientBase client) + { + var hasLock = false; + try + { + hasLock = await _clientAddLock.WaitAsync(TimeSpan.FromMinutes(1)); + if (hasLock) + { + return await AddClientSafeAsync(client); + } + } + finally + { + if (hasLock) + { + _clientAddLock.Release(); + } + } + + return GameJoinResult.FromError(GameJoinError.InvalidClient); + } + + private async ValueTask HandleJoinGameNew(ClientPlayer sender, bool isNew) + { + _logger.LogInformation("{0} - Player {1} ({2}) is joining.", Code, sender.Client.Name, sender.Client.Id); + if (isNew) + { + await PlayerAdd(sender); + } + + sender.InitializeSpawnTimeout(); + using (var message = MessageWriter.Get(MessageType.Reliable)) + { + WriteJoinedGameMessage(message, false, sender); + WriteAlterGameMessage(message, false, IsPublic); + sender.Limbo = LimboStates.NotLimbo; + await SendToAsync(message, sender.Client.Id); + await BroadcastJoinMessage(message, true, sender); + } + } + + private async ValueTask AddClientSafeAsync(ClientBase client) + { + if (_bannedIps.Contains(client.Connection.EndPoint.Address)) + { + return GameJoinResult.FromError(GameJoinError.Banned); + } + + var player = client.Player; + if (_compatibilityConfig.AllowVersionMixing == false && + this.Host != null && client.GameVersion != this.Host.Client.GameVersion) + { + var versionCheckResult = _compatibilityManager.CanJoinGame(Host.Client.GameVersion, client.GameVersion); + if (versionCheckResult != GameJoinError.None) + { + return GameJoinResult.FromError(versionCheckResult); + } + } + + if (GameState == GameStates.Starting || GameState == GameStates.Started) + { + return GameJoinResult.FromError(GameJoinError.GameStarted); + } + + if (GameState == GameStates.Destroyed) + { + return GameJoinResult.FromError(GameJoinError.GameDestroyed); + } + + if (player?.Game != this && _players.Count >= Options.MaxPlayers) + { + return GameJoinResult.FromError(GameJoinError.GameFull); + } + + var isNew = false; + if (player == null || player.Game != this) + { + var clientPlayer = new ClientPlayer(_serviceProvider.GetRequiredService>(), client, this, _timeoutConfig.SpawnTimeout); + if (!_clientManager.Validate(client)) + { + return GameJoinResult.FromError(GameJoinError.InvalidClient); + } + + isNew = true; + player = clientPlayer; + client.Player = clientPlayer; + } + + if (player.Limbo == LimboStates.NotLimbo) + { + return GameJoinResult.FromError(GameJoinError.InvalidLimbo); + } + + if (GameState == GameStates.Ended) + { + await HandleJoinGameNext(player, isNew); + return GameJoinResult.CreateSuccess(player); + } + + var @event = new GamePlayerJoiningEvent(this, player); + await _eventManager.CallAsync(@event); + if (@event.JoinResult != null && !@event.JoinResult.Value.IsSuccess) + { + return @event.JoinResult.Value; + } + + await HandleJoinGameNew(player, isNew); + return GameJoinResult.CreateSuccess(player); + } + + private async ValueTask HandleJoinGameNext(ClientPlayer sender, bool isNew) + { + _logger.LogInformation("{0} - Player {1} ({2}) is rejoining.", Code, sender.Client.Name, sender.Client.Id); + if (isNew) + { + await PlayerAdd(sender); + } + + if (sender.Client.Id == HostId) + { + GameState = GameStates.NotStarted; + await HandleJoinGameNew(sender, false); + await CheckLimboPlayers(); + return; + } + + sender.Limbo = LimboStates.WaitingForHost; + using (var packet = MessageWriter.Get(MessageType.Reliable)) + { + WriteWaitForHostMessage(packet, false, sender); + await SendToAsync(packet, sender.Client.Id); + await BroadcastJoinMessage(packet, true, sender); + } + } + } +} diff --git a/src/Impostor.Server/Net/State/Game.Outgoing.cs b/src/Impostor.Server/Net/State/Game.Outgoing.cs new file mode 100644 index 0000000..5a60d9b --- /dev/null +++ b/src/Impostor.Server/Net/State/Game.Outgoing.cs @@ -0,0 +1,140 @@ +using System.Linq; +using System.Threading.Tasks; +using Impostor.Api.Net; +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Messages; +using Impostor.Api.Net.Messages.S2C; +using Next.Hazel; +using Impostor.Server.Net.Inner; + +namespace Impostor.Server.Net.State +{ + internal partial class Game + { + public async ValueTask SendToAllAsync(IMessageWriter writer, LimboStates states = LimboStates.NotLimbo) + { + foreach (var connection in GetConnections(x => x.Limbo.HasFlag(states))) + { + await connection.SendAsync(writer); + } + } + + public async ValueTask SendToAllExceptAsync(IMessageWriter writer, int senderId, LimboStates states = LimboStates.NotLimbo) + { + foreach (var connection in GetConnections(x => + x.Limbo.HasFlag(states) && + x.Client.Id != senderId)) + { + await connection.SendAsync(writer); + } + } + + public async ValueTask SendToAsync(IMessageWriter writer, int id) + { + if (TryGetPlayer(id, out var player)) + { + await player.Client.Connection.SendAsync(writer); + } + } + + public IMessageWriter StartRpc(uint targetNetId, RpcCalls callId, int? targetClientId = null, MessageType type = MessageType.Reliable) + { + var writer = StartGameData(targetClientId, type); + writer.StartMessage(GameDataTag.RpcFlag); + writer.WritePacked(targetNetId); + writer.Write((byte)callId); + return writer; + } + + public ValueTask FinishRpcAsync(IMessageWriter writer, int? targetClientId = null) + { + writer.EndMessage(); + return FinishGameDataAsync(writer, targetClientId); + } + + public IMessageWriter StartGameData(int? targetClientId = null, MessageType type = MessageType.Reliable) + { + var writer = MessageWriter.Get(type); + if (targetClientId == null || targetClientId < 0) + { + writer.StartMessage(MessageFlags.GameData); + Code.Serialize(writer); + } + else + { + writer.StartMessage(MessageFlags.GameDataTo); + Code.Serialize(writer); + writer.WritePacked(targetClientId.Value); + } + + return writer; + } + + public ValueTask FinishGameDataAsync(IMessageWriter writer, int? targetClientId = null) + { + writer.EndMessage(); + return targetClientId.HasValue + ? SendToAsync(writer, targetClientId.Value) + : SendToAllAsync(writer); + } + + private void WriteRemovePlayerMessage(IMessageWriter message, bool clear, int playerId, DisconnectReason reason) + { + Message04RemovePlayerS2C.Serialize(message, clear, Code, playerId, HostId, reason); + } + + private void WriteJoinedGameMessage(IMessageWriter message, bool clear, IClientPlayer player) + { + var players = _players + .Where(x => x.Value != player) + .Select(x => x.Value) + .ToArray(); + Message07JoinedGameS2C.Serialize(message, clear, Code, player.Client.Id, HostId, players); + } + + private void WriteAlterGameMessage(IMessageWriter message, bool clear, bool isPublic) + { + Message10AlterGameS2C.Serialize(message, clear, Code, isPublic); + } + + private void WriteKickPlayerMessage(IMessageWriter message, bool clear, int playerId, bool isBan) + { + Message11KickPlayerS2C.Serialize(message, clear, Code, playerId, isBan); + } + + private void WriteWaitForHostMessage(IMessageWriter message, bool clear, IClientPlayer player) + { + Message12WaitForHostS2C.Serialize(message, clear, Code, player.Client.Id); + } + + private async ValueTask SendObjectSpawnAsync(InnerNetObject obj, int? targetClientId = null) + { + using var writer = StartGameData(targetClientId); + writer.StartMessage(GameDataTag.SpawnFlag); + writer.WritePacked(SpawnableObjectIds[obj.GetType()]); + writer.WritePacked(obj.OwnerId); + writer.Write((byte)obj.SpawnFlags); + var components = obj.GetComponentsInChildren(); + writer.WritePacked(components.Count); + foreach (var component in components) + { + writer.WritePacked(obj.NetId); + writer.StartMessage(1); + await component.SerializeAsync(writer, true); + writer.EndMessage(); + } + + writer.EndMessage(); + await FinishGameDataAsync(writer, targetClientId); + } + + private async ValueTask SendObjectDespawnAsync(InnerNetObject obj, int? targetClientId = null) + { + using var writer = StartGameData(targetClientId); + writer.StartMessage(GameDataTag.DespawnFlag); + writer.WritePacked(obj.NetId); + writer.EndMessage(); + await FinishGameDataAsync(writer, targetClientId); + } + } +} diff --git a/src/Impostor.Server/Net/State/Game.State.cs b/src/Impostor.Server/Net/State/Game.State.cs new file mode 100644 index 0000000..3806df7 --- /dev/null +++ b/src/Impostor.Server/Net/State/Game.State.cs @@ -0,0 +1,125 @@ +using System.Linq; +using System.Threading.Tasks; +using Impostor.Api; +using Impostor.Api.Net; +using Next.Hazel; +using Impostor.Server.Events; +using Impostor.Server.Net.Hazel; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Net.State +{ + internal partial class Game + { + private async ValueTask PlayerAdd(ClientPlayer player) + { + if (!_players.TryAdd(player.Client.Id, player)) + { + throw new ImpostorException("Failed to add player to game."); + } + + if (HostId == -1) + { + HostId = player.Client.Id; + } + + await _eventManager.CallAsync(new GamePlayerJoinedEvent(this, player)); + } + + private async ValueTask PlayerRemove(int playerId, bool isBan = false) + { + if (!_players.TryRemove(playerId, out var player)) + { + return false; + } + + _logger.LogInformation("{0} - Player {1} ({2}) has left.", Code, player.Client.Name, playerId); + if (GameState == GameStates.Starting || GameState == GameStates.Started || GameState == GameStates.NotStarted) + { + if (player.Character?.PlayerInfo != null) + { + player.Character.PlayerInfo.Disconnected = true; + player.Character.PlayerInfo.LastDeathReason = DeathReason.Disconnect; + } + } + + player.Client.Player = null; + await player.DisposeAsync(); + if (HostId == playerId) + { + await MigrateHost(); + await _eventManager.CallAsync(new GameHostChangedEvent(this, player, Host)); + } + + if (_players.IsEmpty || Host == null) + { + GameState = GameStates.Destroyed; + await _gameManager.RemoveAsync(Code); + return true; + } + + if (isBan) + { + BanIp(player.Client.Connection.EndPoint.Address); + } + + await _eventManager.CallAsync(new GamePlayerLeftEvent(this, player, isBan)); + _ = Task.Run(async () => + { + await Task.Delay(_timeoutConfig.ConnectionTimeout); + if (player.Client.Connection.IsConnected && player.Client.Connection is HazelConnection hazel) + { + _logger.LogInformation("{0} - Player {1} ({2}) kept connection open after leaving, disposing.", Code, player.Client.Name, playerId); + await player.Client.DisconnectAsync(isBan ? DisconnectReason.Banned : DisconnectReason.Kicked); + } + }); + if (GameState == GameStates.NotStarted) + { + if (GameNet.GameData.PlayersByClientId.TryGetValue(playerId, out var playerInfo)) + { + await DespawnPlayerInfoAsync(playerInfo); + } + } + + return true; + } + + private async ValueTask MigrateHost() + { + var host = _players.Values + .OrderBy(p => p.Client.Id) + .FirstOrDefault(); + if (host == null) + { + return; + } + + foreach (var player in _players.Values) + { + player.Character?.RequestedPlayerName.Clear(); + player.Character?.RequestedColorId.Clear(); + } + + HostId = host.Client.Id; + _logger.LogInformation("{0} - Assigned {1} ({2}) as new host.", Code, host.Client.Name, host.Client.Id); + if (GameState == GameStates.Ended && host.Limbo == LimboStates.WaitingForHost) + { + GameState = GameStates.NotStarted; + await HandleJoinGameNew(host, false); + await CheckLimboPlayers(); + } + } + + private async ValueTask CheckLimboPlayers() + { + foreach (var (_, player) in _players.Where(x => x.Value.Limbo == LimboStates.WaitingForHost)) + { + using var message = MessageWriter.Get(MessageType.Reliable); + WriteJoinedGameMessage(message, true, player); + WriteAlterGameMessage(message, false, IsPublic); + player.Limbo = LimboStates.NotLimbo; + await SendToAsync(message, player.Client.Id); + } + } + } +} diff --git a/src/Impostor.Server/Net/State/Game.cs b/src/Impostor.Server/Net/State/Game.cs new file mode 100644 index 0000000..920f351 --- /dev/null +++ b/src/Impostor.Server/Net/State/Game.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Impostor.Api.Config; +using Impostor.Api.Events.Managers; +using Impostor.Api.Games; +using Impostor.Api.Innersloth.Customization; +using Impostor.Api.Innersloth.GameOptions; +using Impostor.Api.Net; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Api.Net.Manager; +using Impostor.Api.Net.Messages.S2C; +using Impostor.Server.Events; +using Impostor.Server.Net.Manager; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Net.State +{ + internal partial class Game + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly GameManager _gameManager; + private readonly ClientManager _clientManager; + private readonly ConcurrentDictionary _players; + private readonly HashSet _bannedIps; + private readonly IEventManager _eventManager; + private readonly ICompatibilityManager _compatibilityManager; + private readonly CompatibilityConfig _compatibilityConfig; + private readonly TimeoutConfig _timeoutConfig; + + public Game( + ILogger logger, + IServiceProvider serviceProvider, + GameManager gameManager, + IPEndPoint publicIp, + GameCode code, + IGameOptions options, + GameFilterOptions filterOptions, + ClientManager clientManager, + IEventManager eventManager, + ICompatibilityManager compatibilityManager, + IOptions compatibilityConfig, + IOptions timeoutConfig) + { + _logger = logger; + _serviceProvider = serviceProvider; + _gameManager = gameManager; + _players = new ConcurrentDictionary(); + _bannedIps = new HashSet(); + PublicIp = publicIp; + Code = code; + HostId = -1; + GameState = GameStates.NotStarted; + GameNet = new GameNet(); + Options = options; + FilterOptions = filterOptions; + _clientManager = clientManager; + _eventManager = eventManager; + _compatibilityManager = compatibilityManager; + _compatibilityConfig = compatibilityConfig.Value; + _timeoutConfig = timeoutConfig.Value; + Items = new ConcurrentDictionary(); + } + + public IPEndPoint PublicIp { get; } + + public GameCode Code { get; } + + public bool IsPublic { get; private set; } + + public string? DisplayName { get; set; } + + public string? Note { get; set; } + + public int HostId { get; private set; } + + public GameStates GameState { get; private set; } + + public IGameOptions Options { get; } + + public GameFilterOptions FilterOptions { get; } + + public IDictionary Items { get; } + + public int PlayerCount => _players.Count; + + public ClientPlayer? Host => _players.GetValueOrDefault(HostId); + + public IEnumerable Players => _players.Select(p => p.Value); + + public bool IsHostAuthoritive => Host != null && Host.Client.GameVersion.HasDisableServerAuthorityFlag; + + internal GameNet GameNet { get; } + + public bool TryGetPlayer(int id, [MaybeNullWhen(false)] out ClientPlayer player) + { + if (_players.TryGetValue(id, out var result)) + { + player = result; + return true; + } + + player = default; + return false; + } + + public IClientPlayer? GetClientPlayer(int clientId) + { + return _players.TryGetValue(clientId, out var clientPlayer) ? clientPlayer : null; + } + + internal async ValueTask StartedAsync() + { + if (GameState == GameStates.Starting) + { + foreach (var player in _players.Values) + { + if (GameNet.ShipStatus != null) + { + await player.Character!.NetworkTransform.SetPositionAsync(player, GameNet.ShipStatus.GetSpawnLocation(player.Character, PlayerCount, true)); + } + } + + GameState = GameStates.Started; + await _eventManager.CallAsync(new GameStartedEvent(this)); + } + } + + internal bool IsColorUsed(ColorType color, IInnerPlayerControl? exceptBy = null) + { + return Players.Any(p => p.Character != null && + p.Character != exceptBy && + p.Character.PlayerInfo != null && + p.Character.PlayerInfo.CurrentOutfit.Color == color); + } + + private ValueTask BroadcastJoinMessage(IMessageWriter message, bool clear, ClientPlayer player) + { + Message01JoinGameS2C.SerializeJoin(message, clear, Code, player, HostId); + return SendToAllExceptAsync(message, player.Client.Id); + } + + private IEnumerable GetConnections(Func filter) + { + return Players + .Where(filter) + .Select(p => p.Client.Connection) + .Where(c => c != null && c.IsConnected)!; + } + } +} diff --git a/src/Impostor.Server/Net/State/GameNet.Api.cs b/src/Impostor.Server/Net/State/GameNet.Api.cs new file mode 100644 index 0000000..d57ec1d --- /dev/null +++ b/src/Impostor.Server/Net/State/GameNet.Api.cs @@ -0,0 +1,21 @@ +using Impostor.Api.Net.Inner; +using Impostor.Api.Net.Inner.Objects; +using Impostor.Api.Net.Inner.Objects.GameManager; +using Impostor.Api.Net.Inner.Objects.ShipStatus; + +namespace Impostor.Server.Net.State +{ + /// + internal partial class GameNet : IGameNet + { + IInnerGameManager? IGameNet.GameManager => GameManager; + + IInnerLobbyBehaviour? IGameNet.LobbyBehaviour => LobbyBehaviour; + + IInnerGameData IGameNet.GameData => GameData; + + IInnerVoteBanSystem? IGameNet.VoteBan => VoteBan; + + IInnerShipStatus? IGameNet.ShipStatus => ShipStatus; + } +} diff --git a/src/Impostor.Server/Net/State/GameNet.cs b/src/Impostor.Server/Net/State/GameNet.cs new file mode 100644 index 0000000..25efec6 --- /dev/null +++ b/src/Impostor.Server/Net/State/GameNet.cs @@ -0,0 +1,20 @@ +using Impostor.Server.Net.Inner.Objects; +using Impostor.Server.Net.Inner.Objects.Components; +using Impostor.Server.Net.Inner.Objects.GameManager; +using Impostor.Server.Net.Inner.Objects.ShipStatus; + +namespace Impostor.Server.Net.State +{ + internal partial class GameNet + { + public InnerGameManager? GameManager { get; internal set; } + + public InnerLobbyBehaviour? LobbyBehaviour { get; internal set; } + + public InnerGameData GameData { get; internal set; } = new(); + + public InnerVoteBanSystem? VoteBan { get; internal set; } + + public InnerShipStatus? ShipStatus { get; internal set; } + } +} diff --git a/src/Impostor.Server/Pages/AdminStrings.json b/src/Impostor.Server/Pages/AdminStrings.json new file mode 100644 index 0000000..91db38b --- /dev/null +++ b/src/Impostor.Server/Pages/AdminStrings.json @@ -0,0 +1,214 @@ +{ + "login.title": "Empostor Admin", + "login.password": "Password", + "login.placeholder": "Enter admin password", + "login.signin": "Sign in", + "login.error": "Incorrect password.", + + "header.signout": "Sign out", + + "nav.monitor": "Monitor", + "nav.overview": "Overview", + "nav.games": "Games", + "nav.clients": "Clients", + "nav.broadcast": "Broadcast", + "nav.kick": "Kick", + "nav.ban": "Ban", + "nav.banlist": "Ban List", + "nav.message": "Message", + "nav.endgame": "End Game", + "nav.privacy": "Privacy", + "nav.marketplace": "Marketplace", + "nav.updates": "Updates", + "nav.reports": "Reports", + "nav.serverinfo": "Server Info", + "nav.actions": "Actions", + "nav.gamecontrol": "Game Control", + "nav.extend": "Extend", + "nav.system": "System", + "nav.player_logs": "Player Logs", + "nav.statistics": "Statistics", + "nav.chat_filter": "Chat Filter", + "nav.reload_lang": "Reload Lang", + + "status.games": "Games", + "status.active": "Active", + "status.players": "Players", + "status.bans": "Bans", + "status.uptime": "Uptime", + "status.active_games": "Active Games", + "status.public": "public", + + "table.code": "Code", + "table.state": "State", + "table.visibility": "Visibility", + "table.map": "Map", + "table.players": "Players", + "table.host": "Host", + "table.host_fc": "Host / FC", + "table.members": "Members", + "table.id": "ID", + "table.name": "Name", + "table.friend_code": "Friend Code", + "table.ip": "IP", + "table.version": "Version", + "table.platform": "Platform", + "table.mods": "Mods", + "table.in_game": "In Game", + "table.reason": "Reason", + "table.time": "Time", + "table.game": "Game", + "table.reporter": "Reporter", + "table.reported": "Reported", + "table.outcome": "Outcome", + + "broadcast.title": "Broadcast to All Games", + "broadcast.placeholder": "Message...", + "broadcast.send": "Send to All Games", + + "kick.title": "Kick by Client ID", + "kick.placeholder": "Client ID", + "kick.button": "Kick", + "kick.quick": "Quick Kick", + + "ban.title_ip": "Ban IP", + "ban.title_fc": "Ban Friend Code", + "ban.ip_placeholder": "1.2.3.4", + "ban.fc_placeholder": "Name#1234", + "ban.reason_placeholder": "Optional...", + "ban.button_ip": "Ban IP", + "ban.button_fc": "Ban FC", + "ban.banned_ips": "Banned IPs", + "ban.banned_fcs": "Banned Friend Codes", + "ban.none": "None", + "ban.unban": "Unban", + + "message.title": "Message to Game", + "message.code_placeholder": "ABCDEF", + "message.msg_placeholder": "Message...", + "message.send": "Send", + + "endgame.title": "Force End Game", + "endgame.code_placeholder": "ABCDEF", + "endgame.button": "End Game", + "endgame.active_games": "Active Games", + "endgame.end": "End", + + "privacy.title": "Set Game Privacy", + "privacy.code_placeholder": "ABCDEF", + "privacy.public": "Public", + "privacy.private": "Private", + "privacy.apply": "Apply", + + "marketplace.title": "Plugin Marketplace", + "marketplace.refresh": "Refresh", + "marketplace.install": "Install", + "marketplace.installed": "Installed", + "marketplace.installing": "Installing...", + "marketplace.restart": "Installed. Restart server to enable.", + "marketplace.loading": "Loading...", + "marketplace.no_plugins": "No plugins.", + + "updates.title": "Server Update Check", + "updates.check": "Check for Updates", + "updates.checking": "Checking...", + "updates.click_check": "Click Check to query GitHub.", + "updates.up_to_date": "Up to date", + "updates.update_available": "Update available", + "updates.current": "Current", + "updates.latest": "Latest", + "updates.release": "Release", + "updates.update_hint": "A new version is available. Update manually.", + + "reports.title": "Player Reports", + "reports.no_reports": "No reports yet.", + "reports.refresh": "Refresh", + + "serverinfo.title": "Server Info", + "serverinfo.started": "Started", + "serverinfo.uptime": "Uptime", + "serverinfo.pid": "PID", + "serverinfo.runtime": "Runtime", + "serverinfo.os": "OS", + + "games.none": "No games", + "games.private": "Private", + "games.public": "Public", + "games.host": "Host", + + "clients.none": "No clients", + "clients.lobby": "Lobby", + "clients.mod_count": "mod(s)", + "clients.detail": "Client Detail", + "clients.reactor_mods": "Reactor Mods", + "clients.no_mods": "No mods.", + "clients.protocol": "Protocol", + "clients.name_label": "Name", + "clients.fc_label": "Friend Code", + "clients.ip_label": "IP", + "clients.id_label": "Client ID", + "clients.version_label": "Version", + "clients.platform_label": "Platform", + "clients.language_label": "Language", + "clients.ingame_label": "In Game", + + "reactor.required_yes": "Yes", + "reactor.required_no": "No", + + "alert.kicked": "Kicked {0}", + "alert.sent_to": "Sent to {0} game(s)", + "alert.banned": "Banned {0} ({1} disconnected)", + "alert.sent": "Sent", + "alert.ended": "Ended ({0} kicked)", + "alert.message_required": "Message required", + "alert.ip_required": "IP required", + "alert.fc_required": "FC required", + "alert.code_required": "Code required", + "alert.both_required": "Both required", + + "privacy_policy.title": "Privacy Policy for this Server", + "privacy_policy.agree": "I have read and understand this privacy policy, and I will ensure it is accessible to all players on my server.", + "privacy_policy.confirm": "Confirm", + + "player_logs.title": "Player Logs", + "player_logs.select_client": "Select a player...", + "player_logs.all_types": "All types", + "player_logs.refresh": "Refresh", + "player_logs.export": "Export JSON", + "player_logs.no_logs": "No logs.", + + "table.type": "Type", + "table.player": "Player", + "table.detail": "Detail", + + "stats.title": "Player Statistics", + "stats.refresh": "Refresh", + "stats.reset": "Reset All", + "stats.rank": "#", + "stats.games": "Games", + "stats.wins": "Wins", + "stats.losses": "Losses", + "stats.impostor_wins": "Imp. Wins", + "stats.kills": "Kills", + "stats.deaths": "Deaths", + "stats.tasks": "Tasks", + "stats.exiled": "Exiled", + "stats.empty": "Statistics not enabled in config.", + "stats.no_players": "No player stats recorded yet.", + "stats.confirm_reset": "Reset all player statistics? This cannot be undone.", + + "chatfilter.title": "Chat Filter", + "chatfilter.enabled": "Enable Filtering", + "chatfilter.block_message": "Block messages (uncheck = log only)", + "chatfilter.words_title": "Blocked Words", + "chatfilter.words_placeholder": "Add a word...", + "chatfilter.add_word": "Add", + "chatfilter.no_words": "No blocked words added.", + "chatfilter.spam_title": "Spam Protection", + "chatfilter.threshold": "Message threshold", + "chatfilter.window": "Window (seconds)", + "chatfilter.save": "Save Settings", + "chatfilter.saved": "Settings saved.", + "chatfilter.word_added": "Word added.", + "chatfilter.word_removed": "Word removed." +} diff --git a/src/Impostor.Server/Pages/index.html b/src/Impostor.Server/Pages/index.html new file mode 100644 index 0000000..1b12467 --- /dev/null +++ b/src/Impostor.Server/Pages/index.html @@ -0,0 +1,20 @@ + + + + + +Empostor Server + + +
+
+

Empostor is running

+

Your private Among Us server is online and accepting connections.

+ +
+
Started 2026-05-15 16:16 UTC  ·  Edit this page at Pages/index.html
+ + diff --git a/src/Impostor.Server/Plugins/AssemblyInformation.cs b/src/Impostor.Server/Plugins/AssemblyInformation.cs new file mode 100644 index 0000000..df1bf84 --- /dev/null +++ b/src/Impostor.Server/Plugins/AssemblyInformation.cs @@ -0,0 +1,38 @@ +using System.IO; +using System.Reflection; +using System.Runtime.Loader; + +namespace Impostor.Server.Plugins +{ + public class AssemblyInformation : IAssemblyInformation + { + private Assembly? _assembly; + + public AssemblyInformation(AssemblyName assemblyName, string path, bool isPlugin) + { + AssemblyName = assemblyName; + Path = path; + IsPlugin = isPlugin; + } + + public string Path { get; } + + public bool IsPlugin { get; } + + public AssemblyName AssemblyName { get; } + + public Assembly Load(AssemblyLoadContext context) + { + if (_assembly != null) + { + return _assembly; + } + + using var stream = File.Open(Path, FileMode.Open, FileAccess.Read, FileShare.Read); + + _assembly = context.LoadFromStream(stream); + + return _assembly; + } + } +} diff --git a/src/Impostor.Server/Plugins/DependencyInformation.cs b/src/Impostor.Server/Plugins/DependencyInformation.cs new file mode 100644 index 0000000..23bf935 --- /dev/null +++ b/src/Impostor.Server/Plugins/DependencyInformation.cs @@ -0,0 +1,18 @@ +using Impostor.Api.Plugins; + +namespace Impostor.Server.Plugins +{ + public class DependencyInformation + { + private readonly ImpostorDependencyAttribute _attribute; + + public DependencyInformation(ImpostorDependencyAttribute attribute) + { + _attribute = attribute; + } + + public string Id => _attribute.Id; + + public DependencyType DependencyType => _attribute.DependencyType; + } +} diff --git a/src/Impostor.Server/Plugins/IAssemblyInformation.cs b/src/Impostor.Server/Plugins/IAssemblyInformation.cs new file mode 100644 index 0000000..f724cf2 --- /dev/null +++ b/src/Impostor.Server/Plugins/IAssemblyInformation.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Impostor.Server.Plugins +{ + public interface IAssemblyInformation + { + AssemblyName AssemblyName { get; } + + bool IsPlugin { get; } + + Assembly Load(AssemblyLoadContext context); + } +} diff --git a/src/Impostor.Server/Plugins/LoadedAssemblyInformation.cs b/src/Impostor.Server/Plugins/LoadedAssemblyInformation.cs new file mode 100644 index 0000000..5f7d0bb --- /dev/null +++ b/src/Impostor.Server/Plugins/LoadedAssemblyInformation.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Impostor.Server.Plugins +{ + public class LoadedAssemblyInformation : IAssemblyInformation + { + private readonly Assembly _assembly; + + public LoadedAssemblyInformation(Assembly assembly) + { + AssemblyName = assembly.GetName(); + _assembly = assembly; + } + + public AssemblyName AssemblyName { get; } + + public bool IsPlugin => false; + + public Assembly Load(AssemblyLoadContext context) + { + return _assembly; + } + } +} diff --git a/src/Impostor.Server/Plugins/PluginConfig.cs b/src/Impostor.Server/Plugins/PluginConfig.cs new file mode 100644 index 0000000..66afceb --- /dev/null +++ b/src/Impostor.Server/Plugins/PluginConfig.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Impostor.Server.Plugins +{ + public class PluginConfig + { + public List Paths { get; set; } = new List(); + + public List LibraryPaths { get; set; } = new List(); + } +} diff --git a/src/Impostor.Server/Plugins/PluginInformation.cs b/src/Impostor.Server/Plugins/PluginInformation.cs new file mode 100644 index 0000000..9caaed0 --- /dev/null +++ b/src/Impostor.Server/Plugins/PluginInformation.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Impostor.Api.Plugins; + +namespace Impostor.Server.Plugins +{ + public class PluginInformation + { + private readonly ImpostorPluginAttribute _attribute; + + public PluginInformation(IPluginStartup? startup, Type pluginType) + { + _attribute = pluginType.GetCustomAttribute()!; + + var assembly = pluginType.Assembly; + Name = _attribute.Name ?? assembly.GetCustomAttribute()?.Title ?? assembly.GetName().Name!; + Author = _attribute.Author ?? assembly.GetCustomAttribute()?.Company ?? string.Empty; + Version = _attribute.Version ?? assembly.GetCustomAttribute()?.InformationalVersion ?? assembly.GetName().Version!.ToString(); + + Dependencies = pluginType.GetCustomAttributes().Select(t => new DependencyInformation(t)).ToList(); + Startup = startup; + PluginType = pluginType; + + var assemblyDir = Path.GetDirectoryName(assembly.Location) ?? Directory.GetCurrentDirectory(); + ConfigPath = Path.Combine(assemblyDir, $"[{Name}]Config.json"); + + startup?.SetConfigPath(ConfigPath); + } + + public string Id => _attribute.Id; + + public string Name { get; } + + public string Author { get; } + + public string Version { get; } + + public string ConfigPath { get; } + + public List Dependencies { get; } + + public IPluginStartup? Startup { get; } + + public Type PluginType { get; } + + public IPlugin? Instance { get; set; } + + public override string ToString() => $"{Id} {Name} ({Version}) by {Author}"; + } +} diff --git a/src/Impostor.Server/Plugins/PluginLoader.cs b/src/Impostor.Server/Plugins/PluginLoader.cs new file mode 100644 index 0000000..4148c6d --- /dev/null +++ b/src/Impostor.Server/Plugins/PluginLoader.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using Impostor.Api.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Impostor.Server.Plugins +{ + public static class PluginLoader + { + private static readonly ILogger Logger = Log.ForContext(typeof(PluginLoader)); + + public static IHostBuilder UsePluginLoader(this IHostBuilder builder, PluginConfig config) + { + var assemblyInfos = new List(); + var context = AssemblyLoadContext.Default; + + // Add the plugins and libraries. + var pluginPaths = new List(config.Paths); + var libraryPaths = new List(config.LibraryPaths); + CheckPaths(pluginPaths); + CheckPaths(libraryPaths); + + var rootFolder = Directory.GetCurrentDirectory(); + + pluginPaths.Add(Path.Combine(rootFolder, "plugins")); + libraryPaths.Add(Path.Combine(rootFolder, "libraries")); + + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); + matcher.AddInclude("*.dll"); + matcher.AddExclude("Impostor.Api.dll"); + + RegisterAssemblies(pluginPaths, matcher, assemblyInfos, true); + RegisterAssemblies(libraryPaths, matcher, assemblyInfos, false); + + // Register the resolver to the current context. + // TODO: Move this to a new context so we can unload/reload plugins. + context.Resolving += (loadContext, name) => + { + Logger.Verbose("Loading assembly {0} v{1}", name.Name, name.Version); + + // Some plugins may be referencing another Impostor.Api version and try to load it. + // We want to only use the one shipped with the server. + if (name.Name == "Impostor.Api") + { + return typeof(IPlugin).Assembly; + } + + var info = assemblyInfos.FirstOrDefault(a => a.AssemblyName.Name == name.Name); + + return info?.Load(loadContext); + }; + + // TODO: Catch uncaught exceptions. + var assemblies = assemblyInfos + .Where(a => a.IsPlugin) + .Select(a => context.LoadFromAssemblyName(a.AssemblyName)) + .ToList(); + + // Find all plugins. + var plugins = new List(); + + foreach (var assembly in assemblies) + { + // Find plugin startup. + var pluginStartup = assembly + .GetTypes() + .Where(t => typeof(IPluginStartup).IsAssignableFrom(t) && t.IsClass) + .ToList(); + + if (pluginStartup.Count > 1) + { + Logger.Warning("A plugin may only define zero or one IPluginStartup implementation ({0}).", assembly); + continue; + } + + // Find plugin. + var plugin = assembly + .GetTypes() + .Where(t => typeof(IPlugin).IsAssignableFrom(t) + && t.IsClass + && !t.IsAbstract + && t.GetCustomAttribute() != null) + .ToList(); + + if (plugin.Count != 1) + { + Logger.Warning("A plugin must define exactly one IPlugin or PluginBase implementation ({0}).", assembly); + continue; + } + + // Save plugin. + plugins.Add(new PluginInformation( + pluginStartup + .Select(Activator.CreateInstance) + .Cast() + .FirstOrDefault(), + plugin.Single())); + } + + var orderedPlugins = LoadOrderPlugins(plugins); + + foreach (var plugin in orderedPlugins) + { + plugin.Startup?.ConfigureHost(builder); + } + + builder.ConfigureServices(services => + { + services.AddSingleton(provider => ActivatorUtilities.CreateInstance(provider, orderedPlugins)); + services.AddSingleton(p => p.GetRequiredService()); + + foreach (var plugin in orderedPlugins) + { + plugin.Startup?.ConfigureServices(services); + } + }); + + return builder; + } + + private static void CheckPaths(IEnumerable paths) + { + foreach (var path in paths) + { + if (!Directory.Exists(path)) + { + Logger.Warning("Path {path} was specified in the PluginLoader configuration, but this folder doesn't exist!", path); + } + } + } + + private static void RegisterAssemblies( + IEnumerable paths, + Matcher matcher, + ICollection assemblyInfos, + bool isPlugin) + { + foreach (var path in paths.SelectMany(matcher.GetResultsInFullPath)) + { + AssemblyName assemblyName; + + try + { + assemblyName = AssemblyName.GetAssemblyName(path); + } + catch (BadImageFormatException) + { + continue; + } + + assemblyInfos.Add(new AssemblyInformation(assemblyName, path, isPlugin)); + } + } + + private static List LoadOrderPlugins(IEnumerable plugins) + { + var pluginDictionary = new Dictionary(); + var hardDependencies = new Dictionary>(); + + foreach (var plugin in plugins) + { + pluginDictionary[plugin.Id] = plugin; + hardDependencies[plugin.Id] = plugin + .Dependencies + .Where(p => p.DependencyType == DependencyType.HardDependency) + .Select(p => p.Id) + .ToList(); + } + + var presentPlugins = pluginDictionary.Keys.ToList(); + + // Check whether the Hard Dependencies are present and remove those without. + var checkedPlugins = CheckHardDependencies(presentPlugins, hardDependencies); + + var dependencyGraph = checkedPlugins.ToDictionary(p => p, _ => new List()); + + foreach (var plugin in checkedPlugins) + { + foreach (var dependency in pluginDictionary[plugin].Dependencies.Where(d => checkedPlugins.Contains(d.Id))) + { + if (dependency.DependencyType == DependencyType.LoadBefore) + { + dependencyGraph[dependency.Id].Add(plugin); + } + else + { + dependencyGraph[plugin].Add(dependency.Id); + } + } + } + + var processed = new List(); + var ordered = new List(); + foreach (var plugin in checkedPlugins) + { + if (!processed.Contains(plugin)) + { + RecursiveOrder(plugin, dependencyGraph, processed, ordered, pluginDictionary); + } + } + + return ordered; + } + + private static List CheckHardDependencies( + List plugins, + IReadOnlyDictionary> hardDependencies) + { + foreach (var plugin in plugins) + { + if (!hardDependencies.ContainsKey(plugin)) + { + continue; + } + + foreach (var dependency in hardDependencies[plugin].Where(dependency => !plugins.Contains(dependency))) + { + Logger.Error( + "The plugin {plugin} has defined the plugin {dependency} as a hard dependency but its not present! {plugin} will not loaded.", + plugin, + dependency, + plugin + ); + + // Remove the plugin from the plugins to load. + plugins.Remove(plugin); + + // Since other plugins might have defined the removed plugin as a hard dependency a recheck is necessary. + return CheckHardDependencies(plugins, hardDependencies); + } + } + + return plugins; + } + + private static void RecursiveOrder( + string plugin, + IReadOnlyDictionary> dependencyGraph, + ICollection processed, + ICollection ordered, + IReadOnlyDictionary pluginDictionary) + { + processed.Add(plugin); + + foreach (var dependency in dependencyGraph[plugin]) + { + // First add the dependencies using a recursive call before adding itself. + if (!processed.Contains(dependency)) + { + RecursiveOrder(dependency, dependencyGraph, processed, ordered, pluginDictionary); + } + } + + ordered.Add(pluginDictionary[plugin]); + } + } +} diff --git a/src/Impostor.Server/Plugins/PluginLoaderException.cs b/src/Impostor.Server/Plugins/PluginLoaderException.cs new file mode 100644 index 0000000..7c47c30 --- /dev/null +++ b/src/Impostor.Server/Plugins/PluginLoaderException.cs @@ -0,0 +1,20 @@ +using System; +using Impostor.Api; + +namespace Impostor.Server.Plugins +{ + public class PluginLoaderException : ImpostorException + { + public PluginLoaderException() + { + } + + public PluginLoaderException(string? message) : base(message) + { + } + + public PluginLoaderException(string? message, Exception? innerException) : base(message, innerException) + { + } + } +} diff --git a/src/Impostor.Server/Plugins/PluginLoaderService.cs b/src/Impostor.Server/Plugins/PluginLoaderService.cs new file mode 100644 index 0000000..56807fc --- /dev/null +++ b/src/Impostor.Server/Plugins/PluginLoaderService.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Impostor.Api.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Plugins +{ + public class PluginLoaderService : IHostedService + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly List _plugins; + + public PluginLoaderService(ILogger logger, IServiceProvider serviceProvider, List plugins) + { + _logger = logger; + _serviceProvider = serviceProvider; + _plugins = plugins; + } + + public IReadOnlyList Plugins => _plugins; + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Loading plugins."); + + foreach (var plugin in _plugins) + { + _logger.LogInformation("Enabling plugin {0}.", plugin); + + var instance = (IPlugin)ActivatorUtilities.CreateInstance(_serviceProvider, plugin.PluginType); + + // Inject ConfigPath into PluginBase so LoadConfig() works without arguments + if (instance is PluginBase pb) + { + pb.ConfigPath = plugin.ConfigPath; + } + + plugin.Instance = instance; + await plugin.Instance.EnableAsync(); + } + + _logger.LogInformation( + _plugins.Count == 1 ? "Loaded {0} plugin." : "Loaded {0} plugins.", + _plugins.Count); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + foreach (var plugin in _plugins) + { + if (plugin.Instance != null) + { + _logger.LogInformation("Disabling plugin {0}.", plugin); + await plugin.Instance.DisableAsync(); + } + } + } + } +} diff --git a/src/Impostor.Server/Program.cs b/src/Impostor.Server/Program.cs new file mode 100644 index 0000000..fb92ef6 --- /dev/null +++ b/src/Impostor.Server/Program.cs @@ -0,0 +1,246 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.Loader; +using Impostor.Api.Config; +using Impostor.Api.Events; +using Impostor.Api.Events.Managers; +using Impostor.Api.Games; +using Impostor.Api.Games.Managers; +using Impostor.Api.Languages; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Manager; +using Impostor.Api.Plugins; +using Impostor.Api.Utils; +using Impostor.Server.Commands; +using Impostor.Server.Commands.Commands; +using Impostor.Server.Events; +using Impostor.Server.Http; +using Impostor.Server.Net; +using Impostor.Server.Net.Custom; +using Impostor.Server.Net.Factories; +using Impostor.Server.Net.Manager; +using Impostor.Server.Net.Messages; +using Impostor.Server.Plugins; +using Impostor.Server.Recorder; +using Impostor.Server.Service.Admin.Ban; +using Impostor.Server.Service.Admin.Chat; +using Impostor.Server.Service.Admin.Reactor; +using Impostor.Server.Service.Admin.Report; +using Impostor.Server.Service.Api; +using Impostor.Server.Service.Auth; +using Impostor.Server.Service.Stat; +using Impostor.Server.Utils; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.ObjectPool; +using Next.Hazel.Extensions; +using Serilog; +using Serilog.Events; +using Serilog.Settings.Configuration; + +namespace Impostor.Server +{ + internal static class Program + { + private static int Main(string[] args) + { + Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger(); + try + { + Log.Information("Starting Empostor v{0}", DotnetUtils.Version); + CreateHostBuilder(args).Build().Run(); + return 0; + } + catch (Exception ex) + { + Log.Fatal(ex, "Impostor terminated unexpectedly"); + return 1; + } + finally + { + Log.CloseAndFlush(); + } + } + + private static IConfiguration CreateConfiguration(string[] args) => + new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("config.json", true) + .AddJsonFile("config.Development.json", true) + .AddEnvironmentVariables(prefix: "IMPOSTOR_") + .AddCommandLine(args) + .Build(); + + private static IHostBuilder CreateHostBuilder(string[] args) + { + var configuration = CreateConfiguration(args); + var pluginConfig = configuration.GetSection("PluginLoader").Get() ?? new PluginConfig(); + var httpConfig = configuration.GetSection(HttpServerConfig.Section).Get() ?? new HttpServerConfig(); + + var hostBuilder = Host.CreateDefaultBuilder(args) + .UseContentRoot(Directory.GetCurrentDirectory()) +#if DEBUG + .UseEnvironment(Environment.GetEnvironmentVariable("IMPOSTOR_ENV") ?? "Development") +#else + .UseEnvironment("Production") +#endif + .ConfigureAppConfiguration(b => b.AddConfiguration(configuration)) + .ConfigureServices((host, services) => + { + var debug = host.Configuration.GetSection(DebugConfig.Section).Get() ?? new DebugConfig(); + + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(); + + services.Configure(host.Configuration.GetSection(DebugConfig.Section)); + services.Configure(host.Configuration.GetSection(AntiCheatConfig.Section)); + services.Configure(host.Configuration.GetSection(CompatibilityConfig.Section)); + services.Configure(host.Configuration.GetSection(ServerConfig.Section)); + services.Configure(host.Configuration.GetSection(TimeoutConfig.Section)); + services.Configure(host.Configuration.GetSection(HttpServerConfig.Section)); + services.Configure(host.Configuration.GetSection(AdminConfig.Section)); + services.Configure(host.Configuration.GetSection(DiscordWebhookConfig.Section)); + services.Configure(host.Configuration.GetSection(PlayerStatsConfig.Section)); + services.Configure(host.Configuration.GetSection(ChatFilterConfig.Section)); + + services.AddSingleton(); + services.AddHttpClient("innersloth", client => + { + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.Add("User-Agent", + "UnityPlayer/2022.3.44f1 (UnityWebRequest/1.0, libcurl/7.84.0-DEV)"); + client.DefaultRequestHeaders.Add("X-Unity-Version", "2022.3.44f1"); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + + if (debug.GameRecorderEnabled) + { + services.AddSingleton(new DefaultObjectPoolProvider()); + services.AddSingleton>(sp => + sp.GetRequiredService() + .Create(new PacketSerializationContextPooledObjectPolicy())); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton>(); + } + else + { + services.AddSingleton>(); + } + + services.AddSingleton(); + services.AddSingleton(p => p.GetRequiredService()); + services.AddSingleton(); + + services.AddEventPools(); + + // Command + services.AddSingleton(); + services.AddHostedService(); + + // Admin + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Language + services.AddSingleton(); + + services.AddHazel(); + services.AddSingleton, CustomMessageManager>(); + services.AddSingleton, CustomMessageManager>(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + }) + .UseSerilog((context, logCfg) => + { +#if DEBUG + var logLevel = LogEventLevel.Debug; +#else + var logLevel = LogEventLevel.Information; +#endif + if (args.Contains("--verbose")) logLevel = LogEventLevel.Verbose; + else if (args.Contains("--errors-only")) + { + logLevel = LogEventLevel.Error; + } + + static Assembly? TryLoad(AssemblyLoadContext ctx, AssemblyName name) + { + foreach (var p in new[] { AppDomain.CurrentDomain.BaseDirectory, Directory.GetCurrentDirectory() }) + { + try { return ctx.LoadFromAssemblyPath(Path.Combine(p, name.Name + ".dll")); } + catch (FileNotFoundException) { } + } + + return null; + } + + AssemblyLoadContext.Default.Resolving += TryLoad; + logCfg + .MinimumLevel.Is(logLevel) +#if DEBUG + .MinimumLevel.Override("Microsoft", LogEventLevel.Debug) +#else + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) +#endif + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console() + .ReadFrom.Configuration(context.Configuration, + new ConfigurationReaderOptions(ConfigurationAssemblySource.AlwaysScanDllFiles)); + AssemblyLoadContext.Default.Resolving -= TryLoad; + }) + .UseConsoleLifetime() + .UsePluginLoader(pluginConfig); + + if (httpConfig.Enabled) + { + hostBuilder.ConfigureWebHostDefaults(builder => + { + builder.ConfigureServices(s => + { + s.AddControllers(); + }); + builder.Configure(app => + { + foreach (var p in app.ApplicationServices.GetRequiredService().Plugins) + if (p.Startup is IPluginHttpStartup s) + { + s.ConfigureWebApplication(app); + } + + app.UseRouting(); + app.UseEndpoints(e => e.MapControllers()); + }); + builder.ConfigureKestrel(opt => + opt.Listen(IPAddress.Parse(httpConfig.ListenIp), httpConfig.ListenPort)); + }); + } + + return hostBuilder; + } + } +} diff --git a/src/Impostor.Server/Properties/launchSettings.json b/src/Impostor.Server/Properties/launchSettings.json new file mode 100644 index 0000000..7044509 --- /dev/null +++ b/src/Impostor.Server/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Impostor.Server": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53722;http://localhost:53723" + } + } +} \ No newline at end of file diff --git a/src/Impostor.Server/RealDateTimeProvider.cs b/src/Impostor.Server/RealDateTimeProvider.cs new file mode 100644 index 0000000..c450338 --- /dev/null +++ b/src/Impostor.Server/RealDateTimeProvider.cs @@ -0,0 +1,10 @@ +using System; +using Impostor.Api.Utils; + +namespace Impostor.Server +{ + public class RealDateTimeProvider : IDateTimeProvider + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } +} diff --git a/src/Impostor.Server/Recorder/ClientRecorder.cs b/src/Impostor.Server/Recorder/ClientRecorder.cs new file mode 100644 index 0000000..0994ca9 --- /dev/null +++ b/src/Impostor.Server/Recorder/ClientRecorder.cs @@ -0,0 +1,89 @@ +using System.Threading.Tasks; +using Impostor.Api.Config; +using Impostor.Api.Events.Managers; +using Impostor.Api.Net.Custom; +using Impostor.Api.Net.Messages; +using Impostor.Server.Net; +using Impostor.Server.Net.Hazel; +using Impostor.Server.Net.Manager; +using Impostor.Server.Service.Admin.Report; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Recorder +{ + internal class ClientRecorder : Client + { + private readonly PacketRecorder _recorder; + private bool _isFirst; + private bool _createdGame; + private bool _recordAfter; + + public ClientRecorder(ILogger logger, IOptions antiCheatOptions, ClientManager clientManager, ICustomMessageManager customMessageManager, IEventManager eventManager, ReportStore reportStore, GameManager gameManager, string name, GameVersion gameVersion, Language language, QuickChatModes chatMode, PlatformSpecificData platformSpecificData, HazelConnection connection, PacketRecorder recorder) + : base(logger, antiCheatOptions, clientManager, gameManager, customMessageManager, eventManager, reportStore, name, gameVersion, language, chatMode, platformSpecificData, connection) + { + _recorder = recorder; + _isFirst = true; + _createdGame = false; + _recordAfter = true; + } + + public override async ValueTask HandleMessageAsync(IMessageReader reader, MessageType messageType) + { + using var messageCopy = reader.Copy(); + + // Trigger connect event. + if (_isFirst) + { + _isFirst = false; + + await _recorder.WriteConnectAsync(this); + } + + // Check if we were in-game before handling the message. + var inGame = Player?.Game != null; + + if (!_recordAfter) + { + // Trigger message event. + await _recorder.WriteMessageAsync(this, messageCopy, messageType); + } + + // Handle the message. + await base.HandleMessageAsync(reader, messageType); + + // Player created a game. + if (reader.Tag == MessageFlags.HostGame) + { + _createdGame = true; + } + else if (reader.Tag == MessageFlags.JoinGame && _createdGame) + { + _createdGame = false; + + // We created a game and are now in-game, store that event. + if (!inGame && Player?.Game != null) + { + await _recorder.WriteGameCreatedAsync(this, Player.Game.Code); + } + + _recordAfter = false; + + // Trigger message event. + await _recorder.WriteMessageAsync(this, messageCopy, messageType); + } + + if (_recordAfter) + { + // Trigger message event. + await _recorder.WriteMessageAsync(this, messageCopy, messageType); + } + } + + public override async ValueTask HandleDisconnectAsync(string reason) + { + await _recorder.WriteDisconnectAsync(this, reason); + await base.HandleDisconnectAsync(reason); + } + } +} diff --git a/src/Impostor.Server/Recorder/PacketRecorder.cs b/src/Impostor.Server/Recorder/PacketRecorder.cs new file mode 100644 index 0000000..2f8bb1a --- /dev/null +++ b/src/Impostor.Server/Recorder/PacketRecorder.cs @@ -0,0 +1,228 @@ +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Impostor.Api.Config; +using Impostor.Api.Games; +using Impostor.Server.Net; +using Impostor.Server.Utils; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ObjectPool; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Recorder +{ + /// + /// Records all packets received in . + /// + internal class PacketRecorder : BackgroundService + { + private readonly string _path; + private readonly ILogger _logger; + private readonly ObjectPool _pool; + private readonly Channel _channel; + private DateTimeOffset _startTime; + + public PacketRecorder(ILogger logger, IOptions options, ObjectPool pool) + { + var name = $"session_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.dat"; + + _path = Path.Combine(options.Value.GameRecorderPath!, name); + _logger = logger; + _pool = pool; + + _channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + } + + public async Task WriteConnectAsync(ClientRecorder client) + { + _logger.LogTrace("Writing Connect."); + + var context = _pool.Get(); + + try + { + WritePacketHeader(context, RecordedPacketType.Connect); + WriteClient(context, client, true); + WriteLength(context); + + await WriteAsync(context.Stream!); + } + finally + { + _pool.Return(context); + } + } + + public async Task WriteDisconnectAsync(ClientRecorder client, string reason) + { + _logger.LogTrace("Writing Disconnect."); + + var context = _pool.Get(); + + try + { + WritePacketHeader(context, RecordedPacketType.Disconnect); + WriteClient(context, client, false); + context.Writer.Write(reason); + WriteLength(context); + + await WriteAsync(context.Stream!); + } + finally + { + _pool.Return(context); + } + } + + public async Task WriteMessageAsync(ClientRecorder client, IMessageReader reader, MessageType messageType) + { + _logger.LogTrace("Writing Message."); + + var context = _pool.Get(); + + try + { + WritePacketHeader(context, RecordedPacketType.Message); + WriteClient(context, client, false); + WritePacket(context, reader, messageType); + WriteLength(context); + + await WriteAsync(context.Stream!); + } + finally + { + _pool.Return(context); + } + } + + public async Task WriteGameCreatedAsync(ClientRecorder client, GameCode gameCode) + { + _logger.LogTrace("Writing GameCreated {0}.", gameCode); + + var context = _pool.Get(); + + try + { + WritePacketHeader(context, RecordedPacketType.GameCreated); + WriteClient(context, client, false); + WriteGameCode(context, gameCode); + WriteLength(context); + + await WriteAsync(context.Stream!); + } + finally + { + _pool.Return(context); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _startTime = DateTimeOffset.UtcNow; + _logger.LogInformation("PacketRecorder is enabled, writing packets to {0}.", _path); + + var writer = File.Open(_path, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + + await WriteFileHeaderAsync(); + + // Handle messages. + try + { + while (!stoppingToken.IsCancellationRequested) + { + var result = await _channel.Reader.ReadAsync(stoppingToken); + + await writer.WriteAsync(result, stoppingToken); + await writer.FlushAsync(stoppingToken); + } + } + catch (TaskCanceledException) + { + } + + // Clean up. + await writer.DisposeAsync(); + } + + private async Task WriteFileHeaderAsync() + { + var context = _pool.Get(); + + try + { + context.Writer.Write((uint)ServerReplayVersion.Initial); + context.Writer.Write(_startTime.ToUnixTimeMilliseconds()); + context.Writer.Write(DotnetUtils.Version); + + await WriteAsync(context.Stream!); + } + finally + { + _pool.Return(context); + } + } + + private void WritePacketHeader(PacketSerializationContext context, RecordedPacketType type) + { + // Length placeholder. + context.Writer.Write(0); + + // Timestamp relative to recording start time. + context.Writer.Write((uint)(DateTimeOffset.UtcNow - _startTime).TotalMilliseconds); + + context.Writer.Write((byte)type); + } + + private void WriteClient(PacketSerializationContext context, ClientBase client, bool full) + { + var address = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 12345); + var addressBytes = address.Address.GetAddressBytes(); + + context.Writer.Write(client.Id); + + if (full) + { + context.Writer.Write((byte)addressBytes.Length); + context.Writer.Write(addressBytes); + context.Writer.Write((ushort)address.Port); + context.Writer.Write(client.Name); + context.Writer.Write(client.GameVersion.Value); + } + } + + private void WritePacket(PacketSerializationContext context, IMessageReader reader, MessageType messageType) + { + context.Writer.Write((byte)messageType); + context.Writer.Write((byte)reader.Tag); + context.Writer.Write((int)reader.Length); + context.Writer.Write(reader.Buffer, reader.Offset, reader.Length); + } + + private void WriteGameCode(PacketSerializationContext context, in GameCode gameCode) + { + context.Writer.Write(gameCode.Code); + } + + private void WriteLength(PacketSerializationContext context) + { + var length = context.Stream.Position; + + context.Stream.Position = 0; + context.Writer.Write((int)length); + context.Stream.Position = length; + } + + private async Task WriteAsync(MemoryStream data) + { + await _channel.Writer.WriteAsync(data.ToArray()); + } + } +} diff --git a/src/Impostor.Server/Recorder/PacketSerializationContext.cs b/src/Impostor.Server/Recorder/PacketSerializationContext.cs new file mode 100644 index 0000000..2290bcb --- /dev/null +++ b/src/Impostor.Server/Recorder/PacketSerializationContext.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace Impostor.Server.Recorder +{ + public class PacketSerializationContext + { + private const int InitialStreamSize = 0x100; + private const int MaximumStreamSize = 0x100000; + + private MemoryStream? _memory; + private BinaryWriter? _writer; + + [AllowNull] + public MemoryStream Stream + { + get + { + return _memory ??= new MemoryStream(InitialStreamSize); + } + private set => _memory = value; + } + + [AllowNull] + public BinaryWriter Writer + { + get + { + return _writer ??= new BinaryWriter(Stream, Encoding.UTF8, true); + } + private set => _writer = value; + } + + public void Reset() + { + if (Stream.Capacity > MaximumStreamSize) + { + Stream = null; + Writer = null; + } + else + { + Stream.Position = 0L; + Stream.SetLength(0L); + } + } + } +} diff --git a/src/Impostor.Server/Recorder/PacketSerializationContextPooledObjectPolicy.cs b/src/Impostor.Server/Recorder/PacketSerializationContextPooledObjectPolicy.cs new file mode 100644 index 0000000..e45948e --- /dev/null +++ b/src/Impostor.Server/Recorder/PacketSerializationContextPooledObjectPolicy.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.ObjectPool; + +namespace Impostor.Server.Recorder +{ + public class PacketSerializationContextPooledObjectPolicy : IPooledObjectPolicy + { + public PacketSerializationContext Create() + { + return new PacketSerializationContext(); + } + + public bool Return(PacketSerializationContext obj) + { + obj.Reset(); + return true; + } + } +} diff --git a/src/Impostor.Server/Recorder/RecordedPacketType.cs b/src/Impostor.Server/Recorder/RecordedPacketType.cs new file mode 100644 index 0000000..336cd57 --- /dev/null +++ b/src/Impostor.Server/Recorder/RecordedPacketType.cs @@ -0,0 +1,10 @@ +namespace Impostor.Server.Recorder +{ + internal enum RecordedPacketType : byte + { + Connect = 1, + Disconnect = 2, + Message = 3, + GameCreated = 4, + } +} diff --git a/src/Impostor.Server/Recorder/ServerReplayVersion.cs b/src/Impostor.Server/Recorder/ServerReplayVersion.cs new file mode 100644 index 0000000..ce77e3c --- /dev/null +++ b/src/Impostor.Server/Recorder/ServerReplayVersion.cs @@ -0,0 +1,18 @@ +namespace Impostor.Server.Recorder +{ + /// + /// Version of the server replay data format. + /// + public enum ServerReplayVersion + { + /// + /// Initial version. + /// + Initial = 1, + + /// + /// Latest version. + /// + Latest = Initial, + } +} diff --git a/src/Impostor.Server/Service/Admin/Ban/BanEnforcementListener.cs b/src/Impostor.Server/Service/Admin/Ban/BanEnforcementListener.cs new file mode 100644 index 0000000..af8912f --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Ban/BanEnforcementListener.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Impostor.Api.Events; +using Impostor.Api.Events.Client; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Service.Admin.Ban; + +public sealed class BanEnforcementListener : IEventListener +{ + private readonly ILogger _logger; + private readonly BanStore _bans; + + public BanEnforcementListener(ILogger logger, BanStore bans) + { + _logger = logger; + _bans = bans; + } + + [EventListener] + public async ValueTask OnClientConnected(IClientConnectedEvent e) + { + var client = e.Client; + var ip = client.Connection?.EndPoint?.Address; + + // IP 封禁检查 + if (ip != null && _bans.IsIpBanned(ip)) + { + _logger.LogWarning("[Ban] Rejecting banned IP {Ip} ({Name})", ip, client.Name); + await client.DisconnectAsync(DisconnectReason.Banned, "You are banned from this server."); + return; + } + + // FriendCode 封禁检查 + if (_bans.IsFriendCodeBanned(client.FriendCode)) + { + _logger.LogWarning("[Ban] Rejecting banned FriendCode {FC} ({Name})", client.FriendCode, client.Name); + await client.DisconnectAsync(DisconnectReason.Banned, "You are banned from this server."); + } + } +} diff --git a/src/Impostor.Server/Service/Admin/Ban/BanStore.cs b/src/Impostor.Server/Service/Admin/Ban/BanStore.cs new file mode 100644 index 0000000..0ef6fc7 --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Ban/BanStore.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Service.Admin.Ban +{ + public sealed class BanStore : IDisposable + { + private static readonly string BanFile = + Path.Combine(Directory.GetCurrentDirectory(), "bans.json"); + + private static readonly JsonSerializerOptions JsonOpts = + new() { WriteIndented = true }; + + private readonly ILogger _logger; + private readonly SemaphoreSlim _saveLock = new(1, 1); + + private ConcurrentDictionary _ips = new(); + private ConcurrentDictionary _friendCodes = new(); + + public BanStore(ILogger logger) + { + _logger = logger; + Load(); + } + + public bool IsIpBanned(IPAddress ip) => _ips.ContainsKey(Normalize(ip)); + + public bool IsFriendCodeBanned(string? fc) => fc != null && _friendCodes.ContainsKey(fc); + + public BanEntry BanIp(IPAddress ip, string reason) + { + var key = Normalize(ip); + var entry = new BanEntry { Value = key, Reason = reason, BannedAt = DateTime.UtcNow }; + _ips[key] = entry; + SaveAsync(); + return entry; + } + + public bool UnbanIp(string key) + { + var r = _ips.TryRemove(key, out _); + if (r) + { + SaveAsync(); + } + + return r; + } + + public BanEntry BanFriendCode(string fc, string reason) + { + var entry = new BanEntry { Value = fc, Reason = reason, BannedAt = DateTime.UtcNow }; + _friendCodes[fc] = entry; + SaveAsync(); + return entry; + } + + public bool UnbanFriendCode(string key) + { + var r = _friendCodes.TryRemove(key, out _); + if (r) + { + SaveAsync(); + } + + return r; + } + + public IReadOnlyList AllIpBans() + => _ips.Values.OrderByDescending(b => b.BannedAt).ToList(); + + public IReadOnlyList AllFriendCodeBans() + => _friendCodes.Values.OrderByDescending(b => b.BannedAt).ToList(); + + public (int IpCount, int FcCount) Stats() => (_ips.Count, _friendCodes.Count); + + private void SaveAsync() + { + _ = System.Threading.Tasks.Task.Run(async () => + { + if (!await _saveLock.WaitAsync(0)) + { + return; + } + + try + { + var data = new BanData { Ips = new(_ips), FriendCodes = new(_friendCodes) }; + await File.WriteAllTextAsync(BanFile, JsonSerializer.Serialize(data, JsonOpts)); + } + catch (Exception ex) { _logger.LogWarning(ex, "[BanStore] Failed to save bans.json"); } + finally { _saveLock.Release(); } + }); + } + + private void Load() + { + if (!File.Exists(BanFile)) + { + return; + } + + try + { + var data = JsonSerializer.Deserialize(File.ReadAllText(BanFile)); + if (data != null) + { + _ips = new(data.Ips ?? new()); + _friendCodes = new(data.FriendCodes ?? new()); + _logger.LogInformation("[BanStore] Loaded {I} IP bans + {F} FC bans", _ips.Count, _friendCodes.Count); + } + } + catch (Exception ex) { _logger.LogWarning(ex, "[BanStore] Failed to load bans.json"); } + } + + private static string Normalize(IPAddress ip) + => ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4().ToString() : ip.ToString(); + + public void Dispose() => _saveLock.Dispose(); + + private sealed class BanData + { + [JsonPropertyName("ips")] + public Dictionary? Ips { get; set; } + + [JsonPropertyName("friendCodes")] + public Dictionary? FriendCodes { get; set; } + } + } + + public sealed class BanEntry + { + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("reason")] + public required string Reason { get; init; } + + [JsonPropertyName("bannedAt")] + public required DateTime BannedAt { get; init; } + } +} diff --git a/src/Impostor.Server/Service/Admin/Chat/ChatFilterListener.cs b/src/Impostor.Server/Service/Admin/Chat/ChatFilterListener.cs new file mode 100644 index 0000000..e2a13a6 --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Chat/ChatFilterListener.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Impostor.Api.Events; +using Impostor.Api.Events.Player; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Service.Admin.Chat; + +internal sealed class ChatFilterListener : IEventListener +{ + private readonly ILogger _logger; + private readonly ChatFilterStore _store; + private readonly ConcurrentDictionary> _playerMessages = new(); + + public ChatFilterListener(ILogger logger, ChatFilterStore store) + { + _logger = logger; + _store = store; + } + + [EventListener] + public void OnPlayerChat(IPlayerChatEvent e) + { + if (!_store.Enabled) + { + return; + } + + var playerKey = e.ClientPlayer.Client.FriendCode ?? e.ClientPlayer.Client.Name; + var message = e.Message; + + // Word filter + var words = _store.BlockedWords; + if (words.Count > 0) + { + var lower = message.ToLowerInvariant(); + foreach (var word in words) + { + if (string.IsNullOrWhiteSpace(word)) + { + continue; + } + + if (lower.Contains(word.ToLowerInvariant())) + { + if (_store.BlockMessage) + { + e.IsCancelled = true; + _logger.LogInformation( + "[ChatFilter] Blocked message from {Name} ({FC}): {Message}", + e.ClientPlayer.Client.Name, playerKey, message); + } + else + { + _logger.LogWarning( + "[ChatFilter] Flagged (not blocked) from {Name} ({FC}): {Message}", + e.ClientPlayer.Client.Name, playerKey, message); + } + + return; + } + } + } + + // Spam rate limit + var threshold = _store.SpamThreshold; + if (threshold > 0) + { + var queue = _playerMessages.GetOrAdd(playerKey, _ => new Queue()); + lock (queue) + { + var now = DateTime.UtcNow; + var window = TimeSpan.FromSeconds(_store.SpamWindowSeconds); + while (queue.Count > 0 && now - queue.Peek() > window) + { + queue.Dequeue(); + } + + if (queue.Count >= threshold) + { + if (_store.BlockMessage) + { + e.IsCancelled = true; + _logger.LogInformation( + "[ChatFilter] Rate-limited {Name} ({FC})", e.ClientPlayer.Client.Name, playerKey); + } + else + { + _logger.LogWarning( + "[ChatFilter] Rate-limit flag (not blocked) for {Name} ({FC})", e.ClientPlayer.Client.Name, playerKey); + } + + return; + } + + queue.Enqueue(now); + } + } + } +} diff --git a/src/Impostor.Server/Service/Admin/Chat/ChatFilterStore.cs b/src/Impostor.Server/Service/Admin/Chat/ChatFilterStore.cs new file mode 100644 index 0000000..a6963bd --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Chat/ChatFilterStore.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using Impostor.Api.Config; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Service.Admin.Chat; + +public sealed class ChatFilterStore +{ + private readonly object _wordsLock = new(); + + private List _blockedWords; + + public ChatFilterStore(IOptions config) + { + var cfg = config.Value; + Enabled = cfg.Enabled; + BlockMessage = cfg.BlockMessage; + SpamThreshold = cfg.SpamThreshold; + SpamWindowSeconds = cfg.SpamWindowSeconds; + _blockedWords = new List(cfg.BlockedWords ?? new List()); + } + + public bool Enabled { get; set; } + + public bool BlockMessage { get; set; } + + public int SpamThreshold { get; set; } + + public int SpamWindowSeconds { get; set; } + + public IReadOnlyList BlockedWords + { + get + { + lock (_wordsLock) + { + return _blockedWords.AsReadOnly(); + } + } + } + + public void AddWord(string word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return; + } + + var trimmed = word.Trim(); + lock (_wordsLock) + { + if (_blockedWords.Exists(w => w.Equals(trimmed, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + _blockedWords.Add(trimmed); + } + } + + public bool RemoveWord(string word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return false; + } + + lock (_wordsLock) + { + return _blockedWords.RemoveAll( + w => w.Equals(word.Trim(), StringComparison.OrdinalIgnoreCase)) > 0; + } + } +} diff --git a/src/Impostor.Server/Service/Admin/Reactor/ReactorHandshakeListener.cs b/src/Impostor.Server/Service/Admin/Reactor/ReactorHandshakeListener.cs new file mode 100644 index 0000000..b8b18dd --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Reactor/ReactorHandshakeListener.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using Impostor.Api.Events; +using Impostor.Api.Events.Client; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Service.Admin.Reactor +{ + // Based on Reactor.Impostor protocol: + // https://github.com/NuclearPowered/Reactor.Impostor + // + // Reactor appends its data after the normal Among Us handshake: + // [normal AU handshake] [uint64 header: 7-byte "reactor" magic + 1-byte protocol version] + // If protocol >= V3: [packed int modCount] [for each: string id, string version, ushort flags] + internal sealed class ReactorHandshakeListener : IEventListener + { + // "reactor" in ASCII (7 bytes), packed as upper 56 bits of a uint64 in little-endian + private const ulong ReactorMagic = 0x726f7463656172; + + private readonly ILogger _logger; + + public ReactorHandshakeListener(ILogger logger) + => _logger = logger; + + [EventListener] + public void OnClientConnection(IClientConnectionEvent e) + { + var reader = e.HandshakeData; + var savedPos = reader.Position; + + try + { + // Skip known Among Us handshake fields to reach the Reactor suffix + if (!SkipAuHandshake(reader)) + { + return; + } + + // Scan for Reactor header in remaining bytes + if (!TryFindReactorHeader(reader, out var protocolVersion)) + { + if (reader.Position < reader.Length) + { + _logger.LogTrace("[ReactorMods] No Reactor header found for {Name}", e.Connection.Client?.Name ?? "unknown"); + } + + return; + } + + // Read mod list for protocol V3+ + var mods = Array.Empty(); + if (protocolVersion >= 3) + { + mods = ReadModList(reader); + } + + var info = new ReactorModInfo($"v{protocolVersion}", mods); + e.Connection.SetReactorMods(info); + + if (mods.Length > 0) + { + _logger.LogInformation( + "[ReactorMods] {Name} has {Count} mod(s) (protocol v{Proto}): {Mods}", + e.Connection.Client?.Name ?? "unknown", + mods.Length, + protocolVersion, + string.Join(", ", System.Linq.Enumerable.Select(mods, m => $"{m.Id} {m.Version}"))); + } + } + catch + { + // Not a Reactor client or parse error — silently skip + } + finally + { + reader.Seek(savedPos); + } + } + + private static bool SkipAuHandshake(IMessageReader reader) + { + // GameVersion (int32, 4 bytes) + if (!CanRead(reader, 4)) + { + return false; + } + + reader.ReadInt32(); + + // Name (Hazel string: packed int length + UTF-8 bytes) + if (!TrySkipString(reader)) + { + return false; + } + + // LastNonce (uint32, 4 bytes) + if (!CanRead(reader, 4)) + { + return false; + } + + reader.ReadUInt32(); + + // Language (uint32, 4 bytes) + if (!CanRead(reader, 4)) + { + return false; + } + + reader.ReadUInt32(); + + // ChatMode (byte) + if (!CanRead(reader, 1)) + { + return false; + } + + reader.ReadByte(); + + // PlatformSpecificData (Hazel sub-message) + if (!TrySkipMessage(reader)) + { + return false; + } + + return true; + } + + // Scan remaining bytes for the Reactor header magic + private static bool TryFindReactorHeader(IMessageReader reader, out int protocolVersion) + { + protocolVersion = 0; + + while (reader.Position + 8 <= reader.Length) + { + var pos = reader.Position; + var value = reader.ReadUInt64(); + var magic = value >> 8; + + if (magic == ReactorMagic) + { + protocolVersion = unchecked((byte)(value & 0xFF)); + return true; + } + + // Not found at this position, advance by 1 byte and retry + reader.Seek(pos + 1); + } + + return false; + } + + private static ClientMod[] ReadModList(IMessageReader reader) + { + if (reader.Position >= reader.Length) + { + return Array.Empty(); + } + + var modCount = reader.ReadPackedInt32(); + var mods = new ClientMod[modCount]; + + for (var i = 0; i < modCount; i++) + { + var id = reader.ReadString(); + var version = reader.ReadString(); + var flags = reader.ReadUInt16(); + var requiredOnAll = (flags & 0x01) != 0; + + // When RequireOnAllClients flag is set, an extra name string follows + if (requiredOnAll && reader.Position < reader.Length) + { + _ = reader.ReadString(); + } + + mods[i] = new ClientMod(id, version, requiredOnAll); + } + + return mods; + } + + private static bool CanRead(IMessageReader reader, int count) + => reader.Position + count <= reader.Length; + + private static bool TrySkipString(IMessageReader reader) + { + if (!CanRead(reader, 1)) + { + return false; + } + + var pos = reader.Position; + var len = reader.ReadPackedInt32(); + + if (len < 0 || reader.Position + len > reader.Length) + { + reader.Seek(pos); + return false; + } + + reader.Seek(reader.Position + len); + return true; + } + + private static bool TrySkipMessage(IMessageReader reader) + { + if (!CanRead(reader, 2)) + { + return false; + } + + var pos = reader.Position; + var len = reader.ReadUInt16(); + + if (reader.Position + len > reader.Length) + { + reader.Seek(pos); + return false; + } + + reader.Seek(reader.Position + len); + return true; + } + } + + public sealed class ReactorModInfo + { + public ReactorModInfo(string protocolVersion, IReadOnlyList mods) + { + ProtocolVersion = protocolVersion; + Mods = mods; + } + + public string ProtocolVersion { get; } + + public IReadOnlyList Mods { get; } + } + + public sealed class ClientMod + { + public ClientMod(string id, string version, bool requiredOnAllClients) + { + Id = id; + Version = version; + RequiredOnAllClients = requiredOnAllClients; + } + + public string Id { get; } + + public string Version { get; } + + public bool RequiredOnAllClients { get; } + } +} diff --git a/src/Impostor.Server/Service/Admin/Reactor/ReactorModExtensions.cs b/src/Impostor.Server/Service/Admin/Reactor/ReactorModExtensions.cs new file mode 100644 index 0000000..6a90e9c --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Reactor/ReactorModExtensions.cs @@ -0,0 +1,20 @@ +using System.Runtime.CompilerServices; +using Impostor.Api.Net; + +namespace Impostor.Server.Service.Admin.Reactor +{ + + public static class ReactorModExtensions + { + private static readonly ConditionalWeakTable Table = new(); + + public static ReactorModInfo? GetReactorMods(this IClient client) + => client.Connection?.GetReactorMods(); + + public static ReactorModInfo? GetReactorMods(this IHazelConnection connection) + => Table.TryGetValue(connection, out var info) ? info : null; + + internal static void SetReactorMods(this IHazelConnection connection, ReactorModInfo info) + => Table.AddOrUpdate(connection, info); + } +} diff --git a/src/Impostor.Server/Service/Admin/Report/ReportStore.cs b/src/Impostor.Server/Service/Admin/Report/ReportStore.cs new file mode 100644 index 0000000..02b9b8b --- /dev/null +++ b/src/Impostor.Server/Service/Admin/Report/ReportStore.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Impostor.Server.Service.Admin.Report +{ + public sealed class ReportStore + { + private readonly List _reports = new(); + private readonly object _lock = new(); + private const int Max = 500; + + public void Add(ReportEntry entry) + { + lock (_lock) + { + _reports.Insert(0, entry); + if (_reports.Count > Max) + { + _reports.RemoveAt(_reports.Count - 1); + } + } + } + + public List GetRecent(int count = 100) + { + lock (_lock) + { + return _reports.Take(count).ToList(); + } + } + } + + public sealed class ReportEntry + { + public DateTime Time { get; init; } = DateTime.UtcNow; + + public string GameCode { get; init; } = string.Empty; + + public string ReporterName { get; init; } = string.Empty; + + public string? ReporterFriendCode { get; init; } + + public string? ReportedName { get; init; } + + public string? ReportedFriendCode { get; init; } + + public ReportReasons Reason { get; init; } + + public ReportOutcome Outcome { get; init; } + } +} diff --git a/src/Impostor.Server/Service/Api/DiscordWebhookListener.cs b/src/Impostor.Server/Service/Api/DiscordWebhookListener.cs new file mode 100644 index 0000000..66798d1 --- /dev/null +++ b/src/Impostor.Server/Service/Api/DiscordWebhookListener.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Impostor.Api.Config; +using Impostor.Api.Events; +using Impostor.Api.Events.Player; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Service.Api; + +internal sealed class DiscordWebhookListener : IEventListener +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false }; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _http; + private readonly DiscordWebhookConfig _config; + private readonly ConcurrentDictionary _throttle = new(); + + public DiscordWebhookListener( + ILogger logger, + IHttpClientFactory http, + IOptions config) + { + _logger = logger; + _http = http; + _config = config.Value; + } + + [EventListener] + public ValueTask OnGameCreated(IGameCreatedEvent e) + { + if (!_config.Enabled || !_config.NotifyOnGameCreated) + { + return default; + } + + return SendAsync("Game Created", 3066993, new() + { + ["Game"] = GameCodeParser.IntToGameName(e.Game.Code), + ["Host"] = e.Host?.Name ?? "—", + ["Host FC"] = e.Host?.FriendCode ?? "—", + }); + } + + [EventListener] + public ValueTask OnPlayerLeft(IGamePlayerLeftEvent e) + { + if (!_config.Enabled || !_config.NotifyOnBan || !e.IsBan) + { + return default; + } + + return SendAsync("Player Banned", 15158332, new() + { + ["Player"] = e.Player.Client.Name, + ["Friend Code"] = e.Player.Client.FriendCode ?? "—", + ["Game"] = GameCodeParser.IntToGameName(e.Game.Code), + }); + } + + [EventListener] + public ValueTask OnPlayerReport(IPlayerReportEvent e) + { + if (!_config.Enabled || !_config.NotifyOnReport) + { + return default; + } + + return SendAsync("Player Reported", 16776960, new() + { + ["Reporter"] = e.ClientPlayer.Client.Name, + ["Reporter FC"] = e.ClientPlayer.Client.FriendCode ?? "—", + ["Reported"] = e.ReportedClient?.Name ?? "body", + ["Reported FC"] = e.ReportedClient?.FriendCode ?? "—", + ["Game"] = GameCodeParser.IntToGameName(e.Game.Code), + ["Reason"] = e.Reason.ToString(), + }); + } + + [EventListener] + public ValueTask OnPlayerJoin(IGamePlayerJoinedEvent e) + { + if (!_config.Enabled || !_config.NotifyOnPlayerJoin) + { + return default; + } + + return SendAsync("Player Joined", 3066993, new() + { + ["Player"] = e.Player.Client.Name, + ["Friend Code"] = e.Player.Client.FriendCode ?? "—", + ["Game"] = GameCodeParser.IntToGameName(e.Game.Code), + ["Players"] = $"{e.Game.PlayerCount}/{e.Game.Options.MaxPlayers}", + }); + } + + [EventListener] + public ValueTask OnGameEnded(IGameEndedEvent e) + { + if (!_config.Enabled || !_config.NotifyOnGameEnded) + { + return default; + } + + return SendAsync("Game Ended", 10181046, new() + { + ["Game"] = GameCodeParser.IntToGameName(e.Game.Code), + ["Result"] = e.GameOverReason.ToString(), + ["Players"] = e.Game.PlayerCount.ToString(), + }); + } + + private async ValueTask SendAsync(string title, int color, Dictionary fields) + { + var url = _config.WebhookUrl; + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + var key = title + (fields.TryGetValue("Game", out var gc) ? gc : string.Empty); + if (_throttle.TryGetValue(key, out var last) && (DateTime.UtcNow - last).TotalSeconds < 2) + { + return; + } + + _throttle[key] = DateTime.UtcNow; + + try + { + var embed = new + { + embeds = new[] + { + new + { + title, + color, + fields = fields.Select(kv => new + { + name = kv.Key, + value = kv.Value, + inline = true, + }), + timestamp = DateTime.UtcNow.ToString("o"), + footer = new { text = "Empostor" }, + }, + }, + }; + + var json = JsonSerializer.Serialize(embed, JsonOpts); + using var client = _http.CreateClient(); + var resp = await client.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json")); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("[Discord] Webhook returned {Status}", (int)resp.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Discord] Failed to send webhook"); + } + } +} diff --git a/src/Impostor.Server/Service/Auth/AuthCacheService.cs b/src/Impostor.Server/Service/Auth/AuthCacheService.cs new file mode 100644 index 0000000..4ac550c --- /dev/null +++ b/src/Impostor.Server/Service/Auth/AuthCacheService.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Service.Auth; + +public sealed class AuthCacheService : IDisposable +{ + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _byToken = new(); + private readonly ConcurrentDictionary _byIp = new(); + + private readonly Timer _cleanupTimer; + private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10); + + public AuthCacheService(ILogger logger) + { + _logger = logger; + _cleanupTimer = new Timer(_ => Cleanup(), null, + TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + } + + public void Store(string productUserId, string matchmakerToken, string? friendCode, IPAddress? clientIp) + { + if (string.IsNullOrEmpty(productUserId) || string.IsNullOrEmpty(matchmakerToken)) + { + throw new ArgumentException("PUID and matchmakerToken cannot be null or empty"); + } + + var info = new UserAuthInfo + { + ProductUserId = productUserId, + MatchmakerToken = matchmakerToken, + FriendCode = friendCode ?? string.Empty, + ClientIp = clientIp != null ? NormalizeIp(clientIp) : null, + CreatedAt = DateTime.UtcNow, + }; + + _byToken[matchmakerToken] = info; + + if (clientIp != null) + { + var key = NormalizeIp(clientIp); + _byIp[key] = matchmakerToken; + if (clientIp.IsIPv4MappedToIPv6) + { + _byIp[clientIp.MapToIPv4().ToString()] = matchmakerToken; + } + } + + _logger.LogDebug("[Auth] Stored PUID={Puid} FC={FC}", productUserId, friendCode ?? "(none)"); + } + + public UserAuthInfo? FindByToken(string? matchmakerToken) + { + if (string.IsNullOrEmpty(matchmakerToken)) + { + return null; + } + + return _byToken.TryGetValue(matchmakerToken, out var info) && !Expired(info) ? info : null; + } + + public UserAuthInfo? FindByIp(IPAddress? clientIp) + { + if (clientIp == null) + { + return null; + } + + var key = NormalizeIp(clientIp); + return _byIp.TryGetValue(key, out var token) ? FindByToken(token) : null; + } + + public (int TokenCount, int IpMappingCount) GetStats() + => (_byToken.Count, _byIp.Count); + + private void Cleanup() + { + var expired = _byToken.Where(kv => Expired(kv.Value)).Select(kv => kv.Key).ToList(); + foreach (var token in expired) + { + if (_byToken.TryRemove(token, out var info) && info.ClientIp != null) + { + _byIp.TryRemove(info.ClientIp, out _); + } + } + + if (expired.Count > 0) + { + _logger.LogDebug("[Auth] Cleaned {Count} expired entries", expired.Count); + } + } + + private static bool Expired(UserAuthInfo info) + => DateTime.UtcNow - info.CreatedAt > Ttl; + + private static string NormalizeIp(IPAddress ip) + => ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4().ToString() : ip.ToString(); + + public void Dispose() => _cleanupTimer.Dispose(); +} + +public sealed class UserAuthInfo +{ + public string ProductUserId { get; set; } = string.Empty; + + public string MatchmakerToken { get; set; } = string.Empty; + + public string FriendCode { get; set; } = string.Empty; + + public string? ClientIp { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Impostor.Server/Service/Stat/PlayerLogListener.cs b/src/Impostor.Server/Service/Stat/PlayerLogListener.cs new file mode 100644 index 0000000..c08bf08 --- /dev/null +++ b/src/Impostor.Server/Service/Stat/PlayerLogListener.cs @@ -0,0 +1,95 @@ +using Impostor.Api.Events; +using Impostor.Api.Events.Client; +using Impostor.Api.Events.Meeting; +using Impostor.Api.Events.Player; + +namespace Impostor.Server.Service.Stat; + +internal sealed class PlayerLogListener : IEventListener +{ + private readonly PlayerLogStore _store; + + public PlayerLogListener(PlayerLogStore store) => _store = store; + + private static string? Gc(IGameEvent e) => e.Game != null ? GameCodeParser.IntToGameName(e.Game.Code) : null; + + private static string? Pn(IPlayerEvent e) => e.ClientPlayer?.Client?.Name; + + private static string? Pf(IPlayerEvent e) => e.ClientPlayer?.Client?.FriendCode; + + private static int? Pid(IPlayerEvent e) => e.ClientPlayer?.Client?.Id; + + // Chat + [EventListener] + public void OnChat(IPlayerChatEvent e) => + _store.Add("Chat", Pid(e), Pn(e), Pf(e), Gc(e), e.Message); + + // RPC-like events + [EventListener] + public void OnReport(IPlayerReportEvent e) => + _store.Add("Report", Pid(e), Pn(e), Pf(e), Gc(e), $"Reported: {e.ReportedClient?.Name ?? "body"}"); + + [EventListener] + public void OnMurder(IPlayerMurderEvent e) => + _store.Add("Murder", Pid(e), Pn(e), Pf(e), Gc(e), $"Victim: {e.Victim?.PlayerInfo?.PlayerName ?? "unknown"}"); + + [EventListener] + public void OnExile(IPlayerExileEvent e) => + _store.Add("Exile", Pid(e), Pn(e), Pf(e), Gc(e), "Ejected"); + + [EventListener] + public void OnVote(IPlayerVotedEvent e) => + _store.Add("Vote", Pid(e), Pn(e), Pf(e), Gc(e), $"Voted: {e.VotedFor?.PlayerInfo?.PlayerName ?? "skipped"}"); + + [EventListener] + public void OnTask(IPlayerCompletedTaskEvent e) => + _store.Add("Task", Pid(e), Pn(e), Pf(e), Gc(e), null); + + [EventListener] + public void OnEnterVent(IPlayerEnterVentEvent e) => + _store.Add("Vent", Pid(e), Pn(e), Pf(e), Gc(e), "Enter vent"); + + [EventListener] + public void OnExitVent(IPlayerExitVentEvent e) => + _store.Add("Vent", Pid(e), Pn(e), Pf(e), Gc(e), "Exit vent"); + + // Meeting events (game-level, not player-level) + [EventListener] + public void OnMeetingStarted(IMeetingStartedEvent e) => + _store.Add("Meeting", null, null, null, Gc(e), "Meeting started"); + + [EventListener] + public void OnMeetingEnded(IMeetingEndedEvent e) => + _store.Add("Meeting", null, null, null, Gc(e), "Meeting ended"); + + // Connection events + [EventListener] + public void OnClientConnected(IClientConnectedEvent e) => + _store.Add("Connect", e.Client.Id, e.Client.Name, e.Client.FriendCode, null, null); + + // Game lifecycle + [EventListener] + public void OnGameCreated(IGameCreatedEvent e) => + _store.Add("Game", null, null, null, Gc(e), "Game created"); + + [EventListener] + public void OnGameStarted(IGameStartedEvent e) => + _store.Add("Game", null, null, null, Gc(e), "Game started"); + + [EventListener] + public void OnGameEnded(IGameEndedEvent e) => + _store.Add("Game", null, null, null, Gc(e), $"Game ended: {e.GameOverReason}"); + + [EventListener] + public void OnGameDestroyed(IGameDestroyedEvent e) => + _store.Add("Game", null, null, null, Gc(e), "Game destroyed"); + + // Player join/leave + [EventListener] + public void OnPlayerJoined(IGamePlayerJoinedEvent e) => + _store.Add("Join", e.Player.Client.Id, e.Player.Client.Name, e.Player.Client.FriendCode, Gc(e), null); + + [EventListener] + public void OnPlayerLeft(IGamePlayerLeftEvent e) => + _store.Add("Leave", e.Player.Client.Id, e.Player.Client.Name, e.Player.Client.FriendCode, Gc(e), e.IsBan ? "Banned" : "Left"); +} diff --git a/src/Impostor.Server/Service/Stat/PlayerLogStore.cs b/src/Impostor.Server/Service/Stat/PlayerLogStore.cs new file mode 100644 index 0000000..027ee95 --- /dev/null +++ b/src/Impostor.Server/Service/Stat/PlayerLogStore.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; + +namespace Impostor.Server.Service.Stat; + +public sealed class PlayerLogEntry +{ + public DateTime Time { get; init; } + + public string Type { get; init; } = string.Empty; + + public int? ClientId { get; init; } + + public string? PlayerName { get; init; } + + public string? FriendCode { get; init; } + + public string? GameCode { get; init; } + + public string? Detail { get; init; } +} + +public sealed class PlayerLogStore +{ + private const int MaxEntries = 10000; + private readonly ConcurrentQueue _entries = new(); + private int _count; + + public void Add(string type, int? clientId, string? playerName, string? friendCode, string? gameCode, string? detail) + { + var entry = new PlayerLogEntry + { + Time = DateTime.UtcNow, + Type = type, + ClientId = clientId, + PlayerName = playerName, + FriendCode = friendCode, + GameCode = gameCode, + Detail = detail, + }; + + _entries.Enqueue(entry); + + if (Interlocked.Increment(ref _count) > MaxEntries) + { + _entries.TryDequeue(out _); + Interlocked.Decrement(ref _count); + } + } + + public List GetAll() => _entries.ToList(); + + public List GetByClient(int clientId) => + _entries.Where(e => e.ClientId == clientId).ToList(); + + public List GetLoggedClientIds() => + _entries.Where(e => e.ClientId.HasValue).Select(e => e.ClientId!.Value).Distinct().ToList(); + + public byte[] ExportJson() + { + var json = JsonSerializer.Serialize(_entries.ToList(), new JsonSerializerOptions { WriteIndented = true }); + return Encoding.UTF8.GetBytes(json); + } + + public byte[] ExportJson(int clientId) + { + var list = _entries.Where(e => e.ClientId == clientId).ToList(); + var json = JsonSerializer.Serialize(list, new JsonSerializerOptions { WriteIndented = true }); + return Encoding.UTF8.GetBytes(json); + } +} diff --git a/src/Impostor.Server/Service/Stat/PlayerStatsListener.cs b/src/Impostor.Server/Service/Stat/PlayerStatsListener.cs new file mode 100644 index 0000000..1a4fdff --- /dev/null +++ b/src/Impostor.Server/Service/Stat/PlayerStatsListener.cs @@ -0,0 +1,105 @@ +using System.Linq; +using Impostor.Api.Config; +using Impostor.Api.Events; +using Impostor.Api.Events.Player; +using Impostor.Api.Games; +using Impostor.Api.Net.Inner.Objects; +using Microsoft.Extensions.Options; + +namespace Impostor.Server.Service.Stat; + +internal sealed class PlayerStatsListener : IEventListener +{ + private readonly PlayerStatsConfig _config; + private readonly PlayerStatsStore _store; + + public PlayerStatsListener(IOptions config, PlayerStatsStore store) + { + _config = config.Value; + _store = store; + } + + [EventListener] + public void OnPlayerMurder(IPlayerMurderEvent e) + { + if (!_config.Enabled) + { + return; + } + + var killerFc = e.ClientPlayer.Client.FriendCode; + if (killerFc != null) + { + _store.RecordKill(killerFc); + _store.GetOrCreate(killerFc, e.ClientPlayer.Client.Name); + } + + var victimFc = GetFriendCode(e.Game, e.Victim); + if (victimFc != null) + { + _store.RecordDeath(victimFc); + } + } + + [EventListener] + public void OnPlayerCompletedTask(IPlayerCompletedTaskEvent e) + { + if (!_config.Enabled) + { + return; + } + + var fc = e.ClientPlayer.Client.FriendCode; + if (fc != null) + { + _store.RecordTaskCompleted(fc); + _store.GetOrCreate(fc, e.ClientPlayer.Client.Name); + } + } + + [EventListener] + public void OnPlayerExile(IPlayerExileEvent e) + { + if (!_config.Enabled) + { + return; + } + + var fc = e.ClientPlayer.Client.FriendCode; + if (fc != null) + { + _store.RecordExile(fc); + _store.GetOrCreate(fc, e.ClientPlayer.Client.Name); + } + } + + [EventListener] + public void OnGameEnded(IGameEndedEvent e) + { + if (!_config.Enabled) + { + return; + } + + var reason = e.GameOverReason.ToString(); + var isCrewmateWin = reason.StartsWith("Crewmates") || reason == "HideAndSeek_CrewmatesByTimer"; + + foreach (var player in e.Game.Players) + { + var fc = player.Client.FriendCode; + if (fc == null) + { + continue; + } + + var wasImpostor = player.Character?.PlayerInfo?.IsImpostor ?? false; + _store.RecordGameEnd(fc, player.Client.Name, isCrewmateWin, wasImpostor); + } + } + + private static string? GetFriendCode(IGame game, IInnerPlayerControl target) + { + var match = game.Players.FirstOrDefault(p => p.Character?.PlayerId == target.PlayerId); + return match?.Client.FriendCode; + } +} diff --git a/src/Impostor.Server/Service/Stat/PlayerStatsStore.cs b/src/Impostor.Server/Service/Stat/PlayerStatsStore.cs new file mode 100644 index 0000000..6c32f15 --- /dev/null +++ b/src/Impostor.Server/Service/Stat/PlayerStatsStore.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Impostor.Server.Service.Stat; + +public sealed class PlayerStatsEntry +{ + public string FriendCode { get; init; } = string.Empty; + + public string? LastKnownName { get; set; } + + public int GamesPlayed { get; set; } + + public int Wins { get; set; } + + public int Losses { get; set; } + + public int ImpostorWins { get; set; } + + public int Kills { get; set; } + + public int Deaths { get; set; } + + public int TasksCompleted { get; set; } + + public int TimesExiled { get; set; } + + public DateTime FirstSeen { get; init; } = DateTime.UtcNow; + + public DateTime LastSeen { get; set; } = DateTime.UtcNow; +} + +public sealed class PlayerStatsStore : IDisposable +{ + private static readonly string StatsFile = + Path.Combine(Directory.GetCurrentDirectory(), "player_stats.json"); + + private static readonly JsonSerializerOptions JsonOpts = + new() { WriteIndented = true }; + + private readonly ILogger _logger; + private readonly SemaphoreSlim _saveLock = new(1, 1); + private ConcurrentDictionary _stats = new(StringComparer.OrdinalIgnoreCase); + + public PlayerStatsStore(ILogger logger) + { + _logger = logger; + Load(); + } + + public PlayerStatsEntry GetOrCreate(string friendCode, string? name = null) + { + var fc = Normalize(friendCode); + var entry = _stats.GetOrAdd(fc, _ => new PlayerStatsEntry { FriendCode = fc, FirstSeen = DateTime.UtcNow }); + if (name != null) + { + entry.LastKnownName = name; + } + + entry.LastSeen = DateTime.UtcNow; + return entry; + } + + public PlayerStatsEntry? GetByFriendCode(string friendCode) + => _stats.TryGetValue(Normalize(friendCode), out var e) ? e : null; + + public void RecordKill(string friendCode) + { + var e = GetOrCreate(friendCode); + e.Kills++; + SaveAsync(); + } + + public void RecordDeath(string friendCode) + { + var e = GetOrCreate(friendCode); + e.Deaths++; + SaveAsync(); + } + + public void RecordTaskCompleted(string friendCode) + { + var e = GetOrCreate(friendCode); + e.TasksCompleted++; + SaveAsync(); + } + + public void RecordExile(string friendCode) + { + var e = GetOrCreate(friendCode); + e.TimesExiled++; + SaveAsync(); + } + + public void RecordGameEnd(string friendCode, string? name, bool isCrewmateWin, bool wasImpostor) + { + var e = GetOrCreate(friendCode, name); + e.GamesPlayed++; + if (wasImpostor) + { + if (!isCrewmateWin) + { + e.ImpostorWins++; + } + } + else + { + if (isCrewmateWin) + { + e.Wins++; + } + else + { + e.Losses++; + } + } + + SaveAsync(); + } + + public List GetAll() + => _stats.Values.OrderByDescending(s => s.GamesPlayed).ToList(); + + public void ClearAll() + { + _stats.Clear(); + SaveAsync(); + } + + private void SaveAsync() + { + _ = Task.Run(async () => + { + if (!await _saveLock.WaitAsync(0)) + { + return; + } + + try + { + var list = _stats.Values.ToList(); + await File.WriteAllTextAsync(StatsFile, JsonSerializer.Serialize(list, JsonOpts)); + } + catch (Exception ex) { _logger.LogWarning(ex, "[PlayerStats] Failed to save stats"); } + finally { _saveLock.Release(); } + }); + } + + private void Load() + { + if (!File.Exists(StatsFile)) + { + _logger.LogDebug("[PlayerStats] No stats file found, starting fresh"); + return; + } + + try + { + var list = JsonSerializer.Deserialize>(File.ReadAllText(StatsFile)); + if (list != null) + { + _stats = new ConcurrentDictionary( + list.ToDictionary(e => e.FriendCode, StringComparer.OrdinalIgnoreCase)); + _logger.LogInformation("[PlayerStats] Loaded {Count} player stats", _stats.Count); + } + } + catch (Exception ex) { _logger.LogWarning(ex, "[PlayerStats] Failed to load stats file"); } + } + + private static string Normalize(string friendCode) + => (friendCode ?? string.Empty).Trim(); + + public void Dispose() => _saveLock.Dispose(); +} diff --git a/src/Impostor.Server/Utils/DotnetUtils.cs b/src/Impostor.Server/Utils/DotnetUtils.cs new file mode 100644 index 0000000..3fbbbfb --- /dev/null +++ b/src/Impostor.Server/Utils/DotnetUtils.cs @@ -0,0 +1,20 @@ +namespace Impostor.Server.Utils +{ + public static class DotnetUtils + { + private static string? _version; + + public static string Version + { + get + { + if (_version == null) + { + _version = "2.0.0"; + } + + return _version; + } + } + } +} diff --git a/src/Impostor.Server/Utils/RealDateTimeProvider.cs b/src/Impostor.Server/Utils/RealDateTimeProvider.cs new file mode 100644 index 0000000..1b7b5f3 --- /dev/null +++ b/src/Impostor.Server/Utils/RealDateTimeProvider.cs @@ -0,0 +1,10 @@ +using System; +using Impostor.Api.Utils; + +namespace Impostor.Server.Utils +{ + public class RealDateTimeProvider : IDateTimeProvider + { + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + } +} diff --git a/src/Impostor.Server/Utils/ServerEnvironment.cs b/src/Impostor.Server/Utils/ServerEnvironment.cs new file mode 100644 index 0000000..a3eba15 --- /dev/null +++ b/src/Impostor.Server/Utils/ServerEnvironment.cs @@ -0,0 +1,11 @@ +using Impostor.Api.Utils; + +namespace Impostor.Server.Utils +{ + public class ServerEnvironment : IServerEnvironment + { + public string Version { get; } = DotnetUtils.Version; + + public bool IsReplay { get; init; } + } +} diff --git a/src/Impostor.Server/config.json b/src/Impostor.Server/config.json new file mode 100644 index 0000000..bb32a88 --- /dev/null +++ b/src/Impostor.Server/config.json @@ -0,0 +1,63 @@ +{ + "Server": { + "PublicIp": "127.0.0.1", + "PublicPort": 22023, + "ListenIp": "0.0.0.0", + "ListenPort": 22023 + }, + "HttpServer": { + "Enabled": true, + "ListenIp": "0.0.0.0", + "ListenPort": 22023 + }, + "AntiCheat": { + "Enabled": true, + "BanIpFromGame": true, + "AllowCheatingHosts": "Never", + "EnableGameFlowChecks": true, + "EnableMustBeHostChecks": true, + "EnableColorLimitChecks": true, + "EnableNameLimitChecks": true, + "EnableOwnershipChecks": true, + "EnableRoleChecks": true, + "EnableTargetChecks": true, + "ForbidProtocolExtensions": true + }, + "Timeout": { + "SpawnTimeout": 2500, + "ConnectionTimeout": 2500 + }, + "Compatibility": { + "AllowFutureGameVersions": false, + "AllowHostAuthority": false, + "AllowVersionMixing": false + }, + "Debug": { + "GameRecorderEnabled": false, + "GameRecorderPath": "" + }, + "Admin": { + "Password": "CHANGE-ME", + "MarketplaceUrl": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/plugins.json" + }, + "DiscordWebhook": { + "Enabled": false, + "WebhookUrl": "", + "NotifyOnGameCreated": true, + "NotifyOnBan": true, + "NotifyOnReport": true, + "NotifyOnPlayerJoin": false, + "NotifyOnGameEnded": false + }, + "PlayerStats": { + "Enabled": false, + "PersistToFile": true + }, + "ChatFilter": { + "Enabled": false, + "BlockedWords": [], + "BlockMessage": true, + "SpamThreshold": 5, + "SpamWindowSeconds": 10 + } +} diff --git a/src/Impostor.Server/icon.ico b/src/Impostor.Server/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a2ad49c9903c386bc5088b22abb744b535183733 GIT binary patch literal 3693 zcmV-z4wCTz0096206;(h0000W09OtG02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|KmY&$ zKnMl^0063Kaozv`00d`2O+f$vv5yP z0Dy!50Qvv`0D$NK0Cg|`0P0`>06Lfe02gqax=}m;01eeiL_t(|ob8)?aMV?n$3OSi z>7*0L7>p1iuZb81AK@`X3=eUD1s!zO*Dln;ig9JMTt`F^VHwFPAX%9jYbf8l%OXZ2 zSk|mM1S~b8P;}5RD+3556A>W^kOxU8B%OYovw!@OhR%aWx&i;-TXie_yT5zS@0|0y zzk6Oca0!=i377EGhq>4coOhIzl*BbQHugPs>{#ml{rmfQz1}Ca6~SQ8vu)e9n^vw| z*$0S;=5sH!W=(6ed_LcAV`5@5Rn-y^V~pu*S=M-COqV?Qe7@4SxVT>dmB5J$#q`<2 zh{#PopYP_Trlzd=`uZ_7H8o?89z8m)rl#f}gTdf&Jp+o1i`DIRhgw!vmI69u$`p0G z-Kwe@2n2jHXU-fCBwTEP0T7X!MZ_Hp28%=_cqSp+P*G8#lP6Es(W6I)n$zi2pv(5k zE3c@C$hvjw{u3Ai#74@0MBkI&@1Jap`4qtC^U>7Q#3!G8(&^amzWa`)OP6*{Y~WRp zHRegg0@a15x8M22oF!0Yv62(yi?)M>7lLjRn-7-M8P6- zi@^Hz>z$Tm^#fSEcrgH0USXuXgrEwjKrmomZ21I^*YM23g#hH`7c7A;x?eNth-Rh>U%>1%3?6|R6o-=n|zHP>8o z4KKdG2<(z z)7fjzoH=~)#TS&7l>y*%I!Q`OqN=J2MvlQ+{$|)@Jy?b=n+u13KsEdY9zA-rp`xOq z&ay1AEK6!@Yfs#I>#bh^4Zu;L1o#fy8}xUR|C545FgHFsJNwqvt5+8QR8>`lOrXQz zVDaL`y#N0Dm_NLO89FR9NuJ4uvVCx(fj8fLlR0zdFn|91t!}rw!s&E2R#a4YN=r*? zu$Oluw#(Z99LKi%)veco2zZYQ43NBN(V`h&fBkjgr=NbhqoAN*m#S)IWu-bC4rNG& zS{rwVntAzs1$6xQ@w#f&DpggjuC8uKPfuSBJP!N{7z+#st_J!6y@AVsc%mE#MFSSl z3&;fi37Ffa0HC7+vY${u*Q{Bis;XYE*Y9$<-oO_A7r@W3-CIX1?eiY2MXFK3Hb=XG zLrqOh-vGS*_S*oMyJv?D*6;Td7Z=B@S+giFFR$<4zkeRE2YXX1#a`MG+xBxoG&O9A z$O&VNtIgtB8+V5dklSy-<#N&7+{}+Z{#Z3=(4gM~p9A}VCN8k>U@x<%prWE8nX^#n z%9Set$j;8*3{(K+TqNNkiV8?gO}!h4EiEmzI~M#$*nRo@V*nm|>@ffe3kxf-Be{zv zJVbF<4F-dj!{IP*z4aCV#%ibA|NedZDJQV^k6LwQ1|UAm>p*QCk5bVgLex zuFdn*sZ#-{tE+2P)qhjft*Y9ns(w`swEk^T)n--omX(z)SiE@gRe&>Ee6~l_6ku7_ zy`c+-19~KeF14u}0h^7K20(Nd7F$qRw zoxEAR6+Cqm6%~=2n@etPF3HKsI2;a)F`c%tW5=>#!v+%@8{2pL_U%i7{+!2^5GhPs zt`AUpC5Jme3s;8c_l!}Tv7NUWzft0QD8|xJqeiLU@7KeJ4@D}V`K-7b!Xei*!%kr^*2<5Hf+DKze{~vz%0TF@0L&FCg*dO{C2n2%n-FM%Y*l)7}INqa2kH(1;C&nfu zB>3NX=bZwF!x7um)P%?53C(l4T;_@^uE^fIckg9GhYo!PIE<~ZE}^Qis_Fp5vaBvw zPE_^)V~p9nc{6^$ze8mV?Aif9X=!OS_Gb1?d3pI0E|*K4PG@}2o;}AIW8we`3JS={ z$)UKoI5dCYz=7=CxsxG7hTQYgOE3Lx-n@BlV%G&aSOpR31N>1{Q-DOEMMSn3V{(i! zQCm47c)i}+RaIYm?KK6|WIqw+1~Vi>)6>(nzP`T8Qh8-%W#~+RF-CDXlxuFV=TE&S z%VWca4dH`~WxKt%+y*@ou^!LqNa)IeVLZS5^2`5o#~pV7;PrZ2KKtx5X=rE& zJn+B+U-|w1iLK-M0USAUByriYW!!)N{iLU-F9a5a5j$sz+o6L3VowR%eJ$Yg;&$)C z?RKB+?>hj-OvK!qZF>>USWM5Kb@>c^Wm)*ODsQ~;#;Vn;SO04u5OCJk)@tj9H?eaH zFnRK14jw$%p+EYC2Gj1Vn)GY%)Y-SjPsOBPch;md7=+J1f<_NdJ!MCd%aP0e)?&qOqtT=h0bb!KjQH6z<~q92Midn+2L?pmYkeSZEY>Q_9sm5R7{VaVPn-( z2le)uCf&CS{&A^6RYcnC{vi>$Peg`w$~n}QX3YVY%LTw~x7`LnFc@t04h^xfv7rFykw+fkuDk9EBO)Rc78Vi+1gNU2V)pFW z9nKE4AwE8yTW+}pfP3$~mxmsD=+pyLJAn#g%nQbtt!HGudoqWZP6WR8`~9x+^75b8 z*Vl(q-)%w#TbFoDOiZT{;E*9hLcdE(OYL}~3%vgN>r9w10jJZ+;K74K{i<3D+>PxT zS6Y_k>54{sobx6qBIAJ@j4`ja_0`wcmwou*hvenuZQ8M8$8E2^`f8WqZXgg~*|KFE zJ$m%C&&kZpWaP+^^y<}%<;$0c(&eh!2)w7NKJ4#$|7i(D6C%s@hiaq4h5 z0-WI+J=^yD^UrHjQR;Io zDJeQ;%$Tsl#i;@ZM8qv3Ux`T6OI+PW zmsY?S6XWrCdYucg+}vCyOqf7QN(yOdX#l9|SHJ@7)YLZ-aeJX4A}Ok>ZnyiiZ#pI> zMyF4ouJ6A4?x}Bjw}|A6$Z3jyKXIu2e>@+0yBe9BvvR%hrh7n`?e;f%3>+WGaitVYnXtp7UCJYB*iTtNIko1M_Z8^UT(00000 LNkvXXu0mjfELSVm literal 0 HcmV?d00001 diff --git a/src/Impostor.Server/packages.lock.json b/src/Impostor.Server/packages.lock.json new file mode 100644 index 0000000..d4e3919 --- /dev/null +++ b/src/Impostor.Server/packages.lock.json @@ -0,0 +1,543 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "OK+670i7esqlQrPjdIKRbsyMCe9g5kSLpRRQGSr4Q58AOYEe/hCnfLZprh7viNisSUUQZmMrbbuDaIrP+V1ebQ==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "ItYHpdqVp5/oFLT5QqbopnkKlyFG9EW/9nhM6/yfObeKt6Su0wkBio6AizgRHGNwhJuAtlE5VIjow5JOTrip6w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.Configuration.CommandLine": "8.0.0", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "8.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "8.0.0", + "Microsoft.Extensions.Configuration.Json": "8.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "8.0.0", + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Configuration": "8.0.0", + "Microsoft.Extensions.Logging.Console": "8.0.0", + "Microsoft.Extensions.Logging.Debug": "8.0.0", + "Microsoft.Extensions.Logging.EventLog": "8.0.0", + "Microsoft.Extensions.Logging.EventSource": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA==" + }, + "Serilog.Extensions.Hosting": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "db0OcbWeSCvYQkHWu6n0v40N4kKaTAXNjlM3BKvcbwvNzYphQFcBR+36eQ/7hMMwOkJvAyLC2a9/jNdUL5NjtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Serilog": "3.1.1", + "Serilog.Extensions.Logging": "8.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "nR0iL5HwKj5v6ULo3/zpP8NMcq9E2pxYA6XKTSWCbugVs4YqPyvaqaKOY+OMpPivKp7zMEpax2UKHnDodbRB0Q==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.0", + "Serilog": "3.1.1" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[5.0.1, )", + "resolved": "5.0.1", + "contentHash": "6Jt8jl9y2ey8VV7nVEUAyjjyxjAQuvd5+qj4XYAT9CwcsvR70HHULGBeD+K2WCALFXf7CFsNQT4lON6qXcu2AA==", + "dependencies": { + "Serilog": "3.1.1" + } + }, + "StyleCop.Analyzers.Unstable": { + "type": "Direct", + "requested": "[1.2.0.556, )", + "resolved": "1.2.0.556", + "contentHash": "zvn9Mqs/ox/83cpYPignI8hJEM2A93s2HkHs8HYMOAQW0PkampyoErAiIyKxgTLqbbad29HX/shv/6LGSjPJNQ==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Direct", + "requested": "[7.6.2, )", + "resolved": "7.6.2", + "contentHash": "dyvPIzxohovbECESKj9ep7yShujU5oN9dKYR3ivvoTM7nLANNrBC5jN9ysZfY5Hp/Xs0R1b79IsdHuu6D4xwow==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "7.6.2", + "Microsoft.IdentityModel.Tokens": "7.6.2" + } + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.1", + "contentHash": "vZsG2YILhthgRqO+ZVgRff4ZFKKTl0v7kqaVBLCtRvpREhfBP33pcWrdA3PRYgWuFL1RxiUFvjMUHTdBZlJcoA==" + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "Nxs7Z1q3f1STfLYKJSVXCs1iBl+Ya6E8o4Oy1bCxJ/rNI44E/0f6tbsrVqAWfB7jlnJfyaAtIalBVxPKUPQb4Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.2.0", + "System.Text.Encodings.Web": "4.5.0" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "ziFz5zH8f33En4dX81LW84I6XrYXKf9jg6aM39cM+LffN9KJahViKZ61dGMSO2gd3e+qe5yBRwsesvyqlZaSMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.2.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NZuZMz3Q8Z780nKX3ifV1fE7lS+6pynDHK71OfU4OZ1ItgvDOhyOC7E6z+JMZrAj63zRpwbdldYFk499t3+1dQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "plvZ0ZIpq+97gdPNNvhwvrEZ92kNml9hd1pe3idMA7svR0PztdzVLkoWLcRFgySYXUJc3kSM3Xw3mNFMo/bxRA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "McP+Lz/EKwvtCv48z0YImw+L1gi1gy5rHhNaNIY2CrjloV+XY8gydT8DjMR6zWeL13AFK+DioVpppwAuO1Gi1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "C2wqUoh9OmRL1akaCcKSTmRU8z0kckfImG7zLNI8uyi47Lp+zd5LWAD17waPQEqCz3ioWOCrFUo+JJuoeZLOBw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ihDHu2dJYQird9pl2CbdwuNDfvCZdOS0S7SPlNfhPt0B81UTT+yyZKz2pimFZGUp3AfuBRnqUCxB2SjsZKHVUw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Json": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.6", + "contentHash": "0Zn6nR/6g+90MxskZyOOMPQvnPnrrGu6bytPwkV+azDcTtCSuQ1+GJUrg8Klmnrjk1i6zMpw2lXijl+tw7Q3kA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "UboiXxpPUpwulHvIAVE36Knq0VSHaAmfrFkegLyBZeaADuKezJ/AIXYAW8F5GBlGk/VaibN2k/Zn1ca8YAfVdA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ixXXV0G/12g6MXK65TLngYN9V5hQQRuV+fZi882WIoVJT7h5JvoYoxTEwCgdqwLjSneqh1O+66gM8sMr9z/rsQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "e+48o7DztoYog+PY430lPxrM4mm3PbA6qucvQtUDDwVo4MO+ejMw7YGc/o2rnxbxj4isPxdfKFzTxvXMwAz83A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "dt0x21qBdudHLW/bjMJpkixv858RRr8eSomgVbU8qljOyfrfDGi1JQvpF9w8S7ziRPtRKisuWaOwFxJM82GxeA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3X9D3sl7EmOu7vQp5MJrmIJBl5XSdOhZPYXUeFfYa6Nnm9+tok8x3t3IVPLhm7UJtPOU61ohFchw8rNm9tIYOQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.EventLog": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "oKcPMrw+luz2DUAKhwFXrmFikZWnyc8l2RKoQwqU3KIZZjcfoJE0zRHAnqATfhRZhtcbjl/QkiY2Xjxp0xu+6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "7.6.2", + "contentHash": "ULeyJwfYTMHOAArrBZorjPyM/BL5PFfwRzDtxlOxawO9vB/wVmHmbzZnOyHCOLJjel7XiVNmVnAs3H0jh4/9jQ==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "7.6.2", + "contentHash": "ySbY72MsWw7BnSZw0fCsmp/oMou8ntJkfuZUBcplV3ncXMzxcXCn3fK6Gg2YLnTT6F2G9JXl9zc2lwfVfGOWEA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "7.6.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "7.6.2", + "contentHash": "0brV311MYxGz7Numa+pbVsxbz5tfe2nbAig1b5tQb3h/L1y5lkoPyOgD0qAfI0iX1njbwr8l9NdxIT1cDbzWKA==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "7.6.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "7.6.2", + "contentHash": "pLnhCq9UNKWkn83zutkObYuzA+sOzx6VZpPI8hB8gD/vAXVt14D0SJ0sKPftwufvAbYGSNRda1vw/IFLbkjxNg==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "7.6.2" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.1-dev-02373", + "contentHash": "f0HNPaleOox5++8zqxAdi9afnUUkoznLOmd0ur/UnLbzl8bIaPyyBGOpbIJmC1kz2vHfkQ0fXh6KXMdlz7bcLQ==" + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0", + "Serilog": "3.1.1" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OdrZO2WjkiEG6ajEFRABTRCi/wuXQPxeV6g8xvUJqdxMvvuCCEk86zPla8UiIQJz3durtUEbNyY/3lIhS0yZvQ==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0" + } + }, + "impostor.api": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "[2.2.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[8.0.0, )", + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )", + "NextFast.Hazel": "[1.0.2-dev, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )" + } + }, + "impostor.api.innersloth.generator": { + "type": "Project" + }, + "NextFast.Hazel": { + "type": "Project", + "dependencies": { + "BouncyCastle.Cryptography": "[2.6.1, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[9.0.6, )", + "Microsoft.Extensions.ObjectPool": "[8.0.1, )", + "NextFast.Hazel.Abstractions": "[1.0.2-dev, )", + "Serilog": "[4.3.1-dev-02373, )" + } + }, + "NextFast.Hazel.Abstractions": { + "type": "Project" + } + }, + "net8.0/linux-arm": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + } + }, + "net8.0/linux-arm64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + } + }, + "net8.0/linux-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + } + }, + "net8.0/osx-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + } + }, + "net8.0/win-x64": { + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "fdYxcRjQqTTacKId/2IECojlDSFvp7LP5N78+0z/xH7v/Tuw5ZAxu23Y6PTCRinqyu2ePx+Gn1098NC6jM6d+A==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + } + } + } +} \ No newline at end of file diff --git a/src/Impostor.sln b/src/Impostor.sln new file mode 100644 index 0000000..3212414 --- /dev/null +++ b/src/Impostor.sln @@ -0,0 +1,98 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11709.299 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Server", "Impostor.Server\Impostor.Server.csproj", "{1B0390AF-A4F3-4FE4-B093-708B0135C0B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Api", "Impostor.Api\Impostor.Api.csproj", "{E096A7D7-D693-4A13-A526-38CC574D84F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Api.Innersloth.Generator", "Impostor.Api.Innersloth.Generator\Impostor.Api.Innersloth.Generator.csproj", "{8B6C3AC5-E2B2-49F8-854D-22DFB4AB3D31}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Plugins.Example", "Impostor.Plugins.Example\Impostor.Plugins.Example.csproj", "{70F97BB7-12A1-4C7A-B2A5-B962D14B813E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Plugins.Welcome", "Impostor.Plugins.Welcome\Impostor.Plugins.Welcome.csproj", "{AA000002-0000-0000-0000-000000000002}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{E0009DCB-32A6-45C4-A06C-7F62D4091A29}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hazel", "Hazel", "{A42D36F0-B635-4BBD-9F96-97AA17B1574A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Next.Hazel", "NextFast.Hazel-New\Next.Hazel\Next.Hazel.csproj", "{07B43A32-0E0C-589F-4796-C15DE906326F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Next.Hazel.Abstractions", "NextFast.Hazel-New\Next.Hazel.Abstractions\Next.Hazel.Abstractions.csproj", "{5F7A70A7-1FE9-0B48-FFC1-C0F88BE0547A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Plugins.FixedCode", "Impostor.Plugins.FixedCode\Impostor.Plugins.FixedCode.csproj", "{C583BD2A-1468-99CA-4E3D-CBDA4D2EDB9F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Plugins.FriendCodeValidator", "Impostor.Plugins.FriendCodeValidator\Impostor.Plugins.FriendCodeValidator.csproj", "{70CD7AC9-1DE3-CD23-602A-EFD504B91450}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Plugins.Titles", "Impostor.Plugins.Titles\Impostor.Plugins.Titles.csproj", "{3038597E-E1A1-2958-1CF7-C4203A85F424}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Plugins.QqVerify", "Impostor.Plugins.QqVerify\Impostor.Plugins.QqVerify.csproj", "{B8A5D7C3-1F4E-4A2B-9C6D-8E3F5A7B1D2E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1B0390AF-A4F3-4FE4-B093-708B0135C0B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B0390AF-A4F3-4FE4-B093-708B0135C0B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B0390AF-A4F3-4FE4-B093-708B0135C0B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B0390AF-A4F3-4FE4-B093-708B0135C0B3}.Release|Any CPU.Build.0 = Release|Any CPU + {E096A7D7-D693-4A13-A526-38CC574D84F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E096A7D7-D693-4A13-A526-38CC574D84F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E096A7D7-D693-4A13-A526-38CC574D84F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E096A7D7-D693-4A13-A526-38CC574D84F8}.Release|Any CPU.Build.0 = Release|Any CPU + {8B6C3AC5-E2B2-49F8-854D-22DFB4AB3D31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B6C3AC5-E2B2-49F8-854D-22DFB4AB3D31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B6C3AC5-E2B2-49F8-854D-22DFB4AB3D31}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B6C3AC5-E2B2-49F8-854D-22DFB4AB3D31}.Release|Any CPU.Build.0 = Release|Any CPU + {70F97BB7-12A1-4C7A-B2A5-B962D14B813E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70F97BB7-12A1-4C7A-B2A5-B962D14B813E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70F97BB7-12A1-4C7A-B2A5-B962D14B813E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70F97BB7-12A1-4C7A-B2A5-B962D14B813E}.Release|Any CPU.Build.0 = Release|Any CPU + {AA000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU + {07B43A32-0E0C-589F-4796-C15DE906326F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07B43A32-0E0C-589F-4796-C15DE906326F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07B43A32-0E0C-589F-4796-C15DE906326F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07B43A32-0E0C-589F-4796-C15DE906326F}.Release|Any CPU.Build.0 = Release|Any CPU + {5F7A70A7-1FE9-0B48-FFC1-C0F88BE0547A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F7A70A7-1FE9-0B48-FFC1-C0F88BE0547A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F7A70A7-1FE9-0B48-FFC1-C0F88BE0547A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F7A70A7-1FE9-0B48-FFC1-C0F88BE0547A}.Release|Any CPU.Build.0 = Release|Any CPU + {C583BD2A-1468-99CA-4E3D-CBDA4D2EDB9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C583BD2A-1468-99CA-4E3D-CBDA4D2EDB9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C583BD2A-1468-99CA-4E3D-CBDA4D2EDB9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C583BD2A-1468-99CA-4E3D-CBDA4D2EDB9F}.Release|Any CPU.Build.0 = Release|Any CPU + {70CD7AC9-1DE3-CD23-602A-EFD504B91450}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70CD7AC9-1DE3-CD23-602A-EFD504B91450}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70CD7AC9-1DE3-CD23-602A-EFD504B91450}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70CD7AC9-1DE3-CD23-602A-EFD504B91450}.Release|Any CPU.Build.0 = Release|Any CPU + {3038597E-E1A1-2958-1CF7-C4203A85F424}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3038597E-E1A1-2958-1CF7-C4203A85F424}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3038597E-E1A1-2958-1CF7-C4203A85F424}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3038597E-E1A1-2958-1CF7-C4203A85F424}.Release|Any CPU.Build.0 = Release|Any CPU + {B8A5D7C3-1F4E-4A2B-9C6D-8E3F5A7B1D2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8A5D7C3-1F4E-4A2B-9C6D-8E3F5A7B1D2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8A5D7C3-1F4E-4A2B-9C6D-8E3F5A7B1D2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8A5D7C3-1F4E-4A2B-9C6D-8E3F5A7B1D2E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {70F97BB7-12A1-4C7A-B2A5-B962D14B813E} = {E0009DCB-32A6-45C4-A06C-7F62D4091A29} + {AA000002-0000-0000-0000-000000000002} = {E0009DCB-32A6-45C4-A06C-7F62D4091A29} + {07B43A32-0E0C-589F-4796-C15DE906326F} = {A42D36F0-B635-4BBD-9F96-97AA17B1574A} + {5F7A70A7-1FE9-0B48-FFC1-C0F88BE0547A} = {A42D36F0-B635-4BBD-9F96-97AA17B1574A} + {C583BD2A-1468-99CA-4E3D-CBDA4D2EDB9F} = {E0009DCB-32A6-45C4-A06C-7F62D4091A29} + {70CD7AC9-1DE3-CD23-602A-EFD504B91450} = {E0009DCB-32A6-45C4-A06C-7F62D4091A29} + {3038597E-E1A1-2958-1CF7-C4203A85F424} = {E0009DCB-32A6-45C4-A06C-7F62D4091A29} + {B8A5D7C3-1F4E-4A2B-9C6D-8E3F5A7B1D2E} = {E0009DCB-32A6-45C4-A06C-7F62D4091A29} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DAF5625C-7E46-4DCC-AF2C-11D20D965454} + EndGlobalSection +EndGlobal diff --git a/src/NextFast.Hazel-New/.gitignore b/src/NextFast.Hazel-New/.gitignore new file mode 100644 index 0000000..1170bdb --- /dev/null +++ b/src/NextFast.Hazel-New/.gitignore @@ -0,0 +1,7 @@ +/.vscode +/build +/.vs +/src/Impostor.Plugins.Debugger/Properties/launchSettings.json +/tools + +.local/ diff --git a/src/NextFast.Hazel-New/Directory.Build.props b/src/NextFast.Hazel-New/Directory.Build.props new file mode 100644 index 0000000..de0722f --- /dev/null +++ b/src/NextFast.Hazel-New/Directory.Build.props @@ -0,0 +1,15 @@ + + + net8.0 + latest + Impostor,Next-Fast + MIT + https://github.com/Next-Fast/Impostor.Hazel + git + 1.0.2 + dev + MIT + true + snupkg + + \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Hazel.sln b/src/NextFast.Hazel-New/Hazel.sln new file mode 100644 index 0000000..6320b0a --- /dev/null +++ b/src/NextFast.Hazel-New/Hazel.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30523.141 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Next.Hazel", "Next.Hazel\Next.Hazel.csproj", "{02CFBD30-D77D-400F-94B2-700F60EFDD7F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Next.Hazel.Abstractions", "Next.Hazel.Abstractions\Next.Hazel.Abstractions.csproj", "{51FD3233-44D5-43BC-A618-A381703EDAD3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Impostor.Hazel.Tests", "Impostor.Hazel.Tests\Impostor.Hazel.Tests.csproj", "{6CC1050D-4142-454E-BA09-974DF77D2C10}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {02CFBD30-D77D-400F-94B2-700F60EFDD7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02CFBD30-D77D-400F-94B2-700F60EFDD7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02CFBD30-D77D-400F-94B2-700F60EFDD7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02CFBD30-D77D-400F-94B2-700F60EFDD7F}.Release|Any CPU.Build.0 = Release|Any CPU + {51FD3233-44D5-43BC-A618-A381703EDAD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51FD3233-44D5-43BC-A618-A381703EDAD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51FD3233-44D5-43BC-A618-A381703EDAD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51FD3233-44D5-43BC-A618-A381703EDAD3}.Release|Any CPU.Build.0 = Release|Any CPU + {6CC1050D-4142-454E-BA09-974DF77D2C10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CC1050D-4142-454E-BA09-974DF77D2C10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CC1050D-4142-454E-BA09-974DF77D2C10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CC1050D-4142-454E-BA09-974DF77D2C10}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8AC8C0A6-FB6B-4E63-9042-EDF10C6A51B8} + EndGlobalSection +EndGlobal diff --git a/src/NextFast.Hazel-New/LICENSE b/src/NextFast.Hazel-New/LICENSE new file mode 100644 index 0000000..f3ad895 --- /dev/null +++ b/src/NextFast.Hazel-New/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2018 Innersloth LLC +Copyright (c) 2016-2017 DarkRift Networking + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageReader.cs b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageReader.cs new file mode 100644 index 0000000..0f78a77 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageReader.cs @@ -0,0 +1,75 @@ +namespace Next.Hazel.Abstractions; + +public interface IMessageReader : IDisposable +{ + /// + /// Gets the tag of the message. + /// + byte Tag { get; } + + /// + /// Gets the buffer of the message. + /// + byte[]? Buffer { get; } + + /// + /// Gets the offset of our current in the entire . + /// + int Offset { get; } + + /// + /// Gets the current position of the reader. + /// + int Position { get; } + + /// + /// Gets the length of the buffer. + /// + int Length { get; } + + int BytesRemaining { get; } + + IMessageReader? Parent { get; } + + IMessageReader ReadMessage(); + + bool ReadBoolean(); + + sbyte ReadSByte(); + + byte ReadByte(); + + ushort ReadUInt16(); + + short ReadInt16(); + + uint ReadUInt32(); + + int ReadInt32(); + + ulong ReadUInt64(); + + long ReadInt64(); + + float ReadSingle(); + + string ReadString(int length); + + string ReadString(); + + ReadOnlyMemory ReadBytesAndSize(); + + ReadOnlyMemory ReadBytes(int length); + + int ReadPackedInt32(); + + uint ReadPackedUInt32(); + + void CopyTo(IMessageWriter writer); + + void Seek(int position); + + void RemoveMessage(IMessageReader message); + + IMessageReader Copy(int offset = 0); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriter.cs b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriter.cs new file mode 100644 index 0000000..0406443 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriter.cs @@ -0,0 +1,137 @@ +using System.Net; + +namespace Next.Hazel.Abstractions; + +/// +/// Base message writer. +/// +public interface IMessageWriter : IDisposable +{ + public byte[] Buffer { get; } + + public int Length { get; set; } + + public int Position { get; set; } + + public MessageType SendOption { get; } + + /// + /// Copies the contents of this writer into a new array. + /// + /// Whether to include message header in the array. + /// An array containing the data in the current writer. + byte[] ToByteArray(bool includeHeader); + + /// + /// Writes a boolean to the message. + /// + /// Value to write. + void Write(bool value); + + /// + /// Writes a sbyte to the message. + /// + /// Value to write. + void Write(sbyte value); + + /// + /// Writes a byte to the message. + /// + /// Value to write. + void Write(byte value); + + /// + /// Writes a short to the message. + /// + /// Value to write. + void Write(short value); + + /// + /// Writes an ushort to the message. + /// + /// Value to write. + void Write(ushort value); + + /// + /// Writes an uint to the message. + /// + /// Value to write. + void Write(uint value); + + /// + /// Writes an int to the message. + /// + /// Value to write. + void Write(int value); + + /// + /// Writes an ulong to the message. + /// + /// Value to write. + void Write(ulong value); + + /// + /// Writes an ulong to the message. + /// + /// Value to write. + void Write(long value); + + /// + /// Writes a float to the message. + /// + /// Value to write. + void Write(float value); + + /// + /// Writes a string to the message. + /// + /// Value to write. + void Write(string value); + + /// + /// Writes a to the message. + /// + /// Value to write. + void Write(IPAddress value); + + /// + /// Writes an packed int to the message. + /// + /// Value to write. + void WritePacked(int value); + + /// + /// Writes an packed uint to the message. + /// + /// Value to write. + void WritePacked(uint value); + + /// + /// Writes raw bytes to the message. + /// + /// Bytes to write. + void Write(ReadOnlyMemory data); + + void WriteBytesAndSize(byte[] bytes); + + void WriteBytesAndSize(byte[] bytes, int length); + + void WriteBytesAndSize(byte[] bytes, int offset, int length); + + /// + /// Starts a new message. + /// + /// Message flag header. + void StartMessage(byte typeFlag); + + /// + /// Mark the end of the message. + /// + void EndMessage(); + + /// + /// Clear the message writer. + /// + /// New type of the message. + void Clear(MessageType type); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriterProvider.cs b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriterProvider.cs new file mode 100644 index 0000000..ec7a4fe --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/IMessageWriterProvider.cs @@ -0,0 +1,15 @@ +namespace Next.Hazel.Abstractions; + +public interface IMessageWriterProvider +{ + /// + /// Retrieves a from the internal pool. + /// Make sure to call when you are done!. + /// + /// + /// Whether to send the message as or . + /// Reliable packets will ensure delivery while unreliable packets may be lost. + /// + /// A from the pool. + IMessageWriter Get(MessageType sendOption = MessageType.Unreliable); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel.Abstractions/MessageType.cs b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/MessageType.cs new file mode 100644 index 0000000..1c33fde --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/MessageType.cs @@ -0,0 +1,29 @@ +namespace Next.Hazel.Abstractions; + +/// +/// Specifies how a message should be sent between connections. +/// +[Flags] +public enum MessageType : byte +{ + /// + /// Requests unreliable delivery with no fragmentation. + /// + /// + /// Sending data using unreliable delivery means that data is not guaranteed to arrive at it's destination nor is + /// it guaranteed to arrive only once. However, unreliable delivery can be faster than other methods and it + /// typically requires a smaller number of protocol bytes than other methods. There is also typically less + /// processing involved and less memory needed as packets are not stored once sent. + /// + Unreliable, + + /// + /// Requests data be sent reliably but with no fragmentation. + /// + /// + /// Sending data reliably means that data is guaranteed to arrive and to arrive only once. Reliable delivery + /// typically requires more processing, more memory (as packets need to be stored in case they need resending), + /// a larger number of protocol bytes and can be slower than unreliable delivery. + /// + Reliable +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel.Abstractions/Next.Hazel.Abstractions.csproj b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/Next.Hazel.Abstractions.csproj new file mode 100644 index 0000000..f72ce49 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel.Abstractions/Next.Hazel.Abstractions.csproj @@ -0,0 +1,13 @@ + + + + enable + enable + true + Next.Hazel.Abstractions + Next.Hazel.Abstractions + true + NextFast.Hazel.Abstractions + + + diff --git a/src/NextFast.Hazel-New/Next.Hazel/ByteSpan.cs b/src/NextFast.Hazel-New/Next.Hazel/ByteSpan.cs new file mode 100644 index 0000000..355d430 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/ByteSpan.cs @@ -0,0 +1,154 @@ +using System; + +namespace Next.Hazel; + +/// +/// This is a minimal implementation of `System.Span` in .NET 5.0 +/// +public struct ByteSpan +{ + private readonly byte[] array_; + + /// + /// Create a new span object containing an entire array + /// + public ByteSpan(byte[] array) + { + if (array == null) + { + array_ = null; + Offset = 0; + Length = 0; + } + else + { + array_ = array; + Offset = 0; + Length = array.Length; + } + } + + /// + /// Creates a new span object containing a subset of an array + /// + public ByteSpan(byte[] array, int offset, int length) + { + if (array == null) + { + if (offset != 0) throw new ArgumentException("Invalid offset", nameof(offset)); + if (length != 0) throw new ArgumentException("Invalid length", nameof(offset)); + + array_ = null; + Offset = 0; + Length = 0; + } + else + { + if (offset < 0 || offset > array.Length) throw new ArgumentException("Invalid offset", nameof(offset)); + if (length < 0) throw new ArgumentException($"Invalid length: {length}", nameof(length)); + if (offset + length > array.Length) + throw new ArgumentException( + $"Invalid length. Length: {length} Offset: {offset} Array size: {array.Length}", nameof(length)); + + array_ = array; + Offset = offset; + Length = length; + } + } + + /// + /// Returns the underlying array. + /// WARNING: This does not return the span, but the entire underlying storage block + /// + public byte[] GetUnderlyingArray() + { + return array_; + } + + /// + /// Returns the offset into the underlying array + /// + public int Offset { get; } + + /// + /// Returns the length of the current span + /// + public int Length { get; } + + /// + /// Gets the span element at the specified index + /// + public byte this[int index] + { + get + { + if (index < 0 || index >= Length) throw new IndexOutOfRangeException(); + + return array_[Offset + index]; + } + set + { + if (index < 0 || index >= Length) throw new IndexOutOfRangeException(); + + array_[Offset + index] = value; + } + } + + /// + /// Create a new span that is a subset of this span [offset, this.Length-offset) + /// + public ByteSpan Slice(int offset) + { + return Slice(offset, Length - offset); + } + + /// + /// Create a new span that is a subset of this span [offset, length) + /// + public ByteSpan Slice(int offset, int length) + { + return new ByteSpan(array_, Offset + offset, length); + } + + /// + /// Copies the contents of the span to an array + /// + public void CopyTo(byte[] array, int offset) + { + CopyTo(new ByteSpan(array, offset, array.Length - offset)); + } + + /// + /// Copies the contents of the span to another span + /// + public void CopyTo(ByteSpan destination) + { + if (destination.Length < Length) + throw new ArgumentException("Destination span is shorter than source", nameof(destination)); + + if (Length > 0) Buffer.BlockCopy(array_, Offset, destination.array_, destination.Offset, Length); + } + + /// + /// Create a new array with the contents of this span + /// + public byte[] ToArray() + { + var result = new byte[Length]; + CopyTo(result); + return result; + } + + /// + /// Implicit conversion from byte[] -> ByteSpan + /// + public static implicit operator ByteSpan(byte[] array) + { + return new ByteSpan(array); + } + + /// + /// Retuns an empty span object + /// + public static ByteSpan Empty => new(null); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/ByteSpanExtensions.cs b/src/NextFast.Hazel-New/Next.Hazel/ByteSpanExtensions.cs new file mode 100644 index 0000000..2dd7f12 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/ByteSpanExtensions.cs @@ -0,0 +1,126 @@ +namespace Next.Hazel; + +/// +/// Extension functions for (en/de)coding integer values +/// +public static class ByteSpanBigEndianExtensions +{ + // Write a 16-bit integer in big-endian format to output[0..2) + public static void WriteBigEndian16(this ByteSpan output, ushort value, int offset = 0) + { + output[offset + 0] = (byte)(value >> 8); + output[offset + 1] = (byte)(value >> 0); + } + + // Write a 24-bit integer in big-endian format to output[0..3) + public static void WriteBigEndian24(this ByteSpan output, uint value, int offset = 0) + { + output[offset + 0] = (byte)(value >> 16); + output[offset + 1] = (byte)(value >> 8); + output[offset + 2] = (byte)(value >> 0); + } + + // Write a 32-bit integer in big-endian format to output[0..4) + public static void WriteBigEndian32(this ByteSpan output, uint value, int offset) + { + output[offset + 0] = (byte)(value >> 24); + output[offset + 1] = (byte)(value >> 16); + output[offset + 2] = (byte)(value >> 8); + output[offset + 3] = (byte)(value >> 0); + } + + // Write a 48-bit integer in big-endian format to output[0..6) + public static void WriteBigEndian48(this ByteSpan output, ulong value, int offset = 0) + { + output[offset + 0] = (byte)(value >> 40); + output[offset + 1] = (byte)(value >> 32); + output[offset + 2] = (byte)(value >> 24); + output[offset + 3] = (byte)(value >> 16); + output[offset + 4] = (byte)(value >> 8); + output[offset + 5] = (byte)(value >> 0); + } + + // Write a 64-bit integer in big-endian format to output[0..8) + public static void WriteBigEndian64(this ByteSpan output, ulong value, int offset = 0) + { + output[offset + 0] = (byte)(value >> 56); + output[offset + 1] = (byte)(value >> 48); + output[offset + 2] = (byte)(value >> 40); + output[offset + 3] = (byte)(value >> 32); + output[offset + 4] = (byte)(value >> 24); + output[offset + 5] = (byte)(value >> 16); + output[offset + 6] = (byte)(value >> 8); + output[offset + 7] = (byte)(value >> 0); + } + + // Read a 16-bit integer in big-endian format from input[0..2) + public static ushort ReadBigEndian16(this ByteSpan input, int offset = 0) + { + ushort value = 0; + value |= (ushort)(input[offset + 0] << 8); + value |= (ushort)(input[offset + 1] << 0); + return value; + } + + // Read a 24-bit integer in big-endian format from input[0..3) + public static uint ReadBigEndian24(this ByteSpan input, int offset = 0) + { + uint value = 0; + value |= (uint)input[offset + 0] << 16; + value |= (uint)input[offset + 1] << 8; + value |= (uint)input[offset + 2] << 0; + return value; + } + + // Read a 48-bit integer in big-endian format from input[0..3) + public static ulong ReadBigEndian48(this ByteSpan input, int offset = 0) + { + ulong value = 0; + value |= (ulong)input[offset + 0] << 40; + value |= (ulong)input[offset + 1] << 32; + value |= (ulong)input[offset + 2] << 24; + value |= (ulong)input[offset + 3] << 16; + value |= (ulong)input[offset + 4] << 8; + value |= (ulong)input[offset + 5] << 0; + return value; + } +} + +public static class ByteSpanLittleEndianExtensions +{ + // Read a 24-bit integer in little-endian format from input[0..3) + public static uint ReadLittleEndian24(this ByteSpan input, int offset = 0) + { + uint value = 0; + value |= input[offset + 0]; + value |= (uint)input[offset + 1] << 8; + value |= (uint)input[offset + 2] << 16; + return value; + } + + // Read a 24-bit integer in little-endian format from input[0..4) + public static uint ReadLittleEndian32(this ByteSpan input, int offset = 0) + { + uint value = 0; + value |= input[offset + 0]; + value |= (uint)input[offset + 1] << 8; + value |= (uint)input[offset + 2] << 16; + value |= (uint)input[offset + 3] << 24; + return value; + } + + /// + /// Reuse an existing span if there is enough space, + /// otherwise allocate new storage + /// + /// + /// Source span we should attempt to reuse + /// + /// Required size (bytes) + public static ByteSpan ReuseSpanIfPossible(this ByteSpan source, int requiredSize) + { + if (source.Length >= requiredSize) return source[..requiredSize]; + + return new byte[requiredSize]; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Connection.cs b/src/NextFast.Hazel-New/Next.Hazel/Connection.cs new file mode 100644 index 0000000..9a512c2 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Connection.cs @@ -0,0 +1,240 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Next.Hazel.Abstractions; +using Serilog; + +namespace Next.Hazel; + +/// +/// Base class for all connections. +/// +/// +/// +/// Connection is the base class for all connections that Hazel can make. It provides common functionality and a +/// standard interface to allow connections to be swapped easily. +/// +/// +/// Any class inheriting from Connection should provide the 3 standard guarantees that Hazel provides: +/// +/// +/// Thread Safe +/// +/// +/// Connection Orientated +/// +/// +/// Packet/Message Based +/// +/// +/// +/// +/// +public abstract class Connection : IDisposable +{ + private static readonly ILogger Logger = Log.ForContext(); + + protected ConnectionState _state; + + /// + /// Called when a message has been received. + /// + /// + /// + /// DataReceived is invoked everytime a message is received from the end point of this connection, the message + /// that was received can be found in the alongside other information from the + /// event. + /// + /// + /// + /// + /// + /// + public Func DataReceived; + + /// + /// Called when the end point disconnects or an error occurs. + /// + /// + /// + /// Disconnected is invoked when the connection is closed due to an exception occuring or because the remote + /// end point disconnected. If it was invoked due to an exception occuring then the exception is available + /// in the passed with the event. + /// + /// + /// + /// + /// + /// + public Func Disconnected; + + protected int testDropCount = 0; + public int TestDropRate = 0; + + public int TestLagMs = -1; + + /// + /// Constructor that initializes the ConnecitonStatistics object. + /// + /// + /// This constructor initialises with empty statistics and sets to + /// . + /// + protected Connection() + { + Statistics = new ConnectionStatistics(); + State = ConnectionState.NotConnected; + } + + /// + /// The remote end point of this Connection. + /// + /// + /// This is the end point that this connection is connected to (i.e. the other device). This returns an abstract + /// which can then be cast to an appropriate end point depending on the + /// connection type. + /// + public IPEndPoint EndPoint { get; protected set; } + + public IPMode IPMode { get; protected set; } + + /// + /// The traffic statistics about this Connection. + /// + /// + /// Contains statistics about the number of messages and bytes sent and received by this connection. + /// + public ConnectionStatistics Statistics { get; protected set; } + + /// + /// The state of this connection. + /// + /// + /// All implementers should be aware that when this is set to ConnectionState.Connected it will + /// release all threads that are blocked on . + /// + public ConnectionState State + { + get => _state; + + protected set + { + _state = value; + SetState(value); + } + } + + /// + /// Disposes of this NetworkConnection. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void SetState(ConnectionState state) + { + } + + /// The message to send. + /// + /// + /// + /// The sendOptions parameter is only a request to use those options and the actual method used to send the + /// data is up to the implementation. There are circumstances where this parameter may be ignored but in + /// general any implementer should aim to always follow the user's request. + /// + /// + public abstract ValueTask SendAsync(IMessageWriter msg); + + /// The bytes of the message to send. + /// The option specifying how the message should be sent. + /// + /// + /// + /// The sendOptions parameter is only a request to use those options and the actual method used to send the + /// data is up to the implementation. There are circumstances where this parameter may be ignored but in + /// general any implementer should aim to always follow the user's request. + /// + /// + public abstract ValueTask SendBytes(byte[] bytes, MessageType sendOption = MessageType.Unreliable); + + /// + /// Connects the connection to a server and begins listening. + /// This method blocks and may thrown if there is a problem connecting. + /// + /// The bytes of data to send in the handshake. + /// The number of milliseconds to wait before giving up on the connect attempt. + public abstract ValueTask ConnectAsync(byte[] bytes = null, int timeout = 5000); + + /// + /// Invokes the DataReceived event. + /// + /// The bytes received. + /// + /// + /// Invokes the event on this connection to alert subscribers a new message has been + /// received. The bytes and the send option that the message was sent with should be passed in to give to the + /// subscribers. + /// + protected async ValueTask InvokeDataReceived(MessageReader msg, MessageType messageType) + { + // Make a copy to avoid race condition between null check and invocation + var handler = DataReceived; + if (handler != null) + try + { + await handler(new DataReceivedEventArgs(this, msg, messageType)); + } + catch (Exception e) + { + Logger.Error(e, "Invoking data received failed"); + await Disconnect("Invoking data received failed"); + } + } + + /// + /// Invokes the Disconnected event. + /// + /// The exception, if any, that occurred to cause this. + /// Extra disconnect data + /// + /// Invokes the event to alert subscribres this connection has been disconnected either + /// by the end point or because an error occurred. If an error occurred the error should be passed in in order to + /// pass to the subscribers, otherwise null can be passed in. + /// + protected async ValueTask InvokeDisconnected(string e, MessageReader reader) + { + // Make a copy to avoid race condition between null check and invocation + var handler = Disconnected; + if (handler != null) + try + { + await handler(new DisconnectedEventArgs(e, reader)); + } + catch (Exception ex) + { + Logger.Error(ex, "Error in InvokeDisconnected"); + } + } + + /// + /// For times when you want to force the disconnect handler to fire as well as close it. + /// If you only want to close it, just use Dispose. + /// + public abstract ValueTask Disconnect(string reason, MessageWriter writer = null); + + /// + /// Disposes of this NetworkConnection. + /// + /// Are we currently disposing? + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + DataReceived = null; + Disconnected = null; + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/ConnectionListener.cs b/src/NextFast.Hazel-New/Next.Hazel/ConnectionListener.cs new file mode 100644 index 0000000..fb2fad1 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/ConnectionListener.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading.Tasks; +using Next.Hazel.Abstractions; +using Serilog; + +namespace Next.Hazel; + +/// +/// Base class for all connection listeners. +/// +/// +/// +/// ConnectionListeners are server side objects that listen for clients and create matching server side connections +/// for each client in a similar way to TCP does. These connections should be ready for communication immediately. +/// +/// +/// Each time a client connects the event will be invoked to alert all subscribers to +/// the new connection. A disconnected event is then present on the that is passed to the +/// subscribers. +/// +/// +/// +public abstract class ConnectionListener : IAsyncDisposable +{ + private static readonly ILogger Logger = Log.ForContext(); + + /// + /// Invoked when a new client connects. + /// + /// + /// + /// NewConnection is invoked each time a client connects to the listener. The + /// contains the new for communication with this + /// client. + /// + /// + /// Hazel may or may not store connections so it is your responsibility to keep track and properly Dispose of + /// connections to your server. + /// + /// + /// + /// + /// + /// + public Func NewConnection; + + /// + /// Call to dispose of the connection listener. + /// + public virtual ValueTask DisposeAsync() + { + NewConnection = null; + return ValueTask.CompletedTask; + } + + /// + /// Makes this connection listener begin listening for connections. + /// + /// + /// + /// This instructs the listener to begin listening for new clients connecting to the server. When a new client + /// connects the event will be invoked containing the connection to the new client. + /// + /// + /// + /// + /// + public abstract Task StartAsync(); + + /// + /// Invokes the NewConnection event with the supplied connection. + /// + /// The user sent bytes that were received as part of the handshake. + /// The connection to pass in the arguments. + /// + /// Implementers should call this to invoke the event before data is received so that + /// subscribers do not miss any data that may have been sent immediately after connecting. + /// + internal async Task InvokeNewConnection(IMessageReader msg, Connection connection) + { + // Make a copy to avoid race condition between null check and invocation + var handler = NewConnection; + if (handler != null) + try + { + await handler(new NewConnectionEventArgs(msg, connection)); + } + catch (Exception e) + { + Logger.Error(e, "Accepting connection failed"); + await connection.Disconnect("Accepting connection failed"); + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/ConnectionState.cs b/src/NextFast.Hazel-New/Next.Hazel/ConnectionState.cs new file mode 100644 index 0000000..21a47e9 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/ConnectionState.cs @@ -0,0 +1,22 @@ +namespace Next.Hazel; + +/// +/// Represents the state a is currently in. +/// +public enum ConnectionState +{ + /// + /// The Connection has either not been established yet or has been disconnected. + /// + NotConnected, + + /// + /// The Connection is currently connecting to an endpoint. + /// + Connecting, + + /// + /// The Connection is connected and data can be transfered. + /// + Connected +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/ConnectionStatistics.cs b/src/NextFast.Hazel-New/Next.Hazel/ConnectionStatistics.cs new file mode 100644 index 0000000..fbaa9cb --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/ConnectionStatistics.cs @@ -0,0 +1,455 @@ +using System.Threading; + +namespace Next.Hazel; + +/// +/// Holds statistics about the traffic through a . +/// +/// +public class ConnectionStatistics +{ + private const int ExpectedMTU = 1200; + + /// + /// The number of acknowledgement messages received. + /// + private int acknowledgementMessagesReceived; + + /// + /// The number of acknowledgement messages sent. + /// + private int acknowledgementMessagesSent; + + /// + /// The number of bytes of data received. + /// + private long dataBytesReceived; + + /// + /// The number of bytes of data sent. + /// + private long dataBytesSent; + + /// + /// The number of messages sent larger than 576 bytes. + /// + private int fragmentableMessagesSent; + + /// + /// The number of fragmented messages received. + /// + private int fragmentedMessagesReceived; + + /// + /// The number of fragmented messages sent. + /// + private int fragmentedMessagesSent; + + /// + /// The number of hello messages received. + /// + private int helloMessagesReceived; + + /// + /// The number of hello messages sent. + /// + private int helloMessagesSent; + + private int messagesResent; + + /// + /// The number of hello messages received. + /// + private int pingMessagesReceived; + + /// + /// The number of reliable messages received. + /// + private int reliableMessagesReceived; + + /// + /// The number of unreliable messages sent. + /// + private int reliableMessagesSent; + + /// + /// The number of bytes received in total. + /// + private long totalBytesReceived; + + /// + /// The number of bytes sent in total. + /// + private long totalBytesSent; + + /// + /// The number of unreliable messages received. + /// + private int unreliableMessagesReceived; + + /// + /// The number of unreliable messages sent. + /// + private int unreliableMessagesSent; + + /// + /// The total number of messages sent. + /// + public int MessagesSent => UnreliableMessagesSent + ReliableMessagesSent + FragmentedMessagesSent + + AcknowledgementMessagesSent + HelloMessagesSent; + + /// + /// The number of messages sent larger than 576 bytes. This is smaller than most default MTUs. + /// + /// + /// This is the number of unreliable messages that were sent from the , incremented + /// each time that LogUnreliableSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + public int FragmentableMessagesSent => fragmentableMessagesSent; + + /// + /// The number of unreliable messages sent. + /// + /// + /// This is the number of unreliable messages that were sent from the , incremented + /// each time that LogUnreliableSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + public int UnreliableMessagesSent => unreliableMessagesSent; + + /// + /// The number of reliable messages sent. + /// + /// + /// This is the number of reliable messages that were sent from the , incremented + /// each time that LogReliableSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + public int ReliableMessagesSent => reliableMessagesSent; + + /// + /// The number of fragmented messages sent. + /// + /// + /// This is the number of fragmented messages that were sent from the , incremented + /// each time that LogFragmentedSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + public int FragmentedMessagesSent => fragmentedMessagesSent; + + /// + /// The number of acknowledgement messages sent. + /// + /// + /// This is the number of acknowledgements that were sent from the , incremented + /// each time that LogAcknowledgementSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + public int AcknowledgementMessagesSent => acknowledgementMessagesSent; + + /// + /// The number of hello messages sent. + /// + /// + /// This is the number of hello messages that were sent from the , incremented + /// each time that LogHelloSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + public int HelloMessagesSent => helloMessagesSent; + + /// + /// The number of bytes of data sent. + /// + /// + /// + /// This is the number of bytes of data (i.e. user bytes) that were sent from the , + /// accumulated each time that LogSend is called by the Connection. Messages that caused an error are not + /// counted and messages are only counted once all other operations in the send are complete. + /// + /// + /// For the number of bytes including protocol bytes see . + /// + /// + public long DataBytesSent => Interlocked.Read(ref dataBytesSent); + + /// + /// The number of bytes sent in total. + /// + /// + /// + /// This is the total number of bytes (the data bytes plus protocol bytes) that were sent from the + /// , accumulated each time that LogSend is called by the Connection. Messages that + /// caused an error are not counted and messages are only counted once all other operations in the send are + /// complete. + /// + /// + /// For the number of data bytes excluding protocol bytes see . + /// + /// + public long TotalBytesSent => Interlocked.Read(ref totalBytesSent); + + /// + /// The total number of messages received. + /// + public int MessagesReceived => UnreliableMessagesReceived + ReliableMessagesReceived + FragmentedMessagesReceived + + AcknowledgementMessagesReceived + helloMessagesReceived; + + /// + /// The number of unreliable messages received. + /// + /// + /// This is the number of unreliable messages that were received by the , incremented + /// each time that LogUnreliableReceive is called by the Connection. Messages are counted before the receive event is + /// invoked. + /// + public int UnreliableMessagesReceived => unreliableMessagesReceived; + + /// + /// The number of reliable messages received. + /// + /// + /// This is the number of reliable messages that were received by the , incremented + /// each time that LogReliableReceive is called by the Connection. Messages are counted before the receive event is + /// invoked. + /// + public int ReliableMessagesReceived => reliableMessagesReceived; + + /// + /// The number of fragmented messages received. + /// + /// + /// This is the number of fragmented messages that were received by the , incremented + /// each time that LogFragmentedReceive is called by the Connection. Messages are counted before the receive event is + /// invoked. + /// + public int FragmentedMessagesReceived => fragmentedMessagesReceived; + + /// + /// The number of acknowledgement messages received. + /// + /// + /// This is the number of acknowledgement messages that were received by the , incremented + /// each time that LogAcknowledgemntReceive is called by the Connection. Messages are counted before the receive event + /// is invoked. + /// + public int AcknowledgementMessagesReceived => acknowledgementMessagesReceived; + + /// + /// The number of ping messages received. + /// + /// + /// This is the number of hello messages that were received by the , incremented + /// each time that LogHelloReceive is called by the Connection. Messages are counted before the receive event is + /// invoked. + /// + public int PingMessagesReceived => pingMessagesReceived; + + /// + /// The number of hello messages received. + /// + /// + /// This is the number of hello messages that were received by the , incremented + /// each time that LogHelloReceive is called by the Connection. Messages are counted before the receive event is + /// invoked. + /// + public int HelloMessagesReceived => helloMessagesReceived; + + /// + /// The number of bytes of data received. + /// + /// + /// + /// This is the number of bytes of data (i.e. user bytes) that were received by the , + /// accumulated each time that LogReceive is called by the Connection. Messages are counted before the receive + /// event is invoked. + /// + /// + /// For the number of bytes including protocol bytes see . + /// + /// + public long DataBytesReceived => Interlocked.Read(ref dataBytesReceived); + + /// + /// The number of bytes received in total. + /// + /// + /// + /// This is the total number of bytes (the data bytes plus protocol bytes) that were received by the + /// , accumulated each time that LogReceive is called by the Connection. Messages are + /// counted before the receive event is invoked. + /// + /// + /// For the number of data bytes excluding protocol bytes see . + /// + /// + public long TotalBytesReceived => Interlocked.Read(ref totalBytesReceived); + + public int MessagesResent => messagesResent; + + /// + /// Logs the sending of an unreliable data packet in the statistics. + /// + /// The number of bytes of data sent. + /// The total number of bytes sent. + /// + /// This should be called after the data has been sent and should only be called for data that is sent sucessfully. + /// + internal void LogUnreliableSend(int dataLength, int totalLength) + { + Interlocked.Increment(ref unreliableMessagesSent); + Interlocked.Add(ref dataBytesSent, dataLength); + Interlocked.Add(ref totalBytesSent, totalLength); + + if (totalLength > ExpectedMTU) Interlocked.Increment(ref fragmentableMessagesSent); + } + + /// + /// Logs the sending of a reliable data packet in the statistics. + /// + /// The number of bytes of data sent. + /// The total number of bytes sent. + /// + /// This should be called after the data has been sent and should only be called for data that is sent sucessfully. + /// + internal void LogReliableSend(int dataLength, int totalLength) + { + Interlocked.Increment(ref reliableMessagesSent); + Interlocked.Add(ref dataBytesSent, dataLength); + Interlocked.Add(ref totalBytesSent, totalLength); + + if (totalLength > ExpectedMTU) Interlocked.Increment(ref fragmentableMessagesSent); + } + + /// + /// Logs the sending of a fragmented data packet in the statistics. + /// + /// The number of bytes of data sent. + /// The total number of bytes sent. + /// + /// This should be called after the data has been sent and should only be called for data that is sent sucessfully. + /// + internal void LogFragmentedSend(int dataLength, int totalLength) + { + Interlocked.Increment(ref fragmentedMessagesSent); + Interlocked.Add(ref dataBytesSent, dataLength); + Interlocked.Add(ref totalBytesSent, totalLength); + + if (totalLength > ExpectedMTU) Interlocked.Increment(ref fragmentableMessagesSent); + } + + /// + /// Logs the sending of a acknowledgement data packet in the statistics. + /// + /// The total number of bytes sent. + /// + /// This should be called after the data has been sent and should only be called for data that is sent sucessfully. + /// + internal void LogAcknowledgementSend(int totalLength) + { + Interlocked.Increment(ref acknowledgementMessagesSent); + Interlocked.Add(ref totalBytesSent, totalLength); + } + + /// + /// Logs the sending of a hellp data packet in the statistics. + /// + /// The total number of bytes sent. + /// + /// This should be called after the data has been sent and should only be called for data that is sent sucessfully. + /// + internal void LogHelloSend(int totalLength) + { + Interlocked.Increment(ref helloMessagesSent); + Interlocked.Add(ref totalBytesSent, totalLength); + } + + /// + /// Logs the receiving of an unreliable data packet in the statistics. + /// + /// The number of bytes of data received. + /// The total number of bytes received. + /// + /// This should be called before the received event is invoked so it is up to date for subscribers to that event. + /// + internal void LogUnreliableReceive(int dataLength, int totalLength) + { + Interlocked.Increment(ref unreliableMessagesReceived); + Interlocked.Add(ref dataBytesReceived, dataLength); + Interlocked.Add(ref totalBytesReceived, totalLength); + } + + /// + /// Logs the receiving of a reliable data packet in the statistics. + /// + /// The number of bytes of data received. + /// The total number of bytes received. + /// + /// This should be called before the received event is invoked so it is up to date for subscribers to that event. + /// + internal void LogReliableReceive(int dataLength, int totalLength) + { + Interlocked.Increment(ref reliableMessagesReceived); + Interlocked.Add(ref dataBytesReceived, dataLength); + Interlocked.Add(ref totalBytesReceived, totalLength); + } + + /// + /// Logs the receiving of a fragmented data packet in the statistics. + /// + /// The number of bytes of data received. + /// The total number of bytes received. + /// + /// This should be called before the received event is invoked so it is up to date for subscribers to that event. + /// + internal void LogFragmentedReceive(int dataLength, int totalLength) + { + Interlocked.Increment(ref fragmentedMessagesReceived); + Interlocked.Add(ref dataBytesReceived, dataLength); + Interlocked.Add(ref totalBytesReceived, totalLength); + } + + /// + /// Logs the receiving of an acknowledgement data packet in the statistics. + /// + /// The total number of bytes received. + /// + /// This should be called before the received event is invoked so it is up to date for subscribers to that event. + /// + internal void LogAcknowledgementReceive(int totalLength) + { + Interlocked.Increment(ref acknowledgementMessagesReceived); + Interlocked.Add(ref totalBytesReceived, totalLength); + } + + /// + /// Logs the receiving of a hello data packet in the statistics. + /// + /// The total number of bytes received. + /// + /// This should be called before the received event is invoked so it is up to date for subscribers to that event. + /// + internal void LogPingReceive(int totalLength) + { + Interlocked.Increment(ref pingMessagesReceived); + Interlocked.Add(ref totalBytesReceived, totalLength); + } + + /// + /// Logs the receiving of a hello data packet in the statistics. + /// + /// The total number of bytes received. + /// + /// This should be called before the received event is invoked so it is up to date for subscribers to that event. + /// + internal void LogHelloReceive(int totalLength) + { + Interlocked.Increment(ref helloMessagesReceived); + Interlocked.Add(ref totalBytesReceived, totalLength); + } + + internal void LogMessageResent() + { + Interlocked.Increment(ref messagesResent); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/AesGcm.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/AesGcm.cs new file mode 100644 index 0000000..80fb6c1 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/AesGcm.cs @@ -0,0 +1,325 @@ +using System; +using System.Diagnostics; + +namespace Next.Hazel.Crypto; + +/// +/// Implementation of AEAD_AES128_GCM based on: +/// * RFC 5116 [1] +/// * NIST SP 800-38d [2] +/// [1] https://tools.ietf.org/html/rfc5116 +/// [2] https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf +/// Adapted from: https://gist.github.com/mendsley/777e6bd9ae7eddcb2b0c0fe18247dc60 +/// +public class Aes128Gcm : IDisposable +{ + public const int KeySize = 16; + public const int NonceSize = 12; + public const int CiphertextOverhead = TagSize; + + private const int TagSize = 16; + private readonly ByteSpan blockJ_; + private readonly ByteSpan blockS_; + private readonly ByteSpan blockScratch_; + private readonly ByteSpan blockV_; + private readonly ByteSpan blockZ_; + + private readonly IAes encryptor_; + + private readonly ByteSpan hashSubkey_; + + /// + /// Creates a new instance of an AEAD_AES128_GCM cipher + /// + /// Symmetric key + public Aes128Gcm(ByteSpan key) + { + if (key.Length != KeySize) throw new ArgumentException("Invalid key length", nameof(key)); + + // Create the AES block cipher + encryptor_ = CryptoProvider.CreateAes(key); + + // Allocate scratch space + ByteSpan scratchSpace = new byte[96]; + hashSubkey_ = scratchSpace[..16]; + blockJ_ = scratchSpace.Slice(16, 16); + blockS_ = scratchSpace.Slice(32, 16); + blockZ_ = scratchSpace.Slice(48, 16); + blockV_ = scratchSpace.Slice(64, 16); + blockScratch_ = scratchSpace.Slice(80, 16); + + // Create the GHASH subkey by encrypting the 0-block + encryptor_.EncryptBlock(hashSubkey_, hashSubkey_); + } + + /// + /// Release resources acquired by the cipher + /// + public void Dispose() + { + encryptor_.Dispose(); + } + + /// + /// Encryptes the specified plaintext and generates an authentication + /// tag for the provided additional data. Returns the byte array + /// containg both the ciphertext and authentication tag. + /// + /// + /// Array in which to encode the encrypted ciphertext and + /// authentication tag. This array must be large enough to hold + /// `plaintext.Lengh + CiphertextOverhead` bytes. + /// + /// Unique value for this message + /// Plaintext data to encrypt + /// + /// Additional data used to authenticate the message + /// + public void Seal(ByteSpan output, ByteSpan nonce, ByteSpan plaintext, ByteSpan associatedData) + { + if (nonce.Length != NonceSize) throw new ArgumentException("Invalid nonce size", nameof(nonce)); + if (output.Length < plaintext.Length + CiphertextOverhead) + throw new ArgumentException("Invalid output size", nameof(output)); + + // Create the initial counter block + nonce.CopyTo(blockJ_); + + // Encrypt the plaintext to output + GCTR(output, blockJ_, 2, plaintext); + + // Generate and append the authentication tag + var tagOffset = plaintext.Length; + GenerateAuthenticationTag(output[tagOffset..], output[..tagOffset], associatedData); + } + + /// + /// Validates the authentication tag against the provided additional + /// data, then decrypts the cipher text returning the original + /// plaintext. + /// + /// + /// The unique value used to seal this message + /// + /// + /// Combined ciphertext and authentication tag + /// + /// + /// Additional data used to authenticate the message + /// + /// + /// On successful validation and decryprion, Open writes the original + /// plaintext to output. Must contain enough space to hold + /// `ciphertext.Length - CiphertextOverhead` bytes. + /// + /// + /// True if the data was validated and successfully decrypted. + /// Otherwise, false. + /// + public bool Open(ByteSpan output, ByteSpan nonce, ByteSpan ciphertext, ByteSpan associatedData) + { + if (nonce.Length != NonceSize) throw new ArgumentException("Invalid nonce size", nameof(nonce)); + if (ciphertext.Length < CiphertextOverhead) + throw new ArgumentException("Invalid ciphertext size", nameof(ciphertext)); + + if (output.Length < ciphertext.Length - CiphertextOverhead) + throw new ArgumentException("Invalid output size", nameof(output)); + + // Split ciphertext into actual ciphertext and authentication + // tag components. + var authenticationTag = ciphertext[^TagSize..]; + ciphertext = ciphertext[..^TagSize]; + + // Create the initial counter block + nonce.CopyTo(blockJ_); + + // Verify the tags match + GenerateAuthenticationTag(blockScratch_, ciphertext, associatedData); + if (0 == Const.ConstantCompareSpans(blockScratch_, authenticationTag)) return false; + + // Decrypt the cipher text to output + GCTR(output, blockJ_, 2, ciphertext); + return true; + } + + // Generate the authentication tag for a ciphertext+associated data + private void GenerateAuthenticationTag(ByteSpan output, ByteSpan ciphertext, ByteSpan associatedData) + { + Debug.Assert(output.Length >= 16); + + // Hash `Associated data || Ciphertext || len(AssociatedD data) || len(Ciphertext)` + // into `blockS` + { + // Clear hash output block + SetSpanToZeros(blockS_); + + // Write associated data blocks to hash + var fullBlocks = associatedData.Length / 16; + GHASH(blockS_, associatedData, fullBlocks); + if (fullBlocks * 16 < associatedData.Length) + { + SetSpanToZeros(blockScratch_); + associatedData[(fullBlocks * 16)..].CopyTo(blockScratch_); + GHASH(blockS_, blockScratch_, 1); + } + + // Write ciphertext blocks to hash + fullBlocks = ciphertext.Length / 16; + GHASH(blockS_, ciphertext, fullBlocks); + if (fullBlocks * 16 < ciphertext.Length) + { + SetSpanToZeros(blockScratch_); + ciphertext[(fullBlocks * 16)..].CopyTo(blockScratch_); + GHASH(blockS_, blockScratch_, 1); + } + + // Write bit sizes to hash + var associatedDataLengthInBits = (ulong)(8 * associatedData.Length); + var ciphertextDataLengthInBits = (ulong)(8 * ciphertext.Length); + blockScratch_.WriteBigEndian64(associatedDataLengthInBits); + blockScratch_.WriteBigEndian64(ciphertextDataLengthInBits, 8); + + GHASH(blockS_, blockScratch_, 1); + } + + // Encrypt the tag. GCM requires this because `GASH` is not + // cryptographically secure. An attacker could derive our hash + // subkey `hashSubkey_` from an unencrypted tag. + GCTR(output, blockJ_, 1, blockS_); + } + + // Run the GCTR cipher + private void GCTR(ByteSpan output, ByteSpan counterBlock, uint counter, ByteSpan data) + { + Debug.Assert(counterBlock.Length == 16); + Debug.Assert(output.Length >= data.Length); + + // Loop through plaintext blocks + var writeIndex = 0; + var numBlocks = (data.Length + 15) / 16; + for (var ii = 0; ii != numBlocks; ++ii) + { + // Encode counter into block + // CB[1] = J0 + // CB[i] = inc[32](CB[i-1]) + counterBlock.WriteBigEndian32(counter, 12); + ++counter; + + // CIPH[k](CB[i]) + encryptor_.EncryptBlock(counterBlock[..16], blockScratch_); + + // Y[i] = X[i] xor CIPH[k](CB[i]) + for (var jj = 0; jj != 16 && writeIndex < data.Length; ++jj, ++writeIndex) + output[writeIndex] = (byte)(data[writeIndex] ^ blockScratch_[jj]); + } + } + + // Run the GHASH function + private void GHASH(ByteSpan output, ByteSpan data, int numBlocks) + { + Debug.Assert(output.Length == 16); + Debug.Assert(data.Length >= numBlocks * 16); + + var readIndex = 0; + for (var ii = 0; ii != numBlocks; ++ii) + { + for (var jj = 0; jj != 16; ++jj, ++readIndex) + // Y[ii-1] xor X[ii] + output[jj] ^= data[readIndex]; + + // Y[ii] = (Y[ii-1] xor X[ii]) · H + MultiplyGF128Elements(output, hashSubkey_, blockZ_, blockV_); + } + } + + // Multiply two Galois field elements `X` and `Y` together and store + // the result in `X` such that at the end of the function: + // X = X·Y + private static void MultiplyGF128Elements(ByteSpan X, ByteSpan Y, ByteSpan scratchZ, ByteSpan scratchV) + { + Debug.Assert(X.Length == 16); + Debug.Assert(Y.Length == 16); + Debug.Assert(scratchZ.Length == 16); + Debug.Assert(scratchV.Length == 16); + + // Galois (finite) fields represented by GF(p) define a set of + // closed algebraic operations. For AES128_GCM we'll be dealing + // with the GF(2^128) field. + // + // We treat each incoming 16 byte block as a polynomial in field + // and define multiplication between two polynomials as the + // polynomial product reduced by (mod) the field polynomial: + // 1 + x + x^2 + x^7 + x^128 + // + // Field polynomials are represented by a 128 bit string. Bit n is + // the coefficient of the x^n term. We use little-endian bit + // ordering (not to be confused with byte ordering) for these + // coefficients. E.g. X[0] & 0x00000001 represents the 7th bit in + // the bit string defined by X, _not_ the 0th bit. + // + + // What follows is a modified version of the "peasant's algorithm" + // to multiply two numbers: + // + // Z contains the accumulated product + // V is a copy of Y (so we can modify it via shifting). + // + // We calculate Z = X·V as follows + // We loop through each of the 128 bits in X maintaining the + // following loop invariant: X·V + Z = the final product + // + // On each iteration `ii`: + // + // If the `ii`th bit of `X` is set, add the add the polynomial + // in `V` to `X`: `X[n] = X[n] ^ V[n]` + // + // Double V (Shift one bit right since we're storing little + // endian bit). This has the effect of multiplying V by the + // polynomial `x`. We track the unrepresentable coefficient + // of `x^128` by storing the most significant bit before the + // shift `V[15] >> 7` as `carry` + // + // Check if we've overflowed our multiplication. If overflow + // occurred, there will be a non-zero coefficient for the + // `x^128` term in the step above `carry` + // + // If we have overflowed, our polynomial is exactly of degree + // 129 (since we're only multiplying by `x`). We reduce the + // polynomial back into degree 128 by adding our field's + // irreducible polynomial: 1 + x + x^2 + x^7 + x^128. This + // reduction cancels out the x^128 term (x^128 + x^128 in GF(2) + // is zero). Therefore this modulo can be achieved by simply + // adding the irreducible polynomial to the new value of `V`. The + // irreducible polynomial is represented by the bit string: + // `11100001` followed by 120 `0`s. We can add this value to `V` + // by: `V[0] = V[0] ^ 0xE1`. + SetSpanToZeros(scratchZ); + X.CopyTo(scratchV); + + for (var ii = 0; ii != 128; ++ii) + { + var bitIndex = 7 - ii % 8; + if ((Y[ii / 8] & (1 << bitIndex)) != 0) + for (var jj = 0; jj != 16; ++jj) + scratchZ[jj] ^= scratchV[jj]; + + var carry = false; + for (var jj = 0; jj != 16; ++jj) + { + var newCarry = (scratchV[jj] & 0x01) != 0; + scratchV[jj] >>= 1; + if (carry) scratchV[jj] |= 0x80; + carry = newCarry; + } + + if (carry) scratchV[0] ^= 0xE1; + } + + scratchZ.CopyTo(X); + } + + // Set the contents of a span to all zero + private static void SetSpanToZeros(ByteSpan span) + { + for (int ii = 0, nn = span.Length; ii != nn; ++ii) span[ii] = 0; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/Const.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/Const.cs new file mode 100644 index 0000000..90a2ae1 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/Const.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; + +namespace Next.Hazel.Crypto; + +public static class Const +{ + /// + /// Compare two bytes for equality. + /// This takes care to always use a constant amount of time to prevent + /// leaking information through side-channel attacks. + /// This is aceived by collapsing the xor bits down into a single bit. + /// Ported from: + /// https://github.com/mendsley/tiny/blob/master/include/tiny/crypto/constant.h + /// + /// + /// Returns `1` is the two bytes or equivalent. Otherwise, returns `0` + /// + public static byte ConstantCompareByte(byte a, byte b) + { + var result = (byte)~(a ^ b); + + // collapse bits down to the LSB + result &= (byte)(result >> 4); + result &= (byte)(result >> 2); + result &= (byte)(result >> 1); + + return result; + } + + /// + /// Compare two equal length spans for equality. + /// This takes care to always use a constant amount of time to prevent + /// leaking information through side-channel attacks. + /// Ported from: + /// https://github.com/mendsley/tiny/blob/master/include/tiny/crypto/constant.h + /// + /// + /// Returns `1` if the spans are equivalent. Others, returns `0`. + /// + public static byte ConstantCompareSpans(ByteSpan a, ByteSpan b) + { + Debug.Assert(a.Length == b.Length); + + byte value = 0; + for (int ii = 0, nn = a.Length; ii != nn; ++ii) value |= (byte)(a[ii] ^ b[ii]); + + return ConstantCompareByte(value, 0); + } + + /// + /// Compare a span against an all zero span + /// This takes care to always use a constant amount of time to prevent + /// leaking information through side-channel attacks. + /// Ported from: + /// https://github.com/mendsley/tiny/blob/master/include/tiny/crypto/constant.h + /// + /// + /// Returns `1` if the spans is all zeros. Others, returns `0`. + /// + public static byte ConstantCompareZeroSpan(ByteSpan a) + { + byte value = 0; + for (int ii = 0, nn = a.Length; ii != nn; ++ii) value |= (byte)(a[ii] ^ 0); + + return ConstantCompareByte(value, 0); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/CryptoProvider.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/CryptoProvider.cs new file mode 100644 index 0000000..c80abfc --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/CryptoProvider.cs @@ -0,0 +1,26 @@ +namespace Next.Hazel.Crypto; + +public static class CryptoProvider +{ + public delegate IAes CreateAesOverrideDelegate(ByteSpan key); + + /// + /// Override the default AES creation function + /// + public static CreateAesOverrideDelegate OverrideCreateAes = null; + + /// + /// Create a new AES cipher + /// + /// Encrtyption key + public static IAes CreateAes(ByteSpan key) + { + if (OverrideCreateAes != null) + { + var result = OverrideCreateAes(key); + if (null != result) return result; + } + + return new DefaultAes(key); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/DefaultAes.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/DefaultAes.cs new file mode 100644 index 0000000..c300f50 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/DefaultAes.cs @@ -0,0 +1,49 @@ +using System; +using System.Security.Cryptography; + +namespace Next.Hazel.Crypto; + +/// +/// AES provider using the default System.Security.Cryptography implementation +/// +public class DefaultAes : IAes +{ + private readonly ICryptoTransform encryptor_; + + /// + /// Create a new default instance of the AES block cipher + /// + /// Encryption key + public DefaultAes(ByteSpan key) + { + // Create the AES block cipher + using (var aes = Aes.Create()) + { + aes.KeySize = key.Length * 8; + aes.BlockSize = aes.KeySize; + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.Zeros; + aes.Key = key.ToArray(); + + encryptor_ = aes.CreateEncryptor(); + } + } + + /// + public void Dispose() + { + encryptor_.Dispose(); + } + + /// + public int EncryptBlock(ByteSpan inputSpan, ByteSpan outputSpan) + { + if (inputSpan.Length != outputSpan.Length) + throw new ArgumentException( + $"ouputSpan length ({outputSpan.Length}) does not match inputSpan length ({inputSpan.Length})", + nameof(outputSpan)); + + return encryptor_.TransformBlock(inputSpan.GetUnderlyingArray(), inputSpan.Offset, inputSpan.Length, + outputSpan.GetUnderlyingArray(), outputSpan.Offset); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/IAes.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/IAes.cs new file mode 100644 index 0000000..da544a5 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/IAes.cs @@ -0,0 +1,22 @@ +using System; + +namespace Next.Hazel.Crypto; + +/// +/// AES encryption interface +/// +public interface IAes : IDisposable +{ + /// + /// Encrypts the specified region of the input byte array and copies + /// the resulting transform to the specified region of the output + /// array. + /// + /// The input for which to encrypt + /// + /// The otput to which to write the encrypted data. This span can + /// overlap with `inputSpan`. + /// + /// The number of bytes written + int EncryptBlock(ByteSpan inputSpan, ByteSpan outputSpan); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/Sha256Stream.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/Sha256Stream.cs new file mode 100644 index 0000000..693f993 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/Sha256Stream.cs @@ -0,0 +1,77 @@ +using System; +using System.Security.Cryptography; + +namespace Next.Hazel.Crypto; + +/// +/// Streams data into a SHA256 digest +/// +public class Sha256Stream : IDisposable +{ + /// + /// Size of the SHA256 digest in bytes + /// + public const int DigestSize = 32; + + private SHA256 hash = SHA256.Create(); + + /// + /// Create a new instance of a SHA256 stream + /// + public Sha256Stream() + { + } + + /// + /// Release resources associated with the stream + /// + public void Dispose() + { + hash?.Dispose(); + hash = null; + + GC.SuppressFinalize(this); + } + + /// + /// Reset the stream to its initial state + /// + public void Reset() + { + hash?.Dispose(); + hash = SHA256.Create(); + } + + /// + /// Add data to the stream + /// + public void AddData(ByteSpan data) + { + while (data.Length > 0) + { + var offset = hash.TransformBlock(data.GetUnderlyingArray(), data.Offset, data.Length, null, 0); + data = data[offset..]; + } + } + + /// + /// Calculate the final hash of the stream data + /// + /// + /// Target span to which the hash will be written + /// + public void CalculateHash(ByteSpan output) + { + if (output.Length != DigestSize) + throw new ArgumentException($"Expected a span of {DigestSize} bytes. Got a span of {output.Length} bytes", + nameof(output)); + + hash.TransformFinalBlock(EmptyArray.Value, 0, 0); + new ByteSpan(hash.Hash).CopyTo(output); + } + + private struct EmptyArray + { + public static readonly byte[] Value = new byte[0]; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/SpanCryptoExtensions.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/SpanCryptoExtensions.cs new file mode 100644 index 0000000..d68621c --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/SpanCryptoExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Security.Cryptography; + +namespace Next.Hazel.Crypto; + +public static class SpanCryptoExtensions +{ + /// + /// Clear a span's contents to zero + /// + public static void SecureClear(this ByteSpan span) + { + if (span.Length > 0) Array.Clear(span.GetUnderlyingArray(), span.Offset, span.Length); + } + + /// + /// Fill a byte span with random data + /// + /// + /// Entropy source + public static void FillWithRandom(this ByteSpan span, RandomNumberGenerator random) + { + if (span.Offset == 0 && span.Length == span.GetUnderlyingArray().Length) + { + random.GetBytes(span.GetUnderlyingArray()); + return; + } + + var temp = new byte[span.Length]; + random.GetBytes(temp); + new ByteSpan(temp).CopyTo(span); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Crypto/X25519.cs b/src/NextFast.Hazel-New/Next.Hazel/Crypto/X25519.cs new file mode 100644 index 0000000..12fe184 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Crypto/X25519.cs @@ -0,0 +1,810 @@ +using System; +using System.Diagnostics; + +namespace Next.Hazel.Crypto; + +/// +/// The x25519 key agreement algorithm +/// +public static class X25519 +{ + public const int KeySize = 32; + + private static readonly byte[] BasePoint = + { 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + + /// + /// Performs the core x25519 function: Multiplying an EC point by a scalar value + /// + public static bool Func(ByteSpan output, ByteSpan scalar, ByteSpan point) + { + InternalFunc(output, scalar, point); + if (Const.ConstantCompareZeroSpan(output) == 1) return false; + + return true; + } + + /// + /// Multiplies the base x25519 point by the provided scalar value + /// + public static void Func(ByteSpan output, ByteSpan scalar) + { + InternalFunc(output, scalar, BasePoint); + } + + // The FieldElement code below is ported from the original + // public domain reference implemtation of X25519 + // by D. J. Bernstien + // + // See: https://cr.yp.to/ecdh.html + + private static void InternalFunc(ByteSpan output, ByteSpan scalar, ByteSpan point) + { + if (output.Length != KeySize) throw new ArgumentException("Invalid output size", nameof(output)); + + if (scalar.Length != KeySize) throw new ArgumentException("Invalid scalar size", nameof(scalar)); + + if (point.Length != KeySize) throw new ArgumentException("Invalid point size", nameof(point)); + + // copy the scalar so we can properly mask it + ByteSpan maskedScalar = new byte[32]; + scalar.CopyTo(maskedScalar); + maskedScalar[0] &= 248; + maskedScalar[31] &= 127; + maskedScalar[31] |= 64; + + var x1 = FieldElement.FromBytes(point); + var x2 = FieldElement.One(); + var x3 = x1; + var z2 = FieldElement.Zero(); + var z3 = FieldElement.One(); + + var tmp0 = new FieldElement(); + var tmp1 = new FieldElement(); + + var swap = 0; + for (var pos = 254; pos >= 0; --pos) + { + var b = maskedScalar[pos / 8] >> (pos % 8); + b &= 1; + swap ^= b; + + FieldElement.ConditionalSwap(ref x2, ref x3, swap); + FieldElement.ConditionalSwap(ref z2, ref z3, swap); + swap = b; + + FieldElement.Sub(ref tmp0, ref x3, ref z3); + FieldElement.Sub(ref tmp1, ref x2, ref z2); + FieldElement.Add(ref x2, ref x2, ref z2); + FieldElement.Add(ref z2, ref x3, ref z3); + FieldElement.Multiply(ref z3, ref tmp0, ref x2); + FieldElement.Multiply(ref z2, ref z2, ref tmp1); + FieldElement.Square(ref tmp0, ref tmp1); + FieldElement.Square(ref tmp1, ref x2); + FieldElement.Add(ref x3, ref z3, ref z2); + FieldElement.Sub(ref z2, ref z3, ref z2); + FieldElement.Multiply(ref x2, ref tmp1, ref tmp0); + FieldElement.Sub(ref tmp1, ref tmp1, ref tmp0); + FieldElement.Square(ref z2, ref z2); + FieldElement.Multiply121666(ref z3, ref tmp1); + FieldElement.Square(ref x3, ref x3); + FieldElement.Add(ref tmp0, ref tmp0, ref z3); + FieldElement.Multiply(ref z3, ref x1, ref z2); + FieldElement.Multiply(ref z2, ref tmp1, ref tmp0); + } + + FieldElement.ConditionalSwap(ref x2, ref x3, swap); + FieldElement.ConditionalSwap(ref z2, ref z3, swap); + + FieldElement.Invert(ref z2, ref z2); + FieldElement.Multiply(ref x2, ref x2, ref z2); + x2.CopyTo(output); + } + + /// + /// Element in the GF(2^255 - 19) field + /// + public partial struct FieldElement + { + public int x0, x1, x2, x3, x4; + public int x5, x6, x7, x8, x9; + } + + + /// + /// Mathematical operators over GF(2^255 - 19) + /// + partial struct FieldElement + { + /// + /// Convert a byte array to a field element + /// + public static FieldElement FromBytes(ByteSpan bytes) + { + Debug.Assert(bytes.Length >= KeySize); + + var tmp0 = (long)bytes.ReadLittleEndian32(); + var tmp1 = (long)bytes.ReadLittleEndian24(4) << 6; + var tmp2 = (long)bytes.ReadLittleEndian24(7) << 5; + var tmp3 = (long)bytes.ReadLittleEndian24(10) << 3; + var tmp4 = (long)bytes.ReadLittleEndian24(13) << 2; + var tmp5 = (long)bytes.ReadLittleEndian32(16); + var tmp6 = (long)bytes.ReadLittleEndian24(20) << 7; + var tmp7 = (long)bytes.ReadLittleEndian24(23) << 5; + var tmp8 = (long)bytes.ReadLittleEndian24(26) << 4; + var tmp9 = (long)(bytes.ReadLittleEndian24(29) & 0x007FFFFF) << 2; + + var carry9 = (tmp9 + (1L << 24)) >> 25; + tmp0 += carry9 * 19; + tmp9 -= carry9 << 25; + var carry1 = (tmp1 + (1L << 24)) >> 25; + tmp2 += carry1; + tmp1 -= carry1 << 25; + var carry3 = (tmp3 + (1L << 24)) >> 25; + tmp4 += carry3; + tmp3 -= carry3 << 25; + var carry5 = (tmp5 + (1L << 24)) >> 25; + tmp6 += carry5; + tmp5 -= carry5 << 25; + var carry7 = (tmp7 + (1L << 24)) >> 25; + tmp8 += carry7; + tmp7 -= carry7 << 25; + + var carry0 = (tmp0 + (1L << 25)) >> 26; + tmp1 += carry0; + tmp0 -= carry0 << 26; + var carry2 = (tmp2 + (1L << 25)) >> 26; + tmp3 += carry2; + tmp2 -= carry2 << 26; + var carry4 = (tmp4 + (1L << 25)) >> 26; + tmp5 += carry4; + tmp4 -= carry4 << 26; + var carry6 = (tmp6 + (1L << 25)) >> 26; + tmp7 += carry6; + tmp6 -= carry6 << 26; + var carry8 = (tmp8 + (1L << 25)) >> 26; + tmp9 += carry8; + tmp8 -= carry8 << 26; + + return new FieldElement + { + x0 = (int)tmp0, + x1 = (int)tmp1, + x2 = (int)tmp2, + x3 = (int)tmp3, + x4 = (int)tmp4, + x5 = (int)tmp5, + x6 = (int)tmp6, + x7 = (int)tmp7, + x8 = (int)tmp8, + x9 = (int)tmp9 + }; + } + + /// + /// Convert the field element to a byte array + /// + public void CopyTo(ByteSpan output) + { + Debug.Assert(output.Length >= 32); + + var q = (19 * x9 + (1L << 24)) >> 25; + q = (x0 + q) >> 26; + q = (x1 + q) >> 25; + q = (x2 + q) >> 26; + q = (x3 + q) >> 25; + q = (x4 + q) >> 26; + q = (x5 + q) >> 25; + q = (x6 + q) >> 26; + q = (x7 + q) >> 25; + q = (x8 + q) >> 26; + q = (x9 + q) >> 25; + + x0 = (int)(x0 + 19L * q); + + var carry0 = x0 >> 26; + x1 = x1 + carry0; + x0 = x0 - (carry0 << 26); + var carry1 = x1 >> 25; + x2 = x2 + carry1; + x1 = x1 - (carry1 << 25); + var carry2 = x2 >> 26; + x3 = x3 + carry2; + x2 = x2 - (carry2 << 26); + var carry3 = x3 >> 25; + x4 = x4 + carry3; + x3 = x3 - (carry3 << 25); + var carry4 = x4 >> 26; + x5 = x5 + carry4; + x4 = x4 - (carry4 << 26); + var carry5 = x5 >> 25; + x6 = x6 + carry5; + x5 = x5 - (carry5 << 25); + var carry6 = x6 >> 26; + x7 = x7 + carry6; + x6 = x6 - (carry6 << 26); + var carry7 = x7 >> 25; + x8 = x8 + carry7; + x7 = x7 - (carry7 << 25); + var carry8 = x8 >> 26; + x9 = x9 + carry8; + x8 = x8 - (carry8 << 26); + var carry9 = x9 >> 25; + x9 = x9 - (carry9 << 25); + + output[0] = (byte)(x0 >> 0); + output[1] = (byte)(x0 >> 8); + output[2] = (byte)(x0 >> 16); + output[3] = (byte)((x0 >> 24) | (x1 << 2)); + output[4] = (byte)(x1 >> 6); + output[5] = (byte)(x1 >> 14); + output[6] = (byte)((x1 >> 22) | (x2 << 3)); + output[7] = (byte)(x2 >> 5); + output[8] = (byte)(x2 >> 13); + output[9] = (byte)((x2 >> 21) | (x3 << 5)); + output[10] = (byte)(x3 >> 3); + output[11] = (byte)(x3 >> 11); + output[12] = (byte)((x3 >> 19) | (x4 << 6)); + output[13] = (byte)(x4 >> 2); + output[14] = (byte)(x4 >> 10); + output[15] = (byte)(x4 >> 18); + output[16] = (byte)(x5 >> 0); + output[17] = (byte)(x5 >> 8); + output[18] = (byte)(x5 >> 16); + output[19] = (byte)((x5 >> 24) | (x6 << 1)); + output[20] = (byte)(x6 >> 7); + output[21] = (byte)(x6 >> 15); + output[22] = (byte)((x6 >> 23) | (x7 << 3)); + output[23] = (byte)(x7 >> 5); + output[24] = (byte)(x7 >> 13); + output[25] = (byte)((x7 >> 21) | (x8 << 4)); + output[26] = (byte)(x8 >> 4); + output[27] = (byte)(x8 >> 12); + output[28] = (byte)((x8 >> 20) | (x9 << 6)); + output[29] = (byte)(x9 >> 2); + output[30] = (byte)(x9 >> 10); + output[31] = (byte)(x9 >> 18); + } + + /// + /// Set the field element to `0` + /// + public static FieldElement Zero() + { + return new FieldElement(); + } + + /// + /// Set the field element to `1` + /// + public static FieldElement One() + { + var result = Zero(); + result.x0 = 1; + return result; + } + + /// + /// Add two field elements + /// + public static void Add(ref FieldElement output, ref FieldElement a, ref FieldElement b) + { + output.x0 = a.x0 + b.x0; + output.x1 = a.x1 + b.x1; + output.x2 = a.x2 + b.x2; + output.x3 = a.x3 + b.x3; + output.x4 = a.x4 + b.x4; + output.x5 = a.x5 + b.x5; + output.x6 = a.x6 + b.x6; + output.x7 = a.x7 + b.x7; + output.x8 = a.x8 + b.x8; + output.x9 = a.x9 + b.x9; + } + + /// + /// Subtract two field elements + /// + public static void Sub(ref FieldElement output, ref FieldElement a, ref FieldElement b) + { + output.x0 = a.x0 - b.x0; + output.x1 = a.x1 - b.x1; + output.x2 = a.x2 - b.x2; + output.x3 = a.x3 - b.x3; + output.x4 = a.x4 - b.x4; + output.x5 = a.x5 - b.x5; + output.x6 = a.x6 - b.x6; + output.x7 = a.x7 - b.x7; + output.x8 = a.x8 - b.x8; + output.x9 = a.x9 - b.x9; + } + + /// + /// Multiply two field elements + /// + public static void Multiply(ref FieldElement output, ref FieldElement a, ref FieldElement b) + { + var b1_19 = 19 * b.x1; + var b2_19 = 19 * b.x2; + var b3_19 = 19 * b.x3; + var b4_19 = 19 * b.x4; + var b5_19 = 19 * b.x5; + var b6_19 = 19 * b.x6; + var b7_19 = 19 * b.x7; + var b8_19 = 19 * b.x8; + var b9_19 = 19 * b.x9; + + var a1_2 = 2 * a.x1; + var a3_2 = 2 * a.x3; + var a5_2 = 2 * a.x5; + var a7_2 = 2 * a.x7; + var a9_2 = 2 * a.x9; + + var a0b0 = a.x0 * (long)b.x0; + var a0b1 = a.x0 * (long)b.x1; + var a0b2 = a.x0 * (long)b.x2; + var a0b3 = a.x0 * (long)b.x3; + var a0b4 = a.x0 * (long)b.x4; + var a0b5 = a.x0 * (long)b.x5; + var a0b6 = a.x0 * (long)b.x6; + var a0b7 = a.x0 * (long)b.x7; + var a0b8 = a.x0 * (long)b.x8; + var a0b9 = a.x0 * (long)b.x9; + var a1b0 = a.x1 * (long)b.x0; + var a1b1_2 = a1_2 * (long)b.x1; + var a1b2 = a.x1 * (long)b.x2; + var a1b3_2 = a1_2 * (long)b.x3; + var a1b4 = a.x1 * (long)b.x4; + var a1b5_2 = a1_2 * (long)b.x5; + var a1b6 = a.x1 * (long)b.x6; + var a1b7_2 = a1_2 * (long)b.x7; + var a1b8 = a.x1 * (long)b.x8; + var a1b9_38 = a1_2 * (long)b9_19; + var a2b0 = a.x2 * (long)b.x0; + var a2b1 = a.x2 * (long)b.x1; + var a2b2 = a.x2 * (long)b.x2; + var a2b3 = a.x2 * (long)b.x3; + var a2b4 = a.x2 * (long)b.x4; + var a2b5 = a.x2 * (long)b.x5; + var a2b6 = a.x2 * (long)b.x6; + var a2b7 = a.x2 * (long)b.x7; + var a2b8_19 = a.x2 * (long)b8_19; + var a2b9_19 = a.x2 * (long)b9_19; + var a3b0 = a.x3 * (long)b.x0; + var a3b1_2 = a3_2 * (long)b.x1; + var a3b2 = a.x3 * (long)b.x2; + var a3b3_2 = a3_2 * (long)b.x3; + var a3b4 = a.x3 * (long)b.x4; + var a3b5_2 = a3_2 * (long)b.x5; + var a3b6 = a.x3 * (long)b.x6; + var a3b7_38 = a3_2 * (long)b7_19; + var a3b8_19 = a.x3 * (long)b8_19; + var a3b9_38 = a3_2 * (long)b9_19; + var a4b0 = a.x4 * (long)b.x0; + var a4b1 = a.x4 * (long)b.x1; + var a4b2 = a.x4 * (long)b.x2; + var a4b3 = a.x4 * (long)b.x3; + var a4b4 = a.x4 * (long)b.x4; + var a4b5 = a.x4 * (long)b.x5; + var a4b6_19 = a.x4 * (long)b6_19; + var a4b7_19 = a.x4 * (long)b7_19; + var a4b8_19 = a.x4 * (long)b8_19; + var a4b9_19 = a.x4 * (long)b9_19; + var a5b0 = a.x5 * (long)b.x0; + var a5b1_2 = a5_2 * (long)b.x1; + var a5b2 = a.x5 * (long)b.x2; + var a5b3_2 = a5_2 * (long)b.x3; + var a5b4 = a.x5 * (long)b.x4; + var a5b5_38 = a5_2 * (long)b5_19; + var a5b6_19 = a.x5 * (long)b6_19; + var a5b7_38 = a5_2 * (long)b7_19; + var a5b8_19 = a.x5 * (long)b8_19; + var a5b9_38 = a5_2 * (long)b9_19; + var a6b0 = a.x6 * (long)b.x0; + var a6b1 = a.x6 * (long)b.x1; + var a6b2 = a.x6 * (long)b.x2; + var a6b3 = a.x6 * (long)b.x3; + var a6b4_19 = a.x6 * (long)b4_19; + var a6b5_19 = a.x6 * (long)b5_19; + var a6b6_19 = a.x6 * (long)b6_19; + var a6b7_19 = a.x6 * (long)b7_19; + var a6b8_19 = a.x6 * (long)b8_19; + var a6b9_19 = a.x6 * (long)b9_19; + var a7b0 = a.x7 * (long)b.x0; + var a7b1_2 = a7_2 * (long)b.x1; + var a7b2 = a.x7 * (long)b.x2; + var a7b3_38 = a7_2 * (long)b3_19; + var a7b4_19 = a.x7 * (long)b4_19; + var a7b5_38 = a7_2 * (long)b5_19; + var a7b6_19 = a.x7 * (long)b6_19; + var a7b7_38 = a7_2 * (long)b7_19; + var a7b8_19 = a.x7 * (long)b8_19; + var a7b9_38 = a7_2 * (long)b9_19; + var a8b0 = a.x8 * (long)b.x0; + var a8b1 = a.x8 * (long)b.x1; + var a8b2_19 = a.x8 * (long)b2_19; + var a8b3_19 = a.x8 * (long)b3_19; + var a8b4_19 = a.x8 * (long)b4_19; + var a8b5_19 = a.x8 * (long)b5_19; + var a8b6_19 = a.x8 * (long)b6_19; + var a8b7_19 = a.x8 * (long)b7_19; + var a8b8_19 = a.x8 * (long)b8_19; + var a8b9_19 = a.x8 * (long)b9_19; + var a9b0 = a.x9 * (long)b.x0; + var a9b1_38 = a9_2 * (long)b1_19; + var a9b2_19 = a.x9 * (long)b2_19; + var a9b3_38 = a9_2 * (long)b3_19; + var a9b4_19 = a.x9 * (long)b4_19; + var a9b5_38 = a9_2 * (long)b5_19; + var a9b6_19 = a.x9 * (long)b6_19; + var a9b7_38 = a9_2 * (long)b7_19; + var a9b8_19 = a.x9 * (long)b8_19; + var a9b9_38 = a9_2 * (long)b9_19; + + var h0 = a0b0 + a1b9_38 + a2b8_19 + a3b7_38 + a4b6_19 + a5b5_38 + a6b4_19 + a7b3_38 + a8b2_19 + a9b1_38; + var h1 = a0b1 + a1b0 + a2b9_19 + a3b8_19 + a4b7_19 + a5b6_19 + a6b5_19 + a7b4_19 + a8b3_19 + a9b2_19; + var h2 = a0b2 + a1b1_2 + a2b0 + a3b9_38 + a4b8_19 + a5b7_38 + a6b6_19 + a7b5_38 + a8b4_19 + a9b3_38; + var h3 = a0b3 + a1b2 + a2b1 + a3b0 + a4b9_19 + a5b8_19 + a6b7_19 + a7b6_19 + a8b5_19 + a9b4_19; + var h4 = a0b4 + a1b3_2 + a2b2 + a3b1_2 + a4b0 + a5b9_38 + a6b8_19 + a7b7_38 + a8b6_19 + a9b5_38; + var h5 = a0b5 + a1b4 + a2b3 + a3b2 + a4b1 + a5b0 + a6b9_19 + a7b8_19 + a8b7_19 + a9b6_19; + var h6 = a0b6 + a1b5_2 + a2b4 + a3b3_2 + a4b2 + a5b1_2 + a6b0 + a7b9_38 + a8b8_19 + a9b7_38; + var h7 = a0b7 + a1b6 + a2b5 + a3b4 + a4b3 + a5b2 + a6b1 + a7b0 + a8b9_19 + a9b8_19; + var h8 = a0b8 + a1b7_2 + a2b6 + a3b5_2 + a4b4 + a5b3_2 + a6b2 + a7b1_2 + a8b0 + a9b9_38; + var h9 = a0b9 + a1b8 + a2b7 + a3b6 + a4b5 + a5b4 + a6b3 + a7b2 + a8b1 + a9b0; + + var carry0 = (h0 + (1L << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry4 = (h4 + (1L << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + + var carry1 = (h1 + (1L << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry5 = (h5 + (1L << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + + var carry2 = (h2 + (1L << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry6 = (h6 + (1L << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + + var carry3 = (h3 + (1L << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry7 = (h7 + (1L << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + carry4 = (h4 + (1L << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry8 = (h8 + (1L << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + + var carry9 = (h9 + (1L << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + + carry0 = (h0 + (1L << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + + output.x0 = (int)h0; + output.x1 = (int)h1; + output.x2 = (int)h2; + output.x3 = (int)h3; + output.x4 = (int)h4; + output.x5 = (int)h5; + output.x6 = (int)h6; + output.x7 = (int)h7; + output.x8 = (int)h8; + output.x9 = (int)h9; + } + + /// + /// Square a field element + /// + public static void Square(ref FieldElement output, ref FieldElement a) + { + var a0_2 = a.x0 * 2; + var a1_2 = a.x1 * 2; + var a2_2 = a.x2 * 2; + var a3_2 = a.x3 * 2; + var a4_2 = a.x4 * 2; + var a5_2 = a.x5 * 2; + var a6_2 = a.x6 * 2; + var a7_2 = a.x7 * 2; + + var a5_38 = a.x5 * 38; + var a6_19 = a.x6 * 19; + var a7_38 = a.x7 * 38; + var a8_19 = a.x8 * 19; + var a9_38 = a.x9 * 38; + + var a0a0 = a.x0 * (long)a.x0; + var a0a1_2 = a0_2 * (long)a.x1; + var a0a2_2 = a0_2 * (long)a.x2; + var a0a3_2 = a0_2 * (long)a.x3; + var a0a4_2 = a0_2 * (long)a.x4; + var a0a5_2 = a0_2 * (long)a.x5; + var a0a6_2 = a0_2 * (long)a.x6; + var a0a7_2 = a0_2 * (long)a.x7; + var a0a8_2 = a0_2 * (long)a.x8; + var a0a9_2 = a0_2 * (long)a.x9; + var a1a1_2 = a1_2 * (long)a.x1; + var a1a2_2 = a1_2 * (long)a.x2; + var a1a3_4 = a1_2 * (long)a3_2; + var a1a4_2 = a1_2 * (long)a.x4; + var a1a5_4 = a1_2 * (long)a5_2; + var a1a6_2 = a1_2 * (long)a.x6; + var a1a7_4 = a1_2 * (long)a7_2; + var a1a8_2 = a1_2 * (long)a.x8; + var a1a9_76 = a1_2 * (long)a9_38; + var a2a2 = a.x2 * (long)a.x2; + var a2a3_2 = a2_2 * (long)a.x3; + var a2a4_2 = a2_2 * (long)a.x4; + var a2a5_2 = a2_2 * (long)a.x5; + var a2a6_2 = a2_2 * (long)a.x6; + var a2a7_2 = a2_2 * (long)a.x7; + var a2a8_38 = a2_2 * (long)a8_19; + var a2a9_38 = a.x2 * (long)a9_38; + var a3a3_2 = a3_2 * (long)a.x3; + var a3a4_2 = a3_2 * (long)a.x4; + var a3a5_4 = a3_2 * (long)a5_2; + var a3a6_2 = a3_2 * (long)a.x6; + var a3a7_76 = a3_2 * (long)a7_38; + var a3a8_38 = a3_2 * (long)a8_19; + var a3a9_76 = a3_2 * (long)a9_38; + var a4a4 = a.x4 * (long)a.x4; + var a4a5_2 = a4_2 * (long)a.x5; + var a4a6_38 = a4_2 * (long)a6_19; + var a4a7_38 = a.x4 * (long)a7_38; + var a4a8_38 = a4_2 * (long)a8_19; + var a4a9_38 = a.x4 * (long)a9_38; + var a5a5_38 = a.x5 * (long)a5_38; + var a5a6_38 = a5_2 * (long)a6_19; + var a5a7_76 = a5_2 * (long)a7_38; + var a5a8_38 = a5_2 * (long)a8_19; + var a5a9_76 = a5_2 * (long)a9_38; + var a6a6_19 = a.x6 * (long)a6_19; + var a6a7_38 = a.x6 * (long)a7_38; + var a6a8_38 = a6_2 * (long)a8_19; + var a6a9_38 = a.x6 * (long)a9_38; + var a7a7_38 = a.x7 * (long)a7_38; + var a7a8_38 = a7_2 * (long)a8_19; + var a7a9_76 = a7_2 * (long)a9_38; + var a8a8_19 = a.x8 * (long)a8_19; + var a8a9_38 = a.x8 * (long)a9_38; + var a9a9_38 = a.x9 * (long)a9_38; + + var h0 = a0a0 + a1a9_76 + a2a8_38 + a3a7_76 + a4a6_38 + a5a5_38; + var h1 = a0a1_2 + a2a9_38 + a3a8_38 + a4a7_38 + a5a6_38; + var h2 = a0a2_2 + a1a1_2 + a3a9_76 + a4a8_38 + a5a7_76 + a6a6_19; + var h3 = a0a3_2 + a1a2_2 + a4a9_38 + a5a8_38 + a6a7_38; + var h4 = a0a4_2 + a1a3_4 + a2a2 + a5a9_76 + a6a8_38 + a7a7_38; + var h5 = a0a5_2 + a1a4_2 + a2a3_2 + a6a9_38 + a7a8_38; + var h6 = a0a6_2 + a1a5_4 + a2a4_2 + a3a3_2 + a7a9_76 + a8a8_19; + var h7 = a0a7_2 + a1a6_2 + a2a5_2 + a3a4_2 + a8a9_38; + var h8 = a0a8_2 + a1a7_4 + a2a6_2 + a3a5_4 + a4a4 + a9a9_38; + var h9 = a0a9_2 + a1a8_2 + a2a7_2 + a3a6_2 + a4a5_2; + + var carry0 = (h0 + (1L << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry4 = (h4 + (1L << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + + var carry1 = (h1 + (1L << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry5 = (h5 + (1L << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + + var carry2 = (h2 + (1L << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry6 = (h6 + (1L << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + + var carry3 = (h3 + (1L << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry7 = (h7 + (1L << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + carry4 = (h4 + (1L << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry8 = (h8 + (1L << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + + var carry9 = (h9 + (1L << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + + carry0 = (h0 + (1L << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + + output.x0 = (int)h0; + output.x1 = (int)h1; + output.x2 = (int)h2; + output.x3 = (int)h3; + output.x4 = (int)h4; + output.x5 = (int)h5; + output.x6 = (int)h6; + output.x7 = (int)h7; + output.x8 = (int)h8; + output.x9 = (int)h9; + } + + /// + /// Multiplay a field element by 121666 + /// + public static void Multiply121666(ref FieldElement output, ref FieldElement a) + { + var h0 = a.x0 * 121666L; + var h1 = a.x1 * 121666L; + var h2 = a.x2 * 121666L; + var h3 = a.x3 * 121666L; + var h4 = a.x4 * 121666L; + var h5 = a.x5 * 121666L; + var h6 = a.x6 * 121666L; + var h7 = a.x7 * 121666L; + var h8 = a.x8 * 121666L; + var h9 = a.x9 * 121666L; + + var carry9 = (h9 + (1L << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + var carry1 = (h1 + (1L << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry3 = (h3 + (1L << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry5 = (h5 + (1L << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry7 = (h7 + (1L << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + var carry0 = (h0 + (1L << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry2 = (h2 + (1L << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry4 = (h4 + (1L << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry6 = (h6 + (1L << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry8 = (h8 + (1L << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + + output.x0 = (int)h0; + output.x1 = (int)h1; + output.x2 = (int)h2; + output.x3 = (int)h3; + output.x4 = (int)h4; + output.x5 = (int)h5; + output.x6 = (int)h6; + output.x7 = (int)h7; + output.x8 = (int)h8; + output.x9 = (int)h9; + } + + /// + /// Invert a field element + /// + public static void Invert(ref FieldElement output, ref FieldElement a) + { + var t0 = new FieldElement(); + Square(ref t0, ref a); + + var t1 = new FieldElement(); + Square(ref t1, ref t0); + Square(ref t1, ref t1); + + var t2 = new FieldElement(); + Multiply(ref t1, ref a, ref t1); + Multiply(ref t0, ref t0, ref t1); + Square(ref t2, ref t0); + //Square(ref t2, ref t2); + + Multiply(ref t1, ref t1, ref t2); + Square(ref t2, ref t1); + for (var ii = 1; ii < 5; ++ii) Square(ref t2, ref t2); + + Multiply(ref t1, ref t2, ref t1); + Square(ref t2, ref t1); + for (var ii = 1; ii < 10; ++ii) Square(ref t2, ref t2); + + var t3 = new FieldElement(); + Multiply(ref t2, ref t2, ref t1); + Square(ref t3, ref t2); + for (var ii = 1; ii < 20; ++ii) Square(ref t3, ref t3); + + Multiply(ref t2, ref t3, ref t2); + Square(ref t2, ref t2); + for (var ii = 1; ii < 10; ++ii) Square(ref t2, ref t2); + + Multiply(ref t1, ref t2, ref t1); + Square(ref t2, ref t1); + for (var ii = 1; ii < 50; ++ii) Square(ref t2, ref t2); + + Multiply(ref t2, ref t2, ref t1); + Square(ref t3, ref t2); + for (var ii = 1; ii < 100; ++ii) Square(ref t3, ref t3); + + Multiply(ref t2, ref t3, ref t2); + Square(ref t2, ref t2); + for (var ii = 1; ii < 50; ++ii) Square(ref t2, ref t2); + + Multiply(ref t1, ref t2, ref t1); + Square(ref t1, ref t1); + for (var ii = 1; ii < 5; ++ii) Square(ref t1, ref t1); + + Multiply(ref output, ref t1, ref t0); + } + + /// + /// Swaps `a` and `b` if `swap` is 1 + /// + public static void ConditionalSwap(ref FieldElement a, ref FieldElement b, int swap) + { + Debug.Assert(swap == 0 || swap == 1); + swap = -swap; + + var temp = new FieldElement + { + x0 = swap & (a.x0 ^ b.x0), + x1 = swap & (a.x1 ^ b.x1), + x2 = swap & (a.x2 ^ b.x2), + x3 = swap & (a.x3 ^ b.x3), + x4 = swap & (a.x4 ^ b.x4), + x5 = swap & (a.x5 ^ b.x5), + x6 = swap & (a.x6 ^ b.x6), + x7 = swap & (a.x7 ^ b.x7), + x8 = swap & (a.x8 ^ b.x8), + x9 = swap & (a.x9 ^ b.x9) + }; + + a.x0 ^= temp.x0; + a.x1 ^= temp.x1; + a.x2 ^= temp.x2; + a.x3 ^= temp.x3; + a.x4 ^= temp.x4; + a.x5 ^= temp.x5; + a.x6 ^= temp.x6; + a.x7 ^= temp.x7; + a.x8 ^= temp.x8; + a.x9 ^= temp.x9; + + b.x0 ^= temp.x0; + b.x1 ^= temp.x1; + b.x2 ^= temp.x2; + b.x3 ^= temp.x3; + b.x4 ^= temp.x4; + b.x5 ^= temp.x5; + b.x6 ^= temp.x6; + b.x7 ^= temp.x7; + b.x8 ^= temp.x8; + b.x9 ^= temp.x9; + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/DataReceivedEventArgs.cs b/src/NextFast.Hazel-New/Next.Hazel/DataReceivedEventArgs.cs new file mode 100644 index 0000000..4e80f08 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/DataReceivedEventArgs.cs @@ -0,0 +1,16 @@ +using Next.Hazel.Abstractions; + +namespace Next.Hazel; + +public struct DataReceivedEventArgs(Connection sender, IMessageReader msg, MessageType type) +{ + public readonly Connection Sender = sender; + + /// + /// The bytes received from the client. + /// + public readonly IMessageReader Message = msg; + + + public readonly MessageType Type = type; +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/DisconnectedEventArgs.cs b/src/NextFast.Hazel-New/Next.Hazel/DisconnectedEventArgs.cs new file mode 100644 index 0000000..cceb0e0 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/DisconnectedEventArgs.cs @@ -0,0 +1,23 @@ +using System; + +namespace Next.Hazel; + +public class DisconnectedEventArgs : EventArgs +{ + /// + /// Optional data sent with a disconnect message. May be null. + /// You must not recycle this. If you need the message outside of a callback, you should copy it. + /// + public readonly MessageReader Message; + + /// + /// Optional disconnect reason. May be null. + /// + public readonly string Reason; + + public DisconnectedEventArgs(string reason, MessageReader message) + { + Reason = reason; + Message = message; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/AesGcmRecordProtection.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/AesGcmRecordProtection.cs new file mode 100644 index 0000000..c6c995c --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/AesGcmRecordProtection.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using Next.Hazel.Crypto; + +namespace Next.Hazel.Dtls; + +/// +/// *_AES_128_GCM_* cipher suite +/// +public class Aes128GcmRecordProtection : IRecordProtection +{ + private const int ImplicitNonceSize = 4; + private const int ExplicitNonceSize = 8; + private readonly Aes128Gcm clientWriteCipher; + private readonly ByteSpan clientWriteIV; + + private readonly Aes128Gcm serverWriteCipher; + + private readonly ByteSpan serverWriteIV; + + /// + /// Create a new instance of the AES128_GCM record protection + /// + /// Shared secret + /// Server random data + /// Client random data + public Aes128GcmRecordProtection(ByteSpan masterSecret, ByteSpan serverRandom, ByteSpan clientRandom) + { + ByteSpan combinedRandom = new byte[serverRandom.Length + clientRandom.Length]; + serverRandom.CopyTo(combinedRandom); + clientRandom.CopyTo(combinedRandom[serverRandom.Length..]); + + // Expand master_secret to encryption keys + const int ExpandedSize = 0 + + 0 // mac_key_length + + 0 // mac_key_length + + Aes128Gcm.KeySize // enc_key_length + + Aes128Gcm.KeySize // enc_key_length + + ImplicitNonceSize // fixed_iv_length + + ImplicitNonceSize // fixed_iv_length + ; + + ByteSpan expandedKey = new byte[ExpandedSize]; + PrfSha256.ExpandSecret(expandedKey, masterSecret, PrfLabel.KEY_EXPANSION, combinedRandom); + + var clientWriteKey = expandedKey[..Aes128Gcm.KeySize]; + var serverWriteKey = expandedKey.Slice(Aes128Gcm.KeySize, Aes128Gcm.KeySize); + clientWriteIV = expandedKey.Slice(2 * Aes128Gcm.KeySize, ImplicitNonceSize); + serverWriteIV = expandedKey.Slice(2 * Aes128Gcm.KeySize + ImplicitNonceSize, ImplicitNonceSize); + + serverWriteCipher = new Aes128Gcm(serverWriteKey); + clientWriteCipher = new Aes128Gcm(clientWriteKey); + } + + /// + public void Dispose() + { + serverWriteCipher.Dispose(); + clientWriteCipher.Dispose(); + } + + /// + public int GetEncryptedSize(int dataSize) + { + return GetEncryptedSizeImpl(dataSize); + } + + /// + public int GetDecryptedSize(int dataSize) + { + return GetDecryptedSizeImpl(dataSize); + } + + /// + public void EncryptServerPlaintext(ByteSpan output, ByteSpan input, ref Record record) + { + EncryptPlaintext(output, input, ref record, serverWriteCipher, serverWriteIV); + } + + /// + public void EncryptClientPlaintext(ByteSpan output, ByteSpan input, ref Record record) + { + EncryptPlaintext(output, input, ref record, clientWriteCipher, clientWriteIV); + } + + /// + public bool DecryptCiphertextFromServer(ByteSpan output, ByteSpan input, ref Record record) + { + return DecryptCiphertext(output, input, ref record, serverWriteCipher, serverWriteIV); + } + + /// + public bool DecryptCiphertextFromClient(ByteSpan output, ByteSpan input, ref Record record) + { + return DecryptCiphertext(output, input, ref record, clientWriteCipher, clientWriteIV); + } + + + private static int GetEncryptedSizeImpl(int dataSize) + { + return dataSize + Aes128Gcm.CiphertextOverhead; + } + + private static int GetDecryptedSizeImpl(int dataSize) + { + return dataSize - Aes128Gcm.CiphertextOverhead; + } + + private static void EncryptPlaintext(ByteSpan output, ByteSpan input, ref Record record, Aes128Gcm cipher, + ByteSpan writeIV) + { + Debug.Assert(output.Length >= GetEncryptedSizeImpl(input.Length)); + + // Build GCM nonce (authenticated data) + ByteSpan nonce = new byte[ImplicitNonceSize + ExplicitNonceSize]; + writeIV.CopyTo(nonce); + nonce.WriteBigEndian16(record.Epoch, ImplicitNonceSize); + nonce.WriteBigEndian48(record.SequenceNumber, ImplicitNonceSize + 2); + + // Serialize record as additional data + var plaintextRecord = record; + plaintextRecord.Length = (ushort)input.Length; + ByteSpan associatedData = new byte[Record.Size]; + plaintextRecord.Encode(associatedData); + + cipher.Seal(output, nonce, input, associatedData); + } + + private static bool DecryptCiphertext(ByteSpan output, ByteSpan input, ref Record record, Aes128Gcm cipher, + ByteSpan writeIV) + { + Debug.Assert(output.Length >= GetDecryptedSizeImpl(input.Length)); + + // Build GCM nonce (authenticated data) + ByteSpan nonce = new byte[ImplicitNonceSize + ExplicitNonceSize]; + writeIV.CopyTo(nonce); + nonce.WriteBigEndian16(record.Epoch, ImplicitNonceSize); + nonce.WriteBigEndian48(record.SequenceNumber, ImplicitNonceSize + 2); + + // Serialize record as additional data + var plaintextRecord = record; + plaintextRecord.Length = (ushort)GetDecryptedSizeImpl(input.Length); + ByteSpan associatedData = new byte[Record.Size]; + plaintextRecord.Encode(associatedData); + + return cipher.Open(output, nonce, input, associatedData); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/ConnectionId.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/ConnectionId.cs new file mode 100644 index 0000000..8549e96 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/ConnectionId.cs @@ -0,0 +1,48 @@ +using System; +using System.Net; + +namespace Next.Hazel.Dtls; + +public struct ConnectionId : IEquatable +{ + public IPEndPoint EndPoint; + public int Serial; + + public static ConnectionId Create(IPEndPoint endPoint, int serial) + { + return new ConnectionId + { + EndPoint = endPoint, + Serial = serial + }; + } + + public bool Equals(ConnectionId other) + { + return Serial == other.Serial + && EndPoint.Equals(other.EndPoint) + ; + } + + public override bool Equals(object obj) + { + if (obj is ConnectionId) return Equals((ConnectionId)obj); + + return false; + } + + public override int GetHashCode() + { + return EndPoint.GetHashCode(); + } + + public static bool operator ==(ConnectionId left, ConnectionId right) + { + return left.Equals(right); + } + + public static bool operator !=(ConnectionId left, ConnectionId right) + { + return !(left == right); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/DtlsConnectionListener.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/DtlsConnectionListener.cs new file mode 100644 index 0000000..1e78199 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/DtlsConnectionListener.cs @@ -0,0 +1,1405 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.ObjectPool; +using Next.Hazel.Crypto; +using Next.Hazel.Dtls.Handshake; +using Next.Hazel.Dtls.Handshake.Constant; +using Next.Hazel.Udp; +using Serilog; +using Random = Next.Hazel.Dtls.Handshake.Constant.Random; + +namespace Next.Hazel.Dtls; + +/// +/// Listens for new UDP-DTLS connections and creates UdpConnections for them. +/// +/// +public class DtlsConnectionListener : UdpConnectionListener +{ + private const int MaxDatagramSize = 1200; + + private const int MaxCertFragmentSizeV0 = 1200; + + // Min MTU - UDP+IP header - 1 (for good measure. :)) + private const int MaxCertFragmentSizeV1 = 576 - 32 - 1; + private static readonly ILogger Logger = Log.ForContext(); + private static readonly TimeSpan CookieHmacRotationTimeout = TimeSpan.FromHours(1.0); + + // Private key component of certificate's public key + private readonly List encodedCertificates = []; + /*private ByteSpan encodedCertificate;*/ + + private readonly ConcurrentDictionary existingPeers = new(); + private RSA certificatePrivateKey; + + private int connectionSerial_unsafe; + + // HMAC key to validate ClientHello cookie + private HMAC currentCookieHmac; + private uint encodedCertificatesTotalSize; + private DateTime nextCookieHmacRotation; + private HMAC previousCookieHmac; + + private RandomNumberGenerator random; + + /// + /// Create a new instance of the DTLS listener + /// + /// + /// + /// + public DtlsConnectionListener(IPEndPoint endPoint, ObjectPool readerPool, + IPMode ipMode = IPMode.IPv4) + : base(endPoint, readerPool, ipMode) + { + random = RandomNumberGenerator.Create(); + + currentCookieHmac = CreateNewCookieHMAC(); + previousCookieHmac = CreateNewCookieHMAC(); + nextCookieHmacRotation = DateTime.UtcNow + CookieHmacRotationTimeout; + } + + public int PeerCount => existingPeers.Count; + + internal async ValueTask SendData(ByteSpan span, IPEndPoint endPoint) + { + var array = span.ToArray(); + await base.SendData(array, array.Length, endPoint); + } + + internal override async ValueTask SendData(byte[] bytes, int length, IPEndPoint remoteEndPoint) + { + var span = new ByteSpan(bytes); + + if (!existingPeers.TryGetValue(remoteEndPoint, out var peer)) + { + Logger.Warning("Peer not found"); + // Drop messages if we don't know how to send them + return; + } + + await peer.Semaphore.WaitAsync(); + { + // If we're negotiating a new epoch, queue data + if (peer.Epoch == 0 || peer.NextEpoch.State != HandshakeState.ExpectingHello) + { + ByteSpan copyOfSpan = new byte[span.Length]; + span.CopyTo(copyOfSpan); + + peer.QueuedApplicationDataMessage.Add(copyOfSpan); + return; + } + + // Send any queued application data now + for (int ii = 0, nn = peer.QueuedApplicationDataMessage.Count; ii != nn; ++ii) + { + var queuedSpan = peer.QueuedApplicationDataMessage[ii]; + + var outgoingRecord = new Record + { + ContentType = ContentType.ApplicationData, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(queuedSpan.Length) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Encode the record to wire format + ByteSpan packet = new byte[Record.Size + outgoingRecord.Length]; + var writer = packet; + outgoingRecord.Encode(writer); + writer = writer[Record.Size..]; + queuedSpan.CopyTo(writer); + + // Protect the record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, outgoingRecord.Length) + , packet.Slice(Record.Size, queuedSpan.Length) + , ref outgoingRecord); + + await SendData(packet, remoteEndPoint); + } + + peer.QueuedApplicationDataMessage.Clear(); + + { + var outgoingRecord = new Record + { + ContentType = ContentType.ApplicationData, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(span.Length) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Encode the record to wire format + ByteSpan packet = new byte[Record.Size + outgoingRecord.Length]; + var writer = packet; + outgoingRecord.Encode(writer); + writer = writer[Record.Size..]; + span.CopyTo(writer); + + // Protect the record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, outgoingRecord.Length) + , packet.Slice(Record.Size, span.Length) + , ref outgoingRecord + ); + + await SendData(packet, remoteEndPoint); + } + } + peer.Semaphore.Release(); + } + + /// + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + + random?.Dispose(); + random = null; + + currentCookieHmac?.Dispose(); + previousCookieHmac?.Dispose(); + currentCookieHmac = null; + previousCookieHmac = null; + + foreach (var pair in existingPeers) pair.Value.Dispose(); + + existingPeers.Clear(); + } + + /// + /// Set the certificate key pair for the listener + /// + /// Certificate for the server + public void SetCertificate(X509Certificate2 certificate) + { + if (!certificate.HasPrivateKey) + throw new ArgumentException("Certificate must have a private key attached", nameof(certificate)); + + var privateKey = certificate.GetRSAPrivateKey(); + if (privateKey == null) + throw new ArgumentException("Certificate must be signed by an RSA key", nameof(certificate)); + + certificatePrivateKey?.Dispose(); + certificatePrivateKey = privateKey; + + // Pre-fragment the certificate data + var certificateData = Certificate.Encode(certificate); + encodedCertificatesTotalSize = (uint)certificateData.Length; + + // The first certificate data needs to leave room for + // * Record header + // * ServerHello header + // * ServerHello payload + // * Certificate header + var padding = Record.Size + Handshake.Handshake.Size + ServerHello.MinSize + Handshake.Handshake.Size; + encodedCertificates.Add(certificateData[..Math.Min(certificateData.Length, MaxDatagramSize - padding)]); + certificateData = certificateData[Math.Min(certificateData.Length, MaxDatagramSize - padding)..]; + + // Subsequent certificate data needs to leave room for + // * Record header + // * Certificate header + padding = Record.Size + Handshake.Handshake.Size; + while (certificateData.Length > 0) + { + encodedCertificates.Add(certificateData[..Math.Min(certificateData.Length, MaxDatagramSize - padding)]); + certificateData = certificateData[Math.Min(certificateData.Length, MaxDatagramSize - padding)..]; + } + /*encodedCertificate = Certificate.Encode(certificate);*/ + } + + /// + /// Handle an incoming datagram from the network. + /// This is primarily a wrapper around ProcessIncomingMessage + /// to ensure `reader.Recycle()` is always called + /// + protected override ValueTask ProcessData(UdpReceiveResult data) + { + var message = new ByteSpan(data.Buffer); + return ProcessIncomingMessage(message, data.RemoteEndPoint); + } + + /// + /// Handle an incoming datagram from the network + /// + private async ValueTask ProcessIncomingMessage(ByteSpan message, IPEndPoint peerAddress) + { + if (!existingPeers.TryGetValue(peerAddress, out var peer)) + { + await HandleNonPeerRecord(message, peerAddress); + return; + } + + await peer.Semaphore.WaitAsync(); + { + // Each incoming packet may contain multiple DTLS + // records + while (message.Length > 0) + { + if (!Record.Parse(out var record, peer.ProtocolVersion, message)) + { + Logger.Error($"Dropping malformed record from `{peerAddress}`"); + return; + } + + message = message[Record.Size..]; + + if (message.Length < record.Length) + { + Logger.Error( + $"Dropping malformed record from `{peerAddress}` Length({record.Length}) AvailableBytes({message.Length})"); + return; + } + + var recordPayload = message[..record.Length]; + message = message[record.Length..]; + + // Early-out and drop ApplicationData records + if (record.ContentType == ContentType.ApplicationData && !peer.CanHandleApplicationData) + { + Logger.Information($"Dropping ApplicationData record from `{peerAddress}` Cannot process yet"); + continue; + } + + // Drop records from a different epoch + if (record.Epoch != peer.Epoch) + { + // Handle existing client negotiating a new connection + if (record is { Epoch: 0, ContentType: ContentType.Handshake }) + { + var handshakePayload = recordPayload; + + if (!Handshake.Handshake.Parse(out var handshake, recordPayload)) + { + Logger.Error($"Dropping malformed re-negotiation Handshake from `{peerAddress}`"); + continue; + } + + handshakePayload = handshakePayload[Handshake.Handshake.Size..]; + + if (handshake.FragmentOffset != 0 || handshake.Length != handshake.FragmentLength) + { + Logger.Error($"Dropping fragmented re-negotiation Handshake from `{peerAddress}`"); + continue; + } + + if (handshake.MessageType != HandshakeType.ClientHello) + { + Logger.Error($"Dropping non-ClientHello re-negotiation Handshake from `{peerAddress}`"); + continue; + } + + if (handshakePayload.Length < handshake.Length) + Logger.Error( + $"Dropping malformed re-negotiation Handshake from `{peerAddress}`: Length({handshake.Length}) AvailableBytes({handshakePayload.Length})"); + + if (!await HandleClientHello(peer, peerAddress, record, handshake, recordPayload, + handshakePayload)) return; + continue; + } + + Logger.Error( + $"Dropping bad-epoch record from `{peerAddress}` RecordEpoch({record.Epoch}) CurrentEpoch({peer.Epoch})"); + continue; + } + + // Prevent replay attacks by dropping records + // we've already processed + var windowIndex = (int)(peer.CurrentEpoch.NextExpectedSequence - record.SequenceNumber - 1); + var windowMask = 1ul << windowIndex; + if (record.SequenceNumber < peer.CurrentEpoch.NextExpectedSequence) + { + if (windowIndex >= 64) + { + Logger.Information( + $"Dropping too-old record from `{peerAddress}` Sequence({record.SequenceNumber}) Expected({peer.CurrentEpoch.NextExpectedSequence})"); + continue; + } + + if ((peer.CurrentEpoch.PreviousSequenceWindowBitmask & windowMask) != 0) + { + Logger.Information($"Dropping duplicate record from `{peerAddress}`"); + continue; + } + } + + // Validate record authenticity + var decryptedSize = peer.CurrentEpoch.RecordProtection.GetDecryptedSize(recordPayload.Length); + if (decryptedSize < 0) + { + Logger.Information( + $"Dropping malformed record: Length {recordPayload.Length} Decrypted length: {decryptedSize}"); + continue; + } + + var decryptedPayload = recordPayload.ReuseSpanIfPossible(decryptedSize); + + if (!peer.CurrentEpoch.RecordProtection.DecryptCiphertextFromClient(decryptedPayload, recordPayload, + ref record)) + { + Logger.Error($"Dropping non-authentic record from `{peerAddress}`"); + return; + } + + recordPayload = decryptedPayload; + + // Update our squence number bookeeping + if (record.SequenceNumber >= peer.CurrentEpoch.NextExpectedSequence) + { + var windowShift = (int)(record.SequenceNumber + 1 - peer.CurrentEpoch.NextExpectedSequence); + peer.CurrentEpoch.PreviousSequenceWindowBitmask <<= windowShift; + peer.CurrentEpoch.NextExpectedSequence = record.SequenceNumber + 1; + } + else + { + peer.CurrentEpoch.PreviousSequenceWindowBitmask |= windowMask; + } + + switch (record.ContentType) + { + case ContentType.ChangeCipherSpec: + if (peer.NextEpoch.State != HandshakeState.ExpectingChangeCipherSpec) + { + Logger.Error( + $"Dropping unexpected ChangeChiperSpec record from `{peerAddress}` State({peer.NextEpoch.State})"); + break; + } + + if (peer.NextEpoch.RecordProtection == null) + { + Debug.Assert(false, + "How did we receive a ChangeCipherSpec message without a pending record protection instance?"); + } + + if (!ChangeCipherSpec.Parse(recordPayload)) + { + Logger.Error($"Dropping malformed ChangeCipherSpec message from `{peerAddress}`"); + break; + } + + // Migrate to the next epoch + peer.Epoch = peer.NextEpoch.Epoch; + peer.CanHandleApplicationData = false; // Need a Finished message + peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch = peer.CurrentEpoch.NextOutgoingSequence; + peer.CurrentEpoch.PreviousRecordProtection?.Dispose(); + peer.CurrentEpoch.PreviousRecordProtection = peer.CurrentEpoch.RecordProtection; + peer.CurrentEpoch.RecordProtection = peer.NextEpoch.RecordProtection; + peer.CurrentEpoch.NextOutgoingSequence = 1; + peer.CurrentEpoch.NextExpectedSequence = 1; + peer.CurrentEpoch.PreviousSequenceWindowBitmask = 0; + peer.NextEpoch.ClientVerification.CopyTo(peer.CurrentEpoch.ExpectedClientFinishedVerification); + peer.NextEpoch.ServerVerification.CopyTo(peer.CurrentEpoch.ServerFinishedVerification); + + peer.NextEpoch.State = HandshakeState.ExpectingHello; + peer.NextEpoch.Handshake?.Dispose(); + peer.NextEpoch.Handshake = null; + peer.NextEpoch.NextOutgoingSequence = 1; + peer.NextEpoch.RecordProtection = null; + peer.NextEpoch.VerificationStream.Reset(); + peer.NextEpoch.ClientVerification.SecureClear(); + peer.NextEpoch.ServerVerification.SecureClear(); + break; + + case ContentType.Alert: + Logger.Error($"Dropping unsupported Alert record from `{peerAddress}`"); + break; + + case ContentType.Handshake: + if (!await ProcessHandshake(peer, peerAddress, record, recordPayload)) return; + + break; + + case ContentType.ApplicationData: + // Forward data to the application + await base.ProcessData(new UdpReceiveResult(recordPayload.ToArray(), peerAddress)); + break; + } + } + } + peer.Semaphore.Release(); + } + + /// + /// Process an incoming Handshake protocol message + /// + /// Originating peer + /// Peer's network address + /// Parent record + /// Record payload + /// + /// True if further processing of the underlying datagram + /// should be continues. Otherwise, false. + /// + private async ValueTask ProcessHandshake(PeerData peer, IPEndPoint peerAddress, Record record, + ByteSpan message) + { + // Each record may have multiple handshake payloads + while (message.Length > 0) + { + var originalMessage = message; + + if (!Handshake.Handshake.Parse(out var handshake, message)) + { + Logger.Error($"Dropping malformed Handshake message from `{peerAddress}`"); + return false; + } + + message = message[Handshake.Handshake.Size..]; + + if (message.Length < handshake.Length) + { + Logger.Error($"Dropping malformed Handshake message from `{peerAddress}`"); + return false; + } + + var payload = message[..message.Length]; + message = message[(int)handshake.Length..]; + originalMessage = originalMessage[..(Handshake.Handshake.Size + (int)handshake.Length)]; + + // We do not support fragmented handshake messages + // from the client + if (handshake.FragmentOffset != 0 || handshake.FragmentLength != handshake.Length) + { + Logger.Error( + $"Dropping fragmented Handshake message from `{peerAddress}` Offset({handshake.FragmentOffset}) FragmentLength({handshake.FragmentLength}) Length({handshake.Length})"); + continue; + } + + switch (handshake.MessageType) + { + case HandshakeType.ClientHello: + if (!await HandleClientHello(peer, peerAddress, record, handshake, originalMessage, payload)) + return false; + break; + + case HandshakeType.ClientKeyExchange: + if (peer.NextEpoch.State != HandshakeState.ExpectingClientKeyExchange) + { + Logger.Error( + $"Dropping unexpected ClientKeyExchange message form `{peerAddress}` State({peer.NextEpoch.State})"); + continue; + } + + if (handshake.MessageSequence != 5) + { + Logger.Error( + $"Dropping bad-sequence ClientKeyExchange message from `{peerAddress}` MessageSequence({handshake.MessageSequence})"); + continue; + } + + ByteSpan sharedSecret = new byte[peer.NextEpoch.Handshake.SharedKeySize()]; + if (!peer.NextEpoch.Handshake.VerifyClientMessageAndGenerateSharedKey(sharedSecret, payload)) + { + Logger.Error($"Dropping malformed ClientKeyExchange message from `{peerAddress}`"); + return false; + } + + // Record incoming ClientKeyExchange message + // to verification stream + peer.NextEpoch.VerificationStream.AddData(originalMessage); + + ByteSpan randomSeed = new byte[2 * Random.Size]; + peer.NextEpoch.ClientRandom.CopyTo(randomSeed); + peer.NextEpoch.ServerRandom.CopyTo(randomSeed[Random.Size..]); + + const int MasterSecretSize = 48; + ByteSpan masterSecret = new byte[MasterSecretSize]; + PrfSha256.ExpandSecret( + masterSecret + , sharedSecret + , PrfLabel.MASTER_SECRET + , randomSeed + ); + + // Create the record protection for the upcoming epoch + switch (peer.NextEpoch.SelectedCipherSuite) + { + case CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: + peer.NextEpoch.RecordProtection = new Aes128GcmRecordProtection( + masterSecret + , peer.NextEpoch.ServerRandom + , peer.NextEpoch.ClientRandom); + break; + + default: + Debug.Assert(false, + $"How did we agree to a cipher suite {peer.NextEpoch.SelectedCipherSuite} we can't create?"); + return false; + } + + // Generate verification signatures + ByteSpan handshakeStreamHash = new byte[Sha256Stream.DigestSize]; + peer.NextEpoch.VerificationStream.CalculateHash(handshakeStreamHash); + + PrfSha256.ExpandSecret( + peer.NextEpoch.ClientVerification + , masterSecret + , PrfLabel.CLIENT_FINISHED + , handshakeStreamHash + ); + PrfSha256.ExpandSecret( + peer.NextEpoch.ServerVerification + , masterSecret + , PrfLabel.SERVER_FINISHED + , handshakeStreamHash + ); + + + // Update handshake state + masterSecret.SecureClear(); + peer.NextEpoch.State = HandshakeState.ExpectingChangeCipherSpec; + break; + + case HandshakeType.Finished: + // Unlike other handshake messages, this is + // for the current epoch - not the next epoch + + // Cannot process a Finished message for + // epoch 0 + if (peer.Epoch == 0) + { + Logger.Error($"Dropping Finished message for 0-epoch from `{peerAddress}`"); + continue; + } + // Cannot process a Finished message when we + // are negotiating the next epoch + + if (peer.NextEpoch.State != HandshakeState.ExpectingHello) + { + Logger.Error($"Dropping Finished message while negotiating new epoch from `{peerAddress}`"); + continue; + } + // Cannot process a Finished message without + // verify data + + if (peer.CurrentEpoch.ExpectedClientFinishedVerification.Length != Finished.Size || + peer.CurrentEpoch.ServerFinishedVerification.Length != Finished.Size) + { + Debug.Assert(false, "How do we have an established non-zero epoch without verify data?"); + + return false; + } + // Cannot process a Finished message without + // record protection for the previous epoch + + if (peer.CurrentEpoch.PreviousRecordProtection == null) + { + Debug.Assert(false, + "How do we have an established non-zero epoch with record protection for the previous epoch?"); + + return false; + } + + // Verify message sequence + if (handshake.MessageSequence != 6) + { + Logger.Error( + $"Dropping bad-sequence Finished message from `{peerAddress}` MessageSequence({handshake.MessageSequence})"); + continue; + } + + // Verify the client has the correct + // handshake sequence + if (payload.Length != Finished.Size) + { + Logger.Error($"Dropping malformed Finished message from `{peerAddress}`"); + return false; + } + + if (1 != Const.ConstantCompareSpans(payload, peer.CurrentEpoch.ExpectedClientFinishedVerification)) + { + Logger.Error($"Dropping non-verified Finished Handshake from `{peerAddress}`"); + + // Abort the connection here + // + // The client is either broken, or + // doen not agree on our epoch settings. + // + // Either way, there is not a feasible + // way to progress the connection. + // base.MarkConnectionAsStale(peer.ConnectionId); + existingPeers.TryRemove(peerAddress, out _); + return false; + } + + // Describe our ChangeCipherSpec+Finished + var outgoingHandshake = new Handshake.Handshake + { + MessageType = HandshakeType.Finished, + Length = Finished.Size, + MessageSequence = 7, + FragmentOffset = 0 + }; + outgoingHandshake.FragmentLength = outgoingHandshake.Length; + + var changeCipherSpecRecord = new Record + { + ContentType = ContentType.ChangeCipherSpec, + Epoch = (ushort)(peer.Epoch - 1), + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch, + Length = (ushort)peer.CurrentEpoch.PreviousRecordProtection.GetEncryptedSize(ChangeCipherSpec + .Size) + }; + ++peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch; + + var plaintextFinishedPayloadSize = Handshake.Handshake.Size + (int)outgoingHandshake.Length; + var finishedRecord = new Record + { + ContentType = ContentType.Handshake, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize( + plaintextFinishedPayloadSize) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Encode the flight into wire format + ByteSpan packet = new byte[Record.Size + changeCipherSpecRecord.Length + Record.Size + + finishedRecord.Length]; + var writer = packet; + changeCipherSpecRecord.Encode(writer); + writer = writer[Record.Size..]; + ChangeCipherSpec.Encode(writer); + + var startOfFinishedRecord = packet[(Record.Size + changeCipherSpecRecord.Length)..]; + writer = startOfFinishedRecord; + finishedRecord.Encode(writer); + writer = writer[Record.Size..]; + outgoingHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + peer.CurrentEpoch.ServerFinishedVerification.CopyTo(writer); + + // Protect the ChangeChipherSpec record + peer.CurrentEpoch.PreviousRecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, changeCipherSpecRecord.Length) + , packet.Slice(Record.Size, ChangeCipherSpec.Size) + , ref changeCipherSpecRecord + ); + + // Protect the Finished Handshake record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + startOfFinishedRecord.Slice(Record.Size, finishedRecord.Length) + , startOfFinishedRecord.Slice(Record.Size, plaintextFinishedPayloadSize) + , ref finishedRecord + ); + + // Current epoch can now handle application data + peer.CanHandleApplicationData = true; + + await SendData(packet, peerAddress); + break; + + // Drop messages that we do not support + case HandshakeType.CertificateVerify: + Logger.Error( + $"Dropping unsupported Handshake message from `{peerAddress}` MessageType({handshake.MessageType})"); + continue; + + // Drop messages that originate from the server + case HandshakeType.HelloRequest: + case HandshakeType.ServerHello: + case HandshakeType.HelloVerifyRequest: + case HandshakeType.Certificate: + case HandshakeType.ServerKeyExchange: + case HandshakeType.CertificateRequest: + case HandshakeType.ServerHelloDone: + Logger.Error( + $"Dropping server Handshake message from `{peerAddress}` MessageType({handshake.MessageType})"); + continue; + } + } + + return true; + } + + /// + /// Handle a ClientHello message for a peer + /// + /// Originating peer + /// Peer address + /// Parent record + /// Parent Handshake header + /// + /// Handshake payload + private async ValueTask HandleClientHello(PeerData peer, IPEndPoint peerAddress, Record record, + Handshake.Handshake handshake, ByteSpan originalMessage, ByteSpan payload) + { + // Verify message sequence + if (handshake.MessageSequence != 0) + { + Logger.Error( + $"Dropping bad-sequence ClientHello from `{peerAddress}` MessageSequence({handshake.MessageSequence})`"); + return true; + } + + // Make sure we can handle a ClientHello message + if (peer.NextEpoch.State != HandshakeState.ExpectingHello && + peer.NextEpoch.State != HandshakeState.ExpectingClientKeyExchange) + // Always handle ClientHello for epoch 0 + if (record.Epoch != 0) + { + Logger.Error($"Dropping ClientHello from `{peer}` Not expecting ClientHello"); + return true; + } + + if (!ClientHello.Parse(out var clientHello, peer.ProtocolVersion, payload)) + { + Logger.Error($"Dropping malformed ClientHello Handshake message from `{peerAddress}`"); + return false; + } + + // Find an acceptable cipher suite we can use + const CipherSuite selectedCipherSuite = CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256; + if (!clientHello.ContainsCipherSuite(CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) || + !clientHello.ContainsCurve(NamedCurve.x25519)) + { + Logger.Error($"Dropping ClientHello from `{peerAddress}` No compatible cipher suite"); + return false; + } + + // If this message was not signed by us, + // request a signed message before doing anything else + if (!HelloVerifyRequest.VerifyCookie(clientHello.Cookie, peerAddress, currentCookieHmac)) + if (!HelloVerifyRequest.VerifyCookie(clientHello.Cookie, peerAddress, previousCookieHmac)) + { + ulong outgoingSequence = 1; + IRecordProtection recordProtection = NullRecordProtection.Instance; + if (record.Epoch != 0) + { + outgoingSequence = peer.CurrentEpoch.NextExpectedSequence; + ++peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch; + + recordProtection = peer.CurrentEpoch.RecordProtection; + } + + await SendHelloVerifyRequest(peerAddress, outgoingSequence, record.Epoch, recordProtection, peer.ProtocolVersion); + return true; + } + + // Client is initiating a brand new connection. We need + // to destroy the existing connection and establish a + // new session. + if (record.Epoch == 0 && peer.Epoch != 0) + { + var oldConnectionId = peer.ConnectionId; + peer.ResetPeer(AllocateConnectionId(peerAddress), record.SequenceNumber + 1); + + // Inform the parent layer that the existing + // connection should be abandoned. + // base.MarkConnectionAsStale(oldConnectionId); + } + + // Determine if this is an original message, or a retransmission + var recordMessagesForVerifyData = false; + Logger.Verbose("Determine if this is an original message, or a retransmission"); + if (peer.NextEpoch.State == HandshakeState.ExpectingHello) + { + // Create our handhake cipher suite + IHandshakeCipherSuite handshakeCipherSuite; + if (clientHello.ContainsCurve(NamedCurve.x25519)) + { + handshakeCipherSuite = new X25519EcdheRsaSha256(random); + } + else + { + Logger.Error( + $"Dropping ClientHello from `{peerAddress}` Could not create TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 cipher suite"); + return false; + } + + peer.Session = clientHello.SessionInfo; + + // Update the state of our epoch transition + peer.NextEpoch.Epoch = (ushort)(record.Epoch + 1); + peer.NextEpoch.State = HandshakeState.ExpectingClientKeyExchange; + peer.NextEpoch.SelectedCipherSuite = selectedCipherSuite; + peer.NextEpoch.Handshake = handshakeCipherSuite; + clientHello.Random.CopyTo(peer.NextEpoch.ClientRandom); + peer.NextEpoch.ServerRandom.FillWithRandom(random); + recordMessagesForVerifyData = true; + + // Copy the original ClientHello + // handshake to our verification stream + peer.NextEpoch.VerificationStream.AddData( + originalMessage[..(Handshake.Handshake.Size + (int)handshake.Length)] + ); + } + + // The initial record flight from the server + // contains the following Handshake messages: + // * ServerHello + // * Certificate + // * ServerKeyExchange + // * ServerHelloDone + // + // The Certificate message is almost always + // too large to fit into a single datagram, + // so it is pre-fragmented + // (see `SetCertificates`). Therefore, we + // need to send multiple record packets for + // this flight. + // + // The first record contains the ServerHello + // handshake message, as well as the first + // portion of the Certificate message. + // + // We then send a record packet until the + // entire Certificate message has been sent + // to the client. + // + // The final record packet contains the + // ServerKeyExchange and the ServerHelloDone + // messages. + + // Describe first record of the flight + Logger.Verbose("SeverHello"); + var serverHello = new ServerHello + { + ServerProtocolVersion = peer.ProtocolVersion, + Random = peer.NextEpoch.ServerRandom, + CipherSuite = selectedCipherSuite + }; + + var serverHelloHandshake = new Handshake.Handshake + { + MessageType = HandshakeType.ServerHello, + Length = ServerHello.MinSize, + MessageSequence = 1, + FragmentOffset = 0 + }; + serverHelloHandshake.FragmentLength = serverHelloHandshake.Length; + + var maxCertFragmentSize = peer.Session.Version == 0 ? MaxCertFragmentSizeV0 : MaxCertFragmentSizeV1; + + /*var certificateData = encodedCertificate; + var initialCertPadding = Record.Size + Handshake.Handshake.Size + serverHello.Size + Handshake.Handshake.Size; + var certInitialFragmentSize = Math.Min(certificateData.Length, maxCertFragmentSize - initialCertPadding); + + Logger.Verbose("certificateHandshake");*/ + var certificateHandshake = new Handshake.Handshake + { + MessageType = HandshakeType.Certificate, + Length = encodedCertificatesTotalSize, + /*Length = (uint)certificateData.Length,*/ + MessageSequence = 2, + FragmentOffset = 0, + /*FragmentLength = (uint)certInitialFragmentSize*/ + FragmentLength = (uint)encodedCertificates[0].Length + }; + + var initialRecordPayloadSize = 0 + + Handshake.Handshake.Size + serverHello.Size + + Handshake.Handshake.Size + (int)certificateHandshake.FragmentLength + ; + var initialRecord = new Record + { + ContentType = ContentType.Handshake, + ProtocolVersion = peer.ProtocolVersion, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(initialRecordPayloadSize) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Convert initial record of the flight to + // wire format + Logger.Verbose("Convert initial record of the flight to"); + ByteSpan packet = new byte[Record.Size + initialRecord.Length]; + var writer = packet; + initialRecord.Encode(writer); + writer = writer[Record.Size..]; + serverHelloHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + serverHello.Encode(writer); + writer = writer[ServerHello.MinSize..]; + certificateHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + encodedCertificates[0].CopyTo(writer); + /*certificateData[..certInitialFragmentSize].CopyTo(writer); + certificateData = certificateData[certInitialFragmentSize..];*/ + + // Protect initial record of the flight + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, initialRecord.Length) + , packet.Slice(Record.Size, initialRecordPayloadSize) + , ref initialRecord + ); + + await SendData(packet, peerAddress); + + // Record record payload for verification + Logger.Verbose("Record record payload for verification"); + if (recordMessagesForVerifyData) + { + var fullCeritficateHandshake = certificateHandshake; + fullCeritficateHandshake.FragmentLength = fullCeritficateHandshake.Length; + + packet = new byte[Handshake.Handshake.Size + ServerHello.MinSize + Handshake.Handshake.Size]; + writer = packet; + serverHelloHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + serverHello.Encode(writer); + writer = writer[ServerHello.MinSize..]; + certificateHandshake.Encode(writer); + writer.Slice(Handshake.Handshake.Size); + + peer.NextEpoch.VerificationStream.AddData(packet); + /*peer.NextEpoch.VerificationStream.AddData(certificateData);*/ + foreach (var span in encodedCertificates) peer.NextEpoch.VerificationStream.AddData(span); + } + + // Process additional certificate records + Logger.Verbose("Process additional certificate records"); + /*const int CertPadding = Record.Size + Handshake.Handshake.Size; + while (certificateData.Length > 0) + { + var certFragmentSize = Math.Min(certificateData.Length, maxCertFragmentSize - CertPadding); + + certificateHandshake.FragmentOffset += certificateHandshake.FragmentLength; + certificateHandshake.FragmentLength = (uint)certFragmentSize; + + var additionalRecordPayloadSize = Handshake.Handshake.Size + (int)certificateHandshake.FragmentLength; + var additionalRecord = new Record + { + ContentType = ContentType.Handshake, + ProtocolVersion = peer.ProtocolVersion, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(additionalRecordPayloadSize) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Convert record to wire format + packet = new byte[Record.Size + additionalRecord.Length]; + writer = packet; + additionalRecord.Encode(writer); + writer = writer[Record.Size..]; + certificateHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + certificateData[..certFragmentSize].CopyTo(writer); + certificateData = certificateData[certFragmentSize..]; + + // Protect record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, additionalRecord.Length), + packet.Slice(Record.Size, additionalRecordPayloadSize), + ref additionalRecord + ); + + await SendData(packet, peerAddress); + }*/ + for (int ii = 1, nn = encodedCertificates.Count; ii != nn; ++ii) + { + certificateHandshake.FragmentOffset += certificateHandshake.FragmentLength; + certificateHandshake.FragmentLength = (uint)encodedCertificates[ii].Length; + + var additionalRecordPayloadSize = Handshake.Handshake.Size + (int)certificateHandshake.FragmentLength; + var additionalRecord = new Record + { + ContentType = ContentType.Handshake, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(additionalRecordPayloadSize) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Convert record to wire format + packet = new byte[Record.Size + additionalRecord.Length]; + writer = packet; + additionalRecord.Encode(writer); + writer = writer[Record.Size..]; + certificateHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + encodedCertificates[ii].CopyTo(writer); + + // Protect record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, additionalRecord.Length) + , packet.Slice(Record.Size, additionalRecordPayloadSize) + , ref additionalRecord + ); + + await SendData(packet, peerAddress); + } + + // Describe final record of the flight + var serverKeyExchangeHandshake = new Handshake.Handshake + { + MessageType = HandshakeType.ServerKeyExchange, + Length = (uint)peer.NextEpoch.Handshake.CalculateServerMessageSize(certificatePrivateKey), + MessageSequence = 3, + FragmentOffset = 0 + }; + serverKeyExchangeHandshake.FragmentLength = serverKeyExchangeHandshake.Length; + + var serverHelloDoneHandshake = new Handshake.Handshake + { + MessageType = HandshakeType.ServerHelloDone, + Length = 0, + MessageSequence = 4, + FragmentOffset = 0, + FragmentLength = 0 + }; + + var finalRecordPayloadSize = 0 + + Handshake.Handshake.Size + (int)serverKeyExchangeHandshake.Length + + Handshake.Handshake.Size + (int)serverHelloDoneHandshake.Length + ; + var finalRecord = new Record + { + ContentType = ContentType.Handshake, + Epoch = peer.Epoch, + SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence, + Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(finalRecordPayloadSize) + }; + ++peer.CurrentEpoch.NextOutgoingSequence; + Logger.Verbose("finalRecordPayload"); + + // Convert final record of the flight to wire + // format + packet = new byte[Record.Size + finalRecord.Length]; + writer = packet; + finalRecord.Encode(writer); + writer = writer[Record.Size..]; + serverKeyExchangeHandshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + peer.NextEpoch.Handshake.EncodeServerKeyExchangeMessage(writer, certificatePrivateKey); + writer = writer[(int)serverKeyExchangeHandshake.Length..]; + serverHelloDoneHandshake.Encode(writer); + Logger.Verbose("Convert final record of the flight to wire"); + + // Record record payload for verification + if (recordMessagesForVerifyData) + peer.NextEpoch.VerificationStream.AddData( + packet.Slice( + packet.Offset + Record.Size + , finalRecordPayloadSize + ) + ); + + // Protect final record of the flight + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, finalRecord.Length) + , packet.Slice(Record.Size, finalRecordPayloadSize) + , ref finalRecord + ); + + await SendData(packet, peerAddress); + + return true; + } + + /// + /// Handle an incoming packet that is not tied to an existing peer + /// + /// Incoming datagram + /// Originating address + private async ValueTask HandleNonPeerRecord(ByteSpan message, IPEndPoint peerAddress) + { + if (!Record.Parse(out var record, null, message)) + { + Logger.Error($"Dropping malformed record from non-peer `{peerAddress}`"); + return; + } + + message = message[Record.Size..]; + + // The protocol only supports receiving a single record + // from a non-peer. + if (record.Length != message.Length) + if (message.Length < record.Length) + { + Logger.Information( + $"Dropping bad record from non-peer `{peerAddress}`. Msg length {message.Length} < {record.Length}"); + return; + } + + // We only accept zero-epoch records from non-peers + if (record.Epoch != 0) return; + + // We only accept Handshake protocol messages from non-peers + if (record.ContentType != ContentType.Handshake) + { + Logger.Error($"Dropping non-handhsake message from non-peer `{peerAddress}`"); + return; + } + + var originalMessage = message; + + if (!Handshake.Handshake.Parse(out var handshake, message)) + { + Logger.Error($"Dropping malformed handshake message from non-peer `{peerAddress}`"); + return; + } + + // We only accept ClientHello messages from non-peers + if (handshake.MessageType != HandshakeType.ClientHello) + { + Logger.Error($"Dropping non-ClientHello ({handshake.MessageType}) message from non-peer `{peerAddress}`"); + return; + } + + message = message[Handshake.Handshake.Size..]; + + if (!ClientHello.Parse(out var clientHello, null, message)) + { + Logger.Error($"Dropping malformed ClientHello message from non-peer `{peerAddress}`"); + return; + } + + // If this ClientHello is not signed by us, request the + // client send us a signed message + if (!HelloVerifyRequest.VerifyCookie(clientHello.Cookie, peerAddress, currentCookieHmac)) + if (!HelloVerifyRequest.VerifyCookie(clientHello.Cookie, peerAddress, previousCookieHmac)) + { + await SendHelloVerifyRequest(peerAddress, 1, 0, NullRecordProtection.Instance, clientHello.ClientProtocolVersion); + return; + } + + // Allocate state for the new peer and register it + var peer = new PeerData(clientHello.ClientProtocolVersion); + peer.ResetPeer(AllocateConnectionId(peerAddress), record.SequenceNumber + 1); + + existingPeers[peerAddress] = peer; + + await peer.Semaphore.WaitAsync(); + { + await ProcessHandshake(peer, peerAddress, record, originalMessage); + } + peer.Semaphore.Release(); + } + + //Send a HelloVerifyRequest handshake message to a peer + private ValueTask SendHelloVerifyRequest(IPEndPoint peerAddress, ulong recordSequence, ushort epoch, + IRecordProtection recordProtection, ProtocolVersion protocolVersion) + { + // Do we need to rotate the HMAC key? + var now = DateTime.UtcNow; + if (now > nextCookieHmacRotation) + { + previousCookieHmac.Dispose(); + previousCookieHmac = currentCookieHmac; + currentCookieHmac = CreateNewCookieHMAC(); + nextCookieHmacRotation = now + CookieHmacRotationTimeout; + } + + var handshake = new Handshake.Handshake + { + MessageType = HandshakeType.HelloVerifyRequest, + Length = HelloVerifyRequest.Size, + MessageSequence = 0, + FragmentOffset = 0 + }; + handshake.FragmentLength = handshake.Length; + + var plaintextPayloadSize = Handshake.Handshake.Size + (int)handshake.Length; + + var record = new Record + { + ContentType = ContentType.Handshake, + ProtocolVersion = protocolVersion, + Epoch = epoch, + SequenceNumber = recordSequence, + Length = (ushort)recordProtection.GetEncryptedSize(plaintextPayloadSize) + }; + + // Encode record to wire format + ByteSpan packet = new byte[Record.Size + record.Length]; + var writer = packet; + record.Encode(writer); + writer = writer[Record.Size..]; + handshake.Encode(writer); + writer = writer[Handshake.Handshake.Size..]; + HelloVerifyRequest.Encode(writer, peerAddress, currentCookieHmac, protocolVersion); + + // Protect record payload + recordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, record.Length) + , packet.Slice(Record.Size, plaintextPayloadSize) + , ref record + ); + + return SendData(packet, peerAddress); + } + + // public override ValueTask DisconnectOldConnections(TimeSpan maxAge, MessageWriter disconnectMessage) + // { + // DateTime now = DateTime.UtcNow; + // foreach (KeyValuePair kvp in this.existingPeers) + // { + // PeerData peer = kvp.Value; + // lock (peer) + // { + // if (peer.Epoch == 0 || peer.NextEpoch.State != HandshakeState.ExpectingHello) + // { + // TimeSpan negotiationAge = now - peer.StartOfNegotiation; + // if (negotiationAge > maxAge) + // { + // base.MarkConnectionAsStale(peer.ConnectionId); + // } + // } + // } + // } + // + // return base.DisconnectOldConnections(maxAge, disconnectMessage); + // } + + internal void RemovePeerRecord(ConnectionId connectionId) + { + existingPeers.TryRemove(connectionId.EndPoint, out _); + } + + /// + /// Allocate a new connection id + /// + private ConnectionId AllocateConnectionId(IPEndPoint endPoint) + { + var rawSerialId = Interlocked.Increment(ref connectionSerial_unsafe); + return ConnectionId.Create(endPoint, rawSerialId); + } + + /// + /// Create a new cookie HMAC signer + /// + private static HMAC CreateNewCookieHMAC() + { + return new HMACSHA1(); + } + + /// + /// Current state of handshake sequence + /// + private enum HandshakeState + { + ExpectingHello, + ExpectingClientKeyExchange, + ExpectingChangeCipherSpec, + ExpectingFinish + } + + /// + /// State to manage the current epoch `N` + /// + private struct CurrentEpoch + { + public ulong NextOutgoingSequence; + + public ulong NextExpectedSequence; + public ulong PreviousSequenceWindowBitmask; + + public IRecordProtection RecordProtection; + public IRecordProtection PreviousRecordProtection; + + // Need to keep these around so we can re-transmit our + // last handshake record flight + public ByteSpan ExpectedClientFinishedVerification; + public ByteSpan ServerFinishedVerification; + public ulong NextOutgoingSequenceForPreviousEpoch; + } + + /// + /// State to manage the transition from the current + /// epoch `N` to epoch `N+1` + /// + private struct NextEpoch + { + public ushort Epoch; + + public HandshakeState State; + public CipherSuite SelectedCipherSuite; + + public ulong NextOutgoingSequence; + + public IHandshakeCipherSuite Handshake; + public IRecordProtection RecordProtection; + + public ByteSpan ClientRandom; + public ByteSpan ServerRandom; + + public Sha256Stream VerificationStream; + + public ByteSpan ClientVerification; + public ByteSpan ServerVerification; + } + + /// + /// Per-peer state + /// + private sealed class PeerData : IDisposable + { + public readonly ProtocolVersion ProtocolVersion; + public readonly List QueuedApplicationDataMessage = []; + + public readonly SemaphoreSlim Semaphore = new(1, 1); + public bool CanHandleApplicationData; + + public ConnectionId ConnectionId; + public CurrentEpoch CurrentEpoch; + public ushort Epoch; + public NextEpoch NextEpoch; + + public HazelDtlsSessionInfo Session; + + public DateTime StartOfNegotiation; + + public PeerData(ProtocolVersion protocolVersion) + { + ProtocolVersion = protocolVersion; + ByteSpan block = new byte[2 * Finished.Size]; + CurrentEpoch.ServerFinishedVerification = block[..Finished.Size]; + CurrentEpoch.ExpectedClientFinishedVerification = block.Slice(Finished.Size, Finished.Size); + + ResetPeer(ConnectionId.Create(new IPEndPoint(0, 0), 0), 1); + } + + public void Dispose() + { + CurrentEpoch.RecordProtection?.Dispose(); + CurrentEpoch.PreviousRecordProtection?.Dispose(); + NextEpoch.RecordProtection?.Dispose(); + NextEpoch.Handshake?.Dispose(); + NextEpoch.VerificationStream?.Dispose(); + } + + public void ResetPeer(ConnectionId connectionId, ulong nextExpectedSequenceNumber) + { + Dispose(); + + Epoch = 0; + CanHandleApplicationData = false; + QueuedApplicationDataMessage.Clear(); + + CurrentEpoch.NextOutgoingSequence = 2; // Account for our ClientHelloVerify + CurrentEpoch.NextExpectedSequence = nextExpectedSequenceNumber; + CurrentEpoch.PreviousSequenceWindowBitmask = 0; + CurrentEpoch.RecordProtection = NullRecordProtection.Instance; + CurrentEpoch.PreviousRecordProtection = null; + CurrentEpoch.ServerFinishedVerification.SecureClear(); + CurrentEpoch.ExpectedClientFinishedVerification.SecureClear(); + + NextEpoch.State = HandshakeState.ExpectingHello; + NextEpoch.RecordProtection = null; + NextEpoch.Handshake = null; + NextEpoch.ClientRandom = new byte[Random.Size]; + NextEpoch.ServerRandom = new byte[Random.Size]; + NextEpoch.VerificationStream = new Sha256Stream(); + NextEpoch.ClientVerification = new byte[Finished.Size]; + NextEpoch.ServerVerification = new byte[Finished.Size]; + + ConnectionId = connectionId; + + StartOfNegotiation = DateTime.UtcNow; + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Certificate.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Certificate.cs new file mode 100644 index 0000000..277284e --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Certificate.cs @@ -0,0 +1,66 @@ +using System; +using System.Security.Cryptography.X509Certificates; + +namespace Next.Hazel.Dtls.Handshake; + +/// +/// Encode/decode Handshake Certificate message +/// +public struct Certificate +{ + /// + /// Encode a certificate to wire formate + /// + public static ByteSpan Encode(X509Certificate2 certificate) + { + ByteSpan certData = certificate.GetRawCertData(); + var totalSize = certData.Length + 3 + 3; + + ByteSpan result = new byte[totalSize]; + + var writer = result; + writer.WriteBigEndian24((uint)certData.Length + 3); + writer = writer[3..]; + writer.WriteBigEndian24((uint)certData.Length); + writer = writer[3..]; + + certData.CopyTo(writer); + return result; + } + + /// + /// Parse a Handshake Certificate payload from wire format + /// + /// True if we successfully decode the Certificate message. Otherwise false + public static bool Parse(out X509Certificate2 certificate, ByteSpan span) + { + certificate = null; + if (span.Length < 6) return false; + + var totalSize = span.ReadBigEndian24(); + span = span[3..]; + + if (span.Length < totalSize) return false; + + var certificateSize = span.ReadBigEndian24(); + span = span[3..]; + if (span.Length < certificateSize) return false; + + var rawData = new byte[certificateSize]; + span.CopyTo(rawData, 0); + try + { +#if NET8_0 || NET6_0 + certificate = new X509Certificate2(rawData); +#else + certificate = X509CertificateLoader.LoadCertificate(rawData); +#endif + } + catch (Exception) + { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ClientHello.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ClientHello.cs new file mode 100644 index 0000000..f2800dd --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ClientHello.cs @@ -0,0 +1,220 @@ +using System.Diagnostics; +using Next.Hazel.Dtls.Handshake.Constant; + +namespace Next.Hazel.Dtls.Handshake; + +/// +/// Encode/decode ClientHello Handshake message +/// +public struct ClientHello +{ + public ProtocolVersion ClientProtocolVersion; + public HazelDtlsSessionInfo SessionInfo; + public ByteSpan Random; + public ByteSpan Cookie; + public ByteSpan CipherSuites; + public ByteSpan SupportedCurves; + + public const int MinSize = 0 + + 2 // client_version + + Constant.Random.Size // random + + 1 // session_id (size) + + 1 // cookie (size) + + 2 // cipher_suites (size) + + 1 // compression_methods (size) + + 1 // compression_method[0] (NULL) + + 2 // extensions size + + 0 // NamedCurveList extensions[0] + + 2 // extensions[0].extension_type + + 2 // extensions[0].extension_data (length) + + 2 // extensions[0].named_curve_list (size) + ; + + /// + /// Calculate the size in bytes required for the ClientHello payload + /// + /// + public int CalculateSize() + { + return MinSize + + SessionInfo.PayloadSize + + Cookie.Length + + CipherSuites.Length + + SupportedCurves.Length + ; + } + + /// + /// Parse a Handshake ClientHello payload from wire format + /// + /// True if we successfully decode the ClientHello message. Otherwise false + public static bool Parse(out ClientHello result, ProtocolVersion? expectedProtocolVersion, ByteSpan span) + { + result = new ClientHello(); + if (span.Length < MinSize) return false; + + result.ClientProtocolVersion = (ProtocolVersion)span.ReadBigEndian16(); + if (expectedProtocolVersion.HasValue && result.ClientProtocolVersion != expectedProtocolVersion.Value) + return false; + span = span[2..]; + + result.Random = span[..Constant.Random.Size]; + span = span[Constant.Random.Size..]; + + if (!HazelDtlsSessionInfo.Parse(out result.SessionInfo, span)) return false; + + span = span[result.SessionInfo.FullSize..]; + + var cookieSize = span[0]; + if (span.Length < 1 + cookieSize) return false; + result.Cookie = span.Slice(1, cookieSize); + span = span[(1 + cookieSize)..]; + + var cipherSuiteSize = span.ReadBigEndian16(); + if (span.Length < 2 + cipherSuiteSize) return false; + + if (cipherSuiteSize % 2 != 0) return false; + result.CipherSuites = span.Slice(2, cipherSuiteSize); + span = span[(2 + cipherSuiteSize)..]; + + int compressionMethodsSize = span[0]; + var foundNullCompressionMethod = false; + for (var ii = 0; ii != compressionMethodsSize; ++ii) + if (span[1 + ii] == (byte)CompressionMethod.Null) + { + foundNullCompressionMethod = true; + break; + } + + if (!foundNullCompressionMethod + || span.Length < 1 + compressionMethodsSize) + return false; + + span = span[(1 + compressionMethodsSize)..]; + + switch (span.Length) + { + // Parse extensions + case <= 0: + return true; + case < 2: + return false; + } + + var extensionsSize = span.ReadBigEndian16(); + span = span[2..]; + if (span.Length != extensionsSize) return false; + + while (span.Length > 0) + { + // Parse extension header + if (span.Length < 4) return false; + + var extensionType = (ExtensionType)span.ReadBigEndian16(); + var extensionLength = span.ReadBigEndian16(2); + + if (span.Length < 4 + extensionLength) return false; + + var extensionData = span.Slice(4, extensionLength); + span = span[(4 + extensionLength)..]; + result.ParseExtension(extensionType, extensionData); + } + + return true; + } + + /// + /// Decode a ClientHello extension + /// + /// Extension type + /// Extension data + private void ParseExtension(ExtensionType extensionType, ByteSpan extensionData) + { + switch (extensionType) + { + case ExtensionType.EllipticCurves: + if (extensionData.Length % 2 != 0) break; + + if (extensionData.Length < 2) break; + + var namedCurveSize = extensionData.ReadBigEndian16(); + if (namedCurveSize % 2 != 0) break; + + SupportedCurves = extensionData.Slice(2, namedCurveSize); + break; + } + } + + /// + /// Determines if the ClientHello message advertises support + /// for the specified cipher suite + /// + public bool ContainsCipherSuite(CipherSuite cipherSuite) + { + var iterator = CipherSuites; + while (iterator.Length >= 2) + { + if (iterator.ReadBigEndian16() == (ushort)cipherSuite) return true; + + iterator = iterator[2..]; + } + + return false; + } + + /// + /// Determines if the ClientHello message advertises support + /// for the specified curve + /// + public bool ContainsCurve(NamedCurve curve) + { + var iterator = SupportedCurves; + while (iterator.Length >= 2) + { + if (iterator.ReadBigEndian16() == (ushort)curve) return true; + + iterator = iterator[2..]; + } + + return false; + } + + /// + /// Encode Handshake ClientHello payload to wire format + /// + public void Encode(ByteSpan span) + { + span.WriteBigEndian16((ushort)ProtocolVersion.DTLS1_2); + span = span[2..]; + + Debug.Assert(Random.Length == Constant.Random.Size); + Random.CopyTo(span); + span = span[Constant.Random.Size..]; + + + SessionInfo.Encode(span); + span = span[SessionInfo.FullSize..]; + + span[0] = (byte)Cookie.Length; + Cookie.CopyTo(span[1..]); + span = span[(1 + Cookie.Length)..]; + + span.WriteBigEndian16((ushort)CipherSuites.Length); + CipherSuites.CopyTo(span[2..]); + span = span[(2 + CipherSuites.Length)..]; + + span[0] = 1; + span[1] = (byte)CompressionMethod.Null; + span = span[2..]; + + // Extensions size + span.WriteBigEndian16((ushort)(6 + SupportedCurves.Length)); + span = span[2..]; + + // Supported curves extension + span.WriteBigEndian16((ushort)ExtensionType.EllipticCurves); + span.WriteBigEndian16((ushort)(2 + SupportedCurves.Length), 2); + span.WriteBigEndian16((ushort)SupportedCurves.Length, 4); + SupportedCurves.CopyTo(span[6..]); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CipherSuite.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CipherSuite.cs new file mode 100644 index 0000000..af324ba --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CipherSuite.cs @@ -0,0 +1,10 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// List of cipher suites +/// +public enum CipherSuite +{ + TLS_NULL_WITH_NULL_NULL = 0x0000, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 0xC02F +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CompressionMethod.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CompressionMethod.cs new file mode 100644 index 0000000..6596c0f --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/CompressionMethod.cs @@ -0,0 +1,9 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// List of compression methods +/// +public enum CompressionMethod : byte +{ + Null = 0 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ECCurveType.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ECCurveType.cs new file mode 100644 index 0000000..db5f0aa --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ECCurveType.cs @@ -0,0 +1,9 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Elliptic curve type +/// +public enum ECCurveType : byte +{ + NamedCurve = 3 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ExtensionType.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ExtensionType.cs new file mode 100644 index 0000000..77da2a0 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/ExtensionType.cs @@ -0,0 +1,9 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Extension type +/// +public enum ExtensionType : ushort +{ + EllipticCurves = 10 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Finished.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Finished.cs new file mode 100644 index 0000000..3f9491d --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Finished.cs @@ -0,0 +1,9 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Encode/decode Handshake Finished message +/// +public struct Finished +{ + public const int Size = 12; +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HandshakeType.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HandshakeType.cs new file mode 100644 index 0000000..0f3f1fc --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HandshakeType.cs @@ -0,0 +1,19 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Handshake message type +/// +public enum HandshakeType : byte +{ + HelloRequest = 0, + ClientHello = 1, + ServerHello = 2, + HelloVerifyRequest = 3, + Certificate = 11, + ServerKeyExchange = 12, + CertificateRequest = 13, + ServerHelloDone = 14, + CertificateVerify = 15, + ClientKeyExchange = 16, + Finished = 20 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HashAlgorithm.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HashAlgorithm.cs new file mode 100644 index 0000000..ce1f4c6 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/HashAlgorithm.cs @@ -0,0 +1,10 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Hash algorithms +/// +public enum HashAlgorithm : byte +{ + None = 0, + Sha256 = 4 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/NamedCurve.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/NamedCurve.cs new file mode 100644 index 0000000..fa2bcb2 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/NamedCurve.cs @@ -0,0 +1,11 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Named curves +/// +public enum NamedCurve : ushort +{ + Reserved = 0, + secp256r1 = 23, + x25519 = 29 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Random.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Random.cs new file mode 100644 index 0000000..eba6f19 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/Random.cs @@ -0,0 +1,12 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Random state for entropy +/// +public struct Random +{ + public const int Size = 0 + + 4 // gmt_unix_time + + 28 // random_bytes + ; +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/SignatureAlgorithm.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/SignatureAlgorithm.cs new file mode 100644 index 0000000..a701b49 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Constant/SignatureAlgorithm.cs @@ -0,0 +1,11 @@ +namespace Next.Hazel.Dtls.Handshake.Constant; + +/// +/// Signature algorithms +/// +public enum SignatureAlgorithm : byte +{ + Anonymous = 0, + RSA = 1, + ECDSA = 3 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Handshake.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Handshake.cs new file mode 100644 index 0000000..bdb2e67 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/Handshake.cs @@ -0,0 +1,48 @@ +using Next.Hazel.Dtls.Handshake.Constant; + +namespace Next.Hazel.Dtls.Handshake; + +/// +/// Encode/decode handshake protocol header +/// +public struct Handshake +{ + public HandshakeType MessageType; + public uint Length; + public ushort MessageSequence; + public uint FragmentOffset; + public uint FragmentLength; + + public const int Size = 12; + + /// + /// Parse a Handshake protocol header from wire format + /// + /// True if we successfully decode a handshake header. Otherwise false + public static bool Parse(out Handshake header, ByteSpan span) + { + header = new Handshake(); + + if (span.Length < Size) return false; + + header.MessageType = (HandshakeType)span[0]; + header.Length = span.ReadBigEndian24(1); + header.MessageSequence = span.ReadBigEndian16(4); + header.FragmentOffset = span.ReadBigEndian24(6); + header.FragmentLength = span.ReadBigEndian24(9); + return true; + } + + /// + /// Encode the Handshake protocol header to wire format + /// + /// + public void Encode(ByteSpan span) + { + span[0] = (byte)MessageType; + span.WriteBigEndian24(Length, 1); + span.WriteBigEndian16(MessageSequence, 4); + span.WriteBigEndian24(FragmentOffset, 6); + span.WriteBigEndian24(FragmentLength, 9); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HazelDtlsSessionInfo.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HazelDtlsSessionInfo.cs new file mode 100644 index 0000000..229e72f --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HazelDtlsSessionInfo.cs @@ -0,0 +1,58 @@ +using System; + +namespace Next.Hazel.Dtls.Handshake; + +/// +/// Encode/Decode session information in ClientHello +/// +public struct HazelDtlsSessionInfo +{ + public const byte CurrentClientSessionSize = 1; + public const byte CurrentClientSessionVersion = 1; + + public byte FullSize => (byte)(1 + PayloadSize); + public byte PayloadSize; + public byte Version; + + public HazelDtlsSessionInfo(byte version) + { + Version = version; + switch (version) + { + case 0: // Does not write version byte + PayloadSize = 0; + return; + case 1: // Writes version byte only + PayloadSize = 1; + return; + } + + throw new ArgumentOutOfRangeException(nameof(version)); + } + + public void Encode(ByteSpan writer) + { + writer[0] = PayloadSize; + + if (Version > 0) writer[1] = Version; + } + + public static bool Parse(out HazelDtlsSessionInfo result, ByteSpan reader) + { + result = new HazelDtlsSessionInfo(); + if (reader.Length < 1) return false; + + result.PayloadSize = reader[0]; + + // Back compat, length may be zero, version defaults to 0. + if (result.PayloadSize == 0) + { + result.Version = 0; + return true; + } + + // Forward compat, if length > 1, ignore the rest + result.Version = reader[1]; + return true; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HelloVerifyRequest.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HelloVerifyRequest.cs new file mode 100644 index 0000000..5b37070 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/HelloVerifyRequest.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Security.Cryptography; +using Next.Hazel.Crypto; + +namespace Next.Hazel.Dtls.Handshake; + +/// +/// Encode/decode Handshake HelloVerifyRequest message +/// +public struct HelloVerifyRequest +{ + public const int CookieSize = 20; + + public const int Size = 0 + + 2 // server_version + + 1 // cookie (size) + + CookieSize // cookie (data) + ; + + public ProtocolVersion ServerProtocolVersion; + public ByteSpan Cookie; + + /// + /// Parse a Handshake HelloVerifyRequest payload from wire + /// format + /// + /// + /// True if we successfully decode the HelloVerifyRequest + /// message. Otherwise false. + /// + public static bool Parse(out HelloVerifyRequest result, ProtocolVersion? expectedProtocolVersion, ByteSpan span) + { + result = new HelloVerifyRequest(); + if (span.Length < 3) return false; + + result.ServerProtocolVersion = (ProtocolVersion)span.ReadBigEndian16(); + if (expectedProtocolVersion.HasValue && result.ServerProtocolVersion != expectedProtocolVersion.Value) + return false; + + + var cookieSize = span[2]; + span = span[3..]; + + if (span.Length < cookieSize) return false; + + result.Cookie = span; + return true; + } + + /// + /// Encode a HelloVerifyRequest payload to wire format + /// + /// + /// Address of the remote peer + /// Listener HMAC signature provider + /// + public static void Encode(ByteSpan span, EndPoint peerAddress, HMAC hmac, ProtocolVersion protocolVersion) + { + var cookie = ComputeAddressMac(peerAddress, hmac); + + span.WriteBigEndian16((ushort)protocolVersion); + span[2] = CookieSize; + cookie.CopyTo(span[3..]); + } + + /// + /// Generate an HMAC for a peer address + /// + /// Address of the remote peer + /// Listener HMAC signature provider + public static ByteSpan ComputeAddressMac(EndPoint peerAddress, HMAC hmac) + { + var address = peerAddress.Serialize(); + var data = new byte[address.Size]; + for (int ii = 0, nn = data.Length; ii != nn; ++ii) data[ii] = address[ii]; + + ByteSpan signature = hmac.ComputeHash(data); + return signature[..CookieSize]; + } + + /// + /// Verify a client's cookie was signed by our listener + /// + /// Wire format cookie + /// Address of the remote peer + /// Listener HMAC signature provider + /// True if the cookie is valid. Otherwise false + public static bool VerifyCookie(ByteSpan cookie, EndPoint peerAddress, HMAC hmac) + { + if (cookie.Length != CookieSize) return false; + + var expectedHash = ComputeAddressMac(peerAddress, hmac); + if (expectedHash.Length != cookie.Length) return false; + + return 1 == Const.ConstantCompareSpans(cookie, expectedHash); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ServerHello.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ServerHello.cs new file mode 100644 index 0000000..547d732 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Handshake/ServerHello.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using Next.Hazel.Dtls.Handshake.Constant; + +namespace Next.Hazel.Dtls.Handshake; + +/// +/// Encode/decode Handshake ServerHello message +/// +public struct ServerHello +{ + public ProtocolVersion ServerProtocolVersion; + public ByteSpan Random; + public CipherSuite CipherSuite; + public HazelDtlsSessionInfo Session; + + public const int MinSize = 0 + + 2 // server_version + + Constant.Random.Size // random + + 1 // session_id (size) + + 2 // cipher_suite + + 1 // compression_method + ; + + public int Size => MinSize + Session.PayloadSize; + + /// + /// Parse a Handshake ServerHello payload from wire format + /// + /// + /// True if we successfully decode the ServerHello + /// message. Otherwise false. + /// + public static bool Parse(out ServerHello result, ByteSpan span) + { + result = new ServerHello(); + if (span.Length < MinSize) return false; + + result.ServerProtocolVersion = (ProtocolVersion)span.ReadBigEndian16(); + span = span[2..]; + + result.Random = span[..Constant.Random.Size]; + span = span[Constant.Random.Size..]; + + if (!HazelDtlsSessionInfo.Parse(out result.Session, span)) return false; + + span = span[result.Session.FullSize..]; + + result.CipherSuite = (CipherSuite)span.ReadBigEndian16(); + span = span[2..]; + + var compressionMethod = (CompressionMethod)span[0]; + return compressionMethod == CompressionMethod.Null; + } + + /// + /// Encode Handshake ServerHello to wire format + /// + public void Encode(ByteSpan span) + { + Debug.Assert(Random.Length == Constant.Random.Size); + + span.WriteBigEndian16((ushort)ServerProtocolVersion); + span = span[2..]; + + Random.CopyTo(span); + span = span[Constant.Random.Size..]; + + Session.Encode(span); + span = span[Session.FullSize..]; + + span.WriteBigEndian16((ushort)CipherSuite); + span = span[2..]; + + span[0] = (byte)CompressionMethod.Null; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/IHandshakeCipherSuite.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/IHandshakeCipherSuite.cs new file mode 100644 index 0000000..d672e3a --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/IHandshakeCipherSuite.cs @@ -0,0 +1,63 @@ +using System; + +namespace Next.Hazel.Dtls; + +/// +/// DTLS cipher suite interface for the handshake portion of +/// the connection. +/// +public interface IHandshakeCipherSuite : IDisposable +{ + /// + /// Gets the size of the shared key + /// + /// Size of the shared key in bytes + int SharedKeySize(); + + /// + /// Calculate the size of the ServerKeyExchnage message + /// + /// + /// Private key that will be used to sign the message + /// + /// Size of the message in bytes + int CalculateServerMessageSize(object privateKey); + + /// + /// Encodes the ServerKeyExchange message + /// + /// + /// Private key to use for signing + void EncodeServerKeyExchangeMessage(ByteSpan output, object privateKey); + + /// + /// Verifies the authenticity of a server key exchange + /// message and calculates the shared secret. + /// + /// + /// True if the authenticity has been validated and a shared key + /// was generated. Otherwise, false. + /// + bool VerifyServerMessageAndGenerateSharedKey(ByteSpan output, ByteSpan serverKeyExchangeMessage, object publicKey); + + /// + /// Calculate the size of the ClientKeyExchange message + /// + /// Size of the message in bytes + int CalculateClientMessageSize(); + + /// + /// Encodes the ClientKeyExchangeMessage + /// + void EncodeClientKeyExchangeMessage(ByteSpan output); + + /// + /// Verifies the validity of a client key exchange message + /// and calculats the hsared secret. + /// + /// + /// True if the client exchange message is valid and a + /// shared key was generated. Otherwise, false. + /// + bool VerifyClientMessageAndGenerateSharedKey(ByteSpan output, ByteSpan clientKeyExchangeMessage); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/IRecordProtection.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/IRecordProtection.cs new file mode 100644 index 0000000..7db4d15 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/IRecordProtection.cs @@ -0,0 +1,81 @@ +using System; +using Next.Hazel.Dtls.Handshake.Constant; + +namespace Next.Hazel.Dtls; + +/// +/// DTLS cipher suite interface for protection of record payload. +/// +public interface IRecordProtection : IDisposable +{ + /// + /// Calculate the size of an encrypted plaintext + /// + /// Size of plaintext in bytes + /// Size of encrypted ciphertext in bytes + int GetEncryptedSize(int dataSize); + + /// + /// Calculate the size of decrypted ciphertext + /// + /// Size of ciphertext in bytes + /// Size of decrypted plaintext in bytes + int GetDecryptedSize(int dataSize); + + /// + /// Encrypt a plaintext intput with server keys + /// Output may overlap with input. + /// + /// Output ciphertext + /// Input plaintext + /// Parent DTLS record + void EncryptServerPlaintext(ByteSpan output, ByteSpan input, ref Record record); + + /// + /// Encrypt a plaintext intput with client keys + /// Output may overlap with input. + /// + /// Output ciphertext + /// Input plaintext + /// Parent DTLS record + void EncryptClientPlaintext(ByteSpan output, ByteSpan input, ref Record record); + + /// + /// Decrypt a ciphertext intput with server keys + /// Output may overlap with input. + /// + /// Output plaintext + /// Input ciphertext + /// Parent DTLS record + /// True if the input was authenticated and decrypted. Otherwise false + bool DecryptCiphertextFromServer(ByteSpan output, ByteSpan input, ref Record record); + + /// + /// Decrypt a ciphertext intput with client keys + /// Output may overlap with input. + /// + /// Output plaintext + /// Input ciphertext + /// Parent DTLS record + /// True if the input was authenticated and decrypted. Otherwise false + bool DecryptCiphertextFromClient(ByteSpan output, ByteSpan input, ref Record record); +} + +/// +/// Factory to create record protection from cipher suite identifiers +/// +public sealed class RecordProtectionFactory +{ + public static IRecordProtection Create(CipherSuite cipherSuite, ByteSpan masterSecret, ByteSpan serverRandom, + ByteSpan clientRandom) + { + switch (cipherSuite) + { + case CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: + return new Aes128GcmRecordProtection(masterSecret, serverRandom, clientRandom); + + default: + return null; + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/NullRecordProtection.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/NullRecordProtection.cs new file mode 100644 index 0000000..d059888 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/NullRecordProtection.cs @@ -0,0 +1,55 @@ +namespace Next.Hazel.Dtls; + +/// +/// Passthrough record protection implementaion +/// +public class NullRecordProtection : IRecordProtection +{ + public static readonly NullRecordProtection Instance = new(); + + public void Dispose() + { + } + + public int GetEncryptedSize(int dataSize) + { + return dataSize; + } + + public int GetDecryptedSize(int dataSize) + { + return dataSize; + } + + public void EncryptServerPlaintext(ByteSpan output, ByteSpan input, ref Record record) + { + CopyMaybeOverlappingSpans(output, input); + } + + public void EncryptClientPlaintext(ByteSpan output, ByteSpan input, ref Record record) + { + CopyMaybeOverlappingSpans(output, input); + } + + public bool DecryptCiphertextFromServer(ByteSpan output, ByteSpan input, ref Record record) + { + CopyMaybeOverlappingSpans(output, input); + return true; + } + + public bool DecryptCiphertextFromClient(ByteSpan output, ByteSpan input, ref Record record) + { + CopyMaybeOverlappingSpans(output, input); + return true; + } + + private static void CopyMaybeOverlappingSpans(ByteSpan output, ByteSpan input) + { + // Early out if the ranges `output` is equal to `input` + if (output.GetUnderlyingArray() == input.GetUnderlyingArray()) + if (output.Offset == input.Offset && output.Length == input.Length) + return; + + input.CopyTo(output); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/PrfSha256.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/PrfSha256.cs new file mode 100644 index 0000000..4c89243 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/PrfSha256.cs @@ -0,0 +1,80 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Next.Hazel.Dtls; + +/// +/// Common Psuedorandom Function labels for TLS +/// +public struct PrfLabel +{ + public static readonly ByteSpan MASTER_SECRET = LabelToBytes("master secert"); + public static readonly ByteSpan KEY_EXPANSION = LabelToBytes("key expansion"); + public static readonly ByteSpan CLIENT_FINISHED = LabelToBytes("client finished"); + public static readonly ByteSpan SERVER_FINISHED = LabelToBytes("server finished"); + + /// + /// Convert a text label to a byte sequence + /// + public static ByteSpan LabelToBytes(string label) + { + return Encoding.ASCII.GetBytes(label); + } +} + +/// +/// The P_SHA256 Psuedorandom Function +/// +public struct PrfSha256 +{ + /// + /// Expand a secret key + /// + /// Output span. Length determines how much data to generate + /// Original key to expand + /// Label (treated as a salt) + /// Seed for expansion (treated as a salt) + public static void ExpandSecret(ByteSpan output, ByteSpan key, string label, ByteSpan initialSeed) + { + ExpandSecret(output, key, PrfLabel.LabelToBytes(label), initialSeed); + } + + /// + /// Expand a secret key + /// + /// Output span. Length determines how much data to generate + /// Original key to expand + /// Label (treated as a salt) + /// Seed for expansion (treated as a salt) + public static void ExpandSecret(ByteSpan output, ByteSpan key, ByteSpan label, ByteSpan initialSeed) + { + var writer = output; + + var roundSeed = new byte[label.Length + initialSeed.Length]; + label.CopyTo(roundSeed); + initialSeed.CopyTo(roundSeed, label.Length); + + var hashA = roundSeed; + + using (var hmac = new HMACSHA256(key.ToArray())) + { + var input = new byte[hmac.OutputBlockSize + roundSeed.Length]; + new ByteSpan(roundSeed).CopyTo(input, hmac.OutputBlockSize); + + while (writer.Length > 0) + { + // Update hashA + hashA = hmac.ComputeHash(hashA); + + // generate hash input + new ByteSpan(hashA).CopyTo(input); + + ByteSpan roundOutput = hmac.ComputeHash(input); + if (roundOutput.Length > writer.Length) roundOutput = roundOutput[..writer.Length]; + + roundOutput.CopyTo(writer); + writer = writer[roundOutput.Length..]; + } + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/Record.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Record.cs new file mode 100644 index 0000000..fc2bb44 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/Record.cs @@ -0,0 +1,106 @@ +namespace Next.Hazel.Dtls; + +/// +/// DTLS version constants +/// +public enum ProtocolVersion : ushort +{ + /// + /// Use to obfuscate DTLS as regular UDP packets + /// + UDP = 0, + + /// + /// DTLS 1.2 + /// + DTLS1_2 = 0xFEFD +} + +/// +/// DTLS record content type +/// +public enum ContentType : byte +{ + ChangeCipherSpec = 20, + Alert = 21, + Handshake = 22, + ApplicationData = 23 +} + +/// +/// Encode/decode DTLS record header +/// +public struct Record +{ + public ContentType ContentType; + public ProtocolVersion ProtocolVersion; + public ushort Epoch; + public ulong SequenceNumber; + public ushort Length; + + public const int Size = 13; + + /// + /// Parse a DTLS record from wire format + /// + /// True if we successfully parse the record header. Otherwise false + public static bool Parse(out Record record, ProtocolVersion? expectedProtocolVersion, ByteSpan span) + { + record = new Record(); + + if (span.Length < Size) return false; + + record.ContentType = (ContentType)span[0]; + record.ProtocolVersion = (ProtocolVersion)span.ReadBigEndian16(1); + record.Epoch = span.ReadBigEndian16(3); + record.SequenceNumber = span.ReadBigEndian48(5); + record.Length = span.ReadBigEndian16(11); + + return !expectedProtocolVersion.HasValue || record.ProtocolVersion == expectedProtocolVersion.Value; + } + + /// + /// Encode a DTLS record to wire format + /// + public void Encode(ByteSpan span) + { + span[0] = (byte)ContentType; + span.WriteBigEndian16((ushort)ProtocolVersion, 1); + span.WriteBigEndian16(Epoch, 3); + span.WriteBigEndian48(SequenceNumber, 5); + span.WriteBigEndian16(Length, 11); + } +} + +public struct ChangeCipherSpec +{ + public const int Size = 1; + + private enum Value : byte + { + ChangeCipherSpec = 1 + } + + /// + /// Parse a ChangeCipherSpec record from wire format + /// + /// + /// True if we successfully parse the ChangeCipherSpec + /// record. Otherwise, false. + /// + public static bool Parse(ByteSpan span) + { + if (span.Length != 1) return false; + + var value = (Value)span[0]; + return value == Value.ChangeCipherSpec; + } + + /// + /// Encode a ChangeCipherSpec record to wire format + /// + public static void Encode(ByteSpan span) + { + span[0] = (byte)Value.ChangeCipherSpec; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Dtls/X25519EcdheRsaSha256.cs b/src/NextFast.Hazel-New/Next.Hazel/Dtls/X25519EcdheRsaSha256.cs new file mode 100644 index 0000000..2a77ec7 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Dtls/X25519EcdheRsaSha256.cs @@ -0,0 +1,168 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography; +using Next.Hazel.Crypto; +using Next.Hazel.Dtls.Handshake.Constant; +using HashAlgorithm = Next.Hazel.Dtls.Handshake.Constant.HashAlgorithm; + +namespace Next.Hazel.Dtls; + +/// +/// ECDHE_RSA_*_256 cipher suite +/// +public class X25519EcdheRsaSha256 : IHandshakeCipherSuite +{ + private static readonly int ClientMessageSize = 0 + + 1 + X25519.KeySize // ECPoint ClientKeyExchange.ecdh_Yc + ; + + private readonly ByteSpan privateAgreementKey; + private SHA256 sha256 = SHA256.Create(); + + /// + /// Create a new instance of the x25519 key exchange + /// + /// Random data source + public X25519EcdheRsaSha256(RandomNumberGenerator random) + { + var buffer = new byte[X25519.KeySize]; + random.GetBytes(buffer); + privateAgreementKey = buffer; + } + + /// + public void Dispose() + { + sha256?.Dispose(); + sha256 = null; + } + + /// + public int SharedKeySize() + { + return X25519.KeySize; + } + + /// + public int CalculateServerMessageSize(object privateKey) + { + if (privateKey is not RSA rsaPrivateKey) throw new ArgumentException("Invalid private key", nameof(privateKey)); + + return CalculateServerMessageSize(rsaPrivateKey.KeySize); + } + + /// + public void EncodeServerKeyExchangeMessage(ByteSpan output, object privateKey) + { + var rsaPrivateKey = privateKey as RSA; + if (rsaPrivateKey == null) throw new ArgumentException("Invalid private key", nameof(privateKey)); + + output[0] = (byte)ECCurveType.NamedCurve; + output.WriteBigEndian16((ushort)NamedCurve.x25519, 1); + output[3] = X25519.KeySize; + X25519.Func(output.Slice(4, X25519.KeySize), privateAgreementKey); + + // Hash the key parameters + var paramterDigest = sha256.ComputeHash(output.GetUnderlyingArray(), output.Offset, 4 + X25519.KeySize); + + // Sign the paramter digest + var signer = new RSAPKCS1SignatureFormatter(rsaPrivateKey); + signer.SetHashAlgorithm("SHA256"); + ByteSpan signature = signer.CreateSignature(paramterDigest); + + Debug.Assert(signature.Length == rsaPrivateKey.KeySize / 8); + output[4 + X25519.KeySize] = (byte)HashAlgorithm.Sha256; + output[5 + X25519.KeySize] = (byte)SignatureAlgorithm.RSA; + output[(6 + X25519.KeySize)..].WriteBigEndian16((ushort)signature.Length); + signature.CopyTo(output[(8 + X25519.KeySize)..]); + } + + /// + public bool VerifyServerMessageAndGenerateSharedKey(ByteSpan output, ByteSpan serverKeyExchangeMessage, + object publicKey) + { + if (publicKey is not RSA rsaPublicKey) return false; + + if (output.Length != X25519.KeySize) return false; + + // Verify message is compatible with this cipher suite + if (serverKeyExchangeMessage.Length != CalculateServerMessageSize(rsaPublicKey.KeySize)) return false; + + if (serverKeyExchangeMessage[0] != (byte)ECCurveType.NamedCurve) return false; + + if (serverKeyExchangeMessage.ReadBigEndian16(1) != (ushort)NamedCurve.x25519) return false; + + if (serverKeyExchangeMessage[3] != X25519.KeySize) return false; + + if (serverKeyExchangeMessage[4 + X25519.KeySize] != (byte)HashAlgorithm.Sha256) return false; + + if (serverKeyExchangeMessage[5 + X25519.KeySize] != (byte)SignatureAlgorithm.RSA) return false; + + var keyParameters = serverKeyExchangeMessage[..(4 + X25519.KeySize)]; + var othersPublicKey = keyParameters[4..]; + var signatureSize = serverKeyExchangeMessage.ReadBigEndian16(6 + X25519.KeySize); + var signature = serverKeyExchangeMessage[(4 + keyParameters.Length)..]; + + if (signatureSize != signature.Length) return false; + + // Hash the key parameters + var parameterDigest = + sha256.ComputeHash(keyParameters.GetUnderlyingArray(), keyParameters.Offset, keyParameters.Length); + + // Verify the signature + var verifier = new RSAPKCS1SignatureDeformatter(rsaPublicKey); + verifier.SetHashAlgorithm("SHA256"); + if (!verifier.VerifySignature(parameterDigest, signature.ToArray())) return false; + + // Signature has been validated, generate the shared key + return X25519.Func(output, privateAgreementKey, othersPublicKey); + } + + /// + public int CalculateClientMessageSize() + { + return ClientMessageSize; + } + + /// + public void EncodeClientKeyExchangeMessage(ByteSpan output) + { + output[0] = X25519.KeySize; + X25519.Func(output.Slice(1, X25519.KeySize), privateAgreementKey); + } + + /// + public bool VerifyClientMessageAndGenerateSharedKey(ByteSpan output, ByteSpan clientKeyExchangeMessage) + { + if (clientKeyExchangeMessage.Length != ClientMessageSize) return false; + + if (clientKeyExchangeMessage[0] != X25519.KeySize) return false; + + var othersPublicKey = clientKeyExchangeMessage[1..]; + return X25519.Func(output, privateAgreementKey, othersPublicKey); + } + + /// + /// Calculate the server message size given an RSA key size + /// + /// + /// Size of the private key (in bits) + /// + /// + /// Size of the ServerKeyExchange message in bytes + /// + private static int CalculateServerMessageSize(int keySize) + { + var signatureSize = keySize / 8; + + return 0 + + 1 // ECCurveType ServerKeyExchange.params.curve_params.curve_type + + 2 // NamedCurve ServerKeyExchange.params.curve_params.namedcurve + + 1 + X25519.KeySize // ECPoint ServerKeyExchange.params.public + + 1 // HashAlgorithm ServerKeyExchange.algorithm.hash + + 1 // SignatureAlgorithm ServerKeyExchange.signed_params.algorithm.signature + + 2 // ServerKeyExchange.signed_params.size + + signatureSize // ServerKeyExchange.signed_params.opaque + ; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/DtlsHelper.cs b/src/NextFast.Hazel-New/Next.Hazel/DtlsHelper.cs new file mode 100644 index 0000000..7922150 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/DtlsHelper.cs @@ -0,0 +1,59 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; + +namespace Next.Hazel; + +// https://github.com/willardf/Hazel-Networking/blob/main/Hazel.UnitTests/Dtls/DtlsConnectionTests.cs +public static class DtlsHelper +{ + public static X509Certificate2 GetCertificate(string certificateSting, string privateKeyString = "") + { + var privateKey = privateKeyString != "" ? DecodeRSAKeyFromPEM(privateKeyString) : null; + var rawData = DecodePEM(certificateSting); +#if NET8_0 || NET6_0 + var certificate = new X509Certificate2(rawData); +#else + var certificate = X509CertificateLoader.LoadCertificate(rawData); +#endif + return privateKey != null + ? certificate.CopyWithPrivateKey(privateKey) + : certificate; + } + + public static X509Certificate2Collection GetCertificateCollection(this X509Certificate2 certificate) + { + return new X509Certificate2Collection(certificate); + } + + public static byte[] DecodePEM(string pemData) + { + var result = new List(); + + var lines = pemData.Replace("\r", "").Split('\n'); + foreach (var line in lines) + { + if (line.StartsWith("-----")) continue; + + var lineData = Convert.FromBase64String(line); + result.AddRange(lineData); + } + + return result.ToArray(); + } + + public static RSA DecodeRSAKeyFromPEM(string pemData) + { + var pemReader = new PemReader(new StringReader(pemData)); + var parameters = DotNetUtilities.ToRSAParameters((RsaPrivateCrtKeyParameters)pemReader.ReadObject()); + var rsa = RSA.Create(); + rsa.ImportParameters(parameters); + return rsa; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Extensions/DefaultMessageWriterProvider.cs b/src/NextFast.Hazel-New/Next.Hazel/Extensions/DefaultMessageWriterProvider.cs new file mode 100644 index 0000000..12f64c0 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Extensions/DefaultMessageWriterProvider.cs @@ -0,0 +1,11 @@ +using Next.Hazel.Abstractions; + +namespace Next.Hazel.Extensions; + +public class DefaultMessageWriterProvider : IMessageWriterProvider +{ + public IMessageWriter Get(MessageType sendOption = MessageType.Unreliable) + { + return MessageWriter.Get(sendOption); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Extensions/ServiceProviderExtensions.cs b/src/NextFast.Hazel-New/Next.Hazel/Extensions/ServiceProviderExtensions.cs new file mode 100644 index 0000000..c98f786 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Extensions/ServiceProviderExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; + +namespace Next.Hazel.Extensions; + +#nullable enable +public static class ServiceProviderExtensions +{ + public static IServiceCollection AddPolicy(this IServiceCollection services) + where TPolicy : class, IPooledObjectPolicy + where TP : class + { + return services.AddPolicy(new DefaultObjectPoolProvider()); + } + + public static IServiceCollection AddPolicy(this IServiceCollection services, + TPoolProvider? Instance = null) + where TPolicy : class, IPooledObjectPolicy + where TPoolProvider : ObjectPoolProvider + where TP : class + { + if (Instance != null) + services.TryAddSingleton(Instance); + else + services.TryAddSingleton(); + + return services.AddSingleton(serviceProvider => + { + var provider = serviceProvider.GetRequiredService(); + var policy = ActivatorUtilities.CreateInstance(serviceProvider); + return provider.Create(policy); + }); + } + + public static IServiceCollection AddHazel(this IServiceCollection services) + { + return services.AddPolicy(); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Hazel.nuspec b/src/NextFast.Hazel-New/Next.Hazel/Hazel.nuspec new file mode 100644 index 0000000..cd90fed --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Hazel.nuspec @@ -0,0 +1,19 @@ + + + + DarkRiftNetworking.Hazel + $version$ + $title$ + DarkRiftNetworking + DarkRiftNetworking + https://github.com/DarkRiftNetworking/Hazel-Networking/blob/master/LICENSE + https://github.com/DarkRiftNetworking/Hazel-Networking + false + Hazel Networking is a low-level networking library for C# providing connection orientated, message + based communication via TCP, UDP and RUDP. + + First release (Beta) + Copyright 2016 + network networking TCP UDP RUDP socket sockets Unity3d Unity + + \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/HazelException.cs b/src/NextFast.Hazel-New/Next.Hazel/HazelException.cs new file mode 100644 index 0000000..5702cb4 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/HazelException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Next.Hazel; + +/// +/// Wrapper for exceptions thrown from Hazel. +/// +[Serializable] +public class HazelException : Exception +{ + internal HazelException(string msg) : base(msg) + { + } + + internal HazelException(string msg, Exception e) : base(msg, e) + { + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/IPMode.cs b/src/NextFast.Hazel-New/Next.Hazel/IPMode.cs new file mode 100644 index 0000000..f0f16c3 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/IPMode.cs @@ -0,0 +1,22 @@ +namespace Next.Hazel; + +/// +/// Represents the IP version that a connection or listener will use. +/// +/// +/// this sets the underlying sockets to use IPv6 but still allow IPv4 sockets to connect for backwards compatability +/// and hence it is the default IPMode in most cases. +/// +public enum IPMode +{ + /// + /// Instruction to use IPv4 only, IPv6 connections will not be able to connect. + /// + IPv4, + + /// + /// Instruction to use IPv6 only, IPv4 connections will not be able to connect. IPv4 addresses can be connected + /// by converting to IPv6 addresses. + /// + IPv6 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/IRecyclable.cs b/src/NextFast.Hazel-New/Next.Hazel/IRecyclable.cs new file mode 100644 index 0000000..a3f7b3c --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/IRecyclable.cs @@ -0,0 +1,23 @@ +namespace Next.Hazel; + +/// +/// Interface for all items that can be returned to an object pool. +/// +/// +public interface IRecyclable +{ + /// + /// Returns this object back to the object pool. + /// + /// + /// + /// Calling this when you are done with the object returns the object back to a pool in order to be reused. + /// This can reduce the amount of work the GC has to do dramatically but it is optional to call this. + /// + /// + /// Calling this indicates to Hazel that this can be reused and thus you should only call this when you are + /// completely finished with the object as the contents can be overwritten at any point after. + /// + /// + void Recycle(); +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/MessageReader.cs b/src/NextFast.Hazel-New/Next.Hazel/MessageReader.cs new file mode 100644 index 0000000..6ca457f --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/MessageReader.cs @@ -0,0 +1,274 @@ +#nullable enable +using System; +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.ObjectPool; +using Next.Hazel.Abstractions; + +namespace Next.Hazel; + +public class MessageReader : IMessageReader +{ + private readonly ObjectPool _pool; + private bool _inUse; + + internal MessageReader(ObjectPool pool) + { + _pool = pool; + } + + private MessageReader? _parent { get; set; } + + private int ReadPosition => Offset + Position; + + public int BytesRemaining => Length - Position; + + public IMessageReader? Parent => _parent; + + public byte[]? Buffer { get; private set; } + + public int Offset { get; internal set; } + + public int Position { get; internal set; } + + public int Length { get; internal set; } + + public byte Tag { get; private set; } + + public IMessageReader ReadMessage() + { + var length = ReadUInt16(); + var tag = FastByte(); + var pos = ReadPosition; + + Position += length; + + var reader = _pool.Get(); + reader.Update(Buffer!, pos, 0, length, tag, this); + return reader; + } + + public void RemoveMessage(IMessageReader message) + { + if (message.Buffer != Buffer) + throw new InvalidOperationException( + "Tried to remove message from a message that does not have the same buffer."); + + // Offset of where to start removing. + var offsetStart = message.Offset - 3; + + // Offset of where to end removing. + var offsetEnd = message.Offset + message.Length; + + // The amount of bytes to copy over ourselves. + var lengthToCopy = message.Buffer!.Length - offsetEnd; + + System.Buffer.BlockCopy(Buffer!, offsetEnd, Buffer!, offsetStart, lengthToCopy); + + ((MessageReader)message)._parent?.AdjustLength(message.Offset, message.Length + 3); + } + + public void Dispose() + { + if (_inUse) _pool.Return(this); + } + + public void CopyTo(IMessageWriter writer) + { + writer.Write((ushort)Length); + writer.Write(Tag); + writer.Write(Buffer.AsMemory(Offset, Length)); + } + + public IMessageReader Copy(int offset = 0) + { + var reader = _pool.Get(); + reader.Update(Buffer!, Offset + offset, Position, Length - offset, Tag, _parent); + return reader; + } + + public void Seek(int position) + { + Position = position; + } + + public void Update(byte[] buffer, int offset = 0, int position = 0, int? length = null, byte tag = byte.MaxValue, + MessageReader? parent = null) + { + _inUse = true; + + Buffer = buffer; + Offset = offset; + Position = position; + Length = length ?? buffer.Length; + Tag = tag; + _parent = parent; + } + + internal void Reset() + { + _inUse = false; + + Tag = byte.MaxValue; + Buffer = null; + Offset = 0; + Position = 0; + Length = 0; + _parent = null; + } + + /*public void InsertMessage(IMessageReader reader, IMessageWriter writer) + { + }*/ + + private void AdjustLength(int offset, int amount) + { + Length -= amount; + + if (ReadPosition > offset) Position -= amount; + + if (_parent == null || Buffer == null) return; + var lengthOffset = Offset - 3; + var curLen = Buffer[lengthOffset] | + (Buffer[lengthOffset + 1] << 8); + + curLen -= amount; + + Buffer[lengthOffset] = (byte)curLen; + Buffer[lengthOffset + 1] = (byte)(Buffer[lengthOffset + 1] >> 8); + + _parent.AdjustLength(offset, amount); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private byte FastByte() + { + return Buffer![Offset + Position++]; + } + + #region Read Methods + + public bool ReadBoolean() + { + var val = FastByte(); + return val != 0; + } + + public sbyte ReadSByte() + { + return (sbyte)FastByte(); + } + + public byte ReadByte() + { + return FastByte(); + } + + public ushort ReadUInt16() + { + var output = BinaryPrimitives.ReadUInt16LittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(ushort); + return output; + } + + public short ReadInt16() + { + var output = BinaryPrimitives.ReadInt16LittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(short); + return output; + } + + public uint ReadUInt32() + { + var output = BinaryPrimitives.ReadUInt32LittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(uint); + return output; + } + + public int ReadInt32() + { + var output = BinaryPrimitives.ReadInt32LittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(int); + return output; + } + + public ulong ReadUInt64() + { + var output = BinaryPrimitives.ReadUInt64LittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(ulong); + return output; + } + + public long ReadInt64() + { + var output = BinaryPrimitives.ReadInt64LittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(long); + return output; + } + + public float ReadSingle() + { + var output = BinaryPrimitives.ReadSingleLittleEndian(Buffer.AsSpan(ReadPosition)); + Position += sizeof(float); + return output; + } + + public string ReadString(int length) + { + var output = Encoding.UTF8.GetString(Buffer.AsSpan(ReadPosition, length)); + Position += length; + return output; + } + + public string ReadString() + { + return ReadString(ReadPackedInt32()); + } + + public ReadOnlyMemory ReadBytesAndSize() + { + var len = ReadPackedInt32(); + return ReadBytes(len); + } + + public ReadOnlyMemory ReadBytes(int length) + { + var output = Buffer.AsMemory(ReadPosition, length); + Position += length; + return output; + } + + public int ReadPackedInt32() + { + return (int)ReadPackedUInt32(); + } + + public uint ReadPackedUInt32() + { + var readMore = true; + var shift = 0; + uint output = 0; + + while (readMore) + { + var b = FastByte(); + if (b >= 0x80) + { + readMore = true; + b ^= 0x80; + } + else + { + readMore = false; + } + + output |= (uint)(b << shift); + shift += 7; + } + + return output; + } + + #endregion +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/MessageReaderPolicy.cs b/src/NextFast.Hazel-New/Next.Hazel/MessageReaderPolicy.cs new file mode 100644 index 0000000..c944adb --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/MessageReaderPolicy.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ObjectPool; + +namespace Next.Hazel; + +public class MessageReaderPolicy : IPooledObjectPolicy +{ + private readonly IServiceProvider _serviceProvider; + + public MessageReaderPolicy(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public MessageReader Create() + { + return new MessageReader(_serviceProvider.GetRequiredService>()); + } + + public bool Return(MessageReader obj) + { + obj.Reset(); + return true; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/MessageWriter.cs b/src/NextFast.Hazel-New/Next.Hazel/MessageWriter.cs new file mode 100644 index 0000000..75b3b84 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/MessageWriter.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text; +using Next.Hazel.Abstractions; + +namespace Next.Hazel; + +public class MessageWriter : IMessageWriter, IRecyclable +{ + public static int BufferSize = 64000; + private static readonly ObjectPoolCustom WriterPool = new(() => new MessageWriter(BufferSize)); + + private readonly Stack messageStarts = new(); + + public MessageWriter(byte[] buffer) + { + Buffer = buffer; + Length = Buffer.Length; + } + + public MessageWriter(int bufferSize) + { + Buffer = new byte[bufferSize]; + } + + public MessageType SendOption { get; private set; } + + public byte[] Buffer { get; } + public int Length { get; set; } + public int Position { get; set; } + + public byte[] ToByteArray(bool includeHeader) + { + if (includeHeader) + { + var output = new byte[Length]; + System.Buffer.BlockCopy(Buffer, 0, output, 0, Length); + return output; + } + + switch (SendOption) + { + case MessageType.Reliable: + { + var output = new byte[Length - 3]; + System.Buffer.BlockCopy(Buffer, 3, output, 0, Length - 3); + return output; + } + case MessageType.Unreliable: + { + var output = new byte[Length - 1]; + System.Buffer.BlockCopy(Buffer, 1, output, 0, Length - 1); + return output; + } + + default: + return []; + } + } + + public void StartMessage(byte typeFlag) + { + messageStarts.Push(Position); + Position += 2; // Skip for size + Write(typeFlag); + } + + public void EndMessage() + { + var lastMessageStart = messageStarts.Pop(); + var length = (ushort)(Position - lastMessageStart - 3); // Minus length and type byte + Buffer[lastMessageStart] = (byte)length; + Buffer[lastMessageStart + 1] = (byte)(length >> 8); + } + + public void Clear(MessageType sendOption) + { + messageStarts.Clear(); + SendOption = sendOption; + Buffer[0] = (byte)sendOption; + switch (sendOption) + { + default: + case MessageType.Unreliable: + Length = Position = 1; + break; + + case MessageType.Reliable: + Length = Position = 3; + break; + } + } + + public void Dispose() + { + Recycle(); + } + + public void Recycle() + { + Position = Length = 0; + WriterPool.PutObject(this); + } + + /// The option specifying how the message should be sent. + public static MessageWriter Get(MessageType sendOption = MessageType.Unreliable) + { + var output = WriterPool.GetObject(); + output.Clear(sendOption); + + return output; + } + + public bool HasBytes(int expected) + { + if (SendOption == MessageType.Unreliable) return Length > 1 + expected; + + return Length > 3 + expected; + } + + public void CancelMessage() + { + Position = messageStarts.Pop(); + Length = Position; + } + + #region WriteMethods + + public void Write(bool value) + { + Buffer[Position++] = (byte)(value ? 1 : 0); + if (Position > Length) Length = Position; + } + + public void Write(sbyte value) + { + Buffer[Position++] = (byte)value; + if (Position > Length) Length = Position; + } + + public void Write(byte value) + { + Buffer[Position++] = value; + if (Position > Length) Length = Position; + } + + public void Write(short value) + { + Buffer[Position++] = (byte)value; + Buffer[Position++] = (byte)(value >> 8); + if (Position > Length) Length = Position; + } + + public void Write(ushort value) + { + Buffer[Position++] = (byte)value; + Buffer[Position++] = (byte)(value >> 8); + if (Position > Length) Length = Position; + } + + public void Write(uint value) + { + Buffer[Position++] = (byte)value; + Buffer[Position++] = (byte)(value >> 8); + Buffer[Position++] = (byte)(value >> 16); + Buffer[Position++] = (byte)(value >> 24); + if (Position > Length) Length = Position; + } + + public void Write(int value) + { + Buffer[Position++] = (byte)value; + Buffer[Position++] = (byte)(value >> 8); + Buffer[Position++] = (byte)(value >> 16); + Buffer[Position++] = (byte)(value >> 24); + if (Position > Length) Length = Position; + } + + public void Write(ulong value) + { + Buffer[Position++] = (byte)value; + Buffer[Position++] = (byte)(value >> 8); + Buffer[Position++] = (byte)(value >> 16); + Buffer[Position++] = (byte)(value >> 24); + Buffer[Position++] = (byte)(value >> 32); + Buffer[Position++] = (byte)(value >> 40); + Buffer[Position++] = (byte)(value >> 48); + Buffer[Position++] = (byte)(value >> 56); + if (Position > Length) Length = Position; + } + + public void Write(long value) + { + Buffer[Position++] = (byte)value; + Buffer[Position++] = (byte)(value >> 8); + Buffer[Position++] = (byte)(value >> 16); + Buffer[Position++] = (byte)(value >> 24); + Buffer[Position++] = (byte)(value >> 32); + Buffer[Position++] = (byte)(value >> 40); + Buffer[Position++] = (byte)(value >> 48); + Buffer[Position++] = (byte)(value >> 56); + if (Position > Length) Length = Position; + } + + public unsafe void Write(float value) + { + fixed (byte* ptr = &Buffer[Position]) + { + var valuePtr = (byte*)&value; + + *ptr = *valuePtr; + *(ptr + 1) = *(valuePtr + 1); + *(ptr + 2) = *(valuePtr + 2); + *(ptr + 3) = *(valuePtr + 3); + } + + Position += 4; + if (Position > Length) Length = Position; + } + + public void Write(string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + WritePacked(bytes.Length); + Write(bytes); + } + + public void WriteBytesAndSize(byte[] bytes) + { + WritePacked((uint)bytes.Length); + Write(bytes); + } + + public void WriteBytesAndSize(byte[] bytes, int length) + { + WritePacked((uint)length); + Write(bytes, length); + } + + public void WriteBytesAndSize(byte[] bytes, int offset, int length) + { + WritePacked((uint)length); + Write(bytes, offset, length); + } + + public void Write(ReadOnlyMemory data) + { + Write(data.Span); + } + + public void Write(ReadOnlySpan bytes) + { + bytes.CopyTo(Buffer.AsSpan(Position, bytes.Length)); + + Position += bytes.Length; + if (Position > Length) Length = Position; + } + + public void Write(byte[] bytes) + { + Array.Copy(bytes, 0, Buffer, Position, bytes.Length); + Position += bytes.Length; + if (Position > Length) Length = Position; + } + + public void Write(byte[] bytes, int offset, int length) + { + Array.Copy(bytes, offset, Buffer, Position, length); + Position += length; + if (Position > Length) Length = Position; + } + + public void Write(byte[] bytes, int length) + { + Array.Copy(bytes, 0, Buffer, Position, length); + Position += length; + if (Position > Length) Length = Position; + } + + public void WritePacked(int value) + { + WritePacked((uint)value); + } + + public void WritePacked(uint value) + { + do + { + var b = (byte)(value & 0xFF); + if (value >= 0x80) b |= 0x80; + + Write(b); + value >>= 7; + } while (value > 0); + } + + public void Write(MessageWriter msg, bool includeHeader) + { + var offset = 0; + if (!includeHeader) + switch (msg.SendOption) + { + case MessageType.Unreliable: + offset = 1; + break; + case MessageType.Reliable: + offset = 3; + break; + } + + Write(msg.Buffer, offset, msg.Length - offset); + } + + public void Write(IPAddress value) + { + Write(value.GetAddressBytes()); + } + + #endregion +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/NetworkConnection.cs b/src/NextFast.Hazel-New/Next.Hazel/NetworkConnection.cs new file mode 100644 index 0000000..b1ce147 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/NetworkConnection.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; + +namespace Next.Hazel; + +public enum HazelInternalErrors +{ + SocketExceptionSend, + SocketExceptionReceive, + ReceivedZeroBytes, + PingsWithoutResponse, + ReliablePacketWithoutResponse, + ConnectionDisconnected +} + +/// +/// Abstract base class for a to a remote end point via a network protocol like TCP or UDP. +/// +/// +public abstract class NetworkConnection : Connection +{ + /// + /// An event that gives us a chance to send well-formed disconnect messages to clients when an internal disconnect + /// happens. + /// + public Func OnInternalDisconnect; + + public abstract float AveragePingMs { get; } + + public long GetIP4Address() + { + if (IPMode == IPMode.IPv4) + { +#pragma warning disable 618 + return EndPoint.Address.Address; +#pragma warning restore 618 + } + + var bytes = EndPoint.Address.GetAddressBytes(); + return BitConverter.ToInt64(bytes, bytes.Length - 8); + } + + /// + /// Sends a disconnect message to the end point. + /// + protected abstract ValueTask SendDisconnect(MessageWriter writer); + + /// + /// Called when the socket has been disconnected at the remote host. + /// + protected async ValueTask DisconnectRemote(string reason, MessageReader reader) + { + if (await SendDisconnect(null)) + try + { + await InvokeDisconnected(reason, reader); + } + catch + { + // ignored + } + + Dispose(); + } + + /// + /// Called when socket is disconnected internally + /// + internal async ValueTask DisconnectInternal(HazelInternalErrors error, string reason) + { + var handler = OnInternalDisconnect; + if (handler != null) + { + var messageToRemote = handler(error); + if (messageToRemote != null) + try + { + await Disconnect(reason, messageToRemote); + } + finally + { + messageToRemote.Recycle(); + } + else + await Disconnect(reason); + } + else + { + await Disconnect(reason); + } + } + + /// + /// Called when the socket has been disconnected locally. + /// + public override async ValueTask Disconnect(string reason, MessageWriter writer = null) + { + if (await SendDisconnect(writer)) + try + { + await InvokeDisconnected(reason, null); + } + catch + { + // ignored + } + + Dispose(); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/NetworkConnectionListener.cs b/src/NextFast.Hazel-New/Next.Hazel/NetworkConnectionListener.cs new file mode 100644 index 0000000..7473a92 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/NetworkConnectionListener.cs @@ -0,0 +1,20 @@ +using System.Net; + +namespace Next.Hazel; + +/// +/// Abstract base class for a for network based connections. +/// +/// +public abstract class NetworkConnectionListener : ConnectionListener +{ + /// + /// The local end point the listener is listening for new clients on. + /// + public IPEndPoint EndPoint { get; protected set; } + + /// + /// The IPMode the listener is listening for new clients on. + /// + public IPMode IPMode { get; protected set; } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/NewConnectionEventArgs.cs b/src/NextFast.Hazel-New/Next.Hazel/NewConnectionEventArgs.cs new file mode 100644 index 0000000..b447a7f --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/NewConnectionEventArgs.cs @@ -0,0 +1,23 @@ +using Next.Hazel.Abstractions; + +namespace Next.Hazel; + +public struct NewConnectionEventArgs +{ + /// + /// The data received from the client in the handshake. + /// This data is yours. Remember to recycle it. + /// + public readonly IMessageReader HandshakeData; + + /// + /// The to the new client. + /// + public readonly Connection Connection; + + public NewConnectionEventArgs(IMessageReader handshakeData, Connection connection) + { + HandshakeData = handshakeData; + Connection = connection; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Next.Hazel.csproj b/src/NextFast.Hazel-New/Next.Hazel/Next.Hazel.csproj new file mode 100644 index 0000000..b0e40ff --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Next.Hazel.csproj @@ -0,0 +1,30 @@ + + + + true + HAZEL_BAG + Next.Hazel + Next.Hazel + true + NextFast.Hazel + + + + + <_Parameter1>Hazel.UnitTests + + + + + + + + + + + + + + + + diff --git a/src/NextFast.Hazel-New/Next.Hazel/ObjectPoolCustom.cs b/src/NextFast.Hazel-New/Next.Hazel/ObjectPoolCustom.cs new file mode 100644 index 0000000..b332b0c --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/ObjectPoolCustom.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Next.Hazel; + +/// +/// A fairly simple object pool for items that will be created a lot. +/// +/// The type that is pooled. +/// +public sealed class ObjectPoolCustom where T : IRecyclable +{ + private int numberCreated; + public int NumberCreated => numberCreated; + + public int NumberInUse => inuse.Count; + public int NumberNotInUse => pool.Count; + public int Size => NumberInUse + NumberNotInUse; + +#if HAZEL_BAG + private readonly ConcurrentBag pool = new(); +#else + private readonly List pool = new List(); +#endif + + // Unavailable objects + private readonly ConcurrentDictionary inuse = new(); + + /// + /// The generator for creating new objects. + /// + /// + private readonly Func objectFactory; + + /// + /// Internal constructor for our ObjectPool. + /// + internal ObjectPoolCustom(Func objectFactory) + { + this.objectFactory = objectFactory; + } + + /// + /// Returns a pooled object of type T, if none are available another is created. + /// + /// An instance of T. + internal T GetObject() + { +#if HAZEL_BAG + if (!pool.TryTake(out var item)) + { + Interlocked.Increment(ref numberCreated); + item = objectFactory.Invoke(); + } +#else + T item; + lock (this.pool) + { + if (this.pool.Count > 0) + { + var idx = this.pool.Count - 1; + item = this.pool[idx]; + this.pool.RemoveAt(idx); + } + else + { + Interlocked.Increment(ref numberCreated); + item = objectFactory.Invoke(); + } + } +#endif + + if (!inuse.TryAdd(item, true)) throw new Exception("Duplicate pull " + typeof(T).Name); + + return item; + } + + /// + /// Returns an object to the pool. + /// + /// The item to return. + internal void PutObject(T item) + { + if (inuse.TryRemove(item, out var b)) + { +#if HAZEL_BAG + pool.Add(item); +#else + lock (this.pool) + { + pool.Add(item); + } +#endif + } + else + { +#if DEBUG + throw new Exception("Duplicate add " + typeof(T).Name); +#endif + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/UPnP/ILogger.cs b/src/NextFast.Hazel-New/Next.Hazel/UPnP/ILogger.cs new file mode 100644 index 0000000..3a622d6 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/UPnP/ILogger.cs @@ -0,0 +1,52 @@ +using System; + +namespace Next.Hazel.UPnP; + +public interface ILogger +{ + void WriteVerbose(string msg); + void WriteError(string msg); + void WriteInfo(string msg); +} + +public class NullLogger : ILogger +{ + public static readonly NullLogger Instance = new(); + + public void WriteVerbose(string msg) + { + } + + public void WriteError(string msg) + { + } + + public void WriteInfo(string msg) + { + } +} + +public class ConsoleLogger : ILogger +{ + private readonly bool verbose; + + public ConsoleLogger(bool verbose) + { + this.verbose = verbose; + } + + public void WriteVerbose(string msg) + { + if (verbose) Console.WriteLine($"{DateTime.Now} [VERBOSE] {msg}"); + } + + public void WriteError(string msg) + { + Console.WriteLine($"{DateTime.Now} [ERROR] {msg}"); + } + + public void WriteInfo(string msg) + { + Console.WriteLine($"{DateTime.Now} [INFO] {msg}"); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/UPnP/NetUtility.cs b/src/NextFast.Hazel-New/Next.Hazel/UPnP/NetUtility.cs new file mode 100644 index 0000000..f26ce2e --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/UPnP/NetUtility.cs @@ -0,0 +1,96 @@ +using System; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace Next.Hazel.UPnP; + +internal class NetUtility +{ + private static NetworkInterface GetNetworkInterface() + { + var computerProperties = IPGlobalProperties.GetIPGlobalProperties(); + if (computerProperties == null) + return null; + + var nics = NetworkInterface.GetAllNetworkInterfaces(); + if (nics == null || nics.Length < 1) + return null; + + NetworkInterface best = null; + foreach (var adapter in nics) + { + if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback || + adapter.NetworkInterfaceType == NetworkInterfaceType.Unknown) + continue; + if (!adapter.Supports(NetworkInterfaceComponent.IPv4)) + continue; + if (best == null) + best = adapter; + if (adapter.OperationalStatus != OperationalStatus.Up) + continue; + + // make sure this adapter has any ipv4 addresses + var properties = adapter.GetIPProperties(); + foreach (var unicastAddress in properties.UnicastAddresses) + if (unicastAddress != null && unicastAddress.Address != null && + unicastAddress.Address.AddressFamily == AddressFamily.InterNetwork) + // Yes it does, return this network interface. + return adapter; + } + + return best; + } + + /// + /// Gets my local IPv4 address (not necessarily external) and subnet mask + /// + public static IPAddress GetMyAddress(out IPAddress mask) + { + var ni = GetNetworkInterface(); + if (ni == null) + { + mask = null; + return null; + } + + var properties = ni.GetIPProperties(); + foreach (var unicastAddress in properties.UnicastAddresses) + if (unicastAddress != null && unicastAddress.Address != null && + unicastAddress.Address.AddressFamily == AddressFamily.InterNetwork) + { + mask = unicastAddress.IPv4Mask; + return unicastAddress.Address; + } + + mask = null; + return null; + } + + public static IPAddress GetBroadcastAddress() + { + var ni = GetNetworkInterface(); + if (ni == null) + return null; + + var properties = ni.GetIPProperties(); + foreach (var unicastAddress in properties.UnicastAddresses) + if (unicastAddress != null && unicastAddress.Address != null && + unicastAddress.Address.AddressFamily == AddressFamily.InterNetwork) + { + var mask = unicastAddress.IPv4Mask; + var ipAdressBytes = unicastAddress.Address.GetAddressBytes(); + var subnetMaskBytes = mask.GetAddressBytes(); + + if (ipAdressBytes.Length != subnetMaskBytes.Length) + throw new ArgumentException("Lengths of IP address and subnet mask do not match."); + + var broadcastAddress = new byte[ipAdressBytes.Length]; + for (var i = 0; i < broadcastAddress.Length; i++) + broadcastAddress[i] = (byte)(ipAdressBytes[i] | (subnetMaskBytes[i] ^ 255)); + return new IPAddress(broadcastAddress); + } + + return IPAddress.Broadcast; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/UPnP/UPnPHelper.cs b/src/NextFast.Hazel-New/Next.Hazel/UPnP/UPnPHelper.cs new file mode 100644 index 0000000..513b314 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/UPnP/UPnPHelper.cs @@ -0,0 +1,352 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Xml; + +namespace Next.Hazel.UPnP; + +/// +/// Status of the UPnP capabilities +/// +public enum UPnPStatus +{ + /// + /// Still discovering UPnP capabilities + /// + Discovering, + + /// + /// UPnP is not available + /// + NotAvailable, + + /// + /// UPnP is available and ready to use + /// + Available +} + +public class UPnPHelper : IDisposable +{ + private const int DiscoveryTimeOutMs = 1000; + private readonly byte[] buffer; + + private readonly ManualResetEvent discoveryComplete = new(false); + + private readonly ILogger logger; + private readonly Socket socket; + + private DateTime discoveryResponseDeadline; + + private EndPoint ep; + private string serviceName = ""; + + private string serviceUrl; + + public UPnPHelper(ILogger logger) + { + this.logger = logger; + + socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.EnableBroadcast = true; + socket.MulticastLoopback = false; + + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); + socket.Bind(new IPEndPoint(IPAddress.Any, 0)); + + ep = new IPEndPoint(IPAddress.Any, 1900); + buffer = new byte[ushort.MaxValue]; + + ListenForUPnP(); + + Discover(); + } + + /// + /// Status of the UPnP capabilities of this NetPeer + /// + public UPnPStatus Status { get; private set; } + + public void Dispose() + { + discoveryComplete.Dispose(); + try + { + socket.Shutdown(SocketShutdown.Both); + } + catch + { + // ignored + } + + socket.Dispose(); + } + + private void ListenForUPnP() + { + try + { + socket.BeginReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref ep, HandleMessage, null); + } + catch (Exception e) + { + logger.WriteInfo("Exception listening for UPnP: " + e.Message); + } + } + + private void HandleMessage(IAsyncResult ar) + { + int len; + try + { + len = socket.EndReceiveFrom(ar, ref ep); + } + catch + { + return; + } + + var resp = Encoding.UTF8.GetString(buffer, 0, len); + if (resp.Contains("upnp:rootdevice") || resp.Contains("UPnP/1.0")) + { + var locationStart = resp.IndexOf("location:", StringComparison.OrdinalIgnoreCase); + if (locationStart >= 0) + { + locationStart += 10; + var locationEnd = resp.IndexOf('\r', locationStart); + + resp = resp.Substring(locationStart, locationEnd - locationStart); + if (!ExtractServiceUrl(resp)) ListenForUPnP(); + } + else + { + ListenForUPnP(); + } + } + else + { + ListenForUPnP(); + } + } + + internal void Discover() + { + var str = + "M-SEARCH * HTTP/1.1\r\n" + + "HOST: 239.255.255.250:1900\r\n" + + "ST:upnp:rootdevice\r\n" + + "MAN:\"ssdp:discover\"\r\n" + + "MX:3\r\n\r\n"; + + discoveryResponseDeadline = DateTime.UtcNow.AddSeconds(6); + Status = UPnPStatus.Discovering; + + var bytes = Encoding.UTF8.GetBytes(str); + + logger.WriteInfo("Attempting UPnP discovery"); + + socket.SendTo(bytes, new IPEndPoint(NetUtility.GetBroadcastAddress(), 1900)); + } + + internal bool ExtractServiceUrl(string resp) + { + try + { + var desc = new XmlDocument(); + using var client = new HttpClient(); + using var response = client.GetStreamAsync(resp).Result; + desc.Load(response); + + var nsMgr = new XmlNamespaceManager(desc.NameTable); + nsMgr.AddNamespace("tns", "urn:schemas-upnp-org:device-1-0"); + var typeNode = desc.SelectSingleNode("//tns:device/tns:deviceType/text()", nsMgr); + if (typeNode?.Value == null) + return false; + + if (!typeNode.Value.Contains("InternetGatewayDevice")) + return false; + + serviceName = "WANIPConnection"; + var node = desc.SelectSingleNode( + "//tns:service[tns:serviceType=\"urn:schemas-upnp-org:service:" + serviceName + + ":1\"]/tns:controlURL/text()", nsMgr); + if (node == null) + { + //try another service name + serviceName = "WANPPPConnection"; + node = desc.SelectSingleNode( + "//tns:service[tns:serviceType=\"urn:schemas-upnp-org:service:" + serviceName + + ":1\"]/tns:controlURL/text()", nsMgr); + if (node == null) + return false; + } + + serviceUrl = CombineUrls(resp, node.Value); + logger.WriteInfo("UPnP service ready"); + Status = UPnPStatus.Available; + discoveryComplete.Set(); + return true; + } + catch (Exception e) + { + logger.WriteError("Exception while parsing UPnP Service URL: " + e.Message); + return false; + } + } + + private static string CombineUrls(string gatewayURL, string subURL) + { + // Is Control URL an absolute URL? + if (subURL.Contains("http:") || subURL.Contains(".")) + return subURL; + + gatewayURL = gatewayURL.Replace("http://", ""); // strip any protocol + var n = gatewayURL.IndexOf("/", StringComparison.Ordinal); + if (n >= 0) gatewayURL = gatewayURL[..n]; // Use first portion of URL + + return "http://" + gatewayURL + subURL; + } + + private bool CheckAvailability() + { + switch (Status) + { + case UPnPStatus.NotAvailable: + break; + case UPnPStatus.Available: + return true; + case UPnPStatus.Discovering: + while (!discoveryComplete.WaitOne(DiscoveryTimeOutMs)) + if (DateTime.UtcNow > discoveryResponseDeadline) + { + Status = UPnPStatus.NotAvailable; + return false; + } + + return true; + } + + return false; + } + + /// + /// Add a forwarding rule to the router using UPnP + /// + /// The external, WAN facing, port + /// A description for the port forwarding rule + /// The port on the client machine to send traffic to + /// The lease duration on the port forwarding rule, in seconds. 0 for indefinite. + public bool ForwardPort(int externalPort, string description, int internalPort = 0, int durationSeconds = 0) + { + if (!CheckAvailability()) + return false; + + if (internalPort == 0) + internalPort = externalPort; + + try + { + var client = NetUtility.GetMyAddress(out _); + if (client == null) + return false; + + SOAPRequest(serviceUrl, + $"" + + "" + + $"{externalPort}" + + "UDP" + + $"{internalPort}" + + $"{client}" + + "1" + + $"{description}" + + $"{durationSeconds}" + + "", + "AddPortMapping"); + + logger.WriteInfo("Sent UPnP port forward request."); + return true; + } + catch (Exception ex) + { + logger.WriteError("UPnP port forward failed: " + ex.Message); + return false; + } + } + + /// + /// Delete a forwarding rule from the router using UPnP + /// + /// The external, 'internet facing', port + public bool DeleteForwardingRule(int externalPort) + { + if (!CheckAvailability()) + return false; + + try + { + SOAPRequest(serviceUrl, + $"" + + "" + + $"{externalPort}" + + "UDP" + + "", "DeletePortMapping"); + return true; + } + catch (Exception) + { + // m_peer.LogWarning("UPnP delete forwarding rule failed: " + ex.Message); + return false; + } + } + + /// + /// Retrieve the extern ip using UPnP + /// + public IPAddress GetExternalIP() + { + if (!CheckAvailability()) + return null; + try + { + var xdoc = SOAPRequest(serviceUrl, "" + + "", "GetExternalIPAddress"); + var nsMgr = new XmlNamespaceManager(xdoc.NameTable); + nsMgr.AddNamespace("tns", "urn:schemas-upnp-org:device-1-0"); + var IP = xdoc.SelectSingleNode("//NewExternalIPAddress/text()", nsMgr).Value; + return IPAddress.Parse(IP); + } + catch (Exception) + { + // m_peer.LogWarning("Failed to get external IP: " + ex.Message); + return null; + } + } + + private XmlDocument SOAPRequest(string url, string soap, string function) + { + var req = + "" + + "" + + $"{soap}" + + ""; + + var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(req, Encoding.UTF8, "text/xml") + }; + request.Headers.Add("SOAPACTION", $"\"urn:schemas-upnp-org:service:{serviceName}:1#{function}\""); + + using var client = new HttpClient(); + + using var response = client.SendAsync(request).Result; + + var xmlDoc = new XmlDocument(); + xmlDoc.Load(response.Content.ReadAsStream()); + + return xmlDoc; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/SendOptionInternal.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/SendOptionInternal.cs new file mode 100644 index 0000000..c809144 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/SendOptionInternal.cs @@ -0,0 +1,32 @@ +namespace Next.Hazel.Udp; + +/// +/// Extra internal states for SendOption enumeration when using UDP. +/// +public enum UdpSendOption : byte +{ + /// + /// Hello message for initiating communication. + /// + Hello = 8, + + /// + /// A single byte of continued existence + /// + Ping = 12, + + /// + /// Message for discontinuing communication. + /// + Disconnect = 9, + + /// + /// Message acknowledging the receipt of a message. + /// + Acknowledgement = 10, + + /// + /// Message that is part of a larger, fragmented message. + /// + Fragment = 11 +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcastListener.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcastListener.cs new file mode 100644 index 0000000..9214db3 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcastListener.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Next.Hazel.Udp; + +public class BroadcastPacket +{ + public string Data; + public DateTime ReceiveTime; + public IPEndPoint Sender; + + public BroadcastPacket(string data, IPEndPoint sender) + { + Data = data; + Sender = sender; + ReceiveTime = DateTime.Now; + } + + public string GetAddress() + { + return Sender.Address.ToString(); + } +} + +public class UdpBroadcastListener : IDisposable +{ + private readonly byte[] buffer = new byte[1024]; + private readonly Action logger; + + private readonly List packets = new(); + private Socket socket; + + /// + public UdpBroadcastListener(int port, Action logger = null) + { + this.logger = logger; + socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.EnableBroadcast = true; + socket.MulticastLoopback = false; + EndPoint endpoint1 = new IPEndPoint(IPAddress.Any, port); + socket.Bind(endpoint1); + } + + public bool Running { get; private set; } + + /// + public void Dispose() + { + if (socket == null) return; + try + { + socket.Shutdown(SocketShutdown.Both); + socket.Close(); + socket.Dispose(); + } + catch (Exception e) + { + logger.Invoke(e.ToString()); + } + + socket = null; + } + + /// + public void StartListen() + { + if (Running) return; + Running = true; + + try + { + EndPoint endpt = new IPEndPoint(IPAddress.Any, 0); + socket.BeginReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref endpt, HandleData, null); + } + catch (NullReferenceException) + { + } + catch (Exception e) + { + logger?.Invoke("BroadcastListener: " + e); + Dispose(); + } + } + + private void HandleData(IAsyncResult result) + { + Running = false; + + int numBytes; + EndPoint endpt = new IPEndPoint(IPAddress.Any, 0); + try + { + numBytes = socket.EndReceiveFrom(result, ref endpt); + } + catch (NullReferenceException) + { + // Already disposed + return; + } + catch (Exception e) + { + logger?.Invoke("BroadcastListener: " + e); + Dispose(); + return; + } + + if (numBytes < 3 + || buffer[0] != 4 || buffer[1] != 2) + { + StartListen(); + return; + } + + var ipEnd = (IPEndPoint)endpt; + var data = Encoding.UTF8.GetString(buffer, 2, numBytes - 2); + var dataHash = data.GetHashCode(); + + lock (packets) + { + for (var i = 0; i < packets.Count; ++i) + { + var pkt = packets[i]; + if (pkt == null || pkt.Data == null) + { + packets.RemoveAt(i); + i--; + continue; + } + + if (pkt.Data.GetHashCode() != dataHash + || !pkt.Sender.Equals(ipEnd)) continue; + packets[i].ReceiveTime = DateTime.Now; + break; + } + + packets.Add(new BroadcastPacket(data, ipEnd)); + } + + StartListen(); + } + + /// + public BroadcastPacket[] GetPackets() + { + lock (packets) + { + var output = packets.ToArray(); + packets.Clear(); + return output; + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcaster.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcaster.cs new file mode 100644 index 0000000..891d456 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpBroadcaster.cs @@ -0,0 +1,81 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Next.Hazel.Udp; + +/// +public class UdpBroadcaster : IDisposable +{ + private readonly EndPoint endpoint; + private readonly Action logger; + private byte[] data; + private Socket socket; + + /// + public UdpBroadcaster(int port, Action logger = null) + { + this.logger = logger; + socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.EnableBroadcast = true; + socket.MulticastLoopback = false; + endpoint = new IPEndPoint(IPAddress.Broadcast, port); + } + + /// + public void Dispose() + { + if (socket == null) return; + try + { + socket.Shutdown(SocketShutdown.Both); + socket.Close(); + socket.Dispose(); + } + catch + { + // ignored + } + + socket = null; + } + + /// + public void SetData(string dataValue) + { + var len = Encoding.UTF8.GetByteCount(dataValue); + data = new byte[len + 2]; + data[0] = 4; + data[1] = 2; + + Encoding.UTF8.GetBytes(dataValue, 0, dataValue.Length, data, 2); + } + + /// + public void Broadcast() + { + if (data == null) return; + + try + { + socket.BeginSendTo(data, 0, data.Length, SocketFlags.None, endpoint, FinishSendTo, null); + } + catch (Exception e) + { + logger?.Invoke("BroadcastListener: " + e); + } + } + + private void FinishSendTo(IAsyncResult evt) + { + try + { + socket.EndSendTo(evt); + } + catch (Exception e) + { + logger?.Invoke("BroadcastListener: " + e); + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpClientConnection.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpClientConnection.cs new file mode 100644 index 0000000..951b2e1 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpClientConnection.cs @@ -0,0 +1,244 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.ObjectPool; +using Next.Hazel.Abstractions; + +namespace Next.Hazel.Udp; + +/// +/// Represents a client's connection to a server that uses the UDP protocol. +/// +/// +public class UdpClientConnection : UdpConnection +{ + /// + /// Reset event that is triggered when the connection is marked Connected. + /// + private readonly SemaphoreSlim _connectWaitLock; + + /// + /// The socket we're connected via. + /// + private readonly UdpClient _socket; + + private readonly Timer reliablePacketTimer; + + private Task _listenTask; + + /// + /// Creates a new UdpClientConnection. + /// + public UdpClientConnection(IPEndPoint remoteEndPoint, ObjectPool readerPool, + IPMode ipMode = IPMode.IPv4) : base(null, readerPool) + { + EndPoint = remoteEndPoint; + IPMode = ipMode; + + _socket = new UdpClient + { + DontFragment = false + }; + + reliablePacketTimer = new Timer(ManageReliablePacketsInternal, null, 100, Timeout.Infinite); + _connectWaitLock = new SemaphoreSlim(0, 1); + + InitializeKeepAliveTimer(); + } + + ~UdpClientConnection() + { + Dispose(false); + } + + private async void ManageReliablePacketsInternal(object state) + { + try + { + await ManageReliablePackets(); + reliablePacketTimer.Change(100, Timeout.Infinite); + } + catch + { + // ignored + } + } + + protected virtual async ValueTask ResendPacketsIfNeeded() + { + await ManageReliablePackets(); + } + + /// + protected override ValueTask WriteBytesToConnection(byte[] bytes, int length) + { + return WriteBytesToConnectionReal(bytes, length); + } + + private async ValueTask WriteBytesToConnectionReal(byte[] bytes, int length) + { + try + { + await _socket.SendAsync(bytes, length); + } + catch (NullReferenceException) + { + } + catch (ObjectDisposedException) + { + // Already disposed and disconnected... + } + catch (SocketException ex) + { + await DisconnectInternal(HazelInternalErrors.SocketExceptionSend, + "Could not send data as a SocketException occurred: " + ex.Message); + } + } + + /// + public override async ValueTask ConnectAsync(byte[] bytes = null, int timeout = 5000) + { + State = ConnectionState.Connecting; + + try + { + _socket.Connect(EndPoint); + } + catch (SocketException e) + { + State = ConnectionState.NotConnected; + throw new HazelException("A SocketException occurred while binding to the port.", e); + } + + RestartConnection(); + + try + { + _listenTask = Task.Factory.StartNew(ListenAsync, TaskCreationOptions.LongRunning); + } + catch (ObjectDisposedException) + { + // If the socket's been disposed then we can just end there but make sure we're in NotConnected state. + // If we end up here I'm really lost... + State = ConnectionState.NotConnected; + return; + } + catch (SocketException e) + { + Dispose(); + throw new HazelException("A SocketException occurred while initiating a receive operation.", e); + } + + // Write bytes to the server to tell it hi (and to punch a hole in our NAT, if present) + // When acknowledged set the state to connected + await SendHello(bytes, () => + { + State = ConnectionState.Connected; + InitializeKeepAliveTimer(); + }); + + await _connectWaitLock.WaitAsync(TimeSpan.FromSeconds(10)); + } + + protected virtual void RestartConnection() + { + } + + private async Task ListenAsync() + { + // Start packet handler. + await StartAsync(); + + // Listen. + while (State != ConnectionState.NotConnected) + { + UdpReceiveResult data; + + try + { + data = await _socket.ReceiveAsync(); + } + catch (SocketException e) + { + await DisconnectInternal(HazelInternalErrors.SocketExceptionReceive, + "Socket exception while reading data: " + e.Message); + return; + } + catch (Exception) + { + return; + } + + if (data.Buffer.Length == 0) + { + await DisconnectInternal(HazelInternalErrors.ReceivedZeroBytes, "Received 0 bytes"); + return; + } + + // Write to client. + await Pipeline.Writer.WriteAsync(data.Buffer); + } + } + + protected override void SetState(ConnectionState state) + { + if (state == ConnectionState.Connected) _connectWaitLock.Release(); + } + + /// + /// Sends a disconnect message to the end point. + /// You may include optional disconnect data. The SendOption must be unreliable. + /// + protected override async ValueTask SendDisconnect(MessageWriter data) + { + lock (this) + { + if (_state == ConnectionState.NotConnected) return false; + _state = ConnectionState.NotConnected; + } + + var bytes = EmptyDisconnectBytes; + if (data != null && data.Length > 0) + { + if (data.SendOption != MessageType.Unreliable) + throw new ArgumentException("Disconnect messages can only be unreliable."); + + bytes = data.ToByteArray(true); + bytes[0] = (byte)UdpSendOption.Disconnect; + } + + try + { + await _socket.SendAsync(bytes, bytes.Length, EndPoint); + } + catch + { + // ignored + } + + return true; + } + + /// + protected override void Dispose(bool disposing) + { + State = ConnectionState.NotConnected; + + try + { + _socket.Close(); + _socket.Dispose(); + } + catch + { + // ignored + } + + reliablePacketTimer.Dispose(); + _connectWaitLock.Dispose(); + + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.KeepAlive.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.KeepAlive.cs new file mode 100644 index 0000000..8f713e7 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.KeepAlive.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Next.Hazel.Udp; + +partial class UdpConnection +{ + internal ConcurrentDictionary activePingPackets = new(); + private int keepAliveInterval = 1500; + + /// + /// The timer creating keepalive pulses. + /// + private Timer keepAliveTimer; + + private volatile int pingsSinceAck; + + /// + /// The interval from data being received or transmitted to a keepalive packet being sent in milliseconds. + /// + /// + /// + /// Keepalive packets serve to close connections when an endpoint abruptly disconnects and to ensure than any + /// NAT devices do not close their translation for our argument. By ensuring there is regular contact the + /// connection can detect and prevent these issues. + /// + /// + /// The default value is 10 seconds, set to System.Threading.Timeout.Infinite to disable keepalive packets. + /// + /// + public int KeepAliveInterval + { + get => keepAliveInterval; + + set + { + keepAliveInterval = value; + ResetKeepAliveTimer(); + } + } + + public int MissingPingsUntilDisconnect { get; set; } = 6; + + /// + /// Starts the keepalive timer. + /// + protected void InitializeKeepAliveTimer() + { + keepAliveTimer = new Timer( + HandleKeepAlive, + null, + keepAliveInterval, + keepAliveInterval + ); + } + + private async void HandleKeepAlive(object state) + { + if (State != ConnectionState.Connected) return; + + if (pingsSinceAck >= MissingPingsUntilDisconnect) + { + DisposeKeepAliveTimer(); + await DisconnectInternal(HazelInternalErrors.PingsWithoutResponse, + $"Sent {pingsSinceAck} pings that remote has not responded to."); + return; + } + + try + { + Interlocked.Increment(ref pingsSinceAck); + await SendPing(); + } + catch + { + // ignored + } + } + + // Pings are special, quasi-reliable packets. + // We send them to trigger responses that validate our connection is alive + // An unacked ping should never be the sole cause of a disconnect. + // Rather, the responses will reset our pingsSinceAck, enough unacked + // pings should cause a disconnect. + private async ValueTask SendPing() + { + var id = (ushort)Interlocked.Increment(ref lastIDAllocated); + + var bytes = new byte[3]; + bytes[0] = (byte)UdpSendOption.Ping; + bytes[1] = (byte)(id >> 8); + bytes[2] = (byte)id; + + PingPacket pkt; + if (!activePingPackets.TryGetValue(id, out pkt)) + { + pkt = PingPacket.GetObject(); + if (!activePingPackets.TryAdd(id, pkt)) throw new Exception("This shouldn't be possible"); + } + + pkt.Stopwatch.Restart(); + + await WriteBytesToConnection(bytes, bytes.Length); + + Statistics.LogReliableSend(0, bytes.Length); + } + + /// + /// Resets the keepalive timer to zero. + /// + private void ResetKeepAliveTimer() + { + try + { + keepAliveTimer.Change(keepAliveInterval, keepAliveInterval); + } + catch + { + // ignored + } + } + + /// + /// Disposes of the keep alive timer. + /// + private void DisposeKeepAliveTimer() + { + if (keepAliveTimer != null) keepAliveTimer.Dispose(); + + foreach (var kvp in activePingPackets) + if (activePingPackets.TryRemove(kvp.Key, out var pkt)) + pkt.Recycle(); + } + + /// + /// Class to hold packet data + /// + public class PingPacket : IRecyclable + { + private static readonly ObjectPoolCustom PacketPool = new(() => new PingPacket()); + + public readonly Stopwatch Stopwatch = new(); + + public void Recycle() + { + Stopwatch.Stop(); + PacketPool.PutObject(this); + } + + internal static PingPacket GetObject() + { + return PacketPool.GetObject(); + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.Reliable.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.Reliable.cs new file mode 100644 index 0000000..b0c07c4 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.Reliable.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Next.Hazel.Abstractions; + +namespace Next.Hazel.Udp; + +partial class UdpConnection +{ + private readonly object PingLock = new(); + + /// + /// Packet ids that have not been received, but are expected. + /// + private readonly HashSet reliableDataPacketsMissing = new(); + + /// + /// Returns the average ping to this endpoint. + /// + /// + /// This returns the average ping for a one-way trip as calculated from the reliable packets that have been sent + /// and acknowledged by the endpoint. + /// + private float _pingMs = 500; + + /// + /// The maximum times a message should be resent before marking the endpoint as disconnected. + /// + /// + /// Reliable packets will be resent at an interval defined in for the number of times + /// specified here. Once a packet has been retransmitted this number of times and has not been acknowledged the + /// connection will be marked as disconnected and the Disconnected event + /// will be invoked. + /// + public volatile int DisconnectTimeout = 5000; + + /// + /// Holds the last ID allocated. + /// + private int lastIDAllocated; + + /// + /// The packets of data that have been transmitted reliably and not acknowledged. + /// + internal ConcurrentDictionary reliableDataPacketsSent = new(); + + /// + /// The packet id that was received last. + /// + protected volatile ushort reliableReceiveLast = ushort.MaxValue; + + /// + /// Max number of times to resend. 0 == no limit + /// + public volatile int ResendLimit = 0; + + /// + /// A compounding multiplier to back off resend timeout. + /// Applied to ping before first timeout when ResendTimeout == 0. + /// + public volatile float ResendPingMultiplier = 2; + + /// + /// The starting timeout, in miliseconds, at which data will be resent. + /// + /// + /// + /// For reliable delivery data is resent at specified intervals unless an acknowledgement is received from the + /// receiving device. The ResendTimeout specifies the interval between the packets being resent, each time a packet + /// is resent the interval is increased for that packet until the duration exceeds the + /// value. + /// + /// + /// Setting this to its default of 0 will mean the timeout is 2 times the value of the average ping, usually + /// resulting in a more dynamic resend that responds to endpoints on slower or faster connections. + /// + /// + public volatile int ResendTimeout = 0; + + internal async ValueTask ManageReliablePackets() + { + var output = 0; + if (reliableDataPacketsSent.IsEmpty) return output; + foreach (var kvp in reliableDataPacketsSent) + { + var pkt = kvp.Value; + + try + { + output += await pkt.Resend(); + } + catch + { + // ignored + } + } + + return output; + } + + /// + /// Adds a 2 byte ID to the packet at offset and stores the packet reference for retransmission. + /// + /// The buffer to attach to. + /// The offset to attach at. + /// The callback to make once the packet has been acknowledged. + protected void AttachReliableID(byte[] buffer, int offset, Action ackCallback = null) + { + var id = (ushort)Interlocked.Increment(ref lastIDAllocated); + + buffer[offset] = (byte)(id >> 8); + buffer[offset + 1] = (byte)id; + + var packet = Packet.GetObject(); + lock (PingLock) + { + packet.Set( + id, + this, + buffer, + buffer.Length, + ResendTimeout > 0 ? ResendTimeout : (int)Math.Min(_pingMs * ResendPingMultiplier, 300), + ackCallback); + } + + if (!reliableDataPacketsSent.TryAdd(id, packet)) throw new Exception("That shouldn't be possible"); + } + + public static int ClampToInt(float value, int min, int max) + { + if (value < min) return min; + if (value > max) return max; + return (int)value; + } + + /// + /// Sends the bytes reliably and stores the send. + /// + /// + /// The byte array to write to. + /// The callback to make once the packet has been acknowledged. + private async ValueTask ReliableSend(byte sendOption, byte[] data, Action ackCallback = null) + { + //Inform keepalive not to send for a while + ResetKeepAliveTimer(); + + var bytes = new byte[data.Length + 3]; + + //Add message type + bytes[0] = sendOption; + + //Add reliable ID + AttachReliableID(bytes, 1, ackCallback); + + //Copy data into new array + Buffer.BlockCopy(data, 0, bytes, bytes.Length - data.Length, data.Length); + + //Write to connection + await WriteBytesToConnection(bytes, bytes.Length); + + Statistics.LogReliableSend(data.Length, bytes.Length); + } + + /// + /// Handles a reliable message being received and invokes the data event. + /// + /// The buffer received. + /// + private async ValueTask ReliableMessageReceive(MessageReader message, int bytesReceived) + { + if (await ProcessReliableReceive(message.Buffer, 1)) + await InvokeDataReceived(MessageType.Reliable, message, 3, bytesReceived); + + Statistics.LogReliableReceive(message.Length - 3, message.Length); + } + + /// + /// Handles receives from reliable packets. + /// + /// The buffer containing the data. + /// The offset of the reliable header. + /// Whether the packet was a new packet or not. + private async ValueTask ProcessReliableReceive(ReadOnlyMemory bytes, int offset) + { + var b1 = bytes.Span[offset]; + var b2 = bytes.Span[offset + 1]; + + //Get the ID form the packet + var id = (ushort)((b1 << 8) + b2); + + /* + * It gets a little complicated here (note the fact I'm actually using a multiline comment for once...) + * + * In a simple world if our data is greater than the last reliable packet received (reliableReceiveLast) + * then it is guaranteed to be a new packet, if it's not we can see if we are missing that packet (lookup + * in reliableDataPacketsMissing). + * + * --------rrl############# (1) + * + * (where --- are packets received already and #### are packets that will be counted as new) + * + * Unfortunately if id becomes greater than 65535 it will loop back to zero so we will add a pointer that + * specifies any packets with an id behind it are also new (overwritePointer). + * + * ####op----------rrl##### (2) + * + * ------rll#########op---- (3) + * + * Anything behind than the reliableReceiveLast pointer (but greater than the overwritePointer is either a + * missing packet or something we've already received so when we change the pointers we need to make sure + * we keep note of what hasn't been received yet (reliableDataPacketsMissing). + * + * So... + */ + + var result = true; + + lock (reliableDataPacketsMissing) + { + //Calculate overwritePointer + var overwritePointer = (ushort)(reliableReceiveLast - 32768); + + //Calculate if it is a new packet by examining if it is within the range + bool isNew; + if (overwritePointer < reliableReceiveLast) + isNew = id > reliableReceiveLast || id <= overwritePointer; //Figure (2) + else + isNew = id > reliableReceiveLast && id <= overwritePointer; //Figure (3) + + //If it's new or we've not received anything yet + if (isNew) + { + // Mark items between the most recent receive and the id received as missing + if (id > reliableReceiveLast) + { + for (var i = (ushort)(reliableReceiveLast + 1); i < id; i++) reliableDataPacketsMissing.Add(i); + } + else + { + var cnt = ushort.MaxValue - reliableReceiveLast + id; + for (ushort i = 1; i <= cnt; ++i) reliableDataPacketsMissing.Add((ushort)(i + reliableReceiveLast)); + } + + //Update the most recently received + reliableReceiveLast = id; + } + + //Else it could be a missing packet + else + { + //See if we're missing it, else this packet is a duplicate as so we return false + if (!reliableDataPacketsMissing.Remove(id)) result = false; + } + } + + // Send an acknowledgement + await SendAck(id); + + return result; + } + + /// + /// Handles acknowledgement packets to us. + /// + /// The buffer containing the data. + /// + private void AcknowledgementMessageReceive(byte[] bytes, int bytesReceived) + { + pingsSinceAck = 0; + + var id = (ushort)((bytes[1] << 8) + bytes[2]); + AcknowledgeMessageId(id); + + if (bytesReceived == 4) + { + var recentPackets = bytes[3]; + for (var i = 1; i <= 8; ++i) + { + if ((recentPackets & 1) != 0) AcknowledgeMessageId((ushort)(id - i)); + + recentPackets >>= 1; + } + } + + Statistics.LogReliableReceive(0, bytesReceived); + } + + private void AcknowledgeMessageId(ushort id) + { + // Dispose of timer and remove from dictionary + if (reliableDataPacketsSent.TryRemove(id, out var packet)) + { + float rt = packet.Stopwatch.ElapsedMilliseconds; + + packet.AckCallback?.Invoke(); + packet.Recycle(); + + lock (PingLock) + { + _pingMs = Math.Max(50, _pingMs * .7f + rt * .3f); + } + } + else if (activePingPackets.TryRemove(id, out var pingPkt)) + { + float rt = pingPkt.Stopwatch.ElapsedMilliseconds; + + pingPkt.Recycle(); + + lock (PingLock) + { + _pingMs = Math.Max(50, _pingMs * .7f + rt * .3f); + } + } + } + + /// + /// Sends an acknowledgement for a packet given its identification bytes. + /// + private async ValueTask SendAck(ushort id) + { + byte recentPackets = 0; + lock (reliableDataPacketsMissing) + { + for (var i = 1; i <= 8; ++i) + if (!reliableDataPacketsMissing.Contains((ushort)(id - i))) + recentPackets |= (byte)(1 << (i - 1)); + } + + var bytes = new[] + { + (byte)UdpSendOption.Acknowledgement, + (byte)(id >> 8), + (byte)(id >> 0), + recentPackets + }; + + try + { + await WriteBytesToConnection(bytes, bytes.Length); + } + catch (InvalidOperationException) + { + } + } + + private void DisposeReliablePackets() + { + foreach (var kvp in reliableDataPacketsSent) + if (reliableDataPacketsSent.TryRemove(kvp.Key, out var pkt)) + pkt.Recycle(); + } + + /// + /// Class to hold packet data + /// + public class Packet : IRecyclable + { + /// + /// Object pool for this event. + /// + public static readonly ObjectPoolCustom PacketPool = new(() => new Packet()); + + public Action AckCallback; + public volatile bool Acknowledged; + private UdpConnection Connection; + private byte[] Data; + + public ushort Id; + private int Length; + + public int NextTimeout; + + public int Retransmissions; + public Stopwatch Stopwatch = new(); + + private Packet() + { + } + + /// + /// Returns this object back to the object pool from whence it came. + /// + public void Recycle() + { + Acknowledged = true; + Connection = null; + + PacketPool.PutObject(this); + } + + /// + /// Returns an instance of this object from the pool. + /// + /// + internal static Packet GetObject() + { + return PacketPool.GetObject(); + } + + internal void Set(ushort id, UdpConnection connection, byte[] data, int length, int timeout, Action ackCallback) + { + Id = id; + Data = data; + Connection = connection; + Length = length; + + Acknowledged = false; + NextTimeout = timeout; + AckCallback = ackCallback; + Retransmissions = 0; + + Stopwatch.Restart(); + } + + // Packets resent + public async ValueTask Resend() + { + var connection = Connection; + if (!Acknowledged && connection != null) + { + var lifetime = Stopwatch.ElapsedMilliseconds; + if (lifetime >= connection.DisconnectTimeout) + { + if (connection.reliableDataPacketsSent.TryRemove(Id, out var self)) + { + await connection.DisconnectInternal(HazelInternalErrors.ReliablePacketWithoutResponse, + $"Reliable packet {self.Id} (size={Length}) was not ack'd after {lifetime}ms ({self.Retransmissions} resends)"); + + self.Recycle(); + } + + return 0; + } + + if (lifetime >= NextTimeout) + { + ++Retransmissions; + if (connection.ResendLimit != 0 + && Retransmissions > connection.ResendLimit) + { + if (connection.reliableDataPacketsSent.TryRemove(Id, out var self)) + { + await connection.DisconnectInternal(HazelInternalErrors.ReliablePacketWithoutResponse, + $"Reliable packet {self.Id} (size={Length}) was not ack'd after {self.Retransmissions} resends ({lifetime}ms)"); + + self.Recycle(); + } + + return 0; + } + + NextTimeout += (int)Math.Min(NextTimeout * connection.ResendPingMultiplier, 1000); + try + { + await connection.WriteBytesToConnection(Data, Length); + connection.Statistics.LogMessageResent(); + return 1; + } + catch (InvalidOperationException) + { + await connection.DisconnectInternal(HazelInternalErrors.ConnectionDisconnected, + "Could not resend data as connection is no longer connected"); + } + } + } + + return 0; + } + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs new file mode 100644 index 0000000..4f9b12b --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs @@ -0,0 +1,316 @@ +using System; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.ObjectPool; +using Next.Hazel.Abstractions; +using Serilog; + +namespace Next.Hazel.Udp; + +/// +/// Represents a connection that uses the UDP protocol. +/// +/// +public abstract partial class UdpConnection : NetworkConnection +{ + private const int SioUdpConnectionReset = -1744830452; + private static readonly ILogger Logger = Log.ForContext(); + + public static readonly byte[] EmptyDisconnectBytes = new[] { (byte)UdpSendOption.Disconnect }; + + private readonly ConnectionListener _listener; + protected readonly ObjectPool _readerPool; + private readonly CancellationTokenSource _stoppingCts; + private Task _executingTask; + + private bool _isDisposing; + private bool _isFirst = true; + + protected UdpConnection(ConnectionListener listener, ObjectPool readerPool) + { + _listener = listener; + _readerPool = readerPool; + _stoppingCts = new CancellationTokenSource(); + + Pipeline = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true + }); + } + + public override float AveragePingMs => _pingMs; + + internal Channel Pipeline { get; } + + public Task StartAsync() + { + // Store the task we're executing + _executingTask = Task.Factory.StartNew(ReadAsync, TaskCreationOptions.LongRunning); + + // If the task is completed then return it, this will bubble cancellation and failure to the caller + if (_executingTask.IsCompleted) return _executingTask; + + // Otherwise it's running + return Task.CompletedTask; + } + + public void Stop() + { + // Stop called without start + if (_executingTask == null) return; + + // Signal cancellation to methods. + _stoppingCts.Cancel(); + + try + { + // Cancel reader. + Pipeline.Writer.Complete(); + } + catch (ChannelClosedException) + { + // Already done. + } + + // Remove references. + if (!_isDisposing) Dispose(true); + } + + private async Task ReadAsync() + { + var reader = new MessageReader(_readerPool); + + while (!_stoppingCts.IsCancellationRequested) + { + var result = await Pipeline.Reader.ReadAsync(_stoppingCts.Token); + + try + { + reader.Update(result); + + await HandleReceive(reader, reader.Length); + } + catch (Exception e) + { + Logger.Error(e, "Exception during ReadAsync"); + Dispose(true); + break; + } + } + } + + /// + /// Writes the given bytes to the connection. + /// + /// The bytes to write. + /// + protected abstract ValueTask WriteBytesToConnection(byte[] bytes, int length); + + /// + public override async ValueTask SendAsync(IMessageWriter msg) + { + if (_state != ConnectionState.Connected) + throw new InvalidOperationException( + "Could not send data as this Connection is not connected. Did you disconnect?"); + + var buffer = new byte[msg.Length]; + Buffer.BlockCopy(msg.Buffer, 0, buffer, 0, msg.Length); + + switch (msg.SendOption) + { + case MessageType.Reliable: + ResetKeepAliveTimer(); + + AttachReliableID(buffer, 1); + await WriteBytesToConnection(buffer, buffer.Length); + Statistics.LogReliableSend(buffer.Length - 3, buffer.Length); + break; + + default: + await WriteBytesToConnection(buffer, buffer.Length); + Statistics.LogUnreliableSend(buffer.Length - 1, buffer.Length); + break; + } + } + + /// + /// + /// + /// + /// Udp connections can currently send messages using and + /// . Fragmented messages are not currently supported and will default to + /// until implemented. + /// + /// + public override async ValueTask SendBytes(byte[] bytes, MessageType sendOption = MessageType.Unreliable) + { + //Add header information and send + await HandleSend(bytes, (byte)sendOption); + } + + /// + /// Handles the reliable/fragmented sending from this connection. + /// + /// The data being sent. + /// + /// The callback to invoke when this packet is acknowledged. + /// The bytes that should actually be sent. + protected async ValueTask HandleSend(byte[] data, byte sendOption, Action ackCallback = null) + { + switch (sendOption) + { + case (byte)UdpSendOption.Ping: + case (byte)MessageType.Reliable: + case (byte)UdpSendOption.Hello: + await ReliableSend(sendOption, data, ackCallback); + break; + + //Treat all else as unreliable + default: + await UnreliableSend(sendOption, data); + break; + } + } + + /// + /// Handles the receiving of data. + /// + /// The buffer containing the bytes received. + /// + internal virtual async ValueTask HandleReceive(MessageReader message, int bytesReceived) + { + // Check if the first message received is the hello packet. + if (_isFirst) + { + _isFirst = false; + + // Slice 4 bytes to get handshake data. + if (_listener != null) + { + using var handshake = message.Copy(4); + await _listener.InvokeNewConnection(handshake, this); + } + } + + switch (message.Buffer[0]) + { + //Handle reliable receives + case (byte)MessageType.Reliable: + await ReliableMessageReceive(message, bytesReceived); + break; + + //Handle acknowledgments + case (byte)UdpSendOption.Acknowledgement: + AcknowledgementMessageReceive(message.Buffer, bytesReceived); + break; + + //We need to acknowledge hello and ping messages but dont want to invoke any events! + case (byte)UdpSendOption.Ping: + case (byte)UdpSendOption.Hello: + await ProcessReliableReceive(message.Buffer, 1); + Statistics.LogHelloReceive(bytesReceived); + break; + + case (byte)UdpSendOption.Disconnect: + message.Offset = 1; + message.Position = 0; + await DisconnectRemote("The remote sent a disconnect request", message); + break; + + //Treat everything else as unreliable + default: + await InvokeDataReceived(MessageType.Unreliable, message, 1, bytesReceived); + Statistics.LogUnreliableReceive(bytesReceived - 1, bytesReceived); + break; + } + } + + /// + /// Sends bytes using the unreliable UDP protocol. + /// + /// The SendOption to attach. + /// The data. + private ValueTask UnreliableSend(byte sendOption, byte[] data) + { + return UnreliableSend(sendOption, data, 0, data.Length); + } + + /// + /// Sends bytes using the unreliable UDP protocol. + /// + /// The data. + /// The SendOption to attach. + /// + /// + private async ValueTask UnreliableSend(byte sendOption, byte[] data, int offset, int length) + { + var bytes = new byte[length + 1]; + + //Add message type + bytes[0] = sendOption; + + //Copy data into new array + Buffer.BlockCopy(data, offset, bytes, bytes.Length - length, length); + + //Write to connection + await WriteBytesToConnection(bytes, bytes.Length); + + Statistics.LogUnreliableSend(length, bytes.Length); + } + + /// + /// Helper method to invoke the data received event. + /// + /// The send option the message was received with. + /// The buffer received. + /// The offset of data in the buffer. + /// + private ValueTask InvokeDataReceived(MessageType sendOption, MessageReader buffer, int dataOffset, + int bytesReceived) + { + buffer.Offset = dataOffset; + buffer.Length = bytesReceived - dataOffset; + buffer.Position = 0; + + return InvokeDataReceived(buffer, sendOption); + } + + /// + /// Sends a hello packet to the remote endpoint. + /// + /// + /// The callback to invoke when the hello packet is acknowledged. + protected ValueTask SendHello(byte[] bytes, Action acknowledgeCallback) + { + //First byte of handshake is version indicator so add data after + byte[] actualBytes; + if (bytes == null) + { + actualBytes = new byte[1]; + } + else + { + actualBytes = new byte[bytes.Length + 1]; + Buffer.BlockCopy(bytes, 0, actualBytes, 1, bytes.Length); + } + + return HandleSend(actualBytes, (byte)UdpSendOption.Hello, acknowledgeCallback); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _isDisposing = true; + + Stop(); + DisposeKeepAliveTimer(); + DisposeReliablePackets(); + } + + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs new file mode 100644 index 0000000..63e884b --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.ObjectPool; +using Serilog; + +namespace Next.Hazel.Udp; + +/// +/// Listens for new UDP connections and creates UdpConnections for them. +/// +/// +public class UdpConnectionListener : NetworkConnectionListener +{ + public delegate bool AcceptConnectionCheck(IPEndPoint endPoint, byte[] input, out byte[] response); + + private static readonly ILogger Logger = Log.ForContext(); + private readonly ConcurrentDictionary _allConnections; + private readonly UdpConnectionRateLimit _connectionRateLimit; + protected readonly ObjectPool _readerPool; + private readonly Timer _reliablePacketTimer; + + private readonly UdpClient _socket; + private readonly CancellationTokenSource _stoppingCts; + private Task _executingTask; + + /// + /// A callback for early connection rejection. + /// * Return false to reject connection. + /// * A null response is ok, we just won't send anything. + /// + public AcceptConnectionCheck AcceptConnection; + + #nullable enable + /// + /// Creates a new UdpConnectionListener for the given , port and . + /// + /// The endpoint to listen on. + /// + /// + /// + public UdpConnectionListener(IPEndPoint endPoint, ObjectPool readerPool, IPMode ipMode = IPMode.IPv4, Action? Setup = null) + { + EndPoint = endPoint; + IPMode = ipMode; + + _readerPool = readerPool; + _socket = new UdpClient(endPoint); + + try + { + _socket.DontFragment = false; + } + catch (SocketException) + { + } + + _reliablePacketTimer = new Timer(ManageReliablePackets, null, 100, Timeout.Infinite); + + _allConnections = new ConcurrentDictionary(); + + _stoppingCts = new CancellationTokenSource(); + _stoppingCts.Token.Register(() => { _socket.Dispose(); }); + + _connectionRateLimit = new UdpConnectionRateLimit(); + Setup?.Invoke(_socket, _connectionRateLimit); + } + + private async void ManageReliablePackets(object? state) + { + foreach (var kvp in _allConnections) + { + var sock = kvp.Value; + await sock.ManageReliablePackets(); + } + + try + { + _reliablePacketTimer.Change(100, Timeout.Infinite); + } + catch + { + // ignored + } + } + + /// + public override Task StartAsync() + { + // Store the task we're executing + _executingTask = Task.Factory.StartNew(ListenAsync, TaskCreationOptions.LongRunning); + + // If the task is completed then return it, this will bubble cancellation and failure to the caller + return _executingTask.IsCompleted ? _executingTask : + // Otherwise it's running + Task.CompletedTask; + } + + private async Task StopAsync() + { + // Stop called without start + if (_executingTask == null) return; + + try + { + // Signal cancellation to the executing method + await _stoppingCts.CancelAsync(); + } + finally + { + // Wait until the task completes or the timeout triggers + await Task.WhenAny(_executingTask, Task.Delay(TimeSpan.FromSeconds(5))); + } + } + + /// + /// Instructs the listener to begin listening. + /// + private async Task ListenAsync() + { + try + { + while (!_stoppingCts.IsCancellationRequested) + { + UdpReceiveResult data; + + try + { + data = await _socket.ReceiveAsync(); + + if (data.Buffer.Length == 0) + { + Logger.Fatal("Hazel read 0 bytes from UDP server socket."); + continue; + } + } + catch (SocketException) + { + // Client no longer reachable, pretend it didn't happen + continue; + } + catch (ObjectDisposedException) + { + // Socket was disposed, don't care. + return; + } + + await ProcessData(data); + } + } + catch (Exception e) + { + Logger.Error(e, "Listen loop error"); + } + } + + protected virtual async ValueTask ProcessData(UdpReceiveResult data) + { + // Get client from active clients + if (!_allConnections.TryGetValue(data.RemoteEndPoint, out var client)) + { + // Check for malformed connection attempts + if (data.Buffer[0] != (byte)UdpSendOption.Hello) return; + + // Check rateLimit. + if (!_connectionRateLimit.IsAllowed(data.RemoteEndPoint.Address)) + { + Logger.Warning("Ratelimited connection attempt from {0}.", data.RemoteEndPoint); + return; + } + + // Create new client + client = new UdpServerConnection(this, data.RemoteEndPoint, IPMode, _readerPool); + + // Store the client + if (!_allConnections.TryAdd(data.RemoteEndPoint, client)) + throw new HazelException("Failed to add a connection. This should never happen."); + + // Activate the reader loop of the client + await client.StartAsync(); + } + + // Write to client. + await client.Pipeline.Writer.WriteAsync(data.Buffer); + } + + /// + /// Sends data from the listener socket. + /// + /// The bytes to send. + /// + /// The endpoint to send to. + internal virtual async ValueTask SendData(byte[] bytes, int length, IPEndPoint endPoint) + { + if (length > bytes.Length) return; + + try + { + await _socket.SendAsync(bytes, length, endPoint); + } + catch (SocketException e) + { + Logger.Error(e, "Could not send data as a SocketException occurred"); + } + catch (ObjectDisposedException) + { + //Keep alive timer probably ran, ignore + } + } + + /// + /// Removes a virtual connection from the list. + /// + /// The endpoint of the virtual connection. + internal void RemoveConnectionTo(EndPoint endPoint) + { + _allConnections.TryRemove(endPoint, out var conn); + } + + /// + public override async ValueTask DisposeAsync() + { + foreach (var kvp in _allConnections) kvp.Value.Dispose(); + + await StopAsync(); + + await _reliablePacketTimer.DisposeAsync(); + + _connectionRateLimit.Dispose(); + + await base.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionRateLimit.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionRateLimit.cs new file mode 100644 index 0000000..788910e --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionRateLimit.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Serilog; + +namespace Next.Hazel.Udp; + +public class UdpConnectionRateLimit : IDisposable +{ + // Allow burst to 5 connections. + // Decrease by 1 every second. + public int MaxConnections { get; private set; } = 5; + + public int FalloffMs { get; private set; } = 1000; + private static readonly ILogger Logger = Log.ForContext(); + + private readonly ConcurrentDictionary _connectionCount; + private IReadOnlyDictionary _ConnectionCount => _connectionCount.AsReadOnly(); + private readonly Timer _timer; + private bool _isDisposed; + + public void Reset(int maxConnections, int falloffMs) + { + MaxConnections = maxConnections; + FalloffMs = falloffMs; + _timer.Change(FalloffMs, Timeout.Infinite); + } + + public UdpConnectionRateLimit() + { + _connectionCount = new ConcurrentDictionary(); + _timer = new Timer(UpdateRateLimit, null, FalloffMs, Timeout.Infinite); + } + + public void Dispose() + { + _isDisposed = true; + _timer.Dispose(); + } + + private void UpdateRateLimit(object state) + { + try + { + foreach (var pair in _connectionCount) + { + var count = pair.Value - 1; + if (count > 0) + _connectionCount.TryUpdate(pair.Key, count, pair.Value); + else + _connectionCount.TryRemove(pair); + } + } + catch (Exception e) + { + Logger.Error(e, "Exception caught in UpdateRateLimit."); + } + finally + { + if (!_isDisposed) _timer.Change(FalloffMs, Timeout.Infinite); + } + } + + public bool IsAllowed(IPAddress key) + { + if (_connectionCount.TryGetValue(key, out var value) && value >= MaxConnections) + return false; + + _connectionCount.AddOrUpdate(key, _ => 1, (_, i) => i + 1); + return true; + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpServerConnection.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpServerConnection.cs new file mode 100644 index 0000000..60290e6 --- /dev/null +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpServerConnection.cs @@ -0,0 +1,97 @@ +#nullable enable +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Extensions.ObjectPool; +using Next.Hazel.Abstractions; + +namespace Next.Hazel.Udp; + +internal sealed class UdpServerConnection : UdpConnection +{ + /// + /// Creates a UdpConnection for the virtual connection to the endpoint. + /// + /// The listener that created this connection. + /// The endpoint that we are connected to. + /// The IPMode we are connected using. + /// + internal UdpServerConnection(UdpConnectionListener listener, IPEndPoint endPoint, IPMode IPMode, + ObjectPool readerPool) + : base(listener, readerPool) + { + Listener = listener; + EndPoint = endPoint; + this.IPMode = IPMode; + + State = ConnectionState.Connected; + InitializeKeepAliveTimer(); + } + + /// + /// The connection listener that we use the socket of. + /// + /// + /// Udp server connections utilize the same socket in the listener for sends/receives, this is the listener that + /// created this connection and is hence the listener this conenction sends and receives via. + /// + public UdpConnectionListener Listener { get; } + + /// + protected override ValueTask WriteBytesToConnection(byte[] bytes, int length) + { + return Listener.SendData(bytes, length, EndPoint); + } + + /// + /// + /// This will always throw a HazelException. + /// + public override ValueTask ConnectAsync(byte[] bytes = null!, int timeout = 5000) + { + throw new InvalidOperationException( + "Cannot manually connect a UdpServerConnection, did you mean to use UdpClientConnection?"); + } + + /// + /// Sends a disconnect message to the end point. + /// + protected override async ValueTask SendDisconnect(MessageWriter? data) + { + lock (this) + { + if (_state != ConnectionState.Connected) return false; + _state = ConnectionState.NotConnected; + } + + var bytes = EmptyDisconnectBytes; + if (data is { Length: > 0 }) + { + if (data.SendOption != MessageType.Unreliable) + throw new ArgumentException("Disconnect messages can only be unreliable."); + + bytes = data.ToByteArray(true); + bytes[0] = (byte)UdpSendOption.Disconnect; + } + + try + { + await Listener.SendData(bytes, bytes.Length, EndPoint); + } + catch + { + // ignored + } + + return true; + } + + protected override void Dispose(bool disposing) + { + Listener.RemoveConnectionTo(EndPoint); + + if (disposing) _ = SendDisconnect(null); + + base.Dispose(disposing); + } +} \ No newline at end of file diff --git a/src/NextFast.Hazel-New/README.md b/src/NextFast.Hazel-New/README.md new file mode 100644 index 0000000..6907485 --- /dev/null +++ b/src/NextFast.Hazel-New/README.md @@ -0,0 +1,3 @@ +# Impostor.Hazel + +Fork of Hazel made for [Impostor](https://github.com/Impostor/Impostor) diff --git a/src/NextFast.Hazel-New/build.cake b/src/NextFast.Hazel-New/build.cake new file mode 100644 index 0000000..7d03b16 --- /dev/null +++ b/src/NextFast.Hazel-New/build.cake @@ -0,0 +1,29 @@ +var target = Argument("target", "Build"); + +var workflow = BuildSystem.GitHubActions.Environment.Workflow; +var buildId = workflow.RunNumber; +var tag = workflow.RefType == GitHubActionsRefType.Tag ? workflow.RefName : null; + +Task("Build") + .Does(() => +{ + var settings = new DotNetBuildSettings + { + Configuration = "Release", + OutputDirectory = "./Build/", + MSBuildSettings = new DotNetMSBuildSettings(), + }; + + if (tag != null) + { + settings.MSBuildSettings.Version = tag; + } + else if (buildId != 0) + { + settings.MSBuildSettings.VersionSuffix = "ci." + buildId; + } + + DotNetBuild(".", settings); +}); + +RunTarget(target); diff --git a/src/ProjectRules.ruleset b/src/ProjectRules.ruleset new file mode 100644 index 0000000..445d19b --- /dev/null +++ b/src/ProjectRules.ruleset @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/stylecop.json b/src/stylecop.json new file mode 100644 index 0000000..216b35a --- /dev/null +++ b/src/stylecop.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "orderingRules": { + "usingDirectivesPlacement": "preserve" + }, + "layoutRules": { + "newlineAtEndOfFile": "require" + } + } +}