arthexis 0.1.9__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.

Files changed (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
nodes/views.py CHANGED
@@ -1,304 +1,1768 @@
1
- import json
2
- import base64
3
-
4
- from django.http import JsonResponse
5
- from django.views.decorators.csrf import csrf_exempt
6
- from django.shortcuts import get_object_or_404
7
- from django.conf import settings
8
- from pathlib import Path
9
- from django.utils.cache import patch_vary_headers
10
-
11
- from utils.api import api_login_required
12
-
13
- from cryptography.hazmat.primitives import serialization, hashes
14
- from cryptography.hazmat.primitives.asymmetric import padding
15
-
16
- from .models import Node, NetMessage, NodeRole
17
- from .utils import capture_screenshot, save_screenshot
18
-
19
-
20
- @api_login_required
21
- def node_list(request):
22
- """Return a JSON list of all known nodes."""
23
-
24
- nodes = [
25
- {
26
- "hostname": node.hostname,
27
- "address": node.address,
28
- "port": node.port,
29
- "last_seen": node.last_seen,
30
- "features": list(node.features.values_list("slug", flat=True)),
31
- }
32
- for node in Node.objects.prefetch_related("features")
33
- ]
34
- return JsonResponse({"nodes": nodes})
35
-
36
-
37
- @csrf_exempt
38
- def node_info(request):
39
- """Return information about the local node and sign ``token`` if provided."""
40
-
41
- node = Node.get_local()
42
- if node is None:
43
- node, _ = Node.register_current()
44
-
45
- token = request.GET.get("token", "")
46
- data = {
47
- "hostname": node.hostname,
48
- "address": node.address,
49
- "port": node.port,
50
- "mac_address": node.mac_address,
51
- "public_key": node.public_key,
52
- "features": list(node.features.values_list("slug", flat=True)),
53
- }
54
-
55
- if token:
56
- try:
57
- priv_path = (
58
- Path(node.base_path or settings.BASE_DIR)
59
- / "security"
60
- / f"{node.public_endpoint}"
61
- )
62
- private_key = serialization.load_pem_private_key(
63
- priv_path.read_bytes(), password=None
64
- )
65
- signature = private_key.sign(
66
- token.encode(),
67
- padding.PKCS1v15(),
68
- hashes.SHA256(),
69
- )
70
- data["token_signature"] = base64.b64encode(signature).decode()
71
- except Exception:
72
- pass
73
-
74
- response = JsonResponse(data)
75
- response["Access-Control-Allow-Origin"] = "*"
76
- return response
77
-
78
-
79
- def _add_cors_headers(request, response):
80
- origin = request.headers.get("Origin")
81
- if origin:
82
- response["Access-Control-Allow-Origin"] = origin
83
- response["Access-Control-Allow-Credentials"] = "true"
84
- allow_headers = request.headers.get(
85
- "Access-Control-Request-Headers", "Content-Type"
86
- )
87
- response["Access-Control-Allow-Headers"] = allow_headers
88
- response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
89
- patch_vary_headers(response, ["Origin"])
90
- return response
91
-
92
-
93
- @csrf_exempt
94
- @api_login_required
95
- def register_node(request):
96
- """Register or update a node from POSTed JSON data."""
97
-
98
- if request.method == "OPTIONS":
99
- response = JsonResponse({"detail": "ok"})
100
- return _add_cors_headers(request, response)
101
-
102
- if request.method != "POST":
103
- response = JsonResponse({"detail": "POST required"}, status=400)
104
- return _add_cors_headers(request, response)
105
-
106
- try:
107
- data = json.loads(request.body.decode())
108
- except json.JSONDecodeError:
109
- data = request.POST
110
-
111
- if hasattr(data, "getlist"):
112
- raw_features = data.getlist("features")
113
- if not raw_features:
114
- features = None
115
- elif len(raw_features) == 1:
116
- features = raw_features[0]
117
- else:
118
- features = raw_features
119
- else:
120
- features = data.get("features")
121
-
122
- hostname = data.get("hostname")
123
- address = data.get("address")
124
- port = data.get("port", 8000)
125
- mac_address = data.get("mac_address")
126
- public_key = data.get("public_key")
127
- token = data.get("token")
128
- signature = data.get("signature")
129
-
130
- if not hostname or not address or not mac_address:
131
- response = JsonResponse(
132
- {"detail": "hostname, address and mac_address required"}, status=400
133
- )
134
- return _add_cors_headers(request, response)
135
-
136
- verified = False
137
- if public_key and token and signature:
138
- try:
139
- pub = serialization.load_pem_public_key(public_key.encode())
140
- pub.verify(
141
- base64.b64decode(signature),
142
- token.encode(),
143
- padding.PKCS1v15(),
144
- hashes.SHA256(),
145
- )
146
- verified = True
147
- except Exception:
148
- response = JsonResponse({"detail": "invalid signature"}, status=403)
149
- return _add_cors_headers(request, response)
150
-
151
- mac_address = mac_address.lower()
152
- defaults = {
153
- "hostname": hostname,
154
- "address": address,
155
- "port": port,
156
- }
157
- if verified:
158
- defaults["public_key"] = public_key
159
-
160
- node, created = Node.objects.get_or_create(
161
- mac_address=mac_address,
162
- defaults=defaults,
163
- )
164
- if not created:
165
- node.hostname = hostname
166
- node.address = address
167
- node.port = port
168
- update_fields = ["hostname", "address", "port"]
169
- if verified:
170
- node.public_key = public_key
171
- update_fields.append("public_key")
172
- node.save(update_fields=update_fields)
173
- if features is not None:
174
- if isinstance(features, (str, bytes)):
175
- feature_list = [features]
176
- else:
177
- feature_list = list(features)
178
- node.update_manual_features(feature_list)
179
- response = JsonResponse(
180
- {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
181
- )
182
- return _add_cors_headers(request, response)
183
-
184
- if features is not None:
185
- if isinstance(features, (str, bytes)):
186
- feature_list = [features]
187
- else:
188
- feature_list = list(features)
189
- node.update_manual_features(feature_list)
190
-
191
- response = JsonResponse({"id": node.id})
192
- return _add_cors_headers(request, response)
193
-
194
-
195
- @api_login_required
196
- def capture(request):
197
- """Capture a screenshot of the site's root URL and record it."""
198
-
199
- url = request.build_absolute_uri("/")
200
- try:
201
- path = capture_screenshot(url)
202
- except Exception as exc: # pragma: no cover - depends on selenium setup
203
- return JsonResponse({"detail": str(exc)}, status=500)
204
- node = Node.get_local()
205
- screenshot = save_screenshot(path, node=node, method=request.method)
206
- node_id = screenshot.node.id if screenshot and screenshot.node else None
207
- return JsonResponse({"screenshot": str(path), "node": node_id})
208
-
209
-
210
- @csrf_exempt
211
- @api_login_required
212
- def public_node_endpoint(request, endpoint):
213
- """Public API endpoint for a node.
214
-
215
- - ``GET`` returns information about the node.
216
- - ``POST`` broadcasts the request body as a :class:`NetMessage`.
217
- """
218
-
219
- node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
220
-
221
- if request.method == "GET":
222
- data = {
223
- "hostname": node.hostname,
224
- "address": node.address,
225
- "port": node.port,
226
- "badge_color": node.badge_color,
227
- "last_seen": node.last_seen,
228
- "features": list(node.features.values_list("slug", flat=True)),
229
- }
230
- return JsonResponse(data)
231
-
232
- if request.method == "POST":
233
- NetMessage.broadcast(
234
- subject=request.method,
235
- body=request.body.decode("utf-8") if request.body else "",
236
- seen=[str(node.uuid)],
237
- )
238
- return JsonResponse({"status": "stored"})
239
-
240
- return JsonResponse({"detail": "Method not allowed"}, status=405)
241
-
242
-
243
- @csrf_exempt
244
- def net_message(request):
245
- """Receive a network message and continue propagation."""
246
-
247
- if request.method != "POST":
248
- return JsonResponse({"detail": "POST required"}, status=400)
249
- try:
250
- data = json.loads(request.body.decode())
251
- except json.JSONDecodeError:
252
- return JsonResponse({"detail": "invalid json"}, status=400)
253
-
254
- signature = request.headers.get("X-Signature")
255
- sender_id = data.get("sender")
256
- if not signature or not sender_id:
257
- return JsonResponse({"detail": "signature required"}, status=403)
258
- node = Node.objects.filter(uuid=sender_id).first()
259
- if not node or not node.public_key:
260
- return JsonResponse({"detail": "unknown sender"}, status=403)
261
- try:
262
- public_key = serialization.load_pem_public_key(node.public_key.encode())
263
- public_key.verify(
264
- base64.b64decode(signature),
265
- request.body,
266
- padding.PKCS1v15(),
267
- hashes.SHA256(),
268
- )
269
- except Exception:
270
- return JsonResponse({"detail": "invalid signature"}, status=403)
271
-
272
- msg_uuid = data.get("uuid")
273
- subject = data.get("subject", "")
274
- body = data.get("body", "")
275
- reach_name = data.get("reach")
276
- reach_role = None
277
- if reach_name:
278
- reach_role = NodeRole.objects.filter(name=reach_name).first()
279
- seen = data.get("seen", [])
280
- if not msg_uuid:
281
- return JsonResponse({"detail": "uuid required"}, status=400)
282
- msg, created = NetMessage.objects.get_or_create(
283
- uuid=msg_uuid,
284
- defaults={"subject": subject[:64], "body": body[:256], "reach": reach_role},
285
- )
286
- if not created:
287
- msg.subject = subject[:64]
288
- msg.body = body[:256]
289
- update_fields = ["subject", "body"]
290
- if reach_role and msg.reach_id != reach_role.id:
291
- msg.reach = reach_role
292
- update_fields.append("reach")
293
- msg.save(update_fields=update_fields)
294
- msg.propagate(seen=seen)
295
- return JsonResponse({"status": "propagated", "complete": msg.complete})
296
-
297
-
298
- def last_net_message(request):
299
- """Return the most recent :class:`NetMessage`."""
300
-
301
- msg = NetMessage.objects.order_by("-created").first()
302
- if not msg:
303
- return JsonResponse({"subject": "", "body": ""})
304
- return JsonResponse({"subject": msg.subject, "body": msg.body})
1
+ import base64
2
+ import ipaddress
3
+ import json
4
+ import re
5
+ import secrets
6
+ import socket
7
+ import uuid
8
+ from collections.abc import Mapping
9
+ from datetime import timedelta
10
+
11
+ from django.apps import apps
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
21
+ from django.urls import reverse
22
+ from django.utils import timezone
23
+ from django.utils.dateparse import parse_datetime
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
29
+
30
+ from utils.api import api_login_required
31
+
32
+ from cryptography.hazmat.primitives import serialization, hashes
33
+ from cryptography.hazmat.primitives.asymmetric import padding
34
+
35
+ from django.db.models import Q
36
+
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
47
+
48
+ from .rfid_sync import apply_rfid_payload, serialize_rfid
49
+
50
+ from .models import (
51
+ Node,
52
+ NetMessage,
53
+ PendingNetMessage,
54
+ NodeRole,
55
+ node_information_updated,
56
+ )
57
+ from .utils import capture_screenshot, save_screenshot
58
+
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
+
220
+ def _get_client_ip(request):
221
+ """Return the client IP from the request headers."""
222
+
223
+ forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
224
+ if forwarded_for:
225
+ for value in forwarded_for.split(","):
226
+ candidate = value.strip()
227
+ if candidate:
228
+ return candidate
229
+ return request.META.get("REMOTE_ADDR", "")
230
+
231
+
232
+ def _get_route_address(remote_ip: str, port: int) -> str:
233
+ """Return the local address used to reach ``remote_ip``."""
234
+
235
+ if not remote_ip:
236
+ return ""
237
+ try:
238
+ parsed = ipaddress.ip_address(remote_ip)
239
+ except ValueError:
240
+ return ""
241
+
242
+ try:
243
+ target_port = int(port)
244
+ except (TypeError, ValueError):
245
+ target_port = 1
246
+ if target_port <= 0 or target_port > 65535:
247
+ target_port = 1
248
+
249
+ family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
250
+ try:
251
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
252
+ if family == socket.AF_INET6:
253
+ sock.connect((remote_ip, target_port, 0, 0))
254
+ else:
255
+ sock.connect((remote_ip, target_port))
256
+ return sock.getsockname()[0]
257
+ except OSError:
258
+ return ""
259
+
260
+
261
+ def _get_host_ip(request) -> str:
262
+ """Return the IP address from the host header if available."""
263
+
264
+ try:
265
+ host = request.get_host()
266
+ except Exception: # pragma: no cover - defensive
267
+ return ""
268
+ if not host:
269
+ return ""
270
+ domain, _ = split_domain_port(host)
271
+ if not domain:
272
+ return ""
273
+ try:
274
+ ipaddress.ip_address(domain)
275
+ except ValueError:
276
+ return ""
277
+ return domain
278
+
279
+
280
+ def _get_host_domain(request) -> str:
281
+ """Return the domain from the host header when it isn't an IP."""
282
+
283
+ try:
284
+ host = request.get_host()
285
+ except Exception: # pragma: no cover - defensive
286
+ return ""
287
+ if not host:
288
+ return ""
289
+ domain, _ = split_domain_port(host)
290
+ if not domain:
291
+ return ""
292
+ if domain.lower() == "localhost":
293
+ return ""
294
+ try:
295
+ ipaddress.ip_address(domain)
296
+ except ValueError:
297
+ return domain
298
+ return ""
299
+
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
+
355
+ def _get_advertised_address(request, node) -> str:
356
+ """Return the best address for the client to reach this node."""
357
+
358
+ client_ip = _get_client_ip(request)
359
+ route_address = _get_route_address(client_ip, node.port)
360
+ if route_address:
361
+ return route_address
362
+ host_ip = _get_host_ip(request)
363
+ if host_ip:
364
+ return host_ip
365
+ return node.get_primary_contact() or node.address or node.hostname
366
+
367
+
368
+ @api_login_required
369
+ def node_list(request):
370
+ """Return a JSON list of all known nodes."""
371
+
372
+ nodes = [
373
+ {
374
+ "hostname": node.hostname,
375
+ "network_hostname": node.network_hostname,
376
+ "address": node.address,
377
+ "ipv4_address": node.ipv4_address,
378
+ "ipv6_address": node.ipv6_address,
379
+ "port": node.port,
380
+ "last_seen": node.last_seen,
381
+ "features": list(node.features.values_list("slug", flat=True)),
382
+ }
383
+ for node in Node.objects.prefetch_related("features")
384
+ ]
385
+ return JsonResponse({"nodes": nodes})
386
+
387
+
388
+ @csrf_exempt
389
+ def node_info(request):
390
+ """Return information about the local node and sign ``token`` if provided."""
391
+
392
+ node = Node.get_local()
393
+ if node is None:
394
+ node, _ = Node.register_current()
395
+
396
+ token = request.GET.get("token", "")
397
+ host_domain = _get_host_domain(request)
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
404
+ if host_domain:
405
+ hostname = host_domain
406
+ address = advertised_address or host_domain
407
+ else:
408
+ hostname = node.hostname
409
+ address = advertised_address or node.address or node.network_hostname or ""
410
+ data = {
411
+ "hostname": hostname,
412
+ "network_hostname": node.network_hostname,
413
+ "address": address,
414
+ "ipv4_address": node.ipv4_address,
415
+ "ipv6_address": node.ipv6_address,
416
+ "port": advertised_port,
417
+ "mac_address": node.mac_address,
418
+ "public_key": node.public_key,
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(),
422
+ }
423
+
424
+ if token:
425
+ try:
426
+ priv_path = (
427
+ Path(node.base_path or settings.BASE_DIR)
428
+ / "security"
429
+ / f"{node.public_endpoint}"
430
+ )
431
+ private_key = serialization.load_pem_private_key(
432
+ priv_path.read_bytes(), password=None
433
+ )
434
+ signature = private_key.sign(
435
+ token.encode(),
436
+ padding.PKCS1v15(),
437
+ hashes.SHA256(),
438
+ )
439
+ data["token_signature"] = base64.b64encode(signature).decode()
440
+ except Exception:
441
+ pass
442
+
443
+ response = JsonResponse(data)
444
+ response["Access-Control-Allow-Origin"] = "*"
445
+ return response
446
+
447
+
448
+ def _add_cors_headers(request, response):
449
+ origin = request.headers.get("Origin")
450
+ if origin:
451
+ response["Access-Control-Allow-Origin"] = origin
452
+ response["Access-Control-Allow-Credentials"] = "true"
453
+ allow_headers = request.headers.get(
454
+ "Access-Control-Request-Headers", "Content-Type"
455
+ )
456
+ response["Access-Control-Allow-Headers"] = allow_headers
457
+ response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
458
+ patch_vary_headers(response, ["Origin"])
459
+ return response
460
+
461
+
462
+ def _node_display_name(node: Node) -> str:
463
+ """Return a human-friendly name for ``node`` suitable for messaging."""
464
+
465
+ for attr in (
466
+ "hostname",
467
+ "network_hostname",
468
+ "public_endpoint",
469
+ "address",
470
+ "ipv6_address",
471
+ "ipv4_address",
472
+ ):
473
+ value = getattr(node, attr, "") or ""
474
+ value = value.strip()
475
+ if value:
476
+ return value
477
+ identifier = getattr(node, "pk", None)
478
+ return str(identifier or node)
479
+
480
+
481
+ def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
482
+ """Emit a network message when the visitor node links to a host."""
483
+
484
+ if relation != Node.Relation.UPSTREAM:
485
+ return
486
+
487
+ local_node = Node.get_local()
488
+ if not local_node:
489
+ return
490
+
491
+ visitor_name = _node_display_name(local_node)
492
+ host_name = _node_display_name(new_node)
493
+ NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
494
+
495
+
496
+ @csrf_exempt
497
+ def register_node(request):
498
+ """Register or update a node from POSTed JSON data."""
499
+
500
+ if request.method == "OPTIONS":
501
+ response = JsonResponse({"detail": "ok"})
502
+ return _add_cors_headers(request, response)
503
+
504
+ if request.method != "POST":
505
+ response = JsonResponse({"detail": "POST required"}, status=400)
506
+ return _add_cors_headers(request, response)
507
+
508
+ try:
509
+ data = json.loads(request.body.decode())
510
+ except json.JSONDecodeError:
511
+ data = request.POST
512
+
513
+ if hasattr(data, "getlist"):
514
+ raw_features = data.getlist("features")
515
+ if not raw_features:
516
+ features = None
517
+ elif len(raw_features) == 1:
518
+ features = raw_features[0]
519
+ else:
520
+ features = raw_features
521
+ else:
522
+ features = data.get("features")
523
+
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()
529
+ port = data.get("port", 8000)
530
+ mac_address = (data.get("mac_address") or "").strip()
531
+ public_key = data.get("public_key")
532
+ token = data.get("token")
533
+ signature = data.get("signature")
534
+ installed_version = data.get("installed_version")
535
+ installed_revision = data.get("installed_revision")
536
+ relation_present = False
537
+ if hasattr(data, "getlist"):
538
+ relation_present = "current_relation" in data
539
+ else:
540
+ relation_present = "current_relation" in data
541
+ raw_relation = data.get("current_relation")
542
+ relation_value = (
543
+ Node.normalize_relation(raw_relation) if relation_present else None
544
+ )
545
+
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]):
553
+ response = JsonResponse(
554
+ {
555
+ "detail": "at least one of address, network_hostname, "
556
+ "ipv4_address or ipv6_address must be provided",
557
+ },
558
+ status=400,
559
+ )
560
+ return _add_cors_headers(request, response)
561
+
562
+ try:
563
+ port = int(port)
564
+ except (TypeError, ValueError):
565
+ port = 8000
566
+
567
+ verified = False
568
+ if public_key and token and signature:
569
+ try:
570
+ pub = serialization.load_pem_public_key(public_key.encode())
571
+ pub.verify(
572
+ base64.b64decode(signature),
573
+ token.encode(),
574
+ padding.PKCS1v15(),
575
+ hashes.SHA256(),
576
+ )
577
+ verified = True
578
+ except Exception:
579
+ response = JsonResponse({"detail": "invalid signature"}, status=403)
580
+ return _add_cors_headers(request, response)
581
+
582
+ if not verified and not request.user.is_authenticated:
583
+ response = JsonResponse({"detail": "authentication required"}, status=401)
584
+ return _add_cors_headers(request, response)
585
+
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)
603
+ defaults = {
604
+ "hostname": hostname,
605
+ "network_hostname": network_hostname,
606
+ "address": address_value,
607
+ "ipv4_address": ipv4_value,
608
+ "ipv6_address": ipv6_value,
609
+ "port": port,
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
617
+ if verified:
618
+ defaults["public_key"] = public_key
619
+ if installed_version is not None:
620
+ defaults["installed_version"] = str(installed_version)[:20]
621
+ if installed_revision is not None:
622
+ defaults["installed_revision"] = str(installed_revision)[:40]
623
+ if relation_value is not None:
624
+ defaults["current_relation"] = relation_value
625
+
626
+ node, created = Node.objects.get_or_create(
627
+ mac_address=mac_address,
628
+ defaults=defaults,
629
+ )
630
+ if not created:
631
+ previous_version = (node.installed_version or "").strip()
632
+ previous_revision = (node.installed_revision or "").strip()
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)
645
+ if verified:
646
+ node.public_key = public_key
647
+ update_fields.append("public_key")
648
+ if installed_version is not None:
649
+ node.installed_version = str(installed_version)[:20]
650
+ if "installed_version" not in update_fields:
651
+ update_fields.append("installed_version")
652
+ if installed_revision is not None:
653
+ node.installed_revision = str(installed_revision)[:40]
654
+ if "installed_revision" not in update_fields:
655
+ update_fields.append("installed_revision")
656
+ if relation_value is not None and node.current_relation != relation_value:
657
+ node.current_relation = relation_value
658
+ update_fields.append("current_relation")
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)
664
+ current_version = (node.installed_version or "").strip()
665
+ current_revision = (node.installed_revision or "").strip()
666
+ node_information_updated.send(
667
+ sender=Node,
668
+ node=node,
669
+ previous_version=previous_version,
670
+ previous_revision=previous_revision,
671
+ current_version=current_version,
672
+ current_revision=current_revision,
673
+ request=request,
674
+ )
675
+ if features is not None and (verified or request.user.is_authenticated):
676
+ if isinstance(features, (str, bytes)):
677
+ feature_list = [features]
678
+ else:
679
+ feature_list = list(features)
680
+ node.update_manual_features(feature_list)
681
+ response = JsonResponse(
682
+ {
683
+ "id": node.id,
684
+ "uuid": str(node.uuid),
685
+ "detail": f"Node already exists (id: {node.id})",
686
+ }
687
+ )
688
+ return _add_cors_headers(request, response)
689
+
690
+ if features is not None and (verified or request.user.is_authenticated):
691
+ if isinstance(features, (str, bytes)):
692
+ feature_list = [features]
693
+ else:
694
+ feature_list = list(features)
695
+ node.update_manual_features(feature_list)
696
+
697
+ current_version = (node.installed_version or "").strip()
698
+ current_revision = (node.installed_revision or "").strip()
699
+ node_information_updated.send(
700
+ sender=Node,
701
+ node=node,
702
+ previous_version="",
703
+ previous_revision="",
704
+ current_version=current_version,
705
+ current_revision=current_revision,
706
+ request=request,
707
+ )
708
+
709
+ _announce_visitor_join(node, relation_value)
710
+
711
+ response = JsonResponse({"id": node.id, "uuid": str(node.uuid)})
712
+ return _add_cors_headers(request, response)
713
+
714
+
715
+ @api_login_required
716
+ def capture(request):
717
+ """Capture a screenshot of the site's root URL and record it."""
718
+
719
+ url = request.build_absolute_uri("/")
720
+ try:
721
+ path = capture_screenshot(url)
722
+ except Exception as exc: # pragma: no cover - depends on selenium setup
723
+ return JsonResponse({"detail": str(exc)}, status=500)
724
+ node = Node.get_local()
725
+ screenshot = save_screenshot(path, node=node, method=request.method)
726
+ node_id = screenshot.node.id if screenshot and screenshot.node else None
727
+ return JsonResponse({"screenshot": str(path), "node": node_id})
728
+
729
+
730
+ @csrf_exempt
731
+ def export_rfids(request):
732
+ """Return serialized RFID records for authenticated peers."""
733
+
734
+ if request.method != "POST":
735
+ return JsonResponse({"detail": "POST required"}, status=405)
736
+
737
+ try:
738
+ payload = json.loads(request.body.decode() or "{}")
739
+ except json.JSONDecodeError:
740
+ return JsonResponse({"detail": "invalid json"}, status=400)
741
+
742
+ requester = payload.get("requester")
743
+ if not requester:
744
+ return JsonResponse({"detail": "requester required"}, status=400)
745
+
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
758
+
759
+ tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
760
+
761
+ return JsonResponse({"rfids": tags})
762
+
763
+
764
+ @csrf_exempt
765
+ def import_rfids(request):
766
+ """Import RFID payloads from a trusted peer."""
767
+
768
+ if request.method != "POST":
769
+ return JsonResponse({"detail": "POST required"}, status=405)
770
+
771
+ try:
772
+ payload = json.loads(request.body.decode() or "{}")
773
+ except json.JSONDecodeError:
774
+ return JsonResponse({"detail": "invalid json"}, status=400)
775
+
776
+ requester = payload.get("requester")
777
+ if not requester:
778
+ return JsonResponse({"detail": "requester required"}, status=400)
779
+
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
792
+
793
+ rfids = payload.get("rfids", [])
794
+ if not isinstance(rfids, list):
795
+ return JsonResponse({"detail": "rfids must be a list"}, status=400)
796
+
797
+ created = 0
798
+ updated = 0
799
+ linked_accounts = 0
800
+ missing_accounts: list[str] = []
801
+ errors = 0
802
+
803
+ for entry in rfids:
804
+ if not isinstance(entry, Mapping):
805
+ errors += 1
806
+ continue
807
+ outcome = apply_rfid_payload(entry, origin_node=node)
808
+ if not outcome.ok:
809
+ errors += 1
810
+ if outcome.error:
811
+ missing_accounts.append(outcome.error)
812
+ continue
813
+ if outcome.created:
814
+ created += 1
815
+ else:
816
+ updated += 1
817
+ linked_accounts += outcome.accounts_linked
818
+ missing_accounts.extend(outcome.missing_accounts)
819
+
820
+ return JsonResponse(
821
+ {
822
+ "processed": len(rfids),
823
+ "created": created,
824
+ "updated": updated,
825
+ "accounts_linked": linked_accounts,
826
+ "missing_accounts": missing_accounts,
827
+ "errors": errors,
828
+ }
829
+ )
830
+
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
+
1608
+ @csrf_exempt
1609
+ @api_login_required
1610
+ def public_node_endpoint(request, endpoint):
1611
+ """Public API endpoint for a node.
1612
+
1613
+ - ``GET`` returns information about the node.
1614
+ - ``POST`` broadcasts the request body as a :class:`NetMessage`.
1615
+ """
1616
+
1617
+ node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
1618
+
1619
+ if request.method == "GET":
1620
+ data = {
1621
+ "hostname": node.hostname,
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,
1626
+ "port": node.port,
1627
+ "badge_color": node.badge_color,
1628
+ "last_seen": node.last_seen,
1629
+ "features": list(node.features.values_list("slug", flat=True)),
1630
+ }
1631
+ return JsonResponse(data)
1632
+
1633
+ if request.method == "POST":
1634
+ NetMessage.broadcast(
1635
+ subject=request.method,
1636
+ body=request.body.decode("utf-8") if request.body else "",
1637
+ seen=[str(node.uuid)],
1638
+ )
1639
+ return JsonResponse({"status": "stored"})
1640
+
1641
+ return JsonResponse({"detail": "Method not allowed"}, status=405)
1642
+
1643
+
1644
+ @csrf_exempt
1645
+ def net_message(request):
1646
+ """Receive a network message and continue propagation."""
1647
+
1648
+ if request.method != "POST":
1649
+ return JsonResponse({"detail": "POST required"}, status=400)
1650
+ try:
1651
+ data = json.loads(request.body.decode())
1652
+ except json.JSONDecodeError:
1653
+ return JsonResponse({"detail": "invalid json"}, status=400)
1654
+
1655
+ signature = request.headers.get("X-Signature")
1656
+ sender_id = data.get("sender")
1657
+ if not signature or not sender_id:
1658
+ return JsonResponse({"detail": "signature required"}, status=403)
1659
+ node = Node.objects.filter(uuid=sender_id).first()
1660
+ if not node or not node.public_key:
1661
+ return JsonResponse({"detail": "unknown sender"}, status=403)
1662
+ try:
1663
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
1664
+ public_key.verify(
1665
+ base64.b64decode(signature),
1666
+ request.body,
1667
+ padding.PKCS1v15(),
1668
+ hashes.SHA256(),
1669
+ )
1670
+ except Exception:
1671
+ return JsonResponse({"detail": "invalid signature"}, status=403)
1672
+
1673
+ try:
1674
+ msg = NetMessage.receive_payload(data, sender=node)
1675
+ except ValueError as exc:
1676
+ return JsonResponse({"detail": str(exc)}, status=400)
1677
+ return JsonResponse({"status": "propagated", "complete": msg.complete})
1678
+
1679
+
1680
+ @csrf_exempt
1681
+ def net_message_pull(request):
1682
+ """Allow downstream nodes to retrieve queued network messages."""
1683
+
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")
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})