Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

31 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mbproxy

A lightweight Modbus TCP proxy with in-memory caching. Designed to reduce load on Modbus devices by serving cached responses to multiple clients.

Features

  • Caching: In-memory cache with configurable TTL
  • Request coalescing: Identical concurrent requests share a single upstream fetch
  • Read-only mode: Optionally block or ignore write requests
  • Auto-reconnect: Automatic upstream reconnection on failure
  • Stale data fallback: Optionally serve stale cache on upstream errors
  • Request diagnostics: Structured lifecycle timing, retry, exception, and health state
  • Graceful shutdown: Complete in-flight requests before terminating
  • Minimal footprint: ~6MB Docker image (scratch base)

Quick Start

docker run --rm \
  -e MODBUS_UPSTREAM=192.168.1.100:502 \
  -p 5502:5502 \
  ghcr.io/tma/mbproxy

Configuration

All configuration is via environment variables:

Variable Description Default
MODBUS_LISTEN TCP address to listen on :5502
MODBUS_UPSTREAM Upstream Modbus device address (required)
MODBUS_SLAVE_ID Default slave ID 1
MODBUS_CACHE_TTL Cache time-to-live 10s
MODBUS_CACHE_SERVE_STALE Serve stale data on upstream error false
MODBUS_READONLY Read-only mode: false, true, deny true
MODBUS_ATTEMPT_TIMEOUT Per-attempt upstream socket timeout 10s
MODBUS_TIMEOUT Deprecated alias for MODBUS_ATTEMPT_TIMEOUT unset
MODBUS_REQUEST_TIMEOUT Total request budget, including coalescing, queueing, reconnect, retry, and pacing 30s
MODBUS_REQUEST_DELAY Minimum interval between successful upstream requests 0 (disabled)
MODBUS_CONNECT_DELAY Silent period after connecting to upstream 0 (disabled)
MODBUS_SHUTDOWN_TIMEOUT Graceful shutdown timeout 30s
LOG_LEVEL Log level: INFO, DEBUG INFO

The end-to-end request budget always caps each individual attempt. A full read retry budget needs room for two attempt timeouts, two connect delays, request pacing, and any dial time. Pacing is a context-aware pre-wire wait charged to the next request's budget; it never delays an already received response. Existing configurations may keep using MODBUS_TIMEOUT during migration. When both attempt timeout variables are set, their parsed durations must match or startup fails. Neither setting changes MODBUS_REQUEST_TIMEOUT.

Downstream exceptions preserve genuine upstream Modbus exception responses with nonzero exception codes. Upstream transport, framing or malformed exception failures and total request deadlines map to gateway target failed to respond (0x0B). Local internal failures map to server failure (0x04), while local validation uses the standard validation exception codes.

/mbproxy -health performs an internal upstream connectivity check and does not open a separate local TCP health port.

Production Diagnostics

Upstream lifecycle logs identify requests with slave_id, func, addr, qty, and write; write payloads are never logged. Timing fields separate queue_duration, attempt_duration, reconnect_duration, and total_duration. Retry and failure records also include attempt, attempts, error_kind, will_retry, and the preserved exception_code or mapped downstream_exception when applicable.

The first retryable read transport failure is logged at DEBUG with will_retry=true; a recovered second attempt is logged at DEBUG with attempts=2. Final upstream failures and genuine upstream Modbus exceptions are WARN records. Request cancellation is DEBUG. Requests already canceled or expired do not select stale fallback, and the fallback record does not claim downstream delivery. Coalesced followers report coalesced=true, coalesced_wait_duration, and zero attempts so they are not mistaken for independent upstream traffic. A follower that outlives a canceled leader and performs the replacement fetch instead reports coalesced_waited=true with coalesced=false, preserving both its wait and its real upstream attempt.

The health response includes cumulative total_retries and recovered_retries, consecutive first-attempt and final-failure counts, the last first-attempt success, the last successful request, and degradation flags. A recovered retry immediately sets degraded=true but remains HTTP 200 with status ok. If no first-attempt success clears that state for one minute, status becomes degraded while remaining HTTP 200. A final upstream failure still returns HTTP 503 unhealthy. Success timestamps are null until the corresponding success has occurred.

Read-Only Modes

  • false: Full read/write passthrough to upstream device
  • true: Silently ignore write requests, return success response
  • deny: Reject write requests with Modbus illegal function exception

Docker Compose Examples

Basic Setup

services:
  mbproxy:
    image: ghcr.io/tma/mbproxy
    ports:
      - "5502:5502"
    environment:
      MODBUS_UPSTREAM: "192.168.1.100:502"
    restart: unless-stopped

All Configuration Options

services:
  mbproxy:
    image: ghcr.io/tma/mbproxy
    ports:
      - "5502:5502"
    environment:
      MODBUS_LISTEN: ":5502"
      MODBUS_UPSTREAM: "192.168.1.100:502"
      MODBUS_SLAVE_ID: "1"
      MODBUS_CACHE_TTL: "10s"
      MODBUS_CACHE_SERVE_STALE: "false"
      MODBUS_READONLY: "true"
      MODBUS_ATTEMPT_TIMEOUT: "10s"
      MODBUS_REQUEST_TIMEOUT: "30s"
      MODBUS_REQUEST_DELAY: "0"
      MODBUS_CONNECT_DELAY: "0"
      MODBUS_SHUTDOWN_TIMEOUT: "30s"
      LOG_LEVEL: "INFO"
    restart: unless-stopped

Multiple Devices (Multiple Proxies)

services:
  inverter-proxy:
    image: ghcr.io/tma/mbproxy
    ports:
      - "5502:5502"
    environment:
      MODBUS_UPSTREAM: "192.168.1.100:502"
      MODBUS_CACHE_TTL: "10s"

  meter-proxy:
    image: ghcr.io/tma/mbproxy
    ports:
      - "5503:5502"
    environment:
      MODBUS_UPSTREAM: "192.168.1.101:502"
      MODBUS_CACHE_TTL: "2s"

Building from Source

# Build Docker image
docker build -t mbproxy .

# Run tests
docker build --target test .

# Or run tests directly
docker run --rm -v $(pwd):/app -w /app golang:1.24 go test ./...

Supported Modbus Functions

Code Function
0x01 Read Coils
0x02 Read Discrete Inputs
0x03 Read Holding Registers
0x04 Read Input Registers
0x05 Write Single Coil
0x06 Write Single Register
0x0F Write Multiple Coils
0x10 Write Multiple Registers

Cache Behavior

  • Key format: values are cached per register/coil as {slave_id}:{function_code}:{address}
  • Read requests: Served from cache only if every register/coil in the requested range is present and not expired
  • Cache misses: If any value in the requested range is missing or expired, the full range is fetched from upstream and decomposed into per-register/coil cache entries
  • Write requests: Before an allowed write is forwarded, its generation is incremented and the written range is invalidated. The generation is incremented and the range invalidated again after every outcome, so neither older reads nor reads that execute in the write scheduling window can leave pre-write values cached.
  • Request coalescing: Multiple identical range requests in the same write generation share a single upstream fetch using {write_generation}:{slave_id}:{function_code}:{start_address}:{quantity} as the coalescing key
  • Stale fallback: If enabled, expired entries are retained and can be served when upstream transport requests fail. Upstream Modbus exceptions are never replaced with stale data.

License

MIT

About

Lightweight Modbus TCP proxy with in-memory caching.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages