arthexis 0.1.19__py3-none-any.whl → 0.1.21__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/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
 
@@ -113,6 +204,60 @@ def _get_host_domain(request) -> str:
113
204
  return ""
114
205
 
115
206
 
207
+ def _normalize_port(value: str | int | None) -> int | None:
208
+ """Return ``value`` as an integer port number when valid."""
209
+
210
+ if value in (None, ""):
211
+ return None
212
+ try:
213
+ port = int(value)
214
+ except (TypeError, ValueError):
215
+ return None
216
+ if port <= 0 or port > 65535:
217
+ return None
218
+ return port
219
+
220
+
221
+ def _get_host_port(request) -> int | None:
222
+ """Return the port implied by the current request if available."""
223
+
224
+ forwarded_port = request.headers.get("X-Forwarded-Port") or request.META.get(
225
+ "HTTP_X_FORWARDED_PORT"
226
+ )
227
+ port = _normalize_port(forwarded_port)
228
+ if port:
229
+ return port
230
+
231
+ try:
232
+ host = request.get_host()
233
+ except Exception: # pragma: no cover - defensive
234
+ host = ""
235
+ if host:
236
+ _, host_port = split_domain_port(host)
237
+ port = _normalize_port(host_port)
238
+ if port:
239
+ return port
240
+
241
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
242
+ if forwarded_proto:
243
+ scheme = forwarded_proto.split(",")[0].strip().lower()
244
+ if scheme == "https":
245
+ return 443
246
+ if scheme == "http":
247
+ return 80
248
+
249
+ if request.is_secure():
250
+ return 443
251
+
252
+ scheme = getattr(request, "scheme", "")
253
+ if scheme.lower() == "https":
254
+ return 443
255
+ if scheme.lower() == "http":
256
+ return 80
257
+
258
+ return None
259
+
260
+
116
261
  def _get_advertised_address(request, node) -> str:
117
262
  """Return the best address for the client to reach this node."""
118
263
 
@@ -154,6 +299,11 @@ def node_info(request):
154
299
  token = request.GET.get("token", "")
155
300
  host_domain = _get_host_domain(request)
156
301
  advertised_address = _get_advertised_address(request, node)
302
+ advertised_port = node.port
303
+ if host_domain:
304
+ host_port = _get_host_port(request)
305
+ if host_port:
306
+ advertised_port = host_port
157
307
  if host_domain:
158
308
  hostname = host_domain
159
309
  if advertised_address and advertised_address != node.address:
@@ -166,7 +316,7 @@ def node_info(request):
166
316
  data = {
167
317
  "hostname": hostname,
168
318
  "address": address,
169
- "port": node.port,
319
+ "port": advertised_port,
170
320
  "mac_address": node.mac_address,
171
321
  "public_key": node.public_key,
172
322
  "features": list(node.features.values_list("slug", flat=True)),
@@ -315,6 +465,12 @@ def register_node(request):
315
465
  "address": address,
316
466
  "port": port,
317
467
  }
468
+ role_name = str(data.get("role") or data.get("role_name") or "").strip()
469
+ desired_role = None
470
+ if role_name and (verified or request.user.is_authenticated):
471
+ desired_role = NodeRole.objects.filter(name=role_name).first()
472
+ if desired_role:
473
+ defaults["role"] = desired_role
318
474
  if verified:
319
475
  defaults["public_key"] = public_key
320
476
  if installed_version is not None:
@@ -349,6 +505,9 @@ def register_node(request):
349
505
  if relation_value is not None and node.current_relation != relation_value:
350
506
  node.current_relation = relation_value
351
507
  update_fields.append("current_relation")
508
+ if desired_role and node.role_id != desired_role.id:
509
+ node.role = desired_role
510
+ update_fields.append("role")
352
511
  node.save(update_fields=update_fields)
353
512
  current_version = (node.installed_version or "").strip()
354
513
  current_revision = (node.installed_revision or "").strip()
@@ -368,7 +527,11 @@ def register_node(request):
368
527
  feature_list = list(features)
369
528
  node.update_manual_features(feature_list)
370
529
  response = JsonResponse(
371
- {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
530
+ {
531
+ "id": node.id,
532
+ "uuid": str(node.uuid),
533
+ "detail": f"Node already exists (id: {node.id})",
534
+ }
372
535
  )
373
536
  return _add_cors_headers(request, response)
374
537
 
@@ -393,7 +556,7 @@ def register_node(request):
393
556
 
394
557
  _announce_visitor_join(node, relation_value)
395
558
 
396
- response = JsonResponse({"id": node.id})
559
+ response = JsonResponse({"id": node.id, "uuid": str(node.uuid)})
397
560
  return _add_cors_headers(request, response)
398
561
 
399
562
 
@@ -524,6 +687,293 @@ def import_rfids(request):
524
687
  )
525
688
 
526
689
 
690
+ @csrf_exempt
691
+ def proxy_session(request):
692
+ """Create a proxy login session for a remote administrator."""
693
+
694
+ if request.method != "POST":
695
+ return JsonResponse({"detail": "POST required"}, status=405)
696
+
697
+ try:
698
+ payload = json.loads(request.body.decode() or "{}")
699
+ except json.JSONDecodeError:
700
+ return JsonResponse({"detail": "invalid json"}, status=400)
701
+
702
+ requester = payload.get("requester")
703
+ if not requester:
704
+ return JsonResponse({"detail": "requester required"}, status=400)
705
+
706
+ node, error_response = _load_signed_node(request, requester)
707
+ if error_response is not None:
708
+ return error_response
709
+
710
+ user_payload = payload.get("user") or {}
711
+ username = str(user_payload.get("username", "")).strip()
712
+ if not username:
713
+ return JsonResponse({"detail": "username required"}, status=400)
714
+
715
+ User = get_user_model()
716
+ user, created = User.objects.get_or_create(
717
+ username=username,
718
+ defaults={
719
+ "email": user_payload.get("email", ""),
720
+ "first_name": user_payload.get("first_name", ""),
721
+ "last_name": user_payload.get("last_name", ""),
722
+ },
723
+ )
724
+
725
+ updates: list[str] = []
726
+ for field in ("first_name", "last_name", "email"):
727
+ value = user_payload.get(field)
728
+ if isinstance(value, str) and getattr(user, field) != value:
729
+ setattr(user, field, value)
730
+ updates.append(field)
731
+
732
+ if created:
733
+ user.set_unusable_password()
734
+ updates.append("password")
735
+
736
+ staff_flag = user_payload.get("is_staff")
737
+ if staff_flag is not None:
738
+ is_staff = bool(staff_flag)
739
+ else:
740
+ is_staff = True
741
+ if user.is_staff != is_staff:
742
+ user.is_staff = is_staff
743
+ updates.append("is_staff")
744
+
745
+ superuser_flag = user_payload.get("is_superuser")
746
+ if superuser_flag is not None:
747
+ is_superuser = bool(superuser_flag)
748
+ if user.is_superuser != is_superuser:
749
+ user.is_superuser = is_superuser
750
+ updates.append("is_superuser")
751
+
752
+ if not user.is_active:
753
+ user.is_active = True
754
+ updates.append("is_active")
755
+
756
+ if updates:
757
+ user.save(update_fields=updates)
758
+
759
+ _assign_groups_and_permissions(user, user_payload)
760
+
761
+ target_path = _sanitize_proxy_target(payload.get("target"), request)
762
+ nonce = secrets.token_urlsafe(24)
763
+ cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
764
+ cache.set(cache_key, {"user_id": user.pk}, PROXY_TOKEN_TIMEOUT)
765
+
766
+ signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
767
+ token = signer.sign_object({"user": user.pk, "next": target_path, "nonce": nonce})
768
+ login_url = request.build_absolute_uri(
769
+ reverse("node-proxy-login", args=[token])
770
+ )
771
+ expires = timezone.now() + timedelta(seconds=PROXY_TOKEN_TIMEOUT)
772
+
773
+ return JsonResponse({"login_url": login_url, "expires": expires.isoformat()})
774
+
775
+
776
+ @csrf_exempt
777
+ def proxy_login(request, token):
778
+ """Redeem a proxy login token and redirect to the target path."""
779
+
780
+ signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
781
+ try:
782
+ payload = signer.unsign_object(token, max_age=PROXY_TOKEN_TIMEOUT)
783
+ except SignatureExpired:
784
+ return HttpResponse(status=410)
785
+ except BadSignature:
786
+ return HttpResponse(status=400)
787
+
788
+ nonce = payload.get("nonce")
789
+ if not nonce:
790
+ return HttpResponse(status=400)
791
+
792
+ cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
793
+ cache_payload = cache.get(cache_key)
794
+ if not cache_payload:
795
+ return HttpResponse(status=410)
796
+ cache.delete(cache_key)
797
+
798
+ user_id = cache_payload.get("user_id")
799
+ if not user_id:
800
+ return HttpResponse(status=403)
801
+
802
+ User = get_user_model()
803
+ user = User.objects.filter(pk=user_id).first()
804
+ if not user or not user.is_active:
805
+ return HttpResponse(status=403)
806
+
807
+ backend = getattr(user, "backend", "")
808
+ if not backend:
809
+ backends = getattr(settings, "AUTHENTICATION_BACKENDS", None) or ()
810
+ backend = backends[0] if backends else "django.contrib.auth.backends.ModelBackend"
811
+ login(request, user, backend=backend)
812
+
813
+ next_path = payload.get("next") or reverse("admin:index")
814
+ if not url_has_allowed_host_and_scheme(
815
+ next_path,
816
+ allowed_hosts={request.get_host()},
817
+ require_https=request.is_secure(),
818
+ ):
819
+ next_path = reverse("admin:index")
820
+
821
+ return redirect(next_path)
822
+
823
+
824
+ def _suite_model_name(meta) -> str:
825
+ base = str(meta.verbose_name_plural or meta.verbose_name or meta.object_name)
826
+ normalized = re.sub(r"[^0-9A-Za-z]+", " ", base).title().replace(" ", "")
827
+ return normalized or meta.object_name
828
+
829
+
830
+ @csrf_exempt
831
+ def proxy_execute(request):
832
+ """Execute model operations on behalf of a remote interface node."""
833
+
834
+ if request.method != "POST":
835
+ return JsonResponse({"detail": "POST required"}, status=405)
836
+
837
+ try:
838
+ payload = json.loads(request.body.decode() or "{}")
839
+ except json.JSONDecodeError:
840
+ return JsonResponse({"detail": "invalid json"}, status=400)
841
+
842
+ requester = payload.get("requester")
843
+ if not requester:
844
+ return JsonResponse({"detail": "requester required"}, status=400)
845
+
846
+ node, error_response = _load_signed_node(request, requester)
847
+ if error_response is not None:
848
+ return error_response
849
+
850
+ action = str(payload.get("action", "")).strip().lower()
851
+ if not action:
852
+ return JsonResponse({"detail": "action required"}, status=400)
853
+
854
+ credentials = payload.get("credentials") or {}
855
+ username = str(credentials.get("username", "")).strip()
856
+ password_value = credentials.get("password")
857
+ password = password_value if isinstance(password_value, str) else str(password_value or "")
858
+ if not username or not password:
859
+ return JsonResponse({"detail": "credentials required"}, status=401)
860
+
861
+ User = get_user_model()
862
+ existing_user = User.objects.filter(username=username).first()
863
+ auth_user = authenticate(request=None, username=username, password=password)
864
+
865
+ if auth_user is None:
866
+ if existing_user is not None:
867
+ return JsonResponse({"detail": "authentication failed"}, status=403)
868
+ auth_user = User.objects.create_user(
869
+ username=username,
870
+ password=password,
871
+ email=str(credentials.get("email", "")),
872
+ )
873
+ auth_user.is_staff = True
874
+ auth_user.is_superuser = True
875
+ auth_user.first_name = str(credentials.get("first_name", ""))
876
+ auth_user.last_name = str(credentials.get("last_name", ""))
877
+ auth_user.save()
878
+ else:
879
+ updates: list[str] = []
880
+ for field in ("first_name", "last_name", "email"):
881
+ value = credentials.get(field)
882
+ if isinstance(value, str) and getattr(auth_user, field) != value:
883
+ setattr(auth_user, field, value)
884
+ updates.append(field)
885
+ for flag in ("is_staff", "is_superuser"):
886
+ if flag in credentials:
887
+ desired = bool(credentials.get(flag))
888
+ if getattr(auth_user, flag) != desired:
889
+ setattr(auth_user, flag, desired)
890
+ updates.append(flag)
891
+ if updates:
892
+ auth_user.save(update_fields=updates)
893
+
894
+ if not auth_user.is_active:
895
+ return JsonResponse({"detail": "user inactive"}, status=403)
896
+
897
+ _assign_groups_and_permissions(auth_user, credentials)
898
+
899
+ model_label = payload.get("model")
900
+ model = None
901
+ if action != "schema":
902
+ if not isinstance(model_label, str) or "." not in model_label:
903
+ return JsonResponse({"detail": "model required"}, status=400)
904
+ app_label, model_name = model_label.split(".", 1)
905
+ model = apps.get_model(app_label, model_name)
906
+ if model is None:
907
+ return JsonResponse({"detail": "model not found"}, status=404)
908
+
909
+ if action == "schema":
910
+ models_payload = []
911
+ for registered_model in apps.get_models():
912
+ meta = registered_model._meta
913
+ models_payload.append(
914
+ {
915
+ "app_label": meta.app_label,
916
+ "model": meta.model_name,
917
+ "object_name": meta.object_name,
918
+ "verbose_name": str(meta.verbose_name),
919
+ "verbose_name_plural": str(meta.verbose_name_plural),
920
+ "suite_name": _suite_model_name(meta),
921
+ }
922
+ )
923
+ return JsonResponse({"models": models_payload})
924
+
925
+ action_perm = {
926
+ "list": "view",
927
+ "get": "view",
928
+ "create": "add",
929
+ "update": "change",
930
+ "delete": "delete",
931
+ }.get(action)
932
+
933
+ if action_perm and not auth_user.is_superuser:
934
+ perm_codename = f"{model._meta.app_label}.{action_perm}_{model._meta.model_name}"
935
+ if not auth_user.has_perm(perm_codename):
936
+ return JsonResponse({"detail": "forbidden"}, status=403)
937
+
938
+ try:
939
+ if action == "list":
940
+ filters = payload.get("filters") or {}
941
+ if filters and not isinstance(filters, Mapping):
942
+ return JsonResponse({"detail": "filters must be a mapping"}, status=400)
943
+ queryset = model._default_manager.all()
944
+ if filters:
945
+ queryset = queryset.filter(**filters)
946
+ limit = payload.get("limit")
947
+ if limit is not None:
948
+ try:
949
+ limit_value = int(limit)
950
+ if limit_value > 0:
951
+ queryset = queryset[:limit_value]
952
+ except (TypeError, ValueError):
953
+ pass
954
+ data = serializers.serialize("python", queryset)
955
+ return JsonResponse({"objects": data})
956
+
957
+ if action == "get":
958
+ filters = payload.get("filters") or {}
959
+ if filters and not isinstance(filters, Mapping):
960
+ return JsonResponse({"detail": "filters must be a mapping"}, status=400)
961
+ lookup = dict(filters)
962
+ if not lookup and "pk" in payload:
963
+ lookup = {"pk": payload.get("pk")}
964
+ if not lookup:
965
+ return JsonResponse({"detail": "lookup required"}, status=400)
966
+ obj = model._default_manager.get(**lookup)
967
+ data = serializers.serialize("python", [obj])[0]
968
+ return JsonResponse({"object": data})
969
+ except model.DoesNotExist:
970
+ return JsonResponse({"detail": "not found"}, status=404)
971
+ except Exception as exc:
972
+ return JsonResponse({"detail": str(exc)}, status=400)
973
+
974
+ return JsonResponse({"detail": "unsupported action"}, status=400)
975
+
976
+
527
977
  @csrf_exempt
528
978
  @api_login_required
529
979
  def public_node_endpoint(request, endpoint):
@@ -586,85 +1036,99 @@ def net_message(request):
586
1036
  except Exception:
587
1037
  return JsonResponse({"detail": "invalid signature"}, status=403)
588
1038
 
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)
1039
+ try:
1040
+ msg = NetMessage.receive_payload(data, sender=node)
1041
+ except ValueError as exc:
1042
+ return JsonResponse({"detail": str(exc)}, status=400)
670
1043
  return JsonResponse({"status": "propagated", "complete": msg.complete})
1044
+
1045
+
1046
+ @csrf_exempt
1047
+ def net_message_pull(request):
1048
+ """Allow downstream nodes to retrieve queued network messages."""
1049
+
1050
+ if request.method != "POST":
1051
+ return JsonResponse({"detail": "POST required"}, status=405)
1052
+ try:
1053
+ data = json.loads(request.body.decode() or "{}")
1054
+ except json.JSONDecodeError:
1055
+ return JsonResponse({"detail": "invalid json"}, status=400)
1056
+
1057
+ requester = data.get("requester")
1058
+ if not requester:
1059
+ return JsonResponse({"detail": "requester required"}, status=400)
1060
+ signature = request.headers.get("X-Signature")
1061
+ if not signature:
1062
+ return JsonResponse({"detail": "signature required"}, status=403)
1063
+
1064
+ node = Node.objects.filter(uuid=requester).first()
1065
+ if not node or not node.public_key:
1066
+ return JsonResponse({"detail": "unknown requester"}, status=403)
1067
+ try:
1068
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
1069
+ public_key.verify(
1070
+ base64.b64decode(signature),
1071
+ request.body,
1072
+ padding.PKCS1v15(),
1073
+ hashes.SHA256(),
1074
+ )
1075
+ except Exception:
1076
+ return JsonResponse({"detail": "invalid signature"}, status=403)
1077
+
1078
+ local = Node.get_local()
1079
+ if not local:
1080
+ return JsonResponse({"detail": "local node unavailable"}, status=503)
1081
+ private_key = local.get_private_key()
1082
+ if not private_key:
1083
+ return JsonResponse({"detail": "signing unavailable"}, status=503)
1084
+
1085
+ entries = (
1086
+ PendingNetMessage.objects.select_related(
1087
+ "message",
1088
+ "message__filter_node",
1089
+ "message__filter_node_feature",
1090
+ "message__filter_node_role",
1091
+ "message__node_origin",
1092
+ )
1093
+ .filter(node=node)
1094
+ .order_by("queued_at")
1095
+ )
1096
+ messages: list[dict[str, object]] = []
1097
+ expired_ids: list[int] = []
1098
+ delivered_ids: list[int] = []
1099
+
1100
+ origin_fallback = str(local.uuid)
1101
+
1102
+ for entry in entries:
1103
+ if entry.is_stale:
1104
+ expired_ids.append(entry.pk)
1105
+ continue
1106
+ message = entry.message
1107
+ reach_source = message.filter_node_role or message.reach
1108
+ reach_name = reach_source.name if reach_source else None
1109
+ origin_node = message.node_origin
1110
+ origin_uuid = str(origin_node.uuid) if origin_node else origin_fallback
1111
+ sender_id = str(local.uuid)
1112
+ seen = [str(value) for value in entry.seen]
1113
+ payload = message._build_payload(
1114
+ sender_id=sender_id,
1115
+ origin_uuid=origin_uuid,
1116
+ reach_name=reach_name,
1117
+ seen=seen,
1118
+ )
1119
+ payload_json = message._serialize_payload(payload)
1120
+ payload_signature = message._sign_payload(payload_json, private_key)
1121
+ if not payload_signature:
1122
+ logger.warning(
1123
+ "Unable to sign queued NetMessage %s for node %s", message.pk, node.pk
1124
+ )
1125
+ continue
1126
+ messages.append({"payload": payload, "signature": payload_signature})
1127
+ delivered_ids.append(entry.pk)
1128
+
1129
+ if expired_ids:
1130
+ PendingNetMessage.objects.filter(pk__in=expired_ids).delete()
1131
+ if delivered_ids:
1132
+ PendingNetMessage.objects.filter(pk__in=delivered_ids).delete()
1133
+
1134
+ return JsonResponse({"messages": messages})