diff --git a/.editorconfig b/.editorconfig
index a121307e..0b234c24 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -223,6 +223,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 3a67909d..d14dbb63 100644
--- a/.github/actions/setup-runner/action.yml
+++ b/.github/actions/setup-runner/action.yml
@@ -19,3 +19,7 @@ runs:
- name: Restore .NET tools
shell: bash
run: dotnet tool restore
+
+ - name: Install Containerlab
+ shell: bash
+ run: bash -c "$(curl -sL https://get.containerlab.dev)"
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index dbf0b9b8..706dc6da 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -25,7 +25,7 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
- timeout-minutes: 5
+ timeout-minutes: 10
env:
# https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging
ACTIONS_RUNNER_DEBUG: false
diff --git a/.github/workflows/prerelease.yaml b/.github/workflows/prerelease.yaml
index d164d396..a85d46b4 100644
--- a/.github/workflows/prerelease.yaml
+++ b/.github/workflows/prerelease.yaml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # Required by Release target
- timeout-minutes: 5
+ timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index a9429e27..e4e18bb9 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # Required by Release target
- timeout-minutes: 5
+ timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
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 40e55bc6..f3aa6165 100644
--- a/.nuke/build.schema.json
+++ b/.nuke/build.schema.json
@@ -40,6 +40,7 @@
"ReleaseContainer",
"Restore",
"Test",
+ "TestClab",
"TestE2E",
"TestLocal",
"TestSelf",
@@ -117,6 +118,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'"
@@ -143,10 +148,18 @@
"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'"
},
+ "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 a58f0300..ee81c680 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,6 +5,12 @@
true
+
+
+
+
+
+
@@ -13,6 +19,7 @@
+
@@ -35,6 +42,7 @@
+
diff --git a/Drift.Build.slnx b/Drift.Build.slnx
index 4923d451..e6781204 100644
--- a/Drift.Build.slnx
+++ b/Drift.Build.slnx
@@ -2,14 +2,16 @@
+
+
-
-
-
+
+
+
\ No newline at end of file
diff --git a/Drift.sln b/Drift.sln
index d29c2422..55808dea 100644
--- a/Drift.sln
+++ b/Drift.sln
@@ -73,6 +73,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli.Settings.SchemaGenerato
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Common.Schemas\Common.Schemas.csproj", "{BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}"
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
@@ -177,11 +199,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 7a89c5ed..f58d70a3 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/Build.Utilities.Tests.csproj b/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj
index 2a3d630e..56d8698c 100644
--- a/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj
+++ b/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj
@@ -1,11 +1,11 @@
-
+
-
+
diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs
index dfdbc08c..72e8cf9f 100644
--- a/build/NukeBuild.Test.cs
+++ b/build/NukeBuild.Test.cs
@@ -17,7 +17,7 @@
sealed partial class NukeBuild {
Target Test => _ => _
- .DependsOn( TestSelf, TestUnit, TestE2E );
+ .DependsOn( TestSelf, TestUnit, TestE2E, TestClab );
Target TestSelf => _ => _
.Before( BuildInfo )
diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs
new file mode 100644
index 00000000..e20b5482
--- /dev/null
+++ b/build/NukeBuild.TestContainerlab.cs
@@ -0,0 +1,351 @@
+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 Serilog;
+
+// ReSharper disable VariableHidesOuterVariable
+// ReSharper disable AllUnderscoreLocalParameterName
+// ReSharper disable UnusedMember.Local
+
+sealed partial class NukeBuild {
+ [Parameter( "Skip Containerlab deployment (useful for debugging when topology is already running)" )]
+ readonly bool SkipClabDeploy = false;
+
+ [Parameter( "Keep Containerlab topology running after tests" )]
+ readonly bool KeepClabRunning = false;
+
+ [Parameter( "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified." )]
+ readonly string ClabTopology = null;
+
+ ///
+ /// Defines all Containerlab integration test cases.
+ /// Each test case specifies a topology, its spec file, the CLI container name,
+ /// and assertions to validate the scan output.
+ ///
+ private static readonly ContainerlabTestCase[] TestCases = [
+ new(
+ Name: "simple-test",
+ TopologyFile: "simple-test.clab.yaml",
+ SpecFile: "simple-test-spec.yaml",
+ CliContainer: "clab-drift-simple-test-cli",
+ Assertions: [
+ new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ),
+ new ScanAssertion( "Both scans successful (local + agent)",
+ output => output.Contains( "2/2 scan operations successful" ) ),
+ new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ),
+ ]
+ ),
+ new(
+ Name: "cooperation-test",
+ TopologyFile: "cooperation-test.clab.yaml",
+ SpecFile: "cooperation-test-spec.yaml",
+ CliContainer: "clab-drift-cooperation-test-cli",
+ Assertions: [
+ new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ),
+ new ScanAssertion( "All 4 scans successful (local + 3 agents)",
+ output => output.Contains( "4/4 scan operations successful" ) ),
+ new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ),
+ ]
+ ),
+ new(
+ Name: "subnet-isolation-test",
+ TopologyFile: "subnet-isolation-test.clab.yaml",
+ SpecFile: "subnet-isolation-test-spec.yaml",
+ CliContainer: "clab-drift-subnet-isolation-test-cli",
+ Assertions: [
+ new ScanAssertion( "Subnet-A scanned", output => output.Contains( "192.168.10.0/24" ) ),
+ new ScanAssertion( "Subnet-B scanned", output => output.Contains( "192.168.20.0/24" ) ),
+ new ScanAssertion( "All scan operations successful",
+ output => output.Contains( "7/7 scan operations successful" ) ),
+ new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ),
+ ]
+ ),
+ ];
+
+ Target TestClab => _ => _
+ .DependsOn( PublishContainer )
+ .After( TestUnit )
+ .Executes( async () => {
+ using var _ = new OperationTimer( nameof(TestClab) );
+
+ 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 9b2ea201..a12009f7 100644
--- a/build/NukeBuild.cs
+++ b/build/NukeBuild.cs
@@ -107,6 +107,8 @@ private static class Paths {
internal static AbsolutePath PublishDirectoryForRuntime( DotNetRuntimeIdentifier id ) =>
PublishDirectory / id.ToString();
+
+ internal static AbsolutePath ContainerlabsDirectory => RootDirectory / "containerlab";
}
Target BuildInfo => _ => _
diff --git a/build/_build.csproj b/build/_build.csproj
index 6b7e5a43..fc36a1e0 100644
--- a/build/_build.csproj
+++ b/build/_build.csproj
@@ -13,14 +13,15 @@
+
+
+
+
+
-
-
-
-
diff --git a/containerlab/README.md b/containerlab/README.md
new file mode 100644
index 00000000..6450a9b1
--- /dev/null
+++ b/containerlab/README.md
@@ -0,0 +1,124 @@
+# Containerlab Integration Testing
+
+This directory contains containerlab topologies for testing Drift's distributed network scanning capabilities.
+
+## Prerequisites
+
+- [Containerlab](https://containerlab.dev/) installed
+- Docker with sufficient resources (at least 4GB RAM, 2 CPUs)
+- Drift Docker image: `localhost:5000/drift:dev`
+
+## Quick Start
+
+The easiest way to run containerlab integration tests is via Nuke:
+
+```bash
+# Run all tests including containerlab integration
+dotnet nuke Test
+
+# Run only containerlab tests
+dotnet nuke TestContainerlab --skip test
+
+# Run a single topology for debugging
+dotnet nuke TestContainerlab --skip test --clab-topology simple-test
+
+# Keep containers running after tests (for debugging)
+dotnet nuke TestContainerlab --skip test --keep-clab-running
+```
+
+## Topologies
+
+### `simple-test.clab.yaml`
+
+Minimal topology: 1 agent, 1 CLI, 1 target on the management network.
+
+```
+ CLI Agent1 Target1
+(172.20.20.x) (172.20.20.x) (172.20.20.x)
+ | | |
+ +--------------------+---------------------+
+ 172.20.20.0/24
+```
+
+**Assertions:**
+- `172.20.20.0/24` is scanned
+- 2/2 scan operations successful (local + 1 agent)
+- Scan completes successfully
+
+### `cooperation-test.clab.yaml`
+
+Multi-agent cooperation topology: 3 agents, 1 CLI, 5 targets on a flat management network.
+Tests multi-agent coordination and result merging.
+
+```
+CLI + Agent1 + Agent2 + Agent3 + Target1..5
+ |
+ 172.20.20.0/24
+```
+
+**Assertions:**
+- `172.20.20.0/24` is scanned
+- 4/4 scan operations successful (local + 3 agents)
+- Scan completes successfully
+
+### `subnet-isolation-test.clab.yaml`
+
+Subnet isolation topology: 2 agents, 1 CLI, 4 targets. Each agent is connected to its own
+isolated subnet via veth links and an in-container bridge. Tests that each agent only reports
+devices reachable on its own subnet.
+
+```
+CLI ─── mgmt (172.20.20.0/24) ─── Agent1 ─── subnet-a (192.168.10.0/24) ─── Target-A1, Target-A2
+ └─ Agent2 ─── subnet-b (192.168.20.0/24) ─── Target-B1, Target-B2
+```
+
+**Assertions:**
+- `192.168.10.0/24` is scanned (by Agent1)
+- `192.168.20.0/24` is scanned (by Agent2)
+- 7/7 scan operations successful (mgmt×3 + subnet-a×2 + subnet-b×2)
+- Scan completes successfully
+
+## Agent Identity
+
+Agents in these topologies use the `--id` flag to set a fixed, predictable agent ID:
+
+```yaml
+agent1:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_test1
+```
+
+The `--id` flag is hidden from the help output and logs a warning when used — it is only for testing.
+
+In production, agents generate and persist their own ID at `/root/.config/drift/agent/agent-identity.json`.
+
+## Nuke Target Parameters
+
+| Parameter | Description |
+|---|---|
+| `--clab-topology ` | Run only the named topology (e.g. `simple-test`). Runs all if omitted. |
+| `--skip-clab-deploy` | Skip deployment — useful when topology is already running |
+| `--keep-clab-running` | Keep containers running after tests for debugging |
+
+## Troubleshooting
+
+**Deploy fails with "Link not found"** — This is a known issue with rootless Podman + pasta networking. The Nuke target works around it by pre-creating the `clab` management network before deploying. If you are deploying manually, run:
+```bash
+docker network rm clab 2>/dev/null; docker network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab
+containerlab deploy --topo simple-test.clab.yaml
+```
+
+**Agents not starting** — Check container logs:
+```bash
+docker logs clab-drift-simple-test-agent1
+docker logs clab-drift-cooperation-test-agent1
+docker logs clab-drift-subnet-isolation-test-agent1
+```
+
+**Cannot reach agents** — Verify containers are on the management network:
+```bash
+docker network inspect clab
+```
+
+**Connection refused on first scan attempt** — Normal. The agent starts slowly and the client retries automatically.
diff --git a/containerlab/cooperation-test-spec.yaml b/containerlab/cooperation-test-spec.yaml
new file mode 100644
index 00000000..00994efd
--- /dev/null
+++ b/containerlab/cooperation-test-spec.yaml
@@ -0,0 +1,21 @@
+version: "v1-preview"
+
+network:
+ subnets: []
+ devices: []
+
+agents:
+ - id: agentid_coop_agent1
+ address: http://clab-drift-cooperation-test-agent1:5000
+ authentication:
+ type: none
+
+ - id: agentid_coop_agent2
+ address: http://clab-drift-cooperation-test-agent2:5000
+ authentication:
+ type: none
+
+ - id: agentid_coop_agent3
+ address: http://clab-drift-cooperation-test-agent3:5000
+ authentication:
+ type: none
diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml
new file mode 100644
index 00000000..e7859935
--- /dev/null
+++ b/containerlab/cooperation-test.clab.yaml
@@ -0,0 +1,70 @@
+name: drift-cooperation-test
+
+# Multi-agent cooperation topology
+#
+# All nodes share a single flat management network (172.20.20.0/24).
+# Three agents cooperate to scan the same subnet, testing:
+# - Multi-agent coordination (3 agents)
+# - Result merging from multiple agents
+# - Correct handling of overlapping scan results
+#
+# Targets:
+# - 5 Alpine containers as scan targets
+
+topology:
+ nodes:
+ # Controller/CLI node
+ cli:
+ kind: linux
+ image: localhost:5000/drift:dev
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ # Agent 1
+ agent1:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_coop_agent1
+
+ # Agent 2
+ agent2:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_coop_agent2
+
+ # Agent 3
+ agent3:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_coop_agent3
+
+ # Target devices
+ target1:
+ kind: linux
+ image: alpine:latest
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ target2:
+ kind: linux
+ image: alpine:latest
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ target3:
+ kind: linux
+ image: alpine:latest
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ target4:
+ kind: linux
+ image: alpine:latest
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ target5:
+ kind: linux
+ image: alpine:latest
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
diff --git a/containerlab/simple-test-spec.yaml b/containerlab/simple-test-spec.yaml
new file mode 100644
index 00000000..8075f51a
--- /dev/null
+++ b/containerlab/simple-test-spec.yaml
@@ -0,0 +1,11 @@
+version: "v1-preview"
+
+network:
+ subnets: []
+ devices: []
+
+agents:
+ - id: agentid_test1
+ address: http://clab-drift-simple-test-agent1:5000
+ authentication:
+ type: none
diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml
new file mode 100644
index 00000000..6c8bb507
--- /dev/null
+++ b/containerlab/simple-test.clab.yaml
@@ -0,0 +1,26 @@
+name: drift-simple-test
+
+# Simplified topology for initial testing
+topology:
+ nodes:
+ # Controller/CLI node
+ cli:
+ kind: linux
+ image: localhost:5000/drift:dev
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ # Single agent for basic test
+ agent1:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_test1
+ ports:
+ - "5001:5000"
+
+ # Single target device
+ target1:
+ kind: linux
+ image: alpine:latest
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
diff --git a/containerlab/subnet-isolation-test-spec.yaml b/containerlab/subnet-isolation-test-spec.yaml
new file mode 100644
index 00000000..c3fc35c6
--- /dev/null
+++ b/containerlab/subnet-isolation-test-spec.yaml
@@ -0,0 +1,18 @@
+version: "v1-preview"
+
+network:
+ subnets:
+ - address: 192.168.10.0/24
+ - address: 192.168.20.0/24
+ devices: []
+
+agents:
+ - id: agentid_subnet_agent1
+ address: http://clab-drift-subnet-isolation-test-agent1:5000
+ authentication:
+ type: none
+
+ - id: agentid_subnet_agent2
+ address: http://clab-drift-subnet-isolation-test-agent2:5000
+ authentication:
+ type: none
diff --git a/containerlab/subnet-isolation-test.clab.yaml b/containerlab/subnet-isolation-test.clab.yaml
new file mode 100644
index 00000000..bad5dfe0
--- /dev/null
+++ b/containerlab/subnet-isolation-test.clab.yaml
@@ -0,0 +1,112 @@
+name: drift-subnet-isolation-test
+
+# Subnet isolation topology
+#
+# Tests that each agent only scans targets reachable on its own isolated subnet.
+#
+# Networks:
+# - mgmt (172.20.20.0/24): CLI + Agent1 + Agent2 [management/control plane]
+# - subnet-a (veth + bridge): Agent1 + Target-A1 + Target-A2 [192.168.10.0/24]
+# - subnet-b (veth + bridge): Agent2 + Target-B1 + Target-B2 [192.168.20.0/24]
+#
+# Targets use network-mode: none — they have no management interface and are
+# only reachable via the veth link to their respective agent.
+#
+# Inside each agent, a Linux bridge (br0) is created to bridge the two veth
+# links together on a single /24 subnet — allowing the agent to reach both
+# targets with a single IP address.
+#
+# Scan spec explicitly declares the two isolated subnets.
+# Agent1 can reach 192.168.10.0/24; Agent2 can reach 192.168.20.0/24.
+# Neither agent can reach the other's subnet.
+
+topology:
+ nodes:
+ # Controller/CLI node (mgmt only)
+ cli:
+ kind: linux
+ image: localhost:5000/drift:dev
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+
+ # Agent 1 — scans subnet-a (192.168.10.0/24)
+ # Creates br0 bridging eth1+eth2, assigns 192.168.10.1/24 to the bridge
+ agent1:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent1
+ exec:
+ - ip link add br0 type bridge
+ - ip link set eth1 master br0
+ - ip link set eth2 master br0
+ - ip link set eth1 up
+ - ip link set eth2 up
+ - ip link set br0 up
+ - ip addr add 192.168.10.1/24 dev br0
+
+ # Agent 2 — scans subnet-b (192.168.20.0/24)
+ # Creates br0 bridging eth1+eth2, assigns 192.168.20.1/24 to the bridge
+ agent2:
+ kind: linux
+ image: localhost:5000/drift:dev
+ cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent2
+ exec:
+ - ip link add br0 type bridge
+ - ip link set eth1 master br0
+ - ip link set eth2 master br0
+ - ip link set eth1 up
+ - ip link set eth2 up
+ - ip link set br0 up
+ - ip addr add 192.168.20.1/24 dev br0
+
+ # Subnet-A targets (only reachable by agent1)
+ # network-mode: none means no management interface; eth0 is the veth link
+ target-a1:
+ kind: linux
+ image: alpine:latest
+ network-mode: none
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+ exec:
+ - ip addr add 192.168.10.101/24 dev eth0
+ - ip link set eth0 up
+
+ target-a2:
+ kind: linux
+ image: alpine:latest
+ network-mode: none
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+ exec:
+ - ip addr add 192.168.10.102/24 dev eth0
+ - ip link set eth0 up
+
+ # Subnet-B targets (only reachable by agent2)
+ target-b1:
+ kind: linux
+ image: alpine:latest
+ network-mode: none
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+ exec:
+ - ip addr add 192.168.20.101/24 dev eth0
+ - ip link set eth0 up
+
+ target-b2:
+ kind: linux
+ image: alpine:latest
+ network-mode: none
+ entrypoint: /bin/sh
+ cmd: -c "sleep infinity"
+ exec:
+ - ip addr add 192.168.20.102/24 dev eth0
+ - ip link set eth0 up
+
+ links:
+ # Subnet-A: agent1 <-> target-a1 and agent1 <-> target-a2
+ - endpoints: ["agent1:eth1", "target-a1:eth0"]
+ - endpoints: ["agent1:eth2", "target-a2:eth0"]
+
+ # Subnet-B: agent2 <-> target-b1 and agent2 <-> target-b2
+ - endpoints: ["agent2:eth1", "target-b1:eth0"]
+ - endpoints: ["agent2:eth2", "target-b2:eth0"]
diff --git a/src/Agent.Hosting/Agent.Hosting.csproj b/src/Agent.Hosting/Agent.Hosting.csproj
new file mode 100644
index 00000000..93719ce4
--- /dev/null
+++ b/src/Agent.Hosting/Agent.Hosting.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Agent.Hosting/AgentHost.cs b/src/Agent.Hosting/AgentHost.cs
new file mode 100644
index 00000000..fe8e4960
--- /dev/null
+++ b/src/Agent.Hosting/AgentHost.cs
@@ -0,0 +1,70 @@
+using Drift.Agent.PeerProtocol.Subnets;
+using Drift.Networking.PeerStreaming.Client;
+using Drift.Networking.PeerStreaming.Core;
+using Drift.Networking.PeerStreaming.Server;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Agent.Hosting;
+
+public static class AgentHost {
+ public static Task Run(
+ ushort port,
+ ILogger logger,
+ Action? configureServices,
+ CancellationToken cancellationToken,
+ TaskCompletionSource? ready = null
+ ) {
+ var app = Build( port, logger, configureServices, ready );
+ return app.RunAsync( cancellationToken );
+ }
+
+ private static WebApplication Build(
+ ushort port,
+ ILogger logger,
+ Action? configureServices = null,
+ TaskCompletionSource? ready = null
+ ) {
+ var builder = WebApplication.CreateSlimBuilder();
+
+ builder.Logging.ClearProviders();
+ builder.Services.AddSingleton( logger );
+ builder.Services.AddPeerStreamingServer( options => {
+ options.EnableDetailedErrors = true;
+ } );
+ builder.Services.AddPeerStreamingClient();
+ var peerStreamingOptions = new PeerStreamingOptions { MessageAssembly = typeof(SubnetsRequest).Assembly };
+ builder.Services.AddPeerStreamingCore( peerStreamingOptions );
+ configureServices?.Invoke( builder.Services );
+
+ builder.WebHost.ConfigureKestrel( options => {
+ options.ListenAnyIP( port, o => {
+ o.Protocols = HttpProtocols.Http2; // Allow HTTP/2 over plain HTTP i.e., non-HTTPS
+ } );
+ } );
+
+ var app = builder.Build();
+
+ peerStreamingOptions.StoppingToken = app.Lifetime.ApplicationStopping;
+
+ app.MapPeerStreamingServerEndpoints();
+
+ app.Lifetime.ApplicationStarted.Register( () => {
+ logger.LogInformation( "Listening for incoming connections on port {Port}", port );
+ logger.LogInformation( "Agent started" );
+ ready?.TrySetResult();
+ } );
+ app.Lifetime.ApplicationStopping.Register( () => {
+ logger.LogInformation( "Agent stopping..." );
+ } );
+ app.Lifetime.ApplicationStopped.Register( () => {
+ logger.LogInformation( "Agent stopped" );
+ } );
+
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs
new file mode 100644
index 00000000..a462b561
--- /dev/null
+++ b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs
@@ -0,0 +1,77 @@
+using System.Text.Json;
+using Drift.Domain;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Agent.Hosting.Identity;
+
+public partial class AgentIdentity {
+ private IAgentIdentityLocationProvider? _loadLocation;
+
+ public static AgentIdentity Load( ILogger? logger = null, IAgentIdentityLocationProvider? location = null ) {
+ try {
+ location ??= new DefaultAgentIdentityLocationProvider();
+
+ logger?.LogTrace( "Loading agent identity from {Path}", location.GetFile() );
+
+ if ( !File.Exists( location.GetFile() ) ) {
+ logger?.LogDebug( "Agent identity file not found. Generating new identity." );
+ var newIdentity = CreateNew();
+ newIdentity._loadLocation = location;
+ return newIdentity;
+ }
+
+ var json = File.ReadAllText( location.GetFile() );
+ var identity = JsonSerializer.Deserialize( json, AgentIdentityJsonContext.Default.AgentIdentity );
+
+ logger?.LogTrace( "Loaded agent identity: {AgentId}", identity?.Id );
+
+ if ( identity == null ) {
+ logger?.LogWarning( "Deserialized identity is null. Generating new identity." );
+ var newIdentity = CreateNew();
+ newIdentity._loadLocation = location;
+ return newIdentity;
+ }
+
+ identity._loadLocation = location;
+
+ return identity;
+ }
+ catch ( Exception e ) {
+ logger?.LogError( e, "Error loading agent identity" );
+ var newIdentity = CreateNew();
+ newIdentity._loadLocation = location;
+ return newIdentity;
+ }
+ }
+
+ public void Save( ILogger logger, IAgentIdentityLocationProvider? location = null ) {
+ location ??= new DefaultAgentIdentityLocationProvider();
+
+ logger.LogTrace( "Saving agent identity to {Path}", location.GetFile() );
+
+ if ( !Directory.Exists( location.GetDirectory() ) ) {
+ Directory.CreateDirectory( location.GetDirectory() );
+ }
+
+ if ( !File.Exists( location.GetFile() ) ) {
+ logger.LogInformation( "Creating new agent identity file at {Path}", location.GetFile() );
+ }
+ else if ( _loadLocation == null ||
+ !_loadLocation.GetFile().Equals( location.GetFile(), StringComparison.Ordinal ) // Casing matters on Linux
+ ) {
+ throw new InvalidOperationException( "Prevented overwriting an existing file, which had not first been loaded." );
+ }
+
+ var json = JsonSerializer.Serialize( this, AgentIdentityJsonContext.Default.AgentIdentity );
+ File.WriteAllText( location.GetFile(), json );
+
+ // logger.LogDebug( "Agent identity saved: {AgentId}", Id );
+ }
+
+ private static AgentIdentity CreateNew() {
+ return new AgentIdentity {
+ Id = AgentId.New(),
+ CreatedAt = DateTime.UtcNow
+ };
+ }
+}
diff --git a/src/Agent.Hosting/Identity/AgentIdentity.cs b/src/Agent.Hosting/Identity/AgentIdentity.cs
new file mode 100644
index 00000000..30f82cff
--- /dev/null
+++ b/src/Agent.Hosting/Identity/AgentIdentity.cs
@@ -0,0 +1,10 @@
+using Drift.Domain;
+
+namespace Drift.Agent.Hosting.Identity;
+
+public partial class AgentIdentity {
+ public required AgentId Id { get; init; }
+ public required DateTime CreatedAt { get; init; }
+ public string? ClusterId { get; init; }
+ public DateTime? EnrolledAt { get; init; }
+}
diff --git a/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs
new file mode 100644
index 00000000..2d1be19f
--- /dev/null
+++ b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs
@@ -0,0 +1,17 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Drift.Agent.Hosting.Identity;
+
+[JsonSourceGenerationOptions(
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectRequiredConstructorParameters = true,
+ RespectNullableAnnotations = true
+)]
+[JsonSerializable( typeof(AgentIdentity) )]
+internal sealed partial class AgentIdentityJsonContext : JsonSerializerContext {
+}
diff --git a/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs
new file mode 100644
index 00000000..9a05bab7
--- /dev/null
+++ b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs
@@ -0,0 +1,24 @@
+using System.Runtime.InteropServices;
+
+namespace Drift.Agent.Hosting.Identity;
+
+public sealed class DefaultAgentIdentityLocationProvider : IAgentIdentityLocationProvider {
+ public string GetDirectory() {
+ if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) {
+ return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "Drift", "agent" );
+ }
+
+ if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) {
+ // https://specifications.freedesktop.org/basedir-spec/latest/
+ var xdgConfigHome = Environment.GetEnvironmentVariable( "XDG_CONFIG_HOME" );
+
+ var baseDir = string.IsNullOrEmpty( xdgConfigHome )
+ ? Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), ".config" )
+ : xdgConfigHome;
+
+ return Path.Combine( baseDir, "drift", "agent" );
+ }
+
+ throw new PlatformNotSupportedException();
+ }
+}
diff --git a/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs
new file mode 100644
index 00000000..356686f7
--- /dev/null
+++ b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs
@@ -0,0 +1,9 @@
+namespace Drift.Agent.Hosting.Identity;
+
+public interface IAgentIdentityLocationProvider {
+ string GetDirectory();
+
+ string GetFile() {
+ return Path.Combine( GetDirectory(), "agent-identity.json" );
+ }
+}
diff --git a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj b/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj
new file mode 100644
index 00000000..e00ea9c0
--- /dev/null
+++ b/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs b/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..5493e66a
--- /dev/null
+++ b/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs
@@ -0,0 +1 @@
+[assembly: Category( "Unit" )]
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs
new file mode 100644
index 00000000..eac8dbc8
--- /dev/null
+++ b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs
@@ -0,0 +1,59 @@
+using System.Reflection;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Agent.PeerProtocol.Tests;
+
+internal sealed class PeerMessageHandlerTests {
+ private static readonly Assembly ProtocolAssembly = typeof(PeerProtocolAssemblyMarker).Assembly;
+ private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IPeerRequest<>) );
+ private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IPeerResponse) );
+ private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes();
+
+ [Test]
+ public void FindMessagesAndHandlersAndMessages() {
+ using ( Assert.EnterMultipleScope() ) {
+ Assert.That( RequestTypes.ToList(), Has.Count.GreaterThan( 1 ), "No request messages found via reflection" );
+ Assert.That( ResponseTypes.ToList(), Has.Count.GreaterThan( 1 ), "No response messages found via reflection" );
+ Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" );
+ }
+ }
+
+ [Explicit( "Disabled until interface has settled" )]
+ [TestCaseSource( nameof(RequestTypes) )]
+ [TestCaseSource( nameof(ResponseTypes) )]
+ public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) {
+ var messageTypeValue = type
+ .GetProperty( nameof(IPeerMessage.MessageType), BindingFlags.Public | BindingFlags.Static )!
+ .GetValue( null ) as string;
+
+ Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty );
+
+ var jsonInfoValue = type
+ .GetProperty( nameof(IPeerMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )!
+ .GetValue( null );
+
+ Assert.That( jsonInfoValue, Is.Not.Null );
+ }
+
+ private static List GetAllConcreteMessageTypes( Type baseType ) {
+ return ProtocolAssembly
+ .GetTypes()
+ .Concat( [typeof(Empty)] )
+ .Where( t => t is { IsAbstract: false, IsInterface: false } )
+ .Where( t =>
+ // Generic base type
+ t.GetInterfaces().Any( i => i.IsGenericType && i.GetGenericTypeDefinition() == baseType ) ||
+ // Non-generic base type
+ baseType.IsAssignableFrom( t )
+ )
+ .ToList();
+ }
+
+ private static List GetAllConcreteHandlerTypes() {
+ return ProtocolAssembly
+ .GetTypes()
+ .Where( t => t is { IsAbstract: false, IsInterface: false } )
+ .Where( t => typeof(IPeerMessageHandler).IsAssignableFrom( t ) )
+ .ToList();
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs
new file mode 100644
index 00000000..c586ed34
--- /dev/null
+++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs
@@ -0,0 +1,24 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Agent.PeerProtocol.Adopt;
+
+internal sealed class AdoptRequestHandler : IPeerMessageHandler {
+ // private readonly ILogger _logger; // Example: inject what you need
+
+ public string MessageType => AdoptRequestPayload.MessageType;
+
+ public Task HandleAsync(
+ PeerMessage envelope,
+ IPeerMessageEnvelopeConverter converter,
+ IPeerStream stream,
+ CancellationToken cancellationToken
+ ) {
+ // var message = converter.FromEnvelope( envelope );
+ // _logger.LogInformation( "[AdoptRequest] Controller: {ControllerId}", message.ControllerId );
+
+ // This handler doesn't send a response (Empty response pattern)
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs
new file mode 100644
index 00000000..2ad48099
--- /dev/null
+++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization.Metadata;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Agent.PeerProtocol.Adopt;
+
+internal sealed class AdoptRequestPayload : IPeerRequest {
+ public static string MessageType => "adopt-request";
+
+ public required string Jwt {
+ get;
+ set;
+ }
+
+ public required string ControllerId {
+ get;
+ set;
+ }
+
+ public static JsonTypeInfo JsonInfo {
+ get;
+ } = null!;
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs
new file mode 100644
index 00000000..67fa75dc
--- /dev/null
+++ b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs
@@ -0,0 +1,18 @@
+namespace Drift.Agent.PeerProtocol.Adopt;
+
+internal sealed class AdoptResponsePayload {
+ public required string Status {
+ get;
+ set;
+ } // "accepted" or "rejected"
+
+ public required string AgentId {
+ get;
+ set;
+ } // Only with "accepted"
+
+ public required string Reason {
+ get;
+ set;
+ } // Only with "rejected"
+}
diff --git a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj
new file mode 100644
index 00000000..09269a20
--- /dev/null
+++ b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs b/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs
new file mode 100644
index 00000000..1907e215
--- /dev/null
+++ b/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs
@@ -0,0 +1,7 @@
+namespace Drift.Agent.PeerProtocol;
+
+// Justification: marker class
+#pragma warning disable S2094
+public class PeerProtocolAssemblyMarker {
+}
+#pragma warning restore S2094
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs
new file mode 100644
index 00000000..e330d4b2
--- /dev/null
+++ b/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Drift.Domain.Scan;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Serialization.Converters;
+
+namespace Drift.Agent.PeerProtocol.Scan;
+
+public sealed class ScanSubnetCompleteResponse : IPeerResponse {
+ public static string MessageType => "scan-complete";
+
+ public required SubnetScanResult Result {
+ get;
+ init;
+ }
+
+ public static JsonTypeInfo JsonInfo => ScanSubnetCompleteResponseJsonContext.Default.ScanSubnetCompleteResponse;
+}
+
+[JsonSourceGenerationOptions(
+ Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter), typeof(DeviceAddressConverter), typeof(IpV4AddressSetConverter)],
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase
+)]
+[JsonSerializable( typeof(ScanSubnetCompleteResponse) )]
+internal sealed partial class ScanSubnetCompleteResponseJsonContext : JsonSerializerContext;
diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs
new file mode 100644
index 00000000..596703fe
--- /dev/null
+++ b/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Agent.PeerProtocol.Scan;
+
+public sealed class ScanSubnetProgressUpdate : IPeerMessage {
+ public static string MessageType => "scan-progress-update";
+
+ public required byte ProgressPercentage {
+ get;
+ init;
+ }
+
+ public required int DevicesFound {
+ get;
+ init;
+ }
+
+ public string Status {
+ get;
+ init;
+ } = string.Empty;
+
+ public static JsonTypeInfo JsonInfo => ScanSubnetProgressUpdateJsonContext.Default.ScanSubnetProgressUpdate;
+}
+
+[JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase )]
+[JsonSerializable( typeof(ScanSubnetProgressUpdate) )]
+internal sealed partial class ScanSubnetProgressUpdateJsonContext : JsonSerializerContext;
diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs
new file mode 100644
index 00000000..4da42d37
--- /dev/null
+++ b/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Drift.Domain;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Serialization.Converters;
+
+namespace Drift.Agent.PeerProtocol.Scan;
+
+public sealed class ScanSubnetRequest : IPeerRequest {
+ public static string MessageType => "scan-subnet-request";
+
+ public required CidrBlock Cidr {
+ get;
+ init;
+ }
+
+ public uint PingsPerSecond {
+ get;
+ init;
+ } = 50;
+
+ public static JsonTypeInfo JsonInfo => ScanSubnetRequestJsonContext.Default.ScanSubnetRequest;
+}
+
+[JsonSourceGenerationOptions(
+ Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)],
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase
+)]
+[JsonSerializable( typeof(ScanSubnetRequest) )]
+internal sealed partial class ScanSubnetRequestJsonContext : JsonSerializerContext;
diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs
new file mode 100644
index 00000000..27c2db90
--- /dev/null
+++ b/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs
@@ -0,0 +1,112 @@
+using Drift.Domain;
+using Drift.Domain.Scan;
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Scanning.Scanners;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Agent.PeerProtocol.Scan;
+
+internal sealed class ScanSubnetRequestHandler(
+ ISubnetScannerFactory subnetScannerFactory,
+ ILogger logger
+) : IPeerMessageHandler {
+ public string MessageType => ScanSubnetRequest.MessageType;
+
+ public async Task HandleAsync(
+ PeerMessage envelope,
+ IPeerMessageEnvelopeConverter converter,
+ IPeerStream stream,
+ CancellationToken cancellationToken
+ ) {
+ logger.LogInformation( "Handling scan subnet request" );
+
+ var request = converter.FromEnvelope( envelope );
+ var options = new SubnetScanOptions { Cidr = request.Cidr, PingsPerSecond = request.PingsPerSecond };
+
+ logger.LogInformation( "Starting scan of {Cidr}", request.Cidr );
+
+ var scanner = subnetScannerFactory.Get( request.Cidr );
+ var policy = new ProgressUpdatePolicy( stream, converter, envelope, request.Cidr, logger );
+
+ scanner.ResultUpdated += policy.Handle;
+
+ try {
+ var result = await scanner.ScanAsync( options, logger, cancellationToken );
+
+ logger.LogInformation(
+ "Scan complete for {Cidr}: {DeviceCount} devices found",
+ request.Cidr,
+ result.DiscoveredDevices.Count
+ );
+
+ var completeResponse = new ScanSubnetCompleteResponse { Result = result };
+ await stream.SendResponseAsync( converter, completeResponse, envelope.CorrelationId );
+ }
+ finally {
+ scanner.ResultUpdated -= policy.Handle;
+ }
+ }
+
+ private sealed class ProgressUpdatePolicy {
+ private readonly IPeerStream _stream;
+ private readonly IPeerMessageEnvelopeConverter _converter;
+ private readonly PeerMessage _envelope;
+ private readonly CidrBlock _cidr;
+ private readonly ILogger _logger;
+
+ private byte _lastProgressPercentage;
+ private uint _lastDeviceCount;
+ private DateTime _lastSentAt = DateTime.UtcNow;
+
+ public EventHandler Handle {
+ get;
+ }
+
+ public ProgressUpdatePolicy(
+ IPeerStream stream,
+ IPeerMessageEnvelopeConverter converter,
+ PeerMessage envelope,
+ CidrBlock cidr,
+ ILogger logger
+ ) {
+ _stream = stream;
+ _converter = converter;
+ _envelope = envelope;
+ _cidr = cidr;
+ _logger = logger;
+ Handle = OnResultUpdated;
+ }
+
+ private void OnResultUpdated( object? sender, SubnetScanResult result ) {
+ var progressPercentage = result.Progress.Value;
+ var deviceCount = result.DiscoveredDevices.Count;
+ var now = DateTime.UtcNow;
+
+ bool progressThresholdReached = progressPercentage >= _lastProgressPercentage + 5;
+ bool isFirstCompletion = progressPercentage == 100 && _lastProgressPercentage < 100;
+ bool heartbeatDue = now - _lastSentAt > TimeSpan.FromSeconds( 10 );
+ bool firstDeviceDiscovered = deviceCount > 0 && _lastDeviceCount == 0;
+
+ if ( !( progressThresholdReached || isFirstCompletion || heartbeatDue || firstDeviceDiscovered ) ) {
+ return;
+ }
+
+ _lastProgressPercentage = progressPercentage;
+ _lastDeviceCount = (uint) deviceCount;
+ _lastSentAt = now;
+
+ var progressUpdate = new ScanSubnetProgressUpdate {
+ ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString()
+ };
+
+ _stream.SendResponseFireAndForget( _converter, progressUpdate, _envelope.CorrelationId );
+
+ _logger.LogDebug(
+ "Sent progress update: {Progress}% for {Cidr}",
+ progressPercentage,
+ _cidr
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..693ba852
--- /dev/null
+++ b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs
@@ -0,0 +1,15 @@
+using Drift.Agent.PeerProtocol.Scan;
+using Drift.Agent.PeerProtocol.Subnets;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Drift.Agent.PeerProtocol;
+
+public static class ServiceCollectionExtensions {
+ extension( IServiceCollection services ) {
+ public void AddPeerProtocol() {
+ services.AddScoped();
+ services.AddScoped();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs b/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs
new file mode 100644
index 00000000..7f0323f7
--- /dev/null
+++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Agent.PeerProtocol.Subnets;
+
+public sealed class SubnetsRequest : IPeerRequest {
+ public static string MessageType => "subnets-request";
+
+ public static JsonTypeInfo JsonInfo => SubnetsRequestJsonContext.Default.SubnetsRequest;
+}
+
+[JsonSerializable( typeof(SubnetsRequest) )]
+internal sealed partial class SubnetsRequestJsonContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs
new file mode 100644
index 00000000..a8d3eee5
--- /dev/null
+++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs
@@ -0,0 +1,29 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Scanning.Subnets.Interface;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Agent.PeerProtocol.Subnets;
+
+internal sealed class SubnetsRequestHandler(
+ IInterfaceSubnetProvider interfaceSubnetProvider,
+ ILogger logger
+) : IPeerMessageHandler {
+ public string MessageType => SubnetsRequest.MessageType;
+
+ public async Task HandleAsync(
+ PeerMessage envelope,
+ IPeerMessageEnvelopeConverter converter,
+ IPeerStream stream,
+ CancellationToken cancellationToken
+ ) {
+ logger.LogInformation( "Handling subnet request" );
+
+ var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList();
+
+ logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) );
+
+ var response = new SubnetsResponse { Subnets = subnets };
+ await stream.SendResponseAsync( converter, response, envelope.CorrelationId );
+ }
+}
\ No newline at end of file
diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs
new file mode 100644
index 00000000..f2402b6c
--- /dev/null
+++ b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Drift.Domain;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Serialization.Converters;
+
+namespace Drift.Agent.PeerProtocol.Subnets;
+
+public sealed class SubnetsResponse : IPeerResponse {
+ public static string MessageType => "subnets-response";
+
+ public required IReadOnlyList Subnets {
+ get;
+ init;
+ }
+
+ public static JsonTypeInfo JsonInfo => SubnetsResponseJsonContext.Default.SubnetsResponse;
+}
+
+[JsonSourceGenerationOptions(
+ Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)],
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase
+)]
+[JsonSerializable( typeof(SubnetsResponse) )]
+internal sealed partial class SubnetsResponseJsonContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/src/ArchTests/SanityTests.cs b/src/ArchTests/SanityTests.cs
index 3bac161e..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 = 20;
- private const uint ExpectedAssemblyCountTolerance = 3;
+ private const uint ExpectedAssemblyCount = 30;
+ private const uint ExpectedAssemblyCountTolerance = 10;
[Test]
public void FindManyAssemblies() {
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 96bd313e..c57fda8f 100644
--- a/src/Cli.Tests/Commands/LintCommandTests.cs
+++ b/src/Cli.Tests/Commands/LintCommandTests.cs
@@ -14,7 +14,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
);
@@ -36,7 +36,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
);
@@ -51,7 +51,7 @@ await Verify( output.ToString() + error )
[Test]
public async Task LintMissingSpec() {
// Arrange / Act
- var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( "lint" );
+ var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "lint" );
// Assert
Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) );
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt
new file mode 100644
index 00000000..538f05f0
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt
@@ -0,0 +1,18 @@
+Requesting subnets from agent local1
+Received subnet(s) from agent local1: 192.168.10.0/24
+Requesting subnets from agent local2
+Received subnet(s) from agent local2: 192.168.20.0/24
+Scanning 3 subnets
+ 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local
+ 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1
+ 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2
+
+192.168.0.0/24 (1 devices)
+└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device)
+
+192.168.10.0/24 (1 devices)
+└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device)
+└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device)
+
+192.168.20.0/24 (1 devices)
+└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device)
\ No newline at end of file
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs
new file mode 100644
index 00000000..af1356c6
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs
@@ -0,0 +1,348 @@
+using Drift.Cli.Abstractions;
+using Drift.Cli.Tests.Utils;
+using Drift.Domain;
+using Drift.Domain.Device.Addresses;
+using Drift.Domain.Scan;
+using Drift.Scanning.Scanners;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Cli.Tests.Commands;
+
+internal sealed partial class ScanCommandTests {
+ [Test]
+ public async Task RemoteScan() {
+ // Arrange
+ var scanConfig = ConfigureServices(
+ new CidrBlock( "192.168.0.0/24" ),
+ [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]],
+ new Inventory {
+ Network = new Network(),
+ Agents = [
+ new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" },
+ new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" }
+ ]
+ }
+ );
+
+ using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) );
+
+ Console.WriteLine( "Starting agents..." );
+ RunningCliCommand[] agents = [
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.10.0/24" ),
+ discoveredDevices: [
+ [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )],
+ [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )]
+ ]
+ )
+ ),
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable --port 51516",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.20.0/24" ),
+ discoveredDevices: [[new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )]]
+ )
+ )
+ ];
+
+ // Act
+ Console.WriteLine( "Starting scan..." );
+ var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync(
+ "scan unittest",
+ scanConfig,
+ cancellationToken: tcs.Token
+ );
+
+ Console.WriteLine( "\nScan finished" );
+ Console.WriteLine( "----------------" );
+ Console.WriteLine( scanOutput.ToString() + scanError );
+ Console.WriteLine( "----------------\n" );
+
+ Console.WriteLine( "Signalling agent cancellation..." );
+ await tcs.CancelAsync();
+ Console.WriteLine( "Waiting for agents to shut down..." );
+
+ foreach ( var agent in agents ) {
+ var (agentExitCode, agentOutput, agentError) = await agent.Completion;
+
+ Console.WriteLine( "\nAgent finished" );
+ Console.WriteLine( "----------------" );
+ Console.WriteLine( agentOutput.ToString() + agentError );
+ Console.WriteLine( "----------------\n" );
+
+ Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) );
+ }
+
+ // Assert
+ Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) );
+ await Verify( scanOutput.ToString() + scanError );
+ }
+
+ [Test]
+ public async Task RemoteScan_OverlappingSubnets() {
+ // Arrange - Both agents can see the same subnet (192.168.10.0/24)
+ var scanConfig = ConfigureServices(
+ new CidrBlock( "192.168.0.0/24" ),
+ [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]],
+ new Inventory {
+ Network = new Network(),
+ Agents = [
+ new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" },
+ new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" }
+ ]
+ }
+ );
+
+ using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) );
+
+ Console.WriteLine( "Starting agents with overlapping subnet visibility..." );
+ RunningCliCommand[] agents = [
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.10.0/24" ),
+ discoveredDevices: [
+ [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )],
+ [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )]
+ ]
+ )
+ ),
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable --port 51516",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.10.0/24" ), // Same subnet as agent1
+ discoveredDevices: [
+ [new IpV4Address( "192.168.10.102" ), new MacAddress( "44:44:44:44:44:44" )],
+ [new IpV4Address( "192.168.10.103" ), new MacAddress( "55:55:55:55:55:55" )]
+ ]
+ )
+ )
+ ];
+
+ // Act
+ Console.WriteLine( "Starting scan..." );
+ var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync(
+ "scan unittest",
+ scanConfig,
+ cancellationToken: tcs.Token
+ );
+
+ Console.WriteLine( "\nScan finished" );
+ Console.WriteLine( "----------------" );
+ Console.WriteLine( scanOutput.ToString() + scanError );
+ Console.WriteLine( "----------------\n" );
+
+ Console.WriteLine( "Signalling agent cancellation..." );
+ await tcs.CancelAsync();
+ Console.WriteLine( "Waiting for agents to shut down..." );
+
+ foreach ( var agent in agents ) {
+ var (agentExitCode, agentOutput, agentError) = await agent.Completion;
+
+ Console.WriteLine( "\nAgent finished" );
+ Console.WriteLine( "----------------" );
+ Console.WriteLine( agentOutput.ToString() + agentError );
+ Console.WriteLine( "----------------\n" );
+
+ Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) );
+ }
+
+ // Assert
+ Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) );
+
+ // Verify the subnet was only scanned once (not twice)
+ var outputStr = scanOutput.ToString();
+ Console.WriteLine( "Checking output for duplicate scans..." );
+
+ // The output should show "192.168.10.0/24" being scanned, but only once
+ // We expect to see results from only ONE agent (the first one to claim it)
+ await Verify( outputStr + scanError );
+ }
+
+ [Test]
+ public async Task RemoteScan_EmptyResults() {
+ // Arrange - Agents report subnets but no devices are found
+ var scanConfig = ConfigureServices(
+ new CidrBlock( "192.168.0.0/24" ),
+ [], // No local devices
+ new Inventory {
+ Network = new Network(),
+ Agents = [
+ new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }
+ ]
+ }
+ );
+
+ using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) );
+
+ Console.WriteLine( "Starting agent with empty scan results..." );
+ RunningCliCommand[] agents = [
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.10.0/24" ),
+ discoveredDevices: [] // No devices found
+ )
+ )
+ ];
+
+ // Act
+ Console.WriteLine( "Starting scan..." );
+ var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync(
+ "scan unittest",
+ scanConfig,
+ cancellationToken: tcs.Token
+ );
+
+ Console.WriteLine( "\nScan finished" );
+ Console.WriteLine( "----------------" );
+ Console.WriteLine( scanOutput.ToString() + scanError );
+ Console.WriteLine( "----------------\n" );
+
+ Console.WriteLine( "Signalling agent cancellation..." );
+ await tcs.CancelAsync();
+ Console.WriteLine( "Waiting for agents to shut down..." );
+
+ foreach ( var agent in agents ) {
+ var (agentExitCode, _, _) = await agent.Completion;
+ Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) );
+ }
+
+ // Assert
+ Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) );
+ await Verify( scanOutput.ToString() + scanError );
+ }
+
+ [Test]
+ public async Task RemoteScan_AgentsOnly_NoLocalInterfaces() {
+ // Arrange - CLI has no local interfaces, all scanning delegated to agents
+ var scanConfig = ConfigureServices(
+ interfaces: (CidrBlock?) null, // No local interfaces - explicitly cast to resolve ambiguity
+ discoveredDevices: new List>(),
+ inventory: new Inventory {
+ Network = new Network(),
+ Agents = [
+ new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" },
+ new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" }
+ ]
+ }
+ );
+
+ using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) );
+
+ Console.WriteLine( "Starting agents for agents-only scan..." );
+ RunningCliCommand[] agents = [
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.10.0/24" ),
+ discoveredDevices: [
+ [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )]
+ ]
+ )
+ ),
+ await DriftTestCli.StartAgentAsync(
+ "--adoptable --port 51516",
+ tcs.Token,
+ ConfigureServices(
+ interfaces: new CidrBlock( "192.168.20.0/24" ),
+ discoveredDevices: [
+ [new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )]
+ ]
+ )
+ )
+ ];
+
+ // Act
+ Console.WriteLine( "Starting scan with no local interfaces..." );
+ var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync(
+ "scan unittest",
+ scanConfig,
+ cancellationToken: tcs.Token
+ );
+
+ Console.WriteLine( "\nScan finished" );
+ Console.WriteLine( "----------------" );
+ Console.WriteLine( scanOutput.ToString() + scanError );
+ Console.WriteLine( "----------------\n" );
+
+ Console.WriteLine( "Signalling agent cancellation..." );
+ await tcs.CancelAsync();
+ Console.WriteLine( "Waiting for agents to shut down..." );
+
+ foreach ( var agent in agents ) {
+ var (agentExitCode, _, _) = await agent.Completion;
+ Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) );
+ }
+
+ // Assert
+ Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) );
+ await Verify( scanOutput.ToString() + scanError );
+ }
+
+ ///
+ /// Mock subnet scanner that returns predefined results for testing.
+ ///
+ private sealed class MockSubnetScanner( SubnetScanResult result ) : ISubnetScanner {
+ public event EventHandler? ResultUpdated;
+
+ public Task ScanAsync(
+ SubnetScanOptions options,
+ ILogger logger,
+ CancellationToken cancellationToken = default
+ ) {
+ // Simulate progress updates
+ var progressResult = new SubnetScanResult {
+ CidrBlock = result.CidrBlock,
+ DiscoveredDevices = result.DiscoveredDevices,
+ Metadata = result.Metadata,
+ Status = result.Status,
+ DiscoveryAttempts = result.DiscoveryAttempts,
+ Progress = new Percentage( 50 )
+ };
+ ResultUpdated?.Invoke( this, progressResult );
+
+ var finalResult = new SubnetScanResult {
+ CidrBlock = result.CidrBlock,
+ DiscoveredDevices = result.DiscoveredDevices,
+ Metadata = result.Metadata,
+ Status = result.Status,
+ DiscoveryAttempts = result.DiscoveryAttempts,
+ Progress = new Percentage( 100 )
+ };
+ ResultUpdated?.Invoke( this, finalResult );
+
+ return Task.FromResult( finalResult );
+ }
+ }
+
+ ///
+ /// Mock factory that creates scanners with predefined results based on CIDR.
+ ///
+ private sealed class MockSubnetScannerFactory(
+ Dictionary resultsByCidr
+ ) : ISubnetScannerFactory {
+ public ISubnetScanner Get( CidrBlock cidr ) {
+ if ( resultsByCidr.TryGetValue( cidr, out var result ) ) {
+ return new MockSubnetScanner( result );
+ }
+
+ // Return empty result for unknown CIDRs
+ return new MockSubnetScanner( new SubnetScanResult {
+ CidrBlock = cidr,
+ DiscoveredDevices = [],
+ Metadata = new Metadata { StartedAt = default, EndedAt = default },
+ Status = ScanResultStatus.Success,
+ DiscoveryAttempts = System.Collections.Immutable.ImmutableHashSet.Empty
+ } );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt
new file mode 100644
index 00000000..b86a1ad8
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt
@@ -0,0 +1,24 @@
+Requesting subnets from agent agentid_local1
+Received subnet(s) from agent agentid_local1: 192.168.10.0/24
+Requesting subnets from agent agentid_local2
+Received subnet(s) from agent agentid_local2: 192.168.20.0/24
+Scanning 3 subnets
+ 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local
+ 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1
+ 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2
+Distributed scanning via agents is a preview feature and should be used with caution.
+Agent communication is unencrypted. Do not use on untrusted networks.
+Agents run without authentication. Any client that can reach the agent port can connect.
+Agent adoption (--adoptable / --join) is not yet implemented and has no effect.
+Distributed scan completed: 3/3 scan operations successful, 3 unique subnets
+
+192.168.0.0/24 (1 devices)
+└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device)
+
+192.168.10.0/24 (2 devices)
+├── 192.168.10.100 22-22-22-22-22-22 Online (unknown device)
+└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device)
+
+192.168.20.0/24 (1 devices)
+└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device)
+
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt
new file mode 100644
index 00000000..2765abc7
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt
@@ -0,0 +1,19 @@
+Requesting subnets from agent agentid_local1
+Received subnet(s) from agent agentid_local1: 192.168.10.0/24
+Requesting subnets from agent agentid_local2
+Received subnet(s) from agent agentid_local2: 192.168.20.0/24
+Scanning 2 subnets
+ 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1
+ 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2
+Distributed scanning via agents is a preview feature and should be used with caution.
+Agent communication is unencrypted. Do not use on untrusted networks.
+Agents run without authentication. Any client that can reach the agent port can connect.
+Agent adoption (--adoptable / --join) is not yet implemented and has no effect.
+Distributed scan completed: 2/2 scan operations successful, 2 unique subnets
+
+192.168.10.0/24 (1 devices)
+└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device)
+
+192.168.20.0/24 (1 devices)
+└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device)
+
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt
new file mode 100644
index 00000000..21d98e9b
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt
@@ -0,0 +1,15 @@
+Requesting subnets from agent agentid_local1
+Received subnet(s) from agent agentid_local1: 192.168.10.0/24
+Scanning 2 subnets
+ 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local
+ 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1
+Distributed scanning via agents is a preview feature and should be used with caution.
+Agent communication is unencrypted. Do not use on untrusted networks.
+Agents run without authentication. Any client that can reach the agent port can connect.
+Agent adoption (--adoptable / --join) is not yet implemented and has no effect.
+Distributed scan completed: 2/2 scan operations successful, 2 unique subnets
+
+192.168.0.0/24 (0 devices)
+
+192.168.10.0/24 (0 devices)
+
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt
new file mode 100644
index 00000000..aa5468ac
--- /dev/null
+++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt
@@ -0,0 +1,23 @@
+Requesting subnets from agent agentid_local1
+Received subnet(s) from agent agentid_local1: 192.168.10.0/24
+Requesting subnets from agent agentid_local2
+Received subnet(s) from agent agentid_local2: 192.168.10.0/24
+Scanning 2 subnets
+ 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local
+ 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1, agentid_local2
+Distributed scanning via agents is a preview feature and should be used with caution.
+Agent communication is unencrypted. Do not use on untrusted networks.
+Agents run without authentication. Any client that can reach the agent port can connect.
+Agent adoption (--adoptable / --join) is not yet implemented and has no effect.
+Merged 4 unique devices from 2 scans of 192.168.10.0/24
+Distributed scan completed: 3/3 scan operations successful, 2 unique subnets
+
+192.168.0.0/24 (1 devices)
+└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device)
+
+192.168.10.0/24 (4 devices)
+├── 192.168.10.100 22-22-22-22-22-22 Online (unknown device)
+├── 192.168.10.101 21-21-21-21-21-21 Online (unknown device)
+├── 192.168.10.102 44-44-44-44-44-44 Online (unknown device)
+└── 192.168.10.103 55-55-55-55-55-55 Online (unknown device)
+
diff --git a/src/Cli.Tests/Commands/ScanCommandTests.cs b/src/Cli.Tests/Commands/ScanCommandTests.cs
index 5e77b568..c245dabb 100644
--- a/src/Cli.Tests/Commands/ScanCommandTests.cs
+++ b/src/Cli.Tests/Commands/ScanCommandTests.cs
@@ -11,14 +11,18 @@
using Drift.Domain.Device.Discovered;
using Drift.Domain.Extensions;
using Drift.Domain.Scan;
+using Drift.Scanning.Scanners;
using Drift.Scanning.Subnets.Interface;
using Drift.Scanning.Tests.Utils;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using NetworkInterface = Drift.Scanning.Subnets.Interface.NetworkInterface;
namespace Drift.Cli.Tests.Commands;
-internal sealed class ScanCommandTests {
+internal sealed partial class ScanCommandTests {
+ private const string SpecName = "unittest";
+
private static readonly INetworkInterface DefaultInterface = new NetworkInterface {
Description = "eth0", OperationalStatus = OperationalStatus.Up, UnicastAddress = new CidrBlock( "192.168.0.0/24" )
};
@@ -170,7 +174,7 @@ List interfaces
var serviceConfig = ConfigureServices( interfaces, discoveredDevices );
// Act
- var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan", serviceConfig );
+ var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "scan", serviceConfig );
// var exitCode = await config.InvokeAsync( $"scan -o {outputFormat}" );
// Assert
@@ -202,7 +206,7 @@ List discoveredDevices
);
// Act
- var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan unittest", serviceConfig );
+ var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"scan {SpecName}", serviceConfig );
// Assert
using ( Assert.EnterMultipleScope() ) {
@@ -216,7 +220,7 @@ await Verify( output.ToString() + error )
[Test]
public async Task NonExistingSpecOption() {
// Arrange / Act
- var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan blah_spec.yaml" );
+ var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "scan blah_spec.yaml" );
// Assert
using ( Assert.EnterMultipleScope() ) {
@@ -225,19 +229,56 @@ public async Task NonExistingSpecOption() {
}
}
+ private static Action ConfigureServices(
+ CidrBlock? interfaces,
+ List> discoveredDevices,
+ Inventory? inventory = null
+ ) {
+ var interfaceList = interfaces.HasValue
+ ? [
+ new NetworkInterface {
+ Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces.Value
+ }
+ ]
+ : new List();
+
+ return ConfigureServices(
+ interfaceList,
+ discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(),
+ inventory
+ );
+ }
+
+ private static Action ConfigureServices(
+ CidrBlock interfaces,
+ List> discoveredDevices,
+ Inventory? inventory = null
+ ) {
+ return ConfigureServices(
+ [
+ new NetworkInterface {
+ Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces
+ }
+ ],
+ discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(),
+ inventory
+ );
+ }
+
private static Action ConfigureServices(
List interfaces,
List discoveredDevices,
Inventory? inventory = null
) {
return services => {
- services.AddScoped( _ =>
- new PredefinedInterfaceSubnetProvider( interfaces )
+ services.Replace( ServiceDescriptor.Scoped( _ =>
+ new PredefinedInterfaceSubnetProvider( interfaces )
+ )
);
if ( inventory != null ) {
services.AddScoped( _ =>
- new PredefinedSpecProvider( new Dictionary { { "unittest", inventory } } )
+ new PredefinedSpecProvider( new Dictionary { { SpecName, inventory } } )
);
}
@@ -245,22 +286,41 @@ private static Action ConfigureServices(
new NetworkScanResult {
Metadata = new Metadata { StartedAt = default, EndedAt = default },
Status = ScanResultStatus.Success,
- Subnets = [
- new SubnetScanResult {
- CidrBlock = DefaultInterface.UnicastAddress!.Value,
- DiscoveredDevices = discoveredDevices,
- Metadata = new Metadata { StartedAt = default, EndedAt = default },
- Status = ScanResultStatus.Success,
- // TODO could/should also include ip's of non-discovered devices?
- DiscoveryAttempts = discoveredDevices.Select( d =>
- new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) )
- )
- .ToImmutableHashSet()
- }
- ]
+ Subnets = interfaces.Count > 0
+ ? [
+ new SubnetScanResult {
+ CidrBlock = DefaultInterface.UnicastAddress!.Value,
+ DiscoveredDevices = discoveredDevices,
+ Metadata = new Metadata { StartedAt = default, EndedAt = default },
+ Status = ScanResultStatus.Success,
+ // TODO could/should also include ip's of non-discovered devices?
+ DiscoveryAttempts = discoveredDevices.Select( d =>
+ new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) )
+ )
+ .ToImmutableHashSet()
+ }
+ ]
+ : []
}
)
);
+
+ // Register ISubnetScannerFactory for agent tests
+ // Build a map of CIDR -> SubnetScanResult based on interfaces
+ var resultsByCidr = interfaces.ToDictionary(
+ iface => iface.UnicastAddress!.Value,
+ iface => new SubnetScanResult {
+ CidrBlock = iface.UnicastAddress!.Value,
+ DiscoveredDevices = discoveredDevices,
+ Metadata = new Metadata { StartedAt = default, EndedAt = default },
+ Status = ScanResultStatus.Success,
+ DiscoveryAttempts = discoveredDevices.Select( d =>
+ new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) )
+ )
+ .ToImmutableHashSet()
+ }
+ );
+ services.AddScoped( _ => new MockSubnetScannerFactory( resultsByCidr ) );
};
}
}
\ No newline at end of file
diff --git a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt
index 1756febb..bf9563ca 100644
--- a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt
+++ b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt
@@ -18,6 +18,7 @@ Commands:
init Create a network spec
scan Scan the network and detect drift
lint Validate a network spec
+ agent Manage the local Drift agent
Required command was not provided.
Unrecognized command or argument 'nonexisting'.
diff --git a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt
index f1d8cd7a..7bb72412 100644
--- a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt
+++ b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt
@@ -1,6 +1,8 @@
-✗ This exception was thrown from ExceptionCommandHandler
- at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DefaultParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 104
- at Drift.Cli.Commands.Common.CommandBase`2.<>c__DisplayClass0_0.<.ctor>b__0(ParseResult parseResult, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/Commands/Common/CommandBase.cs:line 25
- at System.CommandLine.Invocation.AnonymousAsynchronousCommandLineAction.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken)
+✗ This exception was thrown from ExceptionCommandHandler
+ at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DummyParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 105
+ at Drift.Cli.Commands.Common.Commands.CommandBase`2.<>c__DisplayClass0_0.<<-ctor>b__0>d.MoveNext() in {SolutionDirectory}src/Cli/Commands/Common/Commands/CommandBase.cs:line 35
+--- End of stack trace from previous location ---
+ at Drift.Cli.Commands.Common.Commands.CommandBase`2.<>c__DisplayClass0_0.<<-ctor>b__0>d.MoveNext() in {SolutionDirectory}src/Cli/Commands/Common/Commands/CommandBase.cs:line 35
+--- End of stack trace from previous location ---
at System.CommandLine.Invocation.InvocationPipeline.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken)
- at Drift.Cli.DriftCli.InvokeAsync(String[] args, Boolean toConsole, Boolean plainConsole, Action`1 configureServices, CommandRegistration[] customCommands, Action`1 configureInvocation, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/DriftCli.cs:line 41
+ at Drift.Cli.DriftCli.InvokeAsync(String[] args, Boolean toConsole, Boolean plainConsole, Action`1 configureServices, CommandRegistration[] customCommands, Action`1 configureInvocation, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/DriftCli.cs:line 40
diff --git a/src/Cli.Tests/ExitCodeTests.cs b/src/Cli.Tests/ExitCodeTests.cs
index 3d588492..40d5fd6e 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() ) {
@@ -71,37 +72,42 @@ public async Task UnhandledExceptionReturnsUnknownErrorTest() {
}
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..cfba9f51
--- /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.Save( 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.Load( location: SettingsLocationProvider ).IsFeatureEnabled( MyFeature )
+ ? "Feature is enabled"
+ : "Feature is disabled"
+ );
+
+ return Task.FromResult( DummyCommandExitCode );
+ }
+ }
+
+ private sealed record DummyTestParameters : BaseParameters {
+ public DummyTestParameters( ParseResult parseResult ) : base( parseResult ) {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Cli.Tests/SpecFilePathResolverTests.cs b/src/Cli.Tests/SpecFilePathResolverTests.cs
index 6f3aebc6..0d23bb41 100644
--- a/src/Cli.Tests/SpecFilePathResolverTests.cs
+++ b/src/Cli.Tests/SpecFilePathResolverTests.cs
@@ -18,7 +18,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 );
_originalHome = Environment.GetEnvironmentVariable( HomeEnvVar );
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 0ab879ce..d33141d8 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;
@@ -8,7 +9,7 @@ namespace Drift.Cli.Tests.Utils;
internal static class DriftTestCli {
private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds( 7 );
- 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,
@@ -31,22 +32,66 @@ void ConfigureInvocation( InvocationConfiguration config ) {
}
try {
- return (
- await DriftCli.InvokeAsync(
- CommandLineParser.SplitCommandLine( args ).ToArray(),
- false,
- true,
- configureServices,
- customCommands,
- ConfigureInvocation,
- token
- ),
- output,
- error
+ 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 {
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
+ );
+
+ return new RunningCliCommand( task, cts );
+ }
+
+ ///
+ /// Starts a new agent asynchronously and returns tasks that complete when it has started.
+ ///
+ internal static async Task StartAgentAsync(
+ string args,
+ CancellationToken cancellationToken,
+ Action? configureServices = null
+ ) {
+ var readyTcs = new AgentLifetime();
+
+ var command = StartAsync(
+ "agent start " + args,
+ services => {
+ services.AddSingleton( readyTcs );
+ configureServices?.Invoke( services );
+ },
+ cancellationToken
+ );
+
+ // Wait for either readiness or command exit
+ var completed = await Task.WhenAny( readyTcs.Ready.Task, command.Completion );
+
+ if ( completed == command.Completion ) {
+ var com = await command.Completion;
+ throw new InvalidOperationException( "Command exited before agent was started. Details:\n" + com.Error );
+ }
+
+ return command;
+ }
}
\ No newline at end of file
diff --git a/src/Cli.Tests/Utils/RunningCliCommand.cs b/src/Cli.Tests/Utils/RunningCliCommand.cs
new file mode 100644
index 00000000..00fa3eba
--- /dev/null
+++ b/src/Cli.Tests/Utils/RunningCliCommand.cs
@@ -0,0 +1,20 @@
+namespace Drift.Cli.Tests.Utils;
+
+internal sealed class RunningCliCommand : IAsyncDisposable {
+ private readonly CancellationTokenSource _cts;
+
+ internal RunningCliCommand( Task task, CancellationTokenSource cts ) {
+ Completion = task;
+ _cts = cts;
+ }
+
+ public Task Completion {
+ get;
+ }
+
+ public async ValueTask DisposeAsync() {
+ await _cts.CancelAsync();
+ await Completion;
+ _cts.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj
index 4c2454ff..7e18921b 100644
--- a/src/Cli/Cli.csproj
+++ b/src/Cli/Cli.csproj
@@ -1,4 +1,4 @@
-
+
linux-x64;linux-musl-x64;linux-arm64;linux-arm
@@ -14,14 +14,19 @@
+
+
+
+
+
@@ -32,13 +37,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..72316f43
--- /dev/null
+++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs
@@ -0,0 +1,115 @@
+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;
+
+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..." );
+
+ await AgentHost.Run( parameters.Port, logger, ConfigureServices, cancellationToken, agentLifetime?.Ready );
+
+ output.Log.LogDebug( "Completed 'agent start' command" );
+
+ return ExitCodes.Success;
+
+ void ConfigureServices( IServiceCollection services ) {
+ // Configure core agent services (scanning, subnet discovery, execution environment)
+ RootCommandFactory.ConfigureAgentCoreServices( services );
+
+ // Add peer protocol message handlers
+ services.AddPeerProtocol();
+
+ // Allow test overrides
+ configureServicesOverride?.Invoke( services );
+ }
+ }
+
+ private AgentId LoadAgentIdentity( string? idOverride ) {
+ var logger = output.GetLogger();
+ IAgentIdentityLocationProvider locationProvider = new DefaultAgentIdentityLocationProvider();
+ var identityFilePath = locationProvider.GetFile();
+
+ // If an ID override is provided, use it directly without loading/saving
+ if ( !string.IsNullOrWhiteSpace( idOverride ) ) {
+ logger.LogWarning( "Agent started with --id flag. This should only be used for testing purposes." );
+ logger.LogInformation( "Using provided agent ID: {AgentId}", idOverride );
+ return new AgentId( idOverride );
+ }
+
+ // Load existing identity or create new one
+ var identity = AgentIdentity.Load( logger, locationProvider );
+
+ // Save immediately if it's new to persist it
+ if ( !File.Exists( identityFilePath ) ) {
+ identity.Save( logger, locationProvider );
+ logger.LogInformation( "Generated and saved new agent identity: {AgentId}", identity.Id );
+ }
+ else {
+ logger.LogDebug( "Loaded existing agent identity: {AgentId}", identity.Id );
+ }
+
+ return identity.Id;
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs
new file mode 100644
index 00000000..b4c07c02
--- /dev/null
+++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs
@@ -0,0 +1,64 @@
+using System.CommandLine;
+using Drift.Cli.Commands.Common.Parameters;
+
+namespace Drift.Cli.Commands.Agent.Subcommands.Start;
+
+internal record AgentStartParameters : BaseParameters {
+ internal static class Options {
+ internal static readonly Option Adoptable = new("--adoptable") {
+ DefaultValueFactory = _ => false,
+ Description = "Allow this agent to be adopted by another peer in the agent cluster"
+ };
+
+ // support @ for supplying local file
+ internal static readonly Option Join = new("--join") { Description = "Join the agent cluster using a JWT" };
+
+ internal static readonly Option Daemon = new("--daemon", "-d") {
+ Description = "Run the agent as a background daemon"
+ };
+
+ internal static readonly Option Port = new("--port", "-p") {
+ DefaultValueFactory = _ => 51515, Description = "Set the port used for both adoption and communication"
+ };
+
+ internal static readonly Option Id = new("--id") {
+ Description = "Set a specific agent ID (for testing purposes)",
+ Hidden = true
+ };
+ }
+
+ internal AgentStartParameters( ParseResult parseResult ) : base( parseResult ) {
+ Port = parseResult.GetValue( Options.Port );
+ Adoptable = parseResult.GetValue( Options.Adoptable );
+ Join = parseResult.GetValue( Options.Join );
+ Id = parseResult.GetValue( Options.Id );
+
+ if ( !Adoptable && string.IsNullOrWhiteSpace( Join ) ) {
+ throw new ArgumentException( "Either --adoptable or --join must be specified." );
+ }
+
+ if ( Adoptable && !string.IsNullOrWhiteSpace( Join ) ) {
+ throw new ArgumentException( "Cannot specify both --adoptable and --join." );
+ }
+ }
+
+ public string? Join {
+ get;
+ set;
+ }
+
+ public bool Adoptable {
+ get;
+ set;
+ }
+
+ public ushort Port {
+ get;
+ set;
+ }
+
+ public string? Id {
+ get;
+ set;
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Commands/Common/CommandBase.cs b/src/Cli/Commands/Common/Commands/CommandBase.cs
similarity index 53%
rename from src/Cli/Commands/Common/CommandBase.cs
rename to src/Cli/Commands/Common/Commands/CommandBase.cs
index 111412df..5bbc133d 100644
--- a/src/Cli/Commands/Common/CommandBase.cs
+++ b/src/Cli/Commands/Common/Commands/CommandBase.cs
@@ -1,10 +1,12 @@
using System.CommandLine;
-using Microsoft.Extensions.DependencyInjection;
+using Drift.Cli.Abstractions;
+using Drift.Cli.Commands.Common.Parameters;
+using Drift.Cli.Presentation.Console.Managers.Abstractions;
-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 +15,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 d574e9f6..488b3183 100644
--- a/src/Cli/Commands/Common/CommonParameters.cs
+++ b/src/Cli/Commands/Common/CommonParameters.cs
@@ -1,7 +1,6 @@
using System.CommandLine;
using Drift.Cli.Presentation.Console;
using Drift.Cli.Settings.V1_preview;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Commands.Common;
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 a49ceb7a..24eec1a0 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;
@@ -11,7 +11,6 @@
using Drift.Common.Network;
using Drift.Domain.Scan;
using Drift.Scanning.Subnets.Interface;
-using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Drift.Cli.Commands.Init;
@@ -179,7 +178,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..ba9ab8e4 100644
--- a/src/Cli/Commands/Lint/LintCommand.cs
+++ b/src/Cli/Commands/Lint/LintCommand.cs
@@ -1,13 +1,13 @@
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;
namespace Drift.Cli.Commands.Lint;
@@ -48,7 +48,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/Lint/Presentation/LogLintRenderer.cs b/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs
index e4cebbd5..776c3ebb 100644
--- a/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs
+++ b/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs
@@ -1,7 +1,6 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
using Drift.Cli.Presentation.Rendering;
using Drift.Spec.Validation;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Commands.Lint.Presentation;
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..c8a188ab
--- /dev/null
+++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs
@@ -0,0 +1,47 @@
+using Drift.Domain;
+using Drift.Networking.Cluster;
+using Drift.Scanning.Subnets;
+
+namespace Drift.Cli.Commands.Scan;
+
+internal sealed class AgentSubnetProvider(
+ ILogger logger,
+ List agents,
+ ICluster cluster,
+ CancellationToken cancellationToken
+) : ISubnetProvider {
+ public async Task> GetAsync() {
+ logger.LogDebug( "Getting subnets from agents" );
+ var allSubnets = new List();
+
+ foreach ( var agent in agents ) {
+ logger.LogInformation( "Requesting subnets from agent {Id}", agent.Id );
+
+ try {
+ var response = await cluster.GetSubnetsAsync( agent, cancellationToken );
+
+ logger.LogInformation(
+ "Received subnet(s) from agent {Id}: {Subnets}",
+ agent.Id,
+ string.Join( ", ", response.Subnets )
+ );
+
+ allSubnets.AddRange( response.Subnets.Select( cidr =>
+ new ResolvedSubnet( cidr, SubnetSource.Agent(
+ new AgentId( agent.Id )
+ ) ) )
+ );
+ }
+ catch ( Exception ex ) {
+ logger.LogWarning(
+ ex,
+ "Failed requesting subnets from agent {Id} ({Address}) — agent will be excluded from scan",
+ agent.Id,
+ agent.Address
+ );
+ }
+ }
+
+ return allSubnets;
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs
new file mode 100644
index 00000000..fd340461
--- /dev/null
+++ b/src/Cli/Commands/Scan/ClusterExtensions.cs
@@ -0,0 +1,50 @@
+using Drift.Agent.PeerProtocol.Scan;
+using Drift.Agent.PeerProtocol.Subnets;
+using Drift.Domain;
+using Drift.Networking.Cluster;
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Cli.Commands.Scan;
+
+internal static class ClusterExtensions {
+ internal static Task GetSubnetsAsync(
+ this ICluster cluster,
+ Domain.Agent agent,
+ CancellationToken cancellationToken
+ ) {
+ return cluster.SendAndWaitAsync(
+ agent,
+ new SubnetsRequest(),
+ timeout: TimeSpan.FromSeconds( 10 ),
+ cancellationToken
+ );
+ }
+
+ internal static Task ScanSubnetAsync(
+ this ICluster cluster,
+ Domain.Agent agent,
+ CidrBlock cidr,
+ uint pingsPerSecond,
+ IPeerMessageEnvelopeConverter converter,
+ Action onProgressUpdate,
+ CancellationToken cancellationToken
+ ) {
+ var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond };
+
+ return cluster.SendAndWaitStreamingAsync(
+ agent,
+ request,
+ ScanSubnetCompleteResponse.MessageType,
+ ( progressEnvelope ) => {
+ // Deserialize progress update and call handler
+ if ( progressEnvelope.MessageType == ScanSubnetProgressUpdate.MessageType ) {
+ var progressUpdate = converter.FromEnvelope( progressEnvelope );
+ onProgressUpdate( progressUpdate );
+ }
+ },
+ timeout: TimeSpan.FromMinutes( 10 ),
+ cancellationToken
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs
new file mode 100644
index 00000000..ac27d9b9
--- /dev/null
+++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs
@@ -0,0 +1,304 @@
+using System.Collections.Immutable;
+using Drift.Domain;
+using Drift.Domain.Device;
+using Drift.Domain.Device.Addresses;
+using Drift.Domain.Device.Discovered;
+using Drift.Domain.Scan;
+using Drift.Networking.Cluster;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Scanning.Subnets;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Cli.Commands.Scan;
+
+///
+/// Network scanner that delegates scanning to agents based on subnet source.
+///
+internal sealed class DistributedNetworkScanner(
+ INetworkScanner localScanner,
+ ICluster cluster,
+ IPeerMessageEnvelopeConverter converter,
+ List resolvedSubnets,
+ Inventory inventory,
+ ILogger logger
+) : INetworkScanner {
+ public event EventHandler? ResultUpdated;
+
+ public async Task ScanAsync(
+ NetworkScanOptions options,
+ ILogger logger,
+ CancellationToken cancellationToken = default
+ ) {
+ logger.LogDebug( "Starting distributed network scan" );
+
+ var subnetsBySource = PartitionSubnetsBySource( options );
+ var allSubnetResults = new List();
+ var startTime = DateTime.UtcNow;
+
+ foreach ( var (source, cidrs) in subnetsBySource ) {
+ if ( source is Local ) {
+ await ScanLocalSubnetsAsync(
+ cidrs,
+ options.PingsPerSecond,
+ allSubnetResults,
+ startTime,
+ logger,
+ cancellationToken
+ );
+ }
+ else if ( source is Scanning.Subnets.Agent agentSource ) {
+ await ScanAgentSubnetsAsync( agentSource, cidrs, options.PingsPerSecond, allSubnetResults, cancellationToken );
+ }
+ }
+
+ return BuildFinalResult( allSubnetResults, startTime, logger );
+ }
+
+ private List<(SubnetSource Source, List Cidrs)> PartitionSubnetsBySource( NetworkScanOptions options ) {
+ // Allow each source to scan subnets it can see - don't deduplicate
+ // Different agents may see different devices on the same subnet from their network position
+ return resolvedSubnets
+ .Where( rs => options.Cidrs.Contains( rs.Cidr ) )
+ .GroupBy( rs => rs.Source )
+ .Select( group => ( group.Key, group.Select( rs => rs.Cidr ).Distinct().ToList() ) )
+ .ToList();
+ }
+
+ private async Task ScanLocalSubnetsAsync(
+ List cidrs,
+ uint pingsPerSecond,
+ List allResults,
+ DateTime startTime,
+ ILogger logger,
+ CancellationToken cancellationToken
+ ) {
+ logger.LogDebug( "Scanning {Count} local subnet(s)", cidrs.Count );
+
+ var localOptions = new NetworkScanOptions { Cidrs = cidrs, PingsPerSecond = pingsPerSecond };
+
+ EventHandler progressHandler = ( _, result ) => {
+ var overallResult = BuildProgressResult( allResults, result.Subnets, startTime );
+ ResultUpdated?.Invoke( this, overallResult );
+ };
+
+ try {
+ localScanner.ResultUpdated += progressHandler;
+ var localResult = await localScanner.ScanAsync( localOptions, logger, cancellationToken );
+ allResults.AddRange( localResult.Subnets );
+ }
+ finally {
+ localScanner.ResultUpdated -= progressHandler;
+ }
+ }
+
+ private async Task ScanAgentSubnetsAsync(
+ Scanning.Subnets.Agent agentSource,
+ List cidrs,
+ uint pingsPerSecond,
+ List allResults,
+ CancellationToken cancellationToken
+ ) {
+ logger.LogDebug( "Scanning {Count} subnet(s) via agent {AgentId}", cidrs.Count, agentSource.AgentId );
+
+ // TODO: Parallel scanning of multiple subnets per agent
+ foreach ( var cidr in cidrs ) {
+ var result = await ScanSingleSubnetViaAgentAsync( agentSource, cidr, pingsPerSecond, cancellationToken );
+ allResults.Add( result );
+ }
+ }
+
+ private async Task ScanSingleSubnetViaAgentAsync(
+ Scanning.Subnets.Agent agentSource,
+ CidrBlock cidr,
+ uint pingsPerSecond,
+ CancellationToken cancellationToken
+ ) {
+ try {
+ var response = await cluster.ScanSubnetAsync(
+ MapAgentIdToDomainAgent( agentSource.AgentId ),
+ cidr,
+ pingsPerSecond,
+ converter,
+ progressUpdate => LogAgentProgress( agentSource.AgentId, cidr, progressUpdate.ProgressPercentage ),
+ cancellationToken
+ );
+
+ return response.Result;
+ }
+ catch ( OperationCanceledException ex ) {
+ logger.LogWarning( ex, "Scan of subnet {Cidr} via agent {AgentId} was cancelled", cidr, agentSource.AgentId );
+ return CreateFailedScanResult( cidr );
+ }
+ catch ( Exception ex ) {
+ logger.LogWarning(
+ ex,
+ "Failed to scan subnet {Cidr} via agent {AgentId}: {ErrorMessage}. Returning partial results.",
+ cidr,
+ agentSource.AgentId,
+ ex.Message
+ );
+ return CreateFailedScanResult( cidr );
+ }
+ }
+
+ private Domain.Agent MapAgentIdToDomainAgent( AgentId agentId ) {
+ var agent = inventory.Agents.FirstOrDefault( a => a.Id == agentId.Value );
+
+ if ( agent == null ) {
+ logger.LogWarning( "Agent {AgentId} not found in inventory", agentId );
+ return new Domain.Agent { Id = agentId.Value, Address = string.Empty };
+ }
+
+ return agent;
+ }
+
+ private void LogAgentProgress( AgentId agentId, CidrBlock cidr, byte progressPercentage ) {
+ logger.LogDebug( "Agent {AgentId} scan progress for {Cidr}: {Progress}%", agentId, cidr, progressPercentage );
+ // TODO: Emit progress updates via ResultUpdated event
+ }
+
+ private static SubnetScanResult CreateFailedScanResult( CidrBlock cidr ) {
+ return new SubnetScanResult {
+ CidrBlock = cidr,
+ DiscoveredDevices = [],
+ Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow },
+ Status = ScanResultStatus.Error
+ };
+ }
+
+ private static NetworkScanResult BuildProgressResult(
+ List completedSubnets,
+ IReadOnlyCollection inProgressSubnets,
+ DateTime startTime
+ ) {
+ var allSubnets = completedSubnets.Concat( inProgressSubnets ).ToList();
+ var progress = CalculateProgress( completedSubnets.Count, allSubnets.Count );
+
+ return new NetworkScanResult {
+ Subnets = allSubnets,
+ Metadata = new Metadata { StartedAt = startTime, EndedAt = DateTime.UtcNow },
+ Status = ScanResultStatus.InProgress,
+ Progress = progress
+ };
+ }
+
+ private NetworkScanResult BuildFinalResult(
+ List allResults,
+ DateTime startTime,
+ ILogger logger
+ ) {
+ var endTime = DateTime.UtcNow;
+
+ // Merge results for subnets that were scanned multiple times
+ var mergedResults = MergeOverlappingSubnetResults( allResults, logger );
+ var successCount = allResults.Count( s => s.Status == ScanResultStatus.Success );
+ var failureCount = allResults.Count - successCount;
+
+ var finalResult = new NetworkScanResult {
+ Subnets = mergedResults,
+ Metadata = new Metadata { StartedAt = startTime, EndedAt = endTime },
+ Status = successCount == allResults.Count ? ScanResultStatus.Success : ScanResultStatus.Error,
+ Progress = Percentage.Hundred
+ };
+
+ ResultUpdated?.Invoke( this, finalResult );
+
+ // Log summary with warnings if there were failures
+ if ( failureCount > 0 ) {
+ var failedSubnets = allResults.Where( s => s.Status == ScanResultStatus.Error ).Select( s => s.CidrBlock )
+ .ToList();
+ logger.LogWarning(
+ "Distributed scan completed with partial results: {SuccessCount}/{TotalCount} scan operations successful, {FailureCount} failed. Failed subnets: {FailedSubnets}",
+ successCount,
+ allResults.Count,
+ failureCount,
+ string.Join( ", ", failedSubnets )
+ );
+ }
+ else {
+ logger.LogInformation(
+ "Distributed scan completed: {SuccessCount}/{TotalCount} scan operations successful, {UniqueSubnets} unique subnets",
+ successCount,
+ allResults.Count,
+ mergedResults.Count
+ );
+ }
+
+ return finalResult;
+ }
+
+ private List MergeOverlappingSubnetResults( List allResults, ILogger logger ) {
+ // Group results by CIDR and merge devices from multiple scans
+ var resultsByCidr = allResults
+ .GroupBy( r => r.CidrBlock )
+ .Select( group => {
+ var cidr = group.Key;
+ var scans = group.ToList();
+
+ if ( scans.Count == 1 ) {
+ return scans[0];
+ }
+
+ // Multiple scans of the same subnet - merge the results
+ logger.LogDebug( "Merging {Count} scan results for subnet {Cidr}", scans.Count, cidr );
+
+ // Combine all discovered devices, deduplicating by Device ID and unioning addresses
+ var allDevices = scans
+ .SelectMany( s => s.DiscoveredDevices )
+ .GroupBy( d => ( (IAddressableDevice) d ).GetDeviceId().ToString() )
+ .Where( g => g.Key != string.Empty )
+ .Select( g => {
+ // Union addresses from all instances of this device, preserving richest data
+ var mergedAddresses = g
+ .SelectMany( d => d.Addresses )
+ .GroupBy( a => ( a.Type, a.Value ) )
+ .Select( ag => ag.First() )
+ .ToList();
+ return new DiscoveredDevice {
+ Addresses = mergedAddresses,
+ Ports = g.SelectMany( d => d.Ports ).Distinct().ToList(),
+ Timestamp = g.Min( d => d.Timestamp )
+ };
+ } )
+ .ToList();
+
+ // Combine all discovery attempts
+ var allAttempts = scans
+ .SelectMany( s => s.DiscoveryAttempts )
+ .ToImmutableHashSet();
+
+ // Use the earliest start time and latest end time
+ var startTime = scans.Min( s => s.Metadata.StartedAt );
+ var endTime = scans.Max( s => s.Metadata.EndedAt );
+
+ logger.LogInformation(
+ "Merged {DeviceCount} unique devices from {ScanCount} scans of {Cidr}",
+ allDevices.Count,
+ scans.Count,
+ cidr
+ );
+
+ return new SubnetScanResult {
+ CidrBlock = cidr,
+ DiscoveredDevices = allDevices,
+ Metadata = new Metadata { StartedAt = startTime, EndedAt = endTime },
+ Status = scans.All( s => s.Status == ScanResultStatus.Success )
+ ? ScanResultStatus.Success
+ : ScanResultStatus.Error,
+ DiscoveryAttempts = allAttempts
+ };
+ } )
+ .ToList();
+
+ return resultsByCidr;
+ }
+
+ private static Percentage CalculateProgress( int completed, int total ) {
+ if ( total == 0 ) {
+ return Percentage.Zero;
+ }
+
+ var progressValue = (byte) ( completed * 100 / total );
+ return new Percentage( progressValue );
+ }
+}
\ No newline at end of file
diff --git a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs
index 169a91cb..5fc1bd79 100644
--- a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs
+++ b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs
@@ -8,7 +8,6 @@
using Drift.Cli.Presentation.Rendering;
using Drift.Domain;
using Drift.Domain.Scan;
-using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Drift.Cli.Commands.Scan.NonInteractive;
diff --git a/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs b/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs
index 54ad61a9..1e6c6b7f 100644
--- a/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs
+++ b/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs
@@ -2,7 +2,6 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
using Drift.Cli.Presentation.Rendering;
using Drift.Cli.Presentation.Rendering.DeviceState;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Commands.Scan.Rendering;
diff --git a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs
index 0359f08a..2f4ebabf 100644
--- a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs
+++ b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs
@@ -18,7 +18,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..7f17c70e 100644
--- a/src/Cli/Commands/Scan/ScanCommand.cs
+++ b/src/Cli/Commands/Scan/ScanCommand.cs
@@ -1,29 +1,25 @@
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;
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 +54,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 e50e8b62..2b988b6b 100644
--- a/src/Cli/DriftCli.cs
+++ b/src/Cli/DriftCli.cs
@@ -2,7 +2,6 @@
using Drift.Cli.Abstractions;
using Drift.Cli.Infrastructure;
using Drift.Cli.Presentation.Rendering;
-using Microsoft.Extensions.DependencyInjection;
namespace Drift.Cli;
diff --git a/src/Cli/ExecutionEnvironment.cs b/src/Cli/ExecutionEnvironment.cs
index 96674e39..f1933a23 100644
--- a/src/Cli/ExecutionEnvironment.cs
+++ b/src/Cli/ExecutionEnvironment.cs
@@ -5,7 +5,7 @@ namespace Drift.Cli;
internal static class ExecutionEnvironment {
internal static DriftExecutionEnvironment GetCurrent() {
- var envVar = System.Environment.GetEnvironmentVariable( nameof(EnvVar.DRIFT_EXECUTION__ENVIRONMENT) );
+ var envVar = Environment.GetEnvironmentVariable( nameof(EnvVar.DRIFT_EXECUTION__ENVIRONMENT) );
return Get( envVar );
}
diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs
index 1161862f..9a38652e 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,11 +17,12 @@
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;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Infrastructure;
@@ -50,7 +54,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 +75,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 +94,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 +116,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 +124,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 +132,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 +151,7 @@ private static void ConfigureDynamicCommands(
}
}
- private static void ConfigureNetworkScanner( IServiceCollection services ) {
+ internal static void ConfigureNetworkScanner( IServiceCollection services ) {
if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) {
services.AddSingleton();
}
@@ -140,6 +163,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..330d3204 100644
--- a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs
+++ b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs
@@ -1,5 +1,4 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Presentation.Console.Logging;
@@ -17,18 +16,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..cfbe94ec 100644
--- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs
+++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs
@@ -1,6 +1,5 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
using Drift.Common.Logging;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Presentation.Console.Logging;
@@ -12,4 +11,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/Abstractions/ILogOutput.cs b/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs
index 05bd518c..0dedb05b 100644
--- a/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs
+++ b/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs
@@ -1,5 +1,3 @@
-using Microsoft.Extensions.Logging;
-
namespace Drift.Cli.Presentation.Console.Managers.Abstractions;
internal interface ILogOutput : ILogger;
\ 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..a16bd5a0 100644
--- a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs
+++ b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs
@@ -1,6 +1,5 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
using Drift.Cli.Presentation.Console.Managers.Outputs;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Presentation.Console.Managers;
diff --git a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs
index b6460270..a80b6003 100644
--- a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs
+++ b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs
@@ -1,5 +1,4 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
-using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace Drift.Cli.Presentation.Console.Managers;
diff --git a/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs
index 3d87c45a..d035010a 100644
--- a/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs
+++ b/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs
@@ -1,5 +1,4 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.Presentation.Console.Managers.Outputs;
diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs
index e8fb4d76..f197d94f 100644
--- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs
+++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs
@@ -79,14 +79,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 +99,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..691a6ad8 100644
--- a/src/Cli/Presentation/Console/OutputManagerFactory.cs
+++ b/src/Cli/Presentation/Console/OutputManagerFactory.cs
@@ -5,7 +5,6 @@
using Drift.Cli.Presentation.Console.Managers;
using Drift.Cli.Presentation.Console.Managers.Abstractions;
using Drift.Common.IO;
-using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Serilog.Events;
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/Cli/SpecFile/FileSystemSpecProvider.cs b/src/Cli/SpecFile/FileSystemSpecProvider.cs
index 7c46a0df..c2b312e7 100644
--- a/src/Cli/SpecFile/FileSystemSpecProvider.cs
+++ b/src/Cli/SpecFile/FileSystemSpecProvider.cs
@@ -5,7 +5,6 @@
using Drift.Spec.Schema;
using Drift.Spec.Serialization;
using Drift.Spec.Validation;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.SpecFile;
diff --git a/src/Cli/SpecFile/SpecFilePathResolver.cs b/src/Cli/SpecFile/SpecFilePathResolver.cs
index 7b6c54db..4b7ee1c5 100644
--- a/src/Cli/SpecFile/SpecFilePathResolver.cs
+++ b/src/Cli/SpecFile/SpecFilePathResolver.cs
@@ -1,5 +1,4 @@
using Drift.Cli.Presentation.Console.Managers.Abstractions;
-using Microsoft.Extensions.Logging;
namespace Drift.Cli.SpecFile;
diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj
index 2bdadc5e..a49980c3 100644
--- a/src/Common/Common.csproj
+++ b/src/Common/Common.csproj
@@ -1,11 +1,11 @@
-
+
-
+
\ No newline at end of file
diff --git a/src/Diff.Tests/DiffTest.cs b/src/Diff.Tests/DiffTest.cs
index fbb171db..363206a9 100644
--- a/src/Diff.Tests/DiffTest.cs
+++ b/src/Diff.Tests/DiffTest.cs
@@ -1,6 +1,4 @@
using System.Globalization;
-using System.Text.Json;
-using System.Text.Json.Serialization;
using Drift.Diff.Domain;
using Drift.Domain;
using Drift.Domain.Device.Addresses;
@@ -8,6 +6,7 @@
using Drift.Domain.Device.Discovered;
using Drift.Domain.Extensions;
using Drift.Domain.Scan;
+using Drift.Serialization.Converters;
using Drift.TestUtilities;
using JsonConverter = Drift.Serialization.JsonConverter;
@@ -247,21 +246,4 @@ private static void Print( List diffs ) {
Console.WriteLine( $"{diff.PropertyPath}: {diff.DiffType} — '{diff.Original}' → '{diff.Updated}'" );
}
}
-
- private sealed class IpAddressConverter : JsonConverter {
- public override System.Net.IPAddress Read(
- ref Utf8JsonReader reader,
- Type typeToConvert,
- JsonSerializerOptions options
- ) {
- string? ip = reader.GetString();
- var ipAddress = ( ip == null ) ? null : System.Net.IPAddress.Parse( ip );
- return ipAddress ?? throw new Exception( "Cannot read" ); // System.Net.IPAddress.None;
- }
-
- public override void Write( Utf8JsonWriter writer, System.Net.IPAddress value, JsonSerializerOptions options ) {
- ArgumentNullException.ThrowIfNull( writer );
- writer.WriteStringValue( value?.ToString() );
- }
- }
}
\ No newline at end of file
diff --git a/src/Domain/AgentId.cs b/src/Domain/AgentId.cs
new file mode 100644
index 00000000..8fc538e5
--- /dev/null
+++ b/src/Domain/AgentId.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+
+namespace Drift.Domain;
+
+public class AgentId {
+ private const string Prefix = "agentid_";
+
+ [JsonConstructor]
+ public AgentId() {
+ }
+
+ public AgentId( string value ) {
+ if ( !value.StartsWith( Prefix ) ) {
+ throw new FormatException( $"AgentId must start with '{Prefix}'." );
+ }
+
+ Value = value;
+ }
+
+ public string Value {
+ get;
+ set;
+ } = string.Empty;
+
+ public bool IsGuidBased =>
+ Guid.TryParse( Value[Prefix.Length..], out _ );
+
+ public Guid? AsGuidOrNull =>
+ Guid.TryParse( Value[Prefix.Length..], out var guid ) ? guid : null;
+
+ public static implicit operator AgentId( string value ) => new AgentId( value );
+
+ public static implicit operator string( AgentId id ) => id.Value;
+
+ public static AgentId New() => new AgentId( Prefix + Guid.NewGuid() );
+
+ public override string ToString() => Value;
+}
\ No newline at end of file
diff --git a/src/Domain/Environment.cs b/src/Domain/Environment.cs
new file mode 100644
index 00000000..e56727f2
--- /dev/null
+++ b/src/Domain/Environment.cs
@@ -0,0 +1,65 @@
+using System.Text.Json.Serialization;
+
+// TODO remove when no longer a draft
+#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
+
+namespace Drift.Domain;
+
+/*[JsonSerializable( typeof(Environment) )] // Enable source generation for this type
+[JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Default )]
+public partial class EnvironmentContext : JsonSerializerContext {
+}*/
+
+public record Environment {
+ public required string Name {
+ get;
+ init;
+ }
+
+ public bool Active {
+ get;
+ set;
+ }
+
+ public List Agents {
+ get;
+ set;
+ }
+}
+
+public record Agent {
+ public string Id { // TODO use AgentId???? or should that only be for internal use
+ get;
+ set;
+ }
+
+ /*public IpAddress Address {
+ //TODO support hostname too, maybe even mac!?
+ get;
+ set;
+ }*/
+
+ public string Address {
+ get;
+ set;
+ }
+
+ public AgentAuthentication Authentication {
+ get;
+ set;
+ }
+}
+
+public record AgentAuthentication {
+ [JsonIgnore( Condition = JsonIgnoreCondition.Never )]
+ public AuthType Type {
+ get;
+ set;
+ }
+}
+
+public enum AuthType {
+ None = 1,
+ ApiKey = 2,
+ Certificate =3
+}
\ No newline at end of file
diff --git a/src/Domain/Inventory.cs b/src/Domain/Inventory.cs
index 2ece762e..347999be 100644
--- a/src/Domain/Inventory.cs
+++ b/src/Domain/Inventory.cs
@@ -5,4 +5,9 @@ public required Network Network {
get;
init;
}
+
+ public List Agents {
+ get;
+ set;
+ } = [];
}
\ No newline at end of file
diff --git a/src/Domain/RequestId.cs b/src/Domain/RequestId.cs
new file mode 100644
index 00000000..259b7369
--- /dev/null
+++ b/src/Domain/RequestId.cs
@@ -0,0 +1,23 @@
+namespace Drift.Domain;
+
+public record RequestId( Guid Value ) {
+ private const string Prefix = "requestid_";
+
+ public static implicit operator RequestId( string value ) {
+#pragma warning disable S3877 // Throwing in an implicit operator is intentional here — invalid inputs should be rejected early
+ if ( !value.StartsWith( Prefix ) ) {
+ throw new FormatException( $"Invalid RequestId format. Must start with '{Prefix}'." );
+ }
+
+ var guidPart = value[Prefix.Length..];
+
+ if ( !Guid.TryParse( guidPart, out var guid ) ) {
+ throw new FormatException( "Invalid GUID in RequestId." );
+ }
+#pragma warning restore S3877
+
+ return new RequestId( guid );
+ }
+
+ public static implicit operator string( RequestId id ) => $"{Prefix}{id.Value}";
+}
\ No newline at end of file
diff --git a/src/Networking.Cluster/Cluster.cs b/src/Networking.Cluster/Cluster.cs
new file mode 100644
index 00000000..722bd799
--- /dev/null
+++ b/src/Networking.Cluster/Cluster.cs
@@ -0,0 +1,210 @@
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Networking.PeerStreaming.Core.Messages;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Networking.Cluster;
+
+internal sealed class Cluster(
+ IPeerMessageEnvelopeConverter envelopeConverter,
+ IPeerStreamManager peerStreamManager,
+ PeerResponseCorrelator responseCorrelator,
+ ILogger logger,
+ ClusterOptions? options = null
+) : ICluster {
+ private readonly ClusterOptions _options = options ?? new ClusterOptions();
+ /*public async Task SendAsync(
+ Domain.Agent agent,
+ TMessage message,
+ CancellationToken cancellationToken = default
+ ) where TMessage : IPeerMessage {
+ try {
+ await SendInternalAsync( agent, message, cancellationToken );
+ }
+ catch ( Exception ex ) {
+ logger.LogWarning( ex, "Send to {Peer} failed", agent );
+ }
+ }*/
+
+ /* public async Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ) {
+ var peers = peerStreamManager.GetConnectedPeers();
+
+ var tasks = peers.Select( async peer => {
+ // TODO optimistically assume connection is alive, but automatically reconnect if it's not
+ try {
+ await SendInternalAsync( peer, message, cancellationToken );
+ }
+ catch ( Exception ex ) {
+ logger.LogWarning( ex, "Broadcast to {Peer} failed", peer );
+ }
+ } );
+
+ await Task.WhenAll( tasks );
+ }*/
+
+ /*public async Task SendInternalAsync(
+ Domain.Agent agent,
+ TMessage message,
+ CancellationToken cancellationToken = default
+ ) where TMessage : IPeerMessage {
+ var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), "agentid_local1" );
+ var envelope = envelopeConverter.ToEnvelope( message );
+ await connection.SendAsync( envelope );
+ }*/
+
+ public async Task SendAndWaitAsync(
+ Domain.Agent agent,
+ TRequest message,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default
+ ) where TResponse : IPeerResponse where TRequest : IPeerRequest {
+ return await ExecuteWithRetryAsync(
+ agent,
+ async () => await SendAndWaitInternalAsync( agent, message, timeout, cancellationToken ),
+ cancellationToken
+ );
+ }
+
+ private async Task SendAndWaitInternalAsync(
+ Domain.Agent agent,
+ TRequest message,
+ TimeSpan? timeout,
+ CancellationToken cancellationToken
+ ) where TResponse : IPeerResponse where TRequest : IPeerRequest {
+ var correlationId = Guid.NewGuid().ToString();
+ var envelope = envelopeConverter.ToEnvelope( message );
+ envelope.CorrelationId = correlationId;
+
+ // Register correlator BEFORE sending
+ var responseTask = responseCorrelator.WaitForResponseAsync(
+ correlationId,
+ timeout ?? _options.DefaultTimeout,
+ cancellationToken
+ );
+
+ // Request
+ var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id );
+ await connection.SendAsync( envelope );
+
+ // Response
+ var response = await responseTask;
+ return envelopeConverter.FromEnvelope( response );
+ }
+
+ public async Task SendAndWaitStreamingAsync(
+ Domain.Agent agent,
+ TRequest message,
+ string finalMessageType,
+ Action onProgressUpdate,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default
+ ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage {
+ return await ExecuteWithRetryAsync(
+ agent,
+ async () => await SendAndWaitStreamingInternalAsync(
+ agent,
+ message,
+ finalMessageType,
+ onProgressUpdate,
+ timeout,
+ cancellationToken
+ ),
+ cancellationToken
+ );
+ }
+
+ private async Task SendAndWaitStreamingInternalAsync(
+ Domain.Agent agent,
+ TRequest message,
+ string finalMessageType,
+ Action onProgressUpdate,
+ TimeSpan? timeout,
+ CancellationToken cancellationToken
+ ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage {
+ var correlationId = Guid.NewGuid().ToString();
+ var envelope = envelopeConverter.ToEnvelope( message );
+ envelope.CorrelationId = correlationId;
+
+ // Register streaming correlator BEFORE sending
+ var responseTask = responseCorrelator.WaitForStreamingResponseAsync(
+ correlationId,
+ finalMessageType,
+ onProgressUpdate,
+ timeout ?? _options.StreamingTimeout,
+ cancellationToken
+ );
+
+ // Request
+ var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id );
+ await connection.SendAsync( envelope );
+
+ // Final Response
+ var response = await responseTask;
+ return envelopeConverter.FromEnvelope( response );
+ }
+
+ private async Task ExecuteWithRetryAsync(
+ Domain.Agent agent,
+ Func> operation,
+ CancellationToken cancellationToken
+ ) {
+ var attempt = 0;
+ Exception? lastException = null;
+
+ while ( attempt <= _options.MaxRetryAttempts ) {
+ try {
+ if ( attempt > 0 ) {
+ var delay = CalculateBackoffDelay( attempt );
+ logger.LogDebug(
+ "Retrying operation for agent {AgentId} (attempt {Attempt}/{MaxAttempts}) after {Delay}ms",
+ agent.Id,
+ attempt,
+ _options.MaxRetryAttempts,
+ delay
+ );
+ await Task.Delay( delay, cancellationToken );
+ }
+
+ return await operation();
+ }
+ catch ( OperationCanceledException ) {
+ // Don't retry on cancellation
+ throw;
+ }
+ catch ( Exception ex ) {
+ lastException = ex;
+ attempt++;
+
+ if ( attempt > _options.MaxRetryAttempts ) {
+ logger.LogError(
+ ex,
+ "Operation failed for agent {AgentId} after {Attempts} attempts",
+ agent.Id,
+ attempt
+ );
+ break;
+ }
+
+ logger.LogWarning(
+ ex,
+ "Operation failed for agent {AgentId} (attempt {Attempt}/{MaxAttempts}): {Message}",
+ agent.Id,
+ attempt,
+ _options.MaxRetryAttempts,
+ ex.Message
+ );
+ }
+ }
+
+ // All retries exhausted
+ throw new AggregateException(
+ $"Operation failed for agent {agent.Id} after {attempt} attempts",
+ lastException!
+ );
+ }
+
+ private int CalculateBackoffDelay( int attempt ) {
+ // Exponential backoff: base * 2^(attempt-1)
+ var delay = _options.RetryBaseDelayMs * Math.Pow( 2, attempt - 1 );
+ return (int)Math.Min( delay, _options.RetryMaxDelayMs );
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.Cluster/ClusterOptions.cs b/src/Networking.Cluster/ClusterOptions.cs
new file mode 100644
index 00000000..032ccbb7
--- /dev/null
+++ b/src/Networking.Cluster/ClusterOptions.cs
@@ -0,0 +1,43 @@
+namespace Drift.Networking.Cluster;
+
+public sealed class ClusterOptions {
+ ///
+ /// Gets the maximum number of retry attempts for failed operations.
+ ///
+ public int MaxRetryAttempts {
+ get;
+ init;
+ } = 3;
+
+ ///
+ /// Gets the base delay between retry attempts in milliseconds.
+ ///
+ public int RetryBaseDelayMs {
+ get;
+ init;
+ } = 100;
+
+ ///
+ /// Gets the maximum delay between retry attempts in milliseconds.
+ ///
+ public int RetryMaxDelayMs {
+ get;
+ init;
+ } = 5000;
+
+ ///
+ /// Gets the default timeout for send-and-wait operations.
+ ///
+ public TimeSpan DefaultTimeout {
+ get;
+ init;
+ } = TimeSpan.FromSeconds( 30 );
+
+ ///
+ /// Gets the default timeout for streaming operations (e.g., scans).
+ ///
+ public TimeSpan StreamingTimeout {
+ get;
+ init;
+ } = TimeSpan.FromMinutes( 5 );
+}
\ No newline at end of file
diff --git a/src/Networking.Cluster/Enrollment.cs b/src/Networking.Cluster/Enrollment.cs
new file mode 100644
index 00000000..4d8fcee4
--- /dev/null
+++ b/src/Networking.Cluster/Enrollment.cs
@@ -0,0 +1,12 @@
+namespace Drift.Networking.Cluster;
+
+#pragma warning disable CS9113
+public class EnrollmentRequest( bool parametersAdoptable, string? parametersJoin ) {
+#pragma warning restore CS9113
+ public EnrollmentMethod Method => parametersAdoptable ? EnrollmentMethod.Adoption : EnrollmentMethod.Jwt;
+}
+
+public enum EnrollmentMethod {
+ Adoption,
+ Jwt
+}
\ No newline at end of file
diff --git a/src/Networking.Cluster/ICluster.cs b/src/Networking.Cluster/ICluster.cs
new file mode 100644
index 00000000..14861026
--- /dev/null
+++ b/src/Networking.Cluster/ICluster.cs
@@ -0,0 +1,29 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Networking.Cluster;
+
+public interface ICluster {
+ // Task SendAsync( Domain.Agent agent, IPeerMessage message, CancellationToken cancellationToken = default );
+
+ Task SendAndWaitAsync(
+ Domain.Agent agent,
+ TRequest message,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default
+ ) where TResponse : IPeerResponse where TRequest : IPeerRequest;
+
+ Task SendAndWaitStreamingAsync(
+ Domain.Agent agent,
+ TRequest message,
+ string finalMessageType,
+ Action onProgressUpdate,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default
+ ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage;
+
+ /*Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default );
+ Task> RequestSubnetsAsync( string peerAddress, CancellationToken cancellationToken = default );
+ Task EnsureConnectedAsync( string peerAddress, CancellationToken cancellationToken = default );
+ IReadOnlyCollection GetConnectedPeers();*/
+}
\ No newline at end of file
diff --git a/src/Networking.Cluster/Networking.Cluster.csproj b/src/Networking.Cluster/Networking.Cluster.csproj
new file mode 100644
index 00000000..9c366ae1
--- /dev/null
+++ b/src/Networking.Cluster/Networking.Cluster.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/Networking.Cluster/ServiceCollectionExtensions.cs b/src/Networking.Cluster/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..5d26edd7
--- /dev/null
+++ b/src/Networking.Cluster/ServiceCollectionExtensions.cs
@@ -0,0 +1,9 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Drift.Networking.Cluster;
+
+public static class ServiceCollectionExtensions {
+ public static void AddClustering( this IServiceCollection services ) {
+ services.AddScoped();
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs
new file mode 100644
index 00000000..85cbf055
--- /dev/null
+++ b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs
@@ -0,0 +1,13 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Grpc.Net.Client;
+
+namespace Drift.Networking.PeerStreaming.Client;
+
+internal sealed class DefaultPeerClientFactory : IPeerClientFactory {
+ public (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ) {
+ var channel = GrpcChannel.ForAddress( address, new GrpcChannelOptions() );
+ var client = new PeerService.PeerServiceClient( channel );
+ return ( client, channel );
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj b/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj
new file mode 100644
index 00000000..20592b5f
--- /dev/null
+++ b/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..81cf54a9
--- /dev/null
+++ b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs
@@ -0,0 +1,10 @@
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Drift.Networking.PeerStreaming.Client;
+
+public static class ServiceCollectionExtensions {
+ public static void AddPeerStreamingClient( this IServiceCollection services ) {
+ services.AddSingleton();
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs
new file mode 100644
index 00000000..d516ef7b
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs
@@ -0,0 +1,6 @@
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public enum ConnectionSide {
+ Incoming,
+ Outgoing
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs b/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs
new file mode 100644
index 00000000..2c43940d
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public class Empty : IPeerResponse {
+ private Empty() {
+ }
+
+ internal static Empty Instance {
+ get;
+ } = new();
+
+ public static string MessageType => "empty-response";
+
+ public static JsonTypeInfo JsonInfo => throw new NotSupportedException();
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs
new file mode 100644
index 00000000..f7a92656
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs
@@ -0,0 +1,8 @@
+using Drift.Networking.Grpc.Generated;
+using Grpc.Net.Client;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerClientFactory {
+ (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address );
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs
new file mode 100644
index 00000000..0d6ef8e3
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerMessage {
+ static abstract string MessageType {
+ get;
+ }
+
+ static abstract JsonTypeInfo JsonInfo {
+ get;
+ }
+}
+
+#pragma warning disable S2326 // TResponse is an intentional phantom type parameter for type-safe request/response pairing
+public interface IPeerRequest : IPeerMessage where TResponse : IPeerResponse;
+#pragma warning restore S2326
+
+public interface IPeerResponse : IPeerMessage {
+ static readonly Empty Empty = Empty.Instance;
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs
new file mode 100644
index 00000000..69a02c1a
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs
@@ -0,0 +1,9 @@
+using Drift.Networking.Grpc.Generated;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerMessageEnvelopeConverter {
+ public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage;
+
+ public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage;
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs
new file mode 100644
index 00000000..26a7740b
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs
@@ -0,0 +1,23 @@
+using Drift.Networking.Grpc.Generated;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerMessageHandler {
+ ///
+ /// Gets the message type name that this handler can process.
+ ///
+ string MessageType {
+ get;
+ }
+
+ ///
+ /// Handles an incoming peer message. The handler is responsible for sending response(s)
+ /// via the provided stream. Can send multiple responses for streaming scenarios.
+ ///
+ Task HandleAsync(
+ PeerMessage envelope,
+ IPeerMessageEnvelopeConverter converter,
+ IPeerStream stream,
+ CancellationToken cancellationToken
+ );
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs
new file mode 100644
index 00000000..37d6fe8d
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs
@@ -0,0 +1,7 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerMessageTypesProvider {
+ Dictionary Get();
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs
new file mode 100644
index 00000000..6d34c9b8
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs
@@ -0,0 +1,20 @@
+using Drift.Domain;
+using Drift.Networking.Grpc.Generated;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerStream : IAsyncDisposable {
+ public int InstanceNo {
+ get;
+ }
+
+ public AgentId AgentId {
+ get;
+ }
+
+ public Task ReadTask {
+ get;
+ }
+
+ public Task SendAsync( PeerMessage message );
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs
new file mode 100644
index 00000000..cf4576e3
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs
@@ -0,0 +1,15 @@
+using Drift.Domain;
+using Drift.Networking.Grpc.Generated;
+using Grpc.Core;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public interface IPeerStreamManager : IAsyncDisposable {
+ public IPeerStream GetOrCreate( Uri peerAddress, AgentId id );
+
+ public IPeerStream Create(
+ IAsyncStreamReader requestStream,
+ IAsyncStreamWriter responseStream,
+ ServerCallContext context
+ );
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj b/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj
new file mode 100644
index 00000000..97a14ed6
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs
new file mode 100644
index 00000000..92ea5b17
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs
@@ -0,0 +1,36 @@
+using Drift.Networking.Grpc.Generated;
+
+namespace Drift.Networking.PeerStreaming.Core.Abstractions;
+
+public static class PeerMessageHandlerExtensions {
+ ///
+ /// Helper to send a response with the correct correlation ID set.
+ ///
+ /// The type of the response message to send.
+ public static async Task SendResponseAsync(
+ this IPeerStream stream,
+ IPeerMessageEnvelopeConverter converter,
+ TResponse response,
+ string correlationId
+ ) where TResponse : IPeerMessage {
+ var envelope = converter.ToEnvelope( response );
+ envelope.ReplyTo = correlationId;
+ await stream.SendAsync( envelope );
+ }
+
+ ///
+ /// Helper to send a response without awaiting (fire and forget).
+ /// Useful for progress updates that shouldn't block processing.
+ ///
+ /// The type of the response message to send.
+ public static void SendResponseFireAndForget(
+ this IPeerStream stream,
+ IPeerMessageEnvelopeConverter converter,
+ TResponse response,
+ string correlationId
+ ) where TResponse : IPeerMessage {
+ var envelope = converter.ToEnvelope( response );
+ envelope.ReplyTo = correlationId;
+ _ = stream.SendAsync( envelope );
+ }
+}
diff --git a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs b/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs
new file mode 100644
index 00000000..77717661
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs
@@ -0,0 +1,16 @@
+using Drift.Domain;
+using Grpc.Core;
+
+namespace Drift.Networking.PeerStreaming.Core.Common;
+
+internal static class GrpcMetadataExtensions {
+ internal static AgentId GetAgentId( this Metadata metadata ) {
+ var v = metadata.Get( "agent-id" );
+
+ if ( v == null ) {
+ throw new Exception( "AgentId not found in gRPC metadata" );
+ }
+
+ return new AgentId( v.Value );
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs
new file mode 100644
index 00000000..18fa525e
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs
@@ -0,0 +1,48 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Networking.PeerStreaming.Core.Messages;
+
+public sealed class PeerMessageDispatcher {
+ private readonly PeerResponseCorrelator _responseCorrelator;
+ private readonly IPeerMessageEnvelopeConverter _envelopeConverter;
+ private readonly ILogger _logger;
+ private readonly Dictionary _handlers;
+
+ public PeerMessageDispatcher(
+ IEnumerable handlers,
+ IPeerMessageEnvelopeConverter envelopeConverter,
+ PeerResponseCorrelator responseCorrelator,
+ ILogger logger
+ ) {
+ _responseCorrelator = responseCorrelator;
+ _envelopeConverter = envelopeConverter;
+ _logger = logger;
+ _handlers = handlers.ToDictionary( h => h.MessageType, StringComparer.OrdinalIgnoreCase );
+ }
+
+ public async Task DispatchAsync( PeerMessage message, PeerStream peerStream, CancellationToken ct = default ) {
+ _logger.LogDebug( "Dispatching message: {Type}", message.MessageType );
+
+ // If this is a response to a pending request, complete it
+ if ( !string.IsNullOrEmpty( message.ReplyTo ) ) {
+ if ( _responseCorrelator.TryCompleteResponse( message.ReplyTo, message ) ) {
+ _logger.LogDebug( "Completed pending request: {CorrelationId}", message.ReplyTo );
+ }
+ else {
+ _logger.LogWarning( "Ignoring response for unknown correlation ID: {CorrelationId}", message.ReplyTo );
+ }
+
+ return;
+ }
+
+ // Dispatch to handler - handler is responsible for sending response(s)
+ if ( _handlers.TryGetValue( message.MessageType, out var handler ) ) {
+ await handler.HandleAsync( message, _envelopeConverter, peerStream, ct );
+ return;
+ }
+
+ throw new NotImplementedException( "Unknown message type '" + message.MessageType + "'" );
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs
new file mode 100644
index 00000000..3b9a4467
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs
@@ -0,0 +1,22 @@
+using System.Text.Json;
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+
+namespace Drift.Networking.PeerStreaming.Core.Messages;
+
+internal sealed class PeerMessageEnvelopeConverter : IPeerMessageEnvelopeConverter {
+ public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage {
+ string json = JsonSerializer.Serialize( message, T.JsonInfo );
+ return new PeerMessage { MessageType = T.MessageType, Message = json, };
+ }
+
+ public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage {
+ if ( envelope.MessageType != T.MessageType ) {
+ throw new InvalidOperationException(
+ $"Envelope contains '{envelope.MessageType}' but caller expects '{T.MessageType}'."
+ );
+ }
+
+ return JsonSerializer.Deserialize( envelope.Message, T.JsonInfo.Options )!;
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs
new file mode 100644
index 00000000..d0c16f2f
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs
@@ -0,0 +1,104 @@
+using System.Collections.Concurrent;
+using Drift.Networking.Grpc.Generated;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Networking.PeerStreaming.Core.Messages;
+
+// TODO private?
+public sealed class PeerResponseCorrelator {
+ private readonly ConcurrentDictionary> _pendingRequests = new();
+ private readonly ConcurrentDictionary _streamingRequests = new();
+ private readonly ILogger _logger;
+
+ public PeerResponseCorrelator( ILogger logger ) {
+ _logger = logger;
+ }
+
+ public Task WaitForResponseAsync( string correlationId, TimeSpan timeout, CancellationToken ct ) {
+ var tcs = new TaskCompletionSource();
+
+ if ( !_pendingRequests.TryAdd( correlationId, tcs ) ) {
+ throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" );
+ }
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource( ct );
+ cts.CancelAfter( timeout );
+
+ cts.Token.Register( () => {
+ if ( _pendingRequests.TryRemove( correlationId, out var removed ) ) {
+ removed.TrySetCanceled();
+ }
+ } );
+
+ return tcs.Task;
+ }
+
+ public Task WaitForStreamingResponseAsync(
+ string correlationId,
+ string finalMessageType,
+ Action onProgressUpdate,
+ TimeSpan timeout,
+ CancellationToken ct
+ ) {
+ var handler = new StreamingResponseHandler {
+ CompletionSource = new TaskCompletionSource(),
+ FinalMessageType = finalMessageType,
+ OnProgressUpdate = onProgressUpdate
+ };
+
+ if ( !_streamingRequests.TryAdd( correlationId, handler ) ) {
+ throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" );
+ }
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource( ct );
+ cts.CancelAfter( timeout );
+
+ cts.Token.Register( () => {
+ if ( _streamingRequests.TryRemove( correlationId, out var removed ) ) {
+ removed.CompletionSource.TrySetCanceled();
+ }
+ } );
+
+ return handler.CompletionSource.Task;
+ }
+
+ public bool TryCompleteResponse( string correlationId, PeerMessage response ) {
+ // Check for streaming response first
+ if ( _streamingRequests.TryGetValue( correlationId, out var streamingHandler ) ) {
+ // If this is the final message, complete the task
+ if ( response.MessageType == streamingHandler.FinalMessageType ) {
+ _streamingRequests.TryRemove( correlationId, out _ );
+ return streamingHandler.CompletionSource.TrySetResult( response );
+ }
+
+ // Otherwise, it's a progress update
+ streamingHandler.OnProgressUpdate( response );
+ return true;
+ }
+
+ // Check for regular single response
+ if ( _pendingRequests.TryRemove( correlationId, out var tcs ) ) {
+ return tcs.TrySetResult( response );
+ }
+
+ _logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId );
+ return false;
+ }
+
+ private sealed class StreamingResponseHandler {
+ public required TaskCompletionSource CompletionSource {
+ get;
+ init;
+ }
+
+ public required string FinalMessageType {
+ get;
+ init;
+ }
+
+ public required Action OnProgressUpdate {
+ get;
+ init;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj
new file mode 100644
index 00000000..9d441dfb
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Networking.PeerStreaming.Core/PeerStream.cs b/src/Networking.PeerStreaming.Core/PeerStream.cs
new file mode 100644
index 00000000..4608989c
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/PeerStream.cs
@@ -0,0 +1,121 @@
+using Drift.Domain;
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Networking.PeerStreaming.Core.Messages;
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Networking.PeerStreaming.Core;
+
+public sealed class PeerStream : IPeerStream {
+ private static int _instanceCounter; // Being static is not ideal for testing with multiple instances
+ private readonly IAsyncStreamReader _reader;
+ private readonly IAsyncStreamWriter _writer;
+ private readonly PeerMessageDispatcher _dispatcher;
+ private readonly ILogger _logger;
+ private readonly CancellationToken _cancellationToken;
+
+ public PeerStream(
+ IAsyncStreamReader reader,
+ IAsyncStreamWriter writer,
+ PeerMessageDispatcher dispatcher,
+ ILogger logger,
+ CancellationToken cancellationToken
+ ) {
+ Side = ConnectionSide.Incoming;
+ _reader = reader;
+ _writer = writer;
+ _dispatcher = dispatcher;
+ _logger = logger;
+ _cancellationToken = cancellationToken;
+ // The read loop is considering the cancellation token to ensure a clean shutdown. Don't pass it to the task.
+ ReadTask = Task.Run( ReadLoopAsync, CancellationToken.None );
+ }
+
+ public PeerStream(
+ Uri address,
+ IAsyncStreamReader reader,
+ IAsyncStreamWriter writer,
+ PeerMessageDispatcher dispatcher,
+ ILogger logger,
+ CancellationToken cancellationToken
+ ) : this( reader, writer, dispatcher, logger, cancellationToken ) {
+ Side = ConnectionSide.Outgoing;
+ Address = address;
+ }
+
+ public int InstanceNo {
+ get;
+ } = Interlocked.Increment( ref _instanceCounter );
+
+ private ConnectionSide Side {
+ get;
+ }
+
+ private Uri? Address {
+ get;
+ }
+
+ public required AgentId AgentId {
+ get;
+ init;
+ }
+
+ public Task ReadTask {
+ get;
+ private init;
+ }
+
+ public async Task SendAsync( PeerMessage message ) {
+ await _writer.WriteAsync( message, _cancellationToken ); // TODO also take another cancellation token (combine)
+ }
+
+ private async Task ReadLoopAsync() {
+ _logger.LogDebug( "Read loop starting..." );
+
+ try {
+ await foreach ( var message in _reader.ReadAllAsync( _cancellationToken ) ) {
+ try {
+ // TODO ensure this is printed in the output
+ using var scope = _logger.BeginScope(
+ new Dictionary { ["RequestId"] = message.CorrelationId ?? "no-id" }
+ );
+ _logger.LogDebug( "Received message. Dispatching to handler..." );
+ await _dispatcher.DispatchAsync( message, this, CancellationToken.None );
+ _logger.LogDebug( "Dispatch completed. Waiting for next message..." );
+ }
+ catch ( Exception ex ) {
+ _logger.LogError( ex, "Message dispatch failed" );
+ }
+ }
+
+ _logger.LogDebug( "Read loop ended gracefully (end of stream)" );
+ }
+ catch ( OperationCanceledException ) {
+ // Justification: exception is control flow, not an error
+#pragma warning disable S6667
+ _logger.LogDebug( "Read loop ended gracefully (cancelled)" );
+#pragma warning restore S6667
+ }
+ catch ( Exception ex ) {
+ _logger.LogError( ex, "Read loop failed" );
+ }
+ }
+
+ public async ValueTask DisposeAsync() {
+ _logger.LogTrace( "Disposing {PeerStream}", this );
+
+ if ( _writer is IClientStreamWriter clientWriter ) {
+ // I.e., outgoing stream (client initiated)
+ // Server streams are automatically completed by the gRPC framework
+ await clientWriter.CompleteAsync();
+ }
+
+ await ReadTask;
+ }
+
+ public override string ToString() {
+ return
+ $"{nameof(PeerStream)}[#{InstanceNo}, {nameof(AgentId)}={AgentId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]";
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs
new file mode 100644
index 00000000..78b0f673
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs
@@ -0,0 +1,79 @@
+using System.Collections.Concurrent;
+using Drift.Domain;
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Networking.PeerStreaming.Core.Common;
+using Drift.Networking.PeerStreaming.Core.Messages;
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Networking.PeerStreaming.Core;
+
+internal sealed class PeerStreamManager(
+ ILogger logger,
+ IPeerClientFactory? peerClientFactory,
+ PeerMessageDispatcher dispatcher,
+ PeerStreamingOptions options
+) : IPeerStreamManager {
+ private readonly ConcurrentDictionary _streams = new();
+
+ public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ) {
+ logger.LogDebug(
+ "Getting or creating {ConnectionSide} stream to agent {Id} ({Address})",
+ ConnectionSide.Outgoing,
+ id,
+ peerAddress
+ );
+
+ return _streams.GetOrAdd( id, agentId => Create( peerAddress, agentId ) );
+ }
+
+ private IPeerStream Create( Uri peerAddress, AgentId id ) {
+ if ( peerClientFactory == null ) {
+ throw new Exception( $"Cannot create outbound stream since {nameof(peerClientFactory)} is null" );
+ }
+
+ var (client, _) = peerClientFactory.Create( peerAddress );
+ var callOptions = new CallOptions( new Metadata { { "agent-id", id } } );
+ var call = client.PeerStream( callOptions );
+
+ var stream = new PeerStream(
+ peerAddress,
+ call.ResponseStream,
+ call.RequestStream,
+ dispatcher,
+ logger,
+ options.StoppingToken
+ ) { AgentId = id };
+ Add( stream );
+ return stream;
+ }
+
+ public IPeerStream Create(
+ IAsyncStreamReader requestStream,
+ IAsyncStreamWriter responseStream,
+ ServerCallContext context
+ ) {
+ var agentId = context.RequestHeaders.GetAgentId();
+
+ logger.LogInformation( "Creating {ConnectionSide} stream from agent {Id}", ConnectionSide.Incoming, agentId );
+
+ var stream =
+ new PeerStream( requestStream, responseStream, dispatcher, logger, options.StoppingToken ) { AgentId = agentId };
+ Add( stream );
+ return stream;
+ }
+
+ private void Add( IPeerStream stream ) {
+ logger.LogTrace( "Created {Stream}", stream );
+ _streams[stream.AgentId] = stream;
+ }
+
+ public async ValueTask DisposeAsync() {
+ logger.LogDebug( "Disposing peer stream manager (including all streams)" );
+ foreach ( var stream in _streams.Values ) {
+ logger.LogTrace( "Disposing peer stream #{StreamNo}", stream.InstanceNo );
+ await stream.DisposeAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs b/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs
new file mode 100644
index 00000000..2ca2a36e
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs
@@ -0,0 +1,15 @@
+using System.Reflection;
+
+namespace Drift.Networking.PeerStreaming.Core;
+
+public sealed class PeerStreamingOptions {
+ public CancellationToken StoppingToken {
+ get;
+ set;
+ }
+
+ public Assembly MessageAssembly {
+ get;
+ init;
+ } = Assembly.GetExecutingAssembly();
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..4ca96603
--- /dev/null
+++ b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs
@@ -0,0 +1,18 @@
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Drift.Networking.PeerStreaming.Core.Messages;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Drift.Networking.PeerStreaming.Core;
+
+public static class ServiceCollectionExtensions {
+ public static void AddPeerStreamingCore(
+ this IServiceCollection services,
+ PeerStreamingOptions options
+ ) {
+ services.AddSingleton( options );
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj b/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj
new file mode 100644
index 00000000..92ab3e5b
--- /dev/null
+++ b/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto b/src/Networking.PeerStreaming.Grpc/Protos/peer.proto
new file mode 100644
index 00000000..c0fccd55
--- /dev/null
+++ b/src/Networking.PeerStreaming.Grpc/Protos/peer.proto
@@ -0,0 +1,17 @@
+syntax = "proto3";
+
+option csharp_namespace = "Drift.Networking.Grpc.Generated";
+
+package peer;
+
+// AgentService?
+service PeerService {
+ rpc PeerStream (stream PeerMessage) returns (stream PeerMessage);
+}
+
+message PeerMessage {
+ string message_type = 1;
+ string message = 2;
+ string correlation_id = 3; // For request-response matching
+ string reply_to = 4; // If this is a response, which request it replies to
+}
diff --git a/src/Networking.PeerStreaming.Server/InboundPeerService.cs b/src/Networking.PeerStreaming.Server/InboundPeerService.cs
new file mode 100644
index 00000000..d503718f
--- /dev/null
+++ b/src/Networking.PeerStreaming.Server/InboundPeerService.cs
@@ -0,0 +1,31 @@
+using Drift.Networking.Grpc.Generated;
+using Drift.Networking.PeerStreaming.Core.Abstractions;
+using Grpc.Core;
+using Microsoft.Extensions.Logging;
+
+namespace Drift.Networking.PeerStreaming.Server;
+
+// Handle incoming connections (AKA server-side).
+internal sealed class InboundPeerService( IPeerStreamManager peerStreamManager, ILogger logger )
+ : PeerService.PeerServiceBase {
+ public override async Task PeerStream(
+ IAsyncStreamReader requestStream,
+ IServerStreamWriter responseStream,
+ ServerCallContext context
+ ) {
+ try {
+ logger.LogInformation( "Inbound stream starting..." );
+ var stream = peerStreamManager.Create( requestStream, responseStream, context );
+ logger.LogInformation( "Peer stream #{StreamNo} created", stream.InstanceNo );
+
+ // The stream is closed when the method returns.
+ // We thus wait for the read loop to complete (meaning that this client is no longer interested in the stream).
+ await stream.ReadTask;
+
+ logger.LogInformation( "Peer stream #{StreamNo} completed", stream.InstanceNo );
+ }
+ catch ( Exception ex ) {
+ logger.LogError( ex, "Inbound stream failed" );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj
new file mode 100644
index 00000000..76db19ff
--- /dev/null
+++ b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs
new file mode 100644
index 00000000..2b09ab2f
--- /dev/null
+++ b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs
@@ -0,0 +1,8 @@
+namespace Drift.Networking.PeerStreaming.Server;
+
+internal sealed class PeerStreamingServerMarker {
+ internal bool EndpointsMapped {
+ get;
+ set;
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..79843ab4
--- /dev/null
+++ b/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs
@@ -0,0 +1,47 @@
+using Grpc.AspNetCore.Server;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Drift.Networking.PeerStreaming.Server;
+
+public static class ServiceCollectionExtensions {
+ public static void AddPeerStreamingServer(
+ this IServiceCollection services,
+ Action? configureOptions = null
+ ) {
+ services.AddSingleton();
+ services.AddGrpc( options => configureOptions?.Invoke( options ) );
+ services.AddTransient();
+ }
+
+ public static void MapPeerStreamingServerEndpoints( this IEndpointRouteBuilder app ) {
+ var marker = app.ServiceProvider.GetService();
+
+ if ( marker == null ) {
+ throw new InvalidOperationException(
+ $"Unable to find the required services. Add them by calling '{nameof(IServiceCollection)}.{nameof(AddPeerStreamingServer)}'."
+ );
+ }
+
+ app.MapGrpcService();
+
+ marker.EndpointsMapped = true;
+ }
+}
+
+internal sealed class PeerStreamingServerValidationFilter : IStartupFilter {
+ public Action Configure( Action next ) {
+ return app => {
+ next( app );
+
+ var marker = app.ApplicationServices.GetRequiredService();
+
+ if ( !marker.EndpointsMapped ) {
+ throw new InvalidOperationException(
+ $"Server endpoints were not mapped. Map them by calling '{nameof(IEndpointRouteBuilder)}.{nameof(ServiceCollectionExtensions.MapPeerStreamingServerEndpoints)}'." );
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs b/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..5493e66a
--- /dev/null
+++ b/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs
@@ -0,0 +1 @@
+[assembly: Category( "Unit" )]
\ No newline at end of file
diff --git a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs b/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs
new file mode 100644
index 00000000..d4a78d08
--- /dev/null
+++ b/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs
@@ -0,0 +1,121 @@
+using System.Threading.Channels;
+using Grpc.Core;
+using Channel = System.Threading.Channels.Channel;
+
+namespace Drift.Networking.PeerStreaming.Tests.Helpers;
+
+internal sealed record DuplexStreamEndpoint( TRequest RequestStream, TResponse ResponseStream );
+
+///
+/// Provides an in-memory bidirectional gRPC stream pair (one endpoint is the client, the other is the server).
+///
+internal static class InMemoryDuplexStreamPair {
+ public static (
+ DuplexStreamEndpoint, IAsyncStreamReader> Client,
+ DuplexStreamEndpoint, IServerStreamWriter> Server
+ )
+ Create( ServerCallContext serverContext ) where TRequest : class where TResponse : class {
+ var clientToServer = Channel.CreateUnbounded();
+ var serverToClient = Channel.CreateUnbounded();
+
+ var server = new DuplexStreamEndpoint, IServerStreamWriter>(
+ new InMemoryServerStreamReader( clientToServer.Reader, serverContext ),
+ new InMemoryServerStreamWriter( serverToClient.Writer, serverContext )
+ );
+
+ var client = new DuplexStreamEndpoint, IAsyncStreamReader>(
+ new InMemoryStreamWriter( clientToServer.Writer ),
+ new InMemoryStreamReader( serverToClient.Reader )
+ );
+
+ return ( client, server );
+ }
+
+ private sealed class InMemoryStreamWriter : IClientStreamWriter where T : class {
+ private readonly ChannelWriter _writer;
+
+ internal InMemoryStreamWriter( ChannelWriter writer ) {
+ _writer = writer;
+ }
+
+ public WriteOptions? WriteOptions {
+ get;
+ set;
+ }
+
+ public async Task WriteAsync( T message ) {
+ await _writer.WriteAsync( message );
+ }
+
+ public Task CompleteAsync() {
+ _writer.Complete();
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class InMemoryServerStreamReader : InMemoryStreamReader where T : class {
+ private readonly ServerCallContext _context;
+
+ internal InMemoryServerStreamReader( ChannelReader reader, ServerCallContext context ) : base( reader ) {
+ _context = context;
+ }
+
+ public override Task MoveNext( CancellationToken cancellationToken ) {
+ _context.CancellationToken.ThrowIfCancellationRequested();
+ return base.MoveNext( cancellationToken );
+ }
+ }
+
+ private class InMemoryStreamReader : IAsyncStreamReader where T : class {
+ private readonly ChannelReader _reader;
+
+ internal InMemoryStreamReader( ChannelReader reader ) {
+ _reader = reader;
+ }
+
+ public T Current {
+ get;
+ private set;
+ } = null!;
+
+ public virtual async Task MoveNext( CancellationToken cancellationToken ) {
+ if ( await _reader.WaitToReadAsync( cancellationToken ) && _reader.TryRead( out var message ) ) {
+ Current = message;
+ return true;
+ }
+
+ Current = null!;
+ return false;
+ }
+ }
+
+ private sealed class InMemoryServerStreamWriter : IServerStreamWriter where T : class {
+ private readonly ChannelWriter