arthexis 0.1.16__py3-none-any.whl → 0.1.26__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/METADATA +84 -35
- arthexis-0.1.26.dist-info/RECORD +111 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +15 -30
- config/urls.py +53 -1
- core/admin.py +540 -450
- core/apps.py +0 -6
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1566 -203
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +268 -2
- core/tasks.py +174 -48
- core/tests.py +314 -16
- core/user_data.py +42 -2
- core/views.py +278 -183
- nodes/admin.py +557 -65
- nodes/apps.py +11 -0
- nodes/models.py +658 -113
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +97 -2
- nodes/tests.py +1212 -116
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1239 -154
- ocpp/admin.py +979 -152
- ocpp/consumers.py +268 -28
- ocpp/models.py +488 -3
- ocpp/network.py +398 -0
- ocpp/store.py +6 -4
- ocpp/tasks.py +296 -2
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +121 -4
- ocpp/tests.py +950 -11
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +3 -3
- ocpp/views.py +596 -51
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +26 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +77 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +885 -109
- pages/urls.py +13 -2
- pages/utils.py +70 -0
- pages/views.py +558 -55
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/views.py
CHANGED
|
@@ -1,37 +1,222 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import ipaddress
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
4
6
|
import socket
|
|
7
|
+
import uuid
|
|
5
8
|
from collections.abc import Mapping
|
|
9
|
+
from datetime import timedelta
|
|
6
10
|
|
|
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
|
|
11
|
+
from django.apps import apps
|
|
11
12
|
from django.conf import settings
|
|
13
|
+
from django.contrib.auth import authenticate, get_user_model, login
|
|
14
|
+
from django.contrib.auth.models import Group, Permission
|
|
15
|
+
from django.core import serializers
|
|
16
|
+
from django.core.cache import cache
|
|
17
|
+
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
|
|
18
|
+
from django.http import HttpResponse, JsonResponse
|
|
19
|
+
from django.http.request import split_domain_port
|
|
20
|
+
from django.shortcuts import get_object_or_404, redirect
|
|
12
21
|
from django.urls import reverse
|
|
13
|
-
from
|
|
22
|
+
from django.utils import timezone
|
|
23
|
+
from django.utils.dateparse import parse_datetime
|
|
14
24
|
from django.utils.cache import patch_vary_headers
|
|
25
|
+
from django.utils.http import url_has_allowed_host_and_scheme
|
|
26
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from urllib.parse import urlsplit
|
|
15
29
|
|
|
16
30
|
from utils.api import api_login_required
|
|
17
31
|
|
|
18
32
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
19
33
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
20
34
|
|
|
35
|
+
from django.db.models import Q
|
|
36
|
+
|
|
21
37
|
from core.models import RFID
|
|
38
|
+
from ocpp import store
|
|
39
|
+
from ocpp.models import Charger
|
|
40
|
+
from ocpp.network import (
|
|
41
|
+
apply_remote_charger_payload,
|
|
42
|
+
serialize_charger_for_network,
|
|
43
|
+
sync_transactions_payload,
|
|
44
|
+
)
|
|
45
|
+
from ocpp.transactions_io import export_transactions
|
|
46
|
+
from asgiref.sync import async_to_sync
|
|
22
47
|
|
|
23
48
|
from .rfid_sync import apply_rfid_payload, serialize_rfid
|
|
24
49
|
|
|
25
50
|
from .models import (
|
|
26
51
|
Node,
|
|
27
52
|
NetMessage,
|
|
28
|
-
|
|
53
|
+
PendingNetMessage,
|
|
29
54
|
NodeRole,
|
|
30
55
|
node_information_updated,
|
|
31
56
|
)
|
|
32
57
|
from .utils import capture_screenshot, save_screenshot
|
|
33
58
|
|
|
34
59
|
|
|
60
|
+
PROXY_TOKEN_SALT = "nodes.proxy.session"
|
|
61
|
+
PROXY_TOKEN_TIMEOUT = 300
|
|
62
|
+
PROXY_CACHE_PREFIX = "nodes:proxy-session:"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load_signed_node(
|
|
66
|
+
request,
|
|
67
|
+
requester_id: str,
|
|
68
|
+
*,
|
|
69
|
+
mac_address: str | None = None,
|
|
70
|
+
public_key: str | None = None,
|
|
71
|
+
):
|
|
72
|
+
signature = request.headers.get("X-Signature")
|
|
73
|
+
if not signature:
|
|
74
|
+
return None, JsonResponse({"detail": "signature required"}, status=403)
|
|
75
|
+
try:
|
|
76
|
+
signature_bytes = base64.b64decode(signature)
|
|
77
|
+
except Exception:
|
|
78
|
+
return None, JsonResponse({"detail": "invalid signature"}, status=403)
|
|
79
|
+
|
|
80
|
+
candidates: list[Node] = []
|
|
81
|
+
seen: set[int] = set()
|
|
82
|
+
|
|
83
|
+
lookup_values: list[tuple[str, str]] = []
|
|
84
|
+
if requester_id:
|
|
85
|
+
lookup_values.append(("uuid", requester_id))
|
|
86
|
+
if mac_address:
|
|
87
|
+
lookup_values.append(("mac_address__iexact", mac_address))
|
|
88
|
+
if public_key:
|
|
89
|
+
lookup_values.append(("public_key", public_key))
|
|
90
|
+
|
|
91
|
+
for field, value in lookup_values:
|
|
92
|
+
node = Node.objects.filter(**{field: value}).first()
|
|
93
|
+
if not node or not node.public_key:
|
|
94
|
+
continue
|
|
95
|
+
if node.pk is not None and node.pk in seen:
|
|
96
|
+
continue
|
|
97
|
+
if node.pk is not None:
|
|
98
|
+
seen.add(node.pk)
|
|
99
|
+
candidates.append(node)
|
|
100
|
+
|
|
101
|
+
if not candidates:
|
|
102
|
+
return None, JsonResponse({"detail": "unknown requester"}, status=403)
|
|
103
|
+
|
|
104
|
+
for node in candidates:
|
|
105
|
+
try:
|
|
106
|
+
loaded_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
107
|
+
loaded_key.verify(
|
|
108
|
+
signature_bytes,
|
|
109
|
+
request.body,
|
|
110
|
+
padding.PKCS1v15(),
|
|
111
|
+
hashes.SHA256(),
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
continue
|
|
115
|
+
return node, None
|
|
116
|
+
|
|
117
|
+
return None, JsonResponse({"detail": "invalid signature"}, status=403)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _clean_requester_hint(value, *, strip: bool = True) -> str | None:
|
|
121
|
+
if not isinstance(value, str):
|
|
122
|
+
return None
|
|
123
|
+
cleaned = value.strip() if strip else value
|
|
124
|
+
if not cleaned:
|
|
125
|
+
return None
|
|
126
|
+
return cleaned
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sanitize_proxy_target(target: str | None, request) -> str:
|
|
130
|
+
default_target = reverse("admin:index")
|
|
131
|
+
if not target:
|
|
132
|
+
return default_target
|
|
133
|
+
candidate = str(target).strip()
|
|
134
|
+
if not candidate:
|
|
135
|
+
return default_target
|
|
136
|
+
if candidate.startswith(("http://", "https://")):
|
|
137
|
+
parsed = urlsplit(candidate)
|
|
138
|
+
if not parsed.path:
|
|
139
|
+
return default_target
|
|
140
|
+
allowed = url_has_allowed_host_and_scheme(
|
|
141
|
+
candidate,
|
|
142
|
+
allowed_hosts={request.get_host()},
|
|
143
|
+
require_https=request.is_secure(),
|
|
144
|
+
)
|
|
145
|
+
if not allowed:
|
|
146
|
+
return default_target
|
|
147
|
+
path = parsed.path
|
|
148
|
+
if parsed.query:
|
|
149
|
+
path = f"{path}?{parsed.query}"
|
|
150
|
+
return path
|
|
151
|
+
if not candidate.startswith("/"):
|
|
152
|
+
candidate = f"/{candidate}"
|
|
153
|
+
return candidate
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _assign_groups_and_permissions(user, payload: Mapping) -> None:
|
|
157
|
+
groups = payload.get("groups", [])
|
|
158
|
+
group_objs: list[Group] = []
|
|
159
|
+
if isinstance(groups, (list, tuple)):
|
|
160
|
+
for name in groups:
|
|
161
|
+
if not isinstance(name, str):
|
|
162
|
+
continue
|
|
163
|
+
cleaned = name.strip()
|
|
164
|
+
if not cleaned:
|
|
165
|
+
continue
|
|
166
|
+
group, _ = Group.objects.get_or_create(name=cleaned)
|
|
167
|
+
group_objs.append(group)
|
|
168
|
+
if group_objs or user.groups.exists():
|
|
169
|
+
user.groups.set(group_objs)
|
|
170
|
+
|
|
171
|
+
permissions = payload.get("permissions", [])
|
|
172
|
+
perm_objs: list[Permission] = []
|
|
173
|
+
if isinstance(permissions, (list, tuple)):
|
|
174
|
+
for label in permissions:
|
|
175
|
+
if not isinstance(label, str):
|
|
176
|
+
continue
|
|
177
|
+
app_label, _, codename = label.partition(".")
|
|
178
|
+
if not app_label or not codename:
|
|
179
|
+
continue
|
|
180
|
+
perm = Permission.objects.filter(
|
|
181
|
+
content_type__app_label=app_label, codename=codename
|
|
182
|
+
).first()
|
|
183
|
+
if perm:
|
|
184
|
+
perm_objs.append(perm)
|
|
185
|
+
if perm_objs:
|
|
186
|
+
user.user_permissions.set(perm_objs)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _normalize_requested_chargers(values) -> list[tuple[str, int | None, object]]:
|
|
190
|
+
if not isinstance(values, list):
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
normalized: list[tuple[str, int | None, object]] = []
|
|
194
|
+
for entry in values:
|
|
195
|
+
if not isinstance(entry, Mapping):
|
|
196
|
+
continue
|
|
197
|
+
serial = Charger.normalize_serial(entry.get("charger_id"))
|
|
198
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
199
|
+
continue
|
|
200
|
+
connector = entry.get("connector_id")
|
|
201
|
+
if connector in ("", None):
|
|
202
|
+
connector_value = None
|
|
203
|
+
elif isinstance(connector, int):
|
|
204
|
+
connector_value = connector
|
|
205
|
+
else:
|
|
206
|
+
try:
|
|
207
|
+
connector_value = int(str(connector))
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
connector_value = None
|
|
210
|
+
since_raw = entry.get("since")
|
|
211
|
+
since_dt = None
|
|
212
|
+
if isinstance(since_raw, str):
|
|
213
|
+
since_dt = parse_datetime(since_raw)
|
|
214
|
+
if since_dt is not None and timezone.is_naive(since_dt):
|
|
215
|
+
since_dt = timezone.make_aware(since_dt, timezone.get_current_timezone())
|
|
216
|
+
normalized.append((serial, connector_value, since_dt))
|
|
217
|
+
return normalized
|
|
218
|
+
|
|
219
|
+
|
|
35
220
|
def _get_client_ip(request):
|
|
36
221
|
"""Return the client IP from the request headers."""
|
|
37
222
|
|
|
@@ -104,6 +289,8 @@ def _get_host_domain(request) -> str:
|
|
|
104
289
|
domain, _ = split_domain_port(host)
|
|
105
290
|
if not domain:
|
|
106
291
|
return ""
|
|
292
|
+
if domain.lower() == "localhost":
|
|
293
|
+
return ""
|
|
107
294
|
try:
|
|
108
295
|
ipaddress.ip_address(domain)
|
|
109
296
|
except ValueError:
|
|
@@ -111,6 +298,60 @@ def _get_host_domain(request) -> str:
|
|
|
111
298
|
return ""
|
|
112
299
|
|
|
113
300
|
|
|
301
|
+
def _normalize_port(value: str | int | None) -> int | None:
|
|
302
|
+
"""Return ``value`` as an integer port number when valid."""
|
|
303
|
+
|
|
304
|
+
if value in (None, ""):
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
port = int(value)
|
|
308
|
+
except (TypeError, ValueError):
|
|
309
|
+
return None
|
|
310
|
+
if port <= 0 or port > 65535:
|
|
311
|
+
return None
|
|
312
|
+
return port
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _get_host_port(request) -> int | None:
|
|
316
|
+
"""Return the port implied by the current request if available."""
|
|
317
|
+
|
|
318
|
+
forwarded_port = request.headers.get("X-Forwarded-Port") or request.META.get(
|
|
319
|
+
"HTTP_X_FORWARDED_PORT"
|
|
320
|
+
)
|
|
321
|
+
port = _normalize_port(forwarded_port)
|
|
322
|
+
if port:
|
|
323
|
+
return port
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
host = request.get_host()
|
|
327
|
+
except Exception: # pragma: no cover - defensive
|
|
328
|
+
host = ""
|
|
329
|
+
if host:
|
|
330
|
+
_, host_port = split_domain_port(host)
|
|
331
|
+
port = _normalize_port(host_port)
|
|
332
|
+
if port:
|
|
333
|
+
return port
|
|
334
|
+
|
|
335
|
+
forwarded_proto = request.headers.get("X-Forwarded-Proto", "")
|
|
336
|
+
if forwarded_proto:
|
|
337
|
+
scheme = forwarded_proto.split(",")[0].strip().lower()
|
|
338
|
+
if scheme == "https":
|
|
339
|
+
return 443
|
|
340
|
+
if scheme == "http":
|
|
341
|
+
return 80
|
|
342
|
+
|
|
343
|
+
if request.is_secure():
|
|
344
|
+
return 443
|
|
345
|
+
|
|
346
|
+
scheme = getattr(request, "scheme", "")
|
|
347
|
+
if scheme.lower() == "https":
|
|
348
|
+
return 443
|
|
349
|
+
if scheme.lower() == "http":
|
|
350
|
+
return 80
|
|
351
|
+
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
114
355
|
def _get_advertised_address(request, node) -> str:
|
|
115
356
|
"""Return the best address for the client to reach this node."""
|
|
116
357
|
|
|
@@ -121,7 +362,7 @@ def _get_advertised_address(request, node) -> str:
|
|
|
121
362
|
host_ip = _get_host_ip(request)
|
|
122
363
|
if host_ip:
|
|
123
364
|
return host_ip
|
|
124
|
-
return node.address
|
|
365
|
+
return node.get_primary_contact() or node.address or node.hostname
|
|
125
366
|
|
|
126
367
|
|
|
127
368
|
@api_login_required
|
|
@@ -131,7 +372,10 @@ def node_list(request):
|
|
|
131
372
|
nodes = [
|
|
132
373
|
{
|
|
133
374
|
"hostname": node.hostname,
|
|
375
|
+
"network_hostname": node.network_hostname,
|
|
134
376
|
"address": node.address,
|
|
377
|
+
"ipv4_address": node.ipv4_address,
|
|
378
|
+
"ipv6_address": node.ipv6_address,
|
|
135
379
|
"port": node.port,
|
|
136
380
|
"last_seen": node.last_seen,
|
|
137
381
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
@@ -152,22 +396,29 @@ def node_info(request):
|
|
|
152
396
|
token = request.GET.get("token", "")
|
|
153
397
|
host_domain = _get_host_domain(request)
|
|
154
398
|
advertised_address = _get_advertised_address(request, node)
|
|
399
|
+
advertised_port = node.port
|
|
400
|
+
if host_domain:
|
|
401
|
+
host_port = _get_host_port(request)
|
|
402
|
+
if host_port:
|
|
403
|
+
advertised_port = host_port
|
|
155
404
|
if host_domain:
|
|
156
405
|
hostname = host_domain
|
|
157
|
-
|
|
158
|
-
address = advertised_address
|
|
159
|
-
else:
|
|
160
|
-
address = host_domain
|
|
406
|
+
address = advertised_address or host_domain
|
|
161
407
|
else:
|
|
162
408
|
hostname = node.hostname
|
|
163
|
-
address = advertised_address
|
|
409
|
+
address = advertised_address or node.address or node.network_hostname or ""
|
|
164
410
|
data = {
|
|
165
411
|
"hostname": hostname,
|
|
412
|
+
"network_hostname": node.network_hostname,
|
|
166
413
|
"address": address,
|
|
167
|
-
"
|
|
414
|
+
"ipv4_address": node.ipv4_address,
|
|
415
|
+
"ipv6_address": node.ipv6_address,
|
|
416
|
+
"port": advertised_port,
|
|
168
417
|
"mac_address": node.mac_address,
|
|
169
418
|
"public_key": node.public_key,
|
|
170
419
|
"features": list(node.features.values_list("slug", flat=True)),
|
|
420
|
+
"role": node.role.name if node.role_id else "",
|
|
421
|
+
"contact_hosts": node.get_remote_host_candidates(),
|
|
171
422
|
}
|
|
172
423
|
|
|
173
424
|
if token:
|
|
@@ -211,7 +462,14 @@ def _add_cors_headers(request, response):
|
|
|
211
462
|
def _node_display_name(node: Node) -> str:
|
|
212
463
|
"""Return a human-friendly name for ``node`` suitable for messaging."""
|
|
213
464
|
|
|
214
|
-
for attr in (
|
|
465
|
+
for attr in (
|
|
466
|
+
"hostname",
|
|
467
|
+
"network_hostname",
|
|
468
|
+
"public_endpoint",
|
|
469
|
+
"address",
|
|
470
|
+
"ipv6_address",
|
|
471
|
+
"ipv4_address",
|
|
472
|
+
):
|
|
215
473
|
value = getattr(node, attr, "") or ""
|
|
216
474
|
value = value.strip()
|
|
217
475
|
if value:
|
|
@@ -263,10 +521,13 @@ def register_node(request):
|
|
|
263
521
|
else:
|
|
264
522
|
features = data.get("features")
|
|
265
523
|
|
|
266
|
-
hostname = data.get("hostname")
|
|
267
|
-
address = data.get("address")
|
|
524
|
+
hostname = (data.get("hostname") or "").strip()
|
|
525
|
+
address = (data.get("address") or "").strip()
|
|
526
|
+
network_hostname = (data.get("network_hostname") or "").strip()
|
|
527
|
+
ipv4_address = (data.get("ipv4_address") or "").strip()
|
|
528
|
+
ipv6_address = (data.get("ipv6_address") or "").strip()
|
|
268
529
|
port = data.get("port", 8000)
|
|
269
|
-
mac_address = data.get("mac_address")
|
|
530
|
+
mac_address = (data.get("mac_address") or "").strip()
|
|
270
531
|
public_key = data.get("public_key")
|
|
271
532
|
token = data.get("token")
|
|
272
533
|
signature = data.get("signature")
|
|
@@ -282,12 +543,27 @@ def register_node(request):
|
|
|
282
543
|
Node.normalize_relation(raw_relation) if relation_present else None
|
|
283
544
|
)
|
|
284
545
|
|
|
285
|
-
if not hostname or not
|
|
546
|
+
if not hostname or not mac_address:
|
|
547
|
+
response = JsonResponse(
|
|
548
|
+
{"detail": "hostname and mac_address required"}, status=400
|
|
549
|
+
)
|
|
550
|
+
return _add_cors_headers(request, response)
|
|
551
|
+
|
|
552
|
+
if not any([address, network_hostname, ipv4_address, ipv6_address]):
|
|
286
553
|
response = JsonResponse(
|
|
287
|
-
{
|
|
554
|
+
{
|
|
555
|
+
"detail": "at least one of address, network_hostname, "
|
|
556
|
+
"ipv4_address or ipv6_address must be provided",
|
|
557
|
+
},
|
|
558
|
+
status=400,
|
|
288
559
|
)
|
|
289
560
|
return _add_cors_headers(request, response)
|
|
290
561
|
|
|
562
|
+
try:
|
|
563
|
+
port = int(port)
|
|
564
|
+
except (TypeError, ValueError):
|
|
565
|
+
port = 8000
|
|
566
|
+
|
|
291
567
|
verified = False
|
|
292
568
|
if public_key and token and signature:
|
|
293
569
|
try:
|
|
@@ -308,11 +584,36 @@ def register_node(request):
|
|
|
308
584
|
return _add_cors_headers(request, response)
|
|
309
585
|
|
|
310
586
|
mac_address = mac_address.lower()
|
|
587
|
+
address_value = address or None
|
|
588
|
+
ipv4_value = ipv4_address or None
|
|
589
|
+
ipv6_value = ipv6_address or None
|
|
590
|
+
|
|
591
|
+
for candidate in (address, network_hostname, hostname):
|
|
592
|
+
candidate = (candidate or "").strip()
|
|
593
|
+
if not candidate:
|
|
594
|
+
continue
|
|
595
|
+
try:
|
|
596
|
+
parsed_ip = ipaddress.ip_address(candidate)
|
|
597
|
+
except ValueError:
|
|
598
|
+
continue
|
|
599
|
+
if parsed_ip.version == 4 and not ipv4_value:
|
|
600
|
+
ipv4_value = str(parsed_ip)
|
|
601
|
+
elif parsed_ip.version == 6 and not ipv6_value:
|
|
602
|
+
ipv6_value = str(parsed_ip)
|
|
311
603
|
defaults = {
|
|
312
604
|
"hostname": hostname,
|
|
313
|
-
"
|
|
605
|
+
"network_hostname": network_hostname,
|
|
606
|
+
"address": address_value,
|
|
607
|
+
"ipv4_address": ipv4_value,
|
|
608
|
+
"ipv6_address": ipv6_value,
|
|
314
609
|
"port": port,
|
|
315
610
|
}
|
|
611
|
+
role_name = str(data.get("role") or data.get("role_name") or "").strip()
|
|
612
|
+
desired_role = None
|
|
613
|
+
if role_name and (verified or request.user.is_authenticated):
|
|
614
|
+
desired_role = NodeRole.objects.filter(name=role_name).first()
|
|
615
|
+
if desired_role:
|
|
616
|
+
defaults["role"] = desired_role
|
|
316
617
|
if verified:
|
|
317
618
|
defaults["public_key"] = public_key
|
|
318
619
|
if installed_version is not None:
|
|
@@ -329,10 +630,18 @@ def register_node(request):
|
|
|
329
630
|
if not created:
|
|
330
631
|
previous_version = (node.installed_version or "").strip()
|
|
331
632
|
previous_revision = (node.installed_revision or "").strip()
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
633
|
+
update_fields = []
|
|
634
|
+
for field, value in (
|
|
635
|
+
("hostname", hostname),
|
|
636
|
+
("network_hostname", network_hostname),
|
|
637
|
+
("address", address_value),
|
|
638
|
+
("ipv4_address", ipv4_value),
|
|
639
|
+
("ipv6_address", ipv6_value),
|
|
640
|
+
("port", port),
|
|
641
|
+
):
|
|
642
|
+
if getattr(node, field) != value:
|
|
643
|
+
setattr(node, field, value)
|
|
644
|
+
update_fields.append(field)
|
|
336
645
|
if verified:
|
|
337
646
|
node.public_key = public_key
|
|
338
647
|
update_fields.append("public_key")
|
|
@@ -347,7 +656,11 @@ def register_node(request):
|
|
|
347
656
|
if relation_value is not None and node.current_relation != relation_value:
|
|
348
657
|
node.current_relation = relation_value
|
|
349
658
|
update_fields.append("current_relation")
|
|
350
|
-
node.
|
|
659
|
+
if desired_role and node.role_id != desired_role.id:
|
|
660
|
+
node.role = desired_role
|
|
661
|
+
update_fields.append("role")
|
|
662
|
+
if update_fields:
|
|
663
|
+
node.save(update_fields=update_fields)
|
|
351
664
|
current_version = (node.installed_version or "").strip()
|
|
352
665
|
current_revision = (node.installed_revision or "").strip()
|
|
353
666
|
node_information_updated.send(
|
|
@@ -366,7 +679,11 @@ def register_node(request):
|
|
|
366
679
|
feature_list = list(features)
|
|
367
680
|
node.update_manual_features(feature_list)
|
|
368
681
|
response = JsonResponse(
|
|
369
|
-
{
|
|
682
|
+
{
|
|
683
|
+
"id": node.id,
|
|
684
|
+
"uuid": str(node.uuid),
|
|
685
|
+
"detail": f"Node already exists (id: {node.id})",
|
|
686
|
+
}
|
|
370
687
|
)
|
|
371
688
|
return _add_cors_headers(request, response)
|
|
372
689
|
|
|
@@ -391,7 +708,7 @@ def register_node(request):
|
|
|
391
708
|
|
|
392
709
|
_announce_visitor_join(node, relation_value)
|
|
393
710
|
|
|
394
|
-
response = JsonResponse({"id": node.id})
|
|
711
|
+
response = JsonResponse({"id": node.id, "uuid": str(node.uuid)})
|
|
395
712
|
return _add_cors_headers(request, response)
|
|
396
713
|
|
|
397
714
|
|
|
@@ -423,26 +740,21 @@ def export_rfids(request):
|
|
|
423
740
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
424
741
|
|
|
425
742
|
requester = payload.get("requester")
|
|
426
|
-
signature = request.headers.get("X-Signature")
|
|
427
743
|
if not requester:
|
|
428
744
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
429
|
-
if not signature:
|
|
430
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
431
745
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
except Exception:
|
|
445
|
-
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
746
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
747
|
+
requester_public_key = _clean_requester_hint(
|
|
748
|
+
payload.get("requester_public_key"), strip=False
|
|
749
|
+
)
|
|
750
|
+
node, error_response = _load_signed_node(
|
|
751
|
+
request,
|
|
752
|
+
requester,
|
|
753
|
+
mac_address=requester_mac,
|
|
754
|
+
public_key=requester_public_key,
|
|
755
|
+
)
|
|
756
|
+
if error_response is not None:
|
|
757
|
+
return error_response
|
|
446
758
|
|
|
447
759
|
tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
|
|
448
760
|
|
|
@@ -462,26 +774,21 @@ def import_rfids(request):
|
|
|
462
774
|
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
463
775
|
|
|
464
776
|
requester = payload.get("requester")
|
|
465
|
-
signature = request.headers.get("X-Signature")
|
|
466
777
|
if not requester:
|
|
467
778
|
return JsonResponse({"detail": "requester required"}, status=400)
|
|
468
|
-
if not signature:
|
|
469
|
-
return JsonResponse({"detail": "signature required"}, status=403)
|
|
470
|
-
|
|
471
|
-
node = Node.objects.filter(uuid=requester).first()
|
|
472
|
-
if not node or not node.public_key:
|
|
473
|
-
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
474
779
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
780
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
781
|
+
requester_public_key = _clean_requester_hint(
|
|
782
|
+
payload.get("requester_public_key"), strip=False
|
|
783
|
+
)
|
|
784
|
+
node, error_response = _load_signed_node(
|
|
785
|
+
request,
|
|
786
|
+
requester,
|
|
787
|
+
mac_address=requester_mac,
|
|
788
|
+
public_key=requester_public_key,
|
|
789
|
+
)
|
|
790
|
+
if error_response is not None:
|
|
791
|
+
return error_response
|
|
485
792
|
|
|
486
793
|
rfids = payload.get("rfids", [])
|
|
487
794
|
if not isinstance(rfids, list):
|
|
@@ -522,6 +829,782 @@ def import_rfids(request):
|
|
|
522
829
|
)
|
|
523
830
|
|
|
524
831
|
|
|
832
|
+
@csrf_exempt
|
|
833
|
+
def network_chargers(request):
|
|
834
|
+
"""Return serialized charger information for trusted peers."""
|
|
835
|
+
|
|
836
|
+
if request.method != "POST":
|
|
837
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
838
|
+
|
|
839
|
+
try:
|
|
840
|
+
body = json.loads(request.body.decode() or "{}")
|
|
841
|
+
except json.JSONDecodeError:
|
|
842
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
843
|
+
|
|
844
|
+
requester = body.get("requester")
|
|
845
|
+
if not requester:
|
|
846
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
847
|
+
|
|
848
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
849
|
+
requester_public_key = _clean_requester_hint(
|
|
850
|
+
body.get("requester_public_key"), strip=False
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
node, error_response = _load_signed_node(
|
|
854
|
+
request,
|
|
855
|
+
requester,
|
|
856
|
+
mac_address=requester_mac,
|
|
857
|
+
public_key=requester_public_key,
|
|
858
|
+
)
|
|
859
|
+
if error_response is not None:
|
|
860
|
+
return error_response
|
|
861
|
+
|
|
862
|
+
requested = _normalize_requested_chargers(body.get("chargers") or [])
|
|
863
|
+
|
|
864
|
+
qs = Charger.objects.all()
|
|
865
|
+
local_node = Node.get_local()
|
|
866
|
+
if local_node:
|
|
867
|
+
qs = qs.filter(Q(node_origin=local_node) | Q(node_origin__isnull=True))
|
|
868
|
+
|
|
869
|
+
if requested:
|
|
870
|
+
filters = Q()
|
|
871
|
+
for serial, connector_value, _ in requested:
|
|
872
|
+
if connector_value is None:
|
|
873
|
+
filters |= Q(charger_id=serial, connector_id__isnull=True)
|
|
874
|
+
else:
|
|
875
|
+
filters |= Q(charger_id=serial, connector_id=connector_value)
|
|
876
|
+
qs = qs.filter(filters)
|
|
877
|
+
|
|
878
|
+
chargers = [serialize_charger_for_network(charger) for charger in qs]
|
|
879
|
+
|
|
880
|
+
include_transactions = bool(body.get("include_transactions"))
|
|
881
|
+
response_data: dict[str, object] = {"chargers": chargers}
|
|
882
|
+
|
|
883
|
+
if include_transactions:
|
|
884
|
+
serials = [serial for serial, _, _ in requested] or list(
|
|
885
|
+
{charger["charger_id"] for charger in chargers}
|
|
886
|
+
)
|
|
887
|
+
since_values = [since for _, _, since in requested if since]
|
|
888
|
+
start = min(since_values) if since_values else None
|
|
889
|
+
tx_payload = export_transactions(start=start, chargers=serials or None)
|
|
890
|
+
response_data["transactions"] = tx_payload
|
|
891
|
+
|
|
892
|
+
return JsonResponse(response_data)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@csrf_exempt
|
|
896
|
+
def forward_chargers(request):
|
|
897
|
+
"""Receive forwarded charger metadata and transactions from trusted peers."""
|
|
898
|
+
|
|
899
|
+
if request.method != "POST":
|
|
900
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
901
|
+
|
|
902
|
+
try:
|
|
903
|
+
body = json.loads(request.body.decode() or "{}")
|
|
904
|
+
except json.JSONDecodeError:
|
|
905
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
906
|
+
|
|
907
|
+
requester = body.get("requester")
|
|
908
|
+
if not requester:
|
|
909
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
910
|
+
|
|
911
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
912
|
+
requester_public_key = _clean_requester_hint(
|
|
913
|
+
body.get("requester_public_key"), strip=False
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
node, error_response = _load_signed_node(
|
|
917
|
+
request,
|
|
918
|
+
requester,
|
|
919
|
+
mac_address=requester_mac,
|
|
920
|
+
public_key=requester_public_key,
|
|
921
|
+
)
|
|
922
|
+
if error_response is not None:
|
|
923
|
+
return error_response
|
|
924
|
+
|
|
925
|
+
processed = 0
|
|
926
|
+
chargers_payload = body.get("chargers", [])
|
|
927
|
+
if not isinstance(chargers_payload, list):
|
|
928
|
+
chargers_payload = []
|
|
929
|
+
for entry in chargers_payload:
|
|
930
|
+
if not isinstance(entry, Mapping):
|
|
931
|
+
continue
|
|
932
|
+
charger = apply_remote_charger_payload(node, entry)
|
|
933
|
+
if charger:
|
|
934
|
+
processed += 1
|
|
935
|
+
|
|
936
|
+
imported = 0
|
|
937
|
+
transactions_payload = body.get("transactions")
|
|
938
|
+
if isinstance(transactions_payload, Mapping):
|
|
939
|
+
imported = sync_transactions_payload(transactions_payload)
|
|
940
|
+
|
|
941
|
+
return JsonResponse({"status": "ok", "chargers": processed, "transactions": imported})
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _require_local_origin(charger: Charger) -> bool:
|
|
945
|
+
local = Node.get_local()
|
|
946
|
+
if not local:
|
|
947
|
+
return charger.node_origin_id is None
|
|
948
|
+
if charger.node_origin_id is None:
|
|
949
|
+
return True
|
|
950
|
+
return charger.node_origin_id == local.pk
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _send_trigger_status(
|
|
954
|
+
charger: Charger, payload: Mapping | None = None
|
|
955
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
956
|
+
connector_value = charger.connector_id
|
|
957
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
958
|
+
if ws is None:
|
|
959
|
+
return False, "no active connection", {}
|
|
960
|
+
payload: dict[str, object] = {"requestedMessage": "StatusNotification"}
|
|
961
|
+
if connector_value is not None:
|
|
962
|
+
payload["connectorId"] = connector_value
|
|
963
|
+
message_id = uuid.uuid4().hex
|
|
964
|
+
msg = json.dumps([2, message_id, "TriggerMessage", payload])
|
|
965
|
+
try:
|
|
966
|
+
async_to_sync(ws.send)(msg)
|
|
967
|
+
except Exception as exc:
|
|
968
|
+
return False, f"failed to send TriggerMessage ({exc})", {}
|
|
969
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
970
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
971
|
+
store.register_pending_call(
|
|
972
|
+
message_id,
|
|
973
|
+
{
|
|
974
|
+
"action": "TriggerMessage",
|
|
975
|
+
"charger_id": charger.charger_id,
|
|
976
|
+
"connector_id": connector_value,
|
|
977
|
+
"log_key": log_key,
|
|
978
|
+
"trigger_target": "StatusNotification",
|
|
979
|
+
"trigger_connector": connector_value,
|
|
980
|
+
"requested_at": timezone.now(),
|
|
981
|
+
},
|
|
982
|
+
)
|
|
983
|
+
store.schedule_call_timeout(
|
|
984
|
+
message_id,
|
|
985
|
+
timeout=5.0,
|
|
986
|
+
action="TriggerMessage",
|
|
987
|
+
log_key=log_key,
|
|
988
|
+
message="TriggerMessage StatusNotification timed out",
|
|
989
|
+
)
|
|
990
|
+
return True, "requested status update", {}
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _send_get_configuration(
|
|
994
|
+
charger: Charger, payload: Mapping | None = None
|
|
995
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
996
|
+
connector_value = charger.connector_id
|
|
997
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
998
|
+
if ws is None:
|
|
999
|
+
return False, "no active connection", {}
|
|
1000
|
+
message_id = uuid.uuid4().hex
|
|
1001
|
+
msg = json.dumps([2, message_id, "GetConfiguration", {}])
|
|
1002
|
+
try:
|
|
1003
|
+
async_to_sync(ws.send)(msg)
|
|
1004
|
+
except Exception as exc:
|
|
1005
|
+
return False, f"failed to send GetConfiguration ({exc})", {}
|
|
1006
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1007
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1008
|
+
store.register_pending_call(
|
|
1009
|
+
message_id,
|
|
1010
|
+
{
|
|
1011
|
+
"action": "GetConfiguration",
|
|
1012
|
+
"charger_id": charger.charger_id,
|
|
1013
|
+
"connector_id": connector_value,
|
|
1014
|
+
"log_key": log_key,
|
|
1015
|
+
"requested_at": timezone.now(),
|
|
1016
|
+
},
|
|
1017
|
+
)
|
|
1018
|
+
store.schedule_call_timeout(
|
|
1019
|
+
message_id,
|
|
1020
|
+
timeout=5.0,
|
|
1021
|
+
action="GetConfiguration",
|
|
1022
|
+
log_key=log_key,
|
|
1023
|
+
message=(
|
|
1024
|
+
"GetConfiguration timed out: charger did not respond"
|
|
1025
|
+
" (operation may not be supported)"
|
|
1026
|
+
),
|
|
1027
|
+
)
|
|
1028
|
+
return True, "requested configuration update", {}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _send_reset(
|
|
1032
|
+
charger: Charger, payload: Mapping | None = None
|
|
1033
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1034
|
+
connector_value = charger.connector_id
|
|
1035
|
+
tx = store.get_transaction(charger.charger_id, connector_value)
|
|
1036
|
+
if tx:
|
|
1037
|
+
return False, "active session in progress", {}
|
|
1038
|
+
message_id = uuid.uuid4().hex
|
|
1039
|
+
reset_type = None
|
|
1040
|
+
if payload:
|
|
1041
|
+
reset_type = payload.get("reset_type")
|
|
1042
|
+
msg = json.dumps(
|
|
1043
|
+
[2, message_id, "Reset", {"type": (reset_type or "Soft")}]
|
|
1044
|
+
)
|
|
1045
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1046
|
+
if ws is None:
|
|
1047
|
+
return False, "no active connection", {}
|
|
1048
|
+
try:
|
|
1049
|
+
async_to_sync(ws.send)(msg)
|
|
1050
|
+
except Exception as exc:
|
|
1051
|
+
return False, f"failed to send Reset ({exc})", {}
|
|
1052
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1053
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1054
|
+
store.register_pending_call(
|
|
1055
|
+
message_id,
|
|
1056
|
+
{
|
|
1057
|
+
"action": "Reset",
|
|
1058
|
+
"charger_id": charger.charger_id,
|
|
1059
|
+
"connector_id": connector_value,
|
|
1060
|
+
"log_key": log_key,
|
|
1061
|
+
"requested_at": timezone.now(),
|
|
1062
|
+
},
|
|
1063
|
+
)
|
|
1064
|
+
store.schedule_call_timeout(
|
|
1065
|
+
message_id,
|
|
1066
|
+
timeout=5.0,
|
|
1067
|
+
action="Reset",
|
|
1068
|
+
log_key=log_key,
|
|
1069
|
+
message="Reset timed out: charger did not respond",
|
|
1070
|
+
)
|
|
1071
|
+
return True, "reset requested", {}
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _toggle_rfid(
|
|
1075
|
+
charger: Charger, payload: Mapping | None = None
|
|
1076
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1077
|
+
enable = None
|
|
1078
|
+
if payload is not None:
|
|
1079
|
+
enable = payload.get("enable")
|
|
1080
|
+
if isinstance(enable, str):
|
|
1081
|
+
enable = enable.lower() in {"1", "true", "yes", "on"}
|
|
1082
|
+
elif isinstance(enable, (int, bool)):
|
|
1083
|
+
enable = bool(enable)
|
|
1084
|
+
if enable is None:
|
|
1085
|
+
enable = not charger.require_rfid
|
|
1086
|
+
enable_bool = bool(enable)
|
|
1087
|
+
Charger.objects.filter(pk=charger.pk).update(require_rfid=enable_bool)
|
|
1088
|
+
charger.require_rfid = enable_bool
|
|
1089
|
+
detail = "RFID authentication enabled" if enable_bool else "RFID authentication disabled"
|
|
1090
|
+
return True, detail, {"require_rfid": enable_bool}
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _change_availability_remote(
|
|
1094
|
+
charger: Charger, payload: Mapping | None = None
|
|
1095
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1096
|
+
availability_type = None
|
|
1097
|
+
if payload is not None:
|
|
1098
|
+
availability_type = payload.get("availability_type")
|
|
1099
|
+
availability_label = str(availability_type or "").strip()
|
|
1100
|
+
if availability_label not in {"Operative", "Inoperative"}:
|
|
1101
|
+
return False, "invalid availability type", {}
|
|
1102
|
+
connector_value = charger.connector_id
|
|
1103
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1104
|
+
if ws is None:
|
|
1105
|
+
return False, "no active connection", {}
|
|
1106
|
+
connector_id = connector_value if connector_value is not None else 0
|
|
1107
|
+
message_id = uuid.uuid4().hex
|
|
1108
|
+
msg = json.dumps(
|
|
1109
|
+
[
|
|
1110
|
+
2,
|
|
1111
|
+
message_id,
|
|
1112
|
+
"ChangeAvailability",
|
|
1113
|
+
{"connectorId": connector_id, "type": availability_label},
|
|
1114
|
+
]
|
|
1115
|
+
)
|
|
1116
|
+
try:
|
|
1117
|
+
async_to_sync(ws.send)(msg)
|
|
1118
|
+
except Exception as exc:
|
|
1119
|
+
return False, f"failed to send ChangeAvailability ({exc})", {}
|
|
1120
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1121
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1122
|
+
timestamp = timezone.now()
|
|
1123
|
+
store.register_pending_call(
|
|
1124
|
+
message_id,
|
|
1125
|
+
{
|
|
1126
|
+
"action": "ChangeAvailability",
|
|
1127
|
+
"charger_id": charger.charger_id,
|
|
1128
|
+
"connector_id": connector_value,
|
|
1129
|
+
"availability_type": availability_label,
|
|
1130
|
+
"requested_at": timestamp,
|
|
1131
|
+
},
|
|
1132
|
+
)
|
|
1133
|
+
updates = {
|
|
1134
|
+
"availability_requested_state": availability_label,
|
|
1135
|
+
"availability_requested_at": timestamp,
|
|
1136
|
+
"availability_request_status": "",
|
|
1137
|
+
"availability_request_status_at": None,
|
|
1138
|
+
"availability_request_details": "",
|
|
1139
|
+
}
|
|
1140
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1141
|
+
for field, value in updates.items():
|
|
1142
|
+
setattr(charger, field, value)
|
|
1143
|
+
return True, f"requested ChangeAvailability {availability_label}", updates
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _set_availability_state_remote(
|
|
1147
|
+
charger: Charger, payload: Mapping | None = None
|
|
1148
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1149
|
+
availability_state = None
|
|
1150
|
+
if payload is not None:
|
|
1151
|
+
availability_state = payload.get("availability_state")
|
|
1152
|
+
availability_label = str(availability_state or "").strip()
|
|
1153
|
+
if availability_label not in {"Operative", "Inoperative"}:
|
|
1154
|
+
return False, "invalid availability state", {}
|
|
1155
|
+
timestamp = timezone.now()
|
|
1156
|
+
updates = {
|
|
1157
|
+
"availability_state": availability_label,
|
|
1158
|
+
"availability_state_updated_at": timestamp,
|
|
1159
|
+
}
|
|
1160
|
+
Charger.objects.filter(pk=charger.pk).update(**updates)
|
|
1161
|
+
for field, value in updates.items():
|
|
1162
|
+
setattr(charger, field, value)
|
|
1163
|
+
return True, f"availability marked {availability_label}", updates
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _remote_stop_transaction_remote(
|
|
1167
|
+
charger: Charger, payload: Mapping | None = None
|
|
1168
|
+
) -> tuple[bool, str, dict[str, object]]:
|
|
1169
|
+
connector_value = charger.connector_id
|
|
1170
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
1171
|
+
if ws is None:
|
|
1172
|
+
return False, "no active connection", {}
|
|
1173
|
+
tx_obj = store.get_transaction(charger.charger_id, connector_value)
|
|
1174
|
+
if tx_obj is None:
|
|
1175
|
+
return False, "no active transaction", {}
|
|
1176
|
+
message_id = uuid.uuid4().hex
|
|
1177
|
+
msg = json.dumps(
|
|
1178
|
+
[
|
|
1179
|
+
2,
|
|
1180
|
+
message_id,
|
|
1181
|
+
"RemoteStopTransaction",
|
|
1182
|
+
{"transactionId": tx_obj.pk},
|
|
1183
|
+
]
|
|
1184
|
+
)
|
|
1185
|
+
try:
|
|
1186
|
+
async_to_sync(ws.send)(msg)
|
|
1187
|
+
except Exception as exc:
|
|
1188
|
+
return False, f"failed to send RemoteStopTransaction ({exc})", {}
|
|
1189
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
1190
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
1191
|
+
store.register_pending_call(
|
|
1192
|
+
message_id,
|
|
1193
|
+
{
|
|
1194
|
+
"action": "RemoteStopTransaction",
|
|
1195
|
+
"charger_id": charger.charger_id,
|
|
1196
|
+
"connector_id": connector_value,
|
|
1197
|
+
"transaction_id": tx_obj.pk,
|
|
1198
|
+
"log_key": log_key,
|
|
1199
|
+
"requested_at": timezone.now(),
|
|
1200
|
+
},
|
|
1201
|
+
)
|
|
1202
|
+
return True, "remote stop requested", {}
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
REMOTE_ACTIONS = {
|
|
1206
|
+
"trigger-status": _send_trigger_status,
|
|
1207
|
+
"get-configuration": _send_get_configuration,
|
|
1208
|
+
"reset": _send_reset,
|
|
1209
|
+
"toggle-rfid": _toggle_rfid,
|
|
1210
|
+
"change-availability": _change_availability_remote,
|
|
1211
|
+
"set-availability-state": _set_availability_state_remote,
|
|
1212
|
+
"remote-stop": _remote_stop_transaction_remote,
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
@csrf_exempt
|
|
1217
|
+
def network_charger_action(request):
|
|
1218
|
+
"""Execute remote admin actions on behalf of trusted nodes."""
|
|
1219
|
+
|
|
1220
|
+
if request.method != "POST":
|
|
1221
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
1222
|
+
|
|
1223
|
+
try:
|
|
1224
|
+
body = json.loads(request.body.decode() or "{}")
|
|
1225
|
+
except json.JSONDecodeError:
|
|
1226
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
1227
|
+
|
|
1228
|
+
requester = body.get("requester")
|
|
1229
|
+
if not requester:
|
|
1230
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
1231
|
+
|
|
1232
|
+
requester_mac = _clean_requester_hint(body.get("requester_mac"))
|
|
1233
|
+
requester_public_key = _clean_requester_hint(
|
|
1234
|
+
body.get("requester_public_key"), strip=False
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
node, error_response = _load_signed_node(
|
|
1238
|
+
request,
|
|
1239
|
+
requester,
|
|
1240
|
+
mac_address=requester_mac,
|
|
1241
|
+
public_key=requester_public_key,
|
|
1242
|
+
)
|
|
1243
|
+
if error_response is not None:
|
|
1244
|
+
return error_response
|
|
1245
|
+
|
|
1246
|
+
serial = Charger.normalize_serial(body.get("charger_id"))
|
|
1247
|
+
if not serial or Charger.is_placeholder_serial(serial):
|
|
1248
|
+
return JsonResponse({"detail": "invalid charger"}, status=400)
|
|
1249
|
+
|
|
1250
|
+
connector = body.get("connector_id")
|
|
1251
|
+
if connector in ("", None):
|
|
1252
|
+
connector_value = None
|
|
1253
|
+
elif isinstance(connector, int):
|
|
1254
|
+
connector_value = connector
|
|
1255
|
+
else:
|
|
1256
|
+
try:
|
|
1257
|
+
connector_value = int(str(connector))
|
|
1258
|
+
except (TypeError, ValueError):
|
|
1259
|
+
return JsonResponse({"detail": "invalid connector"}, status=400)
|
|
1260
|
+
|
|
1261
|
+
charger = Charger.objects.filter(
|
|
1262
|
+
charger_id=serial, connector_id=connector_value
|
|
1263
|
+
).first()
|
|
1264
|
+
if not charger:
|
|
1265
|
+
return JsonResponse({"detail": "charger not found"}, status=404)
|
|
1266
|
+
|
|
1267
|
+
if not charger.allow_remote:
|
|
1268
|
+
return JsonResponse({"detail": "remote actions disabled"}, status=403)
|
|
1269
|
+
|
|
1270
|
+
if not _require_local_origin(charger):
|
|
1271
|
+
return JsonResponse({"detail": "charger is not managed by this node"}, status=403)
|
|
1272
|
+
|
|
1273
|
+
authorized_node_ids = {
|
|
1274
|
+
pk for pk in (charger.manager_node_id, charger.node_origin_id) if pk
|
|
1275
|
+
}
|
|
1276
|
+
if authorized_node_ids and node and node.pk not in authorized_node_ids:
|
|
1277
|
+
return JsonResponse(
|
|
1278
|
+
{"detail": "requester does not manage this charger"}, status=403
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
action = body.get("action")
|
|
1282
|
+
handler = REMOTE_ACTIONS.get(action or "")
|
|
1283
|
+
if handler is None:
|
|
1284
|
+
return JsonResponse({"detail": "unsupported action"}, status=400)
|
|
1285
|
+
|
|
1286
|
+
success, message, updates = handler(charger, body)
|
|
1287
|
+
|
|
1288
|
+
status_code = 200 if success else 409
|
|
1289
|
+
status_label = "ok" if success else "error"
|
|
1290
|
+
serialized_updates: dict[str, object] = {}
|
|
1291
|
+
if isinstance(updates, Mapping):
|
|
1292
|
+
for key, value in updates.items():
|
|
1293
|
+
if hasattr(value, "isoformat"):
|
|
1294
|
+
serialized_updates[key] = value.isoformat()
|
|
1295
|
+
else:
|
|
1296
|
+
serialized_updates[key] = value
|
|
1297
|
+
return JsonResponse(
|
|
1298
|
+
{"status": status_label, "detail": message, "updates": serialized_updates},
|
|
1299
|
+
status=status_code,
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
@csrf_exempt
|
|
1304
|
+
def proxy_session(request):
|
|
1305
|
+
"""Create a proxy login session for a remote administrator."""
|
|
1306
|
+
|
|
1307
|
+
if request.method != "POST":
|
|
1308
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
payload = json.loads(request.body.decode() or "{}")
|
|
1312
|
+
except json.JSONDecodeError:
|
|
1313
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
1314
|
+
|
|
1315
|
+
requester = payload.get("requester")
|
|
1316
|
+
if not requester:
|
|
1317
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
1318
|
+
|
|
1319
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
1320
|
+
requester_public_key = _clean_requester_hint(
|
|
1321
|
+
payload.get("requester_public_key"), strip=False
|
|
1322
|
+
)
|
|
1323
|
+
node, error_response = _load_signed_node(
|
|
1324
|
+
request,
|
|
1325
|
+
requester,
|
|
1326
|
+
mac_address=requester_mac,
|
|
1327
|
+
public_key=requester_public_key,
|
|
1328
|
+
)
|
|
1329
|
+
if error_response is not None:
|
|
1330
|
+
return error_response
|
|
1331
|
+
|
|
1332
|
+
user_payload = payload.get("user") or {}
|
|
1333
|
+
username = str(user_payload.get("username", "")).strip()
|
|
1334
|
+
if not username:
|
|
1335
|
+
return JsonResponse({"detail": "username required"}, status=400)
|
|
1336
|
+
|
|
1337
|
+
User = get_user_model()
|
|
1338
|
+
user, created = User.objects.get_or_create(
|
|
1339
|
+
username=username,
|
|
1340
|
+
defaults={
|
|
1341
|
+
"email": user_payload.get("email", ""),
|
|
1342
|
+
"first_name": user_payload.get("first_name", ""),
|
|
1343
|
+
"last_name": user_payload.get("last_name", ""),
|
|
1344
|
+
},
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
updates: list[str] = []
|
|
1348
|
+
for field in ("first_name", "last_name", "email"):
|
|
1349
|
+
value = user_payload.get(field)
|
|
1350
|
+
if isinstance(value, str) and getattr(user, field) != value:
|
|
1351
|
+
setattr(user, field, value)
|
|
1352
|
+
updates.append(field)
|
|
1353
|
+
|
|
1354
|
+
if created:
|
|
1355
|
+
user.set_unusable_password()
|
|
1356
|
+
updates.append("password")
|
|
1357
|
+
|
|
1358
|
+
staff_flag = user_payload.get("is_staff")
|
|
1359
|
+
if staff_flag is not None:
|
|
1360
|
+
is_staff = bool(staff_flag)
|
|
1361
|
+
else:
|
|
1362
|
+
is_staff = True
|
|
1363
|
+
if user.is_staff != is_staff:
|
|
1364
|
+
user.is_staff = is_staff
|
|
1365
|
+
updates.append("is_staff")
|
|
1366
|
+
|
|
1367
|
+
superuser_flag = user_payload.get("is_superuser")
|
|
1368
|
+
if superuser_flag is not None:
|
|
1369
|
+
is_superuser = bool(superuser_flag)
|
|
1370
|
+
if user.is_superuser != is_superuser:
|
|
1371
|
+
user.is_superuser = is_superuser
|
|
1372
|
+
updates.append("is_superuser")
|
|
1373
|
+
|
|
1374
|
+
if not user.is_active:
|
|
1375
|
+
user.is_active = True
|
|
1376
|
+
updates.append("is_active")
|
|
1377
|
+
|
|
1378
|
+
if updates:
|
|
1379
|
+
user.save(update_fields=updates)
|
|
1380
|
+
|
|
1381
|
+
_assign_groups_and_permissions(user, user_payload)
|
|
1382
|
+
|
|
1383
|
+
target_path = _sanitize_proxy_target(payload.get("target"), request)
|
|
1384
|
+
nonce = secrets.token_urlsafe(24)
|
|
1385
|
+
cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
|
|
1386
|
+
cache.set(cache_key, {"user_id": user.pk}, PROXY_TOKEN_TIMEOUT)
|
|
1387
|
+
|
|
1388
|
+
signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
|
|
1389
|
+
token = signer.sign_object({"user": user.pk, "next": target_path, "nonce": nonce})
|
|
1390
|
+
login_url = request.build_absolute_uri(
|
|
1391
|
+
reverse("node-proxy-login", args=[token])
|
|
1392
|
+
)
|
|
1393
|
+
expires = timezone.now() + timedelta(seconds=PROXY_TOKEN_TIMEOUT)
|
|
1394
|
+
|
|
1395
|
+
return JsonResponse({"login_url": login_url, "expires": expires.isoformat()})
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
@csrf_exempt
|
|
1399
|
+
def proxy_login(request, token):
|
|
1400
|
+
"""Redeem a proxy login token and redirect to the target path."""
|
|
1401
|
+
|
|
1402
|
+
signer = TimestampSigner(salt=PROXY_TOKEN_SALT)
|
|
1403
|
+
try:
|
|
1404
|
+
payload = signer.unsign_object(token, max_age=PROXY_TOKEN_TIMEOUT)
|
|
1405
|
+
except SignatureExpired:
|
|
1406
|
+
return HttpResponse(status=410)
|
|
1407
|
+
except BadSignature:
|
|
1408
|
+
return HttpResponse(status=400)
|
|
1409
|
+
|
|
1410
|
+
nonce = payload.get("nonce")
|
|
1411
|
+
if not nonce:
|
|
1412
|
+
return HttpResponse(status=400)
|
|
1413
|
+
|
|
1414
|
+
cache_key = f"{PROXY_CACHE_PREFIX}{nonce}"
|
|
1415
|
+
cache_payload = cache.get(cache_key)
|
|
1416
|
+
if not cache_payload:
|
|
1417
|
+
return HttpResponse(status=410)
|
|
1418
|
+
cache.delete(cache_key)
|
|
1419
|
+
|
|
1420
|
+
user_id = cache_payload.get("user_id")
|
|
1421
|
+
if not user_id:
|
|
1422
|
+
return HttpResponse(status=403)
|
|
1423
|
+
|
|
1424
|
+
User = get_user_model()
|
|
1425
|
+
user = User.objects.filter(pk=user_id).first()
|
|
1426
|
+
if not user or not user.is_active:
|
|
1427
|
+
return HttpResponse(status=403)
|
|
1428
|
+
|
|
1429
|
+
backend = getattr(user, "backend", "")
|
|
1430
|
+
if not backend:
|
|
1431
|
+
backends = getattr(settings, "AUTHENTICATION_BACKENDS", None) or ()
|
|
1432
|
+
backend = backends[0] if backends else "django.contrib.auth.backends.ModelBackend"
|
|
1433
|
+
login(request, user, backend=backend)
|
|
1434
|
+
|
|
1435
|
+
next_path = payload.get("next") or reverse("admin:index")
|
|
1436
|
+
if not url_has_allowed_host_and_scheme(
|
|
1437
|
+
next_path,
|
|
1438
|
+
allowed_hosts={request.get_host()},
|
|
1439
|
+
require_https=request.is_secure(),
|
|
1440
|
+
):
|
|
1441
|
+
next_path = reverse("admin:index")
|
|
1442
|
+
|
|
1443
|
+
return redirect(next_path)
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _suite_model_name(meta) -> str:
|
|
1447
|
+
base = str(meta.verbose_name_plural or meta.verbose_name or meta.object_name)
|
|
1448
|
+
normalized = re.sub(r"[^0-9A-Za-z]+", " ", base).title().replace(" ", "")
|
|
1449
|
+
return normalized or meta.object_name
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
@csrf_exempt
|
|
1453
|
+
def proxy_execute(request):
|
|
1454
|
+
"""Execute model operations on behalf of a remote interface node."""
|
|
1455
|
+
|
|
1456
|
+
if request.method != "POST":
|
|
1457
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
1458
|
+
|
|
1459
|
+
try:
|
|
1460
|
+
payload = json.loads(request.body.decode() or "{}")
|
|
1461
|
+
except json.JSONDecodeError:
|
|
1462
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
1463
|
+
|
|
1464
|
+
requester = payload.get("requester")
|
|
1465
|
+
if not requester:
|
|
1466
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
1467
|
+
|
|
1468
|
+
requester_mac = _clean_requester_hint(payload.get("requester_mac"))
|
|
1469
|
+
requester_public_key = _clean_requester_hint(
|
|
1470
|
+
payload.get("requester_public_key"), strip=False
|
|
1471
|
+
)
|
|
1472
|
+
node, error_response = _load_signed_node(
|
|
1473
|
+
request,
|
|
1474
|
+
requester,
|
|
1475
|
+
mac_address=requester_mac,
|
|
1476
|
+
public_key=requester_public_key,
|
|
1477
|
+
)
|
|
1478
|
+
if error_response is not None:
|
|
1479
|
+
return error_response
|
|
1480
|
+
|
|
1481
|
+
action = str(payload.get("action", "")).strip().lower()
|
|
1482
|
+
if not action:
|
|
1483
|
+
return JsonResponse({"detail": "action required"}, status=400)
|
|
1484
|
+
|
|
1485
|
+
credentials = payload.get("credentials") or {}
|
|
1486
|
+
username = str(credentials.get("username", "")).strip()
|
|
1487
|
+
password_value = credentials.get("password")
|
|
1488
|
+
password = password_value if isinstance(password_value, str) else str(password_value or "")
|
|
1489
|
+
if not username or not password:
|
|
1490
|
+
return JsonResponse({"detail": "credentials required"}, status=401)
|
|
1491
|
+
|
|
1492
|
+
User = get_user_model()
|
|
1493
|
+
existing_user = User.objects.filter(username=username).first()
|
|
1494
|
+
auth_user = authenticate(request=None, username=username, password=password)
|
|
1495
|
+
|
|
1496
|
+
if auth_user is None:
|
|
1497
|
+
if existing_user is not None:
|
|
1498
|
+
return JsonResponse({"detail": "authentication failed"}, status=403)
|
|
1499
|
+
auth_user = User.objects.create_user(
|
|
1500
|
+
username=username,
|
|
1501
|
+
password=password,
|
|
1502
|
+
email=str(credentials.get("email", "")),
|
|
1503
|
+
)
|
|
1504
|
+
auth_user.is_staff = True
|
|
1505
|
+
auth_user.is_superuser = True
|
|
1506
|
+
auth_user.first_name = str(credentials.get("first_name", ""))
|
|
1507
|
+
auth_user.last_name = str(credentials.get("last_name", ""))
|
|
1508
|
+
auth_user.save()
|
|
1509
|
+
else:
|
|
1510
|
+
updates: list[str] = []
|
|
1511
|
+
for field in ("first_name", "last_name", "email"):
|
|
1512
|
+
value = credentials.get(field)
|
|
1513
|
+
if isinstance(value, str) and getattr(auth_user, field) != value:
|
|
1514
|
+
setattr(auth_user, field, value)
|
|
1515
|
+
updates.append(field)
|
|
1516
|
+
for flag in ("is_staff", "is_superuser"):
|
|
1517
|
+
if flag in credentials:
|
|
1518
|
+
desired = bool(credentials.get(flag))
|
|
1519
|
+
if getattr(auth_user, flag) != desired:
|
|
1520
|
+
setattr(auth_user, flag, desired)
|
|
1521
|
+
updates.append(flag)
|
|
1522
|
+
if updates:
|
|
1523
|
+
auth_user.save(update_fields=updates)
|
|
1524
|
+
|
|
1525
|
+
if not auth_user.is_active:
|
|
1526
|
+
return JsonResponse({"detail": "user inactive"}, status=403)
|
|
1527
|
+
|
|
1528
|
+
_assign_groups_and_permissions(auth_user, credentials)
|
|
1529
|
+
|
|
1530
|
+
model_label = payload.get("model")
|
|
1531
|
+
model = None
|
|
1532
|
+
if action != "schema":
|
|
1533
|
+
if not isinstance(model_label, str) or "." not in model_label:
|
|
1534
|
+
return JsonResponse({"detail": "model required"}, status=400)
|
|
1535
|
+
app_label, model_name = model_label.split(".", 1)
|
|
1536
|
+
model = apps.get_model(app_label, model_name)
|
|
1537
|
+
if model is None:
|
|
1538
|
+
return JsonResponse({"detail": "model not found"}, status=404)
|
|
1539
|
+
|
|
1540
|
+
if action == "schema":
|
|
1541
|
+
models_payload = []
|
|
1542
|
+
for registered_model in apps.get_models():
|
|
1543
|
+
meta = registered_model._meta
|
|
1544
|
+
models_payload.append(
|
|
1545
|
+
{
|
|
1546
|
+
"app_label": meta.app_label,
|
|
1547
|
+
"model": meta.model_name,
|
|
1548
|
+
"object_name": meta.object_name,
|
|
1549
|
+
"verbose_name": str(meta.verbose_name),
|
|
1550
|
+
"verbose_name_plural": str(meta.verbose_name_plural),
|
|
1551
|
+
"suite_name": _suite_model_name(meta),
|
|
1552
|
+
}
|
|
1553
|
+
)
|
|
1554
|
+
return JsonResponse({"models": models_payload})
|
|
1555
|
+
|
|
1556
|
+
action_perm = {
|
|
1557
|
+
"list": "view",
|
|
1558
|
+
"get": "view",
|
|
1559
|
+
"create": "add",
|
|
1560
|
+
"update": "change",
|
|
1561
|
+
"delete": "delete",
|
|
1562
|
+
}.get(action)
|
|
1563
|
+
|
|
1564
|
+
if action_perm and not auth_user.is_superuser:
|
|
1565
|
+
perm_codename = f"{model._meta.app_label}.{action_perm}_{model._meta.model_name}"
|
|
1566
|
+
if not auth_user.has_perm(perm_codename):
|
|
1567
|
+
return JsonResponse({"detail": "forbidden"}, status=403)
|
|
1568
|
+
|
|
1569
|
+
try:
|
|
1570
|
+
if action == "list":
|
|
1571
|
+
filters = payload.get("filters") or {}
|
|
1572
|
+
if filters and not isinstance(filters, Mapping):
|
|
1573
|
+
return JsonResponse({"detail": "filters must be a mapping"}, status=400)
|
|
1574
|
+
queryset = model._default_manager.all()
|
|
1575
|
+
if filters:
|
|
1576
|
+
queryset = queryset.filter(**filters)
|
|
1577
|
+
limit = payload.get("limit")
|
|
1578
|
+
if limit is not None:
|
|
1579
|
+
try:
|
|
1580
|
+
limit_value = int(limit)
|
|
1581
|
+
if limit_value > 0:
|
|
1582
|
+
queryset = queryset[:limit_value]
|
|
1583
|
+
except (TypeError, ValueError):
|
|
1584
|
+
pass
|
|
1585
|
+
data = serializers.serialize("python", queryset)
|
|
1586
|
+
return JsonResponse({"objects": data})
|
|
1587
|
+
|
|
1588
|
+
if action == "get":
|
|
1589
|
+
filters = payload.get("filters") or {}
|
|
1590
|
+
if filters and not isinstance(filters, Mapping):
|
|
1591
|
+
return JsonResponse({"detail": "filters must be a mapping"}, status=400)
|
|
1592
|
+
lookup = dict(filters)
|
|
1593
|
+
if not lookup and "pk" in payload:
|
|
1594
|
+
lookup = {"pk": payload.get("pk")}
|
|
1595
|
+
if not lookup:
|
|
1596
|
+
return JsonResponse({"detail": "lookup required"}, status=400)
|
|
1597
|
+
obj = model._default_manager.get(**lookup)
|
|
1598
|
+
data = serializers.serialize("python", [obj])[0]
|
|
1599
|
+
return JsonResponse({"object": data})
|
|
1600
|
+
except model.DoesNotExist:
|
|
1601
|
+
return JsonResponse({"detail": "not found"}, status=404)
|
|
1602
|
+
except Exception as exc:
|
|
1603
|
+
return JsonResponse({"detail": str(exc)}, status=400)
|
|
1604
|
+
|
|
1605
|
+
return JsonResponse({"detail": "unsupported action"}, status=400)
|
|
1606
|
+
|
|
1607
|
+
|
|
525
1608
|
@csrf_exempt
|
|
526
1609
|
@api_login_required
|
|
527
1610
|
def public_node_endpoint(request, endpoint):
|
|
@@ -536,7 +1619,10 @@ def public_node_endpoint(request, endpoint):
|
|
|
536
1619
|
if request.method == "GET":
|
|
537
1620
|
data = {
|
|
538
1621
|
"hostname": node.hostname,
|
|
539
|
-
"
|
|
1622
|
+
"network_hostname": node.network_hostname,
|
|
1623
|
+
"address": node.address or node.get_primary_contact(),
|
|
1624
|
+
"ipv4_address": node.ipv4_address,
|
|
1625
|
+
"ipv6_address": node.ipv6_address,
|
|
540
1626
|
"port": node.port,
|
|
541
1627
|
"badge_color": node.badge_color,
|
|
542
1628
|
"last_seen": node.last_seen,
|
|
@@ -584,100 +1670,99 @@ def net_message(request):
|
|
|
584
1670
|
except Exception:
|
|
585
1671
|
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
586
1672
|
|
|
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)
|
|
1673
|
+
try:
|
|
1674
|
+
msg = NetMessage.receive_payload(data, sender=node)
|
|
1675
|
+
except ValueError as exc:
|
|
1676
|
+
return JsonResponse({"detail": str(exc)}, status=400)
|
|
668
1677
|
return JsonResponse({"status": "propagated", "complete": msg.complete})
|
|
669
1678
|
|
|
670
1679
|
|
|
671
|
-
|
|
672
|
-
|
|
1680
|
+
@csrf_exempt
|
|
1681
|
+
def net_message_pull(request):
|
|
1682
|
+
"""Allow downstream nodes to retrieve queued network messages."""
|
|
673
1683
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1684
|
+
if request.method != "POST":
|
|
1685
|
+
return JsonResponse({"detail": "POST required"}, status=405)
|
|
1686
|
+
try:
|
|
1687
|
+
data = json.loads(request.body.decode() or "{}")
|
|
1688
|
+
except json.JSONDecodeError:
|
|
1689
|
+
return JsonResponse({"detail": "invalid json"}, status=400)
|
|
1690
|
+
|
|
1691
|
+
requester = data.get("requester")
|
|
1692
|
+
if not requester:
|
|
1693
|
+
return JsonResponse({"detail": "requester required"}, status=400)
|
|
1694
|
+
signature = request.headers.get("X-Signature")
|
|
1695
|
+
if not signature:
|
|
1696
|
+
return JsonResponse({"detail": "signature required"}, status=403)
|
|
1697
|
+
|
|
1698
|
+
node = Node.objects.filter(uuid=requester).first()
|
|
1699
|
+
if not node or not node.public_key:
|
|
1700
|
+
return JsonResponse({"detail": "unknown requester"}, status=403)
|
|
1701
|
+
try:
|
|
1702
|
+
public_key = serialization.load_pem_public_key(node.public_key.encode())
|
|
1703
|
+
public_key.verify(
|
|
1704
|
+
base64.b64decode(signature),
|
|
1705
|
+
request.body,
|
|
1706
|
+
padding.PKCS1v15(),
|
|
1707
|
+
hashes.SHA256(),
|
|
1708
|
+
)
|
|
1709
|
+
except Exception:
|
|
1710
|
+
return JsonResponse({"detail": "invalid signature"}, status=403)
|
|
1711
|
+
|
|
1712
|
+
local = Node.get_local()
|
|
1713
|
+
if not local:
|
|
1714
|
+
return JsonResponse({"detail": "local node unavailable"}, status=503)
|
|
1715
|
+
private_key = local.get_private_key()
|
|
1716
|
+
if not private_key:
|
|
1717
|
+
return JsonResponse({"detail": "signing unavailable"}, status=503)
|
|
1718
|
+
|
|
1719
|
+
entries = (
|
|
1720
|
+
PendingNetMessage.objects.select_related(
|
|
1721
|
+
"message",
|
|
1722
|
+
"message__filter_node",
|
|
1723
|
+
"message__filter_node_feature",
|
|
1724
|
+
"message__filter_node_role",
|
|
1725
|
+
"message__node_origin",
|
|
1726
|
+
)
|
|
1727
|
+
.filter(node=node)
|
|
1728
|
+
.order_by("queued_at")
|
|
683
1729
|
)
|
|
1730
|
+
messages: list[dict[str, object]] = []
|
|
1731
|
+
expired_ids: list[int] = []
|
|
1732
|
+
delivered_ids: list[int] = []
|
|
1733
|
+
|
|
1734
|
+
origin_fallback = str(local.uuid)
|
|
1735
|
+
|
|
1736
|
+
for entry in entries:
|
|
1737
|
+
if entry.is_stale:
|
|
1738
|
+
expired_ids.append(entry.pk)
|
|
1739
|
+
continue
|
|
1740
|
+
message = entry.message
|
|
1741
|
+
reach_source = message.filter_node_role or message.reach
|
|
1742
|
+
reach_name = reach_source.name if reach_source else None
|
|
1743
|
+
origin_node = message.node_origin
|
|
1744
|
+
origin_uuid = str(origin_node.uuid) if origin_node else origin_fallback
|
|
1745
|
+
sender_id = str(local.uuid)
|
|
1746
|
+
seen = [str(value) for value in entry.seen]
|
|
1747
|
+
payload = message._build_payload(
|
|
1748
|
+
sender_id=sender_id,
|
|
1749
|
+
origin_uuid=origin_uuid,
|
|
1750
|
+
reach_name=reach_name,
|
|
1751
|
+
seen=seen,
|
|
1752
|
+
)
|
|
1753
|
+
payload_json = message._serialize_payload(payload)
|
|
1754
|
+
payload_signature = message._sign_payload(payload_json, private_key)
|
|
1755
|
+
if not payload_signature:
|
|
1756
|
+
logger.warning(
|
|
1757
|
+
"Unable to sign queued NetMessage %s for node %s", message.pk, node.pk
|
|
1758
|
+
)
|
|
1759
|
+
continue
|
|
1760
|
+
messages.append({"payload": payload, "signature": payload_signature})
|
|
1761
|
+
delivered_ids.append(entry.pk)
|
|
1762
|
+
|
|
1763
|
+
if expired_ids:
|
|
1764
|
+
PendingNetMessage.objects.filter(pk__in=expired_ids).delete()
|
|
1765
|
+
if delivered_ids:
|
|
1766
|
+
PendingNetMessage.objects.filter(pk__in=delivered_ids).delete()
|
|
1767
|
+
|
|
1768
|
+
return JsonResponse({"messages": messages})
|