ser2netweb 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1928 @@
|
|
|
1
|
+
"""Per-mapping bridge runner: TCP listener + shared serial endpoint + clients.
|
|
2
|
+
|
|
3
|
+
One ``MappingRunner`` per configured mapping owns:
|
|
4
|
+
- an asyncio TCP server bound to the mapping's IP:port,
|
|
5
|
+
- a supervised serial endpoint (serial_link.SerialLink: opened and kept open,
|
|
6
|
+
auto-reconnecting),
|
|
7
|
+
- a set of connected ``_ClientConn`` objects.
|
|
8
|
+
|
|
9
|
+
Data model: a single serial port is shared by up to ``max_connections`` TCP
|
|
10
|
+
clients. Bytes read from serial are broadcast to every client (each through its
|
|
11
|
+
own protocol session); bytes from any client are written to the shared serial
|
|
12
|
+
port. Each client has its OWN bounded outbound queue + writer task, so a slow
|
|
13
|
+
client cannot stall serial reads for the others (per-client backpressure
|
|
14
|
+
isolation). Network->serial backpressure is handled by awaiting the serial
|
|
15
|
+
writer's drain() before reading more from that client.
|
|
16
|
+
|
|
17
|
+
Every TCP data socket (accepted or connect-out) gets TCP keepalive (sockopts.py), so
|
|
18
|
+
a peer that vanished without a FIN/RST is detected and its slot freed instead of
|
|
19
|
+
staying ESTABLISHED forever; each disconnect is logged with its reason.
|
|
20
|
+
|
|
21
|
+
A UDP mapping can't slow a sender down: its datagrams wait in a bounded queue
|
|
22
|
+
(UDP_BACKLOG_MAX) that is written to the port the same way, and a datagram that finds it
|
|
23
|
+
full is dropped and counted. The device's output goes to the last allowed sender, or
|
|
24
|
+
always to a configured reply address.
|
|
25
|
+
|
|
26
|
+
Traffic counters (RunnerStatus) count at the device, once: bytes_in what was read from
|
|
27
|
+
it, bytes_out what was handed to it, bytes_dropped what the network sent for it that
|
|
28
|
+
was not written (the port was not open, or the UDP queue was full).
|
|
29
|
+
|
|
30
|
+
What a telnet / RFC 2217 client sends is carried out in order (ProtocolSession.
|
|
31
|
+
from_network_parts): its data, a serial BREAK it asks for (IAC BRK) and the commands that
|
|
32
|
+
act on the port (RFC 2217 SET-* / PURGE-DATA), each after the data before it was written
|
|
33
|
+
and, for a BREAK or a setting, has left the port (at most SETTLE_MAX_S). A BREAK holds the
|
|
34
|
+
line in the break condition for BREAK_S without blocking the event loop (pyserial's
|
|
35
|
+
send_break() sleeps in the calling thread); a read-only mapping sends none.
|
|
36
|
+
|
|
37
|
+
With MQTT, the device's lines are published; with mqtt.tx_enabled each message on
|
|
38
|
+
<base_topic>/tx is written to the port as it is, in one piece between what TCP clients
|
|
39
|
+
send, from a queue of at most MQTT_TX_MAX_BYTES, and answered on <base_topic>/tx/result.
|
|
40
|
+
options.poll_string is sent every poll_interval_s while the port is open (by default not
|
|
41
|
+
while a TCP client is connected).
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import asyncio
|
|
46
|
+
import concurrent.futures
|
|
47
|
+
import contextlib
|
|
48
|
+
import errno
|
|
49
|
+
import io
|
|
50
|
+
import ipaddress
|
|
51
|
+
import math
|
|
52
|
+
import os
|
|
53
|
+
import queue
|
|
54
|
+
import re
|
|
55
|
+
import socket
|
|
56
|
+
import sys
|
|
57
|
+
import threading
|
|
58
|
+
import time
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
from typing import TYPE_CHECKING, Optional
|
|
61
|
+
|
|
62
|
+
# asyncio.TaskGroup and exception groups are native on Python 3.11+. On 3.10 we
|
|
63
|
+
# use the pure-Python backports (bundled in vendor/wheels: taskgroup +
|
|
64
|
+
# exceptiongroup), which preserve TaskGroup semantics exactly. BaseExceptionGroup
|
|
65
|
+
# is a builtin on 3.11+, so it is only imported on the backport path.
|
|
66
|
+
if sys.version_info >= (3, 11):
|
|
67
|
+
from asyncio import TaskGroup
|
|
68
|
+
else: # pragma: no cover - exercised only on Python 3.10
|
|
69
|
+
from taskgroup import TaskGroup
|
|
70
|
+
from exceptiongroup import BaseExceptionGroup
|
|
71
|
+
|
|
72
|
+
from .. import i18n, storage
|
|
73
|
+
from ..config import (
|
|
74
|
+
MQTT_TX_MAX_BYTES, MappingConfig, canonical_ip, host_port, mapping_file, mqtt_topic_problem,
|
|
75
|
+
poll_bytes, udp_peer_address, udp_peer_timeout, udp_targets,
|
|
76
|
+
)
|
|
77
|
+
from . import netguard, portdiag, serial_io, sockopts, tls
|
|
78
|
+
from .netguard import LogEvery as _LogEvery
|
|
79
|
+
from .access import AccessPolicy, connection_order, server_ssl
|
|
80
|
+
from .protocols import make_session
|
|
81
|
+
from .protocols.base import BREAK, ProtocolError, ProtocolSession
|
|
82
|
+
from .serial_link import (
|
|
83
|
+
Backoff, FailureLog, NoteLog, SerialLink, close_stream, explain, fmt_duration, sent_within,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if TYPE_CHECKING:
|
|
87
|
+
from .mqtt_pub import MqttPublisher
|
|
88
|
+
|
|
89
|
+
READ_CHUNK = 4096
|
|
90
|
+
# directions for traffic monitors (the live hex view): bytes read from the serial
|
|
91
|
+
# device / bytes written to it (serial<->serial bridge: port A -> B / B -> A)
|
|
92
|
+
FROM_DEVICE = 0
|
|
93
|
+
TO_DEVICE = 1
|
|
94
|
+
CLIENT_QUEUE_MAX = 2048 # outbound chunks buffered per client before it's dropped
|
|
95
|
+
# UDP can't make a sender wait, so a UDP mapping's datagrams wait for its serial port in a
|
|
96
|
+
# queue of at most this many bytes, written like a TCP client's data (drain() keeps the
|
|
97
|
+
# port's own buffer at its high-water mark). A datagram that would go beyond it is dropped
|
|
98
|
+
# and counted (bytes_dropped): a sender faster than the line costs neither memory nor
|
|
99
|
+
# event-loop time. 64 KiB is that high-water mark too, about 68 s of line time at 9600
|
|
100
|
+
# baud and 6 s at 115200; the largest datagram fits.
|
|
101
|
+
UDP_BACKLOG_MAX = 64 * 1024
|
|
102
|
+
# a client connection's graceful close (and the flush before a closeon close) gets this
|
|
103
|
+
# long; a peer that keeps the connection up but reads nothing is then cut off, instead of
|
|
104
|
+
# holding one of the mapping's connection slots for as long as TCP takes to give up
|
|
105
|
+
TCP_CLOSE_TIMEOUT_S = 5.0
|
|
106
|
+
# a serial BREAK (telnet IAC BRK, the web console): the line is held in the break condition
|
|
107
|
+
# this long, as pyserial's send_break() and most terminal programs do
|
|
108
|
+
BREAK_S = 0.25
|
|
109
|
+
# before a BREAK or an RFC 2217 setting change, the data the client sent ahead of it gets
|
|
110
|
+
# up to this long to leave the port (its line time, twice, plus 0.2 s): a port that holds
|
|
111
|
+
# its output (flow control, a driver that never reports an empty queue) delays the command
|
|
112
|
+
# no more than this, since an RFC 2217 client waits about 3 s for its answer
|
|
113
|
+
SETTLE_MAX_S = 2.0
|
|
114
|
+
# errno/winerror values for "the peer stopped answering" (keepalive or retransmission
|
|
115
|
+
# timeout): ETIMEDOUT, plus on Windows WSAETIMEDOUT, WSAENETRESET and the overlapped-I/O
|
|
116
|
+
# ERROR_SEM_TIMEOUT.
|
|
117
|
+
_PEER_TIMEOUT_ERRNOS = {errno.ETIMEDOUT, 10060, 10052, 121}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _Disconnect(ConnectionError):
|
|
121
|
+
"""Ends a client session for a known reason (the message is logged)."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _close_client(writer: asyncio.StreamWriter) -> None:
|
|
125
|
+
"""Close a client's connection gracefully (what is queued for it goes out first), and
|
|
126
|
+
abort it if that takes longer than TCP_CLOSE_TIMEOUT_S."""
|
|
127
|
+
with contextlib.suppress(Exception):
|
|
128
|
+
writer.close()
|
|
129
|
+
closed = asyncio.ensure_future(writer.wait_closed())
|
|
130
|
+
closed.add_done_callback(lambda f: f.cancelled() or f.exception())
|
|
131
|
+
# asyncio.wait, not wait_for: the timeout must not cancel the stream's close future
|
|
132
|
+
done, _pending = await asyncio.wait((closed,), timeout=TCP_CLOSE_TIMEOUT_S)
|
|
133
|
+
if not done:
|
|
134
|
+
with contextlib.suppress(Exception):
|
|
135
|
+
writer.transport.abort()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def describe_disconnect(exc: BaseException) -> str:
|
|
139
|
+
"""Short, operator-facing reason a TCP client connection ended."""
|
|
140
|
+
if isinstance(exc, (_Disconnect, ProtocolError)):
|
|
141
|
+
return str(exc)
|
|
142
|
+
code = getattr(exc, "winerror", None) or getattr(exc, "errno", None)
|
|
143
|
+
if code in _PEER_TIMEOUT_ERRNOS:
|
|
144
|
+
return "peer stopped responding (TCP timeout: unreachable, or not reading its data)"
|
|
145
|
+
if isinstance(exc, ConnectionResetError):
|
|
146
|
+
# Windows reports a failed keepalive as WSAECONNRESET too, so don't blame the peer
|
|
147
|
+
if sys.platform == "win32":
|
|
148
|
+
return "connection reset (by the peer, or TCP keepalive found it unreachable)"
|
|
149
|
+
return "connection reset by peer"
|
|
150
|
+
if isinstance(exc, asyncio.IncompleteReadError):
|
|
151
|
+
return "closed by peer"
|
|
152
|
+
return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _iter_leaf_exceptions(eg: BaseExceptionGroup):
|
|
156
|
+
"""Yield the individual (non-group) exceptions from a possibly-nested group."""
|
|
157
|
+
for exc in eg.exceptions:
|
|
158
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
159
|
+
yield from _iter_leaf_exceptions(exc)
|
|
160
|
+
else:
|
|
161
|
+
yield exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def trace_path(trace_dir: str, mapping: MappingConfig) -> str:
|
|
165
|
+
"""Where a mapping's trace goes: <trace_dir>/<mapping id>-<file name>. The id
|
|
166
|
+
prefix keeps two mappings with the same file name apart (and makes a mapping's
|
|
167
|
+
files easy to find); the name is already sanitised by config.trace_file_name.
|
|
168
|
+
Raises ValueError if that would not be a file in trace_dir (config.mapping_file)."""
|
|
169
|
+
return mapping_file(trace_dir, mapping.id, f"-{mapping.options.trace_both}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def open_tracer(mapping: MappingConfig, trace_dir: Optional[str], log,
|
|
173
|
+
status: Optional["RunnerStatus"] = None) -> Optional["Tracer"]:
|
|
174
|
+
"""The mapping's tracer when tracing is on (None when off or unavailable). `status`:
|
|
175
|
+
the runner's, whose trace_error the tracer keeps up to date."""
|
|
176
|
+
if not mapping.options.trace_both:
|
|
177
|
+
return None
|
|
178
|
+
if not trace_dir:
|
|
179
|
+
log("trace not written: no data directory to keep it in")
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
path = trace_path(trace_dir, mapping)
|
|
183
|
+
except ValueError as e:
|
|
184
|
+
log(f"trace not written: {e}")
|
|
185
|
+
return None
|
|
186
|
+
tracer = Tracer(path, mapping.options, log=log, status=status)
|
|
187
|
+
if not tracer.error: # else it said why, and keeps trying
|
|
188
|
+
log(f"tracing traffic to {tracer.path}")
|
|
189
|
+
return tracer
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def remove_files(paths) -> dict[str, Optional[OSError]]:
|
|
193
|
+
"""Delete these files: path -> None once it is gone (also when it was gone already),
|
|
194
|
+
else the OSError (e.g. Windows: the file is open for a download)."""
|
|
195
|
+
out: dict[str, Optional[OSError]] = {}
|
|
196
|
+
for path in paths:
|
|
197
|
+
try:
|
|
198
|
+
os.remove(path)
|
|
199
|
+
out[path] = None
|
|
200
|
+
except FileNotFoundError:
|
|
201
|
+
out[path] = None
|
|
202
|
+
except OSError as e:
|
|
203
|
+
out[path] = e
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _Job:
|
|
208
|
+
"""Something a Tracer's writer thread does in turn with the lines queued before it."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, fn) -> None:
|
|
211
|
+
self.fn = fn
|
|
212
|
+
self.future: concurrent.futures.Future = concurrent.futures.Future()
|
|
213
|
+
|
|
214
|
+
def run(self) -> None:
|
|
215
|
+
try:
|
|
216
|
+
self.future.set_result(self.fn())
|
|
217
|
+
except BaseException as e: # for the waiter
|
|
218
|
+
self.future.set_exception(e)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Tracer:
|
|
222
|
+
"""Append a mapping's traffic to its trace file (optional hex dump + timestamp)
|
|
223
|
+
without blocking the event loop.
|
|
224
|
+
|
|
225
|
+
``write()`` only queues the chunk (with its arrival time); a writer thread formats,
|
|
226
|
+
writes and flushes in batches and rotates the file once it passes ``max_bytes``
|
|
227
|
+
(<file>.1 ... <file>.<keep>; keep=0 begins a new file; rotated files past keep, left
|
|
228
|
+
when it was lowered, are deleted when the trace starts). If the disk can't keep
|
|
229
|
+
up, at most QUEUE_MAX_BYTES wait in memory: newer chunks are dropped and the
|
|
230
|
+
trace says how many bytes are missing.
|
|
231
|
+
|
|
232
|
+
When the file can't be opened or written (the traces folder was removed, the disk is
|
|
233
|
+
full, no file descriptor is left, another program holds the file), the trace pauses
|
|
234
|
+
instead of ending: `error` says why (also the runner's status.trace_error), write()
|
|
235
|
+
only counts the bytes, the writer tries again every RETRY_S seconds (making the folder
|
|
236
|
+
again), and once it writes again the trace says how many bytes are missing. The log
|
|
237
|
+
says so at most once per LOG_EVERY_S. So does a disk almost full: below
|
|
238
|
+
storage.floor() free space (checked at each open and after every SPACE_CHECK_BYTES
|
|
239
|
+
written) the trace pauses before the disk fills up, and goes on once there is 10%
|
|
240
|
+
more again."""
|
|
241
|
+
|
|
242
|
+
QUEUE_MAX_BYTES = 8 * 1024 * 1024
|
|
243
|
+
BATCH = 256
|
|
244
|
+
RETRY_S = 5.0
|
|
245
|
+
LOG_EVERY_S = 600.0
|
|
246
|
+
SPACE_CHECK_BYTES = 8 * 1024 * 1024
|
|
247
|
+
|
|
248
|
+
def __init__(self, path: str, options, log=None,
|
|
249
|
+
status: Optional["RunnerStatus"] = None) -> None:
|
|
250
|
+
self.path = path
|
|
251
|
+
self._hex = options.trace_hexdump
|
|
252
|
+
self._ts = options.trace_timestamp
|
|
253
|
+
self._max_bytes = max(1, int(options.trace_max_mb * 1024 * 1024))
|
|
254
|
+
self._keep = int(options.trace_keep)
|
|
255
|
+
self._log = log or (lambda _m: None)
|
|
256
|
+
self._log_failure = _LogEvery(self._log, self.LOG_EVERY_S)
|
|
257
|
+
self._log_rotation = _LogEvery(self._log, self.LOG_EVERY_S)
|
|
258
|
+
self._log_resumed = _LogEvery(self._log, self.LOG_EVERY_S)
|
|
259
|
+
self._status = status
|
|
260
|
+
self._q: queue.SimpleQueue = queue.SimpleQueue()
|
|
261
|
+
self._lock = threading.Lock()
|
|
262
|
+
self._queued = 0 # bytes waiting for the writer
|
|
263
|
+
self._dropped = 0 # bytes dropped since the last line written
|
|
264
|
+
self._drop_reason = ""
|
|
265
|
+
self._closing = False # aclose() was called: nothing more is queued
|
|
266
|
+
self._done = False # the writer has ended (remove_files then returns None)
|
|
267
|
+
self._resumed = False # opened again after an error: logged once it writes
|
|
268
|
+
self._unchecked = 0 # bytes written since the free space was checked
|
|
269
|
+
self._begun: Optional[float] = None # when the traffic in the file begins (None: empty)
|
|
270
|
+
self._low_space = False # paused for the free space (goes on with some to spare)
|
|
271
|
+
self.error = "" # why the trace is not written now ("" while it is)
|
|
272
|
+
self._fh: Optional[io.BufferedWriter] = None
|
|
273
|
+
try:
|
|
274
|
+
self._reopen()
|
|
275
|
+
except OSError as e:
|
|
276
|
+
self._failed(e)
|
|
277
|
+
self._thread = threading.Thread(target=self._run, daemon=True,
|
|
278
|
+
name=f"trace:{os.path.basename(path)}")
|
|
279
|
+
self._thread.start()
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def active(self) -> bool:
|
|
283
|
+
"""The trace is on (it may be paused, see `error`), not closed."""
|
|
284
|
+
return not self._closing
|
|
285
|
+
|
|
286
|
+
def write(self, direction: str, data: bytes) -> None:
|
|
287
|
+
if not data or self._closing:
|
|
288
|
+
return
|
|
289
|
+
with self._lock:
|
|
290
|
+
if self.error: # paused: only counted (the trace says so once it goes on)
|
|
291
|
+
self._dropped += len(data)
|
|
292
|
+
return
|
|
293
|
+
if self._queued + len(data) > self.QUEUE_MAX_BYTES:
|
|
294
|
+
self._dropped += len(data)
|
|
295
|
+
self._drop_reason = "the trace file could not keep up"
|
|
296
|
+
return
|
|
297
|
+
self._queued += len(data)
|
|
298
|
+
self._q.put((time.time(), direction, bytes(data)))
|
|
299
|
+
|
|
300
|
+
def remove_files(self, paths) -> Optional[concurrent.futures.Future]:
|
|
301
|
+
"""Delete some of this trace's files in the writer thread, in turn with the lines
|
|
302
|
+
queued before (so never in the middle of a rotation). The file it writes is
|
|
303
|
+
closed first and begun again with the next traffic: a deleted trace is never
|
|
304
|
+
written on (on POSIX an open file that was deleted keeps its disk space, on
|
|
305
|
+
Windows it can't be deleted). The future's result: as remove_files(). None once
|
|
306
|
+
the writer has ended: they are files like any other then."""
|
|
307
|
+
job = _Job(lambda: self._remove(paths))
|
|
308
|
+
with self._lock:
|
|
309
|
+
if self._done:
|
|
310
|
+
return None
|
|
311
|
+
self._q.put(job)
|
|
312
|
+
return job.future
|
|
313
|
+
|
|
314
|
+
def _prune_rotated(self) -> None:
|
|
315
|
+
"""Delete this trace's rotated files past the number kept (<file>.N, N > keep):
|
|
316
|
+
left when keep was lowered, no rotation would ever reach them. Logged."""
|
|
317
|
+
folder, base = os.path.split(os.path.abspath(self.path))
|
|
318
|
+
try:
|
|
319
|
+
names = os.listdir(folder)
|
|
320
|
+
except OSError:
|
|
321
|
+
return
|
|
322
|
+
extra = []
|
|
323
|
+
for name in names:
|
|
324
|
+
number = name[len(base) + 1:] if name.startswith(base + ".") else ""
|
|
325
|
+
if number.isascii() and number.isdigit() and int(number) > self._keep:
|
|
326
|
+
extra.append(os.path.join(folder, name))
|
|
327
|
+
gone = [p for p, e in remove_files(extra).items() if e is None]
|
|
328
|
+
if gone:
|
|
329
|
+
self._log(f"trace: deleted {len(gone)} rotated file(s) of {self.path} past the "
|
|
330
|
+
f"{self._keep} kept")
|
|
331
|
+
|
|
332
|
+
def renew_if_older(self, begun_before: float) -> Optional[concurrent.futures.Future]:
|
|
333
|
+
"""Have the writer begin a new file (rotating this one, as at its size) if the file
|
|
334
|
+
it writes holds traffic from before `begun_before`, in turn with the lines queued
|
|
335
|
+
before: the retention by age (trace_max_age_days) can then delete it. The future's
|
|
336
|
+
result: whether it did. None once the writer has ended."""
|
|
337
|
+
job = _Job(lambda: self._renew(begun_before))
|
|
338
|
+
with self._lock:
|
|
339
|
+
if self._done:
|
|
340
|
+
return None
|
|
341
|
+
self._q.put(job)
|
|
342
|
+
return job.future
|
|
343
|
+
|
|
344
|
+
def _renew(self, begun_before: float) -> bool:
|
|
345
|
+
if self._fh is None or self._begun is None or self._begun >= begun_before:
|
|
346
|
+
return False
|
|
347
|
+
self._rotate()
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
def _remove(self, paths) -> dict[str, Optional[OSError]]:
|
|
351
|
+
mine = os.path.normcase(os.path.abspath(self.path))
|
|
352
|
+
if any(os.path.normcase(os.path.abspath(p)) == mine for p in paths):
|
|
353
|
+
self._close_file()
|
|
354
|
+
return remove_files(paths)
|
|
355
|
+
|
|
356
|
+
def _format(self, ts: float, direction: str, data: bytes) -> bytes:
|
|
357
|
+
prefix = time.strftime("%Y-%m-%d %H:%M:%S ", time.localtime(ts)) if self._ts else ""
|
|
358
|
+
if self._hex:
|
|
359
|
+
return f"{prefix}{direction} [{len(data)}] {data.hex(' ')}\n".encode()
|
|
360
|
+
return prefix.encode() + direction.encode() + b" " + data + b"\n"
|
|
361
|
+
|
|
362
|
+
def _lines(self, ts: float, direction: str, data: bytes) -> list:
|
|
363
|
+
"""The trace lines of one chunk, each with the traffic bytes it stands for: what
|
|
364
|
+
was not traced before it (if anything), then the chunk."""
|
|
365
|
+
with self._lock:
|
|
366
|
+
self._queued -= len(data)
|
|
367
|
+
dropped, self._dropped = self._dropped, 0
|
|
368
|
+
reason = self._drop_reason
|
|
369
|
+
lines = []
|
|
370
|
+
if dropped:
|
|
371
|
+
lines.append((self._format(ts, "!!", f"{dropped} bytes not traced "
|
|
372
|
+
f"({reason})".encode()), dropped))
|
|
373
|
+
lines.append((self._format(ts, direction, data), len(data)))
|
|
374
|
+
return lines
|
|
375
|
+
|
|
376
|
+
def _run(self) -> None:
|
|
377
|
+
self._prune_rotated()
|
|
378
|
+
stop = False
|
|
379
|
+
while not stop:
|
|
380
|
+
try: # paused: wake up to try the file again
|
|
381
|
+
first = self._q.get(timeout=self.RETRY_S) if self.error else self._q.get()
|
|
382
|
+
except queue.Empty:
|
|
383
|
+
try:
|
|
384
|
+
self._reopen()
|
|
385
|
+
except OSError as e:
|
|
386
|
+
self._failed(e)
|
|
387
|
+
continue
|
|
388
|
+
batch = [first]
|
|
389
|
+
while len(batch) < self.BATCH:
|
|
390
|
+
try:
|
|
391
|
+
batch.append(self._q.get_nowait())
|
|
392
|
+
except queue.Empty:
|
|
393
|
+
break
|
|
394
|
+
out: list = []
|
|
395
|
+
try:
|
|
396
|
+
for item in batch:
|
|
397
|
+
if item is None:
|
|
398
|
+
stop = True
|
|
399
|
+
elif isinstance(item, _Job):
|
|
400
|
+
self._append(out)
|
|
401
|
+
out = []
|
|
402
|
+
item.run()
|
|
403
|
+
else:
|
|
404
|
+
out.extend(self._lines(*item))
|
|
405
|
+
self._append(out)
|
|
406
|
+
except Exception as e: # never end the writer: the trace would stop unnoticed
|
|
407
|
+
self._failed(e)
|
|
408
|
+
with self._lock:
|
|
409
|
+
self._done = True
|
|
410
|
+
self._close_file()
|
|
411
|
+
self._set_error("")
|
|
412
|
+
while True: # a job queued as the writer ended: the file is closed now
|
|
413
|
+
try:
|
|
414
|
+
item = self._q.get_nowait()
|
|
415
|
+
except queue.Empty:
|
|
416
|
+
break
|
|
417
|
+
if isinstance(item, _Job):
|
|
418
|
+
item.run()
|
|
419
|
+
|
|
420
|
+
def _append(self, lines: list) -> None:
|
|
421
|
+
"""Write a batch of lines; rotate as soon as the file reaches its size. When that
|
|
422
|
+
fails, the trace pauses and the lines not written are counted as not traced."""
|
|
423
|
+
if not lines:
|
|
424
|
+
return
|
|
425
|
+
done = 0 # lines written
|
|
426
|
+
try:
|
|
427
|
+
fh = self._fh if self._fh is not None else self._reopen()
|
|
428
|
+
size = fh.tell()
|
|
429
|
+
start = 0
|
|
430
|
+
for i, (line, _n) in enumerate(lines):
|
|
431
|
+
size += len(line)
|
|
432
|
+
if size >= self._max_bytes:
|
|
433
|
+
self._write(lines[start:i + 1])
|
|
434
|
+
done = start = i + 1
|
|
435
|
+
self._rotate()
|
|
436
|
+
size = 0
|
|
437
|
+
if start < len(lines):
|
|
438
|
+
self._write(lines[start:])
|
|
439
|
+
done = len(lines)
|
|
440
|
+
except Exception as e: # OSError mostly; ValueError for a file closed under it
|
|
441
|
+
self._failed(e, lines[done:])
|
|
442
|
+
return
|
|
443
|
+
if self._resumed:
|
|
444
|
+
self._resumed = False
|
|
445
|
+
self._log_resumed(f"trace written again ({self.path})")
|
|
446
|
+
|
|
447
|
+
def _write(self, lines: list) -> None:
|
|
448
|
+
if self._fh is None:
|
|
449
|
+
raise OSError(f"{self.path} is not open")
|
|
450
|
+
data = b"".join(line for line, _n in lines)
|
|
451
|
+
if self._begun is None:
|
|
452
|
+
self._begun = time.time()
|
|
453
|
+
self._unchecked += len(data)
|
|
454
|
+
if self._unchecked >= self.SPACE_CHECK_BYTES:
|
|
455
|
+
self._unchecked = 0
|
|
456
|
+
storage.check_space(os.path.dirname(os.path.abspath(self.path)))
|
|
457
|
+
self._fh.write(data)
|
|
458
|
+
self._fh.flush()
|
|
459
|
+
|
|
460
|
+
def _rotate(self) -> None:
|
|
461
|
+
"""Move the file to <file>.1 (the older ones up), or with keep=0 delete it, and
|
|
462
|
+
begin a new one. Never cut in place, so a download still reading the file gets
|
|
463
|
+
all of it (POSIX; on Windows a file open for a download can't be renamed or
|
|
464
|
+
deleted: the trace goes on in the same file until it can). Raises OSError when
|
|
465
|
+
the new file can't be opened."""
|
|
466
|
+
fh, self._fh = self._fh, None
|
|
467
|
+
if fh is not None:
|
|
468
|
+
fh.close()
|
|
469
|
+
try:
|
|
470
|
+
if self._keep > 0:
|
|
471
|
+
for i in range(self._keep, 0, -1):
|
|
472
|
+
src = self.path if i == 1 else f"{self.path}.{i - 1}"
|
|
473
|
+
if os.path.exists(src):
|
|
474
|
+
os.replace(src, f"{self.path}.{i}")
|
|
475
|
+
else:
|
|
476
|
+
with contextlib.suppress(FileNotFoundError):
|
|
477
|
+
os.remove(self.path)
|
|
478
|
+
except OSError as e: # e.g. Windows: a rotated file is open for download
|
|
479
|
+
self._log_rotation(f"trace rotation failed ({self.path}): {e}; "
|
|
480
|
+
"continuing in the same file")
|
|
481
|
+
self._reopen()
|
|
482
|
+
|
|
483
|
+
def _reopen(self) -> io.BufferedWriter:
|
|
484
|
+
"""Open the file to append, making its folder again (it may have been removed).
|
|
485
|
+
Raises OSError (storage.LowSpace: the disk is almost full)."""
|
|
486
|
+
folder = os.path.dirname(os.path.abspath(self.path))
|
|
487
|
+
os.makedirs(folder, mode=0o700, exist_ok=True)
|
|
488
|
+
storage.check_space(folder, resuming=self._low_space)
|
|
489
|
+
self._fh = open(self.path, "ab")
|
|
490
|
+
self._unchecked, self._low_space = 0, False
|
|
491
|
+
self._begun = self._first_time() if self._fh.tell() else None
|
|
492
|
+
if self.error:
|
|
493
|
+
self._resumed = True
|
|
494
|
+
self._set_error("")
|
|
495
|
+
return self._fh
|
|
496
|
+
|
|
497
|
+
def _first_time(self) -> float:
|
|
498
|
+
"""When the traffic in the (not empty) file begins: the time of its first line
|
|
499
|
+
(trace_timestamp), else when it was last written (no earlier time is known)."""
|
|
500
|
+
try:
|
|
501
|
+
with open(self.path, "rb") as fh:
|
|
502
|
+
head = fh.read(19).decode("ascii")
|
|
503
|
+
return time.mktime(time.strptime(head, "%Y-%m-%d %H:%M:%S"))
|
|
504
|
+
except (OSError, ValueError, OverflowError):
|
|
505
|
+
try:
|
|
506
|
+
return os.path.getmtime(self.path)
|
|
507
|
+
except OSError:
|
|
508
|
+
return time.time()
|
|
509
|
+
|
|
510
|
+
def _close_file(self) -> None:
|
|
511
|
+
fh, self._fh = self._fh, None
|
|
512
|
+
if fh is not None:
|
|
513
|
+
with contextlib.suppress(Exception):
|
|
514
|
+
fh.close()
|
|
515
|
+
|
|
516
|
+
def _set_error(self, text: str) -> None:
|
|
517
|
+
with self._lock:
|
|
518
|
+
self.error = text
|
|
519
|
+
if self._status is not None:
|
|
520
|
+
self._status.trace_error = text
|
|
521
|
+
|
|
522
|
+
def _failed(self, e: BaseException, lost=()) -> None:
|
|
523
|
+
"""Opening or writing the file failed: close it, count the lines `lost` as not
|
|
524
|
+
traced and pause until the file can be opened again."""
|
|
525
|
+
self._close_file()
|
|
526
|
+
reason = (e.strerror if isinstance(e, OSError) and e.strerror else str(e)) \
|
|
527
|
+
or type(e).__name__
|
|
528
|
+
self._low_space = isinstance(e, storage.LowSpace)
|
|
529
|
+
with self._lock:
|
|
530
|
+
self._dropped += sum(n for _line, n in lost)
|
|
531
|
+
self._drop_reason = ("the disk was almost full" if self._low_space else
|
|
532
|
+
"the trace file could not be written")
|
|
533
|
+
self._set_error(reason)
|
|
534
|
+
self._log_failure(f"trace not written ({self.path}): {reason}; trying again every "
|
|
535
|
+
f"{self.RETRY_S:g} s (the trace will say how much is missing)")
|
|
536
|
+
|
|
537
|
+
async def aclose(self) -> None:
|
|
538
|
+
"""Flush what is queued, close the file. Waits for the writer off the loop."""
|
|
539
|
+
if self._closing:
|
|
540
|
+
return
|
|
541
|
+
self._closing = True
|
|
542
|
+
self._q.put(None)
|
|
543
|
+
await asyncio.to_thread(self._thread.join, 10)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass
|
|
547
|
+
class RunnerStatus:
|
|
548
|
+
state: str = "stopped" # stopped|running|reconnecting|device-missing|error
|
|
549
|
+
device: str = ""
|
|
550
|
+
client_count: int = 0
|
|
551
|
+
# connected peers, in the order they connected: each {"peer": "ip:port",
|
|
552
|
+
# "connected_at": time.monotonic() then, "priority": bool}
|
|
553
|
+
clients: list[dict] = field(default_factory=list)
|
|
554
|
+
# counted at the device, once (not per client, no telnet/RFC2217 control bytes);
|
|
555
|
+
# serial<->serial bridge: in = port A -> B, out = port B -> A
|
|
556
|
+
bytes_in: int = 0 # read from the serial device
|
|
557
|
+
bytes_out: int = 0 # handed to the serial device (from clients, datagrams, the console)
|
|
558
|
+
bytes_dropped: int = 0 # sent for the device but not written: the port was not open,
|
|
559
|
+
# or a UDP datagram found UDP_BACKLOG_MAX bytes waiting
|
|
560
|
+
reconnects: int = 0 # serial port: attempts to open it again (failed opens + losses)
|
|
561
|
+
open_failures: int = 0 # of which: opens that failed
|
|
562
|
+
device_lost: int = 0 # times the open port went away
|
|
563
|
+
hangups: int = 0 # of those: hung up and reopened at once (SerialLink)
|
|
564
|
+
dropped_clients: int = 0 # clients dropped because their output queue overflowed
|
|
565
|
+
queue_overflows: int = 0 # total serial->net chunks lost to queue overflow
|
|
566
|
+
last_error: str = ""
|
|
567
|
+
# state "error" because the listener could not take its address: the supervisor tries
|
|
568
|
+
# again at this time.monotonic() (0: no retry pending); as_dict() shows it as
|
|
569
|
+
# retrying / next_retry_s
|
|
570
|
+
retry_at: float = 0.0
|
|
571
|
+
# a connect-out mapping's connection to its remote server ("" for any other mapping,
|
|
572
|
+
# and while stopped): "connecting" (no attempt has failed since the last one that
|
|
573
|
+
# worked, or the link was just closed), "up", or "down" (the last attempt failed:
|
|
574
|
+
# link_error says why). `state` keeps reporting the serial side.
|
|
575
|
+
link: str = ""
|
|
576
|
+
link_error: str = ""
|
|
577
|
+
link_since: float = 0.0 # time.time() of the last change of `link`
|
|
578
|
+
connect_failures: int = 0 # connect-out attempts that failed
|
|
579
|
+
# why the serial port can't be opened, when that can be told (portdiag.Note: the group
|
|
580
|
+
# the device needs, the mapping that has it open, ...), or, with state "error", why the
|
|
581
|
+
# system refused the listener's port and what to do (engine/bindhint.py); last_error
|
|
582
|
+
# keeps the error itself
|
|
583
|
+
hint: Optional[portdiag.Note] = None
|
|
584
|
+
# what to know about the serial port(s) open now (portdiag.Note): a setting a port did
|
|
585
|
+
# not take at open (RTS / DTR, RS-485 mode); as_dict() has them (and hint) as text
|
|
586
|
+
warnings: tuple = ()
|
|
587
|
+
trace_error: str = "" # why the mapping's trace is not written now (bridge.Tracer)
|
|
588
|
+
# the serial devices open now, for the Ports page's "in use" (both ports of a
|
|
589
|
+
# serial<->serial bridge, whose `device` reads "A <-> B"); not part of as_dict()
|
|
590
|
+
devices: tuple[str, ...] = ()
|
|
591
|
+
# what the device sends (watch_traffic): time.time() of the last byte seen from it,
|
|
592
|
+
# and, by time.monotonic(), since when it has sent nothing (from the port's opening)
|
|
593
|
+
# and since when what was sent to it has had no answer
|
|
594
|
+
last_rx: float = 0.0
|
|
595
|
+
_seen: tuple[int, int] = (0, 0)
|
|
596
|
+
_quiet_since: Optional[float] = None
|
|
597
|
+
_unanswered_since: Optional[float] = None
|
|
598
|
+
|
|
599
|
+
def set_link(self, link: str, error: str = "") -> None:
|
|
600
|
+
if link != self.link:
|
|
601
|
+
self.link, self.link_since = link, time.time()
|
|
602
|
+
self.link_error = error
|
|
603
|
+
# Modbus gateway in edge mode: each polled unit, {"unit", "state" (unknown | online |
|
|
604
|
+
# offline), "cause" (offline: "no reply" | "port down"), "since" (unix time of the last
|
|
605
|
+
# change, None before one), "errors" (failed point reads)}
|
|
606
|
+
units: list[dict] = field(default_factory=list)
|
|
607
|
+
# ... and each polled point's last read: {"name", "unit", "value" (the last value read,
|
|
608
|
+
# None before one), "ts" (when, ms since the epoch), "error" (why the last read failed;
|
|
609
|
+
# "" when it gave the value)}
|
|
610
|
+
points: list[dict] = field(default_factory=list)
|
|
611
|
+
# the MQTT publisher's connection to its broker while the mapping runs (empty: MQTT
|
|
612
|
+
# off): MqttPublisher.state — "state" (connecting | connected | down | off), "broker",
|
|
613
|
+
# "since", "error", "dropped"
|
|
614
|
+
mqtt: dict = field(default_factory=dict)
|
|
615
|
+
|
|
616
|
+
def set_clients(self, clients) -> None:
|
|
617
|
+
"""Publish the connected clients (access.Client: peer, seq, connected_at, priority),
|
|
618
|
+
first connected first."""
|
|
619
|
+
self.client_count = len(clients)
|
|
620
|
+
self.clients = [{"peer": c.peer, "connected_at": c.connected_at, "priority": c.priority}
|
|
621
|
+
for c in sorted(clients, key=lambda c: c.seq)]
|
|
622
|
+
|
|
623
|
+
def watch_traffic(self, now: float) -> None:
|
|
624
|
+
"""Compare the counters with the last look (as_dict, every POLL_S at least: the
|
|
625
|
+
notifier's): new bytes from the device end its silence; bytes to it with no reply
|
|
626
|
+
since begin a wait for one. Precise to that interval, which the no-data alarm
|
|
627
|
+
(at least NO_DATA_MIN_S) allows, and nothing is added to the data path."""
|
|
628
|
+
seen, self._seen = self._seen, (self.bytes_in, self.bytes_out)
|
|
629
|
+
if self.state != "running":
|
|
630
|
+
self._quiet_since = self._unanswered_since = None
|
|
631
|
+
return
|
|
632
|
+
if self.bytes_in != seen[0]:
|
|
633
|
+
self.last_rx = time.time()
|
|
634
|
+
self._quiet_since = now
|
|
635
|
+
self._unanswered_since = None
|
|
636
|
+
elif self._quiet_since is None: # the port opened (or a first look): from now
|
|
637
|
+
self._quiet_since = now
|
|
638
|
+
if self.bytes_out != seen[1] and self.bytes_in == seen[0] and \
|
|
639
|
+
self._unanswered_since is None:
|
|
640
|
+
self._unanswered_since = now
|
|
641
|
+
|
|
642
|
+
def as_dict(self) -> dict:
|
|
643
|
+
now = time.monotonic()
|
|
644
|
+
self.watch_traffic(now)
|
|
645
|
+
# connected_at (Unix time, whole seconds): the dashboard counts the duration on from
|
|
646
|
+
# it. Worked out from the monotonic time each time, so it stays the same from one
|
|
647
|
+
# refresh to the next, and a clock step moves it along with the server's clock
|
|
648
|
+
wall = time.time() - now
|
|
649
|
+
clients = [
|
|
650
|
+
{"peer": c["peer"], "duration": fmt_duration(now - c["connected_at"]),
|
|
651
|
+
"connected_s": max(0, int(now - c["connected_at"])), # the same, as a number
|
|
652
|
+
"priority": c.get("priority", False),
|
|
653
|
+
"connected_at": float(round(wall + c["connected_at"]))}
|
|
654
|
+
for c in self.clients
|
|
655
|
+
]
|
|
656
|
+
return {
|
|
657
|
+
"state": self.state,
|
|
658
|
+
"device": self.device,
|
|
659
|
+
"client_count": self.client_count,
|
|
660
|
+
"clients": clients,
|
|
661
|
+
"bytes_in": self.bytes_in,
|
|
662
|
+
"bytes_out": self.bytes_out,
|
|
663
|
+
"bytes_dropped": self.bytes_dropped,
|
|
664
|
+
"reconnects": self.reconnects,
|
|
665
|
+
"open_failures": self.open_failures,
|
|
666
|
+
"device_lost": self.device_lost,
|
|
667
|
+
"hangups": self.hangups,
|
|
668
|
+
"dropped_clients": self.dropped_clients,
|
|
669
|
+
"queue_overflows": self.queue_overflows,
|
|
670
|
+
"last_error": self.last_error,
|
|
671
|
+
"retrying": bool(self.retry_at),
|
|
672
|
+
"next_retry_s": (max(0, math.ceil(self.retry_at - time.monotonic()))
|
|
673
|
+
if self.retry_at else 0),
|
|
674
|
+
"link": self.link,
|
|
675
|
+
"link_error": self.link_error,
|
|
676
|
+
"link_since": int(self.link_since),
|
|
677
|
+
"connect_failures": self.connect_failures,
|
|
678
|
+
# in the web UI's language on the dashboard (English for the REST API)
|
|
679
|
+
"hint": self.hint.text(i18n.gettext) if self.hint else "",
|
|
680
|
+
"warnings": portdiag.texts(self.warnings, i18n.gettext),
|
|
681
|
+
"trace_error": self.trace_error,
|
|
682
|
+
# the device: when it last sent something (None: not yet), how long it has sent
|
|
683
|
+
# nothing and how long what was sent to it has had no answer (s; None: not
|
|
684
|
+
# running / nothing waits for an answer)
|
|
685
|
+
"last_rx": self.last_rx or None,
|
|
686
|
+
"rx_idle_s": _since(self._quiet_since, now),
|
|
687
|
+
"reply_wait_s": _since(self._unanswered_since, now),
|
|
688
|
+
"units": [dict(u) for u in self.units],
|
|
689
|
+
"points": [dict(p) for p in self.points],
|
|
690
|
+
"mqtt": dict(self.mqtt),
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _since(mark: Optional[float], now: float) -> Optional[float]:
|
|
695
|
+
return None if mark is None else round(now - mark, 1)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def log_ignored_settings(mapping: MappingConfig, log) -> None:
|
|
699
|
+
"""At a runner's start: each setting of the mapping that does nothing, in English
|
|
700
|
+
(MappingConfig.ignored_settings; the edit form shows the same)."""
|
|
701
|
+
with i18n.english():
|
|
702
|
+
for text in mapping.ignored_settings():
|
|
703
|
+
log(f"warning: {text}")
|
|
704
|
+
_BANNER_ESCAPE = re.compile(r"\\([\\dNpIrn])")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _render_banner(template: str, mapping: MappingConfig, device: str, peer: str) -> bytes:
|
|
708
|
+
"""The connect banner: \\d the device, \\N the mapping's name, \\p its port, \\I the
|
|
709
|
+
client's IP, \\r \\n a line break, \\\\ a backslash (C:\\\\data stays C:\\data); any other
|
|
710
|
+
backslash is kept. One pass, so what an escape puts in is not read again."""
|
|
711
|
+
values = {"d": device, "N": mapping.name, "p": str(mapping.network.port), "I": peer,
|
|
712
|
+
"r": "\r", "n": "\n", "\\": "\\"}
|
|
713
|
+
return _BANNER_ESCAPE.sub(lambda m: values[m.group(1)], template).encode("utf-8", "replace")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class _ClientConn:
|
|
717
|
+
def __init__(self, runner: "MappingRunner", reader: asyncio.StreamReader,
|
|
718
|
+
writer: asyncio.StreamWriter, peer_ip: str, peer_port: int = 0,
|
|
719
|
+
priority: bool = False):
|
|
720
|
+
self.runner = runner
|
|
721
|
+
self.reader = reader
|
|
722
|
+
self.writer = writer
|
|
723
|
+
self.peer_ip = peer_ip
|
|
724
|
+
self.peer_port = peer_port
|
|
725
|
+
self.peer = netguard.client_label(peer_ip, peer_port)
|
|
726
|
+
self.priority = priority
|
|
727
|
+
self.seq = connection_order()
|
|
728
|
+
self.connected_at = time.monotonic()
|
|
729
|
+
self._queue_max = runner.mapping.network.client_queue_max or CLIENT_QUEUE_MAX
|
|
730
|
+
# None = graceful-close sentinel (see request_graceful_close)
|
|
731
|
+
self._out: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=self._queue_max)
|
|
732
|
+
self._session: Optional[ProtocolSession] = None
|
|
733
|
+
# what the client sends that its protocol session refuses or ignores (malformed
|
|
734
|
+
# RFC 2217 requests, ...): logged at most once a minute per client
|
|
735
|
+
self._protocol_log = _LogEvery(lambda msg: runner._log(f"client {self.peer}: {msg}"))
|
|
736
|
+
self._last_activity = time.monotonic()
|
|
737
|
+
self._closing = False
|
|
738
|
+
self.close_reason = "" # why the session ended (first cause wins; for the log)
|
|
739
|
+
self._unsettled = 0 # bytes written to the port since its last BREAK / setting change
|
|
740
|
+
|
|
741
|
+
@property
|
|
742
|
+
def closing(self) -> bool:
|
|
743
|
+
"""Kicked, dropped or ending: no longer one of the mapping's connections (it stays
|
|
744
|
+
in the runner's set until its handler is done)."""
|
|
745
|
+
return self._closing
|
|
746
|
+
|
|
747
|
+
def _log_unexpected(self, exc: BaseException) -> None:
|
|
748
|
+
self.runner._log(f"client {self.peer} pump error: {type(exc).__name__}: {exc}")
|
|
749
|
+
|
|
750
|
+
# called synchronously from the runner's serial read loop (broadcast)
|
|
751
|
+
def feed_from_serial(self, data: bytes) -> None:
|
|
752
|
+
if self._closing or self._session is None:
|
|
753
|
+
return
|
|
754
|
+
try:
|
|
755
|
+
net = self._session.from_serial(data)
|
|
756
|
+
except Exception as e:
|
|
757
|
+
self.runner._log(f"protocol error (from_serial) for {self.peer}: "
|
|
758
|
+
f"{type(e).__name__}: {e}")
|
|
759
|
+
return
|
|
760
|
+
if not net:
|
|
761
|
+
return
|
|
762
|
+
try:
|
|
763
|
+
self._out.put_nowait(net)
|
|
764
|
+
except asyncio.QueueFull:
|
|
765
|
+
# slow client: protect the others by dropping this one. This loses the
|
|
766
|
+
# current chunk for THIS client only — surface it instead of hiding it.
|
|
767
|
+
self._closing = True
|
|
768
|
+
self.close_reason = self.close_reason or "dropped: output queue full (slow client)"
|
|
769
|
+
self.runner.status.queue_overflows += 1
|
|
770
|
+
self.runner.status.dropped_clients += 1
|
|
771
|
+
self.runner._log(f"dropping slow client {self.peer}: output queue full "
|
|
772
|
+
f"({self._queue_max} chunks) — data lost for this client")
|
|
773
|
+
with contextlib.suppress(Exception):
|
|
774
|
+
self.writer.transport.abort()
|
|
775
|
+
|
|
776
|
+
def serial_changed(self, serial) -> None:
|
|
777
|
+
"""The runner's serial port was reopened (the new pyserial object) or closed
|
|
778
|
+
(None): an RFC 2217 session follows it. A reopened port that refuses the client's
|
|
779
|
+
settings ends the session (ProtocolError says which)."""
|
|
780
|
+
session = self._session
|
|
781
|
+
if session is None or self._closing:
|
|
782
|
+
return
|
|
783
|
+
try:
|
|
784
|
+
session.serial_changed(serial)
|
|
785
|
+
except ProtocolError as e:
|
|
786
|
+
self.kick(str(e))
|
|
787
|
+
return
|
|
788
|
+
ctrl = session.take_net_out() # e.g. the modem lines as they are now
|
|
789
|
+
if ctrl:
|
|
790
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
791
|
+
self._out.put_nowait(ctrl)
|
|
792
|
+
|
|
793
|
+
async def run(self) -> None:
|
|
794
|
+
m = self.runner.mapping
|
|
795
|
+
# Create the protocol session. RFC2217 needs the live serial instance.
|
|
796
|
+
ser = self.runner.serial_instance
|
|
797
|
+
if ((m.network.protocol == "rfc2217" and ser is None)
|
|
798
|
+
or (self.runner.drops_clients and not self.runner._link.is_open)):
|
|
799
|
+
# an RFC 2217 session drives the port, so it starts only while the port is open,
|
|
800
|
+
# as does any session with disconnect_without_device (a connect-out link can
|
|
801
|
+
# come up just after the port went away)
|
|
802
|
+
self._closing = True
|
|
803
|
+
self.close_reason = "the serial port is not open"
|
|
804
|
+
await _close_client(self.writer)
|
|
805
|
+
return
|
|
806
|
+
self._session = make_session(m.network.protocol, ser,
|
|
807
|
+
poll_interval=m.options.rfc2217_poll_modem_interval_s,
|
|
808
|
+
read_only=m.network.read_only, log=self._protocol_log,
|
|
809
|
+
signature=m.options.rfc2217_signature)
|
|
810
|
+
|
|
811
|
+
# initial bytes: protocol negotiation, then banner
|
|
812
|
+
init = bytearray(self._session.initial_net_bytes())
|
|
813
|
+
if m.options.banner:
|
|
814
|
+
init += _render_banner(m.options.banner, m, self.runner.status.device, self.peer_ip)
|
|
815
|
+
|
|
816
|
+
try:
|
|
817
|
+
if init: # (a client that resets right after connecting fails this write)
|
|
818
|
+
self.writer.write(bytes(init))
|
|
819
|
+
await self.writer.drain()
|
|
820
|
+
async with TaskGroup() as tg:
|
|
821
|
+
tg.create_task(self._pump_net_to_serial())
|
|
822
|
+
tg.create_task(self._pump_serial_to_net())
|
|
823
|
+
if self._session.poll_interval:
|
|
824
|
+
tg.create_task(self._pump_poll())
|
|
825
|
+
if m.options.idle_timeout_s > 0:
|
|
826
|
+
tg.create_task(self._idle_guard(m.options.idle_timeout_s))
|
|
827
|
+
if m.options.max_connection_time_s > 0:
|
|
828
|
+
tg.create_task(self._lifetime_guard(m.options.max_connection_time_s))
|
|
829
|
+
except (ConnectionError, OSError) as e: # the client left before the pumps started
|
|
830
|
+
if not self.close_reason:
|
|
831
|
+
self.close_reason = describe_disconnect(e)
|
|
832
|
+
except BaseExceptionGroup as eg:
|
|
833
|
+
# Mirror the original `except*` split: connection-reset paths are a
|
|
834
|
+
# normal disconnect (swallow them); log every other Exception; let
|
|
835
|
+
# anything that is not an Exception (e.g. CancelledError) propagate.
|
|
836
|
+
_normal = (ConnectionError, asyncio.IncompleteReadError, OSError)
|
|
837
|
+
normal, rest = eg.split(_normal)
|
|
838
|
+
if normal is not None and not self.close_reason:
|
|
839
|
+
self.close_reason = describe_disconnect(next(_iter_leaf_exceptions(normal)))
|
|
840
|
+
if rest is not None:
|
|
841
|
+
unexpected, base_only = rest.split(Exception)
|
|
842
|
+
if unexpected is not None:
|
|
843
|
+
for exc in _iter_leaf_exceptions(unexpected):
|
|
844
|
+
self._log_unexpected(exc)
|
|
845
|
+
if base_only is not None:
|
|
846
|
+
raise base_only from None
|
|
847
|
+
finally:
|
|
848
|
+
self._closing = True
|
|
849
|
+
await _close_client(self.writer)
|
|
850
|
+
self._release_break()
|
|
851
|
+
|
|
852
|
+
def _release_break(self) -> None:
|
|
853
|
+
"""An RFC 2217 client that set BREAK on (SET-CONTROL) and left without turning it
|
|
854
|
+
off: turn it off, so the line is not held in the break condition for whoever
|
|
855
|
+
comes next."""
|
|
856
|
+
left_on = getattr(self._session, "break_left_on", None)
|
|
857
|
+
ser = self.runner.serial_instance
|
|
858
|
+
if left_on is None or not left_on() or ser is None:
|
|
859
|
+
return
|
|
860
|
+
try:
|
|
861
|
+
ser.break_condition = False
|
|
862
|
+
except Exception as e:
|
|
863
|
+
self.runner._log(f"serial BREAK left on by {self.peer} not turned off: "
|
|
864
|
+
f"{type(e).__name__}: {e}")
|
|
865
|
+
return
|
|
866
|
+
self.runner._log(f"serial BREAK turned off: {self.peer} left it on")
|
|
867
|
+
|
|
868
|
+
def _send_net(self, ctrl: bytes) -> None:
|
|
869
|
+
"""Control bytes for the client (telnet / RFC 2217 replies)."""
|
|
870
|
+
if ctrl:
|
|
871
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
872
|
+
self._out.put_nowait(ctrl)
|
|
873
|
+
|
|
874
|
+
async def _pump_net_to_serial(self) -> None:
|
|
875
|
+
m = self.runner.mapping
|
|
876
|
+
runner = self.runner
|
|
877
|
+
session = self._session
|
|
878
|
+
assert session is not None # noqa: S101 - the pumps start after run() created it
|
|
879
|
+
while not self._closing:
|
|
880
|
+
data = await self.reader.read(READ_CHUNK)
|
|
881
|
+
if not data:
|
|
882
|
+
break # client closed
|
|
883
|
+
self._last_activity = time.monotonic()
|
|
884
|
+
parts = session.from_network_parts(data)
|
|
885
|
+
self._send_net(session.take_net_out())
|
|
886
|
+
written = False
|
|
887
|
+
# in order: the data for the port, a BREAK, the commands that act on the port
|
|
888
|
+
# (each after what came before it; see the module docstring)
|
|
889
|
+
for part in parts:
|
|
890
|
+
if isinstance(part, bytes):
|
|
891
|
+
if not m.network.read_only and await runner.serial_write(part):
|
|
892
|
+
written = True
|
|
893
|
+
self._unsettled += len(part)
|
|
894
|
+
continue
|
|
895
|
+
if part is BREAK or part.settle:
|
|
896
|
+
if self._unsettled:
|
|
897
|
+
await runner.serial_settled(self._unsettled)
|
|
898
|
+
self._unsettled = 0
|
|
899
|
+
if part is BREAK:
|
|
900
|
+
await runner.send_break(f"client {self.peer}")
|
|
901
|
+
else:
|
|
902
|
+
part.apply()
|
|
903
|
+
self._send_net(session.take_net_out())
|
|
904
|
+
if not written:
|
|
905
|
+
# Nothing reached the port (read-only, only telnet/RFC 2217 commands, or
|
|
906
|
+
# the port is not open), so no serial drain() paces this client: let the
|
|
907
|
+
# other tasks run before the next read, which returns at once while the
|
|
908
|
+
# socket buffer holds more.
|
|
909
|
+
await asyncio.sleep(0)
|
|
910
|
+
raise _Disconnect("closed by peer")
|
|
911
|
+
|
|
912
|
+
async def _pump_serial_to_net(self) -> None:
|
|
913
|
+
while True:
|
|
914
|
+
data = await self._out.get()
|
|
915
|
+
if data is None:
|
|
916
|
+
# graceful-close sentinel (e.g. closeon): flush queued bytes, then close
|
|
917
|
+
while not self._out.empty():
|
|
918
|
+
d = self._out.get_nowait()
|
|
919
|
+
if d:
|
|
920
|
+
self.writer.write(d)
|
|
921
|
+
try:
|
|
922
|
+
await asyncio.wait_for(self.writer.drain(), timeout=TCP_CLOSE_TIMEOUT_S)
|
|
923
|
+
except asyncio.TimeoutError: # a peer that reads nothing: cut it off
|
|
924
|
+
with contextlib.suppress(Exception):
|
|
925
|
+
self.writer.transport.abort()
|
|
926
|
+
raise _Disconnect("closed by closeon match")
|
|
927
|
+
self.writer.write(data)
|
|
928
|
+
await self.writer.drain()
|
|
929
|
+
self._last_activity = time.monotonic()
|
|
930
|
+
|
|
931
|
+
async def _pump_poll(self) -> None:
|
|
932
|
+
session = self._session
|
|
933
|
+
assert session is not None # noqa: S101 - as above
|
|
934
|
+
interval = session.poll_interval or 1.0
|
|
935
|
+
while not self._closing:
|
|
936
|
+
await asyncio.sleep(interval)
|
|
937
|
+
extra = session.poll()
|
|
938
|
+
if extra:
|
|
939
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
940
|
+
self._out.put_nowait(extra)
|
|
941
|
+
|
|
942
|
+
async def _idle_guard(self, timeout: float) -> None:
|
|
943
|
+
while not self._closing:
|
|
944
|
+
await asyncio.sleep(1.0)
|
|
945
|
+
if time.monotonic() - self._last_activity > timeout:
|
|
946
|
+
raise _Disconnect(f"idle timeout ({fmt_duration(timeout)} without traffic)")
|
|
947
|
+
|
|
948
|
+
async def _lifetime_guard(self, limit: float) -> None:
|
|
949
|
+
await asyncio.sleep(limit)
|
|
950
|
+
raise _Disconnect(f"max connection time reached ({fmt_duration(limit)})")
|
|
951
|
+
|
|
952
|
+
def kick(self, reason: str = "kicked") -> None:
|
|
953
|
+
self._closing = True
|
|
954
|
+
self.close_reason = self.close_reason or reason
|
|
955
|
+
with contextlib.suppress(Exception):
|
|
956
|
+
self.writer.transport.abort()
|
|
957
|
+
|
|
958
|
+
def request_graceful_close(self) -> None:
|
|
959
|
+
"""Stop feeding new serial data, but let already-queued bytes flush to the
|
|
960
|
+
client before closing (used by closeon so the triggering data is delivered)."""
|
|
961
|
+
if self._closing:
|
|
962
|
+
return
|
|
963
|
+
self._closing = True
|
|
964
|
+
self.close_reason = self.close_reason or "closed by closeon match"
|
|
965
|
+
try:
|
|
966
|
+
self._out.put_nowait(None) # sentinel handled by _pump_serial_to_net
|
|
967
|
+
except asyncio.QueueFull:
|
|
968
|
+
with contextlib.suppress(Exception):
|
|
969
|
+
self.writer.transport.abort()
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
class MappingRunner:
|
|
973
|
+
def __init__(self, mapping: MappingConfig, logger=None, trace_dir: Optional[str] = None):
|
|
974
|
+
self.mapping = mapping
|
|
975
|
+
self.trace_dir = trace_dir # where traces go (data/traces); None = tracing unavailable
|
|
976
|
+
self.status = RunnerStatus(state="stopped")
|
|
977
|
+
self.log = logger
|
|
978
|
+
self._server: Optional[netguard.Listener] = None
|
|
979
|
+
self._serial_task: Optional[asyncio.Task] = None
|
|
980
|
+
self._main_task: Optional[asyncio.Task] = None # client/udp/serialbridge driver
|
|
981
|
+
self._udp_transport: Optional[asyncio.DatagramTransport] = None
|
|
982
|
+
self._tracer: Optional[Tracer] = None
|
|
983
|
+
self._clients: set = set()
|
|
984
|
+
self._monitors: set = set() # browser console observers (read traffic)
|
|
985
|
+
self._traffic: set = set() # live hex view observers: both directions
|
|
986
|
+
self._mqtt: Optional[MqttPublisher] = None # optional MQTT publisher (serial -> broker)
|
|
987
|
+
self._stop = asyncio.Event()
|
|
988
|
+
# the serial port shared by server/client/udp (a serial<->serial bridge opens its own)
|
|
989
|
+
self._link = SerialLink(mapping.serial, self.status, self._stop, self._log,
|
|
990
|
+
on_change=self._serial_changed, owner=mapping.name)
|
|
991
|
+
self._bridge_backoff: Optional[Backoff] = None # a serial<->serial bridge's retries
|
|
992
|
+
self._access = AccessPolicy(mapping.network)
|
|
993
|
+
self._drop_log = _LogEvery(self._log) # data for the device that was not written
|
|
994
|
+
self._break_lock = asyncio.Lock() # one serial BREAK at a time
|
|
995
|
+
self._break_log = _LogEvery(self._log)
|
|
996
|
+
self._refusal_log = netguard.RefusalLog(self._log) # refused connections
|
|
997
|
+
# MQTT writes (<base_topic>/tx) waiting for the serial port, and their bytes
|
|
998
|
+
self._tx_topic = ""
|
|
999
|
+
self._tx_queue: list[bytes] = []
|
|
1000
|
+
self._tx_queued = 0
|
|
1001
|
+
self._tx_wake = asyncio.Event()
|
|
1002
|
+
self._tx_log = _LogEvery(self._log)
|
|
1003
|
+
self._tx_task: Optional[asyncio.Task] = None
|
|
1004
|
+
self._poll_task: Optional[asyncio.Task] = None # options.poll_string
|
|
1005
|
+
co = mapping.options.closeon
|
|
1006
|
+
# nothing to close on UDP (ignored_settings says so at start)
|
|
1007
|
+
if mapping.kind == "net" and mapping.network.mode == "udp":
|
|
1008
|
+
co = ""
|
|
1009
|
+
self._closeon = co.encode("utf-8", "replace") if co else b""
|
|
1010
|
+
self._closeon_tail = b"" # rolling carry-over so matches across reads aren't missed
|
|
1011
|
+
|
|
1012
|
+
@property
|
|
1013
|
+
def serial_instance(self):
|
|
1014
|
+
"""The open pyserial object (None while the port is closed)."""
|
|
1015
|
+
return self._link.serial
|
|
1016
|
+
|
|
1017
|
+
@property
|
|
1018
|
+
def tracer(self) -> Optional[Tracer]:
|
|
1019
|
+
"""The trace writer while the mapping runs with tracing on."""
|
|
1020
|
+
return self._tracer
|
|
1021
|
+
|
|
1022
|
+
@property
|
|
1023
|
+
def listener(self) -> Optional[netguard.Listener]:
|
|
1024
|
+
"""The server mode listener while the mapping runs, else None."""
|
|
1025
|
+
return self._server
|
|
1026
|
+
|
|
1027
|
+
@property
|
|
1028
|
+
def needs_port(self) -> bool:
|
|
1029
|
+
"""A client session runs only while the serial port is open: RFC 2217 (it drives
|
|
1030
|
+
the port), and any TCP mapping with disconnect_without_device."""
|
|
1031
|
+
return self.mapping.network.protocol == "rfc2217" or self.drops_clients
|
|
1032
|
+
|
|
1033
|
+
@property
|
|
1034
|
+
def drops_clients(self) -> bool:
|
|
1035
|
+
"""disconnect_without_device, on a TCP mapping (true itself, not text)."""
|
|
1036
|
+
net = self.mapping.network
|
|
1037
|
+
return net.disconnect_without_device is True and net.mode in ("server", "client")
|
|
1038
|
+
|
|
1039
|
+
def wake_serial(self) -> None:
|
|
1040
|
+
"""A serial device was plugged in (PortWatcher): a port waiting to be opened again
|
|
1041
|
+
is tried now instead of after its backoff."""
|
|
1042
|
+
self._link.wake()
|
|
1043
|
+
if self._bridge_backoff is not None:
|
|
1044
|
+
self._bridge_backoff.wake()
|
|
1045
|
+
|
|
1046
|
+
def _log(self, msg: str) -> None:
|
|
1047
|
+
# The logger (a per-mapping logger from AppState) adds the "[name]" prefix
|
|
1048
|
+
# for the global all.log and also writes to this mapping's own log file.
|
|
1049
|
+
if self.log:
|
|
1050
|
+
self.log(msg)
|
|
1051
|
+
|
|
1052
|
+
# ---------------- console monitors (browser xterm) ----------------
|
|
1053
|
+
def add_monitor(self, mon) -> None:
|
|
1054
|
+
self._monitors.add(mon)
|
|
1055
|
+
|
|
1056
|
+
def discard_monitor(self, mon) -> None:
|
|
1057
|
+
self._monitors.discard(mon)
|
|
1058
|
+
|
|
1059
|
+
def _feed_monitors(self, data: bytes) -> None:
|
|
1060
|
+
for mon in list(self._monitors):
|
|
1061
|
+
mon.feed(data)
|
|
1062
|
+
|
|
1063
|
+
def add_traffic_monitor(self, mon) -> None:
|
|
1064
|
+
self._traffic.add(mon)
|
|
1065
|
+
|
|
1066
|
+
def discard_traffic_monitor(self, mon) -> None:
|
|
1067
|
+
self._traffic.discard(mon)
|
|
1068
|
+
|
|
1069
|
+
def _feed_traffic(self, direction: int, data: bytes) -> None:
|
|
1070
|
+
for mon in list(self._traffic):
|
|
1071
|
+
mon.feed_traffic(direction, data)
|
|
1072
|
+
|
|
1073
|
+
# ---------------- lifecycle ----------------
|
|
1074
|
+
async def start(self) -> None:
|
|
1075
|
+
self._stop.clear()
|
|
1076
|
+
|
|
1077
|
+
if self.mapping.kind == "serialbridge":
|
|
1078
|
+
self._tracer = open_tracer(self.mapping, self.trace_dir, self._log, self.status)
|
|
1079
|
+
log_ignored_settings(self.mapping, self._log)
|
|
1080
|
+
self._main_task = asyncio.create_task(
|
|
1081
|
+
self._serialbridge_supervisor(), name=f"serbridge:{self.mapping.name}")
|
|
1082
|
+
self.status.state = "running"
|
|
1083
|
+
self._log("serial-to-serial bridge starting")
|
|
1084
|
+
return
|
|
1085
|
+
|
|
1086
|
+
net = self.mapping.network
|
|
1087
|
+
# The listener comes first (its TLS context, then the bind): a start that fails
|
|
1088
|
+
# there has opened nothing yet (no serial port, whose open pulses DTR/RTS, no
|
|
1089
|
+
# broker connection, no trace file), so the supervisor can simply try it again
|
|
1090
|
+
# later when the address can't be taken (BindError). Data for the device that
|
|
1091
|
+
# arrives before the port is open is dropped, as while it reconnects.
|
|
1092
|
+
udp: Optional[_UdpBridge] = None
|
|
1093
|
+
if net.mode == "server":
|
|
1094
|
+
# admission right after accept; a TLS client's allow-list check comes
|
|
1095
|
+
# before its handshake (netguard.start_server)
|
|
1096
|
+
self._server = await netguard.start_server(
|
|
1097
|
+
self._on_client, netguard.listen_hosts(net), net.port, ssl=server_ssl(net),
|
|
1098
|
+
allowed=self._access.allowed, log=self._log)
|
|
1099
|
+
self._log(f"listening on {netguard.listen_where(net, self._server.sockets)} "
|
|
1100
|
+
f"({net.protocol}{', TLS' if net.tls else ''}, {sockopts.describe(net)})")
|
|
1101
|
+
elif net.mode == "udp":
|
|
1102
|
+
udp = _UdpBridge(self) # a malformed stored reply address fails the start
|
|
1103
|
+
bound = udp
|
|
1104
|
+
sock = await netguard.udp_socket(net)
|
|
1105
|
+
try:
|
|
1106
|
+
self._udp_transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(
|
|
1107
|
+
lambda: bound, sock=sock)
|
|
1108
|
+
except OSError as e:
|
|
1109
|
+
sock.close()
|
|
1110
|
+
raise netguard.BindError.of(e) from e
|
|
1111
|
+
self._log(f"UDP on {netguard.listen_where(net, [sock])}, {udp.describe()}")
|
|
1112
|
+
if self._stop.is_set(): # stopped while the listener came up: it found none to close
|
|
1113
|
+
await self.stop()
|
|
1114
|
+
return
|
|
1115
|
+
|
|
1116
|
+
# If the rest fails to come up, tear everything down (the listener too), so that
|
|
1117
|
+
# nothing is left holding the port or the serial device.
|
|
1118
|
+
try:
|
|
1119
|
+
self._tracer = open_tracer(self.mapping, self.trace_dir, self._log, self.status)
|
|
1120
|
+
log_ignored_settings(self.mapping, self._log)
|
|
1121
|
+
# optional MQTT publisher: serial lines -> broker (net mappings), and with
|
|
1122
|
+
# tx_enabled writes from <base_topic>/tx -> the serial port
|
|
1123
|
+
if self.mapping.mqtt.enabled:
|
|
1124
|
+
from .mqtt_pub import MqttPublisher
|
|
1125
|
+
subs = self._mqtt_subscriptions()
|
|
1126
|
+
self._mqtt = MqttPublisher(self.mapping.mqtt, logger=self._log,
|
|
1127
|
+
subscriptions=subs)
|
|
1128
|
+
self.status.mqtt = self._mqtt.state
|
|
1129
|
+
self._mqtt.connect()
|
|
1130
|
+
if subs:
|
|
1131
|
+
self._tx_task = asyncio.create_task(
|
|
1132
|
+
self._mqtt_tx_loop(), name=f"mqtt-tx:{self.mapping.name}")
|
|
1133
|
+
poll = self._poll_settings()
|
|
1134
|
+
if poll is not None:
|
|
1135
|
+
self._poll_task = asyncio.create_task(
|
|
1136
|
+
self._poll_loop(*poll), name=f"poll:{self.mapping.name}")
|
|
1137
|
+
# up from here; the serial side reports its own state once it has tried the port
|
|
1138
|
+
self.status.state = "running"
|
|
1139
|
+
self.status.last_error = ""
|
|
1140
|
+
# serial side is shared by server/client/udp
|
|
1141
|
+
self._serial_task = asyncio.create_task(
|
|
1142
|
+
self._link.run(self._serial_session), name=f"serial:{self.mapping.name}")
|
|
1143
|
+
if net.mode == "client":
|
|
1144
|
+
self.status.set_link("connecting")
|
|
1145
|
+
self._main_task = asyncio.create_task(
|
|
1146
|
+
self._client_supervisor(), name=f"connectout:{self.mapping.name}")
|
|
1147
|
+
self._log(f"connect-out to {host_port(net.remote_host, net.remote_port)} "
|
|
1148
|
+
f"({net.protocol}, {sockopts.describe(net)})")
|
|
1149
|
+
elif udp is not None:
|
|
1150
|
+
self._main_task = asyncio.create_task(udp.run(), name=f"udp:{self.mapping.name}")
|
|
1151
|
+
except BaseException:
|
|
1152
|
+
await self.stop()
|
|
1153
|
+
raise
|
|
1154
|
+
|
|
1155
|
+
async def stop(self) -> None:
|
|
1156
|
+
self._stop.set()
|
|
1157
|
+
# Stop accepting new connections first (but do NOT await wait_closed yet).
|
|
1158
|
+
if self._server is not None:
|
|
1159
|
+
self._server.close()
|
|
1160
|
+
if self._udp_transport is not None:
|
|
1161
|
+
with contextlib.suppress(Exception):
|
|
1162
|
+
self._udp_transport.close()
|
|
1163
|
+
self._udp_transport = None
|
|
1164
|
+
# Abort existing clients/monitors BEFORE awaiting the listener teardown:
|
|
1165
|
+
# on Python 3.12+ asyncio.Server.wait_closed() blocks until every active
|
|
1166
|
+
# connection has finished, so awaiting it before kicking clients would
|
|
1167
|
+
# deadlock a stop() while clients are still connected.
|
|
1168
|
+
for c in list(self._clients):
|
|
1169
|
+
c.kick("mapping stopped")
|
|
1170
|
+
for mon in list(self._monitors) + list(self._traffic):
|
|
1171
|
+
mon.close()
|
|
1172
|
+
self._monitors.clear()
|
|
1173
|
+
self._traffic.clear()
|
|
1174
|
+
if self._mqtt is not None:
|
|
1175
|
+
mqtt, self._mqtt = self._mqtt, None
|
|
1176
|
+
await mqtt.aclose()
|
|
1177
|
+
self.status.mqtt = {}
|
|
1178
|
+
if self._server is not None:
|
|
1179
|
+
with contextlib.suppress(Exception):
|
|
1180
|
+
await asyncio.wait_for(self._server.wait_closed(), timeout=5.0)
|
|
1181
|
+
self._server = None
|
|
1182
|
+
for task in (self._serial_task, self._main_task, self._tx_task, self._poll_task):
|
|
1183
|
+
if task:
|
|
1184
|
+
task.cancel()
|
|
1185
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1186
|
+
await task
|
|
1187
|
+
self._serial_task = self._main_task = self._tx_task = self._poll_task = None
|
|
1188
|
+
self._tx_queue.clear()
|
|
1189
|
+
self._tx_queued = 0
|
|
1190
|
+
await self._link.close()
|
|
1191
|
+
if self._tracer:
|
|
1192
|
+
await self._tracer.aclose()
|
|
1193
|
+
self._tracer = None
|
|
1194
|
+
self.status.state = "stopped"
|
|
1195
|
+
self.status.set_link("")
|
|
1196
|
+
self.status.client_count = 0
|
|
1197
|
+
self.status.clients = []
|
|
1198
|
+
self._log("stopped")
|
|
1199
|
+
|
|
1200
|
+
# ---------------- MQTT writes and the poll string ----------------
|
|
1201
|
+
def _mqtt_subscriptions(self) -> dict:
|
|
1202
|
+
"""With mqtt.tx_enabled: {<base_topic>/tx: handler} (nothing with a wildcard in the
|
|
1203
|
+
base topic, which edits refuse: such a subscription would take every topic it
|
|
1204
|
+
matches)."""
|
|
1205
|
+
mq = self.mapping.mqtt
|
|
1206
|
+
if mq.tx_enabled is not True:
|
|
1207
|
+
return {}
|
|
1208
|
+
if mqtt_topic_problem(mq.base_topic):
|
|
1209
|
+
self._log("MQTT writes not taken: the base topic has '+', '#' or a null character")
|
|
1210
|
+
return {}
|
|
1211
|
+
self._tx_topic = str(mq.base_topic).rstrip("/") + "/tx"
|
|
1212
|
+
note = (" (read-only: each is refused)" if self.mapping.network.read_only else "")
|
|
1213
|
+
self._log(f"MQTT: messages on {self._tx_topic} are written to the serial port{note}")
|
|
1214
|
+
missing = [what for what, have in (("TLS", mq.tls), ("a username", mq.username))
|
|
1215
|
+
if not have]
|
|
1216
|
+
if self.mapping.accepts_mqtt_tx and missing:
|
|
1217
|
+
self._log("warning: MQTT writes to the serial port come over a broker connection "
|
|
1218
|
+
f"without {' or '.join(missing)}: any client the broker lets publish to "
|
|
1219
|
+
f"{self._tx_topic} can write to the device; restrict it with the "
|
|
1220
|
+
"broker's access rules (ACLs)")
|
|
1221
|
+
return {self._tx_topic: self._on_mqtt_tx}
|
|
1222
|
+
|
|
1223
|
+
def _on_mqtt_tx(self, topic: str, payload: bytes) -> None:
|
|
1224
|
+
"""A message on <base_topic>/tx (on the event loop; never empty or a retained copy:
|
|
1225
|
+
MqttPublisher). Queued for the serial port, or refused on .../tx/result."""
|
|
1226
|
+
mqtt = self._mqtt
|
|
1227
|
+
if mqtt is None:
|
|
1228
|
+
return
|
|
1229
|
+
why = ""
|
|
1230
|
+
if self.mapping.network.read_only:
|
|
1231
|
+
why = "mapping is read-only"
|
|
1232
|
+
elif len(payload) > MQTT_TX_MAX_BYTES:
|
|
1233
|
+
why = f"message too long ({len(payload)} bytes, at most {MQTT_TX_MAX_BYTES})"
|
|
1234
|
+
elif self._tx_queued + len(payload) > MQTT_TX_MAX_BYTES:
|
|
1235
|
+
why = f"busy ({self._tx_queued} bytes waiting for the serial port)"
|
|
1236
|
+
if why:
|
|
1237
|
+
self._tx_log(f"MQTT write from {topic} refused: {why}")
|
|
1238
|
+
mqtt.publish_raw(topic + "/result", f"error: {why}".encode())
|
|
1239
|
+
return
|
|
1240
|
+
self._tx_queue.append(payload)
|
|
1241
|
+
self._tx_queued += len(payload)
|
|
1242
|
+
self._tx_wake.set()
|
|
1243
|
+
|
|
1244
|
+
async def _mqtt_tx_loop(self) -> None:
|
|
1245
|
+
"""Write the MQTT messages to the serial port, oldest first, each in one piece (so
|
|
1246
|
+
never mixed into a TCP client's data), and answer each: "ok" once the port took it,
|
|
1247
|
+
"error: ..." when it was not open."""
|
|
1248
|
+
result = self._tx_topic + "/result"
|
|
1249
|
+
while True:
|
|
1250
|
+
await self._tx_wake.wait()
|
|
1251
|
+
self._tx_wake.clear()
|
|
1252
|
+
while self._tx_queue:
|
|
1253
|
+
payload = self._tx_queue.pop(0)
|
|
1254
|
+
try:
|
|
1255
|
+
err = None if await self.serial_write(payload) else \
|
|
1256
|
+
"the serial port is not open"
|
|
1257
|
+
except Exception as e: # the port went away mid-write: SerialLink reopens it
|
|
1258
|
+
err = str(e) or type(e).__name__
|
|
1259
|
+
finally:
|
|
1260
|
+
self._tx_queued -= len(payload)
|
|
1261
|
+
if self._mqtt is not None:
|
|
1262
|
+
self._mqtt.publish_raw(result, b"ok" if err is None else
|
|
1263
|
+
f"error: {err}".encode())
|
|
1264
|
+
|
|
1265
|
+
def _poll_settings(self) -> Optional[tuple[bytes, float]]:
|
|
1266
|
+
"""options.poll_string as bytes and poll_interval_s, or None when there is no poll
|
|
1267
|
+
(off, a serial-to-serial bridge; a stored value edits refuse: logged)."""
|
|
1268
|
+
o = self.mapping.options
|
|
1269
|
+
interval = o.poll_interval_s
|
|
1270
|
+
if not isinstance(interval, (int, float)) or isinstance(interval, bool) \
|
|
1271
|
+
or not interval or not o.poll_string:
|
|
1272
|
+
return None
|
|
1273
|
+
try:
|
|
1274
|
+
data = poll_bytes(o.poll_string)
|
|
1275
|
+
except ValueError as e:
|
|
1276
|
+
self._log(f"poll string not sent: {e}")
|
|
1277
|
+
return None
|
|
1278
|
+
if not (0.1 <= interval <= 86400) or not data:
|
|
1279
|
+
self._log(f"poll string not sent: the interval {interval!r} is not between 0.1 "
|
|
1280
|
+
"and 86400 seconds")
|
|
1281
|
+
return None
|
|
1282
|
+
return data, float(interval)
|
|
1283
|
+
|
|
1284
|
+
async def _poll_loop(self, data: bytes, interval: float) -> None:
|
|
1285
|
+
"""Send the poll string every ``interval`` seconds while the port is open, and (unless
|
|
1286
|
+
poll_with_clients) while no TCP client is connected, which does the asking then."""
|
|
1287
|
+
with_clients = self.mapping.options.poll_with_clients is True
|
|
1288
|
+
self._log(f"polling the device every {interval:g} s with {data!r}"
|
|
1289
|
+
+ ("" if with_clients else " while no TCP client is connected"))
|
|
1290
|
+
while True:
|
|
1291
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
1292
|
+
await asyncio.wait_for(self._stop.wait(), timeout=interval)
|
|
1293
|
+
return
|
|
1294
|
+
if not self._link.is_open:
|
|
1295
|
+
continue
|
|
1296
|
+
if not with_clients and any(isinstance(c, _ClientConn) for c in self._clients):
|
|
1297
|
+
continue
|
|
1298
|
+
with contextlib.suppress(Exception): # the port went away: SerialLink reopens it
|
|
1299
|
+
await self.serial_write(data)
|
|
1300
|
+
|
|
1301
|
+
# ssl context for a connect-out TLS data bridge (a listener's: access.server_ssl)
|
|
1302
|
+
def _client_ssl(self):
|
|
1303
|
+
return tls.mapping_context(self.mapping.network)
|
|
1304
|
+
|
|
1305
|
+
# ---------------- serial side ----------------
|
|
1306
|
+
async def serial_write(self, data: bytes) -> bool:
|
|
1307
|
+
"""Write data from the network (a client, a datagram, the browser console) to the
|
|
1308
|
+
device and count it: bytes_out once the open port took it, else bytes_dropped.
|
|
1309
|
+
Returns False when it was dropped. Awaits drain(): the caller reads no more from
|
|
1310
|
+
its client until the port has room (network->serial backpressure)."""
|
|
1311
|
+
w = self._link.writer
|
|
1312
|
+
if w is None or w.is_closing():
|
|
1313
|
+
# serial not currently open (status shows reconnecting): drop
|
|
1314
|
+
self._drop(len(data), "the serial port is not open")
|
|
1315
|
+
return False
|
|
1316
|
+
if self._tracer:
|
|
1317
|
+
self._tracer.write("net>ser", data)
|
|
1318
|
+
self._feed_traffic(TO_DEVICE, data)
|
|
1319
|
+
w.write(data)
|
|
1320
|
+
self.status.bytes_out += len(data)
|
|
1321
|
+
await w.drain()
|
|
1322
|
+
return True
|
|
1323
|
+
|
|
1324
|
+
async def serial_settled(self, written: int) -> None:
|
|
1325
|
+
"""Wait until the port has sent what it holds (``written``: the bytes a client
|
|
1326
|
+
wrote since its last BREAK / setting change), at most their line time twice plus
|
|
1327
|
+
0.2 s and never more than SETTLE_MAX_S."""
|
|
1328
|
+
w = self._link.writer
|
|
1329
|
+
if w is None or w.is_closing():
|
|
1330
|
+
return
|
|
1331
|
+
baud = getattr(self._link.serial, "baudrate", 0) or self.mapping.serial.baudrate
|
|
1332
|
+
line_time = written * 11 / max(1, int(baud)) # (a start, 8 data, parity, stop bit)
|
|
1333
|
+
await sent_within(w, min(SETTLE_MAX_S, 0.2 + 2 * line_time))
|
|
1334
|
+
|
|
1335
|
+
async def send_break(self, who: str) -> bool:
|
|
1336
|
+
"""Send a serial BREAK (a telnet client's IAC BRK, the web console; ``who`` for the
|
|
1337
|
+
log): the line is held in the break condition for BREAK_S, on the event loop's
|
|
1338
|
+
timer. One at a time; False when none was sent (a read-only mapping, the port not
|
|
1339
|
+
open, a port that can't). Logged at most once a minute, with a count."""
|
|
1340
|
+
if self.mapping.network.read_only:
|
|
1341
|
+
self._break_log(f"serial BREAK from {who} not sent: the mapping is read-only")
|
|
1342
|
+
return False
|
|
1343
|
+
async with self._break_lock:
|
|
1344
|
+
ser = self._link.serial
|
|
1345
|
+
if ser is None or self._stop.is_set():
|
|
1346
|
+
self._break_log(f"serial BREAK from {who} not sent: the serial port is not "
|
|
1347
|
+
"open")
|
|
1348
|
+
return False
|
|
1349
|
+
try:
|
|
1350
|
+
ser.break_condition = True
|
|
1351
|
+
except Exception as e: # (a stand-in or a port without break control)
|
|
1352
|
+
self._break_log(f"serial BREAK from {who} not sent: {type(e).__name__}: {e}")
|
|
1353
|
+
return False
|
|
1354
|
+
try:
|
|
1355
|
+
await asyncio.sleep(BREAK_S)
|
|
1356
|
+
finally:
|
|
1357
|
+
with contextlib.suppress(Exception):
|
|
1358
|
+
ser.break_condition = False
|
|
1359
|
+
self._break_log(f"serial BREAK sent ({who})")
|
|
1360
|
+
return True
|
|
1361
|
+
|
|
1362
|
+
def _serial_changed(self, serial) -> None:
|
|
1363
|
+
"""SerialLink: the port was reopened (``serial``) or closed (None). With
|
|
1364
|
+
disconnect_without_device a closed port disconnects every client. Otherwise an RFC
|
|
1365
|
+
2217 session drives the port itself, so the connected client's session follows it:
|
|
1366
|
+
the client stays connected through a reconnect and keeps its settings."""
|
|
1367
|
+
if serial is None and self.drops_clients and not self._stop.is_set():
|
|
1368
|
+
if self._clients:
|
|
1369
|
+
self._log(f"serial port closed: disconnecting {len(self._clients)} "
|
|
1370
|
+
"client(s) (disconnect_without_device)")
|
|
1371
|
+
for c in list(self._clients):
|
|
1372
|
+
c.kick("the serial port is not open")
|
|
1373
|
+
elif self.mapping.network.protocol == "rfc2217":
|
|
1374
|
+
for c in list(self._clients):
|
|
1375
|
+
c.serial_changed(serial)
|
|
1376
|
+
|
|
1377
|
+
def _restore_port_settings(self) -> None:
|
|
1378
|
+
"""rfc2217_restore_on_disconnect: once the RFC 2217 client has left, put the port
|
|
1379
|
+
back to this mapping's settings, so what comes next (the next client, the console,
|
|
1380
|
+
MQTT, a trace) runs at what the mapping says. Only what the mapping sets and a
|
|
1381
|
+
client can change over RFC 2217 (baud rate, data bits, parity, stop bits, flow
|
|
1382
|
+
control): RTS/DTR stay as they are, since toggling them can reset a device."""
|
|
1383
|
+
m = self.mapping
|
|
1384
|
+
ser = self._link.serial
|
|
1385
|
+
if (m.network.protocol != "rfc2217" or not m.options.rfc2217_restore_on_disconnect
|
|
1386
|
+
or ser is None or self._clients or self._stop.is_set()):
|
|
1387
|
+
return
|
|
1388
|
+
restored = False
|
|
1389
|
+
for name, value in serial_io.port_settings(m.serial).items():
|
|
1390
|
+
try:
|
|
1391
|
+
if getattr(ser, name) != value:
|
|
1392
|
+
setattr(ser, name, value)
|
|
1393
|
+
restored = True
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
self._log(f"serial setting not restored ({name} {value!r}): "
|
|
1396
|
+
f"{type(e).__name__}: {e}")
|
|
1397
|
+
if restored:
|
|
1398
|
+
self._log(f"serial settings restored to the mapping's ({m.serial.compact()}, "
|
|
1399
|
+
f"flow control {m.serial.flowcontrol}) after the RFC 2217 client left")
|
|
1400
|
+
|
|
1401
|
+
async def _serial_open(self) -> bool:
|
|
1402
|
+
"""Wait until the serial port is open; False when the mapping stops first."""
|
|
1403
|
+
link = self._link
|
|
1404
|
+
if not link.is_open:
|
|
1405
|
+
ready = asyncio.ensure_future(link.ready.wait())
|
|
1406
|
+
stop = asyncio.ensure_future(self._stop.wait())
|
|
1407
|
+
try:
|
|
1408
|
+
await asyncio.wait((ready, stop), return_when=asyncio.FIRST_COMPLETED)
|
|
1409
|
+
finally:
|
|
1410
|
+
ready.cancel()
|
|
1411
|
+
stop.cancel()
|
|
1412
|
+
return link.is_open and not self._stop.is_set()
|
|
1413
|
+
|
|
1414
|
+
def _drop(self, n: int, why: str) -> None:
|
|
1415
|
+
"""Count data for the device that is not written, and log it (rate-limited)."""
|
|
1416
|
+
if n <= 0:
|
|
1417
|
+
return
|
|
1418
|
+
self.status.bytes_dropped += n
|
|
1419
|
+
self._drop_log(f"data for the device dropped: {why} "
|
|
1420
|
+
f"({self.status.bytes_dropped} bytes so far)")
|
|
1421
|
+
|
|
1422
|
+
async def _serial_session(self, reader: asyncio.StreamReader,
|
|
1423
|
+
writer: asyncio.StreamWriter) -> None:
|
|
1424
|
+
"""While the port is open (SerialLink.run): send the open string, then relay what
|
|
1425
|
+
the device sends until the port goes away."""
|
|
1426
|
+
if self.mapping.options.openstr:
|
|
1427
|
+
writer.write(self.mapping.options.openstr.encode("utf-8", "replace"))
|
|
1428
|
+
await writer.drain()
|
|
1429
|
+
await self._serial_read_loop(reader)
|
|
1430
|
+
|
|
1431
|
+
async def _serial_read_loop(self, reader: asyncio.StreamReader) -> None:
|
|
1432
|
+
while not self._stop.is_set():
|
|
1433
|
+
data = await reader.read(READ_CHUNK)
|
|
1434
|
+
if not data:
|
|
1435
|
+
raise ConnectionError("serial EOF / device removed")
|
|
1436
|
+
self.status.bytes_in += len(data) # once, whoever receives it (or nobody)
|
|
1437
|
+
if self._tracer:
|
|
1438
|
+
self._tracer.write("ser>net", data)
|
|
1439
|
+
self._feed_monitors(data)
|
|
1440
|
+
self._feed_traffic(FROM_DEVICE, data)
|
|
1441
|
+
if self._mqtt is not None:
|
|
1442
|
+
self._mqtt.feed(data)
|
|
1443
|
+
for c in list(self._clients):
|
|
1444
|
+
c.feed_from_serial(data)
|
|
1445
|
+
if self._closeon:
|
|
1446
|
+
self._check_closeon(data)
|
|
1447
|
+
|
|
1448
|
+
def _check_closeon(self, data: bytes) -> None:
|
|
1449
|
+
window = self._closeon_tail + data
|
|
1450
|
+
if self._closeon in window:
|
|
1451
|
+
self._log(f"closeon matched {self._closeon!r} — closing {len(self._clients)} client(s)")
|
|
1452
|
+
for c in list(self._clients):
|
|
1453
|
+
c.request_graceful_close()
|
|
1454
|
+
self._closeon_tail = b""
|
|
1455
|
+
elif len(self._closeon) > 1:
|
|
1456
|
+
self._closeon_tail = window[-(len(self._closeon) - 1):]
|
|
1457
|
+
|
|
1458
|
+
# ---------------- network side ----------------
|
|
1459
|
+
def _refresh_clients(self) -> None:
|
|
1460
|
+
self.status.set_clients(self._clients)
|
|
1461
|
+
|
|
1462
|
+
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
1463
|
+
if self._stop.is_set(): # accepted while the mapping stops
|
|
1464
|
+
writer.close()
|
|
1465
|
+
return
|
|
1466
|
+
peer_ip, peer_port = netguard.peer_address(writer)
|
|
1467
|
+
peer_label = netguard.client_label(peer_ip, peer_port)
|
|
1468
|
+
|
|
1469
|
+
# An RFC2217 session (and any, with disconnect_without_device) needs the open serial
|
|
1470
|
+
# port: wait briefly for it (an allowed client) BEFORE admission, so a client
|
|
1471
|
+
# waiting here holds no slot and nobody is kicked for a newcomer that is then
|
|
1472
|
+
# turned away.
|
|
1473
|
+
ready = self._link.ready
|
|
1474
|
+
if self.needs_port and not ready.is_set() and self._access.allowed(peer_ip):
|
|
1475
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
1476
|
+
await asyncio.wait_for(ready.wait(),
|
|
1477
|
+
timeout=self.mapping.options.rfc2217_net_timeout_s)
|
|
1478
|
+
if not ready.is_set():
|
|
1479
|
+
# (a poller retrying all the time: each reason once a minute, with a count)
|
|
1480
|
+
if self.drops_clients:
|
|
1481
|
+
self._refusal_log(f"rejected {peer_label}: the serial port is not open "
|
|
1482
|
+
"(disconnect_without_device)")
|
|
1483
|
+
else:
|
|
1484
|
+
self._refusal_log(f"rejected {peer_label}: serial not ready for RFC2217")
|
|
1485
|
+
writer.close()
|
|
1486
|
+
return
|
|
1487
|
+
|
|
1488
|
+
# allow-list, then max connections (a priority client / kick_old_user makes room);
|
|
1489
|
+
# from here until the client is in self._clients nothing awaits, so connections
|
|
1490
|
+
# accepted together are admitted one after the other, each seeing the one before
|
|
1491
|
+
admission = self._access.admit(peer_ip, peer_label, self._clients, self._refusal_log)
|
|
1492
|
+
if not admission.admitted:
|
|
1493
|
+
if admission.at_capacity:
|
|
1494
|
+
with contextlib.suppress(Exception):
|
|
1495
|
+
writer.write(b"Busy: maximum connections reached.\r\n")
|
|
1496
|
+
await writer.drain()
|
|
1497
|
+
writer.close()
|
|
1498
|
+
return
|
|
1499
|
+
is_priority = admission.priority
|
|
1500
|
+
|
|
1501
|
+
self._enable_keepalive(writer, peer_label)
|
|
1502
|
+
client = _ClientConn(self, reader, writer, peer_ip, peer_port, priority=is_priority)
|
|
1503
|
+
self._clients.add(client)
|
|
1504
|
+
self._refresh_clients()
|
|
1505
|
+
tag = (" [priority]" if is_priority else "") + tls.client_certificate(writer)
|
|
1506
|
+
self._log(f"client connected: {peer_label}{tag} ({len(self._clients)} total)")
|
|
1507
|
+
try:
|
|
1508
|
+
await client.run()
|
|
1509
|
+
finally:
|
|
1510
|
+
self._clients.discard(client)
|
|
1511
|
+
self._refresh_clients()
|
|
1512
|
+
held = fmt_duration(time.monotonic() - client.connected_at)
|
|
1513
|
+
why = f" — {client.close_reason}" if client.close_reason else ""
|
|
1514
|
+
self._log(f"client disconnected: {peer_label} after {held}{why} "
|
|
1515
|
+
f"({len(self._clients)} total)")
|
|
1516
|
+
self._restore_port_settings()
|
|
1517
|
+
# closestr to device when the last client leaves
|
|
1518
|
+
sw = self._link.writer
|
|
1519
|
+
if not self._clients and self.mapping.options.closestr and sw:
|
|
1520
|
+
with contextlib.suppress(Exception):
|
|
1521
|
+
sw.write(self.mapping.options.closestr.encode("utf-8", "replace"))
|
|
1522
|
+
await sw.drain()
|
|
1523
|
+
|
|
1524
|
+
def _enable_keepalive(self, writer: asyncio.StreamWriter, peer: str) -> None:
|
|
1525
|
+
net = self.mapping.network
|
|
1526
|
+
if err := sockopts.apply_keepalive(writer, net):
|
|
1527
|
+
self._log(f"TCP keepalive not enabled for {peer}: {err}")
|
|
1528
|
+
|
|
1529
|
+
# ---------------- connect-out (TCP client) ----------------
|
|
1530
|
+
async def _connect_out(self):
|
|
1531
|
+
"""Resolve the remote host, then connect (+ TLS handshake) to each resolved
|
|
1532
|
+
address in turn, giving each one connect_timeout_s — like an OS connect
|
|
1533
|
+
timeout per address — so a dual-stack host whose IPv6 path is dead still
|
|
1534
|
+
falls back to IPv4 instead of the first address eating the whole budget.
|
|
1535
|
+
|
|
1536
|
+
The DNS lookup is deliberately NOT under the timeout: getaddrinfo runs in a
|
|
1537
|
+
worker thread that cancellation can't stop, so timing it out would leave one
|
|
1538
|
+
stuck thread behind per retry during a DNS outage and starve the shared
|
|
1539
|
+
executor (asyncio.to_thread: password hashing, config saves, log reads).
|
|
1540
|
+
Connecting to the resolved IP literal needs no further lookup.
|
|
1541
|
+
|
|
1542
|
+
Returns (reader, writer, what the log says about TLS); over TLS the server's
|
|
1543
|
+
certificate is checked first (tls.check_server: a pin that does not match is that
|
|
1544
|
+
address's failure)."""
|
|
1545
|
+
net = self.mapping.network
|
|
1546
|
+
loop = asyncio.get_running_loop()
|
|
1547
|
+
infos = await loop.getaddrinfo(net.remote_host, net.remote_port,
|
|
1548
|
+
type=socket.SOCK_STREAM)
|
|
1549
|
+
ssl_ctx = self._client_ssl()
|
|
1550
|
+
error: BaseException = ConnectionError(f"no address for {net.remote_host}")
|
|
1551
|
+
for family, _type, _proto, _name, sockaddr in infos:
|
|
1552
|
+
try:
|
|
1553
|
+
reader, writer = await asyncio.wait_for(
|
|
1554
|
+
asyncio.open_connection(
|
|
1555
|
+
str(sockaddr[0]), int(sockaddr[1]), family=family, ssl=ssl_ctx,
|
|
1556
|
+
server_hostname=net.remote_host if ssl_ctx else None),
|
|
1557
|
+
timeout=net.connect_timeout_s or None)
|
|
1558
|
+
except asyncio.TimeoutError as e:
|
|
1559
|
+
if getattr(e, "errno", None) is not None:
|
|
1560
|
+
error = e # the OS's own connect timeout, not ours
|
|
1561
|
+
continue
|
|
1562
|
+
error = ConnectionError(f"timed out after {net.connect_timeout_s:g}s")
|
|
1563
|
+
continue
|
|
1564
|
+
except OSError as e:
|
|
1565
|
+
error = e
|
|
1566
|
+
continue
|
|
1567
|
+
if ssl_ctx is None:
|
|
1568
|
+
return reader, writer, ""
|
|
1569
|
+
try: # the server's certificate, before any byte of the link is used
|
|
1570
|
+
return reader, writer, f" ({tls.check_server(net, writer)})"
|
|
1571
|
+
except ConnectionError as e:
|
|
1572
|
+
writer.transport.abort()
|
|
1573
|
+
error = e
|
|
1574
|
+
raise error
|
|
1575
|
+
|
|
1576
|
+
async def _client_supervisor(self) -> None:
|
|
1577
|
+
net = self.mapping.network
|
|
1578
|
+
target = host_port(net.remote_host, net.remote_port)
|
|
1579
|
+
backoff = Backoff()
|
|
1580
|
+
failures = FailureLog(self._log, "connect-out failed")
|
|
1581
|
+
while not self._stop.is_set():
|
|
1582
|
+
# an RFC 2217 session drives the serial port (disconnect_without_device: no
|
|
1583
|
+
# session without it): connect out only while it is open
|
|
1584
|
+
if self.needs_port and not await self._serial_open():
|
|
1585
|
+
break
|
|
1586
|
+
st = self.status
|
|
1587
|
+
try:
|
|
1588
|
+
reader, writer, tls_note = await self._connect_out()
|
|
1589
|
+
self._enable_keepalive(writer, target)
|
|
1590
|
+
client = _ClientConn(self, reader, writer, net.remote_host, net.remote_port)
|
|
1591
|
+
self._clients.add(client)
|
|
1592
|
+
self._refresh_clients()
|
|
1593
|
+
self._log(f"connected out to {target}{tls_note}{failures.recovered()}")
|
|
1594
|
+
if st.link_error and st.last_error == st.link_error:
|
|
1595
|
+
st.last_error = "" # (the link's error, not one of the serial side)
|
|
1596
|
+
st.set_link("up")
|
|
1597
|
+
backoff.reset()
|
|
1598
|
+
try:
|
|
1599
|
+
await client.run()
|
|
1600
|
+
finally:
|
|
1601
|
+
self._clients.discard(client)
|
|
1602
|
+
self._refresh_clients()
|
|
1603
|
+
# closed (by either end, or a timer): "down" only if the next try fails,
|
|
1604
|
+
# so a max connection time or idle timeout recycle is no outage
|
|
1605
|
+
st.set_link("connecting")
|
|
1606
|
+
held = fmt_duration(time.monotonic() - client.connected_at)
|
|
1607
|
+
why = f" — {client.close_reason}" if client.close_reason else ""
|
|
1608
|
+
self._log(f"connect-out link to {target} closed after {held}{why}")
|
|
1609
|
+
self._restore_port_settings()
|
|
1610
|
+
except asyncio.CancelledError:
|
|
1611
|
+
raise
|
|
1612
|
+
except Exception as e:
|
|
1613
|
+
st.last_error = str(e)
|
|
1614
|
+
st.connect_failures += 1
|
|
1615
|
+
st.set_link("down", str(e))
|
|
1616
|
+
failures.failed(str(e))
|
|
1617
|
+
if self._stop.is_set():
|
|
1618
|
+
break
|
|
1619
|
+
if not await backoff.wait(self._stop):
|
|
1620
|
+
break
|
|
1621
|
+
|
|
1622
|
+
# ---------------- serial <-> serial bridge ----------------
|
|
1623
|
+
async def _serialbridge_supervisor(self) -> None:
|
|
1624
|
+
"""Both ports open and close together, so this keeps its own loop (SerialLink
|
|
1625
|
+
supervises one port). The side that could not be opened last time is opened first:
|
|
1626
|
+
while one port is missing, the other one is not opened and closed again at every
|
|
1627
|
+
retry (each open pulses its DTR and RTS: a board that resets on DTR would reset
|
|
1628
|
+
every few seconds)."""
|
|
1629
|
+
backoff = self._bridge_backoff = Backoff()
|
|
1630
|
+
notes = NoteLog(self._log)
|
|
1631
|
+
failures = FailureLog(self._log, "serial bridge error")
|
|
1632
|
+
st = self.status
|
|
1633
|
+
sides = (self.mapping.serial, self.mapping.serial_b)
|
|
1634
|
+
first = 0 # the side opened first: 0 = A, 1 = B
|
|
1635
|
+
while not self._stop.is_set():
|
|
1636
|
+
opened: list = [None, None] # (reader, writer, serial, device) of each side
|
|
1637
|
+
was_open = False
|
|
1638
|
+
try:
|
|
1639
|
+
for side in (first, 1 - first):
|
|
1640
|
+
try:
|
|
1641
|
+
opened[side] = await serial_io.open_serial(sides[side])
|
|
1642
|
+
except Exception:
|
|
1643
|
+
first = side
|
|
1644
|
+
raise
|
|
1645
|
+
portdiag.opened(opened[side][2], self.mapping.name, opened[side][3])
|
|
1646
|
+
was_open = True
|
|
1647
|
+
(ra, wa, sa, da), (rb, wb, sb, db) = opened
|
|
1648
|
+
self.status.device = f"{da} <-> {db}"
|
|
1649
|
+
self.status.devices = (da, db)
|
|
1650
|
+
self.status.last_error = ""
|
|
1651
|
+
self.status.hint = None
|
|
1652
|
+
self.status.warnings = tuple(
|
|
1653
|
+
portdiag.Note("side", (side, note))
|
|
1654
|
+
for side, ser in (("A", sa), ("B", sb)) for note in serial_io.open_notes(ser))
|
|
1655
|
+
self.status.state = "running"
|
|
1656
|
+
backoff.reset()
|
|
1657
|
+
self._log(f"serial bridge open: {da} <-> {db}{failures.recovered()}")
|
|
1658
|
+
notes(self.status.warnings)
|
|
1659
|
+
async with TaskGroup() as tg:
|
|
1660
|
+
tg.create_task(self._pump_serial_pair(ra, wb, "A>B"))
|
|
1661
|
+
tg.create_task(self._pump_serial_pair(rb, wa, "B>A"))
|
|
1662
|
+
if not self._stop.is_set(): # (a pump ends by raising why)
|
|
1663
|
+
raise ConnectionError("serial EOF / device removed")
|
|
1664
|
+
except asyncio.CancelledError:
|
|
1665
|
+
raise
|
|
1666
|
+
except Exception as e:
|
|
1667
|
+
if not self._stop.is_set():
|
|
1668
|
+
# TaskGroup raises an ExceptionGroup; flatten its messages
|
|
1669
|
+
inner = getattr(e, "exceptions", None)
|
|
1670
|
+
st.last_error = "; ".join(str(x) for x in inner) if inner else str(e)
|
|
1671
|
+
if was_open:
|
|
1672
|
+
st.device_lost += 1
|
|
1673
|
+
st.state = "reconnecting"
|
|
1674
|
+
else:
|
|
1675
|
+
st.open_failures += 1
|
|
1676
|
+
st.state = "device-missing" if portdiag.missing(e) else "reconnecting"
|
|
1677
|
+
failures.failed(st.last_error)
|
|
1678
|
+
hint = None if was_open else await asyncio.to_thread(
|
|
1679
|
+
explain, e, sides[first])
|
|
1680
|
+
st.hint = portdiag.Note("side", ("AB"[first], hint)) if hint else None
|
|
1681
|
+
finally:
|
|
1682
|
+
self.status.warnings = ()
|
|
1683
|
+
await asyncio.gather(*(close_stream(port[1], self._log)
|
|
1684
|
+
for port in opened if port is not None))
|
|
1685
|
+
if self._stop.is_set():
|
|
1686
|
+
break
|
|
1687
|
+
self.status.reconnects += 1
|
|
1688
|
+
if not await backoff.wait(self._stop):
|
|
1689
|
+
break
|
|
1690
|
+
|
|
1691
|
+
async def _pump_serial_pair(self, reader, writer, tag: str) -> None:
|
|
1692
|
+
# port A is "the device" of the counters and the live view: A -> B counts as in,
|
|
1693
|
+
# B -> A as out
|
|
1694
|
+
from_a = tag.startswith("A")
|
|
1695
|
+
while not self._stop.is_set():
|
|
1696
|
+
data = await reader.read(READ_CHUNK)
|
|
1697
|
+
if not data:
|
|
1698
|
+
raise ConnectionError(f"serial EOF ({tag})")
|
|
1699
|
+
if self._tracer:
|
|
1700
|
+
self._tracer.write(tag, data)
|
|
1701
|
+
self._feed_monitors(data)
|
|
1702
|
+
self._feed_traffic(FROM_DEVICE if from_a else TO_DEVICE, data)
|
|
1703
|
+
writer.write(data)
|
|
1704
|
+
if from_a:
|
|
1705
|
+
self.status.bytes_in += len(data)
|
|
1706
|
+
else:
|
|
1707
|
+
self.status.bytes_out += len(data)
|
|
1708
|
+
await writer.drain()
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
# ---------------------------------------------------------------------------
|
|
1712
|
+
# UDP transport (datagram bridge). The serial read loop broadcasts to _clients, so the
|
|
1713
|
+
# _UdpPeer in _clients carries serial->net; datagram_received carries net->serial, through
|
|
1714
|
+
# a bounded queue that _UdpBridge.run() writes to the port. One peer gets the device's
|
|
1715
|
+
# output: the reply address when one is set (udp_peer_host / udp_peer_port), otherwise
|
|
1716
|
+
# the sender of the last allowed datagram (forgotten after udp_peer_timeout_s without a
|
|
1717
|
+
# datagram from it, when that is set). The targets (udp_targets, a _UdpTarget each in
|
|
1718
|
+
# _clients) get it too, always; a peer that is one of them is not sent it twice.
|
|
1719
|
+
# ---------------------------------------------------------------------------
|
|
1720
|
+
def _udp_label(addr) -> str:
|
|
1721
|
+
"""ip:port of a datagram's sender (an IPv4 one of a socket for both families as IPv4)."""
|
|
1722
|
+
return netguard.client_label(canonical_ip(str(addr[0])), addr[1])
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
class _UdpPeer:
|
|
1726
|
+
"""Where a UDP mapping sends the device's output; listed as its client."""
|
|
1727
|
+
|
|
1728
|
+
def __init__(self, bridge: "_UdpBridge", addr, label: str = "") -> None:
|
|
1729
|
+
self.bridge = bridge
|
|
1730
|
+
self.addr = addr
|
|
1731
|
+
self.peer = label or _udp_label(addr)
|
|
1732
|
+
self.priority = False
|
|
1733
|
+
self.seq = connection_order()
|
|
1734
|
+
self.connected_at = time.monotonic() # since when it gets the output
|
|
1735
|
+
self.last_heard = time.monotonic() # its last datagram (for the peer timeout)
|
|
1736
|
+
|
|
1737
|
+
def feed_from_serial(self, data: bytes) -> None:
|
|
1738
|
+
self.bridge.send(self, data)
|
|
1739
|
+
|
|
1740
|
+
def kick(self, reason: str = "") -> None:
|
|
1741
|
+
pass
|
|
1742
|
+
|
|
1743
|
+
def request_graceful_close(self) -> None:
|
|
1744
|
+
pass
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
class _UdpTarget(_UdpPeer):
|
|
1748
|
+
"""A UDP target (udp_targets): always gets the device's output; listed as a client."""
|
|
1749
|
+
|
|
1750
|
+
def feed_from_serial(self, data: bytes) -> None:
|
|
1751
|
+
self.bridge.send_target(self, data)
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
class _UdpBridge(asyncio.DatagramProtocol):
|
|
1755
|
+
"""A UDP mapping's socket side. Every datagram is checked against the allow-list; an
|
|
1756
|
+
allowed one makes its sender the peer (unless there is a reply address) and waits in a
|
|
1757
|
+
queue that run() writes to the serial port. A datagram that would take the queue past
|
|
1758
|
+
UDP_BACKLOG_MAX bytes is dropped and counted instead."""
|
|
1759
|
+
|
|
1760
|
+
def __init__(self, runner: "MappingRunner") -> None:
|
|
1761
|
+
self.runner = runner
|
|
1762
|
+
net = runner.mapping.network
|
|
1763
|
+
self.reply_to = udp_peer_address(net) # ValueError: a malformed stored one
|
|
1764
|
+
self.targets = udp_targets(net) # (the same)
|
|
1765
|
+
# the peer timeout is for a sender that was learned, not for the reply address
|
|
1766
|
+
self.timeout = 0.0 if self.reply_to else udp_peer_timeout(net)
|
|
1767
|
+
self.transport: Optional[asyncio.DatagramTransport] = None
|
|
1768
|
+
self.peer: Optional[_UdpPeer] = None
|
|
1769
|
+
self._target_addrs: set = set() # where the targets are sent (as the socket needs)
|
|
1770
|
+
self._queue: list[bytes] = []
|
|
1771
|
+
self._queued = 0 # bytes waiting: queued, or being written by run()
|
|
1772
|
+
self._wake = asyncio.Event()
|
|
1773
|
+
self._peer_log = _LogEvery(runner._log)
|
|
1774
|
+
self._refused_log = _LogEvery(runner._log)
|
|
1775
|
+
self._error_log = _LogEvery(runner._log)
|
|
1776
|
+
|
|
1777
|
+
def describe(self) -> str:
|
|
1778
|
+
"""Where the device's output goes (for the start-up log line)."""
|
|
1779
|
+
if self.reply_to:
|
|
1780
|
+
out = f"device output to {_udp_label(self.reply_to)}"
|
|
1781
|
+
elif self.timeout:
|
|
1782
|
+
out = (f"device output to the last sender (forgotten after "
|
|
1783
|
+
f"{fmt_duration(self.timeout)} without a datagram from it)")
|
|
1784
|
+
else:
|
|
1785
|
+
out = "device output to the last sender"
|
|
1786
|
+
if self.targets:
|
|
1787
|
+
out += " and to " + ", ".join(_udp_label(t) for t in self.targets)
|
|
1788
|
+
return out
|
|
1789
|
+
|
|
1790
|
+
# ---- asyncio.DatagramProtocol ----
|
|
1791
|
+
def connection_made(self, transport) -> None:
|
|
1792
|
+
self.transport = transport
|
|
1793
|
+
sock = transport.get_extra_info("socket")
|
|
1794
|
+
v6 = sock is not None and sock.family == socket.AF_INET6
|
|
1795
|
+
|
|
1796
|
+
def sent_to(addr):
|
|
1797
|
+
host, port = addr
|
|
1798
|
+
if v6 and ipaddress.ip_address(host).version == 4:
|
|
1799
|
+
host = f"::ffff:{host}" # an IPv4 peer of a socket bound to an IPv6 address
|
|
1800
|
+
return host, port
|
|
1801
|
+
|
|
1802
|
+
r = self.runner
|
|
1803
|
+
for target in self.targets:
|
|
1804
|
+
addr = sent_to(target)
|
|
1805
|
+
if addr not in self._target_addrs:
|
|
1806
|
+
self._target_addrs.add(addr)
|
|
1807
|
+
r._clients.add(_UdpTarget(self, addr, _udp_label(target)))
|
|
1808
|
+
r._refresh_clients()
|
|
1809
|
+
if self.reply_to is not None:
|
|
1810
|
+
self._set_peer(sent_to(self.reply_to), _udp_label(self.reply_to))
|
|
1811
|
+
|
|
1812
|
+
def connection_lost(self, exc) -> None:
|
|
1813
|
+
self.transport = None
|
|
1814
|
+
|
|
1815
|
+
def error_received(self, exc: Exception) -> None:
|
|
1816
|
+
# e.g. the peer's port is closed (ICMP port unreachable) or there is no route to it
|
|
1817
|
+
hint = ""
|
|
1818
|
+
if (getattr(exc, "errno", None) == errno.EACCES
|
|
1819
|
+
and self.runner.mapping.network.udp_broadcast is not True):
|
|
1820
|
+
hint = " (to send to a broadcast address, allow broadcast: udp_broadcast)"
|
|
1821
|
+
self._error_log(f"udp error: {exc}{hint}")
|
|
1822
|
+
|
|
1823
|
+
def datagram_received(self, data: bytes, addr) -> None:
|
|
1824
|
+
r = self.runner
|
|
1825
|
+
# Access control: a TCP listener checks the allow-list in _on_client, but UDP has
|
|
1826
|
+
# no accept hook, so it is checked HERE, for every datagram. An unlisted source is
|
|
1827
|
+
# dropped entirely: it neither reaches the device nor becomes the peer. The check
|
|
1828
|
+
# looks at the datagram's source address, which a sender can forge (README,
|
|
1829
|
+
# Security); with a reply address the device's output can't be redirected.
|
|
1830
|
+
if not r._access.allowed(addr[0]):
|
|
1831
|
+
self._refused_log(f"rejected datagram from {_udp_label(addr)}: not in allowed list")
|
|
1832
|
+
return
|
|
1833
|
+
if self.reply_to is None:
|
|
1834
|
+
self._heard_from(addr)
|
|
1835
|
+
if r.mapping.network.read_only or not data:
|
|
1836
|
+
return
|
|
1837
|
+
if r._link.writer is None:
|
|
1838
|
+
r._drop(len(data), "the serial port is not open")
|
|
1839
|
+
elif self._queued + len(data) > UDP_BACKLOG_MAX:
|
|
1840
|
+
r._drop(len(data), f"the serial port cannot keep up with the datagrams "
|
|
1841
|
+
f"({self._queued} bytes waiting for it)")
|
|
1842
|
+
else:
|
|
1843
|
+
self._queue.append(data)
|
|
1844
|
+
self._queued += len(data)
|
|
1845
|
+
self._wake.set()
|
|
1846
|
+
|
|
1847
|
+
# ---- the peer ----
|
|
1848
|
+
def _set_peer(self, addr, label: str = "") -> _UdpPeer:
|
|
1849
|
+
r = self.runner
|
|
1850
|
+
if self.peer is not None:
|
|
1851
|
+
r._clients.discard(self.peer)
|
|
1852
|
+
peer = self.peer = _UdpPeer(self, addr, label)
|
|
1853
|
+
r._clients.add(peer)
|
|
1854
|
+
r._refresh_clients()
|
|
1855
|
+
return peer
|
|
1856
|
+
|
|
1857
|
+
def _heard_from(self, addr) -> None:
|
|
1858
|
+
"""No reply address: the sender of an allowed datagram gets the device's output
|
|
1859
|
+
from now on. A new sender is logged (a change at most once a minute) and listed
|
|
1860
|
+
with its own connection time."""
|
|
1861
|
+
old = self.peer
|
|
1862
|
+
if old is not None and old.addr == addr:
|
|
1863
|
+
old.last_heard = time.monotonic()
|
|
1864
|
+
return
|
|
1865
|
+
peer = self._set_peer(addr)
|
|
1866
|
+
if old is None:
|
|
1867
|
+
self.runner._log(f"udp peer {peer.peer}")
|
|
1868
|
+
else:
|
|
1869
|
+
self._peer_log(f"udp peer is now {peer.peer} (was {old.peer})")
|
|
1870
|
+
|
|
1871
|
+
def _expired(self) -> bool:
|
|
1872
|
+
"""Forget the sender once it has sent nothing for the peer timeout."""
|
|
1873
|
+
peer = self.peer
|
|
1874
|
+
if (peer is None or not self.timeout
|
|
1875
|
+
or time.monotonic() - peer.last_heard < self.timeout):
|
|
1876
|
+
return False
|
|
1877
|
+
r = self.runner
|
|
1878
|
+
r._clients.discard(peer)
|
|
1879
|
+
self.peer = None
|
|
1880
|
+
r._refresh_clients()
|
|
1881
|
+
r._log(f"udp peer {peer.peer} forgotten: no datagram for {fmt_duration(self.timeout)}")
|
|
1882
|
+
return True
|
|
1883
|
+
|
|
1884
|
+
def send(self, peer: _UdpPeer, data: bytes) -> None:
|
|
1885
|
+
"""The device's output, to the peer (nothing once it has expired, or when it is one
|
|
1886
|
+
of the targets, which get it anyway)."""
|
|
1887
|
+
if (self.transport is None or peer is not self.peer or self._expired()
|
|
1888
|
+
or tuple(peer.addr[:2]) in self._target_addrs):
|
|
1889
|
+
return
|
|
1890
|
+
with contextlib.suppress(Exception): # a failed send reaches error_received
|
|
1891
|
+
self.transport.sendto(data, peer.addr)
|
|
1892
|
+
|
|
1893
|
+
def send_target(self, target: _UdpTarget, data: bytes) -> None:
|
|
1894
|
+
"""The device's output, to a target."""
|
|
1895
|
+
if self.transport is None:
|
|
1896
|
+
return
|
|
1897
|
+
try:
|
|
1898
|
+
self.transport.sendto(data, target.addr)
|
|
1899
|
+
except OSError as e: # (a synchronous refusal, e.g. EACCES: a broadcast address)
|
|
1900
|
+
self.error_received(e)
|
|
1901
|
+
except Exception:
|
|
1902
|
+
pass
|
|
1903
|
+
|
|
1904
|
+
# ---- network -> serial ----
|
|
1905
|
+
async def run(self) -> None:
|
|
1906
|
+
"""Write the queued datagrams to the serial port, oldest first: what has queued up
|
|
1907
|
+
goes in one write that awaits drain(), like a TCP client's data, so the port's own
|
|
1908
|
+
buffer stays at its high-water mark and the queue holds at most UDP_BACKLOG_MAX.
|
|
1909
|
+
With a peer timeout, also forgets a sender that went quiet."""
|
|
1910
|
+
r = self.runner
|
|
1911
|
+
while True:
|
|
1912
|
+
if self.timeout:
|
|
1913
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
1914
|
+
await asyncio.wait_for(self._wake.wait(), timeout=1.0)
|
|
1915
|
+
self._expired()
|
|
1916
|
+
else:
|
|
1917
|
+
await self._wake.wait()
|
|
1918
|
+
self._wake.clear()
|
|
1919
|
+
if not self._queue:
|
|
1920
|
+
continue
|
|
1921
|
+
batch = self._queue[0] if len(self._queue) == 1 else b"".join(self._queue)
|
|
1922
|
+
self._queue.clear()
|
|
1923
|
+
try:
|
|
1924
|
+
await r.serial_write(batch)
|
|
1925
|
+
except Exception: # the port went away mid-write: SerialLink reopens it
|
|
1926
|
+
pass
|
|
1927
|
+
finally:
|
|
1928
|
+
self._queued -= len(batch)
|