1use super::model;
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, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct RedfishProtocol;
15impl RedfishProtocol {
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 ActionDefinition {
37 name: name.into(),
38 description: description.into(),
39 parameters,
40 example,
41 log_template: Some(LogTemplate::new().with_info(format!("-> Redfish {name}"))),
42 }
43}
44
45fn accept_action() -> ActionDefinition {
46 action(
47 "redfish_login_accept",
48 "Accept the credentials. Rust creates the session (X-Auth-Token, Location, Session resource) or admits the Basic-auth request.",
49 vec![parameter("role", "string", "Role reported on the session, e.g. Administrator, Operator or ReadOnly (default Administrator)", false)],
50 json!({"type":"redfish_login_accept","role":"Administrator"}),
51 )
52}
53fn reject_action() -> ActionDefinition {
54 action(
55 "redfish_login_reject",
56 "Refuse the credentials: the client gets 401 with a NoValidSession error",
57 vec![parameter(
58 "reason",
59 "string",
60 "Why, for the log only; the client sees the standard message",
61 false,
62 )],
63 json!({"type":"redfish_login_reject","reason":"unknown user"}),
64 )
65}
66fn resource_action() -> ActionDefinition {
67 action(
68 "redfish_resource",
69 "Answer with a Redfish resource: the GET result, the resource after a PATCH, or the created member of a collection POST (sent 201 with Location). Rust fills @odata.id when missing, checks @odata.type, Id and Name, and counts collection Members.",
70 vec![parameter("resource", "object", "The resource JSON, e.g. {\"@odata.type\": \"#ComputerSystem.v1_22_0.ComputerSystem\", \"Id\": \"1\", \"Name\": \"Server\", \"PowerState\": \"On\"}; collections use \"#XCollection.XCollection\" with Members [{\"@odata.id\": ...}]", true)],
71 json!({"type":"redfish_resource","resource":{"@odata.type":"#ComputerSystemCollection.ComputerSystemCollection","Name":"Computer System Collection","Members":[{"@odata.id":"/redfish/v1/Systems/1"}]}}),
72 )
73}
74fn no_content_action() -> ActionDefinition {
75 action(
76 "redfish_no_content",
77 "Succeed without a body (204): an action that completed, a PATCH applied without returning the resource, a DELETE",
78 vec![],
79 json!({"type":"redfish_no_content"}),
80 )
81}
82fn task_action() -> ActionDefinition {
83 action(
84 "redfish_task",
85 "Accept the request as a long-running task: the client gets 202 with a task monitor; Rust runs the Task resource and reports the final state after complete_after_secs",
86 vec![
87 parameter("final_state", "string", "State the task reaches: Completed (default), Exception, Killed or Cancelled", false),
88 parameter("complete_after_secs", "number", "Seconds until the task finishes, 0 to 600 (default 2)", false),
89 parameter("messages", "array", "Messages the finished task reports, as strings", false),
90 parameter("result", "object", "Optional body the task monitor returns once the task completes (default: no body, 204)", false),
91 ],
92 json!({"type":"redfish_task","final_state":"Completed","complete_after_secs":2,"messages":["Reset completed"]}),
93 )
94}
95fn error_action() -> ActionDefinition {
96 action(
97 "redfish_error",
98 "Refuse the request with a Redfish error body carrying a Base-registry message",
99 vec![
100 parameter("error", "string", "One of resource_not_found (404), property_not_writable, property_unknown, property_value_not_in_list, action_not_supported, action_parameter_missing, action_parameter_value_not_in_list (400), insufficient_privilege (403), operation_not_allowed (405), resource_in_use, resource_already_exists (409), service_temporarily_unavailable (503), general_error (500)", true),
101 parameter("message", "string", "Optional human-readable message replacing the registry's default", false),
102 ],
103 json!({"type":"redfish_error","error":"resource_not_found"}),
104 )
105}
106
107pub static LOGIN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
108 EventType::new(
109 "redfish_login",
110 "A client presents credentials: a session login (POST SessionService/Sessions) or HTTP Basic auth. Accept or reject; Rust owns tokens and expiry.",
111 accept_action().example.clone(),
112 )
113 .with_parameters(vec![
114 parameter("user_name", "string", "The UserName the client sent", true),
115 parameter("password", "string", "The password the client sent, in the clear", true),
116 parameter("method", "string", "session or basic", true),
117 ])
118 .with_actions(vec![accept_action(), reject_action()])
119});
120
121pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
122 EventType::new(
123 "redfish_request",
124 "An authenticated request for a resource the handler owns (everything except the service root, OData documents, SessionService and TaskService, which Rust serves). Answer with the resource, no content, a task or an error.",
125 resource_action().example.clone(),
126 )
127 .with_parameters(vec![
128 parameter("method", "string", "GET, PATCH, POST or DELETE", true),
129 parameter("kind", "string", "read, update (PATCH), create (POST to a collection), action (POST to .../Actions/X) or delete", true),
130 parameter("path", "string", "Normalised request path, e.g. /redfish/v1/Systems/1", true),
131 parameter("body", "object", "The JSON request body for PATCH and POST", false),
132 parameter("action", "string", "For kind action: the action name, e.g. ComputerSystem.Reset", false),
133 parameter("resource_path", "string", "For kind action: the resource the action belongs to", false),
134 parameter("user_name", "string", "The authenticated user", false),
135 parameter("if_match", "string", "The If-Match header, when the client sent one", false),
136 ])
137 .with_actions(vec![
138 resource_action(),
139 no_content_action(),
140 task_action(),
141 error_action(),
142 ])
143});
144
145fn startup(
146 name: &str,
147 kind: &str,
148 description: &str,
149 example: Value,
150 default: Value,
151) -> ParameterDefinition {
152 ParameterDefinition {
153 name: name.into(),
154 type_hint: kind.into(),
155 description: description.into(),
156 required: false,
157 example,
158 default: Some(default),
159 }
160}
161
162impl Protocol for RedfishProtocol {
163 fn protocol_name(&self) -> &'static str {
164 "Redfish"
165 }
166 fn stack_name(&self) -> &'static str {
167 "ETH>IP>TCP>HTTP>Redfish"
168 }
169 fn description(&self) -> &'static str {
170 "DMTF Redfish service: service root, sessions, tasks and handler-supplied Systems, Chassis, Managers and actions"
171 }
172 fn keywords(&self) -> Vec<&'static str> {
173 vec![
174 "redfish",
175 "bmc",
176 "dmtf",
177 "ipmi replacement",
178 "out-of-band management",
179 ]
180 }
181 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
182 vec![]
183 }
184 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
185 vec![
186 accept_action(),
187 reject_action(),
188 resource_action(),
189 no_content_action(),
190 task_action(),
191 error_action(),
192 ]
193 }
194 fn get_event_types(&self) -> Vec<EventType> {
195 vec![LOGIN_EVENT.clone(), REQUEST_EVENT.clone()]
196 }
197 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
198 vec![
199 startup("product", "string", "Product name on the service root", json!("PowerEdge R760"), json!(super::DEFAULT_PRODUCT)),
200 startup("vendor", "string", "Vendor name on the service root", json!("Contoso"), json!(super::DEFAULT_VENDOR)),
201 startup("uuid", "string", "Service UUID on the service root", json!("92384634-2938-2342-8820-489239905423"), json!(super::DEFAULT_UUID)),
202 startup("auth", "string", "required (sessions or Basic auth for everything but the service root and OData documents) or none", json!("none"), json!(super::DEFAULT_AUTH)),
203 startup("session_timeout_secs", "integer", "Seconds of inactivity after which a session expires (30 to 86400)", json!(600), json!(super::DEFAULT_SESSION_TIMEOUT.as_secs())),
204 ]
205 }
206 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
207 use crate::protocol::metadata::*;
208 ProtocolMetadataV2::builder()
209 .state(DevelopmentState::Experimental)
210 .privilege_requirement(PrivilegeRequirement::None)
211 .implementation("hyper HTTP/1.1; Rust serves the service root, /redfish/v1/odata, $metadata, SessionService (X-Auth-Token sessions, Basic auth) and TaskService (202 task monitors); handler answers every other resource, PATCH, create, delete and action; envelopes and Base-registry errors checked by Rust")
212 .llm_control("Login decisions and every resource, update, action result and task outcome")
213 .e2e_testing("tests/server/redfish: gofish v0.26.0 (Go) and DMTF redfishtool 1.1.8 (Python), both independent, log in, walk systems, chassis sensors and managers, reset a system through a task, PATCH, and are refused a bad password")
214 .notes("Plain HTTP (put TLS in front); no $expand, $select, $filter, ETag checks, EventService subscriptions or SSE. No storage: resources come from the handler; sessions and tasks live in memory. 1 MiB bodies.")
215 .request_only("Redfish answers each HTTP request; event subscriptions are not offered")
216 .answers_on_failure()
217 .max_inbound_bytes(model::MAX_BODY_BYTES)
218 .build()
219 }
220 fn example_prompt(&self) -> &'static str {
221 "Redfish BMC for one rack server with two CPUs, a chassis with temperature sensors and a manager; accept admin/secret"
222 }
223 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
224 let llm = json!({"type":"open_server","base_stack":"redfish","port":8443,"instruction":"A BMC for one server, Systems/1, powered on; accept admin/secret"});
225 let mut static_example = llm.clone();
226 static_example["event_handlers"] = json!([
227 {"event_pattern":"redfish_login","handler":{"type":"static","actions":[{"type":"redfish_login_accept"}]}},
228 {"event_pattern":"redfish_request","handler":{"type":"static","actions":[{"type":"redfish_error","error":"resource_not_found"}]}}
229 ]);
230 let mut scripted = static_example.clone();
231 scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nok=e['user_name']=='admin' and e['password']=='secret'\nprint(json.dumps({'actions':[{'type':'redfish_login_accept' if ok else 'redfish_login_reject'}]}))"});
232 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
233 }
234 fn group_name(&self) -> &'static str {
235 "Network Management"
236 }
237}
238
239impl Server for RedfishProtocol {
240 fn spawn(
241 &self,
242 ctx: SpawnContext,
243 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
244 {
245 Box::pin(super::spawn(ctx))
246 }
247 fn execute_action(&self, v: Value) -> Result<ActionResult> {
248 check_answer(&v)?;
249 Ok(ActionResult::Custom {
250 name: v["type"].as_str().unwrap_or_default().into(),
251 data: v,
252 })
253 }
254}
255
256pub fn check_answer(v: &Value) -> Result<()> {
257 ensure!(model::budget_ok(v), "answer exceeds the Redfish bounds");
258 match v["type"].as_str() {
259 Some("redfish_login_accept") => {
260 if let Some(r) = v.get("role").filter(|r| !r.is_null()) {
261 ensure!(
262 r.as_str().is_some_and(|r| !r.is_empty()
263 && r.len() <= 64
264 && r.bytes().all(|b| b.is_ascii_alphanumeric())),
265 "role is an identifier"
266 );
267 }
268 }
269 Some("redfish_login_reject" | "redfish_no_content") => {}
270 Some("redfish_resource") => {
271 ensure!(v["resource"].is_object(), "resource must be an object");
272 }
273 Some("redfish_task") => {
274 if let Some(s) = v.get("final_state").filter(|s| !s.is_null()) {
275 let s = s.as_str().context("final_state must be a string")?;
276 ensure!(
277 model::task_finished(s),
278 "final_state must be Completed, Exception, Killed or Cancelled"
279 );
280 }
281 if let Some(n) = v.get("complete_after_secs").filter(|n| !n.is_null()) {
282 ensure!(
283 n.as_u64().is_some_and(|n| n <= 600),
284 "complete_after_secs must be 0..=600"
285 );
286 }
287 model::messages_from(v.get("messages"))?;
288 if let Some(r) = v.get("result").filter(|r| !r.is_null()) {
289 ensure!(r.is_object(), "result must be an object");
290 }
291 }
292 Some("redfish_error") => {
293 let name = v["error"].as_str().context("error is required")?;
294 ensure!(model::error_named(name).is_some(), "unknown error '{name}'");
295 if let Some(m) = v.get("message").filter(|m| !m.is_null()) {
296 ensure!(
297 m.as_str().is_some_and(|m| !m.is_empty() && m.len() <= 1024),
298 "message must be 1..1024 bytes"
299 );
300 }
301 }
302 _ => bail!("Unknown Redfish server action"),
303 }
304 Ok(())
305}