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,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
|
+
]
|