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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: elo-node
3
- Version: 0.4.6
3
+ Version: 0.4.8
4
4
  Summary: Elo — malha P2P de mensagens para agentes de IA. Zero infraestrutura.
5
5
  Author: Elo Contributors
6
6
  License: MIT
@@ -26,4 +26,4 @@ __all__ = [
26
26
  "Node", "Task", "Result", "Event", "NodeInfo", "Capabilities",
27
27
  "EphemeralIdentity", "generate_and_save_identity", "load_identity",
28
28
  ]
29
- __version__ = "0.4.6"
29
+ __version__ = "0.4.8"
@@ -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.6",
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 = target_node if target_node and target_node in self._tcp.peer_addresses else None
237
- if not peer:
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
- # Se não encontrou, faz QUERY broadcast
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
- await self._tcp.broadcast(query_msg(capability, query_id, ttl=ttl - 1), exclude={peer_addr})
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
- return {
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: elo-node
3
- Version: 0.4.6
3
+ Version: 0.4.8
4
4
  Summary: Elo — malha P2P de mensagens para agentes de IA. Zero infraestrutura.
5
5
  Author: Elo Contributors
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "elo-node"
7
- version = "0.4.6"
7
+ version = "0.4.8"
8
8
  description = "Elo — malha P2P de mensagens para agentes de IA. Zero infraestrutura."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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.6"
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