b330faff03
Make the service build, run, and install on Linux as a first-class target while keeping the Windows Service + Event Log behaviour intact. - Build: drop the hardcoded win-x64 RID — single-file publish now works for any RID. publish.ps1 gains -Rid; new publish.sh for Linux hosts. - Diagnostics: DiagnosticSinkSelector picks the Error+ sink per host — Windows Event Log under the SCM, local syslog under systemd (Serilog.Sinks.SyslogMessages), none for interactive runs. The EventLog truncation helper is extracted so it is testable cross-OS. - Host: Program.cs registers AddSystemd() alongside AddWindowsService(). - Config: a RID-conditioned appsettings template ships Windows or Unix paths; both templates are schema-validated by a test. - Install: systemd unit (Type=exec) plus install.sh / uninstall.sh. Also fixes two cross-platform bugs found while testing: install.ps1 and uninstall.ps1 used New-EventLog / Remove-EventLog (absent in PowerShell 7), and the E2E sim launcher hardcoded Windows venv paths. - Docs updated across README, CLAUDE.md, and docs/ for dual-platform. 413 tests pass on Windows; 374 (all non-simulator) on Linux. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
452 lines
29 KiB
Markdown
452 lines
29 KiB
Markdown
# Configuration Reference
|
||
|
||
`mbproxy` binds its runtime configuration from `appsettings.json` under the `Mbproxy` section. This document is the full reference for every supported key, its type, default, range, and validation rules.
|
||
|
||
## File Location
|
||
|
||
The configuration loader resolves `appsettings.json` relative to the executable.
|
||
|
||
- **Development run** (`dotnet run`): `src/Mbproxy/appsettings.json` next to the build output.
|
||
- **Single-file publish** (`dotnet publish -c Release -r <rid>`): `appsettings.json` next to the published binary. A `win-x64` publish ships `install/mbproxy.config.template.json`; a `linux-x64` publish ships `install/mbproxy.linux.config.template.json` (same keys, Unix log path) — each linked into the bundle as `appsettings.json`.
|
||
- **Installed as a Windows Service**: `%ProgramData%\mbproxy\appsettings.json`, seeded by `install.ps1` from `mbproxy.config.template.json`.
|
||
- **Installed as a systemd unit**: `/etc/mbproxy/appsettings.json` (the unit's `WorkingDirectory`), seeded by `install.sh` from the Linux template.
|
||
|
||
In both installed cases the install script copies the template only when no config already exists — an existing file is preserved across reinstalls.
|
||
|
||
The file is loaded with `reloadOnChange: true`. All consumers read through `IOptionsMonitor<MbproxyOptions>`, so a save propagates without restarting the service. See [`../Features/HotReload.md`](../Features/HotReload.md) for per-key propagation semantics.
|
||
|
||
The .NET configuration provider accepts `//` and `/* */` comments (JSONC) in `appsettings.json` when loaded through `Host.CreateApplicationBuilder`. The install template ships with comments.
|
||
|
||
Environment variables and command-line arguments are also accepted by the host. Either form can override any `Mbproxy:*` key; for example, `Mbproxy__AdminPort=9090` (double-underscore segment separator) overrides the JSON. Environment overrides are useful for ephemeral diagnostic switches but should not replace the file as the source of truth — `ReloadValidator` runs against the merged configuration on every reload.
|
||
|
||
## Top-Level Schema
|
||
|
||
Every supported key under `Mbproxy:*`, populated to a representative default:
|
||
|
||
```jsonc
|
||
{
|
||
"Mbproxy": {
|
||
|
||
// Global BCD tag list — applies to every PLC unless overridden per-PLC.
|
||
"BcdTags": {
|
||
"Global": [
|
||
{ "Address": 1024, "Width": 16 }, // 16-bit BCD register
|
||
{ "Address": 1056, "Width": 32 }, // 32-bit BCD pair (CDAB)
|
||
{ "Address": 1088, "Width": 16, "CacheTtlMs": 1000 } // opt-in cache, 1 s TTL
|
||
]
|
||
},
|
||
|
||
// One entry per PLC. Each maps an upstream proxy port to a backend Modbus TCP endpoint.
|
||
"Plcs": [
|
||
{
|
||
"Name": "Line1-Mixer",
|
||
"ListenPort": 5020,
|
||
"Host": "10.0.1.1",
|
||
"Port": 502,
|
||
"DefaultCacheTtlMs": 0,
|
||
"BcdTags": {
|
||
"Add": [ { "Address": 1200, "Width": 32 } ],
|
||
"Remove": [ 1056 ]
|
||
}
|
||
}
|
||
],
|
||
|
||
// Read-only HTTP status page. Set to 0 to disable.
|
||
"AdminPort": 8080,
|
||
|
||
// Backend connection / request / shutdown timeouts and keepalive.
|
||
"Connection": {
|
||
"BackendConnectTimeoutMs": 3000,
|
||
"BackendRequestTimeoutMs": 3000,
|
||
"GracefulShutdownTimeoutMs": 10000,
|
||
"Keepalive": {
|
||
"Enabled": true,
|
||
"TcpIdleTimeMs": 30000,
|
||
"TcpProbeIntervalMs": 5000,
|
||
"TcpProbeCount": 4,
|
||
"BackendHeartbeatIdleMs": 30000,
|
||
"BackendHeartbeatProbeAddress": 0
|
||
}
|
||
},
|
||
|
||
// Polly resilience policies.
|
||
"Resilience": {
|
||
"BackendConnect": {
|
||
"MaxAttempts": 3,
|
||
"BackoffMs": [ 100, 500, 2000 ]
|
||
},
|
||
"ListenerRecovery": {
|
||
"InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ],
|
||
"SteadyStateMs": 30000
|
||
},
|
||
"ReadCoalescing": {
|
||
"Enabled": true,
|
||
"MaxParties": 32
|
||
}
|
||
},
|
||
|
||
// Response-cache safety knobs. The cache is off by default per tag.
|
||
"Cache": {
|
||
"AllowLongTtl": false,
|
||
"MaxEntriesPerPlc": 1000,
|
||
"EvictionIntervalMs": 5000
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`Serilog` configuration is documented in [`./Troubleshooting.md`](./Troubleshooting.md) and lives outside the `Mbproxy` section.
|
||
|
||
> The Windows Event Log sink is **not** the standard `Serilog.Sinks.EventLog` package. It is a custom `EventLogBridge` (`src/Mbproxy/Diagnostics/EventLogBridge.cs`) that writes Error+ events to the `mbproxy` source under `Application` only when the service runs under the SCM. Event Log source registration is intentionally NOT attempted at runtime (the service account may not be admin); `install.ps1` registers the source at install time. Don't add `Serilog.Sinks.EventLog` — the bridge would duplicate every event. The bridge caches the source-exists check at construction (Phase 12 / W2.23), so a missing source produces no per-event registry traffic.
|
||
|
||
## `Mbproxy.AdminPort`
|
||
|
||
Port for the read-only HTTP status server. Binds to all interfaces on startup.
|
||
|
||
| Field | Type | Default | Range |
|
||
|-------|------|---------|-------|
|
||
| `AdminPort` | int | `8080` | `[1, 65535]` |
|
||
|
||
`ReloadValidator` rejects values outside `[1, 65535]` and rejects collisions with any `Plcs[i].ListenPort`. Source: `MbproxyOptions.AdminPort`.
|
||
|
||
The server exposes `GET /` (auto-refreshing HTML) and `GET /status.json`. See [`./StatusPage.md`](./StatusPage.md) for the schema.
|
||
|
||
Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — there are no `POST` / `PUT` / `DELETE` routes — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations.
|
||
|
||
## `Mbproxy.Plcs[]`
|
||
|
||
One entry per PLC. The array drives the listener supervisor; on reload, entries added here cause new listeners to bind and entries removed here cause listeners to stop. Source: `PlcOptions.cs`.
|
||
|
||
| Field | Type | Default | Required | Notes |
|
||
|-------|------|---------|----------|-------|
|
||
| `Name` | string | `""` | yes | Non-empty, unique across the array. Shown on the status page and in structured logs as `plc`. |
|
||
| `ListenPort` | int | `0` | yes | Port the proxy listens on. `[1, 65535]`. Unique across the array. Cannot collide with `AdminPort`. |
|
||
| `Host` | string | `""` | yes | PLC IP address or hostname. |
|
||
| `Port` | int | `502` | no | Backend Modbus TCP port on the PLC. |
|
||
| `BcdTags` | object | `null` | no | Per-PLC overrides on top of `Mbproxy.BcdTags.Global`. See below. |
|
||
| `DefaultCacheTtlMs` | int | `0` | no | Fallback TTL in milliseconds for any tag on this PLC whose explicit `CacheTtlMs` is unset (`null`). `0` disables caching by default. |
|
||
|
||
### `Plcs[i].BcdTags`
|
||
|
||
Per-PLC override block. Resolution: the effective tag list for a PLC is `Global ∪ Add − Remove`, with `Add` winning on width when an address appears in both `Global` and `Add`. Source: `BcdTagListOptions.PlcBcdOverrides`.
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `Add` | `BcdTagOptions[]` | `[]` | Tags to append for this PLC. Can override a `Global` entry's `Width` by repeating the address. Each entry follows the `BcdTagOptions` shape (see next section). |
|
||
| `Remove` | `ushort[]` | `[]` | Addresses to drop from this PLC's effective list. Matches by address. |
|
||
|
||
The full tag-list resolution algorithm — `Add` width override semantics, overlap detection, and per-PLC tag-map flushing on reload — is documented in [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md).
|
||
|
||
A subtle case worth pinning down: when an address appears in both `Mbproxy.BcdTags.Global[]` and `Plcs[i].BcdTags.Add[]`, the per-PLC `Add` entry wins on `Width` and `CacheTtlMs`. This is how a per-PLC width override is expressed (for example, a 16-bit tag globally, promoted to 32-bit on the one PLC that uses the wider format). To strip a global tag from a PLC entirely, use `Remove`; do not add a same-address entry with `Width = 0`.
|
||
|
||
## `Mbproxy.BcdTags.Global[]`
|
||
|
||
The fleet-wide BCD tag list. Every PLC starts with this set, then applies its per-PLC `Add` / `Remove` overrides. Source: `BcdTagListOptions.Global`, entries of type `BcdTagOptions`.
|
||
|
||
| Field | Type | Default | Range | Notes |
|
||
|-------|------|---------|-------|-------|
|
||
| `Address` | ushort | `0` | `[0, 65535]` | Modbus PDU address (decimal). Address `0` is valid on DL205/DL260 — do not skip it. Octal V-memory addresses must be converted: `V2000` octal = decimal `1024`. |
|
||
| `Width` | byte | `0` | `{ 16, 32 }` | Bit width. `16` is one register holding 4 BCD digits (`0–9999`). `32` is a CDAB-ordered register pair at `Address` (low word) and `Address+1` (high word). |
|
||
| `CacheTtlMs` | int? | `null` | `>= 0`, `<= 60000` unless `Cache.AllowLongTtl = true` | Optional per-tag opt-in to the response cache. `null` falls back to the PLC's `DefaultCacheTtlMs`. `0` explicitly disables caching for this tag even when the PLC default is non-zero. |
|
||
|
||
`MbproxyOptionsValidator` rejects any entry whose `Width` is not `16` or `32`. See [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md) for the wire encoding rules and the multi-tag-overlap validation that runs in `BcdTagMapBuilder`.
|
||
|
||
Address conversion examples for operators coming from DirectLOGIC ladder:
|
||
|
||
| V-memory (octal) | Modbus PDU (decimal) |
|
||
|------------------|----------------------|
|
||
| `V2000` | `1024` |
|
||
| `V2040` | `1056` |
|
||
| `V2100` | `1088` |
|
||
| `V2200` | `1152` |
|
||
|
||
The proxy expects PDU-decimal addresses. Do not use octal V-memory addresses and do not use 1-based `4xxxx` Modbus references — both will resolve to the wrong register.
|
||
|
||
## `Mbproxy.Connection`
|
||
|
||
Backend connection and shutdown timeouts. Source: `ConnectionOptions.cs`.
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `BackendConnectTimeoutMs` | int | `3000` | Max time in milliseconds to wait for one TCP connect to the backend PLC. Each Polly retry attempt is bounded by its own copy of this timeout — total worst-case connect time is `MaxAttempts * BackendConnectTimeoutMs` plus the configured backoffs. |
|
||
| `BackendRequestTimeoutMs` | int | `3000` | Max time in milliseconds to wait for the PLC to respond to a forwarded PDU. On timeout the upstream client is disconnected. FC06 / FC16 writes are not retried because they are non-idempotent on BCD tags; FC03 / FC04 reads are also not retried mid-request (a fresh upstream request takes the full pipeline again). |
|
||
| `GracefulShutdownTimeoutMs` | int | `10000` | Max time in milliseconds the shutdown coordinator waits for in-flight PDUs to drain after a stop signal (`sc.exe stop` or Windows Service stop). After this deadline, remaining work is cancelled. Keep at or below the Service Control Manager wait hint (30 s). |
|
||
|
||
On hot reload, `BackendConnectTimeoutMs` and `BackendRequestTimeoutMs` apply to the next backend connect or request — in-flight operations keep their already-applied timeout. `GracefulShutdownTimeoutMs` is sampled only at shutdown.
|
||
|
||
Operational sizing notes:
|
||
|
||
- The default 3 s connect timeout is appropriate for a local Ethernet segment to a healthy ECOM100. On WAN paths or for devices behind switches with slow MAC-table aging, raise to 5–10 s.
|
||
- A 3 s request timeout is generous compared with typical DL205/DL260 scan times (a few ms to tens of ms for FC03 of 100 registers). The slack absorbs PLC scan-overlap jitter without faulting the upstream client.
|
||
- `GracefulShutdownTimeoutMs` should be less than the Service Control Manager's stop deadline. The default 10 s suits a fleet of 54 PLCs; on a much larger fleet, raise both the SCM wait hint and this value in lockstep.
|
||
|
||
## `Mbproxy.Connection.Keepalive`
|
||
|
||
TCP keepalive and backend heartbeat settings. Source: `KeepaliveOptions.cs`. Enabled by default — the DL205/DL260 ECOM never emits TCP keepalives, so an idle socket is otherwise dropped by middleboxes after 2–5 minutes. See [`../Architecture/Keepalive.md`](../Architecture/Keepalive.md) for the full design.
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `Enabled` | bool | `true` | Master switch. When `false`, neither `SO_KEEPALIVE` nor the backend heartbeat is applied and the proxy behaves exactly as a pre-keepalive build. |
|
||
| `TcpIdleTimeMs` | int | `30000` | `SO_KEEPALIVE` idle time before the OS sends its first probe. Applied to the backend socket and accepted upstream sockets. |
|
||
| `TcpProbeIntervalMs` | int | `5000` | `SO_KEEPALIVE` interval between probes once idle. |
|
||
| `TcpProbeCount` | int | `4` | `SO_KEEPALIVE` unanswered probes before the OS declares the socket dead. |
|
||
| `BackendHeartbeatIdleMs` | int | `30000` | After this much backend idle, the proxy issues a synthetic FC03 qty=1 read to keep the path warm and prove the ECOM still answers Modbus. Must be greater than `BackendRequestTimeoutMs`. |
|
||
| `BackendHeartbeatProbeAddress` | int | `0` | Modbus PDU address the heartbeat FC03 probe reads. Address `0` (`V0`) is valid on DL205/DL260 in factory absolute mode. Range `[0, 65535]`. |
|
||
|
||
On hot reload, the heartbeat interval and probe address are re-read on every loop tick. The `Tcp*` socket options are applied at connect/accept time, so a reload affects only sockets opened after the change. A reload where `BackendHeartbeatIdleMs <= BackendRequestTimeoutMs` is rejected — a heartbeat interval at or below the request timeout would fire continuously.
|
||
|
||
## `Mbproxy.Resilience`
|
||
|
||
Polly retry pipelines for backend connect, listener bind, and the in-flight read coalescer. Source: `ResilienceOptions.cs`.
|
||
|
||
### `Mbproxy.Resilience.BackendConnect`
|
||
|
||
Bounded retries on the backend TCP connect path. Mid-request failures (during a forwarded PDU) are never retried.
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `MaxAttempts` | int | `3` | Total connect tries, including the first. `1` disables retries. |
|
||
| `BackoffMs` | int[] | `[100, 500, 2000]` | Delay in milliseconds between attempts. Must have `MaxAttempts - 1` entries. |
|
||
|
||
### `Mbproxy.Resilience.ListenerRecovery`
|
||
|
||
Unbounded retries on the listener bind path. If a PLC's `ListenPort` cannot be bound (port in use, bad interface, transient OS error), the supervisor cycles through `InitialBackoffMs` once, then repeats `SteadyStateMs` forever. The same recovery code path also reacts to a listener that faults at runtime (for example, the underlying socket dies) and to listeners that come online from a hot-reload that adds a new PLC.
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `InitialBackoffMs` | int[] | `[1000, 2000, 5000, 15000, 30000]` | Backoff schedule for the first N retries after a fault. |
|
||
| `SteadyStateMs` | int | `30000` | Backoff for every retry after the initial schedule is exhausted. Runs indefinitely. |
|
||
|
||
### `Mbproxy.Resilience.ReadCoalescing`
|
||
|
||
In-flight de-duplication of identical FC03 / FC04 reads. When multiple upstream clients issue the same `(unitId, fc, startAddress, qty)` tuple while a matching backend round-trip is already in flight, the late arrivals attach to the existing entry and the single response is fanned out to every party. See [`../Architecture/ReadCoalescing.md`](../Architecture/ReadCoalescing.md).
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `Enabled` | bool | `true` | Master switch. Hot-reloadable; flipping to `false` lets already-coalesced entries drain naturally. |
|
||
| `MaxParties` | int | `32` | Per-entry cap on attached parties. Past this cap, the next identical request opens a fresh in-flight entry. |
|
||
|
||
Writes (FC06 / FC16) are never coalesced. FC03 and FC04 never share an entry. Different `unitId` bytes never share an entry.
|
||
|
||
Total FC03 + FC04 request accounting is preserved across the coalescing path: `coalescedHitCount + coalescedMissCount` equals the total reads observed by the multiplexer since startup. `coalescedHitCount` stays at `0` while `Enabled = false`, but every read still increments `coalescedMissCount`. See [`./StatusPage.md`](./StatusPage.md) for the full counter catalogue.
|
||
|
||
## `Mbproxy.Cache`
|
||
|
||
Service-wide safety knobs for the opt-in response cache. The cache is off by default per tag — this section only governs the limits when an operator opts a tag in via `CacheTtlMs` or `DefaultCacheTtlMs`. Source: `CacheOptions` in `MbproxyOptions.cs`.
|
||
|
||
| Field | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `AllowLongTtl` | bool | `false` | Gate for any `CacheTtlMs > 60_000`. When `false`, `ReloadValidator` rejects any tag or PLC default that exceeds 60 s. Set to `true` to opt in explicitly. |
|
||
| `MaxEntriesPerPlc` | int | `1000` | LRU cap on the number of entries per PLC. When full, the next insert evicts the least-recently-used entry. Must be `>= 0`. `0` is accepted but means "evict every insert immediately" — effectively the cache is disabled even for tags with non-zero TTL. |
|
||
| `EvictionIntervalMs` | int | `5000` | Background eviction loop tick in milliseconds. Each tick scans the per-PLC caches and removes entries past their `ExpiresAtUtc`. Must be `>= 0`; values below 100 ms are clamped at 100 ms internally to avoid pathologically tight loops. |
|
||
|
||
On hot reload, `AllowLongTtl` is enforced by the next reload validation. `MaxEntriesPerPlc` applies to subsequent inserts (existing entries are not pruned). `EvictionIntervalMs` is read by each fresh eviction loop iteration.
|
||
|
||
Any tag-list change for a given PLC drops that PLC's entire cache on reload — per-tag flush granularity is intentionally not implemented. New entries re-populate on demand under the new TTL. Process restart wipes every cache; there is no persistence and no last-known-good snapshot.
|
||
|
||
See [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md) for the cache contract (lookup order, write-invalidation by address-range overlap, post-rewriter byte storage).
|
||
|
||
## Per-Tag `CacheTtlMs`
|
||
|
||
Per-tag opt-in to the cache. The same field appears on every `BcdTagOptions` entry — both `Mbproxy.BcdTags.Global[]` and `Mbproxy.Plcs[i].BcdTags.Add[]`.
|
||
|
||
| Value | Meaning |
|
||
|-------|---------|
|
||
| `null` (omitted) | Unset. Falls back to `Plcs[i].DefaultCacheTtlMs`. |
|
||
| `0` | Caching explicitly disabled for this tag, even if the PLC default is non-zero. |
|
||
| `1..60000` | Cache enabled with this TTL in milliseconds. |
|
||
| `> 60000` | Rejected at reload unless `Cache.AllowLongTtl = true`. |
|
||
|
||
TTL resolution order for any single tag: **explicit per-tag value → per-PLC `DefaultCacheTtlMs` → 0 (off)**.
|
||
|
||
For multi-tag read ranges, the effective TTL is `min(TTLs)` across all configured tags inside the read range. If any tag in the range has `CacheTtlMs = 0`, the entire read is uncached.
|
||
|
||
The cache itself is described in detail in [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md). The properties most relevant to operators setting TTLs:
|
||
|
||
- **Lookup order is cache → coalesce → backend.** A cache hit short-circuits the read coalescer entirely.
|
||
- **Writes invalidate by address-range overlap.** A successful FC06 / FC16 response invalidates every cached FC03 / FC04 entry whose read range overlaps the write range — not just exact-key matches. Exception responses do not invalidate (the write did not take effect on the PLC).
|
||
- **Cache stores post-rewriter bytes.** Hits never re-invoke the BCD rewriter. Tag-list reloads flush the affected PLC's whole cache so a rewriter-relevant change cannot serve stale post-rewriter bytes from before the change.
|
||
- **Different `unitId` bytes never invalidate each other.** Invalidation is scoped to `(unitId, FC ∈ {3, 4})`.
|
||
|
||
## Validation Rules
|
||
|
||
`ReloadValidator.Validate` runs on every config load (startup and hot reload) and rejects the entire snapshot if any rule fails. On rejection at startup, the service exits non-zero. On rejection at runtime, the current in-memory config stays in effect and `mbproxy.config.reload.rejected` is logged at `Error`.
|
||
|
||
Rules (in order):
|
||
|
||
1. **PLC names**: every `Plcs[i].Name` is non-empty and unique (ordinal comparison).
|
||
2. **ListenPort**: every `Plcs[i].ListenPort` is in `[1, 65535]` and unique across the array.
|
||
3. **AdminPort**: in `[1, 65535]` and does not collide with any `ListenPort`.
|
||
4. **BCD tag map** per PLC, delegated to `BcdTagMapBuilder.Build`:
|
||
- duplicate addresses within a single PLC's resolved tag list
|
||
- 32-bit entries whose high register (`Address + 1`) overlaps a separate 16-bit entry at that address
|
||
5. **Cache TTL bounds**:
|
||
- any `CacheTtlMs` or `DefaultCacheTtlMs` less than 0 is rejected
|
||
- any `CacheTtlMs` or `DefaultCacheTtlMs` greater than `60_000` is rejected unless `Cache.AllowLongTtl = true`
|
||
6. **Cache size knobs**: `Cache.MaxEntriesPerPlc >= 0`, `Cache.EvictionIntervalMs >= 0`.
|
||
7. **Width**: every `BcdTagOptions.Width` is `16` or `32` (enforced by `MbproxyOptionsValidator` at schema time).
|
||
|
||
Sample rejection messages (logged at `Error` with the structured property `errors` carrying the full list):
|
||
|
||
```text
|
||
Plc 'Line1-Mixer': Duplicate ListenPort 5020 (already used by 'Line1-Conveyor').
|
||
AdminPort 5020 collides with ListenPort of PLC 'Line1-Mixer'.
|
||
Plc 'Line1-Mixer': BCD tag map error (DuplicateAddress): address 1024 appears twice.
|
||
BcdTags.Global Address 1024: CacheTtlMs=120000 exceeds 60_000 ms without Cache.AllowLongTtl=true.
|
||
Plcs[2] (Line2-Press): DefaultCacheTtlMs must be >= 0.
|
||
```
|
||
|
||
Warning case (not a rejection):
|
||
|
||
- `Plcs[i].BcdTags.Remove[]` entries that do not match any global tag address are logged as warnings — probably stale config, but the reload proceeds.
|
||
|
||
Two additional rejection categories handled earlier in the pipeline:
|
||
|
||
- **Type-mismatched / malformed JSON.** The .NET configuration binder rejects values whose type does not match the bound property (for example, a string in `BackendConnectTimeoutMs`). At startup this aborts the host; on hot reload the binder retains the previous snapshot and the reload never reaches `ReloadValidator`.
|
||
- **Width invalid.** `MbproxyOptionsValidator` rejects any `BcdTagOptions.Width` that is not `16` or `32`. This runs as part of options validation before `ReloadValidator` and surfaces the same way as schema errors.
|
||
|
||
See [`../Features/HotReload.md`](../Features/HotReload.md) for the full reload-acceptance flow, including the log event names emitted on acceptance (`mbproxy.config.reload.applied`) and rejection (`mbproxy.config.reload.rejected`).
|
||
|
||
## Two Concrete Examples
|
||
|
||
The minimal and production examples below are both complete `appsettings.json` snippets — paste either one and the service will start without further edits beyond the addresses and ports.
|
||
|
||
### Minimal
|
||
|
||
One PLC, no BCD tags, no cache. The proxy is pure pass-through.
|
||
|
||
```jsonc
|
||
{
|
||
"Mbproxy": {
|
||
"BcdTags": { "Global": [] },
|
||
"Plcs": [
|
||
{
|
||
"Name": "Line1-Mixer",
|
||
"ListenPort": 5020,
|
||
"Host": "10.0.1.1"
|
||
}
|
||
],
|
||
"AdminPort": 8080
|
||
}
|
||
}
|
||
```
|
||
|
||
Everything else picks up defaults: `Port = 502`, `Connection.BackendConnectTimeoutMs = 3000`, `Connection.BackendRequestTimeoutMs = 3000`, `Connection.GracefulShutdownTimeoutMs = 10000`, `Resilience.BackendConnect.MaxAttempts = 3`, `Resilience.ReadCoalescing.Enabled = true`, `Cache.AllowLongTtl = false`, `Cache.MaxEntriesPerPlc = 1000`, `Cache.EvictionIntervalMs = 5000`, and so on.
|
||
|
||
Behaviour in this snapshot: every byte passes through unchanged in both directions, FC03 / FC04 reads are still subject to in-flight coalescing (the feature is on by default), and no responses are cached.
|
||
|
||
### Production
|
||
|
||
Three PLCs, a global BCD tag list, one PLC with overrides, cache enabled on hot reads.
|
||
|
||
```jsonc
|
||
{
|
||
"Mbproxy": {
|
||
"BcdTags": {
|
||
"Global": [
|
||
{ "Address": 1024, "Width": 16 }, // V2000 — 16-bit BCD counter
|
||
{ "Address": 1056, "Width": 32 }, // V2040 — 32-bit BCD total
|
||
{ "Address": 1088, "Width": 16, "CacheTtlMs": 1000 } // V2100 — setpoint, 1 s cache
|
||
]
|
||
},
|
||
"Plcs": [
|
||
{
|
||
"Name": "Line1-Mixer",
|
||
"ListenPort": 5020,
|
||
"Host": "10.0.1.1",
|
||
"Port": 502,
|
||
"DefaultCacheTtlMs": 0,
|
||
"BcdTags": {
|
||
"Add": [ { "Address": 1200, "Width": 32 } ],
|
||
"Remove": [ 1056 ]
|
||
}
|
||
},
|
||
{
|
||
"Name": "Line1-Conveyor",
|
||
"ListenPort": 5021,
|
||
"Host": "10.0.1.2"
|
||
},
|
||
{
|
||
"Name": "Line2-Press",
|
||
"ListenPort": 5022,
|
||
"Host": "10.0.2.1",
|
||
"DefaultCacheTtlMs": 500
|
||
}
|
||
],
|
||
"AdminPort": 8080,
|
||
"Connection": {
|
||
"BackendConnectTimeoutMs": 3000,
|
||
"BackendRequestTimeoutMs": 3000,
|
||
"GracefulShutdownTimeoutMs": 10000
|
||
},
|
||
"Resilience": {
|
||
"BackendConnect": { "MaxAttempts": 3, "BackoffMs": [ 100, 500, 2000 ] },
|
||
"ListenerRecovery": { "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ], "SteadyStateMs": 30000 },
|
||
"ReadCoalescing": { "Enabled": true, "MaxParties": 32 }
|
||
},
|
||
"Cache": {
|
||
"AllowLongTtl": false,
|
||
"MaxEntriesPerPlc": 1000,
|
||
"EvictionIntervalMs": 5000
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
In this snapshot, `Line1-Mixer` adds a 32-bit tag at `1200` and removes the global 32-bit tag at `1056`. `Line2-Press` opts every tag in (whose `CacheTtlMs` is `null`) into a 500 ms cache via its `DefaultCacheTtlMs`. The setpoint at `1088` already has an explicit per-tag TTL and that value wins.
|
||
|
||
The effective tag map per PLC after resolution:
|
||
|
||
| PLC | Effective tag list |
|
||
|-----|--------------------|
|
||
| `Line1-Mixer` | `1024` (16-bit), `1088` (16-bit, `CacheTtlMs = 1000`), `1200` (32-bit). `1056` is removed. |
|
||
| `Line1-Conveyor` | `1024` (16-bit), `1056` (32-bit), `1088` (16-bit, `CacheTtlMs = 1000`). |
|
||
| `Line2-Press` | `1024` (16-bit, effective `CacheTtlMs = 500` via PLC default), `1056` (32-bit, effective `CacheTtlMs = 500`), `1088` (16-bit, effective `CacheTtlMs = 1000` from explicit per-tag value). |
|
||
|
||
Any FC03 / FC04 read whose register range overlaps `Line2-Press`'s tag `1088` resolves to the per-tag 1 s TTL. A read that spans tags with different TTLs takes `min(TTLs)` across the range; a read that includes a tag with `CacheTtlMs = 0` is uncached even if every other tag in the range is opted in.
|
||
|
||
## Hot-Reload Propagation Summary
|
||
|
||
A reduced view of [`../Features/HotReload.md`](../Features/HotReload.md), restricted to the keys documented here. Every accepted reload emits `mbproxy.config.reload.applied` at `Information` with a summary of which PLCs were added or removed and the size of the tag-list delta.
|
||
|
||
| Change | Propagation |
|
||
|--------|-------------|
|
||
| `BcdTags.Global` add / remove / width | Rewriter dereferences `IOptionsMonitor` per PDU. Next PDU sees the new map; in-flight PDUs are not retroactively touched. |
|
||
| `Plcs[i].BcdTags.{Add,Remove}` | Same per-PDU resolution as above, scoped to the affected PLC. |
|
||
| New `Plcs[i]` entry | Listener supervisor binds the new port under `ListenerRecovery`. |
|
||
| `Plcs[i]` removed | Supervisor stops the listener and closes all upstream connections for that PLC. |
|
||
| `Plcs[i].ListenPort` or `Host` changed | Equivalent to remove + add. |
|
||
| `Connection.Backend*TimeoutMs` | Next backend connect or request uses the new value. |
|
||
| `Connection.Keepalive` heartbeat fields | Re-read on every heartbeat loop tick. `Tcp*` socket options apply to backend/upstream sockets opened after the change. |
|
||
| `AdminPort` | Requires a service restart — the Kestrel admin host is built once at startup. |
|
||
| `Resilience.ReadCoalescing.Enabled` | Hot-reloadable; in-flight coalesced entries drain naturally. |
|
||
| `BcdTags.*.CacheTtlMs`, `Plcs[i].DefaultCacheTtlMs` | Tag-map reseat for the affected PLC drops that PLC's entire cache. |
|
||
| `Cache.AllowLongTtl` / `MaxEntriesPerPlc` / `EvictionIntervalMs` | Enforced on next reload validation / next insert / next eviction tick respectively. |
|
||
|
||
## Where Options Live in Code
|
||
|
||
| Section | File | Binding class |
|
||
|---------|------|---------------|
|
||
| Root | `src/Mbproxy/Options/MbproxyOptions.cs` | `MbproxyOptions` |
|
||
| `Plcs[]` | `src/Mbproxy/Options/PlcOptions.cs` | `PlcOptions` |
|
||
| `BcdTags.Global[]` entry shape | `src/Mbproxy/Options/BcdTagOptions.cs` | `BcdTagOptions` |
|
||
| `BcdTags.Global` / `Plcs[i].BcdTags` | `src/Mbproxy/Options/BcdTagListOptions.cs` | `BcdTagListOptions`, `PlcBcdOverrides` |
|
||
| `Connection` | `src/Mbproxy/Options/ConnectionOptions.cs` | `ConnectionOptions` |
|
||
| `Resilience` | `src/Mbproxy/Options/ResilienceOptions.cs` | `ResilienceOptions`, `RetryProfile`, `RecoveryProfile`, `ReadCoalescingOptions` |
|
||
| `Cache` | `src/Mbproxy/Options/MbproxyOptions.cs` | `CacheOptions` (declared alongside `MbproxyOptions` in the same file) |
|
||
| Schema validation | `src/Mbproxy/Options/MbproxyOptions.cs` | `MbproxyOptionsValidator` |
|
||
| Reload validation | `src/Mbproxy/Configuration/ReloadValidator.cs` | `ReloadValidator` |
|
||
| Tag-map resolution | `src/Mbproxy/Bcd/BcdTagMapBuilder.cs` | `BcdTagMapBuilder` |
|
||
| Reload reconciliation | `src/Mbproxy/Configuration/ConfigReconciler.cs` | `ConfigReconciler`, `ReloadPlan` |
|
||
|
||
All option classes are registered through `services.Configure<T>(...)` against the `Mbproxy:*` section in `Program.cs`. `IOptionsMonitor<T>` is the runtime read path; direct `IOptions<T>` injection is not used because it does not propagate reloads.
|
||
|
||
## Related Documentation
|
||
|
||
- [`../Features/HotReload.md`](../Features/HotReload.md) — reload acceptance flow and per-key propagation semantics
|
||
- [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md) — tag-list resolution, wire encoding, multi-tag overlap rules
|
||
- [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md) — cache contract, lookup order, write invalidation
|
||
- [`./StatusPage.md`](./StatusPage.md) — schema served by `AdminPort`
|
||
- [`./Troubleshooting.md`](./Troubleshooting.md) — Serilog block and common config rejection diagnostics
|
||
- [`../../README.md`](../../README.md) — install and operational entry point
|