elo-node 0.4.6__tar.gz → 0.4.8__tar.gz
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.
- {elo_node-0.4.6 → elo_node-0.4.8}/PKG-INFO +1 -1
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/__init__.py +1 -1
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/node.py +85 -19
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/transport/protocol.py +8 -3
- {elo_node-0.4.6 → elo_node-0.4.8}/elo_node.egg-info/PKG-INFO +1 -1
- {elo_node-0.4.6 → elo_node-0.4.8}/pyproject.toml +1 -1
- {elo_node-0.4.6 → elo_node-0.4.8}/tests/test_unit.py +14 -1
- {elo_node-0.4.6 → elo_node-0.4.8}/README.md +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/__main__.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/security.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/transport/__init__.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/transport/routing.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/transport/tcp.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/transport/tracker.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo/types.py +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo_node.egg-info/SOURCES.txt +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo_node.egg-info/dependency_links.txt +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo_node.egg-info/entry_points.txt +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo_node.egg-info/requires.txt +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/elo_node.egg-info/top_level.txt +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/setup.cfg +0 -0
- {elo_node-0.4.6 → elo_node-0.4.8}/tests/test_security.py +0 -0
|
@@ -78,7 +78,7 @@ class Node:
|
|
|
78
78
|
peers: list[str] | None = None,
|
|
79
79
|
tracker: str = "public",
|
|
80
80
|
allowlist: list[str] | None = None,
|
|
81
|
-
version: str = "0.4.
|
|
81
|
+
version: str = "0.4.8",
|
|
82
82
|
identity: EphemeralIdentity | None = None,
|
|
83
83
|
verify_peers: bool = True,
|
|
84
84
|
heartbeat_interval_s: int = 30,
|
|
@@ -116,6 +116,14 @@ class Node:
|
|
|
116
116
|
self._pubkey_cache: dict[str, tuple[Any, float]] = {}
|
|
117
117
|
self._cache_ttl_s = heartbeat_interval_s * 5
|
|
118
118
|
|
|
119
|
+
# Rate limiting
|
|
120
|
+
self._msg_count: dict[str, tuple[int, float]] = {} # peer_addr → (count, window_start)
|
|
121
|
+
self._hello_count: dict[str, float] = {} # peer_addr → last_hello_timestamp
|
|
122
|
+
|
|
123
|
+
# Cache de query_ids para prevenir broadcast loop
|
|
124
|
+
self._seen_queries: dict[str, float] = {}
|
|
125
|
+
self._seen_query_ttl = 60 # segundos
|
|
126
|
+
|
|
119
127
|
# ── propriedades ──────────────────────────────────────────
|
|
120
128
|
|
|
121
129
|
@property
|
|
@@ -232,19 +240,24 @@ class Node:
|
|
|
232
240
|
task_dict.pop("signature", None)
|
|
233
241
|
task_dict["signature"] = self._identity.sign(task_dict)
|
|
234
242
|
|
|
235
|
-
# Tenta encontrar peer
|
|
236
|
-
peer =
|
|
237
|
-
if
|
|
243
|
+
# Tenta encontrar peer — matcha node_id contra addresses no formato "prefix@ip:port"
|
|
244
|
+
peer = None
|
|
245
|
+
if target_node:
|
|
246
|
+
for addr in self._tcp.peer_addresses:
|
|
247
|
+
if addr.startswith(target_node[:12] + "@"):
|
|
248
|
+
peer = addr
|
|
249
|
+
break
|
|
250
|
+
if not peer:
|
|
251
|
+
# Bug fix: target_node especificado mas offline —
|
|
252
|
+
# NÃO fazer find_peer_for(capability) que acharia o tracker.
|
|
253
|
+
# Vai direto pra relay-via-tracker.
|
|
254
|
+
return await self.send_task_via_tracker(
|
|
255
|
+
"", target_node, capability, payload, ttl_s=ttl_s
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
238
258
|
peer = self._routing.find_peer_for(capability)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if not peer:
|
|
242
|
-
peer = await self._query_capability(capability, ttl=5, timeout=5)
|
|
243
|
-
if peer:
|
|
244
|
-
hello = hello_msg(self._node_id, self._tracker.get_public_caps(),
|
|
245
|
-
list(self._routing.local_interests),
|
|
246
|
-
self._tracker.visibility, self._version)
|
|
247
|
-
peer = await self._tcp.connect_to_peer(peer, hello_payload=hello)
|
|
259
|
+
if not peer:
|
|
260
|
+
peer = await self._query_capability(capability, ttl=5, timeout=5)
|
|
248
261
|
|
|
249
262
|
if peer:
|
|
250
263
|
try:
|
|
@@ -253,10 +266,6 @@ class Node:
|
|
|
253
266
|
except Exception as e:
|
|
254
267
|
return Result.make_error(task_id, "SEND_ERROR", str(e))
|
|
255
268
|
|
|
256
|
-
# Bug 2: Fallback via tracker antes de desistir
|
|
257
|
-
if not peer:
|
|
258
|
-
return await self.send_task_via_tracker("", target_node, capability, payload, ttl_s=ttl_s)
|
|
259
|
-
|
|
260
269
|
return Result.make_error(task_id, "NO_PEER", f"No peer for: {capability}")
|
|
261
270
|
|
|
262
271
|
async def send_task_async(self, target_node: str, capability: str,
|
|
@@ -343,7 +352,7 @@ class Node:
|
|
|
343
352
|
discovered: dict[str, dict[str, Any]] = {}
|
|
344
353
|
|
|
345
354
|
# Broadcast QUERY for any capability
|
|
346
|
-
await self._tcp.broadcast(query_msg("", query_id, ttl=3))
|
|
355
|
+
await self._tcp.broadcast(query_msg("", query_id, ttl=3, origin=self._node_id))
|
|
347
356
|
|
|
348
357
|
try:
|
|
349
358
|
await asyncio.wait_for(event.wait(), timeout=timeout)
|
|
@@ -413,6 +422,27 @@ class Node:
|
|
|
413
422
|
async def _handle_message(self, peer_addr: str, msg: dict[str, Any]) -> None:
|
|
414
423
|
msg_type = msg.get("type", "")
|
|
415
424
|
|
|
425
|
+
# ── Rate limiting ───────────────────────────────────────
|
|
426
|
+
now = time.monotonic()
|
|
427
|
+
# HELLO: max 1 per minute per peer
|
|
428
|
+
if msg_type == MessageType.HELLO:
|
|
429
|
+
last_hello = self._hello_count.get(peer_addr, 0.0)
|
|
430
|
+
if now - last_hello < 60:
|
|
431
|
+
logger.warning("[elo] rate limit: HELLO flood from %s", peer_addr[:20])
|
|
432
|
+
return
|
|
433
|
+
self._hello_count[peer_addr] = now
|
|
434
|
+
|
|
435
|
+
# General: max 100 messages per 60s window per peer
|
|
436
|
+
count, win_start = self._msg_count.get(peer_addr, (0, now))
|
|
437
|
+
if now - win_start > 60:
|
|
438
|
+
count, win_start = 0, now
|
|
439
|
+
count += 1
|
|
440
|
+
self._msg_count[peer_addr] = (count, win_start)
|
|
441
|
+
if count > 100:
|
|
442
|
+
logger.warning("[elo] rate limit: %d msgs in 60s from %s — dropping", count, peer_addr[:20])
|
|
443
|
+
return
|
|
444
|
+
# ── Fim rate limiting ───────────────────────────────────
|
|
445
|
+
|
|
416
446
|
if msg_type == MessageType.HELLO:
|
|
417
447
|
await self._on_hello(peer_addr, msg)
|
|
418
448
|
elif msg_type == MessageType.HELLO_ACK:
|
|
@@ -467,6 +497,22 @@ class Node:
|
|
|
467
497
|
capability = msg.get("capability", "")
|
|
468
498
|
query_id = msg.get("id", "")
|
|
469
499
|
ttl = msg.get("ttl", 5)
|
|
500
|
+
origin = msg.get("origin", "")
|
|
501
|
+
|
|
502
|
+
# Previne broadcast loop: não reencaminha se origin == self
|
|
503
|
+
if origin and origin == self._node_id:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# Previne duplicatas: cache de query_ids recentes
|
|
507
|
+
now = time.time()
|
|
508
|
+
# Limpa entradas expiradas
|
|
509
|
+
expired = [qid for qid, ts in self._seen_queries.items() if now - ts > self._seen_query_ttl]
|
|
510
|
+
for qid in expired:
|
|
511
|
+
self._seen_queries.pop(qid, None)
|
|
512
|
+
if query_id in self._seen_queries:
|
|
513
|
+
return
|
|
514
|
+
self._seen_queries[query_id] = now
|
|
515
|
+
|
|
470
516
|
nodes: list[dict[str, Any]] = []
|
|
471
517
|
|
|
472
518
|
# Modelo BitTorrent: tracker retorna SEMPRE todos os peers conhecidos,
|
|
@@ -490,7 +536,9 @@ class Node:
|
|
|
490
536
|
except Exception:
|
|
491
537
|
pass
|
|
492
538
|
elif ttl > 1:
|
|
493
|
-
|
|
539
|
+
# Propaga origin: usa a existente ou define self como origin
|
|
540
|
+
origin = origin or self._node_id
|
|
541
|
+
await self._tcp.broadcast(query_msg(capability, query_id, ttl=ttl - 1, origin=origin), exclude={peer_addr})
|
|
494
542
|
|
|
495
543
|
async def _on_query_resp(self, peer_addr: str, msg: dict) -> None:
|
|
496
544
|
query_id = msg.get("id", "")
|
|
@@ -543,6 +591,9 @@ class Node:
|
|
|
543
591
|
result_payload = {"message": "no handler registered"}
|
|
544
592
|
|
|
545
593
|
result = result_msg(task_id, "success", payload=result_payload)
|
|
594
|
+
# Assina resultado para garantir autenticidade
|
|
595
|
+
result_no_sig = {k: v for k, v in result.items() if k != "signature"}
|
|
596
|
+
result["signature"] = self._identity.sign(result_no_sig)
|
|
546
597
|
await self._tcp.send_to(peer_addr, result)
|
|
547
598
|
|
|
548
599
|
except Exception as e:
|
|
@@ -556,6 +607,21 @@ class Node:
|
|
|
556
607
|
|
|
557
608
|
async def _on_result(self, peer_addr: str, msg: dict) -> None:
|
|
558
609
|
task_id = msg.get("id", "")
|
|
610
|
+
|
|
611
|
+
# Verifica assinatura do resultado se verify_peers estiver ativo
|
|
612
|
+
if self._verify_peers and msg.get("signature"):
|
|
613
|
+
result_no_sig = {k: v for k, v in msg.items() if k != "signature"}
|
|
614
|
+
# Extrai caller_id do pending_result (já que o caller original sabe quem enviou)
|
|
615
|
+
# O peer que enviou é conhecido via peer_addr — extraímos node_id do prefixo
|
|
616
|
+
peer_node_id = peer_addr.split("@")[0] if "@" in peer_addr else ""
|
|
617
|
+
if peer_node_id:
|
|
618
|
+
caller_pub = await self._get_caller_pubkey(peer_node_id)
|
|
619
|
+
if caller_pub:
|
|
620
|
+
from elo.security import verify_signature
|
|
621
|
+
if not verify_signature(caller_pub, result_no_sig, msg["signature"]):
|
|
622
|
+
logger.warning("[elo] bad signature on result from %s", peer_node_id[:12])
|
|
623
|
+
return # descarta resultado com assinatura inválida
|
|
624
|
+
|
|
559
625
|
future = self._pending_results.get(task_id)
|
|
560
626
|
if future and not future.done():
|
|
561
627
|
status = msg.get("status", "error")
|
|
@@ -103,13 +103,16 @@ def hello_ack_msg(node_id: str, caps: dict, interests: list[str],
|
|
|
103
103
|
return msg
|
|
104
104
|
|
|
105
105
|
|
|
106
|
-
def query_msg(capability: str, query_id: str, ttl: int = 5) -> dict:
|
|
107
|
-
|
|
106
|
+
def query_msg(capability: str, query_id: str, ttl: int = 5, origin: str = "") -> dict:
|
|
107
|
+
msg: dict[str, Any] = {
|
|
108
108
|
"type": MessageType.QUERY,
|
|
109
109
|
"capability": capability,
|
|
110
110
|
"id": query_id,
|
|
111
111
|
"ttl": ttl,
|
|
112
112
|
}
|
|
113
|
+
if origin:
|
|
114
|
+
msg["origin"] = origin
|
|
115
|
+
return msg
|
|
113
116
|
|
|
114
117
|
|
|
115
118
|
def query_resp_msg(query_id: str, nodes: list[dict]) -> dict:
|
|
@@ -144,7 +147,7 @@ def task_msg(task_id: str, target: str, caller: str, capability: str,
|
|
|
144
147
|
|
|
145
148
|
|
|
146
149
|
def result_msg(task_id: str, status: str, payload: dict | None = None,
|
|
147
|
-
error: dict | None = None) -> dict:
|
|
150
|
+
error: dict | None = None, signature: str = "") -> dict:
|
|
148
151
|
msg = {
|
|
149
152
|
"type": MessageType.RESULT,
|
|
150
153
|
"id": task_id,
|
|
@@ -155,6 +158,8 @@ def result_msg(task_id: str, status: str, payload: dict | None = None,
|
|
|
155
158
|
msg["payload"] = payload
|
|
156
159
|
if error is not None:
|
|
157
160
|
msg["error"] = error
|
|
161
|
+
if signature:
|
|
162
|
+
msg["signature"] = signature
|
|
158
163
|
return msg
|
|
159
164
|
|
|
160
165
|
|
|
@@ -136,7 +136,7 @@ class TestNodeConstruction:
|
|
|
136
136
|
node = Node("test-node")
|
|
137
137
|
assert node._name == "test-node"
|
|
138
138
|
assert node._port == 7878
|
|
139
|
-
assert node._version == "0.4.
|
|
139
|
+
assert node._version == "0.4.8"
|
|
140
140
|
assert node.connected is False
|
|
141
141
|
assert node.node_id is not None
|
|
142
142
|
assert len(node.node_id) > 20
|
|
@@ -148,6 +148,19 @@ class TestNodeConstruction:
|
|
|
148
148
|
assert node._port == 9000
|
|
149
149
|
assert node.tracker_visibility == "private"
|
|
150
150
|
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_send_task_to_offline_target_returns_error(self):
|
|
153
|
+
"""send_task with offline target_node must return error, not route to tracker."""
|
|
154
|
+
node = Node("test-a", port=0)
|
|
155
|
+
await node.connect()
|
|
156
|
+
await node.register(agents=["echo"])
|
|
157
|
+
|
|
158
|
+
# Target node_id that won't match any connected peer address
|
|
159
|
+
result = await node.send_task("nonexistent-node-id-42", "echo", {"msg": "hello"})
|
|
160
|
+
|
|
161
|
+
assert result.status == "error"
|
|
162
|
+
await node.disconnect()
|
|
163
|
+
|
|
151
164
|
|
|
152
165
|
# ── TestNodeLifecycle ──────────────────────────────────────
|
|
153
166
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|