Skip to main content

netget/server/nut/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct NutProtocol;
14impl NutProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
20    Parameter {
21        name: name.into(),
22        type_hint: kind.into(),
23        description: description.into(),
24        required,
25    }
26}
27pub fn action(
28    name: &str,
29    description: &str,
30    parameters: Vec<Parameter>,
31    example: Value,
32) -> ActionDefinition {
33    let log_template = match name {
34        "nut_reply" => LogTemplate::new().with_info(
35            "-> NUT reply: entries={entries_len} value={preview(value,80)} types={preview(types,80)} ok={ok} error={error}",
36        ),
37        "nut_auth_decision" => {
38            LogTemplate::new().with_info("-> NUT authentication allowed={allowed}")
39        }
40        // The client uses this helper too. Credentials may be in value, so omit it.
41        "nut_request" => LogTemplate::new().with_info("-> NUT {operation} {ups} {name}"),
42        "disconnect" => LogTemplate::new().with_info("-> NUT disconnect"),
43        _ => LogTemplate::new().with_info(format!("-> NUT {name}")),
44    };
45    ActionDefinition {
46        name: name.into(),
47        description: description.into(),
48        parameters,
49        example,
50        log_template: Some(log_template),
51    }
52}
53fn response_action() -> ActionDefinition {
54    action("nut_reply", "Answer the pending request. Rust supplies matching UPS identity and list framing. Supply error OR entries/value/types/ok appropriate to the request.", vec![
55        parameter("entries", "array", "For list_ups: [{name,description}]; list_var/list_rw: [{name,value}]; list_cmd: [{name}]; list_enum: [{value}]. All values are strings.", false),
56        parameter("value", "string", "Value for get_var/get_desc/get_cmddesc/get_upsdesc", false),
57        parameter("types", "array", "get_type tokens: RW, ENUM, RANGE, NUMBER, STRING:n", false),
58        parameter("ok", "boolean", "Explicit true only when set_var or instcmd succeeded", false),
59        parameter("error", "string", "NUT error token, e.g. UNKNOWN-UPS, VAR-NOT-SUPPORTED, ACCESS-DENIED", false),
60    ], json!({"type":"nut_reply","entries":[{"name":"ups","description":"Simulated UPS"}]}))
61}
62fn auth_action() -> ActionDefinition {
63    action(
64        "nut_auth_decision",
65        "Accept or reject the supplied username/password. No built-in accounts; default denial.",
66        vec![parameter(
67            "allowed",
68            "boolean",
69            "True only if these credentials should be allowed",
70            true,
71        )],
72        json!({"type":"nut_auth_decision","allowed":false}),
73    )
74}
75pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
76    EventType::new("nut_request", "A UPS query or authenticated operation; data and policy come from the handler.", response_action().example.clone()).with_parameters(vec![
77    parameter("operation","string","list_ups/list_var/list_rw/list_cmd/list_enum/get_var/get_desc/get_cmddesc/get_upsdesc/get_type/set_var/instcmd",true),
78    parameter("ups","string","UPS identifier requested by the peer, such as rack1",false), parameter("name","string","Variable or command name",false), parameter("value","string","Value for set_var",false), parameter("username","string","Authenticated user for write decisions",false),
79]).with_actions(vec![response_action()])
80});
81pub static AUTH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
82    EventType::new("nut_auth", "Authenticate credentials for this connection. Use a deterministic handler for real credentials.", auth_action().example.clone()).with_parameters(vec![parameter("username","string","Username supplied by the peer for authentication",true),parameter("password","string","Password supplied by remote peer",true)]).with_actions(vec![auth_action()])
83});
84impl Protocol for NutProtocol {
85    fn protocol_name(&self) -> &'static str {
86        "NUT"
87    }
88    fn stack_name(&self) -> &'static str {
89        "ETH>IP>TCP>NUT"
90    }
91    fn description(&self) -> &'static str {
92        "NUT UPS management server (RFC 9271), programmable UPS data and commands"
93    }
94    fn keywords(&self) -> Vec<&'static str> {
95        vec!["nut", "ups", "upsd", "rfc9271"]
96    }
97    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
98        vec![]
99    }
100    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
101        vec![response_action(), auth_action()]
102    }
103    fn get_event_types(&self) -> Vec<EventType> {
104        vec![REQUEST_EVENT.clone(), AUTH_EVENT.clone()]
105    }
106    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
107        vec![ParameterDefinition {
108            name: "idle_timeout_secs".into(),
109            type_hint: "number".into(),
110            description:
111                "Whole-command deadline in seconds (1..=86400), including first command; does not time out a parked handler"
112                    .into(),
113            required: false,
114            example: json!(300),
115            default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
116        }]
117    }
118    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
119        use crate::protocol::metadata::*;
120        ProtocolMetadataV2::builder().request_only("NUT replies require a pending command and its UPS or variable identity; no unsolicited peer messages").state(DevelopmentState::Experimental).privilege_requirement(PrivilegeRequirement::None).well_known_port(3493)
121            .implementation("RFC 9271 ASCII line protocol over Tokio TCP, bounded strict parser and correlated structured replies")
122            .llm_control("UPS discovery, variable values/types/descriptions, instant-command lists, authentication and write authorization/results")
123            .e2e_testing("tests/server/nut: raw-wire sessions, framing and bounds; independent client evidence is documented in CLAUDE.md")
124            .notes("Plain TCP only: STARTTLS returns FEATURE-NOT-SUPPORTED. No built-in UPS storage or accounts. SET/INSTCMD require an accepted nut_auth decision and explicit handler success. ATTACH/LOGIN/PRIMARY/FSD, RANGE and tracking are not supported; unknown commands return UNKNOWN-COMMAND. 256 connections; 8192-byte lines; 4096 entries and 1 MiB replies; 30s write deadline.")
125            .request_only("NUT replies require the current parsed request and authenticated session state; unsolicited replies are not part of this protocol.")
126            .max_inbound_bytes(super::wire::MAX_LINE_BYTES).build()
127    }
128    fn example_prompt(&self) -> &'static str {
129        "NUT server on port 3493 simulating UPS rack1 at full battery charge"
130    }
131    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
132        let llm = json!({"type":"open_server","base_stack":"nut","port":3493,"instruction":"Simulate UPS rack1"});
133        let mut scripted = llm.clone();
134        scripted["event_handlers"] = json!([{"event_pattern":"nut_request","handler":{"type":"script","language":"python","code":"respond([{'type':'nut_reply','error':'UNKNOWN-UPS'}])"}},{"event_pattern":"nut_auth","handler":{"type":"static","actions":[{"type":"nut_auth_decision","allowed":false}]}}]);
135        let mut static_example = llm.clone();
136        static_example["event_handlers"] = json!([{"event_pattern":"nut_request","handler":{"type":"static","actions":[{"type":"nut_reply","error":"UNKNOWN-UPS"}]}},{"event_pattern":"nut_auth","handler":{"type":"static","actions":[{"type":"nut_auth_decision","allowed":false}]}}]);
137        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
138    }
139    fn group_name(&self) -> &'static str {
140        "Monitoring"
141    }
142}
143impl Server for NutProtocol {
144    fn spawn(
145        &self,
146        ctx: SpawnContext,
147    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
148    {
149        Box::pin(super::spawn(ctx))
150    }
151    fn execute_action(&self, v: Value) -> Result<ActionResult> {
152        match v["type"].as_str() {
153            Some("nut_reply") => {
154                if let Some(error) = v.get("error") {
155                    super::wire::error_reply(
156                        error
157                            .as_str()
158                            .ok_or_else(|| anyhow::anyhow!("error must be string"))?,
159                    )?;
160                }
161                ensure!(
162                    serde_json::to_vec(&v)?.len() <= 2 * super::wire::MAX_RESPONSE_BYTES,
163                    "Reply action too large"
164                );
165                Ok(ActionResult::Custom {
166                    name: "nut_reply".into(),
167                    data: v,
168                })
169            }
170            Some("nut_auth_decision") => {
171                ensure!(v["allowed"].is_boolean(), "allowed must be boolean");
172                Ok(ActionResult::Custom {
173                    name: "nut_auth_decision".into(),
174                    data: v,
175                })
176            }
177            _ => bail!("Unknown NUT server action"),
178        }
179    }
180}