Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 34 additions & 12 deletions docs/adr/0008-multicast-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,40 @@ single round-trip.

### 3. VXI-11 portmapper broadcast

A minimal ONC RPC encoder (no dependency on `Vxi11Backend`) sends
`PMAPPROC_GETPORT` for the VXI-11 Device Core program
(`0x0607AF`, version 1) over UDP broadcast `255.255.255.255:111`.
Any host that registers the program in its portmapper replies with
the TCP port; the scanner records the sender IP and builds
`TCPIP0::<sender>::inst0::INSTR`.

Wire format follows RFC 1833 exactly:

- AUTH_NONE credentials + verifier (flavor = 0, length = 0)
- Big-endian XDR throughout
- 72-byte request, 28-byte minimum successful reply
The scanner sends `PMAPPROC_GETPORT` for the VXI-11 Device Core
program (`0x0607AF` / 395183, version 1) over UDP/111. Any host that
registers the program in its portmapper replies with the TCP port;
the scanner records the sender IP and builds
`TCPIP0::<sender>::inst0::INSTR`. The GETPORT request/reply codec is
shared with the client backend's unicast portmapper resolution via
`Vxi11Portmapper` (ADR 0029).

**Per-interface directed broadcast.** The scanner enumerates every
operational, non-loopback IPv4 interface and sends one probe per NIC,
bound to that NIC's local address, addressed to the interface's
**subnet-directed** broadcast (e.g. `192.168.3.255:111`). A limited
broadcast (`255.255.255.255`) egresses only a single interface on a
multi-homed host — typically whichever owns the default route — so
on a machine with the instruments on a secondary lab NIC it never
reaches them. Probing each subnet directly fixes that. Replies are
de-duplicated by sender IP across all interfaces.

Wire format follows RFC 1833: AUTH_NONE credentials + verifier
(flavor = 0, length = 0), big-endian XDR, 28-byte minimum successful
reply.

The scanner does not chase the per-host TCP port (e.g. by issuing
`create_link`) — the portmapper response is sufficient evidence
that the standard `Vxi11Backend.OpenAsync` path can reach the
instrument.

**Inherent limits.** Broadcast/multicast discovery is link-local: it
cannot cross a router into another subnet (limited broadcast is never
forwarded; directed broadcast is dropped by routers by default per
RFC 2644; mDNS is TTL-scoped), and it only finds instruments that
answer a broadcast GETPORT or advertise mDNS. Instruments on another
subnet are reached by `visa add` with the known address, not by scan.

### 4. `visa scan` UX

```sh
Expand All @@ -99,6 +115,12 @@ resource shape so repeated invocations are idempotent:
Existing alias collisions are surfaced as "skipped (alias taken)"
rather than errors.

`visa scan` prints the **unmasked** resource string (real host) in
both human and `--json` output — it is user-requested discovery
output, not a log line, so the `ToLogString()` masking rule (ADR 0017,
scoped to logging) does not apply. This matches the value `--add`
writes to config.

### 5. Cancellation + timeout semantics

- The discovery window per scanner is fixed at 3 s by default; future
Expand Down
176 changes: 135 additions & 41 deletions src/IviCli.Backends.Vxi11/Vxi11BroadcastScanner.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using IviCli.Application.Backends;
using IviCli.Domain;
Expand All @@ -12,20 +13,22 @@
namespace IviCli.Backends.Vxi11;

/// <summary>
/// VXI-11 portmapper broadcast scanner (ADR 0008, Batch W).
/// VXI-11 portmapper broadcast scanner (ADR 0008).
///
/// Sends an ONC RPC <c>PMAPPROC_GETPORT</c> request asking for the
/// VXI-11 Device Core program (0x0607AF) over UDP broadcast
/// (255.255.255.255:111). Any host with a VXI-11 server registered
/// answers with the TCP port it listens on; the scanner builds a
/// <c>TCPIP::host::inst0::INSTR</c> resource for each responder.
/// Enumerates every operational IPv4 interface and sends an ONC RPC
/// <c>PMAPPROC_GETPORT</c> request asking for the VXI-11 Device Core
/// program to each interface's <em>subnet-directed</em> broadcast
/// address (e.g. <c>192.168.3.255:111</c>), bound to that interface's
/// local address. Limited broadcast (<c>255.255.255.255</c>) only ever
/// egresses one interface on a multi-homed host, so a dedicated probe
/// per NIC is required to reach instruments on a secondary lab subnet.
/// Any host with a VXI-11 server registered answers with the TCP port
/// it listens on; the scanner builds a <c>TCPIP::host::inst0::INSTR</c>
/// resource for each responder.
///
/// Discovery window is bounded by a configurable timeout (default
/// 3 s). The scanner intentionally does not chase the per-host TCP
/// port (e.g. by issuing a follow-up <c>create_link</c>) — the
/// presence of a portmapper registration is sufficient evidence
/// that <c>ivicli visa add</c> + the standard backend dispatch will
/// reach the instrument.
/// Broadcast/multicast discovery is link-local: it cannot cross a router
/// into another subnet, and it only finds instruments that answer a
/// broadcast GETPORT. Those limits are inherent (see ADR 0008).
/// </summary>
public sealed class Vxi11BroadcastScanner : IBackendScanner
{
Expand All @@ -49,49 +52,66 @@ CancellationToken ct
{
ct.ThrowIfCancellationRequested();

var responders = new ConcurrentDictionary<IPAddress, int>();

using var udp = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };

// Bind to an ephemeral port so replies have somewhere to land.
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));

var xid = unchecked((uint)Random.Shared.Next(int.MinValue, int.MaxValue));
var request = Vxi11Portmapper.BuildGetportRequest(xid);

try
var targets = EnumerateTargets();
if (targets.Count == 0)
{
await udp.SendAsync(request, new IPEndPoint(IPAddress.Broadcast, PortmapperPort), ct)
.ConfigureAwait(false);
}
catch (SocketException ex)
{
_logger.LogDebug(ex, "VXI-11 broadcast send failed");
return Result.Success<ImmutableArray<DiscoveredResource>, BackendError>(
ImmutableArray<DiscoveredResource>.Empty
);
}

var responders = new ConcurrentDictionary<IPAddress, int>();
var xid = unchecked((uint)Random.Shared.Next(int.MinValue, int.MaxValue));
var request = Vxi11Portmapper.BuildGetportRequest(xid);

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_discoveryWindow);

await Task.WhenAll(targets.Select(t => ProbeAsync(t, request, xid, responders, cts.Token)))
.ConfigureAwait(false);

ct.ThrowIfCancellationRequested();

var resources = responders
.Keys.Select(BuildDiscovered)
.Where(r => r is not null)
.Select(r => r!)
.ToImmutableArray();

return Result.Success<ImmutableArray<DiscoveredResource>, BackendError>(resources);
}

private async Task ProbeAsync(
BroadcastTarget target,
byte[] request,
uint xid,
ConcurrentDictionary<IPAddress, int> responders,
CancellationToken ct
)
{
try
{
using var udp = new UdpClient(new IPEndPoint(target.Local, 0))
{
EnableBroadcast = true,
};
await udp.SendAsync(request, new IPEndPoint(target.Broadcast, PortmapperPort), ct)
.ConfigureAwait(false);

while (true)
{
cts.Token.ThrowIfCancellationRequested();
UdpReceiveResult datagram;
try
{
datagram = await udp.ReceiveAsync(cts.Token).ConfigureAwait(false);
datagram = await udp.ReceiveAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
break; // discovery window elapsed
}
catch (SocketException ex)
{
_logger.LogDebug(ex, "VXI-11 broadcast receive failed");
_logger.LogDebug(ex, "VXI-11 probe receive failed on {Local}", target.Local);
break;
}

Expand All @@ -104,18 +124,90 @@ CancellationToken ct
}
}
}
finally
catch (OperationCanceledException)
{
// Window elapsed before the send completed — nothing to collect.
}
catch (SocketException ex)
{
// UdpClient.Dispose handles socket teardown.
// Binding/sending on this interface failed (e.g. APIPA, tunnel);
// skip it and let the other interfaces report.
_logger.LogDebug(ex, "VXI-11 probe failed on interface {Local}", target.Local);
}
}

var resources = responders
.Select(kvp => BuildDiscovered(kvp.Key))
.Where(r => r is not null)
.Select(r => r!)
.ToImmutableArray();
/// <summary>
/// Computes the subnet-directed broadcast address for
/// <paramref name="address"/> under <paramref name="mask"/> by setting
/// every host bit (e.g. <c>192.168.3.10 / 255.255.255.0</c> →
/// <c>192.168.3.255</c>).
/// </summary>
public static IPAddress DirectedBroadcast(IPAddress address, IPAddress mask)
{
var a = address.GetAddressBytes();
var m = mask.GetAddressBytes();
var b = new byte[a.Length];
for (var i = 0; i < b.Length; i++)
{
b[i] = (byte)(a[i] | (byte)~m[i]);
}
return new IPAddress(b);
}

return Result.Success<ImmutableArray<DiscoveredResource>, BackendError>(resources);
/// <summary>
/// Decides whether a unicast address belongs on the probe list: it must
/// sit on an operational, non-loopback interface, be IPv4, and carry a
/// usable subnet mask.
/// </summary>
public static bool ShouldProbe(
OperationalStatus status,
NetworkInterfaceType type,
AddressFamily family,
IPAddress? mask
) =>
status == OperationalStatus.Up
&& type != NetworkInterfaceType.Loopback
&& family == AddressFamily.InterNetwork
&& mask is not null
&& !mask.Equals(IPAddress.Any);

private static IReadOnlyList<BroadcastTarget> EnumerateTargets()
{
var targets = new List<BroadcastTarget>();
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
foreach (var addr in nic.GetIPProperties().UnicastAddresses)
{
if (addr.Address.AddressFamily != AddressFamily.InterNetwork)
{
continue;
}
IPAddress? mask = null;
try
{
mask = addr.IPv4Mask;
}
catch (Exception)
{
mask = null; // platform without IPv4 mask info for this address
}
if (
!ShouldProbe(
nic.OperationalStatus,
nic.NetworkInterfaceType,
addr.Address.AddressFamily,
mask
)
)
{
continue;
}
targets.Add(
new BroadcastTarget(addr.Address, DirectedBroadcast(addr.Address, mask!))
);
}
}
return targets;
}

private static DiscoveredResource? BuildDiscovered(IPAddress host)
Expand All @@ -128,4 +220,6 @@ CancellationToken ct
}
return new DiscoveredResource(resource, Idn: null);
}

private readonly record struct BroadcastTarget(IPAddress Local, IPAddress Broadcast);
}
4 changes: 2 additions & 2 deletions src/IviCli.Cli/Commands/VisaScanCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ private static int Success(ScanResult scan, bool emitJson)
Console.Write(",");
}
var r = scan.Resources[i];
var resourceString = r.Resource.ToLogString();
var resourceString = FormatResource(r.Resource);
var idnJson = r.Idn is null ? "null" : $"\"{Escape(r.Idn)}\"";
Console.Write(
string.Create(
Expand All @@ -200,7 +200,7 @@ private static int Success(ScanResult scan, bool emitJson)
var r = scan.Resources[i];
Console.WriteLine(string.Create(inv, $"[{i + 1}]"));
Console.WriteLine(
string.Create(inv, $" Resource: {r.Resource.ToLogString()}")
string.Create(inv, $" Resource: {FormatResource(r.Resource)}")
);
if (r.Idn is not null)
{
Expand Down
Loading