diff --git a/Library/DiscUtils.Iscsi/Connection.cs b/Library/DiscUtils.Iscsi/Connection.cs index 0b4abff8..173c82ee 100644 --- a/Library/DiscUtils.Iscsi/Connection.cs +++ b/Library/DiscUtils.Iscsi/Connection.cs @@ -26,6 +26,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Sockets; @@ -46,6 +47,17 @@ internal sealed class Connection : IDisposable private readonly NetworkStream _stream; + /// + /// Semaphore to synchronize access to the network stream. NOP-Out responses from the + /// keepalive timer and normal Send/ReadPdu traffic must not interleave. + /// + private readonly SemaphoreSlim _streamSemaphore = new(1, 1); + + /// + /// Timer that periodically sends NOP-Out pings to keep the iSCSI session alive. + /// + private Timer _keepAliveTimer; + public Connection(Session session, TargetAddress address, Authenticator[] authenticators) { Session = session; @@ -80,6 +92,14 @@ public Connection(Session session, TargetAddress address, Authenticator[] authen _negotiatedParameters = []; NegotiateSecurity(); NegotiateFeatures(); + + // Start keepalive timer after login completes. + // Send a NOP-Out every 10 seconds to prevent the target from + // timing out the session during idle periods. + _keepAliveTimer = new Timer(callback: KeepAliveCallback, + state: null, + dueTime: TimeSpan.FromSeconds(10), + period: TimeSpan.FromSeconds(10)); } internal LoginStages CurrentLoginStage { get; private set; } = LoginStages.SecurityNegotiation; @@ -106,17 +126,30 @@ public void Close(LogoutReason reason) { try { - var req = new LogoutRequest(this); - var packet = req.GetBytes(reason); - _stream.Write(packet, 0, packet.Length); - _stream.Flush(); + _streamSemaphore.Wait(); + try + { + // Stop the keepalive timer while holding the semaphore + // to ensure no callback is in-flight. + _keepAliveTimer?.Dispose(); + _keepAliveTimer = null; + + var req = new LogoutRequest(this); + var packet = req.GetBytes(reason); + _stream.Write(packet, 0, packet.Length); + _stream.Flush(); - var pdu = ReadPdu(); - var resp = ParseResponse(pdu); + var pdu = ReadPdu(); + var resp = ParseResponse(pdu); - if (resp.Response != LogoutResponseCode.ClosedSuccessfully) + if (resp.Response != LogoutResponseCode.ClosedSuccessfully) + { + throw new InvalidProtocolException($"Target indicated failure during logout: {resp.Response}"); + } + } + finally { - throw new InvalidProtocolException($"Target indicated failure during logout: {resp.Response}"); + _streamSemaphore.Release(); } } catch (EndOfStreamException) @@ -133,6 +166,73 @@ public void Close(LogoutReason reason) } } + /// + /// Timer callback: sends a NOP-Out to keep the iSCSI session alive. + /// Uses ITT=0xFFFFFFFF and TTT=0xFFFFFFFF so the target does not send a NOP-In response + /// (RFC 3720 §10.18), avoiding read-side contention with the main Send path. + /// + private async void KeepAliveCallback(object _) + { + if (!_streamSemaphore.Wait(0)) + { + // A Send or Close is in progress; skip this ping and retry on the next timer tick. + return; + } + + try + { +#if DEBUG + Trace.WriteLine("Sending keep-alive..."); +#endif + + await SendNopOutAsync().ConfigureAwait(false); + } + catch (IOException) + { + // Connection is dead — the next Send will surface the error. + } + catch (ObjectDisposedException) + { + // Stream was disposed — connection is shutting down. + } + finally + { + _streamSemaphore.Release(); + } + } + + private byte[] nopOut; + + /// + /// Sends an initiator-initiated NOP-Out with ITT=0xFFFFFFFF and TTT=0xFFFFFFFF. + /// This is a "ping" that keeps the iSCSI session alive without requiring a response. + /// + private async ValueTask SendNopOutAsync() + { + if (nopOut is null) + { + nopOut = new byte[48]; + + // Byte 0: Immediate (0x40) | OpCode NopOut (0x00) + nopOut[0] = 0x40; + // Byte 1: Final bit + nopOut[1] = 0x80; + // Bytes 16-19: ITT = 0xFFFFFFFF (no response expected) + EndianUtilities.WriteBytesBigEndian(0xFFFFFFFF, nopOut, 16); + // Bytes 20-23: TTT = 0xFFFFFFFF + EndianUtilities.WriteBytesBigEndian(0xFFFFFFFF, nopOut, 20); + } + + // Bytes 24-27: CmdSN (not incremented for immediate PDUs) + EndianUtilities.WriteBytesBigEndian(Session.CommandSequenceNumber, nopOut, 24); + // Bytes 28-31: ExpStatSN + EndianUtilities.WriteBytesBigEndian(ExpectedStatusSequenceNumber, nopOut, 28); + + await _stream.WriteAsync(nopOut).ConfigureAwait(false); + + await _stream.FlushAsync().ConfigureAwait(false); + } + /// /// Sends an SCSI command (aka task) to a LUN via the connected target. /// @@ -142,145 +242,154 @@ public void Close(LogoutReason reason) /// The number of bytes received. public int Send(ScsiCommand cmd, ReadOnlySpan outBuffer, Span inBuffer) { - for (var i = 0; ; i++) + _streamSemaphore.Wait(); + + try { - try + for (var i = 0; ; i++) { - // RFC 3720 Bug #8a: Allocate new Task Tag for this command - // Originally added because we thought each command must have a unique ITT - // TESTING RESULT: NOT NECESSARY - Test passes without this call - // The Session already maintains ITT correctly without explicit increment here - // Leaving commented for reference in case unique ITT per command is needed in future - //Session.NextTaskTag(); + try + { + // RFC 3720 Bug #8a: Allocate new Task Tag for this command + // Originally added because we thought each command must have a unique ITT + // TESTING RESULT: NOT NECESSARY - Test passes without this call + // The Session already maintains ITT correctly without explicit increment here + // Leaving commented for reference in case unique ITT per command is needed in future + //Session.NextTaskTag(); - // RFC 3720: DataSN starts at 0 for each new command - var expectedDataSN = 0u; + // RFC 3720: DataSN starts at 0 for each new command + var expectedDataSN = 0u; - var req = new CommandRequest(this, cmd.TargetLun); + var req = new CommandRequest(this, cmd.TargetLun); - var outBufferCount = outBuffer.Length; - var inBufferMax = inBuffer.Length; + var outBufferCount = outBuffer.Length; + var inBufferMax = inBuffer.Length; - var toSend = Math.Min(outBufferCount, Session.ImmediateData ? Session.FirstBurstLength : 0); + var toSend = Math.Min(outBufferCount, Session.ImmediateData ? Session.FirstBurstLength : 0); - // F bit (isFinalData) = true means "no more unsolicited Data-Out PDUs will follow". - // Even if we're sending < outBufferCount, we set F=1 because any remaining data - // will be sent via solicited Data-Out (after R2T), not more unsolicited Data-Out. + // F bit (isFinalData) = true means "no more unsolicited Data-Out PDUs will follow". + // Even if we're sending < outBufferCount, we set F=1 because any remaining data + // will be sent via solicited Data-Out (after R2T), not more unsolicited Data-Out. - // Simpler logic: Use buffer sizes directly - var expectedTransferLength = outBufferCount != 0 ? (uint)outBufferCount : (uint)inBufferMax; + // Simpler logic: Use buffer sizes directly + var expectedTransferLength = outBufferCount != 0 ? (uint)outBufferCount : (uint)inBufferMax; - var packet = req.GetBytes(cmd, - immediateData: outBuffer.Slice(0, toSend), - isFinalData: true, - willRead: inBufferMax != 0, - willWrite: outBufferCount != 0, - expected: expectedTransferLength); + var packet = req.GetBytes(cmd, + immediateData: outBuffer.Slice(0, toSend), + isFinalData: true, + willRead: inBufferMax != 0, + willWrite: outBufferCount != 0, + expected: expectedTransferLength); - _stream.Write(packet, 0, packet.Length); - _stream.Flush(); + _stream.Write(packet, 0, packet.Length); + _stream.Flush(); - var numSent = toSend; - while (numSent < outBufferCount) - { - var pdu = ReadPdu(); + var numSent = toSend; + while (numSent < outBufferCount) + { + var pdu = ReadPdu(); - var resp = ParseResponse(pdu); - var numApproved = (int)resp.DesiredTransferLength; - var targetTransferTag = resp.TargetTransferTag; + var resp = ParseResponse(pdu); + var numApproved = (int)resp.DesiredTransferLength; + var targetTransferTag = resp.TargetTransferTag; - // DataSN resets to 0 for each R2T (not continuous across multiple R2Ts) - var pktsSent = 0; - while (numApproved > 0) - { - toSend = Math.Min(Math.Min(outBufferCount - numSent, numApproved), MaxTargetReceiveDataSegmentLength ?? MaxInitiatorTransmitDataSegmentLength); - - var pkt = new DataOutPacket(this, cmd.TargetLun); - var currentDataSN = pktsSent++; - - packet = pkt.GetBytes(data: outBuffer.Slice(numSent, toSend), - isFinalData: toSend == numApproved, - dataSeqNumber: currentDataSN, - bufferOffset: (uint)numSent, - targetTransferTag: targetTransferTag); - - _stream.Write(packet, 0, packet.Length); - _stream.Flush(); - - numApproved -= toSend; - numSent += toSend; - } - } + // DataSN resets to 0 for each R2T (not continuous across multiple R2Ts) + var pktsSent = 0; + while (numApproved > 0) + { + toSend = Math.Min(Math.Min(outBufferCount - numSent, numApproved), MaxTargetReceiveDataSegmentLength ?? MaxInitiatorTransmitDataSegmentLength); - var isFinal = false; - var numRead = 0; - while (!isFinal) - { - var pdu = ReadPdu(); + var pkt = new DataOutPacket(this, cmd.TargetLun); + var currentDataSN = pktsSent++; + + packet = pkt.GetBytes(data: outBuffer.Slice(numSent, toSend), + isFinalData: toSend == numApproved, + dataSeqNumber: currentDataSN, + bufferOffset: (uint)numSent, + targetTransferTag: targetTransferTag); + + _stream.Write(packet, 0, packet.Length); + _stream.Flush(); - if (pdu.OpCode == OpCode.ScsiResponse) + numApproved -= toSend; + numSent += toSend; + } + } + + var isFinal = false; + var numRead = 0; + while (!isFinal) { - var resp = ParseResponse(pdu); + var pdu = ReadPdu(); - if (resp.StatusPresent && resp.Status == ScsiStatus.CheckCondition) + if (pdu.OpCode == OpCode.ScsiResponse) { - var senseLength = EndianUtilities.ToUInt16BigEndian(pdu.ContentData, 0); - var senseData = pdu.ContentData.AsSpan(2, senseLength).ToArray(); + var resp = ParseResponse(pdu); - if (i == 0 && ScsiSenseParser.TryParse(senseData, out var sense) && sense.IndicatesRetryRequired) + if (resp.StatusPresent && resp.Status == ScsiStatus.CheckCondition) { - goto retry; - } + var senseLength = EndianUtilities.ToUInt16BigEndian(pdu.ContentData, 0); + var senseData = pdu.ContentData.AsSpan(2, senseLength).ToArray(); - throw new ScsiCommandException(resp.Status, senseData); - } + if (i == 0 && ScsiSenseParser.TryParse(senseData, out var sense) && sense.IndicatesRetryRequired) + { + goto retry; + } - if (resp.StatusPresent && resp.Status != ScsiStatus.Good) - { - throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); - } + throw new ScsiCommandException(resp.Status, senseData); + } - isFinal = resp.Header.FinalPdu; - } - else if (pdu.OpCode == OpCode.ScsiDataIn) - { - var resp = ParseResponse(pdu); + if (resp.StatusPresent && resp.Status != ScsiStatus.Good) + { + throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); + } - // RFC 3720 Section 10.7.4: Validate DataSN sequence - if (resp.DataSequenceNumber != expectedDataSN) - { - throw new InvalidProtocolException($"DataSN mismatch: received {resp.DataSequenceNumber}, expected {expectedDataSN}"); + isFinal = resp.Header.FinalPdu; } - - if (resp.StatusPresent && resp.Status != ScsiStatus.Good) + else if (pdu.OpCode == OpCode.ScsiDataIn) { - throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); - } + var resp = ParseResponse(pdu); - if (resp.ReadData != null) - { - resp.ReadData.AsSpan().CopyTo(inBuffer.Slice((int)resp.BufferOffset)); - numRead += resp.ReadData.Length; - } + // RFC 3720 Section 10.7.4: Validate DataSN sequence + if (resp.DataSequenceNumber != expectedDataSN) + { + throw new InvalidProtocolException($"DataSN mismatch: received {resp.DataSequenceNumber}, expected {expectedDataSN}"); + } - isFinal = resp.Header.FinalPdu; + if (resp.StatusPresent && resp.Status != ScsiStatus.Good) + { + throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); + } - expectedDataSN++; + if (resp.ReadData != null) + { + resp.ReadData.AsSpan().CopyTo(inBuffer.Slice((int)resp.BufferOffset)); + numRead += resp.ReadData.Length; + } + + isFinal = resp.Header.FinalPdu; + + expectedDataSN++; + } } - } - return Math.Max(numRead, numSent); - } - finally - { - Session.NextTaskTag(); - Session.NextCommandSequenceNumber(); - } + return Math.Max(numRead, numSent); + } + finally + { + Session.NextTaskTag(); + Session.NextCommandSequenceNumber(); + } - retry: - { + retry: + { + } } } + finally + { + _streamSemaphore.Release(); + } } /// @@ -293,123 +402,132 @@ public int Send(ScsiCommand cmd, ReadOnlySpan outBuffer, Span inBuff /// The number of bytes received. public async ValueTask SendAsync(ScsiCommand cmd, ReadOnlyMemory outBuffer, Memory inBuffer, CancellationToken cancellationToken) { - for (var i = 0; ; i++) + await _streamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try { - try + for (var i = 0; ; i++) { - // RFC 3720: DataSN starts at 0 for each new command - var expectedDataSN = 0u; + try + { + // RFC 3720: DataSN starts at 0 for each new command + var expectedDataSN = 0u; - var req = new CommandRequest(this, cmd.TargetLun); + var req = new CommandRequest(this, cmd.TargetLun); - var outBufferCount = outBuffer.Length; - var inBufferMax = inBuffer.Length; + var outBufferCount = outBuffer.Length; + var inBufferMax = inBuffer.Length; - var toSend = Math.Min(outBufferCount, Session.ImmediateData ? Session.FirstBurstLength : 0); + var toSend = Math.Min(outBufferCount, Session.ImmediateData ? Session.FirstBurstLength : 0); - // F bit (isFinalData) = true means "no more unsolicited Data-Out PDUs will follow". - // Even if we're sending < outBufferCount, we set F=1 because any remaining data - // will be sent via solicited Data-Out (after R2T), not more unsolicited Data-Out. + // F bit (isFinalData) = true means "no more unsolicited Data-Out PDUs will follow". + // Even if we're sending < outBufferCount, we set F=1 because any remaining data + // will be sent via solicited Data-Out (after R2T), not more unsolicited Data-Out. - // Simpler logic: Use buffer sizes directly - var expectedTransferLength = outBufferCount != 0 ? (uint)outBufferCount : (uint)inBufferMax; + // Simpler logic: Use buffer sizes directly + var expectedTransferLength = outBufferCount != 0 ? (uint)outBufferCount : (uint)inBufferMax; - var packet = req.GetBytes(cmd, outBuffer.Span.Slice(0, toSend), true, inBufferMax != 0, outBufferCount != 0, expectedTransferLength); - await _stream.WriteAsync(packet, cancellationToken).ConfigureAwait(false); - await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); - var numSent = toSend; - var pktsSent = 0; - while (numSent < outBufferCount) - { - var pdu = await ReadPduAsync(cancellationToken).ConfigureAwait(false); + var packet = req.GetBytes(cmd, outBuffer.Span.Slice(0, toSend), true, inBufferMax != 0, outBufferCount != 0, expectedTransferLength); + await _stream.WriteAsync(packet, cancellationToken).ConfigureAwait(false); + await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + var numSent = toSend; + var pktsSent = 0; + while (numSent < outBufferCount) + { + var pdu = await ReadPduAsync(cancellationToken).ConfigureAwait(false); - var resp = ParseResponse(pdu); - var numApproved = (int)resp.DesiredTransferLength; - var targetTransferTag = resp.TargetTransferTag; + var resp = ParseResponse(pdu); + var numApproved = (int)resp.DesiredTransferLength; + var targetTransferTag = resp.TargetTransferTag; - while (numApproved > 0) - { - toSend = Math.Min(Math.Min(outBufferCount - numSent, numApproved), MaxTargetReceiveDataSegmentLength ?? MaxInitiatorTransmitDataSegmentLength); + while (numApproved > 0) + { + toSend = Math.Min(Math.Min(outBufferCount - numSent, numApproved), MaxTargetReceiveDataSegmentLength ?? MaxInitiatorTransmitDataSegmentLength); - var pkt = new DataOutPacket(this, cmd.TargetLun); - var currentDataSN = pktsSent++; - packet = pkt.GetBytes(outBuffer.Span.Slice(numSent, toSend), toSend == numApproved, currentDataSN, (uint)numSent, targetTransferTag); - await _stream.WriteAsync(packet, cancellationToken).ConfigureAwait(false); - await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); + var pkt = new DataOutPacket(this, cmd.TargetLun); + var currentDataSN = pktsSent++; + packet = pkt.GetBytes(outBuffer.Span.Slice(numSent, toSend), toSend == numApproved, currentDataSN, (uint)numSent, targetTransferTag); + await _stream.WriteAsync(packet, cancellationToken).ConfigureAwait(false); + await _stream.FlushAsync(cancellationToken).ConfigureAwait(false); - numApproved -= toSend; - numSent += toSend; + numApproved -= toSend; + numSent += toSend; + } } - } - var isFinal = false; - var numRead = 0; - while (!isFinal) - { - var pdu = await ReadPduAsync(cancellationToken).ConfigureAwait(false); - - if (pdu.OpCode == OpCode.ScsiResponse) + var isFinal = false; + var numRead = 0; + while (!isFinal) { - var resp = ParseResponse(pdu); + var pdu = await ReadPduAsync(cancellationToken).ConfigureAwait(false); - if (resp.StatusPresent && resp.Status == ScsiStatus.CheckCondition) + if (pdu.OpCode == OpCode.ScsiResponse) { - var senseLength = EndianUtilities.ToUInt16BigEndian(pdu.ContentData, 0); - var senseData = pdu.ContentData.AsSpan(2, senseLength).ToArray(); + var resp = ParseResponse(pdu); - if (i == 0 && ScsiSenseParser.TryParse(senseData, out var sense) && sense.IndicatesRetryRequired) + if (resp.StatusPresent && resp.Status == ScsiStatus.CheckCondition) { - goto retry; + var senseLength = EndianUtilities.ToUInt16BigEndian(pdu.ContentData, 0); + var senseData = pdu.ContentData.AsSpan(2, senseLength).ToArray(); + + if (i == 0 && ScsiSenseParser.TryParse(senseData, out var sense) && sense.IndicatesRetryRequired) + { + goto retry; + } + + throw new ScsiCommandException(resp.Status, senseData); } - throw new ScsiCommandException(resp.Status, senseData); - } + if (resp.StatusPresent && resp.Status != ScsiStatus.Good) + { + throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); + } - if (resp.StatusPresent && resp.Status != ScsiStatus.Good) - { - throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); + isFinal = resp.Header.FinalPdu; } + else if (pdu.OpCode == OpCode.ScsiDataIn) + { + var resp = ParseResponse(pdu); - isFinal = resp.Header.FinalPdu; - } - else if (pdu.OpCode == OpCode.ScsiDataIn) - { - var resp = ParseResponse(pdu); + // RFC 3720 Section 10.7.4: Validate DataSN sequence + if (resp.DataSequenceNumber != expectedDataSN) + { + throw new InvalidProtocolException($"DataSN mismatch: received {resp.DataSequenceNumber}, expected {expectedDataSN}"); + } + expectedDataSN++; - // RFC 3720 Section 10.7.4: Validate DataSN sequence - if (resp.DataSequenceNumber != expectedDataSN) - { - throw new InvalidProtocolException($"DataSN mismatch: received {resp.DataSequenceNumber}, expected {expectedDataSN}"); - } - expectedDataSN++; + if (resp.StatusPresent && resp.Status != ScsiStatus.Good) + { + throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); + } - if (resp.StatusPresent && resp.Status != ScsiStatus.Good) - { - throw new ScsiCommandException(resp.Status, "Target indicated SCSI failure"); - } + if (resp.ReadData != null) + { + resp.ReadData.CopyTo(inBuffer.Slice((int)resp.BufferOffset)); + numRead += resp.ReadData.Length; + } - if (resp.ReadData != null) - { - resp.ReadData.CopyTo(inBuffer.Slice((int)resp.BufferOffset)); - numRead += resp.ReadData.Length; + isFinal = resp.Header.FinalPdu; } - - isFinal = resp.Header.FinalPdu; } - } - return Math.Max(numRead, numSent); - } - finally - { - Session.NextTaskTag(); - Session.NextCommandSequenceNumber(); - } + return Math.Max(numRead, numSent); + } + finally + { + Session.NextTaskTag(); + Session.NextCommandSequenceNumber(); + } - retry: - { + retry: + { + } } } + finally + { + _streamSemaphore.Release(); + } } public T Send(ScsiCommand cmd, ReadOnlySpan buffer, int expected) @@ -816,32 +934,115 @@ private void NegotiateFeatures() private ProtocolDataUnit ReadPdu() { - var pdu = ProtocolDataUnit.ReadFrom(_stream, HeaderDigest != Digest.None, DataDigest != Digest.None); - - if (pdu.OpCode == OpCode.Reject) + const int MaxNopInRetries = 16; + for (var nopCount = 0; ; nopCount++) { - var pkt = new RejectPacket(); - pkt.Parse(pdu); + var pdu = ProtocolDataUnit.ReadFrom(_stream, HeaderDigest != Digest.None, DataDigest != Digest.None); - throw new IscsiException($"Target sent reject packet, reason {pkt.Reason}"); - } + if (pdu.OpCode == OpCode.Reject) + { + var pkt = new RejectPacket(); + pkt.Parse(pdu); + + throw new IscsiException($"Target sent reject packet, reason {pkt.Reason}"); + } + + // RFC 3720 §10.19: Handle target-initiated NOP-In (ping). + // Respond with NOP-Out echoing the Target Transfer Tag, then + // continue reading the next PDU. + if (pdu.OpCode == OpCode.NopIn) + { + if (nopCount >= MaxNopInRetries) + { + throw new InvalidProtocolException($"Received {nopCount} consecutive NOP-In PDUs without a command response"); + } + + HandleNopIn(pdu); + continue; + } - return pdu; + return pdu; + } } private async ValueTask ReadPduAsync(CancellationToken cancellationToken) { - var pdu = await ProtocolDataUnit.ReadFromAsync(_stream, HeaderDigest != Digest.None, DataDigest != Digest.None, cancellationToken).ConfigureAwait(false); - - if (pdu.OpCode == OpCode.Reject) + const int MaxNopInRetries = 16; + for (var nopCount = 0; ; nopCount++) { - var pkt = new RejectPacket(); - pkt.Parse(pdu); + var pdu = await ProtocolDataUnit.ReadFromAsync(_stream, HeaderDigest != Digest.None, DataDigest != Digest.None, cancellationToken).ConfigureAwait(false); + + if (pdu.OpCode == OpCode.Reject) + { + var pkt = new RejectPacket(); + pkt.Parse(pdu); + + throw new IscsiException($"Target sent reject packet, reason {pkt.Reason}"); + } + + if (pdu.OpCode == OpCode.NopIn) + { + if (nopCount >= MaxNopInRetries) + { + throw new InvalidProtocolException($"Received {nopCount} consecutive NOP-In PDUs without a command response"); + } - throw new IscsiException($"Target sent reject packet, reason {pkt.Reason}"); + HandleNopIn(pdu); + continue; + } + + return pdu; } + } + + /// + /// Responds to a target-initiated NOP-In with a NOP-Out (RFC 3720 §10.18/10.19). + /// Target-initiated NOP-In has TTT != 0xFFFFFFFF and requires a NOP-Out reply. + /// The StatSN in a target-initiated NOP-In is informational and does NOT consume + /// a sequence number, so we must NOT call SeenStatusSequenceNumber here. + /// + private void HandleNopIn(ProtocolDataUnit pdu) + { + var headerData = pdu.HeaderData; - return pdu; + // Target Transfer Tag at offset 20 + var targetTransferTag = EndianUtilities.ToUInt32BigEndian(headerData.AsSpan(20)); + + // If TTT is 0xFFFFFFFF, this is a response to our own NOP-Out (unsolicited); + // no reply needed. + if (targetTransferTag == 0xFFFFFFFF) + { + return; + } + + // LUN at offset 8 + var lun = EndianUtilities.ToUInt64BigEndian(headerData.AsSpan(8)); + + // NOTE: We intentionally do NOT call SeenStatusSequenceNumber() here. + // RFC 3720 §10.19: A target-initiated NOP-In carries a StatSN, but this + // StatSN is NOT consumed — it's the same value the target will use for the + // next real command response. Advancing ExpectedStatusSequenceNumber here + // would cause the next ParseResponse to fail with a sequence mismatch. + + // Build NOP-Out response (RFC 3720 §10.18) + var nopOut = new byte[48]; + // Byte 0: Immediate bit (0x40) | OpCode NopOut (0x00) + nopOut[0] = 0x40; + // Byte 1: Final bit (0x80) + nopOut[1] = 0x80; + // Bytes 8-15: LUN (copy from NOP-In) + EndianUtilities.WriteBytesBigEndian(lun, nopOut, 8); + // Bytes 16-19: Initiator Task Tag = 0xFFFFFFFF (response to target ping) + EndianUtilities.WriteBytesBigEndian(0xFFFFFFFF, nopOut, 16); + // Bytes 20-23: Target Transfer Tag (echo from NOP-In) + EndianUtilities.WriteBytesBigEndian(targetTransferTag, nopOut, 20); + // Bytes 24-27: CmdSN (not consumed for immediate PDUs with ITT=0xFFFFFFFF) + EndianUtilities.WriteBytesBigEndian(Session.CommandSequenceNumber, nopOut, 24); + // Bytes 28-31: ExpStatSN + EndianUtilities.WriteBytesBigEndian(ExpectedStatusSequenceNumber, nopOut, 28); + + _stream.Write(nopOut, 0, nopOut.Length); + _stream.Flush(); } private void GetParametersToNegotiate(TextBuffer parameters, KeyUsagePhase phase, SessionType sessionType)