Skip to content

Commit a7c6ebd

Browse files
committed
Version 1.5.1 - See changelog for updates.
1 parent 26130e3 commit a7c6ebd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1761
-98
lines changed

CHANGELOG.md

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,119 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [1.5.1] - 2026-02-15
9+
10+
### Added
11+
12+
- **SC TLS stress testing**: All BACnet/SC stress tests and benchmarks now use
13+
mutual TLS 1.3 with a mock CA (EC P-256) by default, matching production
14+
requirements (Annex AB.7.4). A shared `docker/lib/sc_pki.py` module generates
15+
the test PKI. Docker SC scenarios use init containers to generate certificates
16+
into shared volumes. The local `bench_sc.py` benchmark accepts `--no-tls` to
17+
fall back to plaintext for comparison.
18+
19+
- **`add_route()` API**: Added `add_route(network, router_address)` to `Client`,
20+
`BACnetApplication`, and `NetworkLayer` for pre-populating the router cache.
21+
Enables communication with devices on remote networks without broadcast-based
22+
router discovery — required in Docker bridge networks where ephemeral-port
23+
clients cannot receive broadcast responses on the standard BACnet port.
24+
25+
### Fixed
26+
27+
- **Router per-port broadcast address**: Added `broadcast_address` field to
28+
`RouterPortConfig` and pass it to `BIPTransport` in router mode. Previously,
29+
all router ports used the default global broadcast (`255.255.255.255`), which
30+
fails in Docker bridge networks where directed subnet broadcasts are required.
31+
Docker router services now use `BROADCAST_ADDRESS_1`/`BROADCAST_ADDRESS_2`
32+
environment variables for per-port configuration.
33+
- **BIPTransport ephemeral port broadcast**: Fixed `BIPTransport.send_broadcast()`
34+
sending to port 0 when the transport was created with `port=0` (ephemeral).
35+
The bound port is now stored after socket binding so broadcasts and BBMD
36+
advertisements use the correct port.
37+
- **Docker router stress test**: Fixed the pre-existing router stress test failure
38+
caused by three compounding issues: (1) per-port broadcast addresses not being
39+
passed to the router's BIPTransport, (2) ephemeral port clients unable to
40+
receive broadcast I-Am responses forwarded by the router, and (3) missing
41+
router cache pre-population. The test now uses `add_route()` and a direct
42+
server address (`SERVER_ADDRESS`) to bypass all broadcast-dependent discovery.
43+
44+
### Security
45+
46+
- **SC hub VMAC collision race fix**: Added `_pending_vmacs` reservation set to
47+
`SCHubFunction` to prevent a TOCTOU race between VMAC collision check and
48+
connection registration during the handshake window (Annex AB.6.2).
49+
- **SC URI scheme validation**: `SCNodeSwitch.establish_direct()` now validates
50+
that hub-provided peer URIs use `ws://` or `wss://` schemes before
51+
connecting, preventing SSRF-like redirection to non-WebSocket endpoints.
52+
- **SC header options count cap**: BVLC-SC header option decoding now limits
53+
lists to 32 options per message (defense-in-depth against malformed payloads).
54+
- **SC pending resolution cache cap**: `SCNodeSwitch.resolve_address()` now
55+
rejects new resolution requests when the cache reaches `max_connections`,
56+
preventing unbounded memory growth from address resolution flooding.
57+
- **BBMD max BDT entries**: Added `max_bdt_entries` parameter (default 128) to
58+
`BBMDManager`. Write-BDT requests exceeding the limit are NAKed, preventing
59+
oversized BDT payloads from consuming unbounded memory.
60+
- **IPv6 VMAC cache size limit**: `VMACCache` now accepts a `max_entries`
61+
parameter (default 4096). When full, stale entries are evicted first; if still
62+
full, the oldest entry is dropped.
63+
- **IPv6 pending resolution cap**: `BIP6Transport.send_unicast()` now limits
64+
the pending VMAC resolution cache to 1024 entries, preventing unbounded growth
65+
from resolution requests to many unknown VMACs.
66+
- **H1: `decode_real`/`decode_double` buffer validation**: Added explicit length
67+
checks before `struct.unpack_from` in `encoding/primitives.py`, raising
68+
`ValueError` instead of the opaque `struct.error` on truncated input.
69+
- **H2: ErrorPDU bounds check**: Added bounds checks after each `decode_tag()` in
70+
`_decode_error()` (`encoding/apdu.py`) to reject truncated error class/code
71+
fields before slicing.
72+
- **H3: `extract_context_value` overflow check**: Added bounds validation in
73+
`encoding/tags.py` to reject primitive tags whose length extends past the
74+
buffer end, preventing silent reads of stale/adjacent memory.
75+
- **H4: Ethernet 802.3 minimum length**: `_decode_frame()` in
76+
`transport/ethernet.py` now rejects frames with length field < LLC header
77+
size (3 bytes), preventing underflow in NPDU extraction.
78+
- **C1: Service decoder list caps**: Added `_MAX_DECODED_ITEMS = 10,000` cap to
79+
all unbounded decode loops across 8 service files (19 loops total):
80+
`read_property_multiple`, `write_property_multiple`, `alarm_summary`, `cov`,
81+
`write_group`, `virtual_terminal`, `object_mgmt`, and `audit`.
82+
- **C2: `ObjectType` vendor cache cap**: `ObjectType._missing_()` now clears the
83+
vendor cache at 4096 entries, matching the `PropertyIdentifier` pattern and
84+
preventing unbounded growth from vendor-proprietary object types.
85+
- **C3: Segmentation reassembly size cap**: `SegmentReceiver` now tracks total
86+
reassembly bytes and returns `ABORT` when the cumulative size exceeds 1 MiB.
87+
Added `created_at` timestamp field for stale receiver detection.
88+
- **C4: Audit nesting depth enforcement**: Added depth checks (max 32) to all
89+
manual tag-nesting loops in `services/audit.py` (4 loops), preventing stack
90+
exhaustion from deeply nested opening tags in audit decode paths.
91+
- **S1: Hub pending VMAC TTL and cap**: Converted `_pending_vmacs` from `set` to
92+
`dict[SCVMAC, float]` with 30-second TTL purge and `max_connections` cap in
93+
`transport/sc/hub_function.py`, preventing unbounded growth from slow or
94+
abandoned handshakes.
95+
- **S2: SC header option data size cap**: Added `_MAX_OPTION_DATA_SIZE = 512`
96+
limit to `SCHeaderOption.decode_list()` in `transport/sc/bvlc.py`, rejecting
97+
oversized option data (up to 65535 per option) early in the decode path.
98+
- **S3: SC WebSocket oversized frame rate limit**: `SCWebSocket._process_frame()`
99+
now tracks consecutive oversized frames and raises `ConnectionClosedError`
100+
after 3 in a row, preventing log flooding from misbehaving peers.
101+
- **S4: SC WebSocket pending events cap**: Capped `_pending_events` buffer at 64
102+
entries in `transport/sc/websocket.py`, silently dropping excess frames when a
103+
single TCP segment delivers many WebSocket frames.
104+
- **S5: SC address resolution URI cap**: `AddressResolutionAckPayload.decode()`
105+
now truncates the URI list to 16 entries, preventing unbounded allocations
106+
from malformed address resolution responses.
107+
- **B1: FDT TTL upper bound**: Foreign device registration TTL is now capped at
108+
3600 seconds (1 hour) in `transport/bbmd.py`, preventing unreasonably long
109+
registration durations.
110+
- **A1: Change callback cap**: `ObjectDatabase.register_change_callback()` now
111+
raises `ValueError` when a single property exceeds 100 registered callbacks,
112+
preventing unbounded list growth.
113+
- **COV nesting depth enforcement**: Added depth check (max 32) to the manual
114+
tag-nesting loop in `COVPropertyValue.decode()` (`services/cov.py`),
115+
preventing stack exhaustion from deeply nested opening tags in COV value
116+
decode paths.
117+
- **`decode_boolean` buffer validation**: Added explicit length check before
118+
accessing `data[0]` in `decode_boolean()` (`encoding/primitives.py`),
119+
raising `ValueError` on empty input for consistency with `decode_real` and
120+
`decode_double`.
9121

10122
## [1.5.0] - 2026-02-15
11123

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ async with Client(instance_number=999) as client:
4343
| **Convenience API** | String-based addressing (`"ai,1"`, `"pv"`), smart type coercion, auto-discovery |
4444
| **Serialization** | `to_dict()`/`from_dict()` on all data types; optional `orjson` backend |
4545
| **Conformance** | BIBB declarations and PICS generation per Clause 24 |
46-
| **Quality** | 6,380+ unit tests, Docker integration tests, local benchmarks, type-safe enums and frozen dataclasses throughout |
46+
| **Quality** | 6,420+ unit tests, Docker integration tests, local benchmarks, type-safe enums and frozen dataclasses throughout |
4747

4848
## Installation
4949

@@ -356,7 +356,7 @@ detailed walkthroughs.
356356
## Testing
357357

358358
```bash
359-
make test # 6,380+ unit tests
359+
make test # 6,420+ unit tests
360360
make lint # ruff check + format verification
361361
make typecheck # mypy
362362
make docs # sphinx-build

docker/conftest.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,29 @@ def router_instance() -> int:
5454
return int(os.environ.get("ROUTER_INSTANCE", "300"))
5555

5656

57+
@pytest.fixture
58+
def sc_tls_config() -> object | None:
59+
"""SC TLS config from TLS_CERT_DIR/TLS_CERT_NAME env vars, or None."""
60+
cert_dir = os.environ.get("TLS_CERT_DIR", "")
61+
cert_name = os.environ.get("TLS_CERT_NAME", "")
62+
if cert_dir and cert_name:
63+
from bac_py.transport.sc.tls import SCTLSConfig
64+
65+
return SCTLSConfig(
66+
private_key_path=os.path.join(cert_dir, f"{cert_name}.key"),
67+
certificate_path=os.path.join(cert_dir, f"{cert_name}.crt"),
68+
ca_certificates_path=os.path.join(cert_dir, "ca.crt"),
69+
)
70+
return None
71+
72+
5773
@pytest.fixture
5874
def sc_hub_uri() -> str:
5975
"""SC hub WebSocket URI from env."""
6076
host = os.environ.get("SC_HUB_ADDRESS", "172.30.1.120")
6177
port = os.environ.get("SC_HUB_PORT", "4443")
62-
return f"ws://{host}:{port}"
78+
scheme = "wss" if os.environ.get("TLS_CERT_DIR") else "ws"
79+
return f"{scheme}://{host}:{port}"
6380

6481

6582
@pytest.fixture
@@ -79,7 +96,8 @@ def sc_stress_hub_uri() -> str:
7996
"""SC stress hub WebSocket URI from env."""
8097
host = os.environ.get("SC_STRESS_HUB_ADDRESS", "172.30.1.130")
8198
port = os.environ.get("SC_STRESS_HUB_PORT", "4443")
82-
return f"ws://{host}:{port}"
99+
scheme = "wss" if os.environ.get("TLS_CERT_DIR") else "ws"
100+
return f"{scheme}://{host}:{port}"
83101

84102

85103
@pytest.fixture

docker/docker-compose.yml

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
# make docker-clean # remove all containers/images
3232
# =============================================================================
3333

34+
volumes:
35+
sc-certs:
36+
sc-stress-certs:
37+
3438
networks:
3539
bacnet-main:
3640
driver: bridge
@@ -172,7 +176,8 @@ services:
172176
INTERFACE_2: "172.30.2.50"
173177
PORT_1: "47808"
174178
PORT_2: "47808"
175-
BROADCAST_ADDRESS: "172.30.1.255"
179+
BROADCAST_ADDRESS_1: "172.30.1.255"
180+
BROADCAST_ADDRESS_2: "172.30.2.255"
176181
networks:
177182
bacnet-main:
178183
ipv4_address: 172.30.1.50
@@ -410,26 +415,48 @@ services:
410415
# Scenario 9: BACnet Secure Connect (SC hub + node WebSocket communication)
411416
# ===========================================================================
412417

418+
sc-cert-gen:
419+
<<: *build
420+
container_name: bacnet-sc-cert-gen
421+
environment:
422+
ROLE: sc-cert-gen
423+
CERT_DIR: /certs
424+
CERT_NAMES: "hub,node1,node2,stress"
425+
volumes:
426+
- sc-certs:/certs
427+
profiles: ["secure-connect", "all"]
428+
413429
sc-hub:
414430
<<: [*build, *healthcheck]
415431
container_name: bacnet-sc-hub
416432
environment:
417433
ROLE: sc-hub
418434
BIND_ADDRESS: "0.0.0.0"
419435
BIND_PORT: "4443"
436+
TLS_CERT_DIR: /certs
437+
TLS_CERT_NAME: hub
438+
volumes:
439+
- sc-certs:/certs:ro
420440
networks:
421441
bacnet-main:
422442
ipv4_address: 172.30.1.120
443+
depends_on:
444+
sc-cert-gen:
445+
condition: service_completed_successfully
423446
profiles: ["secure-connect", "all"]
424447

425448
sc-node1:
426449
<<: [*build, *healthcheck]
427450
container_name: bacnet-sc-node1
428451
environment:
429452
ROLE: sc-node
430-
HUB_URI: "ws://172.30.1.120:4443"
453+
HUB_URI: "wss://172.30.1.120:4443"
431454
VMAC: "02AA00000001"
432455
NODE_SWITCH_PORT: "4444"
456+
TLS_CERT_DIR: /certs
457+
TLS_CERT_NAME: node1
458+
volumes:
459+
- sc-certs:/certs:ro
433460
networks:
434461
bacnet-main:
435462
ipv4_address: 172.30.1.121
@@ -443,9 +470,13 @@ services:
443470
container_name: bacnet-sc-node2
444471
environment:
445472
ROLE: sc-node
446-
HUB_URI: "ws://172.30.1.120:4443"
473+
HUB_URI: "wss://172.30.1.120:4443"
447474
VMAC: "02AA00000002"
448475
NODE_SWITCH_PORT: "4445"
476+
TLS_CERT_DIR: /certs
477+
TLS_CERT_NAME: node2
478+
volumes:
479+
- sc-certs:/certs:ro
449480
networks:
450481
bacnet-main:
451482
ipv4_address: 172.30.1.122
@@ -464,6 +495,10 @@ services:
464495
SC_HUB_PORT: "4443"
465496
SC_NODE1_VMAC: "02AA00000001"
466497
SC_NODE2_VMAC: "02AA00000002"
498+
TLS_CERT_DIR: /certs
499+
TLS_CERT_NAME: stress
500+
volumes:
501+
- sc-certs:/certs:ro
467502
networks:
468503
bacnet-main:
469504
ipv4_address: 172.30.1.123
@@ -478,6 +513,17 @@ services:
478513
# Scenario 10: SC Stress (WebSocket hub/node throughput)
479514
# ===========================================================================
480515

516+
sc-stress-cert-gen:
517+
<<: *build
518+
container_name: bacnet-sc-stress-cert-gen
519+
environment:
520+
ROLE: sc-cert-gen
521+
CERT_DIR: /certs
522+
CERT_NAMES: "hub,node1,node2,stress"
523+
volumes:
524+
- sc-stress-certs:/certs
525+
profiles: ["sc-stress", "sc-stress-runner"]
526+
481527
sc-stress-hub:
482528
<<: [*build, *healthcheck]
483529
container_name: bacnet-sc-stress-hub
@@ -486,9 +532,16 @@ services:
486532
ROLE: sc-hub
487533
BIND_ADDRESS: "0.0.0.0"
488534
BIND_PORT: "4443"
535+
TLS_CERT_DIR: /certs
536+
TLS_CERT_NAME: hub
537+
volumes:
538+
- sc-stress-certs:/certs:ro
489539
networks:
490540
bacnet-main:
491541
ipv4_address: 172.30.1.130
542+
depends_on:
543+
sc-stress-cert-gen:
544+
condition: service_completed_successfully
492545
profiles: ["sc-stress", "sc-stress-runner"]
493546

494547
sc-stress-node1:
@@ -497,8 +550,12 @@ services:
497550
mem_limit: 1g
498551
environment:
499552
ROLE: sc-node
500-
HUB_URI: "ws://172.30.1.130:4443"
553+
HUB_URI: "wss://172.30.1.130:4443"
501554
VMAC: "02BB00000001"
555+
TLS_CERT_DIR: /certs
556+
TLS_CERT_NAME: node1
557+
volumes:
558+
- sc-stress-certs:/certs:ro
502559
networks:
503560
bacnet-main:
504561
ipv4_address: 172.30.1.131
@@ -513,8 +570,12 @@ services:
513570
mem_limit: 1g
514571
environment:
515572
ROLE: sc-node
516-
HUB_URI: "ws://172.30.1.130:4443"
573+
HUB_URI: "wss://172.30.1.130:4443"
517574
VMAC: "02BB00000002"
575+
TLS_CERT_DIR: /certs
576+
TLS_CERT_NAME: node2
577+
volumes:
578+
- sc-stress-certs:/certs:ro
518579
networks:
519580
bacnet-main:
520581
ipv4_address: 172.30.1.132
@@ -534,6 +595,10 @@ services:
534595
SC_STRESS_HUB_PORT: "4443"
535596
SC_STRESS_NODE1_VMAC: "02BB00000001"
536597
SC_STRESS_NODE2_VMAC: "02BB00000002"
598+
TLS_CERT_DIR: /certs
599+
TLS_CERT_NAME: stress
600+
volumes:
601+
- sc-stress-certs:/certs:ro
537602
networks:
538603
bacnet-main:
539604
ipv4_address: 172.30.1.133
@@ -550,13 +615,17 @@ services:
550615
mem_limit: 2g
551616
environment:
552617
ROLE: sc-stress
553-
SC_STRESS_HUB_URI: "ws://172.30.1.130:4443"
618+
SC_STRESS_HUB_URI: "wss://172.30.1.130:4443"
554619
SC_STRESS_NODE1_VMAC: "02BB00000001"
555620
SC_STRESS_NODE2_VMAC: "02BB00000002"
556621
UNICAST_WORKERS: "8"
557622
BROADCAST_WORKERS: "2"
558623
WARMUP_SECONDS: "15"
559624
SUSTAIN_SECONDS: "60"
625+
TLS_CERT_DIR: /certs
626+
TLS_CERT_NAME: stress
627+
volumes:
628+
- sc-stress-certs:/certs:ro
560629
networks:
561630
bacnet-main:
562631
ipv4_address: 172.30.1.134
@@ -584,7 +653,8 @@ services:
584653
INTERFACE_2: "172.30.2.50"
585654
PORT_1: "47808"
586655
PORT_2: "47808"
587-
BROADCAST_ADDRESS: "172.30.1.255"
656+
BROADCAST_ADDRESS_1: "172.30.1.255"
657+
BROADCAST_ADDRESS_2: "172.30.2.255"
588658
networks:
589659
bacnet-main:
590660
ipv4_address: 172.30.1.150
@@ -618,6 +688,9 @@ services:
618688
TEST_FILE: test_router_stress.py
619689
SERVER_INSTANCE: "501"
620690
REMOTE_NETWORK: "2"
691+
BROADCAST_ADDRESS: "172.30.1.255"
692+
ROUTER_ADDRESS: "172.30.1.150"
693+
SERVER_ADDRESS: "2:172.30.2.60"
621694
networks:
622695
bacnet-main:
623696
ipv4_address: 172.30.1.160
@@ -636,6 +709,9 @@ services:
636709
ROLE: router-stress
637710
SERVER_INSTANCE: "501"
638711
REMOTE_NETWORK: "2"
712+
BROADCAST_ADDRESS: "172.30.1.255"
713+
ROUTER_ADDRESS: "172.30.1.150"
714+
SERVER_ADDRESS: "2:172.30.2.60"
639715
NUM_POOLS: "1"
640716
READERS_PER_POOL: "2"
641717
WRITERS_PER_POOL: "1"

0 commit comments

Comments
 (0)