diff --git a/.editorconfig b/.editorconfig
index 0c9e904f..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
@@ -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..087e9f37 100644
--- a/Drift.Build.slnx
+++ b/Drift.Build.slnx
@@ -1,15 +1,18 @@
+
+
+
-
-
-
+
+
+
\ No newline at end of file
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/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs
index 6819a80d..c94378af 100644
--- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs
+++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs
@@ -31,7 +31,7 @@ public async Task DefaultVersioningVersionTest() {
[Test]
public async Task DefaultVersioningWhenNoReleaseTargets() {
// Arrange
- var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => b.Arbitrary );
+ var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary );
// Act
var factory = new VersioningStrategyFactory( build );
@@ -307,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/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..c790de52
--- /dev/null
+++ b/build/NukeBuild.TestContainerlab.cs
@@ -0,0 +1,350 @@
+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( "Scan completed successfully",
+ output => output.Contains( "Scan completed: 1 local, 1 via agents, 1 unique subnets" ) ),
+ ]
+ ),
+ 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( "Scan completed successfully",
+ output => output.Contains( "Scan completed: 1 local, 3 via agents, 1 unique subnets" ) ),
+ ]
+ ),
+ 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( "Scan completed successfully",
+ output => output.Contains( "Scan completed: 3 local, 4 via agents, 3 unique subnets" ) ),
+ ]
+ ),
+ ];
+
+ 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..5b9778c4
--- /dev/null
+++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs
@@ -0,0 +1,23 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Agent.PeerProtocol.Adopt;
+
+internal sealed class AdoptRequestHandler : IPeerMessageHandler {
+ // private readonly ILogger _logger; // Example: inject what you need
+
+ public string MessageType => AdoptRequestPayload.MessageType;
+
+ public Task HandleAsync(
+ PeerMessage envelope,
+ IPeerMessageEnvelopeConverter converter,
+ IPeerStream stream,
+ CancellationToken cancellationToken
+ ) {
+ // var message = converter.FromEnvelope( envelope );
+ // _logger.LogInformation( "[AdoptRequest] Controller: {ControllerId}", message.ControllerId );
+
+ // This handler doesn't send a response (Empty response pattern)
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs
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.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.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.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs
new file mode 100644
index 00000000..5c83c405
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs
@@ -0,0 +1,194 @@
+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.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.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.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..4ba45cbb
--- /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: 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)
+
+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..77f50b59
--- /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: 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)
+
+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 5e77b568..56b3be10 100644
--- a/src/Cli.Tests/Commands/ScanCommandTests.cs
+++ b/src/Cli.Tests/Commands/ScanCommandTests.cs
@@ -5,20 +5,25 @@
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;
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 +175,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 +207,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 +221,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() ) {
@@ -231,13 +236,14 @@ private static Action ConfigureServices(
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 +251,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/GlobalSuppressions.cs b/src/Cli.Tests/GlobalSuppressions.cs
new file mode 100644
index 00000000..b6301b01
--- /dev/null
+++ b/src/Cli.Tests/GlobalSuppressions.cs
@@ -0,0 +1,36 @@
+// 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/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/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..eb81b4a7
--- /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/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/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..16e46561
--- /dev/null
+++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs
@@ -0,0 +1,115 @@
+using System.Net;
+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 = IPAddress.Parse( ip.Value ).GetAddressBytes();
+ var cidrParts = cidr.ToString().Split( '/' );
+ if ( cidrParts.Length != 2 ) {
+ return false;
+ }
+
+ var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes();
+ if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) {
+ return false;
+ }
+
+ var mask = ~0u << ( 32 - prefixLength );
+ var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3];
+ var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) |
+ ( (uint) networkBytes[2] << 8 ) | networkBytes[3];
+
+ return ( ipInt & mask ) == ( networkInt & mask );
+ }
+ 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..3768a802
--- /dev/null
+++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs
@@ -0,0 +1,96 @@
+using System.Net;
+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 = IPAddress.Parse( ip.Value ).GetAddressBytes();
+ var cidrParts = cidr.ToString().Split( '/' );
+ if ( cidrParts.Length != 2 ) {
+ return false;
+ }
+
+ var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes();
+ if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) {
+ return false;
+ }
+
+ var mask = ~0u << ( 32 - prefixLength );
+ var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3];
+ var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) |
+ ( (uint) networkBytes[2] << 8 ) | networkBytes[3];
+
+ return ( ipInt & mask ) == ( networkInt & mask );
+ }
+ 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..9e4ee629
--- /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;
+ }
+
+ ///
+ /// 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).
+ ///
+ 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..c00f54c3
--- /dev/null
+++ b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs
@@ -0,0 +1,305 @@
+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/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.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/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..d946d1c8
--- /dev/null
+++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs
@@ -0,0 +1,65 @@
+using System.CommandLine;
+using Drift.Cli.Abstractions;
+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 = _ => 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
+ };
+ }
+
+ 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..4967ca30
--- /dev/null
+++ b/src/Cli/Commands/Scan/ClusterExtensions.cs
@@ -0,0 +1,49 @@
+using Drift.Agent.PeerProtocol.Scan;
+using Drift.Agent.PeerProtocol.Subnets;
+using Drift.Domain;
+using Drift.Networking.Cluster;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Cli.Commands.Scan;
+
+internal static class ClusterExtensions {
+ internal static Task GetSubnetsAsync(
+ this ICluster cluster,
+ Domain.Agent agent,
+ CancellationToken cancellationToken
+ ) {
+ return cluster.SendAndWaitAsync(
+ agent,
+ new SubnetsRequest(),
+ timeout: TimeSpan.FromSeconds( 10 ),
+ cancellationToken
+ );
+ }
+
+ internal static Task ScanSubnetAsync(
+ this ICluster cluster,
+ Domain.Agent agent,
+ CidrBlock cidr,
+ uint pingsPerSecond,
+ IPeerMessageEnvelopeConverter converter,
+ Action onProgressUpdate,
+ CancellationToken cancellationToken
+ ) {
+ var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond };
+
+ return cluster.SendAndWaitStreamingAsync(
+ agent,
+ request,
+ ScanSubnetCompleteResponse.MessageType,
+ ( progressEnvelope ) => {
+ // Deserialize progress update and call handler
+ if ( progressEnvelope.MessageType == ScanSubnetProgressUpdate.MessageType ) {
+ var progressUpdate = converter.FromEnvelope( progressEnvelope );
+ onProgressUpdate( progressUpdate );
+ }
+ },
+ timeout: TimeSpan.FromMinutes( 10 ),
+ cancellationToken
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs
new file mode 100644
index 00000000..32cdea2c
--- /dev/null
+++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs
@@ -0,0 +1,309 @@
+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 );
+ }
+ }
+
+ 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 ) {
+ // 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,
+ int localScanCount,
+ int agentScanCount,
+ 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(
+ "Scan completed: {LocalCount} local, {AgentCount} via agents, {UniqueSubnets} unique subnets",
+ localScanCount,
+ agentScanCount,
+ mergedResults.Count
+ );
+ }
+
+ return finalResult;
+ }
+
+ 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 )
+ .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..e317e86d
--- /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(value);
+
+ public static implicit operator string( AgentId id ) => id.Value;
+
+ 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..e42c25ff 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( IpV4Address ip ) =>
+ IPNetwork.Parse( ToString() ).Contains( IPAddress.Parse( ip.Value ) );
}
\ 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/IpV4Address.cs b/src/Domain/Device/Addresses/IpV4Address.cs
index f32cafa4..5ba1f9b6 100644
--- a/src/Domain/Device/Addresses/IpV4Address.cs
+++ b/src/Domain/Device/Addresses/IpV4Address.cs
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Sockets;
-
namespace Drift.Domain.Device.Addresses;
public readonly record struct IpV4Address : IDeviceAddress {
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/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..cf3c9f6e
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs
@@ -0,0 +1,34 @@
+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