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.
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/METADATA +39 -12
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/RECORD +44 -44
- config/settings.py +1 -5
- 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/system.py +125 -0
- core/tasks.py +24 -23
- core/tests.py +1 -0
- core/views.py +105 -40
- nodes/admin.py +134 -3
- nodes/models.py +310 -69
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +100 -2
- nodes/tests.py +573 -48
- nodes/urls.py +4 -1
- nodes/views.py +498 -106
- ocpp/admin.py +124 -5
- ocpp/consumers.py +106 -9
- ocpp/models.py +90 -1
- ocpp/store.py +6 -4
- ocpp/tasks.py +4 -0
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +3 -1
- ocpp/tests.py +114 -10
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +166 -40
- pages/admin.py +63 -10
- pages/context_processors.py +26 -9
- pages/defaults.py +1 -1
- pages/middleware.py +3 -0
- pages/models.py +35 -0
- pages/module_defaults.py +5 -5
- pages/tests.py +280 -65
- pages/urls.py +3 -1
- pages/views.py +176 -29
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/WHEEL +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.18.dist-info → arthexis-0.1.20.dist-info}/top_level.txt +0 -0
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("
|
|
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.
|
|
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
|
|
|
@@ -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
|
-
{
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
672
|
-
|
|
987
|
+
@csrf_exempt
|
|
988
|
+
def net_message_pull(request):
|
|
989
|
+
"""Allow downstream nodes to retrieve queued network messages."""
|
|
673
990
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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})
|