Skip to main content

netget/server/nbd/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct NbdProtocol;
14impl NbdProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    ActionDefinition {
36        name: name.into(),
37        description: description.into(),
38        parameters,
39        example,
40        log_template: Some(LogTemplate::new().with_info(format!("NBD {name}"))),
41    }
42}
43
44fn export() -> ActionDefinition {
45    action(
46        "nbd_export",
47        "Serve the requested export read-only: its size and content as extents (text, hex or a fill byte at an offset; everything else reads as zeroes), optional error regions that fail reads, and block sizes. Rust answers every read from this description.",
48        vec![
49            parameter("size", "number", "Export size in bytes (up to 1 TiB)", true),
50            parameter("extents", "array", "[{offset, text} | {offset, hex} | {offset, length, fill: 0-255}], up to 16 MiB of explicit bytes", false),
51            parameter("errors", "array", "[{offset, length, error: EIO|EPERM|ENOMEM|EINVAL|ENOSPC}] regions whose reads fail", false),
52            parameter("description", "string", "Free-text description shown by NBD_OPT_INFO/GO", false),
53            parameter("block_size", "object", "{minimum (default 1), preferred (default 4096), maximum (default 32 MiB)}", false),
54        ],
55        json!({"type": "nbd_export", "size": 1048576, "description": "boot disk", "extents": [{"offset": 0, "text": "hello"}, {"offset": 4096, "length": 512, "fill": 255}], "errors": [{"offset": 524288, "length": 4096, "error": "EIO"}]}),
56    )
57}
58
59fn reject() -> ActionDefinition {
60    action(
61        "nbd_reject",
62        "Refuse: the client is told the export is unknown, or that policy forbids it (or the export list)",
63        vec![parameter("reason", "string", "unknown (no such export) or policy", true)],
64        json!({"type": "nbd_reject", "reason": "unknown"}),
65    )
66}
67
68fn list() -> ActionDefinition {
69    action(
70        "nbd_list_exports",
71        "Answer NBD_OPT_LIST with the exports this server offers",
72        vec![parameter("exports", "array", "[{name, description}]", true)],
73        json!({"type": "nbd_list_exports", "exports": [{"name": "disk0", "description": "boot disk"}]}),
74    )
75}
76
77pub static EXPORT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
78    EventType::new(
79        "nbd_export_request",
80        "A client asked for an export by name (NBD_OPT_GO, NBD_OPT_INFO or NBD_OPT_EXPORT_NAME); describe it or refuse",
81        export().example.clone(),
82    )
83    .with_parameters(vec![
84        parameter("export", "string", "The export name the client asked for (empty for the default export)", true),
85        parameter("option", "string", "go, info or export_name", true),
86    ])
87    .with_actions(vec![export(), reject()])
88});
89
90pub static LIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
91    EventType::new(
92        "nbd_list",
93        "A client asked which exports exist (NBD_OPT_LIST)",
94        list().example.clone(),
95    )
96    .with_parameters(vec![parameter(
97        "client",
98        "string",
99        "The client's address",
100        true,
101    )])
102    .with_actions(vec![list(), reject()])
103});
104
105impl Protocol for NbdProtocol {
106    fn protocol_name(&self) -> &'static str {
107        "NBD"
108    }
109    fn stack_name(&self) -> &'static str {
110        "ETH>IP>TCP>NBD"
111    }
112    fn description(&self) -> &'static str {
113        "Network Block Device server: read-only exports the handler describes, served by fixed newstyle negotiation with structured replies and block status"
114    }
115    fn keywords(&self) -> Vec<&'static str> {
116        vec!["nbd", "network block device", "block device", "disk image"]
117    }
118    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
119        vec![]
120    }
121    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
122        vec![export(), reject(), list()]
123    }
124    fn get_event_types(&self) -> Vec<EventType> {
125        vec![EXPORT_EVENT.clone(), LIST_EVENT.clone()]
126    }
127    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
128        vec![]
129    }
130    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
131        use crate::protocol::metadata::*;
132        ProtocolMetadataV2::builder()
133            .state(DevelopmentState::Experimental)
134            .privilege_requirement(PrivilegeRequirement::None)
135            .well_known_port(10809)
136            .implementation("Native NBD fixed newstyle negotiation (LIST, INFO, GO, EXPORT_NAME, STRUCTURED_REPLY, base:allocation meta context) and transmission (READ, BLOCK_STATUS, FLUSH, CACHE, DISC) over Tokio TCP; exports are read-only and served from the handler's description")
137            .llm_control("Which exports exist and what each contains, including regions whose reads fail")
138            .e2e_testing("tests/server/nbd: libnbd 1.24.3's nbdinfo and nbdcopy (independent, C) list exports, read export info and allocation maps, copy whole exports and hit error regions")
139            .notes("Read-only: writes, trims and zeroing fail with EPERM. One export description per name per connection. No TLS, no extended headers. 64 KiB per option, 32 MiB per request, 16 MiB of explicit content per export, 256 connections.")
140            .request_only("NBD answers each request; the server pushes nothing")
141            .answers_on_failure()
142            .max_inbound_bytes(super::wire::MAX_OPTION)
143            .build()
144    }
145    fn example_prompt(&self) -> &'static str {
146        "NBD server on port 10809 exporting a 1 MiB disk that starts with \"hello\""
147    }
148    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
149        let llm = json!({"type":"open_server","base_stack":"nbd","port":10809,"instruction":"Export disk0: 1 MiB, starting with hello"});
150        let mut static_example = llm.clone();
151        static_example["event_handlers"] = json!([
152            {"event_pattern":"nbd_export_request","handler":{"type":"static","actions":[{"type":"nbd_export","size":1048576,"extents":[{"offset":0,"text":"hello"}]}]}},
153            {"event_pattern":"nbd_list","handler":{"type":"static","actions":[{"type":"nbd_list_exports","exports":[{"name":"disk0"}]}]}}
154        ]);
155        let mut scripted = llm.clone();
156        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python","code":"import json,sys\ni=json.load(sys.stdin)\nif i['event_type_id']=='nbd_list':\n    a={'type':'nbd_list_exports','exports':[{'name':'disk0'}]}\nelif i['event']['export'] in ('','disk0'):\n    a={'type':'nbd_export','size':1048576,'extents':[{'offset':0,'text':'hello'}]}\nelse:\n    a={'type':'nbd_reject','reason':'unknown'}\nprint(json.dumps({'actions':[a]}))"}}]);
157        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
158    }
159    fn group_name(&self) -> &'static str {
160        "Web & File"
161    }
162}
163
164impl Server for NbdProtocol {
165    fn spawn(
166        &self,
167        ctx: SpawnContext,
168    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
169    {
170        Box::pin(super::spawn(ctx))
171    }
172    fn execute_action(&self, v: Value) -> Result<ActionResult> {
173        match v["type"].as_str() {
174            Some("nbd_export") => {
175                super::wire::Export::from_action(&v)?;
176            }
177            Some("nbd_reject") => ensure!(
178                matches!(v["reason"].as_str(), Some("unknown" | "policy")),
179                "reason is unknown or policy"
180            ),
181            Some("nbd_list_exports") => {
182                let exports = v["exports"].as_array().filter(|a| a.len() <= 1024);
183                let Some(exports) = exports else {
184                    bail!("exports is an array of up to 1024 {{name, description}}")
185                };
186                for e in exports {
187                    ensure!(
188                        e["name"].as_str().is_some_and(|n| n.len() <= 4096),
189                        "each export has a name up to 4096 bytes"
190                    );
191                }
192            }
193            _ => bail!("Unknown NBD server action"),
194        }
195        Ok(ActionResult::Custom {
196            name: v["type"].as_str().unwrap_or_default().into(),
197            data: v,
198        })
199    }
200}