Skip to main content

netget/client/netconf/
actions.rs

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}