1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::netconf::actions::{action, parameter};
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct NetconfClientProtocol;
14impl NetconfClientProtocol {
15 pub fn new() -> Self {
16 Self
17 }
18}
19
20pub const OPERATIONS: &[&str] = &[
21 "get",
22 "get-config",
23 "edit-config",
24 "lock",
25 "unlock",
26 "commit",
27 "discard-changes",
28 "validate",
29 "close-session",
30 "kill-session",
31 "custom",
32];
33
34fn rpc_action() -> ActionDefinition {
35 action(
36 "netconf_rpc",
37 "Send one NETCONF <rpc>. Rust assigns the message-id, enforces the server's advertised capabilities for candidate/startup/validate/rollback, and raises netconf_rpc_reply with the correlated reply.",
38 vec![
39 parameter("operation", "string", "get, get-config, edit-config, lock, unlock, commit, discard-changes, validate, close-session, kill-session or custom", true),
40 parameter("source", "string", "get-config/validate datastore: running, candidate or startup", false),
41 parameter("target", "string", "edit-config/lock/unlock datastore: running, candidate or startup", false),
42 parameter("filter_xml", "string", "Subtree filter content for get/get-config, with xmlns", false),
43 parameter("config_xml", "string", "edit-config <config> content, with xmlns", false),
44 parameter("default_operation", "string", "edit-config default-operation: merge, replace or none", false),
45 parameter("error_option", "string", "edit-config error-option", false),
46 parameter("session_id", "number", "kill-session target", false),
47 parameter("input_xml", "string", "custom: exactly one operation element in its own namespace", false),
48 ],
49 json!({"type":"netconf_rpc","operation":"get-config","source":"running"}),
50 )
51}
52fn disconnect_action() -> ActionDefinition {
53 action(
54 "disconnect",
55 "Close the NETCONF session and SSH connection without close-session",
56 vec![],
57 json!({"type":"disconnect"}),
58 )
59}
60fn actions() -> Vec<ActionDefinition> {
61 vec![rpc_action(), disconnect_action()]
62}
63
64pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
65 EventType::new(
66 "netconf_connected",
67 "NETCONF session established after SSH authentication and <hello> exchange",
68 rpc_action().example.clone(),
69 )
70 .with_parameters(vec![
71 parameter(
72 "session_id",
73 "number",
74 "Server-assigned NETCONF session id",
75 true,
76 ),
77 parameter(
78 "server_capabilities",
79 "array",
80 "Capabilities the server advertised",
81 true,
82 ),
83 parameter(
84 "base_version",
85 "string",
86 "Negotiated base version: 1.0 or 1.1",
87 true,
88 ),
89 ])
90 .with_actions(actions())
91});
92pub static REPLY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
93 EventType::new("netconf_rpc_reply", "Correlated <rpc-reply> for the last request", disconnect_action().example.clone())
94 .with_parameters(vec![
95 parameter("operation", "string", "The request's operation", true),
96 parameter("message_id", "string", "The request's message-id", true),
97 parameter("ok", "boolean", "Present and true for <ok/>", false),
98 parameter("data_xml", "string", "<data> content for get/get-config", false),
99 parameter("output_xml", "string", "Custom RPC output elements", false),
100 parameter("errors", "array", "rpc-error entries: error_type, error_tag, error_severity, error_message, error_path, error_info_xml", false),
101 ])
102 .with_actions(actions())
103});
104
105fn startup(
106 name: &str,
107 kind: &str,
108 description: &str,
109 required: bool,
110 example: Value,
111 default: Option<Value>,
112) -> ParameterDefinition {
113 ParameterDefinition {
114 name: name.into(),
115 type_hint: kind.into(),
116 description: description.into(),
117 required,
118 example,
119 default,
120 }
121}
122
123impl Protocol for NetconfClientProtocol {
124 fn protocol_name(&self) -> &'static str {
125 "NETCONF"
126 }
127 fn stack_name(&self) -> &'static str {
128 "ETH>IP>TCP>SSH>NETCONF"
129 }
130 fn keywords(&self) -> Vec<&'static str> {
131 vec!["netconf", "rfc6241", "yang", "network configuration"]
132 }
133 fn description(&self) -> &'static str {
134 "NETCONF client over SSH with pinned host keys and correlated structured RPCs"
135 }
136 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
137 actions()
138 }
139 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
140 vec![]
141 }
142 fn get_event_types(&self) -> Vec<EventType> {
143 vec![CONNECTED_EVENT.clone(), REPLY_EVENT.clone()]
144 }
145 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
146 vec![
147 startup("username", "string", "Login name for SSH password authentication on the NETCONF device", true, json!("admin"), None),
148 startup("password", "string", "Password for SSH password authentication; never placed in events or logs", true, json!("secret"), None),
149 startup("host_key_sha256", "string", "Required pinned server host key, OpenSSH form SHA256:<base64>. A different key fails the handshake.", true, json!("SHA256:<trusted fingerprint>"), None),
150 startup("base_versions", "array", "Base versions to offer: [\"1.0\"], [\"1.1\"] or both", false, json!(["1.0", "1.1"]), Some(json!(["1.0", "1.1"]))),
151 startup("handshake_timeout_secs", "number", "Seconds (1..=600) for TCP, SSH, authentication and <hello>", false, json!(30), Some(json!(crate::server::netconf::HANDSHAKE_TIMEOUT.as_secs()))),
152 startup("reply_timeout_secs", "number", "Seconds (1..=3600) to wait for each <rpc-reply>", false, json!(60), Some(json!(super::REPLY_TIMEOUT.as_secs()))),
153 ]
154 }
155 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
156 use crate::protocol::metadata::*;
157 ProtocolMetadataV2::builder()
158 .state(DevelopmentState::Experimental)
159 .privilege_requirement(PrivilegeRequirement::None)
160 .well_known_port(830)
161 .implementation("russh 0.45 SSH client with mandatory host-key pin and password auth; netconf subsystem with native RFC 6242 framing and the shared bounded XML codec")
162 .llm_control("Which RPCs to send (get, get-config, edit-config, lock/unlock, commit, discard-changes, validate, close/kill-session, custom) and what to do with each reply")
163 .e2e_testing("tests/client/netconf: the Python netconf 2.1.0 server (independent) over base 1.0 and 1.1, plus framing, correlation, pin-refusal and command-injection checks")
164 .notes("One outstanding RPC at a time; replies are matched on message-id. Password authentication only. Notifications, NETCONF over TLS, url and confirmed commit are excluded. 1 MiB messages.")
165 .max_inbound_bytes(crate::server::netconf::wire::MAX_MESSAGE_BYTES)
166 .build()
167 }
168 fn example_prompt(&self) -> &'static str {
169 "Connect to the NETCONF device at 192.0.2.1:830 and read its running configuration"
170 }
171 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
172 let llm = json!({"type":"open_client","protocol":"netconf","remote_addr":"127.0.0.1:830","instruction":"Read the running configuration","startup_params":{"username":"admin","password":"secret","host_key_sha256":"SHA256:<trusted fingerprint>"}});
173 let mut static_example = llm.clone();
174 static_example["event_handlers"] = json!([
175 {"event_pattern":"netconf_connected","handler":{"type":"static","actions":[{"type":"netconf_rpc","operation":"get-config","source":"running"}]}},
176 {"event_pattern":"netconf_rpc_reply","handler":{"type":"static","actions":[]}}
177 ]);
178 let mut scripted = static_example.clone();
179 scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"respond([{'type':'netconf_rpc','operation':'get-config','source':'running'}])"});
180 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
181 }
182 fn group_name(&self) -> &'static str {
183 "Network Management"
184 }
185}
186
187impl Client for NetconfClientProtocol {
188 fn connect(
189 &self,
190 ctx: ConnectContext,
191 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
192 {
193 Box::pin(super::connect(ctx))
194 }
195 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
196 match v["type"].as_str() {
197 Some("netconf_rpc") => {
198 let op = v["operation"].as_str().unwrap_or("");
199 ensure!(OPERATIONS.contains(&op), "unknown NETCONF operation '{op}'");
200 Ok(ClientActionResult::Custom {
201 name: "netconf_rpc".into(),
202 data: v,
203 })
204 }
205 Some("disconnect") => Ok(ClientActionResult::Disconnect),
206 _ => bail!("Unknown NETCONF client action"),
207 }
208 }
209}