Skip to main content

netget/server/libp2p/
actions.rs

1//! What the model does as a libp2p host: answer messages on the application protocols'
2//! streams, open streams of its own to a connected peer, close them, and drop peers. Rust
3//! owns the transport (Noise, yamux, multistream-select) and answers identify and ping.
4use 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";
24/// IPFS (Bitswap and the DHT) is off unless asked for.
25pub 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
182/// The blocks an `ipfs_bitswap_blocks` action supplies, each checked and encoded, its CID
183/// not yet compared with any want.
184pub 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
215/// The peers an `ipfs_kad_reply` names, (closer, providers): ids checked, addresses parsed,
216/// and "self" and omitted addresses filled in from what the host knows.
217pub 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            // Checked against a stand-in host: the real one fills in "self" when it answers.
404            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}