This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new c771277ea2 fix: robust Modbus Receive with desync detection and resync 
ability (#2362)
c771277ea2 is described below

commit c771277ea27cf75be902609c6b4cc55dc9c33de9
Author: Shaun <[email protected]>
AuthorDate: Thu May 28 00:16:24 2026 -0700

    fix: robust Modbus Receive with desync detection and resync ability (#2362)
    
    * fix: harden Modbus Receive against truncated/extended frames
    - retry parsing with full buffered data when initial MBAP length is wrong
    - treat EOF as incomplete data and avoid dropping partial frames
    - keep discarding truly unparsable packets with diagnostic logging
    
    * fix: watch for and discard trailing CRCs from misbehaving gateways
    fix: attempt to resynchronize the read stream if desynchronized
    
    * fix: additional sanity checks on the MBAP
    fix: io.EOF is a trap, we checked fragmentation above
    
    * fix: deal with TCP keep-alive padding that leaks from the kernel
    
    * refactor: attempting to simply logic while still covering desync handling
    
    * refactor: more robust consistency checks
    
    * fix: Final consistency check case should discard all available bytes
    
    * refactor: reduce log spam
    
    * fix: handleDesync more robustly handles the padding leak issue
    
    * fix: keep the last 5 bytes to avoid breaking fragmentation
---
 plc4go/internal/modbus/MessageCodec.go | 378 +++++++++++++++++++++++++++++----
 plc4go/spi/utils/base64Stringer.go     |  36 ++++
 2 files changed, 377 insertions(+), 37 deletions(-)

diff --git a/plc4go/internal/modbus/MessageCodec.go 
b/plc4go/internal/modbus/MessageCodec.go
index 5aa6456a5a..1c388e7987 100644
--- a/plc4go/internal/modbus/MessageCodec.go
+++ b/plc4go/internal/modbus/MessageCodec.go
@@ -7,7 +7,7 @@
  * "License"); you may not use this file except in compliance
  * with the License.  You may obtain a copy of the License at
  *
- *   https://www.apache.org/licenses/LICENSE-2.0
+ * https://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing,
  * software distributed under the License is distributed on an
@@ -21,7 +21,8 @@ package modbus
 
 import (
        "context"
-       "encoding/base64"
+       "fmt"
+       "io"
 
        "github.com/rs/zerolog"
 
@@ -31,6 +32,7 @@ import (
        "github.com/apache/plc4x/plc4go/spi/errors"
        "github.com/apache/plc4x/plc4go/spi/options"
        "github.com/apache/plc4x/plc4go/spi/transports"
+       "github.com/apache/plc4x/plc4go/spi/utils"
 )
 
 //go:generate go tool plc4xGenerator -type=MessageCodec
@@ -88,6 +90,7 @@ func (m *MessageCodec) Receive(ctx context.Context) 
(spi.Message, error) {
                return nil, errors.New("Transport instance not connected")
        }
 
+       // 1. Fill the buffer
        if err := ti.FillBuffer(ctx, func(pos uint, currentByte byte, reader 
transports.ExtendedReader) bool {
                m.log.Trace().Uint("pos", pos).Uint8("currentByte", 
currentByte).Msg("filling")
                numBytesAvailable, err := ti.GetNumBytesAvailableInBuffer()
@@ -95,50 +98,351 @@ func (m *MessageCodec) Receive(ctx context.Context) 
(spi.Message, error) {
                        m.log.Debug().Err(err).Msg("error getting available 
bytes")
                        return false
                }
-               m.log.Trace().Uint32("numBytesAvailable", 
numBytesAvailable).Msg("check available bytes < 6")
                return numBytesAvailable < 6
        }); err != nil {
-               m.log.Debug().Err(err).Msg("error filling buffer")
+               if err != io.EOF {
+                       m.log.Debug().Err(err).Msg("error filling buffer")
+               }
+               // Fall through on errors, we might have enough data...
        }
 
-       // We need at least 6 bytes in order to know how big the packet is in 
total
-       if num, err := ti.GetNumBytesAvailableInBuffer(); (err == nil) && (num 
>= 6) {
-               m.log.Debug().Uint32("num", num).Msg("we got num readable 
bytes")
-               data, err := ti.PeekReadableBytes(ctx, 6)
-               if err != nil {
-                       m.log.Warn().Err(err).Msg("error peeking")
-                       // TODO: Possibly clean up ...
+       // 2. Check buffer status
+       numBytesAvail, err := ti.GetNumBytesAvailableInBuffer()
+       if err != nil {
+               // Yield if we can't check buffer
+               if err == io.EOF {
                        return nil, nil
                }
-               // Get the size of the entire packet
-               packetSize := (uint32(data[4]) << 8) + uint32(data[5]) + 6
-               if num < packetSize {
-                       m.log.Debug().
-                               Uint32("num", num).
-                               Uint32("packetSize", packetSize).Msg("Not 
enough bytes. Got: num Need: packetSize")
-                       return nil, nil
+               return nil, fmt.Errorf("error getting buffer length")
+       }
+
+       // Need at least 6 bytes for MBAP header
+       if numBytesAvail < 6 {
+               return nil, nil
+       }
+
+       // Re-peek the header at the current head
+       header, err := ti.PeekReadableBytes(ctx, 6)
+       if err != nil {
+               m.log.Warn().Err(err).Msg("error peeking header")
+               return nil, nil
+       }
+
+       // 
-------------------------------------------------------------------------
+       // MBAP SANITY CHECKS
+       // 
-------------------------------------------------------------------------
+
+       // We now peek up to 9 bytes (or whatever is available) to perform the 
full Consistency Check.
+       // IMPORTANT: If we have 8 bytes (Header + Unit + Func), we MUST pass 
all 8 bytes
+       // so that checkPacketConsistency can validate the Function Code and 
Length limits.
+       // If we only pass 6, it will skip the Function check and potentially 
accept huge lengths.
+       var checkBytes []byte
+       peekLen := uint32(9)
+       if numBytesAvail < 9 {
+               peekLen = numBytesAvail
+       }
+       checkBytes, _ = ti.PeekReadableBytes(ctx, peekLen)
+
+       // Perform Consistency Check
+       if !m.checkPacketConsistency(checkBytes) {
+               return m.handleDesync(ctx, "Sanity Check Failed", 
map[string]interface{}{
+                       "data": utils.Base64Stringer(checkBytes),
+               })
+       }
+
+       // 
-------------------------------------------------------------------------
+       // PARSING
+       // 
-------------------------------------------------------------------------
+
+       // Length field is big endian encoded WORD
+       payloadLength := (uint32(header[4]) << 8) + uint32(header[5])
+       packetSize := payloadLength + 6
+
+       // Yield on TCP fragmentation
+       if numBytesAvail < packetSize {
+               // Wait for more data (standard TCP fragmentation handling)
+               var peekedBytes []byte
+               if m.log.Debug().Enabled() {
+                       peekedBytes, _ = ti.PeekReadableBytes(ctx, 
numBytesAvail)
                }
-               data, err = ti.Read(ctx, packetSize)
-               if err != nil {
-                       // TODO: Possibly clean up ...
-                       return nil, nil
+               m.log.Debug().
+                       Stringer("dataFragment", 
utils.Base64Stringer(peekedBytes)).
+                       Uint32("num", numBytesAvail).
+                       Uint32("packetSize", packetSize).Msgf("Received 
fragment. Got: %d Need: %d. Waiting for more data...", numBytesAvail, 
packetSize)
+               return nil, nil
+       }
+
+       // Read the entire frame
+       frameSlice, err := ti.PeekReadableBytes(ctx, packetSize)
+       if err != nil {
+               m.log.Warn().Err(err).Msg("Error peeking frame slice")
+               return nil, nil
+       }
+
+       // Parse the frame
+       ctxForModel := options.GetLoggerContextForModel(ctx, m.log, 
options.WithPassLoggerToModel(m.passLogToModel))
+       tcpAdu, err := model.ModbusADUParse[model.ModbusTcpADU](ctxForModel, 
frameSlice, model.DriverType_MODBUS_TCP, true)
+       if err != nil {
+               // Parser wasn't happy at packetSize, if there is more 
available, try parsing all of it
+               if numBytesAvail > packetSize {
+                       extendedSlice, extendedErr := ti.PeekReadableBytes(ctx, 
numBytesAvail)
+                       if extendedErr == nil {
+                               // Try parsing it all
+                               extendedAdu, extendedParseErr := 
model.ModbusADUParse[model.ModbusTcpADU](ctxForModel, extendedSlice, 
model.DriverType_MODBUS_TCP, true)
+                               if extendedParseErr == nil {
+                                       // Parse succeded...
+                                       // NOTE: MBAP length errors are rare in 
well-behaved devices, but they happen when
+                                       // firmware miscomputes the length 
field (or hard-codes to 6), mixes RTU framing
+                                       // with TCP transport, or when 
intermediate gateways/proxies truncate or merge
+                                       // frames. Duplicate reads and 
out-of-order slicing in buggy drivers can also leave
+                                       // stale data that makes the reported 
length disagree with the actual payload.
+                                       // ... looking at you old Moxa and 
Lantronix "transparent" serial-to-tcp gateways.
+
+                                       // What is the actual ADU size parsed?
+                                       actualSize := 
uint32(extendedAdu.GetLengthInBytes(ctxForModel))
+                                       if actualSize > numBytesAvail {
+                                               return nil, nil
+                                       }
+
+                                       m.log.Info().
+                                               Uint32("reportedSize", 
packetSize).
+                                               Uint32("actualSize", 
actualSize).
+                                               Stringer("extendedData", 
utils.Base64Stringer(extendedSlice)).
+                                               Stringer("consumedData", 
utils.Base64Stringer(extendedSlice[:actualSize])).
+                                               Msg("MBAP had wrong/hardcoded 
length. Consumed extended frame with corrected size")
+                                       if _, consumeErr := ti.Read(ctx, 
actualSize); consumeErr != nil {
+                                               
m.log.Debug().Err(consumeErr).Msg("error consuming extended frame")
+                                               return nil, nil
+                                       }
+                                       return extendedAdu, nil
+                               }
+                       }
                }
-               ctxForModel := options.GetLoggerContextForModel(ctx, m.log, 
options.WithPassLoggerToModel(m.passLogToModel))
-               tcpAdu, err := 
model.ModbusADUParse[model.ModbusTcpADU](ctxForModel, data, 
model.DriverType_MODBUS_TCP, true)
-               if err != nil {
-                       dataStr := base64.StdEncoding.EncodeToString(data)
-                       m.log.Warn().Err(err).
-                               Str("data", dataStr). // Max PDU size is 253 
bytes, and catching parse errors for inspection is important
-                               Uint32("packetSize", packetSize).
-                               Msg("error parsing")
-                       // TODO: Possibly clean up ...
-                       return nil, nil
+
+               // Seems unparsable - log and discard
+               // NOTE: If checkPacketConsistency PASSED, but Parse FAILED, it 
means the header
+               // structure is valid (Length, Proto, Func), but the Content is 
bad.
+               // We MUST discard this frame to advance the stream.
+               // We DO NOT call handleDesync here because we don't want to 
scan ahead;
+               // we just want to eat the bad packet and try the next one.
+               m.log.Warn().Err(err).
+                       Stringer("data", utils.Base64Stringer(frameSlice)).
+                       Uint32("packetSize", packetSize).
+                       Msg("Error parsing frame. Discarding invalid frame.")
+
+               // Discard the unparsable frame from the buffer
+               if _, discardErr := ti.Read(ctx, packetSize); discardErr != nil 
{
+                       m.log.Debug().Err(discardErr).Uint32("packetSize", 
packetSize).Msg("error discarding unparsable frame")
+                       return nil, discardErr
+               }
+
+               // Yield
+               return nil, nil
+       }
+
+       // 
-------------------------------------------------------------------------
+       // SUCCESS
+       // 
-------------------------------------------------------------------------
+       if _, consumeErr := ti.Read(ctx, packetSize); consumeErr != nil {
+               m.log.Debug().Err(consumeErr).Msg("error consuming parsed 
frame")
+               return nil, nil
+       }
+
+       return tcpAdu, nil
+}
+
+// checkPacketConsistency validates a candidate Modbus TCP packet against the 
protocol spec.
+// It enforces strict relationship rules between the MBAP Header Length and 
the PDU content (Function Code & Byte Count).
+// Returns true if the packet looks valid, false if it is definitely garbage.
+// NOTE: When new functions are added to the mspec, this needs to be updated 
to reflect their structure.
+func (m *MessageCodec) checkPacketConsistency(data []byte) bool {
+       // 1. Check Protocol ID immediately (Need 4 bytes)
+       if len(data) < 4 {
+               return false
+       }
+       // Modbus TCP Protocol ID must be 0
+       if data[2] != 0x00 || data[3] != 0x00 {
+               return false
+       }
+
+       // 2. Check Header Length (Need 6 bytes)
+       if len(data) < 6 {
+               // Not enough data to check length, but Protocol ID was OK.
+               // We shouldn't reject yet if we just don't have the bytes.
+               // However, this function is typically called with a slice that 
*should* contain the header.
+               return false
+       }
+
+       length := (uint32(data[4]) << 8) + uint32(data[5])
+       if length < 2 {
+               return false
+       }
+
+       // 3. Check Function Code (Need 8 bytes: 6 Header + 1 Unit + 1 Func)
+       if len(data) < 8 {
+               // We have a valid ProtoID and Length, but not enough data to 
check content.
+               // Return true (benefit of the doubt) because we can't prove 
it's bad yet.
+               return true
+       }
+
+       fc := data[7]
+       if fc == 0 {
+               return false
+       }
+
+       // VALIDATION: Check Internal Consistency of Response Structure
+       // NOTE: These rules are primarily for Modbus TCP RESPONSES (Client 
Mode).
+       // If acting as a Server (receiving Requests), some rules (like Write 
Multiple) would be different.
+
+       if fc >= 0x80 {
+               // --- EXCEPTION ---
+               // Structure: [UnitID] [FuncCode+0x80] [ExceptionCode]
+               // Length must be exactly 3.
+               return length == 3
+       }
+
+       // --- STANDARD FUNCTIONS ---
+       switch fc {
+       case 0x01, 0x02, 0x03, 0x04, 0x17, 0x14, 0x15:
+               // Variable Length Responses
+               // Rule: Header Length == ByteCount + 3
+               // We need 9 bytes to see the ByteCount at offset 8.
+               if len(data) >= 9 {
+                       byteCount := uint32(data[8])
+                       if length != byteCount+3 {
+                               return false
+                       }
+               }
+               // Implicit Max Check: Max ByteCount 255 -> Max Length 258.
+               if length > 258 {
+                       return false
+               }
+
+       case 0x05, 0x06, 0x0F, 0x10:
+               // Fixed Length Responses (Write Single, Write Multi)
+               // Structure: [UnitID] [FuncCode] [AddrHi] [AddrLo] [ValHi] 
[ValLo]
+               // Length must be exactly 6.
+               if length != 6 {
+                       // SERVER MODE CAVEAT:
+                       // If we are a Server receiving a Write Multiple 
Request (FC 15/16),
+                       // the length will be > 6. If we strictly return false 
here, we break Server mode.
+                       // As a heuristic, if length is > 6 for these codes, we 
treat it as potentially valid
+                       // (assuming it's a Request) to be safe, unless it's 
huge.
+                       if length > 260 {
+                               return false
+                       }
+                       // Ideally we would enforce Request structure (Len = 
ByteCount + 7),
+                       // but that requires more bytes than we might have 
peeked.
+                       return true
+               }
+
+       case 0x16:
+               // Mask Write Register
+               // Length must be exactly 8.
+               if length != 8 {
+                       return false
+               }
+
+       default:
+               // Other/Custom Functions.
+               // Max PDU size is 253 -> Length 254. Allow margin.
+               if length > 260 {
+                       return false
+               }
+       }
+
+       return true
+}
+
+// handleDesync handles stream realignment when an invalid header is detected 
at the head.
+// It strictly scans the available buffer for a valid MBAP header using 
checkPacketConsistency.
+// If one is found, it realigns the stream.
+// If NO valid header is found in the *entire* buffer, it treats the 
connection as dead.
+func (m *MessageCodec) handleDesync(ctx context.Context, reason string, fields 
map[string]interface{}) (spi.Message, error) {
+       ti := m.GetTransportInstance()
+
+       // Get total available bytes
+       numBytesAvail, err := ti.GetNumBytesAvailableInBuffer()
+       if err != nil {
+               return nil, err // Yield if we can't check buffer
+       }
+
+       // Create a logger with context
+       fields["reason"] = reason
+       fields["bytesAvail"] = numBytesAvail
+       opLog := m.log.With().
+               Interface("desyncContext", fields).
+               Logger()
+
+       // Log on the way out to minimize log spam
+       dispMsg := ""
+       dispLevel := zerolog.WarnLevel
+       defer func() {
+               opLog.WithLevel(dispLevel).Msg("Desync detected at stream head: 
" + dispMsg)
+       }()
+
+       // CASE 1: Small Buffer (< 10 bytes).
+       // We don't have enough data to scan for a full header+function (need 
~8-9 bytes min).
+       // We can't definitively say the stream is dead, so we just Discard 1 
and Yield.
+       if numBytesAvail < 10 {
+               dispMsg = "Small buffer - Discard 1"
+               dispLevel = zerolog.DebugLevel
+               if _, err := ti.Read(ctx, 1); err != nil {
+                       opLog.Debug().Err(err).Msg("Error reading byte during 
discard")
+                       return nil, err // Return error to kill connection if 
we can't consume
                }
-               return tcpAdu, nil
-       } else if err != nil {
-               m.log.Warn().Err(err).Msg("Got error reading")
                return nil, nil
        }
-       // TODO: maybe we return here a not enough error error
-       return nil, nil
+
+       // CASE 2: Scan for Recovery.
+       // We peek everything we have.
+       allBytes, err := ti.PeekReadableBytes(ctx, numBytesAvail)
+       if err != nil {
+               return nil, fmt.Errorf("error peeking bytes during desync 
recovery")
+       }
+
+       // Scan Loop: Start at offset 1 (since offset 0 is known bad).
+       // We verify candidates up to the end of the buffer.
+       // We stop when we don't have enough bytes left to even check the 
Protocol ID (4 bytes).
+       // ... This deals with any gateway forwarding RTU CRC16 extra bytes or 
leaky kernels that
+       // shove TCP keep-alive Ethernet II frame padding bytes into the stream.
+       limit := uint32(0)
+       if numBytesAvail >= 4 {
+               limit = numBytesAvail - 4
+       }
+
+       for i := uint32(1); i <= limit; i++ {
+               // Use our robust consistency check on the slice starting at i
+               if m.checkPacketConsistency(allBytes[i:]) {
+                       dispMsg = fmt.Sprintf("Found MBAP candidate at +%d", i)
+                       dispLevel = zerolog.DebugLevel
+
+                       // Discard 'i' bytes to align the stream to this 
candidate
+                       if _, err := ti.Read(ctx, i); err != nil {
+                               opLog.Debug().Err(err).Msg("Error discarding 
garbage during recovery")
+                               return nil, err // Return error to kill 
connection
+                       }
+
+                       // Yield. Next Receive() will pick up this valid header.
+                       return nil, nil
+               }
+       }
+
+       // CASE 3: We scanned the entire available buffer and found NO valid 
candidates.
+       // We want to keep the last 5 bytes, as they might be the start of a 
header
+       // (TransID[2] + ProtoID[2] + Len[1 here, 1 missing]) that completes in 
the
+       // next TCP packet of at least 1 byte.
+       bytesToDiscard := numBytesAvail
+       if numBytesAvail > 5 {
+               bytesToDiscard = numBytesAvail - 5
+       }
+
+       dispMsg = fmt.Sprintf("No MBAP candidate found. Discarding (%d) bytes, 
keeping 5-byte tail", bytesToDiscard)
+       dispLevel = zerolog.InfoLevel
+       if _, err := ti.Read(ctx, bytesToDiscard); err != nil {
+               opLog.Debug().Err(err).Msg("Error discarding garbage during 
recovery")
+               return nil, err // Return error to kill connection
+       }
+
+       return nil, fmt.Errorf("stream desynchronized: discarded %d bytes of 
garbage", bytesToDiscard)
 }
diff --git a/plc4go/spi/utils/base64Stringer.go 
b/plc4go/spi/utils/base64Stringer.go
new file mode 100644
index 0000000000..098421debb
--- /dev/null
+++ b/plc4go/spi/utils/base64Stringer.go
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package utils
+
+import (
+       "encoding/base64"
+)
+
+// Base64Stringer is a helper to print byte slices as base64 encoded strings
+// and can be used in structured logging, so that String() is only called when
+// the log level requires it.
+type Base64Stringer []byte
+
+func (b Base64Stringer) String() string {
+       if b == nil {
+               return "<nil>"
+       }
+       return base64.StdEncoding.EncodeToString(b)
+}

Reply via email to