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