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.
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/METADATA +3 -3
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/RECORD +38 -38
- core/admin.py +142 -1
- core/backends.py +8 -2
- core/environment.py +221 -4
- core/models.py +124 -25
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/sigil_builder.py +2 -2
- core/tasks.py +24 -1
- core/tests.py +1 -0
- core/views.py +70 -36
- nodes/admin.py +133 -1
- nodes/models.py +294 -48
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +532 -15
- nodes/urls.py +4 -0
- nodes/views.py +500 -95
- ocpp/admin.py +101 -3
- ocpp/consumers.py +106 -9
- ocpp/models.py +83 -1
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +100 -9
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +101 -28
- pages/context_processors.py +15 -9
- pages/defaults.py +1 -1
- pages/module_defaults.py +5 -5
- pages/tests.py +110 -38
- pages/urls.py +1 -0
- pages/views.py +108 -8
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.19.dist-info → arthexis-0.1.20.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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})
|