From c5b8bf03e8599a8cd33cfa82c4262c63ed8cfa5e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 19 May 2026 21:28:29 +0200 Subject: [PATCH 01/31] wip # Conflicts: # Directory.Packages.props --- .editorconfig | 2 + .github/actions/setup-runner/action.yml | 5 + .gitignore | 4 +- .nuke/build.schema.json | 13 + Directory.Packages.props | 8 + Drift.Build.slnx | 2 + Drift.sln | 69 ++++ README_dev.md | 4 + .../NukeBuild/TestNukeBuild.cs | 1 + .../Versioning/TestNukeBuildExtensions.cs | 2 + .../Versioning/VersioningTests.cs | 4 + build/NukeBuild.Test.cs | 2 +- build/NukeBuild.TestContainerlab.cs | 353 ++++++++++++++++++ build/NukeBuild.cs | 2 + build/_build.csproj | 9 +- containerlab/README.md | 124 ++++++ containerlab/cooperation-test-spec.yaml | 21 ++ containerlab/cooperation-test.clab.yaml | 70 ++++ containerlab/simple-test-spec.yaml | 11 + containerlab/simple-test.clab.yaml | 26 ++ containerlab/subnet-isolation-test-spec.yaml | 18 + containerlab/subnet-isolation-test.clab.yaml | 112 ++++++ src/Agent.Hosting/Agent.Hosting.csproj | 14 + src/Agent.Hosting/AgentHost.cs | 70 ++++ .../Identity/AgentIdentity.Serialization.cs | 77 ++++ src/Agent.Hosting/Identity/AgentIdentity.cs | 10 + .../Identity/AgentIdentityJsonContext.cs | 17 + .../DefaultAgentIdentityLocationProvider.cs | 24 ++ .../IAgentIdentityLocationProvider.cs | 9 + .../Agent.PeerProtocol.Tests.csproj | 19 + src/Agent.PeerProtocol.Tests/AssemblyInfo.cs | 1 + .../PeerMessageHandlerTests.cs | 59 +++ .../Adopt/AdoptRequestHandler.cs | 24 ++ .../Adopt/AdoptRequestPayload.cs | 22 ++ .../Adopt/AdoptResponsePayload.cs | 18 + .../Agent.PeerProtocol.csproj | 10 + .../PeerProtocolAssemblyMarker.cs | 7 + .../Scan/ScanSubnetCompleteResponse.cs | 25 ++ .../Scan/ScanSubnetProgressUpdate.cs | 30 ++ .../Scan/ScanSubnetRequest.cs | 30 ++ .../Scan/ScanSubnetRequestHandler.cs | 112 ++++++ .../ServiceCollectionExtensions.cs | 15 + .../Subnets/SubnetsRequest.cs | 14 + .../Subnets/SubnetsRequestHandler.cs | 29 ++ .../Subnets/SubnetsResponse.cs | 25 ++ src/ArchTests/SanityTests.cs | 4 +- ...alOptionsTests.HelpOptionTest.verified.txt | 1 + ...ommand_ReturnsSuccessExitCode.verified.txt | 1 + .../TemporarySettingsLocationProvider.cs | 5 +- src/Cli.Tests/BadSettingsFileTests.cs | 2 +- src/Cli.Tests/Cli.Tests.csproj | 1 + ...entCommandTests.MissingOption.verified.txt | 1 + ...ommandTests.SuccessfulStartup.verified.txt | 10 + src/Cli.Tests/Commands/AgentCommandTests.cs | 51 +++ src/Cli.Tests/Commands/InitCommandTests.cs | 8 +- src/Cli.Tests/Commands/LintCommandTests.cs | 6 +- ...ommandTests.Remote.RemoteScan.verified.txt | 18 + .../Commands/ScanCommandTests.Remote.cs | 348 +++++++++++++++++ .../ScanCommandTests.RemoteScan.verified.txt | 24 ++ ..._AgentsOnly_NoLocalInterfaces.verified.txt | 19 + ...Tests.RemoteScan_EmptyResults.verified.txt | 15 + ...RemoteScan_OverlappingSubnets.verified.txt | 23 ++ src/Cli.Tests/Commands/ScanCommandTests.cs | 100 ++++- ...emCommandLineDefaultErrorTest.verified.txt | 1 + ...eptionReturnsUnknownErrorTest.verified.txt | 2 +- src/Cli.Tests/ExitCodeTests.cs | 34 +- src/Cli.Tests/FeatureFlagTest.cs | 80 ++++ src/Cli.Tests/SpecFilePathResolverTests.cs | 2 +- src/Cli.Tests/Utils/CliCommandResult.cs | 24 ++ src/Cli.Tests/Utils/DriftTestCli.cs | 103 ++++- src/Cli.Tests/Utils/RunningCliCommand.cs | 20 + src/Cli/Cli.csproj | 13 +- src/Cli/Commands/Agent/AgentCommand.cs | 24 ++ .../Agent/Subcommands/AgentLifetime.cs | 7 + .../Subcommands/Start/AgentStartCommand.cs | 122 ++++++ .../Subcommands/Start/AgentStartParameters.cs | 64 ++++ .../Common/{ => Commands}/CommandBase.cs | 23 +- .../Common/Commands/ContainerCommandBase.cs | 8 + .../Common/{ => Commands}/ICommandHandler.cs | 6 +- src/Cli/Commands/Common/CommonParameters.cs | 5 +- src/Cli/Commands/Common/DefaultParameters.cs | 19 - .../Common/Parameters/BaseParameters.cs | 14 + .../Common/Parameters/SpecParameters.cs | 13 + src/Cli/Commands/Common/ParseResultHolder.cs | 2 +- src/Cli/Commands/Init/InitCommand.cs | 6 +- src/Cli/Commands/Init/InitParameters.cs | 4 +- src/Cli/Commands/Lint/LintCommand.cs | 5 +- src/Cli/Commands/Lint/LintParameters.cs | 4 +- src/Cli/Commands/Preview/AgentCommand.cs | 36 -- src/Cli/Commands/Scan/AgentSubnetProvider.cs | 48 +++ src/Cli/Commands/Scan/ClusterExtensions.cs | 50 +++ .../Scan/DistributedNetworkScanner.cs | 304 +++++++++++++++ .../SubnetScanResultProcessor.cs | 2 +- src/Cli/Commands/Scan/ScanCommand.cs | 158 ++++++-- src/Cli/Commands/Scan/ScanParameters.cs | 3 +- src/Cli/DriftCli.cs | 2 +- src/Cli/Infrastructure/RootCommandFactory.cs | 45 ++- .../Logging/NormalOutputLoggerAdapter.cs | 20 + .../Logging/OutputManagerExtensions.cs | 12 + .../Console/Managers/ConsoleOutputManager.cs | 5 +- .../Console/Managers/Outputs/NormalOutput.cs | 23 +- .../Console/OutputManagerFactory.cs | 31 +- src/Cli/Properties/launchSettings.json | 14 +- src/Common/Common.csproj | 4 +- src/Diff.Tests/DiffTest.cs | 20 +- src/Domain/AgentId.cs | 38 ++ src/Domain/Environment.cs | 65 ++++ src/Domain/Inventory.cs | 5 + src/Domain/RequestId.cs | 23 ++ src/Networking.Cluster/Cluster.cs | 210 +++++++++++ src/Networking.Cluster/ClusterOptions.cs | 43 +++ src/Networking.Cluster/Enrollment.cs | 12 + src/Networking.Cluster/ICluster.cs | 29 ++ .../Networking.Cluster.csproj | 7 + .../ServiceCollectionExtensions.cs | 9 + .../DefaultPeerClientFactory.cs | 13 + .../Networking.PeerStreaming.Client.csproj | 11 + .../ServiceCollectionExtensions.cs | 10 + .../ConnectionSide.cs | 6 + .../Empty.cs | 16 + .../IPeerClientFactory.cs | 8 + .../IPeerMessage.cs | 21 ++ .../IPeerMessageEnvelopeConverter.cs | 9 + .../IPeerMessageHandler.cs | 23 ++ .../IPeerMessageTypesProvider.cs | 7 + .../IPeerStream.cs | 20 + .../IPeerStreamManager.cs | 15 + ...ing.PeerStreaming.Core.Abstractions.csproj | 12 + .../PeerMessageHandlerExtensions.cs | 36 ++ .../Common/GrpcMetadataExtensions.cs | 16 + .../Messages/PeerMessageDispatcher.cs | 48 +++ .../Messages/PeerMessageEnvelopeConverter.cs | 22 ++ .../Messages/PeerResponseCorrelator.cs | 108 ++++++ .../Networking.PeerStreaming.Core.csproj | 14 + .../PeerStream.cs | 121 ++++++ .../PeerStreamManager.cs | 79 ++++ .../PeerStreamingOptions.cs | 15 + .../ServiceCollectionExtensions.cs | 18 + .../Networking.PeerStreaming.Grpc.csproj | 13 + .../Protos/peer.proto | 17 + .../InboundPeerService.cs | 31 ++ .../Networking.PeerStreaming.Server.csproj | 15 + .../PeerStreamingServerMarker.cs | 8 + .../ServiceCollectionExtensions.cs | 47 +++ .../AssemblyInfo.cs | 1 + .../Helpers/InMemoryDuplexStreamPair.cs | 121 ++++++ .../Helpers/TestPeerMessage.cs | 37 ++ .../Helpers/TestServerCallContext.cs | 74 ++++ .../TestServerCallContextExtensions.cs | 23 ++ .../InboundTests.cs | 77 ++++ .../Networking.PeerStreaming.Tests.csproj | 20 + .../PeerStreamManagerTests.cs | 45 +++ .../Scanners/PingSubnetScannerBase.cs | 37 +- .../Subnets/CompositeSubnetProvider.cs | 7 +- src/Scanning/Subnets/IResolvedSubnet.cs | 26 ++ src/Scanning/Subnets/ISubnetProvider.cs | 4 +- .../Interface/InterfaceSubnetProviderBase.cs | 5 +- .../Subnets/PredefinedSubnetProvider.cs | 16 +- .../Converters/CidrBlockConverter.cs | 25 ++ .../Converters/DeviceAddressConverter.cs | 38 ++ .../Converters/IpAddressConverter.cs | 21 ++ .../Converters/IpV4AddressSetConverter.cs | 43 +++ src/Serialization/Serialization.csproj | 4 + src/Spec.Tests/ValidationTests.cs | 5 +- src/Spec/Dtos/V1_preview/DriftSpec.cs | 20 + .../V1_preview/Mappers/Mapper.ToDomain.cs | 49 ++- .../Dtos/V1_preview/Mappers/Mapper.ToDto.cs | 28 +- src/Spec/Schema/SchemaGenerator.cs | 3 +- src/Spec/Serialization/YamlStaticContext.cs | 16 +- src/Spec/Validation/SpecValidator.cs | 18 +- .../schemas/drift-spec-v1-preview.schema.json | 22 ++ src/TestUtilities/StringLogger.cs | 6 +- 172 files changed, 5257 insertions(+), 296 deletions(-) create mode 100644 build/NukeBuild.TestContainerlab.cs create mode 100644 containerlab/README.md create mode 100644 containerlab/cooperation-test-spec.yaml create mode 100644 containerlab/cooperation-test.clab.yaml create mode 100644 containerlab/simple-test-spec.yaml create mode 100644 containerlab/simple-test.clab.yaml create mode 100644 containerlab/subnet-isolation-test-spec.yaml create mode 100644 containerlab/subnet-isolation-test.clab.yaml create mode 100644 src/Agent.Hosting/Agent.Hosting.csproj create mode 100644 src/Agent.Hosting/AgentHost.cs create mode 100644 src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs create mode 100644 src/Agent.Hosting/Identity/AgentIdentity.cs create mode 100644 src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs create mode 100644 src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs create mode 100644 src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs create mode 100644 src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj create mode 100644 src/Agent.PeerProtocol.Tests/AssemblyInfo.cs create mode 100644 src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs create mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs create mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs create mode 100644 src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs create mode 100644 src/Agent.PeerProtocol/Agent.PeerProtocol.csproj create mode 100644 src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs create mode 100644 src/Agent.PeerProtocol/ServiceCollectionExtensions.cs create mode 100644 src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs create mode 100644 src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs create mode 100644 src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt create mode 100644 src/Cli.Tests/FeatureFlagTest.cs create mode 100644 src/Cli.Tests/Utils/CliCommandResult.cs create mode 100644 src/Cli.Tests/Utils/RunningCliCommand.cs create mode 100644 src/Cli/Commands/Agent/AgentCommand.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs rename src/Cli/Commands/Common/{ => Commands}/CommandBase.cs (54%) create mode 100644 src/Cli/Commands/Common/Commands/ContainerCommandBase.cs rename src/Cli/Commands/Common/{ => Commands}/ICommandHandler.cs (56%) delete mode 100644 src/Cli/Commands/Common/DefaultParameters.cs create mode 100644 src/Cli/Commands/Common/Parameters/BaseParameters.cs create mode 100644 src/Cli/Commands/Common/Parameters/SpecParameters.cs delete mode 100644 src/Cli/Commands/Preview/AgentCommand.cs create mode 100644 src/Cli/Commands/Scan/AgentSubnetProvider.cs create mode 100644 src/Cli/Commands/Scan/ClusterExtensions.cs create mode 100644 src/Cli/Commands/Scan/DistributedNetworkScanner.cs create mode 100644 src/Domain/AgentId.cs create mode 100644 src/Domain/Environment.cs create mode 100644 src/Domain/RequestId.cs create mode 100644 src/Networking.Cluster/Cluster.cs create mode 100644 src/Networking.Cluster/ClusterOptions.cs create mode 100644 src/Networking.Cluster/Enrollment.cs create mode 100644 src/Networking.Cluster/ICluster.cs create mode 100644 src/Networking.Cluster/Networking.Cluster.csproj create mode 100644 src/Networking.Cluster/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs create mode 100644 src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj create mode 100644 src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/Empty.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs create mode 100644 src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs create mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs create mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs create mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs create mode 100644 src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj create mode 100644 src/Networking.PeerStreaming.Core/PeerStream.cs create mode 100644 src/Networking.PeerStreaming.Core/PeerStreamManager.cs create mode 100644 src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs create mode 100644 src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj create mode 100644 src/Networking.PeerStreaming.Grpc/Protos/peer.proto create mode 100644 src/Networking.PeerStreaming.Server/InboundPeerService.cs create mode 100644 src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj create mode 100644 src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs create mode 100644 src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Tests/AssemblyInfo.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs create mode 100644 src/Networking.PeerStreaming.Tests/InboundTests.cs create mode 100644 src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj create mode 100644 src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs create mode 100644 src/Scanning/Subnets/IResolvedSubnet.cs create mode 100644 src/Serialization/Converters/CidrBlockConverter.cs create mode 100644 src/Serialization/Converters/DeviceAddressConverter.cs create mode 100644 src/Serialization/Converters/IpAddressConverter.cs create mode 100644 src/Serialization/Converters/IpV4AddressSetConverter.cs diff --git a/.editorconfig b/.editorconfig index 0c9e904f..8c034db3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -225,6 +225,8 @@ dotnet_diagnostic.SA1502.severity = none dotnet_diagnostic.SA1508.severity = none # SA1516 Elements should be separated by blank line (but reports false positives) dotnet_diagnostic.SA1516.severity = none +# SA1201 An element within a C# code file is out of order in relation to the other elements in the code. +dotnet_diagnostic.SA1201.severity = none # TODO TBD diff --git a/.github/actions/setup-runner/action.yml b/.github/actions/setup-runner/action.yml index b95a16c3..df4f17d8 100644 --- a/.github/actions/setup-runner/action.yml +++ b/.github/actions/setup-runner/action.yml @@ -19,3 +19,8 @@ runs: - name: Restore .NET tools shell: bash run: dotnet tool restore + + - name: Install Containerlab + if: runner.os == 'Linux' + shell: bash + run: bash -c "$(curl -sL https://get.containerlab.dev)" diff --git a/.gitignore b/.gitignore index d3ed220a..80a95396 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ obj/ #*.idea .idea/.idea.Drift/.idea/watcherTasks.xml .idea/.idea.Drift/.idea/encodings.xml +.idea/.idea.Drift.Build/.idea/encodings.xml *.DotSettings *.received.* artifacts/ @@ -14,4 +15,5 @@ TestResults/ build.binlog build.binlog-warnings-only.log publish.binlog -publish.binlog-warnings-only.log \ No newline at end of file +publish.binlog-warnings-only.log +containerlab/*/ \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 092c5c15..af8e28ea 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -58,6 +58,7 @@ "Test", "TestE2E", "TestE2E_Binary", + "TestE2E_Clab", "TestE2E_Container", "TestE2E_General", "TestLocal", @@ -137,6 +138,10 @@ "allOf": [ { "properties": { + "ClabTopology": { + "type": "string", + "description": "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified" + }, "Commit": { "type": "string", "description": "Commit - e.g. '4c16978aa41a3b435c0b2e34590f1759c1dc0763'" @@ -167,6 +172,10 @@ "description": "GitHubToken - GitHub token used to create releases", "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, + "KeepClabRunning": { + "type": "boolean", + "description": "Keep Containerlab topology running after tests" + }, "MsBuildVerbosity": { "type": "string", "description": "MsBuildVerbosity - Console output verbosity - Default is 'normal'" @@ -218,6 +227,10 @@ "description": "ReleaseType - None (default/safe), PreRelease, or Release", "$ref": "#/definitions/ReleaseType" }, + "SkipClabDeploy": { + "type": "boolean", + "description": "Skip Containerlab deployment (useful for debugging when topology is already running)" + }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" diff --git a/Directory.Packages.props b/Directory.Packages.props index ef361756..73587bb1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,12 @@ + + + + + + @@ -18,6 +24,7 @@ + @@ -41,6 +48,7 @@ + diff --git a/Drift.Build.slnx b/Drift.Build.slnx index 7b2e0f28..e6781204 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -2,8 +2,10 @@ + + diff --git a/Drift.sln b/Drift.sln index 0086ed09..a42a0e23 100644 --- a/Drift.sln +++ b/Drift.sln @@ -81,6 +81,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Commo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "E2E", "E2E", "{D1DBBF0F-1A0D-486C-A893-ACEB667F2A63}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Grpc", "src\Networking.PeerStreaming.Grpc\Networking.PeerStreaming.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Cluster", "src\Networking.Cluster\Networking.Cluster.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core", "src\Networking.PeerStreaming.Core\Networking.PeerStreaming.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Hosting", "src\Agent.Hosting\Agent.Hosting.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Server", "src\Networking.PeerStreaming.Server\Networking.PeerStreaming.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Client", "src\Networking.PeerStreaming.Client\Networking.PeerStreaming.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core.Abstractions", "src\Networking.PeerStreaming.Core.Abstractions\Networking.PeerStreaming.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Tests", "src\Networking.PeerStreaming.Tests\Networking.PeerStreaming.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol", "src\Agent.PeerProtocol\Agent.PeerProtocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol.Tests", "src\Agent.PeerProtocol.Tests\Agent.PeerProtocol.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -193,11 +215,58 @@ Global {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.Build.0 = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.Build.0 = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.Build.0 = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.ActiveCfg = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.Build.0 = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.Build.0 = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.Build.0 = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.Build.0 = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.Build.0 = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.Build.0 = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.Build.0 = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.Build.0 = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {FEA2FBBE-785F-4187-8242-FD348F9E78AF} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {272166CF-E425-45F8-984F-FAFD3CE953C9} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {DD70FBC7-8367-45B3-8D3D-757F1CDF6531} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {80445644-7342-4C6D-88E5-BF27126FE9A2} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {A26B4527-6EBF-4A20-8E75-945CCD59016B} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {E69772D3-8A07-414F-8F9A-30370D81A972} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {9DFDD692-22F8-4F9A-8808-94E318863D23} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} EndGlobalSection EndGlobal diff --git a/README_dev.md b/README_dev.md index df6d5434..eaa861b0 100644 --- a/README_dev.md +++ b/README_dev.md @@ -35,6 +35,10 @@ One or more addresses (MAC, IPv4, IPv6, and/or hostname) that together serve as a unique identifier for a network device. +- **Agent** + A running instance of Drift in agent mode that reports network state to other Drift peers. + Agents help ensure full network visibility by uncovering state that's only observable when scanning from specific subnets. + ## Concepts ### Device ID diff --git a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs index dd4c6d05..7c09695c 100644 --- a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs +++ b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Drift.Build.Utilities.Versioning.Abstractions; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs index ce055e5d..34e0bb60 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using Drift.Build.Utilities.Tests.NukeBuild; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index 6819a80d..85c8313b 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text.RegularExpressions; using Drift.Build.Utilities.Tests.NukeBuild; using Drift.Build.Utilities.Versioning; @@ -7,6 +9,8 @@ using Nuke.Common; using Nuke.Common.Git; using Octokit; +using TUnit.Assertions.Extensions; +using TUnit.Core; using Assert = TUnit.Assertions.Assert; namespace Drift.Build.Utilities.Tests.Versioning; diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index 75d54042..13e15344 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -22,7 +22,7 @@ sealed partial class NukeBuild { throw new PlatformNotSupportedException(); Target Test => _ => _ - .DependsOn( TestSelf, TestUnit, TestE2E ); + .DependsOn( TestSelf, TestUnit, TestE2E, TestE2E_Clab ); Target TestSelf => _ => _ .Before( BuildInfo ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs new file mode 100644 index 00000000..0d3313e1 --- /dev/null +++ b/build/NukeBuild.TestContainerlab.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Drift.Build.Utilities; +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tooling; +using Nuke.Common.Tools.DotNet; +using Serilog; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + [Parameter( "Skip Containerlab deployment (useful for debugging when topology is already running)" )] + readonly bool SkipClabDeploy = false; + + [Parameter( "Keep Containerlab topology running after tests" )] + readonly bool KeepClabRunning = false; + + [Parameter( "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified." )] + readonly string ClabTopology = null; + + /// + /// Defines all Containerlab integration test cases. + /// Each test case specifies a topology, its spec file, the CLI container name, + /// and assertions to validate the scan output. + /// + private static readonly ContainerlabTestCase[] TestCases = [ + new( + Name: "simple-test", + TopologyFile: "simple-test.clab.yaml", + SpecFile: "simple-test-spec.yaml", + CliContainer: "clab-drift-simple-test-cli", + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "Both scans successful (local + agent)", + output => output.Contains( "2/2 scan operations successful" ) ), + new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + ] + ), + new( + Name: "cooperation-test", + TopologyFile: "cooperation-test.clab.yaml", + SpecFile: "cooperation-test-spec.yaml", + CliContainer: "clab-drift-cooperation-test-cli", + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "All 4 scans successful (local + 3 agents)", + output => output.Contains( "4/4 scan operations successful" ) ), + new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + ] + ), + new( + Name: "subnet-isolation-test", + TopologyFile: "subnet-isolation-test.clab.yaml", + SpecFile: "subnet-isolation-test-spec.yaml", + CliContainer: "clab-drift-subnet-isolation-test-cli", + Assertions: [ + new ScanAssertion( "Subnet-A scanned", output => output.Contains( "192.168.10.0/24" ) ), + new ScanAssertion( "Subnet-B scanned", output => output.Contains( "192.168.20.0/24" ) ), + new ScanAssertion( "All scan operations successful", + output => output.Contains( "7/7 scan operations successful" ) ), + new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + ] + ), + ]; + + Target TestE2E_Clab => _ => _ + .DependsOn( BuildContainerImage ) + .After( TestUnit, TestE2E_Container ) + .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) + .Executes( async () => { + using var _ = new OperationTimer( nameof(TestE2E_Clab) ); + + var imageRef = _driftImageRef ?? throw new ArgumentNullException( nameof(_driftImageRef) ); + Log.Information( "Using image {ImageRef} for Containerlab tests", imageRef ); + + if ( !RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + Log.Warning( "Containerlab tests require Linux. Skipping." ); + return; + } + + if ( !await IsContainerlabAvailableAsync() ) { + throw new Exception( + "Containerlab does not appear to be installed or in PATH. " + + "See https://containerlab.dev/install/ for installation instructions." + ); + } + + var casesToRun = SelectTestCases(); + + Log.Information( + "Running {Count} Containerlab test case(s): {Names}", + casesToRun.Length, + string.Join( ", ", casesToRun.Select( tc => tc.Name ) ) + ); + + var total = casesToRun.Length; + var passed = 0; + var failed = 0; + + foreach ( var testCase in casesToRun ) { + var run = passed + failed + 1; + Log.Information( "━━━ Test case: {Name} ({Run}/{Total}) ━━━", testCase.Name, run, total ); + + if ( await RunTestCaseAsync( testCase ) ) { + passed++; + Log.Information( "PASS: {Name}", testCase.Name ); + } + else { + failed++; + Log.Error( "FAIL: {Name}", testCase.Name ); + } + } + + Log.Information( "Containerlab integration tests: {Passed} passed, {Failed} failed", passed, failed ); + + if ( failed > 0 ) { + throw new Exception( $"{failed} Containerlab test case(s) failed" ); + } + } + ); + + private ContainerlabTestCase[] SelectTestCases() { + if ( ClabTopology == null ) { + return TestCases; + } + + var selected = TestCases.Where( tc => tc.Name == ClabTopology ).ToArray(); + if ( !selected.Any() ) { + throw new Exception( + $"No test case found matching topology '{ClabTopology}'. " + + $"Valid names: {string.Join( ", ", TestCases.Select( tc => tc.Name ) )}" + ); + } + + return selected; + } + + private async Task RunTestCaseAsync( ContainerlabTestCase testCase ) { + var topoFile = Paths.ContainerlabsDirectory / testCase.TopologyFile; + var specFile = Paths.ContainerlabsDirectory / testCase.SpecFile; + + if ( !File.Exists( topoFile ) ) { + Log.Error( "Topology file not found: {File}", topoFile ); + return false; + } + + if ( !File.Exists( specFile ) ) { + Log.Error( "Spec file not found: {File}", specFile ); + return false; + } + + try { + if ( SkipClabDeploy ) { + Log.Information( "Skipping deployment (--skip-clab-deploy)" ); + } + else { + await DeployTopologyAsync( testCase.TopologyFile ); + } + + await RunScanAndAssertAsync( specFile, testCase ); + return true; + } + catch ( Exception ex ) { + Log.Error( "Test case '{Name}' failed: {Error}", testCase.Name, ex.Message ); + return false; + } + finally { + if ( KeepClabRunning ) { + Log.Information( "Keeping topology running (--keep-clab-running)" ); + } + else { + await DestroyTopologyAsync( testCase.TopologyFile ); + } + } + } + + private static async Task IsContainerlabAvailableAsync() { + try { + var versionOutput = await CommandRunner.RunAsync( "containerlab", "version" ); + Log.Debug( "\n{Version}", versionOutput ); + return true; + } + catch { + return false; + } + } + + private static async Task DeployTopologyAsync( string topologyFile ) { + Log.Information( "Deploying topology: {File}", topologyFile ); + + DestroyTopologyIfExists( topologyFile ); + EnsureClabManagementNetwork(); + + Clab( + $"deploy --topo {topologyFile}", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 5 ) + ).AssertZeroExitCode(); + + Log.Information( "Waiting for containers to be ready..." ); + await Task.Delay( TimeSpan.FromSeconds( 10 ) ); + } + + private static void DestroyTopologyIfExists( string topologyFile ) { + try { + Clab( + $"destroy --topo {topologyFile} --cleanup", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 2 ), + logOutput: false + ).AssertZeroExitCode(); + } + catch { + Log.Debug( "No existing topology to destroy (or destroy failed — continuing)" ); + } + } + + /// + /// Pre-creates the 'clab' management network before deploying. + /// + /// Rootless Podman with pasta networking does NOT create kernel bridge interfaces. + /// Containerlab always tries `ip link show br-<network-id>` immediately after + /// creating a new network, which fatally fails ("Link not found") because no + /// kernel bridge was created. However, when the network already exists, + /// Containerlab skips the creation step and reuses it — avoiding the fatal lookup. + /// + /// Strategy: try to remove any stale 'clab' network (ignore failure — may be in + /// use by another running topology), then create it. Ignore "already exists" errors + /// from create — the important thing is the network is present before deploy. + /// + private static void EnsureClabManagementNetwork() { + Log.Debug( "Pre-creating Containerlab management network..." ); + + // Ignore failure — network may not exist yet, or may still be in use by another topology + var rm = ProcessTasks.StartProcess( "docker", "network rm clab", logOutput: false ); + rm.WaitForExit(); + + // Ignore failure — "network already exists" is acceptable; we just need it to be present + var create = ProcessTasks.StartProcess( + "docker", "network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab", + logOutput: false + ); + create.WaitForExit(); + + Log.Debug( "Management network 'clab' ready" ); + } + + private static async Task DestroyTopologyAsync( string topologyFile ) { + Log.Information( "Destroying topology: {File}", topologyFile ); + try { + Clab( + $"destroy --topo {topologyFile} --cleanup", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 2 ) + ).AssertZeroExitCode(); + } + catch ( Exception ex ) { + Log.Warning( "Failed to destroy topology: {Error}", ex.Message ); + } + } + + private static async Task RunScanAndAssertAsync( AbsolutePath specFile, ContainerlabTestCase testCase ) { + Log.Information( "Running scan for test case: {Name}", testCase.Name ); + + // Give agent(s) a moment to finish starting up + await Task.Delay( TimeSpan.FromSeconds( 5 ) ); + + Log.Debug( "Copying spec to CLI container {Container}...", testCase.CliContainer ); + Docker( $"cp {specFile} {testCase.CliContainer}:/tmp/spec.yaml" ).AssertZeroExitCode(); + + Log.Information( "Running scan in {Container}...", testCase.CliContainer ); + var scanResult = Docker( + $"exec {testCase.CliContainer} /app/drift scan /tmp/spec.yaml", + timeout: TimeSpan.FromMinutes( 5 ) + ); + + foreach ( var line in scanResult.Output ) { + Log.Debug( "[scan:{Name}] {Line}", testCase.Name, line.Text ); + } + + scanResult.AssertZeroExitCode(); + + AssertScanOutput( testCase, scanResult.Output.Select( o => o.Text ) ); + } + + private static void AssertScanOutput( ContainerlabTestCase testCase, IEnumerable outputLines ) { + var output = string.Join( "\n", outputLines ); + var failures = new List(); + + foreach ( var assertion in testCase.Assertions ) { + if ( assertion.Check( output ) ) { + Log.Debug( "Assertion passed: {Description}", assertion.Description ); + } + else { + failures.Add( assertion.Description ); + Log.Error( "Assertion failed: {Description}", assertion.Description ); + } + } + + if ( failures.Count > 0 ) { + Log.Error( "Scan output was:\n{Output}", output ); + var failList = string.Join( "\n", failures.Select( f => $" FAIL: {f}" ) ); + throw new Exception( $"Scan assertions failed for '{testCase.Name}':\n{failList}" ); + } + + Log.Information( "All {Count} assertions passed for '{Name}'", testCase.Assertions.Length, testCase.Name ); + } + + // ── Process helpers ──────────────────────────────────────────────────────── + + /// + /// Containerlab writes all its output to stderr by design. + /// Use a custom logger that routes both stdout and stderr to Debug + /// to avoid GH Actions annotating every Containerlab log line as an error. + /// + private static void ClabLogger( OutputType type, string text ) => Log.Debug( text ); + + private static IProcess Clab( string args, AbsolutePath workDir = null, TimeSpan? timeout = null, + bool logOutput = true ) => + ProcessTasks.StartProcess( + "containerlab", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds, + logOutput: logOutput, + logger: logOutput ? ClabLogger : null + ); + + private static IProcess Docker( string args, AbsolutePath workDir = null, TimeSpan? timeout = null ) => + ProcessTasks.StartProcess( + "docker", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds + ); +} + +/// A Containerlab integration test case. +sealed record ContainerlabTestCase( + string Name, + string TopologyFile, + string SpecFile, + string CliContainer, + ScanAssertion[] Assertions +); + +/// A named assertion over scan output text. +sealed record ScanAssertion( string Description, Func Check ); \ No newline at end of file diff --git a/build/NukeBuild.cs b/build/NukeBuild.cs index 94be3123..4148d23d 100644 --- a/build/NukeBuild.cs +++ b/build/NukeBuild.cs @@ -129,6 +129,8 @@ private static class Paths { internal static AbsolutePath PublishDirectoryForRuntime( DotNetRuntimeIdentifier id ) => PublishDirectory / id.ToString(); + + internal static AbsolutePath ContainerlabsDirectory => RootDirectory / "containerlab"; } Target OutputVersion => _ => _ diff --git a/build/_build.csproj b/build/_build.csproj index 7737c9f5..1db84060 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,16 +12,17 @@ false + + + + + - - - - diff --git a/containerlab/README.md b/containerlab/README.md new file mode 100644 index 00000000..6450a9b1 --- /dev/null +++ b/containerlab/README.md @@ -0,0 +1,124 @@ +# Containerlab Integration Testing + +This directory contains containerlab topologies for testing Drift's distributed network scanning capabilities. + +## Prerequisites + +- [Containerlab](https://containerlab.dev/) installed +- Docker with sufficient resources (at least 4GB RAM, 2 CPUs) +- Drift Docker image: `localhost:5000/drift:dev` + +## Quick Start + +The easiest way to run containerlab integration tests is via Nuke: + +```bash +# Run all tests including containerlab integration +dotnet nuke Test + +# Run only containerlab tests +dotnet nuke TestContainerlab --skip test + +# Run a single topology for debugging +dotnet nuke TestContainerlab --skip test --clab-topology simple-test + +# Keep containers running after tests (for debugging) +dotnet nuke TestContainerlab --skip test --keep-clab-running +``` + +## Topologies + +### `simple-test.clab.yaml` + +Minimal topology: 1 agent, 1 CLI, 1 target on the management network. + +``` + CLI Agent1 Target1 +(172.20.20.x) (172.20.20.x) (172.20.20.x) + | | | + +--------------------+---------------------+ + 172.20.20.0/24 +``` + +**Assertions:** +- `172.20.20.0/24` is scanned +- 2/2 scan operations successful (local + 1 agent) +- Scan completes successfully + +### `cooperation-test.clab.yaml` + +Multi-agent cooperation topology: 3 agents, 1 CLI, 5 targets on a flat management network. +Tests multi-agent coordination and result merging. + +``` +CLI + Agent1 + Agent2 + Agent3 + Target1..5 + | + 172.20.20.0/24 +``` + +**Assertions:** +- `172.20.20.0/24` is scanned +- 4/4 scan operations successful (local + 3 agents) +- Scan completes successfully + +### `subnet-isolation-test.clab.yaml` + +Subnet isolation topology: 2 agents, 1 CLI, 4 targets. Each agent is connected to its own +isolated subnet via veth links and an in-container bridge. Tests that each agent only reports +devices reachable on its own subnet. + +``` +CLI ─── mgmt (172.20.20.0/24) ─── Agent1 ─── subnet-a (192.168.10.0/24) ─── Target-A1, Target-A2 + └─ Agent2 ─── subnet-b (192.168.20.0/24) ─── Target-B1, Target-B2 +``` + +**Assertions:** +- `192.168.10.0/24` is scanned (by Agent1) +- `192.168.20.0/24` is scanned (by Agent2) +- 7/7 scan operations successful (mgmt×3 + subnet-a×2 + subnet-b×2) +- Scan completes successfully + +## Agent Identity + +Agents in these topologies use the `--id` flag to set a fixed, predictable agent ID: + +```yaml +agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_test1 +``` + +The `--id` flag is hidden from the help output and logs a warning when used — it is only for testing. + +In production, agents generate and persist their own ID at `/root/.config/drift/agent/agent-identity.json`. + +## Nuke Target Parameters + +| Parameter | Description | +|---|---| +| `--clab-topology ` | Run only the named topology (e.g. `simple-test`). Runs all if omitted. | +| `--skip-clab-deploy` | Skip deployment — useful when topology is already running | +| `--keep-clab-running` | Keep containers running after tests for debugging | + +## Troubleshooting + +**Deploy fails with "Link not found"** — This is a known issue with rootless Podman + pasta networking. The Nuke target works around it by pre-creating the `clab` management network before deploying. If you are deploying manually, run: +```bash +docker network rm clab 2>/dev/null; docker network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab +containerlab deploy --topo simple-test.clab.yaml +``` + +**Agents not starting** — Check container logs: +```bash +docker logs clab-drift-simple-test-agent1 +docker logs clab-drift-cooperation-test-agent1 +docker logs clab-drift-subnet-isolation-test-agent1 +``` + +**Cannot reach agents** — Verify containers are on the management network: +```bash +docker network inspect clab +``` + +**Connection refused on first scan attempt** — Normal. The agent starts slowly and the client retries automatically. diff --git a/containerlab/cooperation-test-spec.yaml b/containerlab/cooperation-test-spec.yaml new file mode 100644 index 00000000..00994efd --- /dev/null +++ b/containerlab/cooperation-test-spec.yaml @@ -0,0 +1,21 @@ +version: "v1-preview" + +network: + subnets: [] + devices: [] + +agents: + - id: agentid_coop_agent1 + address: http://clab-drift-cooperation-test-agent1:5000 + authentication: + type: none + + - id: agentid_coop_agent2 + address: http://clab-drift-cooperation-test-agent2:5000 + authentication: + type: none + + - id: agentid_coop_agent3 + address: http://clab-drift-cooperation-test-agent3:5000 + authentication: + type: none diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml new file mode 100644 index 00000000..e7859935 --- /dev/null +++ b/containerlab/cooperation-test.clab.yaml @@ -0,0 +1,70 @@ +name: drift-cooperation-test + +# Multi-agent cooperation topology +# +# All nodes share a single flat management network (172.20.20.0/24). +# Three agents cooperate to scan the same subnet, testing: +# - Multi-agent coordination (3 agents) +# - Result merging from multiple agents +# - Correct handling of overlapping scan results +# +# Targets: +# - 5 Alpine containers as scan targets + +topology: + nodes: + # Controller/CLI node + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Agent 1 + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent1 + + # Agent 2 + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent2 + + # Agent 3 + agent3: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent3 + + # Target devices + target1: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target2: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target3: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target4: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target5: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" diff --git a/containerlab/simple-test-spec.yaml b/containerlab/simple-test-spec.yaml new file mode 100644 index 00000000..8075f51a --- /dev/null +++ b/containerlab/simple-test-spec.yaml @@ -0,0 +1,11 @@ +version: "v1-preview" + +network: + subnets: [] + devices: [] + +agents: + - id: agentid_test1 + address: http://clab-drift-simple-test-agent1:5000 + authentication: + type: none diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml new file mode 100644 index 00000000..6c8bb507 --- /dev/null +++ b/containerlab/simple-test.clab.yaml @@ -0,0 +1,26 @@ +name: drift-simple-test + +# Simplified topology for initial testing +topology: + nodes: + # Controller/CLI node + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Single agent for basic test + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_test1 + ports: + - "5001:5000" + + # Single target device + target1: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" diff --git a/containerlab/subnet-isolation-test-spec.yaml b/containerlab/subnet-isolation-test-spec.yaml new file mode 100644 index 00000000..c3fc35c6 --- /dev/null +++ b/containerlab/subnet-isolation-test-spec.yaml @@ -0,0 +1,18 @@ +version: "v1-preview" + +network: + subnets: + - address: 192.168.10.0/24 + - address: 192.168.20.0/24 + devices: [] + +agents: + - id: agentid_subnet_agent1 + address: http://clab-drift-subnet-isolation-test-agent1:5000 + authentication: + type: none + + - id: agentid_subnet_agent2 + address: http://clab-drift-subnet-isolation-test-agent2:5000 + authentication: + type: none diff --git a/containerlab/subnet-isolation-test.clab.yaml b/containerlab/subnet-isolation-test.clab.yaml new file mode 100644 index 00000000..bad5dfe0 --- /dev/null +++ b/containerlab/subnet-isolation-test.clab.yaml @@ -0,0 +1,112 @@ +name: drift-subnet-isolation-test + +# Subnet isolation topology +# +# Tests that each agent only scans targets reachable on its own isolated subnet. +# +# Networks: +# - mgmt (172.20.20.0/24): CLI + Agent1 + Agent2 [management/control plane] +# - subnet-a (veth + bridge): Agent1 + Target-A1 + Target-A2 [192.168.10.0/24] +# - subnet-b (veth + bridge): Agent2 + Target-B1 + Target-B2 [192.168.20.0/24] +# +# Targets use network-mode: none — they have no management interface and are +# only reachable via the veth link to their respective agent. +# +# Inside each agent, a Linux bridge (br0) is created to bridge the two veth +# links together on a single /24 subnet — allowing the agent to reach both +# targets with a single IP address. +# +# Scan spec explicitly declares the two isolated subnets. +# Agent1 can reach 192.168.10.0/24; Agent2 can reach 192.168.20.0/24. +# Neither agent can reach the other's subnet. + +topology: + nodes: + # Controller/CLI node (mgmt only) + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Agent 1 — scans subnet-a (192.168.10.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.10.1/24 to the bridge + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent1 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.10.1/24 dev br0 + + # Agent 2 — scans subnet-b (192.168.20.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.20.1/24 to the bridge + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent2 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.20.1/24 dev br0 + + # Subnet-A targets (only reachable by agent1) + # network-mode: none means no management interface; eth0 is the veth link + target-a1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.101/24 dev eth0 + - ip link set eth0 up + + target-a2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.102/24 dev eth0 + - ip link set eth0 up + + # Subnet-B targets (only reachable by agent2) + target-b1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.101/24 dev eth0 + - ip link set eth0 up + + target-b2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.102/24 dev eth0 + - ip link set eth0 up + + links: + # Subnet-A: agent1 <-> target-a1 and agent1 <-> target-a2 + - endpoints: ["agent1:eth1", "target-a1:eth0"] + - endpoints: ["agent1:eth2", "target-a2:eth0"] + + # Subnet-B: agent2 <-> target-b1 and agent2 <-> target-b2 + - endpoints: ["agent2:eth1", "target-b1:eth0"] + - endpoints: ["agent2:eth2", "target-b2:eth0"] diff --git a/src/Agent.Hosting/Agent.Hosting.csproj b/src/Agent.Hosting/Agent.Hosting.csproj new file mode 100644 index 00000000..93719ce4 --- /dev/null +++ b/src/Agent.Hosting/Agent.Hosting.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Agent.Hosting/AgentHost.cs b/src/Agent.Hosting/AgentHost.cs new file mode 100644 index 00000000..fe8e4960 --- /dev/null +++ b/src/Agent.Hosting/AgentHost.cs @@ -0,0 +1,70 @@ +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Networking.PeerStreaming.Client; +using Drift.Networking.PeerStreaming.Core; +using Drift.Networking.PeerStreaming.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Hosting; + +public static class AgentHost { + public static Task Run( + ushort port, + ILogger logger, + Action? configureServices, + CancellationToken cancellationToken, + TaskCompletionSource? ready = null + ) { + var app = Build( port, logger, configureServices, ready ); + return app.RunAsync( cancellationToken ); + } + + private static WebApplication Build( + ushort port, + ILogger logger, + Action? configureServices = null, + TaskCompletionSource? ready = null + ) { + var builder = WebApplication.CreateSlimBuilder(); + + builder.Logging.ClearProviders(); + builder.Services.AddSingleton( logger ); + builder.Services.AddPeerStreamingServer( options => { + options.EnableDetailedErrors = true; + } ); + builder.Services.AddPeerStreamingClient(); + var peerStreamingOptions = new PeerStreamingOptions { MessageAssembly = typeof(SubnetsRequest).Assembly }; + builder.Services.AddPeerStreamingCore( peerStreamingOptions ); + configureServices?.Invoke( builder.Services ); + + builder.WebHost.ConfigureKestrel( options => { + options.ListenAnyIP( port, o => { + o.Protocols = HttpProtocols.Http2; // Allow HTTP/2 over plain HTTP i.e., non-HTTPS + } ); + } ); + + var app = builder.Build(); + + peerStreamingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + + app.MapPeerStreamingServerEndpoints(); + + app.Lifetime.ApplicationStarted.Register( () => { + logger.LogInformation( "Listening for incoming connections on port {Port}", port ); + logger.LogInformation( "Agent started" ); + ready?.TrySetResult(); + } ); + app.Lifetime.ApplicationStopping.Register( () => { + logger.LogInformation( "Agent stopping..." ); + } ); + app.Lifetime.ApplicationStopped.Register( () => { + logger.LogInformation( "Agent stopped" ); + } ); + + return app; + } +} \ No newline at end of file diff --git a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs new file mode 100644 index 00000000..a462b561 --- /dev/null +++ b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Drift.Domain; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Hosting.Identity; + +public partial class AgentIdentity { + private IAgentIdentityLocationProvider? _loadLocation; + + public static AgentIdentity Load( ILogger? logger = null, IAgentIdentityLocationProvider? location = null ) { + try { + location ??= new DefaultAgentIdentityLocationProvider(); + + logger?.LogTrace( "Loading agent identity from {Path}", location.GetFile() ); + + if ( !File.Exists( location.GetFile() ) ) { + logger?.LogDebug( "Agent identity file not found. Generating new identity." ); + var newIdentity = CreateNew(); + newIdentity._loadLocation = location; + return newIdentity; + } + + var json = File.ReadAllText( location.GetFile() ); + var identity = JsonSerializer.Deserialize( json, AgentIdentityJsonContext.Default.AgentIdentity ); + + logger?.LogTrace( "Loaded agent identity: {AgentId}", identity?.Id ); + + if ( identity == null ) { + logger?.LogWarning( "Deserialized identity is null. Generating new identity." ); + var newIdentity = CreateNew(); + newIdentity._loadLocation = location; + return newIdentity; + } + + identity._loadLocation = location; + + return identity; + } + catch ( Exception e ) { + logger?.LogError( e, "Error loading agent identity" ); + var newIdentity = CreateNew(); + newIdentity._loadLocation = location; + return newIdentity; + } + } + + public void Save( ILogger logger, IAgentIdentityLocationProvider? location = null ) { + location ??= new DefaultAgentIdentityLocationProvider(); + + logger.LogTrace( "Saving agent identity to {Path}", location.GetFile() ); + + if ( !Directory.Exists( location.GetDirectory() ) ) { + Directory.CreateDirectory( location.GetDirectory() ); + } + + if ( !File.Exists( location.GetFile() ) ) { + logger.LogInformation( "Creating new agent identity file at {Path}", location.GetFile() ); + } + else if ( _loadLocation == null || + !_loadLocation.GetFile().Equals( location.GetFile(), StringComparison.Ordinal ) // Casing matters on Linux + ) { + throw new InvalidOperationException( "Prevented overwriting an existing file, which had not first been loaded." ); + } + + var json = JsonSerializer.Serialize( this, AgentIdentityJsonContext.Default.AgentIdentity ); + File.WriteAllText( location.GetFile(), json ); + + // logger.LogDebug( "Agent identity saved: {AgentId}", Id ); + } + + private static AgentIdentity CreateNew() { + return new AgentIdentity { + Id = AgentId.New(), + CreatedAt = DateTime.UtcNow + }; + } +} diff --git a/src/Agent.Hosting/Identity/AgentIdentity.cs b/src/Agent.Hosting/Identity/AgentIdentity.cs new file mode 100644 index 00000000..30f82cff --- /dev/null +++ b/src/Agent.Hosting/Identity/AgentIdentity.cs @@ -0,0 +1,10 @@ +using Drift.Domain; + +namespace Drift.Agent.Hosting.Identity; + +public partial class AgentIdentity { + public required AgentId Id { get; init; } + public required DateTime CreatedAt { get; init; } + public string? ClusterId { get; init; } + public DateTime? EnrolledAt { get; init; } +} diff --git a/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs new file mode 100644 index 00000000..2d1be19f --- /dev/null +++ b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Drift.Agent.Hosting.Identity; + +[JsonSourceGenerationOptions( + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectRequiredConstructorParameters = true, + RespectNullableAnnotations = true +)] +[JsonSerializable( typeof(AgentIdentity) )] +internal sealed partial class AgentIdentityJsonContext : JsonSerializerContext { +} diff --git a/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs new file mode 100644 index 00000000..9a05bab7 --- /dev/null +++ b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; + +namespace Drift.Agent.Hosting.Identity; + +public sealed class DefaultAgentIdentityLocationProvider : IAgentIdentityLocationProvider { + public string GetDirectory() { + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "Drift", "agent" ); + } + + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + // https://specifications.freedesktop.org/basedir-spec/latest/ + var xdgConfigHome = Environment.GetEnvironmentVariable( "XDG_CONFIG_HOME" ); + + var baseDir = string.IsNullOrEmpty( xdgConfigHome ) + ? Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), ".config" ) + : xdgConfigHome; + + return Path.Combine( baseDir, "drift", "agent" ); + } + + throw new PlatformNotSupportedException(); + } +} diff --git a/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs new file mode 100644 index 00000000..356686f7 --- /dev/null +++ b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs @@ -0,0 +1,9 @@ +namespace Drift.Agent.Hosting.Identity; + +public interface IAgentIdentityLocationProvider { + string GetDirectory(); + + string GetFile() { + return Path.Combine( GetDirectory(), "agent-identity.json" ); + } +} diff --git a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj b/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj new file mode 100644 index 00000000..e00ea9c0 --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs b/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs new file mode 100644 index 00000000..eac8dbc8 --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Tests; + +internal sealed class PeerMessageHandlerTests { + private static readonly Assembly ProtocolAssembly = typeof(PeerProtocolAssemblyMarker).Assembly; + private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IPeerRequest<>) ); + private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IPeerResponse) ); + private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); + + [Test] + public void FindMessagesAndHandlersAndMessages() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( RequestTypes.ToList(), Has.Count.GreaterThan( 1 ), "No request messages found via reflection" ); + Assert.That( ResponseTypes.ToList(), Has.Count.GreaterThan( 1 ), "No response messages found via reflection" ); + Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); + } + } + + [Explicit( "Disabled until interface has settled" )] + [TestCaseSource( nameof(RequestTypes) )] + [TestCaseSource( nameof(ResponseTypes) )] + public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { + var messageTypeValue = type + .GetProperty( nameof(IPeerMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ) as string; + + Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); + + var jsonInfoValue = type + .GetProperty( nameof(IPeerMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ); + + Assert.That( jsonInfoValue, Is.Not.Null ); + } + + private static List GetAllConcreteMessageTypes( Type baseType ) { + return ProtocolAssembly + .GetTypes() + .Concat( [typeof(Empty)] ) + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => + // Generic base type + t.GetInterfaces().Any( i => i.IsGenericType && i.GetGenericTypeDefinition() == baseType ) || + // Non-generic base type + baseType.IsAssignableFrom( t ) + ) + .ToList(); + } + + private static List GetAllConcreteHandlerTypes() { + return ProtocolAssembly + .GetTypes() + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => typeof(IPeerMessageHandler).IsAssignableFrom( t ) ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs new file mode 100644 index 00000000..c586ed34 --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs @@ -0,0 +1,24 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptRequestHandler : IPeerMessageHandler { + // private readonly ILogger _logger; // Example: inject what you need + + public string MessageType => AdoptRequestPayload.MessageType; + + public Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + // var message = converter.FromEnvelope( envelope ); + // _logger.LogInformation( "[AdoptRequest] Controller: {ControllerId}", message.ControllerId ); + + // This handler doesn't send a response (Empty response pattern) + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs new file mode 100644 index 00000000..2ad48099 --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptRequestPayload : IPeerRequest { + public static string MessageType => "adopt-request"; + + public required string Jwt { + get; + set; + } + + public required string ControllerId { + get; + set; + } + + public static JsonTypeInfo JsonInfo { + get; + } = null!; +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs new file mode 100644 index 00000000..67fa75dc --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs @@ -0,0 +1,18 @@ +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptResponsePayload { + public required string Status { + get; + set; + } // "accepted" or "rejected" + + public required string AgentId { + get; + set; + } // Only with "accepted" + + public required string Reason { + get; + set; + } // Only with "rejected" +} diff --git a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj new file mode 100644 index 00000000..09269a20 --- /dev/null +++ b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs b/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs new file mode 100644 index 00000000..1907e215 --- /dev/null +++ b/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs @@ -0,0 +1,7 @@ +namespace Drift.Agent.PeerProtocol; + +// Justification: marker class +#pragma warning disable S2094 +public class PeerProtocolAssemblyMarker { +} +#pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs new file mode 100644 index 00000000..e330d4b2 --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain.Scan; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Scan; + +public sealed class ScanSubnetCompleteResponse : IPeerResponse { + public static string MessageType => "scan-complete"; + + public required SubnetScanResult Result { + get; + init; + } + + public static JsonTypeInfo JsonInfo => ScanSubnetCompleteResponseJsonContext.Default.ScanSubnetCompleteResponse; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter), typeof(DeviceAddressConverter), typeof(IpV4AddressSetConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(ScanSubnetCompleteResponse) )] +internal sealed partial class ScanSubnetCompleteResponseJsonContext : JsonSerializerContext; diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs new file mode 100644 index 00000000..596703fe --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Scan; + +public sealed class ScanSubnetProgressUpdate : IPeerMessage { + public static string MessageType => "scan-progress-update"; + + public required byte ProgressPercentage { + get; + init; + } + + public required int DevicesFound { + get; + init; + } + + public string Status { + get; + init; + } = string.Empty; + + public static JsonTypeInfo JsonInfo => ScanSubnetProgressUpdateJsonContext.Default.ScanSubnetProgressUpdate; +} + +[JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase )] +[JsonSerializable( typeof(ScanSubnetProgressUpdate) )] +internal sealed partial class ScanSubnetProgressUpdateJsonContext : JsonSerializerContext; diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs new file mode 100644 index 00000000..4da42d37 --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Scan; + +public sealed class ScanSubnetRequest : IPeerRequest { + public static string MessageType => "scan-subnet-request"; + + public required CidrBlock Cidr { + get; + init; + } + + public uint PingsPerSecond { + get; + init; + } = 50; + + public static JsonTypeInfo JsonInfo => ScanSubnetRequestJsonContext.Default.ScanSubnetRequest; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(ScanSubnetRequest) )] +internal sealed partial class ScanSubnetRequestJsonContext : JsonSerializerContext; diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs new file mode 100644 index 00000000..27c2db90 --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs @@ -0,0 +1,112 @@ +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.PeerProtocol.Scan; + +internal sealed class ScanSubnetRequestHandler( + ISubnetScannerFactory subnetScannerFactory, + ILogger logger +) : IPeerMessageHandler { + public string MessageType => ScanSubnetRequest.MessageType; + + public async Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + logger.LogInformation( "Handling scan subnet request" ); + + var request = converter.FromEnvelope( envelope ); + var options = new SubnetScanOptions { Cidr = request.Cidr, PingsPerSecond = request.PingsPerSecond }; + + logger.LogInformation( "Starting scan of {Cidr}", request.Cidr ); + + var scanner = subnetScannerFactory.Get( request.Cidr ); + var policy = new ProgressUpdatePolicy( stream, converter, envelope, request.Cidr, logger ); + + scanner.ResultUpdated += policy.Handle; + + try { + var result = await scanner.ScanAsync( options, logger, cancellationToken ); + + logger.LogInformation( + "Scan complete for {Cidr}: {DeviceCount} devices found", + request.Cidr, + result.DiscoveredDevices.Count + ); + + var completeResponse = new ScanSubnetCompleteResponse { Result = result }; + await stream.SendResponseAsync( converter, completeResponse, envelope.CorrelationId ); + } + finally { + scanner.ResultUpdated -= policy.Handle; + } + } + + private sealed class ProgressUpdatePolicy { + private readonly IPeerStream _stream; + private readonly IPeerMessageEnvelopeConverter _converter; + private readonly PeerMessage _envelope; + private readonly CidrBlock _cidr; + private readonly ILogger _logger; + + private byte _lastProgressPercentage; + private uint _lastDeviceCount; + private DateTime _lastSentAt = DateTime.UtcNow; + + public EventHandler Handle { + get; + } + + public ProgressUpdatePolicy( + IPeerStream stream, + IPeerMessageEnvelopeConverter converter, + PeerMessage envelope, + CidrBlock cidr, + ILogger logger + ) { + _stream = stream; + _converter = converter; + _envelope = envelope; + _cidr = cidr; + _logger = logger; + Handle = OnResultUpdated; + } + + private void OnResultUpdated( object? sender, SubnetScanResult result ) { + var progressPercentage = result.Progress.Value; + var deviceCount = result.DiscoveredDevices.Count; + var now = DateTime.UtcNow; + + bool progressThresholdReached = progressPercentage >= _lastProgressPercentage + 5; + bool isFirstCompletion = progressPercentage == 100 && _lastProgressPercentage < 100; + bool heartbeatDue = now - _lastSentAt > TimeSpan.FromSeconds( 10 ); + bool firstDeviceDiscovered = deviceCount > 0 && _lastDeviceCount == 0; + + if ( !( progressThresholdReached || isFirstCompletion || heartbeatDue || firstDeviceDiscovered ) ) { + return; + } + + _lastProgressPercentage = progressPercentage; + _lastDeviceCount = (uint) deviceCount; + _lastSentAt = now; + + var progressUpdate = new ScanSubnetProgressUpdate { + ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() + }; + + _stream.SendResponseFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); + + _logger.LogDebug( + "Sent progress update: {Progress}% for {Cidr}", + progressPercentage, + _cidr + ); + } + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..693ba852 --- /dev/null +++ b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Drift.Agent.PeerProtocol.Scan; +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.PeerProtocol; + +public static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + public void AddPeerProtocol() { + services.AddScoped(); + services.AddScoped(); + } + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs b/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs new file mode 100644 index 00000000..7f0323f7 --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Subnets; + +public sealed class SubnetsRequest : IPeerRequest { + public static string MessageType => "subnets-request"; + + public static JsonTypeInfo JsonInfo => SubnetsRequestJsonContext.Default.SubnetsRequest; +} + +[JsonSerializable( typeof(SubnetsRequest) )] +internal sealed partial class SubnetsRequestJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs new file mode 100644 index 00000000..a8d3eee5 --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs @@ -0,0 +1,29 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.PeerProtocol.Subnets; + +internal sealed class SubnetsRequestHandler( + IInterfaceSubnetProvider interfaceSubnetProvider, + ILogger logger +) : IPeerMessageHandler { + public string MessageType => SubnetsRequest.MessageType; + + public async Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + logger.LogInformation( "Handling subnet request" ); + + var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList(); + + logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); + + var response = new SubnetsResponse { Subnets = subnets }; + await stream.SendResponseAsync( converter, response, envelope.CorrelationId ); + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs new file mode 100644 index 00000000..f2402b6c --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Subnets; + +public sealed class SubnetsResponse : IPeerResponse { + public static string MessageType => "subnets-response"; + + public required IReadOnlyList Subnets { + get; + init; + } + + public static JsonTypeInfo JsonInfo => SubnetsResponseJsonContext.Default.SubnetsResponse; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(SubnetsResponse) )] +internal sealed partial class SubnetsResponseJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/ArchTests/SanityTests.cs b/src/ArchTests/SanityTests.cs index 8639eace..b3268160 100644 --- a/src/ArchTests/SanityTests.cs +++ b/src/ArchTests/SanityTests.cs @@ -3,8 +3,8 @@ namespace Drift.ArchTests; internal sealed class SanityTests : DriftArchitectureFixture { - private const uint ExpectedAssemblyCount = 25; - private const uint ExpectedAssemblyCountTolerance = 5; + private const uint ExpectedAssemblyCount = 30; + private const uint ExpectedAssemblyCountTolerance = 10; [Test] public void FindManyAssemblies() { diff --git a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt index 046ac81e..c69b8662 100644 --- a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt +++ b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt @@ -17,4 +17,5 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + agent Manage the local Drift agent diff --git a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt index 8523aa12..cd3c69c5 100644 --- a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt +++ b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt @@ -19,6 +19,7 @@ + diff --git a/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt new file mode 100644 index 00000000..9b0bccae --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt @@ -0,0 +1 @@ +✗ Either --adoptable or --join must be specified. diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt new file mode 100644 index 00000000..27ac3915 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt @@ -0,0 +1,10 @@ +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Agent starting... +Agent cluster enrollment method is Adoption +Listening for incoming connections on port 51515 +Agent started +Agent stopping... +Agent stopped diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs new file mode 100644 index 00000000..18b2a018 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -0,0 +1,51 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; + +namespace Drift.Cli.Tests.Commands; + +internal sealed class AgentCommandTests { + [CancelAfter( 3000 )] + [Test] + public async Task RespectsCancellationToken() { + using var tcs = new CancellationTokenSource( TimeSpan.FromMilliseconds( 2000 ) ); + + var (exitCode, output, _) = await DriftTestCli.InvokeAsync( + "agent start --adoptable", + cancellationToken: tcs.Token + ); + + Console.WriteLine( output ); + + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task SuccessfulStartup() { + using var tcs = new CancellationTokenSource(); + + var runningCommand = await DriftTestCli.StartAgentAsync( + "--adoptable", + cancellationToken: tcs.Token + ); + + await tcs.CancelAsync(); + + var (exitCode, output, error) = await runningCommand.Completion; + + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( output.ToString() ); + Assert.That( error.ToString(), Is.Empty ); + } + } + + [Test] + public async Task MissingOption() { + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "agent start" ); + + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + await Verify( output.ToString() + error ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/InitCommandTests.cs b/src/Cli.Tests/Commands/InitCommandTests.cs index 3a9a27bb..b502d1a1 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.cs +++ b/src/Cli.Tests/Commands/InitCommandTests.cs @@ -78,7 +78,7 @@ public void TearDown() { [Test] public async Task MissingNameOption() { // Arrange / Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "init --overwrite" ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "init --overwrite" ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -95,7 +95,7 @@ public async Task CancellationIsRespected() { try { // Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "init", cancellationToken: cancellationTokenSource.Token ); @@ -126,7 +126,7 @@ public async Task GenerateSpecWithDiscoverySuccess( }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithDiscovery} --discover {outputFormat} {verbose}", serviceConfig ); @@ -155,7 +155,7 @@ public async Task GenerateSpecWithoutDiscoverySuccess() { }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithoutDiscovery}", serviceConfig ); diff --git a/src/Cli.Tests/Commands/LintCommandTests.cs b/src/Cli.Tests/Commands/LintCommandTests.cs index 2e01a0f3..b8d6e086 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.cs +++ b/src/Cli.Tests/Commands/LintCommandTests.cs @@ -19,7 +19,7 @@ public async Task LintValidSpec( var outputOption = string.IsNullOrWhiteSpace( outputFormat ) ? string.Empty : $" -o {outputFormat}"; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"lint ../../../../Spec.Tests/resources/{specName}.yaml" + outputOption ); @@ -45,7 +45,7 @@ public async Task LintInvalidSpec( var outputOption = string.IsNullOrWhiteSpace( outputFormat ) ? string.Empty : $" -o {outputFormat}"; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"lint ../../../../Spec.Tests/resources/{specName}.yaml" + outputOption ); @@ -60,7 +60,7 @@ await Verify( output.ToString() + error ) [Test] public async Task LintMissingSpec() { // Arrange / Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( "lint" ); + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "lint" ); // Assert Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt new file mode 100644 index 00000000..538f05f0 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent local1 +Received subnet(s) from agent local1: 192.168.10.0/24 +Requesting subnets from agent local2 +Received subnet(s) from agent local2: 192.168.20.0/24 +Scanning 3 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs new file mode 100644 index 00000000..af1356c6 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs @@ -0,0 +1,348 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Scan; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class ScanCommandTests { + [Test] + public async Task RemoteScan() { + // Arrange + var scanConfig = ConfigureServices( + new CidrBlock( "192.168.0.0/24" ), + [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]], + new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agents..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )], + [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )] + ] + ) + ), + await DriftTestCli.StartAgentAsync( + "--adoptable --port 51516", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.20.0/24" ), + discoveredDevices: [[new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )]] + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, agentOutput, agentError) = await agent.Completion; + + Console.WriteLine( "\nAgent finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( agentOutput.ToString() + agentError ); + Console.WriteLine( "----------------\n" ); + + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( scanOutput.ToString() + scanError ); + } + + [Test] + public async Task RemoteScan_OverlappingSubnets() { + // Arrange - Both agents can see the same subnet (192.168.10.0/24) + var scanConfig = ConfigureServices( + new CidrBlock( "192.168.0.0/24" ), + [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]], + new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agents with overlapping subnet visibility..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )], + [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )] + ] + ) + ), + await DriftTestCli.StartAgentAsync( + "--adoptable --port 51516", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), // Same subnet as agent1 + discoveredDevices: [ + [new IpV4Address( "192.168.10.102" ), new MacAddress( "44:44:44:44:44:44" )], + [new IpV4Address( "192.168.10.103" ), new MacAddress( "55:55:55:55:55:55" )] + ] + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, agentOutput, agentError) = await agent.Completion; + + Console.WriteLine( "\nAgent finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( agentOutput.ToString() + agentError ); + Console.WriteLine( "----------------\n" ); + + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + + // Verify the subnet was only scanned once (not twice) + var outputStr = scanOutput.ToString(); + Console.WriteLine( "Checking output for duplicate scans..." ); + + // The output should show "192.168.10.0/24" being scanned, but only once + // We expect to see results from only ONE agent (the first one to claim it) + await Verify( outputStr + scanError ); + } + + [Test] + public async Task RemoteScan_EmptyResults() { + // Arrange - Agents report subnets but no devices are found + var scanConfig = ConfigureServices( + new CidrBlock( "192.168.0.0/24" ), + [], // No local devices + new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agent with empty scan results..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [] // No devices found + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, _, _) = await agent.Completion; + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( scanOutput.ToString() + scanError ); + } + + [Test] + public async Task RemoteScan_AgentsOnly_NoLocalInterfaces() { + // Arrange - CLI has no local interfaces, all scanning delegated to agents + var scanConfig = ConfigureServices( + interfaces: (CidrBlock?) null, // No local interfaces - explicitly cast to resolve ambiguity + discoveredDevices: new List>(), + inventory: new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agents for agents-only scan..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )] + ] + ) + ), + await DriftTestCli.StartAgentAsync( + "--adoptable --port 51516", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.20.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )] + ] + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan with no local interfaces..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, _, _) = await agent.Completion; + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( scanOutput.ToString() + scanError ); + } + + /// + /// Mock subnet scanner that returns predefined results for testing. + /// + private sealed class MockSubnetScanner( SubnetScanResult result ) : ISubnetScanner { + public event EventHandler? ResultUpdated; + + public Task ScanAsync( + SubnetScanOptions options, + ILogger logger, + CancellationToken cancellationToken = default + ) { + // Simulate progress updates + var progressResult = new SubnetScanResult { + CidrBlock = result.CidrBlock, + DiscoveredDevices = result.DiscoveredDevices, + Metadata = result.Metadata, + Status = result.Status, + DiscoveryAttempts = result.DiscoveryAttempts, + Progress = new Percentage( 50 ) + }; + ResultUpdated?.Invoke( this, progressResult ); + + var finalResult = new SubnetScanResult { + CidrBlock = result.CidrBlock, + DiscoveredDevices = result.DiscoveredDevices, + Metadata = result.Metadata, + Status = result.Status, + DiscoveryAttempts = result.DiscoveryAttempts, + Progress = new Percentage( 100 ) + }; + ResultUpdated?.Invoke( this, finalResult ); + + return Task.FromResult( finalResult ); + } + } + + /// + /// Mock factory that creates scanners with predefined results based on CIDR. + /// + private sealed class MockSubnetScannerFactory( + Dictionary resultsByCidr + ) : ISubnetScannerFactory { + public ISubnetScanner Get( CidrBlock cidr ) { + if ( resultsByCidr.TryGetValue( cidr, out var result ) ) { + return new MockSubnetScanner( result ); + } + + // Return empty result for unknown CIDRs + return new MockSubnetScanner( new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = [], + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = System.Collections.Immutable.ImmutableHashSet.Empty + } ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt new file mode 100644 index 00000000..b86a1ad8 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt @@ -0,0 +1,24 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Requesting subnets from agent agentid_local2 +Received subnet(s) from agent agentid_local2: 192.168.20.0/24 +Scanning 3 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Distributed scan completed: 3/3 scan operations successful, 3 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (2 devices) +├── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt new file mode 100644 index 00000000..2765abc7 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt @@ -0,0 +1,19 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Requesting subnets from agent agentid_local2 +Received subnet(s) from agent agentid_local2: 192.168.20.0/24 +Scanning 2 subnets + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Distributed scan completed: 2/2 scan operations successful, 2 unique subnets + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt new file mode 100644 index 00000000..21d98e9b --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt @@ -0,0 +1,15 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Scanning 2 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Distributed scan completed: 2/2 scan operations successful, 2 unique subnets + +192.168.0.0/24 (0 devices) + +192.168.10.0/24 (0 devices) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt new file mode 100644 index 00000000..aa5468ac --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt @@ -0,0 +1,23 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Requesting subnets from agent agentid_local2 +Received subnet(s) from agent agentid_local2: 192.168.10.0/24 +Scanning 2 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1, agentid_local2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 4 unique devices from 2 scans of 192.168.10.0/24 +Distributed scan completed: 3/3 scan operations successful, 2 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (4 devices) +├── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +├── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) +├── 192.168.10.102 44-44-44-44-44-44 Online (unknown device) +└── 192.168.10.103 55-55-55-55-55-55 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.cs b/src/Cli.Tests/Commands/ScanCommandTests.cs index 5e77b568..c245dabb 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.cs @@ -11,14 +11,18 @@ using Drift.Domain.Device.Discovered; using Drift.Domain.Extensions; using Drift.Domain.Scan; +using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; using Drift.Scanning.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using NetworkInterface = Drift.Scanning.Subnets.Interface.NetworkInterface; namespace Drift.Cli.Tests.Commands; -internal sealed class ScanCommandTests { +internal sealed partial class ScanCommandTests { + private const string SpecName = "unittest"; + private static readonly INetworkInterface DefaultInterface = new NetworkInterface { Description = "eth0", OperationalStatus = OperationalStatus.Up, UnicastAddress = new CidrBlock( "192.168.0.0/24" ) }; @@ -170,7 +174,7 @@ List interfaces var serviceConfig = ConfigureServices( interfaces, discoveredDevices ); // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan", serviceConfig ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "scan", serviceConfig ); // var exitCode = await config.InvokeAsync( $"scan -o {outputFormat}" ); // Assert @@ -202,7 +206,7 @@ List discoveredDevices ); // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan unittest", serviceConfig ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"scan {SpecName}", serviceConfig ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -216,7 +220,7 @@ await Verify( output.ToString() + error ) [Test] public async Task NonExistingSpecOption() { // Arrange / Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan blah_spec.yaml" ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "scan blah_spec.yaml" ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -225,19 +229,56 @@ public async Task NonExistingSpecOption() { } } + private static Action ConfigureServices( + CidrBlock? interfaces, + List> discoveredDevices, + Inventory? inventory = null + ) { + var interfaceList = interfaces.HasValue + ? [ + new NetworkInterface { + Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces.Value + } + ] + : new List(); + + return ConfigureServices( + interfaceList, + discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(), + inventory + ); + } + + private static Action ConfigureServices( + CidrBlock interfaces, + List> discoveredDevices, + Inventory? inventory = null + ) { + return ConfigureServices( + [ + new NetworkInterface { + Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces + } + ], + discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(), + inventory + ); + } + private static Action ConfigureServices( List interfaces, List discoveredDevices, Inventory? inventory = null ) { return services => { - services.AddScoped( _ => - new PredefinedInterfaceSubnetProvider( interfaces ) + services.Replace( ServiceDescriptor.Scoped( _ => + new PredefinedInterfaceSubnetProvider( interfaces ) + ) ); if ( inventory != null ) { services.AddScoped( _ => - new PredefinedSpecProvider( new Dictionary { { "unittest", inventory } } ) + new PredefinedSpecProvider( new Dictionary { { SpecName, inventory } } ) ); } @@ -245,22 +286,41 @@ private static Action ConfigureServices( new NetworkScanResult { Metadata = new Metadata { StartedAt = default, EndedAt = default }, Status = ScanResultStatus.Success, - Subnets = [ - new SubnetScanResult { - CidrBlock = DefaultInterface.UnicastAddress!.Value, - DiscoveredDevices = discoveredDevices, - Metadata = new Metadata { StartedAt = default, EndedAt = default }, - Status = ScanResultStatus.Success, - // TODO could/should also include ip's of non-discovered devices? - DiscoveryAttempts = discoveredDevices.Select( d => - new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) ) - ) - .ToImmutableHashSet() - } - ] + Subnets = interfaces.Count > 0 + ? [ + new SubnetScanResult { + CidrBlock = DefaultInterface.UnicastAddress!.Value, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + // TODO could/should also include ip's of non-discovered devices? + DiscoveryAttempts = discoveredDevices.Select( d => + new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) ) + ) + .ToImmutableHashSet() + } + ] + : [] } ) ); + + // Register ISubnetScannerFactory for agent tests + // Build a map of CIDR -> SubnetScanResult based on interfaces + var resultsByCidr = interfaces.ToDictionary( + iface => iface.UnicastAddress!.Value, + iface => new SubnetScanResult { + CidrBlock = iface.UnicastAddress!.Value, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = discoveredDevices.Select( d => + new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) ) + ) + .ToImmutableHashSet() + } + ); + services.AddScoped( _ => new MockSubnetScannerFactory( resultsByCidr ) ); }; } } \ No newline at end of file diff --git a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt index 1756febb..bf9563ca 100644 --- a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt @@ -18,6 +18,7 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + agent Manage the local Drift agent Required command was not provided. Unrecognized command or argument 'nonexisting'. diff --git a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt index 6b515840..2973d897 100644 --- a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt @@ -1,2 +1,2 @@ ✕ This exception was thrown from ExceptionCommandHandler - at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DefaultParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 111 \ No newline at end of file + at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DummyParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 112 \ No newline at end of file diff --git a/src/Cli.Tests/ExitCodeTests.cs b/src/Cli.Tests/ExitCodeTests.cs index 0781e1bd..204d664a 100644 --- a/src/Cli.Tests/ExitCodeTests.cs +++ b/src/Cli.Tests/ExitCodeTests.cs @@ -1,7 +1,8 @@ using System.CommandLine; using System.Text.RegularExpressions; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; using Drift.Cli.Infrastructure; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Tests.Utils; @@ -25,7 +26,7 @@ public async Task ExitCodeIsReturnedFromCommandHandlerTest() { // Act var (exitCode, output, error) = - await DriftTestCli.InvokeFromTestAsync( ExitCodeCommand, customCommands: customCommands ); + await DriftTestCli.InvokeAsync( ExitCodeCommand, customCommands: customCommands ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -37,7 +38,7 @@ public async Task ExitCodeIsReturnedFromCommandHandlerTest() { [Test] public async Task NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest() { // Arrange - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( NonExistingCommand ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( NonExistingCommand ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -61,7 +62,7 @@ public async Task UnhandledExceptionReturnsUnknownErrorTest() { // Act var (exitCode, output, error) = - await DriftTestCli.InvokeFromTestAsync( ExceptionThrowingCommand, customCommands: customCommands ); + await DriftTestCli.InvokeAsync( ExceptionThrowingCommand, customCommands: customCommands ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -78,37 +79,42 @@ await Verify( } private sealed class ExitCodeTestCommand( IServiceProvider provider ) - : CommandBase( + : CommandBase( ExitCodeCommand, "Command that returns a specific exit code", provider ) { - protected override DefaultParameters CreateParameters( ParseResult result ) { - return new DefaultParameters( result ); + protected override DummyParameters CreateParameters( ParseResult result ) { + return new DummyParameters( result ); } } - private sealed class ExitCodeCommandHandler( IOutputManager output ) : ICommandHandler { - public Task Invoke( DefaultParameters parameters, CancellationToken cancellationToken ) { + private sealed class ExitCodeCommandHandler( IOutputManager output ) : ICommandHandler { + public Task Invoke( DummyParameters parameters, CancellationToken cancellationToken ) { output.Normal.Write( $"Output from command '{ExitCodeCommand}'" ); return Task.FromResult( ExitCodeCommandExitCode ); } } private sealed class ExceptionTestCommand( IServiceProvider provider ) - : CommandBase( + : CommandBase( ExceptionThrowingCommand, "Command that throws an exception", provider ) { - protected override DefaultParameters CreateParameters( ParseResult result ) { - return new DefaultParameters( result ); + protected override DummyParameters CreateParameters( ParseResult result ) { + return new DummyParameters( result ); } } - private sealed class ExceptionCommandHandler : ICommandHandler { - public Task Invoke( DefaultParameters parameters, CancellationToken cancellationToken ) { + private sealed class ExceptionCommandHandler : ICommandHandler { + public Task Invoke( DummyParameters parameters, CancellationToken cancellationToken ) { throw new Exception( $"This exception was thrown from {nameof(ExceptionCommandHandler)}" ); } } + + private sealed record DummyParameters : BaseParameters { + public DummyParameters( ParseResult parseResult ) : base( parseResult ) { + } + } } \ No newline at end of file diff --git a/src/Cli.Tests/FeatureFlagTest.cs b/src/Cli.Tests/FeatureFlagTest.cs new file mode 100644 index 00000000..ed240dc8 --- /dev/null +++ b/src/Cli.Tests/FeatureFlagTest.cs @@ -0,0 +1,80 @@ +using System.CommandLine; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Infrastructure; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.FeatureFlags; +using Drift.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests; + +internal sealed class FeatureFlagTest { + private const string DummyCodeCommand = "dummy"; + private const int DummyCommandExitCode = 1337; + private static readonly FeatureFlag MyFeature = new("myFeature"); + private static readonly ISettingsLocationProvider SettingsLocationProvider = new TemporarySettingsLocationProvider(); + + [Test] + public async Task SettingsControlFlag( [Values( false, true, null )] bool? featureEnabled ) { + // Arrange + if ( Directory.Exists( SettingsLocationProvider.GetDirectory() ) ) { + Directory.Delete( SettingsLocationProvider.GetDirectory(), true ); + } + + var settings = new CliSettings(); + if ( featureEnabled != null ) { + settings.Features = [new FeatureFlagSetting( MyFeature, featureEnabled.Value )]; + } + + settings.Write( logger: NullLogger.Instance, location: SettingsLocationProvider ); + + RootCommandFactory.CommandRegistration[] customCommands = [ + new(typeof(DummyTestCommandHandler), sp => new DummyTestCommand( sp )) + ]; + + // Act + var result = await DriftTestCli.InvokeAsync( $"{DummyCodeCommand}", customCommands: customCommands ); + + // Assert + using ( Assert.EnterMultipleScope() ) { + Assert.That( result.ExitCode, Is.EqualTo( DummyCommandExitCode ) ); + var expectedOutput = featureEnabled == true ? "Feature is enabled" : "Feature is disabled"; + Assert.That( result.Output.ToString(), Is.EqualTo( expectedOutput ) ); + Assert.That( result.Error.ToString(), Is.Empty ); + } + + Console.WriteLine( result.Output.ToString() ); + } + + private sealed class DummyTestCommand( IServiceProvider provider ) + : CommandBase( + DummyCodeCommand, + "Command that switches behavior using a feature flag", + provider + ) { + protected override DummyTestParameters CreateParameters( ParseResult result ) { + return new DummyTestParameters( result ); + } + } + + private sealed class DummyTestCommandHandler( IOutputManager output ) : ICommandHandler { + public Task Invoke( DummyTestParameters parameters, CancellationToken cancellationToken ) { + output.Normal.Write( + CliSettings.Read( location: SettingsLocationProvider ).IsFeatureEnabled( MyFeature ) + ? "Feature is enabled" + : "Feature is disabled" + ); + + return Task.FromResult( DummyCommandExitCode ); + } + } + + private sealed record DummyTestParameters : BaseParameters { + public DummyTestParameters( ParseResult parseResult ) : base( parseResult ) { + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/SpecFilePathResolverTests.cs b/src/Cli.Tests/SpecFilePathResolverTests.cs index aee842be..90111ed5 100644 --- a/src/Cli.Tests/SpecFilePathResolverTests.cs +++ b/src/Cli.Tests/SpecFilePathResolverTests.cs @@ -20,7 +20,7 @@ internal sealed class SpecFilePathResolverTests { [OneTimeSetUp] public void SetupHomeDir() { - _tempHome = Path.Combine( Path.GetTempPath(), "fake-home-" + Guid.NewGuid().ToString() ); + _tempHome = Path.Combine( Path.GetTempPath(), "fake-home-" + Guid.NewGuid() ); Directory.CreateDirectory( _tempHome ); if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { diff --git a/src/Cli.Tests/Utils/CliCommandResult.cs b/src/Cli.Tests/Utils/CliCommandResult.cs new file mode 100644 index 00000000..9665fafc --- /dev/null +++ b/src/Cli.Tests/Utils/CliCommandResult.cs @@ -0,0 +1,24 @@ +namespace Drift.Cli.Tests.Utils; + +internal sealed class CliCommandResult { + internal required int ExitCode { + get; + init; + } + + internal required TextWriter Output { + get; + init; + } + + internal required TextWriter Error { + get; + init; + } + + public void Deconstruct( out int exitCode, out TextWriter output, out TextWriter error ) { + exitCode = ExitCode; + output = Output; + error = Error; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/DriftTestCli.cs b/src/Cli.Tests/Utils/DriftTestCli.cs index b7b7abac..162f0163 100644 --- a/src/Cli.Tests/Utils/DriftTestCli.cs +++ b/src/Cli.Tests/Utils/DriftTestCli.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.CommandLine.Parsing; +using Drift.Cli.Commands.Agent.Subcommands; using Drift.Cli.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -11,11 +12,12 @@ internal static class DriftTestCli { // Console.SetOut/SetError mutates global state, so only one invocation may redirect it at a time. private static readonly SemaphoreSlim ConsoleRedirectLock = new(1, 1); - internal static async Task<(int ExitCode, TextWriter Output, TextWriter Error )> InvokeFromTestAsync( + internal static async Task InvokeAsync( string args, Action? configureServices = null, RootCommandFactory.CommandRegistration[]? customCommands = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + bool redirectConsole = true ) { var token = cancellationToken; CancellationTokenSource? cancellationTokenSource = null; @@ -36,17 +38,22 @@ void ConfigureInvocation( InvocationConfiguration config ) { /* * Most output is written to the InvocationConfiguration's TextWriters, but a few errors may be written to * Console.Out/Error when DI is not yet available. + * + * Long-running background processes (e.g. agents) must NOT hold ConsoleRedirectLock for their + * entire lifetime — doing so would prevent any other InvokeAsync call from acquiring the lock + * (deadlock when running multiple agents or a scan alongside agents). Pass redirectConsole: false + * to skip the lock and Console.SetOut/SetError for those cases. */ - await ConsoleRedirectLock.WaitAsync( token ); + if ( redirectConsole ) { + await ConsoleRedirectLock.WaitAsync( token ); - var previousOut = Console.Out; - var previousErr = Console.Error; - Console.SetOut( output ); - Console.SetError( error ); + var previousOut = Console.Out; + var previousErr = Console.Error; + Console.SetOut( output ); + Console.SetError( error ); - try { - return ( - await DriftCli.InvokeAsync( + try { + var exitCode = await DriftCli.InvokeAsync( CommandLineParser.SplitCommandLine( args ).ToArray(), false, true, @@ -54,16 +61,80 @@ await DriftCli.InvokeAsync( customCommands, ConfigureInvocation, token - ), - output, - error + ); + + return new CliCommandResult { ExitCode = exitCode, Output = output, Error = error }; + } + finally { + Console.SetOut( previousOut ); + Console.SetError( previousErr ); + ConsoleRedirectLock.Release(); + cancellationTokenSource?.Dispose(); + } + } + + try { + var exitCode = await DriftCli.InvokeAsync( + CommandLineParser.SplitCommandLine( args ).ToArray(), + false, + true, + configureServices, + customCommands, + ConfigureInvocation, + token ); + + return new CliCommandResult { ExitCode = exitCode, Output = output, Error = error }; } finally { - Console.SetOut( previousOut ); - Console.SetError( previousErr ); - ConsoleRedirectLock.Release(); cancellationTokenSource?.Dispose(); } } + + internal static RunningCliCommand StartAsync( + string args, + Action configureServices, + CancellationToken cancellationToken + ) { + var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken ); + + var task = InvokeAsync( + args, + configureServices, + cancellationToken: cts.Token, + redirectConsole: false + ); + + return new RunningCliCommand( task, cts ); + } + + /// + /// Starts a new agent asynchronously and returns tasks that complete when it has started. + /// + internal static async Task StartAgentAsync( + string args, + CancellationToken cancellationToken, + Action? configureServices = null + ) { + var readyTcs = new AgentLifetime(); + + var command = StartAsync( + "agent start " + args, + services => { + services.AddSingleton( readyTcs ); + configureServices?.Invoke( services ); + }, + cancellationToken + ); + + // Wait for either readiness or command exit + var completed = await Task.WhenAny( readyTcs.Ready.Task, command.Completion ); + + if ( completed == command.Completion ) { + var com = await command.Completion; + throw new InvalidOperationException( "Command exited before agent was started. Details:\n" + com.Error ); + } + + return command; + } } \ No newline at end of file diff --git a/src/Cli.Tests/Utils/RunningCliCommand.cs b/src/Cli.Tests/Utils/RunningCliCommand.cs new file mode 100644 index 00000000..00fa3eba --- /dev/null +++ b/src/Cli.Tests/Utils/RunningCliCommand.cs @@ -0,0 +1,20 @@ +namespace Drift.Cli.Tests.Utils; + +internal sealed class RunningCliCommand : IAsyncDisposable { + private readonly CancellationTokenSource _cts; + + internal RunningCliCommand( Task task, CancellationTokenSource cts ) { + Completion = task; + _cts = cts; + } + + public Task Completion { + get; + } + + public async ValueTask DisposeAsync() { + await _cts.CancelAsync(); + await Completion; + _cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj index c9d77929..7991f9e0 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -13,14 +13,19 @@ + + + + + @@ -31,13 +36,7 @@ - - all - - - - - + diff --git a/src/Cli/Commands/Agent/AgentCommand.cs b/src/Cli/Commands/Agent/AgentCommand.cs new file mode 100644 index 00000000..b7d42b85 --- /dev/null +++ b/src/Cli/Commands/Agent/AgentCommand.cs @@ -0,0 +1,24 @@ +using Drift.Cli.Commands.Agent.Subcommands.Start; +using Drift.Cli.Commands.Common.Commands; + +namespace Drift.Cli.Commands.Agent; + +internal class AgentCommand : ContainerCommandBase { + internal AgentCommand( IServiceProvider provider ) : base( "agent", "Manage the local Drift agent" ) { + Subcommands.Add( new AgentStartCommand( provider ) ); + // Subcommands.Add( new AgentServiceCommand( provider ) ); + + /*// Support other init systems in the future + var installCmd = new Command( "install", "Create agent systemd service file" ); + installCmd.Options.Add( new Option( "--join" ) { + Description = "Join the distributed agent network using a JWT" + } ); + Subcommands.Add( installCmd ); + + var uninstallCmd = new Command( "uninstall", "Remove agent systemd service file" ); + Subcommands.Add( uninstallCmd ); + + var statusCmd = new Command( "status", "Show agent status" ); + Subcommands.Add( statusCmd );*/ + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs b/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs new file mode 100644 index 00000000..416ee801 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs @@ -0,0 +1,7 @@ +namespace Drift.Cli.Commands.Agent.Subcommands; + +internal sealed class AgentLifetime { + public TaskCompletionSource Ready { + get; + } = new(); +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs new file mode 100644 index 00000000..02e00a69 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs @@ -0,0 +1,122 @@ +using System.CommandLine; +using Drift.Agent.Hosting; +using Drift.Agent.Hosting.Identity; +using Drift.Agent.PeerProtocol; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Infrastructure; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Domain; +using Drift.Networking.Cluster; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Agent.Subcommands.Start; + +internal class AgentStartCommand : CommandBase { + internal AgentStartCommand( IServiceProvider provider ) : base( "start", "Start a local Drift agent", provider ) { + Options.Add( AgentStartParameters.Options.Port ); + Options.Add( AgentStartParameters.Options.Adoptable ); + Options.Add( AgentStartParameters.Options.Join ); + Options.Add( AgentStartParameters.Options.Id ); + } + + protected override AgentStartParameters CreateParameters( ParseResult result ) { + return new AgentStartParameters( result ); + } +} + +internal class AgentStartCommandHandler( + IOutputManager output, + AgentLifetime? agentLifetime = null, + Action? configureServicesOverride = null +) + : ICommandHandler { + public async Task Invoke( AgentStartParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'agent start' command" ); + + output.WarnAgentPreview(); + + var logger = output.GetLogger(); + + logger.LogInformation( "Agent starting..." ); + + _ = LoadAgentIdentity( parameters.Id ); + + // Check if agent has cluster membership info + var agentIdentity = AgentIdentity.Load( logger ); + var isEnrolled = agentIdentity.ClusterId != null; + + if ( !isEnrolled ) { + logger.LogDebug( "Agent is not enrolled" ); + + var enrollmentRequest = new EnrollmentRequest( parameters.Adoptable, parameters.Join ); + logger.LogInformation( "Agent cluster enrollment method is {EnrollmentMethod}", enrollmentRequest.Method ); + } + else { + logger.LogDebug( "Agent is enrolled into cluster '{ClusterId}'", agentIdentity.ClusterId ); + logger.LogInformation( "Attempting to re-join cluster '{ClusterId}'...", agentIdentity.ClusterId ); + } + + /*Inventory? inventory; + + try { + inventory = await specProvider.GetDeserializedAsync( parameters.SpecFile ); + } + catch ( FileNotFoundException ) { + return ExitCodes.GeneralError; + }*/ + + output.Log.LogDebug( "Starting agent..." ); + + try { + await AgentHost.Run( parameters.Port, logger, ConfigureServices, cancellationToken, agentLifetime?.Ready ); + } + catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + // Graceful shutdown via cancellation + } + + output.Log.LogDebug( "Completed 'agent start' command" ); + + return ExitCodes.Success; + + void ConfigureServices( IServiceCollection services ) { + // Configure core agent services (scanning, subnet discovery, execution environment) + RootCommandFactory.ConfigureAgentCoreServices( services ); + + // Add peer protocol message handlers + services.AddPeerProtocol(); + + // Allow test overrides + configureServicesOverride?.Invoke( services ); + } + } + + private AgentId LoadAgentIdentity( string? idOverride ) { + var logger = output.GetLogger(); + IAgentIdentityLocationProvider locationProvider = new DefaultAgentIdentityLocationProvider(); + var identityFilePath = locationProvider.GetFile(); + + // If an ID override is provided, use it directly without loading/saving + if ( !string.IsNullOrWhiteSpace( idOverride ) ) { + logger.LogWarning( "Agent started with --id flag. This should only be used for testing purposes." ); + logger.LogInformation( "Using provided agent ID: {AgentId}", idOverride ); + return new AgentId( idOverride ); + } + + // Load existing identity or create new one + var identity = AgentIdentity.Load( logger, locationProvider ); + + // Save immediately if it's new to persist it + if ( !File.Exists( identityFilePath ) ) { + identity.Save( logger, locationProvider ); + logger.LogInformation( "Generated and saved new agent identity: {AgentId}", identity.Id ); + } + else { + logger.LogDebug( "Loaded existing agent identity: {AgentId}", identity.Id ); + } + + return identity.Id; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs new file mode 100644 index 00000000..b4c07c02 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -0,0 +1,64 @@ +using System.CommandLine; +using Drift.Cli.Commands.Common.Parameters; + +namespace Drift.Cli.Commands.Agent.Subcommands.Start; + +internal record AgentStartParameters : BaseParameters { + internal static class Options { + internal static readonly Option Adoptable = new("--adoptable") { + DefaultValueFactory = _ => false, + Description = "Allow this agent to be adopted by another peer in the agent cluster" + }; + + // support @ for supplying local file + internal static readonly Option Join = new("--join") { Description = "Join the agent cluster using a JWT" }; + + internal static readonly Option Daemon = new("--daemon", "-d") { + Description = "Run the agent as a background daemon" + }; + + internal static readonly Option Port = new("--port", "-p") { + DefaultValueFactory = _ => 51515, Description = "Set the port used for both adoption and communication" + }; + + internal static readonly Option Id = new("--id") { + Description = "Set a specific agent ID (for testing purposes)", + Hidden = true + }; + } + + internal AgentStartParameters( ParseResult parseResult ) : base( parseResult ) { + Port = parseResult.GetValue( Options.Port ); + Adoptable = parseResult.GetValue( Options.Adoptable ); + Join = parseResult.GetValue( Options.Join ); + Id = parseResult.GetValue( Options.Id ); + + if ( !Adoptable && string.IsNullOrWhiteSpace( Join ) ) { + throw new ArgumentException( "Either --adoptable or --join must be specified." ); + } + + if ( Adoptable && !string.IsNullOrWhiteSpace( Join ) ) { + throw new ArgumentException( "Cannot specify both --adoptable and --join." ); + } + } + + public string? Join { + get; + set; + } + + public bool Adoptable { + get; + set; + } + + public ushort Port { + get; + set; + } + + public string? Id { + get; + set; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/CommandBase.cs b/src/Cli/Commands/Common/Commands/CommandBase.cs similarity index 54% rename from src/Cli/Commands/Common/CommandBase.cs rename to src/Cli/Commands/Common/Commands/CommandBase.cs index 111412df..3c8b819c 100644 --- a/src/Cli/Commands/Common/CommandBase.cs +++ b/src/Cli/Commands/Common/Commands/CommandBase.cs @@ -1,10 +1,13 @@ using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Managers.Abstractions; using Microsoft.Extensions.DependencyInjection; -namespace Drift.Cli.Commands.Common; +namespace Drift.Cli.Commands.Common.Commands; internal abstract class CommandBase : Command - where TParameters : DefaultParameters + where TParameters : BaseParameters where THandler : ICommandHandler { protected CommandBase( string name, string description, IServiceProvider provider ) : base( name, description ) { Add( CommonParameters.Options.Verbose ); @@ -13,16 +16,24 @@ protected CommandBase( string name, string description, IServiceProvider provide Add( CommonParameters.Options.OutputFormat ); Add( CommonParameters.Arguments.Spec ); - SetAction( ( parseResult, cancellationToken ) => { - using var scope = provider.CreateScope(); + SetAction( async ( parseResult, cancellationToken ) => { + await using var scope = provider.CreateAsyncScope(); var serviceProvider = scope.ServiceProvider; serviceProvider.GetRequiredService().ParseResult = parseResult; var handler = serviceProvider.GetRequiredService(); - var parameters = CreateParameters( parseResult ); - return handler.Invoke( parameters, cancellationToken ); + TParameters parameters; + try { + parameters = CreateParameters( parseResult ); + } + catch ( ArgumentException e ) { + serviceProvider.GetRequiredService().Normal.WriteLineError( $"✗ {e.Message}" ); + return ExitCodes.GeneralError; + } + + return await handler.Invoke( parameters, cancellationToken ); } ); } diff --git a/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs b/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs new file mode 100644 index 00000000..146b4c3c --- /dev/null +++ b/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs @@ -0,0 +1,8 @@ +using System.CommandLine; + +namespace Drift.Cli.Commands.Common.Commands; + +internal class ContainerCommandBase : Command { + public ContainerCommandBase( string name, string description ) : base( name, description ) { + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/ICommandHandler.cs b/src/Cli/Commands/Common/Commands/ICommandHandler.cs similarity index 56% rename from src/Cli/Commands/Common/ICommandHandler.cs rename to src/Cli/Commands/Common/Commands/ICommandHandler.cs index e303b028..07ea9ef0 100644 --- a/src/Cli/Commands/Common/ICommandHandler.cs +++ b/src/Cli/Commands/Common/Commands/ICommandHandler.cs @@ -1,5 +1,7 @@ -namespace Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; -internal interface ICommandHandler where TParameters : DefaultParameters { +namespace Drift.Cli.Commands.Common.Commands; + +internal interface ICommandHandler where TParameters : BaseParameters { Task Invoke( TParameters parameters, CancellationToken cancellationToken ); } \ No newline at end of file diff --git a/src/Cli/Commands/Common/CommonParameters.cs b/src/Cli/Commands/Common/CommonParameters.cs index f4f7f5b3..bd9beff8 100644 --- a/src/Cli/Commands/Common/CommonParameters.cs +++ b/src/Cli/Commands/Common/CommonParameters.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.Diagnostics; using Drift.Cli.Presentation.Console; using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Settings.V1_preview; @@ -25,14 +26,14 @@ internal static class Arguments { /// internal static class Options { /// - /// Enable detailed output, corresponding to log level. + /// Enable detailed output, corresponding to log level. /// internal static readonly Option Verbose = new("--verbose", "-v") { Description = "Verbose output", Arity = ArgumentArity.Zero }; /// - /// Enable the most detailed output available, corresponding to log level. + /// Enable the most detailed output available, corresponding to log level. /// internal static readonly Option VeryVerbose = new("--very-verbose", "-vv") { Description = "Very verbose output", Arity = ArgumentArity.Zero, Hidden = true diff --git a/src/Cli/Commands/Common/DefaultParameters.cs b/src/Cli/Commands/Common/DefaultParameters.cs deleted file mode 100644 index 8e8eb2db..00000000 --- a/src/Cli/Commands/Common/DefaultParameters.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.CommandLine; -using Drift.Cli.Presentation.Console; - -namespace Drift.Cli.Commands.Common; - -internal record DefaultParameters { - internal DefaultParameters( ParseResult parseResult ) { - OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); - SpecFile = parseResult.GetValue( CommonParameters.Arguments.Spec ); - } - - internal OutputFormat OutputFormat { - get; - } - - internal FileInfo? SpecFile { - get; - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Common/Parameters/BaseParameters.cs b/src/Cli/Commands/Common/Parameters/BaseParameters.cs new file mode 100644 index 00000000..07e4338e --- /dev/null +++ b/src/Cli/Commands/Common/Parameters/BaseParameters.cs @@ -0,0 +1,14 @@ +using System.CommandLine; +using Drift.Cli.Presentation.Console; + +namespace Drift.Cli.Commands.Common.Parameters; + +internal abstract record BaseParameters { + protected BaseParameters( ParseResult parseResult ) { + OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); + } + + internal OutputFormat OutputFormat { + get; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/Parameters/SpecParameters.cs b/src/Cli/Commands/Common/Parameters/SpecParameters.cs new file mode 100644 index 00000000..3d55c247 --- /dev/null +++ b/src/Cli/Commands/Common/Parameters/SpecParameters.cs @@ -0,0 +1,13 @@ +using System.CommandLine; + +namespace Drift.Cli.Commands.Common.Parameters; + +internal abstract record SpecParameters : BaseParameters { + protected SpecParameters( ParseResult parseResult ) : base( parseResult ) { + SpecFile = parseResult.GetValue( CommonParameters.Arguments.Spec ); + } + + internal FileInfo? SpecFile { + get; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/ParseResultHolder.cs b/src/Cli/Commands/Common/ParseResultHolder.cs index a05afe98..ee3b7751 100644 --- a/src/Cli/Commands/Common/ParseResultHolder.cs +++ b/src/Cli/Commands/Common/ParseResultHolder.cs @@ -8,7 +8,7 @@ internal class ParseResultHolder { public ParseResult ParseResult { get => _parseResult ?? throw new InvalidOperationException( - $"{nameof(ParseResult)} is null. This should have been set via dependency injection." + $"{nameof(ParseResult)} is null. This should have been set during dependency injection." ); set => _parseResult = value; } diff --git a/src/Cli/Commands/Init/InitCommand.cs b/src/Cli/Commands/Init/InitCommand.cs index 2b5cc347..c72b080e 100644 --- a/src/Cli/Commands/Init/InitCommand.cs +++ b/src/Cli/Commands/Init/InitCommand.cs @@ -1,6 +1,6 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Init.Helpers; using Drift.Cli.Commands.Scan.NonInteractive; using Drift.Cli.Presentation.Console; @@ -179,7 +179,9 @@ private async Task Initialize( InitOptions options ) { return false; } - var scanOptions = new NetworkScanOptions { Cidrs = interfaceSubnetProvider.Get().ToList() }; + var scanOptions = new NetworkScanOptions { + Cidrs = ( await interfaceSubnetProvider.GetAsync() ).Select( subnet => subnet.Cidr ).ToList() + }; LogSubnetDetails( scanOptions ); diff --git a/src/Cli/Commands/Init/InitParameters.cs b/src/Cli/Commands/Init/InitParameters.cs index 8115012a..0eac12a6 100644 --- a/src/Cli/Commands/Init/InitParameters.cs +++ b/src/Cli/Commands/Init/InitParameters.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Init; -internal record InitParameters : DefaultParameters { +internal record InitParameters : SpecParameters { internal bool? Discover { get; } diff --git a/src/Cli/Commands/Lint/LintCommand.cs b/src/Cli/Commands/Lint/LintCommand.cs index 28f99de0..0f822178 100644 --- a/src/Cli/Commands/Lint/LintCommand.cs +++ b/src/Cli/Commands/Lint/LintCommand.cs @@ -1,11 +1,12 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Lint.Presentation; using Drift.Cli.Presentation.Console; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Cli.SpecFile; +using Drift.Spec.Schema; using Drift.Spec.Validation; using Microsoft.Extensions.Logging; @@ -48,7 +49,7 @@ public async Task Invoke( LintParameters parameters, CancellationToken canc var yamlContent = await File.ReadAllTextAsync( filePath!.FullName, cancellationToken ); - var result = SpecValidator.Validate( yamlContent, Spec.Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yamlContent, SpecVersion.V1_preview ); IRenderer renderer = parameters.OutputFormat switch { diff --git a/src/Cli/Commands/Lint/LintParameters.cs b/src/Cli/Commands/Lint/LintParameters.cs index b3e533bf..ef92c71d 100644 --- a/src/Cli/Commands/Lint/LintParameters.cs +++ b/src/Cli/Commands/Lint/LintParameters.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Lint; -internal record LintParameters : DefaultParameters { +internal record LintParameters : SpecParameters { internal LintParameters( ParseResult parseResult ) : base( parseResult ) { } } \ No newline at end of file diff --git a/src/Cli/Commands/Preview/AgentCommand.cs b/src/Cli/Commands/Preview/AgentCommand.cs deleted file mode 100644 index 4c036ff7..00000000 --- a/src/Cli/Commands/Preview/AgentCommand.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.CommandLine; - -namespace Drift.Cli.Commands.Preview; - -internal class AgentCommand : Command { - internal AgentCommand() : base( "agent", "Manage the local Drift agent" ) { - var runCmd = new Command( "run", "Start the agent process" ); - runCmd.Options.Add( new Option( "--adoptable" ) { - Description = "Allow this agent to be adopted by another peer in the distributed agent network" - } ); - // terminology: agent network or agent group? - // support @ for supplying local file - runCmd.Options.Add( new Option( "--join" ) { - Description = "Join the distributed agent network using a JWT" - } ); - runCmd.Options.Add( new Option( "--daemon", "-d" ) { Description = "Run the agent as a background daemon" } ); - runCmd.Options.Add( new Option( "--adoptable" - ) { Description = "Allow this agent to be adopted by another peer in the distributed agent network" } ); - Subcommands.Add( runCmd ); - - // Support other init systems in the future - var installCmd = new Command( "install", "Create agent systemd service file" ); - installCmd.Options.Add( new Option( "--join" ) { - Description = "Join the distributed agent network using a JWT" - } ); - Subcommands.Add( installCmd ); - - var uninstallCmd = new Command( "uninstall", "Remove agent systemd service file" ); - Subcommands.Add( uninstallCmd ); - - var statusCmd = new Command( "status", "Show agent status" ); - Subcommands.Add( statusCmd ); - - // logs? - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/AgentSubnetProvider.cs b/src/Cli/Commands/Scan/AgentSubnetProvider.cs new file mode 100644 index 00000000..97ebfdc2 --- /dev/null +++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs @@ -0,0 +1,48 @@ +using Drift.Domain; +using Drift.Networking.Cluster; +using Drift.Scanning.Subnets; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Scan; + +internal sealed class AgentSubnetProvider( + ILogger logger, + List agents, + ICluster cluster, + CancellationToken cancellationToken +) : ISubnetProvider { + public async Task> GetAsync() { + logger.LogDebug( "Getting subnets from agents" ); + var allSubnets = new List(); + + foreach ( var agent in agents ) { + logger.LogInformation( "Requesting subnets from agent {Id}", agent.Id ); + + try { + var response = await cluster.GetSubnetsAsync( agent, cancellationToken ); + + logger.LogInformation( + "Received subnet(s) from agent {Id}: {Subnets}", + agent.Id, + string.Join( ", ", response.Subnets ) + ); + + allSubnets.AddRange( response.Subnets.Select( cidr => + new ResolvedSubnet( cidr, SubnetSource.Agent( + new AgentId( agent.Id ) + ) ) ) + ); + } + catch ( Exception ex ) { + logger.LogWarning( + ex, + "Failed requesting subnets from agent {Id} ({Address}) — agent will be excluded from scan", + agent.Id, + agent.Address + ); + } + } + + return allSubnets; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs new file mode 100644 index 00000000..fd340461 --- /dev/null +++ b/src/Cli/Commands/Scan/ClusterExtensions.cs @@ -0,0 +1,50 @@ +using Drift.Agent.PeerProtocol.Scan; +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Domain; +using Drift.Networking.Cluster; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Cli.Commands.Scan; + +internal static class ClusterExtensions { + internal static Task GetSubnetsAsync( + this ICluster cluster, + Domain.Agent agent, + CancellationToken cancellationToken + ) { + return cluster.SendAndWaitAsync( + agent, + new SubnetsRequest(), + timeout: TimeSpan.FromSeconds( 10 ), + cancellationToken + ); + } + + internal static Task ScanSubnetAsync( + this ICluster cluster, + Domain.Agent agent, + CidrBlock cidr, + uint pingsPerSecond, + IPeerMessageEnvelopeConverter converter, + Action onProgressUpdate, + CancellationToken cancellationToken + ) { + var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond }; + + return cluster.SendAndWaitStreamingAsync( + agent, + request, + ScanSubnetCompleteResponse.MessageType, + ( progressEnvelope ) => { + // Deserialize progress update and call handler + if ( progressEnvelope.MessageType == ScanSubnetProgressUpdate.MessageType ) { + var progressUpdate = converter.FromEnvelope( progressEnvelope ); + onProgressUpdate( progressUpdate ); + } + }, + timeout: TimeSpan.FromMinutes( 10 ), + cancellationToken + ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs new file mode 100644 index 00000000..ac27d9b9 --- /dev/null +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -0,0 +1,304 @@ +using System.Collections.Immutable; +using Drift.Domain; +using Drift.Domain.Device; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Discovered; +using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Scanning.Subnets; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Scan; + +/// +/// Network scanner that delegates scanning to agents based on subnet source. +/// +internal sealed class DistributedNetworkScanner( + INetworkScanner localScanner, + ICluster cluster, + IPeerMessageEnvelopeConverter converter, + List resolvedSubnets, + Inventory inventory, + ILogger logger +) : INetworkScanner { + public event EventHandler? ResultUpdated; + + public async Task ScanAsync( + NetworkScanOptions options, + ILogger logger, + CancellationToken cancellationToken = default + ) { + logger.LogDebug( "Starting distributed network scan" ); + + var subnetsBySource = PartitionSubnetsBySource( options ); + var allSubnetResults = new List(); + var startTime = DateTime.UtcNow; + + foreach ( var (source, cidrs) in subnetsBySource ) { + if ( source is Local ) { + await ScanLocalSubnetsAsync( + cidrs, + options.PingsPerSecond, + allSubnetResults, + startTime, + logger, + cancellationToken + ); + } + else if ( source is Scanning.Subnets.Agent agentSource ) { + await ScanAgentSubnetsAsync( agentSource, cidrs, options.PingsPerSecond, allSubnetResults, cancellationToken ); + } + } + + return BuildFinalResult( allSubnetResults, startTime, logger ); + } + + private List<(SubnetSource Source, List Cidrs)> PartitionSubnetsBySource( NetworkScanOptions options ) { + // Allow each source to scan subnets it can see - don't deduplicate + // Different agents may see different devices on the same subnet from their network position + return resolvedSubnets + .Where( rs => options.Cidrs.Contains( rs.Cidr ) ) + .GroupBy( rs => rs.Source ) + .Select( group => ( group.Key, group.Select( rs => rs.Cidr ).Distinct().ToList() ) ) + .ToList(); + } + + private async Task ScanLocalSubnetsAsync( + List cidrs, + uint pingsPerSecond, + List allResults, + DateTime startTime, + ILogger logger, + CancellationToken cancellationToken + ) { + logger.LogDebug( "Scanning {Count} local subnet(s)", cidrs.Count ); + + var localOptions = new NetworkScanOptions { Cidrs = cidrs, PingsPerSecond = pingsPerSecond }; + + EventHandler progressHandler = ( _, result ) => { + var overallResult = BuildProgressResult( allResults, result.Subnets, startTime ); + ResultUpdated?.Invoke( this, overallResult ); + }; + + try { + localScanner.ResultUpdated += progressHandler; + var localResult = await localScanner.ScanAsync( localOptions, logger, cancellationToken ); + allResults.AddRange( localResult.Subnets ); + } + finally { + localScanner.ResultUpdated -= progressHandler; + } + } + + private async Task ScanAgentSubnetsAsync( + Scanning.Subnets.Agent agentSource, + List cidrs, + uint pingsPerSecond, + List allResults, + CancellationToken cancellationToken + ) { + logger.LogDebug( "Scanning {Count} subnet(s) via agent {AgentId}", cidrs.Count, agentSource.AgentId ); + + // TODO: Parallel scanning of multiple subnets per agent + foreach ( var cidr in cidrs ) { + var result = await ScanSingleSubnetViaAgentAsync( agentSource, cidr, pingsPerSecond, cancellationToken ); + allResults.Add( result ); + } + } + + private async Task ScanSingleSubnetViaAgentAsync( + Scanning.Subnets.Agent agentSource, + CidrBlock cidr, + uint pingsPerSecond, + CancellationToken cancellationToken + ) { + try { + var response = await cluster.ScanSubnetAsync( + MapAgentIdToDomainAgent( agentSource.AgentId ), + cidr, + pingsPerSecond, + converter, + progressUpdate => LogAgentProgress( agentSource.AgentId, cidr, progressUpdate.ProgressPercentage ), + cancellationToken + ); + + return response.Result; + } + catch ( OperationCanceledException ex ) { + logger.LogWarning( ex, "Scan of subnet {Cidr} via agent {AgentId} was cancelled", cidr, agentSource.AgentId ); + return CreateFailedScanResult( cidr ); + } + catch ( Exception ex ) { + logger.LogWarning( + ex, + "Failed to scan subnet {Cidr} via agent {AgentId}: {ErrorMessage}. Returning partial results.", + cidr, + agentSource.AgentId, + ex.Message + ); + return CreateFailedScanResult( cidr ); + } + } + + private Domain.Agent MapAgentIdToDomainAgent( AgentId agentId ) { + var agent = inventory.Agents.FirstOrDefault( a => a.Id == agentId.Value ); + + if ( agent == null ) { + logger.LogWarning( "Agent {AgentId} not found in inventory", agentId ); + return new Domain.Agent { Id = agentId.Value, Address = string.Empty }; + } + + return agent; + } + + private void LogAgentProgress( AgentId agentId, CidrBlock cidr, byte progressPercentage ) { + logger.LogDebug( "Agent {AgentId} scan progress for {Cidr}: {Progress}%", agentId, cidr, progressPercentage ); + // TODO: Emit progress updates via ResultUpdated event + } + + private static SubnetScanResult CreateFailedScanResult( CidrBlock cidr ) { + return new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = [], + Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.Error + }; + } + + private static NetworkScanResult BuildProgressResult( + List completedSubnets, + IReadOnlyCollection inProgressSubnets, + DateTime startTime + ) { + var allSubnets = completedSubnets.Concat( inProgressSubnets ).ToList(); + var progress = CalculateProgress( completedSubnets.Count, allSubnets.Count ); + + return new NetworkScanResult { + Subnets = allSubnets, + Metadata = new Metadata { StartedAt = startTime, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.InProgress, + Progress = progress + }; + } + + private NetworkScanResult BuildFinalResult( + List allResults, + DateTime startTime, + ILogger logger + ) { + var endTime = DateTime.UtcNow; + + // Merge results for subnets that were scanned multiple times + var mergedResults = MergeOverlappingSubnetResults( allResults, logger ); + var successCount = allResults.Count( s => s.Status == ScanResultStatus.Success ); + var failureCount = allResults.Count - successCount; + + var finalResult = new NetworkScanResult { + Subnets = mergedResults, + Metadata = new Metadata { StartedAt = startTime, EndedAt = endTime }, + Status = successCount == allResults.Count ? ScanResultStatus.Success : ScanResultStatus.Error, + Progress = Percentage.Hundred + }; + + ResultUpdated?.Invoke( this, finalResult ); + + // Log summary with warnings if there were failures + if ( failureCount > 0 ) { + var failedSubnets = allResults.Where( s => s.Status == ScanResultStatus.Error ).Select( s => s.CidrBlock ) + .ToList(); + logger.LogWarning( + "Distributed scan completed with partial results: {SuccessCount}/{TotalCount} scan operations successful, {FailureCount} failed. Failed subnets: {FailedSubnets}", + successCount, + allResults.Count, + failureCount, + string.Join( ", ", failedSubnets ) + ); + } + else { + logger.LogInformation( + "Distributed scan completed: {SuccessCount}/{TotalCount} scan operations successful, {UniqueSubnets} unique subnets", + successCount, + allResults.Count, + mergedResults.Count + ); + } + + return finalResult; + } + + private List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { + // Group results by CIDR and merge devices from multiple scans + var resultsByCidr = allResults + .GroupBy( r => r.CidrBlock ) + .Select( group => { + var cidr = group.Key; + var scans = group.ToList(); + + if ( scans.Count == 1 ) { + return scans[0]; + } + + // Multiple scans of the same subnet - merge the results + logger.LogDebug( "Merging {Count} scan results for subnet {Cidr}", scans.Count, cidr ); + + // Combine all discovered devices, deduplicating by Device ID and unioning addresses + var allDevices = scans + .SelectMany( s => s.DiscoveredDevices ) + .GroupBy( d => ( (IAddressableDevice) d ).GetDeviceId().ToString() ) + .Where( g => g.Key != string.Empty ) + .Select( g => { + // Union addresses from all instances of this device, preserving richest data + var mergedAddresses = g + .SelectMany( d => d.Addresses ) + .GroupBy( a => ( a.Type, a.Value ) ) + .Select( ag => ag.First() ) + .ToList(); + return new DiscoveredDevice { + Addresses = mergedAddresses, + Ports = g.SelectMany( d => d.Ports ).Distinct().ToList(), + Timestamp = g.Min( d => d.Timestamp ) + }; + } ) + .ToList(); + + // Combine all discovery attempts + var allAttempts = scans + .SelectMany( s => s.DiscoveryAttempts ) + .ToImmutableHashSet(); + + // Use the earliest start time and latest end time + var startTime = scans.Min( s => s.Metadata.StartedAt ); + var endTime = scans.Max( s => s.Metadata.EndedAt ); + + logger.LogInformation( + "Merged {DeviceCount} unique devices from {ScanCount} scans of {Cidr}", + allDevices.Count, + scans.Count, + cidr + ); + + return new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = allDevices, + Metadata = new Metadata { StartedAt = startTime, EndedAt = endTime }, + Status = scans.All( s => s.Status == ScanResultStatus.Success ) + ? ScanResultStatus.Success + : ScanResultStatus.Error, + DiscoveryAttempts = allAttempts + }; + } ) + .ToList(); + + return resultsByCidr; + } + + private static Percentage CalculateProgress( int completed, int total ) { + if ( total == 0 ) { + return Percentage.Zero; + } + + var progressValue = (byte) ( completed * 100 / total ); + return new Percentage( progressValue ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs index 4ae23f39..50b8bf7f 100644 --- a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs +++ b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs @@ -19,7 +19,7 @@ namespace Drift.Cli.Commands.Scan.ResultProcessors; internal static class SubnetScanResultProcessor { private const string Na = "n/a"; - private static int _deviceIdCounter = 0; + private static int _deviceIdCounter; internal static List Process( SubnetScanResult scanResult, Network? network ) { // TODO test throw new Exception( "ads" ); diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index 1aac7dc0..e0065d8c 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -1,14 +1,17 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Scan.Interactive; using Drift.Cli.Commands.Scan.Interactive.Input; using Drift.Cli.Commands.Scan.NonInteractive; +using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.SpecFile; using Drift.Common.Network; using Drift.Domain; using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Core.Abstractions; using Drift.Scanning.Subnets; using Drift.Scanning.Subnets.Interface; using Microsoft.Extensions.Logging; @@ -16,14 +19,8 @@ namespace Drift.Cli.Commands.Scan; /* - * Ideas: - * Interactive mode: - * ➤ New host found: 192.168.1.42 - * ➤ Port 22 no longer open on 192.168.1.10 - * → Would you like to update the declared state? [y/N] - * Monitor mode: - * drift monitor --reference declared.yaml --interval 10m --notify slack,email,log,webhook + * drift monitor declared.yaml --interval 10m --notify slack,email,log,webhook */ internal class ScanCommand : CommandBase { public ScanCommand( IServiceProvider provider ) : base( "scan", "Scan the network and detect drift", provider ) { @@ -58,70 +55,149 @@ protected override ScanParameters CreateParameters( ParseResult result ) { internal class ScanCommandHandler( IOutputManager output, - INetworkScanner scanner, + INetworkScanner localScanner, IInterfaceSubnetProvider interfaceSubnetProvider, - ISpecFileProvider specProvider + ISpecFileProvider specProvider, + ICluster cluster, + IPeerMessageEnvelopeConverter converter ) : ICommandHandler { public async Task Invoke( ScanParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running scan command" ); - Network? network; + var (inventory, loadFailed) = await LoadInventoryAsync( parameters.SpecFile ); + if ( loadFailed ) { + return ExitCodes.GeneralError; + } + + var resolvedSubnets = await ResolveSubnetsAsync( inventory, cancellationToken ); + var scanRequest = BuildScanRequest( resolvedSubnets ); + + PrintScanSummary( resolvedSubnets, scanRequest, inventory.Agents.Any() ); + + var scanner = CreateScanner( inventory, resolvedSubnets ); + var uiTask = StartUi( parameters, inventory, scanner, scanRequest ); + Task.WaitAll( uiTask ); + + output.Log.LogDebug( "scan command completed" ); + + return ExitCodes.Success; + } + + private async Task<(Inventory inventory, bool loadFailed)> LoadInventoryAsync( FileInfo? specFile ) { try { - network = ( await specProvider.GetDeserializedAsync( parameters.SpecFile ) )?.Network; + var loadedInventory = await specProvider.GetDeserializedAsync( specFile ); + // If no spec file provided, use empty inventory + var inventory = loadedInventory ?? new Inventory { Network = new Network(), Agents = [] }; + return (inventory, false); } catch ( FileNotFoundException ) { - return ExitCodes.GeneralError; - } - - var subnetProviders = new List { interfaceSubnetProvider }; - if ( network != null ) { - subnetProviders.Add( new PredefinedSubnetProvider( network.Subnets ) ); + // Spec file was explicitly provided but not found - this is an error + return (new Inventory { Network = new Network(), Agents = [] }, true); } + } + private async Task> ResolveSubnetsAsync( Inventory inventory, CancellationToken cancellationToken ) { + var subnetProviders = BuildSubnetProviders( inventory, cancellationToken ); var subnetProvider = new CompositeSubnetProvider( subnetProviders ); output.Normal.WriteLineVerbose( $"Using {subnetProvider.GetType().Name}" ); output.Log.LogDebug( "Using {SubnetProviderType}", subnetProvider.GetType().Name ); - var subnets = subnetProvider.Get(); + return await subnetProvider.GetAsync(); + } + + private List BuildSubnetProviders( Inventory inventory, CancellationToken cancellationToken ) { + var providers = new List { interfaceSubnetProvider }; + + if ( inventory.Network != null ) { + providers.Add( new PredefinedSubnetProvider( inventory.Network.Subnets ) ); + } + + if ( inventory.Agents.Any() ) { + providers.Add( new AgentSubnetProvider( + output.GetLogger(), + inventory.Agents, + cluster, + cancellationToken + ) ); + } + + return providers; + } + + private static NetworkScanOptions BuildScanRequest( List resolvedSubnets ) { + var uniqueCidrs = resolvedSubnets + .Select( rs => rs.Cidr ) + .Distinct() + .ToList(); - var scanRequest = new NetworkScanOptions { Cidrs = subnets }; + return new NetworkScanOptions { Cidrs = uniqueCidrs }; + } - // TODO many more varieties - output.Normal.WriteLine( 0, $"Scanning {subnets.Count} subnet{( subnets.Count > 1 ? "s" : string.Empty )}" ); - foreach ( var cidr in subnets ) { - // TODO write name if from spec: Ui.WriteLine( 1, $"{subnet.Id}: {subnet.Network}" ); - output.Normal.Write( 1, $"{cidr}", ConsoleColor.Cyan ); + private void PrintScanSummary( List resolvedSubnets, NetworkScanOptions scanRequest, bool hasAgents ) { + var groupedSubnets = resolvedSubnets + .GroupBy( subnet => subnet.Cidr ) + .Select( group => new { Cidr = group.Key, Sources = group.Select( r => r.Source ).Distinct().ToList() } ) + .ToList(); + + output.Normal.WriteLine( + 0, + $"Scanning {groupedSubnets.Count} subnet{( groupedSubnets.Count > 1 ? "s" : string.Empty )}" + ); + + foreach ( var subnet in groupedSubnets ) { + var sourceList = string.Join( ", ", subnet.Sources ); + output.Normal.Write( 1, $"{subnet.Cidr}", ConsoleColor.Cyan ); output.Normal.WriteLine( - " (" + IpNetworkUtils.GetIpRangeCount( cidr ) + + " (" + IpNetworkUtils.GetIpRangeCount( subnet.Cidr ) + " addresses, estimated scan time is " + - scanRequest.EstimatedDuration( - cidr ) + // TODO .Humanize( 2, CultureInfo.InvariantCulture, minUnit: TimeUnit.Second ) - ")", ConsoleColor.DarkGray ); + scanRequest.EstimatedDuration( subnet.Cidr ) + + ")" + + ( hasAgents ? $" via {sourceList}" : string.Empty ), + ConsoleColor.DarkGray + ); } output.Log.LogInformation( "Scanning {SubnetCount} subnet(s): {SubnetList}", - subnets.Count, - string.Join( ", ", subnets ) + groupedSubnets.Count, + string.Join( ", ", groupedSubnets.Select( s => s.Cidr ) ) ); + } - Task uiTask; + private INetworkScanner CreateScanner( Inventory inventory, List resolvedSubnets ) { + if ( !inventory.Agents.Any() ) { + return localScanner; + } + output.WarnAgentPreview(); + + return new DistributedNetworkScanner( + localScanner, + cluster, + converter, + resolvedSubnets, + inventory, + output.GetLogger() + ); + } + + private Task StartUi( ScanParameters parameters, Inventory inventory, INetworkScanner scanner, NetworkScanOptions scanRequest ) { if ( parameters.Interactive ) { - var ui = new InteractiveUi( output, network, scanner, scanRequest, new DefaultKeyMap(), parameters.ShowLogPanel ); - uiTask = ui.RunAsync(); + var ui = new InteractiveUi( + output, + inventory.Network, + scanner, + scanRequest, + new DefaultKeyMap(), + parameters.ShowLogPanel + ); + return ui.RunAsync(); } else { var ui = new NonInteractiveUi( output, scanner ); - uiTask = ui.RunAsync( scanRequest, network, parameters.OutputFormat ); + return ui.RunAsync( scanRequest, inventory.Network, parameters.OutputFormat ); } - - Task.WaitAll( uiTask ); - - output.Log.LogDebug( "scan command completed" ); - - return ExitCodes.Success; } } \ No newline at end of file diff --git a/src/Cli/Commands/Scan/ScanParameters.cs b/src/Cli/Commands/Scan/ScanParameters.cs index 37178c9e..63235d26 100644 --- a/src/Cli/Commands/Scan/ScanParameters.cs +++ b/src/Cli/Commands/Scan/ScanParameters.cs @@ -1,9 +1,10 @@ using System.CommandLine; using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Scan; -internal record ScanParameters : DefaultParameters { +internal record ScanParameters : SpecParameters { internal static class Options { internal static readonly Option Interactive = new("--interactive", "-i") { Description = "Interactive mode", Arity = ArgumentArity.Zero diff --git a/src/Cli/DriftCli.cs b/src/Cli/DriftCli.cs index d7d88550..f1f36fb7 100644 --- a/src/Cli/DriftCli.cs +++ b/src/Cli/DriftCli.cs @@ -16,7 +16,7 @@ internal static async Task InvokeAsync( Action? configureInvocation = null, CancellationToken cancellationToken = default ) { - // Justification: intentionally using the most basic output form to make sure the error is surfaced, no matter what code fails + // Justification: intentionally using the most basic output form to make sure the error is surfaced, no matter what fails #pragma warning disable RS0030 var error = Console.Error; diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index ef3cdc08..d7136c5d 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -2,6 +2,9 @@ using System.CommandLine.Help; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using Drift.Agent.PeerProtocol; +using Drift.Cli.Commands.Agent; +using Drift.Cli.Commands.Agent.Subcommands.Start; using Drift.Cli.Commands.Common; using Drift.Cli.Commands.Help; using Drift.Cli.Commands.Init; @@ -14,6 +17,9 @@ using Drift.Cli.SpecFile; using Drift.Domain.ExecutionEnvironment; using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Client; +using Drift.Networking.PeerStreaming.Core; using Drift.Scanning; using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; @@ -50,7 +56,12 @@ internal static RootCommand Create( ConfigureDefaults( services, toConsole, plainConsole ); ConfigureBuiltInCommandHandlers( services ); ConfigureDynamicCommands( services, customCommands ?? [] ); - configureServices?.Invoke( services ); + + if ( configureServices != null ) { + configureServices.Invoke( services ); + // Allow agent host to override it's services with the same configuration + services.AddScoped>( _ => configureServices ); + } var provider = services.BuildServiceProvider(); var rootCommand = CreateRootCommand( provider ); @@ -66,9 +77,18 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureSpecProvider( services ); ConfigureSubnetProvider( services ); ConfigureNetworkScanner( services ); + ConfigureAgentCluster( services ); + } + + private static void ConfigureAgentCluster( IServiceCollection services ) { + services.AddPeerStreamingCore( new PeerStreamingOptions { + MessageAssembly = typeof(PeerProtocolAssemblyMarker).Assembly + } ); + services.AddPeerStreamingClient(); + services.AddClustering(); } - private static void ConfigureExecutionEnvironment( IServiceCollection services ) { + internal static void ConfigureExecutionEnvironment( IServiceCollection services ) { services.AddSingleton(); } @@ -76,7 +96,10 @@ private static RootCommand CreateRootCommand( IServiceProvider provider ) { // TODO 'from' or 'against'? var rootCommand = new RootCommand( $"{Chars.SatelliteAntenna} Drift CLI — monitor network drift against your declared state" ) { - new InitCommand( provider ), new ScanCommand( provider ), new LintCommand( provider ) + new InitCommand( provider ), + new ScanCommand( provider ), + new LintCommand( provider ), + new AgentCommand( provider ) }; rootCommand.TreatUnmatchedTokensAsErrors = true; @@ -95,6 +118,7 @@ private static void ConfigureOutput( IServiceCollection services, bool toConsole var factory = sp.GetRequiredService(); return factory.Create( parseResult, plainConsole ); } ); + // Note: since ILogger is scoped, singletons cannot access logging via DI services.AddScoped( sp => sp.GetRequiredService().GetLogger() ); } @@ -102,7 +126,7 @@ private static void ConfigureSpecProvider( IServiceCollection services ) { services.AddScoped(); } - private static void ConfigureSubnetProvider( IServiceCollection services ) { + public static void ConfigureSubnetProvider( IServiceCollection services ) { services.AddScoped(); } @@ -110,6 +134,7 @@ private static void ConfigureBuiltInCommandHandlers( IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void ConfigureDynamicCommands( IServiceCollection services, CommandRegistration[] commands ) { @@ -128,7 +153,7 @@ private static void ConfigureDynamicCommands( } } - private static void ConfigureNetworkScanner( IServiceCollection services ) { + internal static void ConfigureNetworkScanner( IServiceCollection services ) { if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { services.AddSingleton(); } else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { @@ -142,6 +167,16 @@ private static void ConfigureNetworkScanner( IServiceCollection services ) { services.AddScoped(); } + /// + /// Configures core services required for agent functionality (scanning, subnet discovery, execution environment). + /// This is used both by the CLI when running locally and by agents when handling remote requests. + /// + internal static void ConfigureAgentCoreServices( IServiceCollection services ) { + ConfigureExecutionEnvironment( services ); + ConfigureSubnetProvider( services ); + ConfigureNetworkScanner( services ); + } + private static void AddFigletHeaderToHelpCommand( RootCommand rootCommand ) { // rootCommand.Add(CommonParameters.Options.OutputFormat ); foreach ( var t in rootCommand.Options ) { diff --git a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs index dbb31e36..56f3986d 100644 --- a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs +++ b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs @@ -17,18 +17,38 @@ public void Log( case LogLevel.Critical: case LogLevel.Error: normalOutput.WriteLineError( message ); + if ( exception != null ) { + normalOutput.WriteLineError( exception.ToString() ); + } + break; case LogLevel.Warning: normalOutput.WriteLineWarning( message ); + if ( exception != null ) { + normalOutput.WriteLineWarning( exception.ToString() ); + } + break; case LogLevel.Information: normalOutput.WriteLine( message ); + if ( exception != null ) { + normalOutput.WriteLine( exception.ToString() ); + } + break; case LogLevel.Debug: normalOutput.WriteLineVerbose( message ); + if ( exception != null ) { + normalOutput.WriteLineVerbose( exception.ToString() ); + } + break; case LogLevel.Trace: normalOutput.WriteLineVeryVerbose( message ); + if ( exception != null ) { + normalOutput.WriteLineVeryVerbose( exception.ToString() ); + } + break; case LogLevel.None: break; diff --git a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs index f252e41e..991e0372 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -12,4 +12,16 @@ internal static class OutputManagerExtensions { public static ILogger GetLogger( this IOutputManager outputManager ) { return new CompoundLogger( [new NormalOutputLoggerAdapter( outputManager.Normal ), outputManager.Log] ); } + + /// + /// Writes preview-mode warnings for agent support to all outputs. + /// Applicable to both agent hosting and distributed scanning via agents. + /// + public static void WarnAgentPreview( this IOutputManager outputManager ) { + var logger = outputManager.GetLogger(); + logger.LogWarning( "Distributed scanning via agents is a preview feature and should be used with caution." ); + logger.LogWarning( "Agent communication is unencrypted. Do not use on untrusted networks." ); + logger.LogWarning( "Agents run without authentication. Any client that can reach the agent port can connect." ); + logger.LogWarning( "Agent adoption (--adoptable / --join) is not yet implemented and has no effect." ); + } } \ No newline at end of file diff --git a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs index 5295d769..71510390 100644 --- a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs @@ -13,7 +13,8 @@ internal class ConsoleOutputManager( bool normalVeryVerbose, OutputFormat outputFormat, bool plainConsole, - TextReader reader + TextReader reader, + TextWriter? ansiConsoleOut = null ) : IOutputManager { public ILogOutput Log { get; @@ -21,7 +22,7 @@ public ILogOutput Log { public INormalOutput Normal { get; - } = new NormalOutput( normalStdOut, normalErrOut, plainConsole, normalVerbose, normalVeryVerbose ); + } = new NormalOutput( normalStdOut, normalErrOut, plainConsole, normalVerbose, normalVeryVerbose, ansiConsoleOut ); public IJsonOutput Json { get; diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs index e8fb4d76..5c1da1c9 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs @@ -14,13 +14,14 @@ internal partial class NormalOutput( TextWriter errOut, bool plainOutput = false, bool verbose = false, - bool veryVerbose = false + bool veryVerbose = false, + TextWriter? ansiConsoleOut = null ) : INormalOutput { // TODO test private const bool MarkupOutput = false; public IAnsiConsole GetAnsiConsole() { - var settings = new AnsiConsoleSettings { Out = new AnsiConsoleOutput( stdOut ) }; + var settings = new AnsiConsoleSettings { Out = new AnsiConsoleOutput( ansiConsoleOut ?? stdOut ) }; if ( plainOutput ) { settings.Ansi = AnsiSupport.No; @@ -79,14 +80,13 @@ private static void WriteLineInternal( // ... on bgcolor] } } - else { - if ( foreground.HasValue ) { - System.Console.ForegroundColor = foreground.Value; - } - if ( background.HasValue ) { - System.Console.BackgroundColor = background.Value; - } + if ( foreground.HasValue ) { + System.Console.ForegroundColor = foreground.Value; + } + + if ( background.HasValue ) { + System.Console.BackgroundColor = background.Value; } textWriter.Write( line ); @@ -100,9 +100,8 @@ private static void WriteLineInternal( System.Console.BackgroundColor = background.Value; } } - else { - System.Console.ResetColor(); - } + + System.Console.ResetColor(); textWriter.WriteLine(); } diff --git a/src/Cli/Presentation/Console/OutputManagerFactory.cs b/src/Cli/Presentation/Console/OutputManagerFactory.cs index 976338ec..caf78669 100644 --- a/src/Cli/Presentation/Console/OutputManagerFactory.cs +++ b/src/Cli/Presentation/Console/OutputManagerFactory.cs @@ -59,16 +59,34 @@ public IOutputManager Create( bool plainConsole ) { var bridge = new WriterReaderBridge(); + + // Validate that we don't have duplicate writer instances that would break synchronization + if ( !interactiveOutputOnly ) { + if ( ReferenceEquals( consoleOut, consoleErr ) ) { + throw new InvalidOperationException( + $"{nameof(consoleOut)} and {nameof(consoleErr)} cannot be the same instance - this would break thread synchronization" + ); + } + + if ( ReferenceEquals( bridge.Writer, consoleOut ) || ReferenceEquals( bridge.Writer, consoleErr ) ) { + throw new InvalidOperationException( + $"bridge.Writer cannot be the same instance as {nameof(consoleOut)} or {nameof(consoleErr)} - this would break thread synchronization" + ); + } + } + + var syncBridgeWriter = TextWriter.Synchronized( bridge.Writer ); + var outWrapper = new CompoundTextWriter(); - outWrapper.Writers.Add( bridge.Writer ); + outWrapper.Writers.Add( syncBridgeWriter ); if ( !interactiveOutputOnly ) { - outWrapper.Writers.Add( consoleOut ); + outWrapper.Writers.Add( TextWriter.Synchronized( consoleOut ) ); } var errWrapper = new CompoundTextWriter(); - errWrapper.Writers.Add( bridge.Writer ); + errWrapper.Writers.Add( syncBridgeWriter ); if ( !interactiveOutputOnly ) { - errWrapper.Writers.Add( consoleErr ); + errWrapper.Writers.Add( TextWriter.Synchronized( consoleErr ) ); } var consoleOuts = GetConsoleOuts( @@ -99,7 +117,10 @@ bool plainConsole veryVerbose, outputFormat, plainConsole, - bridge.Reader + bridge.Reader, + // When in interactive mode, text output is suppressed from the terminal (goes to pipe only), + // but the AnsiConsole for the Live display must still write to the real terminal. + interactiveOutputOnly ? consoleOut : null ); } diff --git a/src/Cli/Properties/launchSettings.json b/src/Cli/Properties/launchSettings.json index e8bbdf6a..d9f79120 100644 --- a/src/Cli/Properties/launchSettings.json +++ b/src/Cli/Properties/launchSettings.json @@ -20,7 +20,7 @@ }, "scan ~": { "commandName": "Project", - "commandLineArgs": "scan ~", + "commandLineArgs": "scan ~ -v", "dotnetRunMessages": true, "environmentVariables": {} }, @@ -47,6 +47,18 @@ "commandLineArgs": "lint ~", "dotnetRunMessages": true, "environmentVariables": {} + }, + "agent start (home)": { + "commandName": "Project", + "commandLineArgs": "agent start ~ -vv -o log --adoptable", + "dotnetRunMessages": true, + "environmentVariables": {} + }, + "agent start --port 51516 (home)": { + "commandName": "Project", + "commandLineArgs": "agent start ~ --port 51516", + "dotnetRunMessages": true, + "environmentVariables": {} } } } diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index 2bdadc5e..a49980c3 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -1,11 +1,11 @@  - + - + \ No newline at end of file diff --git a/src/Diff.Tests/DiffTest.cs b/src/Diff.Tests/DiffTest.cs index fbb171db..363206a9 100644 --- a/src/Diff.Tests/DiffTest.cs +++ b/src/Diff.Tests/DiffTest.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; using Drift.Diff.Domain; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -8,6 +6,7 @@ using Drift.Domain.Device.Discovered; using Drift.Domain.Extensions; using Drift.Domain.Scan; +using Drift.Serialization.Converters; using Drift.TestUtilities; using JsonConverter = Drift.Serialization.JsonConverter; @@ -247,21 +246,4 @@ private static void Print( List diffs ) { Console.WriteLine( $"{diff.PropertyPath}: {diff.DiffType} — '{diff.Original}' → '{diff.Updated}'" ); } } - - private sealed class IpAddressConverter : JsonConverter { - public override System.Net.IPAddress Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options - ) { - string? ip = reader.GetString(); - var ipAddress = ( ip == null ) ? null : System.Net.IPAddress.Parse( ip ); - return ipAddress ?? throw new Exception( "Cannot read" ); // System.Net.IPAddress.None; - } - - public override void Write( Utf8JsonWriter writer, System.Net.IPAddress value, JsonSerializerOptions options ) { - ArgumentNullException.ThrowIfNull( writer ); - writer.WriteStringValue( value?.ToString() ); - } - } } \ No newline at end of file diff --git a/src/Domain/AgentId.cs b/src/Domain/AgentId.cs new file mode 100644 index 00000000..8fc538e5 --- /dev/null +++ b/src/Domain/AgentId.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace Drift.Domain; + +public class AgentId { + private const string Prefix = "agentid_"; + + [JsonConstructor] + public AgentId() { + } + + public AgentId( string value ) { + if ( !value.StartsWith( Prefix ) ) { + throw new FormatException( $"AgentId must start with '{Prefix}'." ); + } + + Value = value; + } + + public string Value { + get; + set; + } = string.Empty; + + public bool IsGuidBased => + Guid.TryParse( Value[Prefix.Length..], out _ ); + + public Guid? AsGuidOrNull => + Guid.TryParse( Value[Prefix.Length..], out var guid ) ? guid : null; + + public static implicit operator AgentId( string value ) => new AgentId( value ); + + public static implicit operator string( AgentId id ) => id.Value; + + public static AgentId New() => new AgentId( Prefix + Guid.NewGuid() ); + + public override string ToString() => Value; +} \ No newline at end of file diff --git a/src/Domain/Environment.cs b/src/Domain/Environment.cs new file mode 100644 index 00000000..e56727f2 --- /dev/null +++ b/src/Domain/Environment.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +// TODO remove when no longer a draft +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + +namespace Drift.Domain; + +/*[JsonSerializable( typeof(Environment) )] // Enable source generation for this type +[JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Default )] +public partial class EnvironmentContext : JsonSerializerContext { +}*/ + +public record Environment { + public required string Name { + get; + init; + } + + public bool Active { + get; + set; + } + + public List Agents { + get; + set; + } +} + +public record Agent { + public string Id { // TODO use AgentId???? or should that only be for internal use + get; + set; + } + + /*public IpAddress Address { + //TODO support hostname too, maybe even mac!? + get; + set; + }*/ + + public string Address { + get; + set; + } + + public AgentAuthentication Authentication { + get; + set; + } +} + +public record AgentAuthentication { + [JsonIgnore( Condition = JsonIgnoreCondition.Never )] + public AuthType Type { + get; + set; + } +} + +public enum AuthType { + None = 1, + ApiKey = 2, + Certificate =3 +} \ No newline at end of file diff --git a/src/Domain/Inventory.cs b/src/Domain/Inventory.cs index 2ece762e..347999be 100644 --- a/src/Domain/Inventory.cs +++ b/src/Domain/Inventory.cs @@ -5,4 +5,9 @@ public required Network Network { get; init; } + + public List Agents { + get; + set; + } = []; } \ No newline at end of file diff --git a/src/Domain/RequestId.cs b/src/Domain/RequestId.cs new file mode 100644 index 00000000..259b7369 --- /dev/null +++ b/src/Domain/RequestId.cs @@ -0,0 +1,23 @@ +namespace Drift.Domain; + +public record RequestId( Guid Value ) { + private const string Prefix = "requestid_"; + + public static implicit operator RequestId( string value ) { +#pragma warning disable S3877 // Throwing in an implicit operator is intentional here — invalid inputs should be rejected early + if ( !value.StartsWith( Prefix ) ) { + throw new FormatException( $"Invalid RequestId format. Must start with '{Prefix}'." ); + } + + var guidPart = value[Prefix.Length..]; + + if ( !Guid.TryParse( guidPart, out var guid ) ) { + throw new FormatException( "Invalid GUID in RequestId." ); + } +#pragma warning restore S3877 + + return new RequestId( guid ); + } + + public static implicit operator string( RequestId id ) => $"{Prefix}{id.Value}"; +} \ No newline at end of file diff --git a/src/Networking.Cluster/Cluster.cs b/src/Networking.Cluster/Cluster.cs new file mode 100644 index 00000000..722bd799 --- /dev/null +++ b/src/Networking.Cluster/Cluster.cs @@ -0,0 +1,210 @@ +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Messages; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.Cluster; + +internal sealed class Cluster( + IPeerMessageEnvelopeConverter envelopeConverter, + IPeerStreamManager peerStreamManager, + PeerResponseCorrelator responseCorrelator, + ILogger logger, + ClusterOptions? options = null +) : ICluster { + private readonly ClusterOptions _options = options ?? new ClusterOptions(); + /*public async Task SendAsync( + Domain.Agent agent, + TMessage message, + CancellationToken cancellationToken = default + ) where TMessage : IPeerMessage { + try { + await SendInternalAsync( agent, message, cancellationToken ); + } + catch ( Exception ex ) { + logger.LogWarning( ex, "Send to {Peer} failed", agent ); + } + }*/ + + /* public async Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ) { + var peers = peerStreamManager.GetConnectedPeers(); + + var tasks = peers.Select( async peer => { + // TODO optimistically assume connection is alive, but automatically reconnect if it's not + try { + await SendInternalAsync( peer, message, cancellationToken ); + } + catch ( Exception ex ) { + logger.LogWarning( ex, "Broadcast to {Peer} failed", peer ); + } + } ); + + await Task.WhenAll( tasks ); + }*/ + + /*public async Task SendInternalAsync( + Domain.Agent agent, + TMessage message, + CancellationToken cancellationToken = default + ) where TMessage : IPeerMessage { + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), "agentid_local1" ); + var envelope = envelopeConverter.ToEnvelope( message ); + await connection.SendAsync( envelope ); + }*/ + + public async Task SendAndWaitAsync( + Domain.Agent agent, + TRequest message, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + return await ExecuteWithRetryAsync( + agent, + async () => await SendAndWaitInternalAsync( agent, message, timeout, cancellationToken ), + cancellationToken + ); + } + + private async Task SendAndWaitInternalAsync( + Domain.Agent agent, + TRequest message, + TimeSpan? timeout, + CancellationToken cancellationToken + ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + var correlationId = Guid.NewGuid().ToString(); + var envelope = envelopeConverter.ToEnvelope( message ); + envelope.CorrelationId = correlationId; + + // Register correlator BEFORE sending + var responseTask = responseCorrelator.WaitForResponseAsync( + correlationId, + timeout ?? _options.DefaultTimeout, + cancellationToken + ); + + // Request + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + await connection.SendAsync( envelope ); + + // Response + var response = await responseTask; + return envelopeConverter.FromEnvelope( response ); + } + + public async Task SendAndWaitStreamingAsync( + Domain.Agent agent, + TRequest message, + string finalMessageType, + Action onProgressUpdate, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + return await ExecuteWithRetryAsync( + agent, + async () => await SendAndWaitStreamingInternalAsync( + agent, + message, + finalMessageType, + onProgressUpdate, + timeout, + cancellationToken + ), + cancellationToken + ); + } + + private async Task SendAndWaitStreamingInternalAsync( + Domain.Agent agent, + TRequest message, + string finalMessageType, + Action onProgressUpdate, + TimeSpan? timeout, + CancellationToken cancellationToken + ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + var correlationId = Guid.NewGuid().ToString(); + var envelope = envelopeConverter.ToEnvelope( message ); + envelope.CorrelationId = correlationId; + + // Register streaming correlator BEFORE sending + var responseTask = responseCorrelator.WaitForStreamingResponseAsync( + correlationId, + finalMessageType, + onProgressUpdate, + timeout ?? _options.StreamingTimeout, + cancellationToken + ); + + // Request + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + await connection.SendAsync( envelope ); + + // Final Response + var response = await responseTask; + return envelopeConverter.FromEnvelope( response ); + } + + private async Task ExecuteWithRetryAsync( + Domain.Agent agent, + Func> operation, + CancellationToken cancellationToken + ) { + var attempt = 0; + Exception? lastException = null; + + while ( attempt <= _options.MaxRetryAttempts ) { + try { + if ( attempt > 0 ) { + var delay = CalculateBackoffDelay( attempt ); + logger.LogDebug( + "Retrying operation for agent {AgentId} (attempt {Attempt}/{MaxAttempts}) after {Delay}ms", + agent.Id, + attempt, + _options.MaxRetryAttempts, + delay + ); + await Task.Delay( delay, cancellationToken ); + } + + return await operation(); + } + catch ( OperationCanceledException ) { + // Don't retry on cancellation + throw; + } + catch ( Exception ex ) { + lastException = ex; + attempt++; + + if ( attempt > _options.MaxRetryAttempts ) { + logger.LogError( + ex, + "Operation failed for agent {AgentId} after {Attempts} attempts", + agent.Id, + attempt + ); + break; + } + + logger.LogWarning( + ex, + "Operation failed for agent {AgentId} (attempt {Attempt}/{MaxAttempts}): {Message}", + agent.Id, + attempt, + _options.MaxRetryAttempts, + ex.Message + ); + } + } + + // All retries exhausted + throw new AggregateException( + $"Operation failed for agent {agent.Id} after {attempt} attempts", + lastException! + ); + } + + private int CalculateBackoffDelay( int attempt ) { + // Exponential backoff: base * 2^(attempt-1) + var delay = _options.RetryBaseDelayMs * Math.Pow( 2, attempt - 1 ); + return (int)Math.Min( delay, _options.RetryMaxDelayMs ); + } +} \ No newline at end of file diff --git a/src/Networking.Cluster/ClusterOptions.cs b/src/Networking.Cluster/ClusterOptions.cs new file mode 100644 index 00000000..032ccbb7 --- /dev/null +++ b/src/Networking.Cluster/ClusterOptions.cs @@ -0,0 +1,43 @@ +namespace Drift.Networking.Cluster; + +public sealed class ClusterOptions { + /// + /// Gets the maximum number of retry attempts for failed operations. + /// + public int MaxRetryAttempts { + get; + init; + } = 3; + + /// + /// Gets the base delay between retry attempts in milliseconds. + /// + public int RetryBaseDelayMs { + get; + init; + } = 100; + + /// + /// Gets the maximum delay between retry attempts in milliseconds. + /// + public int RetryMaxDelayMs { + get; + init; + } = 5000; + + /// + /// Gets the default timeout for send-and-wait operations. + /// + public TimeSpan DefaultTimeout { + get; + init; + } = TimeSpan.FromSeconds( 30 ); + + /// + /// Gets the default timeout for streaming operations (e.g., scans). + /// + public TimeSpan StreamingTimeout { + get; + init; + } = TimeSpan.FromMinutes( 5 ); +} \ No newline at end of file diff --git a/src/Networking.Cluster/Enrollment.cs b/src/Networking.Cluster/Enrollment.cs new file mode 100644 index 00000000..4d8fcee4 --- /dev/null +++ b/src/Networking.Cluster/Enrollment.cs @@ -0,0 +1,12 @@ +namespace Drift.Networking.Cluster; + +#pragma warning disable CS9113 +public class EnrollmentRequest( bool parametersAdoptable, string? parametersJoin ) { +#pragma warning restore CS9113 + public EnrollmentMethod Method => parametersAdoptable ? EnrollmentMethod.Adoption : EnrollmentMethod.Jwt; +} + +public enum EnrollmentMethod { + Adoption, + Jwt +} \ No newline at end of file diff --git a/src/Networking.Cluster/ICluster.cs b/src/Networking.Cluster/ICluster.cs new file mode 100644 index 00000000..14861026 --- /dev/null +++ b/src/Networking.Cluster/ICluster.cs @@ -0,0 +1,29 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Networking.Cluster; + +public interface ICluster { + // Task SendAsync( Domain.Agent agent, IPeerMessage message, CancellationToken cancellationToken = default ); + + Task SendAndWaitAsync( + Domain.Agent agent, + TRequest message, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TResponse : IPeerResponse where TRequest : IPeerRequest; + + Task SendAndWaitStreamingAsync( + Domain.Agent agent, + TRequest message, + string finalMessageType, + Action onProgressUpdate, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage; + + /*Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ); + Task> RequestSubnetsAsync( string peerAddress, CancellationToken cancellationToken = default ); + Task EnsureConnectedAsync( string peerAddress, CancellationToken cancellationToken = default ); + IReadOnlyCollection GetConnectedPeers();*/ +} \ No newline at end of file diff --git a/src/Networking.Cluster/Networking.Cluster.csproj b/src/Networking.Cluster/Networking.Cluster.csproj new file mode 100644 index 00000000..9c366ae1 --- /dev/null +++ b/src/Networking.Cluster/Networking.Cluster.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Networking.Cluster/ServiceCollectionExtensions.cs b/src/Networking.Cluster/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5d26edd7 --- /dev/null +++ b/src/Networking.Cluster/ServiceCollectionExtensions.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.Cluster; + +public static class ServiceCollectionExtensions { + public static void AddClustering( this IServiceCollection services ) { + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs new file mode 100644 index 00000000..85cbf055 --- /dev/null +++ b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs @@ -0,0 +1,13 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Grpc.Net.Client; + +namespace Drift.Networking.PeerStreaming.Client; + +internal sealed class DefaultPeerClientFactory : IPeerClientFactory { + public (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ) { + var channel = GrpcChannel.ForAddress( address, new GrpcChannelOptions() ); + var client = new PeerService.PeerServiceClient( channel ); + return ( client, channel ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj b/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj new file mode 100644 index 00000000..20592b5f --- /dev/null +++ b/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..81cf54a9 --- /dev/null +++ b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,10 @@ +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.PeerStreaming.Client; + +public static class ServiceCollectionExtensions { + public static void AddPeerStreamingClient( this IServiceCollection services ) { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs new file mode 100644 index 00000000..d516ef7b --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs @@ -0,0 +1,6 @@ +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public enum ConnectionSide { + Incoming, + Outgoing +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs b/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs new file mode 100644 index 00000000..2c43940d --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public class Empty : IPeerResponse { + private Empty() { + } + + internal static Empty Instance { + get; + } = new(); + + public static string MessageType => "empty-response"; + + public static JsonTypeInfo JsonInfo => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs new file mode 100644 index 00000000..f7a92656 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs @@ -0,0 +1,8 @@ +using Drift.Networking.Grpc.Generated; +using Grpc.Net.Client; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerClientFactory { + (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs new file mode 100644 index 00000000..0d6ef8e3 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessage { + static abstract string MessageType { + get; + } + + static abstract JsonTypeInfo JsonInfo { + get; + } +} + +#pragma warning disable S2326 // TResponse is an intentional phantom type parameter for type-safe request/response pairing +public interface IPeerRequest : IPeerMessage where TResponse : IPeerResponse; +#pragma warning restore S2326 + +public interface IPeerResponse : IPeerMessage { + static readonly Empty Empty = Empty.Instance; +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs new file mode 100644 index 00000000..69a02c1a --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs @@ -0,0 +1,9 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessageEnvelopeConverter { + public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage; + + public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage; +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs new file mode 100644 index 00000000..26a7740b --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs @@ -0,0 +1,23 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessageHandler { + /// + /// Gets the message type name that this handler can process. + /// + string MessageType { + get; + } + + /// + /// Handles an incoming peer message. The handler is responsible for sending response(s) + /// via the provided stream. Can send multiple responses for streaming scenarios. + /// + Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs new file mode 100644 index 00000000..37d6fe8d --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessageTypesProvider { + Dictionary Get(); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs new file mode 100644 index 00000000..6d34c9b8 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs @@ -0,0 +1,20 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerStream : IAsyncDisposable { + public int InstanceNo { + get; + } + + public AgentId AgentId { + get; + } + + public Task ReadTask { + get; + } + + public Task SendAsync( PeerMessage message ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs new file mode 100644 index 00000000..cf4576e3 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs @@ -0,0 +1,15 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerStreamManager : IAsyncDisposable { + public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ); + + public IPeerStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj b/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj new file mode 100644 index 00000000..97a14ed6 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs new file mode 100644 index 00000000..92ea5b17 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs @@ -0,0 +1,36 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public static class PeerMessageHandlerExtensions { + /// + /// Helper to send a response with the correct correlation ID set. + /// + /// The type of the response message to send. + public static async Task SendResponseAsync( + this IPeerStream stream, + IPeerMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IPeerMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + await stream.SendAsync( envelope ); + } + + /// + /// Helper to send a response without awaiting (fire and forget). + /// Useful for progress updates that shouldn't block processing. + /// + /// The type of the response message to send. + public static void SendResponseFireAndForget( + this IPeerStream stream, + IPeerMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IPeerMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + _ = stream.SendAsync( envelope ); + } +} diff --git a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs b/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs new file mode 100644 index 00000000..77717661 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs @@ -0,0 +1,16 @@ +using Drift.Domain; +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Core.Common; + +internal static class GrpcMetadataExtensions { + internal static AgentId GetAgentId( this Metadata metadata ) { + var v = metadata.Get( "agent-id" ); + + if ( v == null ) { + throw new Exception( "AgentId not found in gRPC metadata" ); + } + + return new AgentId( v.Value ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs new file mode 100644 index 00000000..18fa525e --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs @@ -0,0 +1,48 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core.Messages; + +public sealed class PeerMessageDispatcher { + private readonly PeerResponseCorrelator _responseCorrelator; + private readonly IPeerMessageEnvelopeConverter _envelopeConverter; + private readonly ILogger _logger; + private readonly Dictionary _handlers; + + public PeerMessageDispatcher( + IEnumerable handlers, + IPeerMessageEnvelopeConverter envelopeConverter, + PeerResponseCorrelator responseCorrelator, + ILogger logger + ) { + _responseCorrelator = responseCorrelator; + _envelopeConverter = envelopeConverter; + _logger = logger; + _handlers = handlers.ToDictionary( h => h.MessageType, StringComparer.OrdinalIgnoreCase ); + } + + public async Task DispatchAsync( PeerMessage message, PeerStream peerStream, CancellationToken ct = default ) { + _logger.LogDebug( "Dispatching message: {Type}", message.MessageType ); + + // If this is a response to a pending request, complete it + if ( !string.IsNullOrEmpty( message.ReplyTo ) ) { + if ( _responseCorrelator.TryCompleteResponse( message.ReplyTo, message ) ) { + _logger.LogDebug( "Completed pending request: {CorrelationId}", message.ReplyTo ); + } + else { + _logger.LogWarning( "Ignoring response for unknown correlation ID: {CorrelationId}", message.ReplyTo ); + } + + return; + } + + // Dispatch to handler - handler is responsible for sending response(s) + if ( _handlers.TryGetValue( message.MessageType, out var handler ) ) { + await handler.HandleAsync( message, _envelopeConverter, peerStream, ct ); + return; + } + + throw new NotImplementedException( "Unknown message type '" + message.MessageType + "'" ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs new file mode 100644 index 00000000..3b9a4467 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Networking.PeerStreaming.Core.Messages; + +internal sealed class PeerMessageEnvelopeConverter : IPeerMessageEnvelopeConverter { + public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage { + string json = JsonSerializer.Serialize( message, T.JsonInfo ); + return new PeerMessage { MessageType = T.MessageType, Message = json, }; + } + + public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage { + if ( envelope.MessageType != T.MessageType ) { + throw new InvalidOperationException( + $"Envelope contains '{envelope.MessageType}' but caller expects '{T.MessageType}'." + ); + } + + return JsonSerializer.Deserialize( envelope.Message, T.JsonInfo.Options )!; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs new file mode 100644 index 00000000..3f0b2138 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; +using Drift.Networking.Grpc.Generated; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core.Messages; + +// TODO private? +public sealed class PeerResponseCorrelator { + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly ConcurrentDictionary _streamingRequests = new(); + private readonly ILogger _logger; + + public PeerResponseCorrelator( ILogger logger ) { + _logger = logger; + } + + public Task WaitForResponseAsync( string correlationId, TimeSpan timeout, CancellationToken ct ) { + var tcs = new TaskCompletionSource(); + + if ( !_pendingRequests.TryAdd( correlationId, tcs ) ) { + throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" ); + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + cts.CancelAfter( timeout ); + + cts.Token.Register( () => { + if ( _pendingRequests.TryRemove( correlationId, out var removed ) ) { + removed.TrySetCanceled(); + } + + cts.Dispose(); + } ); + + return tcs.Task; + } + + public Task WaitForStreamingResponseAsync( + string correlationId, + string finalMessageType, + Action onProgressUpdate, + TimeSpan timeout, + CancellationToken ct + ) { + var handler = new StreamingResponseHandler { + CompletionSource = new TaskCompletionSource(), + FinalMessageType = finalMessageType, + OnProgressUpdate = onProgressUpdate + }; + + if ( !_streamingRequests.TryAdd( correlationId, handler ) ) { + throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" ); + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + cts.CancelAfter( timeout ); + + cts.Token.Register( () => { + if ( _streamingRequests.TryRemove( correlationId, out var removed ) ) { + removed.CompletionSource.TrySetCanceled(); + } + + cts.Dispose(); + } ); + + return handler.CompletionSource.Task; + } + + public bool TryCompleteResponse( string correlationId, PeerMessage response ) { + // Check for streaming response first + if ( _streamingRequests.TryGetValue( correlationId, out var streamingHandler ) ) { + // If this is the final message, complete the task + if ( response.MessageType == streamingHandler.FinalMessageType ) { + _streamingRequests.TryRemove( correlationId, out _ ); + return streamingHandler.CompletionSource.TrySetResult( response ); + } + + // Otherwise, it's a progress update + streamingHandler.OnProgressUpdate( response ); + return true; + } + + // Check for regular single response + if ( _pendingRequests.TryRemove( correlationId, out var tcs ) ) { + return tcs.TrySetResult( response ); + } + + _logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); + return false; + } + + private sealed class StreamingResponseHandler { + public required TaskCompletionSource CompletionSource { + get; + init; + } + + public required string FinalMessageType { + get; + init; + } + + public required Action OnProgressUpdate { + get; + init; + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj new file mode 100644 index 00000000..9d441dfb --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Core/PeerStream.cs b/src/Networking.PeerStreaming.Core/PeerStream.cs new file mode 100644 index 00000000..4608989c --- /dev/null +++ b/src/Networking.PeerStreaming.Core/PeerStream.cs @@ -0,0 +1,121 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Messages; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core; + +public sealed class PeerStream : IPeerStream { + private static int _instanceCounter; // Being static is not ideal for testing with multiple instances + private readonly IAsyncStreamReader _reader; + private readonly IAsyncStreamWriter _writer; + private readonly PeerMessageDispatcher _dispatcher; + private readonly ILogger _logger; + private readonly CancellationToken _cancellationToken; + + public PeerStream( + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + PeerMessageDispatcher dispatcher, + ILogger logger, + CancellationToken cancellationToken + ) { + Side = ConnectionSide.Incoming; + _reader = reader; + _writer = writer; + _dispatcher = dispatcher; + _logger = logger; + _cancellationToken = cancellationToken; + // The read loop is considering the cancellation token to ensure a clean shutdown. Don't pass it to the task. + ReadTask = Task.Run( ReadLoopAsync, CancellationToken.None ); + } + + public PeerStream( + Uri address, + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + PeerMessageDispatcher dispatcher, + ILogger logger, + CancellationToken cancellationToken + ) : this( reader, writer, dispatcher, logger, cancellationToken ) { + Side = ConnectionSide.Outgoing; + Address = address; + } + + public int InstanceNo { + get; + } = Interlocked.Increment( ref _instanceCounter ); + + private ConnectionSide Side { + get; + } + + private Uri? Address { + get; + } + + public required AgentId AgentId { + get; + init; + } + + public Task ReadTask { + get; + private init; + } + + public async Task SendAsync( PeerMessage message ) { + await _writer.WriteAsync( message, _cancellationToken ); // TODO also take another cancellation token (combine) + } + + private async Task ReadLoopAsync() { + _logger.LogDebug( "Read loop starting..." ); + + try { + await foreach ( var message in _reader.ReadAllAsync( _cancellationToken ) ) { + try { + // TODO ensure this is printed in the output + using var scope = _logger.BeginScope( + new Dictionary { ["RequestId"] = message.CorrelationId ?? "no-id" } + ); + _logger.LogDebug( "Received message. Dispatching to handler..." ); + await _dispatcher.DispatchAsync( message, this, CancellationToken.None ); + _logger.LogDebug( "Dispatch completed. Waiting for next message..." ); + } + catch ( Exception ex ) { + _logger.LogError( ex, "Message dispatch failed" ); + } + } + + _logger.LogDebug( "Read loop ended gracefully (end of stream)" ); + } + catch ( OperationCanceledException ) { + // Justification: exception is control flow, not an error +#pragma warning disable S6667 + _logger.LogDebug( "Read loop ended gracefully (cancelled)" ); +#pragma warning restore S6667 + } + catch ( Exception ex ) { + _logger.LogError( ex, "Read loop failed" ); + } + } + + public async ValueTask DisposeAsync() { + _logger.LogTrace( "Disposing {PeerStream}", this ); + + if ( _writer is IClientStreamWriter clientWriter ) { + // I.e., outgoing stream (client initiated) + // Server streams are automatically completed by the gRPC framework + await clientWriter.CompleteAsync(); + } + + await ReadTask; + } + + public override string ToString() { + return + $"{nameof(PeerStream)}[#{InstanceNo}, {nameof(AgentId)}={AgentId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]"; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs new file mode 100644 index 00000000..78b0f673 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Common; +using Drift.Networking.PeerStreaming.Core.Messages; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core; + +internal sealed class PeerStreamManager( + ILogger logger, + IPeerClientFactory? peerClientFactory, + PeerMessageDispatcher dispatcher, + PeerStreamingOptions options +) : IPeerStreamManager { + private readonly ConcurrentDictionary _streams = new(); + + public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ) { + logger.LogDebug( + "Getting or creating {ConnectionSide} stream to agent {Id} ({Address})", + ConnectionSide.Outgoing, + id, + peerAddress + ); + + return _streams.GetOrAdd( id, agentId => Create( peerAddress, agentId ) ); + } + + private IPeerStream Create( Uri peerAddress, AgentId id ) { + if ( peerClientFactory == null ) { + throw new Exception( $"Cannot create outbound stream since {nameof(peerClientFactory)} is null" ); + } + + var (client, _) = peerClientFactory.Create( peerAddress ); + var callOptions = new CallOptions( new Metadata { { "agent-id", id } } ); + var call = client.PeerStream( callOptions ); + + var stream = new PeerStream( + peerAddress, + call.ResponseStream, + call.RequestStream, + dispatcher, + logger, + options.StoppingToken + ) { AgentId = id }; + Add( stream ); + return stream; + } + + public IPeerStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ) { + var agentId = context.RequestHeaders.GetAgentId(); + + logger.LogInformation( "Creating {ConnectionSide} stream from agent {Id}", ConnectionSide.Incoming, agentId ); + + var stream = + new PeerStream( requestStream, responseStream, dispatcher, logger, options.StoppingToken ) { AgentId = agentId }; + Add( stream ); + return stream; + } + + private void Add( IPeerStream stream ) { + logger.LogTrace( "Created {Stream}", stream ); + _streams[stream.AgentId] = stream; + } + + public async ValueTask DisposeAsync() { + logger.LogDebug( "Disposing peer stream manager (including all streams)" ); + foreach ( var stream in _streams.Values ) { + logger.LogTrace( "Disposing peer stream #{StreamNo}", stream.InstanceNo ); + await stream.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs b/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs new file mode 100644 index 00000000..2ca2a36e --- /dev/null +++ b/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace Drift.Networking.PeerStreaming.Core; + +public sealed class PeerStreamingOptions { + public CancellationToken StoppingToken { + get; + set; + } + + public Assembly MessageAssembly { + get; + init; + } = Assembly.GetExecutingAssembly(); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..4ca96603 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Messages; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.PeerStreaming.Core; + +public static class ServiceCollectionExtensions { + public static void AddPeerStreamingCore( + this IServiceCollection services, + PeerStreamingOptions options + ) { + services.AddSingleton( options ); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj b/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj new file mode 100644 index 00000000..92ab3e5b --- /dev/null +++ b/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto b/src/Networking.PeerStreaming.Grpc/Protos/peer.proto new file mode 100644 index 00000000..c0fccd55 --- /dev/null +++ b/src/Networking.PeerStreaming.Grpc/Protos/peer.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option csharp_namespace = "Drift.Networking.Grpc.Generated"; + +package peer; + +// AgentService? +service PeerService { + rpc PeerStream (stream PeerMessage) returns (stream PeerMessage); +} + +message PeerMessage { + string message_type = 1; + string message = 2; + string correlation_id = 3; // For request-response matching + string reply_to = 4; // If this is a response, which request it replies to +} diff --git a/src/Networking.PeerStreaming.Server/InboundPeerService.cs b/src/Networking.PeerStreaming.Server/InboundPeerService.cs new file mode 100644 index 00000000..d503718f --- /dev/null +++ b/src/Networking.PeerStreaming.Server/InboundPeerService.cs @@ -0,0 +1,31 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Server; + +// Handle incoming connections (AKA server-side). +internal sealed class InboundPeerService( IPeerStreamManager peerStreamManager, ILogger logger ) + : PeerService.PeerServiceBase { + public override async Task PeerStream( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context + ) { + try { + logger.LogInformation( "Inbound stream starting..." ); + var stream = peerStreamManager.Create( requestStream, responseStream, context ); + logger.LogInformation( "Peer stream #{StreamNo} created", stream.InstanceNo ); + + // The stream is closed when the method returns. + // We thus wait for the read loop to complete (meaning that this client is no longer interested in the stream). + await stream.ReadTask; + + logger.LogInformation( "Peer stream #{StreamNo} completed", stream.InstanceNo ); + } + catch ( Exception ex ) { + logger.LogError( ex, "Inbound stream failed" ); + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj new file mode 100644 index 00000000..76db19ff --- /dev/null +++ b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs new file mode 100644 index 00000000..2b09ab2f --- /dev/null +++ b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs @@ -0,0 +1,8 @@ +namespace Drift.Networking.PeerStreaming.Server; + +internal sealed class PeerStreamingServerMarker { + internal bool EndpointsMapped { + get; + set; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..79843ab4 --- /dev/null +++ b/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using Grpc.AspNetCore.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.PeerStreaming.Server; + +public static class ServiceCollectionExtensions { + public static void AddPeerStreamingServer( + this IServiceCollection services, + Action? configureOptions = null + ) { + services.AddSingleton(); + services.AddGrpc( options => configureOptions?.Invoke( options ) ); + services.AddTransient(); + } + + public static void MapPeerStreamingServerEndpoints( this IEndpointRouteBuilder app ) { + var marker = app.ServiceProvider.GetService(); + + if ( marker == null ) { + throw new InvalidOperationException( + $"Unable to find the required services. Add them by calling '{nameof(IServiceCollection)}.{nameof(AddPeerStreamingServer)}'." + ); + } + + app.MapGrpcService(); + + marker.EndpointsMapped = true; + } +} + +internal sealed class PeerStreamingServerValidationFilter : IStartupFilter { + public Action Configure( Action next ) { + return app => { + next( app ); + + var marker = app.ApplicationServices.GetRequiredService(); + + if ( !marker.EndpointsMapped ) { + throw new InvalidOperationException( + $"Server endpoints were not mapped. Map them by calling '{nameof(IEndpointRouteBuilder)}.{nameof(ServiceCollectionExtensions.MapPeerStreamingServerEndpoints)}'." ); + } + }; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs b/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs b/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs new file mode 100644 index 00000000..d4a78d08 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs @@ -0,0 +1,121 @@ +using System.Threading.Channels; +using Grpc.Core; +using Channel = System.Threading.Channels.Channel; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal sealed record DuplexStreamEndpoint( TRequest RequestStream, TResponse ResponseStream ); + +/// +/// Provides an in-memory bidirectional gRPC stream pair (one endpoint is the client, the other is the server). +/// +internal static class InMemoryDuplexStreamPair { + public static ( + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server + ) + Create( ServerCallContext serverContext ) where TRequest : class where TResponse : class { + var clientToServer = Channel.CreateUnbounded(); + var serverToClient = Channel.CreateUnbounded(); + + var server = new DuplexStreamEndpoint, IServerStreamWriter>( + new InMemoryServerStreamReader( clientToServer.Reader, serverContext ), + new InMemoryServerStreamWriter( serverToClient.Writer, serverContext ) + ); + + var client = new DuplexStreamEndpoint, IAsyncStreamReader>( + new InMemoryStreamWriter( clientToServer.Writer ), + new InMemoryStreamReader( serverToClient.Reader ) + ); + + return ( client, server ); + } + + private sealed class InMemoryStreamWriter : IClientStreamWriter where T : class { + private readonly ChannelWriter _writer; + + internal InMemoryStreamWriter( ChannelWriter writer ) { + _writer = writer; + } + + public WriteOptions? WriteOptions { + get; + set; + } + + public async Task WriteAsync( T message ) { + await _writer.WriteAsync( message ); + } + + public Task CompleteAsync() { + _writer.Complete(); + return Task.CompletedTask; + } + } + + private sealed class InMemoryServerStreamReader : InMemoryStreamReader where T : class { + private readonly ServerCallContext _context; + + internal InMemoryServerStreamReader( ChannelReader reader, ServerCallContext context ) : base( reader ) { + _context = context; + } + + public override Task MoveNext( CancellationToken cancellationToken ) { + _context.CancellationToken.ThrowIfCancellationRequested(); + return base.MoveNext( cancellationToken ); + } + } + + private class InMemoryStreamReader : IAsyncStreamReader where T : class { + private readonly ChannelReader _reader; + + internal InMemoryStreamReader( ChannelReader reader ) { + _reader = reader; + } + + public T Current { + get; + private set; + } = null!; + + public virtual async Task MoveNext( CancellationToken cancellationToken ) { + if ( await _reader.WaitToReadAsync( cancellationToken ) && _reader.TryRead( out var message ) ) { + Current = message; + return true; + } + + Current = null!; + return false; + } + } + + private sealed class InMemoryServerStreamWriter : IServerStreamWriter where T : class { + private readonly ChannelWriter _writer; + private readonly ServerCallContext _context; + + internal InMemoryServerStreamWriter( ChannelWriter writer, ServerCallContext context ) { + _writer = writer; + _context = context; + } + + public WriteOptions? WriteOptions { + get { + return new WriteOptions(); + } + + set { + throw new NotSupportedException(); + } + } + + public Task WriteAsync( T message ) { + _context.CancellationToken.ThrowIfCancellationRequested(); + + if ( !_writer.TryWrite( message ) ) { + throw new InvalidOperationException( "Unable to write message." ); + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs b/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs new file mode 100644 index 00000000..4cef4584 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal sealed class TestPeerMessage : IPeerRequest, IPeerResponse { + public static string MessageType => "test-peer-message"; + + public static JsonTypeInfo JsonInfo => TestPeerMessageJsonContext.Default.TestPeerMessage; +} + +[JsonSerializable( typeof(TestPeerMessage) )] +internal sealed partial class TestPeerMessageJsonContext : JsonSerializerContext; + +internal sealed class TestMessageHandler : IPeerMessageHandler { + public TestPeerMessage? LastMessage { + get; + private set; + } + + public string MessageType => TestPeerMessage.MessageType; + + public Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + var message = converter.FromEnvelope( envelope ); + LastMessage = message; + + // For this test handler, we don't send a response + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs new file mode 100644 index 00000000..01212db3 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs @@ -0,0 +1,74 @@ +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal sealed class TestServerCallContext : ServerCallContext { + private readonly Metadata _requestHeaders; + private readonly CancellationToken _cancellationToken; + private readonly Metadata _responseTrailers; + private readonly AuthContext _authContext; + private readonly Dictionary _userState; + + private TestServerCallContext( Metadata requestHeaders, CancellationToken cancellationToken ) { + _requestHeaders = requestHeaders; + _cancellationToken = cancellationToken; + _responseTrailers = new Metadata(); + _authContext = new AuthContext( string.Empty, new Dictionary>() ); + _userState = new Dictionary(); + } + + public Metadata? ResponseHeaders { + get; + private set; + } + + protected override string MethodCore => "MethodName"; + + protected override string HostCore => "HostName"; + + protected override string PeerCore => "PeerName"; + + protected override DateTime DeadlineCore { + get; + } + + protected override Metadata RequestHeadersCore => _requestHeaders; + + protected override CancellationToken CancellationTokenCore => _cancellationToken; + + protected override Metadata ResponseTrailersCore => _responseTrailers; + + protected override Status StatusCore { + get; + set; + } + + protected override WriteOptions? WriteOptionsCore { + get; + set; + } + + protected override AuthContext AuthContextCore => _authContext; + + protected override IDictionary UserStateCore => _userState; + + public static TestServerCallContext Create( + Metadata? requestHeaders = null, + CancellationToken cancellationToken = default + ) { + return new TestServerCallContext( requestHeaders ?? new Metadata(), cancellationToken ); + } + + protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options ) { + throw new NotImplementedException(); + } + + protected override Task WriteResponseHeadersAsyncCore( Metadata responseHeaders ) { + if ( ResponseHeaders != null ) { + throw new InvalidOperationException( "Response headers have already been written." ); + } + + ResponseHeaders = responseHeaders; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs new file mode 100644 index 00000000..3129b711 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs @@ -0,0 +1,23 @@ +using Drift.Networking.Grpc.Generated; +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal static class TestServerCallContextExtensions { + public static ( + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server + ) + CreateDuplexStreams( this TestServerCallContext serverContext ) { + return InMemoryDuplexStreamPair.Create( serverContext ); + } + + internal static ( + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server + ) + CreateDuplexStreams( TestServerCallContext serverContext ) where TRequest : class + where TResponse : class { + return InMemoryDuplexStreamPair.Create( serverContext ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/InboundTests.cs b/src/Networking.PeerStreaming.Tests/InboundTests.cs new file mode 100644 index 00000000..a54b2260 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/InboundTests.cs @@ -0,0 +1,77 @@ +using Drift.Networking.PeerStreaming.Core; +using Drift.Networking.PeerStreaming.Core.Messages; +using Drift.Networking.PeerStreaming.Server; +using Drift.Networking.PeerStreaming.Tests.Helpers; +using Drift.TestUtilities; + +namespace Drift.Networking.PeerStreaming.Tests; + +internal sealed class InboundTests { + [Test] + public async Task InboundStreamIsClosedWhenCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new PeerResponseCorrelator( logger ); + var envelopeConverter = new PeerMessageEnvelopeConverter(); + var peerStreamManager = new PeerStreamManager( + logger, + null, + new PeerMessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new PeerStreamingOptions { StoppingToken = cts.Token } + ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + var inboundPeerService = new InboundPeerService( peerStreamManager, logger ); + + // Act / Assert + var serverStreams = duplexStreams.Server; + var peerStreamTask = + inboundPeerService.PeerStream( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + + Assert.That( peerStreamTask.IsCompleted, Is.False ); + + await cts.CancelAsync(); + + await Task.WhenAny( peerStreamTask, Task.Delay( 1000 ) ); + + Assert.That( peerStreamTask.IsCompleted, Is.True ); + } + + [Test] + public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new PeerResponseCorrelator( logger ); + var envelopeConverter = new PeerMessageEnvelopeConverter(); + var peerStreamManager = new PeerStreamManager( + logger, + null, + new PeerMessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new PeerStreamingOptions { StoppingToken = cts.Token } + ); + var inboundPeerService = new InboundPeerService( peerStreamManager, logger ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + // Act + var peerStreamTask = inboundPeerService.PeerStream( + duplexStreams.Server.RequestStream, + duplexStreams.Server.ResponseStream, + callContext + ); + + // Assert + Assert.That( peerStreamTask.IsCompleted, Is.False ); + + await Task.WhenAny( peerStreamTask, Task.Delay( 1000 ) ); + + Assert.That( peerStreamTask.IsCompleted, Is.False ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj b/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj new file mode 100644 index 00000000..42b50c1e --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs b/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs new file mode 100644 index 00000000..9e930af8 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs @@ -0,0 +1,45 @@ +using Drift.Networking.PeerStreaming.Core; +using Drift.Networking.PeerStreaming.Core.Messages; +using Drift.Networking.PeerStreaming.Tests.Helpers; +using Drift.TestUtilities; + +namespace Drift.Networking.PeerStreaming.Tests; + +internal sealed class PeerStreamManagerTests { + [Test] + public async Task IncomingMessageIsDispatchedToHandler() { + // Arrange + var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var testMessageHandler = new TestMessageHandler(); + var envelopeConverter = new PeerMessageEnvelopeConverter(); + var responseCorrelator = new PeerResponseCorrelator( logger ); + var dispatcher = new PeerMessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); + var peerStreamManager = new PeerStreamManager( + logger, + null, + dispatcher, + new PeerStreamingOptions { StoppingToken = cts.Token } + ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + var serverStreams = duplexStreams.Server; + var stream = peerStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + var converter = new PeerMessageEnvelopeConverter(); + + // Act + var clientStreams = duplexStreams.Client; + await clientStreams.RequestStream.WriteAsync( converter.ToEnvelope( new TestPeerMessage() ) ); + + await cts.CancelAsync(); + await stream.ReadTask; + + // Assert + Assert.That( testMessageHandler.LastMessage, Is.Not.Null ); + // Assert.That( testMessageHandler.LastMessage.MessageType, Is.EqualTo( "TestMessageType" ) ); + + cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index 11e8a9ff..701fab39 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Net; +using System.Net.NetworkInformation; using System.Threading.RateLimiting; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -75,7 +76,8 @@ public async Task ScanAsync( Status = ScanResultStatus.InProgress, DiscoveredDevices = ToDiscoveredDevices( pingReplies, ArpTables().Cached ), DiscoveryAttempts = ToDiscoveryAttempts( ipRange, completed ), - Progress = new((byte) Math.Ceiling( ( (double) completed / total ) * 100 )), + // Intermediate result should never report 100%, so cap at 99% + Progress = new((byte) Math.Min( 99, Math.Ceiling( ( (double) completed / total ) * 100 ) )), CidrBlock = cidr }; @@ -124,6 +126,8 @@ private static List ToDiscoveredDevices( ConcurrentBag<( IPAddress Ip, bool Success, string? Hostname)> pingReplies, ArpTable arpTable ) { + var localMacs = BuildLocalInterfaceMacTable(); + return pingReplies.Where( r => r.Success ).Select( pingReply => new DiscoveredDevice { Addresses = CreateAddresses( pingReply ) } ).ToList(); @@ -138,11 +142,42 @@ List CreateAddresses( ( IPAddress Ip, bool Success, string? Host if ( arpTable.TryGetValue( pingReply.Ip, out var mac ) ) { list.Add( mac ); } + else if ( localMacs.TryGetValue( pingReply.Ip, out var localMac ) ) { + // ARP cache never contains the machine's own IPs. Fall back to reading + // the MAC directly from the matching local interface. + list.Add( localMac ); + } return list; } } + /// + /// Builds a map of local unicast IPv4 addresses to the MAC address of the + /// interface that owns them. Used to resolve the MAC for the scanner's own + /// IP addresses, which never appear in the ARP cache. + /// + private static Dictionary BuildLocalInterfaceMacTable() { + var map = new Dictionary(); + + foreach ( var iface in NetworkInterface.GetAllNetworkInterfaces() ) { + var physicalAddress = iface.GetPhysicalAddress(); + if ( physicalAddress.GetAddressBytes().Length == 0 ) { + continue; // loopback and tunnel interfaces have no MAC + } + + var macString = string.Join( "-", physicalAddress.GetAddressBytes().Select( b => b.ToString( "X2" ) ) ); + + foreach ( var unicastAddress in iface.GetIPProperties().UnicastAddresses.Select( u => u.Address ) ) { + if ( unicastAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ) { + map[unicastAddress] = new MacAddress( macString ); + } + } + } + + return map; + } + private static async Task GetHostNameAsync( IPAddress ip, int timeoutMs = 1000 ) { var task = Dns.GetHostEntryAsync( ip ); if ( await Task.WhenAny( task, Task.Delay( timeoutMs ) ) == task ) { diff --git a/src/Scanning/Subnets/CompositeSubnetProvider.cs b/src/Scanning/Subnets/CompositeSubnetProvider.cs index 5fab4eff..8a14fd54 100644 --- a/src/Scanning/Subnets/CompositeSubnetProvider.cs +++ b/src/Scanning/Subnets/CompositeSubnetProvider.cs @@ -1,12 +1,11 @@ -using Drift.Domain; - namespace Drift.Scanning.Subnets; // TODO needed? public class CompositeSubnetProvider( IEnumerable providers ) : ISubnetProvider { private readonly List _providers = providers.ToList(); - public List Get() { - return _providers.SelectMany( p => p.Get() ).Distinct().ToList(); + public async Task> GetAsync() { + var results = await Task.WhenAll( _providers.Select( p => p.GetAsync() ) ); + return results.SelectMany( x => x ).Distinct().ToList(); } } \ No newline at end of file diff --git a/src/Scanning/Subnets/IResolvedSubnet.cs b/src/Scanning/Subnets/IResolvedSubnet.cs new file mode 100644 index 00000000..c1d43f19 --- /dev/null +++ b/src/Scanning/Subnets/IResolvedSubnet.cs @@ -0,0 +1,26 @@ +using Drift.Domain; + +namespace Drift.Scanning.Subnets; + +public abstract record SubnetSource { + public static readonly Local Local = new(); + + public static Agent Agent( AgentId agentId ) { + ArgumentNullException.ThrowIfNull( agentId ); + return new Agent( agentId ); + } +} + +public sealed record Agent( AgentId AgentId ) : SubnetSource { + public override string ToString() { + return AgentId; + } +} + +public sealed record Local : SubnetSource { + public override string ToString() { + return "local"; + } +} + +public sealed record ResolvedSubnet( CidrBlock Cidr, SubnetSource Source ); \ No newline at end of file diff --git a/src/Scanning/Subnets/ISubnetProvider.cs b/src/Scanning/Subnets/ISubnetProvider.cs index f62aaee0..e8067faa 100644 --- a/src/Scanning/Subnets/ISubnetProvider.cs +++ b/src/Scanning/Subnets/ISubnetProvider.cs @@ -1,7 +1,5 @@ -using Drift.Domain; - namespace Drift.Scanning.Subnets; public interface ISubnetProvider { - List Get(); + Task> GetAsync(); } \ No newline at end of file diff --git a/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs b/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs index 1def3545..48cb1817 100644 --- a/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs +++ b/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs @@ -1,6 +1,5 @@ using System.Net.NetworkInformation; using Drift.Common.Network; -using Drift.Domain; using Microsoft.Extensions.Logging; namespace Drift.Scanning.Subnets.Interface; @@ -8,7 +7,7 @@ namespace Drift.Scanning.Subnets.Interface; public abstract class InterfaceSubnetProviderBase( ILogger? logger ) : IInterfaceSubnetProvider { public abstract List GetInterfaces(); - public List Get() { + public Task> GetAsync() { var interfaces = GetInterfaces(); var interfaceDescriptions = string.Join( @@ -41,7 +40,7 @@ public List Get() { string.Join( ", ", cidrs ) ); - return cidrs; + return Task.FromResult( cidrs.Select( cidr => new ResolvedSubnet( cidr, SubnetSource.Local ) ).ToList() ); } private static bool IsUp( INetworkInterface i ) { diff --git a/src/Scanning/Subnets/PredefinedSubnetProvider.cs b/src/Scanning/Subnets/PredefinedSubnetProvider.cs index 5607d676..0cf08797 100644 --- a/src/Scanning/Subnets/PredefinedSubnetProvider.cs +++ b/src/Scanning/Subnets/PredefinedSubnetProvider.cs @@ -3,10 +3,16 @@ namespace Drift.Scanning.Subnets; public class PredefinedSubnetProvider( IEnumerable subnets ) : ISubnetProvider { - public List Get() { - return subnets - .Where( s => s.Enabled ?? true ) - .Select( s => new CidrBlock( s.Address ) ) - .ToList(); + public Task> GetAsync() { + return Task.FromResult( + subnets + .Where( s => s.Enabled ?? true ) + .Select( s => new ResolvedSubnet( + new CidrBlock( s.Address ), + // TODO how to determine source when from spec? + SubnetSource.Local + ) ) + .ToList() + ); } } \ No newline at end of file diff --git a/src/Serialization/Converters/CidrBlockConverter.cs b/src/Serialization/Converters/CidrBlockConverter.cs new file mode 100644 index 00000000..cfcf45fa --- /dev/null +++ b/src/Serialization/Converters/CidrBlockConverter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Drift.Domain; + +namespace Drift.Serialization.Converters; + +public sealed class CidrBlockConverter : JsonConverter { + public override CidrBlock Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { + var cidrString = reader.GetString(); + if ( string.IsNullOrEmpty( cidrString ) ) { + throw new JsonException( "CIDR block string cannot be null or empty" ); + } + + return new CidrBlock( cidrString ); + } + + public override void Write( Utf8JsonWriter writer, CidrBlock value, JsonSerializerOptions options ) { + ArgumentNullException.ThrowIfNull( writer ); + writer.WriteStringValue( value.ToString() ); + } +} \ No newline at end of file diff --git a/src/Serialization/Converters/DeviceAddressConverter.cs b/src/Serialization/Converters/DeviceAddressConverter.cs new file mode 100644 index 00000000..1c1ec4f6 --- /dev/null +++ b/src/Serialization/Converters/DeviceAddressConverter.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Drift.Domain.Device.Addresses; + +namespace Drift.Serialization.Converters; + +public sealed class DeviceAddressConverter : JsonConverter { + public override IDeviceAddress? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { + using var doc = JsonDocument.ParseValue( ref reader ); + var root = doc.RootElement; + + if ( !root.TryGetProperty( "type", out var typeProperty ) ) { + throw new JsonException( "Missing 'type' property in IDeviceAddress JSON" ); + } + + var addressType = (AddressType) typeProperty.GetInt32(); + var value = root.GetProperty( "value" ).GetString()!; + var isId = root.TryGetProperty( "isId", out var isIdProperty ) ? isIdProperty.GetBoolean() : (bool?) null; + + return addressType switch { + AddressType.IpV4 => new IpV4Address( value, isId ), + AddressType.Mac => new MacAddress( value, isId ), + AddressType.Hostname => new HostnameAddress( value, isId ), + _ => throw new JsonException( $"Unknown AddressType: {addressType}" ) + }; + } + + public override void Write( Utf8JsonWriter writer, IDeviceAddress value, JsonSerializerOptions options ) { + writer.WriteStartObject(); + writer.WriteNumber( "type", (int) value.Type ); + writer.WriteString( "value", value.Value ); + if ( value.IsId.HasValue ) { + writer.WriteBoolean( "isId", value.IsId.Value ); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Serialization/Converters/IpAddressConverter.cs b/src/Serialization/Converters/IpAddressConverter.cs new file mode 100644 index 00000000..2ffb2b8c --- /dev/null +++ b/src/Serialization/Converters/IpAddressConverter.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Drift.Serialization.Converters; + +public sealed class IpAddressConverter : JsonConverter { + public override System.Net.IPAddress Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { + string? ip = reader.GetString(); + var ipAddress = ( ip == null ) ? null : System.Net.IPAddress.Parse( ip ); + return ipAddress ?? throw new Exception( "Cannot read" ); // System.Net.IPAddress.None; + } + + public override void Write( Utf8JsonWriter writer, System.Net.IPAddress value, JsonSerializerOptions options ) { + ArgumentNullException.ThrowIfNull( writer ); + writer.WriteStringValue( value?.ToString() ); + } +} diff --git a/src/Serialization/Converters/IpV4AddressSetConverter.cs b/src/Serialization/Converters/IpV4AddressSetConverter.cs new file mode 100644 index 00000000..c542b8ad --- /dev/null +++ b/src/Serialization/Converters/IpV4AddressSetConverter.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using Drift.Domain.Device.Addresses; + +namespace Drift.Serialization.Converters; + +public sealed class IpV4AddressSetConverter : JsonConverter> { + public override IReadOnlySet? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { + if ( reader.TokenType != JsonTokenType.StartArray ) { + throw new JsonException( "Expected array" ); + } + + var builder = ImmutableHashSet.CreateBuilder(); + + while ( reader.Read() ) { + if ( reader.TokenType == JsonTokenType.EndArray ) { + return builder.ToImmutable(); + } + + var ipAddress = JsonSerializer.Deserialize( ref reader, options ); + if ( ipAddress != null ) { + builder.Add( new IpV4Address( ipAddress ) ); + } + } + + throw new JsonException( "Unexpected end of JSON" ); + } + + public override void Write( Utf8JsonWriter writer, IReadOnlySet value, JsonSerializerOptions options ) { + writer.WriteStartArray(); + + foreach ( var ip in value ) { + JsonSerializer.Serialize( writer, ip.Value, options ); + } + + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/src/Serialization/Serialization.csproj b/src/Serialization/Serialization.csproj index c6321610..493404aa 100644 --- a/src/Serialization/Serialization.csproj +++ b/src/Serialization/Serialization.csproj @@ -1,3 +1,7 @@  + + + + diff --git a/src/Spec.Tests/ValidationTests.cs b/src/Spec.Tests/ValidationTests.cs index 4407f7ec..a75398c0 100644 --- a/src/Spec.Tests/ValidationTests.cs +++ b/src/Spec.Tests/ValidationTests.cs @@ -1,3 +1,4 @@ +using Drift.Spec.Schema; using Drift.Spec.Validation; using Drift.TestUtilities; @@ -88,7 +89,7 @@ internal sealed class ValidationTests { )] public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) { // Arrange / Act - var result = SpecValidator.Validate( yaml, Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); using ( Assert.EnterMultipleScope() ) { Assert.That( result.IsValid, Is.False, "Expected YAML to be invalid, but it was not" ); Assert.That( result.Errors, Is.Not.Empty ); @@ -131,7 +132,7 @@ public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) """ )] public void YamlIsValidTest( int caseNo, string yaml ) { // Arrange / Act - var result = SpecValidator.Validate( yaml, Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); using ( Assert.EnterMultipleScope() ) { Assert.That( result.IsValid, Is.True, result.ToUnitTestMessage() ); Assert.That( result.Errors, Is.Empty ); diff --git a/src/Spec/Dtos/V1_preview/DriftSpec.cs b/src/Spec/Dtos/V1_preview/DriftSpec.cs index b2bc980a..1dbc1a23 100644 --- a/src/Spec/Dtos/V1_preview/DriftSpec.cs +++ b/src/Spec/Dtos/V1_preview/DriftSpec.cs @@ -32,6 +32,11 @@ public Network Network { get; set; }*/ + + public List? Agents { + get; + set; + } } // [Title( "Network declaration" )] @@ -147,6 +152,21 @@ public bool? ScanOnlyDeclaredSubnets { } } +[AdditionalProperties( false )] +public record Agent { + [Required] + public string Id { + get; + set; + } + + [Required] + public string Address { + get; + set; + } +} + public enum UnknownDevicePolicy { Disallowed = 1, Allowed = 2 diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs index d1926213..3f53429c 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs @@ -1,9 +1,32 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Declared; + namespace Drift.Spec.Dtos.V1_preview.Mappers; public static partial class Mapper { - public static Domain.Inventory ToDomain( DriftSpec dto ) { + public static Inventory ToDomain( DriftSpec dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); - return new Domain.Inventory { Network = Map( dto.Network ) }; + var spec = new Inventory { Network = Map( dto.Network ) }; + + if ( dto.Agents != null ) { + spec.Agents = Map( dto.Agents ); + } + + return spec; + } + + private static List Map( List dto ) { + return dto.Select( Map ).ToList(); + } + + private static Domain.Agent Map( Agent dto ) { + var agent = new Domain.Agent(); + + agent.Id = dto.Id; + agent.Address = dto.Address; + + return agent; } private static Domain.Network Map( Network dto ) { @@ -20,14 +43,14 @@ private static Domain.Network Map( Network dto ) { return network; } - private static List Map( List dto ) { + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } - private static Domain.DeclaredSubnet Map( Subnet dto ) { + private static DeclaredSubnet Map( Subnet dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); - var subnet = new Domain.DeclaredSubnet { Address = dto.Address }; + var subnet = new DeclaredSubnet { Address = dto.Address }; if ( dto.Id != null ) { subnet.Id = dto.Id; @@ -40,14 +63,14 @@ private static Domain.DeclaredSubnet Map( Subnet dto ) { return subnet; } - private static List Map( List dto ) { + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } - private static Domain.Device.Declared.DeclaredDevice Map( Device dto ) { + private static DeclaredDevice Map( Device dto ) { // ArgumentNullException.ThrowIfNull( dto.Addresses ); - var declaredDevice = new Domain.Device.Declared.DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; + var declaredDevice = new DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; if ( dto.Id != null ) { declaredDevice.Id = dto.Id; @@ -64,7 +87,7 @@ private static Domain.Device.Declared.DeclaredDevice Map( Device dto ) { return declaredDevice; } - private static Domain.Device.Addresses.IDeviceAddress Map( DeviceAddress dto ) { + private static IDeviceAddress Map( DeviceAddress dto ) { return dto.Type switch { "ip-v4" => new Domain.Device.Addresses.IpV4Address( dto.Value, dto.IsId ?? true ), "mac" => new Domain.Device.Addresses.MacAddress( dto.Value, dto.IsId ?? true ), @@ -73,12 +96,12 @@ private static Domain.Device.Addresses.IDeviceAddress Map( DeviceAddress dto ) { }; } - private static Domain.Device.Declared.DeclaredDeviceState? Map( DeviceState? dto ) { + private static DeclaredDeviceState? Map( DeviceState? dto ) { return dto switch { null => null, - DeviceState.Up => Domain.Device.Declared.DeclaredDeviceState.Up, - DeviceState.Dynamic => Domain.Device.Declared.DeclaredDeviceState.Dynamic, - DeviceState.Down => Domain.Device.Declared.DeclaredDeviceState.Down, + DeviceState.Up => DeclaredDeviceState.Up, + DeviceState.Dynamic => DeclaredDeviceState.Dynamic, + DeviceState.Down => DeclaredDeviceState.Down, _ => throw new ArgumentOutOfRangeException( nameof(dto), dto, null ) }; } diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs index 910568bf..65ab15d9 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs @@ -1,9 +1,13 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Declared; + namespace Drift.Spec.Dtos.V1_preview.Mappers; public static partial class Mapper { internal const string VersionConstant = "v1-preview"; - public static DriftSpec ToDto( Domain.Inventory domain ) { + public static DriftSpec ToDto( Inventory domain ) { return new DriftSpec { Version = VersionConstant, Network = Map( domain.Network ) }; } @@ -13,11 +17,11 @@ private static Network Map( Domain.Network domain ) { }; } - private static Subnet Map( Domain.DeclaredSubnet domain ) { + private static Subnet Map( DeclaredSubnet domain ) { return new Subnet { Id = domain.Id, Address = domain.Address, Enabled = domain.Enabled }; } - private static Device Map( Domain.Device.Declared.DeclaredDevice domain ) { + private static Device Map( DeclaredDevice domain ) { return new Device { Id = domain.Id, Addresses = domain.Addresses.Select( Map ).ToList(), @@ -26,25 +30,25 @@ private static Device Map( Domain.Device.Declared.DeclaredDevice domain ) { }; } - private static DeviceAddress Map( Domain.Device.Addresses.IDeviceAddress domain ) { + private static DeviceAddress Map( IDeviceAddress domain ) { return new DeviceAddress { Value = domain.Value, Type = Map( domain.Type ), IsId = domain.IsId }; } - private static string Map( Domain.Device.Addresses.AddressType addressType ) { + private static string Map( AddressType addressType ) { return addressType switch { - Domain.Device.Addresses.AddressType.IpV4 => "ip-v4", - Domain.Device.Addresses.AddressType.Mac => "mac", - Domain.Device.Addresses.AddressType.Hostname => "hostname", + AddressType.IpV4 => "ip-v4", + AddressType.Mac => "mac", + AddressType.Hostname => "hostname", _ => throw new ArgumentOutOfRangeException( nameof(addressType), addressType, null ) }; } - private static DeviceState? Map( Domain.Device.Declared.DeclaredDeviceState? domain ) { + private static DeviceState? Map( DeclaredDeviceState? domain ) { return domain switch { null => null, - Domain.Device.Declared.DeclaredDeviceState.Up => DeviceState.Up, - Domain.Device.Declared.DeclaredDeviceState.Dynamic => DeviceState.Dynamic, - Domain.Device.Declared.DeclaredDeviceState.Down => DeviceState.Down, + DeclaredDeviceState.Up => DeviceState.Up, + DeclaredDeviceState.Dynamic => DeviceState.Dynamic, + DeclaredDeviceState.Down => DeviceState.Down, _ => throw new ArgumentOutOfRangeException( nameof(domain), domain, null ) }; } diff --git a/src/Spec/Schema/SchemaGenerator.cs b/src/Spec/Schema/SchemaGenerator.cs index bdd3ce14..5a0681aa 100644 --- a/src/Spec/Schema/SchemaGenerator.cs +++ b/src/Spec/Schema/SchemaGenerator.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Drift.Common.Schemas; +using Drift.Spec.Dtos.V1_preview; using Json.Schema; using Json.Schema.Generation; @@ -19,7 +20,7 @@ public static class SchemaGenerator { public static string Generate( SpecVersion version ) { return version switch { - SpecVersion.V1_preview => Generate( version ), + SpecVersion.V1_preview => Generate( version ), _ => throw new ArgumentOutOfRangeException( nameof(version), version, "Unknown spec version" ) }; } diff --git a/src/Spec/Serialization/YamlStaticContext.cs b/src/Spec/Serialization/YamlStaticContext.cs index 78473bcd..b06693eb 100644 --- a/src/Spec/Serialization/YamlStaticContext.cs +++ b/src/Spec/Serialization/YamlStaticContext.cs @@ -1,19 +1,21 @@ +using Drift.Spec.Dtos.V1_preview; using YamlDotNet.Serialization; namespace Drift.Spec.Serialization; [YamlStaticContext] // TODO rely on attributes on the individual types instead? -[YamlSerializable( typeof(Dtos.V1_preview.DriftSpec) )] -[YamlSerializable( typeof(Dtos.V1_preview.Network) )] -[YamlSerializable( typeof(Dtos.V1_preview.Subnet) )] -[YamlSerializable( typeof(Dtos.V1_preview.Device) )] -[YamlSerializable( typeof(Dtos.V1_preview.DeviceState) )] -[YamlSerializable( typeof(Dtos.V1_preview.DeviceAddress) )] +[YamlSerializable( typeof(DriftSpec) )] +[YamlSerializable( typeof(Network) )] +[YamlSerializable( typeof(Subnet) )] +[YamlSerializable( typeof(Device) )] +[YamlSerializable( typeof(DeviceState) )] +[YamlSerializable( typeof(DeviceAddress) )] +[YamlSerializable( typeof(Agent) )] /*[YamlSerializable( typeof(IpV4Address) )] [YamlSerializable( typeof(HostnameAddress) )] [YamlSerializable( typeof(MacAddress) )] [YamlSerializable( typeof(Port) )] [YamlSerializable( typeof(AddressType) )]*/ -public partial class YamlStaticContext : YamlDotNet.Serialization.StaticContext { +public partial class YamlStaticContext : StaticContext { } \ No newline at end of file diff --git a/src/Spec/Validation/SpecValidator.cs b/src/Spec/Validation/SpecValidator.cs index b8ef946c..640f183f 100644 --- a/src/Spec/Validation/SpecValidator.cs +++ b/src/Spec/Validation/SpecValidator.cs @@ -2,13 +2,27 @@ using Drift.Spec.Schema; using Drift.Spec.Serialization; using Json.Schema; +using YamlDotNet.Core; namespace Drift.Spec.Validation; public static class SpecValidator { public static ValidationResult Validate( string yaml, SpecVersion version ) { - var schema = SpecSchemaProvider.Get( version ); - return Validate( yaml, schema ); + try { + var schema = SpecSchemaProvider.Get( version ); + return Validate( yaml, schema ); + } + catch ( YamlException ex ) { + var errors = new List(); + + Exception? exp = ex; + do { + errors.Add( new ValidationError { Message = exp.Message } ); + exp = exp.InnerException; + } while ( exp != null ); + + return new ValidationResult { IsValid = false, Errors = errors }; + } } private static ValidationResult Validate( string yaml, JsonSchema schema ) { diff --git a/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json b/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json index 3c955ed3..cc11b3d2 100644 --- a/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json +++ b/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json @@ -102,6 +102,28 @@ } }, "additionalProperties": false + }, + "agents": { + "type": [ + "null", + "array" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "id", + "address" + ], + "additionalProperties": false + } } }, "required": [ diff --git a/src/TestUtilities/StringLogger.cs b/src/TestUtilities/StringLogger.cs index 5fe21b83..9c612518 100644 --- a/src/TestUtilities/StringLogger.cs +++ b/src/TestUtilities/StringLogger.cs @@ -2,8 +2,8 @@ namespace Drift.TestUtilities; -public sealed class StringLogger( StringWriter? writer = null ) : ILogger { - private readonly StringWriter _writer = writer ?? new StringWriter(); +public sealed class StringLogger( TextWriter? writer = null ) : ILogger { + private readonly TextWriter _writer = writer ?? new StringWriter(); public void Log( LogLevel logLevel, @@ -35,7 +35,7 @@ public bool IsEnabled( LogLevel logLevel ) { } public override string ToString() { - return _writer.ToString(); + return _writer.ToString() ?? string.Empty; } private static string ToSerilogStyleLevel( LogLevel logLevel ) { From e548e63fdbc76ac2e3ce3b2985ca20dc2e6ca7bf Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:12:43 +0200 Subject: [PATCH 02/31] refactor --- .../NukeBuild/TestNukeBuild.cs | 1 - .../Versioning/TestNukeBuildExtensions.cs | 2 - .../Versioning/VersioningTests.cs | 32 +- .../Adopt/AdoptRequestHandler.cs | 1 - src/Cli.Abstractions/Ports.cs | 5 + .../ScanCommandTests.Orchestration.cs | 179 +++++++++ .../Commands/ScanCommandTests.Remote.cs | 348 ------------------ ...DuplicateDevices_Deduplicates.verified.txt | 18 + ...hAgents_EmptyResults_Succeeds.verified.txt | 12 + ...rewallRules_FiltersVisibility.verified.txt | 22 ++ ...rfaces_UsesDistributedScanner.verified.txt | 22 ++ ...rlappingSubnets_MergesDevices.verified.txt | 24 ++ ...ection_UsesDistributedScanner.verified.txt | 18 + ...sjointSubnets_CombinesResults.verified.txt | 24 ++ src/Cli.Tests/Commands/ScanCommandTests.cs | 37 +- src/Cli.Tests/GlobalSuppressions.cs | 24 ++ .../Utils/Agent/AgentConfiguration.cs | 47 +++ src/Cli.Tests/Utils/Agent/AgentTestHarness.cs | 325 ++++++++++++++++ src/Cli.Tests/Utils/Agent/CliConfiguration.cs | 44 +++ src/Cli.Tests/Utils/Agent/HarnessResult.cs | 142 +++++++ .../Utils/Agent/NetworkTopologyAdapter.cs | 159 ++++++++ src/Cli.Tests/Utils/FirewallRulesTests.cs | 279 ++++++++++++++ .../Network/Firewall/FirewallEvaluator.cs | 114 ++++++ .../Utils/Network/Firewall/FirewallRules.cs | 83 +++++ .../Utils/Network/Firewall/FirewallTarget.cs | 95 +++++ .../Utils/Network/Topology/NetworkTopology.cs | 133 +++++++ .../Topology/NetworkTopologyBuilder.cs | 110 ++++++ .../Utils/Network/Topology/Topologies.cs | 229 ++++++++++++ .../Utils/Testing/MockSubnetScannerFactory.cs | 65 ++++ .../Utils/Testing/ScanResultHelper.cs | 157 ++++++++ .../Subcommands/Start/AgentStartParameters.cs | 7 +- src/Cli/Commands/Scan/ClusterExtensions.cs | 1 - .../Scan/DistributedNetworkScanner.cs | 10 +- src/Domain/AgentId.cs | 4 +- src/Domain/CidrBlock.cs | 4 + src/Domain/Device/Addresses/AddressType.cs | 2 +- .../Device/Addresses/DeviceAddressSet.cs | 50 +++ src/Domain/Device/Addresses/IIPAddress.cs | 9 + src/Domain/Device/Addresses/IpV4Address.cs | 4 +- .../Device/Discovered/DiscoveredDevice.cs | 1 + .../PeerMessageHandlerExtensions.cs | 2 - .../Scanners/LinuxFpingSubnetScanner.cs | 4 +- .../Scanners/PingSubnetScannerBase.cs | 45 ++- src/Scanning/Scanners/Preview/Ideas.cs | 2 +- 44 files changed, 2450 insertions(+), 446 deletions(-) create mode 100644 src/Cli.Abstractions/Ports.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs delete mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt create mode 100644 src/Cli.Tests/GlobalSuppressions.cs create mode 100644 src/Cli.Tests/Utils/Agent/AgentConfiguration.cs create mode 100644 src/Cli.Tests/Utils/Agent/AgentTestHarness.cs create mode 100644 src/Cli.Tests/Utils/Agent/CliConfiguration.cs create mode 100644 src/Cli.Tests/Utils/Agent/HarnessResult.cs create mode 100644 src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs create mode 100644 src/Cli.Tests/Utils/FirewallRulesTests.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/Topologies.cs create mode 100644 src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs create mode 100644 src/Cli.Tests/Utils/Testing/ScanResultHelper.cs create mode 100644 src/Domain/Device/Addresses/DeviceAddressSet.cs create mode 100644 src/Domain/Device/Addresses/IIPAddress.cs diff --git a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs index 7c09695c..dd4c6d05 100644 --- a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs +++ b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Drift.Build.Utilities.Versioning.Abstractions; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs index 34e0bb60..ce055e5d 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Drift.Build.Utilities.Tests.NukeBuild; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index 85c8313b..b69133aa 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Text.RegularExpressions; using Drift.Build.Utilities.Tests.NukeBuild; using Drift.Build.Utilities.Versioning; @@ -9,15 +7,13 @@ using Nuke.Common; using Nuke.Common.Git; using Octokit; -using TUnit.Assertions.Extensions; -using TUnit.Core; using Assert = TUnit.Assertions.Assert; namespace Drift.Build.Utilities.Tests.Versioning; internal sealed class VersioningTests { [Test] - public async Task DefaultVersioningVersionTest() { + public static async Task DefaultVersioningVersionTest() { // Arrange var build = new TestNukeBuild(); @@ -33,9 +29,9 @@ public async Task DefaultVersioningVersionTest() { } [Test] - public async Task DefaultVersioningWhenNoReleaseTargets() { + public static async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange - var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => b.Arbitrary ); + var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary ); // Act var factory = new VersioningStrategyFactory( build ); @@ -50,7 +46,7 @@ public async Task DefaultVersioningWhenNoReleaseTargets() { } [Test] - public async Task MultipleReleaseTargetsInPlanThrows() { + public static async Task MultipleReleaseTargetsInPlanThrows() { // Arrange var build = new TestNukeBuild().WithExecutionPlan( b => b.CreateRelease, b => b.CreatePreRelease ); @@ -106,7 +102,7 @@ public async Task MismatchedReleaseTypeThrows( } [Test] - public void PreReleaseWithoutVersionThrows() { + public static void PreReleaseWithoutVersionThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -149,7 +145,7 @@ public void DebugConfigurationThrows( } [Test] - public async Task PreReleaseValid() { + public static async Task PreReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -170,7 +166,7 @@ await Assert.That( version.PrereleaseIdentifiers[version.PrereleaseIdentifiers.C } [Test] - public void PreReleaseRejectsFullSemVerString() { + public static void PreReleaseRejectsFullSemVerString() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -186,7 +182,7 @@ public void PreReleaseRejectsFullSemVerString() { } [Test] - public async Task PreReleaseVersionIsTemporallyConsistent() { + public static async Task PreReleaseVersionIsTemporallyConsistent() { // Arrange var timeProvider = new FakeTimeProvider(); var build = new TestNukeBuild() @@ -205,7 +201,7 @@ public async Task PreReleaseVersionIsTemporallyConsistent() { } [Test] - public void ReleaseWithPrereleaseIdentifiersThrows() { + public static void ReleaseWithPrereleaseIdentifiersThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -221,7 +217,7 @@ public void ReleaseWithPrereleaseIdentifiersThrows() { } [Test] - public async Task ReleaseValid() { + public static async Task ReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -266,7 +262,7 @@ public async Task ReleaseValid() { } [Test] - public async Task ExactVersioningValid() { + public static async Task ExactVersioningValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -282,7 +278,7 @@ public async Task ExactVersioningValid() { } [Test] - public void ExactVersioningWithNullThrows() { + public static void ExactVersioningWithNullThrows() { // Arrange var strategy = new ExactVersioning( Configuration.Release, " ", null!, null! ); @@ -291,7 +287,7 @@ public void ExactVersioningWithNullThrows() { } [Test] - public async Task ExactVersioningValidWithReleaseVersion() { + public static async Task ExactVersioningValidWithReleaseVersion() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -311,7 +307,7 @@ public async Task ExactVersioningValidWithReleaseVersion() { internal sealed class NukeBuildWithArbitraryTarget : TestNukeBuild { // Justification: NUKE Target properties are instance properties by convention; static is not valid here #pragma warning disable S2325 - public Target Arbitrary => _ => _ + public static Target Arbitrary => _ => _ #pragma warning restore S2325 .Executes( () => { } diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs index c586ed34..5b9778c4 100644 --- a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs @@ -1,6 +1,5 @@ using Drift.Networking.Grpc.Generated; using Drift.Networking.PeerStreaming.Core.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Agent.PeerProtocol.Adopt; diff --git a/src/Cli.Abstractions/Ports.cs b/src/Cli.Abstractions/Ports.cs new file mode 100644 index 00000000..50ae2171 --- /dev/null +++ b/src/Cli.Abstractions/Ports.cs @@ -0,0 +1,5 @@ +namespace Drift.Cli.Abstractions; + +public static class Ports { + public const ushort AgentDefault = 51515; +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs new file mode 100644 index 00000000..db181c9d --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -0,0 +1,179 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils.Agent; +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Cli.Tests.Utils.Network.Topology; +using Drift.Domain; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class ScanCommandTests { + [Test] + public async Task WithAgents_TwoDisjointSubnets_CombinesResults() { + // Arrange + var builder = Topologies.TwoAgentsDisjointSubnetsWithCli() + .WithFirewall( fw => { + fw.DefaultPolicy = FirewallAction.Deny; + // CLI must be able to reach agents to collect their results (control plane = same firewall). + // Allow CLI→agent routing; deny-default blocks all other cross-subnet traffic (agent↔agent). + fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent1-subnet" ) ); + fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent2-subnet" ) ); + } ); + var topology = builder.Build(); + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + // Verify full output + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_TwoDisjointSubnets_CombinesResults)}" ); + } + + [Test] + public async Task WithAgents_OverlappingSubnets_MergesDevices() { + // Arrange + var topology = Topologies.TwoAgentsOverlappingSubnet(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + + // Verify output shows merged results + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_OverlappingSubnets_MergesDevices)}" ); + } + + [Test] + public async Task WithAgents_EmptyResults_Succeeds() { + // Arrange + var topology = Topologies.SingleAgentEmptyResults(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 1 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_EmptyResults_Succeeds)}" ); + } + + [Test] + public async Task WithAgents_NoLocalInterfaces_UsesDistributedScanner() { + // Arrange - Two agents, no CLI config (so CLI has no local interfaces) + var topology = Topologies.TwoAgentsDisjointSubnetsNoCli(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_NoLocalInterfaces_UsesDistributedScanner)}" ); + } + + [Test] + public async Task WithAgents_ScannerSelection_UsesDistributedScanner() { + // Arrange + var topology = Topologies.MixedCliAndAgents(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + + // The output should show both local scan (192.168.0.x) and agent scan (192.168.10.x) + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_ScannerSelection_UsesDistributedScanner)}" ); + } + + [Test] + public async Task WithAgents_DuplicateDevices_Deduplicates() { + // Arrange - Both agents discover the same device on the same subnet + var topology = new NetworkTopologyBuilder() + .AddSubnet( "shared", "192.168.10.0/24", [ + ( "192.168.10.100", "Shared device" ), // Both agents will see this + ( "192.168.10.101", "Device from agent1" ), + ( "192.168.10.102", "Device from agent2" ) + ], out var sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) + .Build(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + // The duplicate device (192.168.10.100) should only appear once in merged results + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_DuplicateDevices_Deduplicates)}" ); + } + + [Test] + public async Task WithAgents_FirewallRules_FiltersVisibility() { + // Arrange - DMZ agent can see internal, but internal agent cannot see DMZ + // This tests that firewall rules properly restrict network visibility + var topology = new NetworkTopologyBuilder() + .AddSubnet( "dmz", "192.168.1.0/24", [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], out var dmzSubnet ) + .AddSubnet( "internal", "10.0.0.0/24", [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "File server" ) + ], out var internalSubnet ) + .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) + .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) + .WithFirewall( fw => { + // DMZ can see both networks + fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "dmz" ) ); + fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); + + // Internal can only see itself + fw.Allow( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "internal" ) ); + fw.Deny( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "dmz" ) ); // Explicit deny + } ) + .Build(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + // Verify output shows DMZ agent scanned both networks, internal agent only scanned internal + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_FirewallRules_FiltersVisibility)}" ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs deleted file mode 100644 index af1356c6..00000000 --- a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs +++ /dev/null @@ -1,348 +0,0 @@ -using Drift.Cli.Abstractions; -using Drift.Cli.Tests.Utils; -using Drift.Domain; -using Drift.Domain.Device.Addresses; -using Drift.Domain.Scan; -using Drift.Scanning.Scanners; -using Microsoft.Extensions.Logging; - -namespace Drift.Cli.Tests.Commands; - -internal sealed partial class ScanCommandTests { - [Test] - public async Task RemoteScan() { - // Arrange - var scanConfig = ConfigureServices( - new CidrBlock( "192.168.0.0/24" ), - [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]], - new Inventory { - Network = new Network(), - Agents = [ - new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, - new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } - ] - } - ); - - using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); - - Console.WriteLine( "Starting agents..." ); - RunningCliCommand[] agents = [ - await DriftTestCli.StartAgentAsync( - "--adoptable", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.10.0/24" ), - discoveredDevices: [ - [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )], - [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )] - ] - ) - ), - await DriftTestCli.StartAgentAsync( - "--adoptable --port 51516", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.20.0/24" ), - discoveredDevices: [[new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )]] - ) - ) - ]; - - // Act - Console.WriteLine( "Starting scan..." ); - var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( - "scan unittest", - scanConfig, - cancellationToken: tcs.Token - ); - - Console.WriteLine( "\nScan finished" ); - Console.WriteLine( "----------------" ); - Console.WriteLine( scanOutput.ToString() + scanError ); - Console.WriteLine( "----------------\n" ); - - Console.WriteLine( "Signalling agent cancellation..." ); - await tcs.CancelAsync(); - Console.WriteLine( "Waiting for agents to shut down..." ); - - foreach ( var agent in agents ) { - var (agentExitCode, agentOutput, agentError) = await agent.Completion; - - Console.WriteLine( "\nAgent finished" ); - Console.WriteLine( "----------------" ); - Console.WriteLine( agentOutput.ToString() + agentError ); - Console.WriteLine( "----------------\n" ); - - Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); - } - - // Assert - Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); - await Verify( scanOutput.ToString() + scanError ); - } - - [Test] - public async Task RemoteScan_OverlappingSubnets() { - // Arrange - Both agents can see the same subnet (192.168.10.0/24) - var scanConfig = ConfigureServices( - new CidrBlock( "192.168.0.0/24" ), - [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]], - new Inventory { - Network = new Network(), - Agents = [ - new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, - new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } - ] - } - ); - - using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); - - Console.WriteLine( "Starting agents with overlapping subnet visibility..." ); - RunningCliCommand[] agents = [ - await DriftTestCli.StartAgentAsync( - "--adoptable", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.10.0/24" ), - discoveredDevices: [ - [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )], - [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )] - ] - ) - ), - await DriftTestCli.StartAgentAsync( - "--adoptable --port 51516", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.10.0/24" ), // Same subnet as agent1 - discoveredDevices: [ - [new IpV4Address( "192.168.10.102" ), new MacAddress( "44:44:44:44:44:44" )], - [new IpV4Address( "192.168.10.103" ), new MacAddress( "55:55:55:55:55:55" )] - ] - ) - ) - ]; - - // Act - Console.WriteLine( "Starting scan..." ); - var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( - "scan unittest", - scanConfig, - cancellationToken: tcs.Token - ); - - Console.WriteLine( "\nScan finished" ); - Console.WriteLine( "----------------" ); - Console.WriteLine( scanOutput.ToString() + scanError ); - Console.WriteLine( "----------------\n" ); - - Console.WriteLine( "Signalling agent cancellation..." ); - await tcs.CancelAsync(); - Console.WriteLine( "Waiting for agents to shut down..." ); - - foreach ( var agent in agents ) { - var (agentExitCode, agentOutput, agentError) = await agent.Completion; - - Console.WriteLine( "\nAgent finished" ); - Console.WriteLine( "----------------" ); - Console.WriteLine( agentOutput.ToString() + agentError ); - Console.WriteLine( "----------------\n" ); - - Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); - } - - // Assert - Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); - - // Verify the subnet was only scanned once (not twice) - var outputStr = scanOutput.ToString(); - Console.WriteLine( "Checking output for duplicate scans..." ); - - // The output should show "192.168.10.0/24" being scanned, but only once - // We expect to see results from only ONE agent (the first one to claim it) - await Verify( outputStr + scanError ); - } - - [Test] - public async Task RemoteScan_EmptyResults() { - // Arrange - Agents report subnets but no devices are found - var scanConfig = ConfigureServices( - new CidrBlock( "192.168.0.0/24" ), - [], // No local devices - new Inventory { - Network = new Network(), - Agents = [ - new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" } - ] - } - ); - - using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); - - Console.WriteLine( "Starting agent with empty scan results..." ); - RunningCliCommand[] agents = [ - await DriftTestCli.StartAgentAsync( - "--adoptable", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.10.0/24" ), - discoveredDevices: [] // No devices found - ) - ) - ]; - - // Act - Console.WriteLine( "Starting scan..." ); - var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( - "scan unittest", - scanConfig, - cancellationToken: tcs.Token - ); - - Console.WriteLine( "\nScan finished" ); - Console.WriteLine( "----------------" ); - Console.WriteLine( scanOutput.ToString() + scanError ); - Console.WriteLine( "----------------\n" ); - - Console.WriteLine( "Signalling agent cancellation..." ); - await tcs.CancelAsync(); - Console.WriteLine( "Waiting for agents to shut down..." ); - - foreach ( var agent in agents ) { - var (agentExitCode, _, _) = await agent.Completion; - Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); - } - - // Assert - Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); - await Verify( scanOutput.ToString() + scanError ); - } - - [Test] - public async Task RemoteScan_AgentsOnly_NoLocalInterfaces() { - // Arrange - CLI has no local interfaces, all scanning delegated to agents - var scanConfig = ConfigureServices( - interfaces: (CidrBlock?) null, // No local interfaces - explicitly cast to resolve ambiguity - discoveredDevices: new List>(), - inventory: new Inventory { - Network = new Network(), - Agents = [ - new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, - new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } - ] - } - ); - - using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); - - Console.WriteLine( "Starting agents for agents-only scan..." ); - RunningCliCommand[] agents = [ - await DriftTestCli.StartAgentAsync( - "--adoptable", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.10.0/24" ), - discoveredDevices: [ - [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )] - ] - ) - ), - await DriftTestCli.StartAgentAsync( - "--adoptable --port 51516", - tcs.Token, - ConfigureServices( - interfaces: new CidrBlock( "192.168.20.0/24" ), - discoveredDevices: [ - [new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )] - ] - ) - ) - ]; - - // Act - Console.WriteLine( "Starting scan with no local interfaces..." ); - var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( - "scan unittest", - scanConfig, - cancellationToken: tcs.Token - ); - - Console.WriteLine( "\nScan finished" ); - Console.WriteLine( "----------------" ); - Console.WriteLine( scanOutput.ToString() + scanError ); - Console.WriteLine( "----------------\n" ); - - Console.WriteLine( "Signalling agent cancellation..." ); - await tcs.CancelAsync(); - Console.WriteLine( "Waiting for agents to shut down..." ); - - foreach ( var agent in agents ) { - var (agentExitCode, _, _) = await agent.Completion; - Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); - } - - // Assert - Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); - await Verify( scanOutput.ToString() + scanError ); - } - - /// - /// Mock subnet scanner that returns predefined results for testing. - /// - private sealed class MockSubnetScanner( SubnetScanResult result ) : ISubnetScanner { - public event EventHandler? ResultUpdated; - - public Task ScanAsync( - SubnetScanOptions options, - ILogger logger, - CancellationToken cancellationToken = default - ) { - // Simulate progress updates - var progressResult = new SubnetScanResult { - CidrBlock = result.CidrBlock, - DiscoveredDevices = result.DiscoveredDevices, - Metadata = result.Metadata, - Status = result.Status, - DiscoveryAttempts = result.DiscoveryAttempts, - Progress = new Percentage( 50 ) - }; - ResultUpdated?.Invoke( this, progressResult ); - - var finalResult = new SubnetScanResult { - CidrBlock = result.CidrBlock, - DiscoveredDevices = result.DiscoveredDevices, - Metadata = result.Metadata, - Status = result.Status, - DiscoveryAttempts = result.DiscoveryAttempts, - Progress = new Percentage( 100 ) - }; - ResultUpdated?.Invoke( this, finalResult ); - - return Task.FromResult( finalResult ); - } - } - - /// - /// Mock factory that creates scanners with predefined results based on CIDR. - /// - private sealed class MockSubnetScannerFactory( - Dictionary resultsByCidr - ) : ISubnetScannerFactory { - public ISubnetScanner Get( CidrBlock cidr ) { - if ( resultsByCidr.TryGetValue( cidr, out var result ) ) { - return new MockSubnetScanner( result ); - } - - // Return empty result for unknown CIDRs - return new MockSubnetScanner( new SubnetScanResult { - CidrBlock = cidr, - DiscoveredDevices = [], - Metadata = new Metadata { StartedAt = default, EndedAt = default }, - Status = ScanResultStatus.Success, - DiscoveryAttempts = System.Collections.Immutable.ImmutableHashSet.Empty - } ); - } - } -} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt new file mode 100644 index 00000000..3765181a --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.10.0/24 +Scanning 1 subnet + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 3 unique devices from 2 scans of 192.168.10.0/24 +Scan completed: 0 local, 2 via agents, 1 unique subnets + +192.168.10.0/24 (3 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +├── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) +└── 192.168.10.102 AA-BB-C0-A8-0A-66 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt new file mode 100644 index 00000000..171df816 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt @@ -0,0 +1,12 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 +Scanning 1 subnet + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Scan completed: 0 local, 1 via agents, 1 unique subnets + +192.168.10.0/24 (0 devices) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt new file mode 100644 index 00000000..64f9f78f --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt @@ -0,0 +1,22 @@ +Requesting subnets from agent agentid_dmz +Received subnet(s) from agent agentid_dmz: 192.168.1.0/24, 10.0.0.0/24 +Requesting subnets from agent agentid_internal +Received subnet(s) from agent agentid_internal: 10.0.0.0/24 +Scanning 2 subnets + 192.168.1.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz + 10.0.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz, agentid_internal +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 2 unique devices from 2 scans of 10.0.0.0/24 +Scan completed: 0 local, 3 via agents, 2 unique subnets + +192.168.1.0/24 (2 devices) +├── 192.168.1.100 AA-BB-C0-A8-01-64 Online (unknown device) +└── 192.168.1.101 AA-BB-C0-A8-01-65 Online (unknown device) + +10.0.0.0/24 (2 devices) +├── 10.0.0.100 AA-BB-0A-00-00-64 Online (unknown device) +└── 10.0.0.101 AA-BB-0A-00-00-65 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt new file mode 100644 index 00000000..f63d42b0 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt @@ -0,0 +1,22 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24, 192.168.20.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.10.0/24, 192.168.20.0/24 +Scanning 2 subnets + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 2 unique devices from 2 scans of 192.168.10.0/24 +Merged 1 unique devices from 2 scans of 192.168.20.0/24 +Scan completed: 0 local, 4 via agents, 2 unique subnets + +192.168.10.0/24 (2 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +└── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 AA-BB-C0-A8-14-64 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt new file mode 100644 index 00000000..6f068c80 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt @@ -0,0 +1,24 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.0.0/24, 192.168.10.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.0.0/24, 192.168.10.0/24 +Scanning 2 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local, agentid_agent1, agentid_agent2 + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 1 unique devices from 3 scans of 192.168.0.0/24 +Merged 4 unique devices from 2 scans of 192.168.10.0/24 +Scan completed: 3 local, 2 via agents, 2 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) + +192.168.10.0/24 (4 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +├── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) +├── 192.168.10.102 AA-BB-C0-A8-0A-66 Online (unknown device) +└── 192.168.10.103 AA-BB-C0-A8-0A-67 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt new file mode 100644 index 00000000..de22f662 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.0.0/24, 192.168.10.0/24 +Scanning 2 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local, agentid_agent1 + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 1 unique devices from 2 scans of 192.168.0.0/24 +Scan completed: 2 local, 1 via agents, 2 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt new file mode 100644 index 00000000..e9dfccb1 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt @@ -0,0 +1,24 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.20.0/24 +Scanning 3 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Scan completed: 1 local, 2 via agents, 3 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) + +192.168.10.0/24 (2 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +└── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 AA-BB-C0-A8-14-64 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.cs b/src/Cli.Tests/Commands/ScanCommandTests.cs index c245dabb..56b3be10 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.cs @@ -5,6 +5,7 @@ using Drift.Cli.Presentation.Rendering; using Drift.Cli.SpecFile; using Drift.Cli.Tests.Utils; +using Drift.Cli.Tests.Utils.Testing; using Drift.Domain; using Drift.Domain.Device.Addresses; using Drift.Domain.Device.Declared; @@ -229,42 +230,6 @@ public async Task NonExistingSpecOption() { } } - private static Action ConfigureServices( - CidrBlock? interfaces, - List> discoveredDevices, - Inventory? inventory = null - ) { - var interfaceList = interfaces.HasValue - ? [ - new NetworkInterface { - Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces.Value - } - ] - : new List(); - - return ConfigureServices( - interfaceList, - discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(), - inventory - ); - } - - private static Action ConfigureServices( - CidrBlock interfaces, - List> discoveredDevices, - Inventory? inventory = null - ) { - return ConfigureServices( - [ - new NetworkInterface { - Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces - } - ], - discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(), - inventory - ); - } - private static Action ConfigureServices( List interfaces, List discoveredDevices, diff --git a/src/Cli.Tests/GlobalSuppressions.cs b/src/Cli.Tests/GlobalSuppressions.cs new file mode 100644 index 00000000..5c1b0887 --- /dev/null +++ b/src/Cli.Tests/GlobalSuppressions.cs @@ -0,0 +1,24 @@ + // This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +// Test infrastructure classes are intentionally public to be used across test files +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Agent" )] +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Testing" )] +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Network.Firewall" )] +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Network.Topology" )] \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs new file mode 100644 index 00000000..a639551e --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs @@ -0,0 +1,47 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Configuration for an agent in a test scenario. +/// +public sealed record AgentConfiguration { + public required AgentId Id { + get; + init; + } + + /// + /// Gets HTTP address where the agent listens (e.g., http://localhost:51515). + /// Assigned by after port allocation. + /// + public Uri Address { + get; + init; + } = null!; + + /// + /// Gets subnets visible to this agent (what interfaces/networks the agent can see). + /// + public List VisibleSubnets { + get; + init; + } = []; + + /// + /// Gets devices that this agent will discover when scanning its visible subnets. + /// + public Dictionary> DiscoveredDevices { + get; + init; + } = new(); + + /// + /// Gets additional command-line arguments to pass when starting the agent. + /// + public string AdditionalArgs { + get; + init; + } = "--adoptable"; +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/AgentTestHarness.cs b/src/Cli.Tests/Utils/Agent/AgentTestHarness.cs new file mode 100644 index 00000000..03b1c8dc --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/AgentTestHarness.cs @@ -0,0 +1,325 @@ +using System.Collections.Immutable; +using System.Net.NetworkInformation; +using Drift.Cli.Abstractions; +using Drift.Cli.SpecFile; +using Drift.Cli.Tests.Utils.Network.Topology; +using Drift.Cli.Tests.Utils.Testing; +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Discovered; +using Drift.Domain.Scan; +using Drift.Scanning.Scanners; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using DomainNetwork = Drift.Domain.Network; +using NetworkInterface = Drift.Scanning.Subnets.Interface.NetworkInterface; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Test harness for orchestrating distributed scans with multiple agents. +/// +public sealed class AgentTestHarness : IAsyncDisposable { + private const string SpecName = "unittest"; + + // Thread-safe port allocation for parallel test execution + // Note that this does not guarantee that the port is free... + private static int _nextPort = Ports.AgentDefault; + + private readonly List _agents; + private readonly CliConfiguration _cli; + private readonly List _runningAgents = []; + private readonly CancellationTokenSource _cancellationTokenSource; + + private AgentTestHarness( + List agents, + CliConfiguration cli, + TimeSpan timeout + ) { + _agents = agents; + _cli = cli; + _cancellationTokenSource = new CancellationTokenSource( timeout ); + } + + /// + /// Creates a harness from a . + /// + public static async Task CreateAsync( + NetworkTopology topology, + TimeSpan? timeout = null + ) { + var agents = NetworkTopologyAdapter.ToAgentConfigurations( topology ) + .Select( a => a with { Address = new Uri( $"http://localhost:{Interlocked.Add( ref _nextPort, 1 )}" ) } ) + .ToList(); + var cliConfig = NetworkTopologyAdapter.ToCliConfiguration( topology ); + + var harness = new AgentTestHarness( + agents, + cliConfig, + timeout ?? TimeSpan.FromMinutes( 1 ) + ); + + Validate( agents, cliConfig ); + + await harness.StartAgentsAsync(); + + return harness; + } + + private static void Validate( List agents, CliConfiguration cliConfig ) { + // Agent IDs must be non-empty and unique + var blankAgents = agents.Where( a => string.IsNullOrWhiteSpace( a.Id.Value ) ).ToList(); + if ( blankAgents.Count > 0 ) { + throw new InvalidOperationException( "One or more agents have a blank ID." ); + } + + var duplicateIds = agents + .GroupBy( a => a.Id ) + .Where( g => g.Count() > 1 ) + .Select( g => g.Key ) + .ToList(); + if ( duplicateIds.Count > 0 ) { + throw new InvalidOperationException( + $"Agent IDs must be unique. Duplicates: {string.Join( ", ", duplicateIds )}" ); + } + + foreach ( var agent in agents ) { + // Device subnets must be in VisibleSubnets + var extraKeys = agent.DiscoveredDevices.Keys + .Except( agent.VisibleSubnets ) + .ToList(); + if ( extraKeys.Count > 0 ) { + throw new InvalidOperationException( + $"Agent {agent.Id} has devices in subnets it cannot see: {string.Join( ", ", extraKeys )}" ); + } + + // Device IPs must fall within their declared subnet + foreach ( var (cidr, devices) in agent.DiscoveredDevices ) { + var outOfRange = devices + .Where( d => d.Ip is not null && !cidr.Contains( d.Ip.Value ) ) + .Select( d => d.Ip!.Value.Value ) + .ToList(); + if ( outOfRange.Count > 0 ) { + throw new InvalidOperationException( + $"Agent {agent.Id} has devices in subnet {cidr} whose IPs are outside that subnet: {string.Join( ", ", outOfRange )}" ); + } + } + } + + var cliExtraKeys = cliConfig.DiscoveredDevices.Keys + .Except( cliConfig.VisibleSubnets ) + .ToList(); + if ( cliExtraKeys.Count > 0 ) { + throw new InvalidOperationException( + $"CLI has devices in subnets it cannot see: {string.Join( ", ", cliExtraKeys )}" ); + } + + // CLI device IPs must fall within their declared subnet + foreach ( var (cidr, devices) in cliConfig.DiscoveredDevices ) { + var outOfRange = devices + .Where( d => d.Ip is not null && !cidr.Contains( d.Ip.Value ) ) + .Select( d => d.Ip!.Value.Value ) + .ToList(); + if ( outOfRange.Count > 0 ) { + throw new InvalidOperationException( + $"CLI has devices in subnet {cidr} whose IPs are outside that subnet: {string.Join( ", ", outOfRange )}" ); + } + } + + if ( cliConfig.ReachableAgents != null ) { + var knownIds = agents.Select( a => a.Id ).ToHashSet(); + var unknownIds = cliConfig.ReachableAgents + .Except( knownIds ) + .ToList(); + if ( unknownIds.Count > 0 ) { + throw new InvalidOperationException( + $"CLI.ReachableAgentIds references unknown agent IDs: {string.Join( ", ", unknownIds )}" ); + } + } + } + + /// + /// Runs a scan using the configured agents. + /// + public async Task RunScanAsync() { + Console.WriteLine( "Starting scan..." ); + + var scanConfig = BuildScanConfiguration(); + + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan " + SpecName, + scanConfig, + cancellationToken: _cancellationTokenSource.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + // Stop agents and collect their exit codes + Console.WriteLine( "Signalling agent cancellation..." ); + await _cancellationTokenSource.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + var agentExitCodes = new Dictionary(); + for ( int i = 0; i < _runningAgents.Count; i++ ) { + var agent = _runningAgents[i]; + var agentId = _agents[i].Id; + + var (agentExitCode, agentOutput, agentError) = await agent.Completion; + + Console.WriteLine( $"\nAgent {agentId} finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( agentOutput.ToString() + agentError ); + Console.WriteLine( "----------------\n" ); + + agentExitCodes[agentId] = agentExitCode; + } + + return new HarnessResult { + ScanExitCode = scanExitCode, + ScanOutput = scanOutput.ToString() ?? string.Empty, + ScanError = scanError.ToString() ?? string.Empty, + ScanResult = null, // TODO: Capture actual result + AgentExitCodes = agentExitCodes + }; + } + + private async Task StartAgentsAsync() { + Console.WriteLine( $"Starting {_agents.Count} agent(s)..." ); + + foreach ( var agentConfig in _agents ) { + Console.WriteLine( $"Starting agent {agentConfig.Id} on port {agentConfig.Address.Port}..." ); + + var args = $"{agentConfig.AdditionalArgs} --port {agentConfig.Address.Port}"; + + var agent = await DriftTestCli.StartAgentAsync( + args, + _cancellationTokenSource.Token, + BuildAgentConfiguration( agentConfig ) + ); + + _runningAgents.Add( agent ); + + Console.WriteLine( $"Agent {agentConfig.Id} started successfully" ); + } + } + + private static Action BuildAgentConfiguration( AgentConfiguration agentConfig ) { + return services => { + // Configure interfaces the agent can see + var interfaces = agentConfig.VisibleSubnets + .Select( cidr => new NetworkInterface { + Description = $"eth_{cidr}", OperationalStatus = OperationalStatus.Up, UnicastAddress = cidr + } ) + .ToList(); + + services.Replace( ServiceDescriptor.Scoped( _ => + new PredefinedInterfaceSubnetProvider( interfaces ) + ) + ); + + // Configure subnet scanner factory with mock results + var resultsByCidr = new Dictionary(); + + foreach ( var (cidr, deviceAddressList) in agentConfig.DiscoveredDevices ) { + var discoveredDevices = deviceAddressList + .Select( d => new DiscoveredDevice { Addresses = d.ToAddresses(), Timestamp = DateTime.UtcNow } ) + .ToList(); + + var ipAddresses = discoveredDevices + .SelectMany( d => d.Addresses.OfType() ) + .ToImmutableHashSet(); + + resultsByCidr[cidr] = new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = ipAddresses + }; + } + + services.Replace( ServiceDescriptor.Scoped( _ => + new MockSubnetScannerFactory( resultsByCidr ) + ) + ); + }; + } + + private Action BuildScanConfiguration() { + return services => { + // Configure CLI's local interfaces + var cliInterfaces = _cli.VisibleSubnets + .Select( cidr => new NetworkInterface { + Description = $"cli_eth_{cidr}", OperationalStatus = OperationalStatus.Up, UnicastAddress = cidr + } ) + .ToList(); + + services.Replace( ServiceDescriptor.Scoped( _ => + new PredefinedInterfaceSubnetProvider( cliInterfaces ) + ) + ); + + // Configure subnet scanner factory with mock results for CLI's local scans + var resultsByCidr = new Dictionary(); + + foreach ( var (cidr, deviceAddressList) in _cli.DiscoveredDevices ) { + var discoveredDevices = deviceAddressList + .Select( d => new DiscoveredDevice { Addresses = d.ToAddresses(), Timestamp = DateTime.UtcNow } ) + .ToList(); + + var ipAddresses = discoveredDevices + .SelectMany( d => d.Addresses.OfType() ) + .ToImmutableHashSet(); + + resultsByCidr[cidr] = new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = ipAddresses + }; + } + + services.Replace( ServiceDescriptor.Scoped( _ => + new MockSubnetScannerFactory( resultsByCidr ) + ) + ); + + // Configure inventory with only the agents CLI can reach. + // If ReachableAgentIds is null, all agents are reachable (no CLI subnet or no firewall). + var reachableAgents = _cli.ReachableAgents == null + ? _agents + : _agents.Where( a => _cli.ReachableAgents.Contains( a.Id ) ).ToList(); + + var inventory = new Inventory { + Network = _cli.Network ?? new DomainNetwork(), + Agents = reachableAgents + .Select( a => new Domain.Agent { Id = a.Id, Address = a.Address.ToString() } ) + .ToList() + }; + + services.AddScoped( _ => + new PredefinedSpecProvider( new Dictionary { { SpecName, inventory } } ) + ); + }; + } + + public async ValueTask DisposeAsync() { + await _cancellationTokenSource.CancelAsync(); + + foreach ( var agent in _runningAgents ) { + try { + await agent.Completion; + } + catch { + // Ignore errors during disposal + } + } + + _cancellationTokenSource.Dispose(); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/CliConfiguration.cs b/src/Cli.Tests/Utils/Agent/CliConfiguration.cs new file mode 100644 index 00000000..08339c24 --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/CliConfiguration.cs @@ -0,0 +1,44 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using DomainNetwork = Drift.Domain.Network; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Configuration for the CLI in a test scenario. +/// Defines what the CLI can see locally (interfaces and devices). +/// +public sealed class CliConfiguration { + /// + /// Gets subnets visible to the CLI locally (what interfaces the CLI has). + /// + public List VisibleSubnets { + get; + init; + } = []; + + /// + /// Gets devices that the CLI will discover when scanning its local subnets. + /// + public Dictionary> DiscoveredDevices { + get; + init; + } = new(); + + /// + /// Gets network spec (optional) - devices declared in the spec file. + /// + public DomainNetwork? Network { + get; + init; + } + + /// + /// Gets the IDs of agents that the CLI can reach based on network topology and firewall rules. + /// null means all agents are reachable (no CLI subnet defined, or no firewall configured). + /// + public IReadOnlyList? ReachableAgents { + get; + init; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/HarnessResult.cs b/src/Cli.Tests/Utils/Agent/HarnessResult.cs new file mode 100644 index 00000000..bcdf0bb2 --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/HarnessResult.cs @@ -0,0 +1,142 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils.Testing; +using Drift.Domain; +using Drift.Domain.Scan; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Result from running a scan with the agent test harness. +/// +/// RECOMMENDED USAGE: +/// 1. Assert on exit codes: Assert.That(result.ScanExitCode, Is.EqualTo(ExitCodes.Success)) +/// 2. Use Verify for output: await Verify(result.CombinedOutput) +/// 3. Query results if needed: result.Results.GetDevicesWithIp(...) +/// +/// Only use result.Logs when you need to programmatically query specific log entries. +/// For most tests, Verify on CombinedOutput is easier to work with. +/// +public sealed class HarnessResult { + /// + /// Gets the exit code from the scan command. + /// + public required int ScanExitCode { + get; + init; + } + + /// + /// Gets the standard output from the scan command. + /// + public required string ScanOutput { + get; + init; + } + + /// + /// Gets the standard error from the scan command. + /// + public required string ScanError { + get; + init; + } + + /// + /// Gets the combined output (stdout + stderr). + /// + public string CombinedOutput => ScanOutput + ScanError; + + /// + /// Gets the parsed scan result (if scan was successful). + /// + public NetworkScanResult? ScanResult { + get; + init; + } + + /// + /// Gets the exit codes from each agent, keyed by agent ID. + /// + public required Dictionary AgentExitCodes { + get; + init; + } + + /// + /// Gets all agent IDs from the result. + /// + public IEnumerable AgentIds => AgentExitCodes.Keys; + + /// + /// Gets a value indicating whether the scan completed successfully. + /// + public bool IsSuccess => ScanExitCode == ExitCodes.Success; + + /// + /// Gets the query methods for scan results (convenience accessors to ScanResultHelper). + /// + public ScanResultQuery Results => new(ScanResult); +} + +/// +/// Convenience wrapper for querying scan results. +/// Provides direct access to ScanResultHelper methods without needing to pass the result each time. +/// +public sealed class ScanResultQuery( NetworkScanResult? result ) { + private readonly NetworkScanResult _result = result ?? throw new InvalidOperationException( + "Cannot query results - scan did not produce a result" + ); + + public List GetAllDevices() => + ScanResultHelper.GetAllDevices( _result ); + + public List GetDevicesWithIp( + Drift.Domain.Device.Addresses.IpV4Address ipAddress ) => + ScanResultHelper.GetDevicesWithIp( _result, ipAddress ); + + public List GetDevicesWithMac( + Drift.Domain.Device.Addresses.MacAddress macAddress ) => + ScanResultHelper.GetDevicesWithMac( _result, macAddress ); + + public List GetAllIpAddresses() => + ScanResultHelper.GetAllIpAddresses( _result ); + + public List GetAllMacAddresses() => + ScanResultHelper.GetAllMacAddresses( _result ); + + public List GetScannedSubnets() => + ScanResultHelper.GetScannedSubnets( _result ); + + public SubnetScanResult? GetSubnetResult( Drift.Domain.CidrBlock cidr ) => + ScanResultHelper.GetSubnetResult( _result, cidr ); + + public List GetSubnetsByStatus( ScanResultStatus status ) => + ScanResultHelper.GetSubnetsByStatus( _result, status ); + + public int GetTotalDeviceCount() => + ScanResultHelper.GetTotalDeviceCount( _result ); + + public int GetUniqueIpCount() => + ScanResultHelper.GetUniqueIpCount( _result ); + + public int GetUniqueMacCount() => + ScanResultHelper.GetUniqueMacCount( _result ); + + public bool HasIpAddress( Drift.Domain.Device.Addresses.IpV4Address ipAddress ) => + ScanResultHelper.HasIpAddress( _result, ipAddress ); + + public bool HasMacAddress( Drift.Domain.Device.Addresses.MacAddress macAddress ) => + ScanResultHelper.HasMacAddress( _result, macAddress ); + + public bool HasSubnet( Drift.Domain.CidrBlock cidr ) => + ScanResultHelper.HasSubnet( _result, cidr ); + + public List GetDevicesInSubnet( Drift.Domain.CidrBlock cidr ) => + ScanResultHelper.GetDevicesInSubnet( _result, cidr ); + + public List GetFailedSubnets() => + ScanResultHelper.GetFailedSubnets( _result ); + + public List GetSuccessfulSubnets() => + ScanResultHelper.GetSuccessfulSubnets( _result ); +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs b/src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs new file mode 100644 index 00000000..a7bdbc2a --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs @@ -0,0 +1,159 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Cli.Tests.Utils.Network.Topology; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Adapter for converting to individual s +/// and s. +/// Computes visibility based on firewall rules and generates configurations for test execution. +/// +internal static class NetworkTopologyAdapter { + /// + /// to individual s. + /// + internal static List ToAgentConfigurations( NetworkTopology topology ) { + var configs = new List(); + + foreach ( var agent in topology.Agents ) { + var visibleSubnets = GetAccessibleSubnets( topology, agent ); + var visibleDevices = GetAccessibleDevices( topology, agent ); + + var devicesBySubnet = new Dictionary>(); + + foreach ( var subnet in visibleSubnets ) { + // Get devices that are in this subnet AND visible to the agent + var devicesInSubnet = subnet.Devices + .Where( visibleDevices.Contains ) + .Select( d => new DeviceAddressSet( ip: d.Ip, mac: d.Mac ) ) + .ToList(); + + devicesBySubnet[subnet.Cidr] = devicesInSubnet; + } + + configs.Add( new AgentConfiguration { + Id = agent.Id, + VisibleSubnets = visibleSubnets.Select( s => s.Cidr ).ToList(), + DiscoveredDevices = devicesBySubnet + } ); + } + + return configs; + } + + /// + /// to individual s. + /// + internal static CliConfiguration ToCliConfiguration( NetworkTopology topology ) { + var cliSubnet = topology.GetCliSubnet(); + + var devicesBySubnet = new Dictionary>(); + + if ( cliSubnet != null ) { + var devicesInSubnet = cliSubnet.Devices + .Select( d => new DeviceAddressSet( ip: d.Ip, mac: d.Mac ) ) + .ToList(); + + devicesBySubnet[cliSubnet.Cidr] = devicesInSubnet; + } + + // Compute which agents CLI can actually reach based on firewall rules. + // null means all agents are reachable (no CLI subnet or no firewall = flat/management network). + IReadOnlyList? reachableAgentIds = null; + + if ( cliSubnet != null && topology.FirewallEvaluator != null ) { + reachableAgentIds = GetAccessibleAgents( topology, cliSubnet ) + .Select( agent => agent.Id ) + .ToList(); + } + + return new CliConfiguration { + VisibleSubnets = cliSubnet != null ? [cliSubnet.Cidr] : [], + DiscoveredDevices = devicesBySubnet, + Network = null, // Can be extended if needed + ReachableAgents = reachableAgentIds + }; + } + + /// + /// Returns subnets accessible to an agent given its attachment point and firewall rules. + /// Directly attached subnet is always accessible unless ClientIsolation is enabled. + /// Firewall governs cross-subnet (routed) traffic only. + /// If no firewall is configured, all subnets are accessible (flat routed network). + /// + private static List GetAccessibleSubnets( + NetworkTopology topology, + NetworkTopology.AgentDefinition agent + ) { + if ( topology.FirewallEvaluator == null ) { + return topology.Subnets; + } + + var accessible = new HashSet(); + + if ( !agent.AttachedSubnet.ClientIsolation ) { + accessible.Add( agent.AttachedSubnet ); + } + + foreach ( var targetSubnet in topology.Subnets ) { + if ( targetSubnet == agent.AttachedSubnet ) { + continue; + } + + if ( topology.FirewallEvaluator.IsAllowed( + FirewallTarget.Subnet( agent.AttachedSubnet.Name ), + FirewallTarget.Subnet( targetSubnet.Name ) ) ) { + accessible.Add( targetSubnet ); + } + } + + return accessible.ToList(); + } + + /// + /// Returns devices accessible to an agent given its attachment point and firewall rules. + /// Devices on the directly attached subnet are accessible unless ClientIsolation is enabled. + /// Firewall governs cross-subnet (routed) traffic only. + /// + private static List GetAccessibleDevices( + NetworkTopology topology, + NetworkTopology.AgentDefinition agent + ) { + var accessible = new List(); + + foreach ( var subnet in topology.Subnets ) { + bool isSameSubnet = subnet == agent.AttachedSubnet; + + foreach ( var device in subnet.Devices ) { + bool canAccess = isSameSubnet + ? !subnet.ClientIsolation + : ( topology.FirewallEvaluator?.IsAllowed( + FirewallTarget.Subnet( agent.AttachedSubnet.Name ), + FirewallTarget.FromIp( device.Ip ) ) ?? true ); + + if ( canAccess ) { + accessible.Add( device ); + } + } + } + + return accessible; + } + + /// + /// Returns agents accessible from the CLI's subnet through the firewall. + /// + private static List GetAccessibleAgents( + NetworkTopology topology, + NetworkTopology.SubnetDefinition cliSubnet + ) { + return topology.Agents + .Where( agent => topology.FirewallEvaluator!.IsAllowed( + FirewallTarget.Subnet( cliSubnet.Name ), + FirewallTarget.Subnet( agent.AttachedSubnet.Name ) ) + ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/FirewallRulesTests.cs b/src/Cli.Tests/Utils/FirewallRulesTests.cs new file mode 100644 index 00000000..869f77e2 --- /dev/null +++ b/src/Cli.Tests/Utils/FirewallRulesTests.cs @@ -0,0 +1,279 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils; + +/// +/// Tests for FirewallEvaluator functionality including rule matching, +/// priority evaluation, and device-level filtering. +/// +internal sealed class FirewallRulesTests { + // Standard test subnets + private static readonly List<(string Name, CidrBlock Cidr)> TestSubnets = [ + ( "guest", new CidrBlock( "192.168.100.0/24" ) ), + ( "internal", new CidrBlock( "10.0.0.0/24" ) ), + ( "external", new CidrBlock( "172.16.0.0/24" ) ), + ( "dmz", new CidrBlock( "192.168.1.0/24" ) ), + ( "internet", new CidrBlock( "1.0.0.0/8" ) ), + ( "anywhere", new CidrBlock( "192.168.2.0/24" ) ), + ( "source", new CidrBlock( "192.168.3.0/24" ) ), + ( "dest", new CidrBlock( "192.168.4.0/24" ) ), + ( "subnet1", new CidrBlock( "192.168.5.0/24" ) ), + ( "subnet2", new CidrBlock( "192.168.6.0/24" ) ), + ( "subnet3", new CidrBlock( "192.168.7.0/24" ) ), + ( "subnet4", new CidrBlock( "192.168.8.0/24" ) ) + ]; + + [Test] + public void NoRules_DefaultAllow_ReturnsTrue() { + // Arrange + var rules = new FirewallRules(); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( allowed, Is.True ); + } + + [Test] + public void SubnetNameMatching_ExactMatch_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "internal" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var toExternalAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "external" ) + ); + var toInternalAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( toExternalAllowed, Is.True ); + Assert.That( toInternalAllowed, Is.False ); + } + + [Test] + public void FirstMatchWins_OrderMatters() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Any ); + rules.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "dmz" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "dmz" ) + ); + + // Assert + Assert.That( allowed, Is.False ); + } + + [Test] + public void FirstMatchWins_AllowBeforeDeny() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "dmz" ) ); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "dmz" ) + ); + + // Assert + Assert.That( allowed, Is.True ); + } + + [Test] + public void DeviceIpMatching_ExactIp_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Any, FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var toDeniedAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) + ); + + var toAllowedAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.101" ) ) + ); + + // Assert + Assert.That( toDeniedAllowed, Is.False ); + Assert.That( toAllowedAllowed, Is.True, "Traffic to other device should be allowed" ); + } + + [Test] + public void DeviceIpMatching_SourceIp_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), FirewallTarget.Any ); + rules.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var bastionAllowed = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.Subnet( "internal" ) + ); + + var otherDenied = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.20" ) ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( bastionAllowed, Is.True, "Bastion host should be allowed" ); + Assert.That( otherDenied, Is.False, "Other DMZ hosts should be denied" ); + } + + [Test] + public void CidrMatching_DeviceInRange_Matches() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.FromCidr( new CidrBlock( "192.168.1.0/24" ) ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var deviceInRange = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.50" ) ), + FirewallTarget.Subnet( "anywhere" ) + ); + + var deviceOutOfRange = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.2.50" ) ), + FirewallTarget.Subnet( "anywhere" ) + ); + + // Assert + Assert.That( deviceInRange, Is.False, "Device in CIDR range should be blocked" ); + Assert.That( deviceOutOfRange, Is.True, "Device outside CIDR range should be allowed" ); + } + + [Test] + public void CidrMatching_DestinationCidr_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.Subnet( "external" ), FirewallTarget.FromCidr( new CidrBlock( "10.0.0.0/8" ) ) ); + rules.Deny( FirewallTarget.Subnet( "external" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowedRange = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "10.5.10.100" ) ) + ); + + var deniedRange = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "192.168.1.100" ) ) + ); + + // Assert + Assert.That( allowedRange, Is.True, "Device in allowed CIDR range should pass" ); + Assert.That( deniedRange, Is.False, "Device outside allowed range should be denied" ); + } + + [Test] + public void MixedRules_SubnetAndDeviceLevel_Work() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) + ); + rules.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var specificAllowed = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) + ); + + var genericDenied = evaluator.IsAllowed( + FirewallTarget.Subnet( "dmz" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( specificAllowed, Is.True, "Specific device exception should be allowed" ); + Assert.That( genericDenied, Is.False, "Generic subnet traffic should be denied" ); + } + + [Test] + public void GuestNetwork_Isolation_DevicesCantSeeEachOther() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "guest" ) ); + rules.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "internet" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var guestToGuest = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "guest" ) + ); + var guestToInternet = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "internet" ) + ); + + // Assert + Assert.That( guestToGuest, Is.False ); + Assert.That( guestToInternet, Is.True ); + } + + [Test] + public void AnyKeyword_WorksSameAsWildcard() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Any, FirewallTarget.Subnet( "internal" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( allowed, Is.False ); + } + + [Test] + public void InvalidCidr_DoesNotCrash_ReturnsFalse() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.Subnet( "invalid-cidr" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act / Assert + Assert.DoesNotThrow( () => { + var result = evaluator.IsAllowed( + FirewallTarget.Subnet( "source" ), + FirewallTarget.Subnet( "dest" ) + ); + Assert.That( result, Is.True, "Subnet name 'invalid-cidr' should not match 'source', fall through to default" ); + } ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs new file mode 100644 index 00000000..257672c7 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs @@ -0,0 +1,114 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Firewall; + +/// +/// Evaluates firewall rules with subnet awareness. +/// Combines firewall rules with subnet topology to determine if traffic is allowed. +/// +public sealed class FirewallEvaluator { + private readonly FirewallRules _rules; + private readonly IReadOnlyList<(string Name, CidrBlock Cidr)> _subnets; + + public FirewallEvaluator( FirewallRules rules, IReadOnlyList<(string Name, CidrBlock Cidr)> subnets ) { + _rules = rules; + _subnets = subnets; + } + + /// + /// Evaluate if traffic is allowed from source to destination. + /// Supports subnet names, IP addresses, CIDR blocks, and wildcards. + /// Rules are evaluated in order - first match wins. + /// If no rule matches, the default policy from is applied. + /// + /// Source firewall target (subnet, IP, CIDR, or wildcard). + /// Destination firewall target (subnet, IP, CIDR, or wildcard). + /// True if traffic is allowed, false if denied. + public bool IsAllowed( FirewallTarget source, FirewallTarget dest ) { + // Extract subnet name and IP from source and destination targets + var (sourceSubnet, sourceIp) = ResolveTarget( source ); + var (destSubnet, destIp) = ResolveTarget( dest ); + + // Evaluate rules in order - first match wins + foreach ( var rule in _rules.Rules ) { + if ( RuleMatches( rule, sourceSubnet, destSubnet, sourceIp, destIp ) ) { + return rule.Action == FirewallAction.Allow; + } + } + + // Default policy: defer to FirewallRules + return _rules.DefaultPolicy == FirewallAction.Allow; + } + + /// + /// Resolve a FirewallTarget to a subnet name and optional IP address. + /// For IP targets, looks up which subnet the IP belongs to. + /// + private (string? subnetName, IpV4Address? ip) ResolveTarget( FirewallTarget target ) { + return target switch { + FirewallTarget.SubnetTarget subnet => ( subnet.Name, null ), + FirewallTarget.IpTarget ipTarget => ( ResolveSubnet( ipTarget.Address ), ipTarget.Address ), + FirewallTarget.CidrTarget => ( null, null ), // CIDR targets don't resolve to a single subnet + FirewallTarget.WildcardTarget => ( null, null ), + _ => throw new InvalidOperationException( $"Unknown FirewallTarget type: {target.GetType().Name}" ) + }; + } + + /// + /// Resolve an IP address to its subnet name by checking which subnet's CIDR contains the IP. + /// + private string? ResolveSubnet( IpV4Address ip ) { + foreach ( var subnet in _subnets ) { + if ( IpInCidr( ip, subnet.Cidr ) ) { + return subnet.Name; + } + } + + return null; // IP doesn't belong to any known subnet + } + + /// + /// Check if an IP address is within a CIDR block. + /// + private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { + try { + var ipBytes = ip.Ip.GetAddressBytes(); + var cidrParts = cidr.ToString().Split( '/' ); + if ( cidrParts.Length != 2 ) { + return false; + } + + var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes(); + if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) { + return false; + } + + var mask = ~0u << ( 32 - prefixLength ); + var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3]; + var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) | + ( (uint) networkBytes[2] << 8 ) | networkBytes[3]; + + return ( ipInt & mask ) == ( networkInt & mask ); + } + catch { + return false; + } + } + + /// + /// Check if a firewall rule matches the given traffic parameters. + /// + private static bool RuleMatches( + FirewallRule rule, + string? sourceSubnet, + string? destSubnet, + IpV4Address? sourceIp, + IpV4Address? destIp + ) { + bool sourceMatches = rule.Source.Matches( sourceSubnet ?? string.Empty, sourceIp ); + bool destMatches = rule.Destination.Matches( destSubnet ?? string.Empty, destIp ); + + return sourceMatches && destMatches; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs new file mode 100644 index 00000000..f21b3a78 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs @@ -0,0 +1,83 @@ +namespace Drift.Cli.Tests.Utils.Network.Firewall; + +/// +/// Represents firewall rules for controlling network visibility in test topologies. +/// Rules are evaluated in order (first match wins), with a default ALLOW policy. +/// Inspired by OPNsense firewall behavior. +/// Use FirewallEvaluator to evaluate rules with subnet awareness. +/// +public sealed class FirewallRules { + private readonly List _rules = new(); + + /// + /// Add an ALLOW rule. Rules are evaluated in the order they are added. + /// + /// Source target (subnet, IP, CIDR, or wildcard). + /// Destination target (subnet, IP, CIDR, or wildcard). + public void Allow( FirewallTarget source, FirewallTarget destination ) { + _rules.Add( new FirewallRule { Source = source, Destination = destination, Action = FirewallAction.Allow } ); + } + + /// + /// Add a DENY rule. Rules are evaluated in the order they are added. + /// + /// Source target (subnet, IP, CIDR, or wildcard). + /// Destination target (subnet, IP, CIDR, or wildcard). + public void Deny( FirewallTarget source, FirewallTarget destination ) { + _rules.Add( new FirewallRule { Source = source, Destination = destination, Action = FirewallAction.Deny } ); + } + + /// + /// Gets or sets the policy applied when no rule matches. Defaults to . + /// + public FirewallAction DefaultPolicy { get; set; } = FirewallAction.Allow; + + public IReadOnlyList Rules => _rules.AsReadOnly(); +} + +/// +/// Represents a single firewall rule. +/// +public sealed class FirewallRule { + /// + /// Gets source firewall target (subnet, IP, CIDR, or wildcard). + /// + public required FirewallTarget Source { + get; + init; + } + + /// + /// Gets destination firewall target (subnet, IP, CIDR, or wildcard). + /// + public required FirewallTarget Destination { + get; + init; + } + + /// + /// Gets action to take when this rule matches. + /// + public required FirewallAction Action { + get; + init; + } + + public override string ToString() => + $"{Action.ToString().ToUpper()}: {Source} → {Destination}"; +} + +/// +/// Action to take when a firewall rule matches. +/// +public enum FirewallAction { + /// + /// Allow the traffic to pass. + /// + Allow, + + /// + /// Deny/block the traffic. + /// + Deny +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs new file mode 100644 index 00000000..b6533d05 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs @@ -0,0 +1,95 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Firewall; + +/// +/// Represents a firewall target - can be a subnet name, IP address, CIDR block, or wildcard. +/// This is a discriminated union type providing type-safe firewall rule specification. +/// +public abstract record FirewallTarget { + /// + /// Create a firewall target from a subnet name (e.g., "dmz", "internal"). + /// + public static FirewallTarget Subnet( string subnetName ) => new SubnetTarget( subnetName ); + + /// + /// Create a firewall target from an IP address. + /// + public static FirewallTarget FromIp( IpV4Address ip ) => new IpTarget( ip ); + + /// + /// Create a firewall target from a CIDR block (e.g., "192.168.1.0/24"). + /// + public static FirewallTarget FromCidr( CidrBlock cidr ) => new CidrTarget( cidr ); + + /// + /// Wildcard target matching any source or destination. + /// + public static readonly FirewallTarget Any = new WildcardTarget(); + + /// + /// Check if this target matches a given subnet name and optional IP address. + /// Used internally for firewall rule evaluation. + /// + internal abstract bool Matches( string subnetName, IpV4Address? ip ); + + internal sealed record SubnetTarget( string Name ) : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) => Name == subnetName; + + public override string ToString() => Name; + } + + internal sealed record IpTarget( IpV4Address Address ) : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) => ip.HasValue && Address.Equals( ip.Value ); + + public override string ToString() => Address.Value; + } + + internal sealed record CidrTarget( CidrBlock Block ) : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) { + if ( ip == null ) { + return false; + } + + return IpInCidr( ip.Value, Block ); + } + + /// + /// Check if an IP address is within a CIDR block. + /// Since CidrBlock doesn't have a Contains() method, we implement our own. + /// + private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { + try { + var ipBytes = ip.Ip.GetAddressBytes(); + var cidrParts = cidr.ToString().Split( '/' ); + if ( cidrParts.Length != 2 ) { + return false; + } + + var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes(); + if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) { + return false; + } + + var mask = ~0u << ( 32 - prefixLength ); + var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3]; + var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) | + ( (uint) networkBytes[2] << 8 ) | networkBytes[3]; + + return ( ipInt & mask ) == ( networkInt & mask ); + } + catch { + return false; + } + } + + public override string ToString() => Block.ToString(); + } + + internal sealed record WildcardTarget : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) => true; + + public override string ToString() => "*"; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs new file mode 100644 index 00000000..a4943e27 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs @@ -0,0 +1,133 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Topology; + +/// +/// Represents a network topology for testing. +/// Supports optional firewall rules to control network visibility and simulate realistic network segmentation. +/// +public sealed class NetworkTopology { + public required List Subnets { + get; + init; + } + + /// + /// Gets the CLI's network attachment definition. + /// + public CliDefinition? Cli { + get; + init; + } + + public List Agents { + get; + init; + } = []; + + /// + /// Gets optional firewall rules controlling network visibility. + /// If null, all subnets can route to each other (flat network). + /// If present, rules are evaluated in order with a default ALLOW policy. + /// + public FirewallRules? FirewallRules { + get; + init; + } + + /// + /// Gets optional firewall evaluator for subnet-aware rule evaluation. + /// Constructed from FirewallRules + Subnets. + /// + public FirewallEvaluator? FirewallEvaluator { + get; + init; + } + + /// + /// Gets a value indicating whether check if this topology includes agents (distributed scenario). + /// + public bool HasAgents => Agents.Count > 0; + + /// + /// Get the subnet the CLI is attached to, or null if no local interface is configured. + /// + public SubnetDefinition? GetCliSubnet() => Cli?.AttachedSubnet; + + /// + /// Represents a subnet in the topology. + /// + public sealed class SubnetDefinition { + public required string Name { + get; + init; + } + + public required CidrBlock Cidr { + get; + init; + } + + public required List Devices { + get; + init; + } + + /// + /// When true, devices on this subnet cannot communicate with each other (peer isolation). + /// Mirrors UniFi's "Client Device Isolation" / "Device Isolation (ACL)" feature. + /// Defaults to false (devices on the same subnet can communicate freely). + /// + public bool ClientIsolation { + get; + init; + } = false; + } + + /// + /// Represents a device in the topology. + /// + public sealed class DeviceDefinition { + public required IpV4Address Ip { + get; + init; + } + + public required MacAddress Mac { + get; + init; + } + + public required string Description { + get; + init; + } + } + + /// + /// Represents the CLI's network attachment. + /// + public sealed class CliDefinition { + public SubnetDefinition? AttachedSubnet { + get; + init; + } + } + + /// + /// Represents an agent in the topology. + /// + public sealed class AgentDefinition { + public required AgentId Id { + get; + init; + } + + public required SubnetDefinition AttachedSubnet { + get; + init; + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs b/src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs new file mode 100644 index 00000000..78e5e5c8 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs @@ -0,0 +1,110 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Topology; + +public sealed class NetworkTopologyBuilder { + private readonly Dictionary _subnets = new(); + private readonly List _agents = []; + private NetworkTopology.SubnetDefinition? _cli; + private FirewallRules? _firewall; + + public NetworkTopologyBuilder AddSubnet( + string name, + string cidr, + List<(string ip, string description)>? devices, + out NetworkTopology.SubnetDefinition subnet + ) { + subnet = new NetworkTopology.SubnetDefinition { + Name = name, + Cidr = new CidrBlock( cidr ), + Devices = devices?.Select( d => new NetworkTopology.DeviceDefinition { + Ip = new IpV4Address( d.ip ), Mac = GenerateMacFromIp( d.ip ), Description = d.description + } ).ToList() ?? [] + }; + + _subnets[name] = subnet; + return this; + } + + public NetworkTopologyBuilder AddSubnet( + string name, + string cidr, + List<(string ip, string description)>? devices = null + ) => AddSubnet( name, cidr, devices, out _ ); + + /// + /// Attach the CLI to a specific subnet. + /// + public NetworkTopologyBuilder WithCli( NetworkTopology.SubnetDefinition subnet ) { + _cli = subnet; + return this; + } + + /// + /// Add an agent attached to specific subnets. + /// + public NetworkTopologyBuilder AddAgent( AgentId id, NetworkTopology.SubnetDefinition attachedSubnet) { + _agents.Add( new NetworkTopology.AgentDefinition { Id = id, AttachedSubnet = attachedSubnet } ); + return this; + } + + /// + /// Configure firewall rules using a configuration action. + /// When firewall is configured, rules are evaluated in order with default ALLOW policy. + /// + public NetworkTopologyBuilder WithFirewall( Action configure ) { + _firewall ??= new FirewallRules(); + configure( _firewall ); + return this; + } + + /// + /// Shorthand: Add an ALLOW rule from source to destination. + /// Creates a firewall if one doesn't exist yet. + /// + public NetworkTopologyBuilder AllowConnection( string source, string destination ) { + _firewall ??= new FirewallRules(); + _firewall.Allow( FirewallTarget.Subnet( source ), FirewallTarget.Subnet( destination ) ); + return this; + } + + /// + /// Shorthand: Add a DENY rule from source to destination. + /// Creates a firewall if one doesn't exist yet. + /// + public NetworkTopologyBuilder DenyConnection( string source, string destination ) { + _firewall ??= new FirewallRules(); + _firewall.Deny( FirewallTarget.Subnet( source ), FirewallTarget.Subnet( destination ) ); + return this; + } + + /// + /// Build the topology. + /// + public NetworkTopology Build() { + var subnets = _subnets.Values.ToList(); + + // Construct FirewallEvaluator if firewall rules exist + FirewallEvaluator? evaluator = null; + if ( _firewall != null ) { + evaluator = new FirewallEvaluator( _firewall, subnets.Select( s => ( s.Name, s.Cidr ) ).ToList() ); + } + + return new NetworkTopology { + Subnets = subnets, + Cli = new NetworkTopology.CliDefinition { AttachedSubnet = _cli }, + Agents = _agents, + FirewallRules = _firewall, + FirewallEvaluator = evaluator + }; + } + + private static MacAddress GenerateMacFromIp( string ip ) { + var parts = ip.Split( '.' ); + var macString = + $"aa:bb:{int.Parse( parts[0] ):x2}:{int.Parse( parts[1] ):x2}:{int.Parse( parts[2] ):x2}:{int.Parse( parts[3] ):x2}"; + return new MacAddress( macString ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Topology/Topologies.cs b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs new file mode 100644 index 00000000..a75427cf --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs @@ -0,0 +1,229 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Topology; + +/// +/// Pre-configured topology scenarios for common test cases. +/// Returns NetworkTopology instances that can be used with AgentTestHarness.CreateFromTopologyAsync(). +/// +public static class Topologies { + /// + /// Two agents, each on different subnets, CLI on its own subnet. + /// No firewall = flat routed network (all can see all). + /// Tests basic distributed scanning with CLI participation. + /// + public static NetworkTopologyBuilder TwoAgentsDisjointSubnetsWithCli() { + return new NetworkTopologyBuilder() + .AddSubnet( "cli-subnet", "192.168.0.0/24", [( "192.168.0.100", "CLI device" )], out var cliSubnet ) + .AddSubnet( + "agent1-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Agent1 device 1" ), + ( "192.168.10.101", "Agent1 device 2" ) + ], + out var agent1Subnet + ) + .AddSubnet( + "agent2-subnet", + "192.168.20.0/24", + [ + ( "192.168.20.100", "Agent2 device" ) + ], + out var agent2Subnet + ) + .AddAgent( new AgentId( "agentid_agent1" ), agent1Subnet ) + .AddAgent( new AgentId( "agentid_agent2" ), agent2Subnet ) + .WithCli( cliSubnet ); + } + + /// + /// Two agents, each on different subnets, CLI has no local interfaces. + /// No firewall = flat routed network. + /// Tests agents-only scanning without local CLI scanner. + /// + public static NetworkTopology TwoAgentsDisjointSubnetsNoCli() { + return new NetworkTopologyBuilder() + .AddSubnet( "agent1-subnet", "192.168.10.0/24", [ + ( "192.168.10.100", "Agent1 device 1" ), + ( "192.168.10.101", "Agent1 device 2" ) + ], out var agent1Subnet ) + .AddSubnet( "agent2-subnet", "192.168.20.0/24", [ + ( "192.168.20.100", "Agent2 device" ) + ], out var agent2Subnet ) + .AddAgent( new AgentId( "agentid_agent1" ), agent1Subnet ) + .AddAgent( new AgentId( "agentid_agent2" ), agent2Subnet ) + .Build(); + } + + /// + /// Two agents on the SAME subnet with CLI on different subnet. + /// No firewall = flat routed network. + /// Tests result merging when multiple agents scan overlapping networks. + /// + public static NetworkTopology TwoAgentsOverlappingSubnet() { + return new NetworkTopologyBuilder() + .AddSubnet( "cli-subnet", "192.168.0.0/24", [ + ( "192.168.0.100", "CLI device" ) + ], out var cliSubnet ) + .AddSubnet( "shared-subnet", "192.168.10.0/24", [ + ( "192.168.10.100", "Shared device 1" ), + ( "192.168.10.101", "Shared device 2" ), + ( "192.168.10.102", "Shared device 3" ), + ( "192.168.10.103", "Shared device 4" ) + ], out var sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) + .WithCli( cliSubnet ) + .Build(); + } + + /// + /// Single agent with empty subnet (no devices). + /// Tests handling of empty scan results. + /// + public static NetworkTopology SingleAgentEmptyResults() { + return new NetworkTopologyBuilder() + .AddSubnet( "empty-subnet", "192.168.10.0/24", [], out var emptySubnet ) // No devices + .AddAgent( new AgentId( "agentid_agent1" ), emptySubnet ) + .Build(); + } + + /// + /// CLI + one agent on different subnets. + /// No firewall = flat routed network. + /// Tests mixed local and distributed scanning. + /// + public static NetworkTopology MixedCliAndAgents() { + return new NetworkTopologyBuilder() + .AddSubnet( "cli-subnet", "192.168.0.0/24", [ + ( "192.168.0.100", "CLI device" ) + ], out var cliSubnet ) + .AddSubnet( "agent-subnet", "192.168.10.0/24", [ + ( "192.168.10.100", "Agent device" ) + ], out var agentSubnet ) + .AddAgent( new AgentId( "agentid_agent1" ), agentSubnet ) + .WithCli( cliSubnet ) + .Build(); + } + + // ========== FIREWALL SCENARIOS ========== + + /// + /// DMZ with firewall rules. + /// DMZ subnet can reach internal subnet, but internal cannot initiate connections to DMZ. + /// Tests unidirectional firewall rules. + /// + public static NetworkTopology DmzWithFirewall() { + return new NetworkTopologyBuilder() + .AddSubnet( "dmz", "192.168.1.0/24", [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], out var dmzSubnet ) + .AddSubnet( "internal", "10.0.0.0/24", [ + ( "10.0.0.100", "Database server" ), + ( "10.0.0.101", "File server" ) + ], out var internalSubnet ) + .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) + .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) + // Firewall rules: + .AllowConnection( "dmz", "dmz" ) // DMZ can see itself + .AllowConnection( "dmz", "internal" ) // DMZ → internal allowed + .AllowConnection( "internal", "internal" ) // Internal can see itself + // internal → dmz NOT allowed (not specified, will fall through to default ALLOW) + // So we need to explicitly deny: + .DenyConnection( "internal", "dmz" ) + .Build(); + } + + /// + /// Guest network isolation with firewall. + /// Guest devices cannot see each other (client isolation), but can reach external subnet. + /// Tests device-level isolation on same subnet. + /// + public static NetworkTopology GuestNetworkIsolation() { + return new NetworkTopologyBuilder() + .AddSubnet( "guest", "192.168.100.0/24", [ + ( "192.168.100.10", "Guest device 1" ), + ( "192.168.100.11", "Guest device 2" ), + ( "192.168.100.12", "Guest device 3" ) + ], out var guestSubnet ) + .AddSubnet( "external", "203.0.113.0/24", [ + ( "203.0.113.1", "Internet gateway" ) + ] ) + .AddAgent( new AgentId( "agentid_guest" ), guestSubnet ) + .WithFirewall( fw => { + fw.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "guest" ) ); // Block guest-to-guest + fw.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "external" ) ); // Allow guest-to-external + } ) + .Build(); + } + + /// + /// Bastion host scenario with device-level firewall rules. + /// Only specific bastion host (192.168.1.10) can reach internal network. + /// Other DMZ devices are blocked. + /// Tests device-specific firewall rules with first-match-wins priority. + /// + public static NetworkTopology BastionHostAccess() { + return new NetworkTopologyBuilder() + .AddSubnet( "dmz", "192.168.1.0/24", [ + ( "192.168.1.10", "Bastion host" ), + ( "192.168.1.20", "Web server" ), + ( "192.168.1.30", "App server" ) + ], out var dmzSubnet ) + .AddSubnet( "internal", "10.0.0.0/24", [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "Admin server" ) + ], out var internalSubnet ) + .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) + .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) + .WithFirewall( fw => { + // Order matters - first match wins! + fw.Allow( FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.Subnet( "internal" ) ); // Bastion → internal (FIRST) + fw.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); // Other DMZ → internal (SECOND) + fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "dmz" ) ); // DMZ can see itself + fw.Allow( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "internal" ) ); // Internal can see itself + fw.Allow( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "dmz" ) ); // Internal → DMZ allowed + } ) + .Build(); + } + + /// + /// Management network with full visibility using CIDR rules. + /// Management subnet (10.0.0.0/24) can see everything. + /// Production subnets are isolated from each other. + /// Tests CIDR-based firewall rules. + /// + public static NetworkTopology ManagementNetworkWithCidr() { + return new NetworkTopologyBuilder() + .AddSubnet( "management", "10.0.0.0/24", [ + ( "10.0.0.10", "Admin workstation" ) + ], out var mgmtSubnet ) + .AddSubnet( "production1", "192.168.1.0/24", [ + ( "192.168.1.100", "Prod server 1" ) + ], out var prod1Subnet ) + .AddSubnet( "production2", "192.168.2.0/24", [ + ( "192.168.2.100", "Prod server 2" ) + ], out var prod2Subnet ) + .AddAgent( new AgentId( "agentid_mgmt" ), mgmtSubnet ) + .AddAgent( new AgentId( "agentid_prod1" ), prod1Subnet ) + .AddAgent( new AgentId( "agentid_prod2" ), prod2Subnet ) + .WithFirewall( fw => { + // Management can reach everything + fw.Allow( FirewallTarget.Subnet( "management" ), FirewallTarget.Any ); + + // Production subnets can only see themselves + fw.Allow( FirewallTarget.Subnet( "production1" ), FirewallTarget.Subnet( "production1" ) ); + fw.Allow( FirewallTarget.Subnet( "production2" ), FirewallTarget.Subnet( "production2" ) ); + + // Block production cross-talk (explicit deny) + fw.Deny( FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ), + FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ) ); // Block any 192.168.x.x to 192.168.x.x + } ) + .Build(); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs b/src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs new file mode 100644 index 00000000..0a0a63a2 --- /dev/null +++ b/src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs @@ -0,0 +1,65 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Scan; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Tests.Utils.Testing; + +/// +/// Mock subnet scanner that returns predefined results for testing. +/// +public sealed class MockSubnetScanner( SubnetScanResult result ) : ISubnetScanner { + public event EventHandler? ResultUpdated; + + public Task ScanAsync( + SubnetScanOptions options, + ILogger logger, + CancellationToken cancellationToken = default + ) { + // Simulate progress updates + var progressResult = new SubnetScanResult { + CidrBlock = result.CidrBlock, + DiscoveredDevices = result.DiscoveredDevices, + Metadata = result.Metadata, + Status = result.Status, + DiscoveryAttempts = result.DiscoveryAttempts, + Progress = new Percentage( 50 ) + }; + ResultUpdated?.Invoke( this, progressResult ); + + var finalResult = new SubnetScanResult { + CidrBlock = result.CidrBlock, + DiscoveredDevices = result.DiscoveredDevices, + Metadata = result.Metadata, + Status = result.Status, + DiscoveryAttempts = result.DiscoveryAttempts, + Progress = new Percentage( 100 ) + }; + ResultUpdated?.Invoke( this, finalResult ); + + return Task.FromResult( finalResult ); + } +} + +/// +/// Mock factory that creates scanners with predefined results based on CIDR. +/// +public sealed class MockSubnetScannerFactory( + Dictionary resultsByCidr +) : ISubnetScannerFactory { + public ISubnetScanner Get( CidrBlock cidr ) { + if ( resultsByCidr.TryGetValue( cidr, out var result ) ) { + return new MockSubnetScanner( result ); + } + + // Return empty result for unknown CIDRs + return new MockSubnetScanner( new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = [], + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = System.Collections.Immutable.ImmutableHashSet.Empty + } ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Testing/ScanResultHelper.cs b/src/Cli.Tests/Utils/Testing/ScanResultHelper.cs new file mode 100644 index 00000000..2fe1966a --- /dev/null +++ b/src/Cli.Tests/Utils/Testing/ScanResultHelper.cs @@ -0,0 +1,157 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Discovered; +using Drift.Domain.Scan; + +namespace Drift.Cli.Tests.Utils.Testing; + +/// +/// Helper for querying scan result data in tests. +/// Provides query methods that return data - tests should make assertions using standard Assert.That(). +/// +public static class ScanResultHelper { + /// + /// Gets all discovered devices from all subnets in the scan result. + /// + public static List GetAllDevices( NetworkScanResult result ) { + return result.Subnets + .SelectMany( s => s.DiscoveredDevices ) + .ToList(); + } + + /// + /// Gets all discovered devices that have a specific IP address. + /// + public static List GetDevicesWithIp( NetworkScanResult result, IpV4Address ipAddress ) { + return GetAllDevices( result ) + .Where( d => d.Addresses.OfType().Any( ip => ip.Equals( ipAddress ) ) ) + .ToList(); + } + + /// + /// Gets all discovered devices that have a specific MAC address. + /// + public static List GetDevicesWithMac( NetworkScanResult result, MacAddress macAddress ) { + return GetAllDevices( result ) + .Where( d => d.Addresses.OfType().Any( mac => mac.Equals( macAddress ) ) ) + .ToList(); + } + + /// + /// Gets all unique IP addresses discovered across all subnets. + /// + public static List GetAllIpAddresses( NetworkScanResult result ) { + return GetAllDevices( result ) + .SelectMany( d => d.Addresses.OfType() ) + .Distinct() + .ToList(); + } + + /// + /// Gets all unique MAC addresses discovered across all subnets. + /// + public static List GetAllMacAddresses( NetworkScanResult result ) { + return GetAllDevices( result ) + .SelectMany( d => d.Addresses.OfType() ) + .Distinct() + .ToList(); + } + + /// + /// Gets all subnets that were scanned (from the result). + /// + public static List GetScannedSubnets( NetworkScanResult result ) { + return result.Subnets + .Select( s => s.CidrBlock ) + .ToList(); + } + + /// + /// Gets the subnet scan result for a specific CIDR block. + /// Returns null if the subnet was not scanned. + /// + public static SubnetScanResult? GetSubnetResult( NetworkScanResult result, CidrBlock cidr ) { + return result.Subnets.FirstOrDefault( s => s.CidrBlock.Equals( cidr ) ); + } + + /// + /// Gets all subnets with a specific scan status. + /// + public static List GetSubnetsByStatus( + NetworkScanResult result, + ScanResultStatus status + ) { + return result.Subnets + .Where( s => s.Status == status ) + .ToList(); + } + + /// + /// Gets the total number of devices discovered across all subnets. + /// + public static int GetTotalDeviceCount( NetworkScanResult result ) { + return GetAllDevices( result ).Count; + } + + /// + /// Gets the total number of unique IP addresses discovered. + /// + public static int GetUniqueIpCount( NetworkScanResult result ) { + return GetAllIpAddresses( result ).Count; + } + + /// + /// Gets the total number of unique MAC addresses discovered. + /// + public static int GetUniqueMacCount( NetworkScanResult result ) { + return GetAllMacAddresses( result ).Count; + } + + /// + /// Checks if a specific IP address was discovered in the scan. + /// + public static bool HasIpAddress( NetworkScanResult result, IpV4Address ipAddress ) { + return GetDevicesWithIp( result, ipAddress ).Any(); + } + + /// + /// Checks if a specific MAC address was discovered in the scan. + /// + public static bool HasMacAddress( NetworkScanResult result, MacAddress macAddress ) { + return GetDevicesWithMac( result, macAddress ).Any(); + } + + /// + /// Checks if a specific subnet was scanned. + /// + public static bool HasSubnet( NetworkScanResult result, CidrBlock cidr ) { + return GetSubnetResult( result, cidr ) != null; + } + + /// + /// Gets devices discovered in a specific subnet. + /// Returns empty list if the subnet was not scanned. + /// + public static List GetDevicesInSubnet( NetworkScanResult result, CidrBlock cidr ) { + var subnetResult = GetSubnetResult( result, cidr ); + return subnetResult?.DiscoveredDevices.ToList() ?? []; + } + + /// + /// Gets all subnets that had failures during scanning. + /// + public static List GetFailedSubnets( NetworkScanResult result ) { + return GetSubnetsByStatus( result, ScanResultStatus.Error ) + .Select( s => s.CidrBlock ) + .ToList(); + } + + /// + /// Gets all subnets that were scanned successfully. + /// + public static List GetSuccessfulSubnets( NetworkScanResult result ) { + return GetSubnetsByStatus( result, ScanResultStatus.Success ) + .Select( s => s.CidrBlock ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs index b4c07c02..d946d1c8 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using Drift.Cli.Abstractions; using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Agent.Subcommands.Start; @@ -18,12 +19,12 @@ internal static class Options { }; internal static readonly Option Port = new("--port", "-p") { - DefaultValueFactory = _ => 51515, Description = "Set the port used for both adoption and communication" + DefaultValueFactory = _ => Ports.AgentDefault, + Description = "Set the port used for both adoption and communication" }; internal static readonly Option Id = new("--id") { - Description = "Set a specific agent ID (for testing purposes)", - Hidden = true + Description = "Set a specific agent ID (for testing purposes)", Hidden = true }; } diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs index fd340461..4967ca30 100644 --- a/src/Cli/Commands/Scan/ClusterExtensions.cs +++ b/src/Cli/Commands/Scan/ClusterExtensions.cs @@ -2,7 +2,6 @@ using Drift.Agent.PeerProtocol.Subnets; using Drift.Domain; using Drift.Networking.Cluster; -using Drift.Networking.Grpc.Generated; using Drift.Networking.PeerStreaming.Core.Abstractions; namespace Drift.Cli.Commands.Scan; diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs index ac27d9b9..204b18b1 100644 --- a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -216,10 +216,12 @@ ILogger logger ); } else { + var localCount = allResults.Count( r => resolvedSubnets.Any( rs => rs.Cidr == r.CidrBlock && rs.Source is Local ) ); + var agentCount = allResults.Count - localCount; logger.LogInformation( - "Distributed scan completed: {SuccessCount}/{TotalCount} scan operations successful, {UniqueSubnets} unique subnets", - successCount, - allResults.Count, + "Scan completed: {LocalCount} local, {AgentCount} via agents, {UniqueSubnets} unique subnets", + localCount, + agentCount, mergedResults.Count ); } @@ -227,7 +229,7 @@ ILogger logger return finalResult; } - private List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { + private static List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { // Group results by CIDR and merge devices from multiple scans var resultsByCidr = allResults .GroupBy( r => r.CidrBlock ) diff --git a/src/Domain/AgentId.cs b/src/Domain/AgentId.cs index 8fc538e5..e317e86d 100644 --- a/src/Domain/AgentId.cs +++ b/src/Domain/AgentId.cs @@ -28,11 +28,11 @@ public string Value { public Guid? AsGuidOrNull => Guid.TryParse( Value[Prefix.Length..], out var guid ) ? guid : null; - public static implicit operator AgentId( string value ) => new AgentId( value ); + public static implicit operator AgentId( string value ) => new(value); public static implicit operator string( AgentId id ) => id.Value; - public static AgentId New() => new AgentId( Prefix + Guid.NewGuid() ); + public static AgentId New() => new(Prefix + Guid.NewGuid()); public override string ToString() => Value; } \ No newline at end of file diff --git a/src/Domain/CidrBlock.cs b/src/Domain/CidrBlock.cs index acd98fd7..a99b3a31 100644 --- a/src/Domain/CidrBlock.cs +++ b/src/Domain/CidrBlock.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using Drift.Domain.Device.Addresses; namespace Drift.Domain; @@ -58,4 +59,7 @@ public CidrBlock( string cidrNotation ) { } public override string ToString() => $"{NetworkAddress}/{PrefixLength}"; + + public bool Contains( IIpAddress ip ) => + IPNetwork.Parse( ToString() ).Contains( ip.Ip ); } \ No newline at end of file diff --git a/src/Domain/Device/Addresses/AddressType.cs b/src/Domain/Device/Addresses/AddressType.cs index 9549f0cf..fc3ede4b 100644 --- a/src/Domain/Device/Addresses/AddressType.cs +++ b/src/Domain/Device/Addresses/AddressType.cs @@ -12,7 +12,7 @@ Layer Name Purpose / Function Examples */ public enum AddressType { IpV4 = 1, - IpV6 = 2, + // IpV6 = 2, Mac = 3, Hostname = 4 } \ No newline at end of file diff --git a/src/Domain/Device/Addresses/DeviceAddressSet.cs b/src/Domain/Device/Addresses/DeviceAddressSet.cs new file mode 100644 index 00000000..84175e11 --- /dev/null +++ b/src/Domain/Device/Addresses/DeviceAddressSet.cs @@ -0,0 +1,50 @@ +namespace Drift.Domain.Device.Addresses; + +/// +/// Describes the set of known addresses for a discovered device. +/// At least one address must be provided; no single address type is universally required, +/// as different scan methods (ping, ARP, etc.) discover different address types. +/// +public sealed class DeviceAddressSet { + public IpV4Address? Ip { + get; + } + + public MacAddress? Mac { + get; + } + + public HostnameAddress? Hostname { + get; + } + + public DeviceAddressSet( IpV4Address? ip = null, MacAddress? mac = null, HostnameAddress? hostname = null ) { + if ( ip is null && mac is null && hostname is null ) { + throw new ArgumentException( "At least one address must be provided." ); + } + + Ip = ip; + Mac = mac; + Hostname = hostname; + } + + /// + /// Converts to a flat address list suitable for . + /// + public List ToAddresses() { + var list = new List(); + if ( Ip is not null ) { + list.Add( Ip.Value ); + } + + if ( Mac is not null ) { + list.Add( Mac.Value ); + } + + if ( Hostname is not null ) { + list.Add( Hostname.Value ); + } + + return list; + } +} \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IIPAddress.cs b/src/Domain/Device/Addresses/IIPAddress.cs new file mode 100644 index 00000000..fd9ad85c --- /dev/null +++ b/src/Domain/Device/Addresses/IIPAddress.cs @@ -0,0 +1,9 @@ +using System.Net; + +namespace Drift.Domain.Device.Addresses; + +public interface IIpAddress { + IPAddress Ip { + get; + } +} \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IpV4Address.cs b/src/Domain/Device/Addresses/IpV4Address.cs index f32cafa4..ceb95160 100644 --- a/src/Domain/Device/Addresses/IpV4Address.cs +++ b/src/Domain/Device/Addresses/IpV4Address.cs @@ -3,7 +3,7 @@ namespace Drift.Domain.Device.Addresses; -public readonly record struct IpV4Address : IDeviceAddress { +public readonly record struct IpV4Address : IDeviceAddress, IIpAddress { public AddressType Type => AddressType.IpV4; public string Value { @@ -14,6 +14,8 @@ public bool? IsId { get; } + public IPAddress Ip => IPAddress.Parse( Value ); + public IpV4Address( string ipAddress, bool? isId = null ) { if ( !IPAddress.TryParse( ipAddress, out var ip ) || ip.AddressFamily != AddressFamily.InterNetwork ) { throw new ArgumentException( $"'{ipAddress}' is not a valid IPv4 address.", nameof(ipAddress) ); diff --git a/src/Domain/Device/Discovered/DiscoveredDevice.cs b/src/Domain/Device/Discovered/DiscoveredDevice.cs index 4d29c71f..aaa93637 100644 --- a/src/Domain/Device/Discovered/DiscoveredDevice.cs +++ b/src/Domain/Device/Discovered/DiscoveredDevice.cs @@ -3,6 +3,7 @@ namespace Drift.Domain.Device.Discovered; public record DiscoveredDevice : IAddressableDevice { + // TODO use DeviceAddressSet? public List Addresses { get; init; diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs index 92ea5b17..cf3c9f6e 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs +++ b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs @@ -1,5 +1,3 @@ -using Drift.Networking.Grpc.Generated; - namespace Drift.Networking.PeerStreaming.Core.Abstractions; public static class PeerMessageHandlerExtensions { diff --git a/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs b/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs index 11d9a4d0..775df248 100644 --- a/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs +++ b/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs @@ -40,7 +40,9 @@ public async Task ScanAsync( return; } - discoveredDevices.Add( new DiscoveredDevice { Addresses = [new IpV4Address( data )] } ); + discoveredDevices.Add( new DiscoveredDevice { + Addresses = new DeviceAddressSet( ip: new IpV4Address( data ) ).ToAddresses() + } ); var elapsed = stopwatch.Elapsed; var estimatedScanned = elapsed.TotalSeconds * options.PingsPerSecond; var progress = Math.Min( 99, Math.Ceiling( ( estimatedScanned / ipRange.Count ) * 100 ) ); diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index 701fab39..14c94824 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -132,23 +132,20 @@ ArpTable arpTable new DiscoveredDevice { Addresses = CreateAddresses( pingReply ) } ).ToList(); - List CreateAddresses( ( IPAddress Ip, bool Success, string? Hostname) pingReply ) { - var list = new List { new IpV4Address( pingReply.Ip ) }; + List CreateAddresses( (IPAddress Ip, bool Success, string? Hostname) pingReply ) { + IpV4Address ip = new IpV4Address( pingReply.Ip ); - if ( !string.IsNullOrWhiteSpace( pingReply.Hostname ) ) { - list.Add( new HostnameAddress( pingReply.Hostname ) ); - } + MacAddress? mac = arpTable.TryGetValue( pingReply.Ip, out var arpMac ) + ? arpMac + : localMacs.TryGetValue( pingReply.Ip, out var localMac ) + ? localMac + : null; - if ( arpTable.TryGetValue( pingReply.Ip, out var mac ) ) { - list.Add( mac ); - } - else if ( localMacs.TryGetValue( pingReply.Ip, out var localMac ) ) { - // ARP cache never contains the machine's own IPs. Fall back to reading - // the MAC directly from the matching local interface. - list.Add( localMac ); - } + HostnameAddress? hostname = string.IsNullOrWhiteSpace( pingReply.Hostname ) + ? null + : new HostnameAddress( pingReply.Hostname ); - return list; + return new DeviceAddressSet( ip, mac, hostname ).ToAddresses(); } } @@ -180,17 +177,17 @@ private static Dictionary BuildLocalInterfaceMacTable() { private static async Task GetHostNameAsync( IPAddress ip, int timeoutMs = 1000 ) { var task = Dns.GetHostEntryAsync( ip ); - if ( await Task.WhenAny( task, Task.Delay( timeoutMs ) ) == task ) { - try { - return task.Result.HostName; - } - catch { - // Reverse DNS failed - return null; - } + if ( await Task.WhenAny( task, Task.Delay( timeoutMs ) ) != task ) { + // Timed out + return null; } - // Timed out - return null; + try { + return task.Result.HostName; + } + catch { + // Reverse DNS failed + return null; + } } } \ No newline at end of file diff --git a/src/Scanning/Scanners/Preview/Ideas.cs b/src/Scanning/Scanners/Preview/Ideas.cs index 640494f9..42e72247 100644 --- a/src/Scanning/Scanners/Preview/Ideas.cs +++ b/src/Scanning/Scanners/Preview/Ideas.cs @@ -110,7 +110,7 @@ public DefaultScanOrchestrator( _logger = logger; } - public async Task RunScanAsync( ScanOptions options ) { + public static async Task RunScanAsync( ScanOptions options ) { /*_logger.LogInformation("Starting network scan..."); var actualState = await _networkScanner.ScanAsync(); From b3725079d3cab544edf89246386880008317e6cd Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:27:14 +0200 Subject: [PATCH 03/31] fix tests --- Drift.Build.slnx | 7 +++--- .../Versioning/VersioningTests.cs | 24 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Drift.Build.slnx b/Drift.Build.slnx index e6781204..087e9f37 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -1,6 +1,7 @@ + @@ -11,7 +12,7 @@ - - - + + + \ No newline at end of file diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index b69133aa..c94378af 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -13,7 +13,7 @@ namespace Drift.Build.Utilities.Tests.Versioning; internal sealed class VersioningTests { [Test] - public static async Task DefaultVersioningVersionTest() { + public async Task DefaultVersioningVersionTest() { // Arrange var build = new TestNukeBuild(); @@ -29,7 +29,7 @@ public static async Task DefaultVersioningVersionTest() { } [Test] - public static async Task DefaultVersioningWhenNoReleaseTargets() { + public async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary ); @@ -46,7 +46,7 @@ public static async Task DefaultVersioningWhenNoReleaseTargets() { } [Test] - public static async Task MultipleReleaseTargetsInPlanThrows() { + public async Task MultipleReleaseTargetsInPlanThrows() { // Arrange var build = new TestNukeBuild().WithExecutionPlan( b => b.CreateRelease, b => b.CreatePreRelease ); @@ -102,7 +102,7 @@ public async Task MismatchedReleaseTypeThrows( } [Test] - public static void PreReleaseWithoutVersionThrows() { + public void PreReleaseWithoutVersionThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -145,7 +145,7 @@ public void DebugConfigurationThrows( } [Test] - public static async Task PreReleaseValid() { + public async Task PreReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -166,7 +166,7 @@ await Assert.That( version.PrereleaseIdentifiers[version.PrereleaseIdentifiers.C } [Test] - public static void PreReleaseRejectsFullSemVerString() { + public void PreReleaseRejectsFullSemVerString() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -182,7 +182,7 @@ public static void PreReleaseRejectsFullSemVerString() { } [Test] - public static async Task PreReleaseVersionIsTemporallyConsistent() { + public async Task PreReleaseVersionIsTemporallyConsistent() { // Arrange var timeProvider = new FakeTimeProvider(); var build = new TestNukeBuild() @@ -201,7 +201,7 @@ public static async Task PreReleaseVersionIsTemporallyConsistent() { } [Test] - public static void ReleaseWithPrereleaseIdentifiersThrows() { + public void ReleaseWithPrereleaseIdentifiersThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -217,7 +217,7 @@ public static void ReleaseWithPrereleaseIdentifiersThrows() { } [Test] - public static async Task ReleaseValid() { + public async Task ReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -262,7 +262,7 @@ public static async Task ReleaseValid() { } [Test] - public static async Task ExactVersioningValid() { + public async Task ExactVersioningValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -278,7 +278,7 @@ public static async Task ExactVersioningValid() { } [Test] - public static void ExactVersioningWithNullThrows() { + public void ExactVersioningWithNullThrows() { // Arrange var strategy = new ExactVersioning( Configuration.Release, " ", null!, null! ); @@ -287,7 +287,7 @@ public static void ExactVersioningWithNullThrows() { } [Test] - public static async Task ExactVersioningValidWithReleaseVersion() { + public async Task ExactVersioningValidWithReleaseVersion() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) From 0b860f6f30aecb815d1f2da25350e757e3852d81 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:39:37 +0200 Subject: [PATCH 04/31] editorconfig update --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 8c034db3..c21b764c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ root = true indent_style = space # Code files -[*.{cs,csproj,slnx,props,json}] +[*.{cs,csproj,slnx,props,targets,json}] indent_size = 2 insert_final_newline = false charset = utf-8 From 37b49a4e843d4f4664292a4f9ae70eaf15aad313 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:50:30 +0200 Subject: [PATCH 05/31] fix tests --- .../Utils/Network/Firewall/FirewallEvaluator.cs | 3 ++- src/Domain/CidrBlock.cs | 4 ++-- src/Domain/Device/Addresses/IIPAddress.cs | 9 --------- src/Domain/Device/Addresses/IpV4Address.cs | 5 +---- 4 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 src/Domain/Device/Addresses/IIPAddress.cs diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs index 257672c7..16e46561 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs @@ -1,3 +1,4 @@ +using System.Net; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -73,7 +74,7 @@ public bool IsAllowed( FirewallTarget source, FirewallTarget dest ) { /// private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { try { - var ipBytes = ip.Ip.GetAddressBytes(); + var ipBytes = IPAddress.Parse( ip.Value ).GetAddressBytes(); var cidrParts = cidr.ToString().Split( '/' ); if ( cidrParts.Length != 2 ) { return false; diff --git a/src/Domain/CidrBlock.cs b/src/Domain/CidrBlock.cs index a99b3a31..e42c25ff 100644 --- a/src/Domain/CidrBlock.cs +++ b/src/Domain/CidrBlock.cs @@ -60,6 +60,6 @@ public CidrBlock( string cidrNotation ) { public override string ToString() => $"{NetworkAddress}/{PrefixLength}"; - public bool Contains( IIpAddress ip ) => - IPNetwork.Parse( ToString() ).Contains( ip.Ip ); + public bool Contains( IpV4Address ip ) => + IPNetwork.Parse( ToString() ).Contains( IPAddress.Parse( ip.Value ) ); } \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IIPAddress.cs b/src/Domain/Device/Addresses/IIPAddress.cs deleted file mode 100644 index fd9ad85c..00000000 --- a/src/Domain/Device/Addresses/IIPAddress.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Net; - -namespace Drift.Domain.Device.Addresses; - -public interface IIpAddress { - IPAddress Ip { - get; - } -} \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IpV4Address.cs b/src/Domain/Device/Addresses/IpV4Address.cs index ceb95160..5ba1f9b6 100644 --- a/src/Domain/Device/Addresses/IpV4Address.cs +++ b/src/Domain/Device/Addresses/IpV4Address.cs @@ -1,9 +1,8 @@ using System.Net; using System.Net.Sockets; - namespace Drift.Domain.Device.Addresses; -public readonly record struct IpV4Address : IDeviceAddress, IIpAddress { +public readonly record struct IpV4Address : IDeviceAddress { public AddressType Type => AddressType.IpV4; public string Value { @@ -14,8 +13,6 @@ public bool? IsId { get; } - public IPAddress Ip => IPAddress.Parse( Value ); - public IpV4Address( string ipAddress, bool? isId = null ) { if ( !IPAddress.TryParse( ipAddress, out var ip ) || ip.AddressFamily != AddressFamily.InterNetwork ) { throw new ArgumentException( $"'{ipAddress}' is not a valid IPv4 address.", nameof(ipAddress) ); From 2ef2140db7928b585c3b16bed8856c52026bb835 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:55:06 +0200 Subject: [PATCH 06/31] f --- src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs index b6533d05..3768a802 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs @@ -1,3 +1,4 @@ +using System.Net; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -61,7 +62,7 @@ internal override bool Matches( string subnetName, IpV4Address? ip ) { /// private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { try { - var ipBytes = ip.Ip.GetAddressBytes(); + var ipBytes = IPAddress.Parse( ip.Value ).GetAddressBytes(); var cidrParts = cidr.ToString().Split( '/' ); if ( cidrParts.Length != 2 ) { return false; From 529f197de397cedf15bd7e9ba350fa4d0877b6fc Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 22:16:16 +0200 Subject: [PATCH 07/31] fix clab tests --- build/NukeBuild.TestContainerlab.cs | 10 ++++++---- src/Cli/Commands/Scan/DistributedNetworkScanner.cs | 13 ++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs index 0d3313e1..285a6ff3 100644 --- a/build/NukeBuild.TestContainerlab.cs +++ b/build/NukeBuild.TestContainerlab.cs @@ -39,8 +39,9 @@ sealed partial class NukeBuild { Assertions: [ new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), new ScanAssertion( "Both scans successful (local + agent)", - output => output.Contains( "2/2 scan operations successful" ) ), - new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + output => output.Contains( "from 2 scans of" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 1 local, 1 via agents" ) ), ] ), new( @@ -51,8 +52,9 @@ sealed partial class NukeBuild { Assertions: [ new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), new ScanAssertion( "All 4 scans successful (local + 3 agents)", - output => output.Contains( "4/4 scan operations successful" ) ), - new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + output => output.Contains( "from 4 scans of" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 1 local, 3 via agents" ) ), ] ), new( diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs index 204b18b1..32cdea2c 100644 --- a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -51,7 +51,10 @@ await ScanLocalSubnetsAsync( } } - return BuildFinalResult( allSubnetResults, startTime, logger ); + var localScanCount = subnetsBySource.Where( x => x.Source is Local ).Sum( x => x.Cidrs.Count ); + var agentScanCount = subnetsBySource.Where( x => x.Source is Scanning.Subnets.Agent ).Sum( x => x.Cidrs.Count ); + + return BuildFinalResult( allSubnetResults, startTime, localScanCount, agentScanCount, logger ); } private List<(SubnetSource Source, List Cidrs)> PartitionSubnetsBySource( NetworkScanOptions options ) { @@ -185,6 +188,8 @@ DateTime startTime private NetworkScanResult BuildFinalResult( List allResults, DateTime startTime, + int localScanCount, + int agentScanCount, ILogger logger ) { var endTime = DateTime.UtcNow; @@ -216,12 +221,10 @@ ILogger logger ); } else { - var localCount = allResults.Count( r => resolvedSubnets.Any( rs => rs.Cidr == r.CidrBlock && rs.Source is Local ) ); - var agentCount = allResults.Count - localCount; logger.LogInformation( "Scan completed: {LocalCount} local, {AgentCount} via agents, {UniqueSubnets} unique subnets", - localCount, - agentCount, + localScanCount, + agentScanCount, mergedResults.Count ); } From b03d5b7a2a9f1d07a4df3d174b4d8247fdc39609 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 22:22:49 +0200 Subject: [PATCH 08/31] align snapshots --- ...sts.WithAgents_OverlappingSubnets_MergesDevices.verified.txt | 2 +- ...hAgents_ScannerSelection_UsesDistributedScanner.verified.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt index 6f068c80..4ba45cbb 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt @@ -11,7 +11,7 @@ Agents run without authentication. Any client that can reach the agent port can Agent adoption (--adoptable / --join) is not yet implemented and has no effect. Merged 1 unique devices from 3 scans of 192.168.0.0/24 Merged 4 unique devices from 2 scans of 192.168.10.0/24 -Scan completed: 3 local, 2 via agents, 2 unique subnets +Scan completed: 1 local, 4 via agents, 2 unique subnets 192.168.0.0/24 (1 devices) └── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt index de22f662..77f50b59 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt @@ -8,7 +8,7 @@ Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. Agent adoption (--adoptable / --join) is not yet implemented and has no effect. Merged 1 unique devices from 2 scans of 192.168.0.0/24 -Scan completed: 2 local, 1 via agents, 2 unique subnets +Scan completed: 1 local, 2 via agents, 2 unique subnets 192.168.0.0/24 (1 devices) └── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) From 22a3821007837ccff981d6985b8aaf76f89d84f7 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 22:35:00 +0200 Subject: [PATCH 09/31] simplify and fix clab tests --- build/NukeBuild.TestContainerlab.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs index 285a6ff3..c790de52 100644 --- a/build/NukeBuild.TestContainerlab.cs +++ b/build/NukeBuild.TestContainerlab.cs @@ -38,10 +38,8 @@ sealed partial class NukeBuild { CliContainer: "clab-drift-simple-test-cli", Assertions: [ new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), - new ScanAssertion( "Both scans successful (local + agent)", - output => output.Contains( "from 2 scans of" ) ), new ScanAssertion( "Scan completed successfully", - output => output.Contains( "Scan completed: 1 local, 1 via agents" ) ), + output => output.Contains( "Scan completed: 1 local, 1 via agents, 1 unique subnets" ) ), ] ), new( @@ -51,10 +49,8 @@ sealed partial class NukeBuild { CliContainer: "clab-drift-cooperation-test-cli", Assertions: [ new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), - new ScanAssertion( "All 4 scans successful (local + 3 agents)", - output => output.Contains( "from 4 scans of" ) ), new ScanAssertion( "Scan completed successfully", - output => output.Contains( "Scan completed: 1 local, 3 via agents" ) ), + output => output.Contains( "Scan completed: 1 local, 3 via agents, 1 unique subnets" ) ), ] ), new( @@ -65,9 +61,8 @@ sealed partial class NukeBuild { Assertions: [ new ScanAssertion( "Subnet-A scanned", output => output.Contains( "192.168.10.0/24" ) ), new ScanAssertion( "Subnet-B scanned", output => output.Contains( "192.168.20.0/24" ) ), - new ScanAssertion( "All scan operations successful", - output => output.Contains( "7/7 scan operations successful" ) ), - new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 3 local, 4 via agents, 3 unique subnets" ) ), ] ), ]; From d51aa6233c3686f2f4a565f375c5ab9addaef8af Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 23:03:44 +0200 Subject: [PATCH 10/31] fix build warnings --- .../ScanCommandTests.Orchestration.cs | 41 ++-- src/Cli.Tests/GlobalSuppressions.cs | 30 ++- src/Cli.Tests/Utils/Agent/HarnessResult.cs | 4 +- .../Utils/Network/Topology/NetworkTopology.cs | 2 +- .../Utils/Network/Topology/Topologies.cs | 192 ++++++++++++------ 5 files changed, 186 insertions(+), 83 deletions(-) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs index db181c9d..5c83c405 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -112,11 +112,16 @@ await Verify( result.CombinedOutput ) public async Task WithAgents_DuplicateDevices_Deduplicates() { // Arrange - Both agents discover the same device on the same subnet var topology = new NetworkTopologyBuilder() - .AddSubnet( "shared", "192.168.10.0/24", [ - ( "192.168.10.100", "Shared device" ), // Both agents will see this - ( "192.168.10.101", "Device from agent1" ), - ( "192.168.10.102", "Device from agent2" ) - ], out var sharedSubnet ) + .AddSubnet( + "shared", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Shared device" ), // Both agents will see this + ( "192.168.10.101", "Device from agent1" ), + ( "192.168.10.102", "Device from agent2" ) + ], + out var sharedSubnet + ) .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) .Build(); @@ -141,14 +146,24 @@ public async Task WithAgents_FirewallRules_FiltersVisibility() { // Arrange - DMZ agent can see internal, but internal agent cannot see DMZ // This tests that firewall rules properly restrict network visibility var topology = new NetworkTopologyBuilder() - .AddSubnet( "dmz", "192.168.1.0/24", [ - ( "192.168.1.100", "Web server" ), - ( "192.168.1.101", "App server" ) - ], out var dmzSubnet ) - .AddSubnet( "internal", "10.0.0.0/24", [ - ( "10.0.0.100", "Database" ), - ( "10.0.0.101", "File server" ) - ], out var internalSubnet ) + .AddSubnet( + "dmz", + "192.168.1.0/24", + [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], + out var dmzSubnet + ) + .AddSubnet( + "internal", + "10.0.0.0/24", + [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "File server" ) + ], + out var internalSubnet + ) .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) .WithFirewall( fw => { diff --git a/src/Cli.Tests/GlobalSuppressions.cs b/src/Cli.Tests/GlobalSuppressions.cs index 5c1b0887..b6301b01 100644 --- a/src/Cli.Tests/GlobalSuppressions.cs +++ b/src/Cli.Tests/GlobalSuppressions.cs @@ -1,4 +1,4 @@ - // This file is used by Code Analysis to maintain SuppressMessage +// This file is used by Code Analysis to maintain SuppressMessage // attributes that are applied to this project. // Project-level suppressions either have no target or are given // a specific target and scoped to a namespace, type, member, etc. @@ -7,18 +7,30 @@ // Test infrastructure classes are intentionally public to be used across test files [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Agent" )] [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Testing" )] [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Network.Firewall" )] [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Network.Topology" )] \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/HarnessResult.cs b/src/Cli.Tests/Utils/Agent/HarnessResult.cs index bcdf0bb2..eb81b4a7 100644 --- a/src/Cli.Tests/Utils/Agent/HarnessResult.cs +++ b/src/Cli.Tests/Utils/Agent/HarnessResult.cs @@ -7,12 +7,12 @@ namespace Drift.Cli.Tests.Utils.Agent; /// /// Result from running a scan with the agent test harness. -/// +/// /// RECOMMENDED USAGE: /// 1. Assert on exit codes: Assert.That(result.ScanExitCode, Is.EqualTo(ExitCodes.Success)) /// 2. Use Verify for output: await Verify(result.CombinedOutput) /// 3. Query results if needed: result.Results.GetDevicesWithIp(...) -/// +/// /// Only use result.Logs when you need to programmatically query specific log entries. /// For most tests, Verify on CombinedOutput is easier to work with. /// diff --git a/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs index a4943e27..9e4ee629 100644 --- a/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs +++ b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs @@ -76,7 +76,7 @@ public required List Devices { } /// - /// When true, devices on this subnet cannot communicate with each other (peer isolation). + /// Gets a value indicating whether devices on this subnet cannot communicate with each other (peer isolation). /// Mirrors UniFi's "Client Device Isolation" / "Device Isolation (ACL)" feature. /// Defaults to false (devices on the same subnet can communicate freely). /// diff --git a/src/Cli.Tests/Utils/Network/Topology/Topologies.cs b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs index a75427cf..c00f54c3 100644 --- a/src/Cli.Tests/Utils/Network/Topology/Topologies.cs +++ b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs @@ -46,13 +46,23 @@ out var agent2Subnet /// public static NetworkTopology TwoAgentsDisjointSubnetsNoCli() { return new NetworkTopologyBuilder() - .AddSubnet( "agent1-subnet", "192.168.10.0/24", [ - ( "192.168.10.100", "Agent1 device 1" ), - ( "192.168.10.101", "Agent1 device 2" ) - ], out var agent1Subnet ) - .AddSubnet( "agent2-subnet", "192.168.20.0/24", [ - ( "192.168.20.100", "Agent2 device" ) - ], out var agent2Subnet ) + .AddSubnet( + "agent1-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Agent1 device 1" ), + ( "192.168.10.101", "Agent1 device 2" ) + ], + out var agent1Subnet + ) + .AddSubnet( + "agent2-subnet", + "192.168.20.0/24", + [ + ( "192.168.20.100", "Agent2 device" ) + ], + out var agent2Subnet + ) .AddAgent( new AgentId( "agentid_agent1" ), agent1Subnet ) .AddAgent( new AgentId( "agentid_agent2" ), agent2Subnet ) .Build(); @@ -65,15 +75,25 @@ public static NetworkTopology TwoAgentsDisjointSubnetsNoCli() { /// public static NetworkTopology TwoAgentsOverlappingSubnet() { return new NetworkTopologyBuilder() - .AddSubnet( "cli-subnet", "192.168.0.0/24", [ - ( "192.168.0.100", "CLI device" ) - ], out var cliSubnet ) - .AddSubnet( "shared-subnet", "192.168.10.0/24", [ - ( "192.168.10.100", "Shared device 1" ), - ( "192.168.10.101", "Shared device 2" ), - ( "192.168.10.102", "Shared device 3" ), - ( "192.168.10.103", "Shared device 4" ) - ], out var sharedSubnet ) + .AddSubnet( + "cli-subnet", + "192.168.0.0/24", + [ + ( "192.168.0.100", "CLI device" ) + ], + out var cliSubnet + ) + .AddSubnet( + "shared-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Shared device 1" ), + ( "192.168.10.101", "Shared device 2" ), + ( "192.168.10.102", "Shared device 3" ), + ( "192.168.10.103", "Shared device 4" ) + ], + out var sharedSubnet + ) .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) .WithCli( cliSubnet ) @@ -98,12 +118,22 @@ public static NetworkTopology SingleAgentEmptyResults() { /// public static NetworkTopology MixedCliAndAgents() { return new NetworkTopologyBuilder() - .AddSubnet( "cli-subnet", "192.168.0.0/24", [ - ( "192.168.0.100", "CLI device" ) - ], out var cliSubnet ) - .AddSubnet( "agent-subnet", "192.168.10.0/24", [ - ( "192.168.10.100", "Agent device" ) - ], out var agentSubnet ) + .AddSubnet( + "cli-subnet", + "192.168.0.0/24", + [ + ( "192.168.0.100", "CLI device" ) + ], + out var cliSubnet + ) + .AddSubnet( + "agent-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Agent device" ) + ], + out var agentSubnet + ) .AddAgent( new AgentId( "agentid_agent1" ), agentSubnet ) .WithCli( cliSubnet ) .Build(); @@ -118,14 +148,24 @@ public static NetworkTopology MixedCliAndAgents() { /// public static NetworkTopology DmzWithFirewall() { return new NetworkTopologyBuilder() - .AddSubnet( "dmz", "192.168.1.0/24", [ - ( "192.168.1.100", "Web server" ), - ( "192.168.1.101", "App server" ) - ], out var dmzSubnet ) - .AddSubnet( "internal", "10.0.0.0/24", [ - ( "10.0.0.100", "Database server" ), - ( "10.0.0.101", "File server" ) - ], out var internalSubnet ) + .AddSubnet( + "dmz", + "192.168.1.0/24", + [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], + out var dmzSubnet + ) + .AddSubnet( + "internal", + "10.0.0.0/24", + [ + ( "10.0.0.100", "Database server" ), + ( "10.0.0.101", "File server" ) + ], + out var internalSubnet + ) .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) // Firewall rules: @@ -145,14 +185,23 @@ public static NetworkTopology DmzWithFirewall() { /// public static NetworkTopology GuestNetworkIsolation() { return new NetworkTopologyBuilder() - .AddSubnet( "guest", "192.168.100.0/24", [ - ( "192.168.100.10", "Guest device 1" ), - ( "192.168.100.11", "Guest device 2" ), - ( "192.168.100.12", "Guest device 3" ) - ], out var guestSubnet ) - .AddSubnet( "external", "203.0.113.0/24", [ - ( "203.0.113.1", "Internet gateway" ) - ] ) + .AddSubnet( + "guest", + "192.168.100.0/24", + [ + ( "192.168.100.10", "Guest device 1" ), + ( "192.168.100.11", "Guest device 2" ), + ( "192.168.100.12", "Guest device 3" ) + ], + out var guestSubnet + ) + .AddSubnet( + "external", + "203.0.113.0/24", + [ + ( "203.0.113.1", "Internet gateway" ) + ] + ) .AddAgent( new AgentId( "agentid_guest" ), guestSubnet ) .WithFirewall( fw => { fw.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "guest" ) ); // Block guest-to-guest @@ -169,20 +218,31 @@ public static NetworkTopology GuestNetworkIsolation() { /// public static NetworkTopology BastionHostAccess() { return new NetworkTopologyBuilder() - .AddSubnet( "dmz", "192.168.1.0/24", [ - ( "192.168.1.10", "Bastion host" ), - ( "192.168.1.20", "Web server" ), - ( "192.168.1.30", "App server" ) - ], out var dmzSubnet ) - .AddSubnet( "internal", "10.0.0.0/24", [ - ( "10.0.0.100", "Database" ), - ( "10.0.0.101", "Admin server" ) - ], out var internalSubnet ) + .AddSubnet( + "dmz", + "192.168.1.0/24", + [ + ( "192.168.1.10", "Bastion host" ), + ( "192.168.1.20", "Web server" ), + ( "192.168.1.30", "App server" ) + ], + out var dmzSubnet + ) + .AddSubnet( + "internal", + "10.0.0.0/24", + [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "Admin server" ) + ], + out var internalSubnet + ) .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) .WithFirewall( fw => { // Order matters - first match wins! - fw.Allow( FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + fw.Allow( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), FirewallTarget.Subnet( "internal" ) ); // Bastion → internal (FIRST) fw.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); // Other DMZ → internal (SECOND) fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "dmz" ) ); // DMZ can see itself @@ -200,15 +260,30 @@ public static NetworkTopology BastionHostAccess() { /// public static NetworkTopology ManagementNetworkWithCidr() { return new NetworkTopologyBuilder() - .AddSubnet( "management", "10.0.0.0/24", [ - ( "10.0.0.10", "Admin workstation" ) - ], out var mgmtSubnet ) - .AddSubnet( "production1", "192.168.1.0/24", [ - ( "192.168.1.100", "Prod server 1" ) - ], out var prod1Subnet ) - .AddSubnet( "production2", "192.168.2.0/24", [ - ( "192.168.2.100", "Prod server 2" ) - ], out var prod2Subnet ) + .AddSubnet( + "management", + "10.0.0.0/24", + [ + ( "10.0.0.10", "Admin workstation" ) + ], + out var mgmtSubnet + ) + .AddSubnet( + "production1", + "192.168.1.0/24", + [ + ( "192.168.1.100", "Prod server 1" ) + ], + out var prod1Subnet + ) + .AddSubnet( + "production2", + "192.168.2.0/24", + [ + ( "192.168.2.100", "Prod server 2" ) + ], + out var prod2Subnet + ) .AddAgent( new AgentId( "agentid_mgmt" ), mgmtSubnet ) .AddAgent( new AgentId( "agentid_prod1" ), prod1Subnet ) .AddAgent( new AgentId( "agentid_prod2" ), prod2Subnet ) @@ -221,7 +296,8 @@ public static NetworkTopology ManagementNetworkWithCidr() { fw.Allow( FirewallTarget.Subnet( "production2" ), FirewallTarget.Subnet( "production2" ) ); // Block production cross-talk (explicit deny) - fw.Deny( FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ), + fw.Deny( + FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ), FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ) ); // Block any 192.168.x.x to 192.168.x.x } ) .Build(); From 11430a5a05ff6835aa57d93a9eecd6ad9cde498e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:03:18 +0200 Subject: [PATCH 11/31] add agents.md --- AGENTS.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ff47e365 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# AGENTS.md + +This file provides guidance to AI agents when working with code in this repository. + +## Project Overview + +Drift is a .NET 10 CLI tool for network drift detection — it compares a declarative YAML spec (desired network state) against live network scanning results and reports differences. It supports distributed scanning via agents communicating over gRPC. + +## Build System + +The build uses [NUKE](https://nuke.build/). Entry point is `dotnet nuke`. + +Common targets: + +```sh +dotnet nuke Build # Restore + compile +dotnet nuke TestUnit # Unit tests only (fast) +dotnet nuke Test # All tests (unit + E2E) +dotnet nuke TestE2E # E2E tests (General, Binary, Container image, Container network topologies using Containerlab) +dotnet nuke PublishBinaries # Self-contained binary ( +dotnet nuke BuildContainerImage +``` + +Run a single test class or filter by name using standard `dotnet test` filters: + +```sh +dotnet test src/Domain.Tests --filter "FullyQualifiedName~MyTest" +``` + +## Architecture + +### Source layout (`src/`) + +The solution is split into focused projects. The main ones: + +| Project | Role | +|---|------------------------------------------------------------------------------------------| +| `Cli` | Entry point; commands: `init`, `scan`, `agent start`; AOT-compiled | +| `Cli.Abstractions` | Shared CLI constants: exit codes, env var names, port numbers, file names | +| `Cli.Settings` | User settings file (`~/.config/drift/settings.json`) | +| `Domain` | Core value types: `Network`, `Device`, `Inventory`, `CidrBlock`, `Port`, `Scan`, `AgentId` | +| `Spec` | YAML spec parsing and validation (YamlDotNet + JsonSchema.Net) | +| `Scanning` | Network discovery: ARP, ping, port scanning with rate limiting | +| `Diff` | Compares declared spec state vs. discovered scan state to produce drift report | +| `Networking.PeerStreaming.*` | Abstract P2P streaming protocol + gRPC implementation (`peer.proto`) | +| `Networking.Cluster` | Multi-agent coordination | +| `Agent.Hosting` | Agent runtime (`AgentHost`, `Identity`) | +| `Agent.PeerProtocol` | Agent-specific peer messaging | +| `Serialization` | Cross-module serialization helpers | +| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | + +Schema generators live in `Spec.SchemaGenerator.Cli` and `Cli.Settings.SchemaGenerator.Cli` — they produce JSON Schema from C# types. + +### Data flow + +``` +YAML spec → Spec (parse/validate) → Domain types (declared state) + ↓ +Network → Scanning → Domain types (discovered state) + ↓ + Diff → Drift report → Cli (render) +``` + +Agents (remote Drift instances) report discovered state back to the coordinator over gRPC, extending scan coverage across subnets. + +### Key conventions + +- **Central package management**: all NuGet versions in `Directory.Packages.props`; do not add `Version=` attributes to `` in individual project files. +- **Shared project defaults**: `Directory.Build.props` applies nullable refs, implicit usings, and logging config to all projects. +- **InternalsVisibleTo**: test projects access internal members for white-box testing; this is intentional. +- **Snapshot testing**: `Verify.NUnit` is used for golden file comparisons. Run tests to regenerate snapshots when output changes; committed `.verified.*` files are the source of truth. +- **AOT**: `Cli` is published with `PublishAot=true`. Avoid reflection-heavy patterns in the CLI project; use source generators instead. +- **Embedded resources**: schemas, default specs, and scripts are embedded in project assemblies under `embedded_resources/`. + +## Testing + +- **Unit tests**: `*.Tests` projects using NUnit 4 and NSubstitute for mocking. +- **E2E tests**: `Cli.E2ETests.*` projects — General (Testcontainers), Binary (published binary), Container (Docker image), Containerlab (real multi-node topologies). +- **Architecture tests**: `ArchTests` project validates project dependency graph and naming rules. + +Containerlab tests require Containerlab installed and use topology files in `containerlab/`. + +## Terminology (from domain model) + +- **Spec**: declarative YAML definition of desired network state +- **Declared resource**: a device/subnet defined in the spec +- **Discovered resource**: a device/subnet found by scanning +- **Drift**: difference between declared and discovered state +- **Device ID**: one or more addresses (MAC, IPv4, IPv6, hostname) that uniquely identify a device; spec addresses with `is_id: false` are metadata only +- **Agent**: a Drift instance in agent mode that reports scan results to peers From fd92f01f5fe930a1095030b7ab9a26877fcf1e7e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:06:49 +0200 Subject: [PATCH 12/31] add claude.md symlink --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 21ecb3e85cbc5036aea9d8905b39576e09a24ece Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:02:17 +0200 Subject: [PATCH 13/31] major refactor --- AGENTS.md | 1 + Drift.sln | 54 +++++++++--- .../Agent.Host.Tests.csproj} | 5 +- .../AssemblyInfo.cs | 0 .../MessageHandlerTests.cs} | 26 +++--- src/Agent.Host/Agent.Host.csproj | 16 ++++ .../AgentHost.cs | 26 +++--- .../AgentHostAssemblyMarker.cs} | 5 +- .../Scan/ScanSubnetRequestHandler.cs | 25 +++--- src/Agent.Host/ServiceCollectionExtensions.cs | 15 ++++ .../Subnets/SubnetsRequestHandler.cs | 13 +-- src/Agent.Hosting/Agent.Hosting.csproj | 14 ---- .../Identity/AgentIdentity.Serialization.cs | 77 ----------------- src/Agent.Hosting/Identity/AgentIdentity.cs | 10 --- .../Identity/AgentIdentityJsonContext.cs | 17 ---- .../DefaultAgentIdentityLocationProvider.cs | 24 ------ .../IAgentIdentityLocationProvider.cs | 9 -- .../Adopt/AdoptRequestHandler.cs | 23 ----- .../Adopt/AdoptRequestPayload.cs | 22 ----- .../Adopt/AdoptResponsePayload.cs | 18 ---- .../Agent.PeerProtocol.csproj | 10 --- .../ServiceCollectionExtensions.cs | 15 ---- ...entCommandTests.MissingOption.verified.txt | 1 - ...ommandTests.SuccessfulStartup.verified.txt | 4 +- src/Cli.Tests/Commands/AgentCommandTests.cs | 14 +--- .../ScanCommandTests.RemoteScan.verified.txt | 3 +- ..._AgentsOnly_NoLocalInterfaces.verified.txt | 3 +- ...Tests.RemoteScan_EmptyResults.verified.txt | 3 +- ...RemoteScan_OverlappingSubnets.verified.txt | 3 +- ...DuplicateDevices_Deduplicates.verified.txt | 3 +- ...hAgents_EmptyResults_Succeeds.verified.txt | 3 +- ...rewallRules_FiltersVisibility.verified.txt | 3 +- ...rfaces_UsesDistributedScanner.verified.txt | 3 +- ...rlappingSubnets_MergesDevices.verified.txt | 3 +- ...ection_UsesDistributedScanner.verified.txt | 3 +- ...sjointSubnets_CombinesResults.verified.txt | 3 +- .../Utils/Agent/AgentConfiguration.cs | 2 +- src/Cli/Cli.csproj | 6 +- .../Subcommands/Start/AgentStartCommand.cs | 55 +----------- .../Subcommands/Start/AgentStartParameters.cs | 31 +------ .../Commands/Scan/AgentClientExtensions.cs | 49 +++++++++++ src/Cli/Commands/Scan/AgentSubnetProvider.cs | 6 +- src/Cli/Commands/Scan/ClusterExtensions.cs | 49 ----------- .../Scan/DistributedNetworkScanner.cs | 15 ++-- src/Cli/Commands/Scan/ScanCommand.cs | 12 +-- src/Cli/Infrastructure/RootCommandFactory.cs | 23 ++--- .../Logging/OutputManagerExtensions.cs | 3 +- src/Coordinator.Host/Coordinator.Host.csproj | 8 ++ .../ServiceCollectionExtensions.cs | 8 ++ .../AgentClient.cs} | 76 +++++------------ .../AgentClientOptions.cs} | 4 +- .../IAgentClient.cs} | 16 ++-- src/Messaging.Client/Messaging.Client.csproj | 7 ++ .../ServiceCollectionExtensions.cs | 9 ++ .../Messaging.Protocol.csproj | 9 ++ .../ProtocolMessagesAssemblyMarker.cs | 6 ++ .../Scan/ScanSubnetCompleteResponse.cs | 6 +- .../Scan/ScanSubnetProgressUpdate.cs | 6 +- .../Scan/ScanSubnetRequest.cs | 6 +- .../Subnets/SubnetsRequest.cs | 6 +- .../Subnets/SubnetsResponse.cs | 6 +- .../DefaultMessagingClientFactory.cs | 13 +++ .../Networking.Client.csproj} | 4 +- .../ServiceCollectionExtensions.cs | 10 +++ src/Networking.Cluster/Enrollment.cs | 12 --- .../Networking.Cluster.csproj | 7 -- .../ServiceCollectionExtensions.cs | 9 -- .../ConnectionSide.cs | 6 ++ .../Empty.cs | 4 +- .../IMessage.cs} | 8 +- .../IMessageEnvelopeConverter.cs | 9 ++ .../IMessageHandler.cs} | 10 +-- .../IMessageStream.cs | 20 +++++ .../IMessageStreamManager.cs | 15 ++++ .../IMessageTypesProvider.cs | 7 ++ .../IMessagingClientFactory.cs | 8 ++ .../MessageHandlerExtensions.cs} | 28 ++++--- .../Networking.Core.Abstractions.csproj} | 2 +- .../Common/GrpcMetadataExtensions.cs | 2 +- .../MessageStream.cs} | 44 +++++----- src/Networking.Core/MessageStreamManager.cs | 83 +++++++++++++++++++ .../Messages/MessageDispatcher.cs} | 24 +++--- .../Messages/MessageEnvelopeConverter.cs | 22 +++++ .../Messages/MessageResponseCorrelator.cs} | 29 +++---- .../MessagingOptions.cs} | 4 +- src/Networking.Core/Networking.Core.csproj | 14 ++++ .../ServiceCollectionExtensions.cs | 18 ++++ .../Networking.Grpc.csproj} | 2 +- .../Protos/agent.proto} | 11 ++- .../DefaultPeerClientFactory.cs | 13 --- .../ServiceCollectionExtensions.cs | 10 --- .../ConnectionSide.cs | 6 -- .../IPeerClientFactory.cs | 8 -- .../IPeerMessageEnvelopeConverter.cs | 9 -- .../IPeerMessageTypesProvider.cs | 7 -- .../IPeerStream.cs | 20 ----- .../IPeerStreamManager.cs | 15 ---- .../Messages/PeerMessageEnvelopeConverter.cs | 22 ----- .../Networking.PeerStreaming.Core.csproj | 14 ---- .../PeerStreamManager.cs | 79 ------------------ .../ServiceCollectionExtensions.cs | 18 ---- .../InboundPeerService.cs | 31 ------- .../Networking.PeerStreaming.Server.csproj | 15 ---- .../PeerStreamingServerMarker.cs | 8 -- .../InboundTests.cs | 77 ----------------- .../InboundMessageService.cs | 31 +++++++ .../MessagingServerMarker.cs | 8 ++ .../Networking.Server.csproj | 15 ++++ .../ServiceCollectionExtensions.cs | 26 +++--- .../AssemblyInfo.cs | 0 .../Helpers/InMemoryDuplexStreamPair.cs | 2 +- .../Helpers/TestPeerMessage.cs | 14 ++-- .../Helpers/TestServerCallContext.cs | 2 +- .../TestServerCallContextExtensions.cs | 8 +- src/Networking.Tests/InboundTests.cs | 77 +++++++++++++++++ .../MessageStreamManagerTests.cs} | 24 +++--- .../Networking.Tests.csproj} | 6 +- 117 files changed, 817 insertions(+), 1074 deletions(-) rename src/{Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj => Agent.Host.Tests/Agent.Host.Tests.csproj} (71%) rename src/{Agent.PeerProtocol.Tests => Agent.Host.Tests}/AssemblyInfo.cs (100%) rename src/{Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs => Agent.Host.Tests/MessageHandlerTests.cs} (68%) create mode 100644 src/Agent.Host/Agent.Host.csproj rename src/{Agent.Hosting => Agent.Host}/AgentHost.cs (65%) rename src/{Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs => Agent.Host/AgentHostAssemblyMarker.cs} (53%) rename src/{Agent.PeerProtocol => Agent.Host}/Scan/ScanSubnetRequestHandler.cs (87%) create mode 100644 src/Agent.Host/ServiceCollectionExtensions.cs rename src/{Agent.PeerProtocol => Agent.Host}/Subnets/SubnetsRequestHandler.cs (76%) delete mode 100644 src/Agent.Hosting/Agent.Hosting.csproj delete mode 100644 src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs delete mode 100644 src/Agent.Hosting/Identity/AgentIdentity.cs delete mode 100644 src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs delete mode 100644 src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs delete mode 100644 src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs delete mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs delete mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs delete mode 100644 src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs delete mode 100644 src/Agent.PeerProtocol/Agent.PeerProtocol.csproj delete mode 100644 src/Agent.PeerProtocol/ServiceCollectionExtensions.cs delete mode 100644 src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt create mode 100644 src/Cli/Commands/Scan/AgentClientExtensions.cs delete mode 100644 src/Cli/Commands/Scan/ClusterExtensions.cs create mode 100644 src/Coordinator.Host/Coordinator.Host.csproj create mode 100644 src/Coordinator.Host/ServiceCollectionExtensions.cs rename src/{Networking.Cluster/Cluster.cs => Messaging.Client/AgentClient.cs} (64%) rename src/{Networking.Cluster/ClusterOptions.cs => Messaging.Client/AgentClientOptions.cs} (92%) rename src/{Networking.Cluster/ICluster.cs => Messaging.Client/IAgentClient.cs} (58%) create mode 100644 src/Messaging.Client/Messaging.Client.csproj create mode 100644 src/Messaging.Client/ServiceCollectionExtensions.cs create mode 100644 src/Messaging.Protocol/Messaging.Protocol.csproj create mode 100644 src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs rename src/{Agent.PeerProtocol => Messaging.Protocol}/Scan/ScanSubnetCompleteResponse.cs (82%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Scan/ScanSubnetProgressUpdate.cs (81%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Scan/ScanSubnetRequest.cs (80%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Subnets/SubnetsRequest.cs (68%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Subnets/SubnetsResponse.cs (81%) create mode 100644 src/Networking.Client/DefaultMessagingClientFactory.cs rename src/{Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj => Networking.Client/Networking.Client.csproj} (59%) create mode 100644 src/Networking.Client/ServiceCollectionExtensions.cs delete mode 100644 src/Networking.Cluster/Enrollment.cs delete mode 100644 src/Networking.Cluster/Networking.Cluster.csproj delete mode 100644 src/Networking.Cluster/ServiceCollectionExtensions.cs create mode 100644 src/Networking.Core.Abstractions/ConnectionSide.cs rename src/{Networking.PeerStreaming.Core.Abstractions => Networking.Core.Abstractions}/Empty.cs (73%) rename src/{Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs => Networking.Core.Abstractions/IMessage.cs} (61%) create mode 100644 src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs rename src/{Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs => Networking.Core.Abstractions/IMessageHandler.cs} (69%) create mode 100644 src/Networking.Core.Abstractions/IMessageStream.cs create mode 100644 src/Networking.Core.Abstractions/IMessageStreamManager.cs create mode 100644 src/Networking.Core.Abstractions/IMessageTypesProvider.cs create mode 100644 src/Networking.Core.Abstractions/IMessagingClientFactory.cs rename src/{Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs => Networking.Core.Abstractions/MessageHandlerExtensions.cs} (62%) rename src/{Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj => Networking.Core.Abstractions/Networking.Core.Abstractions.csproj} (67%) rename src/{Networking.PeerStreaming.Core => Networking.Core}/Common/GrpcMetadataExtensions.cs (85%) rename src/{Networking.PeerStreaming.Core/PeerStream.cs => Networking.Core/MessageStream.cs} (71%) create mode 100644 src/Networking.Core/MessageStreamManager.cs rename src/{Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs => Networking.Core/Messages/MessageDispatcher.cs} (61%) create mode 100644 src/Networking.Core/Messages/MessageEnvelopeConverter.cs rename src/{Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs => Networking.Core/Messages/MessageResponseCorrelator.cs} (72%) rename src/{Networking.PeerStreaming.Core/PeerStreamingOptions.cs => Networking.Core/MessagingOptions.cs} (67%) create mode 100644 src/Networking.Core/Networking.Core.csproj create mode 100644 src/Networking.Core/ServiceCollectionExtensions.cs rename src/{Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj => Networking.Grpc/Networking.Grpc.csproj} (80%) rename src/{Networking.PeerStreaming.Grpc/Protos/peer.proto => Networking.Grpc/Protos/agent.proto} (61%) delete mode 100644 src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs delete mode 100644 src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs delete mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs delete mode 100644 src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj delete mode 100644 src/Networking.PeerStreaming.Core/PeerStreamManager.cs delete mode 100644 src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs delete mode 100644 src/Networking.PeerStreaming.Server/InboundPeerService.cs delete mode 100644 src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj delete mode 100644 src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs delete mode 100644 src/Networking.PeerStreaming.Tests/InboundTests.cs create mode 100644 src/Networking.Server/InboundMessageService.cs create mode 100644 src/Networking.Server/MessagingServerMarker.cs create mode 100644 src/Networking.Server/Networking.Server.csproj rename src/{Networking.PeerStreaming.Server => Networking.Server}/ServiceCollectionExtensions.cs (55%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/AssemblyInfo.cs (100%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/InMemoryDuplexStreamPair.cs (98%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/TestPeerMessage.cs (69%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/TestServerCallContext.cs (97%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/TestServerCallContextExtensions.cs (65%) create mode 100644 src/Networking.Tests/InboundTests.cs rename src/{Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs => Networking.Tests/MessageStreamManagerTests.cs} (55%) rename src/{Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj => Networking.Tests/Networking.Tests.csproj} (62%) diff --git a/AGENTS.md b/AGENTS.md index ff47e365..f22ef0c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,7 @@ Agents (remote Drift instances) report discovered state back to the coordinator - **Snapshot testing**: `Verify.NUnit` is used for golden file comparisons. Run tests to regenerate snapshots when output changes; committed `.verified.*` files are the source of truth. - **AOT**: `Cli` is published with `PublishAot=true`. Avoid reflection-heavy patterns in the CLI project; use source generators instead. - **Embedded resources**: schemas, default specs, and scripts are embedded in project assemblies under `embedded_resources/`. +- **`Networking.*` are role-agnostic**: No "Agent", "Peer", "Coordinator", or "Server" in type names, property names, parameter names, method names, or log strings inside `Networking.*`. These assemblies implement the transport layer only (streams, messages, connections). Role-specific concerns belong in `Agent.*`, `Coordinator.*`, or `Cli.*`. ## Testing diff --git a/Drift.sln b/Drift.sln index a42a0e23..deeebebc 100644 --- a/Drift.sln +++ b/Drift.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli", "src\Cli\Cli.csproj", "{51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5}" EndProject @@ -81,27 +81,37 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Commo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "E2E", "E2E", "{D1DBBF0F-1A0D-486C-A893-ACEB667F2A63}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Grpc", "src\Networking.PeerStreaming.Grpc\Networking.PeerStreaming.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Grpc", "src\Networking.Grpc\Networking.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Cluster", "src\Networking.Cluster\Networking.Cluster.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core", "src\Networking.Core\Networking.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core", "src\Networking.PeerStreaming.Core\Networking.PeerStreaming.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Server", "src\Networking.Server\Networking.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Hosting", "src\Agent.Hosting\Agent.Hosting.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Client", "src\Networking.Client\Networking.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Server", "src\Networking.PeerStreaming.Server\Networking.PeerStreaming.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Client", "src\Networking.PeerStreaming.Client\Networking.PeerStreaming.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core.Abstractions", "src\Networking.Core.Abstractions\Networking.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Tests", "src\Networking.Tests\Networking.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Protocol", "src\Messaging.Protocol\Messaging.Protocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Client", "src\Messaging.Client\Messaging.Client.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host", "src\Agent.Host\Agent.Host.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host", "src\Coordinator.Host\Coordinator.Host.csproj", "{B9C0D1E2-F3A4-5678-BCDE-F01234567891}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host.Tests", "src\Agent.Host.Tests\Agent.Host.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core.Abstractions", "src\Networking.PeerStreaming.Core.Abstractions\Networking.PeerStreaming.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{F1A2B3C4-D5E6-7890-ABCD-EF1234567890}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Tests", "src\Networking.PeerStreaming.Tests\Networking.PeerStreaming.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Messaging", "Messaging", "{E2F3A4B5-C6D7-8901-BCDE-F12345678901}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol", "src\Agent.PeerProtocol\Agent.PeerProtocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coordinator", "Coordinator", "{0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol.Tests", "src\Agent.PeerProtocol.Tests\Agent.PeerProtocol.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cli", "Cli", "{DD31E4C8-7058-4407-AEC4-43EDA0AB06D1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -255,18 +265,36 @@ Global {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.Build.0 = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {FEA2FBBE-785F-4187-8242-FD348F9E78AF} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {272166CF-E425-45F8-984F-FAFD3CE953C9} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {DD70FBC7-8367-45B3-8D3D-757F1CDF6531} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} - {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} {8ED3FF22-90D2-4F08-A079-55FE7127D1C7} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} {80445644-7342-4C6D-88E5-BF27126FE9A2} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} {A26B4527-6EBF-4A20-8E75-945CCD59016B} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} {E69772D3-8A07-414F-8F9A-30370D81A972} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} {9DFDD692-22F8-4F9A-8808-94E318863D23} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {655124DB-312F-4135-B104-20518CAFDA82} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {C4576156-BD24-463F-88F2-8A4378855BCC} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {A2CE629F-8D56-4539-9642-C31B550F7C30} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {C2B3D4E5-F6A7-8901-BCDE-F12345678901} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {D3C4E5F6-A7B8-9012-CDEF-123456789012} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B9C0D1E2-F3A4-5678-BCDE-F01234567891} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {42ECC801-ADCB-43E4-86C0-E0E4B017D7B6} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {38621D2E-6AF0-4969-897D-898C39D41A20} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {B75C5C0C-A5A2-419E-B0DD-342A44B1B48D} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {130363FC-478E-472A-978B-731D39E3E264} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} EndGlobalSection EndGlobal diff --git a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj b/src/Agent.Host.Tests/Agent.Host.Tests.csproj similarity index 71% rename from src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj rename to src/Agent.Host.Tests/Agent.Host.Tests.csproj index e00ea9c0..faa044fe 100644 --- a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj +++ b/src/Agent.Host.Tests/Agent.Host.Tests.csproj @@ -1,11 +1,12 @@ - + - + + diff --git a/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs b/src/Agent.Host.Tests/AssemblyInfo.cs similarity index 100% rename from src/Agent.PeerProtocol.Tests/AssemblyInfo.cs rename to src/Agent.Host.Tests/AssemblyInfo.cs diff --git a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs similarity index 68% rename from src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs rename to src/Agent.Host.Tests/MessageHandlerTests.cs index eac8dbc8..82ff605e 100644 --- a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -1,16 +1,18 @@ using System.Reflection; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Messaging.Protocol; +using Drift.Networking.Core.Abstractions; -namespace Drift.Agent.PeerProtocol.Tests; +namespace Drift.Agent.Host.Tests; -internal sealed class PeerMessageHandlerTests { - private static readonly Assembly ProtocolAssembly = typeof(PeerProtocolAssemblyMarker).Assembly; - private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IPeerRequest<>) ); - private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IPeerResponse) ); +internal sealed class MessageHandlerTests { + private static readonly Assembly MessagesAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly; + private static readonly Assembly HandlersAssembly = typeof(AgentHostAssemblyMarker).Assembly; + private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IRequest<>) ); + private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IResponse) ); private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); [Test] - public void FindMessagesAndHandlersAndMessages() { + public void FindMessagesAndHandlers() { using ( Assert.EnterMultipleScope() ) { Assert.That( RequestTypes.ToList(), Has.Count.GreaterThan( 1 ), "No request messages found via reflection" ); Assert.That( ResponseTypes.ToList(), Has.Count.GreaterThan( 1 ), "No response messages found via reflection" ); @@ -23,20 +25,20 @@ public void FindMessagesAndHandlersAndMessages() { [TestCaseSource( nameof(ResponseTypes) )] public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { var messageTypeValue = type - .GetProperty( nameof(IPeerMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! + .GetProperty( nameof(IMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! .GetValue( null ) as string; Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); var jsonInfoValue = type - .GetProperty( nameof(IPeerMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! + .GetProperty( nameof(IMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! .GetValue( null ); Assert.That( jsonInfoValue, Is.Not.Null ); } private static List GetAllConcreteMessageTypes( Type baseType ) { - return ProtocolAssembly + return MessagesAssembly .GetTypes() .Concat( [typeof(Empty)] ) .Where( t => t is { IsAbstract: false, IsInterface: false } ) @@ -50,10 +52,10 @@ private static List GetAllConcreteMessageTypes( Type baseType ) { } private static List GetAllConcreteHandlerTypes() { - return ProtocolAssembly + return HandlersAssembly .GetTypes() .Where( t => t is { IsAbstract: false, IsInterface: false } ) - .Where( t => typeof(IPeerMessageHandler).IsAssignableFrom( t ) ) + .Where( t => typeof(IMessageHandler).IsAssignableFrom( t ) ) .ToList(); } } \ No newline at end of file diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj new file mode 100644 index 00000000..4f67bdb9 --- /dev/null +++ b/src/Agent.Host/Agent.Host.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/Agent.Hosting/AgentHost.cs b/src/Agent.Host/AgentHost.cs similarity index 65% rename from src/Agent.Hosting/AgentHost.cs rename to src/Agent.Host/AgentHost.cs index fe8e4960..e86bb9ee 100644 --- a/src/Agent.Hosting/AgentHost.cs +++ b/src/Agent.Host/AgentHost.cs @@ -1,7 +1,7 @@ -using Drift.Agent.PeerProtocol.Subnets; -using Drift.Networking.PeerStreaming.Client; -using Drift.Networking.PeerStreaming.Core; -using Drift.Networking.PeerStreaming.Server; +using Drift.Messaging.Protocol; +using Drift.Networking.Client; +using Drift.Networking.Core; +using Drift.Networking.Server; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -9,7 +9,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Drift.Agent.Hosting; +namespace Drift.Agent.Host; public static class AgentHost { public static Task Run( @@ -33,12 +33,14 @@ private static WebApplication Build( builder.Logging.ClearProviders(); builder.Services.AddSingleton( logger ); - builder.Services.AddPeerStreamingServer( options => { + // TODO consolidate all the addmessaging* into single configurable extension that can be used for all roles + // (CLI, Agent, Coordinator) with different config flags. Should be high-level (domain preferred) + builder.Services.AddMessagingServer( options => { options.EnableDetailedErrors = true; } ); - builder.Services.AddPeerStreamingClient(); - var peerStreamingOptions = new PeerStreamingOptions { MessageAssembly = typeof(SubnetsRequest).Assembly }; - builder.Services.AddPeerStreamingCore( peerStreamingOptions ); + builder.Services.AddMessagingClient(); + var messagingOptions = new MessagingOptions { MessageAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly }; + builder.Services.AddMessagingCore( messagingOptions ); configureServices?.Invoke( builder.Services ); builder.WebHost.ConfigureKestrel( options => { @@ -49,9 +51,11 @@ private static WebApplication Build( var app = builder.Build(); - peerStreamingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + // Note: a service reading StoppingToken during initialization (really, any code run before this point) + // will get CancellationToken.None. + messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; - app.MapPeerStreamingServerEndpoints(); + app.MapMessagingServerEndpoints(); app.Lifetime.ApplicationStarted.Register( () => { logger.LogInformation( "Listening for incoming connections on port {Port}", port ); diff --git a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs b/src/Agent.Host/AgentHostAssemblyMarker.cs similarity index 53% rename from src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs rename to src/Agent.Host/AgentHostAssemblyMarker.cs index 1907e215..e38447cb 100644 --- a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs +++ b/src/Agent.Host/AgentHostAssemblyMarker.cs @@ -1,7 +1,6 @@ -namespace Drift.Agent.PeerProtocol; +namespace Drift.Agent.Host; // Justification: marker class #pragma warning disable S2094 -public class PeerProtocolAssemblyMarker { -} +public sealed class AgentHostAssemblyMarker; #pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs similarity index 87% rename from src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs rename to src/Agent.Host/Scan/ScanSubnetRequestHandler.cs index 27c2db90..15a2b96e 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -1,22 +1,23 @@ using Drift.Domain; using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Scan; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; using Drift.Scanning.Scanners; using Microsoft.Extensions.Logging; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Agent.Host.Scan; internal sealed class ScanSubnetRequestHandler( ISubnetScannerFactory subnetScannerFactory, ILogger logger -) : IPeerMessageHandler { +) : IMessageHandler { public string MessageType => ScanSubnetRequest.MessageType; public async Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ) { logger.LogInformation( "Handling scan subnet request" ); @@ -49,9 +50,9 @@ CancellationToken cancellationToken } private sealed class ProgressUpdatePolicy { - private readonly IPeerStream _stream; - private readonly IPeerMessageEnvelopeConverter _converter; - private readonly PeerMessage _envelope; + private readonly IMessageStream _stream; + private readonly IMessageEnvelopeConverter _converter; + private readonly Message _envelope; private readonly CidrBlock _cidr; private readonly ILogger _logger; @@ -64,9 +65,9 @@ public EventHandler Handle { } public ProgressUpdatePolicy( - IPeerStream stream, - IPeerMessageEnvelopeConverter converter, - PeerMessage envelope, + IMessageStream stream, + IMessageEnvelopeConverter converter, + Message envelope, CidrBlock cidr, ILogger logger ) { diff --git a/src/Agent.Host/ServiceCollectionExtensions.cs b/src/Agent.Host/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f43d65e8 --- /dev/null +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.Subnets; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.Host; + +public static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + public void AddAgentHandlers() { + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs similarity index 76% rename from src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs rename to src/Agent.Host/Subnets/SubnetsRequestHandler.cs index a8d3eee5..f5721f9d 100644 --- a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs +++ b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs @@ -1,20 +1,21 @@ +using Drift.Messaging.Protocol.Subnets; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; using Drift.Scanning.Subnets.Interface; using Microsoft.Extensions.Logging; -namespace Drift.Agent.PeerProtocol.Subnets; +namespace Drift.Agent.Host.Subnets; internal sealed class SubnetsRequestHandler( IInterfaceSubnetProvider interfaceSubnetProvider, ILogger logger -) : IPeerMessageHandler { +) : IMessageHandler { public string MessageType => SubnetsRequest.MessageType; public async Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ) { logger.LogInformation( "Handling subnet request" ); diff --git a/src/Agent.Hosting/Agent.Hosting.csproj b/src/Agent.Hosting/Agent.Hosting.csproj deleted file mode 100644 index 93719ce4..00000000 --- a/src/Agent.Hosting/Agent.Hosting.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs deleted file mode 100644 index a462b561..00000000 --- a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Text.Json; -using Drift.Domain; -using Microsoft.Extensions.Logging; - -namespace Drift.Agent.Hosting.Identity; - -public partial class AgentIdentity { - private IAgentIdentityLocationProvider? _loadLocation; - - public static AgentIdentity Load( ILogger? logger = null, IAgentIdentityLocationProvider? location = null ) { - try { - location ??= new DefaultAgentIdentityLocationProvider(); - - logger?.LogTrace( "Loading agent identity from {Path}", location.GetFile() ); - - if ( !File.Exists( location.GetFile() ) ) { - logger?.LogDebug( "Agent identity file not found. Generating new identity." ); - var newIdentity = CreateNew(); - newIdentity._loadLocation = location; - return newIdentity; - } - - var json = File.ReadAllText( location.GetFile() ); - var identity = JsonSerializer.Deserialize( json, AgentIdentityJsonContext.Default.AgentIdentity ); - - logger?.LogTrace( "Loaded agent identity: {AgentId}", identity?.Id ); - - if ( identity == null ) { - logger?.LogWarning( "Deserialized identity is null. Generating new identity." ); - var newIdentity = CreateNew(); - newIdentity._loadLocation = location; - return newIdentity; - } - - identity._loadLocation = location; - - return identity; - } - catch ( Exception e ) { - logger?.LogError( e, "Error loading agent identity" ); - var newIdentity = CreateNew(); - newIdentity._loadLocation = location; - return newIdentity; - } - } - - public void Save( ILogger logger, IAgentIdentityLocationProvider? location = null ) { - location ??= new DefaultAgentIdentityLocationProvider(); - - logger.LogTrace( "Saving agent identity to {Path}", location.GetFile() ); - - if ( !Directory.Exists( location.GetDirectory() ) ) { - Directory.CreateDirectory( location.GetDirectory() ); - } - - if ( !File.Exists( location.GetFile() ) ) { - logger.LogInformation( "Creating new agent identity file at {Path}", location.GetFile() ); - } - else if ( _loadLocation == null || - !_loadLocation.GetFile().Equals( location.GetFile(), StringComparison.Ordinal ) // Casing matters on Linux - ) { - throw new InvalidOperationException( "Prevented overwriting an existing file, which had not first been loaded." ); - } - - var json = JsonSerializer.Serialize( this, AgentIdentityJsonContext.Default.AgentIdentity ); - File.WriteAllText( location.GetFile(), json ); - - // logger.LogDebug( "Agent identity saved: {AgentId}", Id ); - } - - private static AgentIdentity CreateNew() { - return new AgentIdentity { - Id = AgentId.New(), - CreatedAt = DateTime.UtcNow - }; - } -} diff --git a/src/Agent.Hosting/Identity/AgentIdentity.cs b/src/Agent.Hosting/Identity/AgentIdentity.cs deleted file mode 100644 index 30f82cff..00000000 --- a/src/Agent.Hosting/Identity/AgentIdentity.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Drift.Domain; - -namespace Drift.Agent.Hosting.Identity; - -public partial class AgentIdentity { - public required AgentId Id { get; init; } - public required DateTime CreatedAt { get; init; } - public string? ClusterId { get; init; } - public DateTime? EnrolledAt { get; init; } -} diff --git a/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs deleted file mode 100644 index 2d1be19f..00000000 --- a/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Drift.Agent.Hosting.Identity; - -[JsonSourceGenerationOptions( - ReadCommentHandling = JsonCommentHandling.Skip, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - RespectRequiredConstructorParameters = true, - RespectNullableAnnotations = true -)] -[JsonSerializable( typeof(AgentIdentity) )] -internal sealed partial class AgentIdentityJsonContext : JsonSerializerContext { -} diff --git a/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs deleted file mode 100644 index 9a05bab7..00000000 --- a/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Drift.Agent.Hosting.Identity; - -public sealed class DefaultAgentIdentityLocationProvider : IAgentIdentityLocationProvider { - public string GetDirectory() { - if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { - return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "Drift", "agent" ); - } - - if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { - // https://specifications.freedesktop.org/basedir-spec/latest/ - var xdgConfigHome = Environment.GetEnvironmentVariable( "XDG_CONFIG_HOME" ); - - var baseDir = string.IsNullOrEmpty( xdgConfigHome ) - ? Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), ".config" ) - : xdgConfigHome; - - return Path.Combine( baseDir, "drift", "agent" ); - } - - throw new PlatformNotSupportedException(); - } -} diff --git a/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs deleted file mode 100644 index 356686f7..00000000 --- a/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Drift.Agent.Hosting.Identity; - -public interface IAgentIdentityLocationProvider { - string GetDirectory(); - - string GetFile() { - return Path.Combine( GetDirectory(), "agent-identity.json" ); - } -} diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs deleted file mode 100644 index 5b9778c4..00000000 --- a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; - -namespace Drift.Agent.PeerProtocol.Adopt; - -internal sealed class AdoptRequestHandler : IPeerMessageHandler { - // private readonly ILogger _logger; // Example: inject what you need - - public string MessageType => AdoptRequestPayload.MessageType; - - public Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, - CancellationToken cancellationToken - ) { - // var message = converter.FromEnvelope( envelope ); - // _logger.LogInformation( "[AdoptRequest] Controller: {ControllerId}", message.ControllerId ); - - // This handler doesn't send a response (Empty response pattern) - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs deleted file mode 100644 index 2ad48099..00000000 --- a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization.Metadata; -using Drift.Networking.PeerStreaming.Core.Abstractions; - -namespace Drift.Agent.PeerProtocol.Adopt; - -internal sealed class AdoptRequestPayload : IPeerRequest { - public static string MessageType => "adopt-request"; - - public required string Jwt { - get; - set; - } - - public required string ControllerId { - get; - set; - } - - public static JsonTypeInfo JsonInfo { - get; - } = null!; -} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs deleted file mode 100644 index 67fa75dc..00000000 --- a/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Drift.Agent.PeerProtocol.Adopt; - -internal sealed class AdoptResponsePayload { - public required string Status { - get; - set; - } // "accepted" or "rejected" - - public required string AgentId { - get; - set; - } // Only with "accepted" - - public required string Reason { - get; - set; - } // Only with "rejected" -} diff --git a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj deleted file mode 100644 index 09269a20..00000000 --- a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs deleted file mode 100644 index 693ba852..00000000 --- a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Drift.Agent.PeerProtocol.Scan; -using Drift.Agent.PeerProtocol.Subnets; -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace Drift.Agent.PeerProtocol; - -public static class ServiceCollectionExtensions { - extension( IServiceCollection services ) { - public void AddPeerProtocol() { - services.AddScoped(); - services.AddScoped(); - } - } -} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt deleted file mode 100644 index 9b0bccae..00000000 --- a/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt +++ /dev/null @@ -1 +0,0 @@ -✗ Either --adoptable or --join must be specified. diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt index 27ac3915..3e13a42b 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt @@ -1,9 +1,9 @@ +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Agent starting... -Agent cluster enrollment method is Adoption Listening for incoming connections on port 51515 Agent started Agent stopping... diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs index 18b2a018..7ebc9c3f 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.cs +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -10,7 +10,7 @@ public async Task RespectsCancellationToken() { using var tcs = new CancellationTokenSource( TimeSpan.FromMilliseconds( 2000 ) ); var (exitCode, output, _) = await DriftTestCli.InvokeAsync( - "agent start --adoptable", + "agent start", cancellationToken: tcs.Token ); @@ -24,7 +24,7 @@ public async Task SuccessfulStartup() { using var tcs = new CancellationTokenSource(); var runningCommand = await DriftTestCli.StartAgentAsync( - "--adoptable", + string.Empty, cancellationToken: tcs.Token ); @@ -38,14 +38,4 @@ public async Task SuccessfulStartup() { Assert.That( error.ToString(), Is.Empty ); } } - - [Test] - public async Task MissingOption() { - var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "agent start" ); - - using ( Assert.EnterMultipleScope() ) { - Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); - await Verify( output.ToString() + error ); - } - } } \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt index b86a1ad8..d3a069c5 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt @@ -6,10 +6,11 @@ Scanning 3 subnets 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Distributed scan completed: 3/3 scan operations successful, 3 unique subnets 192.168.0.0/24 (1 devices) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt index 2765abc7..eacd2479 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_local2: 192.168.20.0/24 Scanning 2 subnets 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Distributed scan completed: 2/2 scan operations successful, 2 unique subnets 192.168.10.0/24 (1 devices) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt index 21d98e9b..c7a5f9e1 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt @@ -3,10 +3,11 @@ Received subnet(s) from agent agentid_local1: 192.168.10.0/24 Scanning 2 subnets 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Distributed scan completed: 2/2 scan operations successful, 2 unique subnets 192.168.0.0/24 (0 devices) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt index aa5468ac..5f21fbdf 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_local2: 192.168.10.0/24 Scanning 2 subnets 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1, agentid_local2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Merged 4 unique devices from 2 scans of 192.168.10.0/24 Distributed scan completed: 3/3 scan operations successful, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt index 3765181a..b91f4e66 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt @@ -4,10 +4,11 @@ Requesting subnets from agent agentid_agent2 Received subnet(s) from agent agentid_agent2: 192.168.10.0/24 Scanning 1 subnet 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Merged 3 unique devices from 2 scans of 192.168.10.0/24 Scan completed: 0 local, 2 via agents, 1 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt index 171df816..dacb917f 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt @@ -2,10 +2,11 @@ Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 Scanning 1 subnet 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Scan completed: 0 local, 1 via agents, 1 unique subnets 192.168.10.0/24 (0 devices) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt index 64f9f78f..ae070808 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_internal: 10.0.0.0/24 Scanning 2 subnets 192.168.1.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz 10.0.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz, agentid_internal +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Merged 2 unique devices from 2 scans of 10.0.0.0/24 Scan completed: 0 local, 3 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt index f63d42b0..c810eddd 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_agent2: 192.168.10.0/24, 192.168.20.0/24 Scanning 2 subnets 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Merged 2 unique devices from 2 scans of 192.168.10.0/24 Merged 1 unique devices from 2 scans of 192.168.20.0/24 Scan completed: 0 local, 4 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt index 4ba45cbb..a8e32469 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_agent2: 192.168.0.0/24, 192.168.10.0/24 Scanning 2 subnets 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local, agentid_agent1, agentid_agent2 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Merged 1 unique devices from 3 scans of 192.168.0.0/24 Merged 4 unique devices from 2 scans of 192.168.10.0/24 Scan completed: 1 local, 4 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt index 77f50b59..df2db381 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt @@ -3,10 +3,11 @@ Received subnet(s) from agent agentid_agent1: 192.168.0.0/24, 192.168.10.0/24 Scanning 2 subnets 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local, agentid_agent1 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Merged 1 unique devices from 2 scans of 192.168.0.0/24 Scan completed: 1 local, 2 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt index e9dfccb1..8260934e 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt @@ -6,10 +6,11 @@ Scanning 3 subnets 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- Distributed scanning via agents is a preview feature and should be used with caution. Agent communication is unencrypted. Do not use on untrusted networks. Agents run without authentication. Any client that can reach the agent port can connect. -Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +---------------------------------------------------------------------------------------- Scan completed: 1 local, 2 via agents, 3 unique subnets 192.168.0.0/24 (1 devices) diff --git a/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs index a639551e..49510bff 100644 --- a/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs +++ b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs @@ -43,5 +43,5 @@ public Dictionary> DiscoveredDevices { public string AdditionalArgs { get; init; - } = "--adoptable"; + } = string.Empty; } \ No newline at end of file diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj index 7991f9e0..42f9985d 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -21,11 +21,11 @@ - - + + - + diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs index 02e00a69..e96dd517 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs @@ -1,24 +1,19 @@ using System.CommandLine; -using Drift.Agent.Hosting; -using Drift.Agent.Hosting.Identity; -using Drift.Agent.PeerProtocol; +using Drift.Agent.Host; using Drift.Cli.Abstractions; using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Infrastructure; using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Drift.Domain; -using Drift.Networking.Cluster; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Agent.Subcommands.Start; +// TODO No spec should be provided - do not inherit from CommandBase - or CommandBase should not include it internal class AgentStartCommand : CommandBase { internal AgentStartCommand( IServiceProvider provider ) : base( "start", "Start a local Drift agent", provider ) { Options.Add( AgentStartParameters.Options.Port ); - Options.Add( AgentStartParameters.Options.Adoptable ); - Options.Add( AgentStartParameters.Options.Join ); Options.Add( AgentStartParameters.Options.Id ); } @@ -42,23 +37,6 @@ public async Task Invoke( AgentStartParameters parameters, CancellationToke logger.LogInformation( "Agent starting..." ); - _ = LoadAgentIdentity( parameters.Id ); - - // Check if agent has cluster membership info - var agentIdentity = AgentIdentity.Load( logger ); - var isEnrolled = agentIdentity.ClusterId != null; - - if ( !isEnrolled ) { - logger.LogDebug( "Agent is not enrolled" ); - - var enrollmentRequest = new EnrollmentRequest( parameters.Adoptable, parameters.Join ); - logger.LogInformation( "Agent cluster enrollment method is {EnrollmentMethod}", enrollmentRequest.Method ); - } - else { - logger.LogDebug( "Agent is enrolled into cluster '{ClusterId}'", agentIdentity.ClusterId ); - logger.LogInformation( "Attempting to re-join cluster '{ClusterId}'...", agentIdentity.ClusterId ); - } - /*Inventory? inventory; try { @@ -86,37 +64,10 @@ void ConfigureServices( IServiceCollection services ) { RootCommandFactory.ConfigureAgentCoreServices( services ); // Add peer protocol message handlers - services.AddPeerProtocol(); + services.AddAgentHandlers(); // Allow test overrides configureServicesOverride?.Invoke( services ); } } - - private AgentId LoadAgentIdentity( string? idOverride ) { - var logger = output.GetLogger(); - IAgentIdentityLocationProvider locationProvider = new DefaultAgentIdentityLocationProvider(); - var identityFilePath = locationProvider.GetFile(); - - // If an ID override is provided, use it directly without loading/saving - if ( !string.IsNullOrWhiteSpace( idOverride ) ) { - logger.LogWarning( "Agent started with --id flag. This should only be used for testing purposes." ); - logger.LogInformation( "Using provided agent ID: {AgentId}", idOverride ); - return new AgentId( idOverride ); - } - - // Load existing identity or create new one - var identity = AgentIdentity.Load( logger, locationProvider ); - - // Save immediately if it's new to persist it - if ( !File.Exists( identityFilePath ) ) { - identity.Save( logger, locationProvider ); - logger.LogInformation( "Generated and saved new agent identity: {AgentId}", identity.Id ); - } - else { - logger.LogDebug( "Loaded existing agent identity: {AgentId}", identity.Id ); - } - - return identity.Id; - } } \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs index d946d1c8..f93ae929 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -6,21 +6,12 @@ namespace Drift.Cli.Commands.Agent.Subcommands.Start; internal record AgentStartParameters : BaseParameters { internal static class Options { - internal static readonly Option Adoptable = new("--adoptable") { - DefaultValueFactory = _ => false, - Description = "Allow this agent to be adopted by another peer in the agent cluster" - }; - - // support @ for supplying local file - internal static readonly Option Join = new("--join") { Description = "Join the agent cluster using a JWT" }; - internal static readonly Option Daemon = new("--daemon", "-d") { Description = "Run the agent as a background daemon" }; internal static readonly Option Port = new("--port", "-p") { - DefaultValueFactory = _ => Ports.AgentDefault, - Description = "Set the port used for both adoption and communication" + DefaultValueFactory = _ => Ports.AgentDefault, Description = "Set the port used for communication" }; internal static readonly Option Id = new("--id") { @@ -30,27 +21,7 @@ internal static class Options { internal AgentStartParameters( ParseResult parseResult ) : base( parseResult ) { Port = parseResult.GetValue( Options.Port ); - Adoptable = parseResult.GetValue( Options.Adoptable ); - Join = parseResult.GetValue( Options.Join ); Id = parseResult.GetValue( Options.Id ); - - if ( !Adoptable && string.IsNullOrWhiteSpace( Join ) ) { - throw new ArgumentException( "Either --adoptable or --join must be specified." ); - } - - if ( Adoptable && !string.IsNullOrWhiteSpace( Join ) ) { - throw new ArgumentException( "Cannot specify both --adoptable and --join." ); - } - } - - public string? Join { - get; - set; - } - - public bool Adoptable { - get; - set; } public ushort Port { diff --git a/src/Cli/Commands/Scan/AgentClientExtensions.cs b/src/Cli/Commands/Scan/AgentClientExtensions.cs new file mode 100644 index 00000000..49d01344 --- /dev/null +++ b/src/Cli/Commands/Scan/AgentClientExtensions.cs @@ -0,0 +1,49 @@ +using Drift.Domain; +using Drift.Messaging.Client; +using Drift.Messaging.Protocol.Scan; +using Drift.Messaging.Protocol.Subnets; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Cli.Commands.Scan; + +internal static class AgentClientExtensions { + extension( IAgentClient agentClient ) { + internal Task GetSubnetsAsync( + Domain.Agent agent, + CancellationToken cancellationToken + ) { + return agentClient.SendAndWaitAsync( + agent, + new SubnetsRequest(), + timeout: TimeSpan.FromSeconds( 10 ), + cancellationToken + ); + } + + internal Task ScanSubnetAsync( + Domain.Agent agent, + CidrBlock cidr, + uint pingsPerSecond, + IMessageEnvelopeConverter converter, + Action onProgressUpdate, + CancellationToken cancellationToken + ) { + var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond }; + + return agentClient.SendAndWaitStreamingAsync( + agent, + request, + finalMessageType: ScanSubnetCompleteResponse.MessageType, + progressEnvelope => { + // Deserialize progress update and call handler + if ( progressEnvelope.MessageType == ScanSubnetProgressUpdate.MessageType ) { + var progressUpdate = converter.FromEnvelope( progressEnvelope ); + onProgressUpdate( progressUpdate ); + } + }, + timeout: TimeSpan.FromMinutes( 10 ), + cancellationToken + ); + } + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/AgentSubnetProvider.cs b/src/Cli/Commands/Scan/AgentSubnetProvider.cs index 97ebfdc2..e9265497 100644 --- a/src/Cli/Commands/Scan/AgentSubnetProvider.cs +++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs @@ -1,5 +1,5 @@ using Drift.Domain; -using Drift.Networking.Cluster; +using Drift.Messaging.Client; using Drift.Scanning.Subnets; using Microsoft.Extensions.Logging; @@ -8,7 +8,7 @@ namespace Drift.Cli.Commands.Scan; internal sealed class AgentSubnetProvider( ILogger logger, List agents, - ICluster cluster, + IAgentClient agentClient, CancellationToken cancellationToken ) : ISubnetProvider { public async Task> GetAsync() { @@ -19,7 +19,7 @@ public async Task> GetAsync() { logger.LogInformation( "Requesting subnets from agent {Id}", agent.Id ); try { - var response = await cluster.GetSubnetsAsync( agent, cancellationToken ); + var response = await agentClient.GetSubnetsAsync( agent, cancellationToken ); logger.LogInformation( "Received subnet(s) from agent {Id}: {Subnets}", diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs deleted file mode 100644 index 4967ca30..00000000 --- a/src/Cli/Commands/Scan/ClusterExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Drift.Agent.PeerProtocol.Scan; -using Drift.Agent.PeerProtocol.Subnets; -using Drift.Domain; -using Drift.Networking.Cluster; -using Drift.Networking.PeerStreaming.Core.Abstractions; - -namespace Drift.Cli.Commands.Scan; - -internal static class ClusterExtensions { - internal static Task GetSubnetsAsync( - this ICluster cluster, - Domain.Agent agent, - CancellationToken cancellationToken - ) { - return cluster.SendAndWaitAsync( - agent, - new SubnetsRequest(), - timeout: TimeSpan.FromSeconds( 10 ), - cancellationToken - ); - } - - internal static Task ScanSubnetAsync( - this ICluster cluster, - Domain.Agent agent, - CidrBlock cidr, - uint pingsPerSecond, - IPeerMessageEnvelopeConverter converter, - Action onProgressUpdate, - CancellationToken cancellationToken - ) { - var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond }; - - return cluster.SendAndWaitStreamingAsync( - agent, - request, - ScanSubnetCompleteResponse.MessageType, - ( progressEnvelope ) => { - // Deserialize progress update and call handler - if ( progressEnvelope.MessageType == ScanSubnetProgressUpdate.MessageType ) { - var progressUpdate = converter.FromEnvelope( progressEnvelope ); - onProgressUpdate( progressUpdate ); - } - }, - timeout: TimeSpan.FromMinutes( 10 ), - cancellationToken - ); - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs index 32cdea2c..8a3a3800 100644 --- a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -4,8 +4,8 @@ using Drift.Domain.Device.Addresses; using Drift.Domain.Device.Discovered; using Drift.Domain.Scan; -using Drift.Networking.Cluster; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Messaging.Client; +using Drift.Networking.Core.Abstractions; using Drift.Scanning.Subnets; using Microsoft.Extensions.Logging; @@ -16,8 +16,8 @@ namespace Drift.Cli.Commands.Scan; /// internal sealed class DistributedNetworkScanner( INetworkScanner localScanner, - ICluster cluster, - IPeerMessageEnvelopeConverter converter, + IAgentClient agentClient, + IMessageEnvelopeConverter converter, List resolvedSubnets, Inventory inventory, ILogger logger @@ -117,7 +117,7 @@ private async Task ScanSingleSubnetViaAgentAsync( CancellationToken cancellationToken ) { try { - var response = await cluster.ScanSubnetAsync( + var response = await agentClient.ScanSubnetAsync( MapAgentIdToDomainAgent( agentSource.AgentId ), cidr, pingsPerSecond, @@ -232,7 +232,10 @@ ILogger logger return finalResult; } - private static List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { + private static List MergeOverlappingSubnetResults( + List allResults, + ILogger logger + ) { // Group results by CIDR and merge devices from multiple scans var resultsByCidr = allResults .GroupBy( r => r.CidrBlock ) diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index e0065d8c..157d998c 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -10,8 +10,8 @@ using Drift.Common.Network; using Drift.Domain; using Drift.Domain.Scan; -using Drift.Networking.Cluster; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Messaging.Client; +using Drift.Networking.Core.Abstractions; using Drift.Scanning.Subnets; using Drift.Scanning.Subnets.Interface; using Microsoft.Extensions.Logging; @@ -58,8 +58,8 @@ internal class ScanCommandHandler( INetworkScanner localScanner, IInterfaceSubnetProvider interfaceSubnetProvider, ISpecFileProvider specProvider, - ICluster cluster, - IPeerMessageEnvelopeConverter converter + IAgentClient agentClient, + IMessageEnvelopeConverter converter ) : ICommandHandler { public async Task Invoke( ScanParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running scan command" ); @@ -118,7 +118,7 @@ private List BuildSubnetProviders( Inventory inventory, Cancell providers.Add( new AgentSubnetProvider( output.GetLogger(), inventory.Agents, - cluster, + agentClient, cancellationToken ) ); } @@ -175,7 +175,7 @@ private INetworkScanner CreateScanner( Inventory inventory, List return new DistributedNetworkScanner( localScanner, - cluster, + agentClient, converter, resolvedSubnets, inventory, diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index d7136c5d..56e4f6eb 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -2,7 +2,6 @@ using System.CommandLine.Help; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; -using Drift.Agent.PeerProtocol; using Drift.Cli.Commands.Agent; using Drift.Cli.Commands.Agent.Subcommands.Start; using Drift.Cli.Commands.Common; @@ -17,9 +16,10 @@ using Drift.Cli.SpecFile; using Drift.Domain.ExecutionEnvironment; using Drift.Domain.Scan; -using Drift.Networking.Cluster; -using Drift.Networking.PeerStreaming.Client; -using Drift.Networking.PeerStreaming.Core; +using Drift.Messaging.Client; +using Drift.Messaging.Protocol; +using Drift.Networking.Client; +using Drift.Networking.Core; using Drift.Scanning; using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; @@ -77,15 +77,15 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureSpecProvider( services ); ConfigureSubnetProvider( services ); ConfigureNetworkScanner( services ); - ConfigureAgentCluster( services ); + ConfigureAgentClient( services ); } - private static void ConfigureAgentCluster( IServiceCollection services ) { - services.AddPeerStreamingCore( new PeerStreamingOptions { - MessageAssembly = typeof(PeerProtocolAssemblyMarker).Assembly + private static void ConfigureAgentClient( IServiceCollection services ) { + services.AddMessagingCore( new MessagingOptions { + MessageAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly } ); - services.AddPeerStreamingClient(); - services.AddClustering(); + services.AddMessagingClient(); + services.AddAgentClient(); } internal static void ConfigureExecutionEnvironment( IServiceCollection services ) { @@ -156,7 +156,8 @@ private static void ConfigureDynamicCommands( internal static void ConfigureNetworkScanner( IServiceCollection services ) { if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { services.AddSingleton(); - } else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + } + else if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { services.AddSingleton(); } else { diff --git a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs index 991e0372..96e47dd4 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -19,9 +19,10 @@ public static ILogger GetLogger( this IOutputManager outputManager ) { /// public static void WarnAgentPreview( this IOutputManager outputManager ) { var logger = outputManager.GetLogger(); + logger.LogWarning( "---------------------------------------- PREVIEW ---------------------------------------" ); logger.LogWarning( "Distributed scanning via agents is a preview feature and should be used with caution." ); logger.LogWarning( "Agent communication is unencrypted. Do not use on untrusted networks." ); logger.LogWarning( "Agents run without authentication. Any client that can reach the agent port can connect." ); - logger.LogWarning( "Agent adoption (--adoptable / --join) is not yet implemented and has no effect." ); + logger.LogWarning( "----------------------------------------------------------------------------------------" ); } } \ No newline at end of file diff --git a/src/Coordinator.Host/Coordinator.Host.csproj b/src/Coordinator.Host/Coordinator.Host.csproj new file mode 100644 index 00000000..228a23b5 --- /dev/null +++ b/src/Coordinator.Host/Coordinator.Host.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Coordinator.Host/ServiceCollectionExtensions.cs b/src/Coordinator.Host/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..08db94f0 --- /dev/null +++ b/src/Coordinator.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Coordinator.Host; + +public static class ServiceCollectionExtensions { + public static void AddCoordinatorHandlers( this IServiceCollection services ) { + } +} diff --git a/src/Networking.Cluster/Cluster.cs b/src/Messaging.Client/AgentClient.cs similarity index 64% rename from src/Networking.Cluster/Cluster.cs rename to src/Messaging.Client/AgentClient.cs index 722bd799..302eb62c 100644 --- a/src/Networking.Cluster/Cluster.cs +++ b/src/Messaging.Client/AgentClient.cs @@ -1,62 +1,24 @@ -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Drift.Networking.PeerStreaming.Core.Messages; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Messages; using Microsoft.Extensions.Logging; -namespace Drift.Networking.Cluster; +namespace Drift.Messaging.Client; -internal sealed class Cluster( - IPeerMessageEnvelopeConverter envelopeConverter, - IPeerStreamManager peerStreamManager, - PeerResponseCorrelator responseCorrelator, +internal sealed class AgentClient( + IMessageEnvelopeConverter envelopeConverter, + IMessageStreamManager messageStreamManager, + MessageResponseCorrelator responseCorrelator, ILogger logger, - ClusterOptions? options = null -) : ICluster { - private readonly ClusterOptions _options = options ?? new ClusterOptions(); - /*public async Task SendAsync( - Domain.Agent agent, - TMessage message, - CancellationToken cancellationToken = default - ) where TMessage : IPeerMessage { - try { - await SendInternalAsync( agent, message, cancellationToken ); - } - catch ( Exception ex ) { - logger.LogWarning( ex, "Send to {Peer} failed", agent ); - } - }*/ - - /* public async Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ) { - var peers = peerStreamManager.GetConnectedPeers(); - - var tasks = peers.Select( async peer => { - // TODO optimistically assume connection is alive, but automatically reconnect if it's not - try { - await SendInternalAsync( peer, message, cancellationToken ); - } - catch ( Exception ex ) { - logger.LogWarning( ex, "Broadcast to {Peer} failed", peer ); - } - } ); - - await Task.WhenAll( tasks ); - }*/ - - /*public async Task SendInternalAsync( - Domain.Agent agent, - TMessage message, - CancellationToken cancellationToken = default - ) where TMessage : IPeerMessage { - var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), "agentid_local1" ); - var envelope = envelopeConverter.ToEnvelope( message ); - await connection.SendAsync( envelope ); - }*/ + AgentClientOptions? options = null +) : IAgentClient { + private readonly AgentClientOptions _options = options ?? new AgentClientOptions(); public async Task SendAndWaitAsync( Domain.Agent agent, TRequest message, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + ) where TResponse : IResponse where TRequest : IRequest { return await ExecuteWithRetryAsync( agent, async () => await SendAndWaitInternalAsync( agent, message, timeout, cancellationToken ), @@ -69,7 +31,7 @@ private async Task SendAndWaitInternalAsync( TRequest message, TimeSpan? timeout, CancellationToken cancellationToken - ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + ) where TResponse : IResponse where TRequest : IRequest { var correlationId = Guid.NewGuid().ToString(); var envelope = envelopeConverter.ToEnvelope( message ); envelope.CorrelationId = correlationId; @@ -82,7 +44,7 @@ CancellationToken cancellationToken ); // Request - var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + var connection = messageStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); await connection.SendAsync( envelope ); // Response @@ -94,10 +56,10 @@ public async Task SendAndWaitStreamingAsync onProgressUpdate, + Action onProgressUpdate, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + ) where TFinalResponse : IResponse where TRequest : IMessage { return await ExecuteWithRetryAsync( agent, async () => await SendAndWaitStreamingInternalAsync( @@ -116,10 +78,10 @@ private async Task SendAndWaitStreamingInternalAsync onProgressUpdate, + Action onProgressUpdate, TimeSpan? timeout, CancellationToken cancellationToken - ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + ) where TFinalResponse : IResponse where TRequest : IMessage { var correlationId = Guid.NewGuid().ToString(); var envelope = envelopeConverter.ToEnvelope( message ); envelope.CorrelationId = correlationId; @@ -134,7 +96,7 @@ CancellationToken cancellationToken ); // Request - var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + var connection = messageStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); await connection.SendAsync( envelope ); // Final Response @@ -205,6 +167,6 @@ CancellationToken cancellationToken private int CalculateBackoffDelay( int attempt ) { // Exponential backoff: base * 2^(attempt-1) var delay = _options.RetryBaseDelayMs * Math.Pow( 2, attempt - 1 ); - return (int)Math.Min( delay, _options.RetryMaxDelayMs ); + return (int) Math.Min( delay, _options.RetryMaxDelayMs ); } } \ No newline at end of file diff --git a/src/Networking.Cluster/ClusterOptions.cs b/src/Messaging.Client/AgentClientOptions.cs similarity index 92% rename from src/Networking.Cluster/ClusterOptions.cs rename to src/Messaging.Client/AgentClientOptions.cs index 032ccbb7..f0295a83 100644 --- a/src/Networking.Cluster/ClusterOptions.cs +++ b/src/Messaging.Client/AgentClientOptions.cs @@ -1,6 +1,6 @@ -namespace Drift.Networking.Cluster; +namespace Drift.Messaging.Client; -public sealed class ClusterOptions { +public sealed class AgentClientOptions { /// /// Gets the maximum number of retry attempts for failed operations. /// diff --git a/src/Networking.Cluster/ICluster.cs b/src/Messaging.Client/IAgentClient.cs similarity index 58% rename from src/Networking.Cluster/ICluster.cs rename to src/Messaging.Client/IAgentClient.cs index 14861026..041023e7 100644 --- a/src/Networking.Cluster/ICluster.cs +++ b/src/Messaging.Client/IAgentClient.cs @@ -1,28 +1,28 @@ +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -namespace Drift.Networking.Cluster; +namespace Drift.Messaging.Client; -public interface ICluster { - // Task SendAsync( Domain.Agent agent, IPeerMessage message, CancellationToken cancellationToken = default ); +public interface IAgentClient { + // Task SendAsync( Domain.Agent agent, IMessage message, CancellationToken cancellationToken = default ); Task SendAndWaitAsync( Domain.Agent agent, TRequest message, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TResponse : IPeerResponse where TRequest : IPeerRequest; + ) where TResponse : IResponse where TRequest : IRequest; Task SendAndWaitStreamingAsync( Domain.Agent agent, TRequest message, string finalMessageType, - Action onProgressUpdate, + Action onProgressUpdate, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage; + ) where TFinalResponse : IResponse where TRequest : IMessage; - /*Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ); + /*Task BroadcastAsync( Message message, CancellationToken cancellationToken = default ); Task> RequestSubnetsAsync( string peerAddress, CancellationToken cancellationToken = default ); Task EnsureConnectedAsync( string peerAddress, CancellationToken cancellationToken = default ); IReadOnlyCollection GetConnectedPeers();*/ diff --git a/src/Messaging.Client/Messaging.Client.csproj b/src/Messaging.Client/Messaging.Client.csproj new file mode 100644 index 00000000..b5d05c36 --- /dev/null +++ b/src/Messaging.Client/Messaging.Client.csproj @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/Messaging.Client/ServiceCollectionExtensions.cs b/src/Messaging.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..af61f6d4 --- /dev/null +++ b/src/Messaging.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Messaging.Client; + +public static class ServiceCollectionExtensions { + public static void AddAgentClient( this IServiceCollection services ) { + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Messaging.Protocol/Messaging.Protocol.csproj b/src/Messaging.Protocol/Messaging.Protocol.csproj new file mode 100644 index 00000000..7955e65e --- /dev/null +++ b/src/Messaging.Protocol/Messaging.Protocol.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs b/src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs new file mode 100644 index 00000000..54b85567 --- /dev/null +++ b/src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace Drift.Messaging.Protocol; + +// Justification: marker class +#pragma warning disable S2094 +public class ProtocolMessagesAssemblyMarker; +#pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs b/src/Messaging.Protocol/Scan/ScanSubnetCompleteResponse.cs similarity index 82% rename from src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs rename to src/Messaging.Protocol/Scan/ScanSubnetCompleteResponse.cs index e330d4b2..0645187f 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs +++ b/src/Messaging.Protocol/Scan/ScanSubnetCompleteResponse.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Drift.Domain.Scan; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; using Drift.Serialization.Converters; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Messaging.Protocol.Scan; -public sealed class ScanSubnetCompleteResponse : IPeerResponse { +public sealed class ScanSubnetCompleteResponse : IResponse { public static string MessageType => "scan-complete"; public required SubnetScanResult Result { diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs b/src/Messaging.Protocol/Scan/ScanSubnetProgressUpdate.cs similarity index 81% rename from src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs rename to src/Messaging.Protocol/Scan/ScanSubnetProgressUpdate.cs index 596703fe..8ec83d3c 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs +++ b/src/Messaging.Protocol/Scan/ScanSubnetProgressUpdate.cs @@ -1,10 +1,10 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Messaging.Protocol.Scan; -public sealed class ScanSubnetProgressUpdate : IPeerMessage { +public sealed class ScanSubnetProgressUpdate : IMessage { public static string MessageType => "scan-progress-update"; public required byte ProgressPercentage { diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs b/src/Messaging.Protocol/Scan/ScanSubnetRequest.cs similarity index 80% rename from src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs rename to src/Messaging.Protocol/Scan/ScanSubnetRequest.cs index 4da42d37..52279291 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs +++ b/src/Messaging.Protocol/Scan/ScanSubnetRequest.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Drift.Domain; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; using Drift.Serialization.Converters; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Messaging.Protocol.Scan; -public sealed class ScanSubnetRequest : IPeerRequest { +public sealed class ScanSubnetRequest : IRequest { public static string MessageType => "scan-subnet-request"; public required CidrBlock Cidr { diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs b/src/Messaging.Protocol/Subnets/SubnetsRequest.cs similarity index 68% rename from src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs rename to src/Messaging.Protocol/Subnets/SubnetsRequest.cs index 7f0323f7..5a2e4770 100644 --- a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs +++ b/src/Messaging.Protocol/Subnets/SubnetsRequest.cs @@ -1,10 +1,10 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; -namespace Drift.Agent.PeerProtocol.Subnets; +namespace Drift.Messaging.Protocol.Subnets; -public sealed class SubnetsRequest : IPeerRequest { +public sealed class SubnetsRequest : IRequest { public static string MessageType => "subnets-request"; public static JsonTypeInfo JsonInfo => SubnetsRequestJsonContext.Default.SubnetsRequest; diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs b/src/Messaging.Protocol/Subnets/SubnetsResponse.cs similarity index 81% rename from src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs rename to src/Messaging.Protocol/Subnets/SubnetsResponse.cs index f2402b6c..511baf49 100644 --- a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs +++ b/src/Messaging.Protocol/Subnets/SubnetsResponse.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Drift.Domain; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; using Drift.Serialization.Converters; -namespace Drift.Agent.PeerProtocol.Subnets; +namespace Drift.Messaging.Protocol.Subnets; -public sealed class SubnetsResponse : IPeerResponse { +public sealed class SubnetsResponse : IResponse { public static string MessageType => "subnets-response"; public required IReadOnlyList Subnets { diff --git a/src/Networking.Client/DefaultMessagingClientFactory.cs b/src/Networking.Client/DefaultMessagingClientFactory.cs new file mode 100644 index 00000000..0af68d6e --- /dev/null +++ b/src/Networking.Client/DefaultMessagingClientFactory.cs @@ -0,0 +1,13 @@ +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; +using Grpc.Net.Client; + +namespace Drift.Networking.Client; + +internal sealed class DefaultMessagingClientFactory : IMessagingClientFactory { + public (MessagingService.MessagingServiceClient Client, GrpcChannel Channel) Create( Uri address ) { + var channel = GrpcChannel.ForAddress( address, new GrpcChannelOptions() ); + var client = new MessagingService.MessagingServiceClient( channel ); + return ( client, channel ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj b/src/Networking.Client/Networking.Client.csproj similarity index 59% rename from src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj rename to src/Networking.Client/Networking.Client.csproj index 20592b5f..a4e89826 100644 --- a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj +++ b/src/Networking.Client/Networking.Client.csproj @@ -3,9 +3,9 @@ - + - + diff --git a/src/Networking.Client/ServiceCollectionExtensions.cs b/src/Networking.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..4a9aabb6 --- /dev/null +++ b/src/Networking.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,10 @@ +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.Client; + +public static class ServiceCollectionExtensions { + public static void AddMessagingClient( this IServiceCollection services ) { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Networking.Cluster/Enrollment.cs b/src/Networking.Cluster/Enrollment.cs deleted file mode 100644 index 4d8fcee4..00000000 --- a/src/Networking.Cluster/Enrollment.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Drift.Networking.Cluster; - -#pragma warning disable CS9113 -public class EnrollmentRequest( bool parametersAdoptable, string? parametersJoin ) { -#pragma warning restore CS9113 - public EnrollmentMethod Method => parametersAdoptable ? EnrollmentMethod.Adoption : EnrollmentMethod.Jwt; -} - -public enum EnrollmentMethod { - Adoption, - Jwt -} \ No newline at end of file diff --git a/src/Networking.Cluster/Networking.Cluster.csproj b/src/Networking.Cluster/Networking.Cluster.csproj deleted file mode 100644 index 9c366ae1..00000000 --- a/src/Networking.Cluster/Networking.Cluster.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/Networking.Cluster/ServiceCollectionExtensions.cs b/src/Networking.Cluster/ServiceCollectionExtensions.cs deleted file mode 100644 index 5d26edd7..00000000 --- a/src/Networking.Cluster/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Drift.Networking.Cluster; - -public static class ServiceCollectionExtensions { - public static void AddClustering( this IServiceCollection services ) { - services.AddScoped(); - } -} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/ConnectionSide.cs b/src/Networking.Core.Abstractions/ConnectionSide.cs new file mode 100644 index 00000000..e3ca7b79 --- /dev/null +++ b/src/Networking.Core.Abstractions/ConnectionSide.cs @@ -0,0 +1,6 @@ +namespace Drift.Networking.Core.Abstractions; + +public enum ConnectionSide { + Inbound, + Outbound +} diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs b/src/Networking.Core.Abstractions/Empty.cs similarity index 73% rename from src/Networking.PeerStreaming.Core.Abstractions/Empty.cs rename to src/Networking.Core.Abstractions/Empty.cs index 2c43940d..0f7ffe3c 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs +++ b/src/Networking.Core.Abstractions/Empty.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization.Metadata; -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public class Empty : IPeerResponse { +public class Empty : IResponse { private Empty() { } diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs b/src/Networking.Core.Abstractions/IMessage.cs similarity index 61% rename from src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs rename to src/Networking.Core.Abstractions/IMessage.cs index 0d6ef8e3..49b90e99 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs +++ b/src/Networking.Core.Abstractions/IMessage.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization.Metadata; -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public interface IPeerMessage { +public interface IMessage { static abstract string MessageType { get; } @@ -13,9 +13,9 @@ static abstract JsonTypeInfo JsonInfo { } #pragma warning disable S2326 // TResponse is an intentional phantom type parameter for type-safe request/response pairing -public interface IPeerRequest : IPeerMessage where TResponse : IPeerResponse; +public interface IRequest : IMessage where TResponse : IResponse; #pragma warning restore S2326 -public interface IPeerResponse : IPeerMessage { +public interface IResponse : IMessage { static readonly Empty Empty = Empty.Instance; } \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs b/src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs new file mode 100644 index 00000000..4ddddb7e --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs @@ -0,0 +1,9 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageEnvelopeConverter { + public Message ToEnvelope( IMessage message, string? requestId = null ) where T : IMessage; + + public T FromEnvelope( Message envelope ) where T : IMessage; +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs b/src/Networking.Core.Abstractions/IMessageHandler.cs similarity index 69% rename from src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs rename to src/Networking.Core.Abstractions/IMessageHandler.cs index 26a7740b..eeea55f3 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs +++ b/src/Networking.Core.Abstractions/IMessageHandler.cs @@ -1,8 +1,8 @@ using Drift.Networking.Grpc.Generated; -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public interface IPeerMessageHandler { +public interface IMessageHandler { /// /// Gets the message type name that this handler can process. /// @@ -15,9 +15,9 @@ string MessageType { /// via the provided stream. Can send multiple responses for streaming scenarios. /// Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ); } \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageStream.cs b/src/Networking.Core.Abstractions/IMessageStream.cs new file mode 100644 index 00000000..690885c9 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageStream.cs @@ -0,0 +1,20 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageStream : IAsyncDisposable { + public int InstanceNo { + get; + } + + public AgentId RemoteId { + get; + } + + public Task ReadTask { + get; + } + + public Task SendAsync( Message message ); +} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageStreamManager.cs b/src/Networking.Core.Abstractions/IMessageStreamManager.cs new file mode 100644 index 00000000..1e72b7a3 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageStreamManager.cs @@ -0,0 +1,15 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageStreamManager : IAsyncDisposable { + public IMessageStream GetOrCreate( Uri peerAddress, AgentId id ); + + public IMessageStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ); +} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageTypesProvider.cs b/src/Networking.Core.Abstractions/IMessageTypesProvider.cs new file mode 100644 index 00000000..20f7f7d4 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageTypesProvider.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageTypesProvider { + Dictionary Get(); +} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessagingClientFactory.cs b/src/Networking.Core.Abstractions/IMessagingClientFactory.cs new file mode 100644 index 00000000..b7bde538 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessagingClientFactory.cs @@ -0,0 +1,8 @@ +using Drift.Networking.Grpc.Generated; +using Grpc.Net.Client; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessagingClientFactory { + (MessagingService.MessagingServiceClient Client, GrpcChannel Channel) Create( Uri address ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs similarity index 62% rename from src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs rename to src/Networking.Core.Abstractions/MessageHandlerExtensions.cs index cf3c9f6e..6d8b4b16 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs +++ b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs @@ -1,16 +1,16 @@ -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public static class PeerMessageHandlerExtensions { +public static class MessageHandlerExtensions { /// /// Helper to send a response with the correct correlation ID set. /// /// The type of the response message to send. public static async Task SendResponseAsync( - this IPeerStream stream, - IPeerMessageEnvelopeConverter converter, + this IMessageStream stream, + IMessageEnvelopeConverter converter, TResponse response, string correlationId - ) where TResponse : IPeerMessage { + ) where TResponse : IMessage { var envelope = converter.ToEnvelope( response ); envelope.ReplyTo = correlationId; await stream.SendAsync( envelope ); @@ -22,13 +22,21 @@ string correlationId /// /// The type of the response message to send. public static void SendResponseFireAndForget( - this IPeerStream stream, - IPeerMessageEnvelopeConverter converter, + this IMessageStream stream, + IMessageEnvelopeConverter converter, TResponse response, string correlationId - ) where TResponse : IPeerMessage { + ) where TResponse : IMessage { var envelope = converter.ToEnvelope( response ); envelope.ReplyTo = correlationId; - _ = stream.SendAsync( envelope ); + _ = stream.SendAsync( envelope ).ContinueWith( + t => { + t.Exception?.Handle( ex => { + // TODO: Handle exception + return true; + } ); + }, + TaskContinuationOptions.OnlyOnFaulted + ); } -} +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj b/src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj similarity index 67% rename from src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj rename to src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj index 97a14ed6..2086a57d 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj +++ b/src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj @@ -2,7 +2,7 @@ - + diff --git a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs b/src/Networking.Core/Common/GrpcMetadataExtensions.cs similarity index 85% rename from src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs rename to src/Networking.Core/Common/GrpcMetadataExtensions.cs index 77717661..1f58d162 100644 --- a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs +++ b/src/Networking.Core/Common/GrpcMetadataExtensions.cs @@ -1,7 +1,7 @@ using Drift.Domain; using Grpc.Core; -namespace Drift.Networking.PeerStreaming.Core.Common; +namespace Drift.Networking.Core.Common; internal static class GrpcMetadataExtensions { internal static AgentId GetAgentId( this Metadata metadata ) { diff --git a/src/Networking.PeerStreaming.Core/PeerStream.cs b/src/Networking.Core/MessageStream.cs similarity index 71% rename from src/Networking.PeerStreaming.Core/PeerStream.cs rename to src/Networking.Core/MessageStream.cs index 4608989c..09d5d33e 100644 --- a/src/Networking.PeerStreaming.Core/PeerStream.cs +++ b/src/Networking.Core/MessageStream.cs @@ -1,28 +1,28 @@ using Drift.Domain; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Messages; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Drift.Networking.PeerStreaming.Core.Messages; using Grpc.Core; using Microsoft.Extensions.Logging; -namespace Drift.Networking.PeerStreaming.Core; +namespace Drift.Networking.Core; -public sealed class PeerStream : IPeerStream { +public sealed class MessageStream : IMessageStream { private static int _instanceCounter; // Being static is not ideal for testing with multiple instances - private readonly IAsyncStreamReader _reader; - private readonly IAsyncStreamWriter _writer; - private readonly PeerMessageDispatcher _dispatcher; + private readonly IAsyncStreamReader _reader; + private readonly IAsyncStreamWriter _writer; + private readonly MessageDispatcher _dispatcher; private readonly ILogger _logger; private readonly CancellationToken _cancellationToken; - public PeerStream( - IAsyncStreamReader reader, - IAsyncStreamWriter writer, - PeerMessageDispatcher dispatcher, + public MessageStream( + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + MessageDispatcher dispatcher, ILogger logger, CancellationToken cancellationToken ) { - Side = ConnectionSide.Incoming; + Side = ConnectionSide.Inbound; _reader = reader; _writer = writer; _dispatcher = dispatcher; @@ -32,15 +32,15 @@ CancellationToken cancellationToken ReadTask = Task.Run( ReadLoopAsync, CancellationToken.None ); } - public PeerStream( + public MessageStream( Uri address, - IAsyncStreamReader reader, - IAsyncStreamWriter writer, - PeerMessageDispatcher dispatcher, + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + MessageDispatcher dispatcher, ILogger logger, CancellationToken cancellationToken ) : this( reader, writer, dispatcher, logger, cancellationToken ) { - Side = ConnectionSide.Outgoing; + Side = ConnectionSide.Outbound; Address = address; } @@ -56,7 +56,7 @@ private Uri? Address { get; } - public required AgentId AgentId { + public required AgentId RemoteId { get; init; } @@ -66,7 +66,7 @@ public Task ReadTask { private init; } - public async Task SendAsync( PeerMessage message ) { + public async Task SendAsync( Message message ) { await _writer.WriteAsync( message, _cancellationToken ); // TODO also take another cancellation token (combine) } @@ -103,9 +103,9 @@ private async Task ReadLoopAsync() { } public async ValueTask DisposeAsync() { - _logger.LogTrace( "Disposing {PeerStream}", this ); + _logger.LogTrace( "Disposing {MessageStream}", this ); - if ( _writer is IClientStreamWriter clientWriter ) { + if ( _writer is IClientStreamWriter clientWriter ) { // I.e., outgoing stream (client initiated) // Server streams are automatically completed by the gRPC framework await clientWriter.CompleteAsync(); @@ -116,6 +116,6 @@ public async ValueTask DisposeAsync() { public override string ToString() { return - $"{nameof(PeerStream)}[#{InstanceNo}, {nameof(AgentId)}={AgentId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]"; + $"{nameof(MessageStream)}[#{InstanceNo}, {nameof(RemoteId)}={RemoteId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]"; } } \ No newline at end of file diff --git a/src/Networking.Core/MessageStreamManager.cs b/src/Networking.Core/MessageStreamManager.cs new file mode 100644 index 00000000..3e503d0e --- /dev/null +++ b/src/Networking.Core/MessageStreamManager.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using Drift.Domain; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Common; +using Drift.Networking.Core.Messages; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.Core; + +internal sealed class MessageStreamManager( + ILogger logger, + IMessagingClientFactory? messageClientFactory, + MessageDispatcher dispatcher, + MessagingOptions options +) : IMessageStreamManager { + private readonly ConcurrentDictionary _streams = new(); + + public IMessageStream GetOrCreate( Uri peerAddress, AgentId id ) { + logger.LogDebug( + "Getting or creating {ConnectionSide} stream to agent {Id} ({Address})", + ConnectionSide.Outbound, + id, + peerAddress + ); + + return _streams.GetOrAdd( id, agentId => Create( peerAddress, agentId ) ); + } + + private IMessageStream Create( Uri peerAddress, AgentId id ) { + if ( messageClientFactory == null ) { + throw new Exception( + $"Cannot create {nameof(ConnectionSide.Outbound)} stream since {nameof(messageClientFactory)} is null" + ); + } + + var (client, _) = messageClientFactory.Create( peerAddress ); + var callOptions = new CallOptions( new Metadata { { "agent-id", id } } ); + var call = client.Connect( callOptions ); + + var stream = new MessageStream( + peerAddress, + call.ResponseStream, + call.RequestStream, + dispatcher, + logger, + options.StoppingToken + ) { RemoteId = id }; + Add( stream ); + return stream; + } + + public IMessageStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ) { + var agentId = context.RequestHeaders.GetAgentId(); + + logger.LogInformation( "Creating {ConnectionSide} stream from agent {Id}", ConnectionSide.Inbound, agentId ); + + var stream = + new MessageStream( requestStream, responseStream, dispatcher, logger, options.StoppingToken ) { + RemoteId = agentId + }; + Add( stream ); + return stream; + } + + private void Add( IMessageStream stream ) { + logger.LogTrace( "Created {Stream}", stream ); + _streams[stream.RemoteId] = stream; + } + + public async ValueTask DisposeAsync() { + logger.LogDebug( "Disposing stream manager (including all streams)" ); + foreach ( var stream in _streams.Values ) { + logger.LogTrace( "Disposing stream #{StreamNo}", stream.InstanceNo ); + await stream.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs b/src/Networking.Core/Messages/MessageDispatcher.cs similarity index 61% rename from src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs rename to src/Networking.Core/Messages/MessageDispatcher.cs index 18fa525e..dc2d2ee4 100644 --- a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs +++ b/src/Networking.Core/Messages/MessageDispatcher.cs @@ -1,19 +1,19 @@ +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; using Microsoft.Extensions.Logging; -namespace Drift.Networking.PeerStreaming.Core.Messages; +namespace Drift.Networking.Core.Messages; -public sealed class PeerMessageDispatcher { - private readonly PeerResponseCorrelator _responseCorrelator; - private readonly IPeerMessageEnvelopeConverter _envelopeConverter; +public sealed class MessageDispatcher { + private readonly MessageResponseCorrelator _responseCorrelator; + private readonly IMessageEnvelopeConverter _envelopeConverter; private readonly ILogger _logger; - private readonly Dictionary _handlers; + private readonly Dictionary _handlers; - public PeerMessageDispatcher( - IEnumerable handlers, - IPeerMessageEnvelopeConverter envelopeConverter, - PeerResponseCorrelator responseCorrelator, + public MessageDispatcher( + IEnumerable handlers, + IMessageEnvelopeConverter envelopeConverter, + MessageResponseCorrelator responseCorrelator, ILogger logger ) { _responseCorrelator = responseCorrelator; @@ -22,7 +22,7 @@ ILogger logger _handlers = handlers.ToDictionary( h => h.MessageType, StringComparer.OrdinalIgnoreCase ); } - public async Task DispatchAsync( PeerMessage message, PeerStream peerStream, CancellationToken ct = default ) { + public async Task DispatchAsync( Message message, MessageStream stream, CancellationToken ct = default ) { _logger.LogDebug( "Dispatching message: {Type}", message.MessageType ); // If this is a response to a pending request, complete it @@ -39,7 +39,7 @@ ILogger logger // Dispatch to handler - handler is responsible for sending response(s) if ( _handlers.TryGetValue( message.MessageType, out var handler ) ) { - await handler.HandleAsync( message, _envelopeConverter, peerStream, ct ); + await handler.HandleAsync( message, _envelopeConverter, stream, ct ); return; } diff --git a/src/Networking.Core/Messages/MessageEnvelopeConverter.cs b/src/Networking.Core/Messages/MessageEnvelopeConverter.cs new file mode 100644 index 00000000..a1586a3c --- /dev/null +++ b/src/Networking.Core/Messages/MessageEnvelopeConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.Core.Messages; + +internal sealed class MessageEnvelopeConverter : IMessageEnvelopeConverter { + public Message ToEnvelope( IMessage message, string? requestId = null ) where T : IMessage { + string json = JsonSerializer.Serialize( message, T.JsonInfo ); + return new Message { MessageType = T.MessageType, Payload = json, }; + } + + public T FromEnvelope( Message envelope ) where T : IMessage { + if ( envelope.MessageType != T.MessageType ) { + throw new InvalidOperationException( + $"Envelope contains '{envelope.MessageType}' but caller expects '{T.MessageType}'." + ); + } + + return JsonSerializer.Deserialize( envelope.Payload, T.JsonInfo.Options )!; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs b/src/Networking.Core/Messages/MessageResponseCorrelator.cs similarity index 72% rename from src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs rename to src/Networking.Core/Messages/MessageResponseCorrelator.cs index 3f0b2138..0e07a2ec 100644 --- a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs +++ b/src/Networking.Core/Messages/MessageResponseCorrelator.cs @@ -2,20 +2,15 @@ using Drift.Networking.Grpc.Generated; using Microsoft.Extensions.Logging; -namespace Drift.Networking.PeerStreaming.Core.Messages; +namespace Drift.Networking.Core.Messages; // TODO private? -public sealed class PeerResponseCorrelator { - private readonly ConcurrentDictionary> _pendingRequests = new(); +public sealed class MessageResponseCorrelator( ILogger logger ) { + private readonly ConcurrentDictionary> _pendingRequests = new(); private readonly ConcurrentDictionary _streamingRequests = new(); - private readonly ILogger _logger; - public PeerResponseCorrelator( ILogger logger ) { - _logger = logger; - } - - public Task WaitForResponseAsync( string correlationId, TimeSpan timeout, CancellationToken ct ) { - var tcs = new TaskCompletionSource(); + public Task WaitForResponseAsync( string correlationId, TimeSpan timeout, CancellationToken ct ) { + var tcs = new TaskCompletionSource(); if ( !_pendingRequests.TryAdd( correlationId, tcs ) ) { throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" ); @@ -35,15 +30,15 @@ public Task WaitForResponseAsync( string correlationId, TimeSpan ti return tcs.Task; } - public Task WaitForStreamingResponseAsync( + public Task WaitForStreamingResponseAsync( string correlationId, string finalMessageType, - Action onProgressUpdate, + Action onProgressUpdate, TimeSpan timeout, CancellationToken ct ) { var handler = new StreamingResponseHandler { - CompletionSource = new TaskCompletionSource(), + CompletionSource = new TaskCompletionSource(), FinalMessageType = finalMessageType, OnProgressUpdate = onProgressUpdate }; @@ -66,7 +61,7 @@ CancellationToken ct return handler.CompletionSource.Task; } - public bool TryCompleteResponse( string correlationId, PeerMessage response ) { + public bool TryCompleteResponse( string correlationId, Message response ) { // Check for streaming response first if ( _streamingRequests.TryGetValue( correlationId, out var streamingHandler ) ) { // If this is the final message, complete the task @@ -85,12 +80,12 @@ public bool TryCompleteResponse( string correlationId, PeerMessage response ) { return tcs.TrySetResult( response ); } - _logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); + logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); return false; } private sealed class StreamingResponseHandler { - public required TaskCompletionSource CompletionSource { + public required TaskCompletionSource CompletionSource { get; init; } @@ -100,7 +95,7 @@ public required string FinalMessageType { init; } - public required Action OnProgressUpdate { + public required Action OnProgressUpdate { get; init; } diff --git a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs b/src/Networking.Core/MessagingOptions.cs similarity index 67% rename from src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs rename to src/Networking.Core/MessagingOptions.cs index 2ca2a36e..08d629a2 100644 --- a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs +++ b/src/Networking.Core/MessagingOptions.cs @@ -1,8 +1,8 @@ using System.Reflection; -namespace Drift.Networking.PeerStreaming.Core; +namespace Drift.Networking.Core; -public sealed class PeerStreamingOptions { +public sealed class MessagingOptions { public CancellationToken StoppingToken { get; set; diff --git a/src/Networking.Core/Networking.Core.csproj b/src/Networking.Core/Networking.Core.csproj new file mode 100644 index 00000000..27a7fd88 --- /dev/null +++ b/src/Networking.Core/Networking.Core.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Networking.Core/ServiceCollectionExtensions.cs b/src/Networking.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..609d82de --- /dev/null +++ b/src/Networking.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Messages; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.Core; + +public static class ServiceCollectionExtensions { + public static void AddMessagingCore( + this IServiceCollection services, + MessagingOptions options + ) { + services.AddSingleton( options ); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj b/src/Networking.Grpc/Networking.Grpc.csproj similarity index 80% rename from src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj rename to src/Networking.Grpc/Networking.Grpc.csproj index 92ab3e5b..849107b6 100644 --- a/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj +++ b/src/Networking.Grpc/Networking.Grpc.csproj @@ -1,7 +1,7 @@  - + diff --git a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto b/src/Networking.Grpc/Protos/agent.proto similarity index 61% rename from src/Networking.PeerStreaming.Grpc/Protos/peer.proto rename to src/Networking.Grpc/Protos/agent.proto index c0fccd55..cc232c9c 100644 --- a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto +++ b/src/Networking.Grpc/Protos/agent.proto @@ -2,16 +2,15 @@ syntax = "proto3"; option csharp_namespace = "Drift.Networking.Grpc.Generated"; -package peer; +package drift; -// AgentService? -service PeerService { - rpc PeerStream (stream PeerMessage) returns (stream PeerMessage); +service MessagingService { + rpc Connect (stream Message) returns (stream Message); } -message PeerMessage { +message Message { string message_type = 1; - string message = 2; + string payload = 2; string correlation_id = 3; // For request-response matching string reply_to = 4; // If this is a response, which request it replies to } diff --git a/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs deleted file mode 100644 index 85cbf055..00000000 --- a/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Grpc.Net.Client; - -namespace Drift.Networking.PeerStreaming.Client; - -internal sealed class DefaultPeerClientFactory : IPeerClientFactory { - public (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ) { - var channel = GrpcChannel.ForAddress( address, new GrpcChannelOptions() ); - var client = new PeerService.PeerServiceClient( channel ); - return ( client, channel ); - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs deleted file mode 100644 index 81cf54a9..00000000 --- a/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace Drift.Networking.PeerStreaming.Client; - -public static class ServiceCollectionExtensions { - public static void AddPeerStreamingClient( this IServiceCollection services ) { - services.AddSingleton(); - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs deleted file mode 100644 index d516ef7b..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Drift.Networking.PeerStreaming.Core.Abstractions; - -public enum ConnectionSide { - Incoming, - Outgoing -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs deleted file mode 100644 index f7a92656..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Drift.Networking.Grpc.Generated; -using Grpc.Net.Client; - -namespace Drift.Networking.PeerStreaming.Core.Abstractions; - -public interface IPeerClientFactory { - (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ); -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs deleted file mode 100644 index 69a02c1a..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Drift.Networking.Grpc.Generated; - -namespace Drift.Networking.PeerStreaming.Core.Abstractions; - -public interface IPeerMessageEnvelopeConverter { - public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage; - - public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage; -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs deleted file mode 100644 index 37d6fe8d..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Text.Json.Serialization.Metadata; - -namespace Drift.Networking.PeerStreaming.Core.Abstractions; - -public interface IPeerMessageTypesProvider { - Dictionary Get(); -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs deleted file mode 100644 index 6d34c9b8..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Drift.Domain; -using Drift.Networking.Grpc.Generated; - -namespace Drift.Networking.PeerStreaming.Core.Abstractions; - -public interface IPeerStream : IAsyncDisposable { - public int InstanceNo { - get; - } - - public AgentId AgentId { - get; - } - - public Task ReadTask { - get; - } - - public Task SendAsync( PeerMessage message ); -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs deleted file mode 100644 index cf4576e3..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Drift.Domain; -using Drift.Networking.Grpc.Generated; -using Grpc.Core; - -namespace Drift.Networking.PeerStreaming.Core.Abstractions; - -public interface IPeerStreamManager : IAsyncDisposable { - public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ); - - public IPeerStream Create( - IAsyncStreamReader requestStream, - IAsyncStreamWriter responseStream, - ServerCallContext context - ); -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs deleted file mode 100644 index 3b9a4467..00000000 --- a/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json; -using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; - -namespace Drift.Networking.PeerStreaming.Core.Messages; - -internal sealed class PeerMessageEnvelopeConverter : IPeerMessageEnvelopeConverter { - public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage { - string json = JsonSerializer.Serialize( message, T.JsonInfo ); - return new PeerMessage { MessageType = T.MessageType, Message = json, }; - } - - public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage { - if ( envelope.MessageType != T.MessageType ) { - throw new InvalidOperationException( - $"Envelope contains '{envelope.MessageType}' but caller expects '{T.MessageType}'." - ); - } - - return JsonSerializer.Deserialize( envelope.Message, T.JsonInfo.Options )!; - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj deleted file mode 100644 index 9d441dfb..00000000 --- a/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs deleted file mode 100644 index 78b0f673..00000000 --- a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Concurrent; -using Drift.Domain; -using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Drift.Networking.PeerStreaming.Core.Common; -using Drift.Networking.PeerStreaming.Core.Messages; -using Grpc.Core; -using Microsoft.Extensions.Logging; - -namespace Drift.Networking.PeerStreaming.Core; - -internal sealed class PeerStreamManager( - ILogger logger, - IPeerClientFactory? peerClientFactory, - PeerMessageDispatcher dispatcher, - PeerStreamingOptions options -) : IPeerStreamManager { - private readonly ConcurrentDictionary _streams = new(); - - public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ) { - logger.LogDebug( - "Getting or creating {ConnectionSide} stream to agent {Id} ({Address})", - ConnectionSide.Outgoing, - id, - peerAddress - ); - - return _streams.GetOrAdd( id, agentId => Create( peerAddress, agentId ) ); - } - - private IPeerStream Create( Uri peerAddress, AgentId id ) { - if ( peerClientFactory == null ) { - throw new Exception( $"Cannot create outbound stream since {nameof(peerClientFactory)} is null" ); - } - - var (client, _) = peerClientFactory.Create( peerAddress ); - var callOptions = new CallOptions( new Metadata { { "agent-id", id } } ); - var call = client.PeerStream( callOptions ); - - var stream = new PeerStream( - peerAddress, - call.ResponseStream, - call.RequestStream, - dispatcher, - logger, - options.StoppingToken - ) { AgentId = id }; - Add( stream ); - return stream; - } - - public IPeerStream Create( - IAsyncStreamReader requestStream, - IAsyncStreamWriter responseStream, - ServerCallContext context - ) { - var agentId = context.RequestHeaders.GetAgentId(); - - logger.LogInformation( "Creating {ConnectionSide} stream from agent {Id}", ConnectionSide.Incoming, agentId ); - - var stream = - new PeerStream( requestStream, responseStream, dispatcher, logger, options.StoppingToken ) { AgentId = agentId }; - Add( stream ); - return stream; - } - - private void Add( IPeerStream stream ) { - logger.LogTrace( "Created {Stream}", stream ); - _streams[stream.AgentId] = stream; - } - - public async ValueTask DisposeAsync() { - logger.LogDebug( "Disposing peer stream manager (including all streams)" ); - foreach ( var stream in _streams.Values ) { - logger.LogTrace( "Disposing peer stream #{StreamNo}", stream.InstanceNo ); - await stream.DisposeAsync(); - } - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs deleted file mode 100644 index 4ca96603..00000000 --- a/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Drift.Networking.PeerStreaming.Core.Messages; -using Microsoft.Extensions.DependencyInjection; - -namespace Drift.Networking.PeerStreaming.Core; - -public static class ServiceCollectionExtensions { - public static void AddPeerStreamingCore( - this IServiceCollection services, - PeerStreamingOptions options - ) { - services.AddSingleton( options ); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Server/InboundPeerService.cs b/src/Networking.PeerStreaming.Server/InboundPeerService.cs deleted file mode 100644 index d503718f..00000000 --- a/src/Networking.PeerStreaming.Server/InboundPeerService.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Grpc.Core; -using Microsoft.Extensions.Logging; - -namespace Drift.Networking.PeerStreaming.Server; - -// Handle incoming connections (AKA server-side). -internal sealed class InboundPeerService( IPeerStreamManager peerStreamManager, ILogger logger ) - : PeerService.PeerServiceBase { - public override async Task PeerStream( - IAsyncStreamReader requestStream, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - try { - logger.LogInformation( "Inbound stream starting..." ); - var stream = peerStreamManager.Create( requestStream, responseStream, context ); - logger.LogInformation( "Peer stream #{StreamNo} created", stream.InstanceNo ); - - // The stream is closed when the method returns. - // We thus wait for the read loop to complete (meaning that this client is no longer interested in the stream). - await stream.ReadTask; - - logger.LogInformation( "Peer stream #{StreamNo} completed", stream.InstanceNo ); - } - catch ( Exception ex ) { - logger.LogError( ex, "Inbound stream failed" ); - } - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj deleted file mode 100644 index 76db19ff..00000000 --- a/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs deleted file mode 100644 index 2b09ab2f..00000000 --- a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Drift.Networking.PeerStreaming.Server; - -internal sealed class PeerStreamingServerMarker { - internal bool EndpointsMapped { - get; - set; - } -} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/InboundTests.cs b/src/Networking.PeerStreaming.Tests/InboundTests.cs deleted file mode 100644 index a54b2260..00000000 --- a/src/Networking.PeerStreaming.Tests/InboundTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Drift.Networking.PeerStreaming.Core; -using Drift.Networking.PeerStreaming.Core.Messages; -using Drift.Networking.PeerStreaming.Server; -using Drift.Networking.PeerStreaming.Tests.Helpers; -using Drift.TestUtilities; - -namespace Drift.Networking.PeerStreaming.Tests; - -internal sealed class InboundTests { - [Test] - public async Task InboundStreamIsClosedWhenCancelledTest() { - // Arrange - using var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var responseCorrelator = new PeerResponseCorrelator( logger ); - var envelopeConverter = new PeerMessageEnvelopeConverter(); - var peerStreamManager = new PeerStreamManager( - logger, - null, - new PeerMessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), - new PeerStreamingOptions { StoppingToken = cts.Token } - ); - - var callContext = TestServerCallContext.Create(); - callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); - var duplexStreams = callContext.CreateDuplexStreams(); - - var inboundPeerService = new InboundPeerService( peerStreamManager, logger ); - - // Act / Assert - var serverStreams = duplexStreams.Server; - var peerStreamTask = - inboundPeerService.PeerStream( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); - - Assert.That( peerStreamTask.IsCompleted, Is.False ); - - await cts.CancelAsync(); - - await Task.WhenAny( peerStreamTask, Task.Delay( 1000 ) ); - - Assert.That( peerStreamTask.IsCompleted, Is.True ); - } - - [Test] - public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { - // Arrange - using var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var responseCorrelator = new PeerResponseCorrelator( logger ); - var envelopeConverter = new PeerMessageEnvelopeConverter(); - var peerStreamManager = new PeerStreamManager( - logger, - null, - new PeerMessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), - new PeerStreamingOptions { StoppingToken = cts.Token } - ); - var inboundPeerService = new InboundPeerService( peerStreamManager, logger ); - - var callContext = TestServerCallContext.Create(); - callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); - var duplexStreams = callContext.CreateDuplexStreams(); - - // Act - var peerStreamTask = inboundPeerService.PeerStream( - duplexStreams.Server.RequestStream, - duplexStreams.Server.ResponseStream, - callContext - ); - - // Assert - Assert.That( peerStreamTask.IsCompleted, Is.False ); - - await Task.WhenAny( peerStreamTask, Task.Delay( 1000 ) ); - - Assert.That( peerStreamTask.IsCompleted, Is.False ); - } -} \ No newline at end of file diff --git a/src/Networking.Server/InboundMessageService.cs b/src/Networking.Server/InboundMessageService.cs new file mode 100644 index 00000000..6341612e --- /dev/null +++ b/src/Networking.Server/InboundMessageService.cs @@ -0,0 +1,31 @@ +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.Server; + +// Handle incoming connections (AKA server-side). +internal sealed class InboundMessageService( IMessageStreamManager messageStreamManager, ILogger logger ) + : MessagingService.MessagingServiceBase { + public override async Task Connect( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context + ) { + try { + logger.LogInformation( "Inbound stream starting..." ); + var stream = messageStreamManager.Create( requestStream, responseStream, context ); + logger.LogInformation( "Stream #{StreamNo} created", stream.InstanceNo ); + + // The stream is closed when the method returns. + // We thus wait for the read loop to complete (meaning that this client is no longer interested in the stream). + await stream.ReadTask; + + logger.LogInformation( "Stream #{StreamNo} completed", stream.InstanceNo ); + } + catch ( Exception ex ) { + logger.LogError( ex, "Inbound stream failed" ); + } + } +} \ No newline at end of file diff --git a/src/Networking.Server/MessagingServerMarker.cs b/src/Networking.Server/MessagingServerMarker.cs new file mode 100644 index 00000000..659167f2 --- /dev/null +++ b/src/Networking.Server/MessagingServerMarker.cs @@ -0,0 +1,8 @@ +namespace Drift.Networking.Server; + +internal sealed class MessagingServerMarker { + internal bool EndpointsMapped { + get; + set; + } +} \ No newline at end of file diff --git a/src/Networking.Server/Networking.Server.csproj b/src/Networking.Server/Networking.Server.csproj new file mode 100644 index 00000000..faaa011b --- /dev/null +++ b/src/Networking.Server/Networking.Server.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs b/src/Networking.Server/ServiceCollectionExtensions.cs similarity index 55% rename from src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs rename to src/Networking.Server/ServiceCollectionExtensions.cs index 79843ab4..ec260791 100644 --- a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs +++ b/src/Networking.Server/ServiceCollectionExtensions.cs @@ -1,47 +1,47 @@ -using Grpc.AspNetCore.Server; +using Grpc.AspNetCore.Server; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -namespace Drift.Networking.PeerStreaming.Server; +namespace Drift.Networking.Server; public static class ServiceCollectionExtensions { - public static void AddPeerStreamingServer( + public static void AddMessagingServer( this IServiceCollection services, Action? configureOptions = null ) { - services.AddSingleton(); + services.AddSingleton(); services.AddGrpc( options => configureOptions?.Invoke( options ) ); - services.AddTransient(); + services.AddTransient(); } - public static void MapPeerStreamingServerEndpoints( this IEndpointRouteBuilder app ) { - var marker = app.ServiceProvider.GetService(); + public static void MapMessagingServerEndpoints( this IEndpointRouteBuilder app ) { + var marker = app.ServiceProvider.GetService(); if ( marker == null ) { throw new InvalidOperationException( - $"Unable to find the required services. Add them by calling '{nameof(IServiceCollection)}.{nameof(AddPeerStreamingServer)}'." + $"Unable to find the required services. Add them by calling '{nameof(IServiceCollection)}.{nameof(AddMessagingServer)}'." ); } - app.MapGrpcService(); + app.MapGrpcService(); marker.EndpointsMapped = true; } } -internal sealed class PeerStreamingServerValidationFilter : IStartupFilter { +internal sealed class MessagingServerValidationFilter : IStartupFilter { public Action Configure( Action next ) { return app => { next( app ); - var marker = app.ApplicationServices.GetRequiredService(); + var marker = app.ApplicationServices.GetRequiredService(); if ( !marker.EndpointsMapped ) { throw new InvalidOperationException( - $"Server endpoints were not mapped. Map them by calling '{nameof(IEndpointRouteBuilder)}.{nameof(ServiceCollectionExtensions.MapPeerStreamingServerEndpoints)}'." ); + $"Server endpoints were not mapped. Map them by calling '{nameof(IEndpointRouteBuilder)}.{nameof(ServiceCollectionExtensions.MapMessagingServerEndpoints)}'." ); } }; } -} \ No newline at end of file +} diff --git a/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs b/src/Networking.Tests/AssemblyInfo.cs similarity index 100% rename from src/Networking.PeerStreaming.Tests/AssemblyInfo.cs rename to src/Networking.Tests/AssemblyInfo.cs diff --git a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs b/src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs similarity index 98% rename from src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs rename to src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs index d4a78d08..aa0c9ed6 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs +++ b/src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs @@ -2,7 +2,7 @@ using Grpc.Core; using Channel = System.Threading.Channels.Channel; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; internal sealed record DuplexStreamEndpoint( TRequest RequestStream, TResponse ResponseStream ); diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs b/src/Networking.Tests/Helpers/TestPeerMessage.cs similarity index 69% rename from src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs rename to src/Networking.Tests/Helpers/TestPeerMessage.cs index 4cef4584..74564a77 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs +++ b/src/Networking.Tests/Helpers/TestPeerMessage.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; -internal sealed class TestPeerMessage : IPeerRequest, IPeerResponse { +internal sealed class TestPeerMessage : IRequest, IResponse { public static string MessageType => "test-peer-message"; public static JsonTypeInfo JsonInfo => TestPeerMessageJsonContext.Default.TestPeerMessage; @@ -14,7 +14,7 @@ internal sealed class TestPeerMessage : IPeerRequest, IPeerResp [JsonSerializable( typeof(TestPeerMessage) )] internal sealed partial class TestPeerMessageJsonContext : JsonSerializerContext; -internal sealed class TestMessageHandler : IPeerMessageHandler { +internal sealed class TestMessageHandler : IMessageHandler { public TestPeerMessage? LastMessage { get; private set; @@ -23,9 +23,9 @@ public TestPeerMessage? LastMessage { public string MessageType => TestPeerMessage.MessageType; public Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ) { var message = converter.FromEnvelope( envelope ); diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs b/src/Networking.Tests/Helpers/TestServerCallContext.cs similarity index 97% rename from src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs rename to src/Networking.Tests/Helpers/TestServerCallContext.cs index 01212db3..6068ee79 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs +++ b/src/Networking.Tests/Helpers/TestServerCallContext.cs @@ -1,6 +1,6 @@ using Grpc.Core; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; internal sealed class TestServerCallContext : ServerCallContext { private readonly Metadata _requestHeaders; diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs b/src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs similarity index 65% rename from src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs rename to src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs index 3129b711..86012438 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs +++ b/src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs @@ -1,15 +1,15 @@ using Drift.Networking.Grpc.Generated; using Grpc.Core; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; internal static class TestServerCallContextExtensions { public static ( - DuplexStreamEndpoint, IAsyncStreamReader> Client, - DuplexStreamEndpoint, IServerStreamWriter> Server + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server ) CreateDuplexStreams( this TestServerCallContext serverContext ) { - return InMemoryDuplexStreamPair.Create( serverContext ); + return InMemoryDuplexStreamPair.Create( serverContext ); } internal static ( diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs new file mode 100644 index 00000000..e2accc94 --- /dev/null +++ b/src/Networking.Tests/InboundTests.cs @@ -0,0 +1,77 @@ +using Drift.Networking.Core; +using Drift.Networking.Core.Messages; +using Drift.Networking.Server; +using Drift.Networking.Tests.Helpers; +using Drift.TestUtilities; + +namespace Drift.Networking.Tests; + +internal sealed class InboundTests { + [Test] + public async Task InboundStreamIsClosedWhenCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new MessageResponseCorrelator( logger ); + var envelopeConverter = new MessageEnvelopeConverter(); + var messageStreamManager = new MessageStreamManager( + logger, + null, + new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new MessagingOptions { StoppingToken = cts.Token } + ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); + + // Act / Assert + var serverStreams = duplexStreams.Server; + var connectTask = + inboundMessageService.Connect( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + + Assert.That( connectTask.IsCompleted, Is.False ); + + await cts.CancelAsync(); + + await Task.WhenAny( connectTask, Task.Delay( 1000 ) ); + + Assert.That( connectTask.IsCompleted, Is.True ); + } + + [Test] + public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new MessageResponseCorrelator( logger ); + var envelopeConverter = new MessageEnvelopeConverter(); + var messageStreamManager = new MessageStreamManager( + logger, + null, + new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new MessagingOptions { StoppingToken = cts.Token } + ); + var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + // Act + var connectTask = inboundMessageService.Connect( + duplexStreams.Server.RequestStream, + duplexStreams.Server.ResponseStream, + callContext + ); + + // Assert + Assert.That( connectTask.IsCompleted, Is.False ); + + await Task.WhenAny( connectTask, Task.Delay( 1000 ) ); + + Assert.That( connectTask.IsCompleted, Is.False ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs similarity index 55% rename from src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs rename to src/Networking.Tests/MessageStreamManagerTests.cs index 9e930af8..becf03bf 100644 --- a/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -1,33 +1,33 @@ -using Drift.Networking.PeerStreaming.Core; -using Drift.Networking.PeerStreaming.Core.Messages; -using Drift.Networking.PeerStreaming.Tests.Helpers; +using Drift.Networking.Core; +using Drift.Networking.Core.Messages; +using Drift.Networking.Tests.Helpers; using Drift.TestUtilities; -namespace Drift.Networking.PeerStreaming.Tests; +namespace Drift.Networking.Tests; -internal sealed class PeerStreamManagerTests { +internal sealed class MessageStreamManagerTests { [Test] public async Task IncomingMessageIsDispatchedToHandler() { // Arrange var cts = new CancellationTokenSource(); var logger = new StringLogger( TestContext.Out ); var testMessageHandler = new TestMessageHandler(); - var envelopeConverter = new PeerMessageEnvelopeConverter(); - var responseCorrelator = new PeerResponseCorrelator( logger ); - var dispatcher = new PeerMessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); - var peerStreamManager = new PeerStreamManager( + var envelopeConverter = new MessageEnvelopeConverter(); + var responseCorrelator = new MessageResponseCorrelator( logger ); + var dispatcher = new MessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); + var messageStreamManager = new MessageStreamManager( logger, null, dispatcher, - new PeerStreamingOptions { StoppingToken = cts.Token } + new MessagingOptions { StoppingToken = cts.Token } ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); var duplexStreams = callContext.CreateDuplexStreams(); var serverStreams = duplexStreams.Server; - var stream = peerStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); - var converter = new PeerMessageEnvelopeConverter(); + var stream = messageStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + var converter = new MessageEnvelopeConverter(); // Act var clientStreams = duplexStreams.Client; diff --git a/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj b/src/Networking.Tests/Networking.Tests.csproj similarity index 62% rename from src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj rename to src/Networking.Tests/Networking.Tests.csproj index 42b50c1e..733c8c6e 100644 --- a/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj +++ b/src/Networking.Tests/Networking.Tests.csproj @@ -1,12 +1,12 @@ - + - - + + From 2e1954f832859f411243ea76520d8edfdb0002ea Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:31:32 +0200 Subject: [PATCH 14/31] wip --- .gitignore | 1 + .../Versioning/VersioningTests.cs | 2 +- build/NukeBuild.Binaries.cs | 2 +- build/NukeBuild.Container.cs | 2 +- build/NukeBuild.Test.cs | 12 +-- build/NukeBuild.TestContainerlab.cs | 53 ++++++++----- containerlab/README.md | 76 +++---------------- containerlab/cooperation-test.clab.yaml | 7 +- containerlab/simple-test.clab.yaml | 4 - .../Subcommands/Start/AgentStartParameters.cs | 1 + 10 files changed, 55 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index 80a95396..8319e2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ obj/ .idea/.idea.Drift/.idea/watcherTasks.xml .idea/.idea.Drift/.idea/encodings.xml .idea/.idea.Drift.Build/.idea/encodings.xml +.air/ *.DotSettings *.received.* artifacts/ diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index c94378af..10fd99e1 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -31,7 +31,7 @@ public async Task DefaultVersioningVersionTest() { [Test] public async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange - var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary ); + var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( _ => NukeBuildWithArbitraryTarget.Arbitrary ); // Act var factory = new VersioningStrategyFactory( build ); diff --git a/build/NukeBuild.Binaries.cs b/build/NukeBuild.Binaries.cs index 7a51e37b..a6cfdf36 100644 --- a/build/NukeBuild.Binaries.cs +++ b/build/NukeBuild.Binaries.cs @@ -30,7 +30,7 @@ sealed partial class NukeBuild { Log.Information( "Publishing {Runtime} build to {PublishDir}", Platform, publishDir ); Log.Debug( "Supported runtimes are {SupportedRuntimes}", string.Join( ", ", SupportedRuntimes ) ); DotNetPublish( s => s - .SetProject( Solution.Cli ) + .SetProject( Solution.Cli.Cli ) .SetConfiguration( Configuration ) .SetOutput( publishDir ) .SetSelfContained( true ) diff --git a/build/NukeBuild.Container.cs b/build/NukeBuild.Container.cs index e442ba33..c90d5411 100644 --- a/build/NukeBuild.Container.cs +++ b/build/NukeBuild.Container.cs @@ -45,7 +45,7 @@ partial class NukeBuild { var version = await Versioning.Value.GetVersionAsync(); - _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid().ToString( "N" )}" ) ); + _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid():N}" ) ); Log.Information( "Building container image..." ); // var created = DateTime.UtcNow.ToString( "o", CultureInfo.InvariantCulture ); // o = round-trip format / ISO 8601 diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index 13e15344..d04dbee1 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -22,7 +22,7 @@ sealed partial class NukeBuild { throw new PlatformNotSupportedException(); Target Test => _ => _ - .DependsOn( TestSelf, TestUnit, TestE2E, TestE2E_Clab ); + .DependsOn( TestSelf, TestUnit, TestE2E ); Target TestSelf => _ => _ .Before( BuildInfo ) @@ -70,7 +70,7 @@ sealed partial class NukeBuild { ); Target TestE2E => _ => _ - .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container ); + .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container, TestE2E_Clab ); Target TestE2E_General => _ => _ .DependsOn( Build ) @@ -81,7 +81,7 @@ sealed partial class NukeBuild { Log.Information( "Running general E2E tests" ); DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_General ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_General ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -105,7 +105,7 @@ sealed partial class NukeBuild { var envVars = new Dictionary { { "DRIFT_BINARY_PATH", driftBinary }, }; DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_Binary ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Binary ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -118,7 +118,7 @@ sealed partial class NukeBuild { ); Target TestE2E_Container => _ => _ - .DependsOn( PublishBinaries, BuildContainerImage ) + .DependsOn( BuildContainerImage ) .After( TestUnit ) .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) .Executes( async () => { @@ -145,7 +145,7 @@ sealed partial class NukeBuild { } return settings - .SetProjectFile( Solution.Cli_E2ETests_Container ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Container ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs index c790de52..206e8df7 100644 --- a/build/NukeBuild.TestContainerlab.cs +++ b/build/NukeBuild.TestContainerlab.cs @@ -83,10 +83,7 @@ sealed partial class NukeBuild { } if ( !await IsContainerlabAvailableAsync() ) { - throw new Exception( - "Containerlab does not appear to be installed or in PATH. " + - "See https://containerlab.dev/install/ for installation instructions." - ); + throw new Exception( "Containerlab does not appear to be installed or in PATH." ); } var casesToRun = SelectTestCases(); @@ -103,15 +100,17 @@ sealed partial class NukeBuild { foreach ( var testCase in casesToRun ) { var run = passed + failed + 1; - Log.Information( "━━━ Test case: {Name} ({Run}/{Total}) ━━━", testCase.Name, run, total ); + Log.Information( "---------------------------------------------" ); + Log.Information( "{Name} ({Run}/{Total})", testCase.Name, run, total ); + Log.Information( "---------------------------------------------" ); if ( await RunTestCaseAsync( testCase ) ) { passed++; - Log.Information( "PASS: {Name}", testCase.Name ); + Log.Information( "🟢 PASS: {Name}", testCase.Name ); } else { failed++; - Log.Error( "FAIL: {Name}", testCase.Name ); + Log.Error( "🔴 FAIL: {Name}", testCase.Name ); } } @@ -128,6 +127,8 @@ private ContainerlabTestCase[] SelectTestCases() { return TestCases; } + Log.Warning( "Only selecting test case(s) matching topology '{Topology}'", ClabTopology ); + var selected = TestCases.Where( tc => tc.Name == ClabTopology ).ToArray(); if ( !selected.Any() ) { throw new Exception( @@ -201,8 +202,9 @@ private static async Task DeployTopologyAsync( string topologyFile ) { timeout: TimeSpan.FromMinutes( 5 ) ).AssertZeroExitCode(); - Log.Information( "Waiting for containers to be ready..." ); - await Task.Delay( TimeSpan.FromSeconds( 10 ) ); + // TODO try to disable fixed waiting + // Log.Information( "Waiting for containers to be ready..." ); + // await Task.Delay( TimeSpan.FromSeconds( 10 ) ); } private static void DestroyTopologyIfExists( string topologyFile ) { @@ -267,7 +269,8 @@ private static async Task RunScanAndAssertAsync( AbsolutePath specFile, Containe Log.Information( "Running scan for test case: {Name}", testCase.Name ); // Give agent(s) a moment to finish starting up - await Task.Delay( TimeSpan.FromSeconds( 5 ) ); + // TODO try without fixed delay + // await Task.Delay( TimeSpan.FromSeconds( 5 ) ); Log.Debug( "Copying spec to CLI container {Container}...", testCase.CliContainer ); Docker( $"cp {specFile} {testCase.CliContainer}:/tmp/spec.yaml" ).AssertZeroExitCode(); @@ -310,17 +313,14 @@ private static void AssertScanOutput( ContainerlabTestCase testCase, IEnumerable Log.Information( "All {Count} assertions passed for '{Name}'", testCase.Assertions.Length, testCase.Name ); } - // ── Process helpers ──────────────────────────────────────────────────────── - - /// - /// Containerlab writes all its output to stderr by design. - /// Use a custom logger that routes both stdout and stderr to Debug - /// to avoid GH Actions annotating every Containerlab log line as an error. - /// private static void ClabLogger( OutputType type, string text ) => Log.Debug( text ); - private static IProcess Clab( string args, AbsolutePath workDir = null, TimeSpan? timeout = null, - bool logOutput = true ) => + private static IProcess Clab( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null, + bool logOutput = true + ) => ProcessTasks.StartProcess( "containerlab", args, workingDirectory: workDir, @@ -329,7 +329,11 @@ private static IProcess Clab( string args, AbsolutePath workDir = null, TimeSpan logger: logOutput ? ClabLogger : null ); - private static IProcess Docker( string args, AbsolutePath workDir = null, TimeSpan? timeout = null ) => + private static IProcess Docker( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null + ) => ProcessTasks.StartProcess( "docker", args, workingDirectory: workDir, @@ -337,7 +341,14 @@ private static IProcess Docker( string args, AbsolutePath workDir = null, TimeSp ); } -/// A Containerlab integration test case. +/// +/// A Containerlab-based E2E test case. +/// +/// Test case name +/// A Containerlab topology file +/// A Drift spec file +/// Name of the container hosting the Drift CLI +/// A collection of assertions to be run against the scan output sealed record ContainerlabTestCase( string Name, string TopologyFile, diff --git a/containerlab/README.md b/containerlab/README.md index 6450a9b1..df33876b 100644 --- a/containerlab/README.md +++ b/containerlab/README.md @@ -1,84 +1,30 @@ # Containerlab Integration Testing -This directory contains containerlab topologies for testing Drift's distributed network scanning capabilities. +This directory contains [Containerlab](https://containerlab.dev/) topologies for testing Drift's distributed network scanning capabilities. ## Prerequisites - [Containerlab](https://containerlab.dev/) installed -- Docker with sufficient resources (at least 4GB RAM, 2 CPUs) +- Docker, or Podman with Docker CLI shim - Drift Docker image: `localhost:5000/drift:dev` -## Quick Start - -The easiest way to run containerlab integration tests is via Nuke: +## Quick start ```bash -# Run all tests including containerlab integration +# Run ALL tests including Containerlab integration tests dotnet nuke Test # Run only containerlab tests -dotnet nuke TestContainerlab --skip test +dotnet nuke Test_E2EClab # Run a single topology for debugging -dotnet nuke TestContainerlab --skip test --clab-topology simple-test +dotnet nuke Test_E2EClab --clab-topology simple-test # Keep containers running after tests (for debugging) -dotnet nuke TestContainerlab --skip test --keep-clab-running -``` - -## Topologies - -### `simple-test.clab.yaml` - -Minimal topology: 1 agent, 1 CLI, 1 target on the management network. - -``` - CLI Agent1 Target1 -(172.20.20.x) (172.20.20.x) (172.20.20.x) - | | | - +--------------------+---------------------+ - 172.20.20.0/24 -``` - -**Assertions:** -- `172.20.20.0/24` is scanned -- 2/2 scan operations successful (local + 1 agent) -- Scan completes successfully - -### `cooperation-test.clab.yaml` - -Multi-agent cooperation topology: 3 agents, 1 CLI, 5 targets on a flat management network. -Tests multi-agent coordination and result merging. - -``` -CLI + Agent1 + Agent2 + Agent3 + Target1..5 - | - 172.20.20.0/24 -``` - -**Assertions:** -- `172.20.20.0/24` is scanned -- 4/4 scan operations successful (local + 3 agents) -- Scan completes successfully - -### `subnet-isolation-test.clab.yaml` - -Subnet isolation topology: 2 agents, 1 CLI, 4 targets. Each agent is connected to its own -isolated subnet via veth links and an in-container bridge. Tests that each agent only reports -devices reachable on its own subnet. - +dotnet nuke Test_E2EClab --keep-clab-running ``` -CLI ─── mgmt (172.20.20.0/24) ─── Agent1 ─── subnet-a (192.168.10.0/24) ─── Target-A1, Target-A2 - └─ Agent2 ─── subnet-b (192.168.20.0/24) ─── Target-B1, Target-B2 -``` - -**Assertions:** -- `192.168.10.0/24` is scanned (by Agent1) -- `192.168.20.0/24` is scanned (by Agent2) -- 7/7 scan operations successful (mgmt×3 + subnet-a×2 + subnet-b×2) -- Scan completes successfully -## Agent Identity +## Agent identity Agents in these topologies use the `--id` flag to set a fixed, predictable agent ID: @@ -93,7 +39,7 @@ The `--id` flag is hidden from the help output and logs a warning when used — In production, agents generate and persist their own ID at `/root/.config/drift/agent/agent-identity.json`. -## Nuke Target Parameters +## NUKE target parameters | Parameter | Description | |---|---| @@ -103,7 +49,7 @@ In production, agents generate and persist their own ID at `/root/.config/drift/ ## Troubleshooting -**Deploy fails with "Link not found"** — This is a known issue with rootless Podman + pasta networking. The Nuke target works around it by pre-creating the `clab` management network before deploying. If you are deploying manually, run: +**Deploy fails with "Link not found"** — This is a known issue with rootless Podman + pasta networking. The NUKE target works around it by pre-creating the `clab` management network before deploying. If you are deploying manually, run: ```bash docker network rm clab 2>/dev/null; docker network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab containerlab deploy --topo simple-test.clab.yaml @@ -116,7 +62,7 @@ docker logs clab-drift-cooperation-test-agent1 docker logs clab-drift-subnet-isolation-test-agent1 ``` -**Cannot reach agents** — Verify containers are on the management network: +**Cannot reach agents** — Verify containers are on same network: ```bash docker network inspect clab ``` diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml index e7859935..092c860d 100644 --- a/containerlab/cooperation-test.clab.yaml +++ b/containerlab/cooperation-test.clab.yaml @@ -2,7 +2,7 @@ name: drift-cooperation-test # Multi-agent cooperation topology # -# All nodes share a single flat management network (172.20.20.0/24). +# All nodes share a single flat network (172.20.20.0/24). # Three agents cooperate to scan the same subnet, testing: # - Multi-agent coordination (3 agents) # - Result merging from multiple agents @@ -13,32 +13,27 @@ name: drift-cooperation-test topology: nodes: - # Controller/CLI node cli: kind: linux image: localhost:5000/drift:dev entrypoint: /bin/sh cmd: -c "sleep infinity" - # Agent 1 agent1: kind: linux image: localhost:5000/drift:dev cmd: agent start --adoptable --port 5000 --id agentid_coop_agent1 - # Agent 2 agent2: kind: linux image: localhost:5000/drift:dev cmd: agent start --adoptable --port 5000 --id agentid_coop_agent2 - # Agent 3 agent3: kind: linux image: localhost:5000/drift:dev cmd: agent start --adoptable --port 5000 --id agentid_coop_agent3 - # Target devices target1: kind: linux image: alpine:latest diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml index 6c8bb507..e57eee20 100644 --- a/containerlab/simple-test.clab.yaml +++ b/containerlab/simple-test.clab.yaml @@ -1,16 +1,13 @@ name: drift-simple-test -# Simplified topology for initial testing topology: nodes: - # Controller/CLI node cli: kind: linux image: localhost:5000/drift:dev entrypoint: /bin/sh cmd: -c "sleep infinity" - # Single agent for basic test agent1: kind: linux image: localhost:5000/drift:dev @@ -18,7 +15,6 @@ topology: ports: - "5001:5000" - # Single target device target1: kind: linux image: alpine:latest diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs index f93ae929..5b71a364 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -14,6 +14,7 @@ internal static class Options { DefaultValueFactory = _ => Ports.AgentDefault, Description = "Set the port used for communication" }; + // TODO remove after hub-and-spoke transition? internal static readonly Option Id = new("--id") { Description = "Set a specific agent ID (for testing purposes)", Hidden = true }; From 9e14ed5af3c2e5930ea63da96fdbfe06ca8ef63b Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:38:08 +0200 Subject: [PATCH 15/31] f --- containerlab/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containerlab/README.md b/containerlab/README.md index df33876b..6ebfaafb 100644 --- a/containerlab/README.md +++ b/containerlab/README.md @@ -1,4 +1,4 @@ -# Containerlab Integration Testing +# Containerlab integration testing This directory contains [Containerlab](https://containerlab.dev/) topologies for testing Drift's distributed network scanning capabilities. From 4dc189bfb64bdf6f6e6576132302685b8b5d6060 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:40:35 +0200 Subject: [PATCH 16/31] f --- Drift.sln | 14 ++++++ src/Agent.Host.Tests/Agent.Host.Tests.csproj | 1 - src/Agent.Host.Tests/MessageHandlerTests.cs | 40 +-------------- src/Agent.Host/AgentHostAssemblyMarker.cs | 6 --- src/Agent.Host/ServiceCollectionExtensions.cs | 2 +- src/Coordinator.Host.Tests/AssemblyInfo.cs | 1 + .../Coordinator.Host.Tests.csproj | 19 +++++++ .../MessageHandlerTests.cs | 26 ++++++++++ src/Coordinator.Host/AgentHost.cs | 12 +++++ src/Coordinator.Host/Coordinator.Host.csproj | 4 ++ src/Messaging.Tests/MessageHandlerTests.cs | 50 +++++++++++++++++++ src/Messaging.Tests/Messaging.Tests.csproj | 19 +++++++ src/Networking.Core.Abstractions/Empty.cs | 1 + 13 files changed, 149 insertions(+), 46 deletions(-) delete mode 100644 src/Agent.Host/AgentHostAssemblyMarker.cs create mode 100644 src/Coordinator.Host.Tests/AssemblyInfo.cs create mode 100644 src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj create mode 100644 src/Coordinator.Host.Tests/MessageHandlerTests.cs create mode 100644 src/Coordinator.Host/AgentHost.cs create mode 100644 src/Messaging.Tests/MessageHandlerTests.cs create mode 100644 src/Messaging.Tests/Messaging.Tests.csproj diff --git a/Drift.sln b/Drift.sln index deeebebc..86f9d9ce 100644 --- a/Drift.sln +++ b/Drift.sln @@ -113,6 +113,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coordinator", "Coordinator" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cli", "Cli", "{DD31E4C8-7058-4407-AEC4-43EDA0AB06D1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host.Tests", "src\Coordinator.Host.Tests\Coordinator.Host.Tests.csproj", "{CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Tests", "src\Messaging.Tests\Messaging.Tests.csproj", "{87C0FA35-BC55-4B86-AAAD-C425C9A0E050}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -269,6 +273,14 @@ Global {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.Build.0 = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} @@ -296,5 +308,7 @@ Global {B75C5C0C-A5A2-419E-B0DD-342A44B1B48D} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} {130363FC-478E-472A-978B-731D39E3E264} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} EndGlobalSection EndGlobal diff --git a/src/Agent.Host.Tests/Agent.Host.Tests.csproj b/src/Agent.Host.Tests/Agent.Host.Tests.csproj index faa044fe..435d7306 100644 --- a/src/Agent.Host.Tests/Agent.Host.Tests.csproj +++ b/src/Agent.Host.Tests/Agent.Host.Tests.csproj @@ -6,7 +6,6 @@ - diff --git a/src/Agent.Host.Tests/MessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs index 82ff605e..e7afe190 100644 --- a/src/Agent.Host.Tests/MessageHandlerTests.cs +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -1,56 +1,20 @@ using System.Reflection; -using Drift.Messaging.Protocol; using Drift.Networking.Core.Abstractions; namespace Drift.Agent.Host.Tests; +// TODO almost duplicate of MessageHandlerTests in Coordinator.Host.Tests internal sealed class MessageHandlerTests { - private static readonly Assembly MessagesAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly; - private static readonly Assembly HandlersAssembly = typeof(AgentHostAssemblyMarker).Assembly; - private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IRequest<>) ); - private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IResponse) ); + private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); [Test] public void FindMessagesAndHandlers() { using ( Assert.EnterMultipleScope() ) { - Assert.That( RequestTypes.ToList(), Has.Count.GreaterThan( 1 ), "No request messages found via reflection" ); - Assert.That( ResponseTypes.ToList(), Has.Count.GreaterThan( 1 ), "No response messages found via reflection" ); Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); } } - [Explicit( "Disabled until interface has settled" )] - [TestCaseSource( nameof(RequestTypes) )] - [TestCaseSource( nameof(ResponseTypes) )] - public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { - var messageTypeValue = type - .GetProperty( nameof(IMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! - .GetValue( null ) as string; - - Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); - - var jsonInfoValue = type - .GetProperty( nameof(IMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! - .GetValue( null ); - - Assert.That( jsonInfoValue, Is.Not.Null ); - } - - private static List GetAllConcreteMessageTypes( Type baseType ) { - return MessagesAssembly - .GetTypes() - .Concat( [typeof(Empty)] ) - .Where( t => t is { IsAbstract: false, IsInterface: false } ) - .Where( t => - // Generic base type - t.GetInterfaces().Any( i => i.IsGenericType && i.GetGenericTypeDefinition() == baseType ) || - // Non-generic base type - baseType.IsAssignableFrom( t ) - ) - .ToList(); - } - private static List GetAllConcreteHandlerTypes() { return HandlersAssembly .GetTypes() diff --git a/src/Agent.Host/AgentHostAssemblyMarker.cs b/src/Agent.Host/AgentHostAssemblyMarker.cs deleted file mode 100644 index e38447cb..00000000 --- a/src/Agent.Host/AgentHostAssemblyMarker.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Drift.Agent.Host; - -// Justification: marker class -#pragma warning disable S2094 -public sealed class AgentHostAssemblyMarker; -#pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.Host/ServiceCollectionExtensions.cs b/src/Agent.Host/ServiceCollectionExtensions.cs index f43d65e8..d628abef 100644 --- a/src/Agent.Host/ServiceCollectionExtensions.cs +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -12,4 +12,4 @@ public void AddAgentHandlers() { services.AddScoped(); } } -} +} \ No newline at end of file diff --git a/src/Coordinator.Host.Tests/AssemblyInfo.cs b/src/Coordinator.Host.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Coordinator.Host.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj b/src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj new file mode 100644 index 00000000..031d7da3 --- /dev/null +++ b/src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Coordinator.Host.Tests/MessageHandlerTests.cs b/src/Coordinator.Host.Tests/MessageHandlerTests.cs new file mode 100644 index 00000000..c5015716 --- /dev/null +++ b/src/Coordinator.Host.Tests/MessageHandlerTests.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Coordinator.Host.Tests; + +// TODO almost duplicate of MessageHandlerTests in Agent.Host.Tests +internal sealed class MessageHandlerTests { + private static readonly Assembly HandlersAssembly = typeof(CoordinatorHost).Assembly; + private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); + + [Explicit( "Disable until implemented" )] + [Test] + public void FindMessagesAndHandlers() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); + } + } + + private static List GetAllConcreteHandlerTypes() { + return HandlersAssembly + .GetTypes() + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => typeof(IMessageHandler).IsAssignableFrom( t ) ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Coordinator.Host/AgentHost.cs b/src/Coordinator.Host/AgentHost.cs new file mode 100644 index 00000000..065df926 --- /dev/null +++ b/src/Coordinator.Host/AgentHost.cs @@ -0,0 +1,12 @@ +using Drift.Messaging.Protocol; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Drift.Coordinator.Host; + +public static class CoordinatorHost { + // TODO implement +} \ No newline at end of file diff --git a/src/Coordinator.Host/Coordinator.Host.csproj b/src/Coordinator.Host/Coordinator.Host.csproj index 228a23b5..e32b5757 100644 --- a/src/Coordinator.Host/Coordinator.Host.csproj +++ b/src/Coordinator.Host/Coordinator.Host.csproj @@ -1,5 +1,9 @@ + + + + diff --git a/src/Messaging.Tests/MessageHandlerTests.cs b/src/Messaging.Tests/MessageHandlerTests.cs new file mode 100644 index 00000000..71bdd324 --- /dev/null +++ b/src/Messaging.Tests/MessageHandlerTests.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using Drift.Messaging.Protocol; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Messaging.Tests; + +internal sealed class MessageHandlerTests { + private static readonly Assembly MessagesAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly; + private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IRequest<>) ); + private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IResponse) ); + + [Test] + public void FindMessagesAndHandlers() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( RequestTypes.ToList(), Has.Count.GreaterThan( 1 ), "No request messages found via reflection" ); + Assert.That( ResponseTypes.ToList(), Has.Count.GreaterThan( 1 ), "No response messages found via reflection" ); + } + } + + [TestCaseSource( nameof(RequestTypes) )] + [TestCaseSource( nameof(ResponseTypes) )] + public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { + // Arrange / Act + var messageTypeValue = type + .GetProperty( nameof(IMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ) as string; + + var jsonInfoValue = type + .GetProperty( nameof(IMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ); + + // Assert + Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); + Assert.That( jsonInfoValue, Is.Not.Null ); + } + + private static List GetAllConcreteMessageTypes( Type baseType ) { + return MessagesAssembly + .GetTypes() + //.Concat( [typeof(Empty)] ) // TODO what about Empty? + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => + // Generic base type + t.GetInterfaces().Any( i => i.IsGenericType && i.GetGenericTypeDefinition() == baseType ) || + // Non-generic base type + baseType.IsAssignableFrom( t ) + ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Messaging.Tests/Messaging.Tests.csproj b/src/Messaging.Tests/Messaging.Tests.csproj new file mode 100644 index 00000000..af3d7890 --- /dev/null +++ b/src/Messaging.Tests/Messaging.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Networking.Core.Abstractions/Empty.cs b/src/Networking.Core.Abstractions/Empty.cs index 0f7ffe3c..995f14e6 100644 --- a/src/Networking.Core.Abstractions/Empty.cs +++ b/src/Networking.Core.Abstractions/Empty.cs @@ -2,6 +2,7 @@ namespace Drift.Networking.Core.Abstractions; +// TODO necessary? public class Empty : IResponse { private Empty() { } From 399b0f35291fbc40c8ba4ba183eaa00f34a1f280 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:42:03 +0200 Subject: [PATCH 17/31] f --- src/Agent.Host.Tests/MessageHandlerTests.cs | 1 + src/Coordinator.Host.Tests/MessageHandlerTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Agent.Host.Tests/MessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs index e7afe190..938f569f 100644 --- a/src/Agent.Host.Tests/MessageHandlerTests.cs +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -3,6 +3,7 @@ namespace Drift.Agent.Host.Tests; +// TODO currently not doing anything useful // TODO almost duplicate of MessageHandlerTests in Coordinator.Host.Tests internal sealed class MessageHandlerTests { private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; diff --git a/src/Coordinator.Host.Tests/MessageHandlerTests.cs b/src/Coordinator.Host.Tests/MessageHandlerTests.cs index c5015716..f5d344da 100644 --- a/src/Coordinator.Host.Tests/MessageHandlerTests.cs +++ b/src/Coordinator.Host.Tests/MessageHandlerTests.cs @@ -3,6 +3,7 @@ namespace Drift.Coordinator.Host.Tests; +// TODO currently not doing anything useful // TODO almost duplicate of MessageHandlerTests in Agent.Host.Tests internal sealed class MessageHandlerTests { private static readonly Assembly HandlersAssembly = typeof(CoordinatorHost).Assembly; From 3429bbdfaca9c9ab1b6895a294e5233025316f33 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:53:17 +0200 Subject: [PATCH 18/31] f --- src/Agent.Host/Scan/ScanSubnetRequestHandler.cs | 4 ++-- src/Agent.Host/Subnets/SubnetsRequestHandler.cs | 2 +- src/Networking.Core.Abstractions/MessageHandlerExtensions.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs index 15a2b96e..b699140e 100644 --- a/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -42,7 +42,7 @@ CancellationToken cancellationToken ); var completeResponse = new ScanSubnetCompleteResponse { Result = result }; - await stream.SendResponseAsync( converter, completeResponse, envelope.CorrelationId ); + await stream.SendAsync( converter, completeResponse, envelope.CorrelationId ); } finally { scanner.ResultUpdated -= policy.Handle; @@ -101,7 +101,7 @@ private void OnResultUpdated( object? sender, SubnetScanResult result ) { ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() }; - _stream.SendResponseFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); + _stream.SendFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); _logger.LogDebug( "Sent progress update: {Progress}% for {Cidr}", diff --git a/src/Agent.Host/Subnets/SubnetsRequestHandler.cs b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs index f5721f9d..94d7e01d 100644 --- a/src/Agent.Host/Subnets/SubnetsRequestHandler.cs +++ b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs @@ -25,6 +25,6 @@ CancellationToken cancellationToken logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); var response = new SubnetsResponse { Subnets = subnets }; - await stream.SendResponseAsync( converter, response, envelope.CorrelationId ); + await stream.SendAsync( converter, response, envelope.CorrelationId ); } } \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs index 6d8b4b16..a543bee3 100644 --- a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs +++ b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs @@ -5,7 +5,7 @@ public static class MessageHandlerExtensions { /// Helper to send a response with the correct correlation ID set. /// /// The type of the response message to send. - public static async Task SendResponseAsync( + public static async Task SendAsync( this IMessageStream stream, IMessageEnvelopeConverter converter, TResponse response, @@ -21,7 +21,7 @@ string correlationId /// Useful for progress updates that shouldn't block processing. /// /// The type of the response message to send. - public static void SendResponseFireAndForget( + public static void SendFireAndForget( this IMessageStream stream, IMessageEnvelopeConverter converter, TResponse response, From 49c59c710cbe47ad62849f1d7685181a4c1b4bec Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:05:40 +0200 Subject: [PATCH 19/31] f --- src/Agent.Host/Agent.Host.csproj | 1 - src/Coordinator.Host/AgentHost.cs | 2 + .../ServiceCollectionExtensions.cs | 4 +- src/Messaging.Client/AgentClient.cs | 2 - src/Messaging.Tests/MessageHandlerTests.cs | 2 +- .../MessageHandlerExtensions.cs | 42 ------------------- .../MessageStreamExtensions.cs | 42 +++++++++++++++++++ 7 files changed, 48 insertions(+), 47 deletions(-) delete mode 100644 src/Networking.Core.Abstractions/MessageHandlerExtensions.cs create mode 100644 src/Networking.Core.Abstractions/MessageStreamExtensions.cs diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj index 4f67bdb9..ae98a3fc 100644 --- a/src/Agent.Host/Agent.Host.csproj +++ b/src/Agent.Host/Agent.Host.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Coordinator.Host/AgentHost.cs b/src/Coordinator.Host/AgentHost.cs index 065df926..0ce642fb 100644 --- a/src/Coordinator.Host/AgentHost.cs +++ b/src/Coordinator.Host/AgentHost.cs @@ -7,6 +7,8 @@ namespace Drift.Coordinator.Host; +#pragma warning disable S2094 public static class CoordinatorHost { +#pragma warning restore S2094 // TODO implement } \ No newline at end of file diff --git a/src/Coordinator.Host/ServiceCollectionExtensions.cs b/src/Coordinator.Host/ServiceCollectionExtensions.cs index 08db94f0..fb3fe5f0 100644 --- a/src/Coordinator.Host/ServiceCollectionExtensions.cs +++ b/src/Coordinator.Host/ServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ namespace Drift.Coordinator.Host; public static class ServiceCollectionExtensions { +#pragma warning disable S1186 public static void AddCoordinatorHandlers( this IServiceCollection services ) { +#pragma warning restore S1186 } -} +} \ No newline at end of file diff --git a/src/Messaging.Client/AgentClient.cs b/src/Messaging.Client/AgentClient.cs index 302eb62c..35513ef8 100644 --- a/src/Messaging.Client/AgentClient.cs +++ b/src/Messaging.Client/AgentClient.cs @@ -157,7 +157,6 @@ CancellationToken cancellationToken } } - // All retries exhausted throw new AggregateException( $"Operation failed for agent {agent.Id} after {attempt} attempts", lastException! @@ -165,7 +164,6 @@ CancellationToken cancellationToken } private int CalculateBackoffDelay( int attempt ) { - // Exponential backoff: base * 2^(attempt-1) var delay = _options.RetryBaseDelayMs * Math.Pow( 2, attempt - 1 ); return (int) Math.Min( delay, _options.RetryMaxDelayMs ); } diff --git a/src/Messaging.Tests/MessageHandlerTests.cs b/src/Messaging.Tests/MessageHandlerTests.cs index 71bdd324..a3d7fe1f 100644 --- a/src/Messaging.Tests/MessageHandlerTests.cs +++ b/src/Messaging.Tests/MessageHandlerTests.cs @@ -37,7 +37,7 @@ public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { private static List GetAllConcreteMessageTypes( Type baseType ) { return MessagesAssembly .GetTypes() - //.Concat( [typeof(Empty)] ) // TODO what about Empty? + // .Concat( [typeof(Empty)] ) // TODO what about Empty? .Where( t => t is { IsAbstract: false, IsInterface: false } ) .Where( t => // Generic base type diff --git a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs deleted file mode 100644 index a543bee3..00000000 --- a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Drift.Networking.Core.Abstractions; - -public static class MessageHandlerExtensions { - /// - /// Helper to send a response with the correct correlation ID set. - /// - /// The type of the response message to send. - public static async Task SendAsync( - this IMessageStream stream, - IMessageEnvelopeConverter converter, - TResponse response, - string correlationId - ) where TResponse : IMessage { - var envelope = converter.ToEnvelope( response ); - envelope.ReplyTo = correlationId; - await stream.SendAsync( envelope ); - } - - /// - /// Helper to send a response without awaiting (fire and forget). - /// Useful for progress updates that shouldn't block processing. - /// - /// The type of the response message to send. - public static void SendFireAndForget( - this IMessageStream stream, - IMessageEnvelopeConverter converter, - TResponse response, - string correlationId - ) where TResponse : IMessage { - var envelope = converter.ToEnvelope( response ); - envelope.ReplyTo = correlationId; - _ = stream.SendAsync( envelope ).ContinueWith( - t => { - t.Exception?.Handle( ex => { - // TODO: Handle exception - return true; - } ); - }, - TaskContinuationOptions.OnlyOnFaulted - ); - } -} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/MessageStreamExtensions.cs b/src/Networking.Core.Abstractions/MessageStreamExtensions.cs new file mode 100644 index 00000000..7af7a746 --- /dev/null +++ b/src/Networking.Core.Abstractions/MessageStreamExtensions.cs @@ -0,0 +1,42 @@ +namespace Drift.Networking.Core.Abstractions; + +public static class MessageStreamExtensions { + extension( IMessageStream stream ) { + /// + /// Helper to send a response with the correct correlation ID set. + /// + /// The type of the response message to send. + public async Task SendAsync( + IMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + await stream.SendAsync( envelope ); + } + + /// + /// Helper to send a response without awaiting (fire and forget). + /// Useful for progress updates that shouldn't block processing. + /// + /// The type of the response message to send. + public void SendFireAndForget( + IMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + _ = stream.SendAsync( envelope ).ContinueWith( + t => { + t.Exception?.Handle( ex => { + // TODO: Handle exception + return true; + } ); + }, + TaskContinuationOptions.OnlyOnFaulted + ); + } + } +} \ No newline at end of file From 74a35259649f649e76fe2ab63a8ca951ddafd19c Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:15:05 +0200 Subject: [PATCH 20/31] f --- src/ArchTests/ArchTests.csproj | 1 - ...d.txt => AgentCommandTests.SuccessfulLifecycle.verified.txt} | 0 src/Cli.Tests/Commands/AgentCommandTests.cs | 2 +- 3 files changed, 1 insertion(+), 2 deletions(-) rename src/Cli.Tests/Commands/{AgentCommandTests.SuccessfulStartup.verified.txt => AgentCommandTests.SuccessfulLifecycle.verified.txt} (100%) diff --git a/src/ArchTests/ArchTests.csproj b/src/ArchTests/ArchTests.csproj index 73115aac..7d34c1ed 100644 --- a/src/ArchTests/ArchTests.csproj +++ b/src/ArchTests/ArchTests.csproj @@ -2,7 +2,6 @@ - false none diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt similarity index 100% rename from src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt rename to src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs index 7ebc9c3f..11f913cf 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.cs +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -20,7 +20,7 @@ public async Task RespectsCancellationToken() { } [Test] - public async Task SuccessfulStartup() { + public async Task SuccessfulLifecycle() { using var tcs = new CancellationTokenSource(); var runningCommand = await DriftTestCli.StartAgentAsync( From 1485ccd1b6f5f0176f82bf22eb715daa10df78ee Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:10:12 +0200 Subject: [PATCH 21/31] f --- .nuke/build.schema.json | 1 + build/NukeBuild.Test.cs | 16 ++++++++++++++- .../ScanCommandTests.Orchestration.cs | 1 - .../Network/Firewall/FirewallEvaluator.cs | 20 ++----------------- .../Utils/Network/Firewall/FirewallRules.cs | 10 ++++++---- src/Common/Network/IpNetworkUtils.cs | 2 +- .../MessageHandlerTests.cs | 2 +- src/Networking.Core/MessageStream.cs | 2 +- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index af8e28ea..33bb7c6a 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -64,6 +64,7 @@ "TestLocal", "TestSelf", "TestUnit", + "TestUnitLocal", "UpdateOui" ] }, diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index d04dbee1..e16f7b3e 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -41,10 +41,24 @@ sealed partial class NukeBuild { Target TestLocal => _ => _ .DependsOn( Test ) + .AssuredAfterFailure() .Executes( () => { var result = ProcessTasks.StartProcess( "dotnet", - "trx --verbosity verbose", + "trx", //"trx --verbosity verbose", + workingDirectory: RootDirectory + ); + result.AssertZeroExitCode(); + } + ); + + Target TestUnitLocal => _ => _ + .DependsOn( TestUnit ) + .AssuredAfterFailure() + .Executes( () => { + var result = ProcessTasks.StartProcess( + "dotnet", + "trx", //"trx --verbosity verbose", workingDirectory: RootDirectory ); result.AssertZeroExitCode(); diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs index 5c83c405..38a2475d 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -12,7 +12,6 @@ public async Task WithAgents_TwoDisjointSubnets_CombinesResults() { // Arrange var builder = Topologies.TwoAgentsDisjointSubnetsWithCli() .WithFirewall( fw => { - fw.DefaultPolicy = FirewallAction.Deny; // CLI must be able to reach agents to collect their results (control plane = same firewall). // Allow CLI→agent routing; deny-default blocks all other cross-subnet traffic (agent↔agent). fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent1-subnet" ) ); diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs index 16e46561..c98c5193 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs @@ -19,7 +19,6 @@ public FirewallEvaluator( FirewallRules rules, IReadOnlyList<(string Name, CidrB /// /// Evaluate if traffic is allowed from source to destination. - /// Supports subnet names, IP addresses, CIDR blocks, and wildcards. /// Rules are evaluated in order - first match wins. /// If no rule matches, the default policy from is applied. /// @@ -74,23 +73,8 @@ public bool IsAllowed( FirewallTarget source, FirewallTarget dest ) { /// private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { try { - var ipBytes = IPAddress.Parse( ip.Value ).GetAddressBytes(); - var cidrParts = cidr.ToString().Split( '/' ); - if ( cidrParts.Length != 2 ) { - return false; - } - - var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes(); - if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) { - return false; - } - - var mask = ~0u << ( 32 - prefixLength ); - var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3]; - var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) | - ( (uint) networkBytes[2] << 8 ) | networkBytes[3]; - - return ( ipInt & mask ) == ( networkInt & mask ); + var ipAddress = IPAddress.Parse( ip.Value ); + return IPNetwork2.Parse( cidr.ToString() ).Contains( ipAddress ); } catch { return false; diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs index f21b3a78..d2b9b9c6 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs @@ -2,9 +2,8 @@ namespace Drift.Cli.Tests.Utils.Network.Firewall; /// /// Represents firewall rules for controlling network visibility in test topologies. -/// Rules are evaluated in order (first match wins), with a default ALLOW policy. -/// Inspired by OPNsense firewall behavior. -/// Use FirewallEvaluator to evaluate rules with subnet awareness. +/// Rules are evaluated in order (first match wins), with a default DENY policy. +/// Use to evaluate rules with subnet awareness. /// public sealed class FirewallRules { private readonly List _rules = new(); @@ -30,7 +29,10 @@ public void Deny( FirewallTarget source, FirewallTarget destination ) { /// /// Gets or sets the policy applied when no rule matches. Defaults to . /// - public FirewallAction DefaultPolicy { get; set; } = FirewallAction.Allow; + public FirewallAction DefaultPolicy { + get; + set; + } = FirewallAction.Allow; public IReadOnlyList Rules => _rules.AsReadOnly(); } diff --git a/src/Common/Network/IpNetworkUtils.cs b/src/Common/Network/IpNetworkUtils.cs index da06221d..3796a756 100644 --- a/src/Common/Network/IpNetworkUtils.cs +++ b/src/Common/Network/IpNetworkUtils.cs @@ -60,7 +60,7 @@ public static IPAddress GetNetmask( int prefixLength ) { /// 172.16.0.0 to 172.31.255.255 (172.16.0.0/12) /// 192.168.0.0 to 192.168.255.255 (192.168.0.0/16) /// - /// See alsohttps://www.rfc-editor.org/rfc/rfc1918.html#section-3. + /// See also https://www.rfc-editor.org/rfc/rfc1918.html#section-3. /// public static bool IsPrivateIpV4( IPAddress ip ) { if ( ip.AddressFamily != AddressFamily.InterNetwork ) { diff --git a/src/Coordinator.Host.Tests/MessageHandlerTests.cs b/src/Coordinator.Host.Tests/MessageHandlerTests.cs index f5d344da..2812974d 100644 --- a/src/Coordinator.Host.Tests/MessageHandlerTests.cs +++ b/src/Coordinator.Host.Tests/MessageHandlerTests.cs @@ -9,7 +9,7 @@ internal sealed class MessageHandlerTests { private static readonly Assembly HandlersAssembly = typeof(CoordinatorHost).Assembly; private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); - [Explicit( "Disable until implemented" )] + [Ignore( "Disable until implemented" )] [Test] public void FindMessagesAndHandlers() { using ( Assert.EnterMultipleScope() ) { diff --git a/src/Networking.Core/MessageStream.cs b/src/Networking.Core/MessageStream.cs index 09d5d33e..6dba6089 100644 --- a/src/Networking.Core/MessageStream.cs +++ b/src/Networking.Core/MessageStream.cs @@ -8,7 +8,7 @@ namespace Drift.Networking.Core; public sealed class MessageStream : IMessageStream { - private static int _instanceCounter; // Being static is not ideal for testing with multiple instances + private static int _instanceCounter; // Being static is not ideal for testing with multiple Drift instances in the same process private readonly IAsyncStreamReader _reader; private readonly IAsyncStreamWriter _writer; private readonly MessageDispatcher _dispatcher; From e8e16ab52575c2789488be0c2f3789982dc24104 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:02:26 +0200 Subject: [PATCH 22/31] f --- src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs | 1 + src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs index 38a2475d..5c83c405 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -12,6 +12,7 @@ public async Task WithAgents_TwoDisjointSubnets_CombinesResults() { // Arrange var builder = Topologies.TwoAgentsDisjointSubnetsWithCli() .WithFirewall( fw => { + fw.DefaultPolicy = FirewallAction.Deny; // CLI must be able to reach agents to collect their results (control plane = same firewall). // Allow CLI→agent routing; deny-default blocks all other cross-subnet traffic (agent↔agent). fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent1-subnet" ) ); diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs index d2b9b9c6..1b33b9b5 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs @@ -32,7 +32,7 @@ public void Deny( FirewallTarget source, FirewallTarget destination ) { public FirewallAction DefaultPolicy { get; set; - } = FirewallAction.Allow; + } = FirewallAction.Allow; // TODO change to Deny public IReadOnlyList Rules => _rules.AsReadOnly(); } From 0b429ca5f1ac8f9d68d5e4ff5a859332e90ae6e8 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:06:09 +0200 Subject: [PATCH 23/31] f --- src/Spec/Validation/SpecValidator.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/Spec/Validation/SpecValidator.cs b/src/Spec/Validation/SpecValidator.cs index 640f183f..37dcbeb2 100644 --- a/src/Spec/Validation/SpecValidator.cs +++ b/src/Spec/Validation/SpecValidator.cs @@ -12,6 +12,8 @@ public static ValidationResult Validate( string yaml, SpecVersion version ) { var schema = SpecSchemaProvider.Get( version ); return Validate( yaml, schema ); } + // TODO add failed parsing tests + // An (unexpected) parsing error catch ( YamlException ex ) { var errors = new List(); @@ -26,7 +28,6 @@ public static ValidationResult Validate( string yaml, SpecVersion version ) { } private static ValidationResult Validate( string yaml, JsonSchema schema ) { - // try { // Read YAML and convert to JSON var yamlObject = YamlConverter.DeserializeToDto( yaml ); // var yamlLineNumbers = GetYamlLineNumbers( yamlContent ); @@ -42,17 +43,6 @@ private static ValidationResult Validate( string yaml, JsonSchema schema ) { return new ValidationResult { IsValid = validationResults.IsValid, Errors = ExtractErrors( validationResults ).ToList() }; - - // Throw exceptions - /*} - catch ( Exception ex ) { - return new ValidationResult { - IsValid = false, - Errors = [ - new ValidationError { Message = $"Validation failed: {ex.Message}", Path = "/" } - ] - }; - }*/ } private static IEnumerable ExtractErrors( EvaluationResults results ) { @@ -64,7 +54,7 @@ private static IEnumerable ExtractErrors( EvaluationResults res foreach ( var error in results.Errors ) { yield return new ValidationError { Path = results.InstanceLocation.SegmentCount == 0 ? "/" : results.InstanceLocation.ToString(), - Message = error.Value ?? "Validation error", + Message = error.Value, SchemaPath = results.SchemaLocation.ToString() }; } From 1441e987904a7a6644f386bcb8dc307d1853f0be Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:15:43 +0200 Subject: [PATCH 24/31] bump grpc deps --- Directory.Packages.props | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 73587bb1..cbe80b83 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,11 +10,11 @@ - - - - - + + + + + From ebbbe9b512166781d036c31ee0fa557cfba4ed3c Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:46:00 +0200 Subject: [PATCH 25/31] f --- Drift.sln | 14 +++++++++++++- src/Scanning/Scanners/PingSubnetScannerBase.cs | 16 +++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Drift.sln b/Drift.sln index 86f9d9ce..e28aa864 100644 --- a/Drift.sln +++ b/Drift.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli", "src\Cli\Cli.csproj", "{51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5}" EndProject @@ -117,6 +117,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host.Tests", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Tests", "src\Messaging.Tests\Messaging.Tests.csproj", "{87C0FA35-BC55-4B86-AAAD-C425C9A0E050}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Clab", "Clab", "{BA370A6D-DD96-4E5B-82B4-5CE7B6860F26}" + ProjectSection(SolutionItems) = preProject + containerlab\README.md = containerlab\README.md + containerlab\cooperation-test-spec.yaml = containerlab\cooperation-test-spec.yaml + containerlab\cooperation-test.clab.yaml = containerlab\cooperation-test.clab.yaml + containerlab\simple-test-spec.yaml = containerlab\simple-test-spec.yaml + containerlab\simple-test.clab.yaml = containerlab\simple-test.clab.yaml + containerlab\subnet-isolation-test-spec.yaml = containerlab\subnet-isolation-test-spec.yaml + containerlab\subnet-isolation-test.clab.yaml = containerlab\subnet-isolation-test.clab.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -310,5 +321,6 @@ Global {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} {87C0FA35-BC55-4B86-AAAD-C425C9A0E050} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {BA370A6D-DD96-4E5B-82B4-5CE7B6860F26} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} EndGlobalSection EndGlobal diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index 14c94824..2a2efffe 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -13,6 +13,10 @@ namespace Drift.Scanning.Scanners; +/// +/// Basic subnet scanner that uses the platform provided ping-command. +/// Inefficient, but "ping" is usually available on all platforms. +/// internal abstract class PingSubnetScannerBase : ISubnetScanner { public event EventHandler? ResultUpdated; @@ -150,8 +154,8 @@ List CreateAddresses( (IPAddress Ip, bool Success, string? Hostn } /// - /// Builds a map of local unicast IPv4 addresses to the MAC address of the - /// interface that owns them. Used to resolve the MAC for the scanner's own + /// Builds a map of local [unicast] IPv4 addresses to the MAC address of the + /// interface that owns them. Useful for resolving the MAC for the scanner's own /// IP addresses, which never appear in the ARP cache. /// private static Dictionary BuildLocalInterfaceMacTable() { @@ -159,11 +163,13 @@ private static Dictionary BuildLocalInterfaceMacTable() { foreach ( var iface in NetworkInterface.GetAllNetworkInterfaces() ) { var physicalAddress = iface.GetPhysicalAddress(); - if ( physicalAddress.GetAddressBytes().Length == 0 ) { - continue; // loopback and tunnel interfaces have no MAC + var addressBytes = physicalAddress.GetAddressBytes(); + + if ( addressBytes.Length == 0 ) { + continue; // loopback, tunnel and other logical interfaces have no MAC } - var macString = string.Join( "-", physicalAddress.GetAddressBytes().Select( b => b.ToString( "X2" ) ) ); + var macString = string.Join( "-", addressBytes.Select( b => b.ToString( "X2" ) ) ); foreach ( var unicastAddress in iface.GetIPProperties().UnicastAddresses.Select( u => u.Address ) ) { if ( unicastAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ) { From f06ced68e54756999b565d8861d160317bffe846 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:55:13 +0200 Subject: [PATCH 26/31] f --- src/Networking.Tests/Helpers/TestPeerMessage.cs | 5 +++++ src/Networking.Tests/MessageStreamManagerTests.cs | 15 ++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Networking.Tests/Helpers/TestPeerMessage.cs b/src/Networking.Tests/Helpers/TestPeerMessage.cs index 74564a77..44d4ad7c 100644 --- a/src/Networking.Tests/Helpers/TestPeerMessage.cs +++ b/src/Networking.Tests/Helpers/TestPeerMessage.cs @@ -8,6 +8,11 @@ namespace Drift.Networking.Tests.Helpers; internal sealed class TestPeerMessage : IRequest, IResponse { public static string MessageType => "test-peer-message"; + public string Payload { + get; + init; + } = string.Empty; + public static JsonTypeInfo JsonInfo => TestPeerMessageJsonContext.Default.TestPeerMessage; } diff --git a/src/Networking.Tests/MessageStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs index becf03bf..9ebe547b 100644 --- a/src/Networking.Tests/MessageStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -12,9 +12,12 @@ public async Task IncomingMessageIsDispatchedToHandler() { var cts = new CancellationTokenSource(); var logger = new StringLogger( TestContext.Out ); var testMessageHandler = new TestMessageHandler(); - var envelopeConverter = new MessageEnvelopeConverter(); - var responseCorrelator = new MessageResponseCorrelator( logger ); - var dispatcher = new MessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); + var dispatcher = new MessageDispatcher( + [testMessageHandler], + new MessageEnvelopeConverter(), + new MessageResponseCorrelator( logger ), + logger + ); var messageStreamManager = new MessageStreamManager( logger, null, @@ -31,14 +34,16 @@ public async Task IncomingMessageIsDispatchedToHandler() { // Act var clientStreams = duplexStreams.Client; - await clientStreams.RequestStream.WriteAsync( converter.ToEnvelope( new TestPeerMessage() ) ); + await clientStreams.RequestStream.WriteAsync( converter.ToEnvelope( new TestPeerMessage { + Payload = "test123" + } ) ); await cts.CancelAsync(); await stream.ReadTask; // Assert Assert.That( testMessageHandler.LastMessage, Is.Not.Null ); - // Assert.That( testMessageHandler.LastMessage.MessageType, Is.EqualTo( "TestMessageType" ) ); + Assert.That( testMessageHandler.LastMessage.Payload, Is.EqualTo( "test123" ) ); cts.Dispose(); } From 1cefd123e0c507684ef73f61a256dec5511dd9c1 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:59:42 +0200 Subject: [PATCH 27/31] f --- src/Networking.Tests/InboundTests.cs | 35 +++++++++++----------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs index e2accc94..05e7c4de 100644 --- a/src/Networking.Tests/InboundTests.cs +++ b/src/Networking.Tests/InboundTests.cs @@ -11,22 +11,12 @@ internal sealed class InboundTests { public async Task InboundStreamIsClosedWhenCancelledTest() { // Arrange using var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var responseCorrelator = new MessageResponseCorrelator( logger ); - var envelopeConverter = new MessageEnvelopeConverter(); - var messageStreamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), - new MessagingOptions { StoppingToken = cts.Token } - ); + var inboundMessageService = CreateInboundMessageService( cts ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); var duplexStreams = callContext.CreateDuplexStreams(); - var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); - // Act / Assert var serverStreams = duplexStreams.Server; var connectTask = @@ -41,20 +31,12 @@ public async Task InboundStreamIsClosedWhenCancelledTest() { Assert.That( connectTask.IsCompleted, Is.True ); } + [Test] public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { // Arrange using var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var responseCorrelator = new MessageResponseCorrelator( logger ); - var envelopeConverter = new MessageEnvelopeConverter(); - var messageStreamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), - new MessagingOptions { StoppingToken = cts.Token } - ); - var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); + var inboundMessageService = CreateInboundMessageService( cts ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); @@ -74,4 +56,15 @@ public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { Assert.That( connectTask.IsCompleted, Is.False ); } + + private static InboundMessageService CreateInboundMessageService( CancellationTokenSource cts ) { + var logger = new StringLogger( TestContext.Out ); + var messageStreamManager = new MessageStreamManager( + logger, + null, + new MessageDispatcher( [], new MessageEnvelopeConverter(), new MessageResponseCorrelator( logger ), logger ), + new MessagingOptions { StoppingToken = cts.Token } + ); + return new InboundMessageService( messageStreamManager, logger ); + } } \ No newline at end of file From 04c7587035f59774c07d89a48d77975dc18b40dd Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:02:55 +0200 Subject: [PATCH 28/31] f --- .../MessageStreamManagerTests.cs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Networking.Tests/MessageStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs index 9ebe547b..b6325075 100644 --- a/src/Networking.Tests/MessageStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -11,17 +11,16 @@ public async Task IncomingMessageIsDispatchedToHandler() { // Arrange var cts = new CancellationTokenSource(); var logger = new StringLogger( TestContext.Out ); - var testMessageHandler = new TestMessageHandler(); - var dispatcher = new MessageDispatcher( - [testMessageHandler], - new MessageEnvelopeConverter(), - new MessageResponseCorrelator( logger ), - logger - ); - var messageStreamManager = new MessageStreamManager( + var messageHandler = new TestMessageHandler(); + var streamManager = new MessageStreamManager( logger, null, - dispatcher, + new MessageDispatcher( + [messageHandler], + new MessageEnvelopeConverter(), + new MessageResponseCorrelator( logger ), + logger + ), new MessagingOptions { StoppingToken = cts.Token } ); @@ -29,7 +28,7 @@ public async Task IncomingMessageIsDispatchedToHandler() { callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); var duplexStreams = callContext.CreateDuplexStreams(); var serverStreams = duplexStreams.Server; - var stream = messageStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + var stream = streamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); var converter = new MessageEnvelopeConverter(); // Act @@ -42,8 +41,8 @@ public async Task IncomingMessageIsDispatchedToHandler() { await stream.ReadTask; // Assert - Assert.That( testMessageHandler.LastMessage, Is.Not.Null ); - Assert.That( testMessageHandler.LastMessage.Payload, Is.EqualTo( "test123" ) ); + Assert.That( messageHandler.LastMessage, Is.Not.Null ); + Assert.That( messageHandler.LastMessage.Payload, Is.EqualTo( "test123" ) ); cts.Dispose(); } From 446cdba71c40c808c5a833f4ae30308f3745df65 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:10:01 +0200 Subject: [PATCH 29/31] f --- src/Networking.Server/InboundMessageService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Networking.Server/InboundMessageService.cs b/src/Networking.Server/InboundMessageService.cs index 6341612e..fa5b9209 100644 --- a/src/Networking.Server/InboundMessageService.cs +++ b/src/Networking.Server/InboundMessageService.cs @@ -5,7 +5,9 @@ namespace Drift.Networking.Server; -// Handle incoming connections (AKA server-side). +/// +/// Handles incoming gRPC connections (AKA server-side). +/// internal sealed class InboundMessageService( IMessageStreamManager messageStreamManager, ILogger logger ) : MessagingService.MessagingServiceBase { public override async Task Connect( From d4b5d1f77da8f3e258749d7750d4c742569aae2e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:21:05 +0200 Subject: [PATCH 30/31] f --- src/Networking.Core/MessageStream.cs | 5 +-- .../Messages/MessageDispatcher.cs | 2 +- .../Helpers/TestPeerMessage.cs | 7 +++- src/Networking.Tests/InboundTests.cs | 20 +++++++----- .../MessageStreamManagerTests.cs | 32 +++++++++++-------- src/Networking.Tests/Networking.Tests.csproj | 1 + 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/Networking.Core/MessageStream.cs b/src/Networking.Core/MessageStream.cs index 6dba6089..04f8706f 100644 --- a/src/Networking.Core/MessageStream.cs +++ b/src/Networking.Core/MessageStream.cs @@ -8,7 +8,8 @@ namespace Drift.Networking.Core; public sealed class MessageStream : IMessageStream { - private static int _instanceCounter; // Being static is not ideal for testing with multiple Drift instances in the same process + // Being static is not ideal for testing with multiple Drift instances in the same process + private static int _instanceCounter; private readonly IAsyncStreamReader _reader; private readonly IAsyncStreamWriter _writer; private readonly MessageDispatcher _dispatcher; @@ -71,7 +72,7 @@ public async Task SendAsync( Message message ) { } private async Task ReadLoopAsync() { - _logger.LogDebug( "Read loop starting..." ); + _logger.LogDebug( "Read loop starting (waiting)..." ); try { await foreach ( var message in _reader.ReadAllAsync( _cancellationToken ) ) { diff --git a/src/Networking.Core/Messages/MessageDispatcher.cs b/src/Networking.Core/Messages/MessageDispatcher.cs index dc2d2ee4..5e722de6 100644 --- a/src/Networking.Core/Messages/MessageDispatcher.cs +++ b/src/Networking.Core/Messages/MessageDispatcher.cs @@ -43,6 +43,6 @@ ILogger logger return; } - throw new NotImplementedException( "Unknown message type '" + message.MessageType + "'" ); + throw new NotImplementedException( "No handler exist for message type '" + message.MessageType + "'" ); } } \ No newline at end of file diff --git a/src/Networking.Tests/Helpers/TestPeerMessage.cs b/src/Networking.Tests/Helpers/TestPeerMessage.cs index 44d4ad7c..29e204f7 100644 --- a/src/Networking.Tests/Helpers/TestPeerMessage.cs +++ b/src/Networking.Tests/Helpers/TestPeerMessage.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization.Metadata; using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; +using Microsoft.Extensions.Logging; namespace Drift.Networking.Tests.Helpers; @@ -19,7 +20,7 @@ public string Payload { [JsonSerializable( typeof(TestPeerMessage) )] internal sealed partial class TestPeerMessageJsonContext : JsonSerializerContext; -internal sealed class TestMessageHandler : IMessageHandler { +internal sealed class TestMessageHandler( ILogger logger ) : IMessageHandler { public TestPeerMessage? LastMessage { get; private set; @@ -33,9 +34,13 @@ public Task HandleAsync( IMessageStream stream, CancellationToken cancellationToken ) { + logger.LogInformation( "Received message of type '{MessageType}'", MessageType ); + var message = converter.FromEnvelope( envelope ); LastMessage = message; + logger.LogInformation( "Handled message with payload '{Payload}'", message.Payload ); + // For this test handler, we don't send a response return Task.CompletedTask; } diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs index 05e7c4de..21e5c254 100644 --- a/src/Networking.Tests/InboundTests.cs +++ b/src/Networking.Tests/InboundTests.cs @@ -1,8 +1,11 @@ +using Drift.Networking.Client; using Drift.Networking.Core; -using Drift.Networking.Core.Messages; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Server; using Drift.Networking.Tests.Helpers; using Drift.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Drift.Networking.Tests; @@ -58,13 +61,14 @@ public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { } private static InboundMessageService CreateInboundMessageService( CancellationTokenSource cts ) { + var serviceCollection = new ServiceCollection(); var logger = new StringLogger( TestContext.Out ); - var messageStreamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( [], new MessageEnvelopeConverter(), new MessageResponseCorrelator( logger ), logger ), - new MessagingOptions { StoppingToken = cts.Token } - ); - return new InboundMessageService( messageStreamManager, logger ); + serviceCollection.AddSingleton( logger ); + serviceCollection.AddMessagingCore( new MessagingOptions { StoppingToken = cts.Token } ); + serviceCollection.AddMessagingClient(); + serviceCollection.AddMessagingServer(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var streamManager = serviceProvider.GetRequiredService(); + return new InboundMessageService( streamManager, logger ); } } \ No newline at end of file diff --git a/src/Networking.Tests/MessageStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs index b6325075..42876336 100644 --- a/src/Networking.Tests/MessageStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -1,7 +1,11 @@ +using Drift.Networking.Client; using Drift.Networking.Core; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Core.Messages; using Drift.Networking.Tests.Helpers; using Drift.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Drift.Networking.Tests; @@ -10,19 +14,7 @@ internal sealed class MessageStreamManagerTests { public async Task IncomingMessageIsDispatchedToHandler() { // Arrange var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var messageHandler = new TestMessageHandler(); - var streamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( - [messageHandler], - new MessageEnvelopeConverter(), - new MessageResponseCorrelator( logger ), - logger - ), - new MessagingOptions { StoppingToken = cts.Token } - ); + var (streamManager, messageHandler) = CreateStreamManager( cts ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); @@ -46,4 +38,18 @@ public async Task IncomingMessageIsDispatchedToHandler() { cts.Dispose(); } + + private static (IMessageStreamManager, TestMessageHandler messageHandler) CreateStreamManager( + CancellationTokenSource cts + ) { + var serviceCollection = new ServiceCollection(); + var logger = new StringLogger( TestContext.Out ); + var messageHandler = new TestMessageHandler( logger ); + serviceCollection.AddSingleton( logger ); + serviceCollection.AddSingleton( _ => messageHandler ); + serviceCollection.AddMessagingCore( new MessagingOptions { StoppingToken = cts.Token } ); + serviceCollection.AddMessagingClient(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + return ( serviceProvider.GetRequiredService(), messageHandler ); + } } \ No newline at end of file diff --git a/src/Networking.Tests/Networking.Tests.csproj b/src/Networking.Tests/Networking.Tests.csproj index 733c8c6e..5ce2fb02 100644 --- a/src/Networking.Tests/Networking.Tests.csproj +++ b/src/Networking.Tests/Networking.Tests.csproj @@ -6,6 +6,7 @@ + From 94dead1b341022090dc68de1c442fe6afc195ef2 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:32:29 +0200 Subject: [PATCH 31/31] f --- src/Networking.Tests/InboundTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs index 21e5c254..2474d62b 100644 --- a/src/Networking.Tests/InboundTests.cs +++ b/src/Networking.Tests/InboundTests.cs @@ -34,7 +34,6 @@ public async Task InboundStreamIsClosedWhenCancelledTest() { Assert.That( connectTask.IsCompleted, Is.True ); } - [Test] public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { // Arrange