arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/models.py
CHANGED
|
@@ -4,6 +4,7 @@ from collections.abc import Iterable
|
|
|
4
4
|
from copy import deepcopy
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from django.db import models
|
|
7
|
+
from django.db.models import Q
|
|
7
8
|
from django.db.utils import DatabaseError
|
|
8
9
|
from django.db.models.signals import post_delete
|
|
9
10
|
from django.dispatch import Signal, receiver
|
|
@@ -13,17 +14,19 @@ from core.fields import SigilLongAutoField, SigilShortAutoField
|
|
|
13
14
|
import re
|
|
14
15
|
import json
|
|
15
16
|
import base64
|
|
17
|
+
import ipaddress
|
|
16
18
|
from django.utils import timezone
|
|
17
19
|
from django.utils.text import slugify
|
|
18
20
|
from django.conf import settings
|
|
19
|
-
from datetime import timedelta
|
|
21
|
+
from datetime import datetime, timedelta, timezone as datetime_timezone
|
|
20
22
|
import uuid
|
|
21
23
|
import os
|
|
22
|
-
import shutil
|
|
23
24
|
import socket
|
|
24
25
|
import stat
|
|
25
26
|
import subprocess
|
|
27
|
+
import shutil
|
|
26
28
|
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlparse, urlunsplit
|
|
27
30
|
from utils import revision
|
|
28
31
|
from core.notifications import notify_async
|
|
29
32
|
from django.core.exceptions import ValidationError
|
|
@@ -41,6 +44,9 @@ import logging
|
|
|
41
44
|
logger = logging.getLogger(__name__)
|
|
42
45
|
|
|
43
46
|
|
|
47
|
+
ROLE_RENAMES: dict[str, str] = {"Constellation": "Watchtower"}
|
|
48
|
+
|
|
49
|
+
|
|
44
50
|
class NodeRoleManager(models.Manager):
|
|
45
51
|
def get_by_natural_key(self, name: str):
|
|
46
52
|
return self.get(name=name)
|
|
@@ -143,8 +149,6 @@ class NodeFeature(Entity):
|
|
|
143
149
|
return False
|
|
144
150
|
if node.features.filter(pk=self.pk).exists():
|
|
145
151
|
return True
|
|
146
|
-
if self.slug == "gway-runner":
|
|
147
|
-
return Node._has_gway_runner()
|
|
148
152
|
if self.slug == "gui-toast":
|
|
149
153
|
from core.notifications import supports_gui_toast
|
|
150
154
|
|
|
@@ -188,8 +192,10 @@ class Node(Entity):
|
|
|
188
192
|
|
|
189
193
|
DEFAULT_BADGE_COLOR = "#28a745"
|
|
190
194
|
ROLE_BADGE_COLORS = {
|
|
191
|
-
"
|
|
195
|
+
"Watchtower": "#daa520", # goldenrod
|
|
196
|
+
"Constellation": "#daa520", # legacy alias
|
|
192
197
|
"Control": "#673ab7", # deep purple
|
|
198
|
+
"Interface": "#0dcaf0", # cyan
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
class Relation(models.TextChoices):
|
|
@@ -199,9 +205,20 @@ class Node(Entity):
|
|
|
199
205
|
SELF = "SELF", "Self"
|
|
200
206
|
|
|
201
207
|
hostname = models.CharField(max_length=100)
|
|
202
|
-
|
|
208
|
+
network_hostname = models.CharField(max_length=253, blank=True)
|
|
209
|
+
ipv4_address = models.GenericIPAddressField(
|
|
210
|
+
protocol="IPv4", blank=True, null=True
|
|
211
|
+
)
|
|
212
|
+
ipv6_address = models.GenericIPAddressField(
|
|
213
|
+
protocol="IPv6", blank=True, null=True
|
|
214
|
+
)
|
|
215
|
+
address = models.GenericIPAddressField(blank=True, null=True)
|
|
203
216
|
mac_address = models.CharField(max_length=17, unique=True, null=True, blank=True)
|
|
204
217
|
port = models.PositiveIntegerField(default=8000)
|
|
218
|
+
message_queue_length = models.PositiveSmallIntegerField(
|
|
219
|
+
default=10,
|
|
220
|
+
help_text="Maximum queued NetMessages to retain for this peer.",
|
|
221
|
+
)
|
|
205
222
|
badge_color = models.CharField(max_length=7, default=DEFAULT_BADGE_COLOR)
|
|
206
223
|
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
207
224
|
current_relation = models.CharField(
|
|
@@ -242,19 +259,244 @@ class Node(Entity):
|
|
|
242
259
|
RPI_CAMERA_BINARIES = ("rpicam-hello", "rpicam-still", "rpicam-vid")
|
|
243
260
|
AP_ROUTER_SSID = "gelectriic-ap"
|
|
244
261
|
NMCLI_TIMEOUT = 5
|
|
245
|
-
GWAY_RUNNER_COMMAND = "gway"
|
|
246
|
-
GWAY_RUNNER_CANDIDATES = ("~/.local/bin/gway", "/usr/local/bin/gway")
|
|
247
262
|
AUTO_MANAGED_FEATURES = set(FEATURE_LOCK_MAP.keys()) | {
|
|
248
263
|
"gui-toast",
|
|
249
264
|
"rpi-camera",
|
|
250
265
|
"ap-router",
|
|
251
|
-
"gway-runner",
|
|
252
266
|
}
|
|
253
267
|
MANUAL_FEATURE_SLUGS = {"clipboard-poll", "screenshot-poll", "audio-capture"}
|
|
254
268
|
|
|
255
269
|
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
256
270
|
return f"{self.hostname}:{self.port}"
|
|
257
271
|
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _ip_preference(ip_value: str) -> tuple[int, str]:
|
|
274
|
+
"""Return a sort key favouring globally routable addresses."""
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
parsed = ipaddress.ip_address(ip_value)
|
|
278
|
+
except ValueError:
|
|
279
|
+
return (3, ip_value)
|
|
280
|
+
|
|
281
|
+
if parsed.is_global:
|
|
282
|
+
return (0, ip_value)
|
|
283
|
+
|
|
284
|
+
if parsed.is_loopback or parsed.is_link_local:
|
|
285
|
+
return (2, ip_value)
|
|
286
|
+
|
|
287
|
+
if parsed.is_private:
|
|
288
|
+
return (2, ip_value)
|
|
289
|
+
|
|
290
|
+
return (1, ip_value)
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def _select_preferred_ip(cls, addresses: Iterable[str]) -> str | None:
|
|
294
|
+
"""Return the preferred IP from ``addresses`` when available."""
|
|
295
|
+
|
|
296
|
+
best: tuple[int, str] | None = None
|
|
297
|
+
for candidate in addresses:
|
|
298
|
+
candidate = (candidate or "").strip()
|
|
299
|
+
if not candidate:
|
|
300
|
+
continue
|
|
301
|
+
score = cls._ip_preference(candidate)
|
|
302
|
+
if best is None or score < best:
|
|
303
|
+
best = score
|
|
304
|
+
return best[1] if best else None
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def _resolve_ip_addresses(
|
|
308
|
+
cls, *hosts: str, include_ipv4: bool = True, include_ipv6: bool = True
|
|
309
|
+
) -> tuple[list[str], list[str]]:
|
|
310
|
+
"""Resolve ``hosts`` into IPv4 and IPv6 address lists."""
|
|
311
|
+
|
|
312
|
+
ipv4: list[str] = []
|
|
313
|
+
ipv6: list[str] = []
|
|
314
|
+
|
|
315
|
+
for host in hosts:
|
|
316
|
+
host = (host or "").strip()
|
|
317
|
+
if not host:
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
info = socket.getaddrinfo(
|
|
321
|
+
host,
|
|
322
|
+
None,
|
|
323
|
+
socket.AF_UNSPEC,
|
|
324
|
+
socket.SOCK_STREAM,
|
|
325
|
+
)
|
|
326
|
+
except OSError:
|
|
327
|
+
continue
|
|
328
|
+
for family, _, _, _, sockaddr in info:
|
|
329
|
+
if family == socket.AF_INET and include_ipv4:
|
|
330
|
+
value = sockaddr[0]
|
|
331
|
+
if value not in ipv4:
|
|
332
|
+
ipv4.append(value)
|
|
333
|
+
elif family == socket.AF_INET6 and include_ipv6:
|
|
334
|
+
value = sockaddr[0]
|
|
335
|
+
if value not in ipv6:
|
|
336
|
+
ipv6.append(value)
|
|
337
|
+
|
|
338
|
+
return ipv4, ipv6
|
|
339
|
+
|
|
340
|
+
def get_remote_host_candidates(self) -> list[str]:
|
|
341
|
+
"""Return host strings that may reach this node."""
|
|
342
|
+
|
|
343
|
+
values: list[str] = []
|
|
344
|
+
for attr in (
|
|
345
|
+
"network_hostname",
|
|
346
|
+
"hostname",
|
|
347
|
+
"ipv6_address",
|
|
348
|
+
"ipv4_address",
|
|
349
|
+
"address",
|
|
350
|
+
"public_endpoint",
|
|
351
|
+
):
|
|
352
|
+
value = getattr(self, attr, "") or ""
|
|
353
|
+
value = value.strip()
|
|
354
|
+
if value and value not in values:
|
|
355
|
+
values.append(value)
|
|
356
|
+
|
|
357
|
+
resolved_ipv6: list[str] = []
|
|
358
|
+
resolved_ipv4: list[str] = []
|
|
359
|
+
for host in list(values):
|
|
360
|
+
if host.startswith("http://") or host.startswith("https://"):
|
|
361
|
+
continue
|
|
362
|
+
try:
|
|
363
|
+
ipaddress.ip_address(host)
|
|
364
|
+
except ValueError:
|
|
365
|
+
ipv4, ipv6 = self._resolve_ip_addresses(host)
|
|
366
|
+
for candidate in ipv6:
|
|
367
|
+
if candidate not in values and candidate not in resolved_ipv6:
|
|
368
|
+
resolved_ipv6.append(candidate)
|
|
369
|
+
for candidate in ipv4:
|
|
370
|
+
if candidate not in values and candidate not in resolved_ipv4:
|
|
371
|
+
resolved_ipv4.append(candidate)
|
|
372
|
+
values.extend(resolved_ipv6)
|
|
373
|
+
values.extend(resolved_ipv4)
|
|
374
|
+
return values
|
|
375
|
+
|
|
376
|
+
def get_primary_contact(self) -> str:
|
|
377
|
+
"""Return the first reachable host for this node."""
|
|
378
|
+
|
|
379
|
+
for host in self.get_remote_host_candidates():
|
|
380
|
+
if host:
|
|
381
|
+
return host
|
|
382
|
+
return ""
|
|
383
|
+
|
|
384
|
+
def get_best_ip(self) -> str:
|
|
385
|
+
"""Return the preferred IP address for this node if known."""
|
|
386
|
+
|
|
387
|
+
candidates: list[str] = []
|
|
388
|
+
for value in (
|
|
389
|
+
getattr(self, "address", "") or "",
|
|
390
|
+
getattr(self, "ipv4_address", "") or "",
|
|
391
|
+
getattr(self, "ipv6_address", "") or "",
|
|
392
|
+
):
|
|
393
|
+
value = value.strip()
|
|
394
|
+
if not value:
|
|
395
|
+
continue
|
|
396
|
+
try:
|
|
397
|
+
ipaddress.ip_address(value)
|
|
398
|
+
except ValueError:
|
|
399
|
+
continue
|
|
400
|
+
candidates.append(value)
|
|
401
|
+
if not candidates:
|
|
402
|
+
return ""
|
|
403
|
+
selected = self._select_preferred_ip(candidates)
|
|
404
|
+
return selected or ""
|
|
405
|
+
|
|
406
|
+
def iter_remote_urls(self, path: str):
|
|
407
|
+
"""Yield potential remote URLs for ``path`` on this node."""
|
|
408
|
+
|
|
409
|
+
host_candidates = self.get_remote_host_candidates()
|
|
410
|
+
default_port = self.port or 8000
|
|
411
|
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
412
|
+
seen: set[str] = set()
|
|
413
|
+
|
|
414
|
+
for host in host_candidates:
|
|
415
|
+
host = host.strip()
|
|
416
|
+
if not host:
|
|
417
|
+
continue
|
|
418
|
+
base_path = ""
|
|
419
|
+
formatted_host = host
|
|
420
|
+
port_override: int | None = None
|
|
421
|
+
|
|
422
|
+
if "://" in host:
|
|
423
|
+
parsed = urlparse(host)
|
|
424
|
+
netloc = parsed.netloc or parsed.path
|
|
425
|
+
base_path = (parsed.path or "").rstrip("/")
|
|
426
|
+
combined_path = (
|
|
427
|
+
f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
428
|
+
)
|
|
429
|
+
primary = urlunsplit((parsed.scheme, netloc, combined_path, "", ""))
|
|
430
|
+
if primary not in seen:
|
|
431
|
+
seen.add(primary)
|
|
432
|
+
yield primary
|
|
433
|
+
if parsed.scheme == "https":
|
|
434
|
+
fallback = urlunsplit(("http", netloc, combined_path, "", ""))
|
|
435
|
+
if fallback not in seen:
|
|
436
|
+
seen.add(fallback)
|
|
437
|
+
yield fallback
|
|
438
|
+
elif parsed.scheme == "http":
|
|
439
|
+
alternate = urlunsplit(("https", netloc, combined_path, "", ""))
|
|
440
|
+
if alternate not in seen:
|
|
441
|
+
seen.add(alternate)
|
|
442
|
+
yield alternate
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
if host.startswith("[") and "]" in host:
|
|
446
|
+
end = host.index("]")
|
|
447
|
+
core_host = host[1:end]
|
|
448
|
+
remainder = host[end + 1 :]
|
|
449
|
+
if remainder.startswith(":"):
|
|
450
|
+
remainder = remainder[1:]
|
|
451
|
+
port_part, sep, path_tail = remainder.partition("/")
|
|
452
|
+
if port_part:
|
|
453
|
+
try:
|
|
454
|
+
port_override = int(port_part)
|
|
455
|
+
except ValueError:
|
|
456
|
+
port_override = None
|
|
457
|
+
if sep:
|
|
458
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
459
|
+
elif "/" in remainder:
|
|
460
|
+
_, _, path_tail = remainder.partition("/")
|
|
461
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
462
|
+
formatted_host = f"[{core_host}]"
|
|
463
|
+
else:
|
|
464
|
+
if "/" in host:
|
|
465
|
+
host_only, _, path_tail = host.partition("/")
|
|
466
|
+
formatted_host = host_only or host
|
|
467
|
+
base_path = f"/{path_tail}".rstrip("/")
|
|
468
|
+
try:
|
|
469
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
470
|
+
except ValueError:
|
|
471
|
+
parts = formatted_host.rsplit(":", 1)
|
|
472
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
473
|
+
formatted_host = parts[0]
|
|
474
|
+
port_override = int(parts[1])
|
|
475
|
+
try:
|
|
476
|
+
ip_obj = ipaddress.ip_address(formatted_host)
|
|
477
|
+
except ValueError:
|
|
478
|
+
ip_obj = None
|
|
479
|
+
else:
|
|
480
|
+
if ip_obj.version == 6 and not formatted_host.startswith("["):
|
|
481
|
+
formatted_host = f"[{formatted_host}]"
|
|
482
|
+
|
|
483
|
+
effective_port = port_override if port_override is not None else default_port
|
|
484
|
+
combined_path = f"{base_path}{normalized_path}" if base_path else normalized_path
|
|
485
|
+
|
|
486
|
+
for scheme, scheme_default_port in (("https", 443), ("http", 80)):
|
|
487
|
+
base = f"{scheme}://{formatted_host}"
|
|
488
|
+
if effective_port and (
|
|
489
|
+
port_override is not None or effective_port != scheme_default_port
|
|
490
|
+
):
|
|
491
|
+
explicit = f"{base}:{effective_port}{combined_path}"
|
|
492
|
+
if explicit not in seen:
|
|
493
|
+
seen.add(explicit)
|
|
494
|
+
yield explicit
|
|
495
|
+
candidate = f"{base}{combined_path}"
|
|
496
|
+
if candidate not in seen:
|
|
497
|
+
seen.add(candidate)
|
|
498
|
+
yield candidate
|
|
499
|
+
|
|
258
500
|
@staticmethod
|
|
259
501
|
def get_current_mac() -> str:
|
|
260
502
|
"""Return the MAC address of the current host."""
|
|
@@ -285,7 +527,14 @@ class Node(Entity):
|
|
|
285
527
|
"""Return the node representing the current host if it exists."""
|
|
286
528
|
mac = cls.get_current_mac()
|
|
287
529
|
try:
|
|
288
|
-
|
|
530
|
+
node = cls.objects.filter(mac_address__iexact=mac).first()
|
|
531
|
+
if node:
|
|
532
|
+
return node
|
|
533
|
+
return (
|
|
534
|
+
cls.objects.filter(current_relation=cls.Relation.SELF)
|
|
535
|
+
.filter(Q(mac_address__isnull=True) | Q(mac_address=""))
|
|
536
|
+
.first()
|
|
537
|
+
)
|
|
289
538
|
except DatabaseError:
|
|
290
539
|
logger.debug("nodes.Node.get_local skipped: database unavailable", exc_info=True)
|
|
291
540
|
return None
|
|
@@ -293,11 +542,66 @@ class Node(Entity):
|
|
|
293
542
|
@classmethod
|
|
294
543
|
def register_current(cls):
|
|
295
544
|
"""Create or update the :class:`Node` entry for this host."""
|
|
296
|
-
|
|
545
|
+
hostname_override = (
|
|
546
|
+
os.environ.get("NODE_HOSTNAME")
|
|
547
|
+
or os.environ.get("HOSTNAME")
|
|
548
|
+
or ""
|
|
549
|
+
)
|
|
550
|
+
hostname_override = hostname_override.strip()
|
|
551
|
+
hostname = hostname_override or socket.gethostname()
|
|
552
|
+
|
|
553
|
+
network_hostname = os.environ.get("NODE_PUBLIC_HOSTNAME", "").strip()
|
|
554
|
+
if not network_hostname:
|
|
555
|
+
fqdn = socket.getfqdn(hostname)
|
|
556
|
+
if fqdn and "." in fqdn:
|
|
557
|
+
network_hostname = fqdn
|
|
558
|
+
|
|
559
|
+
ipv4_override = os.environ.get("NODE_PUBLIC_IPV4", "").strip()
|
|
560
|
+
ipv6_override = os.environ.get("NODE_PUBLIC_IPV6", "").strip()
|
|
561
|
+
|
|
562
|
+
ipv4_candidates: list[str] = []
|
|
563
|
+
ipv6_candidates: list[str] = []
|
|
564
|
+
|
|
565
|
+
for override, version in ((ipv4_override, 4), (ipv6_override, 6)):
|
|
566
|
+
override = override.strip()
|
|
567
|
+
if not override:
|
|
568
|
+
continue
|
|
569
|
+
try:
|
|
570
|
+
parsed = ipaddress.ip_address(override)
|
|
571
|
+
except ValueError:
|
|
572
|
+
continue
|
|
573
|
+
if parsed.version == version:
|
|
574
|
+
if version == 4 and override not in ipv4_candidates:
|
|
575
|
+
ipv4_candidates.append(override)
|
|
576
|
+
elif version == 6 and override not in ipv6_candidates:
|
|
577
|
+
ipv6_candidates.append(override)
|
|
578
|
+
|
|
579
|
+
resolve_hosts: list[str] = []
|
|
580
|
+
for value in (network_hostname, hostname_override, hostname):
|
|
581
|
+
value = (value or "").strip()
|
|
582
|
+
if value and value not in resolve_hosts:
|
|
583
|
+
resolve_hosts.append(value)
|
|
584
|
+
|
|
585
|
+
resolved_ipv4, resolved_ipv6 = cls._resolve_ip_addresses(*resolve_hosts)
|
|
586
|
+
for ip_value in resolved_ipv4:
|
|
587
|
+
if ip_value not in ipv4_candidates:
|
|
588
|
+
ipv4_candidates.append(ip_value)
|
|
589
|
+
for ip_value in resolved_ipv6:
|
|
590
|
+
if ip_value not in ipv6_candidates:
|
|
591
|
+
ipv6_candidates.append(ip_value)
|
|
592
|
+
|
|
297
593
|
try:
|
|
298
|
-
|
|
594
|
+
direct_address = socket.gethostbyname(hostname)
|
|
299
595
|
except OSError:
|
|
300
|
-
|
|
596
|
+
direct_address = ""
|
|
597
|
+
|
|
598
|
+
if direct_address and direct_address not in ipv4_candidates:
|
|
599
|
+
ipv4_candidates.append(direct_address)
|
|
600
|
+
|
|
601
|
+
ipv4_address = cls._select_preferred_ip(ipv4_candidates)
|
|
602
|
+
ipv6_address = cls._select_preferred_ip(ipv6_candidates)
|
|
603
|
+
|
|
604
|
+
preferred_contact = ipv4_address or ipv6_address or direct_address or "127.0.0.1"
|
|
301
605
|
port = int(os.environ.get("PORT", 8000))
|
|
302
606
|
base_path = str(settings.BASE_DIR)
|
|
303
607
|
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
@@ -305,13 +609,20 @@ class Node(Entity):
|
|
|
305
609
|
rev_value = revision.get_revision()
|
|
306
610
|
installed_revision = rev_value if rev_value else ""
|
|
307
611
|
mac = cls.get_current_mac()
|
|
308
|
-
|
|
612
|
+
endpoint_override = os.environ.get("NODE_PUBLIC_ENDPOINT", "").strip()
|
|
613
|
+
slug_source = endpoint_override or hostname
|
|
614
|
+
slug = slugify(slug_source)
|
|
615
|
+
if not slug:
|
|
616
|
+
slug = cls._generate_unique_public_endpoint(hostname or mac)
|
|
309
617
|
node = cls.objects.filter(mac_address=mac).first()
|
|
310
618
|
if not node:
|
|
311
619
|
node = cls.objects.filter(public_endpoint=slug).first()
|
|
312
620
|
defaults = {
|
|
313
621
|
"hostname": hostname,
|
|
314
|
-
"
|
|
622
|
+
"network_hostname": network_hostname,
|
|
623
|
+
"ipv4_address": ipv4_address,
|
|
624
|
+
"ipv6_address": ipv6_address,
|
|
625
|
+
"address": preferred_contact,
|
|
315
626
|
"port": port,
|
|
316
627
|
"base_path": base_path,
|
|
317
628
|
"installed_version": installed_version,
|
|
@@ -322,6 +633,7 @@ class Node(Entity):
|
|
|
322
633
|
}
|
|
323
634
|
role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
|
|
324
635
|
role_name = role_lock.read_text().strip() if role_lock.exists() else "Terminal"
|
|
636
|
+
role_name = ROLE_RENAMES.get(role_name, role_name)
|
|
325
637
|
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
326
638
|
|
|
327
639
|
if node:
|
|
@@ -388,7 +700,10 @@ class Node(Entity):
|
|
|
388
700
|
|
|
389
701
|
payload = {
|
|
390
702
|
"hostname": self.hostname,
|
|
703
|
+
"network_hostname": self.network_hostname,
|
|
391
704
|
"address": self.address,
|
|
705
|
+
"ipv4_address": self.ipv4_address,
|
|
706
|
+
"ipv6_address": self.ipv6_address,
|
|
392
707
|
"port": self.port,
|
|
393
708
|
"mac_address": self.mac_address,
|
|
394
709
|
"public_key": self.public_key,
|
|
@@ -405,17 +720,18 @@ class Node(Entity):
|
|
|
405
720
|
|
|
406
721
|
peers = Node.objects.exclude(pk=self.pk)
|
|
407
722
|
for peer in peers:
|
|
408
|
-
host_candidates
|
|
409
|
-
if peer.address:
|
|
410
|
-
host_candidates.append(peer.address)
|
|
411
|
-
if peer.hostname and peer.hostname not in host_candidates:
|
|
412
|
-
host_candidates.append(peer.hostname)
|
|
723
|
+
host_candidates = peer.get_remote_host_candidates()
|
|
413
724
|
port = peer.port or 8000
|
|
414
725
|
urls: list[str] = []
|
|
415
726
|
for host in host_candidates:
|
|
416
727
|
host = host.strip()
|
|
417
728
|
if not host:
|
|
418
729
|
continue
|
|
730
|
+
if host.startswith("http://") or host.startswith("https://"):
|
|
731
|
+
normalized = host.rstrip("/")
|
|
732
|
+
if normalized not in urls:
|
|
733
|
+
urls.append(normalized)
|
|
734
|
+
continue
|
|
419
735
|
if ":" in host and not host.startswith("["):
|
|
420
736
|
host = f"[{host}]"
|
|
421
737
|
http_url = (
|
|
@@ -461,7 +777,24 @@ class Node(Entity):
|
|
|
461
777
|
security_dir.mkdir(parents=True, exist_ok=True)
|
|
462
778
|
priv_path = security_dir / f"{self.public_endpoint}"
|
|
463
779
|
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
464
|
-
|
|
780
|
+
regenerate = not priv_path.exists() or not pub_path.exists()
|
|
781
|
+
if not regenerate:
|
|
782
|
+
key_max_age = getattr(settings, "NODE_KEY_MAX_AGE", timedelta(days=90))
|
|
783
|
+
if key_max_age is not None:
|
|
784
|
+
try:
|
|
785
|
+
priv_mtime = datetime.fromtimestamp(
|
|
786
|
+
priv_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
787
|
+
)
|
|
788
|
+
pub_mtime = datetime.fromtimestamp(
|
|
789
|
+
pub_path.stat().st_mtime, tz=datetime_timezone.utc
|
|
790
|
+
)
|
|
791
|
+
except OSError:
|
|
792
|
+
regenerate = True
|
|
793
|
+
else:
|
|
794
|
+
cutoff = timezone.now() - key_max_age
|
|
795
|
+
if priv_mtime < cutoff or pub_mtime < cutoff:
|
|
796
|
+
regenerate = True
|
|
797
|
+
if regenerate:
|
|
465
798
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
466
799
|
private_bytes = private_key.private_bytes(
|
|
467
800
|
encoding=serialization.Encoding.PEM,
|
|
@@ -474,12 +807,35 @@ class Node(Entity):
|
|
|
474
807
|
)
|
|
475
808
|
priv_path.write_bytes(private_bytes)
|
|
476
809
|
pub_path.write_bytes(public_bytes)
|
|
477
|
-
|
|
478
|
-
self.
|
|
810
|
+
public_text = public_bytes.decode()
|
|
811
|
+
if self.public_key != public_text:
|
|
812
|
+
self.public_key = public_text
|
|
813
|
+
self.save(update_fields=["public_key"])
|
|
479
814
|
elif not self.public_key:
|
|
480
815
|
self.public_key = pub_path.read_text()
|
|
481
816
|
self.save(update_fields=["public_key"])
|
|
482
817
|
|
|
818
|
+
def get_private_key(self):
|
|
819
|
+
"""Return the private key for this node if available."""
|
|
820
|
+
|
|
821
|
+
if not self.public_endpoint:
|
|
822
|
+
return None
|
|
823
|
+
try:
|
|
824
|
+
self.ensure_keys()
|
|
825
|
+
except Exception:
|
|
826
|
+
return None
|
|
827
|
+
priv_path = (
|
|
828
|
+
Path(self.base_path or settings.BASE_DIR)
|
|
829
|
+
/ "security"
|
|
830
|
+
/ f"{self.public_endpoint}"
|
|
831
|
+
)
|
|
832
|
+
try:
|
|
833
|
+
return serialization.load_pem_private_key(
|
|
834
|
+
priv_path.read_bytes(), password=None
|
|
835
|
+
)
|
|
836
|
+
except Exception:
|
|
837
|
+
return None
|
|
838
|
+
|
|
483
839
|
@property
|
|
484
840
|
def is_local(self):
|
|
485
841
|
"""Determine if this node represents the current host."""
|
|
@@ -671,21 +1027,6 @@ class Node(Entity):
|
|
|
671
1027
|
return True
|
|
672
1028
|
return False
|
|
673
1029
|
|
|
674
|
-
@classmethod
|
|
675
|
-
def _find_gway_runner_command(cls) -> str | None:
|
|
676
|
-
command = shutil.which(cls.GWAY_RUNNER_COMMAND)
|
|
677
|
-
if command:
|
|
678
|
-
return command
|
|
679
|
-
for candidate in cls.GWAY_RUNNER_CANDIDATES:
|
|
680
|
-
expanded = os.path.expanduser(candidate)
|
|
681
|
-
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
|
682
|
-
return expanded
|
|
683
|
-
return None
|
|
684
|
-
|
|
685
|
-
@classmethod
|
|
686
|
-
def _has_gway_runner(cls) -> bool:
|
|
687
|
-
return cls._find_gway_runner_command() is not None
|
|
688
|
-
|
|
689
1030
|
def refresh_features(self):
|
|
690
1031
|
if not self.pk:
|
|
691
1032
|
return
|
|
@@ -700,8 +1041,6 @@ class Node(Entity):
|
|
|
700
1041
|
detected_slugs.add(slug)
|
|
701
1042
|
if self._has_rpi_camera():
|
|
702
1043
|
detected_slugs.add("rpi-camera")
|
|
703
|
-
if self._has_gway_runner():
|
|
704
|
-
detected_slugs.add("gway-runner")
|
|
705
1044
|
if self._hosts_gelectriic_ap():
|
|
706
1045
|
detected_slugs.add("ap-router")
|
|
707
1046
|
try:
|
|
@@ -754,6 +1093,7 @@ class Node(Entity):
|
|
|
754
1093
|
self._sync_screenshot_task(screenshot_enabled)
|
|
755
1094
|
self._sync_landing_lead_task(celery_enabled)
|
|
756
1095
|
self._sync_ocpp_session_report_task(celery_enabled)
|
|
1096
|
+
self._sync_upstream_poll_task(celery_enabled)
|
|
757
1097
|
|
|
758
1098
|
def _sync_clipboard_task(self, enabled: bool):
|
|
759
1099
|
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
@@ -859,6 +1199,28 @@ class Node(Entity):
|
|
|
859
1199
|
except (OperationalError, ProgrammingError):
|
|
860
1200
|
logger.debug("Skipping OCPP session report task sync; tables not ready")
|
|
861
1201
|
|
|
1202
|
+
def _sync_upstream_poll_task(self, celery_enabled: bool):
|
|
1203
|
+
if not self.is_local:
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
1207
|
+
|
|
1208
|
+
task_name = "nodes_poll_upstream_messages"
|
|
1209
|
+
if celery_enabled:
|
|
1210
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
1211
|
+
every=1, period=IntervalSchedule.MINUTES
|
|
1212
|
+
)
|
|
1213
|
+
PeriodicTask.objects.update_or_create(
|
|
1214
|
+
name=task_name,
|
|
1215
|
+
defaults={
|
|
1216
|
+
"interval": schedule,
|
|
1217
|
+
"task": "nodes.tasks.poll_unreachable_upstream",
|
|
1218
|
+
"enabled": True,
|
|
1219
|
+
},
|
|
1220
|
+
)
|
|
1221
|
+
else:
|
|
1222
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
1223
|
+
|
|
862
1224
|
def send_mail(
|
|
863
1225
|
self,
|
|
864
1226
|
subject: str,
|
|
@@ -1024,8 +1386,8 @@ class NodeManager(Profile):
|
|
|
1024
1386
|
)
|
|
1025
1387
|
|
|
1026
1388
|
class Meta:
|
|
1027
|
-
verbose_name = "Node
|
|
1028
|
-
verbose_name_plural = "Node
|
|
1389
|
+
verbose_name = "Node Profile"
|
|
1390
|
+
verbose_name_plural = "Node Profiles"
|
|
1029
1391
|
|
|
1030
1392
|
def __str__(self) -> str:
|
|
1031
1393
|
owner = self.owner_display()
|
|
@@ -1410,7 +1772,6 @@ class NetMessage(Entity):
|
|
|
1410
1772
|
propagated_to = models.ManyToManyField(
|
|
1411
1773
|
Node, blank=True, related_name="received_net_messages"
|
|
1412
1774
|
)
|
|
1413
|
-
confirmed_peers = models.JSONField(default=dict, blank=True)
|
|
1414
1775
|
created = models.DateTimeField(auto_now_add=True)
|
|
1415
1776
|
complete = models.BooleanField(default=False, editable=False)
|
|
1416
1777
|
|
|
@@ -1479,6 +1840,7 @@ class NetMessage(Entity):
|
|
|
1479
1840
|
payload = attachments if attachments is not None else self.attachments or []
|
|
1480
1841
|
if not payload:
|
|
1481
1842
|
return
|
|
1843
|
+
|
|
1482
1844
|
try:
|
|
1483
1845
|
objects = list(
|
|
1484
1846
|
serializers.deserialize(
|
|
@@ -1498,6 +1860,193 @@ class NetMessage(Entity):
|
|
|
1498
1860
|
self.pk,
|
|
1499
1861
|
)
|
|
1500
1862
|
|
|
1863
|
+
def _build_payload(
|
|
1864
|
+
self,
|
|
1865
|
+
*,
|
|
1866
|
+
sender_id: str | None,
|
|
1867
|
+
origin_uuid: str | None,
|
|
1868
|
+
reach_name: str | None,
|
|
1869
|
+
seen: list[str],
|
|
1870
|
+
) -> dict[str, object]:
|
|
1871
|
+
payload: dict[str, object] = {
|
|
1872
|
+
"uuid": str(self.uuid),
|
|
1873
|
+
"subject": self.subject,
|
|
1874
|
+
"body": self.body,
|
|
1875
|
+
"seen": list(seen),
|
|
1876
|
+
"reach": reach_name,
|
|
1877
|
+
"sender": sender_id,
|
|
1878
|
+
"origin": origin_uuid,
|
|
1879
|
+
}
|
|
1880
|
+
if self.attachments:
|
|
1881
|
+
payload["attachments"] = self.attachments
|
|
1882
|
+
if self.filter_node:
|
|
1883
|
+
payload["filter_node"] = str(self.filter_node.uuid)
|
|
1884
|
+
if self.filter_node_feature:
|
|
1885
|
+
payload["filter_node_feature"] = self.filter_node_feature.slug
|
|
1886
|
+
if self.filter_node_role:
|
|
1887
|
+
payload["filter_node_role"] = self.filter_node_role.name
|
|
1888
|
+
if self.filter_current_relation:
|
|
1889
|
+
payload["filter_current_relation"] = self.filter_current_relation
|
|
1890
|
+
if self.filter_installed_version:
|
|
1891
|
+
payload["filter_installed_version"] = self.filter_installed_version
|
|
1892
|
+
if self.filter_installed_revision:
|
|
1893
|
+
payload["filter_installed_revision"] = self.filter_installed_revision
|
|
1894
|
+
return payload
|
|
1895
|
+
|
|
1896
|
+
@staticmethod
|
|
1897
|
+
def _serialize_payload(payload: dict[str, object]) -> str:
|
|
1898
|
+
return json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
1899
|
+
|
|
1900
|
+
@staticmethod
|
|
1901
|
+
def _sign_payload(payload_json: str, private_key) -> str | None:
|
|
1902
|
+
if not private_key:
|
|
1903
|
+
return None
|
|
1904
|
+
try:
|
|
1905
|
+
signature = private_key.sign(
|
|
1906
|
+
payload_json.encode(),
|
|
1907
|
+
padding.PKCS1v15(),
|
|
1908
|
+
hashes.SHA256(),
|
|
1909
|
+
)
|
|
1910
|
+
except Exception:
|
|
1911
|
+
return None
|
|
1912
|
+
return base64.b64encode(signature).decode()
|
|
1913
|
+
|
|
1914
|
+
def queue_for_node(self, node: "Node", seen: list[str]) -> None:
|
|
1915
|
+
"""Queue this message for later delivery to ``node``."""
|
|
1916
|
+
|
|
1917
|
+
if node.current_relation != Node.Relation.DOWNSTREAM:
|
|
1918
|
+
return
|
|
1919
|
+
|
|
1920
|
+
now = timezone.now()
|
|
1921
|
+
expires_at = now + timedelta(hours=1)
|
|
1922
|
+
normalized_seen = [str(value) for value in seen]
|
|
1923
|
+
entry, created = PendingNetMessage.objects.get_or_create(
|
|
1924
|
+
node=node,
|
|
1925
|
+
message=self,
|
|
1926
|
+
defaults={
|
|
1927
|
+
"seen": normalized_seen,
|
|
1928
|
+
"stale_at": expires_at,
|
|
1929
|
+
},
|
|
1930
|
+
)
|
|
1931
|
+
if created:
|
|
1932
|
+
entry.queued_at = now
|
|
1933
|
+
entry.save(update_fields=["queued_at"])
|
|
1934
|
+
else:
|
|
1935
|
+
entry.seen = normalized_seen
|
|
1936
|
+
entry.stale_at = expires_at
|
|
1937
|
+
entry.queued_at = now
|
|
1938
|
+
entry.save(update_fields=["seen", "stale_at", "queued_at"])
|
|
1939
|
+
self._trim_queue(node)
|
|
1940
|
+
|
|
1941
|
+
def clear_queue_for_node(self, node: "Node") -> None:
|
|
1942
|
+
PendingNetMessage.objects.filter(node=node, message=self).delete()
|
|
1943
|
+
|
|
1944
|
+
def _trim_queue(self, node: "Node") -> None:
|
|
1945
|
+
limit = max(int(node.message_queue_length or 0), 0)
|
|
1946
|
+
if limit == 0:
|
|
1947
|
+
PendingNetMessage.objects.filter(node=node).delete()
|
|
1948
|
+
return
|
|
1949
|
+
qs = PendingNetMessage.objects.filter(node=node).order_by("-queued_at")
|
|
1950
|
+
keep_ids = list(qs.values_list("pk", flat=True)[:limit])
|
|
1951
|
+
if keep_ids:
|
|
1952
|
+
PendingNetMessage.objects.filter(node=node).exclude(pk__in=keep_ids).delete()
|
|
1953
|
+
else:
|
|
1954
|
+
qs.delete()
|
|
1955
|
+
|
|
1956
|
+
@classmethod
|
|
1957
|
+
def receive_payload(
|
|
1958
|
+
cls,
|
|
1959
|
+
data: dict[str, object],
|
|
1960
|
+
*,
|
|
1961
|
+
sender: "Node",
|
|
1962
|
+
) -> "NetMessage":
|
|
1963
|
+
msg_uuid = data.get("uuid")
|
|
1964
|
+
if not msg_uuid:
|
|
1965
|
+
raise ValueError("uuid required")
|
|
1966
|
+
subject = (data.get("subject") or "")[:64]
|
|
1967
|
+
body = (data.get("body") or "")[:256]
|
|
1968
|
+
attachments = cls.normalize_attachments(data.get("attachments"))
|
|
1969
|
+
reach_name = data.get("reach")
|
|
1970
|
+
reach_role = None
|
|
1971
|
+
if reach_name:
|
|
1972
|
+
reach_role = NodeRole.objects.filter(name=reach_name).first()
|
|
1973
|
+
filter_node_uuid = data.get("filter_node")
|
|
1974
|
+
filter_node = None
|
|
1975
|
+
if filter_node_uuid:
|
|
1976
|
+
filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
|
|
1977
|
+
filter_feature_slug = data.get("filter_node_feature")
|
|
1978
|
+
filter_feature = None
|
|
1979
|
+
if filter_feature_slug:
|
|
1980
|
+
filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
|
|
1981
|
+
filter_role_name = data.get("filter_node_role")
|
|
1982
|
+
filter_role = None
|
|
1983
|
+
if filter_role_name:
|
|
1984
|
+
filter_role = NodeRole.objects.filter(name=filter_role_name).first()
|
|
1985
|
+
filter_relation_value = data.get("filter_current_relation")
|
|
1986
|
+
filter_relation = ""
|
|
1987
|
+
if filter_relation_value:
|
|
1988
|
+
relation = Node.normalize_relation(filter_relation_value)
|
|
1989
|
+
filter_relation = relation.value if relation else ""
|
|
1990
|
+
filter_installed_version = (data.get("filter_installed_version") or "")[:20]
|
|
1991
|
+
filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
|
|
1992
|
+
seen_values = data.get("seen", [])
|
|
1993
|
+
if not isinstance(seen_values, list):
|
|
1994
|
+
seen_values = list(seen_values) # type: ignore[arg-type]
|
|
1995
|
+
normalized_seen = [str(value) for value in seen_values if value is not None]
|
|
1996
|
+
origin_id = data.get("origin")
|
|
1997
|
+
origin_node = None
|
|
1998
|
+
if origin_id:
|
|
1999
|
+
origin_node = Node.objects.filter(uuid=origin_id).first()
|
|
2000
|
+
if not origin_node:
|
|
2001
|
+
origin_node = sender
|
|
2002
|
+
msg, created = cls.objects.get_or_create(
|
|
2003
|
+
uuid=msg_uuid,
|
|
2004
|
+
defaults={
|
|
2005
|
+
"subject": subject,
|
|
2006
|
+
"body": body,
|
|
2007
|
+
"reach": reach_role,
|
|
2008
|
+
"node_origin": origin_node,
|
|
2009
|
+
"attachments": attachments or None,
|
|
2010
|
+
"filter_node": filter_node,
|
|
2011
|
+
"filter_node_feature": filter_feature,
|
|
2012
|
+
"filter_node_role": filter_role,
|
|
2013
|
+
"filter_current_relation": filter_relation,
|
|
2014
|
+
"filter_installed_version": filter_installed_version,
|
|
2015
|
+
"filter_installed_revision": filter_installed_revision,
|
|
2016
|
+
},
|
|
2017
|
+
)
|
|
2018
|
+
if not created:
|
|
2019
|
+
msg.subject = subject
|
|
2020
|
+
msg.body = body
|
|
2021
|
+
update_fields = ["subject", "body"]
|
|
2022
|
+
if reach_role and msg.reach_id != reach_role.id:
|
|
2023
|
+
msg.reach = reach_role
|
|
2024
|
+
update_fields.append("reach")
|
|
2025
|
+
if msg.node_origin_id is None and origin_node:
|
|
2026
|
+
msg.node_origin = origin_node
|
|
2027
|
+
update_fields.append("node_origin")
|
|
2028
|
+
if attachments and msg.attachments != attachments:
|
|
2029
|
+
msg.attachments = attachments
|
|
2030
|
+
update_fields.append("attachments")
|
|
2031
|
+
field_updates = {
|
|
2032
|
+
"filter_node": filter_node,
|
|
2033
|
+
"filter_node_feature": filter_feature,
|
|
2034
|
+
"filter_node_role": filter_role,
|
|
2035
|
+
"filter_current_relation": filter_relation,
|
|
2036
|
+
"filter_installed_version": filter_installed_version,
|
|
2037
|
+
"filter_installed_revision": filter_installed_revision,
|
|
2038
|
+
}
|
|
2039
|
+
for field, value in field_updates.items():
|
|
2040
|
+
if getattr(msg, field) != value:
|
|
2041
|
+
setattr(msg, field, value)
|
|
2042
|
+
update_fields.append(field)
|
|
2043
|
+
if update_fields:
|
|
2044
|
+
msg.save(update_fields=update_fields)
|
|
2045
|
+
if attachments:
|
|
2046
|
+
msg.apply_attachments(attachments)
|
|
2047
|
+
msg.propagate(seen=normalized_seen)
|
|
2048
|
+
return msg
|
|
2049
|
+
|
|
1501
2050
|
def propagate(self, seen: list[str] | None = None):
|
|
1502
2051
|
from core.notifications import notify
|
|
1503
2052
|
import random
|
|
@@ -1532,17 +2081,7 @@ class NetMessage(Entity):
|
|
|
1532
2081
|
local_id = str(local.uuid)
|
|
1533
2082
|
if local_id not in seen:
|
|
1534
2083
|
seen.append(local_id)
|
|
1535
|
-
|
|
1536
|
-
Path(local.base_path or settings.BASE_DIR)
|
|
1537
|
-
/ "security"
|
|
1538
|
-
/ f"{local.public_endpoint}"
|
|
1539
|
-
)
|
|
1540
|
-
try:
|
|
1541
|
-
private_key = serialization.load_pem_private_key(
|
|
1542
|
-
priv_path.read_bytes(), password=None
|
|
1543
|
-
)
|
|
1544
|
-
except Exception:
|
|
1545
|
-
private_key = None
|
|
2084
|
+
private_key = local.get_private_key()
|
|
1546
2085
|
for node_id in seen:
|
|
1547
2086
|
node = Node.objects.filter(uuid=node_id).first()
|
|
1548
2087
|
if node and (not local or node.pk != local.pk):
|
|
@@ -1592,11 +2131,18 @@ class NetMessage(Entity):
|
|
|
1592
2131
|
reach_source = self.filter_node_role or self.reach
|
|
1593
2132
|
reach_name = reach_source.name if reach_source else None
|
|
1594
2133
|
role_map = {
|
|
2134
|
+
"Interface": ["Interface", "Terminal"],
|
|
1595
2135
|
"Terminal": ["Terminal"],
|
|
1596
2136
|
"Control": ["Control", "Terminal"],
|
|
1597
2137
|
"Satellite": ["Satellite", "Control", "Terminal"],
|
|
2138
|
+
"Watchtower": [
|
|
2139
|
+
"Watchtower",
|
|
2140
|
+
"Satellite",
|
|
2141
|
+
"Control",
|
|
2142
|
+
"Terminal",
|
|
2143
|
+
],
|
|
1598
2144
|
"Constellation": [
|
|
1599
|
-
"
|
|
2145
|
+
"Watchtower",
|
|
1600
2146
|
"Satellite",
|
|
1601
2147
|
"Control",
|
|
1602
2148
|
"Terminal",
|
|
@@ -1640,72 +2186,45 @@ class NetMessage(Entity):
|
|
|
1640
2186
|
seen_list = seen.copy()
|
|
1641
2187
|
selected_ids = [str(n.uuid) for n in selected]
|
|
1642
2188
|
payload_seen = seen_list + selected_ids
|
|
1643
|
-
confirmed_peers = dict(self.confirmed_peers or {})
|
|
1644
|
-
|
|
1645
2189
|
for node in selected:
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
"sender": local_id,
|
|
1654
|
-
"origin": origin_uuid,
|
|
1655
|
-
}
|
|
1656
|
-
if self.attachments:
|
|
1657
|
-
payload["attachments"] = self.attachments
|
|
1658
|
-
if self.filter_node:
|
|
1659
|
-
payload["filter_node"] = str(self.filter_node.uuid)
|
|
1660
|
-
if self.filter_node_feature:
|
|
1661
|
-
payload["filter_node_feature"] = self.filter_node_feature.slug
|
|
1662
|
-
if self.filter_node_role:
|
|
1663
|
-
payload["filter_node_role"] = self.filter_node_role.name
|
|
1664
|
-
if self.filter_current_relation:
|
|
1665
|
-
payload["filter_current_relation"] = self.filter_current_relation
|
|
1666
|
-
if self.filter_installed_version:
|
|
1667
|
-
payload["filter_installed_version"] = self.filter_installed_version
|
|
1668
|
-
if self.filter_installed_revision:
|
|
1669
|
-
payload["filter_installed_revision"] = self.filter_installed_revision
|
|
1670
|
-
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
2190
|
+
payload = self._build_payload(
|
|
2191
|
+
sender_id=local_id,
|
|
2192
|
+
origin_uuid=origin_uuid,
|
|
2193
|
+
reach_name=reach_name,
|
|
2194
|
+
seen=payload_seen,
|
|
2195
|
+
)
|
|
2196
|
+
payload_json = self._serialize_payload(payload)
|
|
1671
2197
|
headers = {"Content-Type": "application/json"}
|
|
1672
|
-
|
|
2198
|
+
signature = self._sign_payload(payload_json, private_key)
|
|
2199
|
+
if signature:
|
|
2200
|
+
headers["X-Signature"] = signature
|
|
2201
|
+
success = False
|
|
2202
|
+
for url in node.iter_remote_urls("/nodes/net-message/"):
|
|
1673
2203
|
try:
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
2204
|
+
response = requests.post(
|
|
2205
|
+
url,
|
|
2206
|
+
data=payload_json,
|
|
2207
|
+
headers=headers,
|
|
2208
|
+
timeout=1,
|
|
1678
2209
|
)
|
|
1679
|
-
|
|
2210
|
+
success = bool(response.ok)
|
|
1680
2211
|
except Exception:
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
)
|
|
1694
|
-
status_entry["status_code"] = getattr(response, "status_code", None)
|
|
1695
|
-
if getattr(response, "ok", False):
|
|
1696
|
-
status_entry["status"] = "acknowledged"
|
|
1697
|
-
else:
|
|
1698
|
-
status_entry["status"] = "failed"
|
|
1699
|
-
except Exception:
|
|
1700
|
-
status_entry["status"] = "error"
|
|
2212
|
+
logger.exception(
|
|
2213
|
+
"Failed to propagate NetMessage %s to node %s via %s",
|
|
2214
|
+
self.pk,
|
|
2215
|
+
node.pk,
|
|
2216
|
+
url,
|
|
2217
|
+
)
|
|
2218
|
+
continue
|
|
2219
|
+
if success:
|
|
2220
|
+
break
|
|
2221
|
+
if success:
|
|
2222
|
+
self.clear_queue_for_node(node)
|
|
2223
|
+
else:
|
|
2224
|
+
self.queue_for_node(node, payload_seen)
|
|
1701
2225
|
self.propagated_to.add(node)
|
|
1702
|
-
confirmed_peers[str(node.uuid)] = status_entry
|
|
1703
2226
|
|
|
1704
2227
|
save_fields: list[str] = []
|
|
1705
|
-
if confirmed_peers != (self.confirmed_peers or {}):
|
|
1706
|
-
self.confirmed_peers = confirmed_peers
|
|
1707
|
-
save_fields.append("confirmed_peers")
|
|
1708
|
-
|
|
1709
2228
|
if total_known and self.propagated_to.count() >= total_known:
|
|
1710
2229
|
self.complete = True
|
|
1711
2230
|
save_fields.append("complete")
|
|
@@ -1714,6 +2233,32 @@ class NetMessage(Entity):
|
|
|
1714
2233
|
self.save(update_fields=save_fields)
|
|
1715
2234
|
|
|
1716
2235
|
|
|
2236
|
+
class PendingNetMessage(models.Model):
|
|
2237
|
+
"""Queued :class:`NetMessage` awaiting delivery to a downstream node."""
|
|
2238
|
+
|
|
2239
|
+
node = models.ForeignKey(
|
|
2240
|
+
Node, on_delete=models.CASCADE, related_name="pending_net_messages"
|
|
2241
|
+
)
|
|
2242
|
+
message = models.ForeignKey(
|
|
2243
|
+
NetMessage,
|
|
2244
|
+
on_delete=models.CASCADE,
|
|
2245
|
+
related_name="pending_deliveries",
|
|
2246
|
+
)
|
|
2247
|
+
seen = models.JSONField(default=list)
|
|
2248
|
+
queued_at = models.DateTimeField(auto_now_add=True)
|
|
2249
|
+
stale_at = models.DateTimeField()
|
|
2250
|
+
|
|
2251
|
+
class Meta:
|
|
2252
|
+
unique_together = ("node", "message")
|
|
2253
|
+
ordering = ("queued_at",)
|
|
2254
|
+
|
|
2255
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
2256
|
+
return f"{self.message_id} → {self.node_id}"
|
|
2257
|
+
|
|
2258
|
+
@property
|
|
2259
|
+
def is_stale(self) -> bool:
|
|
2260
|
+
return self.stale_at <= timezone.now()
|
|
2261
|
+
|
|
1717
2262
|
class ContentSample(Entity):
|
|
1718
2263
|
"""Collected content such as text snippets or screenshots."""
|
|
1719
2264
|
|