Initial project state: .NET reference, design, Rust port (M0+M1), evidence
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
Layout:
- src/ .NET 10 x64 reference: MxNativeCodec, MxNativeClient,
MxAsbClient, probes, tests, harnesses. Executable spec.
- design/ Architectural plan for the Rust port (M0–M6), error
model, protocol invariants, risks (R1–R16), adversarial
review log (review.md).
- rust/ Rust workspace. M0 skeleton + M1 codec parity.
mxaccess-codec: 215 unit tests + 2 cross-implementation
parity tests (byte-identical against .NET reference).
Other crates are M0 stubs awaiting M2+.
- captures/ Frida + netsh + pcap evidence per CLAUDE.md
("captures are evidence, not throwaway logs").
- analysis/ Decompiled C# (frida/proxy/decompiled-*),
Ghidra exports for native DLLs (`exports/` only —
working state at `projects/` and AVEVA's input
binaries at `input/` are gitignored).
- docs/ Reverse-engineering reference docs.
- tools/ Setup-LiveProbeEnv.ps1 (Infisical credential fetcher),
Compute-Crc.ps1 (.NET parity helper).
- .github/workflows/ Rust CI: fmt + build + test + clippy on Windows.
- LICENSE MIT (Joseph Doherty, 2026).
Verified:
- cargo test --workspace → 217 passed (215 unit + 2 .NET parity), 0 failed
- cargo clippy --workspace -- -D warnings → clean
- cargo fmt --all -- --check → clean
- cargo publish --dry-run -p mxaccess-codec → packages cleanly
Excluded from history (see .gitignore):
- **/bin, **/obj, **/target — build artifacts
- analysis/ghidra/projects/ — Ghidra working state (regenerable)
- analysis/ghidra/input/ — AVEVA proprietary DLLs (vendor IP)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record ComObjRef(
|
||||
uint Signature,
|
||||
uint Flags,
|
||||
Guid Iid,
|
||||
uint StandardFlags,
|
||||
uint PublicRefs,
|
||||
ulong Oxid,
|
||||
ulong Oid,
|
||||
Guid Ipid,
|
||||
ushort DualStringEntries,
|
||||
ushort DualStringSecurityOffset,
|
||||
IReadOnlyList<ComDualStringEntry> DualStringEntriesDecoded)
|
||||
{
|
||||
public static ComObjRef Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < 68)
|
||||
{
|
||||
throw new ArgumentException("OBJREF buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
ushort dualStringEntries = ReadUInt16(buffer, 64);
|
||||
ushort securityOffset = ReadUInt16(buffer, 66);
|
||||
|
||||
return new ComObjRef(
|
||||
Signature: ReadUInt32(buffer, 0),
|
||||
Flags: ReadUInt32(buffer, 4),
|
||||
Iid: new Guid(buffer.Slice(8, 16)),
|
||||
StandardFlags: ReadUInt32(buffer, 24),
|
||||
PublicRefs: ReadUInt32(buffer, 28),
|
||||
Oxid: ReadUInt64(buffer, 32),
|
||||
Oid: ReadUInt64(buffer, 40),
|
||||
Ipid: new Guid(buffer.Slice(48, 16)),
|
||||
DualStringEntries: dualStringEntries,
|
||||
DualStringSecurityOffset: securityOffset,
|
||||
DualStringEntriesDecoded: DecodeDualStringArray(buffer[68..], dualStringEntries, securityOffset));
|
||||
}
|
||||
|
||||
public IEnumerable<string> ToDiagnosticLines()
|
||||
{
|
||||
yield return $"objref_signature=0x{Signature:X8}";
|
||||
yield return $"objref_flags=0x{Flags:X8}";
|
||||
yield return $"objref_iid={Iid}";
|
||||
yield return $"std_flags=0x{StandardFlags:X8}";
|
||||
yield return $"std_public_refs={PublicRefs}";
|
||||
yield return $"std_oxid=0x{Oxid:X16}";
|
||||
yield return $"std_oid=0x{Oid:X16}";
|
||||
yield return $"std_ipid={Ipid}";
|
||||
yield return $"dual_string_entries={DualStringEntries}";
|
||||
yield return $"dual_string_security_offset={DualStringSecurityOffset}";
|
||||
yield return $"dual_strings={string.Join("|", DualStringEntriesDecoded.Select(static entry => entry.ToDiagnosticString()))}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComDualStringEntry> DecodeDualStringArray(ReadOnlySpan<byte> data, ushort entries, ushort securityOffset)
|
||||
{
|
||||
int count = Math.Min(entries, data.Length / 2);
|
||||
var strings = new List<ComDualStringEntry>();
|
||||
|
||||
for (int i = 0; i < count;)
|
||||
{
|
||||
int entryStart = i;
|
||||
ushort towerId = ReadUInt16(data, i * 2);
|
||||
i++;
|
||||
if (towerId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = new List<char>();
|
||||
while (i < count)
|
||||
{
|
||||
ushort value = ReadUInt16(data, i * 2);
|
||||
i++;
|
||||
if (value == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (value >= 0x20 && value <= 0x7e)
|
||||
{
|
||||
text.Add((char)value);
|
||||
}
|
||||
else
|
||||
{
|
||||
text.Add('<');
|
||||
text.AddRange(value.ToString("x4", CultureInfo.InvariantCulture));
|
||||
text.Add('>');
|
||||
}
|
||||
}
|
||||
|
||||
strings.Add(new ComDualStringEntry(
|
||||
towerId,
|
||||
ProtocolTowerName(towerId),
|
||||
new string(text.ToArray()),
|
||||
IsSecurityBinding: entryStart >= securityOffset));
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static string ProtocolTowerName(ushort towerId)
|
||||
{
|
||||
return towerId switch
|
||||
{
|
||||
0x0007 => "ncacn_ip_tcp",
|
||||
0x0008 => "ncadg_ip_udp",
|
||||
0x0009 => "ncacn_np",
|
||||
0x000f => "ncacn_spx",
|
||||
0x0010 => "ncacn_nb_nb",
|
||||
0x0016 => "ncadg_ip_udp_or_netbios",
|
||||
0x001f => "ncalrpc",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static ushort ReadUInt16(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return (ushort)(buffer[offset] | (buffer[offset + 1] << 8));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return (uint)(buffer[offset]
|
||||
| (buffer[offset + 1] << 8)
|
||||
| (buffer[offset + 2] << 16)
|
||||
| (buffer[offset + 3] << 24));
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(ReadOnlySpan<byte> buffer, int offset)
|
||||
{
|
||||
return ReadUInt32(buffer, offset) | ((ulong)ReadUInt32(buffer, offset + 4) << 32);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ComDualStringEntry(ushort TowerId, string Protocol, string Value, bool IsSecurityBinding)
|
||||
{
|
||||
public string ToDiagnosticString()
|
||||
{
|
||||
string kind = IsSecurityBinding ? "security" : "string";
|
||||
return $"{kind}:0x{TowerId:x4}:{Protocol}:{Value}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ComTypes = System.Runtime.InteropServices.ComTypes;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class ComObjRefProvider
|
||||
{
|
||||
public const uint MarshalContextInProcess = 0;
|
||||
public const uint MarshalContextLocal = 1;
|
||||
public const uint MarshalContextDifferentMachine = 2;
|
||||
|
||||
public static byte[] MarshalActivatedIUnknownObjRef(string progId, uint destinationContext)
|
||||
{
|
||||
var type = Type.GetTypeFromProgID(progId, throwOnError: true)
|
||||
?? throw new InvalidOperationException($"ProgID {progId} was not resolved.");
|
||||
object instance = Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException($"ProgID {progId} activation returned null.");
|
||||
|
||||
try
|
||||
{
|
||||
return MarshalIUnknownObjRef(instance, destinationContext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Marshal.IsComObject(instance))
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] MarshalIUnknownObjRef(object comObject, uint destinationContext)
|
||||
{
|
||||
return MarshalInterfaceObjRef(comObject, new Guid("00000000-0000-0000-C000-000000000046"), destinationContext);
|
||||
}
|
||||
|
||||
public static byte[] MarshalInterfaceObjRef(object comObject, Guid iid, uint destinationContext)
|
||||
{
|
||||
IntPtr unknown = IntPtr.Zero;
|
||||
ComTypes.IStream? stream = null;
|
||||
|
||||
try
|
||||
{
|
||||
unknown = Marshal.GetIUnknownForObject(comObject);
|
||||
Marshal.ThrowExceptionForHR(CreateStreamOnHGlobal(IntPtr.Zero, true, out stream));
|
||||
Marshal.ThrowExceptionForHR(CoMarshalInterface(stream, ref iid, unknown, destinationContext, IntPtr.Zero, 0));
|
||||
Marshal.ThrowExceptionForHR(GetHGlobalFromStream(stream, out var hglobal));
|
||||
|
||||
nuint size = GlobalSize(hglobal);
|
||||
IntPtr pointer = GlobalLock(hglobal);
|
||||
if (pointer == IntPtr.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("GlobalLock failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[(int)size];
|
||||
Marshal.Copy(pointer, buffer, 0, buffer.Length);
|
||||
return buffer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ = GlobalUnlock(hglobal);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (unknown != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Release(unknown);
|
||||
}
|
||||
|
||||
if (stream is not null)
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(stream);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CreateStreamOnHGlobal(IntPtr hGlobal, [MarshalAs(UnmanagedType.Bool)] bool deleteOnRelease, out ComTypes.IStream stream);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoMarshalInterface(ComTypes.IStream stream, ref Guid iid, IntPtr unknown, uint destinationContext, IntPtr destinationContextPointer, uint marshalFlags);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int GetHGlobalFromStream(ComTypes.IStream stream, out IntPtr hGlobal);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern nuint GlobalSize(IntPtr hMem);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public enum DceRpcAuthType : byte
|
||||
{
|
||||
None = 0,
|
||||
GssNegotiate = 9,
|
||||
WinNt = 10,
|
||||
}
|
||||
|
||||
public enum DceRpcAuthLevel : byte
|
||||
{
|
||||
None = 1,
|
||||
Connect = 2,
|
||||
PacketIntegrity = 5,
|
||||
PacketPrivacy = 6,
|
||||
}
|
||||
|
||||
public sealed record DceRpcAuthTrailer(
|
||||
DceRpcAuthType AuthType,
|
||||
DceRpcAuthLevel AuthLevel,
|
||||
byte AuthPadLength,
|
||||
byte AuthReserved,
|
||||
uint AuthContextId)
|
||||
{
|
||||
public const int Length = 8;
|
||||
|
||||
public static DceRpcAuthTrailer Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC auth trailer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new DceRpcAuthTrailer(
|
||||
(DceRpcAuthType)buffer[0],
|
||||
(DceRpcAuthLevel)buffer[1],
|
||||
buffer[2],
|
||||
buffer[3],
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]));
|
||||
}
|
||||
|
||||
public void WriteTo(Span<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC auth trailer buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
buffer[0] = (byte)AuthType;
|
||||
buffer[1] = (byte)AuthLevel;
|
||||
buffer[2] = AuthPadLength;
|
||||
buffer[3] = AuthReserved;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer[4..8], AuthContextId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcAuthValue(DceRpcAuthTrailer Trailer, ReadOnlyMemory<byte> Token);
|
||||
|
||||
public sealed class SspiClientContext : IDisposable
|
||||
{
|
||||
private readonly NegotiateAuthentication _authentication;
|
||||
|
||||
public SspiClientContext(string package, string targetName, ProtectionLevel protectionLevel = ProtectionLevel.None)
|
||||
{
|
||||
var credential = CreateCredentialFromEnvironment();
|
||||
_authentication = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
|
||||
{
|
||||
Package = package,
|
||||
TargetName = targetName,
|
||||
Credential = credential ?? CredentialCache.DefaultNetworkCredentials,
|
||||
RequiredProtectionLevel = protectionLevel,
|
||||
});
|
||||
}
|
||||
|
||||
public byte[] GetOutgoingBlob(ReadOnlySpan<byte> incomingToken)
|
||||
{
|
||||
byte[]? input = incomingToken.IsEmpty ? Array.Empty<byte>() : incomingToken.ToArray();
|
||||
byte[]? output = _authentication.GetOutgoingBlob(input, out var status);
|
||||
if (status is not (NegotiateAuthenticationStatusCode.Completed or NegotiateAuthenticationStatusCode.ContinueNeeded))
|
||||
{
|
||||
throw new InvalidOperationException($"SSPI negotiation failed with {status}.");
|
||||
}
|
||||
|
||||
return output ?? [];
|
||||
}
|
||||
|
||||
public byte[] Wrap(ReadOnlySpan<byte> input, bool requestEncryption, out bool isEncrypted)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(input.Length + 64);
|
||||
var status = _authentication.Wrap(input, writer, requestEncryption, out isEncrypted);
|
||||
if (status != NegotiateAuthenticationStatusCode.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"SSPI wrap failed with {status}.");
|
||||
}
|
||||
|
||||
return writer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
public byte[] Unwrap(ReadOnlySpan<byte> input, out bool wasEncrypted)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(input.Length);
|
||||
var status = _authentication.Unwrap(input, writer, out wasEncrypted);
|
||||
if (status != NegotiateAuthenticationStatusCode.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"SSPI unwrap failed with {status}.");
|
||||
}
|
||||
|
||||
return writer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_authentication.Dispose();
|
||||
}
|
||||
|
||||
private static NetworkCredential? CreateCredentialFromEnvironment()
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("MX_RPC_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("MX_RPC_PASSWORD");
|
||||
if (string.IsNullOrWhiteSpace(user) || password is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? domain = Environment.GetEnvironmentVariable("MX_RPC_DOMAIN");
|
||||
return string.IsNullOrWhiteSpace(domain)
|
||||
? new NetworkCredential(user, password)
|
||||
: new NetworkCredential(user, password, domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public enum DceRpcPacketType : byte
|
||||
{
|
||||
Request = 0,
|
||||
Response = 2,
|
||||
Fault = 3,
|
||||
Bind = 11,
|
||||
BindAck = 12,
|
||||
AlterContext = 14,
|
||||
AlterContextResponse = 15,
|
||||
Auth3 = 16,
|
||||
}
|
||||
|
||||
public readonly record struct DceRpcSyntaxId(Guid Uuid, ushort VersionMajor, ushort VersionMinor)
|
||||
{
|
||||
public static DceRpcSyntaxId Ndr20 { get; } = new(
|
||||
new Guid("8A885D04-1CEB-11C9-9FE8-08002B104860"),
|
||||
2,
|
||||
0);
|
||||
}
|
||||
|
||||
public sealed record DceRpcPresentationContext(
|
||||
ushort ContextId,
|
||||
DceRpcSyntaxId AbstractSyntax,
|
||||
IReadOnlyList<DceRpcSyntaxId> TransferSyntaxes);
|
||||
|
||||
public sealed record DceRpcPduHeader(
|
||||
byte Version,
|
||||
byte VersionMinor,
|
||||
DceRpcPacketType PacketType,
|
||||
byte PacketFlags,
|
||||
uint DataRepresentation,
|
||||
ushort FragmentLength,
|
||||
ushort AuthLength,
|
||||
uint CallId)
|
||||
{
|
||||
public const int Length = 16;
|
||||
|
||||
public static DceRpcPduHeader Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC PDU header is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new DceRpcPduHeader(
|
||||
Version: buffer[0],
|
||||
VersionMinor: buffer[1],
|
||||
PacketType: (DceRpcPacketType)buffer[2],
|
||||
PacketFlags: buffer[3],
|
||||
DataRepresentation: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]),
|
||||
FragmentLength: BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]),
|
||||
AuthLength: BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]),
|
||||
CallId: BinaryPrimitives.ReadUInt32LittleEndian(buffer[12..16]));
|
||||
}
|
||||
|
||||
public void WriteTo(Span<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC PDU header buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
buffer[0] = Version;
|
||||
buffer[1] = VersionMinor;
|
||||
buffer[2] = (byte)PacketType;
|
||||
buffer[3] = PacketFlags;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer[4..8], DataRepresentation);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer[8..10], FragmentLength);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer[10..12], AuthLength);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer[12..16], CallId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcRequestPdu(
|
||||
DceRpcPduHeader Header,
|
||||
uint AllocationHint,
|
||||
ushort ContextId,
|
||||
ushort Opnum,
|
||||
ReadOnlyMemory<byte> StubData)
|
||||
{
|
||||
public static DceRpcRequestPdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType != DceRpcPacketType.Request)
|
||||
{
|
||||
throw new ArgumentException("PDU is not a request.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 24 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC request PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : header.AuthLength + 8;
|
||||
int stubLength = header.FragmentLength - 24 - trailerLength;
|
||||
if (stubLength < 0)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC request PDU has invalid auth/trailer length.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcRequestPdu(
|
||||
Header: header,
|
||||
AllocationHint: BinaryPrimitives.ReadUInt32LittleEndian(span[16..20]),
|
||||
ContextId: BinaryPrimitives.ReadUInt16LittleEndian(span[20..22]),
|
||||
Opnum: BinaryPrimitives.ReadUInt16LittleEndian(span[22..24]),
|
||||
StubData: pdu.Slice(24, stubLength));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
int length = 24 + StubData.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
var header = Header with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Request,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = 0,
|
||||
PacketFlags = Header.PacketFlags == 0 ? (byte)0x03 : Header.PacketFlags,
|
||||
};
|
||||
header.WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(16, 4), AllocationHint);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(20, 2), ContextId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(22, 2), Opnum);
|
||||
StubData.Span.CopyTo(pdu.AsSpan(24));
|
||||
return pdu;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcResponsePdu(
|
||||
DceRpcPduHeader Header,
|
||||
uint AllocationHint,
|
||||
ushort ContextId,
|
||||
byte CancelCount,
|
||||
ReadOnlyMemory<byte> StubData)
|
||||
{
|
||||
public static DceRpcResponsePdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType != DceRpcPacketType.Response)
|
||||
{
|
||||
throw new ArgumentException("PDU is not a response.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 24 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC response PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : header.AuthLength + 8;
|
||||
int stubLength = header.FragmentLength - 24 - trailerLength;
|
||||
if (stubLength < 0)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC response PDU has invalid auth/trailer length.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcResponsePdu(
|
||||
Header: header,
|
||||
AllocationHint: BinaryPrimitives.ReadUInt32LittleEndian(span[16..20]),
|
||||
ContextId: BinaryPrimitives.ReadUInt16LittleEndian(span[20..22]),
|
||||
CancelCount: span[22],
|
||||
StubData: pdu.Slice(24, stubLength));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcFaultPdu(
|
||||
DceRpcPduHeader Header,
|
||||
uint AllocationHint,
|
||||
ushort ContextId,
|
||||
byte CancelCount,
|
||||
uint Status,
|
||||
ReadOnlyMemory<byte> StubData)
|
||||
{
|
||||
public static DceRpcFaultPdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType != DceRpcPacketType.Fault)
|
||||
{
|
||||
throw new ArgumentException("PDU is not a fault.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 28 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC fault PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : header.AuthLength + 8;
|
||||
int stubLength = header.FragmentLength - 28 - trailerLength;
|
||||
if (stubLength < 0)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC fault PDU has invalid auth/trailer length.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcFaultPdu(
|
||||
Header: header,
|
||||
AllocationHint: BinaryPrimitives.ReadUInt32LittleEndian(span[16..20]),
|
||||
ContextId: BinaryPrimitives.ReadUInt16LittleEndian(span[20..22]),
|
||||
CancelCount: span[22],
|
||||
Status: BinaryPrimitives.ReadUInt32LittleEndian(span[24..28]),
|
||||
StubData: pdu.Slice(28, stubLength));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DceRpcBindPdu(
|
||||
DceRpcPduHeader Header,
|
||||
ushort MaxTransmitFragment,
|
||||
ushort MaxReceiveFragment,
|
||||
uint AssociationGroupId,
|
||||
IReadOnlyList<DceRpcPresentationContext> PresentationContexts)
|
||||
{
|
||||
public static DceRpcBindPdu Parse(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var span = pdu.Span;
|
||||
var header = DceRpcPduHeader.Parse(span);
|
||||
if (header.PacketType is not (DceRpcPacketType.Bind or DceRpcPacketType.AlterContext))
|
||||
{
|
||||
throw new ArgumentException("PDU is not a bind or alter-context PDU.", nameof(pdu));
|
||||
}
|
||||
|
||||
if (span.Length < 28 || header.FragmentLength > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC bind PDU is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
int contextCount = span[24];
|
||||
int offset = 28;
|
||||
var contexts = new List<DceRpcPresentationContext>(contextCount);
|
||||
|
||||
for (int i = 0; i < contextCount; i++)
|
||||
{
|
||||
if (offset + 24 > header.FragmentLength)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC bind context is truncated.", nameof(pdu));
|
||||
}
|
||||
|
||||
ushort contextId = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset, 2));
|
||||
byte transferSyntaxCount = span[offset + 2];
|
||||
offset += 4;
|
||||
|
||||
var abstractSyntax = ReadSyntax(span, ref offset);
|
||||
var transferSyntaxes = new List<DceRpcSyntaxId>(transferSyntaxCount);
|
||||
for (int j = 0; j < transferSyntaxCount; j++)
|
||||
{
|
||||
transferSyntaxes.Add(ReadSyntax(span, ref offset));
|
||||
}
|
||||
|
||||
contexts.Add(new DceRpcPresentationContext(contextId, abstractSyntax, transferSyntaxes));
|
||||
}
|
||||
|
||||
return new DceRpcBindPdu(
|
||||
Header: header,
|
||||
MaxTransmitFragment: BinaryPrimitives.ReadUInt16LittleEndian(span[16..18]),
|
||||
MaxReceiveFragment: BinaryPrimitives.ReadUInt16LittleEndian(span[18..20]),
|
||||
AssociationGroupId: BinaryPrimitives.ReadUInt32LittleEndian(span[20..24]),
|
||||
PresentationContexts: contexts);
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
int length = 28 + PresentationContexts.Sum(static context => 24 + 20 * context.TransferSyntaxes.Count);
|
||||
byte[] pdu = new byte[length];
|
||||
var header = Header with { FragmentLength = (ushort)length, AuthLength = 0 };
|
||||
header.WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(16, 2), MaxTransmitFragment);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(18, 2), MaxReceiveFragment);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(20, 4), AssociationGroupId);
|
||||
pdu[24] = (byte)PresentationContexts.Count;
|
||||
|
||||
int offset = 28;
|
||||
foreach (var context in PresentationContexts)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset, 2), context.ContextId);
|
||||
pdu[offset + 2] = (byte)context.TransferSyntaxes.Count;
|
||||
offset += 4;
|
||||
|
||||
WriteSyntax(pdu.AsSpan(), ref offset, context.AbstractSyntax);
|
||||
foreach (var transferSyntax in context.TransferSyntaxes)
|
||||
{
|
||||
WriteSyntax(pdu.AsSpan(), ref offset, transferSyntax);
|
||||
}
|
||||
}
|
||||
|
||||
return pdu;
|
||||
}
|
||||
|
||||
public byte[] EncodeWithAuth(DceRpcAuthTrailer trailer, ReadOnlySpan<byte> authToken)
|
||||
{
|
||||
byte[] unauthenticated = Encode();
|
||||
int padLength = Align(unauthenticated.Length, 4) - unauthenticated.Length;
|
||||
int length = unauthenticated.Length + padLength + DceRpcAuthTrailer.Length + authToken.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
unauthenticated.CopyTo(pdu.AsSpan());
|
||||
|
||||
var header = Header with
|
||||
{
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = (ushort)authToken.Length,
|
||||
};
|
||||
header.WriteTo(pdu);
|
||||
|
||||
var alignedTrailer = trailer with { AuthPadLength = (byte)padLength };
|
||||
alignedTrailer.WriteTo(pdu.AsSpan(unauthenticated.Length + padLength, DceRpcAuthTrailer.Length));
|
||||
authToken.CopyTo(pdu.AsSpan(length - authToken.Length));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
public static byte[] EncodeAuth3(DceRpcPduHeader header, DceRpcAuthTrailer trailer, ReadOnlySpan<byte> authToken)
|
||||
{
|
||||
byte[] body = [(byte)' ', (byte)' ', (byte)' ', (byte)' '];
|
||||
int padLength = Align(DceRpcPduHeader.Length + body.Length, 4) - (DceRpcPduHeader.Length + body.Length);
|
||||
int length = DceRpcPduHeader.Length + body.Length + padLength + DceRpcAuthTrailer.Length + authToken.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
var authHeader = header with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Auth3,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = (ushort)authToken.Length,
|
||||
};
|
||||
authHeader.WriteTo(pdu);
|
||||
body.CopyTo(pdu.AsSpan(DceRpcPduHeader.Length));
|
||||
var alignedTrailer = trailer with { AuthPadLength = (byte)padLength };
|
||||
alignedTrailer.WriteTo(pdu.AsSpan(DceRpcPduHeader.Length + body.Length + padLength, DceRpcAuthTrailer.Length));
|
||||
authToken.CopyTo(pdu.AsSpan(length - authToken.Length));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
public static DceRpcAuthValue ReadAuthValue(ReadOnlyMemory<byte> pdu)
|
||||
{
|
||||
var header = DceRpcPduHeader.Parse(pdu.Span);
|
||||
if (header.AuthLength == 0)
|
||||
{
|
||||
throw new ArgumentException("PDU has no auth value.", nameof(pdu));
|
||||
}
|
||||
|
||||
int trailerOffset = header.FragmentLength - header.AuthLength - DceRpcAuthTrailer.Length;
|
||||
if (trailerOffset < DceRpcPduHeader.Length)
|
||||
{
|
||||
throw new ArgumentException("PDU auth trailer offset is invalid.", nameof(pdu));
|
||||
}
|
||||
|
||||
return new DceRpcAuthValue(
|
||||
DceRpcAuthTrailer.Parse(pdu.Span.Slice(trailerOffset, DceRpcAuthTrailer.Length)),
|
||||
pdu.Slice(header.FragmentLength - header.AuthLength, header.AuthLength));
|
||||
}
|
||||
|
||||
private static DceRpcSyntaxId ReadSyntax(ReadOnlySpan<byte> span, ref int offset)
|
||||
{
|
||||
if (offset + 20 > span.Length)
|
||||
{
|
||||
throw new ArgumentException("DCE/RPC syntax identifier is truncated.", nameof(span));
|
||||
}
|
||||
|
||||
var syntax = new DceRpcSyntaxId(
|
||||
new Guid(span.Slice(offset, 16)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset + 16, 2)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(offset + 18, 2)));
|
||||
offset += 20;
|
||||
return syntax;
|
||||
}
|
||||
|
||||
private static void WriteSyntax(Span<byte> span, ref int offset, DceRpcSyntaxId syntax)
|
||||
{
|
||||
syntax.Uuid.TryWriteBytes(span.Slice(offset, 16));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(offset + 16, 2), syntax.VersionMajor);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(offset + 18, 2), syntax.VersionMinor);
|
||||
offset += 20;
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.Security;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class DceRpcTcpClient : IDisposable
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private uint _nextCallId = 1;
|
||||
private ushort _boundContextId;
|
||||
private SspiClientContext? _sspi;
|
||||
private ManagedNtlmClientContext? _managedNtlm;
|
||||
private DceRpcAuthTrailer? _authTrailer;
|
||||
private DceRpcAuthLevel _authLevel = DceRpcAuthLevel.None;
|
||||
|
||||
public DceRpcTcpClient(string host, int port)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public void Connect()
|
||||
{
|
||||
_client = new TcpClient();
|
||||
_client.Connect(_host, _port);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
public DceRpcPduHeader Bind(Guid interfaceId, ushort versionMajor, ushort versionMinor)
|
||||
{
|
||||
EnsureConnected();
|
||||
uint callId = _nextCallId++;
|
||||
var pdu = new DceRpcBindPdu(
|
||||
Header: CreateHeader(DceRpcPacketType.Bind, callId),
|
||||
MaxTransmitFragment: 4280,
|
||||
MaxReceiveFragment: 4280,
|
||||
AssociationGroupId: 0,
|
||||
PresentationContexts:
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
ContextId: 0,
|
||||
AbstractSyntax: new DceRpcSyntaxId(interfaceId, versionMajor, versionMinor),
|
||||
TransferSyntaxes: [DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
|
||||
Write(pdu.Encode());
|
||||
byte[] response = ReadPdu();
|
||||
return DceRpcPduHeader.Parse(response);
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithNtlmConnect(Guid interfaceId, ushort versionMajor, ushort versionMinor, string targetName = "localhost")
|
||||
{
|
||||
return BindWithNtlm(interfaceId, versionMajor, versionMinor, DceRpcAuthLevel.Connect, targetName);
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithNtlmPacketIntegrity(Guid interfaceId, ushort versionMajor, ushort versionMinor, string targetName = "localhost")
|
||||
{
|
||||
return BindWithNtlm(interfaceId, versionMajor, versionMinor, DceRpcAuthLevel.PacketIntegrity, targetName);
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithManagedNtlmPacketIntegrity(Guid interfaceId, ushort versionMajor, ushort versionMinor)
|
||||
{
|
||||
EnsureConnected();
|
||||
_sspi?.Dispose();
|
||||
_sspi = null;
|
||||
_managedNtlm = ManagedNtlmClientContext.FromEnvironment();
|
||||
byte[] type1 = _managedNtlm.CreateType1();
|
||||
uint callId = _nextCallId++;
|
||||
var pdu = new DceRpcBindPdu(
|
||||
Header: CreateHeader(DceRpcPacketType.Bind, callId),
|
||||
MaxTransmitFragment: 4280,
|
||||
MaxReceiveFragment: 4280,
|
||||
AssociationGroupId: 0,
|
||||
PresentationContexts:
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
ContextId: 0,
|
||||
AbstractSyntax: new DceRpcSyntaxId(interfaceId, versionMajor, versionMinor),
|
||||
TransferSyntaxes: [DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
var trailer = new DceRpcAuthTrailer(
|
||||
AuthType: DceRpcAuthType.WinNt,
|
||||
AuthLevel: DceRpcAuthLevel.PacketIntegrity,
|
||||
AuthPadLength: 0,
|
||||
AuthReserved: 0,
|
||||
AuthContextId: 79232);
|
||||
|
||||
Write(pdu.EncodeWithAuth(trailer, type1));
|
||||
byte[] response = ReadPdu();
|
||||
var responseHeader = DceRpcPduHeader.Parse(response);
|
||||
var challenge = DceRpcBindPdu.ReadAuthValue(response);
|
||||
byte[] type3 = _managedNtlm.CreateType3(challenge.Token.Span);
|
||||
byte[] auth3 = DceRpcBindPdu.EncodeAuth3(
|
||||
CreateHeader(DceRpcPacketType.Auth3, responseHeader.CallId),
|
||||
trailer,
|
||||
type3);
|
||||
Write(auth3);
|
||||
_boundContextId = 0;
|
||||
_authTrailer = trailer;
|
||||
_authLevel = DceRpcAuthLevel.PacketIntegrity;
|
||||
return responseHeader;
|
||||
}
|
||||
|
||||
public DceRpcPduHeader BindWithNtlm(Guid interfaceId, ushort versionMajor, ushort versionMinor, DceRpcAuthLevel authLevel, string targetName = "localhost")
|
||||
{
|
||||
EnsureConnected();
|
||||
_sspi?.Dispose();
|
||||
_managedNtlm = null;
|
||||
_sspi = new SspiClientContext("NTLM", targetName, ProtectionLevelFor(authLevel));
|
||||
byte[] type1 = _sspi.GetOutgoingBlob(ReadOnlySpan<byte>.Empty);
|
||||
uint callId = _nextCallId++;
|
||||
var pdu = new DceRpcBindPdu(
|
||||
Header: CreateHeader(DceRpcPacketType.Bind, callId),
|
||||
MaxTransmitFragment: 4280,
|
||||
MaxReceiveFragment: 4280,
|
||||
AssociationGroupId: 0,
|
||||
PresentationContexts:
|
||||
[
|
||||
new DceRpcPresentationContext(
|
||||
ContextId: 0,
|
||||
AbstractSyntax: new DceRpcSyntaxId(interfaceId, versionMajor, versionMinor),
|
||||
TransferSyntaxes: [DceRpcSyntaxId.Ndr20]),
|
||||
]);
|
||||
var trailer = new DceRpcAuthTrailer(
|
||||
AuthType: DceRpcAuthType.WinNt,
|
||||
AuthLevel: authLevel,
|
||||
AuthPadLength: 0,
|
||||
AuthReserved: 0,
|
||||
AuthContextId: 79232);
|
||||
|
||||
Write(pdu.EncodeWithAuth(trailer, type1));
|
||||
byte[] response = ReadPdu();
|
||||
var responseHeader = DceRpcPduHeader.Parse(response);
|
||||
var challenge = DceRpcBindPdu.ReadAuthValue(response);
|
||||
byte[] type3 = _sspi.GetOutgoingBlob(challenge.Token.Span);
|
||||
byte[] auth3 = DceRpcBindPdu.EncodeAuth3(
|
||||
CreateHeader(DceRpcPacketType.Auth3, responseHeader.CallId),
|
||||
trailer,
|
||||
type3);
|
||||
Write(auth3);
|
||||
_boundContextId = 0;
|
||||
_authTrailer = trailer;
|
||||
_authLevel = authLevel;
|
||||
return responseHeader;
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu Call(ushort contextId, ushort opnum, ReadOnlyMemory<byte> stubData)
|
||||
{
|
||||
return CallCore(contextId, opnum, stubData, objectUuid: null);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu CallBoundObject(Guid objectUuid, ushort opnum, ReadOnlyMemory<byte> stubData)
|
||||
{
|
||||
return CallCore(_boundContextId, opnum, stubData, objectUuid);
|
||||
}
|
||||
|
||||
private DceRpcResponsePdu CallCore(ushort contextId, ushort opnum, ReadOnlyMemory<byte> stubData, Guid? objectUuid)
|
||||
{
|
||||
EnsureConnected();
|
||||
uint callId = _nextCallId++;
|
||||
byte[] request = EncodeRequestBytes(CreateHeader(DceRpcPacketType.Request, callId), contextId, opnum, stubData, objectUuid);
|
||||
|
||||
Write(EncodeRequest(request));
|
||||
byte[] response = ReadPdu();
|
||||
var header = DceRpcPduHeader.Parse(response);
|
||||
if (header.PacketType == DceRpcPacketType.Fault)
|
||||
{
|
||||
var fault = DceRpcFaultPdu.Parse(response);
|
||||
throw new DceRpcFaultException(fault.Status);
|
||||
}
|
||||
|
||||
return DceRpcResponsePdu.Parse(response);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu CallBound(ushort opnum, ReadOnlyMemory<byte> stubData)
|
||||
{
|
||||
return Call(_boundContextId, opnum, stubData);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sspi?.Dispose();
|
||||
_stream?.Dispose();
|
||||
_client?.Dispose();
|
||||
}
|
||||
|
||||
private byte[] EncodeRequest(byte[] request)
|
||||
{
|
||||
if (_authLevel == DceRpcAuthLevel.PacketIntegrity)
|
||||
{
|
||||
return EncodePacketIntegrityRequest(request);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private byte[] EncodePacketIntegrityRequest(byte[] unauthenticated)
|
||||
{
|
||||
if (_authTrailer is null)
|
||||
{
|
||||
throw new InvalidOperationException("Packet-integrity auth was requested without an auth trailer.");
|
||||
}
|
||||
|
||||
int padLength = Align(unauthenticated.Length, 4) - unauthenticated.Length;
|
||||
int signatureLength = 16;
|
||||
int length = unauthenticated.Length + padLength + DceRpcAuthTrailer.Length + signatureLength;
|
||||
byte[] pdu = new byte[length];
|
||||
unauthenticated.CopyTo(pdu.AsSpan());
|
||||
if (padLength > 0)
|
||||
{
|
||||
pdu.AsSpan(unauthenticated.Length, padLength).Fill(0xbb);
|
||||
}
|
||||
|
||||
var parsedHeader = DceRpcPduHeader.Parse(unauthenticated);
|
||||
var header = parsedHeader with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Request,
|
||||
PacketFlags = parsedHeader.PacketFlags == 0 ? (byte)0x03 : parsedHeader.PacketFlags,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = (ushort)signatureLength,
|
||||
};
|
||||
header.WriteTo(pdu);
|
||||
|
||||
var trailer = _authTrailer with { AuthPadLength = (byte)padLength };
|
||||
int trailerOffset = unauthenticated.Length + padLength;
|
||||
trailer.WriteTo(pdu.AsSpan(trailerOffset, DceRpcAuthTrailer.Length));
|
||||
pdu.AsSpan(length - signatureLength, signatureLength).Fill(0x20);
|
||||
|
||||
byte[] verifier;
|
||||
if (_managedNtlm is not null)
|
||||
{
|
||||
verifier = _managedNtlm.Sign(pdu.AsSpan(0, length - signatureLength));
|
||||
}
|
||||
else if (_sspi is not null)
|
||||
{
|
||||
byte[] wrapped = _sspi.Wrap(pdu.AsSpan(0, length - signatureLength), requestEncryption: false, out _);
|
||||
verifier = ExtractNtlmVerifier(wrapped, pdu.AsSpan(0, length - signatureLength));
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Packet-integrity auth was requested without an auth context.");
|
||||
}
|
||||
|
||||
verifier.CopyTo(pdu.AsSpan(length - signatureLength, signatureLength));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] EncodeRequestBytes(
|
||||
DceRpcPduHeader header,
|
||||
ushort contextId,
|
||||
ushort opnum,
|
||||
ReadOnlyMemory<byte> stubData,
|
||||
Guid? objectUuid)
|
||||
{
|
||||
int objectLength = objectUuid.HasValue ? 16 : 0;
|
||||
int fixedOffset = DceRpcPduHeader.Length;
|
||||
int stubOffset = fixedOffset + 8 + objectLength;
|
||||
int length = stubOffset + stubData.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
var requestHeader = header with
|
||||
{
|
||||
PacketType = DceRpcPacketType.Request,
|
||||
FragmentLength = (ushort)length,
|
||||
AuthLength = 0,
|
||||
PacketFlags = (byte)((header.PacketFlags == 0 ? 0x03 : header.PacketFlags) | (objectUuid.HasValue ? 0x80 : 0x00)),
|
||||
};
|
||||
requestHeader.WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(fixedOffset, 4), (uint)stubData.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(fixedOffset + 4, 2), contextId);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(fixedOffset + 6, 2), opnum);
|
||||
if (objectUuid.HasValue)
|
||||
{
|
||||
objectUuid.Value.TryWriteBytes(pdu.AsSpan(fixedOffset + 8, 16));
|
||||
}
|
||||
|
||||
stubData.Span.CopyTo(pdu.AsSpan(stubOffset));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] ExtractNtlmVerifier(ReadOnlySpan<byte> wrapped, ReadOnlySpan<byte> signedInput)
|
||||
{
|
||||
const int signatureLength = 16;
|
||||
TraceWrapShape(wrapped, signedInput, signatureLength);
|
||||
if (wrapped.Length == signatureLength)
|
||||
{
|
||||
return wrapped.ToArray();
|
||||
}
|
||||
|
||||
if (wrapped.Length == signedInput.Length + signatureLength)
|
||||
{
|
||||
if (wrapped[signatureLength..].SequenceEqual(signedInput))
|
||||
{
|
||||
return wrapped[..signatureLength].ToArray();
|
||||
}
|
||||
|
||||
if (wrapped[..signedInput.Length].SequenceEqual(signedInput))
|
||||
{
|
||||
return wrapped[^signatureLength..].ToArray();
|
||||
}
|
||||
|
||||
return wrapped[..signatureLength].ToArray();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected SSPI wrap verifier length {wrapped.Length} for input length {signedInput.Length}.");
|
||||
}
|
||||
|
||||
private static void TraceWrapShape(ReadOnlySpan<byte> wrapped, ReadOnlySpan<byte> signedInput, int signatureLength)
|
||||
{
|
||||
string? tracePath = Environment.GetEnvironmentVariable("MX_RPC_WRAP_TRACE");
|
||||
if (string.IsNullOrWhiteSpace(tracePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool tokenThenInput = wrapped.Length >= signatureLength
|
||||
&& wrapped.Length == signedInput.Length + signatureLength
|
||||
&& wrapped[signatureLength..].SequenceEqual(signedInput);
|
||||
bool inputThenToken = wrapped.Length >= signatureLength
|
||||
&& wrapped.Length == signedInput.Length + signatureLength
|
||||
&& wrapped[..signedInput.Length].SequenceEqual(signedInput);
|
||||
|
||||
string text =
|
||||
$"signed_input_length={signedInput.Length}{Environment.NewLine}" +
|
||||
$"wrapped_length={wrapped.Length}{Environment.NewLine}" +
|
||||
$"token_then_input={tokenThenInput}{Environment.NewLine}" +
|
||||
$"input_then_token={inputThenToken}{Environment.NewLine}" +
|
||||
$"wrapped_first16={Convert.ToHexString(wrapped[..Math.Min(signatureLength, wrapped.Length)])}{Environment.NewLine}" +
|
||||
$"wrapped_last16={Convert.ToHexString(wrapped[^Math.Min(signatureLength, wrapped.Length)..])}{Environment.NewLine}";
|
||||
File.AppendAllText(tracePath, text);
|
||||
}
|
||||
|
||||
private static DceRpcPduHeader CreateHeader(DceRpcPacketType packetType, uint callId)
|
||||
{
|
||||
return new DceRpcPduHeader(
|
||||
Version: 5,
|
||||
VersionMinor: 0,
|
||||
PacketType: packetType,
|
||||
PacketFlags: 0x03,
|
||||
DataRepresentation: 0x10,
|
||||
FragmentLength: 0,
|
||||
AuthLength: 0,
|
||||
CallId: callId);
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
|
||||
private static ProtectionLevel ProtectionLevelFor(DceRpcAuthLevel authLevel)
|
||||
{
|
||||
return authLevel switch
|
||||
{
|
||||
DceRpcAuthLevel.PacketPrivacy => ProtectionLevel.EncryptAndSign,
|
||||
DceRpcAuthLevel.PacketIntegrity => ProtectionLevel.Sign,
|
||||
_ => ProtectionLevel.None,
|
||||
};
|
||||
}
|
||||
|
||||
private void Write(byte[] pdu)
|
||||
{
|
||||
EnsureConnected();
|
||||
_stream!.Write(pdu);
|
||||
_stream.Flush();
|
||||
}
|
||||
|
||||
private byte[] ReadPdu()
|
||||
{
|
||||
EnsureConnected();
|
||||
byte[] headerBytes = ReadExact(DceRpcPduHeader.Length);
|
||||
var header = DceRpcPduHeader.Parse(headerBytes);
|
||||
byte[] pdu = new byte[header.FragmentLength];
|
||||
headerBytes.CopyTo(pdu, 0);
|
||||
byte[] body = ReadExact(header.FragmentLength - DceRpcPduHeader.Length);
|
||||
body.CopyTo(pdu.AsSpan(DceRpcPduHeader.Length));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private byte[] ReadExact(int length)
|
||||
{
|
||||
byte[] buffer = new byte[length];
|
||||
int offset = 0;
|
||||
while (offset < length)
|
||||
{
|
||||
int read = _stream!.Read(buffer, offset, length - offset);
|
||||
if (read == 0)
|
||||
{
|
||||
throw new IOException("DCE/RPC socket closed while reading.");
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_stream is null)
|
||||
{
|
||||
throw new InvalidOperationException("DCE/RPC TCP client is not connected.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DceRpcFaultException : Exception
|
||||
{
|
||||
public DceRpcFaultException(uint status)
|
||||
: base($"DCE/RPC fault 0x{status:x8}")
|
||||
{
|
||||
Status = status;
|
||||
}
|
||||
|
||||
public uint Status { get; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record GalaxyTagMetadata(
|
||||
string ObjectTagName,
|
||||
string AttributeName,
|
||||
string? PrimitiveName,
|
||||
ushort PlatformId,
|
||||
ushort EngineId,
|
||||
ushort ObjectId,
|
||||
short PrimitiveId,
|
||||
short AttributeId,
|
||||
short PropertyId,
|
||||
short MxDataType,
|
||||
bool IsArray,
|
||||
short SecurityClassification,
|
||||
string AttributeSource)
|
||||
{
|
||||
public const short ValuePropertyId = 10;
|
||||
public const short BufferPropertyId = 50;
|
||||
|
||||
public bool IsBufferProperty => PropertyId == BufferPropertyId;
|
||||
|
||||
public MxReferenceHandle ToReferenceHandle(byte galaxyId = 1)
|
||||
{
|
||||
return MxReferenceHandle.Create(
|
||||
galaxyId,
|
||||
PlatformId,
|
||||
EngineId,
|
||||
ObjectId,
|
||||
ObjectTagName,
|
||||
PrimitiveId,
|
||||
AttributeId,
|
||||
PropertyId,
|
||||
AttributeName,
|
||||
IsArray);
|
||||
}
|
||||
|
||||
public MxValueKind ToValueKind()
|
||||
{
|
||||
return NmxWriteMessage.GetValueKind(MxDataType, IsArray);
|
||||
}
|
||||
|
||||
public bool TryGetValueKind(out MxValueKind valueKind)
|
||||
{
|
||||
return NmxWriteMessage.TryGetValueKind(MxDataType, IsArray, out valueKind);
|
||||
}
|
||||
|
||||
public bool IsSupportedValueKind => TryGetValueKind(out _);
|
||||
|
||||
public (MxValueKind ValueKind, object Value) ProjectWriteValue(object value)
|
||||
{
|
||||
if (TryGetValueKind(out MxValueKind valueKind))
|
||||
{
|
||||
return (valueKind, value);
|
||||
}
|
||||
|
||||
if (IsArray)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(value), $"Unsupported MX array data type {MxDataType}.");
|
||||
}
|
||||
|
||||
return (MxDataType, value) switch
|
||||
{
|
||||
((short)MxNativeCodec.MxDataType.ElapsedTime, TimeSpan timeSpan) => (MxValueKind.Int32, checked((int)timeSpan.TotalMilliseconds)),
|
||||
((short)MxNativeCodec.MxDataType.ElapsedTime, _) => (MxValueKind.Int32, value),
|
||||
((short)MxNativeCodec.MxDataType.InternationalizedString, _) => (MxValueKind.String, value),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(value), $"Unsupported MX data type {MxDataType}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GalaxyRepositoryTagResolver
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True";
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public GalaxyRepositoryTagResolver(string connectionString = DefaultConnectionString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
_connectionString = connectionString;
|
||||
}
|
||||
|
||||
public async Task<GalaxyTagMetadata> ResolveAsync(
|
||||
string tagReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<ParsedTagReference> candidates = ParsedTagReference.ParseCandidates(tagReference);
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (ParsedTagReference parsed in candidates)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = ResolveSql;
|
||||
command.Parameters.AddWithValue("@objectTagName", parsed.ObjectTagName);
|
||||
command.Parameters.AddWithValue("@attributeName", parsed.AttributeName);
|
||||
command.Parameters.AddWithValue("@primitiveName", (object?)parsed.PrimitiveName ?? DBNull.Value);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
GalaxyTagMetadata metadata = ReadMetadata(reader);
|
||||
return parsed.PropertyIdOverride.HasValue
|
||||
? metadata with { PropertyId = parsed.PropertyIdOverride.Value }
|
||||
: metadata;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Galaxy tag reference '{tagReference}' was not found in the deployed repository metadata.");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GalaxyTagMetadata>> BrowseAsync(
|
||||
string objectTagLike = "%",
|
||||
string attributeLike = "%",
|
||||
int maxRows = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(objectTagLike);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(attributeLike);
|
||||
if (maxRows <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxRows), "Maximum row count must be positive.");
|
||||
}
|
||||
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = BrowseSql;
|
||||
command.Parameters.AddWithValue("@objectTagLike", objectTagLike);
|
||||
command.Parameters.AddWithValue("@attributeLike", attributeLike);
|
||||
command.Parameters.AddWithValue("@maxRows", Math.Min(maxRows, 1000));
|
||||
|
||||
List<GalaxyTagMetadata> results = [];
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(ReadMetadata(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static GalaxyTagMetadata ReadMetadata(SqlDataReader reader)
|
||||
{
|
||||
return new GalaxyTagMetadata(
|
||||
ObjectTagName: reader.GetString(0),
|
||||
AttributeName: reader.GetString(1),
|
||||
PrimitiveName: reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
PlatformId: checked((ushort)reader.GetInt16(3)),
|
||||
EngineId: checked((ushort)reader.GetInt16(4)),
|
||||
ObjectId: checked((ushort)reader.GetInt16(5)),
|
||||
PrimitiveId: reader.GetInt16(6),
|
||||
AttributeId: reader.GetInt16(7),
|
||||
PropertyId: checked((short)reader.GetInt32(8)),
|
||||
MxDataType: reader.GetInt16(9),
|
||||
IsArray: reader.GetBoolean(10),
|
||||
SecurityClassification: reader.GetInt16(11),
|
||||
AttributeSource: reader.GetString(12));
|
||||
}
|
||||
|
||||
private sealed record ParsedTagReference(
|
||||
string ObjectTagName,
|
||||
string? PrimitiveName,
|
||||
string AttributeName,
|
||||
short? PropertyIdOverride)
|
||||
{
|
||||
public static IReadOnlyList<ParsedTagReference> ParseCandidates(string tagReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagReference);
|
||||
var property = ParsePropertySuffix(tagReference);
|
||||
string[] parts = property.BaseReference.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length switch
|
||||
{
|
||||
2 => [new ParsedTagReference(parts[0], null, parts[1], property.PropertyIdOverride)],
|
||||
>= 3 =>
|
||||
[
|
||||
new ParsedTagReference(parts[0], parts[1], string.Join('.', parts.Skip(2)), property.PropertyIdOverride),
|
||||
new ParsedTagReference(parts[0], null, string.Join('.', parts.Skip(1)), property.PropertyIdOverride),
|
||||
],
|
||||
_ => throw new ArgumentException("Tag reference must be Object.Attribute or Object.Primitive.Attribute.", nameof(tagReference)),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string BaseReference, short? PropertyIdOverride) ParsePropertySuffix(string tagReference)
|
||||
{
|
||||
const string bufferSuffix = ".property(buffer)";
|
||||
if (tagReference.EndsWith(bufferSuffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string baseReference = tagReference[..^bufferSuffix.Length];
|
||||
if (string.IsNullOrWhiteSpace(baseReference))
|
||||
{
|
||||
throw new ArgumentException("Property references must include a base tag reference.", nameof(tagReference));
|
||||
}
|
||||
|
||||
return (baseReference, GalaxyTagMetadata.BufferPropertyId);
|
||||
}
|
||||
|
||||
return (tagReference, null);
|
||||
}
|
||||
}
|
||||
|
||||
private const string ResolveSql = """
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT
|
||||
g.gobject_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
0 AS depth
|
||||
FROM dbo.gobject g
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
AND g.tag_name = @objectTagName
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0
|
||||
AND dpc.depth < 10
|
||||
),
|
||||
ranked_dynamic AS (
|
||||
SELECT
|
||||
g.tag_name AS object_tag_name,
|
||||
da.attribute_name,
|
||||
CAST(NULL AS nvarchar(329)) AS primitive_name,
|
||||
i.mx_platform_id,
|
||||
i.mx_engine_id,
|
||||
i.mx_object_id,
|
||||
da.mx_primitive_id,
|
||||
da.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
da.mx_data_type,
|
||||
da.is_array,
|
||||
da.security_classification,
|
||||
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||
ORDER BY dpc.depth
|
||||
) AS rn
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.dynamic_attribute da
|
||||
ON da.package_id = dpc.package_id
|
||||
INNER JOIN dbo.gobject g
|
||||
ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN dbo.instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
WHERE da.attribute_name = @attributeName
|
||||
AND @primitiveName IS NULL
|
||||
),
|
||||
primitive_attributes AS (
|
||||
SELECT
|
||||
g.tag_name AS object_tag_name,
|
||||
ad.attribute_name,
|
||||
NULLIF(pi.primitive_name, N'') AS primitive_name,
|
||||
i.mx_platform_id,
|
||||
i.mx_engine_id,
|
||||
i.mx_object_id,
|
||||
pi.mx_primitive_id,
|
||||
ad.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
ad.mx_data_type,
|
||||
ad.is_array,
|
||||
ad.security_classification,
|
||||
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
|
||||
1 AS rn
|
||||
FROM dbo.gobject g
|
||||
INNER JOIN dbo.instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
INNER JOIN dbo.primitive_instance pi
|
||||
ON pi.gobject_id = g.gobject_id
|
||||
AND pi.package_id = g.deployed_package_id
|
||||
AND pi.property_bitmask & 0x10 <> 0x10
|
||||
INNER JOIN dbo.attribute_definition ad
|
||||
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
WHERE g.tag_name = @objectTagName
|
||||
AND ad.attribute_name = @attributeName
|
||||
AND (
|
||||
(@primitiveName IS NULL AND pi.primitive_name = N'')
|
||||
OR (@primitiveName IS NOT NULL AND pi.primitive_name = @primitiveName)
|
||||
)
|
||||
)
|
||||
SELECT TOP (1)
|
||||
object_tag_name,
|
||||
attribute_name,
|
||||
primitive_name,
|
||||
mx_platform_id,
|
||||
mx_engine_id,
|
||||
mx_object_id,
|
||||
mx_primitive_id,
|
||||
mx_attribute_id,
|
||||
property_id,
|
||||
mx_data_type,
|
||||
is_array,
|
||||
security_classification,
|
||||
attribute_source
|
||||
FROM (
|
||||
SELECT * FROM ranked_dynamic WHERE rn = 1
|
||||
UNION ALL
|
||||
SELECT * FROM primitive_attributes
|
||||
) resolved
|
||||
ORDER BY CASE attribute_source WHEN N'dynamic' THEN 0 ELSE 1 END
|
||||
""";
|
||||
|
||||
private const string BrowseSql = """
|
||||
;WITH deployed_objects AS (
|
||||
SELECT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
g.deployed_package_id,
|
||||
i.mx_platform_id,
|
||||
i.mx_engine_id,
|
||||
i.mx_object_id
|
||||
FROM dbo.gobject g
|
||||
INNER JOIN dbo.instance i
|
||||
ON i.gobject_id = g.gobject_id
|
||||
WHERE g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
AND g.tag_name LIKE @objectTagLike
|
||||
),
|
||||
deployed_package_chain AS (
|
||||
SELECT
|
||||
d.gobject_id,
|
||||
d.tag_name,
|
||||
d.mx_platform_id,
|
||||
d.mx_engine_id,
|
||||
d.mx_object_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
0 AS depth
|
||||
FROM deployed_objects d
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = d.deployed_package_id
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id,
|
||||
dpc.tag_name,
|
||||
dpc.mx_platform_id,
|
||||
dpc.mx_engine_id,
|
||||
dpc.mx_object_id,
|
||||
p.package_id,
|
||||
p.derived_from_package_id,
|
||||
dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.package p
|
||||
ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0
|
||||
AND dpc.depth < 10
|
||||
),
|
||||
ranked_dynamic AS (
|
||||
SELECT
|
||||
dpc.tag_name AS object_tag_name,
|
||||
da.attribute_name,
|
||||
CAST(NULL AS nvarchar(329)) AS primitive_name,
|
||||
dpc.mx_platform_id,
|
||||
dpc.mx_engine_id,
|
||||
dpc.mx_object_id,
|
||||
da.mx_primitive_id,
|
||||
da.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
da.mx_data_type,
|
||||
da.is_array,
|
||||
da.security_classification,
|
||||
CAST(N'dynamic' AS nvarchar(16)) AS attribute_source,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY dpc.gobject_id, da.attribute_name
|
||||
ORDER BY dpc.depth
|
||||
) AS rn
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dbo.dynamic_attribute da
|
||||
ON da.package_id = dpc.package_id
|
||||
WHERE da.attribute_name LIKE @attributeLike
|
||||
),
|
||||
primitive_attributes AS (
|
||||
SELECT
|
||||
d.tag_name AS object_tag_name,
|
||||
ad.attribute_name,
|
||||
NULLIF(pi.primitive_name, N'') AS primitive_name,
|
||||
d.mx_platform_id,
|
||||
d.mx_engine_id,
|
||||
d.mx_object_id,
|
||||
pi.mx_primitive_id,
|
||||
ad.mx_attribute_id,
|
||||
CAST(10 AS int) AS property_id,
|
||||
ad.mx_data_type,
|
||||
ad.is_array,
|
||||
ad.security_classification,
|
||||
CAST(N'primitive' AS nvarchar(16)) AS attribute_source,
|
||||
1 AS rn
|
||||
FROM deployed_objects d
|
||||
INNER JOIN dbo.gobject g
|
||||
ON g.gobject_id = d.gobject_id
|
||||
INNER JOIN dbo.primitive_instance pi
|
||||
ON pi.gobject_id = g.gobject_id
|
||||
AND pi.package_id = g.deployed_package_id
|
||||
AND pi.property_bitmask & 0x10 <> 0x10
|
||||
INNER JOIN dbo.attribute_definition ad
|
||||
ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
WHERE ad.attribute_name LIKE @attributeLike
|
||||
)
|
||||
SELECT TOP (@maxRows)
|
||||
object_tag_name,
|
||||
attribute_name,
|
||||
primitive_name,
|
||||
mx_platform_id,
|
||||
mx_engine_id,
|
||||
mx_object_id,
|
||||
mx_primitive_id,
|
||||
mx_attribute_id,
|
||||
property_id,
|
||||
mx_data_type,
|
||||
is_array,
|
||||
security_classification,
|
||||
attribute_source
|
||||
FROM (
|
||||
SELECT * FROM ranked_dynamic WHERE rn = 1
|
||||
UNION ALL
|
||||
SELECT * FROM primitive_attributes
|
||||
) resolved
|
||||
ORDER BY object_tag_name, primitive_name, attribute_name
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record GalaxyUserProfile(
|
||||
int UserProfileId,
|
||||
string UserProfileName,
|
||||
Guid UserGuid,
|
||||
string DefaultSecurityGroup,
|
||||
int? InTouchAccessLevel,
|
||||
IReadOnlyList<string> Roles);
|
||||
|
||||
public sealed class GalaxyRepositoryUserResolver
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;TrustServerCertificate=True";
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public GalaxyRepositoryUserResolver(string connectionString = DefaultConnectionString)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
_connectionString = connectionString;
|
||||
}
|
||||
|
||||
public async Task<int> ResolveUserProfileIdByGuidAsync(
|
||||
Guid userGuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
GalaxyUserProfile profile = await ResolveByGuidAsync(userGuid, cancellationToken).ConfigureAwait(false);
|
||||
return profile.UserProfileId;
|
||||
}
|
||||
|
||||
public async Task<GalaxyUserProfile> ResolveByGuidAsync(
|
||||
Guid userGuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = UserByGuidSql;
|
||||
command.Parameters.AddWithValue("@userGuid", userGuid);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new KeyNotFoundException($"Galaxy user GUID {userGuid} was not found in dbo.user_profile.");
|
||||
}
|
||||
|
||||
return ReadProfile(reader);
|
||||
}
|
||||
|
||||
public async Task<GalaxyUserProfile> ResolveByNameAsync(
|
||||
string userName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||
|
||||
await using var connection = new SqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = UserByNameSql;
|
||||
command.Parameters.AddWithValue("@userName", userName);
|
||||
|
||||
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new KeyNotFoundException($"Galaxy user {userName} was not found in dbo.user_profile.");
|
||||
}
|
||||
|
||||
return ReadProfile(reader);
|
||||
}
|
||||
|
||||
private static GalaxyUserProfile ReadProfile(SqlDataReader reader)
|
||||
{
|
||||
return new GalaxyUserProfile(
|
||||
reader.GetInt32(0),
|
||||
reader.GetString(1),
|
||||
reader.GetGuid(2),
|
||||
reader.GetString(3),
|
||||
reader.IsDBNull(4) ? null : reader.GetInt32(4),
|
||||
reader.IsDBNull(5) ? [] : ParseRoleBlob(reader.GetString(5)));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseRoleBlob(string rolesText)
|
||||
{
|
||||
if (!rolesText.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || rolesText.Length <= 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
byte[] bytes = Convert.FromHexString(rolesText[2..]);
|
||||
List<string> roles = [];
|
||||
for (int offset = 0; offset + 3 < bytes.Length; offset++)
|
||||
{
|
||||
List<char> chars = [];
|
||||
int cursor = offset;
|
||||
while (cursor + 1 < bytes.Length)
|
||||
{
|
||||
char c = (char)(bytes[cursor] | (bytes[cursor + 1] << 8));
|
||||
if (c == '\0')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (c < 0x20 || c > 0x7e)
|
||||
{
|
||||
chars.Clear();
|
||||
break;
|
||||
}
|
||||
|
||||
chars.Add(c);
|
||||
cursor += 2;
|
||||
}
|
||||
|
||||
if (chars.Count < 2 || cursor + 1 >= bytes.Length || bytes[cursor] != 0 || bytes[cursor + 1] != 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string role = new(chars.ToArray());
|
||||
if (!roles.Contains(role, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
roles.Add(role);
|
||||
}
|
||||
|
||||
offset = cursor;
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
private const string UserSelectSql = """
|
||||
SELECT TOP (1)
|
||||
user_profile_id,
|
||||
user_profile_name,
|
||||
user_guid,
|
||||
default_security_group,
|
||||
intouch_access_level,
|
||||
CONVERT(nvarchar(max), roles) AS roles_text
|
||||
FROM dbo.user_profile
|
||||
""";
|
||||
|
||||
private const string UserByGuidSql = UserSelectSql + "\nWHERE user_guid = @userGuid\nORDER BY user_profile_id";
|
||||
|
||||
private const string UserByNameSql = UserSelectSql + "\nWHERE user_profile_name = @userName\nORDER BY user_profile_id";
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class ManagedCallbackExporter : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly Task _acceptLoop;
|
||||
private readonly List<string> _events = [];
|
||||
private readonly object _eventsLock = new();
|
||||
private readonly ulong _oxid = RandomUInt64();
|
||||
private readonly ulong _oid = RandomUInt64();
|
||||
|
||||
public ManagedCallbackExporter(int port = 0)
|
||||
{
|
||||
CallbackIpid = Guid.NewGuid();
|
||||
RemUnknownIpid = Guid.NewGuid();
|
||||
_listener = new TcpListener(IPAddress.Any, port);
|
||||
_listener.Start();
|
||||
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
_acceptLoop = Task.Run(AcceptLoopAsync);
|
||||
}
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public Guid CallbackIpid { get; }
|
||||
|
||||
public Guid RemUnknownIpid { get; }
|
||||
|
||||
public IReadOnlyList<string> Events
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_eventsLock)
|
||||
{
|
||||
return _events.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] CreateCallbackObjRef(string hostName)
|
||||
{
|
||||
return ComObjRefBuilder.CreateStandardObjRef(
|
||||
NmxProcedureMetadata.INmxSvcCallback,
|
||||
stdFlags: 0x280,
|
||||
publicRefs: 5,
|
||||
oxid: _oxid,
|
||||
oid: _oid,
|
||||
ipid: CallbackIpid,
|
||||
stringBindings: [$"{hostName}[{Port}]"]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cancellation.Cancel();
|
||||
_listener.Stop();
|
||||
try
|
||||
{
|
||||
_acceptLoop.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_cancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync()
|
||||
{
|
||||
while (!_cancellation.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await _listener.AcceptTcpClientAsync(_cancellation.Token).ConfigureAwait(false);
|
||||
Record($"accept remote={client.Client.RemoteEndPoint}");
|
||||
_ = Task.Run(() => ServeClientAsync(client), _cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Record($"accept_error {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeClientAsync(TcpClient client)
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
NetworkStream stream = client.GetStream();
|
||||
ushort currentContext = 0;
|
||||
Guid currentInterface = Guid.Empty;
|
||||
|
||||
while (!_cancellation.IsCancellationRequested)
|
||||
{
|
||||
byte[]? pdu = await ReadPduAsync(stream, _cancellation.Token).ConfigureAwait(false);
|
||||
if (pdu is null)
|
||||
{
|
||||
Record("client_closed");
|
||||
return;
|
||||
}
|
||||
|
||||
var header = DceRpcPduHeader.Parse(pdu);
|
||||
Record($"pdu type={header.PacketType} flags=0x{header.PacketFlags:X2} frag={header.FragmentLength} auth={header.AuthLength} call={header.CallId}");
|
||||
|
||||
if (header.PacketType == DceRpcPacketType.Bind || header.PacketType == DceRpcPacketType.AlterContext)
|
||||
{
|
||||
var bind = DceRpcBindPdu.Parse(pdu);
|
||||
currentContext = bind.PresentationContexts.Count == 0 ? (ushort)0 : bind.PresentationContexts[0].ContextId;
|
||||
currentInterface = bind.PresentationContexts.Count == 0 ? Guid.Empty : bind.PresentationContexts[0].AbstractSyntax.Uuid;
|
||||
Record($"bind context={currentContext} iid={currentInterface}");
|
||||
await stream.WriteAsync(EncodeBindAck(header.CallId, currentContext), _cancellation.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.PacketType == DceRpcPacketType.Request)
|
||||
{
|
||||
byte[] response = HandleRequest(pdu, header, currentContext, currentInterface);
|
||||
await stream.WriteAsync(response, _cancellation.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.PacketType == DceRpcPacketType.Auth3)
|
||||
{
|
||||
Record("auth3_ignored");
|
||||
continue;
|
||||
}
|
||||
|
||||
Record($"unhandled_pdu {header.PacketType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] HandleRequest(byte[] pdu, DceRpcPduHeader header, ushort currentContext, Guid currentInterface)
|
||||
{
|
||||
int fixedOffset = DceRpcPduHeader.Length;
|
||||
uint allocationHint = BinaryPrimitives.ReadUInt32LittleEndian(pdu.AsSpan(fixedOffset, 4));
|
||||
ushort contextId = BinaryPrimitives.ReadUInt16LittleEndian(pdu.AsSpan(fixedOffset + 4, 2));
|
||||
ushort opnum = BinaryPrimitives.ReadUInt16LittleEndian(pdu.AsSpan(fixedOffset + 6, 2));
|
||||
int stubOffset = fixedOffset + 8;
|
||||
Guid? objectUuid = null;
|
||||
if ((header.PacketFlags & 0x80) != 0)
|
||||
{
|
||||
objectUuid = new Guid(pdu.AsSpan(stubOffset, 16));
|
||||
stubOffset += 16;
|
||||
}
|
||||
|
||||
int trailerLength = header.AuthLength == 0 ? 0 : DceRpcAuthTrailer.Length + header.AuthLength;
|
||||
int stubLength = header.FragmentLength - stubOffset - trailerLength;
|
||||
ReadOnlySpan<byte> stub = pdu.AsSpan(stubOffset, Math.Max(0, stubLength));
|
||||
|
||||
Record($"request iid={currentInterface} context={contextId}/{currentContext} opnum={opnum} object={objectUuid} alloc={allocationHint} stub={stub.Length}");
|
||||
|
||||
if (currentInterface == RemUnknownMessages.IRemUnknown)
|
||||
{
|
||||
return opnum switch
|
||||
{
|
||||
RemUnknownMessages.RemQueryInterfaceOpnum => EncodeResponse(header.CallId, contextId, EncodeRemQueryInterfaceResponse(stub)),
|
||||
RemUnknownMessages.RemAddRefOpnum => EncodeResponse(header.CallId, contextId, EncodeOrpcHResultResponse(0)),
|
||||
RemUnknownMessages.RemReleaseOpnum => EncodeResponse(header.CallId, contextId, EncodeOrpcHResultResponse(0)),
|
||||
_ => EncodeFault(header.CallId, contextId, 0x000006F7),
|
||||
};
|
||||
}
|
||||
|
||||
if (currentInterface == NmxSvcCallbackMessages.InterfaceId)
|
||||
{
|
||||
if (opnum is NmxSvcCallbackMessages.DataReceivedOpnum or NmxSvcCallbackMessages.StatusReceivedOpnum)
|
||||
{
|
||||
var parsed = NmxSvcCallbackMessages.ParseCallbackRequest(stub);
|
||||
Record($"callback opnum={opnum} body={parsed.Body.Length}");
|
||||
return EncodeResponse(header.CallId, contextId, NmxSvcCallbackMessages.EncodeCallbackResponse(0));
|
||||
}
|
||||
|
||||
return EncodeFault(header.CallId, contextId, 0x000006F7);
|
||||
}
|
||||
|
||||
return EncodeFault(header.CallId, contextId, 0x000006F7);
|
||||
}
|
||||
|
||||
private byte[] EncodeRemQueryInterfaceResponse(ReadOnlySpan<byte> request)
|
||||
{
|
||||
Guid requestedIid = request.Length >= 76 ? new Guid(request.Slice(60, 16)) : Guid.Empty;
|
||||
Record($"remqi requested={requestedIid}");
|
||||
|
||||
var std = new StdObjRef(0x280, 5, _oxid, _oid, requestedIid == RemUnknownMessages.IRemUnknown ? RemUnknownIpid : CallbackIpid);
|
||||
int hr = requestedIid == RemUnknownMessages.IRemUnknown
|
||||
|| requestedIid == NmxProcedureMetadata.INmxSvcCallback
|
||||
|| requestedIid == new Guid("00000000-0000-0000-C000-000000000046")
|
||||
? 0
|
||||
: unchecked((int)0x80004002);
|
||||
|
||||
byte[] buffer = new byte[OrpcThat.EncodedLengthWithoutExtensions + 4 + 4 + RemQiResult.EncodedLength + 4];
|
||||
int offset = 0;
|
||||
new OrpcThat(0, 0).Encode().CopyTo(buffer.AsSpan(offset));
|
||||
offset += OrpcThat.EncodedLengthWithoutExtensions;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), 0x00020000);
|
||||
offset += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), 1);
|
||||
offset += 4;
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(offset, 4), hr);
|
||||
offset += 8;
|
||||
std.Encode().CopyTo(buffer.AsSpan(offset));
|
||||
offset += StdObjRef.EncodedLength;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset, 4), 0);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] EncodeOrpcHResultResponse(int hresult)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThat.EncodedLengthWithoutExtensions + 4];
|
||||
new OrpcThat(0, 0).Encode().CopyTo(buffer.AsSpan());
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(OrpcThat.EncodedLengthWithoutExtensions, 4), hresult);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] EncodeBindAck(uint callId, ushort contextId)
|
||||
{
|
||||
const string secondaryAddress = "";
|
||||
byte[] secondary = System.Text.Encoding.ASCII.GetBytes(secondaryAddress + "\0");
|
||||
int secAddrLength = secondary.Length;
|
||||
int secPad = Align(28 + 2 + secAddrLength, 4) - (28 + 2 + secAddrLength);
|
||||
int resultOffset = 28 + 2 + secAddrLength + secPad;
|
||||
int length = resultOffset + 4 + 24;
|
||||
byte[] pdu = new byte[length];
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.BindAck, 0x03, 0x10, (ushort)length, 0, callId).WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(16, 2), 4280);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(18, 2), 4280);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(20, 4), 0x00005353);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(24, 2), (ushort)secAddrLength);
|
||||
secondary.CopyTo(pdu.AsSpan(26));
|
||||
int offset = resultOffset;
|
||||
pdu[offset++] = 1;
|
||||
pdu[offset++] = 0;
|
||||
pdu[offset++] = 0;
|
||||
pdu[offset++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset + 2, 2), 0);
|
||||
offset += 4;
|
||||
DceRpcSyntaxId.Ndr20.Uuid.TryWriteBytes(pdu.AsSpan(offset, 16));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset + 16, 2), DceRpcSyntaxId.Ndr20.VersionMajor);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(offset + 18, 2), DceRpcSyntaxId.Ndr20.VersionMinor);
|
||||
_ = contextId;
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] EncodeResponse(uint callId, ushort contextId, byte[] stubData)
|
||||
{
|
||||
int length = 24 + stubData.Length;
|
||||
byte[] pdu = new byte[length];
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.Response, 0x03, 0x10, (ushort)length, 0, callId).WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(16, 4), (uint)stubData.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(20, 2), contextId);
|
||||
pdu[22] = 0;
|
||||
pdu[23] = 0;
|
||||
stubData.CopyTo(pdu.AsSpan(24));
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static byte[] EncodeFault(uint callId, ushort contextId, uint status)
|
||||
{
|
||||
byte[] pdu = new byte[28];
|
||||
new DceRpcPduHeader(5, 0, DceRpcPacketType.Fault, 0x03, 0x10, (ushort)pdu.Length, 0, callId).WriteTo(pdu);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(16, 4), 0);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(pdu.AsSpan(20, 2), contextId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(pdu.AsSpan(24, 4), status);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
byte[] header = new byte[DceRpcPduHeader.Length];
|
||||
if (!await ReadExactAsync(stream, header, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = DceRpcPduHeader.Parse(header);
|
||||
byte[] pdu = new byte[parsed.FragmentLength];
|
||||
header.CopyTo(pdu, 0);
|
||||
if (!await ReadExactAsync(stream, pdu.AsMemory(DceRpcPduHeader.Length), cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static async Task<bool> ReadExactAsync(NetworkStream stream, Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
int offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer[offset..], cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Record(string message)
|
||||
{
|
||||
lock (_eventsLock)
|
||||
{
|
||||
_events.Add($"{DateTimeOffset.UtcNow:O} {message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
|
||||
private static ulong RandomUInt64()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ComObjRefBuilder
|
||||
{
|
||||
public static byte[] CreateStandardObjRef(
|
||||
Guid iid,
|
||||
uint stdFlags,
|
||||
uint publicRefs,
|
||||
ulong oxid,
|
||||
ulong oid,
|
||||
Guid ipid,
|
||||
IReadOnlyList<string> stringBindings)
|
||||
{
|
||||
ushort securityOffset = (ushort)(stringBindings.Sum(binding => 1 + binding.Length + 1) + 1);
|
||||
var words = new List<ushort>();
|
||||
foreach (string binding in stringBindings)
|
||||
{
|
||||
words.Add(0x0007);
|
||||
foreach (char ch in binding)
|
||||
{
|
||||
words.Add(ch);
|
||||
}
|
||||
|
||||
words.Add(0);
|
||||
}
|
||||
|
||||
words.Add(0);
|
||||
foreach (ushort authenticationService in new ushort[] { 0x0009, 0x001e, 0x0010, 0x000a, 0x0016, 0x001f, 0x000e })
|
||||
{
|
||||
words.Add(authenticationService);
|
||||
words.Add(0xffff);
|
||||
words.Add(0);
|
||||
}
|
||||
|
||||
words.Add(0);
|
||||
|
||||
ushort entries = (ushort)words.Count;
|
||||
byte[] buffer = new byte[68 + entries * 2];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), 0x574F454D);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), 1);
|
||||
iid.TryWriteBytes(buffer.AsSpan(8, 16));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(24, 4), stdFlags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(28, 4), publicRefs);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(32, 8), oxid);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(40, 8), oid);
|
||||
ipid.TryWriteBytes(buffer.AsSpan(48, 16));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(64, 2), entries);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(66, 2), securityOffset);
|
||||
|
||||
int offset = 68;
|
||||
foreach (ushort word in words)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(offset, 2), word);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public enum DceRpcClientAuthentication
|
||||
{
|
||||
ManagedNtlm,
|
||||
WindowsSspiNtlm,
|
||||
}
|
||||
|
||||
public sealed class ManagedNmxService2Client : IDisposable
|
||||
{
|
||||
private readonly object _activatedComObject;
|
||||
private readonly DceRpcTcpClient _serviceClient;
|
||||
private readonly Guid _serviceIpid;
|
||||
private bool _disposed;
|
||||
|
||||
private ManagedNmxService2Client(object activatedComObject, DceRpcTcpClient serviceClient, Guid serviceIpid)
|
||||
{
|
||||
_activatedComObject = activatedComObject;
|
||||
_serviceClient = serviceClient;
|
||||
_serviceIpid = serviceIpid;
|
||||
}
|
||||
|
||||
public string Host { get; private init; } = string.Empty;
|
||||
public int Port { get; private init; }
|
||||
|
||||
public static ManagedNmxService2Client Create(
|
||||
DceRpcClientAuthentication authentication = DceRpcClientAuthentication.ManagedNtlm)
|
||||
{
|
||||
var type = Type.GetTypeFromProgID("NmxSvc.NmxService", throwOnError: true)
|
||||
?? throw new InvalidOperationException("ProgID NmxSvc.NmxService was not resolved.");
|
||||
object instance = Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException("ProgID NmxSvc.NmxService activation returned null.");
|
||||
|
||||
try
|
||||
{
|
||||
var resolved = ResolveService(instance, authentication);
|
||||
var serviceClient = new DceRpcTcpClient(resolved.Host, resolved.Port);
|
||||
serviceClient.Connect();
|
||||
BindWithAuthentication(
|
||||
serviceClient,
|
||||
NmxService2Messages.InterfaceId,
|
||||
authentication,
|
||||
resolved.Host);
|
||||
|
||||
return new ManagedNmxService2Client(instance, serviceClient, resolved.ServiceIpid)
|
||||
{
|
||||
Host = resolved.Host,
|
||||
Port = resolved.Port,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (Marshal.IsComObject(instance))
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(instance);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public int GetPartnerVersion(int galaxyId, int platformId, int engineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeGetPartnerVersionRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
galaxyId,
|
||||
platformId,
|
||||
engineId);
|
||||
var response = _serviceClient.CallBoundObject(_serviceIpid, NmxService2Messages.GetPartnerVersionOpnum, request);
|
||||
var parsed = NmxService2Messages.ParseGetPartnerVersionResponse(response.StubData.Span);
|
||||
ThrowIfFailed(parsed.HResult, nameof(GetPartnerVersion));
|
||||
return parsed.PartnerVersion;
|
||||
}
|
||||
|
||||
public int RegisterEngine2(int localEngineId, string engineName, int version, byte[] callbackObjRef)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeRegisterEngine2Request(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
engineName,
|
||||
version,
|
||||
callbackObjRef);
|
||||
return CallForHResult(NmxService2Messages.RegisterEngine2Opnum, request);
|
||||
}
|
||||
|
||||
public int RegisterEngine2WithoutCallback(int localEngineId, string engineName, int version)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeRegisterEngine2Request(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
engineName,
|
||||
version,
|
||||
callbackObjRef: null);
|
||||
return CallForHResult(NmxService2Messages.RegisterEngine2Opnum, request);
|
||||
}
|
||||
|
||||
public int UnregisterEngine(int localEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeUnregisterEngineRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId);
|
||||
return CallForHResult(NmxService2Messages.UnregisterEngineOpnum, request);
|
||||
}
|
||||
|
||||
public int Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeConnectRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
remoteGalaxyId,
|
||||
remotePlatformId,
|
||||
remoteEngineId);
|
||||
return CallForHResult(NmxService2Messages.ConnectOpnum, request);
|
||||
}
|
||||
|
||||
public int AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeSubscriberEngineRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
subscriberGalaxyId,
|
||||
subscriberPlatformId,
|
||||
subscriberEngineId);
|
||||
return CallForHResult(NmxService2Messages.AddSubscriberEngineOpnum, request);
|
||||
}
|
||||
|
||||
public int RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeSubscriberEngineRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
localEngineId,
|
||||
subscriberGalaxyId,
|
||||
subscriberPlatformId,
|
||||
subscriberEngineId);
|
||||
return CallForHResult(NmxService2Messages.RemoveSubscriberEngineOpnum, request);
|
||||
}
|
||||
|
||||
public int SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] request = NmxService2Messages.EncodeSetHeartbeatSendIntervalRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
ticksPerBeat,
|
||||
maxMissedTicks);
|
||||
return CallForHResult(NmxService2Messages.SetHeartbeatSendIntervalOpnum, request);
|
||||
}
|
||||
|
||||
public int TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, ReadOnlySpan<byte> messageBody)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ValidateTransferDataBody(messageBody, nameof(messageBody));
|
||||
byte[] request = NmxService2Messages.EncodeTransferDataRequest(
|
||||
OrpcThis.Create(Guid.NewGuid()),
|
||||
remoteGalaxyId,
|
||||
remotePlatformId,
|
||||
remoteEngineId,
|
||||
messageBody);
|
||||
return CallForHResult(NmxService2Messages.TransferDataOpnum, request);
|
||||
}
|
||||
|
||||
private static void ValidateTransferDataBody(ReadOnlySpan<byte> messageBody, string parameterName)
|
||||
{
|
||||
if (messageBody.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("TransferData body cannot be empty.", parameterName);
|
||||
}
|
||||
|
||||
NmxTransferEnvelopeTemplate.FromObserved(messageBody);
|
||||
if (messageBody.Length == NmxTransferEnvelopeTemplate.HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("TransferData body must include an inner message after the 46-byte envelope.", parameterName);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] EncodeWriteTransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
var projection = tag.ProjectWriteValue(value);
|
||||
byte[] innerBody = NmxWriteMessage.Encode(
|
||||
tag.ToReferenceHandle((byte)galaxyId),
|
||||
projection.ValueKind,
|
||||
projection.Value,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeWrite2TransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
var projection = tag.ProjectWriteValue(value);
|
||||
byte[] innerBody = NmxWriteMessage.EncodeTimestamped(
|
||||
tag.ToReferenceHandle((byte)galaxyId),
|
||||
projection.ValueKind,
|
||||
projection.Value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken);
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeWriteSecured2TransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
string clientName,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
var projection = tag.ProjectWriteValue(value);
|
||||
byte[] innerBody = NmxSecuredWrite2Message.Encode(
|
||||
tag.ToReferenceHandle((byte)galaxyId),
|
||||
projection.ValueKind,
|
||||
projection.Value,
|
||||
timestamp,
|
||||
clientName,
|
||||
NmxSecuredWrite2Message.ResolveObservedUserToken(currentUserId),
|
||||
NmxSecuredWrite2Message.ResolveObservedUserToken(verifierUserId),
|
||||
writeIndex,
|
||||
clientToken);
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeAdviseSupervisoryTransferBody(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
byte[] innerBody = NmxItemControlMessage.FromReferenceHandle(
|
||||
NmxItemControlCommand.AdviseSupervisory,
|
||||
itemCorrelationId,
|
||||
tag.ToReferenceHandle((byte)galaxyId)).Encode();
|
||||
return NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.ItemControl,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
}
|
||||
|
||||
public int Write(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeWriteTransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
value,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int Write2(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeWrite2TransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int WriteSecured2(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
string clientName,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeWriteSecured2TransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
clientName,
|
||||
currentUserId,
|
||||
verifierUserId,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int AdviseSupervisory(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = EncodeAdviseSupervisoryTransferBody(
|
||||
localEngineId,
|
||||
tag,
|
||||
itemCorrelationId,
|
||||
galaxyId,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int SendObservedPreAdviseMetadata(
|
||||
int localEngineId,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] innerBody = NmxMetadataQueryMessage.EncodeObservedPreAdvise(itemCorrelationId);
|
||||
byte[] transferBody = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Metadata,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
targetPlatformId: 1,
|
||||
targetEngineId: 1,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, 1, 1, transferBody);
|
||||
}
|
||||
|
||||
public int RegisterReference(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata routeTag,
|
||||
NmxReferenceRegistrationMessage message,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] transferBody = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.ItemControl,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
routeTag.PlatformId,
|
||||
routeTag.EngineId,
|
||||
message.Encode(),
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, routeTag.PlatformId, routeTag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public int UnAdvise(
|
||||
int localEngineId,
|
||||
GalaxyTagMetadata tag,
|
||||
Guid itemCorrelationId,
|
||||
int galaxyId = 1,
|
||||
int sourceGalaxyId = 1,
|
||||
int sourcePlatformId = 1)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] innerBody = NmxItemControlMessage.FromReferenceHandle(
|
||||
NmxItemControlCommand.UnAdvise,
|
||||
itemCorrelationId,
|
||||
tag.ToReferenceHandle((byte)galaxyId)).Encode();
|
||||
byte[] transferBody = NmxTransferEnvelope.Encode(
|
||||
NmxTransferMessageKind.Write,
|
||||
localEngineId,
|
||||
galaxyId,
|
||||
tag.PlatformId,
|
||||
tag.EngineId,
|
||||
innerBody,
|
||||
sourceGalaxyId,
|
||||
sourcePlatformId);
|
||||
return TransferData(galaxyId, tag.PlatformId, tag.EngineId, transferBody);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_serviceClient.Dispose();
|
||||
if (Marshal.IsComObject(_activatedComObject))
|
||||
{
|
||||
_ = Marshal.ReleaseComObject(_activatedComObject);
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private int CallForHResult(ushort opnum, byte[] request)
|
||||
{
|
||||
var response = _serviceClient.CallBoundObject(_serviceIpid, opnum, request);
|
||||
var parsed = NmxService2Messages.ParseHResultResponse(response.StubData.Span);
|
||||
return parsed.HResult;
|
||||
}
|
||||
|
||||
private static (string Host, int Port, Guid ServiceIpid) ResolveService(
|
||||
object instance,
|
||||
DceRpcClientAuthentication authentication)
|
||||
{
|
||||
byte[] buffer = ComObjRefProvider.MarshalIUnknownObjRef(
|
||||
instance,
|
||||
ComObjRefProvider.MarshalContextDifferentMachine);
|
||||
var objRef = ComObjRef.Parse(buffer);
|
||||
var exporter = new ObjectExporterClient();
|
||||
var oxidResponse = authentication == DceRpcClientAuthentication.ManagedNtlm
|
||||
? exporter.ResolveOxidWithManagedNtlmPacketIntegrity(objRef.Oxid)
|
||||
: exporter.ResolveOxidWithNtlmPacketIntegrity(objRef.Oxid);
|
||||
var resolved = ObjectExporterMessages.ParseResolveOxidResult(oxidResponse.StubData.Span);
|
||||
var endpoint = resolved.Bindings.First(binding => binding.TowerId == ObjectExporterMessages.ProtseqNcacnIpTcp);
|
||||
string host = ParseBracketedHost(endpoint.Value);
|
||||
int port = ParseBracketedPort(endpoint.Value);
|
||||
|
||||
using var client = new DceRpcTcpClient(host, port);
|
||||
client.Connect();
|
||||
BindWithAuthentication(client, RemUnknownMessages.IRemUnknown, authentication, host);
|
||||
byte[] request = RemUnknownMessages.EncodeRemQueryInterfaceRequest(
|
||||
objRef.Ipid,
|
||||
NmxProcedureMetadata.INmxService2,
|
||||
Guid.NewGuid());
|
||||
var response = client.CallBoundObject(resolved.RemUnknownIpid, RemUnknownMessages.RemQueryInterfaceOpnum, request);
|
||||
var parsed = RemUnknownMessages.ParseRemQueryInterfaceResponse(response.StubData.Span);
|
||||
if (parsed.Result is null || parsed.Result.HResult != 0 || parsed.ErrorCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"RemQueryInterface failed: hresult=0x{parsed.Result?.HResult ?? -1:X8}, error=0x{parsed.ErrorCode:X8}.");
|
||||
}
|
||||
|
||||
return (host, port, parsed.Result.StandardObjectReference.Ipid);
|
||||
}
|
||||
|
||||
private static void BindWithAuthentication(
|
||||
DceRpcTcpClient client,
|
||||
Guid interfaceId,
|
||||
DceRpcClientAuthentication authentication,
|
||||
string targetName)
|
||||
{
|
||||
if (authentication == DceRpcClientAuthentication.ManagedNtlm)
|
||||
{
|
||||
client.BindWithManagedNtlmPacketIntegrity(interfaceId, versionMajor: 0, versionMinor: 0);
|
||||
return;
|
||||
}
|
||||
|
||||
client.BindWithNtlmPacketIntegrity(interfaceId, versionMajor: 0, versionMinor: 0, targetName);
|
||||
}
|
||||
|
||||
private static string ParseBracketedHost(string binding)
|
||||
{
|
||||
int open = binding.LastIndexOf('[');
|
||||
if (open <= 0)
|
||||
{
|
||||
throw new FormatException($"Binding does not contain a bracketed host: {binding}");
|
||||
}
|
||||
|
||||
return binding[..open];
|
||||
}
|
||||
|
||||
private static int ParseBracketedPort(string binding)
|
||||
{
|
||||
int open = binding.LastIndexOf('[');
|
||||
int close = binding.LastIndexOf(']');
|
||||
if (open < 0 || close <= open)
|
||||
{
|
||||
throw new FormatException($"Binding does not contain a bracketed port: {binding}");
|
||||
}
|
||||
|
||||
return int.Parse(binding.AsSpan(open + 1, close - open - 1), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void ThrowIfFailed(int hresult, string operation)
|
||||
{
|
||||
if (hresult < 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hresult);
|
||||
}
|
||||
|
||||
if (hresult != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{operation} returned application status 0x{hresult:X8}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class ManagedNtlmClientContext
|
||||
{
|
||||
private const uint NegotiateUnicode = 0x00000001;
|
||||
private const uint RequestTarget = 0x00000004;
|
||||
private const uint NegotiateSign = 0x00000010;
|
||||
private const uint NegotiateSeal = 0x00000020;
|
||||
private const uint NegotiateNtlm = 0x00000200;
|
||||
private const uint NegotiateAlwaysSign = 0x00008000;
|
||||
private const uint NegotiateExtendedSessionSecurity = 0x00080000;
|
||||
private const uint NegotiateTargetInfo = 0x00800000;
|
||||
private const uint NegotiateVersion = 0x02000000;
|
||||
private const uint Negotiate128 = 0x20000000;
|
||||
private const uint NegotiateKeyExchange = 0x40000000;
|
||||
private const uint Negotiate56 = 0x80000000;
|
||||
|
||||
private readonly string _user;
|
||||
private readonly string _password;
|
||||
private readonly string _domain;
|
||||
private readonly string _workstation;
|
||||
private uint _flags;
|
||||
private byte[] _exportedSessionKey = [];
|
||||
private byte[] _clientSigningKey = [];
|
||||
private Rc4? _clientSealingHandle;
|
||||
private uint _sequence;
|
||||
|
||||
public ManagedNtlmClientContext(string user, string password, string domain, string? workstation = null)
|
||||
{
|
||||
_user = user;
|
||||
_password = password;
|
||||
_domain = domain;
|
||||
_workstation = string.IsNullOrWhiteSpace(workstation) ? Environment.MachineName : workstation;
|
||||
}
|
||||
|
||||
public static ManagedNtlmClientContext FromEnvironment()
|
||||
{
|
||||
string user = Environment.GetEnvironmentVariable("MX_RPC_USER")
|
||||
?? throw new InvalidOperationException("MX_RPC_USER is required for managed NTLM.");
|
||||
string password = Environment.GetEnvironmentVariable("MX_RPC_PASSWORD")
|
||||
?? throw new InvalidOperationException("MX_RPC_PASSWORD is required for managed NTLM.");
|
||||
string domain = Environment.GetEnvironmentVariable("MX_RPC_DOMAIN") ?? string.Empty;
|
||||
return new ManagedNtlmClientContext(user, password, domain);
|
||||
}
|
||||
|
||||
public byte[] CreateType1()
|
||||
{
|
||||
_flags = NegotiateKeyExchange
|
||||
| NegotiateSign
|
||||
| NegotiateAlwaysSign
|
||||
| NegotiateSeal
|
||||
| NegotiateTargetInfo
|
||||
| NegotiateNtlm
|
||||
| NegotiateExtendedSessionSecurity
|
||||
| NegotiateUnicode
|
||||
| RequestTarget
|
||||
| Negotiate128
|
||||
| Negotiate56;
|
||||
|
||||
byte[] message = new byte[32];
|
||||
Encoding.ASCII.GetBytes("NTLMSSP\0").CopyTo(message, 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(8, 4), 1);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(12, 4), _flags);
|
||||
return message;
|
||||
}
|
||||
|
||||
public byte[] CreateType3(ReadOnlySpan<byte> type2)
|
||||
{
|
||||
var challenge = NtlmChallenge.Parse(type2);
|
||||
_flags &= challenge.Flags;
|
||||
|
||||
byte[] clientChallenge = RandomNumberGenerator.GetBytes(8);
|
||||
byte[] targetInfo = BuildTargetInfo(challenge.TargetInfo);
|
||||
byte[] responseKeyNt = HmacMd5(NtHash(_password), Encoding.Unicode.GetBytes(_user.ToUpperInvariant() + _domain));
|
||||
|
||||
byte[] temp = BuildNtlmV2Temp(clientChallenge, targetInfo);
|
||||
byte[] ntProof = HmacMd5(responseKeyNt, Combine(challenge.ServerChallenge, temp));
|
||||
byte[] ntResponse = Combine(ntProof, temp);
|
||||
byte[] lmResponse = Combine(HmacMd5(responseKeyNt, Combine(challenge.ServerChallenge, clientChallenge)), clientChallenge);
|
||||
byte[] sessionBaseKey = HmacMd5(responseKeyNt, ntProof);
|
||||
|
||||
_exportedSessionKey = RandomNumberGenerator.GetBytes(16);
|
||||
byte[] encryptedSessionKey = new Rc4(sessionBaseKey).Transform(_exportedSessionKey);
|
||||
_clientSigningKey = SignKey(_exportedSessionKey, clientMode: true);
|
||||
_clientSealingHandle = new Rc4(SealKey(_exportedSessionKey, clientMode: true));
|
||||
_sequence = 0;
|
||||
|
||||
byte[] domain = Encoding.Unicode.GetBytes(_domain);
|
||||
byte[] user = Encoding.Unicode.GetBytes(_user);
|
||||
byte[] workstation = Encoding.Unicode.GetBytes(_workstation);
|
||||
|
||||
const int headerLength = 64;
|
||||
int payloadLength = lmResponse.Length + ntResponse.Length + domain.Length + user.Length + workstation.Length + encryptedSessionKey.Length;
|
||||
byte[] message = new byte[headerLength + payloadLength];
|
||||
Encoding.ASCII.GetBytes("NTLMSSP\0").CopyTo(message, 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(8, 4), 3);
|
||||
|
||||
int offset = headerLength;
|
||||
WriteSecurityBuffer(message, 12, lmResponse, ref offset);
|
||||
WriteSecurityBuffer(message, 20, ntResponse, ref offset);
|
||||
WriteSecurityBuffer(message, 28, domain, ref offset);
|
||||
WriteSecurityBuffer(message, 36, user, ref offset);
|
||||
WriteSecurityBuffer(message, 44, workstation, ref offset);
|
||||
WriteSecurityBuffer(message, 52, encryptedSessionKey, ref offset);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(60, 4), _flags);
|
||||
return message;
|
||||
}
|
||||
|
||||
public byte[] Sign(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (_clientSealingHandle is null || _clientSigningKey.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("NTLM context has not completed Type3 negotiation.");
|
||||
}
|
||||
|
||||
byte[] sequenceBytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sequenceBytes, _sequence);
|
||||
byte[] digest = HmacMd5(_clientSigningKey, Combine(sequenceBytes, message.ToArray()));
|
||||
byte[] checksum = _clientSealingHandle.Transform(digest[..8]);
|
||||
|
||||
byte[] signature = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(signature.AsSpan(0, 4), 1);
|
||||
checksum.CopyTo(signature.AsSpan(4, 8));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(signature.AsSpan(12, 4), _sequence);
|
||||
_sequence++;
|
||||
return signature;
|
||||
}
|
||||
|
||||
private static byte[] BuildNtlmV2Temp(byte[] clientChallenge, byte[] targetInfo)
|
||||
{
|
||||
byte[] temp = new byte[28 + targetInfo.Length];
|
||||
temp[0] = 1;
|
||||
temp[1] = 1;
|
||||
BinaryPrimitives.WriteInt64LittleEndian(temp.AsSpan(8, 8), DateTimeOffset.UtcNow.ToFileTime());
|
||||
clientChallenge.CopyTo(temp.AsSpan(16, 8));
|
||||
targetInfo.CopyTo(temp.AsSpan(28));
|
||||
return temp;
|
||||
}
|
||||
|
||||
private static byte[] BuildTargetInfo(ReadOnlySpan<byte> original)
|
||||
{
|
||||
var pairs = AvPair.ParseAll(original);
|
||||
byte[]? dnsHost = pairs.FirstOrDefault(static pair => pair.Id == 3)?.Value;
|
||||
if (dnsHost is not null)
|
||||
{
|
||||
byte[] prefix = Encoding.Unicode.GetBytes("cifs/");
|
||||
pairs.RemoveAll(static pair => pair.Id == 9);
|
||||
pairs.Add(new AvPair(9, Combine(prefix, dnsHost)));
|
||||
}
|
||||
|
||||
if (!pairs.Any(static pair => pair.Id == 7))
|
||||
{
|
||||
byte[] timestamp = new byte[8];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(timestamp, DateTimeOffset.UtcNow.ToFileTime());
|
||||
pairs.Add(new AvPair(7, timestamp));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Span<byte> header = stackalloc byte[4];
|
||||
foreach (var pair in pairs.Where(static pair => pair.Id != 0))
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header[..2], pair.Id);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(header[2..], (ushort)pair.Value.Length);
|
||||
stream.Write(header);
|
||||
stream.Write(pair.Value);
|
||||
}
|
||||
|
||||
stream.Write(new byte[4]);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] SignKey(byte[] sessionKey, bool clientMode)
|
||||
{
|
||||
string magic = clientMode
|
||||
? "session key to client-to-server signing key magic constant\0"
|
||||
: "session key to server-to-client signing key magic constant\0";
|
||||
return MD5.HashData(Combine(sessionKey, Encoding.ASCII.GetBytes(magic)));
|
||||
}
|
||||
|
||||
private static byte[] SealKey(byte[] sessionKey, bool clientMode)
|
||||
{
|
||||
string magic = clientMode
|
||||
? "session key to client-to-server sealing key magic constant\0"
|
||||
: "session key to server-to-client sealing key magic constant\0";
|
||||
return MD5.HashData(Combine(sessionKey, Encoding.ASCII.GetBytes(magic)));
|
||||
}
|
||||
|
||||
private static byte[] NtHash(string password)
|
||||
{
|
||||
return Md4.Hash(Encoding.Unicode.GetBytes(password));
|
||||
}
|
||||
|
||||
private static byte[] HmacMd5(byte[] key, byte[] data)
|
||||
{
|
||||
using var hmac = new HMACMD5(key);
|
||||
return hmac.ComputeHash(data);
|
||||
}
|
||||
|
||||
private static byte[] Combine(params byte[][] parts)
|
||||
{
|
||||
byte[] combined = new byte[parts.Sum(static part => part.Length)];
|
||||
int offset = 0;
|
||||
foreach (byte[] part in parts)
|
||||
{
|
||||
part.CopyTo(combined.AsSpan(offset));
|
||||
offset += part.Length;
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
private static void WriteSecurityBuffer(byte[] message, int descriptorOffset, byte[] value, ref int payloadOffset)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(message.AsSpan(descriptorOffset, 2), (ushort)value.Length);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(message.AsSpan(descriptorOffset + 2, 2), (ushort)value.Length);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(message.AsSpan(descriptorOffset + 4, 4), (uint)payloadOffset);
|
||||
value.CopyTo(message.AsSpan(payloadOffset));
|
||||
payloadOffset += value.Length;
|
||||
}
|
||||
|
||||
private sealed record NtlmChallenge(uint Flags, byte[] ServerChallenge, byte[] TargetInfo)
|
||||
{
|
||||
public static NtlmChallenge Parse(ReadOnlySpan<byte> message)
|
||||
{
|
||||
if (message.Length < 48 || !message[..8].SequenceEqual(Encoding.ASCII.GetBytes("NTLMSSP\0")))
|
||||
{
|
||||
throw new ArgumentException("NTLM challenge is truncated or invalid.", nameof(message));
|
||||
}
|
||||
|
||||
int targetInfoLength = BinaryPrimitives.ReadUInt16LittleEndian(message.Slice(40, 2));
|
||||
int targetInfoOffset = (int)BinaryPrimitives.ReadUInt32LittleEndian(message.Slice(44, 4));
|
||||
if (targetInfoOffset < 0 || targetInfoOffset + targetInfoLength > message.Length)
|
||||
{
|
||||
throw new ArgumentException("NTLM challenge target-info buffer is invalid.", nameof(message));
|
||||
}
|
||||
|
||||
return new NtlmChallenge(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(message.Slice(20, 4)),
|
||||
message.Slice(24, 8).ToArray(),
|
||||
message.Slice(targetInfoOffset, targetInfoLength).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AvPair(ushort Id, byte[] Value)
|
||||
{
|
||||
public static List<AvPair> ParseAll(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
var pairs = new List<AvPair>();
|
||||
int offset = 0;
|
||||
while (offset + 4 <= buffer.Length)
|
||||
{
|
||||
ushort id = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset, 2));
|
||||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(buffer.Slice(offset + 2, 2));
|
||||
offset += 4;
|
||||
if (id == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (offset + length > buffer.Length)
|
||||
{
|
||||
throw new ArgumentException("NTLM AV pair buffer is truncated.", nameof(buffer));
|
||||
}
|
||||
|
||||
pairs.Add(new AvPair(id, buffer.Slice(offset, length).ToArray()));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Rc4
|
||||
{
|
||||
private readonly byte[] _s = new byte[256];
|
||||
private int _i;
|
||||
private int _j;
|
||||
|
||||
public Rc4(byte[] key)
|
||||
{
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
_s[i] = (byte)i;
|
||||
}
|
||||
|
||||
int j = 0;
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
j = (j + _s[i] + key[i % key.Length]) & 0xff;
|
||||
(_s[i], _s[j]) = (_s[j], _s[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] Transform(ReadOnlySpan<byte> input)
|
||||
{
|
||||
byte[] output = new byte[input.Length];
|
||||
for (int k = 0; k < input.Length; k++)
|
||||
{
|
||||
_i = (_i + 1) & 0xff;
|
||||
_j = (_j + _s[_i]) & 0xff;
|
||||
(_s[_i], _s[_j]) = (_s[_j], _s[_i]);
|
||||
byte keyByte = _s[(_s[_i] + _s[_j]) & 0xff];
|
||||
output[k] = (byte)(input[k] ^ keyByte);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Md4
|
||||
{
|
||||
public static byte[] Hash(byte[] input)
|
||||
{
|
||||
uint a = 0x67452301;
|
||||
uint b = 0xefcdab89;
|
||||
uint c = 0x98badcfe;
|
||||
uint d = 0x10325476;
|
||||
|
||||
byte[] padded = Pad(input);
|
||||
Span<uint> x = stackalloc uint[16];
|
||||
for (int offset = 0; offset < padded.Length; offset += 64)
|
||||
{
|
||||
uint aa = a;
|
||||
uint bb = b;
|
||||
uint cc = c;
|
||||
uint dd = d;
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
x[i] = BinaryPrimitives.ReadUInt32LittleEndian(padded.AsSpan(offset + i * 4, 4));
|
||||
}
|
||||
|
||||
Round1(ref a, b, c, d, x[0], 3); Round1(ref d, a, b, c, x[1], 7); Round1(ref c, d, a, b, x[2], 11); Round1(ref b, c, d, a, x[3], 19);
|
||||
Round1(ref a, b, c, d, x[4], 3); Round1(ref d, a, b, c, x[5], 7); Round1(ref c, d, a, b, x[6], 11); Round1(ref b, c, d, a, x[7], 19);
|
||||
Round1(ref a, b, c, d, x[8], 3); Round1(ref d, a, b, c, x[9], 7); Round1(ref c, d, a, b, x[10], 11); Round1(ref b, c, d, a, x[11], 19);
|
||||
Round1(ref a, b, c, d, x[12], 3); Round1(ref d, a, b, c, x[13], 7); Round1(ref c, d, a, b, x[14], 11); Round1(ref b, c, d, a, x[15], 19);
|
||||
|
||||
Round2(ref a, b, c, d, x[0], 3); Round2(ref d, a, b, c, x[4], 5); Round2(ref c, d, a, b, x[8], 9); Round2(ref b, c, d, a, x[12], 13);
|
||||
Round2(ref a, b, c, d, x[1], 3); Round2(ref d, a, b, c, x[5], 5); Round2(ref c, d, a, b, x[9], 9); Round2(ref b, c, d, a, x[13], 13);
|
||||
Round2(ref a, b, c, d, x[2], 3); Round2(ref d, a, b, c, x[6], 5); Round2(ref c, d, a, b, x[10], 9); Round2(ref b, c, d, a, x[14], 13);
|
||||
Round2(ref a, b, c, d, x[3], 3); Round2(ref d, a, b, c, x[7], 5); Round2(ref c, d, a, b, x[11], 9); Round2(ref b, c, d, a, x[15], 13);
|
||||
|
||||
Round3(ref a, b, c, d, x[0], 3); Round3(ref d, a, b, c, x[8], 9); Round3(ref c, d, a, b, x[4], 11); Round3(ref b, c, d, a, x[12], 15);
|
||||
Round3(ref a, b, c, d, x[2], 3); Round3(ref d, a, b, c, x[10], 9); Round3(ref c, d, a, b, x[6], 11); Round3(ref b, c, d, a, x[14], 15);
|
||||
Round3(ref a, b, c, d, x[1], 3); Round3(ref d, a, b, c, x[9], 9); Round3(ref c, d, a, b, x[5], 11); Round3(ref b, c, d, a, x[13], 15);
|
||||
Round3(ref a, b, c, d, x[3], 3); Round3(ref d, a, b, c, x[11], 9); Round3(ref c, d, a, b, x[7], 11); Round3(ref b, c, d, a, x[15], 15);
|
||||
|
||||
a += aa;
|
||||
b += bb;
|
||||
c += cc;
|
||||
d += dd;
|
||||
}
|
||||
|
||||
byte[] hash = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(0, 4), a);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(4, 4), b);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(8, 4), c);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hash.AsSpan(12, 4), d);
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static byte[] Pad(byte[] input)
|
||||
{
|
||||
ulong bitLength = (ulong)input.Length * 8;
|
||||
int paddedLength = input.Length + 1;
|
||||
while (paddedLength % 64 != 56)
|
||||
{
|
||||
paddedLength++;
|
||||
}
|
||||
|
||||
byte[] padded = new byte[paddedLength + 8];
|
||||
input.CopyTo(padded.AsSpan());
|
||||
padded[input.Length] = 0x80;
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(padded.AsSpan(paddedLength, 8), bitLength);
|
||||
return padded;
|
||||
}
|
||||
|
||||
private static uint F(uint x, uint y, uint z) => (x & y) | (~x & z);
|
||||
private static uint G(uint x, uint y, uint z) => (x & y) | (x & z) | (y & z);
|
||||
private static uint H(uint x, uint y, uint z) => x ^ y ^ z;
|
||||
private static void Round1(ref uint a, uint b, uint c, uint d, uint x, int s) => a = BitOperations.RotateLeft(a + F(b, c, d) + x, s);
|
||||
private static void Round2(ref uint a, uint b, uint c, uint d, uint x, int s) => a = BitOperations.RotateLeft(a + G(b, c, d) + x + 0x5a827999, s);
|
||||
private static void Round3(ref uint a, uint b, uint c, uint d, uint x, int s) => a = BitOperations.RotateLeft(a + H(b, c, d) + x + 0x6ed9eba1, s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxNativeCodec\MxNativeCodec.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,910 @@
|
||||
using System.Globalization;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record MxNativeDataChangeEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
object? Value,
|
||||
ushort Quality,
|
||||
DateTime TimestampUtc,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeWriteCompleteEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeOperationCompleteEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeBufferedDataChangeEvent(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
short MxDataType,
|
||||
IReadOnlyList<object?> Values,
|
||||
IReadOnlyList<ushort> Qualities,
|
||||
IReadOnlyList<DateTime> TimestampsUtc,
|
||||
IReadOnlyList<MxStatus> Statuses,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeCompatibilityRecoveryAttemptEvent(
|
||||
int ServerHandle,
|
||||
int Attempt,
|
||||
int MaxAttempts);
|
||||
|
||||
public sealed record MxNativeCompatibilityRecoveryFailureEvent(
|
||||
int ServerHandle,
|
||||
int Attempt,
|
||||
int MaxAttempts,
|
||||
Exception Exception,
|
||||
bool WillRetry);
|
||||
|
||||
public sealed record MxNativeCompatibilityRecoveryCompletedEvent(
|
||||
int ServerHandle,
|
||||
int Attempt,
|
||||
int MaxAttempts);
|
||||
|
||||
public sealed class MxNativeCompatibilityServer : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<int, MxNativeSession> _sessions = [];
|
||||
private readonly Dictionary<int, CompatibilityItem> _items = [];
|
||||
private readonly Dictionary<int, int> _bufferedUpdateIntervals = [];
|
||||
private readonly Dictionary<int, int> _nextUserHandles = [];
|
||||
private readonly Dictionary<int, Queue<int>> _pendingWriteItems = [];
|
||||
private int _nextServerHandle = 1;
|
||||
private int _nextItemHandle = 1;
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<MxNativeDataChangeEvent>? DataChanged;
|
||||
public event EventHandler<MxNativeBufferedDataChangeEvent>? BufferedDataChanged;
|
||||
public event EventHandler<MxNativeWriteCompleteEvent>? WriteCompleted;
|
||||
#pragma warning disable CS0067 // OperationComplete has the MXAccess event shape, but no firing path is modeled until captures define its trigger.
|
||||
public event EventHandler<MxNativeOperationCompleteEvent>? OperationCompleted;
|
||||
#pragma warning restore CS0067
|
||||
public event EventHandler<MxNativeCompatibilityRecoveryAttemptEvent>? RecoveryAttemptStarted;
|
||||
public event EventHandler<MxNativeCompatibilityRecoveryFailureEvent>? RecoveryAttemptFailed;
|
||||
public event EventHandler<MxNativeCompatibilityRecoveryCompletedEvent>? RecoveryCompleted;
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var session = MxNativeSession.Open(new MxNativeClientOptions
|
||||
{
|
||||
EngineName = string.IsNullOrWhiteSpace(clientName) ? $"MxNativeClient.{Environment.ProcessId}" : clientName,
|
||||
});
|
||||
|
||||
int serverHandle;
|
||||
lock (_gate)
|
||||
{
|
||||
serverHandle = _nextServerHandle++;
|
||||
_sessions.Add(serverHandle, session);
|
||||
_nextUserHandles.Add(serverHandle, 1);
|
||||
_pendingWriteItems.Add(serverHandle, new Queue<int>());
|
||||
}
|
||||
|
||||
session.CallbackReceived += (_, evt) => OnCallbackReceived(serverHandle, evt);
|
||||
session.OperationStatusReceived += (_, evt) => OnOperationStatusReceived(serverHandle, evt);
|
||||
session.RecoveryAttemptStarted += (_, evt) => RecoveryAttemptStarted?.Invoke(
|
||||
this,
|
||||
new MxNativeCompatibilityRecoveryAttemptEvent(serverHandle, evt.Attempt, evt.MaxAttempts));
|
||||
session.RecoveryAttemptFailed += (_, evt) => RecoveryAttemptFailed?.Invoke(
|
||||
this,
|
||||
new MxNativeCompatibilityRecoveryFailureEvent(
|
||||
serverHandle,
|
||||
evt.Attempt,
|
||||
evt.MaxAttempts,
|
||||
evt.Exception,
|
||||
evt.WillRetry));
|
||||
session.RecoveryCompleted += (_, evt) => RecoveryCompleted?.Invoke(
|
||||
this,
|
||||
new MxNativeCompatibilityRecoveryCompletedEvent(serverHandle, evt.Attempt, evt.MaxAttempts));
|
||||
return serverHandle;
|
||||
}
|
||||
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
foreach (int itemHandle in _items
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
_items.Remove(itemHandle);
|
||||
}
|
||||
|
||||
_sessions.Remove(serverHandle);
|
||||
_bufferedUpdateIntervals.Remove(serverHandle);
|
||||
_nextUserHandles.Remove(serverHandle);
|
||||
_pendingWriteItems.Remove(serverHandle);
|
||||
}
|
||||
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
public void RecoverConnection(int serverHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
session.RecoverConnection();
|
||||
}
|
||||
|
||||
public async Task RecoverConnectionAsync(
|
||||
int serverHandle,
|
||||
MxNativeRecoveryPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
await session.RecoverConnectionAsync(policy, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public int AddItem(int serverHandle, string itemDefinition)
|
||||
{
|
||||
return AddItemAsync(serverHandle, itemDefinition).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<int> AddItemAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddItemCoreAsync(serverHandle, itemDefinition, itemContext: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<int> AddItemCoreAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string? itemContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
GalaxyTagMetadata? metadata;
|
||||
string resolvedReference;
|
||||
try
|
||||
{
|
||||
(metadata, resolvedReference) = await ResolveWithOptionalContextAsync(
|
||||
session,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (IsMissingReferenceResolution(ex))
|
||||
{
|
||||
metadata = null;
|
||||
resolvedReference = string.IsNullOrWhiteSpace(itemContext)
|
||||
? itemDefinition
|
||||
: CombineItemContext(itemDefinition, itemContext);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
int itemHandle = _nextItemHandle++;
|
||||
_items.Add(
|
||||
itemHandle,
|
||||
metadata is null
|
||||
? new CompatibilityItem(
|
||||
serverHandle,
|
||||
resolvedReference,
|
||||
metadata: null,
|
||||
isInvalidReference: true)
|
||||
: new CompatibilityItem(serverHandle, resolvedReference, metadata));
|
||||
return itemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
|
||||
{
|
||||
return AddItemCoreAsync(serverHandle, itemDefinition, itemContext, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void RemoveItem(int serverHandle, int itemHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
_items.Remove(itemHandle);
|
||||
}
|
||||
|
||||
if (item.Subscription is not null)
|
||||
{
|
||||
session.Unsubscribe(item.Subscription.CorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle)
|
||||
{
|
||||
AdviseAsync(serverHandle, itemHandle).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public Task AdviseSupervisoryAsync(int serverHandle, int itemHandle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return AdviseAsync(serverHandle, itemHandle, cancellationToken);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(int serverHandle, int itemHandle)
|
||||
{
|
||||
Advise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public async Task AdviseAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
bool fireInvalidReference = false;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.Subscription is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
item.IsAdvisedInvalidReference = true;
|
||||
fireInvalidReference = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fireInvalidReference)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => FireInvalidReferenceDataChange(serverHandle, itemHandle));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Metadata is null)
|
||||
{
|
||||
throw new InvalidOperationException("Valid compatibility items must carry Galaxy metadata.");
|
||||
}
|
||||
|
||||
MxNativeSubscription subscription = item.IsBuffered
|
||||
? await session.RegisterBufferedItemAsync(
|
||||
item.ItemDefinition,
|
||||
item.ItemContext,
|
||||
itemHandle,
|
||||
cancellationToken).ConfigureAwait(false)
|
||||
: await session.SubscribeAsync(item.TagReference, cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(itemHandle, out CompatibilityItem? current))
|
||||
{
|
||||
current.Subscription = subscription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UnAdvise(int serverHandle, int itemHandle)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
item.IsAdvisedInvalidReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.Unsubscribe(item.Subscription.CorrelationId);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(itemHandle, out CompatibilityItem? current))
|
||||
{
|
||||
current.Subscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(int serverHandle, int itemHandle, object value, int userId = 0)
|
||||
{
|
||||
WriteAsync(serverHandle, itemHandle, value).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task WriteAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsBuffered)
|
||||
{
|
||||
throw new ArgumentException("Normal Write is not valid for buffered item handles.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
throw new ArgumentException("Write requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Metadata?.IsBufferProperty == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GetPendingWriteItemsLocked(serverHandle).Enqueue(itemHandle);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.WriteAsync(item.TagReference, value, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RemovePendingWriteItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void Write2(int serverHandle, int itemHandle, object value, DateTime timestamp, int userId = 0)
|
||||
{
|
||||
Write2Async(serverHandle, itemHandle, value, timestamp).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void Write2(int serverHandle, int itemHandle, object value, object timestamp, int userId = 0)
|
||||
{
|
||||
Write2(serverHandle, itemHandle, value, CoerceWriteTimestamp(timestamp), userId);
|
||||
}
|
||||
|
||||
public async Task Write2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.IsInvalidReference)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsBuffered)
|
||||
{
|
||||
throw new ArgumentException("Normal Write2 is not valid for buffered item handles.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
throw new ArgumentException("Write2 requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
if (item.Metadata?.IsBufferProperty == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GetPendingWriteItemsLocked(serverHandle).Enqueue(itemHandle);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.Write2Async(item.TagReference, value, timestamp, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
RemovePendingWriteItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task Write2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
object timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Write2Async(serverHandle, itemHandle, value, CoerceWriteTimestamp(timestamp), cancellationToken);
|
||||
}
|
||||
|
||||
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value)
|
||||
{
|
||||
throw new NotSupportedException("Dedicated WriteSecured parity is not implemented because current MXAccess captures do not emit a successful NMX secured-write body.");
|
||||
}
|
||||
|
||||
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, DateTime timestamp)
|
||||
{
|
||||
WriteSecured2Async(serverHandle, itemHandle, value, timestamp, currentUserId, verifierUserId).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, object timestamp)
|
||||
{
|
||||
WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, CoerceWriteTimestamp(timestamp));
|
||||
}
|
||||
|
||||
public async Task WriteSecured2Async(int serverHandle, int itemHandle, object value, DateTime timestamp, int currentUserId, int verifierUserId = 0)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
CompatibilityItem item;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
item = GetItemLocked(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
await session.WriteSecured2Async(
|
||||
item.TagReference,
|
||||
value,
|
||||
timestamp,
|
||||
currentUserId,
|
||||
verifierUserId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object value,
|
||||
object timestamp,
|
||||
int currentUserId,
|
||||
int verifierUserId = 0)
|
||||
{
|
||||
return WriteSecured2Async(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
value,
|
||||
CoerceWriteTimestamp(timestamp),
|
||||
currentUserId,
|
||||
verifierUserId);
|
||||
}
|
||||
|
||||
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(verifyUser);
|
||||
_ = verifyUserPassword;
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
return AllocateUserHandleLocked(serverHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public int ArchestrAUserToId(int serverHandle, string userIdGuid)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(userIdGuid, out Guid parsedGuid))
|
||||
{
|
||||
throw new ArgumentException("ArchestrA user ID must be a GUID string.", nameof(userIdGuid));
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return AllocateUserHandleLocked(serverHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public void Suspend(int serverHandle, int itemHandle, out MxStatus status)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
CompatibilityItem item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
status = new MxStatus(0, MxStatusCategory.Unknown, MxStatusSource.Unknown, 0);
|
||||
throw new ArgumentException("Suspend requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
status = MxStatus.SuspendPending;
|
||||
}
|
||||
|
||||
public void Activate(int serverHandle, int itemHandle, out MxStatus status)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
CompatibilityItem item = GetItemLocked(serverHandle, itemHandle);
|
||||
if (item.Subscription is null)
|
||||
{
|
||||
status = new MxStatus(0, MxStatusCategory.Unknown, MxStatusSource.Unknown, 0);
|
||||
throw new ArgumentException("Activate requires an advised item handle.", nameof(itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
status = MxStatus.ActivateOk;
|
||||
}
|
||||
|
||||
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext)
|
||||
{
|
||||
return AddBufferedItemAsync(serverHandle, itemDefinition, itemContext).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task<int> AddBufferedItemAsync(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
MxNativeSession session;
|
||||
lock (_gate)
|
||||
{
|
||||
session = GetSessionLocked(serverHandle);
|
||||
}
|
||||
|
||||
(GalaxyTagMetadata metadata, string routeReference) = await ResolveWithOptionalContextAsync(
|
||||
session,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
lock (_gate)
|
||||
{
|
||||
int itemHandle = _nextItemHandle++;
|
||||
_items.Add(
|
||||
itemHandle,
|
||||
new CompatibilityItem(
|
||||
serverHandle,
|
||||
routeReference,
|
||||
metadata,
|
||||
isBuffered: true,
|
||||
itemDefinition: itemDefinition,
|
||||
itemContext: itemContext));
|
||||
return itemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetBufferedUpdateInterval(int serverHandle, int updateInterval)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (updateInterval <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(updateInterval), updateInterval, "Buffered update interval must be positive.");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_ = GetSessionLocked(serverHandle);
|
||||
_bufferedUpdateIntervals[serverHandle] = ((updateInterval + 99) / 100) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MxNativeSession[] sessions;
|
||||
lock (_gate)
|
||||
{
|
||||
sessions = _sessions.Values.ToArray();
|
||||
_sessions.Clear();
|
||||
_items.Clear();
|
||||
_bufferedUpdateIntervals.Clear();
|
||||
_nextUserHandles.Clear();
|
||||
_pendingWriteItems.Clear();
|
||||
}
|
||||
|
||||
foreach (MxNativeSession session in sessions)
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void OnCallbackReceived(int serverHandle, MxNativeCallbackEvent evt)
|
||||
{
|
||||
int itemHandle;
|
||||
CompatibilityItem? item;
|
||||
lock (_gate)
|
||||
{
|
||||
var matched = _items.FirstOrDefault(pair =>
|
||||
pair.Value.ServerHandle == serverHandle &&
|
||||
pair.Value.Subscription?.CorrelationId == evt.Message.ItemCorrelationId);
|
||||
itemHandle = matched.Key;
|
||||
item = matched.Value;
|
||||
}
|
||||
|
||||
if (itemHandle == 0 || item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.Record.Value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.IsBuffered)
|
||||
{
|
||||
BufferedDataChanged?.Invoke(
|
||||
this,
|
||||
new MxNativeBufferedDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
item.Metadata?.MxDataType ?? 0,
|
||||
[evt.Record.Value],
|
||||
[evt.Record.Quality],
|
||||
[evt.Record.TimestampUtc],
|
||||
[evt.Status],
|
||||
evt.IsDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Metadata is not null && ShouldSuppressCompatibilityDataChange(item.Metadata, evt.Record.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DataChanged?.Invoke(
|
||||
this,
|
||||
new MxNativeDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
evt.Record.Value,
|
||||
evt.Record.Quality,
|
||||
evt.Record.TimestampUtc,
|
||||
[evt.Status],
|
||||
evt.IsDuringRecovery));
|
||||
}
|
||||
|
||||
internal static bool ShouldSuppressCompatibilityDataChange(GalaxyTagMetadata metadata, object value)
|
||||
{
|
||||
return metadata.MxDataType == (short)MxDataType.InternationalizedString
|
||||
&& value is string text
|
||||
&& text.Length == 0;
|
||||
}
|
||||
|
||||
private void FireInvalidReferenceDataChange(int serverHandle, int itemHandle)
|
||||
{
|
||||
DataChanged?.Invoke(
|
||||
this,
|
||||
new MxNativeDataChangeEvent(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
Value: null,
|
||||
Quality: 0,
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
[MxStatus.InvalidReferenceConfiguration]));
|
||||
}
|
||||
|
||||
private void OnOperationStatusReceived(int serverHandle, MxNativeOperationStatusEvent evt)
|
||||
{
|
||||
int itemHandle;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_pendingWriteItems.TryGetValue(serverHandle, out Queue<int>? pendingItems)
|
||||
|| !pendingItems.TryDequeue(out itemHandle))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!evt.Message.IsMxAccessWriteComplete)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteCompleted?.Invoke(
|
||||
this,
|
||||
new MxNativeWriteCompleteEvent(serverHandle, itemHandle, [evt.Message.Status], evt.IsDuringRecovery));
|
||||
}
|
||||
|
||||
private MxNativeSession GetSessionLocked(int serverHandle)
|
||||
{
|
||||
if (!_sessions.TryGetValue(serverHandle, out MxNativeSession? session))
|
||||
{
|
||||
throw new ArgumentException("Unknown MX native server handle.", nameof(serverHandle));
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private CompatibilityItem GetItemLocked(int serverHandle, int itemHandle)
|
||||
{
|
||||
if (!_items.TryGetValue(itemHandle, out CompatibilityItem? item) || item.ServerHandle != serverHandle)
|
||||
{
|
||||
throw new ArgumentException("Unknown MX native item handle.", nameof(itemHandle));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private int AllocateUserHandleLocked(int serverHandle)
|
||||
{
|
||||
int next = _nextUserHandles.TryGetValue(serverHandle, out int value) ? value : 1;
|
||||
_nextUserHandles[serverHandle] = next + 1;
|
||||
return next;
|
||||
}
|
||||
|
||||
private Queue<int> GetPendingWriteItemsLocked(int serverHandle)
|
||||
{
|
||||
if (!_pendingWriteItems.TryGetValue(serverHandle, out Queue<int>? pendingItems))
|
||||
{
|
||||
pendingItems = new Queue<int>();
|
||||
_pendingWriteItems.Add(serverHandle, pendingItems);
|
||||
}
|
||||
|
||||
return pendingItems;
|
||||
}
|
||||
|
||||
private static bool IsMissingReferenceResolution(InvalidOperationException ex)
|
||||
{
|
||||
return ex.Message.Contains("was not found in the deployed repository metadata", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string CombineItemContext(string itemDefinition, string itemContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemContext))
|
||||
{
|
||||
return itemDefinition;
|
||||
}
|
||||
|
||||
string trimmedContext = itemContext.TrimEnd('.');
|
||||
string trimmedDefinition = itemDefinition.TrimStart('.');
|
||||
return trimmedDefinition.StartsWith(trimmedContext + ".", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmedDefinition
|
||||
: $"{trimmedContext}.{trimmedDefinition}";
|
||||
}
|
||||
|
||||
private static async Task<(GalaxyTagMetadata Metadata, string ResolvedReference)> ResolveWithOptionalContextAsync(
|
||||
MxNativeSession session,
|
||||
string itemDefinition,
|
||||
string? itemContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
GalaxyTagMetadata metadata = await session.ResolveTagAsync(itemDefinition, cancellationToken).ConfigureAwait(false);
|
||||
return (metadata, itemDefinition);
|
||||
}
|
||||
catch (Exception ex) when (!string.IsNullOrWhiteSpace(itemContext) && CanRetryWithItemContext(ex))
|
||||
{
|
||||
string combinedReference = CombineItemContext(itemDefinition, itemContext);
|
||||
GalaxyTagMetadata metadata = await session.ResolveTagAsync(combinedReference, cancellationToken).ConfigureAwait(false);
|
||||
return (metadata, combinedReference);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanRetryWithItemContext(Exception ex)
|
||||
{
|
||||
return ex is ArgumentException
|
||||
|| (ex is InvalidOperationException invalidOperation && IsMissingReferenceResolution(invalidOperation));
|
||||
}
|
||||
|
||||
private static DateTime CoerceWriteTimestamp(object timestamp)
|
||||
{
|
||||
return timestamp switch
|
||||
{
|
||||
DateTime dateTime => dateTime,
|
||||
DateTimeOffset dateTimeOffset => dateTimeOffset.UtcDateTime,
|
||||
double oaDate => DateTime.FromOADate(oaDate),
|
||||
float oaDate => DateTime.FromOADate(oaDate),
|
||||
decimal oaDate => DateTime.FromOADate((double)oaDate),
|
||||
string text when DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal,
|
||||
out DateTime parsed) => parsed,
|
||||
_ => throw new ArgumentException(
|
||||
"Timestamp must be a DateTime, DateTimeOffset, OLE Automation date, or invariant parseable date/time string.",
|
||||
nameof(timestamp)),
|
||||
};
|
||||
}
|
||||
|
||||
private void RemovePendingWriteItemLocked(int serverHandle, int itemHandle)
|
||||
{
|
||||
if (!_pendingWriteItems.TryGetValue(serverHandle, out Queue<int>? pendingItems)
|
||||
|| pendingItems.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int pendingCount = pendingItems.Count;
|
||||
bool removed = false;
|
||||
for (int i = 0; i < pendingCount; i++)
|
||||
{
|
||||
int pendingItem = pendingItems.Dequeue();
|
||||
if (!removed && pendingItem == itemHandle)
|
||||
{
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingItems.Enqueue(pendingItem);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CompatibilityItem(
|
||||
int serverHandle,
|
||||
string tagReference,
|
||||
GalaxyTagMetadata? metadata,
|
||||
bool isBuffered = false,
|
||||
bool isInvalidReference = false,
|
||||
string itemDefinition = "",
|
||||
string itemContext = "")
|
||||
{
|
||||
public int ServerHandle { get; } = serverHandle;
|
||||
public string TagReference { get; } = tagReference;
|
||||
public GalaxyTagMetadata? Metadata { get; } = metadata;
|
||||
public bool IsBuffered { get; } = isBuffered;
|
||||
public bool IsInvalidReference { get; } = isInvalidReference;
|
||||
public string ItemDefinition { get; } = itemDefinition;
|
||||
public string ItemContext { get; } = itemContext;
|
||||
public MxNativeSubscription? Subscription { get; set; }
|
||||
public bool IsAdvisedInvalidReference { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record MxNativeClientOptions
|
||||
{
|
||||
public int LocalEngineId { get; init; } = GenerateDefaultLocalEngineId();
|
||||
public string EngineName { get; init; } = $"MxNativeClient.{Environment.ProcessId}";
|
||||
public int PartnerVersion { get; init; } = 6;
|
||||
public int GalaxyId { get; init; } = 1;
|
||||
public int SourcePlatformId { get; init; } = 1;
|
||||
public DceRpcClientAuthentication Authentication { get; init; } = DceRpcClientAuthentication.ManagedNtlm;
|
||||
public int? HeartbeatTicksPerBeat { get; init; }
|
||||
public int HeartbeatMaxMissedTicks { get; init; } = 3;
|
||||
|
||||
private static int GenerateDefaultLocalEngineId()
|
||||
{
|
||||
return 0x7000 + (Environment.ProcessId & 0x0fff);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MxNativeRecoveryPolicy
|
||||
{
|
||||
public static MxNativeRecoveryPolicy SingleAttempt { get; } = new();
|
||||
|
||||
public int MaxAttempts { get; init; } = 1;
|
||||
public TimeSpan Delay { get; init; } = TimeSpan.Zero;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (MaxAttempts < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(MaxAttempts), "Recovery attempts must be at least one.");
|
||||
}
|
||||
|
||||
if (Delay < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(Delay), "Recovery delay cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MxNativeRecoveryAttemptEvent(int Attempt, int MaxAttempts);
|
||||
|
||||
public sealed record MxNativeRecoveryFailureEvent(
|
||||
int Attempt,
|
||||
int MaxAttempts,
|
||||
Exception Exception,
|
||||
bool WillRetry);
|
||||
|
||||
public sealed record MxNativeRecoveryCompletedEvent(int Attempt, int MaxAttempts);
|
||||
|
||||
public sealed record MxNativeSubscription(
|
||||
Guid CorrelationId,
|
||||
string TagReference,
|
||||
GalaxyTagMetadata Metadata,
|
||||
bool IsBuffered = false,
|
||||
string BufferedItemDefinition = "",
|
||||
string BufferedItemContext = "",
|
||||
int BufferedItemHandle = 0);
|
||||
|
||||
public sealed record MxNativeCallbackEvent(
|
||||
NmxSubscriptionMessage Message,
|
||||
NmxSubscriptionRecord Record,
|
||||
byte[] RawBody,
|
||||
bool IsDuringRecovery = false)
|
||||
{
|
||||
public MxStatus Status => Record.ToDataChangeStatus();
|
||||
}
|
||||
|
||||
public sealed record MxNativeOperationStatusEvent(
|
||||
NmxOperationStatusMessage Message,
|
||||
byte[] RawBody,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeReferenceRegistrationEvent(
|
||||
NmxReferenceRegistrationResultMessage Message,
|
||||
byte[] RawBody,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed record MxNativeUnparsedCallbackEvent(
|
||||
byte[] RawBody,
|
||||
string ParseError,
|
||||
bool IsDuringRecovery = false);
|
||||
|
||||
public sealed class MxNativeSession : IDisposable
|
||||
{
|
||||
private readonly MxNativeClientOptions _options;
|
||||
private readonly GalaxyRepositoryTagResolver _resolver;
|
||||
private readonly NmxCallbackSink _callback;
|
||||
private readonly GCHandle _callbackHandle;
|
||||
private readonly Dictionary<Guid, MxNativeSubscription> _subscriptions = [];
|
||||
private readonly HashSet<PublisherEndpoint> _publisherEndpoints = [];
|
||||
private ManagedNmxService2Client _service;
|
||||
private int _recoveryActive;
|
||||
private bool _disposed;
|
||||
|
||||
private MxNativeSession(
|
||||
MxNativeClientOptions options,
|
||||
ManagedNmxService2Client service,
|
||||
GalaxyRepositoryTagResolver resolver,
|
||||
NmxCallbackSink callback,
|
||||
GCHandle callbackHandle)
|
||||
{
|
||||
_options = options;
|
||||
_service = service;
|
||||
_resolver = resolver;
|
||||
_callback = callback;
|
||||
_callbackHandle = callbackHandle;
|
||||
|
||||
_callback.DataReceived += OnCallbackReceived;
|
||||
_callback.StatusReceived += OnCallbackReceived;
|
||||
}
|
||||
|
||||
public event EventHandler<MxNativeCallbackEvent>? CallbackReceived;
|
||||
public event EventHandler<MxNativeOperationStatusEvent>? OperationStatusReceived;
|
||||
public event EventHandler<MxNativeReferenceRegistrationEvent>? ReferenceRegistrationReceived;
|
||||
public event EventHandler<MxNativeUnparsedCallbackEvent>? UnparsedCallbackReceived;
|
||||
public event EventHandler<MxNativeRecoveryAttemptEvent>? RecoveryAttemptStarted;
|
||||
public event EventHandler<MxNativeRecoveryFailureEvent>? RecoveryAttemptFailed;
|
||||
public event EventHandler<MxNativeRecoveryCompletedEvent>? RecoveryCompleted;
|
||||
|
||||
public IReadOnlyCollection<MxNativeSubscription> Subscriptions => _subscriptions.Values;
|
||||
|
||||
public static MxNativeSession Open(MxNativeClientOptions? options = null)
|
||||
{
|
||||
options ??= new MxNativeClientOptions();
|
||||
var callback = new NmxCallbackSink();
|
||||
var callbackHandle = GCHandle.Alloc(callback);
|
||||
|
||||
try
|
||||
{
|
||||
return new MxNativeSession(
|
||||
options,
|
||||
CreateRegisteredService(options, callback),
|
||||
new GalaxyRepositoryTagResolver(),
|
||||
callback,
|
||||
callbackHandle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
callbackHandle.Free();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<GalaxyTagMetadata> ResolveTagAsync(string tagReference, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _resolver.ResolveAsync(tagReference, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<GalaxyTagMetadata>> BrowseAsync(
|
||||
string objectTagLike = "%",
|
||||
string attributeLike = "%",
|
||||
int maxRows = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _resolver.BrowseAsync(objectTagLike, attributeLike, maxRows, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(
|
||||
string tagReference,
|
||||
object value,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSucceeded(
|
||||
_service.Write(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
value,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.Write));
|
||||
}
|
||||
|
||||
public async Task Write2Async(
|
||||
string tagReference,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int writeIndex = 1,
|
||||
uint clientToken = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSucceeded(
|
||||
_service.Write2(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
writeIndex,
|
||||
clientToken,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.Write2));
|
||||
}
|
||||
|
||||
public Task WriteSecuredAsync(
|
||||
string tagReference,
|
||||
object value,
|
||||
int currentUserId,
|
||||
int verifierUserId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
throw new NotSupportedException(
|
||||
"Dedicated MXAccess WriteSecured parity is not implemented. Captures 036, 038, and 039 show the installed MXAccess WriteSecured method returning 0x80004021 before emitting an NMX write body; normal Write to secured-classified public tags is supported through WriteAsync.");
|
||||
}
|
||||
|
||||
public async Task WriteSecured2Async(
|
||||
string tagReference,
|
||||
object value,
|
||||
DateTime timestamp,
|
||||
int currentUserId,
|
||||
int verifierUserId = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsureSucceeded(
|
||||
_service.WriteSecured2(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
value,
|
||||
timestamp,
|
||||
_options.EngineName,
|
||||
currentUserId,
|
||||
verifierUserId,
|
||||
writeIndex: 1,
|
||||
clientToken: 0,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.WriteSecured2));
|
||||
}
|
||||
|
||||
public async Task<MxNativeSubscription> SubscribeAsync(
|
||||
string tagReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
GalaxyTagMetadata tag = await ResolveTagAsync(tagReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsurePublisherConnected(tag);
|
||||
|
||||
var subscription = new MxNativeSubscription(Guid.NewGuid(), tagReference, tag);
|
||||
EnsureSucceeded(
|
||||
_service.AdviseSupervisory(
|
||||
_options.LocalEngineId,
|
||||
tag,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.AdviseSupervisory));
|
||||
_subscriptions.Add(subscription.CorrelationId, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<MxNativeSubscription> RegisterBufferedItemAsync(
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
string routeReference = string.IsNullOrWhiteSpace(itemContext)
|
||||
? itemDefinition
|
||||
: $"{itemContext.TrimEnd('.')}.{itemDefinition.TrimStart('.')}";
|
||||
GalaxyTagMetadata routeTag = await ResolveTagAsync(routeReference, cancellationToken).ConfigureAwait(false);
|
||||
EnsurePublisherConnected(routeTag);
|
||||
|
||||
var subscription = new MxNativeSubscription(
|
||||
Guid.NewGuid(),
|
||||
routeReference,
|
||||
routeTag,
|
||||
IsBuffered: true,
|
||||
BufferedItemDefinition: itemDefinition,
|
||||
BufferedItemContext: itemContext,
|
||||
BufferedItemHandle: itemHandle);
|
||||
var message = new NmxReferenceRegistrationMessage(
|
||||
itemHandle,
|
||||
subscription.CorrelationId,
|
||||
NmxReferenceRegistrationMessage.ToBufferedItemDefinition(itemDefinition),
|
||||
itemContext,
|
||||
Subscribe: true);
|
||||
EnsureSucceeded(
|
||||
_service.RegisterReference(
|
||||
_options.LocalEngineId,
|
||||
routeTag,
|
||||
message,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.RegisterReference));
|
||||
_subscriptions.Add(subscription.CorrelationId, subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<object?> ReadAsync(
|
||||
string tagReference,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), "Read timeout must be positive.");
|
||||
}
|
||||
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
var completion = new TaskCompletionSource<object?>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
MxNativeSubscription? subscription = null;
|
||||
using CancellationTokenRegistration cancellation = timeoutSource.Token.Register(
|
||||
static state => ((TaskCompletionSource<object?>)state!).TrySetCanceled(),
|
||||
completion);
|
||||
|
||||
EventHandler<MxNativeCallbackEvent> handler = (_, evt) =>
|
||||
{
|
||||
if (subscription is null ||
|
||||
evt.Message.ItemCorrelationId != subscription.CorrelationId ||
|
||||
evt.Record.Value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
completion.TrySetResult(evt.Record.Value);
|
||||
};
|
||||
|
||||
CallbackReceived += handler;
|
||||
try
|
||||
{
|
||||
subscription = await SubscribeAsync(tagReference, timeoutSource.Token).ConfigureAwait(false);
|
||||
return await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CallbackReceived -= handler;
|
||||
if (subscription is not null && _subscriptions.ContainsKey(subscription.CorrelationId))
|
||||
{
|
||||
Unsubscribe(subscription.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Unsubscribe(Guid correlationId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!_subscriptions.Remove(correlationId, out MxNativeSubscription? subscription))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription.IsBuffered)
|
||||
{
|
||||
EnsureSucceeded(
|
||||
_service.UnAdvise(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.UnAdvise));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecoverConnection()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
try
|
||||
{
|
||||
RecoveryAttemptStarted?.Invoke(this, new MxNativeRecoveryAttemptEvent(1, 1));
|
||||
RecoverConnectionCore();
|
||||
RecoveryCompleted?.Invoke(this, new MxNativeRecoveryCompletedEvent(1, 1));
|
||||
}
|
||||
catch (Exception ex) when (IsRecoverableRecoveryException(ex))
|
||||
{
|
||||
RecoveryAttemptFailed?.Invoke(this, new MxNativeRecoveryFailureEvent(1, 1, ex, WillRetry: false));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecoverConnectionAsync(
|
||||
MxNativeRecoveryPolicy? policy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
policy ??= MxNativeRecoveryPolicy.SingleAttempt;
|
||||
policy.Validate();
|
||||
|
||||
Exception? lastError = null;
|
||||
for (int attempt = 1; attempt <= policy.MaxAttempts; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
RecoveryAttemptStarted?.Invoke(this, new MxNativeRecoveryAttemptEvent(attempt, policy.MaxAttempts));
|
||||
try
|
||||
{
|
||||
RecoverConnectionCore();
|
||||
RecoveryCompleted?.Invoke(this, new MxNativeRecoveryCompletedEvent(attempt, policy.MaxAttempts));
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsRecoverableRecoveryException(ex))
|
||||
{
|
||||
lastError = ex;
|
||||
bool willRetry = attempt < policy.MaxAttempts;
|
||||
RecoveryAttemptFailed?.Invoke(
|
||||
this,
|
||||
new MxNativeRecoveryFailureEvent(attempt, policy.MaxAttempts, ex, willRetry));
|
||||
if (!willRetry)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (policy.Delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(policy.Delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"MX native recovery failed after {policy.MaxAttempts} attempt(s).",
|
||||
lastError);
|
||||
}
|
||||
|
||||
private void RecoverConnectionCore()
|
||||
{
|
||||
Interlocked.Increment(ref _recoveryActive);
|
||||
try
|
||||
{
|
||||
ManagedNmxService2Client replacement = CreateRegisteredService(_options, _callback);
|
||||
try
|
||||
{
|
||||
foreach (PublisherEndpoint endpoint in _publisherEndpoints)
|
||||
{
|
||||
ConnectPublisher(replacement, endpoint);
|
||||
}
|
||||
|
||||
foreach (MxNativeSubscription subscription in _subscriptions.Values)
|
||||
{
|
||||
ReAdviseSubscription(replacement, subscription);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
replacement.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
ManagedNmxService2Client oldService = _service;
|
||||
_service = replacement;
|
||||
oldService.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _recoveryActive);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (MxNativeSubscription subscription in _subscriptions.Values.ToArray())
|
||||
{
|
||||
if (!subscription.IsBuffered)
|
||||
{
|
||||
TryCleanup(() => _service.UnAdvise(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PublisherEndpoint publisherEndpoint in _publisherEndpoints)
|
||||
{
|
||||
TryCleanup(() => _service.RemoveSubscriberEngine(
|
||||
publisherEndpoint.EngineId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId,
|
||||
_options.LocalEngineId));
|
||||
}
|
||||
|
||||
TryCleanup(() => _service.UnregisterEngine(_options.LocalEngineId));
|
||||
_service.Dispose();
|
||||
if (_callbackHandle.IsAllocated)
|
||||
{
|
||||
_callbackHandle.Free();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void EnsurePublisherConnected(GalaxyTagMetadata tag)
|
||||
{
|
||||
var endpoint = new PublisherEndpoint(tag.PlatformId, tag.EngineId);
|
||||
if (_publisherEndpoints.Contains(endpoint))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectPublisher(_service, endpoint);
|
||||
_publisherEndpoints.Add(endpoint);
|
||||
}
|
||||
|
||||
private void ConnectPublisher(ManagedNmxService2Client service, PublisherEndpoint endpoint)
|
||||
{
|
||||
EnsureSucceeded(
|
||||
service.Connect(_options.LocalEngineId, _options.GalaxyId, endpoint.PlatformId, endpoint.EngineId),
|
||||
nameof(ManagedNmxService2Client.Connect));
|
||||
EnsureSucceeded(
|
||||
service.AddSubscriberEngine(endpoint.EngineId, _options.GalaxyId, _options.SourcePlatformId, _options.LocalEngineId),
|
||||
nameof(ManagedNmxService2Client.AddSubscriberEngine));
|
||||
}
|
||||
|
||||
private void ReAdviseSubscription(ManagedNmxService2Client service, MxNativeSubscription subscription)
|
||||
{
|
||||
if (subscription.IsBuffered)
|
||||
{
|
||||
var message = new NmxReferenceRegistrationMessage(
|
||||
subscription.BufferedItemHandle,
|
||||
subscription.CorrelationId,
|
||||
NmxReferenceRegistrationMessage.ToBufferedItemDefinition(subscription.BufferedItemDefinition),
|
||||
subscription.BufferedItemContext,
|
||||
Subscribe: true);
|
||||
EnsureSucceeded(
|
||||
service.RegisterReference(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
message,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.RegisterReference));
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSucceeded(
|
||||
service.AdviseSupervisory(
|
||||
_options.LocalEngineId,
|
||||
subscription.Metadata,
|
||||
subscription.CorrelationId,
|
||||
_options.GalaxyId,
|
||||
_options.GalaxyId,
|
||||
_options.SourcePlatformId),
|
||||
nameof(ManagedNmxService2Client.AdviseSupervisory));
|
||||
}
|
||||
|
||||
private void OnCallbackReceived(object? sender, NmxServiceMessage message)
|
||||
{
|
||||
bool isDuringRecovery = Volatile.Read(ref _recoveryActive) > 0;
|
||||
if (NmxOperationStatusMessage.TryParseProcessDataReceivedBody(message.Body, out var operationStatus))
|
||||
{
|
||||
OperationStatusReceived?.Invoke(
|
||||
this,
|
||||
new MxNativeOperationStatusEvent(operationStatus, message.Body, isDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
if (NmxReferenceRegistrationResultMessage.TryParseProcessDataReceivedBody(message.Body, out var registrationResult))
|
||||
{
|
||||
ReferenceRegistrationReceived?.Invoke(
|
||||
this,
|
||||
new MxNativeReferenceRegistrationEvent(registrationResult!, message.Body, isDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
NmxSubscriptionMessage parsed;
|
||||
try
|
||||
{
|
||||
parsed = NmxSubscriptionMessage.ParseProcessDataReceivedBody(message.Body);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
UnparsedCallbackReceived?.Invoke(
|
||||
this,
|
||||
new MxNativeUnparsedCallbackEvent(message.Body, ex.Message, isDuringRecovery));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (NmxSubscriptionRecord record in parsed.Records)
|
||||
{
|
||||
CallbackReceived?.Invoke(this, new MxNativeCallbackEvent(parsed, record, message.Body, isDuringRecovery));
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSucceeded(int hresult, string operation)
|
||||
{
|
||||
if (hresult == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (hresult < 0)
|
||||
{
|
||||
Marshal.ThrowExceptionForHR(hresult);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"{operation} returned application status 0x{hresult:X8}.");
|
||||
}
|
||||
|
||||
private static ManagedNmxService2Client CreateRegisteredService(MxNativeClientOptions options, NmxCallbackSink callback)
|
||||
{
|
||||
byte[] callbackObjRef = ComObjRefProvider.MarshalInterfaceObjRef(
|
||||
callback,
|
||||
NmxProcedureMetadata.INmxSvcCallback,
|
||||
ComObjRefProvider.MarshalContextDifferentMachine);
|
||||
ManagedNmxService2Client service = ManagedNmxService2Client.Create(options.Authentication);
|
||||
try
|
||||
{
|
||||
EnsureSucceeded(
|
||||
service.RegisterEngine2(options.LocalEngineId, options.EngineName, options.PartnerVersion, callbackObjRef),
|
||||
nameof(ManagedNmxService2Client.RegisterEngine2));
|
||||
if (options.HeartbeatTicksPerBeat is { } heartbeatTicks)
|
||||
{
|
||||
EnsureSucceeded(
|
||||
service.SetHeartbeatSendInterval(heartbeatTicks, options.HeartbeatMaxMissedTicks),
|
||||
nameof(ManagedNmxService2Client.SetHeartbeatSendInterval));
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
catch
|
||||
{
|
||||
service.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryCleanup(Func<int> operation)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = operation();
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException or ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRecoverableRecoveryException(Exception ex)
|
||||
{
|
||||
return ex is InvalidOperationException or COMException or IOException or ObjectDisposedException;
|
||||
}
|
||||
|
||||
private readonly record struct PublisherEndpoint(int PlatformId, int EngineId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record NmxServiceMessage(byte[] Body);
|
||||
|
||||
[ComVisible(true)]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
public sealed unsafe class NmxCallbackSink : INmxSvcCallback
|
||||
{
|
||||
public event EventHandler<NmxServiceMessage>? DataReceived;
|
||||
public event EventHandler<NmxServiceMessage>? StatusReceived;
|
||||
|
||||
public void DataReceivedRaw(int bufferSize, ref sbyte dataBuffer)
|
||||
{
|
||||
DataReceived?.Invoke(this, new NmxServiceMessage(CopyBuffer(bufferSize, ref dataBuffer)));
|
||||
}
|
||||
|
||||
public void StatusReceivedRaw(int bufferSize, ref sbyte statusBuffer)
|
||||
{
|
||||
StatusReceived?.Invoke(this, new NmxServiceMessage(CopyBuffer(bufferSize, ref statusBuffer)));
|
||||
}
|
||||
|
||||
private static byte[] CopyBuffer(int bufferSize, ref sbyte firstByte)
|
||||
{
|
||||
if (bufferSize <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var output = new byte[bufferSize];
|
||||
fixed (sbyte* source = &firstByte)
|
||||
{
|
||||
new ReadOnlySpan<byte>(source, bufferSize).CopyTo(output);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
[ComImport]
|
||||
[Guid("AE24BD51-2E80-44CC-905B-E5446C942BEB")]
|
||||
[ClassInterface(ClassInterfaceType.None)]
|
||||
internal sealed class NmxServiceClass : INmxService2
|
||||
{
|
||||
public extern void RegisterEngine(int engineId, string engineName, INmxSvcCallback callback);
|
||||
public extern void UnRegisterEngine(int engineId);
|
||||
public extern void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
public extern void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
public extern void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
public extern void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
public extern void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
public extern void RegisterEngine2(int engineId, string engineName, int version, INmxSvcCallback callback);
|
||||
public extern void GetPartnerVersion(int galaxyId, int platformId, int engineId, out int version);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("575008DB-845D-46C6-A906-F6F8CA86F315")]
|
||||
internal interface INmxService
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RegisterEngine(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void UnRegisterEngine(int engineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("2630A513-A974-4B1A-8025-457A9A7C56B8")]
|
||||
internal interface INmxService2 : INmxService
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void RegisterEngine(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void UnRegisterEngine(int engineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, int size, ref byte messageBody);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void AddSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void RemoveSubscriberEngine(int localEngineId, int subscriberGalaxyId, int subscriberPlatformId, int subscriberEngineId);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
new void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void RegisterEngine2(int engineId, [MarshalAs(UnmanagedType.BStr)] string engineName, int version, [MarshalAs(UnmanagedType.Interface)] INmxSvcCallback callback);
|
||||
|
||||
[MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
|
||||
void GetPartnerVersion(int galaxyId, int platformId, int engineId, out int version);
|
||||
}
|
||||
|
||||
[ComVisible(true)]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("B49F92F7-C748-4169-8ECA-A0670B012746")]
|
||||
public interface INmxSvcCallback
|
||||
{
|
||||
[PreserveSig]
|
||||
void DataReceivedRaw(int bufferSize, ref sbyte dataBuffer);
|
||||
|
||||
[PreserveSig]
|
||||
void StatusReceivedRaw(int bufferSize, ref sbyte statusBuffer);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class NmxProcedureMetadata
|
||||
{
|
||||
public static readonly Guid INmxService2 = new("2630A513-A974-4B1A-8025-457A9A7C56B8");
|
||||
public static readonly Guid INmxSvcCallback = new("B49F92F7-C748-4169-8ECA-A0670B012746");
|
||||
|
||||
public static readonly NdrProcedureDescriptor RegisterEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(RegisterEngine),
|
||||
Opnum: 3,
|
||||
X86StackSize: 20,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 4);
|
||||
|
||||
public static readonly NdrProcedureDescriptor UnRegisterEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(UnRegisterEngine),
|
||||
Opnum: 4,
|
||||
X86StackSize: 12,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 2);
|
||||
|
||||
public static readonly NdrProcedureDescriptor Connect = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(Connect),
|
||||
Opnum: 5,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor TransferData = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(TransferData),
|
||||
Opnum: 6,
|
||||
X86StackSize: 28,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 6);
|
||||
|
||||
public static readonly NdrProcedureDescriptor AddSubscriberEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(AddSubscriberEngine),
|
||||
Opnum: 7,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor RemoveSubscriberEngine = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(RemoveSubscriberEngine),
|
||||
Opnum: 8,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 32,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor SetHeartbeatSendInterval = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(SetHeartbeatSendInterval),
|
||||
Opnum: 9,
|
||||
X86StackSize: 16,
|
||||
ClientBufferSize: 16,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 3);
|
||||
|
||||
public static readonly NdrProcedureDescriptor RegisterEngine2 = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(RegisterEngine2),
|
||||
Opnum: 10,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 16,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor GetPartnerVersion = new(
|
||||
InterfaceId: INmxService2,
|
||||
Name: nameof(GetPartnerVersion),
|
||||
Opnum: 11,
|
||||
X86StackSize: 24,
|
||||
ClientBufferSize: 24,
|
||||
ServerBufferSize: 36,
|
||||
ParameterCountIncludingReturn: 5);
|
||||
|
||||
public static readonly NdrProcedureDescriptor DataReceived = new(
|
||||
InterfaceId: INmxSvcCallback,
|
||||
Name: nameof(DataReceived),
|
||||
Opnum: 3,
|
||||
X86StackSize: 16,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 3);
|
||||
|
||||
public static readonly NdrProcedureDescriptor StatusReceived = new(
|
||||
InterfaceId: INmxSvcCallback,
|
||||
Name: nameof(StatusReceived),
|
||||
Opnum: 4,
|
||||
X86StackSize: 16,
|
||||
ClientBufferSize: 8,
|
||||
ServerBufferSize: 8,
|
||||
ParameterCountIncludingReturn: 3);
|
||||
}
|
||||
|
||||
public sealed record NdrProcedureDescriptor(
|
||||
Guid InterfaceId,
|
||||
string Name,
|
||||
int Opnum,
|
||||
int X86StackSize,
|
||||
int ClientBufferSize,
|
||||
int ServerBufferSize,
|
||||
int ParameterCountIncludingReturn);
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record NmxGetPartnerVersionResult(OrpcThat OrpcThat, int PartnerVersion, int HResult);
|
||||
|
||||
public sealed record NmxHResultResponse(OrpcThat OrpcThat, int HResult);
|
||||
|
||||
public static class NmxService2Messages
|
||||
{
|
||||
public static Guid ClassIdNmxService { get; } = new("AE24BD51-2E80-44CC-905B-E5446C942BEB");
|
||||
public static Guid InterfaceId { get; } = NmxProcedureMetadata.INmxService2;
|
||||
|
||||
public const ushort RegisterEngineOpnum = 3;
|
||||
public const ushort UnregisterEngineOpnum = 4;
|
||||
public const ushort ConnectOpnum = 5;
|
||||
public const ushort TransferDataOpnum = 6;
|
||||
public const ushort AddSubscriberEngineOpnum = 7;
|
||||
public const ushort RemoveSubscriberEngineOpnum = 8;
|
||||
public const ushort SetHeartbeatSendIntervalOpnum = 9;
|
||||
public const ushort RegisterEngine2Opnum = 10;
|
||||
public const ushort GetPartnerVersionOpnum = 11;
|
||||
|
||||
public static byte[] EncodeGetPartnerVersionRequest(
|
||||
OrpcThis orpcThis,
|
||||
int galaxyId,
|
||||
int platformId,
|
||||
int engineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 12];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), galaxyId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), platformId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), engineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static NmxGetPartnerVersionResult ParseGetPartnerVersionResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThat.EncodedLengthWithoutExtensions + 8)
|
||||
{
|
||||
throw new ArgumentException("GetPartnerVersion response is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new NmxGetPartnerVersionResult(
|
||||
OrpcThat.Parse(buffer),
|
||||
ReadInt32(buffer[8..12]),
|
||||
ReadInt32(buffer[12..16]));
|
||||
}
|
||||
|
||||
public static byte[] EncodeConnectRequest(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId,
|
||||
int remoteGalaxyId,
|
||||
int remotePlatformId,
|
||||
int remoteEngineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 16];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), localEngineId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), remoteGalaxyId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), remotePlatformId);
|
||||
WriteInt32(buffer.AsSpan(44, 4), remoteEngineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeSubscriberEngineRequest(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId,
|
||||
int subscriberGalaxyId,
|
||||
int subscriberPlatformId,
|
||||
int subscriberEngineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 16];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), localEngineId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), subscriberGalaxyId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), subscriberPlatformId);
|
||||
WriteInt32(buffer.AsSpan(44, 4), subscriberEngineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeUnregisterEngineRequest(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 4];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), localEngineId);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeSetHeartbeatSendIntervalRequest(
|
||||
OrpcThis orpcThis,
|
||||
int ticksPerBeat,
|
||||
int maxMissedTicks)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThis.EncodedLengthWithoutExtensions + 8];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), ticksPerBeat);
|
||||
WriteInt32(buffer.AsSpan(36, 4), maxMissedTicks);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeTransferDataRequest(
|
||||
OrpcThis orpcThis,
|
||||
int remoteGalaxyId,
|
||||
int remotePlatformId,
|
||||
int remoteEngineId,
|
||||
ReadOnlySpan<byte> messageBody)
|
||||
{
|
||||
int bodyOffset = OrpcThis.EncodedLengthWithoutExtensions + 20;
|
||||
int paddedLength = Align(bodyOffset + messageBody.Length, 4);
|
||||
byte[] buffer = new byte[paddedLength];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
WriteInt32(buffer.AsSpan(32, 4), remoteGalaxyId);
|
||||
WriteInt32(buffer.AsSpan(36, 4), remotePlatformId);
|
||||
WriteInt32(buffer.AsSpan(40, 4), remoteEngineId);
|
||||
WriteInt32(buffer.AsSpan(44, 4), messageBody.Length);
|
||||
WriteInt32(buffer.AsSpan(48, 4), messageBody.Length);
|
||||
messageBody.CopyTo(buffer.AsSpan(bodyOffset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeRegisterEngine2Request(
|
||||
OrpcThis orpcThis,
|
||||
int localEngineId,
|
||||
string engineName,
|
||||
int version,
|
||||
byte[]? callbackObjRef = null)
|
||||
{
|
||||
byte[] bstr = EncodeBstrUserMarshal(engineName);
|
||||
byte[] callback = callbackObjRef is null
|
||||
? EncodeNullInterfacePointer()
|
||||
: EncodeInterfacePointer(callbackObjRef);
|
||||
|
||||
int bstrOffset = OrpcThis.EncodedLengthWithoutExtensions + 8;
|
||||
int versionOffset = Align(bstrOffset + bstr.Length, 4);
|
||||
int length = Align(versionOffset + 4 + callback.Length, 4);
|
||||
byte[] buffer = new byte[length];
|
||||
orpcThis.Encode().CopyTo(buffer.AsSpan());
|
||||
int offset = OrpcThis.EncodedLengthWithoutExtensions;
|
||||
WriteInt32(buffer.AsSpan(offset, 4), localEngineId);
|
||||
offset += 4;
|
||||
WriteInt32(buffer.AsSpan(offset, 4), 0x72657355);
|
||||
offset += 4;
|
||||
bstr.CopyTo(buffer.AsSpan(offset));
|
||||
offset = versionOffset;
|
||||
WriteInt32(buffer.AsSpan(offset, 4), version);
|
||||
offset += 4;
|
||||
callback.CopyTo(buffer.AsSpan(offset));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] EncodeBstrUserMarshal(string value)
|
||||
{
|
||||
byte[] utf16 = Encoding.Unicode.GetBytes(value);
|
||||
if ((utf16.Length % 2) != 0)
|
||||
{
|
||||
throw new ArgumentException("BSTR payload must be UTF-16.", nameof(value));
|
||||
}
|
||||
|
||||
int charCount = utf16.Length / 2;
|
||||
byte[] buffer = new byte[12 + utf16.Length];
|
||||
WriteInt32(buffer.AsSpan(0, 4), charCount);
|
||||
WriteInt32(buffer.AsSpan(4, 4), utf16.Length);
|
||||
WriteInt32(buffer.AsSpan(8, 4), charCount);
|
||||
utf16.CopyTo(buffer.AsSpan(12));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static NmxHResultResponse ParseHResultResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThat.EncodedLengthWithoutExtensions + 4)
|
||||
{
|
||||
throw new ArgumentException("HRESULT response is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new NmxHResultResponse(
|
||||
OrpcThat.Parse(buffer),
|
||||
ReadInt32(buffer[8..12]));
|
||||
}
|
||||
|
||||
private static int ReadInt32(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
return BinaryPrimitives.ReadInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteInt32(Span<byte> buffer, int value)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, value);
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
|
||||
private static byte[] EncodeNullInterfacePointer()
|
||||
{
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private static byte[] EncodeInterfacePointer(byte[] objRef)
|
||||
{
|
||||
int length = Align(12 + objRef.Length, 4);
|
||||
byte[] buffer = new byte[length];
|
||||
WriteInt32(buffer.AsSpan(0, 4), 0x00020000);
|
||||
WriteInt32(buffer.AsSpan(4, 4), objRef.Length);
|
||||
WriteInt32(buffer.AsSpan(8, 4), objRef.Length);
|
||||
objRef.CopyTo(buffer.AsSpan(12));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using MxNativeCodec;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class NmxServiceClient : IDisposable
|
||||
{
|
||||
public const int ObservedNmxVersion = 30000;
|
||||
|
||||
private readonly INmxService2 _service;
|
||||
private readonly object _comObject;
|
||||
private int? _registeredEngineId;
|
||||
private bool _disposed;
|
||||
|
||||
private NmxServiceClient(INmxService2 service)
|
||||
{
|
||||
_service = service;
|
||||
_comObject = service;
|
||||
}
|
||||
|
||||
public static NmxServiceClient Create()
|
||||
{
|
||||
var service = (INmxService2)new NmxServiceClass();
|
||||
return new NmxServiceClient(service);
|
||||
}
|
||||
|
||||
public void RegisterEngine(int engineId, string engineName, NmxCallbackSink callback, int version = ObservedNmxVersion)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.RegisterEngine2(engineId, engineName, version, callback);
|
||||
_registeredEngineId = engineId;
|
||||
}
|
||||
|
||||
public void UnregisterEngine()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_registeredEngineId is not { } engineId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_service.UnRegisterEngine(engineId);
|
||||
_registeredEngineId = null;
|
||||
}
|
||||
|
||||
public int GetPartnerVersion(int galaxyId, int platformId, int engineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.GetPartnerVersion(galaxyId, platformId, engineId, out var version);
|
||||
return version;
|
||||
}
|
||||
|
||||
public void Connect(int localEngineId, int remoteGalaxyId, int remotePlatformId, int remoteEngineId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.Connect(localEngineId, remoteGalaxyId, remotePlatformId, remoteEngineId);
|
||||
}
|
||||
|
||||
public void SetHeartbeatSendInterval(int ticksPerBeat, int maxMissedTicks)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_service.SetHeartbeatSendInterval(ticksPerBeat, maxMissedTicks);
|
||||
}
|
||||
|
||||
public void TransferData(int remoteGalaxyId, int remotePlatformId, int remoteEngineId, ReadOnlySpan<byte> messageBody)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ValidateTransferDataBody(messageBody, nameof(messageBody));
|
||||
|
||||
var copy = messageBody.ToArray();
|
||||
_service.TransferData(remoteGalaxyId, remotePlatformId, remoteEngineId, copy.Length, ref copy[0]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_registeredEngineId is not null)
|
||||
{
|
||||
UnregisterEngine();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Marshal.IsComObject(_comObject))
|
||||
{
|
||||
Marshal.ReleaseComObject(_comObject);
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTransferDataBody(ReadOnlySpan<byte> messageBody, string parameterName)
|
||||
{
|
||||
if (messageBody.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("TransferData body cannot be empty.", parameterName);
|
||||
}
|
||||
|
||||
NmxTransferEnvelopeTemplate.FromObserved(messageBody);
|
||||
if (messageBody.Length == NmxTransferEnvelopeTemplate.HeaderLength)
|
||||
{
|
||||
throw new ArgumentException("TransferData body must include an inner message after the 46-byte envelope.", parameterName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed record NmxCallbackRequest(OrpcThis OrpcThis, byte[] Body);
|
||||
|
||||
public static class NmxSvcCallbackMessages
|
||||
{
|
||||
public static Guid InterfaceId { get; } = NmxProcedureMetadata.INmxSvcCallback;
|
||||
|
||||
public const ushort DataReceivedOpnum = 3;
|
||||
public const ushort StatusReceivedOpnum = 4;
|
||||
|
||||
public static NmxCallbackRequest ParseCallbackRequest(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThis.EncodedLengthWithoutExtensions + 8)
|
||||
{
|
||||
throw new ArgumentException("Callback request is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
var orpcThis = OrpcThis.Parse(buffer);
|
||||
int size = BinaryPrimitives.ReadInt32LittleEndian(buffer.Slice(OrpcThis.EncodedLengthWithoutExtensions, 4));
|
||||
int maxCount = BinaryPrimitives.ReadInt32LittleEndian(buffer.Slice(OrpcThis.EncodedLengthWithoutExtensions + 4, 4));
|
||||
if (size < 0 || maxCount < size)
|
||||
{
|
||||
throw new ArgumentException("Callback request has invalid array size metadata.", nameof(buffer));
|
||||
}
|
||||
|
||||
int bodyOffset = OrpcThis.EncodedLengthWithoutExtensions + 8;
|
||||
if (bodyOffset + size > buffer.Length)
|
||||
{
|
||||
throw new ArgumentException("Callback request byte array is truncated.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new NmxCallbackRequest(orpcThis, buffer.Slice(bodyOffset, size).ToArray());
|
||||
}
|
||||
|
||||
public static byte[] EncodeCallbackResponse(int hresult)
|
||||
{
|
||||
byte[] buffer = new byte[OrpcThat.EncodedLengthWithoutExtensions + sizeof(int)];
|
||||
new OrpcThat(0, 0).Encode().CopyTo(buffer.AsSpan());
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(OrpcThat.EncodedLengthWithoutExtensions, sizeof(int)), hresult);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace MxNativeClient;
|
||||
|
||||
public sealed class ObjectExporterClient
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
|
||||
public ObjectExporterClient(string host = "127.0.0.1", int port = 135)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public ResolveOxidFailure ResolveOxidUnauthenticated(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.Bind(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
var response = client.Call(contextId: 0, ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
return ObjectExporterMessages.ParseResolveOxidFailure(response.StubData.Span);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu ResolveOxidWithNtlmConnect(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.BindWithNtlmConnect(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
return client.CallBound(ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu ResolveOxidWithNtlmPacketIntegrity(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.BindWithNtlmPacketIntegrity(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0, targetName: _host);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
return client.CallBound(ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
}
|
||||
|
||||
public DceRpcResponsePdu ResolveOxidWithManagedNtlmPacketIntegrity(ulong oxid, IReadOnlyList<ushort>? requestedProtseqs = null)
|
||||
{
|
||||
using var client = new DceRpcTcpClient(_host, _port);
|
||||
client.Connect();
|
||||
var bind = client.BindWithManagedNtlmPacketIntegrity(ObjectExporterMessages.IObjectExporter, versionMajor: 0, versionMinor: 0);
|
||||
if (bind.PacketType != DceRpcPacketType.BindAck)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected bind response packet type {bind.PacketType}.");
|
||||
}
|
||||
|
||||
byte[] request = ObjectExporterMessages.EncodeResolveOxidRequest(
|
||||
oxid,
|
||||
requestedProtseqs ?? [ObjectExporterMessages.ProtseqNcacnIpTcp]);
|
||||
|
||||
return client.CallBound(ObjectExporterMessages.ResolveOxidOpnum, request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class ObjectExporterMessages
|
||||
{
|
||||
public static readonly Guid IObjectExporter = new("99FCFEC4-5260-101B-BBCB-00AA0021347A");
|
||||
public const ushort ResolveOxidOpnum = 0;
|
||||
public const ushort SimplePingOpnum = 1;
|
||||
public const ushort ComplexPingOpnum = 2;
|
||||
public const ushort ServerAliveOpnum = 3;
|
||||
public const ushort ResolveOxid2Opnum = 4;
|
||||
public const ushort ServerAlive2Opnum = 5;
|
||||
|
||||
public const ushort ProtseqNcacnIpTcp = 0x0007;
|
||||
public const ushort ProtseqNcalRpc = 0x001f;
|
||||
|
||||
public static byte[] EncodeResolveOxidRequest(ulong oxid, IReadOnlyList<ushort> requestedProtseqs)
|
||||
{
|
||||
if (requestedProtseqs.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one protocol sequence is required.", nameof(requestedProtseqs));
|
||||
}
|
||||
|
||||
int length = 8 + 2 + 2 + 4 + requestedProtseqs.Count * sizeof(ushort);
|
||||
length = Align(length, 4);
|
||||
byte[] buffer = new byte[length];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(0, 8), oxid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(8, 2), (ushort)requestedProtseqs.Count);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(12, 4), (uint)requestedProtseqs.Count);
|
||||
for (int i = 0; i < requestedProtseqs.Count; i++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(16 + i * sizeof(ushort), sizeof(ushort)), requestedProtseqs[i]);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static ResolveOxidFailure ParseResolveOxidFailure(ReadOnlySpan<byte> responseStub)
|
||||
{
|
||||
if (responseStub.Length < 4)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid response stub is too short.", nameof(responseStub));
|
||||
}
|
||||
|
||||
return new ResolveOxidFailure(BinaryPrimitives.ReadUInt32LittleEndian(responseStub[^4..]));
|
||||
}
|
||||
|
||||
public static ResolveOxidResult ParseResolveOxidResult(ReadOnlySpan<byte> responseStub)
|
||||
{
|
||||
if (responseStub.Length < 32)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid response stub is too short.", nameof(responseStub));
|
||||
}
|
||||
|
||||
uint referentId = BinaryPrimitives.ReadUInt32LittleEndian(responseStub[0..4]);
|
||||
if (referentId == 0)
|
||||
{
|
||||
uint nullStatus = BinaryPrimitives.ReadUInt32LittleEndian(responseStub[^4..]);
|
||||
return new ResolveOxidResult([], Guid.Empty, 0, nullStatus);
|
||||
}
|
||||
|
||||
uint maxCount = BinaryPrimitives.ReadUInt32LittleEndian(responseStub[4..8]);
|
||||
ushort entries = BinaryPrimitives.ReadUInt16LittleEndian(responseStub[8..10]);
|
||||
ushort securityOffset = BinaryPrimitives.ReadUInt16LittleEndian(responseStub[10..12]);
|
||||
if (maxCount < entries)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid DUALSTRINGARRAY max count is smaller than entry count.", nameof(responseStub));
|
||||
}
|
||||
|
||||
int arrayOffset = 12;
|
||||
int arrayBytes = checked((int)maxCount * sizeof(ushort));
|
||||
if (arrayOffset + arrayBytes > responseStub.Length)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid DUALSTRINGARRAY is truncated.", nameof(responseStub));
|
||||
}
|
||||
|
||||
var decoded = DecodeDualStringArray(responseStub.Slice(arrayOffset, entries * sizeof(ushort)), entries, securityOffset);
|
||||
int offset = Align(arrayOffset + arrayBytes, 4);
|
||||
if (offset + 24 > responseStub.Length)
|
||||
{
|
||||
throw new ArgumentException("ResolveOxid trailing fields are truncated.", nameof(responseStub));
|
||||
}
|
||||
|
||||
return new ResolveOxidResult(
|
||||
decoded,
|
||||
new Guid(responseStub.Slice(offset, 16)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(responseStub.Slice(offset + 16, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(responseStub.Slice(offset + 20, 4)));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ComDualStringEntry> DecodeDualStringArray(ReadOnlySpan<byte> data, ushort entries, ushort securityOffset)
|
||||
{
|
||||
var strings = new List<ComDualStringEntry>();
|
||||
for (int i = 0; i < entries;)
|
||||
{
|
||||
int entryStart = i;
|
||||
ushort towerId = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(i * 2, 2));
|
||||
i++;
|
||||
if (towerId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = new List<char>();
|
||||
while (i < entries)
|
||||
{
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(i * 2, 2));
|
||||
i++;
|
||||
if (value == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
text.Add(value >= 0x20 && value <= 0x7e ? (char)value : '?');
|
||||
}
|
||||
|
||||
strings.Add(new ComDualStringEntry(
|
||||
towerId,
|
||||
towerId == ProtseqNcacnIpTcp ? "ncacn_ip_tcp" : $"protseq_0x{towerId:x4}",
|
||||
new string(text.ToArray()),
|
||||
IsSecurityBinding: entryStart >= securityOffset));
|
||||
}
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static int Align(int value, int alignment)
|
||||
{
|
||||
int remainder = value % alignment;
|
||||
return remainder == 0 ? value : value + alignment - remainder;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ResolveOxidFailure(uint ErrorStatus);
|
||||
|
||||
public sealed record ResolveOxidResult(
|
||||
IReadOnlyList<ComDualStringEntry> Bindings,
|
||||
Guid RemUnknownIpid,
|
||||
uint AuthnHint,
|
||||
uint ErrorStatus);
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public readonly record struct ComVersion(ushort Major, ushort Minor)
|
||||
{
|
||||
public static ComVersion Version57 { get; } = new(5, 7);
|
||||
}
|
||||
|
||||
public sealed record OrpcThis(
|
||||
ComVersion Version,
|
||||
uint Flags,
|
||||
uint Reserved1,
|
||||
Guid Cid,
|
||||
uint ExtensionsReferentId)
|
||||
{
|
||||
public const int EncodedLengthWithoutExtensions = 32;
|
||||
|
||||
public static OrpcThis Create(Guid cid, ComVersion? version = null)
|
||||
{
|
||||
return new OrpcThis(version ?? ComVersion.Version57, 0, 0, cid, 0);
|
||||
}
|
||||
|
||||
public static OrpcThis Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLengthWithoutExtensions)
|
||||
{
|
||||
throw new ArgumentException("ORPCTHIS buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new OrpcThis(
|
||||
Version: new ComVersion(
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4])),
|
||||
Flags: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]),
|
||||
Reserved1: BinaryPrimitives.ReadUInt32LittleEndian(buffer[8..12]),
|
||||
Cid: new Guid(buffer.Slice(12, 16)),
|
||||
ExtensionsReferentId: BinaryPrimitives.ReadUInt32LittleEndian(buffer[28..32]));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[EncodedLengthWithoutExtensions];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), Version.Major);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(2, 2), Version.Minor);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), Flags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(8, 4), Reserved1);
|
||||
Cid.TryWriteBytes(buffer.AsSpan(12, 16));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(28, 4), ExtensionsReferentId);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record OrpcThat(uint Flags, uint ExtensionsReferentId)
|
||||
{
|
||||
public const int EncodedLengthWithoutExtensions = 8;
|
||||
|
||||
public static OrpcThat Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLengthWithoutExtensions)
|
||||
{
|
||||
throw new ArgumentException("ORPCTHAT buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new OrpcThat(
|
||||
Flags: BinaryPrimitives.ReadUInt32LittleEndian(buffer[0..4]),
|
||||
ExtensionsReferentId: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[EncodedLengthWithoutExtensions];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), Flags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), ExtensionsReferentId);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MInterfacePointer(byte[] ObjRefBytes)
|
||||
{
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[sizeof(uint) + ObjRefBytes.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, sizeof(uint)), (uint)ObjRefBytes.Length);
|
||||
ObjRefBytes.CopyTo(buffer.AsSpan(sizeof(uint)));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static MInterfacePointer Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < sizeof(uint))
|
||||
{
|
||||
throw new ArgumentException("MInterfacePointer buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
uint size = BinaryPrimitives.ReadUInt32LittleEndian(buffer[..sizeof(uint)]);
|
||||
if (size > buffer.Length - sizeof(uint))
|
||||
{
|
||||
throw new ArgumentException("MInterfacePointer OBJREF payload is truncated.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new MInterfacePointer(buffer.Slice(sizeof(uint), (int)size).ToArray());
|
||||
}
|
||||
|
||||
public ComObjRef ParseObjRef()
|
||||
{
|
||||
return ComObjRef.Parse(ObjRefBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record StdObjRef(uint Flags, uint PublicRefs, ulong Oxid, ulong Oid, Guid Ipid)
|
||||
{
|
||||
public const int EncodedLength = 40;
|
||||
|
||||
public static StdObjRef Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLength)
|
||||
{
|
||||
throw new ArgumentException("STDOBJREF buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new StdObjRef(
|
||||
Flags: BinaryPrimitives.ReadUInt32LittleEndian(buffer[0..4]),
|
||||
PublicRefs: BinaryPrimitives.ReadUInt32LittleEndian(buffer[4..8]),
|
||||
Oxid: BinaryPrimitives.ReadUInt64LittleEndian(buffer[8..16]),
|
||||
Oid: BinaryPrimitives.ReadUInt64LittleEndian(buffer[16..24]),
|
||||
Ipid: new Guid(buffer.Slice(24, 16)));
|
||||
}
|
||||
|
||||
public byte[] Encode()
|
||||
{
|
||||
byte[] buffer = new byte[EncodedLength];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0, 4), Flags);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4, 4), PublicRefs);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(8, 8), Oxid);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer.AsSpan(16, 8), Oid);
|
||||
Ipid.TryWriteBytes(buffer.AsSpan(24, 16));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MxNativeClient.Tests")]
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace MxNativeClient;
|
||||
|
||||
public static class RemUnknownMessages
|
||||
{
|
||||
public static readonly Guid IRemUnknown = new("00000131-0000-0000-C000-000000000046");
|
||||
public const ushort RemQueryInterfaceOpnum = 3;
|
||||
public const ushort RemAddRefOpnum = 4;
|
||||
public const ushort RemReleaseOpnum = 5;
|
||||
|
||||
public static byte[] EncodeRemQueryInterfaceRequest(Guid sourceIpid, Guid requestedIid, Guid causalityId, uint publicRefs = 5)
|
||||
{
|
||||
var orpcThis = OrpcThis.Create(causalityId).Encode();
|
||||
byte[] body = new byte[orpcThis.Length + 16 + 4 + 4 + 4 + 16];
|
||||
int offset = 0;
|
||||
orpcThis.CopyTo(body.AsSpan(offset));
|
||||
offset += orpcThis.Length;
|
||||
|
||||
sourceIpid.TryWriteBytes(body.AsSpan(offset, 16));
|
||||
offset += 16;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset, 4), publicRefs);
|
||||
offset += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(offset, 2), 1);
|
||||
offset += 2;
|
||||
body[offset++] = 0xce;
|
||||
body[offset++] = 0xce; // NDR alignment before the conformant IID array max count.
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset, 4), 1);
|
||||
offset += 4;
|
||||
requestedIid.TryWriteBytes(body.AsSpan(offset, 16));
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public static RemQueryInterfaceResponse ParseRemQueryInterfaceResponse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < OrpcThat.EncodedLengthWithoutExtensions + 4 + RemQiResult.EncodedLength + 4)
|
||||
{
|
||||
throw new ArgumentException("RemQueryInterface response is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
var orpcThat = OrpcThat.Parse(buffer);
|
||||
uint referentId = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(OrpcThat.EncodedLengthWithoutExtensions, 4));
|
||||
int offset = OrpcThat.EncodedLengthWithoutExtensions + 4;
|
||||
RemQiResult? result = null;
|
||||
if (referentId != 0)
|
||||
{
|
||||
offset += 4; // Conformant array max count for the REMQIRESULT result array.
|
||||
result = RemQiResult.Parse(buffer[offset..]);
|
||||
}
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
offset += RemQiResult.EncodedLength;
|
||||
}
|
||||
|
||||
uint errorCode = BinaryPrimitives.ReadUInt32LittleEndian(buffer.Slice(offset, 4));
|
||||
return new RemQueryInterfaceResponse(orpcThat, result, errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RemQueryInterfaceResponse(OrpcThat OrpcThat, RemQiResult? Result, uint ErrorCode);
|
||||
|
||||
public sealed record RemQiResult(int HResult, StdObjRef StandardObjectReference)
|
||||
{
|
||||
public const int EncodedLength = sizeof(int) + sizeof(int) + StdObjRef.EncodedLength;
|
||||
|
||||
public static RemQiResult Parse(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (buffer.Length < EncodedLength)
|
||||
{
|
||||
throw new ArgumentException("REMQIRESULT buffer is too short.", nameof(buffer));
|
||||
}
|
||||
|
||||
return new RemQiResult(
|
||||
HResult: BinaryPrimitives.ReadInt32LittleEndian(buffer[..4]),
|
||||
StandardObjectReference: StdObjRef.Parse(buffer[8..]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user