arthexis 0.1.18__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,8 +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("last-message/", views.last_net_message, name="last-net-message"),
11
+ path("net-message/pull/", views.net_message_pull, name="net-message-pull"),
12
12
  path("rfid/export/", views.export_rfids, name="node-rfid-export"),
13
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"),
14
17
  path("<slug:endpoint>/", views.public_node_endpoint, name="node-public-endpoint"),
15
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
 
@@ -104,6 +195,8 @@ def _get_host_domain(request) -> str:
104
195
  domain, _ = split_domain_port(host)
105
196
  if not domain:
106
197
  return ""
198
+ if domain.lower() == "localhost":
199
+ return ""
107
200
  try:
108
201
  ipaddress.ip_address(domain)
109
202
  except ValueError:
@@ -313,6 +406,12 @@ def register_node(request):
313
406
  "address": address,
314
407
  "port": port,
315
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
316
415
  if verified:
317
416
  defaults["public_key"] = public_key
318
417
  if installed_version is not None:
@@ -347,6 +446,9 @@ def register_node(request):
347
446
  if relation_value is not None and node.current_relation != relation_value:
348
447
  node.current_relation = relation_value
349
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")
350
452
  node.save(update_fields=update_fields)
351
453
  current_version = (node.installed_version or "").strip()
352
454
  current_revision = (node.installed_revision or "").strip()
@@ -366,7 +468,11 @@ def register_node(request):
366
468
  feature_list = list(features)
367
469
  node.update_manual_features(feature_list)
368
470
  response = JsonResponse(
369
- {"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
+ }
370
476
  )
371
477
  return _add_cors_headers(request, response)
372
478
 
@@ -391,7 +497,7 @@ def register_node(request):
391
497
 
392
498
  _announce_visitor_join(node, relation_value)
393
499
 
394
- response = JsonResponse({"id": node.id})
500
+ response = JsonResponse({"id": node.id, "uuid": str(node.uuid)})
395
501
  return _add_cors_headers(request, response)
396
502
 
397
503
 
@@ -522,6 +628,293 @@ def import_rfids(request):
522
628
  )
523
629
 
524
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
+
525
918
  @csrf_exempt
526
919
  @api_login_required
527
920
  def public_node_endpoint(request, endpoint):
@@ -584,100 +977,99 @@ def net_message(request):
584
977
  except Exception:
585
978
  return JsonResponse({"detail": "invalid signature"}, status=403)
586
979
 
587
- msg_uuid = data.get("uuid")
588
- subject = data.get("subject", "")
589
- body = data.get("body", "")
590
- attachments = NetMessage.normalize_attachments(data.get("attachments"))
591
- reach_name = data.get("reach")
592
- reach_role = None
593
- if reach_name:
594
- reach_role = NodeRole.objects.filter(name=reach_name).first()
595
- filter_node_uuid = data.get("filter_node")
596
- filter_node = None
597
- if filter_node_uuid:
598
- filter_node = Node.objects.filter(uuid=filter_node_uuid).first()
599
- filter_feature_slug = data.get("filter_node_feature")
600
- filter_feature = None
601
- if filter_feature_slug:
602
- filter_feature = NodeFeature.objects.filter(slug=filter_feature_slug).first()
603
- filter_role_name = data.get("filter_node_role")
604
- filter_role = None
605
- if filter_role_name:
606
- filter_role = NodeRole.objects.filter(name=filter_role_name).first()
607
- filter_relation_value = data.get("filter_current_relation")
608
- filter_relation = ""
609
- if filter_relation_value:
610
- relation = Node.normalize_relation(filter_relation_value)
611
- filter_relation = relation.value if relation else ""
612
- filter_installed_version = (data.get("filter_installed_version") or "")[:20]
613
- filter_installed_revision = (data.get("filter_installed_revision") or "")[:40]
614
- seen = data.get("seen", [])
615
- origin_id = data.get("origin")
616
- origin_node = None
617
- if origin_id:
618
- origin_node = Node.objects.filter(uuid=origin_id).first()
619
- if not origin_node:
620
- origin_node = node
621
- if not msg_uuid:
622
- return JsonResponse({"detail": "uuid required"}, status=400)
623
- msg, created = NetMessage.objects.get_or_create(
624
- uuid=msg_uuid,
625
- defaults={
626
- "subject": subject[:64],
627
- "body": body[:256],
628
- "reach": reach_role,
629
- "node_origin": origin_node,
630
- "attachments": attachments or None,
631
- "filter_node": filter_node,
632
- "filter_node_feature": filter_feature,
633
- "filter_node_role": filter_role,
634
- "filter_current_relation": filter_relation,
635
- "filter_installed_version": filter_installed_version,
636
- "filter_installed_revision": filter_installed_revision,
637
- },
638
- )
639
- if not created:
640
- msg.subject = subject[:64]
641
- msg.body = body[:256]
642
- update_fields = ["subject", "body"]
643
- if reach_role and msg.reach_id != reach_role.id:
644
- msg.reach = reach_role
645
- update_fields.append("reach")
646
- if msg.node_origin_id is None and origin_node:
647
- msg.node_origin = origin_node
648
- update_fields.append("node_origin")
649
- if attachments and msg.attachments != attachments:
650
- msg.attachments = attachments
651
- update_fields.append("attachments")
652
- field_updates = {
653
- "filter_node": filter_node,
654
- "filter_node_feature": filter_feature,
655
- "filter_node_role": filter_role,
656
- "filter_current_relation": filter_relation,
657
- "filter_installed_version": filter_installed_version,
658
- "filter_installed_revision": filter_installed_revision,
659
- }
660
- for field, value in field_updates.items():
661
- if getattr(msg, field) != value:
662
- setattr(msg, field, value)
663
- update_fields.append(field)
664
- msg.save(update_fields=update_fields)
665
- if attachments:
666
- msg.apply_attachments(attachments)
667
- 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)
668
984
  return JsonResponse({"status": "propagated", "complete": msg.complete})
669
985
 
670
986
 
671
- def last_net_message(request):
672
- """Return the most recent :class:`NetMessage`."""
987
+ @csrf_exempt
988
+ def net_message_pull(request):
989
+ """Allow downstream nodes to retrieve queued network messages."""
673
990
 
674
- msg = NetMessage.objects.order_by("-created").first()
675
- if not msg:
676
- return JsonResponse({"subject": "", "body": "", "admin_url": ""})
677
- return JsonResponse(
678
- {
679
- "subject": msg.subject,
680
- "body": msg.body,
681
- "admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
682
- }
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")
683
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})