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.

Files changed (63) hide show
  1. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. config/asgi.py +1 -15
  4. config/middleware.py +47 -1
  5. config/settings.py +15 -30
  6. config/urls.py +53 -1
  7. core/admin.py +540 -450
  8. core/apps.py +0 -6
  9. core/auto_upgrade.py +19 -4
  10. core/backends.py +13 -3
  11. core/changelog.py +66 -5
  12. core/environment.py +4 -5
  13. core/models.py +1566 -203
  14. core/notifications.py +1 -1
  15. core/reference_utils.py +10 -11
  16. core/release.py +55 -7
  17. core/sigil_builder.py +2 -2
  18. core/sigil_resolver.py +1 -66
  19. core/system.py +268 -2
  20. core/tasks.py +174 -48
  21. core/tests.py +314 -16
  22. core/user_data.py +42 -2
  23. core/views.py +278 -183
  24. nodes/admin.py +557 -65
  25. nodes/apps.py +11 -0
  26. nodes/models.py +658 -113
  27. nodes/rfid_sync.py +1 -1
  28. nodes/tasks.py +97 -2
  29. nodes/tests.py +1212 -116
  30. nodes/urls.py +15 -1
  31. nodes/utils.py +51 -3
  32. nodes/views.py +1239 -154
  33. ocpp/admin.py +979 -152
  34. ocpp/consumers.py +268 -28
  35. ocpp/models.py +488 -3
  36. ocpp/network.py +398 -0
  37. ocpp/store.py +6 -4
  38. ocpp/tasks.py +296 -2
  39. ocpp/test_export_import.py +1 -0
  40. ocpp/test_rfid.py +121 -4
  41. ocpp/tests.py +950 -11
  42. ocpp/transactions_io.py +9 -1
  43. ocpp/urls.py +3 -3
  44. ocpp/views.py +596 -51
  45. pages/admin.py +262 -30
  46. pages/apps.py +35 -0
  47. pages/context_processors.py +26 -21
  48. pages/defaults.py +1 -1
  49. pages/forms.py +31 -8
  50. pages/middleware.py +6 -2
  51. pages/models.py +77 -2
  52. pages/module_defaults.py +5 -5
  53. pages/site_config.py +137 -0
  54. pages/tests.py +885 -109
  55. pages/urls.py +13 -2
  56. pages/utils.py +70 -0
  57. pages/views.py +558 -55
  58. arthexis-0.1.16.dist-info/RECORD +0 -111
  59. core/workgroup_urls.py +0 -17
  60. core/workgroup_views.py +0 -94
  61. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  62. {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
  63. {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
- "Constellation": "#daa520", # goldenrod
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
- address = models.GenericIPAddressField()
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
- return cls.objects.filter(mac_address=mac).first()
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
- hostname = socket.gethostname()
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
- address = socket.gethostbyname(hostname)
594
+ direct_address = socket.gethostbyname(hostname)
299
595
  except OSError:
300
- address = "127.0.0.1"
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
- slug = slugify(hostname)
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
- "address": address,
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: list[str] = []
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
- if not priv_path.exists() or not pub_path.exists():
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
- self.public_key = public_bytes.decode()
478
- self.save(update_fields=["public_key"])
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 Manager"
1028
- verbose_name_plural = "Node Managers"
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
- priv_path = (
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
- "Constellation",
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
- now = timezone.now().isoformat()
1647
- payload = {
1648
- "uuid": str(self.uuid),
1649
- "subject": self.subject,
1650
- "body": self.body,
1651
- "seen": payload_seen,
1652
- "reach": reach_name,
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
- if private_key:
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
- signature = private_key.sign(
1675
- payload_json.encode(),
1676
- padding.PKCS1v15(),
1677
- hashes.SHA256(),
2204
+ response = requests.post(
2205
+ url,
2206
+ data=payload_json,
2207
+ headers=headers,
2208
+ timeout=1,
1678
2209
  )
1679
- headers["X-Signature"] = base64.b64encode(signature).decode()
2210
+ success = bool(response.ok)
1680
2211
  except Exception:
1681
- pass
1682
- status_entry = {
1683
- "status": "pending",
1684
- "status_code": None,
1685
- "updated": now,
1686
- }
1687
- try:
1688
- response = requests.post(
1689
- f"http://{node.address}:{node.port}/nodes/net-message/",
1690
- data=payload_json,
1691
- headers=headers,
1692
- timeout=1,
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