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,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))
|