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,359 @@
|
|
|
1
|
+
"""What every route module shares: rendering, the caller's identity and role checks.
|
|
2
|
+
|
|
3
|
+
One ``Ctx`` is built per app (``build_routes``) and handed to each route module, so
|
|
4
|
+
the helpers that used to be closures inside one big ``build_routes()`` live here once.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
19
|
+
|
|
20
|
+
from markupsafe import escape
|
|
21
|
+
from starlette.datastructures import ImmutableMultiDict
|
|
22
|
+
from starlette.responses import HTMLResponse, PlainTextResponse, Response
|
|
23
|
+
|
|
24
|
+
from ...config import ROLE_RANK
|
|
25
|
+
from ...explain import explain
|
|
26
|
+
from ...i18n import _
|
|
27
|
+
from .. import auth, proxy
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from starlette.requests import HTTPConnection, Request
|
|
31
|
+
from starlette.templating import Jinja2Templates
|
|
32
|
+
|
|
33
|
+
from ...config import MappingConfig, User
|
|
34
|
+
from ...state import AppState
|
|
35
|
+
|
|
36
|
+
# HTMX response header: tells the dashboard to re-fetch the mappings table now
|
|
37
|
+
TRIGGER = {"HX-Trigger": "refreshMappings"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def unchanged_or(request: Request, resp: Response) -> Response:
|
|
41
|
+
"""A fragment the dashboard polls: 204 when the browser shows this content already
|
|
42
|
+
(the X-Content-Hash it sent; htmx then keeps the panel as it is — no flicker, the
|
|
43
|
+
focus, a text selection and the scroll position stay), else the fragment with its
|
|
44
|
+
hash."""
|
|
45
|
+
digest = hashlib.sha256(resp.body).hexdigest()[:16]
|
|
46
|
+
if request.headers.get("x-content-hash") == digest:
|
|
47
|
+
return Response(status_code=204, headers={"X-Content-Hash": digest})
|
|
48
|
+
resp.headers["X-Content-Hash"] = digest
|
|
49
|
+
return resp
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def page_context(templates: Jinja2Templates, request: Request,
|
|
53
|
+
ctx: dict[str, Any]) -> dict[str, Any]:
|
|
54
|
+
"""What a template rendered straight from its Jinja template (off the event loop:
|
|
55
|
+
LiveTable, Ctx.arender) gets, as a page rendered through the templates does: the
|
|
56
|
+
request, what the context processors give every page (the base path the links go
|
|
57
|
+
under, the instance's name, ...), then `ctx`."""
|
|
58
|
+
context: dict = {"request": request}
|
|
59
|
+
for processor in getattr(templates, "context_processors", ()):
|
|
60
|
+
context.update(processor(request))
|
|
61
|
+
context.update(ctx)
|
|
62
|
+
return context
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LiveTable:
|
|
66
|
+
"""A table the dashboard polls every few seconds (the mappings, the fleet view),
|
|
67
|
+
answered without rendering it again when that can be helped.
|
|
68
|
+
|
|
69
|
+
Its X-Content-Hash is "<shape>.<counters>": a fingerprint of everything the table
|
|
70
|
+
shows except the In / Out counters, and one of those. The browser sends back the one
|
|
71
|
+
it has, and the answer is
|
|
72
|
+
* 204 when both match: the browser keeps its table;
|
|
73
|
+
* when only the counters changed: just those, as htmx out-of-band swaps of their
|
|
74
|
+
<span id=...> (HX-Reswap: none), so the rows, the keyboard focus, a text
|
|
75
|
+
selection, a tooltip or a click in progress stay;
|
|
76
|
+
* else the whole table, rendered off the event loop (a render of a few hundred
|
|
77
|
+
rows takes 100+ ms, during which every bridge on the loop would wait - a Modbus
|
|
78
|
+
RTU reply that pauses for 10 ms counts as finished), and kept for other tabs
|
|
79
|
+
that ask for the same table. (So the template may not use the request beyond
|
|
80
|
+
the language and the base path its links go under, which are part of the
|
|
81
|
+
fingerprint.)
|
|
82
|
+
The fingerprints are taken in the same worker thread (a repr of every mapping is
|
|
83
|
+
not free either). They are keyed with a secret of this process: the shape covers
|
|
84
|
+
settings the browser is not shown. A tab from before an upgrade or a restart sends
|
|
85
|
+
a hash that matches neither and gets the whole table once."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, ctx: Ctx, template: str, size: int = 8) -> None:
|
|
88
|
+
self.ctx, self.template, self._size = ctx, template, size
|
|
89
|
+
self._key = os.urandom(16)
|
|
90
|
+
self._bodies: OrderedDict[str, str] = OrderedDict() # tag -> rendered table
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
def _digest(self, value: object) -> str:
|
|
94
|
+
return hashlib.blake2b(repr(value).encode("utf-8", "backslashreplace"),
|
|
95
|
+
key=self._key, digest_size=8).hexdigest()
|
|
96
|
+
|
|
97
|
+
async def answer(self, request: Request,
|
|
98
|
+
snapshot: Callable[[], tuple[object, dict[str, str] | None]],
|
|
99
|
+
headers: Mapping[str, str] | None = None, **context: Any) -> Response:
|
|
100
|
+
"""`snapshot()` -> (shape, spans): what the table shows apart from the counters,
|
|
101
|
+
and {element id: text} of the counter spans - None when they can't be patched by
|
|
102
|
+
id, and then the shape has to include them. Called in a worker thread; `context`
|
|
103
|
+
renders the table from the same data."""
|
|
104
|
+
sent = request.headers.get("x-content-hash") or ""
|
|
105
|
+
lang = getattr(request.state, "lang", "")
|
|
106
|
+
base = proxy.base(request) # (a tab at the public URL's path, another one at /)
|
|
107
|
+
|
|
108
|
+
def work() -> tuple[str, str | None, bool]:
|
|
109
|
+
"""(tag, body or None for "unchanged", whether body is only the counters)"""
|
|
110
|
+
shape, spans = snapshot()
|
|
111
|
+
tag = self._digest((lang, base, shape)) + "." + self._digest(spans)
|
|
112
|
+
if sent == tag:
|
|
113
|
+
return tag, None, False
|
|
114
|
+
if spans is not None and sent.partition(".")[0] == tag.partition(".")[0]:
|
|
115
|
+
return tag, "".join(f'<span id="{escape(k)}" hx-swap-oob="true">{escape(v)}</span>'
|
|
116
|
+
for k, v in spans.items()), True
|
|
117
|
+
with self._lock:
|
|
118
|
+
body = self._bodies.get(tag)
|
|
119
|
+
if body is None:
|
|
120
|
+
body = self.ctx.templates.get_template(self.template).render(
|
|
121
|
+
page_context(self.ctx.templates, request, context))
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._bodies[tag] = body
|
|
124
|
+
while len(self._bodies) > self._size:
|
|
125
|
+
self._bodies.popitem(last=False)
|
|
126
|
+
return tag, body, False
|
|
127
|
+
|
|
128
|
+
tag, body, patch = await asyncio.to_thread(work)
|
|
129
|
+
h = {**(headers or {}), "X-Content-Hash": tag}
|
|
130
|
+
if body is None:
|
|
131
|
+
return Response(status_code=204, headers=h)
|
|
132
|
+
if patch: # no swap of the table itself; htmx still does the out-of-band ones
|
|
133
|
+
h["HX-Reswap"] = "none"
|
|
134
|
+
return HTMLResponse(body, headers=h)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_NOT_ID_CHAR = re.compile(r"[^A-Za-z0-9_-]")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def domid(value: object) -> str:
|
|
141
|
+
"""*value* as part of an HTML id: other characters than letters, digits, _ and -
|
|
142
|
+
become _. (htmx keeps the keyboard focus on a polled table's buttons across a
|
|
143
|
+
refresh by their ids; a peer's address and mapping id are part of them.)"""
|
|
144
|
+
return _NOT_ID_CHAR.sub("_", str(value))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def bytesize(value: Any) -> str:
|
|
148
|
+
"""A byte count for people: 512 B, 1.5 KB, 12.3 MB, 4.0 GB (units of 1024, one
|
|
149
|
+
decimal) — the dashboard's In / Out and the trace file sizes. Not Jinja's
|
|
150
|
+
filesizeformat, which writes an untranslated "Bytes"; the exact counts stay in the
|
|
151
|
+
REST API and /metrics."""
|
|
152
|
+
try:
|
|
153
|
+
n = int(value)
|
|
154
|
+
except (TypeError, ValueError): # a peer's payload in the fleet view: shown as it is
|
|
155
|
+
return str(value)
|
|
156
|
+
for unit, size in (("TB", 1 << 40), ("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)):
|
|
157
|
+
if n >= size:
|
|
158
|
+
return f"{n / size:.1f} {unit}"
|
|
159
|
+
return f"{n} B"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def duration(secs: Any) -> str:
|
|
163
|
+
"""A connection's age for people, in the page's language: 2m 05s, 1h 02m (as
|
|
164
|
+
engine.bridge.fmt_duration writes it in English for the logs and the REST API;
|
|
165
|
+
app.js counts it on in the browser with the same units)."""
|
|
166
|
+
try:
|
|
167
|
+
s = max(0, int(secs))
|
|
168
|
+
except (TypeError, ValueError, OverflowError):
|
|
169
|
+
return ""
|
|
170
|
+
h, rem = divmod(s, 3600)
|
|
171
|
+
m, s = divmod(rem, 60)
|
|
172
|
+
if h:
|
|
173
|
+
return _("{0}h {1}m").format(h, f"{m:02d}")
|
|
174
|
+
if m:
|
|
175
|
+
return _("{0}m {1}s").format(m, f"{s:02d}")
|
|
176
|
+
return _("{0}s").format(s)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def since(unix_time: Any) -> str:
|
|
180
|
+
"""duration() from `unix_time` (a client's connected_at) until now."""
|
|
181
|
+
try:
|
|
182
|
+
return duration(time.time() - float(unix_time))
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
return ""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def notice(text: str, kind: str = "ok", focus: str = "", detail: str = "") -> dict:
|
|
188
|
+
"""The response header that has the dashboard (app.js, its feedback area) tell the
|
|
189
|
+
user what an action did: "ok" as a status message, "warn" as an alert; `focus`: the
|
|
190
|
+
id of the element that gets the keyboard focus when the form panel closes; `detail`:
|
|
191
|
+
the message's tooltip (the raw error a translated reason stands for). A header,
|
|
192
|
+
so the action's answer and its status stay what they are; JSON, percent-encoded (a
|
|
193
|
+
header value is Latin-1 only)."""
|
|
194
|
+
d = {"kind": kind, "text": text, **({"focus": focus} if focus else {}),
|
|
195
|
+
**({"detail": detail} if detail else {})}
|
|
196
|
+
return {"X-Notice": urllib.parse.quote(json.dumps(d, ensure_ascii=False))}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def reason(error: str) -> tuple[str, str]:
|
|
200
|
+
"""Why a mapping did not start, for a notice: (what it means in the page's language,
|
|
201
|
+
the error itself for the tooltip), or (the error, "") when explain() doesn't know it."""
|
|
202
|
+
short = explain(error)
|
|
203
|
+
return (short, error) if short else (error, "")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def action_done(action: str, name: str) -> str:
|
|
207
|
+
"""What a start / stop / restart of the mapping `name` did, for notice()."""
|
|
208
|
+
if action == "start":
|
|
209
|
+
return _("“{0}” started.").format(name)
|
|
210
|
+
if action == "stop":
|
|
211
|
+
return _("“{0}” stopped.").format(name)
|
|
212
|
+
return _("“{0}” restarted.").format(name)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def start_failed(state: AppState, mid: str) -> str:
|
|
216
|
+
"""Why the mapping `mid` did not start, when it was just started or restarted and
|
|
217
|
+
its runner stopped with an error; else ""."""
|
|
218
|
+
st = state.supervisor.status(mid) or {}
|
|
219
|
+
return (st.get("last_error") or _("unknown error")) if st.get("state") == "error" else ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def local_path(value: object, default: str = "/") -> str:
|
|
223
|
+
"""`value` when it is a path on this site — where to go after a sign-in or a language
|
|
224
|
+
switch — else `default`: no scheme or host, no //host or /\\host (other sites), no
|
|
225
|
+
control characters (a browser drops a tab or newline and may read what is left as
|
|
226
|
+
another site)."""
|
|
227
|
+
if (not isinstance(value, str) or not value.startswith("/") or value.startswith("//")
|
|
228
|
+
or "\\" in value or len(value) > 2048 or any(c < " " or c == "\x7f" for c in value)):
|
|
229
|
+
return default
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def client_ip(request: HTTPConnection) -> str:
|
|
234
|
+
ip = request.client.host if request.client else "?"
|
|
235
|
+
# normalize IPv4-mapped IPv6 (::ffff:1.2.3.4 -> 1.2.3.4) so the rate-limit
|
|
236
|
+
# key is consistent on dual-stack listeners
|
|
237
|
+
if ip.startswith("::ffff:") and "." in ip:
|
|
238
|
+
ip = ip[len("::ffff:"):]
|
|
239
|
+
return ip
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def read_form(request: Request) -> ImmutableMultiDict[str, str]:
|
|
243
|
+
"""The fields of a posted form, all text. None of these forms takes a file: one in
|
|
244
|
+
the body is refused (400) while it is read, instead of reaching a handler that
|
|
245
|
+
expects a string where an upload turns up. (The mappings import, which takes one
|
|
246
|
+
file, reads its form itself.)"""
|
|
247
|
+
form = await request.form(max_files=0)
|
|
248
|
+
return ImmutableMultiDict([(k, v) for k, v in form.multi_items() if isinstance(v, str)])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def current_user(request: HTTPConnection) -> User | None:
|
|
252
|
+
return getattr(request.state, "user", None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def csrf_failed() -> PlainTextResponse:
|
|
256
|
+
return PlainTextResponse(_("CSRF validation failed. Reload the page."), status_code=403)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def has_role(user: User | None, role: str) -> bool:
|
|
260
|
+
"""`user` (an account, the open-access pseudo-user or None) has `role` or a higher one."""
|
|
261
|
+
return user is not None and ROLE_RANK.get(user.role, 0) >= ROLE_RANK[role]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def console_interactive(user: User | None, mapping: MappingConfig) -> bool:
|
|
265
|
+
"""Does what `user` types in the console of `mapping` go to the device? Only on a
|
|
266
|
+
network mapping that isn't read-only, and only for operators and admins."""
|
|
267
|
+
return (mapping.kind == "net" and not mapping.network.read_only
|
|
268
|
+
# a Modbus gateway only watches its bus: raw bytes would corrupt its frames
|
|
269
|
+
and mapping.network.protocol != "modbus" and has_role(user, "operator"))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sees_to_device(user: User | None, mapping: MappingConfig) -> bool:
|
|
273
|
+
"""Does `user`'s hex view of `mapping` show what is sent TO the device? On a network
|
|
274
|
+
mapping that is what clients and web-console users type, passwords at a prompt the
|
|
275
|
+
device doesn't echo included (trace files record it too), so a viewer's hex view shows
|
|
276
|
+
only what the device sends. A serial↔serial bridge has a device on both ports, and its
|
|
277
|
+
console has always shown both directions."""
|
|
278
|
+
return mapping.kind != "net" or has_role(user, "operator")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Ctx:
|
|
282
|
+
"""The templates + shared AppState, and the per-request helpers built on them."""
|
|
283
|
+
|
|
284
|
+
client_ip = staticmethod(client_ip)
|
|
285
|
+
current_user = staticmethod(current_user)
|
|
286
|
+
|
|
287
|
+
def __init__(self, templates: Jinja2Templates, state: AppState) -> None:
|
|
288
|
+
self.templates = templates
|
|
289
|
+
self.state = state
|
|
290
|
+
|
|
291
|
+
def render(self, request: Request, name: str, status_code: int = 200,
|
|
292
|
+
headers: Mapping[str, str] | None = None, **ctx: Any) -> Response:
|
|
293
|
+
return self.templates.TemplateResponse(request, name, ctx,
|
|
294
|
+
status_code=status_code, headers=headers)
|
|
295
|
+
|
|
296
|
+
async def arender(self, request: Request, name: str, status_code: int = 200,
|
|
297
|
+
headers: Mapping[str, str] | None = None, **ctx: Any) -> HTMLResponse:
|
|
298
|
+
"""render() in a worker thread: for the panels that refresh themselves, whose
|
|
299
|
+
render would hold up every bridge on the event loop for as long as it takes (the
|
|
300
|
+
page's language, a context variable, goes along)."""
|
|
301
|
+
template = self.templates.get_template(name)
|
|
302
|
+
body = await asyncio.to_thread(template.render, page_context(self.templates, request, ctx))
|
|
303
|
+
return HTMLResponse(body, status_code=status_code, headers=headers)
|
|
304
|
+
|
|
305
|
+
def set_session(self, response: Response, request: Request, user: User) -> None:
|
|
306
|
+
cfg = self.state.config
|
|
307
|
+
ttl = cfg.session_timeout_s # Settings -> Web UI access -> session lifetime
|
|
308
|
+
self.state.signins.note(user.username, user.source) # its last sign-in
|
|
309
|
+
issued = self.state.sessions_issued
|
|
310
|
+
issued[user.username] = issued.get(user.username, 0) + 1
|
|
311
|
+
auth.set_cookie(response, request, cfg, auth.SESSION_COOKIE,
|
|
312
|
+
auth.issue_session(cfg.secret_key, ttl, user.username, user.pwd_version),
|
|
313
|
+
max_age=ttl)
|
|
314
|
+
request.state.csrf_renew = True # the CSRF cookie lasts as long (GuardMiddleware)
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def new_csrf_token(request: HTTPConnection) -> None:
|
|
318
|
+
"""Give this browser a new CSRF token with this response (GuardMiddleware sets the
|
|
319
|
+
cookie), at a sign-in or a sign-out: a token that got into the browser before is
|
|
320
|
+
not accepted after it. Only for a response that renders no form (a redirect)."""
|
|
321
|
+
request.state.csrf_token = auth.new_csrf_token()
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def require_role(request: HTTPConnection, role: str) -> PlainTextResponse | None:
|
|
325
|
+
"""Return a 403 response if the session user's role is below `role`, else None."""
|
|
326
|
+
if not has_role(current_user(request), role):
|
|
327
|
+
return PlainTextResponse(_("Forbidden: requires the {0} role.").format(role), status_code=403)
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def can_edit(request: HTTPConnection) -> bool:
|
|
332
|
+
return has_role(current_user(request), "operator")
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def is_anonymous(request: HTTPConnection) -> bool:
|
|
336
|
+
u = current_user(request)
|
|
337
|
+
return u is not None and u.source == "anonymous"
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def actor(request: HTTPConnection) -> str:
|
|
341
|
+
"""Who acts, for the audit log: the signed-in user, "(no login)" for an
|
|
342
|
+
open-access visitor."""
|
|
343
|
+
u = current_user(request)
|
|
344
|
+
return (u.username if u.source != "anonymous" else "(no login)") if u is not None else ""
|
|
345
|
+
|
|
346
|
+
def audit(self, request: HTTPConnection, action: str, detail: str = "",
|
|
347
|
+
user: str | None = None) -> None:
|
|
348
|
+
"""An audit record for this request: client IP plus the acting user (the
|
|
349
|
+
signed-in user unless given)."""
|
|
350
|
+
self.state.audit(client_ip(request), action, detail,
|
|
351
|
+
user=self.actor(request) if user is None else user)
|
|
352
|
+
|
|
353
|
+
def require_peer_access(self, request: HTTPConnection) -> PlainTextResponse | None:
|
|
354
|
+
"""Cluster peer actions act on OTHER nodes with the shared key, so they need a
|
|
355
|
+
signed-in operator: open access on this node must not open every peer (which
|
|
356
|
+
may well require a login of its own)."""
|
|
357
|
+
if self.is_anonymous(request):
|
|
358
|
+
return PlainTextResponse(_("Sign in to manage other cluster nodes."), status_code=403)
|
|
359
|
+
return self.require_role(request, "operator")
|