1use super::rpc;
2use crate::llm::actions::{
3 protocol_trait::{ActionResult, Protocol, Server},
4 ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::log_template::LogTemplate;
7use crate::protocol::{EventType, SpawnContext};
8use crate::state::app_state::AppState;
9use anyhow::{bail, ensure, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct NetconfProtocol;
15impl NetconfProtocol {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
22 Parameter {
23 name: name.into(),
24 type_hint: kind.into(),
25 description: description.into(),
26 required,
27 }
28}
29
30pub fn action(
31 name: &str,
32 description: &str,
33 parameters: Vec<Parameter>,
34 example: Value,
35) -> ActionDefinition {
36 let log_template = match name {
37 "netconf_rpc_reply" => LogTemplate::new()
38 .with_info("-> NETCONF reply ok={ok} data={preview(data_xml,80)} errors={errors_len}"),
39 "netconf_auth_decision" => {
40 LogTemplate::new().with_info("-> NETCONF SSH authentication allowed={allowed}")
41 }
42 "netconf_rpc" => LogTemplate::new().with_info("-> NETCONF {operation}"),
44 _ => LogTemplate::new().with_info(format!("-> NETCONF {name}")),
45 };
46 ActionDefinition {
47 name: name.into(),
48 description: description.into(),
49 parameters,
50 example,
51 log_template: Some(log_template),
52 }
53}
54
55fn reply_action() -> ActionDefinition {
56 action(
57 "netconf_rpc_reply",
58 "Answer the pending <rpc>. Rust supplies message-id, the base namespace and the <rpc-reply> envelope. Supply exactly one of: ok=true; data_xml (get/get-config: content placed inside <data>, '' for empty); output_xml (custom RPC output elements); errors (rpc-error list).",
59 vec![
60 parameter("ok", "boolean", "true for an <ok/> reply (edit-config, lock, unlock, commit, discard-changes, validate, custom without output)", false),
61 parameter("data_xml", "string", "XML elements for <data>, each with its own xmlns, e.g. <interfaces xmlns=\"urn:example:if\"><interface><name>eth0</name></interface></interfaces>", false),
62 parameter("output_xml", "string", "Custom RPC output elements placed directly under <rpc-reply>", false),
63 parameter("errors", "array", "[{error_type: transport|rpc|protocol|application, error_tag: RFC 6241 tag such as invalid-value, lock-denied, data-missing, access-denied, operation-failed, error_severity: error|warning, error_message, error_path, error_info_xml}]", false),
64 ],
65 json!({"type":"netconf_rpc_reply","data_xml":"<system xmlns=\"urn:example:system\"><hostname>router1</hostname></system>"}),
66 )
67}
68
69fn auth_action() -> ActionDefinition {
70 action(
71 "netconf_auth_decision",
72 "Accept or reject the SSH password for this NETCONF connection. No built-in accounts; default denial.",
73 vec![parameter("allowed", "boolean", "True only if these credentials should be allowed", true)],
74 json!({"type":"netconf_auth_decision","allowed":false}),
75 )
76}
77
78pub static RPC_EVENT: LazyLock<EventType> = LazyLock::new(|| {
79 EventType::new(
80 "netconf_rpc",
81 "A NETCONF <rpc> that passed envelope, capability and datastore checks. Data and acceptance come from the handler; there is no built-in datastore.",
82 reply_action().example.clone(),
83 )
84 .with_parameters(vec![
85 parameter("operation", "string", "get, get-config, edit-config, lock, unlock, commit, discard-changes, validate, or a custom RPC's element name", true),
86 parameter("namespace", "string", "Operation namespace; the base namespace for standard operations", true),
87 parameter("message_id", "string", "The request's message-id (echoed by Rust)", true),
88 parameter("session_id", "number", "This NETCONF session's id", true),
89 parameter("username", "string", "Authenticated SSH user", true),
90 parameter("source", "string", "get-config/validate datastore: running, candidate or startup", false),
91 parameter("target", "string", "edit-config/lock/unlock datastore", false),
92 parameter("filter_type", "string", "subtree or xpath", false),
93 parameter("filter_xml", "string", "Subtree filter content", false),
94 parameter("filter_select", "string", "XPath filter expression (only with :xpath)", false),
95 parameter("config_xml", "string", "edit-config <config> content, with namespace declarations", false),
96 parameter("default_operation", "string", "merge, replace or none", false),
97 parameter("test_option", "string", "edit-config test-option (only with :validate)", false),
98 parameter("error_option", "string", "edit-config error-option", false),
99 parameter("custom", "boolean", "true for an operation outside the base namespace", false),
100 parameter("input_xml", "string", "Custom RPC input children", false),
101 ])
102 .with_actions(vec![reply_action()])
103});
104
105pub static AUTH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
106 EventType::new(
107 "netconf_auth",
108 "SSH password authentication before a NETCONF session. Use a deterministic handler for real credentials.",
109 auth_action().example.clone(),
110 )
111 .with_parameters(vec![
112 parameter("username", "string", "Login name the SSH client presented; compare it with your own account list", true),
113 parameter("password", "string", "SSH password supplied by the peer", true),
114 ])
115 .with_actions(vec![auth_action()])
116});
117
118fn startup(
119 name: &str,
120 kind: &str,
121 description: &str,
122 example: Value,
123 default: Option<Value>,
124) -> ParameterDefinition {
125 ParameterDefinition {
126 name: name.into(),
127 type_hint: kind.into(),
128 description: description.into(),
129 required: false,
130 example,
131 default,
132 }
133}
134
135impl Protocol for NetconfProtocol {
136 fn protocol_name(&self) -> &'static str {
137 "NETCONF"
138 }
139 fn stack_name(&self) -> &'static str {
140 "ETH>IP>TCP>SSH>NETCONF"
141 }
142 fn description(&self) -> &'static str {
143 "NETCONF server over SSH (RFC 6241/6242) with handler-controlled datastores"
144 }
145 fn keywords(&self) -> Vec<&'static str> {
146 vec![
147 "netconf",
148 "rfc6241",
149 "rfc6242",
150 "yang",
151 "network configuration",
152 ]
153 }
154 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
155 vec![]
156 }
157 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
158 vec![reply_action(), auth_action()]
159 }
160 fn get_event_types(&self) -> Vec<EventType> {
161 vec![RPC_EVENT.clone(), AUTH_EVENT.clone()]
162 }
163 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
164 vec![
165 startup(
166 "capabilities",
167 "array",
168 "Capabilities advertised beside base:1.0 and base:1.1. Datastore and option rules follow them: candidate/commit need :candidate, startup needs :startup, edit-config on running needs :writable-running, validate needs :validate, xpath filters need :xpath.",
169 json!([rpc::WRITABLE_RUNNING, "urn:example:system?module=example-system"]),
170 Some(json!([rpc::WRITABLE_RUNNING])),
171 ),
172 startup(
173 "host_key_path",
174 "string",
175 "OpenSSH private host key to serve. Omitted: a fresh Ed25519 key per start, whose fingerprint and public key are logged so clients can pin them.",
176 json!("/etc/netget/netconf_host_ed25519"),
177 None,
178 ),
179 startup(
180 "handshake_timeout_secs",
181 "number",
182 "Seconds (1..=600) from TCP accept to a completed NETCONF <hello> exchange",
183 json!(30),
184 Some(json!(super::HANDSHAKE_TIMEOUT.as_secs())),
185 ),
186 startup(
187 "idle_timeout_secs",
188 "number",
189 "Seconds (1..=86400) a session may sit without traffic; a parked handler does not count",
190 json!(600),
191 Some(json!(super::IDLE_TIMEOUT.as_secs())),
192 ),
193 ]
194 }
195 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
196 use crate::protocol::metadata::*;
197 ProtocolMetadataV2::builder()
198 .state(DevelopmentState::Experimental)
199 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(830))
200 .well_known_port(830)
201 .implementation("russh 0.45 SSH server with the netconf subsystem; native RFC 6242 end-of-message and chunked framing; bounded flat XML parser (no DTD, depth 32, 1 MiB messages); RFC 6241 envelopes owned by Rust")
202 .llm_control("SSH password decisions; every RPC's data, acceptance and rpc-error; custom RPCs in their own namespace")
203 .e2e_testing("tests/server/netconf: framing and envelope checks, native session lifecycle, and ncclient 0.7.1 (independent Python client) over NETCONF 1.0 and 1.1")
204 .notes("No built-in datastore: get/get-config/edit-config are answered by the handler (use server memory or the SQLite facility to persist). copy-config, delete-config, confirmed commit, url, notifications, call-home and NETCONF over TLS are excluded. kill-session ends another live session of this server. One NETCONF channel per SSH connection; password authentication only.")
205 .request_only("NETCONF replies answer a pending <rpc> with its message-id; notifications are not implemented, so nothing is sent unprompted")
206 .answers_on_failure()
207 .max_inbound_bytes(super::wire::MAX_MESSAGE_BYTES)
208 .build()
209 }
210 fn example_prompt(&self) -> &'static str {
211 "NETCONF server on port 830 for a router whose hostname is edge1"
212 }
213 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
214 let llm = json!({"type":"open_server","base_stack":"netconf","port":830,"instruction":"Router edge1: answer get-config with its hostname; accept edit-config"});
215 let mut scripted = llm.clone();
216 scripted["event_handlers"] = json!([
217 {"event_pattern":"netconf_auth","handler":{"type":"static","actions":[{"type":"netconf_auth_decision","allowed":false}]}},
218 {"event_pattern":"netconf_rpc","handler":{"type":"script","language":"python","code":"respond([{'type':'netconf_rpc_reply','ok':True}] if event['operation'] != 'get-config' else [{'type':'netconf_rpc_reply','data_xml':''}])"}}
219 ]);
220 let mut static_example = llm.clone();
221 static_example["event_handlers"] = json!([
222 {"event_pattern":"netconf_auth","handler":{"type":"static","actions":[{"type":"netconf_auth_decision","allowed":false}]}},
223 {"event_pattern":"netconf_rpc","handler":{"type":"static","actions":[{"type":"netconf_rpc_reply","errors":[{"error_type":"application","error_tag":"operation-not-supported"}]}]}}
224 ]);
225 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
226 }
227 fn group_name(&self) -> &'static str {
228 "Network Management"
229 }
230}
231
232impl Server for NetconfProtocol {
233 fn spawn(
234 &self,
235 ctx: SpawnContext,
236 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
237 {
238 Box::pin(super::spawn(ctx))
239 }
240 fn execute_action(&self, v: Value) -> Result<ActionResult> {
241 match v["type"].as_str() {
242 Some("netconf_rpc_reply") => {
243 reply_body(&v)?;
244 Ok(ActionResult::Custom {
245 name: "netconf_rpc_reply".into(),
246 data: v,
247 })
248 }
249 Some("netconf_auth_decision") => {
250 ensure!(v["allowed"].is_boolean(), "allowed must be boolean");
251 Ok(ActionResult::Custom {
252 name: "netconf_auth_decision".into(),
253 data: v,
254 })
255 }
256 _ => bail!("Unknown NETCONF server action"),
257 }
258 }
259}
260
261pub fn reply_body(v: &Value) -> Result<rpc::ReplyBody> {
263 let present: Vec<&str> = ["ok", "data_xml", "output_xml", "errors"]
264 .into_iter()
265 .filter(|k| v.get(*k).is_some_and(|x| !x.is_null()))
266 .collect();
267 ensure!(
268 present.len() == 1,
269 "netconf_rpc_reply needs exactly one of ok, data_xml, output_xml, errors"
270 );
271 Ok(match present[0] {
272 "ok" => {
273 ensure!(v["ok"] == true, "ok must be true; use errors to refuse");
274 rpc::ReplyBody::Ok
275 }
276 "data_xml" => rpc::ReplyBody::Data(super::xml::parse_fragment(
277 v["data_xml"]
278 .as_str()
279 .ok_or_else(|| anyhow::anyhow!("data_xml must be a string"))?,
280 )?),
281 "output_xml" => rpc::ReplyBody::Output(super::xml::parse_fragment(
282 v["output_xml"]
283 .as_str()
284 .ok_or_else(|| anyhow::anyhow!("output_xml must be a string"))?,
285 )?),
286 _ => {
287 let list = v["errors"]
288 .as_array()
289 .ok_or_else(|| anyhow::anyhow!("errors must be an array"))?;
290 ensure!(
291 !list.is_empty() && list.len() <= 64,
292 "errors must hold 1..=64 entries"
293 );
294 rpc::ReplyBody::Errors(
295 list.iter()
296 .map(rpc::RpcError::from_value)
297 .collect::<Result<_>>()?,
298 )
299 }
300 })
301}