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
15 changes: 13 additions & 2 deletions docs/adr/0029-vxi11-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,20 @@ RPC calls to the Core handler when the program number matches.
[ADR 0041](0041-trigger-and-srq-ports.md).
- Vendor extensions, TLS, UDP transport, broadcast portmapper queries
on UDP 111.
- Real **portmapper-at-111** client conversation — Batch D's client
- ~~Real **portmapper-at-111** client conversation — Batch D's client
backend connects directly to the configured Core port instead, since
the gateway co-locates portmapper + Core on one bind address. v2.
the gateway co-locates portmapper + Core on one bind address. v2.~~
Shipped (issue #20): the client backend now issues a real
`PMAPPROC_GETPORT` over **UDP/111** to resolve the dynamically-assigned
Core port of physical instruments (verified against a Kikusui PWR801L,
whose portmapper answers GETPORT only over UDP and reports a dynamic
Core port — TCP/111 accepts connections but never replies to GETPORT).
When no portmapper answers within a short probe window — as with
ivi-cli's own gateway, which co-locates portmapper + Core on one bind
address and does not answer GETPORT on 111 — the client falls back to
the configured fixed port, preserving the gateway pairing. The shared
GETPORT request/reply codec lives in `Vxi11Portmapper`, reused by the
broadcast scanner (ADR 0008).

The companion client backend (`IviCli.Backends.Vxi11`) shipped in
Batch D, sharing the XDR codec / RPC message records uplifted to
Expand Down
94 changes: 75 additions & 19 deletions src/IviCli.Backends.Vxi11/Vxi11Backend.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,62 @@ namespace IviCli.Backends.Vxi11;

/// <summary>
/// Client-side <see cref="IIviBackend"/> for VXI-11 endpoints
/// (PRD §7.1 priority 2, ADR 0029). v1 implements create_link /
/// (PRD §7.1 priority 2, ADR 0029). Implements create_link /
/// device_write / device_read / destroy_link over a single TCP
/// connection per device. The portmapper round-trip is deliberately
/// skipped — ivi-cli's gateway co-locates portmapper + Core on the
/// same bind port, so a real GETPORT call would only echo back the
/// port we already connected to. Real-portmapper-at-111 support is
/// deferred to v2.
/// connection per device.
///
/// On open the client first asks the instrument's portmapper at UDP/111
/// for the dynamically-assigned VXI-11 Core port (a real GETPORT
/// round-trip — issue #20). When no portmapper answers (e.g. ivi-cli's
/// own gateway, which co-locates portmapper + Core on a single bind
/// port and does not answer GETPORT on 111) it falls back to the fixed
/// port, preserving the gateway pairing.
/// </summary>
public sealed class Vxi11Backend : IIviBackend
{
/// <summary>
/// Fallback TCP port when the constructor override is not used.
/// VXI-11 has no IANA-assigned core port (clients traditionally
/// learn it from portmapper at 111); this value is a placeholder
/// suitable for ad-hoc deployments where the gateway operator
/// configures the server with a matching port.
/// Fallback TCP port used when no portmapper answers. VXI-11 has no
/// IANA-assigned core port; this value matches the port ivi-cli's
/// gateway binds by default for ad-hoc deployments.
/// </summary>
public const int DefaultVxi11Port = 1024;

/// <summary>How long to wait for the portmapper round-trip before falling back.</summary>
private static readonly TimeSpan PortmapperProbeTimeout = TimeSpan.FromSeconds(3);

private readonly Dictionary<DeviceName, Vxi11Session> _sessions = new();
private readonly object _gate = new();
private readonly int _port;
private readonly int _fallbackPort;
private readonly int _portmapperPort;
private readonly bool _usePortmapper;

/// <summary>Creates a backend bound to <see cref="DefaultVxi11Port"/>.</summary>
/// <summary>
/// Production constructor: resolves the Core port via the portmapper at
/// <see cref="Vxi11Constants.PortmapperPort"/>, falling back to
/// <see cref="DefaultVxi11Port"/>.
/// </summary>
public Vxi11Backend()
: this(DefaultVxi11Port) { }
: this(DefaultVxi11Port, PortmapperPort, usePortmapper: true) { }

/// <summary>
/// Creates a backend that connects to <paramref name="port"/> instead of
/// the default. Intended for tests against an in-process gateway listening
/// on a randomly allocated loopback port.
/// Creates a backend that connects to a fixed <paramref name="port"/>
/// without a portmapper round-trip. Intended for tests / co-located
/// gateways listening on a known loopback port.
/// </summary>
public Vxi11Backend(int port)
: this(port, PortmapperPort, usePortmapper: false) { }

/// <summary>
/// Full constructor. When <paramref name="usePortmapper"/> is set the
/// backend issues a GETPORT against <paramref name="portmapperPort"/> to
/// learn the Core port, falling back to <paramref name="fallbackPort"/>
/// if the portmapper is unreachable or has no registration.
/// </summary>
public Vxi11Backend(int fallbackPort, int portmapperPort, bool usePortmapper)
{
_port = port;
_fallbackPort = fallbackPort;
_portmapperPort = portmapperPort;
_usePortmapper = usePortmapper;
}

/// <inheritdoc/>
Expand All @@ -72,10 +93,12 @@ public async Task<Result<Unit, BackendError>> OpenAsync(Device device, Cancellat
);
}

var corePort = await ResolveCorePortAsync(tcpip.Host, ct);

var client = new TcpClient();
try
{
await client.ConnectAsync(tcpip.Host, _port, ct);
await client.ConnectAsync(tcpip.Host, corePort, ct);
}
catch (Exception ex) when (ex is SocketException or IOException)
{
Expand Down Expand Up @@ -307,6 +330,39 @@ [EnumeratorCancellation] CancellationToken ct
}
}

/// <summary>
/// Resolves the VXI-11 Core TCP port for <paramref name="host"/>. Asks the
/// portmapper at <see cref="_portmapperPort"/> when enabled, falling back to
/// <see cref="_fallbackPort"/> if the portmapper is unreachable, times out,
/// or has no Core registration.
/// </summary>
private async Task<int> ResolveCorePortAsync(string host, CancellationToken ct)
{
if (!_usePortmapper)
{
return _fallbackPort;
}
try
{
var resolved = await Vxi11Portmapper.ResolveCorePortAsync(
host,
_portmapperPort,
PortmapperProbeTimeout,
ct
);
return resolved > 0 ? resolved : _fallbackPort;
}
catch (Exception ex)
when (ex is SocketException or IOException
|| (ex is OperationCanceledException && !ct.IsCancellationRequested)
)
{
// No reachable portmapper (e.g. co-located gateway) — use the
// fixed port. Genuine caller cancellation is rethrown.
return _fallbackPort;
}
}

private static async Task<int> CreateLinkAsync(
Vxi11Session session,
string lanDevice,
Expand Down
134 changes: 9 additions & 125 deletions src/IviCli.Backends.Vxi11/Vxi11BroadcastScanner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Net;
Expand All @@ -8,6 +7,7 @@
using IviCli.Domain.Visa;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using static IviCli.Domain.Protocols.Vxi11Constants;

namespace IviCli.Backends.Vxi11;

Expand All @@ -29,25 +29,6 @@ namespace IviCli.Backends.Vxi11;
/// </summary>
public sealed class Vxi11BroadcastScanner : IBackendScanner
{
// Portmapper (RFC 1833) constants.
private const uint PortmapProgram = 100000;
private const uint PortmapVersion = 2;
private const uint PmapprocGetport = 3;
private const int PortmapPort = 111;

// VXI-11 Device Core program identifier (per IVI VXI-11 §B).
private const uint Vxi11DeviceCoreProgram = 0x0607AF;
private const uint Vxi11DeviceCoreVersion = 1;
private const uint IpprotoTcp = 6;

// RPC framing constants.
private const uint RpcCall = 0;
private const uint RpcReply = 1;
private const uint RpcVersion = 2;
private const uint AuthNone = 0;
private const uint MsgAccepted = 0;
private const uint SuccessState = 0;

private readonly TimeSpan _discoveryWindow;
private readonly ILogger<Vxi11BroadcastScanner> _logger;

Expand All @@ -68,19 +49,19 @@ CancellationToken ct
{
ct.ThrowIfCancellationRequested();

var responders = new ConcurrentDictionary<IPAddress, ushort>();
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 = (uint)Random.Shared.Next(int.MinValue, int.MaxValue);
var request = BuildGetportRequest(xid);
var xid = unchecked((uint)Random.Shared.Next(int.MinValue, int.MaxValue));
var request = Vxi11Portmapper.BuildGetportRequest(xid);

try
{
await udp.SendAsync(request, new IPEndPoint(IPAddress.Broadcast, PortmapPort), ct)
await udp.SendAsync(request, new IPEndPoint(IPAddress.Broadcast, PortmapperPort), ct)
.ConfigureAwait(false);
}
catch (SocketException ex)
Expand Down Expand Up @@ -114,7 +95,10 @@ CancellationToken ct
break;
}

if (TryParseGetportReply(datagram.Buffer, xid, out var port) && port > 0)
if (
Vxi11Portmapper.TryParseGetportReply(datagram.Buffer, xid, out var port)
&& port > 0
)
{
responders[datagram.RemoteEndPoint.Address] = port;
}
Expand All @@ -134,106 +118,6 @@ CancellationToken ct
return Result.Success<ImmutableArray<DiscoveredResource>, BackendError>(resources);
}

private static byte[] BuildGetportRequest(uint xid)
{
// RPC CALL header (10 uint32 words = 40 bytes) + AUTH_NONE cred +
// AUTH_NONE verf (2 × 8 bytes = 16 bytes) + GETPORT mapping
// (4 × 4 bytes = 16 bytes) = 72 bytes total.
Span<byte> buffer = stackalloc byte[72];
var pos = 0;

WriteUInt32(buffer, ref pos, xid);
WriteUInt32(buffer, ref pos, RpcCall);
WriteUInt32(buffer, ref pos, RpcVersion);
WriteUInt32(buffer, ref pos, PortmapProgram);
WriteUInt32(buffer, ref pos, PortmapVersion);
WriteUInt32(buffer, ref pos, PmapprocGetport);

// AUTH_NONE credentials: flavor=0, length=0.
WriteUInt32(buffer, ref pos, AuthNone);
WriteUInt32(buffer, ref pos, 0);
// AUTH_NONE verifier: flavor=0, length=0.
WriteUInt32(buffer, ref pos, AuthNone);
WriteUInt32(buffer, ref pos, 0);

// Mapping struct.
WriteUInt32(buffer, ref pos, Vxi11DeviceCoreProgram);
WriteUInt32(buffer, ref pos, Vxi11DeviceCoreVersion);
WriteUInt32(buffer, ref pos, IpprotoTcp);
WriteUInt32(buffer, ref pos, 0); // port is ignored on GETPORT

return buffer.ToArray();
}

private static bool TryParseGetportReply(
ReadOnlySpan<byte> buffer,
uint expectedXid,
out ushort port
)
{
port = 0;
// Minimal successful reply: xid + msg_type + reply_state +
// verf(flavor+length) + accept_state + port = 7 × 4 = 28 bytes.
if (buffer.Length < 28)
{
return false;
}
var pos = 0;
var xid = ReadUInt32(buffer, ref pos);
if (xid != expectedXid)
{
return false;
}
var msgType = ReadUInt32(buffer, ref pos);
if (msgType != RpcReply)
{
return false;
}
var replyState = ReadUInt32(buffer, ref pos);
if (replyState != MsgAccepted)
{
return false;
}
// Verifier flavor + length (length must be 0 for AUTH_NONE replies).
var verfFlavor = ReadUInt32(buffer, ref pos);
var verfLen = ReadUInt32(buffer, ref pos);
if (verfLen != 0)
{
// Skip the verifier opaque body if present.
pos += (int)verfLen;
if (pos > buffer.Length)
{
return false;
}
}
_ = verfFlavor;
var acceptState = ReadUInt32(buffer, ref pos);
if (acceptState != SuccessState)
{
return false;
}
if (pos + 4 > buffer.Length)
{
return false;
}
var rawPort = ReadUInt32(buffer, ref pos);
port = (ushort)rawPort;
return true;
}

private static void WriteUInt32(Span<byte> buffer, ref int pos, uint value)
{
BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(pos, 4), value);
pos += 4;
}

private static uint ReadUInt32(ReadOnlySpan<byte> buffer, ref int pos)
{
var value = BinaryPrimitives.ReadUInt32BigEndian(buffer.Slice(pos, 4));
pos += 4;
return value;
}

private static DiscoveredResource? BuildDiscovered(IPAddress host)
{
var raw = $"TCPIP0::{host}::inst0::INSTR";
Expand Down
Loading
Loading