Connection and anomaly reports¶
The outstation and the master log what happens to their connections, and the session logs each frame or request it drops. A log line is written for a person to read. A host application that keeps a record of every connection attempt, or passes protocol anomalies to its own security monitoring, can instead take the same facts as structured records, through two observers:
- A connection observer receives a
ConnectionEventeach time a connection is accepted, refused, displaced or ended at the outstation, and each time the master connects, fails to connect, loses a connection or closes one. - An anomaly observer receives a
ProtocolAnomalyeach time the session drops a frame or a request it cannot accept, or gives up on an unsolicited response.
An observer is any callable that takes one record. Both kinds are optional and
off by default, and the log lines are written whether or not one is set. The
records and their enumerations are importable from py1815 and from
py1815.observers, which also holds SEVERITY.
At the outstation¶
import dataclasses
import json
from py1815 import ConnectionEvent, OutstationServer, ProtocolAnomaly, Session
def connection(event: ConnectionEvent) -> None:
print(json.dumps(dataclasses.asdict(event)))
def anomaly(found: ProtocolAnomaly) -> None:
print(json.dumps(dataclasses.asdict(found)))
session = Session(provider, outstation_address=1024, master_address=1, anomaly_observer=anomaly)
server = OutstationServer(session, bind="0.0.0.0:20000", connection_observer=connection)
await server.start()
Both can also be set, changed or removed later, as attributes:
A connection accepted and then closed by the master is reported as two events:
{"role": "outstation", "direction": "inbound", "outcome": "accepted", "transport": "tcp",
"remote": {"host": "192.0.2.10", "port": 49152}, "local": {"host": "198.51.100.5", "port": 20000},
"peer_identity": null, "reason": "", "name": null, "timestamp": 1792310400.0}
{"role": "outstation", "direction": "inbound", "outcome": "ended", "transport": "tcp",
"remote": {"host": "192.0.2.10", "port": 49152}, "local": {"host": "198.51.100.5", "port": 20000},
"peer_identity": null, "reason": "closed by peer", "name": null, "timestamp": 1792310460.0}
The outstation reports these outcomes:
| Outcome | When | Reason |
|---|---|---|
accepted |
A connection is admitted as the master's. Over TLS, peer_identity is the allow-list entry the certificate matched. |
empty |
refused |
A TLS peer is not on the allow-list, the listener stopped while the connection was being admitted, or admission failed. | not authorized: ... naming what the certificate offered, listener stopped, admission failed |
displaced |
A newer connection was admitted in place of this one. | displaced by <address>:<port> |
ended |
An admitted connection ended. | closed by peer, connection reset, broken pipe, timeout (nothing received within the idle timeout, or the peer stopped reading), listener stopped, error: <exception type> |
Each admitted connection is reported accepted once and then displaced or
ended once. A TLS handshake that fails, such as one from a client whose
certificate the context does not trust, ends before the listener sees the
connection, and is not reported.
At the master¶
from py1815.master import Master
async with Master() as master:
lab = await master.add("lab", host="192.0.2.10", connection_observer=connection)
The master reports these outcomes. name is the name the outstation was added
under, and remote is the host and port connected to.
| Outcome | When | Reason |
|---|---|---|
connected |
The connection is made. Over TLS, peer_identity is the name the outstation's certificate was checked against, or its sha256: fingerprint when the context checks no name. |
empty |
failed |
An attempt to connect failed. Every attempt is reported, including each automatic reconnection attempt. | refused, timeout, TLS failure: ..., connection reset, unreachable: ..., closed at once |
lost |
A connection that was made ended without close(). |
closed by peer, connection reset, timeout, TLS failure: ..., error: ... |
ended |
close() closed the connection. |
closed by the master |
Each connected is followed by one lost or ended. A reconnection attempt
whose new connection the outstation closes at once is reported as connected
and lost, and then also as failed with the reason closed at once, so that a
count of failed attempts includes it. on_connection, which is called with
True and False, works as before, beside the observer.
Protocol anomalies¶
Each anomaly has a stable code, a severity, the message the session also
logs, and the link addresses involved. peer is the connection the octets
arrived on, or the sender of a broadcast datagram, when the session is served
by OutstationServer; it is None for a session driven some other way.
{"code": "unexpected_master_address", "severity": "high",
"message": "dropping frame from unexpected master address 2", "source": 2, "destination": 1024,
"peer": {"host": "192.0.2.10", "port": 49152}, "timestamp": 1792310400.0}
| Code | Severity | What the session did |
|---|---|---|
unexpected_master_address |
high | Dropped a frame from a master address other than the one it serves |
invalid_source_address |
high | Dropped a frame from an address no master can have: a reserved or broadcast address, or the outstation's own |
invalid_frame_count_valid |
medium | Dropped a frame whose frame count valid bit does not match its function |
link_payload_mismatch |
medium | Dropped a user data frame with no data, or another link function carrying data |
malformed_broadcast_write |
medium | Dropped a broadcast write whose objects could not be read |
not_a_request |
medium | Dropped a fragment that cannot be a request: too short, not a whole message, or marked unsolicited |
malformed_request |
medium | Answered a request whose object headers could not be read with an error indication |
transport_error |
low | Discarded a transport segment out of sequence, too large, or otherwise refused |
broadcast_transport_error |
low | The same, for a broadcast |
unsolicited_not_confirmed |
low | Stopped retrying an unsolicited response the master did not confirm; its events stay buffered |
broadcast_not_acted_on |
info | Received a broadcast and did not carry it out: the function is disabled, broadcast controls are off, or the function is not one acted on by broadcast |
The severities mean:
- high: traffic from an address that should not be speaking on this association.
- medium: a malformed frame or request, from a defective or misconfigured peer or one probing the outstation.
- low: as easily explained by a lossy link or a slow master as by misbehavior.
- info: expected in normal operation, and reported for completeness.
SEVERITY maps each code to its severity. Requests the outstation refuses with
an indication, such as an unsupported function or a control it cannot carry out,
are answers the protocol defines and are not reported as anomalies.
Writing an observer¶
An observer is called on the event loop, or for a session driven without one, on the thread that passed it the octets. It should return quickly: hand the record to a queue or a logger rather than writing to a remote service in the call.
An observer that raises does not affect the connection or the session. The first exception from an observer is logged with its traceback at ERROR; later exceptions from the same observer are logged at DEBUG.
Each record is a frozen dataclass of strings, numbers and nested Endpoint
records, so dataclasses.asdict gives a dictionary that json.dumps accepts.
timestamp is seconds since the Unix epoch. A reason or a message holds no
secret: no key, password or token is ever part of one.