1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::restconf::actions::{action, parameter};
8use crate::server::restconf::path;
9use anyhow::{bail, ensure, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct RestconfClientProtocol;
15impl RestconfClientProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21fn path_param() -> Parameter {
22 parameter(
23 "path",
24 "string",
25 "Data resource under data/, e.g. example:car/tire=1 (empty for the whole datastore)",
26 true,
27 )
28}
29fn data_param() -> Parameter {
30 parameter(
31 "data",
32 "object",
33 "YANG JSON body, e.g. {\"example:speed\": 30}",
34 true,
35 )
36}
37
38pub fn all_actions() -> Vec<ActionDefinition> {
39 vec![
40 action(
41 "restconf_get",
42 "Read a data resource",
43 vec![
44 path_param(),
45 parameter(
46 "depth",
47 "string",
48 "Subtree depth, 1-65535 or unbounded",
49 false,
50 ),
51 parameter("content", "string", "all, config or nonconfig", false),
52 parameter(
53 "fields",
54 "string",
55 "Fields selector, e.g. speed;tire/pos",
56 false,
57 ),
58 ],
59 json!({"type": "restconf_get", "path": "example:car"}),
60 ),
61 action(
62 "restconf_put",
63 "Create or replace a data resource",
64 vec![path_param(), data_param()],
65 json!({"type": "restconf_put", "path": "example:car/speed", "data": {"example:speed": 30}}),
66 ),
67 action(
68 "restconf_post",
69 "Create a child of a data resource",
70 vec![path_param(), data_param()],
71 json!({"type": "restconf_post", "path": "example:car", "data": {"example:tire": [{"pos": 4}]}}),
72 ),
73 action(
74 "restconf_patch",
75 "Merge into a data resource",
76 vec![path_param(), data_param()],
77 json!({"type": "restconf_patch", "path": "example:car", "data": {"example:car": {"speed": 40}}}),
78 ),
79 action(
80 "restconf_delete",
81 "Delete a data resource",
82 vec![path_param()],
83 json!({"type": "restconf_delete", "path": "example:car/tire=4"}),
84 ),
85 action(
86 "restconf_invoke",
87 "Invoke an RPC under operations/",
88 vec![
89 parameter(
90 "operation",
91 "string",
92 "module:rpc, e.g. example:reset",
93 true,
94 ),
95 parameter(
96 "input",
97 "object",
98 "The input leaves (Rust wraps them as module:input)",
99 false,
100 ),
101 ],
102 json!({"type": "restconf_invoke", "operation": "example:reset", "input": {"speed": 0}}),
103 ),
104 action(
105 "disconnect",
106 "Stop the client",
107 vec![],
108 json!({"type": "disconnect"}),
109 ),
110 ]
111}
112
113pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
114 EventType::new(
115 "restconf_connected",
116 "Discovered the RESTCONF root and its YANG library",
117 json!({"type": "restconf_get", "path": ""}),
118 )
119 .with_parameters(vec![
120 parameter(
121 "root",
122 "string",
123 "The RESTCONF root found through host-meta, e.g. /restconf",
124 true,
125 ),
126 parameter(
127 "yang_library_version",
128 "string",
129 "The server's yang-library-version",
130 false,
131 ),
132 parameter(
133 "modules",
134 "array",
135 "The modules the server implements: [{name, revision, namespace}]",
136 true,
137 ),
138 ])
139 .with_actions(all_actions())
140});
141pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
142 EventType::new(
143 "restconf_response",
144 "The server answered a request",
145 json!({"type": "restconf_get", "path": ""}),
146 )
147 .with_parameters(vec![
148 parameter("method", "string", "The HTTP method sent", true),
149 parameter("path", "string", "The resource or operation", true),
150 parameter("status", "number", "The HTTP status", true),
151 parameter(
152 "data",
153 "object",
154 "The YANG JSON body of a successful answer",
155 false,
156 ),
157 parameter(
158 "errors",
159 "array",
160 "The RFC 8040 errors: [{error-type, error-tag, error-message, error-path}]",
161 false,
162 ),
163 parameter(
164 "location",
165 "string",
166 "Location of a created resource",
167 false,
168 ),
169 ])
170 .with_actions(all_actions())
171});
172
173impl Protocol for RestconfClientProtocol {
174 fn protocol_name(&self) -> &'static str {
175 "RESTCONF"
176 }
177 fn stack_name(&self) -> &'static str {
178 "ETH>IP>TCP>HTTP>RESTCONF"
179 }
180 fn keywords(&self) -> Vec<&'static str> {
181 vec!["restconf", "restconf client", "rfc8040", "yang"]
182 }
183 fn description(&self) -> &'static str {
184 "RESTCONF client: discovers a server's root and YANG library, reads and edits data resources and invokes operations"
185 }
186 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
187 all_actions()
188 }
189 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
190 vec![]
191 }
192 fn get_event_types(&self) -> Vec<EventType> {
193 vec![CONNECTED_EVENT.clone(), RESPONSE_EVENT.clone()]
194 }
195 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
196 vec![
197 ParameterDefinition {
198 name: "username".into(),
199 type_hint: "string".into(),
200 description: "HTTP Basic user, if the server needs one".into(),
201 required: false,
202 example: json!("admin"),
203 default: None,
204 },
205 ParameterDefinition {
206 name: "password".into(),
207 type_hint: "string".into(),
208 description: "HTTP Basic password".into(),
209 required: false,
210 example: json!("secret"),
211 default: None,
212 },
213 ]
214 }
215 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
216 use crate::protocol::metadata::*;
217 ProtocolMetadataV2::builder()
218 .state(DevelopmentState::Experimental)
219 .privilege_requirement(PrivilegeRequirement::None)
220 .implementation("RFC 8040 over the shared HTTP fetch client: host-meta discovery (XRD or JSON), the API root, yang-library-version and ietf-yang-library modules-state, JSON data requests and operations with RFC 8040 errors decoded")
221 .llm_control("Which resources to read and edit and which operations to invoke")
222 .e2e_testing("tests/client/restconf: a FreeCONF RESTCONF server (independent, Go) serving its car example: reads, edits, a missing resource and an operation")
223 .notes("Plain HTTP only; JSON only; no event streams.")
224 .build()
225 }
226 fn example_prompt(&self) -> &'static str {
227 "Read example:car from the RESTCONF server at 127.0.0.1:8080"
228 }
229 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
230 let llm = json!({"type":"open_client","protocol":"restconf","remote_addr":"127.0.0.1:8080","instruction":"Read the car's speed"});
231 let mut static_example = llm.clone();
232 static_example["event_handlers"] = json!([{"event_pattern":"restconf_connected","handler":{"type":"static","actions":[{"type":"restconf_get","path":"example:car"}]}}]);
233 let mut scripted = llm.clone();
234 scripted["event_handlers"] = json!([{"event_pattern":"restconf_connected","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nm=e['modules'][0]['name'] if e['modules'] else 'example'\nprint(json.dumps({'actions':[{'type':'restconf_get','path':m+':'}]}))"}}]);
235 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
236 }
237 fn group_name(&self) -> &'static str {
238 "Network Management"
239 }
240}
241
242pub fn check_path(p: &str, allow_empty: bool) -> Result<()> {
245 if p.is_empty() {
246 ensure!(allow_empty, "path names a resource");
247 return Ok(());
248 }
249 path::parse(p)?;
250 ensure!(
251 !p.contains(['?', '#', ' ']) && !crate::utils::sanitize::has_controls(&p),
252 "path has no ?, # or spaces (percent-encode key values)"
253 );
254 Ok(())
255}
256
257impl Client for RestconfClientProtocol {
258 fn connect(
259 &self,
260 ctx: ConnectContext,
261 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
262 {
263 Box::pin(super::connect(ctx))
264 }
265 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
266 match v["type"].as_str() {
267 Some("disconnect") => return Ok(ClientActionResult::Disconnect),
268 Some("restconf_get") => check_path(v["path"].as_str().unwrap_or_default(), true)?,
269 Some("restconf_delete") => check_path(v["path"].as_str().unwrap_or_default(), false)?,
270 Some("restconf_put" | "restconf_post" | "restconf_patch") => {
271 check_path(
272 v["path"].as_str().unwrap_or_default(),
273 v["type"] == "restconf_post",
274 )?;
275 ensure!(v["data"].is_object(), "data is a YANG JSON object");
276 }
277 Some("restconf_invoke") => {
278 let op = v["operation"].as_str().unwrap_or_default();
279 ensure!(
280 path::parse(op).is_ok_and(|s| s.len() == 1 && s[0].keys.is_none()),
281 "operation is module:rpc"
282 );
283 ensure!(
284 v.get("input").is_none_or(|i| i.is_null() || i.is_object()),
285 "input is an object"
286 );
287 }
288 _ => bail!("Unknown RESTCONF client action"),
289 }
290 Ok(ClientActionResult::Custom {
291 name: v["type"].as_str().unwrap_or_default().into(),
292 data: v,
293 })
294 }
295}