Skip to content

Commit 708f55e

Browse files
committed
Add SharpPcap RAW transport project
1 parent ce8831d commit 708f55e

34 files changed

Lines changed: 1367 additions & 0 deletions
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
using System.Net.NetworkInformation;
3+
using System.Text.RegularExpressions;
4+
using PtpLabClock.Core.Abstractions;
5+
using PtpLabClock.Pcap.Infrastructure;
6+
using SharpPcap;
7+
8+
namespace PtpLabClock.Pcap.Adapters;
9+
10+
/// <summary>
11+
/// Enumerates live packet-capture adapters exposed by Npcap/libpcap.
12+
/// </summary>
13+
public sealed class NpcapAdapterProvider : IAdapterProvider
14+
{
15+
public string LastDiagnostic { get; private set; } = string.Empty;
16+
17+
public IReadOnlyList<NetworkAdapterInfoDto> GetAdapters()
18+
{
19+
LastDiagnostic = string.Empty;
20+
21+
try
22+
{
23+
var devices = CaptureDeviceList.Instance;
24+
if (devices.Count == 0)
25+
{
26+
LastDiagnostic = "Npcap/libpcap returned zero capture devices. Install Npcap and allow non-admin capture or run as Administrator.";
27+
return Array.Empty<NetworkAdapterInfoDto>();
28+
}
29+
30+
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
31+
var result = new List<NetworkAdapterInfoDto>();
32+
var skipped = new List<string>();
33+
34+
foreach (var device in devices.OfType<ILiveDevice>())
35+
{
36+
try
37+
{
38+
var name = device.Name ?? string.Empty;
39+
if (string.IsNullOrWhiteSpace(name))
40+
{
41+
skipped.Add("unnamed capture device");
42+
continue;
43+
}
44+
45+
var matchedInterface = TryFindMatchingInterface(name, networkInterfaces);
46+
var isLoopback = LooksLikeLoopback(device, matchedInterface);
47+
if (isLoopback)
48+
{
49+
skipped.Add($"loopback: {GetFriendlyName(device)}");
50+
continue;
51+
}
52+
53+
var friendlyName = matchedInterface?.Name ?? GetFriendlyName(device);
54+
var mac = FormatMac(matchedInterface?.GetPhysicalAddress());
55+
var isWireless = matchedInterface?.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
56+
var isVirtual = LooksVirtual(device, matchedInterface);
57+
var isUp = matchedInterface is null || matchedInterface.OperationalStatus == OperationalStatus.Up;
58+
var diagnostic = BuildDiagnostic(device, matchedInterface, isWireless, isVirtual, isUp, mac);
59+
60+
result.Add(new NetworkAdapterInfoDto
61+
{
62+
Id = PcapAdapterId.FromDeviceName(name),
63+
PcapName = name,
64+
Name = friendlyName,
65+
Description = BuildDescription(device, matchedInterface, diagnostic),
66+
PhysicalAddress = mac,
67+
IsDemo = false,
68+
IsLoopback = false,
69+
IsWireless = isWireless,
70+
IsVirtual = isVirtual,
71+
IsUp = isUp,
72+
OperationalStatus = matchedInterface?.OperationalStatus.ToString() ?? "Unknown",
73+
Diagnostics = diagnostic
74+
});
75+
}
76+
catch (Exception ex)
77+
{
78+
skipped.Add($"device scan error: {ex.Message}");
79+
}
80+
}
81+
82+
LastDiagnostic = BuildSummary(result.Count, skipped);
83+
84+
return result
85+
.OrderBy(x => x.IsUp ? 0 : 1)
86+
.ThenBy(x => x.IsWireless ? 1 : 0)
87+
.ThenBy(x => x.IsVirtual ? 1 : 0)
88+
.ThenBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
89+
.ToArray();
90+
}
91+
catch (DllNotFoundException ex)
92+
{
93+
LastDiagnostic = "Npcap/Packet.dll was not found. Install Npcap, then restart the app. " + ex.Message;
94+
return Array.Empty<NetworkAdapterInfoDto>();
95+
}
96+
catch (Exception ex)
97+
{
98+
LastDiagnostic = "Npcap adapter scan failed. Demo Mode remains available. " + ex.Message;
99+
return Array.Empty<NetworkAdapterInfoDto>();
100+
}
101+
}
102+
103+
private static bool LooksLikeLoopback(ICaptureDevice device, NetworkInterface? networkInterface)
104+
{
105+
if (networkInterface?.NetworkInterfaceType == NetworkInterfaceType.Loopback)
106+
return true;
107+
108+
var text = $"{device.Name} {device.Description} {networkInterface?.Name} {networkInterface?.Description}";
109+
return text.Contains("loopback", StringComparison.OrdinalIgnoreCase)
110+
|| text.Contains("NPF_Loopback", StringComparison.OrdinalIgnoreCase);
111+
}
112+
113+
private static bool LooksVirtual(ICaptureDevice device, NetworkInterface? networkInterface)
114+
{
115+
if (networkInterface?.NetworkInterfaceType is NetworkInterfaceType.Tunnel or NetworkInterfaceType.Ppp)
116+
return true;
117+
118+
var text = $"{device.Name} {device.Description} {networkInterface?.Name} {networkInterface?.Description}";
119+
string[] markers =
120+
[
121+
"virtual", "hyper-v", "vmware", "virtualbox", "vpn", "tap", "tunnel", "docker", "npcap loopback",
122+
"bluetooth", "zerotier", "tailscale", "wireguard", "wintun"
123+
];
124+
125+
return markers.Any(marker => text.Contains(marker, StringComparison.OrdinalIgnoreCase));
126+
}
127+
128+
private static string GetFriendlyName(ICaptureDevice device)
129+
{
130+
if (!string.IsNullOrWhiteSpace(device.Description))
131+
return device.Description!;
132+
133+
return device.Name ?? "Npcap adapter";
134+
}
135+
136+
private static string BuildDescription(ICaptureDevice device, NetworkInterface? networkInterface, string diagnostic)
137+
{
138+
var friendly = networkInterface?.Name ?? GetFriendlyName(device);
139+
var detail = networkInterface?.Description ?? device.Description ?? device.Name ?? "Npcap adapter";
140+
return $"{friendly} - {detail} [{diagnostic}]";
141+
}
142+
143+
private static string BuildDiagnostic(ICaptureDevice device, NetworkInterface? networkInterface, bool isWireless, bool isVirtual, bool isUp, string mac)
144+
{
145+
var tags = new List<string>();
146+
147+
if (!isUp) tags.Add("DOWN");
148+
if (isWireless) tags.Add("Wi-Fi");
149+
if (isVirtual) tags.Add("Virtual/VPN");
150+
if (!string.IsNullOrWhiteSpace(mac)) tags.Add(mac); else tags.Add("No MAC");
151+
if (networkInterface is not null) tags.Add(networkInterface.NetworkInterfaceType.ToString());
152+
else tags.Add("Pcap-only");
153+
154+
return string.Join(" • ", tags);
155+
}
156+
157+
private static string BuildSummary(int usableCount, IReadOnlyList<string> skipped)
158+
{
159+
var baseText = usableCount == 0
160+
? "No usable RAW adapter candidate was detected. Demo Mode is still available."
161+
: $"{usableCount} RAW adapter candidate(s) detected. Prefer a wired Ethernet adapter for process-bus lab traffic.";
162+
163+
if (skipped.Count == 0)
164+
return baseText;
165+
166+
return baseText + " Skipped: " + string.Join("; ", skipped.Take(4));
167+
}
168+
169+
private static string FormatMac(PhysicalAddress? address)
170+
{
171+
if (address is null)
172+
return string.Empty;
173+
174+
var bytes = address.GetAddressBytes();
175+
if (bytes.Length != 6 || bytes.All(b => b == 0))
176+
return string.Empty;
177+
178+
return string.Join("-", bytes.Select(b => b.ToString("X2")));
179+
}
180+
181+
private static NetworkInterface? TryFindMatchingInterface(string deviceName, IEnumerable<NetworkInterface> interfaces)
182+
{
183+
var guid = TryExtractGuid(deviceName);
184+
if (guid is null)
185+
return null;
186+
187+
return interfaces.FirstOrDefault(ni => string.Equals(NormalizeGuid(ni.Id), guid, StringComparison.OrdinalIgnoreCase));
188+
}
189+
190+
private static string? TryExtractGuid(string value)
191+
{
192+
var match = Regex.Match(value, "\\{(?<guid>[0-9A-Fa-f-]{36})\\}");
193+
return match.Success ? NormalizeGuid(match.Groups["guid"].Value) : null;
194+
}
195+
196+
private static string NormalizeGuid(string value) => value.Trim('{', '}').ToUpperInvariant();
197+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
using PtpLabClock.Core.Engine;
3+
using PtpLabClock.Pcap.Infrastructure;
4+
using PtpLabClock.Protocol;
5+
using PtpLabClock.Protocol.Enums;
6+
using PtpLabClock.Protocol.Ethernet;
7+
using PtpLabClock.Protocol.Messages;
8+
using PtpLabClock.Protocol.Serialization;
9+
using SharpPcap;
10+
11+
namespace PtpLabClock.Pcap.Diagnostics;
12+
13+
public sealed class NpcapRawSelfTestResult
14+
{
15+
public bool AdapterOpened { get; init; }
16+
public bool FilterApplied { get; init; }
17+
public bool SendSucceeded { get; init; }
18+
public bool LocalCaptureObserved { get; init; }
19+
public string Summary { get; init; } = string.Empty;
20+
public IReadOnlyList<string> Events { get; init; } = Array.Empty<string>();
21+
22+
public bool Passed => AdapterOpened && FilterApplied && SendSucceeded;
23+
}
24+
25+
public sealed class NpcapRawSelfTest
26+
{
27+
private readonly PtpMessageSerializer _serializer = new();
28+
29+
public async Task<NpcapRawSelfTestResult> RunAsync(
30+
string adapterId,
31+
string sourceMac,
32+
string clockIdentity,
33+
byte domainNumber,
34+
bool enableVlan = false,
35+
ushort vlanId = 100,
36+
byte vlanPriority = 4,
37+
CancellationToken cancellationToken = default)
38+
{
39+
var events = new List<string>();
40+
var deviceName = PcapAdapterId.ToDeviceName(adapterId);
41+
var device = CaptureDeviceList.Instance
42+
.OfType<ILiveDevice>()
43+
.FirstOrDefault(d => string.Equals(d.Name, deviceName, StringComparison.OrdinalIgnoreCase));
44+
45+
if (device is null)
46+
{
47+
return Fail(events, "Adapter not found in Npcap device list. Refresh adapters and select a listed RAW adapter.");
48+
}
49+
50+
var captureObserved = false;
51+
var captureTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
52+
53+
void OnPacketArrival(object sender, PacketCapture packetCapture)
54+
{
55+
try
56+
{
57+
var raw = packetCapture.GetPacket();
58+
var frame = raw.Data.ToArray();
59+
var inspection = PtpFrameInspector.Inspect(frame, domainNumber);
60+
if (!inspection.IsValid)
61+
return;
62+
63+
captureObserved = true;
64+
events.Add($"LOCAL_CAPTURE: observed {inspection.MessageType} seq={inspection.SequenceId}, {inspection.Transport}, offset={inspection.PtpOffset}.");
65+
captureTcs.TrySetResult();
66+
}
67+
catch
68+
{
69+
// Keep self-test capture path robust. The final verdict will still include SEND status.
70+
}
71+
}
72+
73+
var opened = false;
74+
var filterApplied = false;
75+
var sendSucceeded = false;
76+
77+
try
78+
{
79+
cancellationToken.ThrowIfCancellationRequested();
80+
device.OnPacketArrival += OnPacketArrival;
81+
device.Open(new DeviceConfiguration
82+
{
83+
Mode = DeviceModes.Promiscuous,
84+
Immediate = true,
85+
ReadTimeout = 1
86+
});
87+
opened = true;
88+
events.Add("OPEN: adapter opened in promiscuous/immediate mode.");
89+
90+
device.Filter = PtpCaptureFilters.Layer2PtpWithVlan;
91+
filterApplied = true;
92+
events.Add("FILTER: VLAN-aware PTP capture filter applied.");
93+
94+
device.StartCapture();
95+
events.Add("CAPTURE: capture thread started.");
96+
97+
var options = new PtpBuildOptions
98+
{
99+
DomainNumber = domainNumber,
100+
ClockIdentity = ClockIdentity.Parse(clockIdentity),
101+
ClockClass = 248,
102+
ClockAccuracy = PtpClockAccuracy.Unknown,
103+
TwoStep = true
104+
};
105+
var ptp = _serializer.BuildAnnounce(options, 1);
106+
var source = MacAddress.Parse(sourceMac);
107+
var frame = enableVlan
108+
? EthernetFrameBuilder.BuildVlan(PtpMulticastAddresses.General, source, vlanId, vlanPriority, EtherTypes.Ptp, ptp)
109+
: EthernetFrameBuilder.Build(PtpMulticastAddresses.General, source, EtherTypes.Ptp, ptp);
110+
111+
device.SendPacket(frame);
112+
sendSucceeded = true;
113+
events.Add(enableVlan
114+
? $"SEND: one VLAN-tagged Announce test frame injected. VLAN={vlanId}, PCP={vlanPriority}."
115+
: "SEND: one untagged Announce test frame injected.");
116+
117+
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
118+
var completed = await Task.WhenAny(captureTcs.Task, Task.Delay(TimeSpan.FromMilliseconds(1200), timeoutCts.Token)).ConfigureAwait(false);
119+
if (completed == captureTcs.Task)
120+
timeoutCts.Cancel();
121+
else
122+
events.Add("LOCAL_CAPTURE: not observed within 1.2 s. This can be normal on adapters/drivers that do not loop back outbound injected packets; verify with Wireshark.");
123+
124+
var summary = captureObserved
125+
? "RAW self-test passed: open, filter, send, and local capture succeeded."
126+
: "RAW self-test partially passed: open/filter/send succeeded, but local self-capture was not observed. Verify on Wireshark.";
127+
128+
return new NpcapRawSelfTestResult
129+
{
130+
AdapterOpened = opened,
131+
FilterApplied = filterApplied,
132+
SendSucceeded = sendSucceeded,
133+
LocalCaptureObserved = captureObserved,
134+
Summary = summary,
135+
Events = events.ToArray()
136+
};
137+
}
138+
catch (Exception ex) when (ex is not OperationCanceledException)
139+
{
140+
events.Add("ERROR: " + ex.Message);
141+
return new NpcapRawSelfTestResult
142+
{
143+
AdapterOpened = opened,
144+
FilterApplied = filterApplied,
145+
SendSucceeded = sendSucceeded,
146+
LocalCaptureObserved = captureObserved,
147+
Summary = "RAW self-test failed. Check Npcap installation, administrator rights, adapter type, and driver injection support.",
148+
Events = events.ToArray()
149+
};
150+
}
151+
finally
152+
{
153+
device.OnPacketArrival -= OnPacketArrival;
154+
try { device.StopCapture(); } catch { }
155+
try { device.Dispose(); } catch { }
156+
}
157+
}
158+
159+
private static NpcapRawSelfTestResult Fail(List<string> events, string message)
160+
{
161+
events.Add("ERROR: " + message);
162+
return new NpcapRawSelfTestResult
163+
{
164+
Summary = message,
165+
Events = events.ToArray()
166+
};
167+
}
168+
}

0 commit comments

Comments
 (0)