Skip to main content

netget/server/netconf/
actions.rs

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        // The client shares this helper; config may carry secrets, so only the operation.
43        "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
261/// Validate a reply action into the envelope body Rust will render.
262pub 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}