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