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.
Files changed (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,451 @@
1
+ """Live traffic in the browser over WebSockets: the interactive console (xterm.js;
2
+ what the device sends, and typing to it) and the read-only traffic feed of the hex
3
+ view (timestamped; what is sent to the device only for operators and admins). And the
4
+ console's serial BREAK, an audited POST (not a WebSocket frame: those are typed data).
5
+
6
+ What a viewer has not been sent yet waits in its _Feed, and goes out in batches: what
7
+ arrives within FLUSH_S of a chunk goes in one WebSocket message with it.
8
+
9
+ The page asks for protocol 2 with ?v=2 (a socket without it gets what 2.8 sent):
10
+ - text messages from the server are notices, JSON: {"dropped": n} — n bytes of traffic
11
+ were dropped before what comes next (the viewer did not keep up); {"state": "stopped"}
12
+ — once, at the start: the mapping is not running (nothing comes until it is started,
13
+ which ends this socket with CLOSE_STOPPED: the page connects again);
14
+ - the hex view packs its records, [direction u8][time f64 BE][length u32 BE][bytes]
15
+ one after the other (version 1: one record per message, without the length).
16
+
17
+ A refused handshake (no session, another site's page) gets HTTP 403. After the handshake
18
+ a close says why, with one of the CLOSE_* codes and a reason.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import collections
24
+ import contextlib
25
+ import struct
26
+ import time
27
+
28
+ from starlette.responses import PlainTextResponse, Response
29
+ from starlette.routing import Route, WebSocketRoute
30
+
31
+ from ...engine import netguard
32
+ from ...engine.bridge import TO_DEVICE
33
+ from ...i18n import _
34
+ from .. import auth, proxy
35
+ from .common import Ctx, client_ip, console_interactive, current_user, notice, sees_to_device
36
+
37
+ # the protocols whose runner sends a serial BREAK (not a Modbus gateway: an RTU bus)
38
+ _BREAK_PROTOCOLS = ("raw", "telnet", "rfc2217")
39
+
40
+ CONSOLE_ACCESS_RECHECK_S = 2.0 # how often an open console re-checks its access
41
+ # Consoles and hex views open at once (counted together): per account — an open-access
42
+ # visitor: per address — and mapping, per mapping, and in all. One more is accepted, then
43
+ # closed with CLOSE_BUSY and the limit it is over.
44
+ VIEWERS_PER_USER = 4
45
+ VIEWERS_PER_MAPPING = 16
46
+ VIEWERS_TOTAL = 64
47
+ # What a viewer has not been sent yet (_Feed): at most FEED_MAX_BYTES of traffic in at
48
+ # most FEED_MAX_ITEMS chunks, the oldest dropped first; taken FRAME_MAX_BYTES at a time. A
49
+ # viewer whose sending has been stuck for STALL_S while its traffic is being dropped
50
+ # takes nothing: it is closed (CLOSE_SLOW), so the bridges stop feeding it.
51
+ FEED_MAX_BYTES = 1024 * 1024
52
+ FEED_MAX_ITEMS = 4096
53
+ FRAME_MAX_BYTES = 64 * 1024
54
+ FLUSH_S = 0.016 # about one screen refresh: what comes this soon after a chunk goes with it
55
+ STALL_S = 30.0
56
+ CLOSE_WAIT_S = 5.0 # a close the client takes nothing of is given up after this long
57
+ # close codes after the handshake (app.js says what they mean)
58
+ CLOSE_BUSY = 1013 # past a VIEWERS_* limit ("try again later")
59
+ CLOSE_STOPPED = 4001 # the mapping stopped or restarted (its runner let the viewer go)
60
+ CLOSE_ACCESS = 4002 # signed out, session ended or role changed
61
+ CLOSE_SLOW = 4003 # took nothing for STALL_S while its traffic was being dropped
62
+ CLOSE_GONE = 4004 # no such mapping (deleted)
63
+
64
+
65
+ def routes(ctx: Ctx) -> list:
66
+ state = ctx.state
67
+
68
+ # refused WebSockets of another site's page: logged at most once a minute (a client
69
+ # other than a browser can send any Origin)
70
+ cross_site_note = netguard.LogEvery(lambda msg: state.log(msg))
71
+
72
+ async def _authorize(websocket):
73
+ """The user a WebSocket acts as, or None (after closing it). BaseHTTPMiddleware
74
+ does NOT run for the WebSocket scope, so authenticate here; a WebSocket that a
75
+ page of another site opens is rejected (CSWSH guard), by the same rule as the
76
+ UI's forms (auth.cross_site): a reverse proxy that passes the browser's address
77
+ as Host, X-Forwarded-Host or Forwarded host= works for both, and so does a page of
78
+ the public URL (Settings -> Reverse proxy)."""
79
+ token = auth.cookie(websocket, auth.SESSION_COOKIE)
80
+ if why := auth.cross_site(websocket.headers,
81
+ also=proxy.published_origins(state.config.admin_ui)):
82
+ cross_site_note(f"refused WebSocket {websocket.url.path} from "
83
+ f"{client_ip(websocket)}: opened by a page of another site "
84
+ f"({why}); {auth.PROXY_HINT}")
85
+ await websocket.close(code=1008)
86
+ return None, token
87
+ ws_user = auth.request_user(state.config, token, websocket.headers,
88
+ state.revoked_sessions)
89
+ if ws_user is None:
90
+ await websocket.close(code=1008)
91
+ # (one of another site's pages was refused above, with its own line)
92
+ ip = client_ip(websocket)
93
+ state.log_refused((ip, "websocket"),
94
+ f"WebSocket {websocket.url.path} from {ip} refused: "
95
+ "no valid session")
96
+ return None, token
97
+ websocket.state.user = ws_user # like request.state.user (ctx.audit)
98
+ return ws_user, token
99
+
100
+ # ---------------- the viewers: consoles and hex views ----------------
101
+ viewers = _Viewers()
102
+ refused = netguard.LogEvery(lambda msg: state.log(msg)) # past a VIEWERS_* limit: once a minute
103
+
104
+ def _who(websocket) -> tuple:
105
+ """Whose viewer it is, for the limits and the audit records: the account — an
106
+ open-access visitor: per address."""
107
+ return ctx.actor(websocket), client_ip(websocket) if ctx.is_anonymous(websocket) else ""
108
+
109
+ def _record_open(websocket, action: str, mapping, detail: str) -> None:
110
+ """The audit record of a console or hex view opened, bounded (AppState.view_records):
111
+ per account — an open-access visitor: per address —, mapping and kind."""
112
+ state.view_records.open(client_ip(websocket), ctx.actor(websocket), action,
113
+ f"{mapping.name} ({detail})",
114
+ key=(*_who(websocket), action, mapping.id))
115
+
116
+ async def _admit(websocket, kind: str) -> _Accepted | None:
117
+ """The handshake of a console or hex view (`kind`, for the log): accepted and
118
+ counted in (_end counts it out), or None once it has been refused and closed."""
119
+ ws_user, token = await _authorize(websocket)
120
+ if ws_user is None:
121
+ return None
122
+ await websocket.accept()
123
+ # looked up once accepted, with nothing awaited until the monitor is in: the
124
+ # runner that feeds it is the mapping's current one (a restart replaces it)
125
+ mid = websocket.path_params["mid"]
126
+ runner = state.supervisor.get_runner(mid)
127
+ mapping = state.config.get_mapping(mid)
128
+ if runner is None or mapping is None:
129
+ await _close(websocket, CLOSE_GONE, "no such mapping")
130
+ return None
131
+ peer = websocket.client.host if websocket.client else "?"
132
+ who = _who(websocket)
133
+ if why := viewers.admit(who, mid):
134
+ refused(f"[{mapping.name}] {kind} of {ws_user.username} ({peer}) refused: {why}")
135
+ await _close(websocket, CLOSE_BUSY, why)
136
+ return None
137
+ return _Accepted(ws_user, token, mapping, runner, peer, who,
138
+ v2=websocket.query_params.get("v") == "2")
139
+
140
+ async def _serve(websocket, v: _Accepted, mon, sender, receiver) -> tuple[int, str]:
141
+ """Serve an accepted viewer until it ends: `sender` returns (its feed ended: the
142
+ mapping stopped), `receiver` returns (the browser closed it), its access changed,
143
+ or it stalled (_Feed.stalled). The close code and reason for that."""
144
+ if v.v2 and v.runner.status.state in ("stopped", "error"):
145
+ await websocket.send_json({"state": "stopped"}) # nothing comes until it starts
146
+
147
+ async def access_watch():
148
+ # A viewer outlives the check at connect: end it once its access is gone (login
149
+ # switched back on, session revoked by a password change, role changed) instead
150
+ # of letting it keep watching — or writing to — the device.
151
+ while True:
152
+ await asyncio.sleep(CONSOLE_ACCESS_RECHECK_S)
153
+ now = auth.request_user(state.config, v.token, websocket.headers,
154
+ state.revoked_sessions)
155
+ if now is None or now.role != v.user.role:
156
+ return
157
+
158
+ ends = {asyncio.create_task(sender()): (CLOSE_STOPPED, "mapping stopped or restarted"),
159
+ asyncio.create_task(receiver()): None, # nothing more to say to it
160
+ asyncio.create_task(access_watch()): (CLOSE_ACCESS, "access changed"),
161
+ asyncio.create_task(mon.stalled.wait()):
162
+ (CLOSE_SLOW, f"took nothing for {STALL_S:g} s")}
163
+ try:
164
+ done, _ = await asyncio.wait(ends, return_when=asyncio.FIRST_COMPLETED)
165
+ finally:
166
+ for t in ends:
167
+ t.cancel()
168
+ for t in ends:
169
+ with contextlib.suppress(BaseException):
170
+ await t
171
+ for t in done:
172
+ end = ends[t]
173
+ if end and not t.cancelled() and t.exception() is None:
174
+ return end
175
+ return 1000, ""
176
+
177
+ async def _end(websocket, kind: str, v: _Accepted, code: int, reason: str,
178
+ written: int = 0) -> None:
179
+ """After its monitor is gone: count the viewer out and close it, saying why (and
180
+ how many bytes were typed to the device, `written`)."""
181
+ viewers.leave(v.who, v.mapping.id)
182
+ await _close(websocket, code, reason)
183
+ state.log(f"[{v.mapping.name}] {kind} closed ({v.peer}"
184
+ + (f"; {written} bytes written" if written else "") + ")"
185
+ + (f": {reason}" if reason else ""))
186
+
187
+ # ---------------- console (xterm over WebSocket) ----------------
188
+ async def console_ws(websocket):
189
+ v = await _admit(websocket, "console")
190
+ if v is None:
191
+ return
192
+ # viewers get an observe-only console; writing to the device needs operator+
193
+ interactive = console_interactive(v.user, v.mapping)
194
+ mon, (code, reason) = _Monitor(), (1000, "")
195
+ written = [0] # how many bytes were typed to the device (never what)
196
+
197
+ async def sender():
198
+ while True:
199
+ chunks, dropped, ended = await mon.take()
200
+ if dropped and v.v2:
201
+ await websocket.send_json({"dropped": dropped})
202
+ if chunks:
203
+ await websocket.send_bytes(b"".join(chunks))
204
+ mon.sent()
205
+ if ended: # mapping stopped
206
+ return
207
+
208
+ async def receiver():
209
+ while True:
210
+ msg = await websocket.receive()
211
+ if msg.get("type") == "websocket.disconnect":
212
+ return
213
+ data = msg.get("bytes")
214
+ if data is None and msg.get("text") is not None:
215
+ data = msg["text"].encode("utf-8", "replace")
216
+ if data and interactive:
217
+ await v.runner.serial_write(data)
218
+ written[0] += len(data)
219
+
220
+ try:
221
+ v.runner.add_monitor(mon)
222
+ state.log(f"[{v.mapping.name}] console opened by {v.user.username} ({v.peer})")
223
+ _record_open(websocket, "console_open", v.mapping,
224
+ "interactive" if interactive else "read-only")
225
+ code, reason = await _serve(websocket, v, mon, sender, receiver)
226
+ finally:
227
+ v.runner.discard_monitor(mon)
228
+ await _end(websocket, "console", v, code, reason, written[0])
229
+ if written[0]: # who wrote to the device, and how much (bounded: console_writes)
230
+ state.console_writes.add(client_ip(websocket), ctx.actor(websocket),
231
+ "console_close",
232
+ f"{v.mapping.name} ({written[0]} bytes written)")
233
+
234
+ # ---------------- traffic feed for the hex view (read-only) ----------------
235
+ async def traffic_ws(websocket):
236
+ """A mapping's traffic, as records [direction: 1 byte (0 = from the device, 1 = to
237
+ it)][time: float64 BE][length: uint32 BE][bytes], several to a binary message
238
+ (protocol 1: one per message, without the length; see the module docstring).
239
+ Read-only for everyone who may open the console; what is sent to the device
240
+ only for those who may see it (sees_to_device), the others never get it queued."""
241
+ v = await _admit(websocket, "hex view")
242
+ if v is None:
243
+ return
244
+ to_device = sees_to_device(v.user, v.mapping)
245
+ mon, (code, reason) = _TrafficMonitor(to_device=to_device), (1000, "")
246
+
247
+ async def sender():
248
+ while True:
249
+ records, dropped, ended = await mon.take()
250
+ if dropped and v.v2:
251
+ await websocket.send_json({"dropped": dropped})
252
+ if v.v2 and records:
253
+ await websocket.send_bytes(b"".join(
254
+ struct.pack(">BdI", direction, ts, len(data)) + data
255
+ for direction, ts, data in records))
256
+ mon.sent()
257
+ elif records:
258
+ for direction, ts, data in records:
259
+ await websocket.send_bytes(struct.pack(">Bd", direction, ts) + data)
260
+ mon.sent()
261
+ if ended: # mapping stopped
262
+ return
263
+
264
+ async def receiver(): # nothing is accepted from the browser; notice a close
265
+ while (await websocket.receive()).get("type") != "websocket.disconnect":
266
+ pass
267
+
268
+ try:
269
+ v.runner.add_traffic_monitor(mon)
270
+ state.log(f"[{v.mapping.name}] hex view opened by {v.user.username} ({v.peer})")
271
+ _record_open(websocket, "hex_view_open", v.mapping,
272
+ "both directions" if to_device else "device output only")
273
+ code, reason = await _serve(websocket, v, mon, sender, receiver)
274
+ finally:
275
+ v.runner.discard_traffic_monitor(mon)
276
+ await _end(websocket, "hex view", v, code, reason)
277
+
278
+ # ---------------- serial BREAK (the console's "Send BREAK") ----------------
279
+ async def send_break(request):
280
+ """POST /api/mappings/{mid}/break: a serial BREAK on the mapping's port (as a telnet
281
+ client's IAC BRK: the line held in the break condition for bridge.BREAK_S). For
282
+ whoever may type in the console (operators and admins, a network mapping that is
283
+ not read-only); audited as mapping_break. 204 when sent (its X-Notice says so in
284
+ the console's feedback area); 400 read-only or not a serial-to-network mapping,
285
+ 409 the port is not open (or can't send one): their text is shown there."""
286
+ if deny := ctx.require_role(request, "operator"):
287
+ return deny
288
+ mid = request.path_params["mid"]
289
+ mapping = state.config.get_mapping(mid)
290
+ runner = state.supervisor.get_runner(mid)
291
+ if mapping is None or runner is None:
292
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
293
+ if (mapping.kind != "net" or mapping.network.protocol not in _BREAK_PROTOCOLS
294
+ or not hasattr(runner, "send_break")):
295
+ return PlainTextResponse(_("A serial BREAK can't be sent on this mapping."),
296
+ status_code=400)
297
+ if not console_interactive(current_user(request), mapping):
298
+ return PlainTextResponse(_("The mapping is read-only: no serial BREAK is sent."),
299
+ status_code=400)
300
+ if not await runner.send_break(f"web console, {ctx.actor(request)}"):
301
+ return PlainTextResponse(_("No serial BREAK was sent: the serial port is not open, "
302
+ "or it can't send one (see the mapping's log)."),
303
+ status_code=409)
304
+ ctx.audit(request, "mapping_break", mapping.name)
305
+ return Response(status_code=204, headers=notice(
306
+ _("A serial BREAK was sent on “{0}”.").format(mapping.name)))
307
+
308
+ return [
309
+ WebSocketRoute("/api/mappings/{mid}/console", console_ws),
310
+ WebSocketRoute("/api/mappings/{mid}/traffic", traffic_ws),
311
+ Route("/api/mappings/{mid}/break", send_break, methods=["POST"]),
312
+ ]
313
+
314
+
315
+ class _Accepted:
316
+ """A console or hex view past its handshake (routes._admit): who opened it (`user`,
317
+ the session `token`; `peer`: its address, `who`: the key it is counted by), the
318
+ mapping and runner it watches, and whether the page speaks protocol 2 (`v2`)."""
319
+
320
+ __slots__ = ("user", "token", "mapping", "runner", "peer", "who", "v2")
321
+
322
+ def __init__(self, user, token, mapping, runner, peer: str, who: tuple,
323
+ v2: bool = False) -> None:
324
+ self.user, self.token, self.mapping, self.runner = user, token, mapping, runner
325
+ self.peer, self.who, self.v2 = peer, who, v2
326
+
327
+
328
+ async def _close(websocket, code: int = 1000, reason: str = "") -> None:
329
+ """Close a WebSocket, giving up after CLOSE_WAIT_S: a client that takes nothing would
330
+ hold the close, and the handler, for good."""
331
+ with contextlib.suppress(Exception):
332
+ await asyncio.wait_for(websocket.close(code=code, reason=reason), CLOSE_WAIT_S)
333
+
334
+
335
+ class _Viewers:
336
+ """The consoles and hex views open, counted per account (an open-access visitor: per
337
+ address) and mapping, per mapping and in all: the VIEWERS_* limits."""
338
+
339
+ def __init__(self) -> None:
340
+ self._open: collections.Counter = collections.Counter()
341
+
342
+ @staticmethod
343
+ def _keys(who: tuple, mid: str) -> tuple:
344
+ return ("all",), ("mapping", mid), ("user", who, mid)
345
+
346
+ def admit(self, who: tuple, mid: str) -> str:
347
+ """Count one more viewer in: '' — or, past a limit, why not (it isn't counted)."""
348
+ total, mapping, user = (self._open[k] for k in self._keys(who, mid))
349
+ if total >= VIEWERS_TOTAL:
350
+ return f"too many consoles and hex views open ({VIEWERS_TOTAL} in all)"
351
+ if mapping >= VIEWERS_PER_MAPPING:
352
+ return f"too many consoles and hex views of this mapping open ({VIEWERS_PER_MAPPING})"
353
+ if user >= VIEWERS_PER_USER:
354
+ return (f"too many consoles and hex views of this mapping open for one user "
355
+ f"({VIEWERS_PER_USER})")
356
+ self._open.update(self._keys(who, mid))
357
+ return ""
358
+
359
+ def leave(self, who: tuple, mid: str) -> None:
360
+ for k in self._keys(who, mid):
361
+ self._open[k] -= 1
362
+ if self._open[k] <= 0:
363
+ del self._open[k]
364
+
365
+
366
+ class _Feed:
367
+ """What one browser viewer has not been sent yet. The bridges put() into it
368
+ synchronously from their read loops, best-effort: past FEED_MAX_BYTES of traffic or
369
+ FEED_MAX_ITEMS chunks the oldest are dropped, so a slow viewer never stalls a bridge.
370
+ Its end is a flag, not an item in the queue, so a full feed can't lose it: the sender
371
+ sends what is left, then stops. A viewer whose sending has been stuck for STALL_S
372
+ while its traffic is being dropped is `stalled`: it takes nothing."""
373
+
374
+ def __init__(self) -> None:
375
+ self._items: collections.deque = collections.deque() # (item, bytes of traffic)
376
+ self._bytes = 0
377
+ self._ready = asyncio.Event() # something to take, or closed
378
+ self._busy_since: float | None = None # sending what it took, since (monotonic)
379
+ self._unsaid = 0 # bytes dropped since the last take()
380
+ self.dropped = 0 # bytes of traffic dropped in all
381
+ self.closed = False
382
+ self.stalled = asyncio.Event()
383
+
384
+ def put(self, item, size: int) -> None:
385
+ if self.closed:
386
+ return
387
+ items = self._items
388
+ items.append((item, size))
389
+ self._bytes += size
390
+ while self._bytes > FEED_MAX_BYTES or len(items) > FEED_MAX_ITEMS:
391
+ _old, n = items.popleft()
392
+ self._bytes -= n
393
+ self.dropped += n
394
+ self._unsaid += n
395
+ busy = self._busy_since
396
+ if busy is not None and time.monotonic() - busy > STALL_S:
397
+ self.stalled.set()
398
+ self._ready.set()
399
+
400
+ def close(self) -> None:
401
+ """The runner stopped (or let the viewer go): no more items come."""
402
+ self.closed = True
403
+ self._ready.set()
404
+
405
+ async def take(self, max_bytes: int = FRAME_MAX_BYTES) -> tuple[list, int, bool]:
406
+ """What to send next: (items, bytes dropped before them, ended). Waits for an item,
407
+ then FLUSH_S for those right behind it (not when `max_bytes` are there already),
408
+ and takes at most `max_bytes` of traffic, at least one item. `ended`: the feed is
409
+ closed and nothing is left."""
410
+ self._busy_since = None
411
+ while not self._items and not self.closed:
412
+ self._ready.clear()
413
+ await self._ready.wait()
414
+ if not self.closed and self._bytes < max_bytes:
415
+ await asyncio.sleep(FLUSH_S)
416
+ items, n = self._items, 0
417
+ out: list = []
418
+ while items and (not out or n + items[0][1] <= max_bytes):
419
+ item, size = items.popleft()
420
+ out.append(item)
421
+ n += size
422
+ self._bytes -= n
423
+ dropped, self._unsaid = self._unsaid, 0
424
+ self._busy_since = time.monotonic()
425
+ return out, dropped, self.closed and not items
426
+
427
+ def sent(self) -> None:
428
+ """The sender got a message out: it is not stuck."""
429
+ self._busy_since = time.monotonic()
430
+
431
+
432
+ class _Monitor(_Feed):
433
+ """A browser console observer: what the device sends (runner.add_monitor)."""
434
+
435
+ def feed(self, data: bytes) -> None:
436
+ if data:
437
+ self.put(data, len(data))
438
+
439
+
440
+ class _TrafficMonitor(_Feed):
441
+ """A hex-view observer: (direction, time, bytes) items (runner.add_traffic_monitor).
442
+ Without `to_device`, what is sent to the device is left out."""
443
+
444
+ def __init__(self, *, to_device: bool) -> None:
445
+ super().__init__()
446
+ self.to_device = to_device
447
+
448
+ def feed_traffic(self, direction: int, data: bytes) -> None:
449
+ if not data or (direction == TO_DEVICE and not self.to_device):
450
+ return
451
+ self.put((direction, time.time(), bytes(data)), len(data))