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.
- arthexis-0.1.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {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
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
from
|
|
14
|
-
from
|
|
15
|
-
|
|
16
|
-
from .
|
|
17
|
-
from .
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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})
|