arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.

nodes/models.py CHANGED
@@ -1,16 +1,18 @@
1
1
  from collections.abc import Iterable
2
2
  from django.db import models
3
3
  from django.db.models.signals import post_delete
4
- from django.dispatch import receiver
4
+ from django.dispatch import Signal, receiver
5
5
  from core.entity import Entity
6
6
  from core.models import Profile
7
7
  from core.fields import SigilShortAutoField
8
8
  import re
9
9
  import json
10
10
  import base64
11
+ from django.utils import timezone
11
12
  from django.utils.text import slugify
12
13
  from django.conf import settings
13
14
  from django.contrib.sites.models import Site
15
+ from datetime import timedelta
14
16
  import uuid
15
17
  import os
16
18
  import shutil
@@ -19,6 +21,7 @@ import stat
19
21
  import subprocess
20
22
  from pathlib import Path
21
23
  from utils import revision
24
+ from core.notifications import notify_async
22
25
  from django.core.exceptions import ValidationError
23
26
  from cryptography.hazmat.primitives.asymmetric import rsa
24
27
  from cryptography.hazmat.primitives import serialization, hashes
@@ -235,8 +238,112 @@ class Node(Entity):
235
238
  node.save(update_fields=["role"])
236
239
  Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
237
240
  node.ensure_keys()
241
+ node.notify_peers_of_update()
238
242
  return node, created
239
243
 
244
+ def notify_peers_of_update(self):
245
+ """Attempt to update this node's registration with known peers."""
246
+
247
+ from secrets import token_hex
248
+
249
+ try:
250
+ import requests
251
+ except Exception: # pragma: no cover - requests should be available
252
+ return
253
+
254
+ security_dir = Path(self.base_path or settings.BASE_DIR) / "security"
255
+ priv_path = security_dir / f"{self.public_endpoint}"
256
+ if not priv_path.exists():
257
+ logger.debug("Private key for %s not found; skipping peer update", self)
258
+ return
259
+ try:
260
+ private_key = serialization.load_pem_private_key(
261
+ priv_path.read_bytes(), password=None
262
+ )
263
+ except Exception as exc: # pragma: no cover - defensive
264
+ logger.warning("Failed to load private key for %s: %s", self, exc)
265
+ return
266
+ token = token_hex(16)
267
+ try:
268
+ signature = private_key.sign(
269
+ token.encode(),
270
+ padding.PKCS1v15(),
271
+ hashes.SHA256(),
272
+ )
273
+ except Exception as exc: # pragma: no cover - defensive
274
+ logger.warning("Failed to sign peer update for %s: %s", self, exc)
275
+ return
276
+
277
+ payload = {
278
+ "hostname": self.hostname,
279
+ "address": self.address,
280
+ "port": self.port,
281
+ "mac_address": self.mac_address,
282
+ "public_key": self.public_key,
283
+ "token": token,
284
+ "signature": base64.b64encode(signature).decode(),
285
+ }
286
+ if self.installed_version:
287
+ payload["installed_version"] = self.installed_version
288
+ if self.installed_revision:
289
+ payload["installed_revision"] = self.installed_revision
290
+
291
+ payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
292
+ headers = {"Content-Type": "application/json"}
293
+
294
+ peers = Node.objects.exclude(pk=self.pk)
295
+ for peer in peers:
296
+ host_candidates: list[str] = []
297
+ if peer.address:
298
+ host_candidates.append(peer.address)
299
+ if peer.hostname and peer.hostname not in host_candidates:
300
+ host_candidates.append(peer.hostname)
301
+ port = peer.port or 8000
302
+ urls: list[str] = []
303
+ for host in host_candidates:
304
+ host = host.strip()
305
+ if not host:
306
+ continue
307
+ if ":" in host and not host.startswith("["):
308
+ host = f"[{host}]"
309
+ http_url = (
310
+ f"http://{host}/nodes/register/"
311
+ if port == 80
312
+ else f"http://{host}:{port}/nodes/register/"
313
+ )
314
+ https_url = (
315
+ f"https://{host}/nodes/register/"
316
+ if port in {80, 443}
317
+ else f"https://{host}:{port}/nodes/register/"
318
+ )
319
+ for url in (https_url, http_url):
320
+ if url not in urls:
321
+ urls.append(url)
322
+ if not urls:
323
+ continue
324
+ for url in urls:
325
+ try:
326
+ response = requests.post(
327
+ url, data=payload_json, headers=headers, timeout=2
328
+ )
329
+ except Exception as exc: # pragma: no cover - best effort
330
+ logger.debug("Failed to update %s via %s: %s", peer, url, exc)
331
+ continue
332
+ if response.ok:
333
+ version_display = _format_upgrade_body(
334
+ self.installed_version,
335
+ self.installed_revision,
336
+ )
337
+ version_suffix = f" ({version_display})" if version_display else ""
338
+ logger.info(
339
+ "Announced startup to %s%s",
340
+ peer,
341
+ version_suffix,
342
+ )
343
+ break
344
+ else:
345
+ logger.warning("Unable to notify node %s of startup", peer)
346
+
240
347
  def ensure_keys(self):
241
348
  security_dir = Path(settings.BASE_DIR) / "security"
242
349
  security_dir.mkdir(parents=True, exist_ok=True)
@@ -526,6 +633,52 @@ class Node(Entity):
526
633
  )
527
634
 
528
635
 
636
+ node_information_updated = Signal()
637
+
638
+
639
+ def _format_upgrade_body(version: str, revision: str) -> str:
640
+ version = (version or "").strip()
641
+ revision = (revision or "").strip()
642
+ parts: list[str] = []
643
+ if version:
644
+ normalized = version.lstrip("vV") or version
645
+ parts.append(f"v{normalized}")
646
+ if revision:
647
+ rev_clean = re.sub(r"[^0-9A-Za-z]", "", revision)
648
+ rev_short = (rev_clean[-6:] if rev_clean else revision[-6:])
649
+ parts.append(f"r{rev_short}")
650
+ return " ".join(parts).strip()
651
+
652
+
653
+ @receiver(node_information_updated)
654
+ def _announce_peer_startup(
655
+ sender,
656
+ *,
657
+ node: "Node",
658
+ previous_version: str = "",
659
+ previous_revision: str = "",
660
+ current_version: str = "",
661
+ current_revision: str = "",
662
+ **_: object,
663
+ ) -> None:
664
+ current_version = (current_version or "").strip()
665
+ current_revision = (current_revision or "").strip()
666
+ previous_version = (previous_version or "").strip()
667
+ previous_revision = (previous_revision or "").strip()
668
+
669
+ local = Node.get_local()
670
+ if local and node.pk == local.pk:
671
+ return
672
+
673
+ body = _format_upgrade_body(current_version, current_revision)
674
+ if not body:
675
+ body = "Online"
676
+
677
+ hostname = (node.hostname or "Node").strip() or "Node"
678
+ subject = f"UP {hostname}"
679
+ notify_async(subject, body)
680
+
681
+
529
682
  class NodeFeatureAssignment(Entity):
530
683
  """Bridge between :class:`Node` and :class:`NodeFeature`."""
531
684
 
@@ -617,6 +770,26 @@ class EmailOutbox(Profile):
617
770
  verbose_name = "Email Outbox"
618
771
  verbose_name_plural = "Email Outboxes"
619
772
 
773
+ def __str__(self) -> str:
774
+ address = (self.from_email or "").strip()
775
+ if address:
776
+ return address
777
+
778
+ username = (self.username or "").strip()
779
+ host = (self.host or "").strip()
780
+ if username and host:
781
+ return f"{username}@{host}"
782
+ if username:
783
+ return username
784
+ if host:
785
+ return host
786
+
787
+ owner = self.owner_display()
788
+ if owner:
789
+ return owner
790
+
791
+ return super().__str__()
792
+
620
793
  def clean(self):
621
794
  if self.user_id or self.group_id:
622
795
  super().clean()
@@ -662,6 +835,13 @@ class NetMessage(Entity):
662
835
  editable=False,
663
836
  verbose_name="UUID",
664
837
  )
838
+ node_origin = models.ForeignKey(
839
+ "Node",
840
+ on_delete=models.SET_NULL,
841
+ null=True,
842
+ blank=True,
843
+ related_name="originated_net_messages",
844
+ )
665
845
  subject = models.CharField(max_length=64, blank=True)
666
846
  body = models.CharField(max_length=256, blank=True)
667
847
  reach = models.ForeignKey(
@@ -696,10 +876,12 @@ class NetMessage(Entity):
696
876
  role = reach
697
877
  else:
698
878
  role = NodeRole.objects.filter(name=reach).first()
879
+ origin = Node.get_local()
699
880
  msg = cls.objects.create(
700
881
  subject=subject[:64],
701
882
  body=body[:256],
702
883
  reach=role or get_terminal_role(),
884
+ node_origin=origin,
703
885
  )
704
886
  msg.propagate(seen=seen or [])
705
887
  return msg
@@ -709,8 +891,28 @@ class NetMessage(Entity):
709
891
  import random
710
892
  import requests
711
893
 
712
- notify(self.subject, self.body)
894
+ displayed = notify(self.subject, self.body)
713
895
  local = Node.get_local()
896
+ if displayed:
897
+ cutoff = timezone.now() - timedelta(days=7)
898
+ prune_qs = type(self).objects.filter(created__lt=cutoff)
899
+ if local:
900
+ prune_qs = prune_qs.filter(
901
+ models.Q(node_origin=local) | models.Q(node_origin__isnull=True)
902
+ )
903
+ else:
904
+ prune_qs = prune_qs.filter(node_origin__isnull=True)
905
+ if self.pk:
906
+ prune_qs = prune_qs.exclude(pk=self.pk)
907
+ prune_qs.delete()
908
+ if local and not self.node_origin_id:
909
+ self.node_origin = local
910
+ self.save(update_fields=["node_origin"])
911
+ origin_uuid = None
912
+ if self.node_origin_id:
913
+ origin_uuid = str(self.node_origin.uuid)
914
+ elif local:
915
+ origin_uuid = str(local.uuid)
714
916
  private_key = None
715
917
  seen = list(seen or [])
716
918
  local_id = None
@@ -785,6 +987,7 @@ class NetMessage(Entity):
785
987
  "seen": payload_seen,
786
988
  "reach": reach_name,
787
989
  "sender": local_id,
990
+ "origin": origin_uuid,
788
991
  }
789
992
  payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
790
993
  headers = {"Content-Type": "application/json"}