arthexis 0.1.12__py3-none-any.whl → 0.1.14__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 (107) hide show
  1. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -716
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2772
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -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 +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2672
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -350
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1511
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1382
  56. core/widgets.py +213 -51
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -898
  60. nodes/apps.py +87 -70
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1416
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -2497
  71. nodes/urls.py +15 -13
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -451
  74. ocpp/admin.py +948 -804
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1342
  77. ocpp/evcs.py +844 -931
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -915
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -9
  82. ocpp/simulator.py +745 -724
  83. ocpp/status_display.py +26 -0
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -3485
  89. ocpp/transactions_io.py +189 -179
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1151
  92. pages/admin.py +708 -536
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -169
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2083
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1120
  104. arthexis-0.1.12.dist-info/RECORD +0 -102
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.12.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
nodes/views.py CHANGED
@@ -1,451 +1,683 @@
1
- import base64
2
- import ipaddress
3
- import json
4
- import socket
5
-
6
- from django.http import JsonResponse
7
- from django.http.request import split_domain_port
8
- from django.views.decorators.csrf import csrf_exempt
9
- from django.shortcuts import get_object_or_404
10
- from django.conf import settings
11
- from pathlib import Path
12
- from django.utils.cache import patch_vary_headers
13
-
14
- from utils.api import api_login_required
15
-
16
- from cryptography.hazmat.primitives import serialization, hashes
17
- from cryptography.hazmat.primitives.asymmetric import padding
18
-
19
- from .models import Node, NetMessage, NodeRole, node_information_updated
20
- from .utils import capture_screenshot, save_screenshot
21
-
22
-
23
- def _get_client_ip(request):
24
- """Return the client IP from the request headers."""
25
-
26
- forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
27
- if forwarded_for:
28
- for value in forwarded_for.split(","):
29
- candidate = value.strip()
30
- if candidate:
31
- return candidate
32
- return request.META.get("REMOTE_ADDR", "")
33
-
34
-
35
- def _get_route_address(remote_ip: str, port: int) -> str:
36
- """Return the local address used to reach ``remote_ip``."""
37
-
38
- if not remote_ip:
39
- return ""
40
- try:
41
- parsed = ipaddress.ip_address(remote_ip)
42
- except ValueError:
43
- return ""
44
-
45
- try:
46
- target_port = int(port)
47
- except (TypeError, ValueError):
48
- target_port = 1
49
- if target_port <= 0 or target_port > 65535:
50
- target_port = 1
51
-
52
- family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
53
- try:
54
- with socket.socket(family, socket.SOCK_DGRAM) as sock:
55
- if family == socket.AF_INET6:
56
- sock.connect((remote_ip, target_port, 0, 0))
57
- else:
58
- sock.connect((remote_ip, target_port))
59
- return sock.getsockname()[0]
60
- except OSError:
61
- return ""
62
-
63
-
64
- def _get_host_ip(request) -> str:
65
- """Return the IP address from the host header if available."""
66
-
67
- try:
68
- host = request.get_host()
69
- except Exception: # pragma: no cover - defensive
70
- return ""
71
- if not host:
72
- return ""
73
- domain, _ = split_domain_port(host)
74
- if not domain:
75
- return ""
76
- try:
77
- ipaddress.ip_address(domain)
78
- except ValueError:
79
- return ""
80
- return domain
81
-
82
-
83
- def _get_advertised_address(request, node) -> str:
84
- """Return the best address for the client to reach this node."""
85
-
86
- client_ip = _get_client_ip(request)
87
- route_address = _get_route_address(client_ip, node.port)
88
- if route_address:
89
- return route_address
90
- host_ip = _get_host_ip(request)
91
- if host_ip:
92
- return host_ip
93
- return node.address
94
-
95
-
96
- @api_login_required
97
- def node_list(request):
98
- """Return a JSON list of all known nodes."""
99
-
100
- nodes = [
101
- {
102
- "hostname": node.hostname,
103
- "address": node.address,
104
- "port": node.port,
105
- "last_seen": node.last_seen,
106
- "features": list(node.features.values_list("slug", flat=True)),
107
- }
108
- for node in Node.objects.prefetch_related("features")
109
- ]
110
- return JsonResponse({"nodes": nodes})
111
-
112
-
113
- @csrf_exempt
114
- def node_info(request):
115
- """Return information about the local node and sign ``token`` if provided."""
116
-
117
- node = Node.get_local()
118
- if node is None:
119
- node, _ = Node.register_current()
120
-
121
- token = request.GET.get("token", "")
122
- address = _get_advertised_address(request, node)
123
- data = {
124
- "hostname": node.hostname,
125
- "address": address,
126
- "port": node.port,
127
- "mac_address": node.mac_address,
128
- "public_key": node.public_key,
129
- "features": list(node.features.values_list("slug", flat=True)),
130
- }
131
-
132
- if token:
133
- try:
134
- priv_path = (
135
- Path(node.base_path or settings.BASE_DIR)
136
- / "security"
137
- / f"{node.public_endpoint}"
138
- )
139
- private_key = serialization.load_pem_private_key(
140
- priv_path.read_bytes(), password=None
141
- )
142
- signature = private_key.sign(
143
- token.encode(),
144
- padding.PKCS1v15(),
145
- hashes.SHA256(),
146
- )
147
- data["token_signature"] = base64.b64encode(signature).decode()
148
- except Exception:
149
- pass
150
-
151
- response = JsonResponse(data)
152
- response["Access-Control-Allow-Origin"] = "*"
153
- return response
154
-
155
-
156
- def _add_cors_headers(request, response):
157
- origin = request.headers.get("Origin")
158
- if origin:
159
- response["Access-Control-Allow-Origin"] = origin
160
- response["Access-Control-Allow-Credentials"] = "true"
161
- allow_headers = request.headers.get(
162
- "Access-Control-Request-Headers", "Content-Type"
163
- )
164
- response["Access-Control-Allow-Headers"] = allow_headers
165
- response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
166
- patch_vary_headers(response, ["Origin"])
167
- return response
168
-
169
-
170
- @csrf_exempt
171
- def register_node(request):
172
- """Register or update a node from POSTed JSON data."""
173
-
174
- if request.method == "OPTIONS":
175
- response = JsonResponse({"detail": "ok"})
176
- return _add_cors_headers(request, response)
177
-
178
- if request.method != "POST":
179
- response = JsonResponse({"detail": "POST required"}, status=400)
180
- return _add_cors_headers(request, response)
181
-
182
- try:
183
- data = json.loads(request.body.decode())
184
- except json.JSONDecodeError:
185
- data = request.POST
186
-
187
- if hasattr(data, "getlist"):
188
- raw_features = data.getlist("features")
189
- if not raw_features:
190
- features = None
191
- elif len(raw_features) == 1:
192
- features = raw_features[0]
193
- else:
194
- features = raw_features
195
- else:
196
- features = data.get("features")
197
-
198
- hostname = data.get("hostname")
199
- address = data.get("address")
200
- port = data.get("port", 8000)
201
- mac_address = data.get("mac_address")
202
- public_key = data.get("public_key")
203
- token = data.get("token")
204
- signature = data.get("signature")
205
- installed_version = data.get("installed_version")
206
- installed_revision = data.get("installed_revision")
207
- relation_present = False
208
- if hasattr(data, "getlist"):
209
- relation_present = "current_relation" in data
210
- else:
211
- relation_present = "current_relation" in data
212
- raw_relation = data.get("current_relation")
213
- relation_value = (
214
- Node.normalize_relation(raw_relation) if relation_present else None
215
- )
216
-
217
- if not hostname or not address or not mac_address:
218
- response = JsonResponse(
219
- {"detail": "hostname, address and mac_address required"}, status=400
220
- )
221
- return _add_cors_headers(request, response)
222
-
223
- verified = False
224
- if public_key and token and signature:
225
- try:
226
- pub = serialization.load_pem_public_key(public_key.encode())
227
- pub.verify(
228
- base64.b64decode(signature),
229
- token.encode(),
230
- padding.PKCS1v15(),
231
- hashes.SHA256(),
232
- )
233
- verified = True
234
- except Exception:
235
- response = JsonResponse({"detail": "invalid signature"}, status=403)
236
- return _add_cors_headers(request, response)
237
-
238
- if not verified and not request.user.is_authenticated:
239
- response = JsonResponse({"detail": "authentication required"}, status=401)
240
- return _add_cors_headers(request, response)
241
-
242
- mac_address = mac_address.lower()
243
- defaults = {
244
- "hostname": hostname,
245
- "address": address,
246
- "port": port,
247
- }
248
- if verified:
249
- defaults["public_key"] = public_key
250
- if installed_version is not None:
251
- defaults["installed_version"] = str(installed_version)[:20]
252
- if installed_revision is not None:
253
- defaults["installed_revision"] = str(installed_revision)[:40]
254
- if relation_value is not None:
255
- defaults["current_relation"] = relation_value
256
-
257
- node, created = Node.objects.get_or_create(
258
- mac_address=mac_address,
259
- defaults=defaults,
260
- )
261
- if not created:
262
- previous_version = (node.installed_version or "").strip()
263
- previous_revision = (node.installed_revision or "").strip()
264
- node.hostname = hostname
265
- node.address = address
266
- node.port = port
267
- update_fields = ["hostname", "address", "port"]
268
- if verified:
269
- node.public_key = public_key
270
- update_fields.append("public_key")
271
- if installed_version is not None:
272
- node.installed_version = str(installed_version)[:20]
273
- if "installed_version" not in update_fields:
274
- update_fields.append("installed_version")
275
- if installed_revision is not None:
276
- node.installed_revision = str(installed_revision)[:40]
277
- if "installed_revision" not in update_fields:
278
- update_fields.append("installed_revision")
279
- if relation_value is not None and node.current_relation != relation_value:
280
- node.current_relation = relation_value
281
- update_fields.append("current_relation")
282
- node.save(update_fields=update_fields)
283
- current_version = (node.installed_version or "").strip()
284
- current_revision = (node.installed_revision or "").strip()
285
- node_information_updated.send(
286
- sender=Node,
287
- node=node,
288
- previous_version=previous_version,
289
- previous_revision=previous_revision,
290
- current_version=current_version,
291
- current_revision=current_revision,
292
- request=request,
293
- )
294
- if features is not None and (verified or request.user.is_authenticated):
295
- if isinstance(features, (str, bytes)):
296
- feature_list = [features]
297
- else:
298
- feature_list = list(features)
299
- node.update_manual_features(feature_list)
300
- response = JsonResponse(
301
- {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
302
- )
303
- return _add_cors_headers(request, response)
304
-
305
- if features is not None and (verified or request.user.is_authenticated):
306
- if isinstance(features, (str, bytes)):
307
- feature_list = [features]
308
- else:
309
- feature_list = list(features)
310
- node.update_manual_features(feature_list)
311
-
312
- current_version = (node.installed_version or "").strip()
313
- current_revision = (node.installed_revision or "").strip()
314
- node_information_updated.send(
315
- sender=Node,
316
- node=node,
317
- previous_version="",
318
- previous_revision="",
319
- current_version=current_version,
320
- current_revision=current_revision,
321
- request=request,
322
- )
323
-
324
- response = JsonResponse({"id": node.id})
325
- return _add_cors_headers(request, response)
326
-
327
-
328
- @api_login_required
329
- def capture(request):
330
- """Capture a screenshot of the site's root URL and record it."""
331
-
332
- url = request.build_absolute_uri("/")
333
- try:
334
- path = capture_screenshot(url)
335
- except Exception as exc: # pragma: no cover - depends on selenium setup
336
- return JsonResponse({"detail": str(exc)}, status=500)
337
- node = Node.get_local()
338
- screenshot = save_screenshot(path, node=node, method=request.method)
339
- node_id = screenshot.node.id if screenshot and screenshot.node else None
340
- return JsonResponse({"screenshot": str(path), "node": node_id})
341
-
342
-
343
- @csrf_exempt
344
- @api_login_required
345
- def public_node_endpoint(request, endpoint):
346
- """Public API endpoint for a node.
347
-
348
- - ``GET`` returns information about the node.
349
- - ``POST`` broadcasts the request body as a :class:`NetMessage`.
350
- """
351
-
352
- node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
353
-
354
- if request.method == "GET":
355
- data = {
356
- "hostname": node.hostname,
357
- "address": node.address,
358
- "port": node.port,
359
- "badge_color": node.badge_color,
360
- "last_seen": node.last_seen,
361
- "features": list(node.features.values_list("slug", flat=True)),
362
- }
363
- return JsonResponse(data)
364
-
365
- if request.method == "POST":
366
- NetMessage.broadcast(
367
- subject=request.method,
368
- body=request.body.decode("utf-8") if request.body else "",
369
- seen=[str(node.uuid)],
370
- )
371
- return JsonResponse({"status": "stored"})
372
-
373
- return JsonResponse({"detail": "Method not allowed"}, status=405)
374
-
375
-
376
- @csrf_exempt
377
- def net_message(request):
378
- """Receive a network message and continue propagation."""
379
-
380
- if request.method != "POST":
381
- return JsonResponse({"detail": "POST required"}, status=400)
382
- try:
383
- data = json.loads(request.body.decode())
384
- except json.JSONDecodeError:
385
- return JsonResponse({"detail": "invalid json"}, status=400)
386
-
387
- signature = request.headers.get("X-Signature")
388
- sender_id = data.get("sender")
389
- if not signature or not sender_id:
390
- return JsonResponse({"detail": "signature required"}, status=403)
391
- node = Node.objects.filter(uuid=sender_id).first()
392
- if not node or not node.public_key:
393
- return JsonResponse({"detail": "unknown sender"}, status=403)
394
- try:
395
- public_key = serialization.load_pem_public_key(node.public_key.encode())
396
- public_key.verify(
397
- base64.b64decode(signature),
398
- request.body,
399
- padding.PKCS1v15(),
400
- hashes.SHA256(),
401
- )
402
- except Exception:
403
- return JsonResponse({"detail": "invalid signature"}, status=403)
404
-
405
- msg_uuid = data.get("uuid")
406
- subject = data.get("subject", "")
407
- body = data.get("body", "")
408
- reach_name = data.get("reach")
409
- reach_role = None
410
- if reach_name:
411
- reach_role = NodeRole.objects.filter(name=reach_name).first()
412
- seen = data.get("seen", [])
413
- origin_id = data.get("origin")
414
- origin_node = None
415
- if origin_id:
416
- origin_node = Node.objects.filter(uuid=origin_id).first()
417
- if not origin_node:
418
- origin_node = node
419
- if not msg_uuid:
420
- return JsonResponse({"detail": "uuid required"}, status=400)
421
- msg, created = NetMessage.objects.get_or_create(
422
- uuid=msg_uuid,
423
- defaults={
424
- "subject": subject[:64],
425
- "body": body[:256],
426
- "reach": reach_role,
427
- "node_origin": origin_node,
428
- },
429
- )
430
- if not created:
431
- msg.subject = subject[:64]
432
- msg.body = body[:256]
433
- update_fields = ["subject", "body"]
434
- if reach_role and msg.reach_id != reach_role.id:
435
- msg.reach = reach_role
436
- update_fields.append("reach")
437
- if msg.node_origin_id is None and origin_node:
438
- msg.node_origin = origin_node
439
- update_fields.append("node_origin")
440
- msg.save(update_fields=update_fields)
441
- msg.propagate(seen=seen)
442
- return JsonResponse({"status": "propagated", "complete": msg.complete})
443
-
444
-
445
- def last_net_message(request):
446
- """Return the most recent :class:`NetMessage`."""
447
-
448
- msg = NetMessage.objects.order_by("-created").first()
449
- if not msg:
450
- return JsonResponse({"subject": "", "body": ""})
451
- return JsonResponse({"subject": msg.subject, "body": msg.body})
1
+ import base64
2
+ import ipaddress
3
+ import json
4
+ import socket
5
+ from collections.abc import Mapping
6
+
7
+ from django.http import JsonResponse
8
+ from django.http.request import split_domain_port
9
+ from django.views.decorators.csrf import csrf_exempt
10
+ from django.shortcuts import get_object_or_404
11
+ from django.conf import settings
12
+ from django.urls import reverse
13
+ from pathlib import Path
14
+ from django.utils.cache import patch_vary_headers
15
+
16
+ from utils.api import api_login_required
17
+
18
+ from cryptography.hazmat.primitives import serialization, hashes
19
+ from cryptography.hazmat.primitives.asymmetric import padding
20
+
21
+ from core.models import RFID
22
+
23
+ from .rfid_sync import apply_rfid_payload, serialize_rfid
24
+
25
+ from .models import (
26
+ Node,
27
+ NetMessage,
28
+ NodeFeature,
29
+ NodeRole,
30
+ node_information_updated,
31
+ )
32
+ from .utils import capture_screenshot, save_screenshot
33
+
34
+
35
+ def _get_client_ip(request):
36
+ """Return the client IP from the request headers."""
37
+
38
+ forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
39
+ if forwarded_for:
40
+ for value in forwarded_for.split(","):
41
+ candidate = value.strip()
42
+ if candidate:
43
+ return candidate
44
+ return request.META.get("REMOTE_ADDR", "")
45
+
46
+
47
+ def _get_route_address(remote_ip: str, port: int) -> str:
48
+ """Return the local address used to reach ``remote_ip``."""
49
+
50
+ if not remote_ip:
51
+ return ""
52
+ try:
53
+ parsed = ipaddress.ip_address(remote_ip)
54
+ except ValueError:
55
+ return ""
56
+
57
+ try:
58
+ target_port = int(port)
59
+ except (TypeError, ValueError):
60
+ target_port = 1
61
+ if target_port <= 0 or target_port > 65535:
62
+ target_port = 1
63
+
64
+ family = socket.AF_INET6 if parsed.version == 6 else socket.AF_INET
65
+ try:
66
+ with socket.socket(family, socket.SOCK_DGRAM) as sock:
67
+ if family == socket.AF_INET6:
68
+ sock.connect((remote_ip, target_port, 0, 0))
69
+ else:
70
+ sock.connect((remote_ip, target_port))
71
+ return sock.getsockname()[0]
72
+ except OSError:
73
+ return ""
74
+
75
+
76
+ def _get_host_ip(request) -> str:
77
+ """Return the IP address from the host header if available."""
78
+
79
+ try:
80
+ host = request.get_host()
81
+ except Exception: # pragma: no cover - defensive
82
+ return ""
83
+ if not host:
84
+ return ""
85
+ domain, _ = split_domain_port(host)
86
+ if not domain:
87
+ return ""
88
+ try:
89
+ ipaddress.ip_address(domain)
90
+ except ValueError:
91
+ return ""
92
+ return domain
93
+
94
+
95
+ def _get_host_domain(request) -> str:
96
+ """Return the domain from the host header when it isn't an IP."""
97
+
98
+ try:
99
+ host = request.get_host()
100
+ except Exception: # pragma: no cover - defensive
101
+ return ""
102
+ if not host:
103
+ return ""
104
+ domain, _ = split_domain_port(host)
105
+ if not domain:
106
+ return ""
107
+ try:
108
+ ipaddress.ip_address(domain)
109
+ except ValueError:
110
+ return domain
111
+ return ""
112
+
113
+
114
+ def _get_advertised_address(request, node) -> str:
115
+ """Return the best address for the client to reach this node."""
116
+
117
+ client_ip = _get_client_ip(request)
118
+ route_address = _get_route_address(client_ip, node.port)
119
+ if route_address:
120
+ return route_address
121
+ host_ip = _get_host_ip(request)
122
+ if host_ip:
123
+ return host_ip
124
+ return node.address
125
+
126
+
127
+ @api_login_required
128
+ def node_list(request):
129
+ """Return a JSON list of all known nodes."""
130
+
131
+ nodes = [
132
+ {
133
+ "hostname": node.hostname,
134
+ "address": node.address,
135
+ "port": node.port,
136
+ "last_seen": node.last_seen,
137
+ "features": list(node.features.values_list("slug", flat=True)),
138
+ }
139
+ for node in Node.objects.prefetch_related("features")
140
+ ]
141
+ return JsonResponse({"nodes": nodes})
142
+
143
+
144
+ @csrf_exempt
145
+ def node_info(request):
146
+ """Return information about the local node and sign ``token`` if provided."""
147
+
148
+ node = Node.get_local()
149
+ if node is None:
150
+ node, _ = Node.register_current()
151
+
152
+ token = request.GET.get("token", "")
153
+ host_domain = _get_host_domain(request)
154
+ advertised_address = _get_advertised_address(request, node)
155
+ if host_domain:
156
+ hostname = host_domain
157
+ if advertised_address and advertised_address != node.address:
158
+ address = advertised_address
159
+ else:
160
+ address = host_domain
161
+ else:
162
+ hostname = node.hostname
163
+ address = advertised_address
164
+ data = {
165
+ "hostname": hostname,
166
+ "address": address,
167
+ "port": node.port,
168
+ "mac_address": node.mac_address,
169
+ "public_key": node.public_key,
170
+ "features": list(node.features.values_list("slug", flat=True)),
171
+ }
172
+
173
+ if token:
174
+ try:
175
+ priv_path = (
176
+ Path(node.base_path or settings.BASE_DIR)
177
+ / "security"
178
+ / f"{node.public_endpoint}"
179
+ )
180
+ private_key = serialization.load_pem_private_key(
181
+ priv_path.read_bytes(), password=None
182
+ )
183
+ signature = private_key.sign(
184
+ token.encode(),
185
+ padding.PKCS1v15(),
186
+ hashes.SHA256(),
187
+ )
188
+ data["token_signature"] = base64.b64encode(signature).decode()
189
+ except Exception:
190
+ pass
191
+
192
+ response = JsonResponse(data)
193
+ response["Access-Control-Allow-Origin"] = "*"
194
+ return response
195
+
196
+
197
+ def _add_cors_headers(request, response):
198
+ origin = request.headers.get("Origin")
199
+ if origin:
200
+ response["Access-Control-Allow-Origin"] = origin
201
+ response["Access-Control-Allow-Credentials"] = "true"
202
+ allow_headers = request.headers.get(
203
+ "Access-Control-Request-Headers", "Content-Type"
204
+ )
205
+ response["Access-Control-Allow-Headers"] = allow_headers
206
+ response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
207
+ patch_vary_headers(response, ["Origin"])
208
+ return response
209
+
210
+
211
+ def _node_display_name(node: Node) -> str:
212
+ """Return a human-friendly name for ``node`` suitable for messaging."""
213
+
214
+ for attr in ("hostname", "public_endpoint", "address"):
215
+ value = getattr(node, attr, "") or ""
216
+ value = value.strip()
217
+ if value:
218
+ return value
219
+ identifier = getattr(node, "pk", None)
220
+ return str(identifier or node)
221
+
222
+
223
+ def _announce_visitor_join(new_node: Node, relation: Node.Relation | None) -> None:
224
+ """Emit a network message when the visitor node links to a host."""
225
+
226
+ if relation != Node.Relation.UPSTREAM:
227
+ return
228
+
229
+ local_node = Node.get_local()
230
+ if not local_node:
231
+ return
232
+
233
+ visitor_name = _node_display_name(local_node)
234
+ host_name = _node_display_name(new_node)
235
+ NetMessage.broadcast(subject=f"NODE {visitor_name}", body=f"JOINS {host_name}")
236
+
237
+
238
+ @csrf_exempt
239
+ def register_node(request):
240
+ """Register or update a node from POSTed JSON data."""
241
+
242
+ if request.method == "OPTIONS":
243
+ response = JsonResponse({"detail": "ok"})
244
+ return _add_cors_headers(request, response)
245
+
246
+ if request.method != "POST":
247
+ response = JsonResponse({"detail": "POST required"}, status=400)
248
+ return _add_cors_headers(request, response)
249
+
250
+ try:
251
+ data = json.loads(request.body.decode())
252
+ except json.JSONDecodeError:
253
+ data = request.POST
254
+
255
+ if hasattr(data, "getlist"):
256
+ raw_features = data.getlist("features")
257
+ if not raw_features:
258
+ features = None
259
+ elif len(raw_features) == 1:
260
+ features = raw_features[0]
261
+ else:
262
+ features = raw_features
263
+ else:
264
+ features = data.get("features")
265
+
266
+ hostname = data.get("hostname")
267
+ address = data.get("address")
268
+ port = data.get("port", 8000)
269
+ mac_address = data.get("mac_address")
270
+ public_key = data.get("public_key")
271
+ token = data.get("token")
272
+ signature = data.get("signature")
273
+ installed_version = data.get("installed_version")
274
+ installed_revision = data.get("installed_revision")
275
+ relation_present = False
276
+ if hasattr(data, "getlist"):
277
+ relation_present = "current_relation" in data
278
+ else:
279
+ relation_present = "current_relation" in data
280
+ raw_relation = data.get("current_relation")
281
+ relation_value = (
282
+ Node.normalize_relation(raw_relation) if relation_present else None
283
+ )
284
+
285
+ if not hostname or not address or not mac_address:
286
+ response = JsonResponse(
287
+ {"detail": "hostname, address and mac_address required"}, status=400
288
+ )
289
+ return _add_cors_headers(request, response)
290
+
291
+ verified = False
292
+ if public_key and token and signature:
293
+ try:
294
+ pub = serialization.load_pem_public_key(public_key.encode())
295
+ pub.verify(
296
+ base64.b64decode(signature),
297
+ token.encode(),
298
+ padding.PKCS1v15(),
299
+ hashes.SHA256(),
300
+ )
301
+ verified = True
302
+ except Exception:
303
+ response = JsonResponse({"detail": "invalid signature"}, status=403)
304
+ return _add_cors_headers(request, response)
305
+
306
+ if not verified and not request.user.is_authenticated:
307
+ response = JsonResponse({"detail": "authentication required"}, status=401)
308
+ return _add_cors_headers(request, response)
309
+
310
+ mac_address = mac_address.lower()
311
+ defaults = {
312
+ "hostname": hostname,
313
+ "address": address,
314
+ "port": port,
315
+ }
316
+ if verified:
317
+ defaults["public_key"] = public_key
318
+ if installed_version is not None:
319
+ defaults["installed_version"] = str(installed_version)[:20]
320
+ if installed_revision is not None:
321
+ defaults["installed_revision"] = str(installed_revision)[:40]
322
+ if relation_value is not None:
323
+ defaults["current_relation"] = relation_value
324
+
325
+ node, created = Node.objects.get_or_create(
326
+ mac_address=mac_address,
327
+ defaults=defaults,
328
+ )
329
+ if not created:
330
+ previous_version = (node.installed_version or "").strip()
331
+ previous_revision = (node.installed_revision or "").strip()
332
+ node.hostname = hostname
333
+ node.address = address
334
+ node.port = port
335
+ update_fields = ["hostname", "address", "port"]
336
+ if verified:
337
+ node.public_key = public_key
338
+ update_fields.append("public_key")
339
+ if installed_version is not None:
340
+ node.installed_version = str(installed_version)[:20]
341
+ if "installed_version" not in update_fields:
342
+ update_fields.append("installed_version")
343
+ if installed_revision is not None:
344
+ node.installed_revision = str(installed_revision)[:40]
345
+ if "installed_revision" not in update_fields:
346
+ update_fields.append("installed_revision")
347
+ if relation_value is not None and node.current_relation != relation_value:
348
+ node.current_relation = relation_value
349
+ update_fields.append("current_relation")
350
+ node.save(update_fields=update_fields)
351
+ current_version = (node.installed_version or "").strip()
352
+ current_revision = (node.installed_revision or "").strip()
353
+ node_information_updated.send(
354
+ sender=Node,
355
+ node=node,
356
+ previous_version=previous_version,
357
+ previous_revision=previous_revision,
358
+ current_version=current_version,
359
+ current_revision=current_revision,
360
+ request=request,
361
+ )
362
+ if features is not None and (verified or request.user.is_authenticated):
363
+ if isinstance(features, (str, bytes)):
364
+ feature_list = [features]
365
+ else:
366
+ feature_list = list(features)
367
+ node.update_manual_features(feature_list)
368
+ response = JsonResponse(
369
+ {"id": node.id, "detail": f"Node already exists (id: {node.id})"}
370
+ )
371
+ return _add_cors_headers(request, response)
372
+
373
+ if features is not None and (verified or request.user.is_authenticated):
374
+ if isinstance(features, (str, bytes)):
375
+ feature_list = [features]
376
+ else:
377
+ feature_list = list(features)
378
+ node.update_manual_features(feature_list)
379
+
380
+ current_version = (node.installed_version or "").strip()
381
+ current_revision = (node.installed_revision or "").strip()
382
+ node_information_updated.send(
383
+ sender=Node,
384
+ node=node,
385
+ previous_version="",
386
+ previous_revision="",
387
+ current_version=current_version,
388
+ current_revision=current_revision,
389
+ request=request,
390
+ )
391
+
392
+ _announce_visitor_join(node, relation_value)
393
+
394
+ response = JsonResponse({"id": node.id})
395
+ return _add_cors_headers(request, response)
396
+
397
+
398
+ @api_login_required
399
+ def capture(request):
400
+ """Capture a screenshot of the site's root URL and record it."""
401
+
402
+ url = request.build_absolute_uri("/")
403
+ try:
404
+ path = capture_screenshot(url)
405
+ except Exception as exc: # pragma: no cover - depends on selenium setup
406
+ return JsonResponse({"detail": str(exc)}, status=500)
407
+ node = Node.get_local()
408
+ screenshot = save_screenshot(path, node=node, method=request.method)
409
+ node_id = screenshot.node.id if screenshot and screenshot.node else None
410
+ return JsonResponse({"screenshot": str(path), "node": node_id})
411
+
412
+
413
+ @csrf_exempt
414
+ def export_rfids(request):
415
+ """Return serialized RFID records for authenticated peers."""
416
+
417
+ if request.method != "POST":
418
+ return JsonResponse({"detail": "POST required"}, status=405)
419
+
420
+ try:
421
+ payload = json.loads(request.body.decode() or "{}")
422
+ except json.JSONDecodeError:
423
+ return JsonResponse({"detail": "invalid json"}, status=400)
424
+
425
+ requester = payload.get("requester")
426
+ signature = request.headers.get("X-Signature")
427
+ if not requester:
428
+ return JsonResponse({"detail": "requester required"}, status=400)
429
+ if not signature:
430
+ return JsonResponse({"detail": "signature required"}, status=403)
431
+
432
+ node = Node.objects.filter(uuid=requester).first()
433
+ if not node or not node.public_key:
434
+ return JsonResponse({"detail": "unknown requester"}, status=403)
435
+
436
+ try:
437
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
438
+ public_key.verify(
439
+ base64.b64decode(signature),
440
+ request.body,
441
+ padding.PKCS1v15(),
442
+ hashes.SHA256(),
443
+ )
444
+ except Exception:
445
+ return JsonResponse({"detail": "invalid signature"}, status=403)
446
+
447
+ tags = [serialize_rfid(tag) for tag in RFID.objects.all().order_by("label_id")]
448
+
449
+ return JsonResponse({"rfids": tags})
450
+
451
+
452
+ @csrf_exempt
453
+ def import_rfids(request):
454
+ """Import RFID payloads from a trusted peer."""
455
+
456
+ if request.method != "POST":
457
+ return JsonResponse({"detail": "POST required"}, status=405)
458
+
459
+ try:
460
+ payload = json.loads(request.body.decode() or "{}")
461
+ except json.JSONDecodeError:
462
+ return JsonResponse({"detail": "invalid json"}, status=400)
463
+
464
+ requester = payload.get("requester")
465
+ signature = request.headers.get("X-Signature")
466
+ if not requester:
467
+ 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
+
475
+ try:
476
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
477
+ public_key.verify(
478
+ base64.b64decode(signature),
479
+ request.body,
480
+ padding.PKCS1v15(),
481
+ hashes.SHA256(),
482
+ )
483
+ except Exception:
484
+ return JsonResponse({"detail": "invalid signature"}, status=403)
485
+
486
+ rfids = payload.get("rfids", [])
487
+ if not isinstance(rfids, list):
488
+ return JsonResponse({"detail": "rfids must be a list"}, status=400)
489
+
490
+ created = 0
491
+ updated = 0
492
+ linked_accounts = 0
493
+ missing_accounts: list[str] = []
494
+ errors = 0
495
+
496
+ for entry in rfids:
497
+ if not isinstance(entry, Mapping):
498
+ errors += 1
499
+ continue
500
+ outcome = apply_rfid_payload(entry, origin_node=node)
501
+ if not outcome.ok:
502
+ errors += 1
503
+ if outcome.error:
504
+ missing_accounts.append(outcome.error)
505
+ continue
506
+ if outcome.created:
507
+ created += 1
508
+ else:
509
+ updated += 1
510
+ linked_accounts += outcome.accounts_linked
511
+ missing_accounts.extend(outcome.missing_accounts)
512
+
513
+ return JsonResponse(
514
+ {
515
+ "processed": len(rfids),
516
+ "created": created,
517
+ "updated": updated,
518
+ "accounts_linked": linked_accounts,
519
+ "missing_accounts": missing_accounts,
520
+ "errors": errors,
521
+ }
522
+ )
523
+
524
+
525
+ @csrf_exempt
526
+ @api_login_required
527
+ def public_node_endpoint(request, endpoint):
528
+ """Public API endpoint for a node.
529
+
530
+ - ``GET`` returns information about the node.
531
+ - ``POST`` broadcasts the request body as a :class:`NetMessage`.
532
+ """
533
+
534
+ node = get_object_or_404(Node, public_endpoint=endpoint, enable_public_api=True)
535
+
536
+ if request.method == "GET":
537
+ data = {
538
+ "hostname": node.hostname,
539
+ "address": node.address,
540
+ "port": node.port,
541
+ "badge_color": node.badge_color,
542
+ "last_seen": node.last_seen,
543
+ "features": list(node.features.values_list("slug", flat=True)),
544
+ }
545
+ return JsonResponse(data)
546
+
547
+ if request.method == "POST":
548
+ NetMessage.broadcast(
549
+ subject=request.method,
550
+ body=request.body.decode("utf-8") if request.body else "",
551
+ seen=[str(node.uuid)],
552
+ )
553
+ return JsonResponse({"status": "stored"})
554
+
555
+ return JsonResponse({"detail": "Method not allowed"}, status=405)
556
+
557
+
558
+ @csrf_exempt
559
+ def net_message(request):
560
+ """Receive a network message and continue propagation."""
561
+
562
+ if request.method != "POST":
563
+ return JsonResponse({"detail": "POST required"}, status=400)
564
+ try:
565
+ data = json.loads(request.body.decode())
566
+ except json.JSONDecodeError:
567
+ return JsonResponse({"detail": "invalid json"}, status=400)
568
+
569
+ signature = request.headers.get("X-Signature")
570
+ sender_id = data.get("sender")
571
+ if not signature or not sender_id:
572
+ return JsonResponse({"detail": "signature required"}, status=403)
573
+ node = Node.objects.filter(uuid=sender_id).first()
574
+ if not node or not node.public_key:
575
+ return JsonResponse({"detail": "unknown sender"}, status=403)
576
+ try:
577
+ public_key = serialization.load_pem_public_key(node.public_key.encode())
578
+ public_key.verify(
579
+ base64.b64decode(signature),
580
+ request.body,
581
+ padding.PKCS1v15(),
582
+ hashes.SHA256(),
583
+ )
584
+ except Exception:
585
+ return JsonResponse({"detail": "invalid signature"}, status=403)
586
+
587
+ msg_uuid = data.get("uuid")
588
+ subject = data.get("subject", "")
589
+ body = data.get("body", "")
590
+ attachments = NetMessage.normalize_attachments(data.get("attachments"))
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)
668
+ return JsonResponse({"status": "propagated", "complete": msg.complete})
669
+
670
+
671
+ def last_net_message(request):
672
+ """Return the most recent :class:`NetMessage`."""
673
+
674
+ msg = NetMessage.objects.order_by("-created").first()
675
+ if not msg:
676
+ return JsonResponse({"subject": "", "body": "", "admin_url": ""})
677
+ return JsonResponse(
678
+ {
679
+ "subject": msg.subject,
680
+ "body": msg.body,
681
+ "admin_url": reverse("admin:nodes_netmessage_change", args=[msg.pk]),
682
+ }
683
+ )