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}