arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
nodes/models.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from core.entity import Entity
|
|
3
|
+
from core.fields import SigilShortAutoField
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
import base64
|
|
7
|
+
from django.utils.text import slugify
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.contrib.sites.models import Site
|
|
10
|
+
import uuid
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from utils import revision
|
|
15
|
+
from django.core.exceptions import ValidationError
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
17
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
19
|
+
from django.contrib.auth import get_user_model
|
|
20
|
+
from django.core.mail import get_connection, send_mail
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NodeRoleManager(models.Manager):
|
|
24
|
+
def get_by_natural_key(self, name: str):
|
|
25
|
+
return self.get(name=name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NodeRole(Entity):
|
|
29
|
+
"""Assignable role for a :class:`Node`."""
|
|
30
|
+
|
|
31
|
+
name = models.CharField(max_length=50, unique=True)
|
|
32
|
+
description = models.CharField(max_length=200, blank=True)
|
|
33
|
+
|
|
34
|
+
objects = NodeRoleManager()
|
|
35
|
+
|
|
36
|
+
class Meta:
|
|
37
|
+
ordering = ["name"]
|
|
38
|
+
verbose_name = "Node Role"
|
|
39
|
+
verbose_name_plural = "Node Roles"
|
|
40
|
+
|
|
41
|
+
def natural_key(self): # pragma: no cover - simple representation
|
|
42
|
+
return (self.name,)
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
45
|
+
return self.name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_terminal_role():
|
|
49
|
+
"""Return the NodeRole representing a Terminal if it exists."""
|
|
50
|
+
return NodeRole.objects.filter(name="Terminal").first()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Node(Entity):
|
|
54
|
+
"""Information about a running node in the network."""
|
|
55
|
+
|
|
56
|
+
hostname = models.CharField(max_length=100)
|
|
57
|
+
address = models.GenericIPAddressField()
|
|
58
|
+
mac_address = models.CharField(
|
|
59
|
+
max_length=17, unique=True, null=True, blank=True
|
|
60
|
+
)
|
|
61
|
+
port = models.PositiveIntegerField(default=8000)
|
|
62
|
+
badge_color = models.CharField(max_length=7, default="#28a745")
|
|
63
|
+
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
64
|
+
last_seen = models.DateTimeField(auto_now=True)
|
|
65
|
+
enable_public_api = models.BooleanField(
|
|
66
|
+
default=False,
|
|
67
|
+
verbose_name="enable public API",
|
|
68
|
+
)
|
|
69
|
+
public_endpoint = models.SlugField(blank=True, unique=True)
|
|
70
|
+
clipboard_polling = models.BooleanField(default=False)
|
|
71
|
+
screenshot_polling = models.BooleanField(default=False)
|
|
72
|
+
uuid = models.UUIDField(
|
|
73
|
+
default=uuid.uuid4,
|
|
74
|
+
unique=True,
|
|
75
|
+
editable=False,
|
|
76
|
+
verbose_name="UUID",
|
|
77
|
+
)
|
|
78
|
+
public_key = models.TextField(blank=True)
|
|
79
|
+
base_path = models.CharField(max_length=255, blank=True)
|
|
80
|
+
installed_version = models.CharField(max_length=20, blank=True)
|
|
81
|
+
installed_revision = models.CharField(max_length=40, blank=True)
|
|
82
|
+
has_lcd_screen = models.BooleanField(default=False)
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
85
|
+
return f"{self.hostname}:{self.port}"
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def get_current_mac() -> str:
|
|
89
|
+
"""Return the MAC address of the current host."""
|
|
90
|
+
return ":".join(re.findall("..", f"{uuid.getnode():012x}"))
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def get_local(cls):
|
|
94
|
+
"""Return the node representing the current host if it exists."""
|
|
95
|
+
mac = cls.get_current_mac()
|
|
96
|
+
return cls.objects.filter(mac_address=mac).first()
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def register_current(cls):
|
|
100
|
+
"""Create or update the :class:`Node` entry for this host."""
|
|
101
|
+
hostname = socket.gethostname()
|
|
102
|
+
try:
|
|
103
|
+
address = socket.gethostbyname(hostname)
|
|
104
|
+
except OSError:
|
|
105
|
+
address = "127.0.0.1"
|
|
106
|
+
port = int(os.environ.get("PORT", 8000))
|
|
107
|
+
base_path = str(settings.BASE_DIR)
|
|
108
|
+
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
109
|
+
installed_version = ver_path.read_text().strip() if ver_path.exists() else ""
|
|
110
|
+
rev_value = revision.get_revision()
|
|
111
|
+
installed_revision = rev_value if rev_value else ""
|
|
112
|
+
mac = cls.get_current_mac()
|
|
113
|
+
slug = slugify(hostname)
|
|
114
|
+
node = cls.objects.filter(mac_address=mac).first()
|
|
115
|
+
if not node:
|
|
116
|
+
node = cls.objects.filter(public_endpoint=slug).first()
|
|
117
|
+
lcd_lock = Path(settings.BASE_DIR) / "locks" / "lcd_screen.lck"
|
|
118
|
+
defaults = {
|
|
119
|
+
"hostname": hostname,
|
|
120
|
+
"address": address,
|
|
121
|
+
"port": port,
|
|
122
|
+
"base_path": base_path,
|
|
123
|
+
"installed_version": installed_version,
|
|
124
|
+
"installed_revision": installed_revision,
|
|
125
|
+
"public_endpoint": slug,
|
|
126
|
+
"mac_address": mac,
|
|
127
|
+
"has_lcd_screen": lcd_lock.exists(),
|
|
128
|
+
}
|
|
129
|
+
if node:
|
|
130
|
+
for field, value in defaults.items():
|
|
131
|
+
if field == "has_lcd_screen":
|
|
132
|
+
continue
|
|
133
|
+
setattr(node, field, value)
|
|
134
|
+
update_fields = [k for k in defaults.keys() if k != "has_lcd_screen"]
|
|
135
|
+
node.save(update_fields=update_fields)
|
|
136
|
+
created = False
|
|
137
|
+
else:
|
|
138
|
+
node = cls.objects.create(**defaults)
|
|
139
|
+
created = True
|
|
140
|
+
# assign role from installation lock file
|
|
141
|
+
role_lock = Path(settings.BASE_DIR) / "locks" / "role.lck"
|
|
142
|
+
role_name = (
|
|
143
|
+
role_lock.read_text().strip() if role_lock.exists() else "Terminal"
|
|
144
|
+
)
|
|
145
|
+
role = NodeRole.objects.filter(name=role_name).first()
|
|
146
|
+
if role:
|
|
147
|
+
node.role = role
|
|
148
|
+
node.save(update_fields=["role"])
|
|
149
|
+
if created and node.role is None:
|
|
150
|
+
terminal = NodeRole.objects.filter(name="Terminal").first()
|
|
151
|
+
if terminal:
|
|
152
|
+
node.role = terminal
|
|
153
|
+
node.save(update_fields=["role"])
|
|
154
|
+
Site.objects.get_or_create(domain=hostname, defaults={"name": "host"})
|
|
155
|
+
node.ensure_keys()
|
|
156
|
+
return node, created
|
|
157
|
+
|
|
158
|
+
def ensure_keys(self):
|
|
159
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
160
|
+
security_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
priv_path = security_dir / f"{self.public_endpoint}"
|
|
162
|
+
pub_path = security_dir / f"{self.public_endpoint}.pub"
|
|
163
|
+
if not priv_path.exists() or not pub_path.exists():
|
|
164
|
+
private_key = rsa.generate_private_key(
|
|
165
|
+
public_exponent=65537, key_size=2048
|
|
166
|
+
)
|
|
167
|
+
private_bytes = private_key.private_bytes(
|
|
168
|
+
encoding=serialization.Encoding.PEM,
|
|
169
|
+
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
170
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
171
|
+
)
|
|
172
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
173
|
+
encoding=serialization.Encoding.PEM,
|
|
174
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
175
|
+
)
|
|
176
|
+
priv_path.write_bytes(private_bytes)
|
|
177
|
+
pub_path.write_bytes(public_bytes)
|
|
178
|
+
self.public_key = public_bytes.decode()
|
|
179
|
+
self.save(update_fields=["public_key"])
|
|
180
|
+
elif not self.public_key:
|
|
181
|
+
self.public_key = pub_path.read_text()
|
|
182
|
+
self.save(update_fields=["public_key"])
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def is_local(self):
|
|
186
|
+
"""Determine if this node represents the current host."""
|
|
187
|
+
return self.mac_address == self.get_current_mac()
|
|
188
|
+
|
|
189
|
+
def save(self, *args, **kwargs):
|
|
190
|
+
if self.mac_address:
|
|
191
|
+
self.mac_address = self.mac_address.lower()
|
|
192
|
+
if not self.public_endpoint:
|
|
193
|
+
self.public_endpoint = slugify(self.hostname)
|
|
194
|
+
previous_clipboard = previous_screenshot = None
|
|
195
|
+
if self.pk:
|
|
196
|
+
previous = Node.objects.get(pk=self.pk)
|
|
197
|
+
previous_clipboard = previous.clipboard_polling
|
|
198
|
+
previous_screenshot = previous.screenshot_polling
|
|
199
|
+
super().save(*args, **kwargs)
|
|
200
|
+
if previous_clipboard != self.clipboard_polling:
|
|
201
|
+
self._sync_clipboard_task()
|
|
202
|
+
if previous_screenshot != self.screenshot_polling:
|
|
203
|
+
self._sync_screenshot_task()
|
|
204
|
+
|
|
205
|
+
def _sync_clipboard_task(self):
|
|
206
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
207
|
+
|
|
208
|
+
task_name = f"poll_clipboard_node_{self.pk}"
|
|
209
|
+
if self.clipboard_polling:
|
|
210
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
211
|
+
every=5, period=IntervalSchedule.SECONDS
|
|
212
|
+
)
|
|
213
|
+
PeriodicTask.objects.update_or_create(
|
|
214
|
+
name=task_name,
|
|
215
|
+
defaults={
|
|
216
|
+
"interval": schedule,
|
|
217
|
+
"task": "nodes.tasks.sample_clipboard",
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
222
|
+
|
|
223
|
+
def _sync_screenshot_task(self):
|
|
224
|
+
from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|
225
|
+
import json
|
|
226
|
+
|
|
227
|
+
task_name = f"capture_screenshot_node_{self.pk}"
|
|
228
|
+
if self.screenshot_polling:
|
|
229
|
+
schedule, _ = IntervalSchedule.objects.get_or_create(
|
|
230
|
+
every=1, period=IntervalSchedule.MINUTES
|
|
231
|
+
)
|
|
232
|
+
PeriodicTask.objects.update_or_create(
|
|
233
|
+
name=task_name,
|
|
234
|
+
defaults={
|
|
235
|
+
"interval": schedule,
|
|
236
|
+
"task": "nodes.tasks.capture_node_screenshot",
|
|
237
|
+
"kwargs": json.dumps(
|
|
238
|
+
{
|
|
239
|
+
"url": f"http://localhost:{self.port}",
|
|
240
|
+
"port": self.port,
|
|
241
|
+
"method": "AUTO",
|
|
242
|
+
}
|
|
243
|
+
),
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
PeriodicTask.objects.filter(name=task_name).delete()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def send_mail(self, subject: str, message: str, recipient_list: list[str], from_email: str | None = None, **kwargs):
|
|
251
|
+
"""Send an email using this node's configured outbox if available."""
|
|
252
|
+
outbox = getattr(self, "email_outbox", None)
|
|
253
|
+
if outbox:
|
|
254
|
+
return outbox.send_mail(subject, message, recipient_list, from_email, **kwargs)
|
|
255
|
+
from_email = from_email or settings.DEFAULT_FROM_EMAIL
|
|
256
|
+
return send_mail(subject, message, from_email, recipient_list, **kwargs)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class EmailOutbox(Entity):
|
|
260
|
+
"""SMTP credentials for sending mail from a node."""
|
|
261
|
+
|
|
262
|
+
node = models.OneToOneField(
|
|
263
|
+
Node, on_delete=models.CASCADE, related_name="email_outbox"
|
|
264
|
+
)
|
|
265
|
+
host = SigilShortAutoField(
|
|
266
|
+
max_length=100,
|
|
267
|
+
help_text=(
|
|
268
|
+
"Gmail: smtp.gmail.com. "
|
|
269
|
+
"GoDaddy: smtpout.secureserver.net"
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
port = models.PositiveIntegerField(
|
|
273
|
+
default=587,
|
|
274
|
+
help_text=(
|
|
275
|
+
"Gmail: 587 (TLS). "
|
|
276
|
+
"GoDaddy: 587 (TLS) or 465 (SSL)"
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
username = SigilShortAutoField(
|
|
280
|
+
max_length=100,
|
|
281
|
+
blank=True,
|
|
282
|
+
help_text="Full email address for Gmail or GoDaddy",
|
|
283
|
+
)
|
|
284
|
+
password = SigilShortAutoField(
|
|
285
|
+
max_length=100,
|
|
286
|
+
blank=True,
|
|
287
|
+
help_text="Email account password or app password",
|
|
288
|
+
)
|
|
289
|
+
use_tls = models.BooleanField(
|
|
290
|
+
default=True,
|
|
291
|
+
help_text="Check for Gmail or GoDaddy on port 587",
|
|
292
|
+
)
|
|
293
|
+
use_ssl = models.BooleanField(
|
|
294
|
+
default=False,
|
|
295
|
+
help_text="Check for GoDaddy on port 465; Gmail does not use SSL",
|
|
296
|
+
)
|
|
297
|
+
from_email = SigilShortAutoField(
|
|
298
|
+
blank=True,
|
|
299
|
+
verbose_name="From Email",
|
|
300
|
+
max_length=254,
|
|
301
|
+
help_text="Default From address; usually the same as username",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
class Meta:
|
|
305
|
+
verbose_name = "Email Outbox"
|
|
306
|
+
verbose_name_plural = "Email Outboxes"
|
|
307
|
+
|
|
308
|
+
class Meta:
|
|
309
|
+
verbose_name = "Email Outbox"
|
|
310
|
+
verbose_name_plural = "Email Outboxes"
|
|
311
|
+
|
|
312
|
+
def get_connection(self):
|
|
313
|
+
return get_connection(
|
|
314
|
+
host=self.host,
|
|
315
|
+
port=self.port,
|
|
316
|
+
username=self.username or None,
|
|
317
|
+
password=self.password or None,
|
|
318
|
+
use_tls=self.use_tls,
|
|
319
|
+
use_ssl=self.use_ssl,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def send_mail(self, subject, message, recipient_list, from_email=None, **kwargs):
|
|
323
|
+
connection = self.get_connection()
|
|
324
|
+
from_email = from_email or self.from_email or settings.DEFAULT_FROM_EMAIL
|
|
325
|
+
return send_mail(
|
|
326
|
+
subject,
|
|
327
|
+
message,
|
|
328
|
+
from_email,
|
|
329
|
+
recipient_list,
|
|
330
|
+
connection=connection,
|
|
331
|
+
**kwargs,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class NetMessage(Entity):
|
|
336
|
+
"""Message propagated across nodes."""
|
|
337
|
+
|
|
338
|
+
uuid = models.UUIDField(
|
|
339
|
+
default=uuid.uuid4,
|
|
340
|
+
unique=True,
|
|
341
|
+
editable=False,
|
|
342
|
+
verbose_name="UUID",
|
|
343
|
+
)
|
|
344
|
+
subject = models.CharField(max_length=64, blank=True)
|
|
345
|
+
body = models.CharField(max_length=256, blank=True)
|
|
346
|
+
reach = models.ForeignKey(
|
|
347
|
+
NodeRole,
|
|
348
|
+
on_delete=models.SET_NULL,
|
|
349
|
+
null=True,
|
|
350
|
+
blank=True,
|
|
351
|
+
default=get_terminal_role,
|
|
352
|
+
)
|
|
353
|
+
propagated_to = models.ManyToManyField(
|
|
354
|
+
Node, blank=True, related_name="received_net_messages"
|
|
355
|
+
)
|
|
356
|
+
created = models.DateTimeField(auto_now_add=True)
|
|
357
|
+
complete = models.BooleanField(default=False, editable=False)
|
|
358
|
+
|
|
359
|
+
class Meta:
|
|
360
|
+
ordering = ["-created"]
|
|
361
|
+
verbose_name = "Net Message"
|
|
362
|
+
verbose_name_plural = "Net Messages"
|
|
363
|
+
|
|
364
|
+
@classmethod
|
|
365
|
+
def broadcast(
|
|
366
|
+
cls,
|
|
367
|
+
subject: str,
|
|
368
|
+
body: str,
|
|
369
|
+
reach: NodeRole | str | None = None,
|
|
370
|
+
seen: list[str] | None = None,
|
|
371
|
+
):
|
|
372
|
+
role = None
|
|
373
|
+
if reach:
|
|
374
|
+
if isinstance(reach, NodeRole):
|
|
375
|
+
role = reach
|
|
376
|
+
else:
|
|
377
|
+
role = NodeRole.objects.filter(name=reach).first()
|
|
378
|
+
msg = cls.objects.create(
|
|
379
|
+
subject=subject[:64],
|
|
380
|
+
body=body[:256],
|
|
381
|
+
reach=role or get_terminal_role(),
|
|
382
|
+
)
|
|
383
|
+
msg.propagate(seen=seen or [])
|
|
384
|
+
return msg
|
|
385
|
+
|
|
386
|
+
def propagate(self, seen: list[str] | None = None):
|
|
387
|
+
from core.notifications import notify
|
|
388
|
+
import random
|
|
389
|
+
import requests
|
|
390
|
+
|
|
391
|
+
notify(self.subject, self.body)
|
|
392
|
+
local = Node.get_local()
|
|
393
|
+
private_key = None
|
|
394
|
+
seen = list(seen or [])
|
|
395
|
+
local_id = None
|
|
396
|
+
if local:
|
|
397
|
+
local_id = str(local.uuid)
|
|
398
|
+
if local_id not in seen:
|
|
399
|
+
seen.append(local_id)
|
|
400
|
+
priv_path = (
|
|
401
|
+
Path(local.base_path or settings.BASE_DIR)
|
|
402
|
+
/ "security"
|
|
403
|
+
/ f"{local.public_endpoint}"
|
|
404
|
+
)
|
|
405
|
+
try:
|
|
406
|
+
private_key = serialization.load_pem_private_key(
|
|
407
|
+
priv_path.read_bytes(), password=None
|
|
408
|
+
)
|
|
409
|
+
except Exception:
|
|
410
|
+
private_key = None
|
|
411
|
+
for node_id in seen:
|
|
412
|
+
node = Node.objects.filter(uuid=node_id).first()
|
|
413
|
+
if node and (not local or node.pk != local.pk):
|
|
414
|
+
self.propagated_to.add(node)
|
|
415
|
+
|
|
416
|
+
all_nodes = Node.objects.all()
|
|
417
|
+
if local:
|
|
418
|
+
all_nodes = all_nodes.exclude(pk=local.pk)
|
|
419
|
+
total_known = all_nodes.count()
|
|
420
|
+
|
|
421
|
+
remaining = list(
|
|
422
|
+
all_nodes.exclude(pk__in=self.propagated_to.values_list("pk", flat=True))
|
|
423
|
+
)
|
|
424
|
+
if not remaining:
|
|
425
|
+
self.complete = True
|
|
426
|
+
self.save(update_fields=["complete"])
|
|
427
|
+
return
|
|
428
|
+
|
|
429
|
+
target_limit = min(3, len(remaining))
|
|
430
|
+
|
|
431
|
+
reach_name = self.reach.name if self.reach else "Terminal"
|
|
432
|
+
role_map = {
|
|
433
|
+
"Terminal": ["Terminal"],
|
|
434
|
+
"Control": ["Control", "Terminal"],
|
|
435
|
+
"Satellite": ["Satellite", "Control", "Terminal"],
|
|
436
|
+
"Constellation": ["Constellation", "Satellite", "Control", "Terminal"],
|
|
437
|
+
}
|
|
438
|
+
role_order = role_map.get(reach_name, ["Terminal"])
|
|
439
|
+
selected: list[Node] = []
|
|
440
|
+
for role_name in role_order:
|
|
441
|
+
role_nodes = [n for n in remaining if n.role and n.role.name == role_name]
|
|
442
|
+
random.shuffle(role_nodes)
|
|
443
|
+
for n in role_nodes:
|
|
444
|
+
selected.append(n)
|
|
445
|
+
remaining.remove(n)
|
|
446
|
+
if len(selected) >= target_limit:
|
|
447
|
+
break
|
|
448
|
+
if len(selected) >= target_limit:
|
|
449
|
+
break
|
|
450
|
+
|
|
451
|
+
seen_list = seen.copy()
|
|
452
|
+
selected_ids = [str(n.uuid) for n in selected]
|
|
453
|
+
payload_seen = seen_list + selected_ids
|
|
454
|
+
for node in selected:
|
|
455
|
+
payload = {
|
|
456
|
+
"uuid": str(self.uuid),
|
|
457
|
+
"subject": self.subject,
|
|
458
|
+
"body": self.body,
|
|
459
|
+
"seen": payload_seen,
|
|
460
|
+
"reach": reach_name,
|
|
461
|
+
"sender": local_id,
|
|
462
|
+
}
|
|
463
|
+
payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
|
464
|
+
headers = {"Content-Type": "application/json"}
|
|
465
|
+
if private_key:
|
|
466
|
+
try:
|
|
467
|
+
signature = private_key.sign(
|
|
468
|
+
payload_json.encode(),
|
|
469
|
+
padding.PKCS1v15(),
|
|
470
|
+
hashes.SHA256(),
|
|
471
|
+
)
|
|
472
|
+
headers["X-Signature"] = base64.b64encode(signature).decode()
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
try:
|
|
476
|
+
requests.post(
|
|
477
|
+
f"http://{node.address}:{node.port}/nodes/net-message/",
|
|
478
|
+
data=payload_json,
|
|
479
|
+
headers=headers,
|
|
480
|
+
timeout=1,
|
|
481
|
+
)
|
|
482
|
+
except Exception:
|
|
483
|
+
pass
|
|
484
|
+
self.propagated_to.add(node)
|
|
485
|
+
|
|
486
|
+
if total_known and self.propagated_to.count() >= total_known:
|
|
487
|
+
self.complete = True
|
|
488
|
+
self.save(update_fields=["complete"] if self.complete else [])
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class ContentSample(Entity):
|
|
492
|
+
"""Collected content such as text snippets or screenshots."""
|
|
493
|
+
|
|
494
|
+
TEXT = "TEXT"
|
|
495
|
+
IMAGE = "IMAGE"
|
|
496
|
+
KIND_CHOICES = [(TEXT, "Text"), (IMAGE, "Image")]
|
|
497
|
+
|
|
498
|
+
name = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
499
|
+
kind = models.CharField(max_length=10, choices=KIND_CHOICES)
|
|
500
|
+
content = models.TextField(blank=True)
|
|
501
|
+
path = models.CharField(max_length=255, blank=True)
|
|
502
|
+
method = models.CharField(max_length=10, default="", blank=True)
|
|
503
|
+
hash = models.CharField(max_length=64, unique=True, null=True, blank=True)
|
|
504
|
+
transaction_uuid = models.UUIDField(
|
|
505
|
+
default=uuid.uuid4,
|
|
506
|
+
editable=True,
|
|
507
|
+
db_index=True,
|
|
508
|
+
verbose_name="transaction UUID",
|
|
509
|
+
)
|
|
510
|
+
node = models.ForeignKey(
|
|
511
|
+
Node, on_delete=models.SET_NULL, null=True, blank=True
|
|
512
|
+
)
|
|
513
|
+
user = models.ForeignKey(
|
|
514
|
+
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True
|
|
515
|
+
)
|
|
516
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
517
|
+
|
|
518
|
+
class Meta:
|
|
519
|
+
ordering = ["-created_at"]
|
|
520
|
+
verbose_name = "Content Sample"
|
|
521
|
+
verbose_name_plural = "Content Samples"
|
|
522
|
+
|
|
523
|
+
def save(self, *args, **kwargs):
|
|
524
|
+
if self.pk:
|
|
525
|
+
original = type(self).all_objects.get(pk=self.pk)
|
|
526
|
+
if original.transaction_uuid != self.transaction_uuid:
|
|
527
|
+
raise ValidationError(
|
|
528
|
+
{"transaction_uuid": "Cannot modify transaction UUID"}
|
|
529
|
+
)
|
|
530
|
+
if self.node_id is None:
|
|
531
|
+
self.node = Node.get_local()
|
|
532
|
+
super().save(*args, **kwargs)
|
|
533
|
+
|
|
534
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
535
|
+
return str(self.name)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class NodeTask(Entity):
|
|
539
|
+
"""Script that can be executed on nodes."""
|
|
540
|
+
|
|
541
|
+
recipe = models.TextField()
|
|
542
|
+
role = models.ForeignKey(NodeRole, on_delete=models.SET_NULL, null=True, blank=True)
|
|
543
|
+
created = models.DateTimeField(auto_now_add=True)
|
|
544
|
+
|
|
545
|
+
class Meta:
|
|
546
|
+
ordering = ["-created"]
|
|
547
|
+
verbose_name = "Node Task"
|
|
548
|
+
verbose_name_plural = "Node Tasks"
|
|
549
|
+
|
|
550
|
+
def __str__(self) -> str: # pragma: no cover - simple representation
|
|
551
|
+
return self.recipe
|
|
552
|
+
|
|
553
|
+
def run(self, node: Node):
|
|
554
|
+
"""Execute this script on ``node`` and return its output."""
|
|
555
|
+
if not node.is_local:
|
|
556
|
+
raise NotImplementedError("Remote node execution is not implemented")
|
|
557
|
+
import subprocess
|
|
558
|
+
|
|
559
|
+
result = subprocess.run(
|
|
560
|
+
self.recipe, shell=True, capture_output=True, text=True
|
|
561
|
+
)
|
|
562
|
+
return result.stdout + result.stderr
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
UserModel = get_user_model()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class User(UserModel):
|
|
569
|
+
class Meta:
|
|
570
|
+
proxy = True
|
|
571
|
+
app_label = "nodes"
|
|
572
|
+
verbose_name = UserModel._meta.verbose_name
|
|
573
|
+
verbose_name_plural = UserModel._meta.verbose_name_plural
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
nodes/tasks.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pyperclip
|
|
5
|
+
from pyperclip import PyperclipException
|
|
6
|
+
from celery import shared_task
|
|
7
|
+
|
|
8
|
+
from .models import ContentSample, Node
|
|
9
|
+
from .utils import capture_screenshot, save_screenshot
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@shared_task
|
|
15
|
+
def sample_clipboard() -> None:
|
|
16
|
+
"""Save current clipboard contents to a :class:`ContentSample` entry."""
|
|
17
|
+
try:
|
|
18
|
+
content = pyperclip.paste()
|
|
19
|
+
except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
|
|
20
|
+
logger.error("Clipboard error: %s", exc)
|
|
21
|
+
return
|
|
22
|
+
if not content:
|
|
23
|
+
logger.info("Clipboard is empty")
|
|
24
|
+
return
|
|
25
|
+
if ContentSample.objects.filter(
|
|
26
|
+
content=content, kind=ContentSample.TEXT
|
|
27
|
+
).exists():
|
|
28
|
+
logger.info("Duplicate clipboard content; sample not created")
|
|
29
|
+
return
|
|
30
|
+
node = Node.get_local()
|
|
31
|
+
ContentSample.objects.create(content=content, node=node, kind=ContentSample.TEXT)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@shared_task
|
|
35
|
+
def capture_node_screenshot(
|
|
36
|
+
url: str | None = None, port: int = 8000, method: str = "TASK"
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Capture a screenshot of ``url`` and record it as a :class:`ContentSample`."""
|
|
39
|
+
if url is None:
|
|
40
|
+
url = f"http://localhost:{port}"
|
|
41
|
+
try:
|
|
42
|
+
path: Path = capture_screenshot(url)
|
|
43
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
44
|
+
logger.error("Screenshot capture failed: %s", exc)
|
|
45
|
+
return ""
|
|
46
|
+
node = Node.get_local()
|
|
47
|
+
save_screenshot(path, node=node, method=method)
|
|
48
|
+
return str(path)
|
|
49
|
+
|
|
50
|
+
|