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,1140 @@
1
+ """LAN cluster discovery — nodes find each other and share a read-only view.
2
+
3
+ Each ser2netweb instance periodically broadcasts a small UDP *beacon* (its node id,
4
+ hostname, and the IP:port + scheme of its web UI) signed with HMAC-SHA256 over
5
+ the shared cluster key. Every instance listens on the same UDP port and records
6
+ beacons whose signature verifies with its own key — so only nodes configured with
7
+ the *same* key trust each other. Peers not heard from within ``PEER_TTL`` seconds
8
+ expire.
9
+
10
+ Deliberately **not** mDNS/zeroconf and no extra dependencies: just an asyncio
11
+ DatagramProtocol + a periodic broadcast task. This module only does *discovery*
12
+ (who is out there and how to reach their web UI). Aggregating a peer's mappings
13
+ is an HTTP GET to that peer's ``/api/cluster/local`` (guarded by the shared key);
14
+ ``fetch_peer`` performs it off the event loop, on a small thread pool of its own, and
15
+ shares each answer between all open dashboards for a few seconds. The browser only
16
+ ever talks to the node it logged into — that node fans out to its peers server-side.
17
+
18
+ Peer requests don't carry the key itself: each one is signed (header
19
+ ``X-Cluster-Auth``) with a key derived from the cluster key, over its method, path and
20
+ query, a digest of its body, the sending and the receiving node's ids, a time and a
21
+ random nonce. The receiver checks the time against its own clock - the sender corrects
22
+ for the receiver's clock, learned from its signed beacons and replies, so the clocks
23
+ need not agree - remembers every nonce while its request could still pass, and signs
24
+ its reply for that request. So a request seen on the network can't be sent again, to
25
+ this node or to another one, and a reply can't be forged or altered. Releases up to
26
+ 2.8.0 only know ``X-Cluster-Key``, the raw key: while ``legacy_auth`` is on, a node
27
+ accepts it, and sends it only to such peers - one whose signed beacon says it is
28
+ older, at the address that beacon advertises (never at the address a beacon came from:
29
+ beacons can be replayed from anywhere), or a manual peer that refused a signed request
30
+ the way older releases do (never one that has answered a signed request since this
31
+ node started). Beacons carry the protocol version, a random id of the sending process
32
+ (two processes with one node id are copies of one installation) and the sender's own
33
+ addresses; a beacon not newer than the last one from its sender is dropped, unless
34
+ nothing was heard from that sender for longer than a peer lives (a restart, which may
35
+ have set its clock back).
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import concurrent.futures
41
+ import contextlib
42
+ import dataclasses
43
+ import functools
44
+ import hashlib
45
+ import hmac
46
+ import ipaddress
47
+ import json
48
+ import math
49
+ import re
50
+ import secrets
51
+ import socket
52
+ import ssl
53
+ import time
54
+ import unicodedata
55
+ import urllib.error
56
+ import urllib.parse
57
+ import urllib.request
58
+ from typing import Callable
59
+
60
+ from . import netinfo
61
+ from .. import user_agent
62
+ from ..config import node_label, parse_cluster_peer, peer_host
63
+
64
+ BEACON_INTERVAL = 5.0 # seconds between outbound beacons
65
+ PEER_TTL = 20.0 # a peer is dropped if not heard from within this window
66
+ FETCH_CACHE_S = 4.0 # a peer's fleet-view answer is reused this long (by every tab)
67
+ HTTP_WORKERS = 8 # threads for peer requests: their own pool, so slow or
68
+ # unreachable peers never hold up sign-ins, saves or port scans
69
+ MAX_REPLY_BYTES = 8 * 1024 * 1024 # a larger peer reply is refused
70
+ _LOOPBACK = ("0.0.0.0", "::", "127.0.0.1", "localhost", "")
71
+
72
+ AUTH_HEADER = "X-Cluster-Auth" # a signed peer request or reply (after 2.8.0)
73
+ KEY_HEADER = "X-Cluster-Key" # the raw key: all that releases up to 2.8.0 send and check
74
+ # Who acted on the sending node: form fields of a remote start/stop/restart or save, in the
75
+ # body the signature covers (nodes up to 2.8.1 send none and ignore them, as any field
76
+ # their forms don't have). The receiving node records them as that node's claim.
77
+ ACTOR_FIELD, ACTOR_NODE_FIELD = "_actor", "_actor_node"
78
+ ACTOR_MAX = 128 # characters of a user name sent (and taken) at most
79
+ AUTH_SCHEME = "v2"
80
+ PROTOCOL = 2 # a beacon's "v": 2 = the node signs its requests (older: 1)
81
+ CLOCK_WINDOW_S = 60.0 # a signed request's time may differ this much from ours
82
+ NONCES_KEPT = 10000 # signed requests remembered against a replay, at most
83
+ KEY_OVERLAP_S = 7 * 86400 # a replaced key stays accepted this long (a planned change)
84
+ BOOT_SKEW_S = 300.0 # a new run of a node may be dated this much before its last beacon
85
+ NOTE_TTL = 30.0 # how long a node seen with another key / this node's id is shown
86
+ LEGACY_SEEN_S = 600.0 # how long a node that sent the raw key counts as using it
87
+ _FIELD = re.compile(r"[A-Za-z0-9_.-]{0,128}")
88
+ _NODE_ID = re.compile(r"[A-Za-z0-9_.-]{1,64}")
89
+ _TIME = re.compile(r"\d{1,12}(\.\d{1,6})?")
90
+ _NONCE = re.compile(r"[0-9a-f]{16,64}")
91
+
92
+ # Why a peer-facing request was refused (PeerAuth.reason), as the log puts it.
93
+ REFUSALS = {
94
+ "off": "the cluster is off on this node",
95
+ "missing": "no cluster credentials",
96
+ "malformed": "a malformed X-Cluster-Auth header",
97
+ "key": "a wrong cluster key",
98
+ "signature": "a signature that doesn't match this node's cluster key (another key?)",
99
+ "legacy": "the cluster key unsigned (a node running 2.8.0 or older), and legacy authentication is off",
100
+ "replay": "a signed request that was already used",
101
+ }
102
+
103
+ # Why requests to a peer fail (the fleet view explains each code), as the log puts it.
104
+ _PROBLEMS = {
105
+ "off": "the cluster is off on this node",
106
+ "key": "it refused this node's cluster key",
107
+ "cluster-off": "its cluster is off",
108
+ "legacy-off": ("it is a node running 2.8.0 or older (or answers like one), which only "
109
+ "knows the unsigned key, and legacy authentication is off on this node"),
110
+ "manual-only": ("it is a node running 2.8.0 or older that doesn't advertise its LAN "
111
+ "address; the unsigned key only goes to an address a node advertises or "
112
+ "to a manual peer, so add it as a manual peer to manage it from here"),
113
+ "unsigned": "its reply is not signed with the cluster key",
114
+ "clock": "its clock and this node's could not be matched",
115
+ }
116
+
117
+
118
+ def key_bytes(key: str) -> bytes:
119
+ """The cluster key as every check uses it - the beacon HMAC and the credential sent
120
+ with peer requests: Unicode NFKC-normalised, then UTF-8. NFKC makes the same key
121
+ typed or pasted on different systems match (a composed or decomposed letter, a
122
+ no-break space); an ASCII key is unchanged, so its bytes are those every release
123
+ has used."""
124
+ return unicodedata.normalize("NFKC", key or "").encode("utf-8", "surrogatepass")
125
+
126
+
127
+ def key_header(key: str) -> str:
128
+ """The X-Cluster-Key header value for *key*: key_bytes as latin-1 text, which
129
+ http.client sends as exactly those bytes (an ASCII key goes out unchanged)."""
130
+ return key_bytes(key).decode("latin-1")
131
+
132
+
133
+ def key_header_matches(key: str, header: str | None) -> bool:
134
+ """Whether an X-Cluster-Key header carries *key*. The server decodes header bytes
135
+ as latin-1, so encoding back gives the bytes that were sent; they are compared with
136
+ key_bytes(key) as bytes (a text comparison fails on a non-ASCII key)."""
137
+ want = key_bytes(key)
138
+ try:
139
+ got = (header or "").encode("latin-1")
140
+ except UnicodeEncodeError: # not text from a header
141
+ return False
142
+ return bool(want and got) and hmac.compare_digest(got, want)
143
+
144
+
145
+ def _sign(key: str, body: str) -> str:
146
+ return hmac.new(key_bytes(key), body.encode("utf-8"), hashlib.sha256).hexdigest()
147
+
148
+
149
+ def auth_key(key: str) -> bytes:
150
+ """The key peer requests and their replies are signed with. Derived from key_bytes,
151
+ so it is neither the key beacons are signed with nor what older releases send."""
152
+ return hmac.new(key_bytes(key), b"ser2net cluster auth v2", hashlib.sha256).digest()
153
+
154
+
155
+ def wire_id(instance_id) -> str:
156
+ """A node id as X-Cluster-Auth carries it: the id itself (a generated one is hex),
157
+ or a digest of an odd hand-edited one."""
158
+ s = str(instance_id or "")
159
+ if _NODE_ID.fullmatch(s):
160
+ return s
161
+ return hashlib.sha256(s.encode("utf-8", "surrogatepass")).hexdigest()[:16]
162
+
163
+
164
+ def canonical_target(path: str, query: str) -> str:
165
+ """A request's target as it is signed: the decoded path, plus the query decoded and
166
+ encoded again - so what the sender built and what the receiving server sees match."""
167
+ q = urllib.parse.urlencode(urllib.parse.parse_qsl(query or "", keep_blank_values=True))
168
+ return f"{path}?{q}" if q else path
169
+
170
+
171
+ def _mac(key: str, *fields) -> str:
172
+ msg = "\n".join(str(f) for f in fields).encode("utf-8", "surrogatepass")
173
+ return hmac.new(auth_key(key), msg, hashlib.sha256).hexdigest()
174
+
175
+
176
+ def request_mac(key: str, method: str, target: str, body: bytes | None, sender: str,
177
+ recipient: str, ts: str, nonce: str) -> str:
178
+ """The signature ("sig") of a peer request."""
179
+ return _mac(key, "ser2net-cluster-request", method.upper(), target,
180
+ hashlib.sha256(body or b"").hexdigest(), sender, recipient, ts, nonce)
181
+
182
+
183
+ def reply_mac(key: str, nonce: str, status: int, body: bytes | None, responder: str,
184
+ now: str) -> str:
185
+ """The signature of the reply to the signed request *nonce*."""
186
+ return _mac(key, "ser2net-cluster-reply", nonce, int(status),
187
+ hashlib.sha256(body or b"").hexdigest(), responder, now)
188
+
189
+
190
+ def format_auth(params: dict) -> str:
191
+ return f"{AUTH_SCHEME} " + ",".join(f"{k}={v}" for k, v in params.items())
192
+
193
+
194
+ def parse_auth(value) -> dict | None:
195
+ """The parameters of an X-Cluster-Auth value ("v2 k=v,k=v"; a bare "v2" only says
196
+ that its sender understands signed requests), or None."""
197
+ scheme, _sep, rest = str(value or "").strip().partition(" ")
198
+ if scheme != AUTH_SCHEME:
199
+ return None
200
+ out: dict[str, str] = {}
201
+ for part in rest.split(",") if rest.strip() else ():
202
+ k, eq, v = part.strip().partition("=")
203
+ if not eq or k in out or not k or not _FIELD.fullmatch(k) or not _FIELD.fullmatch(v):
204
+ return None
205
+ out[k] = v
206
+ return out
207
+
208
+
209
+ def _remember(d: dict, k, v, limit: int) -> None:
210
+ """d[k] = v as d's newest entry; the oldest ones go past *limit*."""
211
+ d.pop(k, None)
212
+ d[k] = v
213
+ while len(d) > limit:
214
+ d.pop(next(iter(d)))
215
+
216
+
217
+ def peer_port(value) -> int | None:
218
+ """value as a TCP port (1..65535), else None."""
219
+ if isinstance(value, bool):
220
+ return None
221
+ try:
222
+ port = int(value)
223
+ except (TypeError, ValueError):
224
+ return None
225
+ return port if 1 <= port <= 65535 else None
226
+
227
+
228
+ def peer_url(scheme: str, host: str, port, path: str = "/", query: dict | None = None) -> str:
229
+ """The URL of *path* on a peer's web UI. ValueError unless the scheme is http or
230
+ https, the host an IP address or host name and the port 1..65535, so no value can
231
+ point the URL (and the request sent with it) at another host. An IPv6 host is put
232
+ in brackets and the query is encoded."""
233
+ h, p = peer_host(host), peer_port(port)
234
+ if scheme not in ("http", "https") or h is None or p is None or not path.startswith("/"):
235
+ raise ValueError(f"not a cluster peer address: {scheme!r}, {host!r}, {port!r}")
236
+ url = f"{scheme}://[{h}]:{p}{path}" if ":" in h else f"{scheme}://{h}:{p}{path}"
237
+ return f"{url}?{urllib.parse.urlencode(query)}" if query else url
238
+
239
+
240
+ def peer_addr(host, port) -> str:
241
+ """host:port as the UI and the logs show it (an IPv6 address in brackets)."""
242
+ return f"[{host}]:{port}" if ":" in str(host) else f"{host}:{port}"
243
+
244
+
245
+ def _target_key(t: dict) -> tuple:
246
+ return t.get("scheme"), t.get("ip"), t.get("port")
247
+
248
+
249
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
250
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
251
+ return None # following it would send the request on to wherever it points
252
+
253
+
254
+ def _peer_opener() -> urllib.request.OpenerDirector:
255
+ """The urllib opener for peer requests. Always a direct connection: a proxy from
256
+ HTTP(S)_PROXY / ALL_PROXY (or the Windows proxy settings), set so that webhooks or
257
+ SSO can reach the Internet, would see every peer request (and an older peer's raw
258
+ key) - and NO_PROXY can't name a whole LAN. Redirects are not followed. TLS is not
259
+ verified: cluster trust is the shared key (requests and replies are signed with
260
+ it), not the certificate (self-signed is normal)."""
261
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
262
+ ctx.check_hostname = False
263
+ ctx.verify_mode = ssl.CERT_NONE
264
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}),
265
+ urllib.request.HTTPSHandler(context=ctx), _NoRedirect())
266
+
267
+
268
+ class _Nonces:
269
+ """The nonces of signed requests accepted recently, so none is accepted twice. A
270
+ nonce is kept while its request could still pass the time check; when the store is
271
+ full of such nonces, the oldest goes and no request dated at or before it is
272
+ accepted any more (it fails closed, never open)."""
273
+
274
+ def __init__(self, limit: int = NONCES_KEPT):
275
+ self.limit = limit
276
+ self.floor = 0.0
277
+ self._seen: dict[str, float] = {} # nonce -> request time, oldest first
278
+
279
+ def add(self, nonce: str, ts: float, now: float) -> bool:
280
+ """Record a request's nonce; False when it was seen before (or may have been)."""
281
+ if ts <= self.floor or nonce in self._seen:
282
+ return False
283
+ if len(self._seen) >= self.limit:
284
+ cutoff = now - CLOCK_WINDOW_S
285
+ self._seen = {n: t for n, t in self._seen.items() if t >= cutoff}
286
+ while len(self._seen) >= self.limit:
287
+ self.floor = max(self.floor, self._seen.pop(next(iter(self._seen))))
288
+ if ts <= self.floor:
289
+ return False
290
+ self._seen[nonce] = ts
291
+ return True
292
+
293
+
294
+ @dataclasses.dataclass
295
+ class PeerAuth:
296
+ """What ClusterDiscovery.check_request found out about a peer-facing request."""
297
+ ok: bool = False
298
+ reason: str = "" # why it was refused: a REFUSALS key, or "recipient" / "stale" (a
299
+ # signed request for another node id, or dated too far from now)
300
+ legacy: bool = False # it carried the raw key (a node running 2.8.0 or older)
301
+ key: str = "" # the key it was signed with: the reply is signed with it too
302
+ nonce: str = "" # its nonce, which a signed reply names
303
+ sender: str = "" # the node id it says it comes from (signed)
304
+ first: bool = False # the first refusal of this kind (or raw-key use) from that address
305
+ again: bool = False # that address was refused before: accepted again
306
+
307
+
308
+ @dataclasses.dataclass
309
+ class _Link:
310
+ """What this node knows about calling one peer address (a fetch target)."""
311
+ id: str = "" # the peer's node id as X-Cluster-Auth names it, once known
312
+ legacy: bool = False # it refused a signed request the way releases up to 2.8.0 do
313
+ key: int = 0 # which of this node's keys it accepts (0 the key, 1 the previous)
314
+ offset: float | None = None # its clock minus this node's, from a signed reply
315
+ auth: str = "" # how its last answered request authenticated: "v2" / "legacy"
316
+ problem: str = "" # why its last request failed: a _PROBLEMS key or "offline"/"error"
317
+
318
+
319
+ @dataclasses.dataclass
320
+ class _Answer:
321
+ """One peer request's outcome (made in a worker thread, interpreted on the loop)."""
322
+ status: int = 0 # the HTTP status, 0 when nothing (usable) answered
323
+ data: dict | None = None # the reply's JSON object
324
+ problem: str = "" # what went wrong, for the log ("" = a cluster node answered)
325
+ v2: bool = False # the reply says the peer understands signed requests
326
+ signed: bool = False # the reply is signed for this request with this node's key
327
+ reason: str = "" # a refusal's reason (its JSON "reason")
328
+ peer_id: str = "" # the peer's node id, from a signed reply
329
+ offset: float | None = None # the peer's clock minus this node's, from a signed reply
330
+
331
+
332
+ class _BeaconProtocol(asyncio.DatagramProtocol):
333
+ def __init__(self, on_datagram):
334
+ self._on_datagram = on_datagram
335
+
336
+ def datagram_received(self, data, addr):
337
+ self._on_datagram(data, addr)
338
+
339
+ def error_received(self, exc): # ICMP port-unreachable etc. — ignore
340
+ pass
341
+
342
+
343
+ class ClusterDiscovery:
344
+ """Owns the UDP discovery socket + beacon loop + live peer table, signs this node's
345
+ peer requests and checks the ones it receives. Reads the cluster config live from
346
+ AppConfig so a settings change is picked up on the next restart (start/stop are
347
+ driven by AppState's engine lifecycle)."""
348
+
349
+ def __init__(self, config, logger):
350
+ self.config = config # AppConfig (cluster + admin_ui + instance_id, read live)
351
+ self.log = logger
352
+ self.boot = secrets.token_hex(8) # this process: tells a copy of this node from itself
353
+ self.started = time.time() # (wall clock: compared with the times peers send)
354
+ self._transport = None
355
+ self._beacon_task: asyncio.Task | None = None
356
+ # (id, run) -> {id,name,ip,port,scheme,last_seen,...}. How old a row or a note below
357
+ # is goes by time.monotonic(): a step of the system clock (NTP after a boot without
358
+ # an RTC) neither keeps a node that left listed nor drops one that is there
359
+ self._peers: dict[tuple, dict] = {}
360
+ self._ids: dict[str, dict] = {} # peer id -> the runs (processes) heard with it
361
+ self._opener = _peer_opener()
362
+ self._problems: dict[str, str] = {} # peer host:port -> why its last request failed
363
+ self._executor: concurrent.futures.ThreadPoolExecutor | None = None
364
+ self._fetched: dict[tuple, tuple[float, dict | None]] = {} # target -> (when, answer)
365
+ self._fetching: dict[tuple, asyncio.Task] = {} # target -> request running
366
+ self._links: dict[tuple, _Link] = {} # target -> what calling it taught us
367
+ self._signed_ok: dict[tuple, float] = {} # targets that answered a signed request:
368
+ # never sent the raw key (kept on restarts)
369
+ self._nonces = _Nonces() # also kept across restarts of discovery
370
+ self._other_key: dict[str, float] = {} # address -> last beacon with another key
371
+ self._copies: dict[str, dict] = {} # address -> a node with this node's id
372
+ self._legacy_in: dict[str, float] = {} # address -> when it last sent the raw key
373
+ self._refusals: dict[str, str] = {} # address -> why its last request was refused
374
+ self._running = False
375
+ self.discovery_error = "" # non-empty when UDP discovery couldn't start
376
+ # () -> (bind IP, port, TLS) the admin UI serves now (AppState.admin_serving; the
377
+ # address in config.json may wait for a restart); None: admin_ui as configured
378
+ self.serving: Callable[[], tuple[str, int, bool]] | None = None
379
+
380
+ # ----- config helpers -----
381
+ @property
382
+ def cluster(self):
383
+ return self.config.cluster
384
+
385
+ def keys(self) -> list[str]:
386
+ """The keys this node accepts and signs with: the cluster key, then the previous
387
+ one while a planned key change still accepts it (ClusterSettings.previous_key)."""
388
+ c = self.cluster
389
+ out = [c.key] if c.key.strip() else []
390
+ prev = c.previous_key
391
+ if out and prev.strip() and key_bytes(prev) != key_bytes(c.key) and (
392
+ not c.previous_until or time.time() < c.previous_until):
393
+ out.append(prev)
394
+ return out
395
+
396
+ def advertised(self) -> tuple[str, int, str]:
397
+ """(ip, port, scheme) other nodes use to reach THIS node's web UI."""
398
+ c = self.cluster
399
+ ui = self.config.admin_ui
400
+ bind_ip, port, tls = (self.serving() if self.serving is not None
401
+ else (ui.bind_ip, ui.port, ui.tls_enabled))
402
+ ip = c.advertise_ip.strip()
403
+ if not ip:
404
+ bind = (bind_ip or "").strip()
405
+ if bind not in _LOOPBACK:
406
+ ip = bind
407
+ else:
408
+ # no LAN address known (no default route): 127.0.0.1 tells receivers to use
409
+ # the address the beacon came from - on each network the right one
410
+ ip = netinfo.primary_lan_ip() or "127.0.0.1"
411
+ scheme = "https" if tls else "http"
412
+ return ip, int(port), scheme
413
+
414
+ # ----- beacon (de)serialization -----
415
+ def _beacon_body(self) -> str:
416
+ ip, port, scheme = self.advertised()
417
+ # "name": the instance's name (Settings -> Admin interface), else the host name
418
+ payload = {"v": PROTOCOL, "id": self.config.instance_id,
419
+ "name": node_label(self.config.admin_ui),
420
+ "ip": ip, "port": port, "scheme": scheme, "t": round(time.time(), 3),
421
+ "b": self.boot, # this process (see _accept / _same_id)
422
+ "ips": netinfo.own_ipv4()[:32]} # where its packets may come from
423
+ return json.dumps(payload, separators=(",", ":"), sort_keys=True)
424
+
425
+ def _make_beacon(self, key: str | None = None, body: str | None = None) -> bytes:
426
+ body = self._beacon_body() if body is None else body
427
+ sig = _sign(self.cluster.key if key is None else key, body)
428
+ return json.dumps({"d": body, "s": sig}).encode("utf-8")
429
+
430
+ def handle_datagram(self, data: bytes, addr) -> None:
431
+ """Verify a received beacon and record/refresh the peer. Pure (no I/O) so it
432
+ can be called directly from tests to inject a peer. Any host on the LAN can
433
+ send here, so anything malformed is dropped without an error."""
434
+ keys = self.keys()
435
+ if not keys:
436
+ return
437
+ try:
438
+ env = json.loads(data.decode("utf-8"))
439
+ body, sig = env["d"], env["s"]
440
+ if not (isinstance(body, str) and isinstance(sig, str) and sig.isascii()):
441
+ return
442
+ kidx = next((i for i, k in enumerate(keys)
443
+ if hmac.compare_digest(_sign(k, body), sig)), None)
444
+ p = json.loads(body)
445
+ except (ValueError, KeyError, AttributeError, TypeError, RecursionError):
446
+ return # (a UnicodeError is a ValueError)
447
+ if not isinstance(p, dict) or not p.get("id"):
448
+ return
449
+ src = str(addr[0]) if addr else ""
450
+ if kidx is None: # a different key (or tampered): not part of our cluster - only shown
451
+ if src and str(p.get("b") or "") != self.boot:
452
+ _remember(self._other_key, src, time.monotonic(), 16)
453
+ return
454
+ raw_t = p.get("t") # every release since 2.5 dates its beacons
455
+ if isinstance(raw_t, bool) or not isinstance(raw_t, (int, float)):
456
+ return
457
+ t = float(raw_t)
458
+ v: int = p["v"] if type(p.get("v")) is int else 1
459
+ boot = str(p.get("b") or "")[:64] if v >= PROTOCOL else ""
460
+ if not math.isfinite(t) or (boot and boot == self.boot):
461
+ return # (our own beacon - also one sent before this node took a new id)
462
+ pid = str(p["id"])
463
+ if pid == self.config.instance_id:
464
+ self._same_id(p, addr, t, v)
465
+ return
466
+ ip, from_packet = self._beacon_address(p, addr, v)
467
+ port = peer_port(p.get("port"))
468
+ if ip is None or port is None:
469
+ return # no web-UI address to reach it at
470
+ row = self._accept(pid, boot, t)
471
+ if row is None:
472
+ return # not newer than what this node has from that sender: a replay
473
+ scheme = p.get("scheme") if p.get("scheme") in ("http", "https") else "http"
474
+ self._peers[row] = {
475
+ "id": pid, "name": str(p.get("name", "?")), "ip": ip,
476
+ "port": port, "scheme": scheme, "last_seen": time.monotonic(),
477
+ "v": v, "key": kidx, "offset": t - time.time(), "from_packet": from_packet,
478
+ }
479
+ self._other_key.pop(src, None)
480
+
481
+ @staticmethod
482
+ def _beacon_address(p: dict, addr, v: int = 1) -> tuple[str | None, bool]:
483
+ """Where a beacon's sender is reached, and whether that is the packet's source: the
484
+ IP address it advertises or, when that is no usable address (loopback or wildcard:
485
+ the node could not tell its LAN IP), the packet's source address - for a sender that
486
+ lists its own addresses (newer than 2.8.0), only one of those. Never a host
487
+ name from the beacon."""
488
+ try:
489
+ ip = ipaddress.ip_address(str(p.get("ip") or "").strip())
490
+ except ValueError:
491
+ ip = None
492
+ if ip is not None and not (ip.is_loopback or ip.is_unspecified):
493
+ return str(ip), False
494
+ if not addr:
495
+ return None, False
496
+ src, own = str(addr[0]), p.get("ips") if v >= PROTOCOL else None
497
+ if isinstance(own, list) and own and src not in {str(a) for a in own}:
498
+ return None, False # sent from an address that node doesn't have
499
+ return src, True
500
+
501
+ def _accept(self, pid: str, boot: str, t: float) -> tuple | None:
502
+ """The peer-table row for a verified beacon of node *pid*, from its run (process)
503
+ *boot*, dated *t* - or None when it is not newer than the last beacon of that run,
504
+ or a run dated well before the latest (an earlier run, replayed). A new run (a
505
+ restart) replaces the earlier run's row, unless earlier runs keep beaconing too:
506
+ then several nodes share the id (copies of one installation) and each has a row.
507
+ Releases up to 2.8.0 send no run id: their beacons count as one run. After nothing
508
+ was accepted from a node for longer than PEER_TTL (its row is gone), what was
509
+ stored for it is forgotten: that is a restart, which may have set its clock back
510
+ (no RTC, NTP not synced yet), not a replay - and a replayed beacon steers no
511
+ request any more (signed requests; the raw key never goes to a packet's source)."""
512
+ now = time.monotonic() # rec's "seen" and "shared"; its times (t) are the sender's
513
+ rec = self._ids.get(pid)
514
+ if rec is not None and now - rec["seen"] > PEER_TTL:
515
+ del self._ids[pid]
516
+ rec = None
517
+ if rec is None:
518
+ if len(self._ids) >= 512: # ids come and go: keep the ones heard from last
519
+ for gone in sorted(self._ids, key=lambda i: self._ids[i]["seen"])[:256]:
520
+ del self._ids[gone]
521
+ rec = self._ids[pid] = {"runs": {}, "max_t": t, "cur": boot,
522
+ "shared": -math.inf, "seen": now}
523
+ last = rec["runs"].get(boot)
524
+ if last is not None and t <= last:
525
+ return None
526
+ if last is None and t <= rec["max_t"] - BOOT_SKEW_S:
527
+ return None
528
+ if last is not None and boot and rec["cur"] and boot != rec["cur"]:
529
+ rec["shared"] = now # an earlier run beacons again: two nodes use this id
530
+ rec["runs"][boot] = t
531
+ if len(rec["runs"]) > 4:
532
+ del rec["runs"][min(rec["runs"], key=rec["runs"].get)]
533
+ new_run = boot != rec["cur"]
534
+ rec.update(max_t=max(rec["max_t"], t), cur=boot, seen=now)
535
+ if new_run and now - rec["shared"] > PEER_TTL:
536
+ for k in [k for k in self._peers if k[0] == pid and k[1] != boot]:
537
+ del self._peers[k]
538
+ return (pid, boot)
539
+
540
+ def _same_id(self, p: dict, addr, t: float, v: int) -> None:
541
+ """A verified beacon with this node's own id, from another process: a copy of this
542
+ installation (its data folder, disk or VM image) runs elsewhere - unless it was sent
543
+ before this node started (an earlier run of this node, replayed)."""
544
+ if t < self.started - 5:
545
+ return
546
+ ip, _from_packet = self._beacon_address(p, addr, v)
547
+ where = peer_addr(ip or (addr[0] if addr else "?"), peer_port(p.get("port")) or "?")
548
+ name = str(p.get("name", "?"))[:255]
549
+ known = where in self._copies
550
+ _remember(self._copies, where, {"addr": where, "name": name,
551
+ "last_seen": time.monotonic()}, 16)
552
+ if not known:
553
+ self.log(f"cluster: another node uses this node's identity "
554
+ f"({self.config.instance_id[:8]}): '{name}' at {where} — a copy of this "
555
+ "installation (data folder, disk or VM image)? Give one of them a new "
556
+ "identity under Settings → LAN cluster")
557
+
558
+ def peers(self) -> list[dict]:
559
+ """Live peers (TTL-pruned), sorted by name then ip."""
560
+ now = time.monotonic()
561
+ self._peers = {k: p for k, p in self._peers.items() if now - p["last_seen"] <= PEER_TTL}
562
+ return sorted(self._peers.values(), key=lambda p: (p["name"].lower(), p["ip"]))
563
+
564
+ def manual_targets(self) -> list[dict]:
565
+ """Configured manual peers (for routed/L3 networks broadcast can't reach),
566
+ parsed into fetch targets. Bad entries are skipped (validate() rejects them
567
+ at save time, so this only guards a hand-edited config)."""
568
+ out = []
569
+ for entry in self.cluster.peers:
570
+ try:
571
+ scheme, host, port = parse_cluster_peer(entry)
572
+ except Exception:
573
+ continue
574
+ out.append({"scheme": scheme, "ip": peer_host(host) or host, "port": port,
575
+ "source": "manual"})
576
+ return out
577
+
578
+ def all_targets(self) -> list[dict]:
579
+ """Auto-discovered peers + manual peers, deduped by (ip, port). When a manual
580
+ peer is also auto-discovered the richer auto entry wins, marked "configured" (and
581
+ of two auto entries at one address, e.g. a node that just took a new id, the one
582
+ heard from last)."""
583
+ live, manual = self.peers(), self.manual_targets()
584
+ configured = {(m["ip"], m["port"]) for m in manual}
585
+ newest: dict[tuple, dict] = {}
586
+ for p in live:
587
+ k = (p["ip"], p["port"])
588
+ if k not in newest or p["last_seen"] > newest[k]["last_seen"]:
589
+ newest[k] = p
590
+ seen, targets = set(), []
591
+ for p in live:
592
+ k = (p["ip"], p["port"])
593
+ if newest[k] is not p:
594
+ continue
595
+ seen.add(k)
596
+ targets.append({**p, "source": "auto", "configured": k in configured})
597
+ for m in manual:
598
+ if (m["ip"], m["port"]) not in seen:
599
+ seen.add((m["ip"], m["port"]))
600
+ targets.append(m)
601
+ return targets
602
+
603
+ def find_target(self, host, port) -> dict | None:
604
+ """The discovered or configured peer at host:port (as the fleet view shows it),
605
+ else None. Browser-initiated peer calls are resolved through this, so their
606
+ address and scheme never come from the request."""
607
+ h, p = peer_host(host), peer_port(port)
608
+ if h is None or p is None:
609
+ return None
610
+ return next((t for t in self.all_targets() if t["ip"] == h and t["port"] == p), None)
611
+
612
+ # ----- what the fleet view and Settings show -----
613
+ def peer_state(self, target: dict) -> dict:
614
+ """How this node fares with a peer: "auth" ("v2" / "legacy" once a request was
615
+ answered), "problem" (why the last one failed: a code the fleet view explains)
616
+ and "previous_key" (it accepts only the previous key: not changed yet)."""
617
+ link = self._links.get(_target_key(target)) or _Link()
618
+ idx = (target.get("key") or 0) if target.get("source") == "auto" else link.key
619
+ return {"auth": link.auth, "problem": link.problem,
620
+ "previous_key": idx == 1 and len(self.keys()) > 1}
621
+
622
+ def copies(self) -> list[dict]:
623
+ """Nodes heard recently with this node's own id ({addr, name}): copies of it."""
624
+ now = time.monotonic()
625
+ return [c for c in self._copies.values() if now - c["last_seen"] <= NOTE_TTL]
626
+
627
+ def shared_ids(self) -> list[dict]:
628
+ """Peer ids that several live nodes use (copies of one installation)."""
629
+ rows: dict[str, list] = {}
630
+ for p in self.peers():
631
+ rows.setdefault(p["id"], []).append(p)
632
+ return [{"id": pid, "name": r[0]["name"],
633
+ "addrs": sorted({peer_addr(x["ip"], x["port"]) for x in r})}
634
+ for pid, r in rows.items() if len(r) > 1]
635
+
636
+ def other_key_nodes(self) -> list[str]:
637
+ """Addresses that recently broadcast beacons signed with another key (never
638
+ contacted: there is nothing to trust them with)."""
639
+ now = time.monotonic()
640
+ live = {p["ip"] for p in self.peers()}
641
+ return sorted(ip for ip, when in self._other_key.items()
642
+ if now - when <= NOTE_TTL and ip not in live)
643
+
644
+ def legacy_nodes(self) -> list[str]:
645
+ """Nodes using the unsigned key (releases up to 2.8.0): those this node sends it to
646
+ and those that sent it here recently - none while legacy authentication is off."""
647
+ if not self.cluster.legacy_auth:
648
+ return []
649
+ now, out = time.monotonic(), set()
650
+ for ip, when in self._legacy_in.items():
651
+ if now - when <= LEGACY_SEEN_S:
652
+ out.add(ip)
653
+ for t in self.all_targets():
654
+ link = self._links.get(_target_key(t))
655
+ if (t["source"] == "auto" and t.get("v", 1) < PROTOCOL) or (link and link.auth == "legacy"):
656
+ out.add(peer_addr(t["ip"], t["port"]))
657
+ return sorted(out)
658
+
659
+ # ----- lifecycle -----
660
+ async def start(self) -> None:
661
+ if self._running or not self.cluster.active:
662
+ return
663
+ if not self.cluster.discovery: # manual peers only: no beacons sent or read
664
+ self.discovery_error = ""
665
+ self._running = True
666
+ self.log(f"cluster: on, auto-discovery off (manual peers only) as "
667
+ f"'{node_label(self.config.admin_ui)}' ({self.config.instance_id[:8]})")
668
+ return
669
+ loop = asyncio.get_running_loop()
670
+ try: # validate() rejects a bad one at save time; this guards a hand-edited config
671
+ port = int(self.cluster.discovery_port)
672
+ if not 1 <= port <= 65535:
673
+ raise ValueError
674
+ except (TypeError, ValueError):
675
+ raw = self.cluster.discovery_port
676
+ self.discovery_error = f"UDP discovery port {raw!r} is not a port number"
677
+ self.log(f"cluster: discovery port {raw!r} is not a port number — discovery "
678
+ "disabled (manual peers still work)")
679
+ return
680
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
681
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
682
+ reuse_port = getattr(socket, "SO_REUSEPORT", None) # not on Windows
683
+ if reuse_port is not None:
684
+ with contextlib.suppress(OSError):
685
+ sock.setsockopt(socket.SOL_SOCKET, reuse_port, 1)
686
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
687
+ try:
688
+ sock.bind(("", port))
689
+ except OSError as e:
690
+ sock.close()
691
+ self.discovery_error = f"UDP discovery port {port} unavailable: {e}"
692
+ self.log(f"cluster: could not bind UDP {port}: {e} — discovery disabled "
693
+ "(manual peers still work)")
694
+ return
695
+ try:
696
+ self._transport, _ = await loop.create_datagram_endpoint(
697
+ lambda: _BeaconProtocol(self.handle_datagram), sock=sock)
698
+ except OSError as e:
699
+ sock.close() # endpoint creation owns the socket only on success
700
+ self.discovery_error = f"UDP listener on {port} failed: {e}"
701
+ self.log(f"cluster: could not start UDP listener on {port}: {e} — discovery disabled")
702
+ return
703
+ self.discovery_error = ""
704
+ self._running = True
705
+ self._beacon_task = asyncio.create_task(self._beacon_loop(), name="cluster-beacon")
706
+ self.log(f"cluster: discovery on UDP {port} as '{node_label(self.config.admin_ui)}' "
707
+ f"({self.config.instance_id[:8]})")
708
+
709
+ async def stop(self) -> None:
710
+ self._running = False
711
+ if self._beacon_task is not None:
712
+ self._beacon_task.cancel()
713
+ with contextlib.suppress(asyncio.CancelledError):
714
+ await self._beacon_task
715
+ self._beacon_task = None
716
+ if self._transport is not None:
717
+ with contextlib.suppress(Exception):
718
+ self._transport.close()
719
+ self._transport = None
720
+ self._peers.clear() # (not _ids: old beacons must stay old after a settings save)
721
+ self._problems.clear()
722
+ self._links.clear() # the keys may have changed (what a peer signed up for has not)
723
+ self._other_key.clear()
724
+ self._copies.clear()
725
+ self.forget()
726
+ if self._executor is not None: # requests still running finish on their own
727
+ self._executor.shutdown(wait=False)
728
+ self._executor = None
729
+
730
+ async def _beacon_loop(self) -> None:
731
+ while True:
732
+ try:
733
+ self._broadcast()
734
+ except Exception as e: # never let the loop die on a transient send error
735
+ self.log(f"cluster: beacon send failed: {e}")
736
+ await asyncio.sleep(BEACON_INTERVAL)
737
+
738
+ def _broadcast(self) -> None:
739
+ if self._transport is None:
740
+ return
741
+ body = self._beacon_body()
742
+ # one beacon per key: during a planned key change the nodes that only have the
743
+ # previous key still see this node
744
+ msgs = [self._make_beacon(k, body) for k in self.keys()]
745
+ port = int(self.cluster.discovery_port)
746
+ for addr in self._broadcast_addrs():
747
+ for msg in msgs:
748
+ with contextlib.suppress(OSError):
749
+ self._transport.sendto(msg, (addr, port))
750
+
751
+ @staticmethod
752
+ def _broadcast_addrs() -> list[str]:
753
+ """Global broadcast plus each NIC's directed broadcast (some networks only
754
+ deliver the subnet-directed form). Best-effort; psutil is optional."""
755
+ addrs = ["255.255.255.255"]
756
+ try:
757
+ import psutil
758
+ for nic in psutil.net_if_addrs().values():
759
+ for a in nic:
760
+ if getattr(a, "family", None) == socket.AF_INET and getattr(a, "broadcast", None):
761
+ addrs.append(a.broadcast)
762
+ except Exception:
763
+ pass
764
+ seen, out = set(), []
765
+ for a in addrs:
766
+ if a and a not in seen:
767
+ seen.add(a)
768
+ out.append(a)
769
+ return out
770
+
771
+ # ----- peer-facing requests this node receives -----
772
+ def check_request(self, method: str, path: str, query: str, body: bytes, headers,
773
+ client: str) -> PeerAuth:
774
+ """Whether a request to a peer-facing endpoint may proceed: a signed request (see
775
+ the module docstring) for this node, or - while legacy_auth is on - the raw key of
776
+ a node running 2.8.0 or older. *path* is the decoded path, *query* the raw query string,
777
+ *client* the caller's address; refusals and raw-key use are tallied per address
778
+ (PeerAuth.first / .again), so the caller logs each change once."""
779
+ if not self.cluster.active:
780
+ auth = PeerAuth(reason="off")
781
+ elif headers.get(AUTH_HEADER) is not None:
782
+ auth = self._check_signed(method, canonical_target(path, query), body,
783
+ headers.get(AUTH_HEADER))
784
+ elif headers.get(KEY_HEADER) is not None:
785
+ key = next((k for k in self.keys() if key_header_matches(k, headers.get(KEY_HEADER))),
786
+ None)
787
+ if key is None:
788
+ auth = PeerAuth(reason="key")
789
+ elif not self.cluster.legacy_auth:
790
+ auth = PeerAuth(reason="legacy")
791
+ else:
792
+ auth = PeerAuth(ok=True, legacy=True, key=key)
793
+ else:
794
+ auth = PeerAuth(reason="missing")
795
+ if auth.ok:
796
+ auth.again = self._refusals.pop(client, None) is not None
797
+ if auth.legacy:
798
+ auth.first = client not in self._legacy_in
799
+ _remember(self._legacy_in, client, time.monotonic(), 64)
800
+ elif auth.reason not in ("recipient", "stale"): # steps of the exchange, not failures
801
+ auth.first = self._refusals.get(client) != auth.reason
802
+ _remember(self._refusals, client, auth.reason, 256)
803
+ return auth
804
+
805
+ def _check_signed(self, method: str, target: str, body: bytes, value) -> PeerAuth:
806
+ p = parse_auth(value)
807
+ if p is None or "to" not in p or not _TIME.fullmatch(p.get("ts", "")) \
808
+ or not _NONCE.fullmatch(p.get("nonce", "")) or not p.get("sig"):
809
+ return PeerAuth(reason="malformed")
810
+ sender, to, ts, nonce = p.get("from", ""), p["to"], p["ts"], p["nonce"]
811
+ key = next((k for k in self.keys() if hmac.compare_digest(
812
+ request_mac(k, method, target, body, sender, to, ts, nonce), p["sig"])), None)
813
+ if key is None:
814
+ return PeerAuth(reason="signature")
815
+ # from here on the request is from a key holder: a refusal is signed, so the sender
816
+ # can trust what it says (this node's id, its clock) and send the request again
817
+ auth = PeerAuth(key=key, nonce=nonce, sender=sender)
818
+ now, when = time.time(), float(ts)
819
+ if to != wire_id(self.config.instance_id):
820
+ auth.reason = "recipient" # for another node, or sent before our id was known
821
+ elif abs(when - now) > CLOCK_WINDOW_S or when < self.started - 2:
822
+ auth.reason = "stale" # (a request dated before this process started may
823
+ # have been accepted by the one before it)
824
+ elif not self._nonces.add(nonce, when, now):
825
+ return PeerAuth(reason="replay")
826
+ else:
827
+ auth.ok = True
828
+ return auth
829
+
830
+ def reply_auth(self, auth: PeerAuth | None, status: int, body: bytes) -> str:
831
+ """The X-Cluster-Auth value of a peer-facing reply. Signed for a signed request
832
+ (also a refusal of one whose signature was valid); else just "v2", which tells the
833
+ caller that this node understands signed requests."""
834
+ if auth is None or auth.legacy or not (auth.key and auth.nonce):
835
+ return AUTH_SCHEME
836
+ me, now = wire_id(self.config.instance_id), f"{time.time():.3f}"
837
+ return format_auth({"id": me, "now": now,
838
+ "sig": reply_mac(auth.key, auth.nonce, status, body, me, now)})
839
+
840
+ # ----- peer HTTP (signed; the raw key only for nodes running 2.8.0 or older) -----
841
+ async def get_peer(self, target: dict, path: str, query: dict | None = None,
842
+ timeout: float = 3.0) -> dict | None:
843
+ """GET a key-guarded path of a known peer: its JSON answer, or None (no answer,
844
+ refused, not a ser2netweb node)."""
845
+ status, data = await self._request(target, "GET", path, query, None, timeout)
846
+ return data if 200 <= status < 300 else None
847
+
848
+ async def post_peer(self, target: dict, path: str, fields, timeout: float = 6.0,
849
+ actor: str = "") -> dict | None:
850
+ """POST a form to a key-guarded path of a known peer: its JSON answer — also a
851
+ 400 refusal, whose "error" is the peer's own message (e.g. why it can't save
852
+ a mapping) — or None. *actor*: the user acting on this node, sent with this
853
+ node's name (node_label: its instance name, else the host name; ACTOR_FIELD,
854
+ ACTOR_NODE_FIELD) for the peer's audit log."""
855
+ fields = list(fields.items() if isinstance(fields, dict) else fields)
856
+ if actor:
857
+ fields += [(ACTOR_FIELD, actor[:ACTOR_MAX]),
858
+ (ACTOR_NODE_FIELD, node_label(self.config.admin_ui))]
859
+ body = urllib.parse.urlencode(fields).encode()
860
+ status, data = await self._request(target, "POST", path, None, body, timeout)
861
+ return data if 200 <= status < 300 or status == 400 else None
862
+
863
+ async def fetch_peer(self, peer: dict, timeout: float = 2.5) -> dict | None:
864
+ """A peer's fleet-view payload (/api/cluster/local), or None when it doesn't
865
+ answer. Shared by every open dashboard: an answer (also "no answer") is reused
866
+ for FETCH_CACHE_S seconds, and one request per peer at most is in flight,
867
+ however many tabs and users refresh the fleet view."""
868
+ k = _target_key(peer)
869
+ hit = self._fetched.get(k)
870
+ if hit is not None and time.monotonic() - hit[0] < FETCH_CACHE_S:
871
+ return hit[1]
872
+ task = self._fetching.get(k)
873
+ if task is None:
874
+ task = asyncio.create_task(self.get_peer(peer, "/api/cluster/local", timeout=timeout),
875
+ name="cluster-fetch")
876
+ self._fetching[k] = task
877
+ task.add_done_callback(functools.partial(self._fetch_done, k))
878
+ return await asyncio.shield(task) # a closed tab doesn't cancel it for the others
879
+
880
+ def _fetch_done(self, k: tuple, task: asyncio.Task) -> None:
881
+ if self._fetching.get(k) is not task:
882
+ return # forgotten while it ran: its answer may predate a change
883
+ del self._fetching[k]
884
+ if not task.cancelled() and task.exception() is None:
885
+ now = time.monotonic()
886
+ self._fetched = {t: v for t, v in self._fetched.items() if now - v[0] < FETCH_CACHE_S}
887
+ self._fetched[k] = (now, task.result())
888
+
889
+ def forget(self, peer: dict | None = None) -> None:
890
+ """Drop shared fleet-view answers - one peer's or all - so the next refresh asks
891
+ again: after a remote start/stop/edit the table shows the change at once."""
892
+ if peer is None:
893
+ self._fetched.clear()
894
+ self._fetching.clear()
895
+ else:
896
+ self._fetched.pop(_target_key(peer), None)
897
+ self._fetching.pop(_target_key(peer), None)
898
+
899
+ async def control_peer(self, target: dict, mapping_id: str, action: str,
900
+ timeout: float = 4.0, actor: str = "") -> dict | None:
901
+ return await self.post_peer(target, "/api/cluster/control",
902
+ {"mapping_id": mapping_id, "action": action}, timeout,
903
+ actor=actor)
904
+
905
+ async def _request(self, target: dict, method: str, path: str, query: dict | None,
906
+ body: bytes | None, timeout: float) -> tuple[int, dict | None]:
907
+ """One key-guarded request: (HTTP status, 0 when nothing usable answered; the
908
+ reply's JSON object or None). Made only to a discovered or configured peer, at
909
+ an address peer_url accepts. Signed, unless the peer runs 2.8.0 or older (see
910
+ _how); sent again at most a few times when the answer teaches this node what a
911
+ signed request needs (the peer's id or clock, the other key) or that the peer
912
+ is an older node."""
913
+ k = _target_key(target)
914
+ t = next((x for x in self.all_targets() if _target_key(x) == k), None)
915
+ if t is None:
916
+ return 0, None
917
+ try:
918
+ url = peer_url(t["scheme"], t["ip"], t["port"], path, query)
919
+ except ValueError:
920
+ self._note(t, "not a valid peer address")
921
+ return 0, None
922
+ link = self._links.get(k)
923
+ if link is None:
924
+ link = _Link()
925
+ _remember(self._links, k, link, 512)
926
+ ans, steps = _Answer(), []
927
+ for _ in range(4):
928
+ how = self._how(t, link)
929
+ if isinstance(how, str): # nothing may be sent: why
930
+ link.problem = how
931
+ self._note(t, _PROBLEMS[how])
932
+ return 0, None
933
+ try:
934
+ ans = await asyncio.get_running_loop().run_in_executor(
935
+ self._pool(), self._http, method, url, body, timeout, how)
936
+ except RuntimeError: # the interpreter is shutting down
937
+ return 0, None
938
+ step = self._learn(k, t, link, how, ans)
939
+ if not step or step in steps: # done - or the same lesson twice: give up
940
+ break
941
+ steps.append(step)
942
+ self._note(t, ans.problem)
943
+ return ans.status, ans.data
944
+
945
+ def _how(self, t: dict, link: _Link) -> dict | str:
946
+ """How to authenticate the next request to peer *t*: {"mode": "v2" (signed) or
947
+ "legacy" (the raw key), "key", "idx" (which of this node's keys), "to" (its node
948
+ id, "" while unknown), "offset" (its clock minus ours)} - or, when nothing may be
949
+ sent, the reason (a _PROBLEMS key)."""
950
+ keys = self.keys()
951
+ if not keys or not self.cluster.active:
952
+ return "off"
953
+ if t.get("source") == "auto": # its signed beacon says what it is
954
+ idx, older, to = t.get("key") or 0, t.get("v", 1) < PROTOCOL, wire_id(t.get("id"))
955
+ else:
956
+ idx, to = link.key, link.id
957
+ older = link.legacy and _target_key(t) not in self._signed_ok
958
+ if older:
959
+ if not self.cluster.legacy_auth:
960
+ return "legacy-off"
961
+ if t.get("from_packet") and not t.get("configured"):
962
+ # its beacon names no address of its own (127.0.0.1: no default route), and
963
+ # the address a beacon came from proves nothing - a beacon can be replayed
964
+ # from anywhere: the raw key goes only where it says it is, or to a manual peer
965
+ return "manual-only"
966
+ idx = min(idx, len(keys) - 1)
967
+ offset = link.offset if link.offset is not None else float(t.get("offset") or 0.0)
968
+ return {"mode": "legacy" if older else "v2", "key": keys[idx], "idx": idx, "to": to,
969
+ "offset": offset}
970
+
971
+ def _learn(self, k: tuple, t: dict, link: _Link, how: dict, ans: _Answer) -> str:
972
+ """Update what this node knows about peer *t* from one answer and say how it went
973
+ (ans.problem for the log, link.problem for the fleet view). Returns why to send
974
+ the request again ("" = done): the peer's id or clock is known now, the other key
975
+ or the other way of authenticating may work."""
976
+ manual, two_keys = t.get("source") != "auto", len(self.keys()) > 1
977
+ refused = ans.status in (401, 403)
978
+ if ans.status == 0: # no answer at all (ans.problem says why)
979
+ link.problem = "offline"
980
+ link.legacy = False # it may come back upgraded: sign first again
981
+ return ""
982
+ if ans.signed: # a signing node with this node's key
983
+ link.id, link.offset, link.legacy = ans.peer_id or link.id, ans.offset, False
984
+ if manual:
985
+ link.key = how["idx"]
986
+ _remember(self._signed_ok, k, time.monotonic(), 1024)
987
+ if refused and ans.reason == "stale":
988
+ return self._failed(link, ans, "clock", retry="stale")
989
+ if refused and ans.reason == "recipient":
990
+ return self._failed(link, ans, "error", retry="recipient",
991
+ text="it took the request as meant for another node")
992
+ link.auth = "v2"
993
+ return self._answered(link, ans)
994
+ if how["mode"] == "v2":
995
+ if refused and ans.v2: # a signing node refused it
996
+ if ans.reason == "off":
997
+ return self._failed(link, ans, "cluster-off")
998
+ if ans.reason == "replay":
999
+ return self._failed(link, ans, "error", retry="replay",
1000
+ text="it took a new request for a replayed one")
1001
+ if ans.reason == "signature" and manual and two_keys:
1002
+ link.key = 1 - how["idx"] # the other key (if that fails too: this one again)
1003
+ return self._failed(link, ans, "key", retry="key" if how["idx"] == 0 else "")
1004
+ return self._failed(link, ans, "key")
1005
+ if refused: # as releases up to 2.8.0 refuse a request without the raw key
1006
+ if manual and k not in self._signed_ok:
1007
+ if not self.cluster.legacy_auth:
1008
+ return self._failed(link, ans, "legacy-off")
1009
+ link.legacy = True
1010
+ return self._failed(link, ans, "key", retry="legacy")
1011
+ return self._failed(link, ans, "key")
1012
+ if not ans.problem and (200 <= ans.status < 300 or ans.status in (400, 404)):
1013
+ return self._failed(link, ans, "unsigned")
1014
+ return self._answered(link, ans) # (a redirect, an HTTP error: no answer anyway)
1015
+ # it was sent the raw key (a node running 2.8.0 or older)
1016
+ if refused and ans.v2: # a signing node after all
1017
+ if manual:
1018
+ link.legacy = False
1019
+ return self._failed(link, ans, "key", retry="upgraded")
1020
+ return self._failed(link, ans, "key")
1021
+ if refused:
1022
+ if manual and two_keys:
1023
+ link.key = 1 - how["idx"]
1024
+ return self._failed(link, ans, "key", retry="key" if how["idx"] == 0 else "")
1025
+ return self._failed(link, ans, "key")
1026
+ if ans.v2 and manual: # answered, and says it understands signed requests now
1027
+ link.legacy = False
1028
+ link.auth = "legacy"
1029
+ return self._answered(link, ans)
1030
+
1031
+ @staticmethod
1032
+ def _failed(link: _Link, ans: _Answer, problem: str, text: str = "", retry: str = "") -> str:
1033
+ """No usable answer: say why (link.problem a code, ans.problem the log text) and
1034
+ return *retry*."""
1035
+ if not text and problem == "key":
1036
+ why = REFUSALS.get(ans.reason) if ans.v2 else None
1037
+ text = f"HTTP {ans.status}: {_PROBLEMS['key']} " + (
1038
+ f"({why})" if why else "(or its cluster is off)")
1039
+ link.problem, ans.problem = problem, text or _PROBLEMS.get(problem, problem)
1040
+ ans.status, ans.data = 0, None
1041
+ return retry
1042
+
1043
+ @staticmethod
1044
+ def _answered(link: _Link, ans: _Answer) -> str:
1045
+ """The peer answered: what (if anything) to log about it."""
1046
+ code = ans.status
1047
+ if 300 <= code < 400:
1048
+ ans.problem, ans.data = f"HTTP {code}: redirects are not followed", None
1049
+ elif code >= 400 and (ans.data is None or code >= 500):
1050
+ ans.problem = ans.problem or f"HTTP {code}"
1051
+ # (a 4xx with a JSON answer is a peer refusing one request, e.g. a bad edit)
1052
+ link.problem = "error" if ans.problem else ""
1053
+ return ""
1054
+
1055
+ def _pool(self) -> concurrent.futures.ThreadPoolExecutor:
1056
+ if self._executor is None:
1057
+ self._executor = concurrent.futures.ThreadPoolExecutor(
1058
+ max_workers=HTTP_WORKERS, thread_name_prefix="cluster-http")
1059
+ return self._executor
1060
+
1061
+ def _http(self, method: str, url: str, body: bytes | None, timeout: float,
1062
+ how: dict) -> _Answer:
1063
+ """Make one peer request (in a worker thread), authenticated as *how* says (see
1064
+ _how): signed - and then its reply checked - or with the raw key of a node running
1065
+ 2.8.0 or older. Never raises."""
1066
+ headers, nonce = {"User-Agent": user_agent()}, ""
1067
+ if how["mode"] == "legacy":
1068
+ headers[KEY_HEADER] = key_header(how["key"])
1069
+ else:
1070
+ parts = urllib.parse.urlsplit(url)
1071
+ target = canonical_target(urllib.parse.unquote(parts.path), parts.query)
1072
+ me, nonce = wire_id(self.config.instance_id), secrets.token_hex(16)
1073
+ ts = f"{time.time() + how['offset']:.3f}"
1074
+ headers[AUTH_HEADER] = format_auth({
1075
+ "from": me, "to": how["to"], "ts": ts, "nonce": nonce,
1076
+ "sig": request_mac(how["key"], method, target, body, me, how["to"], ts, nonce)})
1077
+ if body is not None:
1078
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
1079
+ req = urllib.request.Request(url, data=body, method=method, # noqa: S310 - peer_url(): http(s)
1080
+ headers=headers)
1081
+ try:
1082
+ with self._opener.open(req, timeout=timeout) as r:
1083
+ status, reply = r.status, r.headers.get(AUTH_HEADER)
1084
+ raw, problem = _read(r)
1085
+ except urllib.error.HTTPError as e:
1086
+ with contextlib.closing(e):
1087
+ status, reply = e.code, (e.headers.get(AUTH_HEADER) if e.headers else None)
1088
+ raw, problem = _read(e)
1089
+ except Exception as e: # no connection, timeout, TLS ...
1090
+ return _Answer(problem=str(getattr(e, "reason", "") or e) or type(e).__name__)
1091
+ ans = _Answer(status=status, problem=problem)
1092
+ params = parse_auth(reply)
1093
+ ans.v2 = params is not None
1094
+ if params and nonce and raw is not None and _TIME.fullmatch(params.get("now", "")) \
1095
+ and params.get("sig") and hmac.compare_digest(
1096
+ reply_mac(how["key"], nonce, status, raw, params.get("id", ""), params["now"]),
1097
+ params["sig"]):
1098
+ ans.signed, ans.peer_id = True, params.get("id", "")
1099
+ ans.offset = float(params["now"]) - time.time()
1100
+ if raw is not None:
1101
+ ans.data = _json_object(raw)
1102
+ if ans.data is None and not problem and 200 <= status < 300:
1103
+ ans.problem = "not a ser2netweb cluster reply"
1104
+ elif ans.data is not None:
1105
+ ans.reason = str(ans.data.get("reason") or "")[:32]
1106
+ return ans
1107
+
1108
+ def _note(self, target: dict, problem: str) -> None:
1109
+ """Log why requests to a peer fail - once per change, not on every refresh: the
1110
+ first failure, a different one, and the first success after them."""
1111
+ addr = peer_addr(target["ip"], target["port"])
1112
+ if problem == self._problems.get(addr, ""):
1113
+ return
1114
+ if problem:
1115
+ if len(self._problems) >= 256: # peers come and go; keep this bounded
1116
+ self._problems.clear()
1117
+ self._problems[addr] = problem
1118
+ self.log(f"cluster: request to peer {addr} failed: {problem}")
1119
+ else:
1120
+ del self._problems[addr]
1121
+ self.log(f"cluster: peer {addr} answers again")
1122
+
1123
+
1124
+ def _read(resp) -> tuple[bytes | None, str]:
1125
+ """A peer reply's body (at most MAX_REPLY_BYTES), or None and why not."""
1126
+ try:
1127
+ raw = resp.read(MAX_REPLY_BYTES + 1)
1128
+ except Exception as e: # cut off, timed out
1129
+ return None, f"reply not read: {e}"
1130
+ if len(raw) > MAX_REPLY_BYTES:
1131
+ return None, f"reply larger than {MAX_REPLY_BYTES // 2**20} MB"
1132
+ return raw, ""
1133
+
1134
+
1135
+ def _json_object(raw: bytes) -> dict | None:
1136
+ try:
1137
+ data = json.loads(raw.decode("utf-8"))
1138
+ except (ValueError, RecursionError):
1139
+ return None
1140
+ return data if isinstance(data, dict) else None