diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9f0ee2c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,260 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true + +# XML project files +[*.csproj] +indent_size = 2 + +resharper_space_before_self_closing = true + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +indent_size = 2 + +# Shell script files +[*.sh] +indent_size = 2 + +# CSharp code style settings: +[*.cs] +indent_size = 4 + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +# Private static fields are camelCase +dotnet_naming_rule.private_static_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.private_static_fields_should_be_camel_case.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +# Non-private readonly fields are PascalCase +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly + +# Private readonly fields with underscore +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = warning + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +# Prefix style +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ + +# Constants are PascalCase +dotnet_naming_rule.local_constants_rule.severity = warning +dotnet_naming_rule.local_constants_rule.style = pascal_case_style +dotnet_naming_rule.local_constants_rule.symbols = local_constants_symbols + +dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private +dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field +dotnet_naming_symbols.private_constants_symbols.required_modifiers = const + +dotnet_naming_rule.private_constants_rule.severity = warning +dotnet_naming_rule.private_constants_rule.style = pascal_case_style +dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols + +dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities = * +dotnet_naming_symbols.local_constants_symbols.applicable_kinds = local +dotnet_naming_symbols.local_constants_symbols.required_modifiers = const + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = warning +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +# Test methods are underscore tolerant +dotnet_naming_rule.test_methods_should_be_underscore_tolerant.severity = warning +dotnet_naming_rule.test_methods_should_be_underscore_tolerant.symbols = test_methods +dotnet_naming_rule.test_methods_should_be_underscore_tolerant.style = test_methods_style + +dotnet_naming_symbols.test_methods.applicable_accessibilities = local, public +dotnet_naming_symbols.test_methods.applicable_kinds = +dotnet_naming_symbols.test_methods.resharper_applicable_kinds = test_member +dotnet_naming_symbols.test_methods.resharper_required_modifiers = instance + +dotnet_naming_style.test_methods_style.capitalization = pascal_case +dotnet_naming_style.test_methods_style.word_separator = _ + +dotnet_naming_style.camel_case_style.capitalization = camel_case +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Microsoft .NET properties +csharp_preferred_modifier_order = public, protected, internal, private, new, abstract, virtual, override, sealed, static, readonly, extern, unsafe, volatile, async:suggestion +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Parentheses settings +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none + +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +resharper_csharp_new_line_before_while = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Prefer "var" everywhere +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion + +resharper_csharp_keep_existing_expr_member_arrangement = true +resharper_csharp_place_expr_method_on_single_line = if_owner_is_single_line +resharper_csharp_place_expr_property_on_single_line = if_owner_is_single_line + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +resharper_local_function_body = expression_body +resharper_method_or_operator_body = expression_body + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Blocks are allowed +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_style_namespace_declarations = file_scoped + +resharper_csharp_braces_for_for = required +resharper_csharp_braces_for_foreach = required +resharper_csharp_braces_for_while = required +resharper_csharp_braces_for_ifelse = required_for_multiline_statement +resharper_csharp_space_within_single_line_array_initializer_braces = true + +# Alignment +resharper_csharp_align_linq_query = true +resharper_csharp_align_multiline_binary_expressions_chain = false +resharper_csharp_keep_blank_lines_in_code = 1 +resharper_csharp_keep_blank_lines_in_declarations = 1 +resharper_csharp_stick_comment = false +resharper_csharp_wrap_before_first_type_parameter_constraint = true +resharper_csharp_wrap_lines = false +resharper_csharp_wrap_multiple_type_parameter_constraints_style = chop_always +resharper_csharp_wrap_linq_expressions = chop_always +resharper_csharp_wrap_array_initializer_style = chop_if_long +resharper_csharp_place_constructor_initializer_on_same_line = false +resharper_csharp_place_accessorholder_attribute_on_same_line = false +resharper_csharp_place_field_attribute_on_same_line = false +resharper_csharp_place_simple_embedded_statement_on_same_line = false +resharper_wrap_before_arrow_with_expressions = true +resharper_csharp_wrap_before_arrow_with_expressions = true + +# Arrangement of method signatures +resharper_csharp_wrap_parameters_style = chop_if_long +resharper_csharp_max_formal_parameters_on_line = 3 +resharper_csharp_wrap_after_declaration_lpar = true + +# Arrangement of invocations +resharper_csharp_wrap_arguments_style = chop_if_long +resharper_csharp_max_invocation_arguments_on_line = 3 +resharper_csharp_wrap_after_invocation_lpar = true + +# Arguments +resharper_csharp_arguments_literal = named + +# Comments +resharper_xmldoc_indent_child_elements = RemoveIndent +resharper_xmldoc_indent_text = RemoveIndent +resharper_xmldoc_space_before_self_closing = false +resharper_xmldoc_max_line_length = 150 + +# Other +resharper_event_handler_pattern_long = $object$_On$event$ +resharper_empty_statement_highlighting = suggestion +resharper_csharp_trailing_comma_in_multiline_lists = true diff --git a/.github/workflows/nuget_publish.yaml b/.github/workflows/nuget_publish.yaml new file mode 100644 index 0000000..b4f111b --- /dev/null +++ b/.github/workflows/nuget_publish.yaml @@ -0,0 +1,125 @@ +name: publish nuget +on: + workflow_dispatch: + push: + branches: + - 'main' + pull_request: + branches: + - '*' + release: + types: + - published + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + NuGetDirectory: ${{github.workspace}}/nuget + DOTNET_TARGET_VERSION: 8.0.x + BUILD_NUMBER: ${{ github.run_number }} + +defaults: + run: + shell: pwsh + +jobs: + create_nuget: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Get all history to allow automatic versioning using MinVer + + # Install the .NET SDK indicated in the global.json file + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_TARGET_VERSION }} + + + # Create the NuGet package in the folder from the environment variable NuGetDirectory + - name: Build packages + run: dotnet pack --property:ContinuousIntegrationBuild=true --version-suffix ".${{ env.BUILD_NUMBER }}-dev" --configuration Release --output ${{ env.NuGetDirectory }} + if: github.event_name != 'release' + + # Create the NuGet package in the folder from the environment variable NuGetDirectory + - name: Build packages + run: dotnet pack --property:ContinuousIntegrationBuild=true --version-suffix "-beta" --configuration Release --output ${{ env.NuGetDirectory }} + if: github.event_name == 'release' + + # Publish the NuGet package as an artifact, so they can be used in the following jobs + - uses: actions/upload-artifact@v4 + with: + name: nuget + if-no-files-found: error + retention-days: 7 + path: | + ${{ env.NuGetDirectory }}/*.nupkg + ${{ env.NuGetDirectory }}/*.snupkg + + validate_nuget: + runs-on: ubuntu-latest + needs: [ create_nuget ] + steps: + # Install the .NET SDK indicated in the global.json file + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_TARGET_VERSION }} + + # Download the NuGet package created in the previous job + - uses: actions/download-artifact@v4 + with: + name: nuget + path: ${{ env.NuGetDirectory }} + + - name: Install nuget validator + run: dotnet tool update Meziantou.Framework.NuGetPackageValidation.Tool --global + + # Validate metadata and content of the NuGet package + # https://www.nuget.org/packages/Meziantou.Framework.NuGetPackageValidation.Tool#readme-body-tab + # If some rules are not applicable, you can disable them + # using the --excluded-rules or --excluded-rule-ids option + - name: Validate package + run: meziantou.validate-nuget-package (Get-ChildItem "${{ env.NuGetDirectory }}/*.nupkg") --excluded-rules LicenseMustBeSet + + + run_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_TARGET_VERSION }} + - name: Run tests + run: dotnet test --configuration Release + + deploy: + # Publish only when creating a GitHub Release + # https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository + # You can update this logic if you want to manage releases differently + if: github.event_name == 'release' + runs-on: ubuntu-latest + needs: [ validate_nuget, run_test ] + steps: + # Download the NuGet package created in the previous job + - uses: actions/download-artifact@v4 + with: + name: nuget + path: ${{ env.NuGetDirectory }} + + # Install the .NET SDK indicated in the global.json file + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_TARGET_VERSION }} + + # Publish all NuGet packages to NuGet.org + # Use --skip-duplicate to prevent errors if a package with the same version already exists. + # If you retry a failed workflow, already published packages will be skipped without error. + - name: Publish NuGet package + run: | + foreach($file in (Get-ChildItem "${{ env.NuGetDirectory }}" -Recurse -Include *.nupkg)) { + dotnet nuget push $file --api-key "${{ secrets.NUGET_API_TOKEN }}" --source https://api.nuget.org/v3/index.json --skip-duplicate + } diff --git a/Directory.Build.props b/Directory.Build.props index 3748614..a3092c3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,19 +8,33 @@ false false true + embedded true snupkg + true - MaxLevs - Copyright (c) 2021-$([System.DateTime]::UtcNow.ToString('yyyy')) $(Authors) - - Library for .Net which implements Minecraft Query protocol. You can use it for getting statuses of a Minecraft server. - + 2.0.0 + + + + + Maxim (MaxLevs) Liven minecraft, query, client icon.png README.md + Copyright (c) 2021-$([System.DateTime]::UtcNow.ToString('yyyy')) $(Authors) + $(VersionPrefix)$(VersionSuffix) + https://github.com/MaxLevs/McQuery.Net + $(BasicPackageUrl) + $(BasicPackageUrl) + git + + + + $(ProjectName) + $(ProjectName) diff --git a/Directory.Packages.props b/Directory.Packages.props index bfee0ac..62c6997 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,5 +4,13 @@ + + + + + + + + \ No newline at end of file diff --git a/McQuery.Net.sln b/McQuery.Net.sln new file mode 100644 index 0000000..82b85ec --- /dev/null +++ b/McQuery.Net.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McQuery.Net", "sources\McQuery.Net\McQuery.Net.csproj", "{2DDC52DA-E5E5-4685-9DFB-08365C6C1771}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McQuery.Net.Samples", "sources\McQuery.Net.Samples\McQuery.Net.Samples.csproj", "{4264FB51-B938-46ED-907A-AAD0AC9363E7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2DDC52DA-E5E5-4685-9DFB-08365C6C1771}.Release|Any CPU.Build.0 = Release|Any CPU + {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4264FB51-B938-46ED-907A-AAD0AC9363E7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/McQuery.Net.sln.DotSettings b/McQuery.Net.sln.DotSettings new file mode 100644 index 0000000..99b124e --- /dev/null +++ b/McQuery.Net.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/McQuery.Net.slnx b/McQuery.Net.slnx deleted file mode 100644 index d632f3e..0000000 --- a/McQuery.Net.slnx +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/README.md b/README.md index 5bb8dc0..e43f3f9 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,34 @@ -# McQueryLib.Net +# McQuery.Net Library for .Net which implements Minecraft Query protocol. You can use it for getting statuses of a Minecraft server. # Example of using ```cs -static async Task DoSomething(IEnumerable mcServersEndPoints) -{ - McQueryService service = new(5, 5000, 500, 1000); +IMcQueryClientFactory factory = new McQueryClientFactory(); +using var client = factory.Get(); - List servers = mcServersEndPoints.Select(service.RegistrateServer).ToList(); +async Task ExecuteQueries(IReadOnlyCollection endpoints, CancellationToken cancellationToken = default) +{ + var queryTasks = endpoints.SelectMany( + endpoint => + [ + GetBasicStatusAndPrint(endpoint, cancellationToken), + GetFullStatusAndPrint(endpoint, cancellationToken) + ], + (_, task) => task + ).ToArray(); - List> requests = new(); - foreach (Server server in servers) - { - requests.Add(service.GetBasicStatusCommon(server)); - requests.Add(service.GetFullStatusCommon(server)); - } + await Task.WhenAll(queryTasks); +} - Task.WaitAll(requests.ToArray()); +async Task GetBasicStatusAndPrint(IPEndPoint endpoint, CancellationToken cancellationToken = default) +{ + Console.WriteLine(await client.GetBasicStatusAsync(endpoint, cancellationToken)); +} - foreach (Task request in requests) - { - IResponse response = await request; - Console.WriteLine(response.ToString() + "\n"); - } +async Task GetFullStatusAndPrint(IPEndPoint endpoint, CancellationToken cancellationToken = default) +{ + Console.WriteLine(await client.GetFullStatusAsync(endpoint, cancellationToken)); } ``` diff --git a/sources/McQuery.Net.Samples/McQuery.Net.Samples.csproj b/sources/McQuery.Net.Samples/McQuery.Net.Samples.csproj new file mode 100644 index 0000000..c1c4c1e --- /dev/null +++ b/sources/McQuery.Net.Samples/McQuery.Net.Samples.csproj @@ -0,0 +1,25 @@ + + + + Exe + + + + + + + + + + + + + + + + + Always + + + + diff --git a/sources/McQuery.Net.Samples/Program.cs b/sources/McQuery.Net.Samples/Program.cs new file mode 100644 index 0000000..9348a92 --- /dev/null +++ b/sources/McQuery.Net.Samples/Program.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; +using System.Net; +using McQuery.Net; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +var loggingConfiguration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("logging.json", optional: false, reloadOnChange: true) + .Build(); +var serviceProvider = new ServiceCollection() + .AddLogging( + builder => + { + builder.AddConfiguration(loggingConfiguration.GetSection("Logging")); + builder.SetMinimumLevel(LogLevel.Debug); + builder.AddConsole(); + }) + .AddSingleton() + .BuildServiceProvider(); + +var factory = serviceProvider.GetRequiredService(); +using var client = factory.Get(); + +int[] ports = [25565, 25566, 25567]; +Func[] commandFactories = +[ + ep => new BasicStatusCommand(ep), + ep => new FullStatusCommand(ep), +]; +CommandBase[] commands = +[ + .. + from _ in Enumerable.Range(start: 0, count: 5000) + from fc in commandFactories + from port in ports + select fc(new IPEndPoint(IPAddress.Loopback, port)), +]; +Random.Shared.Shuffle(commands); + +var logger = serviceProvider.GetRequiredService>(); +logger.IsEnabled(LogLevel.Trace); +try +{ + logger.LogInformation("Starting McQuery.Net.Sample with {Count} requests", commands.Length); + var stopwatch = Stopwatch.StartNew(); + await Task.WhenAll(commands.Select(x => x.ExecuteAsync(client)).ToArray()); + stopwatch.Stop(); + logger.LogInformation("Finished. It took {Elapsed}", stopwatch.Elapsed); +} +catch (Exception ex) +{ + logger.LogError(ex, "Cannot finish calculating McQuery.Net.Sample"); + throw; +} + +abstract file class CommandBase(IPEndPoint endPoint) +{ + protected readonly IPEndPoint EndPoint = endPoint; + + public abstract Task ExecuteAsync(IMcQueryClient client, CancellationToken cancellationToken = default); +} + +file class BasicStatusCommand(IPEndPoint endPoint) : CommandBase(endPoint) +{ + public override Task ExecuteAsync(IMcQueryClient client, CancellationToken cancellationToken = default) => + client.GetBasicStatusAsync(EndPoint, cancellationToken); +} + +file class FullStatusCommand(IPEndPoint endPoint) : CommandBase(endPoint) +{ + public override Task ExecuteAsync(IMcQueryClient client, CancellationToken cancellationToken = default) => + client.GetFullStatusAsync(EndPoint, cancellationToken); +} diff --git a/sources/McQuery.Net.Samples/logging.json b/sources/McQuery.Net.Samples/logging.json new file mode 100644 index 0000000..d983ea9 --- /dev/null +++ b/sources/McQuery.Net.Samples/logging.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + }, + "Console": { + "IncludeScopes": "true", + "TimestampFormat": "[HH:mm:ss] ", + "LogToStandardErrorThreshold": "Warning" + } + } +} diff --git a/sources/McQuery.Net/Data/BasicStatus.cs b/sources/McQuery.Net/Data/BasicStatus.cs new file mode 100644 index 0000000..89831ad --- /dev/null +++ b/sources/McQuery.Net/Data/BasicStatus.cs @@ -0,0 +1,31 @@ +namespace McQuery.Net.Data; + +/// +/// Represents data which is received from BasicStatus request. +/// +/// +/// Message of the day. +/// Type of the game. +/// Name of a map. +/// Current number of players. +/// Maximum number of players what is allowed to enter. +/// Port to connect. +/// Ip to connect. +[PublicAPI] +public record BasicStatus( + string Motd, + string GameType, + string Map, + int NumPlayers, + int MaxPlayers, + int HostPort, + string HostIp +) : StatusBase( + Motd, + GameType, + Map, + NumPlayers, + MaxPlayers, + HostPort, + HostIp +); diff --git a/sources/McQuery.Net/Data/ChallengeToken.cs b/sources/McQuery.Net/Data/ChallengeToken.cs deleted file mode 100644 index d884a33..0000000 --- a/sources/McQuery.Net/Data/ChallengeToken.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace McQuery.Net.Data; - -public class ChallengeToken -{ - private byte[]? challengeToken; - - private const int alivePeriod = 30000; // Milliseconds before revoking - - private DateTime revokeDateTime; - - public bool IsFine => challengeToken != null && DateTime.Now < revokeDateTime; - - public ChallengeToken() - { - challengeToken = null; - } - - public ChallengeToken(byte[] challengeToken) - { - UpdateToken(challengeToken); - } - - public void UpdateToken(byte[] challengeToken) - { - this.challengeToken = (byte[])challengeToken.Clone(); - revokeDateTime = DateTime.Now.AddMilliseconds(alivePeriod); - } - - public string GetString() - { - ArgumentNullException.ThrowIfNull(challengeToken); - - return BitConverter.ToString(challengeToken); - } - - public byte[] GetBytes() - { - ArgumentNullException.ThrowIfNull(challengeToken); - - byte[] challengeTokenSnapshot = new byte[4]; - Buffer.BlockCopy(challengeToken, 0, challengeTokenSnapshot, 0, 4); - - return challengeTokenSnapshot; - } - - public void WriteTo(List list) - { - ArgumentNullException.ThrowIfNull(challengeToken); - - list.AddRange(challengeToken); - } -} diff --git a/sources/McQuery.Net/Data/FullStatus.cs b/sources/McQuery.Net/Data/FullStatus.cs new file mode 100644 index 0000000..d3ad8f2 --- /dev/null +++ b/sources/McQuery.Net/Data/FullStatus.cs @@ -0,0 +1,39 @@ +namespace McQuery.Net.Data; + +/// +/// Represents data which is received from FullStatus request. +/// +/// +/// Message of the day. +/// Type of the game. +/// Identifier of a game. Constant value: MINECRAFT. +/// Game version number. +/// List of plugins as a string. +/// Name of a map. +/// Current number of players. +/// Maximum number of players what is allowed to enter. +/// List of players' nicknames. +/// Port to connect. +/// Ip to connect. +[PublicAPI] +public record FullStatus( + string Motd, + string GameType, + string GameId, + string Version, + string Plugins, + string Map, + int NumPlayers, + int MaxPlayers, + string[] PlayerList, + int HostPort, + string HostIp +) : StatusBase( + Motd, + GameType, + Map, + NumPlayers, + MaxPlayers, + HostPort, + HostIp +); diff --git a/sources/McQuery.Net/Data/Packages/Request.cs b/sources/McQuery.Net/Data/Packages/Request.cs deleted file mode 100644 index 6f7ae99..0000000 --- a/sources/McQuery.Net/Data/Packages/Request.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace McQuery.Net.Data.Packages; - -public class Request -{ - public byte[] RawRequestData { get; private set; } - - public byte RequestType => RawRequestData[2]; - - public Request(byte[] rawRequestData) - { - RawRequestData = rawRequestData; - } -} diff --git a/sources/McQuery.Net/Data/Packages/Responses/IResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/IResponse.cs deleted file mode 100644 index fd42871..0000000 --- a/sources/McQuery.Net/Data/Packages/Responses/IResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace McQuery.Net.Data.Packages.Responses; - -public interface IResponse -{ - public Guid ServerUUID { get; } - - public byte[] RawData { get; } -} diff --git a/sources/McQuery.Net/Data/Packages/Responses/RawResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/RawResponse.cs deleted file mode 100644 index e2d85e2..0000000 --- a/sources/McQuery.Net/Data/Packages/Responses/RawResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace McQuery.Net.Data.Packages.Responses; - -public class RawResponse : IResponse -{ - public RawResponse(Guid serverUUID, byte[] rawData) - { - ServerUUID = serverUUID; - RawData = rawData; - } - - public Guid ServerUUID { get; } - - public byte[] RawData { get; } -} diff --git a/sources/McQuery.Net/Data/Packages/Responses/ServerBasicStateResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/ServerBasicStateResponse.cs deleted file mode 100644 index bc9ce62..0000000 --- a/sources/McQuery.Net/Data/Packages/Responses/ServerBasicStateResponse.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace McQuery.Net.Data.Packages.Responses; - -/// -/// Represents data which is received from BasicState request -/// -public class ServerBasicStateResponse : IResponse -{ - public ServerBasicStateResponse( - Guid serverUUID, - SessionId sessionId, - string motd, - string gameType, - string map, - int numPlayers, - int maxPlayers, - short hostPort, - string hostIp, - byte[] rawData) - { - ServerUUID = serverUUID; - SessionId = sessionId; - Motd = motd; - GameType = gameType; - Map = map; - NumPlayers = numPlayers; - MaxPlayers = maxPlayers; - HostPort = hostPort; - HostIp = hostIp; - RawData = rawData; - } - - public SessionId SessionId { get; } - - public string Motd { get; } - - public string GameType { get; } - - public string Map { get; } - - public int NumPlayers { get; } - - public int MaxPlayers { get; } - - public short HostPort { get; } - - public string HostIp { get; } - - public Guid ServerUUID { get; } - - public byte[] RawData { get; } - - public override string ToString() => "BasicStatus\n" + - $"| {nameof(ServerUUID)}: {ServerUUID}\n" + - $"| {nameof(SessionId)}: {SessionId.GetString()}\n" + - $"| {nameof(Motd)}: {Motd}\n" + - $"| {nameof(GameType)}: {GameType}\n" + - $"| {nameof(Map)}: {Map}\n" + - $"| {nameof(NumPlayers)}: {NumPlayers}\n" + - $"| {nameof(MaxPlayers)}: {MaxPlayers}\n" + - $"| {nameof(HostPort)}: {HostPort}\n" + - $"| {nameof(HostIp)}: {HostIp}"; -} diff --git a/sources/McQuery.Net/Data/Packages/Responses/ServerFullStateResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/ServerFullStateResponse.cs deleted file mode 100644 index 46fdfe6..0000000 --- a/sources/McQuery.Net/Data/Packages/Responses/ServerFullStateResponse.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace McQuery.Net.Data.Packages.Responses; - -/// -/// Represents data which is received from FullState request -/// -public class ServerFullStateResponse : IResponse -{ - public ServerFullStateResponse( - Guid serverUUID, - SessionId sessionId, - string motd, - string gameType, - string gameId, - string version, - string plugins, - string map, - int numPlayers, - int maxPlayers, - string[] playerList, - int hostPort, - string hostIp, - byte[] rawData) - { - ServerUUID = serverUUID; - SessionId = sessionId; - Motd = motd; - GameType = gameType; - GameId = gameId; - Version = version; - Plugins = plugins; - Map = map; - NumPlayers = numPlayers; - MaxPlayers = maxPlayers; - PlayerList = playerList; - HostPort = hostPort; - HostIp = hostIp; - RawData = rawData; - } - - public SessionId SessionId { get; } - - public string Motd { get; } - - public string GameType { get; } - - public string GameId { get; } - - public string Version { get; } - - public string Plugins { get; } - - public string Map { get; } - - public int NumPlayers { get; } - - public int MaxPlayers { get; } - - public string[] PlayerList { get; } - - public int HostPort { get; } - - public string HostIp { get; } - - public Guid ServerUUID { get; } - - public byte[] RawData { get; } - - public override string ToString() => "FullStatus\n" + - $"| {nameof(ServerUUID)}: {ServerUUID}\n" + - $"| {nameof(SessionId)}: {SessionId.GetString()}\n" + - $"| {nameof(Motd)}: {Motd}\n" + - $"| {nameof(GameType)}: {GameType}\n" + - $"| {nameof(GameId)}: {GameId}\n" + - $"| {nameof(Version)}: {Version}\n" + - $"| {nameof(Plugins)}: {Plugins}\n" + - $"| {nameof(Map)}: {Map}\n" + - $"| {nameof(NumPlayers)}: {NumPlayers}\n" + - $"| {nameof(MaxPlayers)}: {MaxPlayers}\n" + - $"| {nameof(PlayerList)}: [{string.Join(", ", PlayerList)}]\n" + - $"| {nameof(HostPort)}: {HostPort}\n" + - $"| {nameof(HostIp)}: {HostIp}"; -} diff --git a/sources/McQuery.Net/Data/Packages/Responses/TimeoutResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/TimeoutResponse.cs deleted file mode 100644 index 6ba02ac..0000000 --- a/sources/McQuery.Net/Data/Packages/Responses/TimeoutResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace McQuery.Net.Data.Packages.Responses; - -public class TimeoutResponse : IResponse -{ - public TimeoutResponse(Guid serverUUID) - { - ServerUUID = serverUUID; - } - - public byte[] RawData => throw new NotSupportedException(); - - public Guid ServerUUID { get; } - - public string Message => "Request is timed out"; -} diff --git a/sources/McQuery.Net/Data/Packages/Responses/WrongResponse.cs b/sources/McQuery.Net/Data/Packages/Responses/WrongResponse.cs deleted file mode 100644 index 77e292d..0000000 --- a/sources/McQuery.Net/Data/Packages/Responses/WrongResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace McQuery.Net.Data.Packages.Responses; - -public class WrongResponse : IResponse -{ - public WrongResponse(Guid serverUUID, byte[] rawData) - { - ServerUUID = serverUUID; - RawData = rawData; - } - - public byte[] RawData { get; } - - public Guid ServerUUID { get; } - - public string Message => "This response package can't be parsed"; -} diff --git a/sources/McQuery.Net/Data/Server.cs b/sources/McQuery.Net/Data/Server.cs deleted file mode 100644 index 8fc6630..0000000 --- a/sources/McQuery.Net/Data/Server.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Net; -using System.Net.Sockets; - -namespace McQuery.Net.Data; - -// todo: add cancellation token support -public class Server : IDisposable -{ - public Server(SessionId sessionId, IPAddress host, int port) - { - UUID = Guid.NewGuid(); - SessionId = sessionId; - Host = host; - Port = port; - ChallengeToken = new ChallengeToken(); - UdpClient = new UdpClient(Host.ToString(), Port); - UdpClientSemaphoreSlim = new SemaphoreSlim(0, 1); - UdpClientSemaphoreSlim.Release(); - } - - public Guid UUID { get; } - - public SessionId SessionId { get; } - - public IPAddress Host { get; } - - public int Port { get; } - - public ChallengeToken ChallengeToken { get; } - - public UdpClient UdpClient { get; private set; } - - public SemaphoreSlim UdpClientSemaphoreSlim { get; } - - public async void InvalidateSocket() - { - await UdpClientSemaphoreSlim.WaitAsync(); - UdpClient.Dispose(); - UdpClient = new UdpClient(Host.ToString(), Port); - UdpClientSemaphoreSlim.Release(); - } - - private bool disposed = false; - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public void Dispose(bool disposing) - { - if (!disposed) - { - if (disposing) - { - UdpClient.Dispose(); - UdpClientSemaphoreSlim.Dispose(); - } - - disposed = true; - } - } - - ~Server() - { - Dispose(true); - } - - public override bool Equals(object? obj) => obj is Server server && - EqualityComparer.Default.Equals(UUID, server.UUID); - - public override int GetHashCode() => UUID.GetHashCode(); -} diff --git a/sources/McQuery.Net/Data/SessionId.cs b/sources/McQuery.Net/Data/SessionId.cs deleted file mode 100644 index 7047b77..0000000 --- a/sources/McQuery.Net/Data/SessionId.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace McQuery.Net.Data; - -/// -/// This class represents SessionId filed into packages. -/// It provides api for create random SessionId or parse it from byte[] -/// -public class SessionId -{ - private readonly byte[] sessionId; - - public SessionId(byte[] sessionId) - { - this.sessionId = sessionId; - } - - public string GetString() => BitConverter.ToString(sessionId); - - public byte[] GetBytes() - { - byte[] sessionIdSnapshot = new byte[4]; - Buffer.BlockCopy(sessionId, 0, sessionIdSnapshot, 0, 4); - - return sessionIdSnapshot; - } - - public void WriteTo(List list) - { - list.AddRange(sessionId); - } - - public override bool Equals(object? obj) => - obj is SessionId anotherSessionId && sessionId.SequenceEqual(anotherSessionId.sessionId); - - public override int GetHashCode() => BitConverter.ToInt32(sessionId, 0); -} diff --git a/sources/McQuery.Net/Data/StatusBase.cs b/sources/McQuery.Net/Data/StatusBase.cs new file mode 100644 index 0000000..f8a0b42 --- /dev/null +++ b/sources/McQuery.Net/Data/StatusBase.cs @@ -0,0 +1,27 @@ +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Data; + +/// +/// Represents a basic status response. +/// +/// +/// Message of the day. +/// Type of the game. +/// Name of a map. +/// Current number of players. +/// Maximum number of players what is allowed to enter. +/// Port to connect. +/// Ip to connect. +[PublicAPI] +public abstract record StatusBase( + string Motd, + string GameType, + string Map, + int NumPlayers, + int MaxPlayers, + int HostPort, + string HostIp) +{ + internal SessionId SessionId { get; init; } = null!; +} diff --git a/sources/McQuery.Net/Exceptions/ExpiredException.cs b/sources/McQuery.Net/Exceptions/ExpiredException.cs new file mode 100644 index 0000000..9a8fc1f --- /dev/null +++ b/sources/McQuery.Net/Exceptions/ExpiredException.cs @@ -0,0 +1,25 @@ +using McQuery.Net.Internal.Abstract; + +namespace McQuery.Net.Exceptions; + +/// +/// Something was expired. +/// +[PublicAPI] +public class ExpiredException : ArgumentException +{ + internal ExpiredException(IExpirable expirable) + : base($"{expirable.GetType().Name} is already expired") + { + } + + /// + /// Helper method to throw new exception form . + /// + /// Something that can be expired. + /// Something was exprired. + internal static void ThrowIfExpired(IExpirable expirable) + { + if (expirable.IsExpired) throw new ExpiredException(expirable); + } +} diff --git a/sources/McQuery.Net/IMcQueryClient.cs b/sources/McQuery.Net/IMcQueryClient.cs new file mode 100644 index 0000000..62d3f53 --- /dev/null +++ b/sources/McQuery.Net/IMcQueryClient.cs @@ -0,0 +1,30 @@ +using System.Net; +using McQuery.Net.Data; + +namespace McQuery.Net; + +/// +/// Client to request minecraft server status by Minecraft Query Protocol. +/// +[PublicAPI] +public interface IMcQueryClient : IDisposable +{ + /// + /// Get . + /// + /// to access Minecraft server by UDP. + /// . + /// . + Task GetBasicStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default); + + /// + /// Get . + /// + /// + /// Minecraft server caches prepared full status response for some time. + /// + /// to access Minecraft server by UDP. + /// . + /// . + Task GetFullStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default); +} diff --git a/sources/McQuery.Net/IMcQueryClientFactory.cs b/sources/McQuery.Net/IMcQueryClientFactory.cs new file mode 100644 index 0000000..837d020 --- /dev/null +++ b/sources/McQuery.Net/IMcQueryClientFactory.cs @@ -0,0 +1,14 @@ +namespace McQuery.Net; + +/// +/// Factory to create instances of . +/// +[PublicAPI] +public interface IMcQueryClientFactory +{ + /// + /// Create instance of . + /// + /// Instance of . + IMcQueryClient Get(); +} diff --git a/sources/McQuery.Net/Internal/Abstract/IAuthOnlyClient.cs b/sources/McQuery.Net/Internal/Abstract/IAuthOnlyClient.cs new file mode 100644 index 0000000..a3eed6a --- /dev/null +++ b/sources/McQuery.Net/Internal/Abstract/IAuthOnlyClient.cs @@ -0,0 +1,23 @@ +using System.Net; +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Abstract; + +/// +/// Client that provides interface to acquire . +/// +internal interface IAuthOnlyClient : IDisposable +{ + /// + /// Request from Minecraft server. + /// + /// + /// . + /// . + /// . + internal Task HandshakeAsync( + IPEndPoint serverEndpoint, + SessionId sessionId, + CancellationToken cancellationToken = default + ); +} diff --git a/sources/McQuery.Net/Internal/Abstract/IExpoirable.cs b/sources/McQuery.Net/Internal/Abstract/IExpoirable.cs new file mode 100644 index 0000000..68e3e2f --- /dev/null +++ b/sources/McQuery.Net/Internal/Abstract/IExpoirable.cs @@ -0,0 +1,6 @@ +namespace McQuery.Net.Internal.Abstract; + +internal interface IExpirable +{ + public bool IsExpired { get; } +} diff --git a/sources/McQuery.Net/Internal/Data/ChallengeToken.cs b/sources/McQuery.Net/Internal/Data/ChallengeToken.cs new file mode 100644 index 0000000..caa45cd --- /dev/null +++ b/sources/McQuery.Net/Internal/Data/ChallengeToken.cs @@ -0,0 +1,42 @@ +using McQuery.Net.Exceptions; +using McQuery.Net.Internal.Abstract; + +namespace McQuery.Net.Internal.Data; + +/// +/// Secret value provided by Minecraft server to issue status requests. +/// +internal record ChallengeToken : IExpirable +{ + private const int AlivePeriod = 29; + private readonly DateTime _expiresAt = DateTime.UtcNow.AddSeconds(AlivePeriod); + + /// + /// .ctor. + /// + /// Bytes that represents challenge token. + /// + /// Number of bytes is incorrect. + /// + public ChallengeToken(byte[] data) + { + if (data.Length != 4) + { + throw new ArgumentOutOfRangeException(nameof(data), data, "Challenge token must have 4 bytes"); + } + + Data = data; + } + + private byte[] Data { get; } + public bool IsExpired => DateTime.UtcNow >= _expiresAt; + + public static implicit operator byte[](ChallengeToken token) + { + ExpiredException.ThrowIfExpired(token); + return [..token.Data]; + } + + public static implicit operator ReadOnlySpan(ChallengeToken token) + => (byte[])token; +} diff --git a/sources/McQuery.Net/Internal/Data/Session.cs b/sources/McQuery.Net/Internal/Data/Session.cs new file mode 100644 index 0000000..2933bf0 --- /dev/null +++ b/sources/McQuery.Net/Internal/Data/Session.cs @@ -0,0 +1,14 @@ +namespace McQuery.Net.Internal.Data; + +/// +/// Represents a combination of and values. +/// +/// +/// Replica of something similar that Minecraft server use. +/// +/// . +/// . +internal record Session(SessionId SessionId, ChallengeToken Token) +{ + public bool IsExpired => Token.IsExpired; +} diff --git a/sources/McQuery.Net/Internal/Data/SessionId.cs b/sources/McQuery.Net/Internal/Data/SessionId.cs new file mode 100644 index 0000000..f6e46aa --- /dev/null +++ b/sources/McQuery.Net/Internal/Data/SessionId.cs @@ -0,0 +1,47 @@ +namespace McQuery.Net.Internal.Data; + +/// +/// Represents Session Identifier. +/// +/// +/// Minecraft server does not validate this value but store along with as long as handshake session +/// for current issuer is alive. +/// Can be rewritten by new value if current client send another one handshake request. +/// Server sends stored in every response (even if status request contains different +/// compared to handshake request, response contains actual from the last handshake request). +/// +internal class SessionId +{ + private byte[] Data { get; } + + /// + /// .ctor. + /// + /// Bytes that represents session identifier. + /// + /// Number of bytes is incorrect. + /// + public SessionId(byte[] data) + { + if (data.Length != 4) + { + throw new ArgumentOutOfRangeException(nameof(data), data, "Session identifier must have 4 bytes"); + } + + Data = data; + } + + public static implicit operator string(SessionId sessionId) => BitConverter.ToString(sessionId.Data); + + public static implicit operator byte[](SessionId sessionId) => [..sessionId.Data]; + + public static implicit operator ReadOnlySpan(SessionId sessionId) => (byte[])sessionId; + + public override bool Equals(object? obj) + { + return obj is SessionId anotherSessionId + && Data.SequenceEqual(anotherSessionId.Data); + } + + public override int GetHashCode() => BitConverter.ToInt32(Data, startIndex: 0); +} diff --git a/sources/McQuery.Net/Internal/Factories/IRequestFactory.cs b/sources/McQuery.Net/Internal/Factories/IRequestFactory.cs new file mode 100644 index 0000000..087f5f3 --- /dev/null +++ b/sources/McQuery.Net/Internal/Factories/IRequestFactory.cs @@ -0,0 +1,30 @@ +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Factories; + +/// +/// Provides methods to build requests. +/// +internal interface IRequestFactory +{ + /// + /// Builds handshake request. + /// + /// . + /// Binary representation of the request. + internal byte[] GetHandshakeRequest(SessionId sessionId); + + /// + /// Builds basic status request. + /// + /// . + /// Binary representation of the request. + internal byte[] GetBasicStatusRequest(Session session); + + /// + /// Builds full status request. + /// + /// . + /// Binary representation of the request. + internal byte[] GetFullStatusRequest(Session session); +} diff --git a/sources/McQuery.Net/Internal/Factories/RequestFactory.cs b/sources/McQuery.Net/Internal/Factories/RequestFactory.cs new file mode 100644 index 0000000..828756e --- /dev/null +++ b/sources/McQuery.Net/Internal/Factories/RequestFactory.cs @@ -0,0 +1,51 @@ +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Factories; + +/// +/// Implementation of . +/// +internal class RequestFactory : IRequestFactory +{ + private const byte HandshakeRequestTypeConst = 0x09; + private const byte StatusRequestTypeConst = 0x00; + private static readonly byte[] magicConst = [0xfe, 0xfd]; + + /// + public byte[] GetHandshakeRequest(SessionId sessionId) + { + using MemoryStream packetStream = new(); + FormRequestHeader(packetStream, HandshakeRequestTypeConst, sessionId); + return packetStream.ToArray(); + } + + /// + public byte[] GetBasicStatusRequest(Session session) + { + using MemoryStream packetStream = new(); + FormBasicStatusRequest(packetStream, session); + return packetStream.ToArray(); + } + + /// + public byte[] GetFullStatusRequest(Session session) + { + using MemoryStream packetStream = new(); + FormBasicStatusRequest(packetStream, session); + packetStream.Write([0x00, 0x00, 0x00, 0x00]); + return packetStream.ToArray(); + } + + private static void FormRequestHeader(Stream packetStream, byte packageType, SessionId sessionId) + { + packetStream.Write(magicConst); + packetStream.Write([packageType]); + packetStream.Write(sessionId); + } + + private static void FormBasicStatusRequest(Stream packetStream, Session session) + { + FormRequestHeader(packetStream, StatusRequestTypeConst, session.SessionId); + packetStream.Write(session.Token); + } +} diff --git a/sources/McQuery.Net/Internal/Helpers/CancellationTokenTimeoutEnricher.cs b/sources/McQuery.Net/Internal/Helpers/CancellationTokenTimeoutEnricher.cs new file mode 100644 index 0000000..6c58c84 --- /dev/null +++ b/sources/McQuery.Net/Internal/Helpers/CancellationTokenTimeoutEnricher.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; + +namespace McQuery.Net.Internal.Helpers; + +internal static class CancellationTokenTimeoutEnrichHelper +{ + public static CancellationTokenSourceWithTimeout ToSourceWithTimeout(this CancellationToken token, TimeSpan timeout) + => CancellationTokenSourceWithTimeout.Create(token, timeout); + + public record struct CancellationTokenSourceWithTimeout : IDisposable + { + private readonly CancellationTokenSource _originSource; + private readonly CancellationTokenSource _linkedSource; + + public CancellationToken Token => _linkedSource.Token; + + private CancellationTokenSourceWithTimeout(CancellationTokenSource originSource, CancellationTokenSource linkedSource) + { + _originSource = originSource; + _linkedSource = linkedSource; + } + + [SuppressMessage("Design", "CA1068:CancellationToken parameters must come last")] + public static CancellationTokenSourceWithTimeout Create(CancellationToken cancellationToken, TimeSpan timeout) + { + CancellationTokenSource timeoutSource = new(timeout); + return new CancellationTokenSourceWithTimeout( + timeoutSource, + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token)); + } + + private bool _isDisposed = false; + + public void Dispose() + { + if (_isDisposed) return; + _originSource.Dispose(); + _linkedSource.Dispose(); + _isDisposed = true; + } + } +} diff --git a/sources/McQuery.Net/Internal/Parsers/BasicStatusResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/BasicStatusResponseParser.cs new file mode 100644 index 0000000..4543d93 --- /dev/null +++ b/sources/McQuery.Net/Internal/Parsers/BasicStatusResponseParser.cs @@ -0,0 +1,24 @@ +using McQuery.Net.Data; + +namespace McQuery.Net.Internal.Parsers; + +internal class BasicStatusResponseParser : StatusResponseParser +{ + public override BasicStatus Parse(byte[] data) + { + var sessionId = StartParsing(data, out var reader); + + return new BasicStatus( + ParseNullTerminatingString(ref reader), + ParseNullTerminatingString(ref reader), + ParseNullTerminatingString(ref reader), + int.Parse(ParseNullTerminatingString(ref reader)), + int.Parse(ParseNullTerminatingString(ref reader)), + ParseShortLittleEndian(ref reader), + ParseNullTerminatingString(ref reader) + ) + { + SessionId = sessionId, + }; + } +} diff --git a/sources/McQuery.Net/Internal/Parsers/FullStatusResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/FullStatusResponseParser.cs new file mode 100644 index 0000000..f507467 --- /dev/null +++ b/sources/McQuery.Net/Internal/Parsers/FullStatusResponseParser.cs @@ -0,0 +1,49 @@ +using McQuery.Net.Data; + +namespace McQuery.Net.Internal.Parsers; + +internal class FullStatusResponseParser : StatusResponseParser +{ + private static readonly byte[] constant1 = [0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00]; + private static readonly byte[] constant2 = [0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00]; + private static readonly InvalidOperationException responseFormatError = new("Invalid full status response format"); + + public override FullStatus Parse(byte[] data) + { + var sessionId = StartParsing(data, out var reader); + + if (!reader.IsNext(constant1, advancePast: true)) throw responseFormatError; + + Dictionary statusKeyValues = new(); + while (!reader.IsNext(next: 0, advancePast: true)) + { + var key = ParseNullTerminatingString(ref reader); + var value = ParseNullTerminatingString(ref reader); + statusKeyValues.Add(key, value); + } + + if (!reader.IsNext(constant2, advancePast: true)) throw responseFormatError; + + List players = []; + while (!reader.IsNext(next: 0, advancePast: true)) + { + players.Add(ParseNullTerminatingString(ref reader)); + } + + return new FullStatus( + statusKeyValues["hostname"], + statusKeyValues["gametype"], + statusKeyValues["game_id"], + statusKeyValues["version"], + statusKeyValues["plugins"], + statusKeyValues["map"], + int.Parse(statusKeyValues["numplayers"]), + int.Parse(statusKeyValues["maxplayers"]), + players.ToArray(), + int.Parse(statusKeyValues["hostport"]), + statusKeyValues["hostip"]) + { + SessionId = sessionId, + }; + } +} diff --git a/sources/McQuery.Net/Internal/Parsers/HandshakeResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/HandshakeResponseParser.cs new file mode 100644 index 0000000..dab59fb --- /dev/null +++ b/sources/McQuery.Net/Internal/Parsers/HandshakeResponseParser.cs @@ -0,0 +1,20 @@ +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Parsers; + +internal class HandshakeResponseParser : ResponseParserBase, IResponseParser +{ + protected override byte ResponseType => 0x09; + + public ChallengeToken Parse(byte[] data) + { + StartParsing(data, out var reader); + + var challengeTokenString = ParseNullTerminatingString(ref reader); + var challengeTokenBytes = BitConverter.GetBytes(int.Parse(challengeTokenString)); + + if (BitConverter.IsLittleEndian) Array.Reverse(challengeTokenBytes); + + return new ChallengeToken(challengeTokenBytes); + } +} diff --git a/sources/McQuery.Net/Internal/Parsers/IResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/IResponseParser.cs new file mode 100644 index 0000000..a702317 --- /dev/null +++ b/sources/McQuery.Net/Internal/Parsers/IResponseParser.cs @@ -0,0 +1,6 @@ +namespace McQuery.Net.Internal.Parsers; + +internal interface IResponseParser +{ + T Parse(byte[] data); +} diff --git a/sources/McQuery.Net/Internal/Parsers/ResponseParserBase.cs b/sources/McQuery.Net/Internal/Parsers/ResponseParserBase.cs new file mode 100644 index 0000000..ab3bbbc --- /dev/null +++ b/sources/McQuery.Net/Internal/Parsers/ResponseParserBase.cs @@ -0,0 +1,52 @@ +using System.Buffers; +using System.Text; +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Parsers; + +internal abstract class ResponseParserBase +{ + protected abstract byte ResponseType { get; } + + internal SessionId StartParsing(byte[] data, out SequenceReader reader) + { + ReadOnlySequence sequence = new(data); + reader = new SequenceReader(sequence); + + if (!reader.IsNext([ResponseType], advancePast: true)) throw new InvalidOperationException("Invalid response type"); + + return ParseSessionId(ref reader); + } + + private static SessionId ParseSessionId(ref SequenceReader reader) + { + if (reader.UnreadSequence.Length < 4) + { + throw new InvalidOperationException("Session id must contain exactly 4 bytes."); + } + + reader.TryReadExact(count: 4, out var sessionIdBytes); + + return new SessionId(sessionIdBytes.ToArray()); + } + + internal static string ParseNullTerminatingString(ref SequenceReader reader) + { + if (!reader.TryReadTo(out ReadOnlySequence bytes, delimiter: 0, advancePastDelimiter: true)) + { + throw new InvalidOperationException("Cannot parse null terminating string: terminator was not found."); + } + + return Encoding.ASCII.GetString(bytes); + } + + internal static short ParseShortLittleEndian(ref SequenceReader reader) + { + if (!reader.TryReadLittleEndian(out short port)) + { + throw new InvalidOperationException("Cannot parse short value"); + } + + return port; + } +} diff --git a/sources/McQuery.Net/Internal/Parsers/StatusResponseParser.cs b/sources/McQuery.Net/Internal/Parsers/StatusResponseParser.cs new file mode 100644 index 0000000..2457aea --- /dev/null +++ b/sources/McQuery.Net/Internal/Parsers/StatusResponseParser.cs @@ -0,0 +1,11 @@ +using McQuery.Net.Data; + +namespace McQuery.Net.Internal.Parsers; + +internal abstract class StatusResponseParser : ResponseParserBase, IResponseParser + where T : StatusBase +{ + protected override byte ResponseType => 0x00; + + public abstract T Parse(byte[] data); +} diff --git a/sources/McQuery.Net/Internal/Providers/ISessionIdProvider.cs b/sources/McQuery.Net/Internal/Providers/ISessionIdProvider.cs new file mode 100644 index 0000000..6dbb297 --- /dev/null +++ b/sources/McQuery.Net/Internal/Providers/ISessionIdProvider.cs @@ -0,0 +1,15 @@ +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Providers; + +/// +/// Provides every time it's needed. +/// +internal interface ISessionIdProvider +{ + /// + /// Gets . + /// + /// . + SessionId Get(); +} diff --git a/sources/McQuery.Net/Internal/Providers/ISessionStorage.cs b/sources/McQuery.Net/Internal/Providers/ISessionStorage.cs new file mode 100644 index 0000000..c9f9b99 --- /dev/null +++ b/sources/McQuery.Net/Internal/Providers/ISessionStorage.cs @@ -0,0 +1,18 @@ +using System.Net; +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Providers; + +/// +/// Creates and stores objects. +/// +internal interface ISessionStorage : IDisposable +{ + /// + /// Gets stored session or acquire new. + /// + /// to access Minecraft server by UDP. + /// . + /// . + Task GetAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default); +} diff --git a/sources/McQuery.Net/Internal/Providers/SessionIdProvider.cs b/sources/McQuery.Net/Internal/Providers/SessionIdProvider.cs new file mode 100644 index 0000000..b5587db --- /dev/null +++ b/sources/McQuery.Net/Internal/Providers/SessionIdProvider.cs @@ -0,0 +1,22 @@ +using McQuery.Net.Internal.Data; + +namespace McQuery.Net.Internal.Providers; + +/// +/// Implementation of . +/// +internal class SessionIdProvider : ISessionIdProvider +{ + private static uint counter; + + /// + public SessionId Get() + { + var currentValue = Interlocked.Increment(ref counter); + + var bytes = BitConverter.GetBytes(currentValue); + if (BitConverter.IsLittleEndian) Array.Reverse(bytes); + + return new SessionId(bytes); + } +} diff --git a/sources/McQuery.Net/Internal/Providers/SessionStorage.cs b/sources/McQuery.Net/Internal/Providers/SessionStorage.cs new file mode 100644 index 0000000..e7dbe4e --- /dev/null +++ b/sources/McQuery.Net/Internal/Providers/SessionStorage.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using McQuery.Net.Internal.Abstract; +using McQuery.Net.Internal.Data; +using Microsoft.VisualStudio.Threading; + +namespace McQuery.Net.Internal.Providers; + +/// +/// Implementation of . +/// +/// . +internal class SessionStorage(ISessionIdProvider sessionIdProvider) : ISessionStorage +{ + [SuppressMessage("Usage", "VSTHRD012:Provide JoinableTaskFactory where allowed")] + private readonly AsyncReaderWriterLock _locker = new(); + + private IAuthOnlyClient? _authClient; + private readonly Dictionary _sessionsByEndpoints = new(); + + /// + public async Task GetAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken = default) + { + await using var releaser = await _locker.UpgradeableReadLockAsync(cancellationToken); + + var sessionExists = _sessionsByEndpoints.TryGetValue(serverEndpoint, out var session); + + if (sessionExists && !session!.IsExpired) + { + return session; + } + + if (sessionExists && session!.IsExpired) + { + if (!_sessionsByEndpoints.Remove(serverEndpoint, out session)) + { + throw new Exception($"Cannot remove expired session {session} for some reason."); + } + } + + return await AcquireSessionAsync(serverEndpoint, cancellationToken); + } + + internal void Init(IAuthOnlyClient client) + { + if (_authClient != null) throw new InvalidOperationException("SessionStorage already initialized."); + + _authClient = client; + } + + private async Task AcquireSessionAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken) + { + await using var releaser = await _locker.WriteLockAsync(cancellationToken); + + var sessionExists = _sessionsByEndpoints.TryGetValue(serverEndpoint, out var currentSession); + if (sessionExists && !currentSession!.IsExpired) + { + return currentSession; + } + + if (_authClient == null) + { + throw new InvalidOperationException("Storage must be initialized before calling this method."); + } + + var sessionId = sessionIdProvider.Get(); + var challengeToken = await _authClient!.HandshakeAsync(serverEndpoint, sessionId, cancellationToken); + Session session = new(sessionId, challengeToken); + + return _sessionsByEndpoints[serverEndpoint] = session; + } + + private bool _isDisposed; + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + + _authClient?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/sources/McQuery.Net/McQuery.Net.csproj b/sources/McQuery.Net/McQuery.Net.csproj index 69a6dd0..010e7ba 100644 --- a/sources/McQuery.Net/McQuery.Net.csproj +++ b/sources/McQuery.Net/McQuery.Net.csproj @@ -2,6 +2,19 @@ true + McQuery.Net + + Library for .Net which implements Minecraft Query protocol. You can use it for getting statuses of a Minecraft server. + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/sources/McQuery.Net/McQueryClient.cs b/sources/McQuery.Net/McQueryClient.cs new file mode 100644 index 0000000..21746ba --- /dev/null +++ b/sources/McQuery.Net/McQueryClient.cs @@ -0,0 +1,146 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Sockets; +using McQuery.Net.Data; +using McQuery.Net.Internal.Abstract; +using McQuery.Net.Internal.Data; +using McQuery.Net.Internal.Factories; +using McQuery.Net.Internal.Helpers; +using McQuery.Net.Internal.Parsers; +using McQuery.Net.Internal.Providers; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Threading; + +namespace McQuery.Net; + +/// +/// Implementation of . +/// +[UsedImplicitly] +public class McQueryClient : IMcQueryClient, IAuthOnlyClient +{ + [SuppressMessage("Usage", "VSTHRD012:Provide JoinableTaskFactory where allowed")] + private readonly AsyncReaderWriterLock _locker = new(); + + private readonly UdpClient _socket; + private readonly IRequestFactory _requestFactory; + private readonly ISessionStorage _sessionStorage; + private readonly ILogger _logger; + + private const int ResponseTimeoutSeconds = 5; // TODO: into the config + + private static readonly IResponseParser handshakeResponseParser = new HandshakeResponseParser(); + private static readonly IResponseParser basicStatusResponseParser = new BasicStatusResponseParser(); + private static readonly IResponseParser fullStatusResponseParser = new FullStatusResponseParser(); + + internal McQueryClient( + UdpClient socket, + IRequestFactory requestFactory, + ISessionStorage sessionStorage, + ILogger logger + ) + { + _requestFactory = requestFactory; + _sessionStorage = sessionStorage; + _logger = logger; + _socket = socket; + } + + /// + public async Task GetBasicStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken) => + await SendRequestAsync( + serverEndpoint, + session => _requestFactory.GetBasicStatusRequest(session), + basicStatusResponseParser, + cancellationToken); + + /// + public async Task GetFullStatusAsync(IPEndPoint serverEndpoint, CancellationToken cancellationToken) => + await SendRequestAsync( + serverEndpoint, + session => _requestFactory.GetFullStatusRequest(session), + fullStatusResponseParser, + cancellationToken); + + /// + async Task IAuthOnlyClient.HandshakeAsync( + IPEndPoint serverEndpoint, + SessionId sessionId, + CancellationToken cancellationToken + ) + { + var packet = _requestFactory.GetHandshakeRequest(sessionId); + return await SendRequestAsync( + serverEndpoint, + packet, + handshakeResponseParser, + cancellationToken); + } + + private async Task SendRequestAsync( + IPEndPoint serverEndpoint, + Func> packetFactory, + IResponseParser responseParser, + CancellationToken cancellationToken = default + ) + { + var session = await _sessionStorage.GetAsync(serverEndpoint, cancellationToken); + var packet = packetFactory(session); + return await SendRequestAsync( + serverEndpoint, + packet, + responseParser, + cancellationToken); + } + + private async Task SendRequestAsync( + IPEndPoint serverEndpoint, + ReadOnlyMemory packet, + IResponseParser responseParser, + CancellationToken cancellationToken = default + ) + { + _logger.LogDebug( + "Sending {PacketLength} bytes to {Endpoint} with content {Content}", + packet.Length, + serverEndpoint, + BitConverter.ToString(packet.ToArray())); + + var response = await ExecuteRequestConcurrentlyAsync(); + + _logger.LogDebug( + "Received response from server {Endpoint} [{Content}]", + serverEndpoint, + BitConverter.ToString(response.Buffer)); + + var responseData = responseParser.Parse(response.Buffer); + _logger.LogDebug( + "Parsed response from server {Endpoint} \n{Response}", + serverEndpoint, + responseData); + + return responseData; + + async Task ExecuteRequestConcurrentlyAsync() + { + using var timeoutSource = cancellationToken.ToSourceWithTimeout(TimeSpan.FromSeconds(ResponseTimeoutSeconds)); + await using var _ = await _locker.WriteLockAsync(timeoutSource.Token); + + await _socket.SendAsync(packet, serverEndpoint, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); + return await _socket.ReceiveAsync(timeoutSource.Token).ConfigureAwait(continueOnCapturedContext: false); + } + } + + private bool _isDisposed; + + /// + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + + _socket.Dispose(); + _sessionStorage.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/sources/McQuery.Net/McQueryClientFactory.cs b/sources/McQuery.Net/McQueryClientFactory.cs new file mode 100644 index 0000000..a5d624e --- /dev/null +++ b/sources/McQuery.Net/McQueryClientFactory.cs @@ -0,0 +1,46 @@ +using System.Net.Sockets; +using McQuery.Net.Internal.Factories; +using McQuery.Net.Internal.Providers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace McQuery.Net; + +/// +/// Implementation of . +/// +[UsedImplicitly] +public class McQueryClientFactory : IMcQueryClientFactory +{ + private readonly ILoggerFactory? _loggerFactory; + private readonly Lazy _sessionIdProvider; + private readonly Lazy _client; + + /// + /// .ctor. + /// + /// . + public McQueryClientFactory(ILoggerFactory? loggerFactory = null) + { + _loggerFactory = loggerFactory; + _sessionIdProvider = new Lazy(() => new SessionIdProvider(), isThreadSafe: true); + _client = new Lazy(AcquireClient, isThreadSafe: true); + } + + /// + public IMcQueryClient Get() => _client.Value; + + private IMcQueryClient AcquireClient() + { + SessionStorage sessionStorage = new(_sessionIdProvider.Value); + + McQueryClient client = new( + new UdpClient(), + new RequestFactory(), + sessionStorage, + _loggerFactory?.CreateLogger() ?? new NullLogger()); + sessionStorage.Init(client); + + return client; + } +} diff --git a/sources/McQuery.Net/Services/McQueryService.cs b/sources/McQuery.Net/Services/McQueryService.cs deleted file mode 100644 index db09f95..0000000 --- a/sources/McQuery.Net/Services/McQueryService.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System.Net; -using McQuery.Net.Data; -using McQuery.Net.Data.Packages; -using McQuery.Net.Data.Packages.Responses; - -namespace McQuery.Net.Services; - -[PublicAPI] -public class McQueryService : IDisposable -{ - public McQueryService( - Random random, - uint maxTriesBeforeSocketInvalidate, - int receiveAwaitInterval, - int retryAwaitShortInterval, - int retryAwaitLongInterval) - { - sessionIdProviderService = new SessionIdProviderService(random); - ServersTimeoutCounters = new Dictionary(); - udpService = new UdpSendReceiveService(receiveAwaitInterval); - - MaxTriesBeforeSocketInvalidate = maxTriesBeforeSocketInvalidate; - RetryAwaitShortInterval = retryAwaitShortInterval; - RetryAwaitLongInterval = retryAwaitLongInterval; - } - - public McQueryService( - uint maxTriesBeforeSocketInvalidate, - int receiveAwaitInterval, - int retryAwaitShortInterval, - int retryAwaitLongInterval) - : this(new Random(), maxTriesBeforeSocketInvalidate, receiveAwaitInterval, retryAwaitShortInterval, retryAwaitLongInterval) - { - } - - private readonly SessionIdProviderService sessionIdProviderService; - - private readonly UdpSendReceiveService udpService; - - private Dictionary ServersTimeoutCounters { get; set; } - - public uint MaxTriesBeforeSocketInvalidate { get; set; } - - public int RetryAwaitShortInterval { get; set; } - - public int RetryAwaitLongInterval { get; set; } - - public Server RegistrateServer(IPEndPoint serverEndPoint) - { - SessionId sessionId = sessionIdProviderService.GenerateRandomId(); - Server server = new(sessionId, serverEndPoint.Address, serverEndPoint.Port); - ServersTimeoutCounters.Add(server, 0); - - return server; - } - - public void DisposeServer(Server server) - { - ServersTimeoutCounters.Remove(server); - server.Dispose(); - } - - private void ResetTimeoutCounter(Server server) - { - ServersTimeoutCounters[server] = 0; - } - - private async Task InvalidateChallengeToken(Server server) - { - Request request = RequestFormingService.HandshakeRequestPackage(server.SessionId); - IResponse response; - - while (true) - { - response = await udpService.SendReceive(server, request); - - if (response is TimeoutResponse) - { - if (ServersTimeoutCounters[server] > MaxTriesBeforeSocketInvalidate) - { - Task delayTask = Task.Delay(RetryAwaitLongInterval); - - server.InvalidateSocket(); - ResetTimeoutCounter(server); - - await delayTask; - - continue; - } - - ServersTimeoutCounters[server]++; - await Task.Delay(RetryAwaitShortInterval); - - continue; - } - - break; - } - - byte[] challengeToken = ResposeParsingService.ParseHandshake((RawResponse)response); - - server.ChallengeToken.UpdateToken(challengeToken); - } - - public async Task GetBasicStatusCommon(Server server) => await GetBasicStatus(server); - - public async Task GetBasicStatus(Server server) - { - if (!server.ChallengeToken.IsFine) - await InvalidateChallengeToken(server); - - IResponse response; - - while (true) - { - Request request = RequestFormingService.GetBasicStatusRequestPackage(server.SessionId, server.ChallengeToken); - response = await udpService.SendReceive(server, request); - - if (response is TimeoutResponse) - { - if (ServersTimeoutCounters[server] > MaxTriesBeforeSocketInvalidate) - { - Task delayTask = Task.Delay(RetryAwaitLongInterval); - - server.InvalidateSocket(); - Task invalidateTask = InvalidateChallengeToken(server); - ResetTimeoutCounter(server); - - Task.WaitAll(new Task[] { delayTask, invalidateTask }); - - continue; - } - - ServersTimeoutCounters[server]++; - await Task.Delay(RetryAwaitShortInterval); - - continue; - } - - break; - } - - ServerBasicStateResponse basicStateResponse = ResposeParsingService.ParseBasicState((RawResponse)response); - - return basicStateResponse; - } - - public async Task GetFullStatusCommon(Server server) => await GetFullStatus(server); - - public async Task GetFullStatus(Server server) - { - if (!server.ChallengeToken.IsFine) - await InvalidateChallengeToken(server); - - IResponse response; - - while (true) - { - Request request = RequestFormingService.GetFullStatusRequestPackage(server.SessionId, server.ChallengeToken); - response = await udpService.SendReceive(server, request); - - if (response is TimeoutResponse) - { - if (ServersTimeoutCounters[server] > MaxTriesBeforeSocketInvalidate) - { - Task delayTask = Task.Delay(RetryAwaitLongInterval); - - server.InvalidateSocket(); - Task invalidateTask = InvalidateChallengeToken(server); - ResetTimeoutCounter(server); - - Task.WaitAll(new Task[] { delayTask, invalidateTask }); - - continue; - } - - ServersTimeoutCounters[server]++; - await Task.Delay(RetryAwaitShortInterval); - - continue; - } - - break; - } - - ServerFullStateResponse fullStateResponse = ResposeParsingService.ParseFullState((RawResponse)response); - - return fullStateResponse; - } - - private bool disposed = false; - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public void Dispose(bool disposing) - { - if (disposed) return; - - if (disposing) - { - foreach (KeyValuePair record in ServersTimeoutCounters) - record.Key.Dispose(); - } - - ServersTimeoutCounters.Clear(); - - disposed = true; - } - - ~McQueryService() - { - Dispose(true); - } -} diff --git a/sources/McQuery.Net/Services/RequestFormingService.cs b/sources/McQuery.Net/Services/RequestFormingService.cs deleted file mode 100644 index 1e9a9f2..0000000 --- a/sources/McQuery.Net/Services/RequestFormingService.cs +++ /dev/null @@ -1,75 +0,0 @@ -using McQuery.Net.Data; -using McQuery.Net.Data.Packages; - -namespace McQuery.Net.Services; - -/// -/// This class builds Minecraft Query Packages for requests -/// Wiki: https://wiki.vg/Query -/// -public static class RequestFormingService -{ - private static readonly byte[] MagicConst = [0xfe, 0xfd]; - - private static readonly byte[] ChallengeRequestConst = [0x09]; - - private static readonly byte[] StatusRequestConst = [0x00]; - - public static Request HandshakeRequestPackage(SessionId sessionId) - { - List data = new(224); - data.AddRange(MagicConst); - data.AddRange(ChallengeRequestConst); - sessionId.WriteTo(data); - - Request request = new(data.ToArray()); - - return request; - } - - public static Request GetBasicStatusRequestPackage(SessionId sessionId, ChallengeToken challengeToken) - { - if (challengeToken == null) throw new ChallengeTokenIsNullException(); - - List data = new(416); - data.AddRange(MagicConst); - data.AddRange(StatusRequestConst); - sessionId.WriteTo(data); - challengeToken.WriteTo(data); - - Request request = new(data.ToArray()); - - return request; - } - - public static Request GetFullStatusRequestPackage(SessionId sessionId, ChallengeToken challengeToken) - { - if (challengeToken == null) throw new ChallengeTokenIsNullException(); - - List data = new(544); - data.AddRange(MagicConst); - data.AddRange(StatusRequestConst); - sessionId.WriteTo(data); - challengeToken.WriteTo(data); - data.AddRange([0x00, 0x00, 0x00, 0x00]); // Padding - - Request request = new(data.ToArray()); - - return request; - } -} - -public class ChallengeTokenIsNullException : Exception -{ - public ChallengeTokenIsNullException() - { - } - - public ChallengeTokenIsNullException(string? message) : base(message) - { - } - - public ChallengeTokenIsNullException(string? message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/sources/McQuery.Net/Services/ResposeParsingService.cs b/sources/McQuery.Net/Services/ResposeParsingService.cs deleted file mode 100644 index 7a390c7..0000000 --- a/sources/McQuery.Net/Services/ResposeParsingService.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Buffers; -using System.Text; -using McQuery.Net.Data; -using McQuery.Net.Data.Packages.Responses; - -namespace McQuery.Net.Services; - -/// -/// This class parses Minecraft Query response packages for getting data from it -/// Wiki: https://wiki.vg/Query -/// -public static class ResposeParsingService -{ - public static byte ParseType(byte[] data) => data[0]; - - public static SessionId ParseSessionId(ref SequenceReader reader) - { - if (reader.UnreadSequence.Length < 4) throw new IncorrectPackageDataException(reader.Sequence.ToArray()); - byte[] sessionIdBytes = new byte[4]; - Span sessionIdSpan = new(sessionIdBytes); - reader.TryCopyTo(sessionIdSpan); - reader.Advance(4); - - return new SessionId(sessionIdSpan.ToArray()); - } - - /// - /// Parses response package and returns ChallengeToken - /// - /// RawResponce package - /// byte[] array which contains ChallengeToken as big-endian - public static byte[] ParseHandshake(RawResponse rawResponse) - { - byte[] data = (byte[])rawResponse.RawData.Clone(); - - if (data.Length < 5) throw new IncorrectPackageDataException(data); - byte[] response = BitConverter.GetBytes(int.Parse(Encoding.ASCII.GetString(data, 5, rawResponse.RawData.Length - 6))); - if (BitConverter.IsLittleEndian) response = response.Reverse().ToArray(); - - return response; - } - - public static ServerBasicStateResponse ParseBasicState(RawResponse rawResponse) - { - if (rawResponse.RawData.Length <= 5) - throw new IncorrectPackageDataException(rawResponse.RawData); - - SequenceReader reader = new(new ReadOnlySequence(rawResponse.RawData)); - reader.Advance(1); // Skip Type - - SessionId sessionId = ParseSessionId(ref reader); - - string motd = ReadString(ref reader); - string gameType = ReadString(ref reader); - string map = ReadString(ref reader); - int numPlayers = int.Parse(ReadString(ref reader)); - int maxPlayers = int.Parse(ReadString(ref reader)); - - if (!reader.TryReadLittleEndian(out short port)) - throw new IncorrectPackageDataException(rawResponse.RawData); - - string hostIp = ReadString(ref reader); - - ServerBasicStateResponse serverInfo = new( - rawResponse.ServerUUID, - sessionId, - motd, - gameType, - map, - numPlayers, - maxPlayers, - port, - hostIp, - (byte[])rawResponse.RawData.Clone() - ); - - return serverInfo; - } - - private static readonly byte[] constant1 = new byte[] { 0x73, 0x70, 0x6C, 0x69, 0x74, 0x6E, 0x75, 0x6D, 0x00, 0x80, 0x00 }; - - private static readonly byte[] constant2 = new byte[] { 0x01, 0x70, 0x6C, 0x61, 0x79, 0x65, 0x72, 0x5F, 0x00, 0x00 }; - - public static ServerFullStateResponse ParseFullState(RawResponse rawResponse) - { - if (rawResponse.RawData.Length <= 5) - throw new IncorrectPackageDataException(rawResponse.RawData); - - SequenceReader reader = new(new ReadOnlySequence(rawResponse.RawData)); - reader.Advance(1); // Skip Type - - SessionId sessionId = ParseSessionId(ref reader); - - if (!reader.IsNext(constant1, true)) - throw new IncorrectPackageDataException(rawResponse.RawData); - - Dictionary statusKeyValues = new(); - while (!reader.IsNext(0, true)) - { - string key = ReadString(ref reader); - string value = ReadString(ref reader); - statusKeyValues.Add(key, value); - } - - if (!reader.IsNext(constant2, true)) // Padding: 10 bytes constant - throw new IncorrectPackageDataException(rawResponse.RawData); - - List players = new(); - while (!reader.IsNext(0, true)) players.Add(ReadString(ref reader)); - - ServerFullStateResponse fullState = new - ( - rawResponse.ServerUUID, - sessionId, - statusKeyValues["hostname"], - statusKeyValues["gametype"], - statusKeyValues["game_id"], - statusKeyValues["version"], - statusKeyValues["plugins"], - statusKeyValues["map"], - int.Parse(statusKeyValues["numplayers"]), - int.Parse(statusKeyValues["maxplayers"]), - players.ToArray(), - hostIp: statusKeyValues["hostip"], - hostPort: int.Parse(statusKeyValues["hostport"]), - rawData: (byte[])rawResponse.RawData.Clone() - ); - - return fullState; - } - - private static string ReadString(ref SequenceReader reader) - { - if (!reader.TryReadTo(out ReadOnlySequence bytes, 0, true)) - throw new IncorrectPackageDataException("Zero byte not found", reader.Sequence.ToArray()); - - return Encoding.ASCII.GetString(bytes); // а точно ASCII? Может, Utf8? - } -} - -public class IncorrectPackageDataException : Exception -{ - public byte[] data { get; } - - public IncorrectPackageDataException(byte[] data) - { - this.data = data; - } - - public IncorrectPackageDataException(string? message, byte[] data) : base(message) - { - this.data = data; - } - - public IncorrectPackageDataException(string? message, Exception? innerException, byte[] data) : base(message, innerException) - { - this.data = data; - } -} diff --git a/sources/McQuery.Net/Services/SessionIdProviderService.cs b/sources/McQuery.Net/Services/SessionIdProviderService.cs deleted file mode 100644 index 60b9732..0000000 --- a/sources/McQuery.Net/Services/SessionIdProviderService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using McQuery.Net.Data; -using McQuery.Net.Utils; - -namespace McQuery.Net.Services; - -public class SessionIdProviderService -{ - public SessionIdProviderService(Random random) - { - this.random = random; - reservedIds = []; - idCounter = new ByteCounter(); - } - - - private readonly List reservedIds; - - private readonly Random random; - - public SessionId GenerateRandomId() - { - byte[] sessionIdData = new byte[4]; - SessionId sessionId; - - do - { - random.NextBytes(sessionIdData); - for (int i = 0; i < sessionIdData.Length; ++i) sessionIdData[i] &= 0x0F; - - sessionId = new SessionId(sessionIdData); - } while (IsIdReserved(sessionId)); - - ReserveId(sessionId); - - return sessionId; - } - - - private readonly ByteCounter idCounter; - - public SessionId GetUniqueId() - { - byte[] sessionIdData = new byte[4]; - if (!idCounter.GetNext(sessionIdData)) - { - // find released sessionIds - } - - SessionId sessionId = new(sessionIdData); - ReserveId(sessionId); - - return sessionId; - } - - private void ReserveId(SessionId sessionId) - { - reservedIds.Add(sessionId); - } - - public bool IsIdReserved(SessionId sessionId) => reservedIds.IndexOf(sessionId) != -1; -} diff --git a/sources/McQuery.Net/Services/UdpSendReceiveService.cs b/sources/McQuery.Net/Services/UdpSendReceiveService.cs deleted file mode 100644 index 1b8d070..0000000 --- a/sources/McQuery.Net/Services/UdpSendReceiveService.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using McQuery.Net.Data; -using McQuery.Net.Data.Packages; -using McQuery.Net.Data.Packages.Responses; - -namespace McQuery.Net.Services; - -// todo: add resend N times before returning TimeoutResponse -// todo: add cancellation token support -public class UdpSendReceiveService -{ - public UdpSendReceiveService(int receiveAwaitInterval) - { - ReceiveAwaitInterval = receiveAwaitInterval; - } - - public int ReceiveAwaitInterval { get; set; } - - public async Task SendReceive(Server server, Request request) - { - UdpClient client = server.UdpClient; - - IPEndPoint? ipEndPoint = null; - byte[]? response = null; - - await server.UdpClientSemaphoreSlim.WaitAsync(); - await server.UdpClient.SendAsync(request.RawRequestData, request.RawRequestData.Length); - IAsyncResult responseToken; - - try - { - responseToken = server.UdpClient.BeginReceive(null, null); - } - catch (SocketException) - { - server.UdpClientSemaphoreSlim.Release(); - - return new TimeoutResponse(server.UUID); - } - - responseToken.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(ReceiveAwaitInterval)); - if (responseToken.IsCompleted) - { - try - { - response = server.UdpClient.EndReceive(responseToken, ref ipEndPoint); - } - - catch (Exception) - { - server.UdpClientSemaphoreSlim.Release(); - - return new TimeoutResponse(server.UUID); - } - } - else - { - server.UdpClientSemaphoreSlim.Release(); - - return new TimeoutResponse(server.UUID); - } - - if (response == null) - { - server.UdpClientSemaphoreSlim.Release(); - - return new TimeoutResponse(server.UUID); - } - - server.UdpClientSemaphoreSlim.Release(); - - return new RawResponse(server.UUID, response); - } -} diff --git a/sources/McQuery.Net/Utils/ByteCounter.cs b/sources/McQuery.Net/Utils/ByteCounter.cs deleted file mode 100644 index ca260e0..0000000 --- a/sources/McQuery.Net/Utils/ByteCounter.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace McQuery.Net.Utils; - -internal class ByteCounter -{ - private readonly byte[] countUnits; - - public ByteCounter() - { - countUnits = new byte[4]; - Reset(); - } - - public bool GetNext(byte[] receiver) - { - for (int i = 0; i < countUnits.Length; ++i) - { - if (countUnits[i] < 0x0F) - { - countUnits[i]++; - countUnits.CopyTo(receiver, 0); - - return true; - } - - countUnits[i] = 0x00; - } - - return false; - } - - public void Reset() - { - for (int i = 0; i < countUnits.Length; ++i) countUnits[i] = 0; - } -}