arthexis 0.1.19__py3-none-any.whl → 0.1.20__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.
nodes/urls.py CHANGED
@@ -8,7 +8,11 @@ urlpatterns = [
8
8
  path("register/", views.register_node, name="register-node"),
9
9
  path("screenshot/", views.capture, name="node-screenshot"),
10
10
  path("net-message/", views.net_message, name="net-message"),
11
+ path("net-message/pull/", views.net_message_pull, name="net-message-pull"),
11
12
  path("rfid/export/", views.export_rfids, name="node-rfid-export"),
12
13
  path("rfid/import/", views.import_rfids, name="node-rfid-import"),
14
+ path("proxy/session/", views.proxy_session, name="node-proxy-session"),
15
+ path("proxy/login/<str:token>/", views.proxy_login, name="node-proxy-login"),
16
+ path("proxy/execute/", views.proxy_execute, name="node-proxy-execute"),
13
17
  path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
14
18
  ]
nodes/views.py CHANGED
@@ -1,17 +1,29 @@
1
1
  import base64
2
2
  import ipaddress
3
3
  import json
4
+ import re
5
+ import secrets
4
6
  import socket
5
7
  from collections.abc import Mapping
8
+ from datetime import timedelta
6
9
 
7
- from django.http import JsonResponse
8
- from django.http.request import split_domain_port
9
- from django.views.decorators.csrf import csrf_exempt
10
- from django.shortcuts import get_object_or_404
10
+ from django.apps import apps
11
11
  from django.conf import settings
12
+ from django.contrib.auth import authenticate, get_user_model, login
13
+ from django.contrib.auth.models import Group, Permission
14
+ from django.core import serializers
15
+ from django.core.cache import cache
16
+ from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
17
+ from django.http import HttpResponse, JsonResponse
18
+ from django.http.request import split_domain_port
19
+ from django.shortcuts import get_object_or_404, redirect
12
20
  from django.urls import reverse
13
- from pathlib import Path
21
+ from django.utils import timezone
14
22
  from django.utils.cache import patch_vary_headers
23
+ from django.utils.http import url_has_allowed_host_and_scheme
24
+ from django.views.decorators.csrf import csrf_exempt
25
+ from pathlib import Path
26
+ from urllib.parse import urlsplit
15
27
 
16
28
  from utils.api import api_login_required
17
29
 
@@ -22,16 +34,95 @@ from core.models import RFID
22
34
 
23
35
  from .rfid_sync import apply_rfid_payload, serialize_rfid
24
36
 
25
- from .models import (
26
- Node,
27
- NetMessage,
28
- NodeFeature,
29
- NodeRole,
30
- node_information_updated,
31
- )
37
+ from .models import Node, NetMessage, PendingNetMessage, node_information_updated
32
38
  from .utils import capture_screenshot, save_screenshot
33
39
 
34
40
 
41
+ PROXY_TOKEN_SALT = "nodes.proxy.session"
42
+ PROXY_TOKEN_TIMEOUT = 300
43
+ PROXY_CACHE_PREFIX = "nodes:proxy-session:"
44
+
45
+
46
+ def _load_signed_node(request, requester_id: str):
47
+ signature = request.headers.get("X-Signature")
48
+ if not signature:
49
+ return None, JsonResponse({"detail": "signature required"}, status=403)
50
+ node = Node.objects.filter(uuid=requester_id).first()
51
+ if not node or not node.public_key:
52
+ return None, JsonResponse({"detail": "unknown requester"}, status=403)
53
+ try:
54
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
55
+ public_key.verify(
56
+ base64.b64decode(signature),
57
+ request.body,
58
+ padding.PKCS1v15(),
59
+ hashes.SHA256(),
60
+ )
61
+ except Exception:
62
+ return None, JsonResponse({"detail": "invalid signature"}, status=403)
63
+ return node, None
64
+
65
+
66
+ def _sanitize_proxy_target(target: str | None, request) -> str:
67
+ default_target = reverse("admin:index")
68
+ if not target:
69
+ return default_target
70
+ candidate = str(target).strip()
71
+ if not candidate:
72
+ return default_target
73
+ if candidate.startswith(("http://", "https://")):
74
+ parsed = urlsplit(candidate)
75
+ if not parsed.path:
76
+ return default_target
77
+ allowed = url_has_allowed_host_and_scheme(
78
+ candidate,
79
+ allowed_hosts={request.get_host()},
80
+ require_https=request.is_secure(),
81
+ )
82
+ if not allowed:
83
+ return default_target
84
+ path = parsed.path
85
+ if parsed.query:
86
+ path = f"{path}?{parsed.query}"
87
+ return path
88
+ if not candidate.startswith("/"):
89
+ candidate = f"/{candidate}"
90
+ return candidate
91
+
92
+
93
+ def _assign_groups_and_permissions(user, payload: Mapping) -> None:
94
+ groups = payload.get("groups", [])
95
+ group_objs: list[Group] = []
96
+ if isinstance(groups, (list, tuple)):
97
+ for name in groups:
98
+ if not isinstance(name, str):
99
+ continue
100
+ cleaned = name.strip()
101
+ if not cleaned:
102
+ continue
103
+ group, _ = Group.objects.get_or_create(name=cleaned)
104
+ group_objs.append(group)
105
+ if group_objs or user.groups.exists():
106
+ user.groups.set(group_objs)
107
+
108
+ permissions = payload.get("permissions", [])
109
+ perm_objs: list[Permission] = []
110
+ if isinstance(permissions, (list, tuple)):
111
+ for label in permissions:
112
+ if not isinstance(label, str):
113
+ continue
114
+ app_label, _, codename = label.partition(".")
115
+ if not app_label or not codename:
116
+ continue
117
+ perm = Permission.objects.filter(
118
+ content_type__app_label=app_label, codename=codename
119
+ ).first()
120
+ if perm:
121
+ perm_objs.append(perm)
122
+ if perm_objs:
123
+ user.user_permissions.set(perm_objs)
124
+
125
+
35
126
  def _get_client_ip(request):
36
127
  """Return the client IP from the request headers."""
37
128
 
@@ -315,6 +406,12 @@ def register_node(request):
315
406
  "address": address,
316
407
  "port": port,
317
408
  }
409
+ role_name = str(data.get("role") or data.get("role_name") or "").strip()
410
+ desired_role = None
411
+ if role_name and (verified or request.user.is_authenticated):
412
+ desired_role = NodeRole.objects.filter(name=role_name).first()
413
+ if desired_role:
414
+ defaults["role"] = desired_role
318
415
  if verified:
319
416
  defaults["public_key"] = public_key
320
417
  if installed_version is not None:
@@ -349,6 +446,9 @@ def register_node(request):
349
446
  if relation_value is not None and node.current_relation != relation_value:
350
447
  node.current_relation = relation_value
351
448
  update_fields.append("current_relation")
449
+ if desired_role and node.role_id != desired_role.id:
450
+ node.role = desired_role
451
+ update_fields.append("role")
352
452
  node.save(update_fields=update_fields)
353
453
  current_version = (node.installed_version or "").strip()
354
454
  current_revision = (node.installed_revision or "").strip()
@@ -368,7 +468,11 @@ def register_node(request):
368
468
  feature_list = list(features)
369
469
  node.update_manual_features(feature_list)
370
470
  response = JsonResponse(
371
- {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
471
+ {
472
+ "id": node.id,
473
+ "uuid": str(node.uuid),
474
+ "detail": f"Node already exists (id: {node.id})",
475
+ }
372
476
  )
373
477
  return _add_cors_headers(request, response)
374
478
 
@@ -393,7 +497,7 @@ def register_node(request):
393
497
 
394
498
  _announce_visitor_join(node, relation_value)
395
499
 
396
- response = JsonResponse({"id": node.id})
500
+ response = JsonResponse({"id": node.id, "uuid": str(node.uuid)})
397
501
  return _add_cors_headers(request, response)
398
502
 
399
503
 
@@ -524,6 +628,293 @@ def import_rfids(request):
524
628
  )
525
629
 
526
630
 
631
+ @csrf_exempt
632
+ def proxy_session(request):
633
+ """Create a proxy login session for a remote administrator."""
634
+
635
+ if request.method != "POST":
636
+ return JsonResponse({"detail": "POST required"}, status=405)
637
+
638
+ try:
639
+ payload = json.loads(request.body.decode() or "{}")
640
+ except json.JSONDecodeError:
641
+ return JsonResponse({"detail": "invalid json"}, status=400)
642
+
643
+ requester = payload.get("requester")
644
+ if not requester:
645
+ return JsonResponse({"detail": "requester required"}, status=400)
646
+
647
+ node, error_response = _load_signed_node(request, requester)
648
+ if error_response is not None:
649
+ return error_response
650
+
651
+ user_payload = payload.get("user") or {}
652
+ username = str(user_payload.get("username", "")).strip()
653
+ if not username:
654
+ return JsonResponse({"detail": "username required"}, status=400)
655
+
656
+ User = get_user_model()
657
+ user, created = User.objects.get_or_create(
658
+ username=username,
659
+ defaults={
660
+ "email": user_payload.get("email", ""),
661
+ "first_name": user_payload.get("first_name", ""),
662
+ "last_name": user_payload.get("last_name", ""),
663
+ },
664
+ )
665
+
666
+ updates: list[str] = []
667
+ for field in ("first_name", "last_name", "email"):
668
+ value = user_payload.get(field)
669
+ if isinstance(value, str) and getattr(user, field) != value:
670
+ setattr(user, field, value)
671
+ updates.append(field)
672
+
673
+ if created:
674
+ user.set_unusable_password()
675
+ updates.append("password")
676
+
677
+ staff_flag = user_payload.get("is_staff")
678
+ if staff_flag is not None:
679
+ is_staff = bool(staff_flag)
680
+ else:
681
+ is_staff = True
682
+ if user.is_staff != is_staff:
683
+ user.is_staff = is_staff
684
+ updates.append("is_staff")
685
+
686
+ superuser_flag = user_payload.get("is_superuser")
687
+ if superuser_flag is not None:
688
+ is_superuser = bool(superuser_flag)
689
+ if user.is_superuser != is_superuser:
690
+ user.is_superuser = is_superuser
691
+ updates.append("is_superuser")
692
+
693
+ if not user.is_active:
694
+ user.is_active = True
695
+ updates.append("is_active")
696
+
697
+ if updates:
698
+ user.save(update_fields=updates)
699
+
700
+ _assign_groups_and_permissions(user, user_payload)
701
+
702
+ target_path = _sanitize_proxy_target(payload.get("target"), request)
703
+ nonce = secrets.token_urlsafe(24)
704
+ cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
705
+ cache.set(cache_key, {"user_id": user.pk}, PROXY_TOKEN_TIMEOUT)
706
+
707
+ signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
708
+ token = signer.sign_object({"user": user.pk, "next": target_path, "nonce": nonce})
709
+ login_url = request.build_absolute_uri(
710
+ reverse("node-proxy-login", args=[token])
711
+ )
712
+ expires = timezone.now() + timedelta(seconds=PROXY_TOKEN_TIMEOUT)
713
+
714
+ return JsonResponse({"login_url": login_url, "expires": expires.isoformat()})
715
+
716
+
717
+ @csrf_exempt
718
+ def proxy_login(request, token):
719
+ """Redeem a proxy login token and redirect to the target path."""
720
+
721
+ signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
722
+ try:
723
+ payload = signer.unsign_object(token, max_age=PROXY_TOKEN_TIMEOUT)
724
+ except SignatureExpired:
725
+ return HttpResponse(status=410)
726
+ except BadSignature:
727
+ return HttpResponse(status=400)
728
+
729
+ nonce = payload.get("nonce")
730
+ if not nonce:
731
+ return HttpResponse(status=400)
732
+
733
+ cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
734
+ cache_payload = cache.get(cache_key)
735
+ if not cache_payload:
736
+ return HttpResponse(status=410)
737
+ cache.delete(cache_key)
738
+
739
+ user_id = cache_payload.get("user_id")
740
+ if not user_id:
741
+ return HttpResponse(status=403)
742
+
743
+ User = get_user_model()
744
+ user = User.objects.filter(pk=user_id).first()
745
+ if not user or not user.is_active:
746
+ return HttpResponse(status=403)
747
+
748
+ backend = getattr(user, "backend", "")
749
+ if not backend:
750
+ backends = getattr(settings, "AUTHENTICATION_BACKENDS", None) or ()
751
+ backend = backends[0] if backends else "django.contrib.auth.backends.ModelBackend"
752
+ login(request, user, backend=backend)
753
+
754
+ next_path = payload.get("next") or reverse("admin:index")
755
+ if not url_has_allowed_host_and_scheme(
756
+ next_path,
757
+ allowed_hosts={request.get_host()},
758
+ require_https=request.is_secure(),
759
+ ):
760
+ next_path = reverse("admin:index")
761
+
762
+ return redirect(next_path)
763
+
764
+
765
+ def _suite_model_name(meta) -> str:
766
+ base = str(meta.verbose_name_plural or meta.verbose_name or meta.object_name)
767
+ normalized = re.sub(r"[^0-9A-Za-z]+", " ", base).title().replace(" ", "")
768
+ return normalized or meta.object_name
769
+
770
+
771
+ @csrf_exempt
772
+ def proxy_execute(request):
773
+ """Execute model operations on behalf of a remote interface node."""
774
+
775
+ if request.method != "POST":
776
+ return JsonResponse({"detail": "POST required"}, status=405)
777
+
778
+ try:
779
+ payload = json.loads(request.body.decode() or "{}")
780
+ except json.JSONDecodeError:
781
+ return JsonResponse({"detail": "invalid json"}, status=400)
782
+
783
+ requester = payload.get("requester")
784
+ if not requester:
785
+ return JsonResponse({"detail": "requester required"}, status=400)
786
+
787
+ node, error_response = _load_signed_node(request, requester)
788
+ if error_response is not None:
789
+ return error_response
790
+
791
+ action = str(payload.get("action", "")).strip().lower()
792
+ if not action:
793
+ return JsonResponse({"detail": "action required"}, status=400)
794
+
795
+ credentials = payload.get("credentials") or {}
796
+ username = str(credentials.get("username", "")).strip()
797
+ password_value = credentials.get("password")
798
+ password = password_value if isinstance(password_value, str) else str(password_value or "")
799
+ if not username or not password:
800
+ return JsonResponse({"detail": "credentials required"}, status=401)
801
+
802
+ User = get_user_model()
803
+ existing_user = User.objects.filter(username=username).first()
804
+ auth_user = authenticate(request=None, username=username, password=password)
805
+
806
+ if auth_user is None:
807
+ if existing_user is not None:
808
+ return JsonResponse({"detail": "authentication failed"}, status=403)
809
+ auth_user = User.objects.create_user(
810
+ username=username,
811
+ password=password,
812
+ email=str(credentials.get("email", "")),
813
+ )
814
+ auth_user.is_staff = True
815
+ auth_user.is_superuser = True
816
+ auth_user.first_name = str(credentials.get("first_name", ""))
817
+ auth_user.last_name = str(credentials.get("last_name", ""))
818
+ auth_user.save()
819
+ else:
820
+ updates: list[str] = []
821
+ for field in ("first_name", "last_name", "email"):
822
+ value = credentials.get(field)
823
+ if isinstance(value, str) and getattr(auth_user, field) != value:
824
+ setattr(auth_user, field, value)
825
+ updates.append(field)
826
+ for flag in ("is_staff", "is_superuser"):
827
+ if flag in credentials:
828
+ desired = bool(credentials.get(flag))
829
+ if getattr(auth_user, flag) != desired:
830
+ setattr(auth_user, flag, desired)
831
+ updates.append(flag)
832
+ if updates:
833
+ auth_user.save(update_fields=updates)
834
+
835
+ if not auth_user.is_active:
836
+ return JsonResponse({"detail": "user inactive"}, status=403)
837
+
838
+ _assign_groups_and_permissions(auth_user, credentials)
839
+
840
+ model_label = payload.get("model")
841
+ model = None
842
+ if action != "schema":
843
+ if not isinstance(model_label, str) or "." not in model_label:
844
+ return JsonResponse({"detail": "model required"}, status=400)
845
+ app_label, model_name = model_label.split(".", 1)
846
+ model = apps.get_model(app_label, model_name)
847
+ if model is None:
848
+ return JsonResponse({"detail": "model not found"}, status=404)
849
+
850
+ if action == "schema":
851
+ models_payload = []
852
+ for registered_model in apps.get_models():
853
+ meta = registered_model._meta
854
+ models_payload.append(
855
+ {
856
+ "app_label": meta.app_label,
857
+ "model": meta.model_name,
858
+ "object_name": meta.object_name,
859
+ "verbose_name": str(meta.verbose_name),
860
+ "verbose_name_plural": str(meta.verbose_name_plural),
861
+ "suite_name": _suite_model_name(meta),
862
+ }
863
+ )
864
+ return JsonResponse({"models": models_payload})
865
+
866
+ action_perm = {
867
+ "list": "view",
868
+ "get": "view",
869
+ "create": "add",
870
+ "update": "change",
871
+ "delete": "delete",
872
+ }.get(action)
873
+
874
+ if action_perm and not auth_user.is_superuser:
875
+ perm_codename = f"{model._meta.app_label}.{action_perm}_{model._meta.model_name}"
876
+ if not auth_user.has_perm(perm_codename):
877
+ return JsonResponse({"detail": "forbidden"}, status=403)
878
+
879
+ try:
880
+ if action == "list":
881
+ filters = payload.get("filters") or {}
882
+ if filters and not isinstance(filters, Mapping):
883
+ return JsonResponse({"detail": "filters must be a mapping"}, status=400)
884
+ queryset = model._default_manager.all()
885
+ if filters:
886
+ queryset = queryset.filter(**filters)
887
+ limit = payload.get("limit")
888
+ if limit is not None:
889
+ try:
890
+ limit_value = int(limit)
891
+ if limit_value > 0:
892
+ queryset = queryset[:limit_value]
893
+ except (TypeError, ValueError):
894
+ pass
895
+ data = serializers.serialize("python", queryset)
896
+ return JsonResponse({"objects": data})
897
+
898
+ if action == "get":
899
+ filters = payload.get("filters") or {}
900
+ if filters and not isinstance(filters, Mapping):
901
+ return JsonResponse({"detail": "filters must be a mapping"}, status=400)
902
+ lookup = dict(filters)
903
+ if not lookup and "pk" in payload:
904
+ lookup = {"pk": payload.get("pk")}
905
+ if not lookup:
906
+ return JsonResponse({"detail": "lookup required"}, status=400)
907
+ obj = model._default_manager.get(**lookup)
908
+ data = serializers.serialize("python", [obj])[0]
909
+ return JsonResponse({"object": data})
910
+ except model.DoesNotExist:
911
+ return JsonResponse({"detail": "not found"}, status=404)
912
+ except Exception as exc:
913
+ return JsonResponse({"detail": str(exc)}, status=400)
914
+
915
+ return JsonResponse({"detail": "unsupported action"}, status=400)
916
+
917
+
527
918
  @csrf_exempt
528
919
  @api_login_required
529
920
  def public_node_endpoint(request, endpoint):
@@ -586,85 +977,99 @@ def net_message(request):
586
977
  except Exception:
587
978
  return JsonResponse({"detail": "invalid signature"}, status=403)
588
979
 
589
- msg_uuid = data.get("uuid")
590
- subject = data.get("subject", "")
591
- body = data.get("body", "")
592
- attachments = NetMessage.normalize_attachments(data.get("attachments"))
593
- reach_name = data.get("reach")
594
- reach_role = None
595
- if reach_name:
596
- reach_role = NodeRole.objects.filter(name=reach_name).first()
597
- filter_node_uuid = data.get("filter_node")
598
- filter_node = None
599
- if filter_node_uuid:
600
- filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
601
- filter_feature_slug = data.get("filter_node_feature")
602
- filter_feature = None
603
- if filter_feature_slug:
604
- filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
605
- filter_role_name = data.get("filter_node_role")
606
- filter_role = None
607
- if filter_role_name:
608
- filter_role = NodeRole.objects.filter(name=filter_role_name).first()
609
- filter_relation_value = data.get("filter_current_relation")
610
- filter_relation = ""
611
- if filter_relation_value:
612
- relation = Node.normalize_relation(filter_relation_value)
613
- filter_relation = relation.value if relation else ""
614
- filter_installed_version = (data.get("filter_installed_version") or "")[:20]
615
- filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
616
- seen = data.get("seen", [])
617
- origin_id = data.get("origin")
618
- origin_node = None
619
- if origin_id:
620
- origin_node = Node.objects.filter(uuid=origin_id).first()
621
- if not origin_node:
622
- origin_node = node
623
- if not msg_uuid:
624
- return JsonResponse({"detail": "uuid required"}, status=400)
625
- msg, created = NetMessage.objects.get_or_create(
626
- uuid=msg_uuid,
627
- defaults={
628
- "subject": subject[:64],
629
- "body": body[:256],
630
- "reach": reach_role,
631
- "node_origin": origin_node,
632
- "attachments": attachments or None,
633
- "filter_node": filter_node,
634
- "filter_node_feature": filter_feature,
635
- "filter_node_role": filter_role,
636
- "filter_current_relation": filter_relation,
637
- "filter_installed_version": filter_installed_version,
638
- "filter_installed_revision": filter_installed_revision,
639
- },
640
- )
641
- if not created:
642
- msg.subject = subject[:64]
643
- msg.body = body[:256]
644
- update_fields = ["subject", "body"]
645
- if reach_role and msg.reach_id != reach_role.id:
646
- msg.reach = reach_role
647
- update_fields.append("reach")
648
- if msg.node_origin_id is None and origin_node:
649
- msg.node_origin = origin_node
650
- update_fields.append("node_origin")
651
- if attachments and msg.attachments != attachments:
652
- msg.attachments = attachments
653
- update_fields.append("attachments")
654
- field_updates = {
655
- "filter_node": filter_node,
656
- "filter_node_feature": filter_feature,
657
- "filter_node_role": filter_role,
658
- "filter_current_relation": filter_relation,
659
- "filter_installed_version": filter_installed_version,
660
- "filter_installed_revision": filter_installed_revision,
661
- }
662
- for field, value in field_updates.items():
663
- if getattr(msg, field) != value:
664
- setattr(msg, field, value)
665
- update_fields.append(field)
666
- msg.save(update_fields=update_fields)
667
- if attachments:
668
- msg.apply_attachments(attachments)
669
- msg.propagate(seen=seen)
980
+ try:
981
+ msg = NetMessage.receive_payload(data, sender=node)
982
+ except ValueError as exc:
983
+ return JsonResponse({"detail": str(exc)}, status=400)
670
984
  return JsonResponse({"status": "propagated", "complete": msg.complete})
985
+
986
+
987
+ @csrf_exempt
988
+ def net_message_pull(request):
989
+ """Allow downstream nodes to retrieve queued network messages."""
990
+
991
+ if request.method != "POST":
992
+ return JsonResponse({"detail": "POST required"}, status=405)
993
+ try:
994
+ data = json.loads(request.body.decode() or "{}")
995
+ except json.JSONDecodeError:
996
+ return JsonResponse({"detail": "invalid json"}, status=400)
997
+
998
+ requester = data.get("requester")
999
+ if not requester:
1000
+ return JsonResponse({"detail": "requester required"}, status=400)
1001
+ signature = request.headers.get("X-Signature")
1002
+ if not signature:
1003
+ return JsonResponse({"detail": "signature required"}, status=403)
1004
+
1005
+ node = Node.objects.filter(uuid=requester).first()
1006
+ if not node or not node.public_key:
1007
+ return JsonResponse({"detail": "unknown requester"}, status=403)
1008
+ try:
1009
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
1010
+ public_key.verify(
1011
+ base64.b64decode(signature),
1012
+ request.body,
1013
+ padding.PKCS1v15(),
1014
+ hashes.SHA256(),
1015
+ )
1016
+ except Exception:
1017
+ return JsonResponse({"detail": "invalid signature"}, status=403)
1018
+
1019
+ local = Node.get_local()
1020
+ if not local:
1021
+ return JsonResponse({"detail": "local node unavailable"}, status=503)
1022
+ private_key = local.get_private_key()
1023
+ if not private_key:
1024
+ return JsonResponse({"detail": "signing unavailable"}, status=503)
1025
+
1026
+ entries = (
1027
+ PendingNetMessage.objects.select_related(
1028
+ "message",
1029
+ "message__filter_node",
1030
+ "message__filter_node_feature",
1031
+ "message__filter_node_role",
1032
+ "message__node_origin",
1033
+ )
1034
+ .filter(node=node)
1035
+ .order_by("queued_at")
1036
+ )
1037
+ messages: list[dict[str, object]] = []
1038
+ expired_ids: list[int] = []
1039
+ delivered_ids: list[int] = []
1040
+
1041
+ origin_fallback = str(local.uuid)
1042
+
1043
+ for entry in entries:
1044
+ if entry.is_stale:
1045
+ expired_ids.append(entry.pk)
1046
+ continue
1047
+ message = entry.message
1048
+ reach_source = message.filter_node_role or message.reach
1049
+ reach_name = reach_source.name if reach_source else None
1050
+ origin_node = message.node_origin
1051
+ origin_uuid = str(origin_node.uuid) if origin_node else origin_fallback
1052
+ sender_id = str(local.uuid)
1053
+ seen = [str(value) for value in entry.seen]
1054
+ payload = message._build_payload(
1055
+ sender_id=sender_id,
1056
+ origin_uuid=origin_uuid,
1057
+ reach_name=reach_name,
1058
+ seen=seen,
1059
+ )
1060
+ payload_json = message._serialize_payload(payload)
1061
+ payload_signature = message._sign_payload(payload_json, private_key)
1062
+ if not payload_signature:
1063
+ logger.warning(
1064
+ "Unable to sign queued NetMessage %s for node %s", message.pk, node.pk
1065
+ )
1066
+ continue
1067
+ messages.append({"payload": payload, "signature": payload_signature})
1068
+ delivered_ids.append(entry.pk)
1069
+
1070
+ if expired_ids:
1071
+ PendingNetMessage.objects.filter(pk__in=expired_ids).delete()
1072
+ if delivered_ids:
1073
+ PendingNetMessage.objects.filter(pk__in=delivered_ids).delete()
1074
+
1075
+ return JsonResponse({"messages": messages})