elo-node 0.4.5__tar.gz → 0.4.7__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.5
3
+ Version: 0.4.7
4
4
  Summary: Elo — malha P2P de mensagens para agentes de IA. Zero infraestrutura.
5
5
  Author: Elo Contributors
6
6
  License: MIT
@@ -35,7 +35,7 @@ import asyncio
35
35
  from elo import Node
36
36
 
37
37
  async def main():
38
- node = Node("my-agent", port=7878)
38
+ node = Node("my-agent", port=7878, peers=["100.91.215.113:7878"])
39
39
  await node.connect()
40
40
  await node.register(agents=["analyst"], tools=["web-search"])
41
41
 
@@ -53,6 +53,7 @@ asyncio.run(main())
53
53
  - **Decentralized P2P** — discovery via public tracker or Kademlia DHT
54
54
  - **ed25519 signatures** — cryptographic identity, authenticated messages
55
55
  - **Capabilities** — publish/subscribe of agent skills across the mesh
56
+ - **Relay via tracker** — nodes behind NAT/Docker can communicate through a tracker
56
57
  - **Zero infra** — no Kafka, Redis, NATS, or central server
57
58
  - **Native CLI** — `python -m elo serve`, `status`, `init`, `id`
58
59
 
@@ -66,26 +67,21 @@ python -m elo init # Generate persistent identity
66
67
  python -m elo serve # Start an interactive node
67
68
  ```
68
69
 
69
- ## Architecture
70
+ ## Key Concepts
70
71
 
71
- ```
72
- ┌──────────────────┐ TCP/JSON ┌──────────────────┐
73
- │ Node A │◄──────────────►│ Node B │
74
- │ ed25519 key │ │ ed25519 key │
75
- │ Capabilities │ │ Capabilities │
76
- │ Interests │ │ Interests │
77
- └──────────────────┘ └──────────────────┘
78
- │ │
79
- │ Tracker (optional) │
80
- └────────────── DHT ───────────────┘
81
- ```
72
+ - **`peers=` is required** for outbound connections. Without it the node only listens.
73
+ - **`send_task()` auto-fallback:** direct → InterestTable → QUERY → **tracker relay** → NO_PEER
74
+ - **HELLO_ACK with known_peers:** tracker shares all peers on handshake (v0.4.4+)
75
+ - **`discover_peers_network()`** — QUERY broadcast across the mesh
76
+
77
+ ## Changelog
82
78
 
83
- Each node:
84
- 1. Generates an ed25519 identity on first run
85
- 2. Listens on a TCP port
86
- 3. Announces capabilities (e.g. "analyst", "web-search")
87
- 4. Discovers other nodes via shared tracker or manual peers
88
- 5. Exchanges signed messages (tasks, results, events)
79
+ | Version | Highlights |
80
+ |---------|------------|
81
+ | 0.4.5 | Multi-response discover, tracker returns all peers on query |
82
+ | 0.4.4 | HELLO_ACK with known_peers, send_task auto-fallback tracker |
83
+ | 0.4.3 | Relay via tracker, send_task_via_tracker() |
84
+ | 0.4.0 | Initial release |
89
85
 
90
86
  ## Compatibility
91
87
 
@@ -13,7 +13,7 @@ import asyncio
13
13
  from elo import Node
14
14
 
15
15
  async def main():
16
- node = Node("my-agent", port=7878)
16
+ node = Node("my-agent", port=7878, peers=["100.91.215.113:7878"])
17
17
  await node.connect()
18
18
  await node.register(agents=["analyst"], tools=["web-search"])
19
19
 
@@ -31,6 +31,7 @@ asyncio.run(main())
31
31
  - **Decentralized P2P** — discovery via public tracker or Kademlia DHT
32
32
  - **ed25519 signatures** — cryptographic identity, authenticated messages
33
33
  - **Capabilities** — publish/subscribe of agent skills across the mesh
34
+ - **Relay via tracker** — nodes behind NAT/Docker can communicate through a tracker
34
35
  - **Zero infra** — no Kafka, Redis, NATS, or central server
35
36
  - **Native CLI** — `python -m elo serve`, `status`, `init`, `id`
36
37
 
@@ -44,26 +45,21 @@ python -m elo init # Generate persistent identity
44
45
  python -m elo serve # Start an interactive node
45
46
  ```
46
47
 
47
- ## Architecture
48
+ ## Key Concepts
48
49
 
49
- ```
50
- ┌──────────────────┐ TCP/JSON ┌──────────────────┐
51
- │ Node A │◄──────────────►│ Node B │
52
- │ ed25519 key │ │ ed25519 key │
53
- │ Capabilities │ │ Capabilities │
54
- │ Interests │ │ Interests │
55
- └──────────────────┘ └──────────────────┘
56
- │ │
57
- │ Tracker (optional) │
58
- └────────────── DHT ───────────────┘
59
- ```
50
+ - **`peers=` is required** for outbound connections. Without it the node only listens.
51
+ - **`send_task()` auto-fallback:** direct → InterestTable → QUERY → **tracker relay** → NO_PEER
52
+ - **HELLO_ACK with known_peers:** tracker shares all peers on handshake (v0.4.4+)
53
+ - **`discover_peers_network()`** — QUERY broadcast across the mesh
54
+
55
+ ## Changelog
60
56
 
61
- Each node:
62
- 1. Generates an ed25519 identity on first run
63
- 2. Listens on a TCP port
64
- 3. Announces capabilities (e.g. "analyst", "web-search")
65
- 4. Discovers other nodes via shared tracker or manual peers
66
- 5. Exchanges signed messages (tasks, results, events)
57
+ | Version | Highlights |
58
+ |---------|------------|
59
+ | 0.4.5 | Multi-response discover, tracker returns all peers on query |
60
+ | 0.4.4 | HELLO_ACK with known_peers, send_task auto-fallback tracker |
61
+ | 0.4.3 | Relay via tracker, send_task_via_tracker() |
62
+ | 0.4.0 | Initial release |
67
63
 
68
64
  ## Compatibility
69
65
 
@@ -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.4"
29
+ __version__ = "0.4.7"
@@ -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.5",
81
+ version: str = "0.4.7",
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,8 +240,13 @@ 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
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
237
250
  if not peer:
238
251
  peer = self._routing.find_peer_for(capability)
239
252
 
@@ -343,7 +356,7 @@ class Node:
343
356
  discovered: dict[str, dict[str, Any]] = {}
344
357
 
345
358
  # Broadcast QUERY for any capability
346
- await self._tcp.broadcast(query_msg("", query_id, ttl=3))
359
+ await self._tcp.broadcast(query_msg("", query_id, ttl=3, origin=self._node_id))
347
360
 
348
361
  try:
349
362
  await asyncio.wait_for(event.wait(), timeout=timeout)
@@ -413,6 +426,27 @@ class Node:
413
426
  async def _handle_message(self, peer_addr: str, msg: dict[str, Any]) -> None:
414
427
  msg_type = msg.get("type", "")
415
428
 
429
+ # ── Rate limiting ───────────────────────────────────────
430
+ now = time.monotonic()
431
+ # HELLO: max 1 per minute per peer
432
+ if msg_type == MessageType.HELLO:
433
+ last_hello = self._hello_count.get(peer_addr, 0.0)
434
+ if now - last_hello < 60:
435
+ logger.warning("[elo] rate limit: HELLO flood from %s", peer_addr[:20])
436
+ return
437
+ self._hello_count[peer_addr] = now
438
+
439
+ # General: max 100 messages per 60s window per peer
440
+ count, win_start = self._msg_count.get(peer_addr, (0, now))
441
+ if now - win_start > 60:
442
+ count, win_start = 0, now
443
+ count += 1
444
+ self._msg_count[peer_addr] = (count, win_start)
445
+ if count > 100:
446
+ logger.warning("[elo] rate limit: %d msgs in 60s from %s — dropping", count, peer_addr[:20])
447
+ return
448
+ # ── Fim rate limiting ───────────────────────────────────
449
+
416
450
  if msg_type == MessageType.HELLO:
417
451
  await self._on_hello(peer_addr, msg)
418
452
  elif msg_type == MessageType.HELLO_ACK:
@@ -467,6 +501,22 @@ class Node:
467
501
  capability = msg.get("capability", "")
468
502
  query_id = msg.get("id", "")
469
503
  ttl = msg.get("ttl", 5)
504
+ origin = msg.get("origin", "")
505
+
506
+ # Previne broadcast loop: não reencaminha se origin == self
507
+ if origin and origin == self._node_id:
508
+ return
509
+
510
+ # Previne duplicatas: cache de query_ids recentes
511
+ now = time.time()
512
+ # Limpa entradas expiradas
513
+ expired = [qid for qid, ts in self._seen_queries.items() if now - ts > self._seen_query_ttl]
514
+ for qid in expired:
515
+ self._seen_queries.pop(qid, None)
516
+ if query_id in self._seen_queries:
517
+ return
518
+ self._seen_queries[query_id] = now
519
+
470
520
  nodes: list[dict[str, Any]] = []
471
521
 
472
522
  # Modelo BitTorrent: tracker retorna SEMPRE todos os peers conhecidos,
@@ -490,7 +540,9 @@ class Node:
490
540
  except Exception:
491
541
  pass
492
542
  elif ttl > 1:
493
- await self._tcp.broadcast(query_msg(capability, query_id, ttl=ttl - 1), exclude={peer_addr})
543
+ # Propaga origin: usa a existente ou define self como origin
544
+ origin = origin or self._node_id
545
+ await self._tcp.broadcast(query_msg(capability, query_id, ttl=ttl - 1, origin=origin), exclude={peer_addr})
494
546
 
495
547
  async def _on_query_resp(self, peer_addr: str, msg: dict) -> None:
496
548
  query_id = msg.get("id", "")
@@ -543,6 +595,9 @@ class Node:
543
595
  result_payload = {"message": "no handler registered"}
544
596
 
545
597
  result = result_msg(task_id, "success", payload=result_payload)
598
+ # Assina resultado para garantir autenticidade
599
+ result_no_sig = {k: v for k, v in result.items() if k != "signature"}
600
+ result["signature"] = self._identity.sign(result_no_sig)
546
601
  await self._tcp.send_to(peer_addr, result)
547
602
 
548
603
  except Exception as e:
@@ -556,6 +611,21 @@ class Node:
556
611
 
557
612
  async def _on_result(self, peer_addr: str, msg: dict) -> None:
558
613
  task_id = msg.get("id", "")
614
+
615
+ # Verifica assinatura do resultado se verify_peers estiver ativo
616
+ if self._verify_peers and msg.get("signature"):
617
+ result_no_sig = {k: v for k, v in msg.items() if k != "signature"}
618
+ # Extrai caller_id do pending_result (já que o caller original sabe quem enviou)
619
+ # O peer que enviou é conhecido via peer_addr — extraímos node_id do prefixo
620
+ peer_node_id = peer_addr.split("@")[0] if "@" in peer_addr else ""
621
+ if peer_node_id:
622
+ caller_pub = await self._get_caller_pubkey(peer_node_id)
623
+ if caller_pub:
624
+ from elo.security import verify_signature
625
+ if not verify_signature(caller_pub, result_no_sig, msg["signature"]):
626
+ logger.warning("[elo] bad signature on result from %s", peer_node_id[:12])
627
+ return # descarta resultado com assinatura inválida
628
+
559
629
  future = self._pending_results.get(task_id)
560
630
  if future and not future.done():
561
631
  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.5
3
+ Version: 0.4.7
4
4
  Summary: Elo — malha P2P de mensagens para agentes de IA. Zero infraestrutura.
5
5
  Author: Elo Contributors
6
6
  License: MIT
@@ -35,7 +35,7 @@ import asyncio
35
35
  from elo import Node
36
36
 
37
37
  async def main():
38
- node = Node("my-agent", port=7878)
38
+ node = Node("my-agent", port=7878, peers=["100.91.215.113:7878"])
39
39
  await node.connect()
40
40
  await node.register(agents=["analyst"], tools=["web-search"])
41
41
 
@@ -53,6 +53,7 @@ asyncio.run(main())
53
53
  - **Decentralized P2P** — discovery via public tracker or Kademlia DHT
54
54
  - **ed25519 signatures** — cryptographic identity, authenticated messages
55
55
  - **Capabilities** — publish/subscribe of agent skills across the mesh
56
+ - **Relay via tracker** — nodes behind NAT/Docker can communicate through a tracker
56
57
  - **Zero infra** — no Kafka, Redis, NATS, or central server
57
58
  - **Native CLI** — `python -m elo serve`, `status`, `init`, `id`
58
59
 
@@ -66,26 +67,21 @@ python -m elo init # Generate persistent identity
66
67
  python -m elo serve # Start an interactive node
67
68
  ```
68
69
 
69
- ## Architecture
70
+ ## Key Concepts
70
71
 
71
- ```
72
- ┌──────────────────┐ TCP/JSON ┌──────────────────┐
73
- │ Node A │◄──────────────►│ Node B │
74
- │ ed25519 key │ │ ed25519 key │
75
- │ Capabilities │ │ Capabilities │
76
- │ Interests │ │ Interests │
77
- └──────────────────┘ └──────────────────┘
78
- │ │
79
- │ Tracker (optional) │
80
- └────────────── DHT ───────────────┘
81
- ```
72
+ - **`peers=` is required** for outbound connections. Without it the node only listens.
73
+ - **`send_task()` auto-fallback:** direct → InterestTable → QUERY → **tracker relay** → NO_PEER
74
+ - **HELLO_ACK with known_peers:** tracker shares all peers on handshake (v0.4.4+)
75
+ - **`discover_peers_network()`** — QUERY broadcast across the mesh
76
+
77
+ ## Changelog
82
78
 
83
- Each node:
84
- 1. Generates an ed25519 identity on first run
85
- 2. Listens on a TCP port
86
- 3. Announces capabilities (e.g. "analyst", "web-search")
87
- 4. Discovers other nodes via shared tracker or manual peers
88
- 5. Exchanges signed messages (tasks, results, events)
79
+ | Version | Highlights |
80
+ |---------|------------|
81
+ | 0.4.5 | Multi-response discover, tracker returns all peers on query |
82
+ | 0.4.4 | HELLO_ACK with known_peers, send_task auto-fallback tracker |
83
+ | 0.4.3 | Relay via tracker, send_task_via_tracker() |
84
+ | 0.4.0 | Initial release |
89
85
 
90
86
  ## Compatibility
91
87
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "elo-node"
7
- version = "0.4.5"
7
+ version = "0.4.7"
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.4"
139
+ assert node._version == "0.4.7"
140
140
  assert node.connected is False
141
141
  assert node.node_id is not None
142
142
  assert len(node.node_id) > 20
File without changes
File without changes
File without changes
File without changes
File without changes