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,669 @@
1
+ """LAN cluster: the unified fleet view, remote start/stop/restart and remote edit.
2
+
3
+ Two kinds of endpoint live here. Peer-facing ones (``/api/cluster/local``,
4
+ ``/control``, ``/mapping-data``, ``/mapping-save``) are called by OTHER nodes and are
5
+ authenticated by the shared cluster key, not a session: a request signed with it (or,
6
+ from a node running 2.8.0 or older while legacy authentication is on, the raw key), and
7
+ their replies are signed for that request. Browser-facing ones (status, ``control-peer``,
8
+ ``peer-form``, ``peer-save``) need a signed-in operator and proxy to a peer the
9
+ browser names by host + port: it must be a discovered or configured peer, and the
10
+ call goes to that peer's own address and scheme.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ import re
17
+ import time
18
+ from typing import Any
19
+
20
+ from markupsafe import escape
21
+ from starlette.datastructures import FormData
22
+ from starlette.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
23
+ from starlette.routing import Route
24
+
25
+ from ... import __version__
26
+ from ...config import (
27
+ ConfigError, MappingConfig, host_port, mapping_revision, node_label, peer_host, trace_file_name,
28
+ )
29
+ from ...engine import netinfo, portlist
30
+ from ...engine.cluster import (ACTOR_FIELD, ACTOR_MAX, ACTOR_NODE_FIELD, AUTH_HEADER, REFUSALS,
31
+ peer_addr, peer_port, peer_url)
32
+ from ...i18n import _
33
+ from .. import auth
34
+ from . import ops
35
+ from .common import (
36
+ Ctx, LiveTable, bytesize, csrf_failed, domid, notice, read_form, reason, start_failed,
37
+ )
38
+ from .forms import (
39
+ _TEXT_INPUTS, MQTT_PASSWORD_CLEAR, MQTT_PASSWORD_WRITE_ONLY, _build_mapping_dict,
40
+ _parse_modbus_points, blocks_not_read, form_context, in_text_box, mapping_for_rerender,
41
+ mqtt_password_from_form,
42
+ )
43
+
44
+ # The mapping form's own field names (bare ones + these prefixes). cluster_peer_save
45
+ # forwards only these to a peer, so a value the browser adds beyond the form is never
46
+ # relayed with the shared key.
47
+ _PEER_FORM_FIELDS = {"id", "name", "kind", "enabled", "rev"}
48
+ _PEER_FORM_PREFIXES = ("serial_", "network_", "opt_", "mqtt_", "modbus_")
49
+ # Not forwarded as posted: the MQTT password goes as the value to store (_peer_save_fields)
50
+ _PEER_PASSWORD_FIELDS = {"mqtt_password", MQTT_PASSWORD_WRITE_ONLY, MQTT_PASSWORD_CLEAR}
51
+ # A peer request's body (at most a mapping form) is far smaller; a larger one is refused
52
+ # (413) before it is read.
53
+ PEER_MAX_BODY = 1024 * 1024
54
+
55
+
56
+ # Fields a node before 2.8.1 sets from every form it saves (it has no presence marker
57
+ # for them), which this node's form leaves out for a Modbus gateway (it doesn't use
58
+ # them): forwarded with the peer's stored values, so the edit doesn't reset them there.
59
+ # form field -> (config section, attribute)
60
+ _READ_BEFORE_2_8_1 = {
61
+ "opt_banner": ("options", "banner"),
62
+ "opt_closeon": ("options", "closeon"),
63
+ "network_client_queue_max": ("network", "client_queue_max"),
64
+ }
65
+
66
+
67
+ def _peer_version(version) -> tuple[int, int, int] | None:
68
+ """(2, 8, 1) for a peer's reported "2.8.1" ("v2.8.1", "2.8.1rc1" ...); None when it
69
+ reports none (a node before 2.6.0) or something else."""
70
+ parts = re.findall(r"\d+", version if isinstance(version, str) else "")
71
+ if len(parts) < 2:
72
+ return None
73
+ major, minor, patch = (int(p) for p in (parts + ["0"])[:3])
74
+ return major, minor, patch
75
+
76
+
77
+ def _peer_older(version, than: tuple[int, int, int]) -> bool:
78
+ """True when a peer's reported version is older than *than*, or unknown."""
79
+ v = _peer_version(version)
80
+ return v is None or v < than
81
+
82
+
83
+ # What other nodes may ask this one to do with its mappings: its /api/cluster/local and
84
+ # mapping-data answers list them as "caps" (nodes before 2.9.0 send none, see peer_caps).
85
+ CAPS = ("control", "edit")
86
+
87
+
88
+ def peer_caps(payload) -> frozenset[str]:
89
+ """What a peer can be asked to do, by its /api/cluster/local answer: its "caps" list
90
+ (2.9.0+), else what its version could do: start/stop/restart ("control") since 2.6.0,
91
+ edit since 2.6.1; a node before 2.6.0 reports no version and can only be shown."""
92
+ if not isinstance(payload, dict):
93
+ return frozenset()
94
+ caps = payload.get("caps")
95
+ if isinstance(caps, list):
96
+ return frozenset(c for c in caps if isinstance(c, str))
97
+ v = _peer_version(payload.get("version"))
98
+ if v is None or v < (2, 6, 0):
99
+ return frozenset()
100
+ return frozenset({"control"} if v < (2, 6, 1) else {"control", "edit"})
101
+
102
+
103
+ def _version_label(version) -> str:
104
+ """How a peer's version compares to this node's: "older", "newer" or ""."""
105
+ v, mine = _peer_version(version), _peer_version(__version__)
106
+ if v is None or mine is None or v == mine:
107
+ return ""
108
+ return "older" if v < mine else "newer"
109
+
110
+
111
+ def _leaves(d: dict, path: str = ""):
112
+ """(path, value) of every setting in a mapping dict: "serial.advanced.nobreak",
113
+ "modbus_poll.points[2].fn"; a USB identity (match) is one value."""
114
+ for k, v in d.items():
115
+ p = path + k
116
+ if isinstance(v, dict) and k != "match":
117
+ yield from _leaves(v, p + ".")
118
+ elif isinstance(v, list) and v and all(isinstance(x, dict) for x in v):
119
+ for i, x in enumerate(v):
120
+ yield from _leaves(x, f"{p}[{i}].")
121
+ else:
122
+ yield p, v
123
+
124
+
125
+ def not_kept(sent: MappingConfig, stored: dict) -> list[str]:
126
+ """The settings of the mapping a peer stores after a fleet-view save (its mapping-data
127
+ *stored*, in the peer's own schema) that differ from what this node sent (*sent*: the
128
+ forwarded fields as this node's save parses them). Only settings the peer has are
129
+ compared; the trace (stored as the peer's own value) and the id are left out."""
130
+ mine = dict(_leaves(sent.to_dict()))
131
+ theirs = dict(_leaves(MappingConfig.from_dict(stored).to_dict()))
132
+ have = {p for p, _v in _leaves(stored)}
133
+ return [p for p in sorted(have) if p not in ("id", "options.trace_both")
134
+ and p in mine and mine[p] != theirs.get(p)]
135
+
136
+
137
+ def _peer_below_2_8(version: str) -> bool:
138
+ """True when a peer's reported version is older than 2.8 (or unknown): such a node
139
+ stores a trace file name as a path and opens it, so it must not be handed a new one."""
140
+ return _peer_older(version, (2, 8, 0))
141
+
142
+
143
+ def _peer_save_fields(form, peer_mapping, peer_version):
144
+ """The fields cluster_peer_save forwards to a peer's key-guarded mapping-save, or an
145
+ error to show instead. Only the known mapping-form fields are forwarded (never a raw
146
+ value the browser added), the submission is validated locally (so a URL serial port
147
+ never reaches the peer), and the trace file is reduced to a sanitised name — kept as
148
+ the peer stored it when unchanged, and refused as a new name for a pre-2.8 peer. The
149
+ form never had the peer's MQTT password in it: the password to store is sent, the one
150
+ the peer has when the field was left blank (a peer of any version stores what it gets).
151
+ A one-line field posted back unchanged keeps the peer's stored value (line breaks
152
+ the field can't show), and a peer before 2.8.1 gets its stored values for the fields
153
+ it reads from every form but this form leaves out (_READ_BEFORE_2_8_1).
154
+ Returns (fields, None) or (None, error)."""
155
+ if not peer_mapping:
156
+ return None, _("This mapping no longer exists on the peer; reload the fleet view.")
157
+ try:
158
+ # the points as strictly as the peer reads them: an older peer would store a
159
+ # top-level object as no points
160
+ _parse_modbus_points(form.get("modbus_points", ""), strict=True)
161
+ MappingConfig.from_dict(_build_mapping_dict(form, strict=False)).validate_input()
162
+ except (ValueError, ConfigError) as e:
163
+ return None, str(e)
164
+ peer_raw = str(((peer_mapping.get("options") or {}).get("trace_both")) or "").strip()
165
+ submitted = form.get("opt_trace_both", "").strip()
166
+ if submitted == trace_file_name(peer_raw):
167
+ out_trace = peer_raw # unchanged: keep the peer's own stored value (legacy path too)
168
+ else:
169
+ out_trace = trace_file_name(submitted)
170
+ if out_trace and _peer_below_2_8(peer_version):
171
+ return None, _("This cluster node is older than 2.8 and would store a trace file "
172
+ "name as a path. Upgrade it to 2.8 or newer before setting a trace "
173
+ "file for it here.")
174
+ def stored(section, attr):
175
+ return (peer_mapping.get(section) or {}).get(attr)
176
+
177
+ mqtt = peer_mapping.get("mqtt")
178
+ stored_password = str(mqtt.get("password") or "") if isinstance(mqtt, dict) else ""
179
+ unchanged = {field: value for field, (section, attr) in _TEXT_INPUTS.items()
180
+ if isinstance(value := stored(section, attr), str)
181
+ and form.get(field) == in_text_box(value)}
182
+ fields = [(k, unchanged.get(k, v)) for k, v in form.multi_items()
183
+ if k != "opt_trace_both" and k not in _PEER_PASSWORD_FIELDS
184
+ and (k in _PEER_FORM_FIELDS or k.startswith(_PEER_FORM_PREFIXES))]
185
+ fields.append(("opt_trace_both", out_trace))
186
+ fields.append(("mqtt_password", mqtt_password_from_form(form, stored_password)))
187
+ if _peer_older(peer_version, (2, 8, 1)):
188
+ fields += [(field, str(value)) for field, (section, attr) in _READ_BEFORE_2_8_1.items()
189
+ if field not in form and (value := stored(section, attr)) is not None]
190
+ return fields, None
191
+
192
+
193
+ def _peer_actor(a, form) -> str:
194
+ """The user column of the audit record of a peer's remote action: "cluster-peer", or
195
+ "cluster-peer:NODE/USER" when the request names who acted on the sending node — what
196
+ that node says (it signed the request with the cluster key, so no one else could
197
+ have), never an account of this node. Only a signed request's name is taken: a node
198
+ running 2.8.0 or older sends the raw key, unsigned, and never a name."""
199
+ user = str(form.get(ACTOR_FIELD) or "").strip()[:ACTOR_MAX]
200
+ if a is None or a.legacy or not user:
201
+ return "cluster-peer"
202
+ node = re.sub(r"[\s/]+", "", str(form.get(ACTOR_NODE_FIELD) or ""))[:64]
203
+ return f"cluster-peer:{node or a.sender[:16] or '?'}/{user}"
204
+
205
+
206
+ def _remote(target, peer_id, mapping, version="") -> dict:
207
+ """The fleet view's edit form for a peer's mapping: the peer's host + port it posts
208
+ back (resolved again on save), the address it shows, and the id it is saved under.
209
+ That is the id the peer itself keys the mapping by, sent back to it unchanged: a
210
+ v2.7/v2.8 peer may keep an id that this node would not take (``mapping`` carries the
211
+ id derived from it here, which that peer has never heard of). This node uses it only
212
+ to address the mapping on the peer, never for a file or path of its own. A peer
213
+ running another version than this node gets a note saying so."""
214
+ if not isinstance(peer_id, str) or not peer_id:
215
+ peer_id = mapping.id
216
+ addr = peer_addr(target["ip"], target["port"])
217
+ note = ""
218
+ if (age := _version_label(version)) == "older":
219
+ note = _("{0} runs version {1}, this node {2}: the settings {1} doesn’t have are left "
220
+ "out of this form. Upgrade every node to the same version (README: Upgrading "
221
+ "a cluster).").format(addr, version, __version__)
222
+ elif age == "newer":
223
+ note = _("{0} runs version {1}, this node {2}: the settings only {1} has are not in "
224
+ "this form, and it keeps them as they are. Upgrade this node too (README: "
225
+ "Upgrading a cluster).").format(addr, version, __version__)
226
+ return {"host": target["ip"], "port": target["port"], "addr": addr, "id": peer_id,
227
+ "version_note": note}
228
+
229
+
230
+ def _peer_form(target, mapping, version, peer_id, data) -> dict:
231
+ """form_context's arguments for a peer's mapping: its remote settings, the blocks a
232
+ peer running *version* doesn't read left out, and the revision of the mapping its
233
+ mapping-data answer (*data*) gave (2.9.0+)."""
234
+ rev = data.get("rev") if isinstance(data, dict) else ""
235
+ return {"remote": _remote(target, peer_id, mapping, version),
236
+ "hide": blocks_not_read(_peer_version(version)),
237
+ "rev": rev if isinstance(rev, str) else ""}
238
+
239
+
240
+ def _peer_links(data) -> dict:
241
+ """The "links" of a peer's mapping-data (older peers send none), as {name: device}."""
242
+ raw = (data or {}).get("links")
243
+ if not isinstance(raw, dict):
244
+ return {}
245
+ return {k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)}
246
+
247
+
248
+ def _mapping_kind_label(m) -> str:
249
+ if m.kind == "serialbridge":
250
+ return "serial↔serial"
251
+ return f"{m.network.mode}/{m.network.protocol}"
252
+
253
+
254
+ def _mapping_endpoint(m) -> str:
255
+ if m.kind == "serialbridge":
256
+ return f"{m.serial.port} ↔ {m.serial_b.port}"
257
+ if m.network.mode == "client":
258
+ return f"→ {host_port(m.network.remote_host, m.network.remote_port)}"
259
+ return f"{m.network.bind_shown}:{m.network.port}"
260
+
261
+
262
+ def _fmt_uptime(seconds) -> str:
263
+ try:
264
+ s = int(max(0, seconds))
265
+ except (TypeError, ValueError):
266
+ return ""
267
+ d, s = divmod(s, 86400)
268
+ h, s = divmod(s, 3600)
269
+ m, _sec = divmod(s, 60)
270
+ # in the page's language
271
+ if d:
272
+ return _("{0}d {1}h").format(d, h)
273
+ if h:
274
+ return _("{0}h {1}m").format(h, m)
275
+ return _("{0}m").format(m)
276
+
277
+
278
+
279
+ def _peer_done(action: str, name: str, addr: str) -> str:
280
+ """What a start / stop / restart of the mapping `name` on the peer `addr` did."""
281
+ if action == "start":
282
+ return _("“{0}” started on {1}.").format(name, addr)
283
+ if action == "stop":
284
+ return _("“{0}” stopped on {1}.").format(name, addr)
285
+ return _("“{0}” restarted on {1}.").format(name, addr)
286
+
287
+
288
+ def _fleet_snapshot(view: dict):
289
+ """(shape, counter spans) of the fleet view for LiveTable: the spans by the ids
290
+ _cluster_body.html gives them. Without them (two rows with one id: a peer's odd
291
+ mapping ids), the counters stay part of the shape."""
292
+ spans: dict[str, str] | None = {}
293
+ for n in view["nodes"]:
294
+ for m in n.get("mappings") or ():
295
+ key = "c-" + domid(f"{n.get('addr', '')}-{m.get('id', '')}") + "-bytes"
296
+ if spans is None or key in spans:
297
+ spans = None
298
+ continue
299
+ spans[key] = f"{bytesize(m.get('bytes_in', ''))} / {bytesize(m.get('bytes_out', ''))}"
300
+ counters = ("bytes_in", "bytes_out") if spans is not None else ()
301
+ nodes = [({k: v for k, v in n.items() if k not in ("uptime_s", "mappings")}, # (shown: uptime)
302
+ [{k: v for k, v in m.items() if k not in counters} for m in n.get("mappings") or ()])
303
+ for n in view["nodes"]]
304
+ return (nodes, {k: v for k, v in view.items() if k != "nodes"}), spans
305
+
306
+
307
+ def routes(ctx: Ctx) -> list:
308
+ state, render, client_ip = ctx.state, ctx.render, ctx.client_ip
309
+ can_edit, is_anonymous = ctx.can_edit, ctx.is_anonymous
310
+ require_peer_access = ctx.require_peer_access
311
+
312
+ # ---------------- LAN cluster (federated view + remote control) ----------------
313
+ def local_node_payload() -> dict:
314
+ """This node's identity, health and a status row per mapping — the unit other
315
+ nodes aggregate. No secrets: just what the unified dashboard table shows."""
316
+ cfg = state.config
317
+ ip, port, scheme = state.cluster.advertised()
318
+ statuses = state.supervisor.all_status()
319
+ maps, running = [], 0
320
+ for m in cfg.mappings:
321
+ st = statuses.get(m.id) or {}
322
+ if st.get("state") == "running":
323
+ running += 1
324
+ maps.append({
325
+ "id": m.id, "name": m.name, "enabled": m.enabled,
326
+ "kind": _mapping_kind_label(m), "endpoint": _mapping_endpoint(m),
327
+ "serial": m.serial.port,
328
+ "state": st.get("state", "stopped"),
329
+ "link": st.get("link", ""), # connect-out: connecting / up / down
330
+ "client_count": st.get("client_count", 0),
331
+ "bytes_in": st.get("bytes_in", 0), "bytes_out": st.get("bytes_out", 0),
332
+ })
333
+ return {"id": cfg.instance_id, "name": node_label(cfg.admin_ui),
334
+ "ip": ip, "port": port, "scheme": scheme,
335
+ "version": __version__, "caps": list(CAPS),
336
+ "uptime_s": int(time.monotonic() - state.started_mono),
337
+ "mapping_count": len(maps), "running_count": running, "mappings": maps}
338
+
339
+ async def _peer_auth(request):
340
+ """The one check every key-guarded endpoint makes: (auth, None) when the request
341
+ is signed with this node's cluster key for this node (or carries the raw key of a
342
+ node running 2.8.0 or older while that is allowed), else (None, the 403 to answer). A refusal
343
+ is logged and audited, and raw-key use logged, once per change per address - a
344
+ peer with another key polls every few seconds."""
345
+ ip = client_ip(request)
346
+ # only a signed request's body is read here (its signature covers it): any other
347
+ # request is decided on its headers, before its body is read
348
+ body = await request.body() if request.headers.get(AUTH_HEADER) is not None else b""
349
+ a = state.cluster.check_request(
350
+ request.method, request.scope.get("path", ""),
351
+ request.scope.get("query_string", b"").decode("latin-1"), body, request.headers, ip)
352
+ where = f"{request.method} {request.url.path}"
353
+ if a.ok:
354
+ if a.again:
355
+ state.log(f"cluster: requests from {ip} are accepted again")
356
+ if a.legacy and a.first:
357
+ state.log(f"cluster: {ip} sent the cluster key unsigned (a node running 2.8.0 or older) "
358
+ "— upgrade it, then turn legacy authentication off")
359
+ return a, None
360
+ if a.first:
361
+ state.log(f"cluster: refused {where} from {ip}: {REFUSALS.get(a.reason, a.reason)}")
362
+ state.audit(ip, "cluster_auth_failed", f"{where}: {a.reason}", user="cluster-peer")
363
+ return None, _peer_json(a, {"error": "forbidden", "reason": a.reason}, 403)
364
+
365
+ def _peer_json(a, payload, status_code=200):
366
+ """A peer-facing JSON answer; for a signed request (also a refusal of one with a
367
+ valid signature) signed for that request, so the caller can trust it."""
368
+ resp = JSONResponse(payload, status_code=status_code)
369
+ resp.headers[AUTH_HEADER] = state.cluster.reply_auth(a, status_code, bytes(resp.body))
370
+ return resp
371
+
372
+ async def cluster_local(request):
373
+ """Peer-facing: returns this node's mappings to another cluster node that
374
+ presents the matching shared key. Guarded by the key, not a user session
375
+ (added to PUBLIC_PATHS) — read-only, exposes no credentials."""
376
+ a, deny = await _peer_auth(request)
377
+ if deny:
378
+ return deny
379
+ return _peer_json(a, local_node_payload())
380
+
381
+ def _node_from(target, data) -> dict:
382
+ """Build a fleet-table node entry from a fetch target + the peer's payload
383
+ (data is None when the peer is unreachable). The address shown, the Open link
384
+ and the actions are the target's - where this node found the peer: its beacon
385
+ or its manual entry - never the address the peer reports for itself. That one
386
+ differs behind NAT or Docker, for a peer entered by name and for one that could
387
+ not tell its LAN address; it is shown only as information."""
388
+ ip, port, scheme = target["ip"], target["port"], target["scheme"]
389
+ base = {"self": False, "source": target.get("source", "auto"),
390
+ "ip": ip, "port": port, "scheme": scheme, "addr": peer_addr(ip, port),
391
+ **state.cluster.peer_state(target)} # auth / problem / previous_key
392
+ with contextlib.suppress(ValueError):
393
+ base["url"] = peer_url(scheme, ip, port)
394
+ if not data:
395
+ return {**base, "online": False, "id": target.get("id", ""),
396
+ "name": target.get("name", base["addr"]),
397
+ "uptime": "", "version": "", "mapping_count": 0, "running_count": 0,
398
+ "mappings": [], "caps": frozenset(), "age": ""}
399
+ maps = data.get("mappings")
400
+ maps = [m for m in maps if isinstance(m, dict)] if isinstance(maps, list) else []
401
+ own_ip, own_port = peer_host(data.get("ip", "")), peer_port(data.get("port"))
402
+ if own_ip and own_port and peer_addr(own_ip, own_port) != base["addr"]:
403
+ base["reported"] = peer_addr(own_ip, own_port)
404
+ version = data.get("version", "")
405
+ return {**base, "online": True, "id": data.get("id", target.get("id", "")),
406
+ "name": data.get("name", base["addr"]),
407
+ "uptime": _fmt_uptime(data.get("uptime_s", 0)), "version": version,
408
+ "mapping_count": data.get("mapping_count", len(maps)),
409
+ "running_count": data.get("running_count", 0), "mappings": maps,
410
+ "caps": peer_caps(data), "age": _version_label(version)}
411
+
412
+ fleet = LiveTable(ctx, "_cluster_body.html")
413
+
414
+ async def cluster_status(request):
415
+ """Aggregated view for the browser: local node + every discovered/manual peer,
416
+ fetched server-side with the shared key. Renders the unified table."""
417
+ if not state.config.cluster.active:
418
+ return await ctx.arender(request, "_cluster_body.html", nodes=[],
419
+ can_edit=can_edit(request), discovery_error="")
420
+ local = local_node_payload()
421
+ nodes = [{**local, "self": True, "online": True, "source": "self",
422
+ "addr": peer_addr(local["ip"], local["port"]),
423
+ "uptime": _fmt_uptime(local["uptime_s"])}]
424
+ # refreshed every 5 s: 204 while the fleet view is what the browser shows already,
425
+ # only the In / Out counters when only they changed (see LiveTable)
426
+ if is_anonymous(request): # other nodes' data needs a real login here
427
+ view: dict[str, Any] = {"nodes": nodes, "can_edit": False, "discovery_error": "",
428
+ "peers_hidden": True}
429
+ return await fleet.answer(request, lambda: _fleet_snapshot(view), **view)
430
+ targets = state.cluster.all_targets()
431
+ results = await asyncio.gather(*[state.cluster.fetch_peer(t) for t in targets]) \
432
+ if targets else []
433
+ for t, data in zip(targets, results, strict=False):
434
+ nodes.append(_node_from(t, data))
435
+ c = state.cluster
436
+ versions = {n["version"] for n in nodes if n["online"] and isinstance(n["version"], str)}
437
+ notes = {"copies": c.copies(), "shared": c.shared_ids(), "other_key": c.other_key_nodes(),
438
+ "legacy": c.legacy_nodes(),
439
+ "versions": sorted(versions, key=lambda v: _peer_version(v) or (0, 0, 0))
440
+ if len(versions) > 1 or "" in versions else []}
441
+ view = {"nodes": nodes, "can_edit": can_edit(request),
442
+ "discovery_error": state.cluster.discovery_error, "notes": notes}
443
+ return await fleet.answer(request, lambda: _fleet_snapshot(view), **view)
444
+
445
+ async def cluster_control(request):
446
+ """Peer-facing remote control: another cluster node (presenting the shared key)
447
+ asks us to start/stop/restart one of OUR mappings. Key-guarded, no session."""
448
+ a, deny = await _peer_auth(request)
449
+ if deny:
450
+ return deny
451
+ form = await read_form(request)
452
+ mid, action = form.get("mapping_id", ""), form.get("action", "")
453
+ if action not in ("start", "stop", "restart"):
454
+ return _peer_json(a, {"error": "bad_action"}, 400)
455
+ res = await ops.apply_action(state, mid, action)
456
+ if not res.found:
457
+ return _peer_json(a, {"error": "Mapping not found"}, 404)
458
+ if res.error is not None: # not saved, nothing changed: the controlling node says why
459
+ return _peer_json(a, {"ok": False, "error": str(res.error)}, 400)
460
+ name = res.name
461
+ ip = client_ip(request)
462
+ state.log(f"cluster: remote {action} of '{name}' from {ip}")
463
+ state.audit(ip, f"cluster_remote_{action}", name, user=_peer_actor(a, form))
464
+ # start_error: why it did not start; changed: False when it ran (or was stopped)
465
+ # already (both ignored by nodes older than 2.9)
466
+ why = start_failed(state, mid) if action != "stop" else ""
467
+ return _peer_json(a, {"ok": True, "name": name, "changed": res.changed,
468
+ **({"start_error": why} if why else {})})
469
+
470
+ async def cluster_control_peer(request):
471
+ """Browser-facing proxy: an operator on THIS node controls a mapping on a peer.
472
+ The peer must be a discovered or configured one (so the browser can't aim this
473
+ at an arbitrary host); the call goes to its address with the shared key."""
474
+ if deny := require_peer_access(request):
475
+ return deny
476
+ form = await read_form(request)
477
+ action, mid = form.get("action", ""), form.get("mid", "")
478
+ if action not in ("start", "stop", "restart"):
479
+ return PlainTextResponse(_("Unknown action"), status_code=400)
480
+ target, err = _peer_target(form.get)
481
+ if err:
482
+ return err
483
+ res = await state.cluster.control_peer(target, mid, action, actor=ctx.actor(request))
484
+ if not res or not res.get("ok"):
485
+ why = res.get("error") if res and res.get("ok") is False else None
486
+ if isinstance(why, str) and why: # a 2.9+ peer that could not save the change
487
+ return PlainTextResponse(_("The peer did not accept the command: {0}").format(
488
+ why[:500]), status_code=502)
489
+ return PlainTextResponse(_("Peer did not accept the command "
490
+ "(unreachable or rejected)."), status_code=502)
491
+ state.cluster.forget(target) # the refresh this triggers shows the new state
492
+ addr = peer_addr(target["ip"], target["port"])
493
+ name = str(res.get("name", mid))
494
+ state.log(f"cluster: sent {action} to {addr} for '{name}'")
495
+ ctx.audit(request, f"cluster_control_{action}", f"{addr}/{name}")
496
+ why = res.get("start_error") # from a 2.9+ peer
497
+ why, raw = reason(why) if isinstance(why, str) else ("", "")
498
+ note = (notice(_("“{0}” on {1} could not start: {2}").format(name, addr, why), "warn",
499
+ detail=raw)
500
+ if why else notice(_peer_done(action, name, addr)))
501
+ return Response("", headers={"HX-Trigger": "refreshCluster", **note})
502
+
503
+ # ---- remote EDIT: peer renders no UI; THIS node renders the form (so the CSRF
504
+ # token + form action belong to the browser's session) and proxies the save.
505
+ async def cluster_mapping_data(request):
506
+ """Peer-facing: a mapping's full config + this node's serial ports + IP
507
+ candidates, so the controlling node can render the edit form, and which listed port
508
+ the mapping's ports lead to when one is a link elsewhere (/dev/serial0; "links",
509
+ which only this node can tell). Key-guarded."""
510
+ a, deny = await _peer_auth(request)
511
+ if deny:
512
+ return deny
513
+ cfg = state.config
514
+ m = cfg.get_mapping(request.query_params.get("mid", ""))
515
+ if not m:
516
+ return _peer_json(a, {"error": "not_found"}, 404)
517
+ ports = await state.ports.current()
518
+ # (nodes before 2.9.0 read only mapping, ports and ips; "rev" goes back with the
519
+ # form, so this node can tell whether the mapping changed since)
520
+ return _peer_json(a, {"mapping": m.to_dict(), "ports": ports,
521
+ "ips": netinfo.list_ip_candidates(),
522
+ "links": portlist.links(ports, (m.serial.port, m.serial_b.port)),
523
+ "version": __version__, "caps": list(CAPS),
524
+ "rev": mapping_revision(cfg.secret_key, m)})
525
+
526
+ async def cluster_mapping_save(request):
527
+ """Peer-facing: validate + persist + apply an edited mapping on THIS node, run
528
+ from the same form the controlling node rendered. Key-guarded."""
529
+ a, deny = await _peer_auth(request)
530
+ if deny:
531
+ return deny
532
+ form = await read_form(request)
533
+ ok, msg, saved = await ops.save_mapping_from_form(state, form)
534
+ if not ok or saved is None:
535
+ return _peer_json(a, {"ok": False, "error": msg}, 400)
536
+ ip = client_ip(request)
537
+ state.log(f"cluster: remote edit of '{msg}' from {ip}")
538
+ state.audit(ip, "cluster_remote_save", msg, user=_peer_actor(a, form))
539
+ # start_error: why it did not start (ignored by nodes older than 2.9)
540
+ return _peer_json(a, {"ok": True, "name": msg,
541
+ **({"start_error": saved.start_error} if saved.start_error else {})})
542
+
543
+ def _peer_target(getter):
544
+ """The peer a browser request names by host + port (as the fleet view shows them)
545
+ — a discovered or configured one — or (None, error response). The address and
546
+ scheme used, and so where the shared key goes, are that peer's own; nothing else
547
+ in the request picks them."""
548
+ try:
549
+ port = int(getter("port", "0"))
550
+ except (TypeError, ValueError):
551
+ return None, PlainTextResponse(_("Bad peer port"), status_code=400)
552
+ target = state.cluster.find_target(getter("host", ""), port)
553
+ if target is None:
554
+ return None, PlainTextResponse(_("Unknown cluster peer"), status_code=403)
555
+ return target, None
556
+
557
+ async def cluster_peer_form(request):
558
+ """Browser-facing proxy: load a peer's mapping into the edit form (rendered
559
+ here so the CSRF token is the browser's). Submits to /api/cluster/peer-save."""
560
+ if deny := require_peer_access(request):
561
+ return deny
562
+ # A GET, so the middleware's CSRF check doesn't cover it; the Edit button (htmx)
563
+ # sends the token header, a link from another site can't — so such a link can't
564
+ # make this node call a peer with the key.
565
+ if not auth.csrf_token_matches(request, request.headers.get("x-csrf-token")):
566
+ return csrf_failed()
567
+ target, err = _peer_target(request.query_params.get)
568
+ if err:
569
+ return err
570
+ data = await state.cluster.get_peer(target, "/api/cluster/mapping-data",
571
+ {"mid": request.query_params.get("mid", "")})
572
+ if not data or not isinstance(data.get("mapping"), dict):
573
+ return PlainTextResponse(_("Could not load the peer's mapping."), status_code=502)
574
+ # its version: in the answer since 2.9.0, else what the fleet view was told
575
+ version = data.get("version") or (await state.cluster.fetch_peer(target) or {}).get(
576
+ "version", "")
577
+ mapping = MappingConfig.from_dict(data["mapping"])
578
+ return render(request, "_mapping_form.html", **form_context(
579
+ mapping, data.get("ports", []), data.get("ips", []), is_new=False,
580
+ links=_peer_links(data), mqtt_stored=mapping.mqtt.password,
581
+ **_peer_form(target, mapping, version, data["mapping"].get("id"), data)))
582
+
583
+ async def cluster_peer_save(request):
584
+ """Browser-facing proxy: forward an edited mapping to the peer's key-guarded save.
585
+ Only the known mapping-form fields are relayed, validated locally first, and the
586
+ trace file is reduced to a sanitised name (see _peer_save_fields); the mapping must
587
+ already exist on the peer. On a peer or validation error, re-render the form."""
588
+ if deny := require_peer_access(request):
589
+ return deny
590
+ form = await read_form(request)
591
+ target, err = _peer_target(form.get)
592
+ if err:
593
+ return err
594
+
595
+ # Fetch the peer's current mapping (for the stored trace value + to confirm it
596
+ # exists — the fleet view only edits) and its version, concurrently.
597
+ data, local = await asyncio.gather(
598
+ state.cluster.get_peer(target, "/api/cluster/mapping-data",
599
+ {"mid": form.get("id", "")}),
600
+ state.cluster.get_peer(target, "/api/cluster/local"))
601
+
602
+ version = (data or {}).get("version") or (local or {}).get("version", "")
603
+
604
+ def _reload(msg):
605
+ peer = data or {}
606
+ try:
607
+ stored = (MappingConfig.from_dict(peer["mapping"])
608
+ if isinstance(peer.get("mapping"), dict) else None)
609
+ except Exception: # (a peer's mapping this node can't read: the form as posted)
610
+ stored = None
611
+ ports = peer.get("ports", [])
612
+ mapping = mapping_for_rerender(form, stored, ports)
613
+ return render(request, "_mapping_form.html", status_code=400, **form_context(
614
+ mapping, ports, peer.get("ips", []), is_new=False, error=msg,
615
+ links=_peer_links(data), mqtt_stored=stored.mqtt.password if stored else "",
616
+ tls_stored=stored.network.tls_key_password if stored else "", form=form,
617
+ mqtt_key_stored=stored.mqtt.client_key_password if stored else "",
618
+ **_peer_form(target, mapping, version, form.get("id", ""), peer)))
619
+
620
+ if not data:
621
+ return _reload(_("The peer rejected the change or was unreachable."))
622
+ fields, ferr = _peer_save_fields(form, data.get("mapping"), version)
623
+ if ferr:
624
+ return _reload(ferr)
625
+ res = await state.cluster.post_peer(target, "/api/cluster/mapping-save", fields,
626
+ actor=ctx.actor(request))
627
+ if res and res.get("ok"):
628
+ state.cluster.forget(target) # the refresh this triggers shows the change
629
+ addr = peer_addr(target["ip"], target["port"])
630
+ name = str(res.get("name", ""))
631
+ state.log(f"cluster: edited '{name}' on {addr}")
632
+ ctx.audit(request, "cluster_edit", f"{addr}/{name}")
633
+ why = res.get("start_error") # from a 2.9+ peer
634
+ why, raw = reason(why) if isinstance(why, str) else ("", "")
635
+ note = (notice(_("“{0}” saved on {1}, but it could not start: {2}").format(
636
+ name, addr, why), "warn", detail=raw) if why
637
+ else notice(_("“{0}” saved on {1}.").format(name, addr)))
638
+ # read it back: what the peer didn't keep of what was sent (an older version)
639
+ after = await state.cluster.get_peer(target, "/api/cluster/mapping-data",
640
+ {"mid": form.get("id", "")})
641
+ lost = []
642
+ if after and isinstance(after.get("mapping"), dict):
643
+ stored = MappingConfig.from_dict(data["mapping"])
644
+ lost = not_kept(mapping_for_rerender(FormData(fields), stored,
645
+ data.get("ports", [])), after["mapping"])
646
+ if not lost:
647
+ return Response("", headers={"HX-Trigger": "refreshCluster", **note})
648
+ state.log(f"cluster: {addr} (version {version or '?'}) did not keep "
649
+ f"{', '.join(lost)} of '{name}'")
650
+ text = _("Saved on {0} (version {1}), but it did not keep: {2}.").format(
651
+ addr, version or "?", ", ".join(lost[:12]) + (" …" if len(lost) > 12 else ""))
652
+ return HTMLResponse(f'<div class="banner warn">{escape(text)} <button type="button" '
653
+ f'class="btn sm" data-cancel>{escape(_("Close"))}</button></div>',
654
+ headers={"HX-Trigger": "refreshCluster", **note})
655
+ return _reload((res or {}).get("error") or _("The peer rejected the change or was "
656
+ "unreachable."))
657
+
658
+ return [
659
+ Route("/api/cluster/status", cluster_status),
660
+ Route("/api/cluster/local", cluster_local, max_body_size=PEER_MAX_BODY),
661
+ Route("/api/cluster/control", cluster_control, methods=["POST"],
662
+ max_body_size=PEER_MAX_BODY),
663
+ Route("/api/cluster/control-peer", cluster_control_peer, methods=["POST"]),
664
+ Route("/api/cluster/mapping-data", cluster_mapping_data, max_body_size=PEER_MAX_BODY),
665
+ Route("/api/cluster/mapping-save", cluster_mapping_save, methods=["POST"],
666
+ max_body_size=PEER_MAX_BODY),
667
+ Route("/api/cluster/peer-form", cluster_peer_form),
668
+ Route("/api/cluster/peer-save", cluster_peer_save, methods=["POST"]),
669
+ ]