1use super::bitswap::{self, Format, Supplied};
5use super::host::{self, Config};
6use super::ipfs::{self, Cid};
7use super::kad;
8use crate::llm::actions::{
9 protocol_trait::{ActionResult, Protocol, Server},
10 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
11};
12use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
13use crate::state::app_state::AppState;
14use anyhow::{bail, ensure, Context, Result};
15use serde_json::{json, Value};
16use std::sync::LazyLock;
17
18pub const SEND: &str = "libp2p_send";
19pub const OPEN: &str = "libp2p_open_stream";
20pub const CLOSE: &str = "libp2p_close_stream";
21pub const DISCONNECT: &str = "libp2p_disconnect";
22pub const BLOCKS: &str = "ipfs_bitswap_blocks";
23pub const KAD_REPLY: &str = "ipfs_kad_reply";
24pub const IPFS_DEFAULT: bool = false;
26
27#[derive(Default, Clone)]
28pub struct Libp2pProtocol;
29
30impl Libp2pProtocol {
31 pub fn new() -> Self {
32 Self
33 }
34}
35
36pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
37 Parameter {
38 name: name.into(),
39 type_hint: type_hint.into(),
40 description: description.into(),
41 required,
42 }
43}
44
45pub fn action(
46 name: &str,
47 description: &str,
48 parameters: Vec<Parameter>,
49 example: Value,
50) -> ActionDefinition {
51 ActionDefinition {
52 name: name.into(),
53 description: description.into(),
54 parameters,
55 example,
56 log_template: Some(
57 LogTemplate::new()
58 .with_info(format!("-> libp2p {}", name.trim_start_matches("libp2p_"))),
59 ),
60 }
61}
62
63fn data_params() -> Vec<Parameter> {
64 vec![
65 p(
66 "data",
67 "string",
68 "The message: text, or hex with encoding hex",
69 true,
70 ),
71 p("encoding", "string", "utf8 (default) or hex", false),
72 ]
73}
74
75pub fn send_action() -> ActionDefinition {
76 let mut params = data_params();
77 params.push(p(
78 "stream_id",
79 "number",
80 "The stream to write on (default: the stream of the message being answered)",
81 false,
82 ));
83 action(
84 SEND,
85 "Send one message on a stream (framed with its length, the libp2p convention).",
86 params,
87 json!({"type": SEND, "data": "hello from NetGet"}),
88 )
89}
90
91pub fn open_action() -> ActionDefinition {
92 let mut params = vec![p(
93 "protocol",
94 "string",
95 "The protocol id to open, e.g. /netget/chat/1.0.0",
96 true,
97 )];
98 params.extend(data_params().into_iter().map(|mut x| {
99 x.required = false;
100 x
101 }));
102 action(
103 OPEN,
104 "Open a stream to the peer on a protocol it supports, optionally sending a first message. Its replies arrive as libp2p_message events.",
105 params,
106 json!({"type": OPEN, "protocol": "/netget/chat/1.0.0", "data": "hi"}),
107 )
108}
109
110pub fn close_action() -> ActionDefinition {
111 action(
112 CLOSE,
113 "Close our half of a stream: the peer reads end-of-stream after what was sent.",
114 vec![p(
115 "stream_id",
116 "number",
117 "The stream to close (default: the stream of the message being answered)",
118 false,
119 )],
120 json!({"type": CLOSE}),
121 )
122}
123
124pub fn disconnect_action() -> ActionDefinition {
125 action(
126 DISCONNECT,
127 "Close the whole connection to this peer.",
128 vec![],
129 json!({"type": DISCONNECT}),
130 )
131}
132
133pub fn all_actions() -> Vec<ActionDefinition> {
134 vec![
135 send_action(),
136 open_action(),
137 close_action(),
138 disconnect_action(),
139 ]
140}
141
142pub fn blocks_action() -> ActionDefinition {
143 action(
144 BLOCKS,
145 "Answer a Bitswap wantlist with the blocks you have. Give each block's content and format; NetGet computes its CID from the bytes and sends it only for a want it really matches (a block claimed under a CID it does not hash to is never sent). Wants you do not answer get DONT_HAVE. Answer with no blocks to say you have none.",
146 vec![p(
147 "blocks",
148 "array",
149 "Blocks: {content, encoding (utf8 default, or hex), format (raw, or unixfs for a single-block UnixFS file; default: from cid's codec, else raw), cid (optional: the wanted CID this answers)}",
150 true,
151 )],
152 json!({"type": BLOCKS, "blocks": [{"content": "hello from NetGet\n", "format": "unixfs"}]}),
153 )
154}
155
156pub fn kad_reply_action() -> ActionDefinition {
157 action(
158 KAD_REPLY,
159 "Answer a DHT GET_PROVIDERS request. provider_peers: peers that have the content; closer_peers: peers closer to the key (default: the connected peers closest to it). Each is {peer_id, addrs}; peer_id \"self\" is this host, and addrs may be omitted for this host and for connected peers.",
160 vec![
161 p(
162 "closer_peers",
163 "array",
164 "[{peer_id, addrs: [multiaddr]}], at most 20",
165 false,
166 ),
167 p(
168 "provider_peers",
169 "array",
170 "[{peer_id, addrs: [multiaddr]}], at most 20",
171 false,
172 ),
173 ],
174 json!({"type": KAD_REPLY, "provider_peers": [{"peer_id": "self"}], "closer_peers": []}),
175 )
176}
177
178pub fn ipfs_actions() -> Vec<ActionDefinition> {
179 vec![blocks_action(), kad_reply_action()]
180}
181
182pub fn supplied_blocks(v: &Value) -> Result<Vec<Supplied>> {
185 let list = v["blocks"].as_array().context("blocks must be an array")?;
186 ensure!(
187 list.len() <= bitswap::MAX_SUPPLIED_BLOCKS,
188 "at most {} blocks per answer",
189 bitswap::MAX_SUPPLIED_BLOCKS
190 );
191 list.iter()
192 .map(|b| {
193 ensure!(b.is_object(), "each block is an object with content");
194 let content = super::wire::data_bytes(&json!({
195 "data": b["content"].as_str().context("each block needs content")?,
196 "encoding": b["encoding"].clone(),
197 }))?;
198 let claimed = match b["cid"].as_str() {
199 Some(c) => Some(Cid::parse(c)?),
200 None => None,
201 };
202 if let Some(c) = &claimed {
203 c.supported()?;
204 }
205 let format = match (b["format"].as_str(), &claimed) {
206 (Some(f), _) => Format::parse(f)?,
207 (None, Some(c)) if c.codec == ipfs::DAG_PB => Format::UnixFsFile,
208 _ => Format::Raw,
209 };
210 Supplied::new(&content, format, claimed)
211 })
212 .collect()
213}
214
215pub fn kad_peers(v: &Value, cfg: &Config) -> Result<(Vec<kad::Peer>, Vec<kad::Peer>)> {
218 let list = |field: &str| -> Result<Vec<kad::Peer>> {
219 let Some(items) = v[field].as_array() else {
220 ensure!(v[field].is_null(), "{field} must be an array");
221 return Ok(Vec::new());
222 };
223 ensure!(
224 items.len() <= kad::MAX_PEERS,
225 "{field} may name at most {} peers",
226 kad::MAX_PEERS
227 );
228 items
229 .iter()
230 .map(|p| {
231 let id = p["peer_id"].as_str().context("each peer needs peer_id")?;
232 let addrs: Vec<String> = match p["addrs"].as_array() {
233 Some(a) => a
234 .iter()
235 .map(|x| x.as_str().map(str::to_string).context("addrs are strings"))
236 .collect::<Result<_>>()?,
237 None => Vec::new(),
238 };
239 let id = if id == "self" {
240 super::wire::base58(&cfg.identity.peer_id)
241 } else {
242 id.to_string()
243 };
244 let mut peer = kad::Peer::from_text(&id, &addrs)?;
245 if peer.addrs.is_empty() {
246 peer.addrs = super::serve_ipfs::known_addrs(cfg, &peer.id);
247 }
248 Ok(peer)
249 })
250 .collect()
251 };
252 Ok((list("closer_peers")?, list("provider_peers")?))
253}
254
255pub static WANT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
256 EventType::new(
257 "ipfs_bitswap_want",
258 "A peer asked this host for blocks over Bitswap. Supply the ones you have with ipfs_bitswap_blocks (content and format; NetGet computes and checks each CID); the rest are answered DONT_HAVE.",
259 json!({"type": BLOCKS, "blocks": [{"content": "hello from NetGet\n", "format": "raw"}]}),
260 )
261 .with_parameters(vec![
262 p("peer_id", "string", "The peer id of the peer asking, proven by its Noise handshake", true),
263 p(
264 "wants",
265 "array",
266 "[{cid, codec (raw or dag-pb), want (block or have)}]",
267 true,
268 ),
269 p(
270 "full",
271 "boolean",
272 "Whether this is the peer's whole wantlist rather than an update",
273 true,
274 ),
275 ])
276 .with_actions(vec![blocks_action(), disconnect_action()])
277});
278
279pub static KAD_EVENT: LazyLock<EventType> = LazyLock::new(|| {
280 EventType::new(
281 "ipfs_kad_request",
282 "A peer asked this host's Kademlia DHT who provides some content (GET_PROVIDERS: answer with ipfs_kad_reply), or announced that it provides some (ADD_PROVIDER: no answer is sent; remember it if you want to name it later). FIND_NODE is answered by NetGet itself from the connected peers.",
283 json!({"type": KAD_REPLY, "provider_peers": [{"peer_id": "self"}]}),
284 )
285 .with_parameters(vec![
286 p("peer_id", "string", "The peer id of the peer asking, proven by its Noise handshake", true),
287 p("stream_id", "number", "The stream it arrived on", true),
288 p(
289 "protocol",
290 "string",
291 "/ipfs/kad/1.0.0, or the LAN DHT's /ipfs/lan/kad/1.0.0",
292 true,
293 ),
294 p(
295 "request",
296 "string",
297 "GET_PROVIDERS or ADD_PROVIDER",
298 true,
299 ),
300 p(
301 "key",
302 "string",
303 "The content key: a base58 sha2-256 multihash (the CIDv0 spelling)",
304 true,
305 ),
306 p(
307 "cids",
308 "array",
309 "The CIDs (v0, v1 dag-pb, v1 raw) the key stands for",
310 true,
311 ),
312 p(
313 "providers",
314 "array",
315 "For ADD_PROVIDER: the peer announcing itself, with its addrs",
316 true,
317 ),
318 p("self", "object", "This host: {peer_id, addrs}", true),
319 p(
320 "connected_peers",
321 "array",
322 "Peers connected to this host, closest to the key first",
323 true,
324 ),
325 ])
326 .with_actions(vec![kad_reply_action(), disconnect_action()])
327});
328
329pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
330 EventType::new(
331 "libp2p_peer_connected",
332 "A peer connected (Noise-authenticated, yamux-multiplexed) and answered identify. Open a stream to speak first, or do nothing and wait for its streams.",
333 json!({"type": OPEN, "protocol": "/netget/chat/1.0.0", "data": "welcome"}),
334 )
335 .with_parameters(vec![
336 p("peer_id", "string", "The peer's id, proven by its Noise handshake", true),
337 p("remote_addr", "string", "The peer's address as a multiaddr", true),
338 p("agent_version", "string", "What the peer's identify says it is, e.g. go-libp2p", true),
339 p("protocols", "array", "The protocols the peer says it supports", true),
340 p("listen_addrs", "array", "Where the peer says it listens", true),
341 ])
342 .with_actions(vec![open_action(), disconnect_action()])
343});
344
345pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
346 EventType::new(
347 "libp2p_message",
348 "A peer sent a message on a stream of one of this host's application protocols.",
349 json!({"type": SEND, "data": "hello back"}),
350 )
351 .with_parameters(vec![
352 p("peer_id", "string", "The peer id of who sent it", true),
353 p("stream_id", "number", "The stream it arrived on", true),
354 p("protocol", "string", "The stream's protocol id", true),
355 p(
356 "data",
357 "string",
358 "The message (text, or hex when encoding is hex)",
359 true,
360 ),
361 p(
362 "encoding",
363 "string",
364 "How data is written: utf8 text, or hex for binary",
365 true,
366 ),
367 ])
368 .with_actions(all_actions())
369});
370
371pub fn check(v: &Value) -> Result<()> {
372 match v["type"].as_str().unwrap_or_default() {
373 SEND => {
374 super::wire::data_bytes(v)?;
375 if !v["stream_id"].is_null() {
376 v["stream_id"]
377 .as_u64()
378 .context("stream_id must be a number")?;
379 }
380 }
381 OPEN => {
382 let proto = v["protocol"].as_str().context("protocol is required")?;
383 ensure!(
384 proto.starts_with('/') && proto.len() <= 256 && !proto.contains('\n'),
385 "protocol must be a protocol id such as /netget/chat/1.0.0"
386 );
387 if !v["data"].is_null() {
388 super::wire::data_bytes(v)?;
389 }
390 }
391 CLOSE => {
392 if !v["stream_id"].is_null() {
393 v["stream_id"]
394 .as_u64()
395 .context("stream_id must be a number")?;
396 }
397 }
398 DISCONNECT => {}
399 BLOCKS => {
400 supplied_blocks(v)?;
401 }
402 KAD_REPLY => {
403 let stand_in = Config {
405 identity: super::noise::Identity::from_seed([0; 32]),
406 protocols: Vec::new(),
407 listen_addrs: Vec::new(),
408 bitswap: false,
409 kad: false,
410 directory: Default::default(),
411 };
412 kad_peers(v, &stand_in)?;
413 }
414 other => bail!("Unknown libp2p action {other:?}"),
415 }
416 Ok(())
417}
418
419impl Protocol for Libp2pProtocol {
420 fn protocol_name(&self) -> &'static str {
421 "libp2p"
422 }
423 fn stack_name(&self) -> &'static str {
424 "ETH>IP>TCP>libp2p"
425 }
426 fn keywords(&self) -> Vec<&'static str> {
427 vec![
428 "libp2p",
429 "ipfs",
430 "p2p",
431 "noise",
432 "yamux",
433 "multistream",
434 "peer id",
435 "4001",
436 "bitswap",
437 "kademlia",
438 "cid",
439 "kubo",
440 ]
441 }
442 fn description(&self) -> &'static str {
443 "libp2p host over TCP (multistream-select, Noise, yamux): answers identify and ping, talks length-prefixed messages on its application protocols' streams, and with ipfs on serves Bitswap blocks the model supplies and answers the Kademlia DHT"
444 }
445 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
446 Vec::new()
447 }
448 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
449 let mut a = all_actions();
450 a.extend(ipfs_actions());
451 a
452 }
453 fn get_event_types(&self) -> Vec<EventType> {
454 vec![
455 CONNECTED_EVENT.clone(),
456 MESSAGE_EVENT.clone(),
457 WANT_EVENT.clone(),
458 KAD_EVENT.clone(),
459 ]
460 }
461 fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
462 Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
463 }
464 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
465 vec![
466 ParameterDefinition {
467 name: "protocols".into(),
468 type_hint: "array".into(),
469 description: "Application protocol ids this host accepts streams for (identify and ping are always answered)".into(),
470 required: false,
471 example: json!(["/netget/chat/1.0.0", "/chat/1.0.0"]),
472 default: Some(json!(host::DEFAULT_PROTOCOLS)),
473 },
474 ParameterDefinition {
475 name: "private_key_seed".into(),
476 type_hint: "string".into(),
477 description: "Any text; its SHA-256 is the Ed25519 identity key, so the same text gives the same peer id (default: a new key each start)".into(),
478 required: false,
479 example: json!("my-stable-node"),
480 default: None,
481 },
482 ParameterDefinition {
483 name: "ipfs".into(),
484 type_hint: "boolean".into(),
485 description: "Also be an IPFS peer: answer Bitswap wantlists (ipfs_bitswap_want) with blocks the model supplies, and Kademlia DHT requests (ipfs_kad_request), so Kubo can fetch from and route through this host".into(),
486 required: false,
487 example: json!(true),
488 default: Some(json!(IPFS_DEFAULT)),
489 },
490 ]
491 }
492 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
493 use crate::protocol::metadata::*;
494 ProtocolMetadataV2::builder()
495 .state(DevelopmentState::Experimental)
496 .privilege_requirement(PrivilegeRequirement::None)
497 .well_known_port(4001)
498 .implementation("Hand-rolled (src/server/libp2p): multistream-select 1.0; Noise XX (25519, ChaChaPoly, SHA256) with the libp2p handshake payload, on ring, x25519-dalek and ed25519-dalek; yamux with per-stream windows; identify (/ipfs/id/1.0.0, push accepted) and ping answered in Rust; Ed25519 peer ids. With ipfs: Bitswap 1.2.0/1.1.0/1.0.0 (answers on a stream of its own), CIDv0/CIDv1 (base58btc, base32) over sha2-256, raw and dag-pb, single-block UnixFS files, and the Kademlia DHT message on /ipfs/kad/1.0.0 and /ipfs/lan/kad/1.0.0")
499 .llm_control("Every message on the application protocols' streams: reply, open streams to the peer, close them, drop the peer. With ipfs: which wanted blocks it has (their content and format; Rust computes each CID and sends only blocks that hash to a want), and who provides a key (GET_PROVIDERS); it is told of ADD_PROVIDER. FIND_NODE is a routing fact answered in Rust")
500 .e2e_testing("tests/server/libp2p: go-libp2p (TCP, Noise, yamux) dials NetGet, verifies its peer id, runs identify and ping, and talks on /netget/chat/1.0.0; Kubo 0.43.1 (loopback only) fetches model-served content with ipfs cat and ipfs block get (raw, CIDv0 UnixFS, a 200 KB block), finds NetGet as a provider through its LAN DHT and sends it ADD_PROVIDER; over go-libp2p, go-cid recomputes the CID of every block sent, a block that does not hash is never sent, a failed model answers DONT_HAVE, and oversized, malformed and unknown-codec messages are refused before the model")
501 .notes("TCP only: no QUIC, WebTransport, WebRTC, TLS security or mplex. Ed25519 identities only (RSA/secp256k1/ECDSA peers are refused). No gossipsub, no relay, no hole punching, no signed peer records. Messages are uvarint-length-prefixed, at most 1 MiB (Bitswap 4 MiB, DHT 64 KiB). A model failure resets the stream it was answering; a failed Bitswap answer is DONT_HAVE, never a block. IPFS limits: sha2-256 only; raw and dag-pb only (dag-cbor and others answered DONT_HAVE); the model serves raw blocks and single-block UnixFS files, not chunked DAGs or directories; DHT records (PUT_VALUE, GET_VALUE values, IPNS) are not stored; provider records are not kept by Rust (the model may remember ADD_PROVIDER); no Bitswap sessions, ledgers or want rebroadcast.")
502 .max_inbound_bytes(host::MAX_MESSAGE)
503 .build()
504 }
505 fn example_prompt(&self) -> &'static str {
506 "libp2p peer on port 4001 that answers /netget/chat/1.0.0 messages"
507 }
508 fn get_startup_examples(&self) -> StartupExamples {
509 let base = json!({"type":"open_server","base_stack":"libp2p","port":0,
510 "instruction":"Answer every chat message with a short friendly reply"});
511 let mut scripted = base.clone();
512 scripted["event_handlers"] = json!([{"event_pattern":"libp2p_message","handler":{"type":"script","language":"python",
513 "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'libp2p_send','data':'echo: '+e['data']}]}))"}},
514 {"event_pattern":"*","handler":{"type":"static","actions":[]}}]);
515 let mut static_example = base.clone();
516 static_example["event_handlers"] = json!([{"event_pattern":"libp2p_message","handler":{"type":"static","actions":[{"type":SEND,"data":"hello"}]}},
517 {"event_pattern":"*","handler":{"type":"static","actions":[]}}]);
518 StartupExamples::new(base, scripted, static_example)
519 }
520 fn group_name(&self) -> &'static str {
521 "Peer-to-peer"
522 }
523}
524
525impl Server for Libp2pProtocol {
526 fn spawn(
527 &self,
528 ctx: SpawnContext,
529 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
530 {
531 Box::pin(super::spawn(ctx))
532 }
533 fn execute_action(&self, action: Value) -> Result<ActionResult> {
534 check(&action)?;
535 let name = action["type"].as_str().unwrap_or_default().to_string();
536 Ok(ActionResult::Custom { name, data: action })
537 }
538}