From 469c06dfd8e4f2980d80c2040ea4e6f25fbebe79 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/10] wip --- .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 434e7d41..12a12fcf 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 03f1c235b55976f0c82eb1b3b183e72243e841c1 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/10] 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 7efa7d81c54fd8550ab2672e47defb37b1dea113 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/10] 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 85ae2863dd1b057f73c4bc4543036d2f8e8b16b8 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/10] 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 5ec6844c6390bbfa27cc426846bec70e2fa809a2 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/10] 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 87147e71c522e30d0affe64239f15e116d1cd9c0 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/10] 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 20712a44d803451699cc6085fa918b1e03dcd641 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/10] 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 3767470e320caf832e3b3b647af014a807e727f4 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/10] 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 df539456c852f9915b9613af62852255cbc21bad 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/10] 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 d767eba41b9b4a733600345a7285434beaa6a8a5 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/10] 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();