netget-tool 0.3.4

Network protocol stubbing toolkit: ~280 protocol servers and clients whose responses come from an LLM, a script, a static rule or a person.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
//! What the model does as a libp2p host: answer messages on the application protocols'
//! streams, open streams of its own to a connected peer, close them, and drop peers. Rust
//! owns the transport (Noise, yamux, multistream-select) and answers identify and ping.
use super::bitswap::{self, Format, Supplied};
use super::host::{self, Config};
use super::ipfs::{self, Cid};
use super::kad;
use crate::llm::actions::{
    protocol_trait::{ActionResult, Protocol, Server},
    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
};
use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
use crate::state::app_state::AppState;
use anyhow::{bail, ensure, Context, Result};
use serde_json::{json, Value};
use std::sync::LazyLock;

pub const SEND: &str = "libp2p_send";
pub const OPEN: &str = "libp2p_open_stream";
pub const CLOSE: &str = "libp2p_close_stream";
pub const DISCONNECT: &str = "libp2p_disconnect";
pub const BLOCKS: &str = "ipfs_bitswap_blocks";
pub const KAD_REPLY: &str = "ipfs_kad_reply";
/// IPFS (Bitswap and the DHT) is off unless asked for.
pub const IPFS_DEFAULT: bool = false;

#[derive(Default, Clone)]
pub struct Libp2pProtocol;

impl Libp2pProtocol {
    pub fn new() -> Self {
        Self
    }
}

pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
    Parameter {
        name: name.into(),
        type_hint: type_hint.into(),
        description: description.into(),
        required,
    }
}

pub fn action(
    name: &str,
    description: &str,
    parameters: Vec<Parameter>,
    example: Value,
) -> ActionDefinition {
    ActionDefinition {
        name: name.into(),
        description: description.into(),
        parameters,
        example,
        log_template: Some(
            LogTemplate::new()
                .with_info(format!("-> libp2p {}", name.trim_start_matches("libp2p_"))),
        ),
    }
}

fn data_params() -> Vec<Parameter> {
    vec![
        p(
            "data",
            "string",
            "The message: text, or hex with encoding hex",
            true,
        ),
        p("encoding", "string", "utf8 (default) or hex", false),
    ]
}

pub fn send_action() -> ActionDefinition {
    let mut params = data_params();
    params.push(p(
        "stream_id",
        "number",
        "The stream to write on (default: the stream of the message being answered)",
        false,
    ));
    action(
        SEND,
        "Send one message on a stream (framed with its length, the libp2p convention).",
        params,
        json!({"type": SEND, "data": "hello from NetGet"}),
    )
}

pub fn open_action() -> ActionDefinition {
    let mut params = vec![p(
        "protocol",
        "string",
        "The protocol id to open, e.g. /netget/chat/1.0.0",
        true,
    )];
    params.extend(data_params().into_iter().map(|mut x| {
        x.required = false;
        x
    }));
    action(
        OPEN,
        "Open a stream to the peer on a protocol it supports, optionally sending a first message. Its replies arrive as libp2p_message events.",
        params,
        json!({"type": OPEN, "protocol": "/netget/chat/1.0.0", "data": "hi"}),
    )
}

pub fn close_action() -> ActionDefinition {
    action(
        CLOSE,
        "Close our half of a stream: the peer reads end-of-stream after what was sent.",
        vec![p(
            "stream_id",
            "number",
            "The stream to close (default: the stream of the message being answered)",
            false,
        )],
        json!({"type": CLOSE}),
    )
}

pub fn disconnect_action() -> ActionDefinition {
    action(
        DISCONNECT,
        "Close the whole connection to this peer.",
        vec![],
        json!({"type": DISCONNECT}),
    )
}

pub fn all_actions() -> Vec<ActionDefinition> {
    vec![
        send_action(),
        open_action(),
        close_action(),
        disconnect_action(),
    ]
}

pub fn blocks_action() -> ActionDefinition {
    action(
        BLOCKS,
        "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.",
        vec![p(
            "blocks",
            "array",
            "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)}",
            true,
        )],
        json!({"type": BLOCKS, "blocks": [{"content": "hello from NetGet\n", "format": "unixfs"}]}),
    )
}

pub fn kad_reply_action() -> ActionDefinition {
    action(
        KAD_REPLY,
        "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.",
        vec![
            p(
                "closer_peers",
                "array",
                "[{peer_id, addrs: [multiaddr]}], at most 20",
                false,
            ),
            p(
                "provider_peers",
                "array",
                "[{peer_id, addrs: [multiaddr]}], at most 20",
                false,
            ),
        ],
        json!({"type": KAD_REPLY, "provider_peers": [{"peer_id": "self"}], "closer_peers": []}),
    )
}

pub fn ipfs_actions() -> Vec<ActionDefinition> {
    vec![blocks_action(), kad_reply_action()]
}

/// The blocks an `ipfs_bitswap_blocks` action supplies, each checked and encoded, its CID
/// not yet compared with any want.
pub fn supplied_blocks(v: &Value) -> Result<Vec<Supplied>> {
    let list = v["blocks"].as_array().context("blocks must be an array")?;
    ensure!(
        list.len() <= bitswap::MAX_SUPPLIED_BLOCKS,
        "at most {} blocks per answer",
        bitswap::MAX_SUPPLIED_BLOCKS
    );
    list.iter()
        .map(|b| {
            ensure!(b.is_object(), "each block is an object with content");
            let content = super::wire::data_bytes(&json!({
                "data": b["content"].as_str().context("each block needs content")?,
                "encoding": b["encoding"].clone(),
            }))?;
            let claimed = match b["cid"].as_str() {
                Some(c) => Some(Cid::parse(c)?),
                None => None,
            };
            if let Some(c) = &claimed {
                c.supported()?;
            }
            let format = match (b["format"].as_str(), &claimed) {
                (Some(f), _) => Format::parse(f)?,
                (None, Some(c)) if c.codec == ipfs::DAG_PB => Format::UnixFsFile,
                _ => Format::Raw,
            };
            Supplied::new(&content, format, claimed)
        })
        .collect()
}

/// The peers an `ipfs_kad_reply` names, (closer, providers): ids checked, addresses parsed,
/// and "self" and omitted addresses filled in from what the host knows.
pub fn kad_peers(v: &Value, cfg: &Config) -> Result<(Vec<kad::Peer>, Vec<kad::Peer>)> {
    let list = |field: &str| -> Result<Vec<kad::Peer>> {
        let Some(items) = v[field].as_array() else {
            ensure!(v[field].is_null(), "{field} must be an array");
            return Ok(Vec::new());
        };
        ensure!(
            items.len() <= kad::MAX_PEERS,
            "{field} may name at most {} peers",
            kad::MAX_PEERS
        );
        items
            .iter()
            .map(|p| {
                let id = p["peer_id"].as_str().context("each peer needs peer_id")?;
                let addrs: Vec<String> = match p["addrs"].as_array() {
                    Some(a) => a
                        .iter()
                        .map(|x| x.as_str().map(str::to_string).context("addrs are strings"))
                        .collect::<Result<_>>()?,
                    None => Vec::new(),
                };
                let id = if id == "self" {
                    super::wire::base58(&cfg.identity.peer_id)
                } else {
                    id.to_string()
                };
                let mut peer = kad::Peer::from_text(&id, &addrs)?;
                if peer.addrs.is_empty() {
                    peer.addrs = super::serve_ipfs::known_addrs(cfg, &peer.id);
                }
                Ok(peer)
            })
            .collect()
    };
    Ok((list("closer_peers")?, list("provider_peers")?))
}

pub static WANT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "ipfs_bitswap_want",
        "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.",
        json!({"type": BLOCKS, "blocks": [{"content": "hello from NetGet\n", "format": "raw"}]}),
    )
    .with_parameters(vec![
        p("peer_id", "string", "The peer id of the peer asking, proven by its Noise handshake", true),
        p(
            "wants",
            "array",
            "[{cid, codec (raw or dag-pb), want (block or have)}]",
            true,
        ),
        p(
            "full",
            "boolean",
            "Whether this is the peer's whole wantlist rather than an update",
            true,
        ),
    ])
    .with_actions(vec![blocks_action(), disconnect_action()])
});

pub static KAD_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "ipfs_kad_request",
        "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.",
        json!({"type": KAD_REPLY, "provider_peers": [{"peer_id": "self"}]}),
    )
    .with_parameters(vec![
        p("peer_id", "string", "The peer id of the peer asking, proven by its Noise handshake", true),
        p("stream_id", "number", "The stream it arrived on", true),
        p(
            "protocol",
            "string",
            "/ipfs/kad/1.0.0, or the LAN DHT's /ipfs/lan/kad/1.0.0",
            true,
        ),
        p(
            "request",
            "string",
            "GET_PROVIDERS or ADD_PROVIDER",
            true,
        ),
        p(
            "key",
            "string",
            "The content key: a base58 sha2-256 multihash (the CIDv0 spelling)",
            true,
        ),
        p(
            "cids",
            "array",
            "The CIDs (v0, v1 dag-pb, v1 raw) the key stands for",
            true,
        ),
        p(
            "providers",
            "array",
            "For ADD_PROVIDER: the peer announcing itself, with its addrs",
            true,
        ),
        p("self", "object", "This host: {peer_id, addrs}", true),
        p(
            "connected_peers",
            "array",
            "Peers connected to this host, closest to the key first",
            true,
        ),
    ])
    .with_actions(vec![kad_reply_action(), disconnect_action()])
});

pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "libp2p_peer_connected",
        "A peer connected (Noise-authenticated, yamux-multiplexed) and answered identify. Open a stream to speak first, or do nothing and wait for its streams.",
        json!({"type": OPEN, "protocol": "/netget/chat/1.0.0", "data": "welcome"}),
    )
    .with_parameters(vec![
        p("peer_id", "string", "The peer's id, proven by its Noise handshake", true),
        p("remote_addr", "string", "The peer's address as a multiaddr", true),
        p("agent_version", "string", "What the peer's identify says it is, e.g. go-libp2p", true),
        p("protocols", "array", "The protocols the peer says it supports", true),
        p("listen_addrs", "array", "Where the peer says it listens", true),
    ])
    .with_actions(vec![open_action(), disconnect_action()])
});

pub static MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
    EventType::new(
        "libp2p_message",
        "A peer sent a message on a stream of one of this host's application protocols.",
        json!({"type": SEND, "data": "hello back"}),
    )
    .with_parameters(vec![
        p("peer_id", "string", "The peer id of who sent it", true),
        p("stream_id", "number", "The stream it arrived on", true),
        p("protocol", "string", "The stream's protocol id", true),
        p(
            "data",
            "string",
            "The message (text, or hex when encoding is hex)",
            true,
        ),
        p(
            "encoding",
            "string",
            "How data is written: utf8 text, or hex for binary",
            true,
        ),
    ])
    .with_actions(all_actions())
});

pub fn check(v: &Value) -> Result<()> {
    match v["type"].as_str().unwrap_or_default() {
        SEND => {
            super::wire::data_bytes(v)?;
            if !v["stream_id"].is_null() {
                v["stream_id"]
                    .as_u64()
                    .context("stream_id must be a number")?;
            }
        }
        OPEN => {
            let proto = v["protocol"].as_str().context("protocol is required")?;
            ensure!(
                proto.starts_with('/') && proto.len() <= 256 && !proto.contains('\n'),
                "protocol must be a protocol id such as /netget/chat/1.0.0"
            );
            if !v["data"].is_null() {
                super::wire::data_bytes(v)?;
            }
        }
        CLOSE => {
            if !v["stream_id"].is_null() {
                v["stream_id"]
                    .as_u64()
                    .context("stream_id must be a number")?;
            }
        }
        DISCONNECT => {}
        BLOCKS => {
            supplied_blocks(v)?;
        }
        KAD_REPLY => {
            // Checked against a stand-in host: the real one fills in "self" when it answers.
            let stand_in = Config {
                identity: super::noise::Identity::from_seed([0; 32]),
                protocols: Vec::new(),
                listen_addrs: Vec::new(),
                bitswap: false,
                kad: false,
                directory: Default::default(),
            };
            kad_peers(v, &stand_in)?;
        }
        other => bail!("Unknown libp2p action {other:?}"),
    }
    Ok(())
}

impl Protocol for Libp2pProtocol {
    fn protocol_name(&self) -> &'static str {
        "libp2p"
    }
    fn stack_name(&self) -> &'static str {
        "ETH>IP>TCP>libp2p"
    }
    fn keywords(&self) -> Vec<&'static str> {
        vec![
            "libp2p",
            "ipfs",
            "p2p",
            "noise",
            "yamux",
            "multistream",
            "peer id",
            "4001",
            "bitswap",
            "kademlia",
            "cid",
            "kubo",
        ]
    }
    fn description(&self) -> &'static str {
        "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"
    }
    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
        Vec::new()
    }
    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
        let mut a = all_actions();
        a.extend(ipfs_actions());
        a
    }
    fn get_event_types(&self) -> Vec<EventType> {
        vec![
            CONNECTED_EVENT.clone(),
            MESSAGE_EVENT.clone(),
            WANT_EVENT.clone(),
            KAD_EVENT.clone(),
        ]
    }
    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
        Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
    }
    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
        vec![
            ParameterDefinition {
                name: "protocols".into(),
                type_hint: "array".into(),
                description: "Application protocol ids this host accepts streams for (identify and ping are always answered)".into(),
                required: false,
                example: json!(["/netget/chat/1.0.0", "/chat/1.0.0"]),
                default: Some(json!(host::DEFAULT_PROTOCOLS)),
            },
            ParameterDefinition {
                name: "private_key_seed".into(),
                type_hint: "string".into(),
                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(),
                required: false,
                example: json!("my-stable-node"),
                default: None,
            },
            ParameterDefinition {
                name: "ipfs".into(),
                type_hint: "boolean".into(),
                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(),
                required: false,
                example: json!(true),
                default: Some(json!(IPFS_DEFAULT)),
            },
        ]
    }
    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
        use crate::protocol::metadata::*;
        ProtocolMetadataV2::builder()
            .state(DevelopmentState::Experimental)
            .privilege_requirement(PrivilegeRequirement::None)
            .well_known_port(4001)
            .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")
            .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")
            .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")
            .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.")
            .max_inbound_bytes(host::MAX_MESSAGE)
            .build()
    }
    fn example_prompt(&self) -> &'static str {
        "libp2p peer on port 4001 that answers /netget/chat/1.0.0 messages"
    }
    fn get_startup_examples(&self) -> StartupExamples {
        let base = json!({"type":"open_server","base_stack":"libp2p","port":0,
            "instruction":"Answer every chat message with a short friendly reply"});
        let mut scripted = base.clone();
        scripted["event_handlers"] = json!([{"event_pattern":"libp2p_message","handler":{"type":"script","language":"python",
            "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'libp2p_send','data':'echo: '+e['data']}]}))"}},
            {"event_pattern":"*","handler":{"type":"static","actions":[]}}]);
        let mut static_example = base.clone();
        static_example["event_handlers"] = json!([{"event_pattern":"libp2p_message","handler":{"type":"static","actions":[{"type":SEND,"data":"hello"}]}},
            {"event_pattern":"*","handler":{"type":"static","actions":[]}}]);
        StartupExamples::new(base, scripted, static_example)
    }
    fn group_name(&self) -> &'static str {
        "Peer-to-peer"
    }
}

impl Server for Libp2pProtocol {
    fn spawn(
        &self,
        ctx: SpawnContext,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
    {
        Box::pin(super::spawn(ctx))
    }
    fn execute_action(&self, action: Value) -> Result<ActionResult> {
        check(&action)?;
        let name = action["type"].as_str().unwrap_or_default().to_string();
        Ok(ActionResult::Custom { name, data: action })
    }
}