1pub mod actions;
5pub mod model;
6
7use crate::llm::action_helper::call_llm;
8use crate::llm::actions::protocol_trait::ActionResult;
9use crate::logging::emit::Log;
10use crate::protocol::{Event, SpawnContext};
11use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter, DEFAULT_MAX_CONNECTIONS};
12use crate::server::connection::ConnectionId;
13use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
14use crate::utils::clock::Instant;
15use base64::Engine;
16use bytes::Bytes;
17use http_body_util::{BodyExt, Full, Limited};
18use hyper::{
19 body::Incoming, header, server::conn::http1, service::service_fn, Method, Request, Response,
20 StatusCode,
21};
22use hyper_util::rt::{TokioIo, TokioTimer};
23use serde_json::{json, Value};
24use std::collections::HashMap;
25use std::hash::{BuildHasher, Hasher};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
28use tokio::sync::Mutex;
29
30pub const DEFAULT_PRODUCT: &str = "NetGet Redfish Service";
31pub const DEFAULT_VENDOR: &str = "NetGet";
32pub const DEFAULT_UUID: &str = "6e657467-6574-4000-8000-726564666973";
33pub const DEFAULT_AUTH: &str = "required";
34pub const DEFAULT_SESSION_TIMEOUT: Duration = Duration::from_secs(1800);
35pub const DEFAULT_TASK_SECS: u64 = 2;
36const MAX_SESSIONS: usize = 64;
37const MAX_TASKS: usize = 256;
38const HEADER_TIMEOUT: Duration = Duration::from_secs(30);
39const BODY_TIMEOUT: Duration = Duration::from_secs(30);
40
41struct Session {
42 id: String,
43 user: String,
44 role: String,
45 last_used: Instant,
46}
47
48struct Task {
49 final_state: String,
50 done_at: Instant,
51 start: String,
52 messages: Vec<Value>,
53 result: Option<Value>,
54}
55
56struct Shared {
57 ctx: SpawnContext,
58 product: String,
59 vendor: String,
60 uuid: String,
61 auth_required: bool,
62 session_timeout: Duration,
63 sessions: Mutex<HashMap<String, Session>>,
64 basic: Mutex<HashMap<u64, Instant>>,
65 basic_key: std::collections::hash_map::RandomState,
66 tasks: Mutex<HashMap<String, Task>>,
67 seq: AtomicU64,
68}
69
70pub async fn spawn(ctx: SpawnContext) -> anyhow::Result<SocketAddr> {
71 let p = ctx.startup_params.as_ref();
72 let s = |k: &str, d: &str| -> anyhow::Result<String> {
73 Ok(p.map(|p| p.get_optional_string(k))
74 .transpose()?
75 .flatten()
76 .unwrap_or_else(|| d.to_owned()))
77 };
78 let product = s("product", DEFAULT_PRODUCT)?;
79 let vendor = s("vendor", DEFAULT_VENDOR)?;
80 let uuid = s("uuid", DEFAULT_UUID)?;
81 anyhow::ensure!(
82 product.len() <= 256 && vendor.len() <= 256,
83 "product and vendor are at most 256 bytes"
84 );
85 anyhow::ensure!(uuid::Uuid::parse_str(&uuid).is_ok(), "uuid must be a UUID");
86 let auth = s("auth", DEFAULT_AUTH)?;
87 anyhow::ensure!(
88 auth == "required" || auth == "none",
89 "auth must be required or none"
90 );
91 let timeout = p
92 .map(|p| p.get_optional_u64("session_timeout_secs"))
93 .transpose()?
94 .flatten()
95 .unwrap_or(DEFAULT_SESSION_TIMEOUT.as_secs());
96 anyhow::ensure!(
97 (30..=86_400).contains(&timeout),
98 "session_timeout_secs must be 30..=86400"
99 );
100 let listener = tokio::net::TcpListener::bind(ctx.legacy_listen_addr()).await?;
101 let local = listener.local_addr()?;
102 Log::new(Some(&ctx.status_tx)).info(format!(
103 "Redfish service at http://{local}{} (auth {auth})",
104 model::ROOT
105 ));
106 let shared = Arc::new(Shared {
107 ctx: ctx.clone(),
108 product,
109 vendor,
110 uuid,
111 auth_required: auth == "required",
112 session_timeout: Duration::from_secs(timeout),
113 sessions: Mutex::default(),
114 basic: Mutex::default(),
115 basic_key: Default::default(),
116 tasks: Mutex::default(),
117 seq: AtomicU64::new(1),
118 });
119 let server_id = ctx.server_id;
120 let accept = tokio::spawn(async move {
121 let limiter = ConnectionLimiter::new(DEFAULT_MAX_CONNECTIONS);
122 loop {
123 let (stream, peer, permit) = match accept_bounded(&listener, &limiter, b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", "Redfish", Some(&shared.ctx.status_tx)).await {
124 Ok(v) => v,
125 Err(_) => break,
126 };
127 let id = ConnectionId::new(shared.ctx.state.get_next_unified_id().await);
128 let now = Instant::now();
129 shared
130 .ctx
131 .state
132 .add_connection_to_server(
133 server_id,
134 ConnectionState {
135 id,
136 remote_addr: peer,
137 local_addr: local,
138 bytes_sent: 0,
139 bytes_received: 0,
140 packets_sent: 0,
141 packets_received: 0,
142 last_activity: now,
143 status: ConnectionStatus::Active,
144 status_changed_at: now,
145 protocol_info: ProtocolConnectionInfo::empty(),
146 },
147 )
148 .await;
149 let child = shared.clone();
150 shared
151 .ctx
152 .state
153 .spawn_server_task(server_id, async move {
154 let _permit = permit;
155 let svc_shared = child.clone();
156 let service = service_fn(move |req| {
157 let shared = svc_shared.clone();
158 async move { Ok::<_, Infallible>(handle(&shared, id, req).await) }
159 });
160 let mut builder = http1::Builder::new();
161 builder
162 .timer(TokioTimer::new())
163 .header_read_timeout(HEADER_TIMEOUT)
164 .max_headers(64)
165 .max_buf_size(32 * 1024);
166 if let Err(e) = builder
167 .serve_connection(TokioIo::new(stream), service)
168 .await
169 {
170 Log::new(Some(&child.ctx.status_tx))
171 .debug(format!("Redfish connection {id}: {e}"));
172 }
173 child
174 .ctx
175 .state
176 .update_connection_status(server_id, id, ConnectionStatus::Closed)
177 .await;
178 let _ = child.ctx.status_tx.send("__UPDATE_UI__".into());
179 })
180 .await;
181 }
182 });
183 ctx.state.register_server_task(server_id, accept).await;
184 Ok(local)
185}
186
187type Reply = Response<Full<Bytes>>;
188
189fn reply(status: u16, body: Option<&Value>, headers: &[(&'static str, String)]) -> Reply {
190 let bytes = body
191 .map(|b| serde_json::to_vec(b).unwrap_or_default())
192 .unwrap_or_default();
193 let mut r = Response::new(Full::new(Bytes::from(bytes)));
194 *r.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
195 let h = r.headers_mut();
196 if body.is_some() {
197 h.insert(
198 header::CONTENT_TYPE,
199 header::HeaderValue::from_static("application/json; charset=utf-8"),
200 );
201 }
202 h.insert("OData-Version", header::HeaderValue::from_static("4.0"));
203 h.insert(
204 header::CACHE_CONTROL,
205 header::HeaderValue::from_static("no-cache"),
206 );
207 for (k, v) in headers {
208 if let Ok(v) = header::HeaderValue::from_str(v) {
209 h.insert(*k, v);
210 }
211 }
212 r
213}
214
215fn error(status: u16, message_id: &str, message: &str) -> Reply {
216 reply(status, Some(&model::error_body(message_id, message)), &[])
217}
218
219fn named_error(name: &str) -> Reply {
220 let (status, id, message) = model::error_named(name).expect("known error name");
221 error(status, id, message)
222}
223
224fn not_allowed(allow: &'static str) -> Reply {
225 let mut r = named_error("operation_not_allowed");
226 r.headers_mut()
227 .insert(header::ALLOW, header::HeaderValue::from_static(allow));
228 r
229}
230
231fn unauthorized() -> Reply {
232 let mut r = error(
233 401,
234 "NoValidSession",
235 "There is no valid session established with the implementation.",
236 );
237 r.headers_mut().insert(
238 header::WWW_AUTHENTICATE,
239 header::HeaderValue::from_static("Basic realm=\"NetGet Redfish\""),
240 );
241 r
242}
243
244fn now_rfc3339() -> String {
245 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
246}
247
248fn outcome(ctx: &SpawnContext, id: ConnectionId, operation: &str, decision: &str) {
249 let summary = format!("Redfish connection {id} operation={operation} decision={decision}");
250 let log = Log::new(Some(&ctx.status_tx));
251 if decision.starts_with("fail_closed_") || decision == "model_silent" {
252 log.error(summary);
253 } else {
254 log.info(summary);
255 }
256}
257
258async fn handle(shared: &Arc<Shared>, id: ConnectionId, req: Request<Incoming>) -> Reply {
259 let ctx = &shared.ctx;
260 ctx.state
261 .update_connection_stats(
262 ctx.server_id,
263 id,
264 Some(req.uri().to_string().len() as u64),
265 None,
266 Some(1),
267 None,
268 )
269 .await;
270 let response = route(shared, id, req).await;
271 let sent = hyper::body::Body::size_hint(response.body())
272 .exact()
273 .unwrap_or(0);
274 ctx.state
275 .update_connection_stats(ctx.server_id, id, None, Some(sent), None, Some(1))
276 .await;
277 response
278}
279
280fn service_root(shared: &Shared) -> Value {
281 json!({
282 "@odata.id": model::ROOT,
283 "@odata.type": "#ServiceRoot.v1_16_1.ServiceRoot",
284 "Id": "RootService",
285 "Name": "Root Service",
286 "RedfishVersion": model::REDFISH_VERSION,
287 "UUID": shared.uuid,
288 "Product": shared.product,
289 "Vendor": shared.vendor,
290 "Systems": model::link("/redfish/v1/Systems"),
291 "Chassis": model::link("/redfish/v1/Chassis"),
292 "Managers": model::link("/redfish/v1/Managers"),
293 "AccountService": model::link("/redfish/v1/AccountService"),
294 "SessionService": model::link("/redfish/v1/SessionService"),
295 "TaskService": model::link("/redfish/v1/TaskService"),
296 "Links": {"Sessions": model::link("/redfish/v1/SessionService/Sessions")},
297 "ProtocolFeaturesSupported": {"ExpandQuery": {"ExpandAll": false}, "FilterQuery": false, "SelectQuery": false, "OnlyMemberQuery": false}
298 })
299}
300
301const ODATA_SETS: &[(&str, &str)] = &[
302 ("Systems", "/redfish/v1/Systems"),
303 ("Chassis", "/redfish/v1/Chassis"),
304 ("Managers", "/redfish/v1/Managers"),
305 ("AccountService", "/redfish/v1/AccountService"),
306 ("SessionService", "/redfish/v1/SessionService"),
307 ("TaskService", "/redfish/v1/TaskService"),
308];
309
310fn metadata_xml() -> String {
311 let mut refs = String::new();
312 for ns in [
313 "ServiceRoot_v1",
314 "ComputerSystemCollection",
315 "ComputerSystem_v1",
316 "ChassisCollection",
317 "Chassis_v1",
318 "ManagerCollection",
319 "Manager_v1",
320 "SessionService_v1",
321 "SessionCollection",
322 "Session_v1",
323 "TaskService_v1",
324 "TaskCollection",
325 "Task_v1",
326 "AccountService_v1",
327 "SensorCollection",
328 "Sensor_v1",
329 ] {
330 let alias = ns.trim_end_matches("_v1");
331 refs.push_str(&format!(
332 " <edmx:Reference Uri=\"http://redfish.dmtf.org/schemas/v1/{ns}.xml\">\n <edmx:Include Namespace=\"{alias}\"/>\n </edmx:Reference>\n"
333 ));
334 }
335 format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<edmx:Edmx xmlns:edmx=\"http://docs.oasis-open.org/odata/ns/edmx\" Version=\"4.0\">\n{refs} <edmx:DataServices>\n <Schema xmlns=\"http://docs.oasis-open.org/odata/ns/edm\" Namespace=\"Service\">\n <EntityContainer Name=\"Service\" Extends=\"ServiceRoot.v1_16_1.ServiceContainer\"/>\n </Schema>\n </edmx:DataServices>\n</edmx:Edmx>\n")
336}
337
338async fn authenticate(
340 shared: &Shared,
341 id: ConnectionId,
342 req: &Request<Incoming>,
343) -> Result<Option<String>, Reply> {
344 if !shared.auth_required {
345 return Ok(None);
346 }
347 if let Some(token) = req
348 .headers()
349 .get("X-Auth-Token")
350 .and_then(|v| v.to_str().ok())
351 {
352 let mut sessions = shared.sessions.lock().await;
353 sessions.retain(|_, s| s.last_used.elapsed() < shared.session_timeout);
354 return match sessions.get_mut(token) {
355 Some(s) => {
356 s.last_used = Instant::now();
357 Ok(Some(s.user.clone()))
358 }
359 None => Err(unauthorized()),
360 };
361 }
362 let basic = req
363 .headers()
364 .get(header::AUTHORIZATION)
365 .and_then(|v| v.to_str().ok())
366 .and_then(|v| v.strip_prefix("Basic "))
367 .and_then(|b| {
368 base64::engine::general_purpose::STANDARD
369 .decode(b.trim())
370 .ok()
371 })
372 .and_then(|b| String::from_utf8(b).ok());
373 let Some((user, password)) = basic.as_deref().and_then(|b| b.split_once(':')) else {
374 return Err(unauthorized());
375 };
376 let key = {
377 let mut h = shared.basic_key.build_hasher();
378 h.write(user.as_bytes());
379 h.write_u8(0);
380 h.write(password.as_bytes());
381 h.finish()
382 };
383 {
384 let mut cache = shared.basic.lock().await;
385 cache.retain(|_, until| until.elapsed() < shared.session_timeout);
386 if cache.contains_key(&key) {
387 return Ok(Some(user.to_owned()));
388 }
389 }
390 match login(shared, id, user, password, "basic").await {
391 Some(_) => {
392 let mut cache = shared.basic.lock().await;
393 if cache.len() < MAX_SESSIONS {
394 cache.insert(key, Instant::now());
395 }
396 Ok(Some(user.to_owned()))
397 }
398 None => Err(unauthorized()),
399 }
400}
401
402async fn login(
405 shared: &Shared,
406 id: ConnectionId,
407 user: &str,
408 password: &str,
409 method: &str,
410) -> Option<String> {
411 let ctx = &shared.ctx;
412 let event = Event::new(
413 &actions::LOGIN_EVENT,
414 json!({"user_name": user, "password": password, "method": method}),
415 );
416 let result = match call_llm(
417 &ctx.llm_client,
418 &ctx.state,
419 ctx.server_id,
420 Some(id),
421 &event,
422 &actions::RedfishProtocol,
423 )
424 .await
425 {
426 Ok(r) => r,
427 Err(_) => {
428 outcome(ctx, id, "login", "fail_closed_llm_error");
429 return None;
430 }
431 };
432 let answers = answers(
433 result.protocol_results,
434 &["redfish_login_accept", "redfish_login_reject"],
435 );
436 match answers.as_slice() {
437 [a] if a["type"] == "redfish_login_accept" && result.failures.is_empty() => {
438 outcome(ctx, id, "login", "model_answer");
439 Some(a["role"].as_str().unwrap_or("Administrator").to_owned())
440 }
441 [a] if a["type"] == "redfish_login_reject" => {
442 outcome(ctx, id, "login", "model_reject");
443 None
444 }
445 [] => {
446 outcome(ctx, id, "login", "model_silent");
447 None
448 }
449 _ => {
450 outcome(ctx, id, "login", "fail_closed_invalid_reply");
451 None
452 }
453 }
454}
455
456fn answers(results: Vec<ActionResult>, allowed: &[&str]) -> Vec<Value> {
457 let mut out = Vec::new();
458 for r in results {
459 match r {
460 ActionResult::Custom { name, data } if allowed.contains(&name.as_str()) => {
461 out.push(data)
462 }
463 ActionResult::Multiple(items) => out.extend(answers(items, allowed)),
464 _ => {}
465 }
466 }
467 out
468}
469
470async fn read_json(req: Request<Incoming>) -> Result<Value, Reply> {
471 let content_type = req
472 .headers()
473 .get(header::CONTENT_TYPE)
474 .and_then(|v| v.to_str().ok())
475 .unwrap_or("")
476 .to_ascii_lowercase();
477 if !content_type.starts_with("application/json") {
478 return Err(error(
479 415,
480 "GeneralError",
481 "The request body must be application/json.",
482 ));
483 }
484 let body = match tokio::time::timeout(
485 BODY_TIMEOUT,
486 Limited::new(req.into_body(), model::MAX_BODY_BYTES).collect(),
487 )
488 .await
489 {
490 Ok(Ok(b)) => b.to_bytes(),
491 _ => {
492 return Err(error(
493 413,
494 "GeneralError",
495 "The request body is unreadable or over 1 MiB.",
496 ))
497 }
498 };
499 match serde_json::from_slice::<Value>(&body) {
500 Ok(v @ Value::Object(_)) if model::budget_ok(&v) => Ok(v),
501 _ => Err(error(400, "MalformedJSON", "The request body submitted was malformed JSON and could not be parsed by the receiving service.")),
502 }
503}
504
505async fn route(shared: &Arc<Shared>, id: ConnectionId, req: Request<Incoming>) -> Reply {
506 let raw = req.uri().path().to_owned();
507 let method = req.method().clone();
508 if raw == "/redfish" || raw == "/redfish/" {
509 return match method {
510 Method::GET => reply(200, Some(&json!({"v1": model::ROOT})), &[]),
511 _ => not_allowed("GET"),
512 };
513 }
514 let Some(path) = model::normalize(&raw) else {
515 return named_error("resource_not_found");
516 };
517 if let Some(v) = req
518 .headers()
519 .get("OData-Version")
520 .and_then(|v| v.to_str().ok())
521 {
522 if v.trim() != "4.0" {
523 return error(412, "GeneralError", "Only OData-Version 4.0 is supported.");
524 }
525 }
526 match (path.as_str(), &method) {
528 (model::ROOT, &Method::GET) => return reply(200, Some(&service_root(shared)), &[]),
529 (model::ROOT, _) => return not_allowed("GET"),
530 ("/redfish/v1/odata", &Method::GET) => {
531 let sets: Vec<Value> = ODATA_SETS
532 .iter()
533 .map(|(n, u)| json!({"name": n, "kind": "Singleton", "url": u}))
534 .chain([json!({"name": "Service", "kind": "Singleton", "url": model::ROOT})])
535 .collect();
536 return reply(
537 200,
538 Some(&json!({"@odata.context": "/redfish/v1/$metadata", "value": sets})),
539 &[],
540 );
541 }
542 ("/redfish/v1/$metadata", &Method::GET) => {
543 let mut r = Response::new(Full::new(Bytes::from(metadata_xml())));
544 r.headers_mut().insert(
545 header::CONTENT_TYPE,
546 header::HeaderValue::from_static("application/xml"),
547 );
548 r.headers_mut()
549 .insert("OData-Version", header::HeaderValue::from_static("4.0"));
550 return r;
551 }
552 ("/redfish/v1/SessionService/Sessions", &Method::POST) => {
553 return create_session(shared, id, req).await
554 }
555 _ => {}
556 }
557 let user = match authenticate(shared, id, &req).await {
558 Ok(u) => u,
559 Err(r) => return r,
560 };
561 if let Some(r) = rust_owned(shared, &path, &method).await {
562 return r;
563 }
564 let if_match = req
565 .headers()
566 .get(header::IF_MATCH)
567 .and_then(|v| v.to_str().ok())
568 .map(str::to_owned);
569 let (kind, body) = match method {
570 Method::GET => ("read", None),
571 Method::DELETE => ("delete", None),
572 Method::PATCH | Method::POST => {
573 let body = match read_json(req).await {
574 Ok(b) => b,
575 Err(r) => return r,
576 };
577 let kind = if method == Method::PATCH {
578 "update"
579 } else if model::split_action(&path).is_some() {
580 "action"
581 } else {
582 "create"
583 };
584 (kind, Some(body))
585 }
586 _ => return not_allowed("GET, PATCH, POST, DELETE"),
587 };
588 let mut data = json!({"method": method.as_str(), "kind": kind, "path": path, "body": body, "user_name": user, "if_match": if_match});
589 if let Some((resource, action)) = model::split_action(&path) {
590 data["action"] = json!(action);
591 data["resource_path"] = json!(resource);
592 }
593 resource_request(shared, id, &path, kind, data).await
594}
595
596async fn create_session(shared: &Arc<Shared>, id: ConnectionId, req: Request<Incoming>) -> Reply {
597 let body = match read_json(req).await {
598 Ok(b) => b,
599 Err(r) => return r,
600 };
601 let field = |k: &str| {
602 body[k]
603 .as_str()
604 .filter(|s| s.len() <= 256)
605 .map(str::to_owned)
606 };
607 let (Some(user), Some(password)) = (field("UserName"), field("Password")) else {
608 return error(
609 400,
610 "PropertyMissing",
611 "UserName and Password are required.",
612 );
613 };
614 if !shared.auth_required {
615 return error(
616 405,
617 "OperationNotAllowed",
618 "Authentication is not enabled on this service.",
619 );
620 }
621 if shared.sessions.lock().await.len() >= MAX_SESSIONS {
622 return error(503, "SessionLimitExceeded", "The session establishment failed due to the number of simultaneous sessions exceeding the limit of the implementation.");
623 }
624 let Some(role) = login(shared, id, &user, &password, "session").await else {
625 return unauthorized();
626 };
627 let sid = shared.seq.fetch_add(1, Ordering::Relaxed).to_string();
628 let token = uuid::Uuid::new_v4().simple().to_string();
629 let location = format!("/redfish/v1/SessionService/Sessions/{sid}");
630 let resource = session_resource(&sid, &user, &role);
631 shared.sessions.lock().await.insert(
632 token.clone(),
633 Session {
634 id: sid,
635 user,
636 role,
637 last_used: Instant::now(),
638 },
639 );
640 reply(
641 201,
642 Some(&resource),
643 &[("X-Auth-Token", token), ("Location", location)],
644 )
645}
646
647fn session_resource(id: &str, user: &str, role: &str) -> Value {
648 json!({
649 "@odata.id": format!("/redfish/v1/SessionService/Sessions/{id}"),
650 "@odata.type": "#Session.v1_7_1.Session",
651 "Id": id,
652 "Name": "User Session",
653 "UserName": user,
654 "Roles": [role],
655 })
656}
657
658async fn rust_owned(shared: &Shared, path: &str, method: &Method) -> Option<Reply> {
660 const SESSIONS: &str = "/redfish/v1/SessionService/Sessions";
661 const TASKS: &str = "/redfish/v1/TaskService/Tasks";
662 const MONITORS: &str = "/redfish/v1/TaskService/TaskMonitors";
663 let get = method == Method::GET;
664 Some(match path {
665 "/redfish/v1/SessionService" if get => reply(
666 200,
667 Some(&json!({
668 "@odata.id": "/redfish/v1/SessionService",
669 "@odata.type": "#SessionService.v1_1_9.SessionService",
670 "Id": "SessionService",
671 "Name": "Session Service",
672 "ServiceEnabled": true,
673 "SessionTimeout": shared.session_timeout.as_secs(),
674 "Sessions": model::link(SESSIONS),
675 })),
676 &[],
677 ),
678 "/redfish/v1/SessionService" => not_allowed("GET"),
679 SESSIONS if get => {
680 let mut sessions = shared.sessions.lock().await;
681 sessions.retain(|_, s| s.last_used.elapsed() < shared.session_timeout);
682 let mut ids: Vec<&String> = sessions.values().map(|s| &s.id).collect();
683 ids.sort();
684 let members = ids
685 .into_iter()
686 .map(|i| model::link(&format!("{SESSIONS}/{i}")))
687 .collect();
688 reply(
689 200,
690 Some(&model::collection(
691 SESSIONS,
692 "SessionCollection",
693 "Session Collection",
694 members,
695 )),
696 &[],
697 )
698 }
699 SESSIONS => not_allowed("GET, POST"),
700 p if p.starts_with(&format!("{SESSIONS}/")) => {
701 let sid = &p[SESSIONS.len() + 1..];
702 let mut sessions = shared.sessions.lock().await;
703 let token = sessions
704 .iter()
705 .find(|(_, s)| s.id == sid)
706 .map(|(t, _)| t.clone());
707 match (token, method) {
708 (None, _) => named_error("resource_not_found"),
709 (Some(t), &Method::GET) => {
710 let s = &sessions[&t];
711 reply(200, Some(&session_resource(&s.id, &s.user, &s.role)), &[])
712 }
713 (Some(t), &Method::DELETE) => {
714 sessions.remove(&t);
715 reply(204, None, &[])
716 }
717 _ => not_allowed("GET, DELETE"),
718 }
719 }
720 "/redfish/v1/TaskService" if get => reply(
721 200,
722 Some(&json!({
723 "@odata.id": "/redfish/v1/TaskService",
724 "@odata.type": "#TaskService.v1_2_1.TaskService",
725 "Id": "TaskService",
726 "Name": "Task Service",
727 "ServiceEnabled": true,
728 "CompletedTaskOverWritePolicy": "Oldest",
729 "Tasks": model::link(TASKS),
730 })),
731 &[],
732 ),
733 "/redfish/v1/TaskService" => not_allowed("GET"),
734 TASKS if get => {
735 let tasks = shared.tasks.lock().await;
736 let mut ids: Vec<&String> = tasks.keys().collect();
737 ids.sort_by_key(|i| i.parse::<u64>().unwrap_or(0));
738 let members = ids
739 .into_iter()
740 .map(|i| model::link(&format!("{TASKS}/{i}")))
741 .collect();
742 reply(
743 200,
744 Some(&model::collection(
745 TASKS,
746 "TaskCollection",
747 "Task Collection",
748 members,
749 )),
750 &[],
751 )
752 }
753 TASKS => not_allowed("GET"),
754 p if p.starts_with(&format!("{TASKS}/")) || p.starts_with(&format!("{MONITORS}/")) => {
755 let monitor = p.starts_with(MONITORS);
756 let tid = p.rsplit('/').next().unwrap_or("");
757 if !get {
758 return Some(not_allowed("GET"));
759 }
760 let tasks = shared.tasks.lock().await;
761 let Some(t) = tasks.get(tid) else {
762 return Some(named_error("resource_not_found"));
763 };
764 let done = Instant::now() >= t.done_at;
765 let (state, percent) = if done {
766 (t.final_state.as_str(), 100)
767 } else {
768 ("Running", 50)
769 };
770 let resource = model::task_resource(
771 tid,
772 state,
773 percent,
774 if done { &t.messages } else { &[] },
775 &t.start,
776 );
777 if !monitor {
778 reply(200, Some(&resource), &[])
779 } else if !done {
780 reply(
781 202,
782 Some(&resource),
783 &[("Location", p.to_owned()), ("Retry-After", "1".into())],
784 )
785 } else {
786 match &t.result {
787 Some(body) => reply(200, Some(body), &[]),
788 None => reply(204, None, &[]),
789 }
790 }
791 }
792 _ => return None,
793 })
794}
795
796async fn resource_request(
797 shared: &Arc<Shared>,
798 id: ConnectionId,
799 path: &str,
800 kind: &str,
801 data: Value,
802) -> Reply {
803 let ctx = &shared.ctx;
804 let failed = |decision: &str, e: anyhow::Error| {
805 outcome(ctx, id, kind, decision);
806 let failure = crate::utils::wire_failure::WireFailure::classify(&e);
807 if failure.is_overloaded() {
808 error(503, "ServiceTemporarilyUnavailable", failure.text())
809 } else {
810 error(500, "GeneralError", failure.text())
811 }
812 };
813 let event = Event::new(&actions::REQUEST_EVENT, data);
814 let result = match call_llm(
815 &ctx.llm_client,
816 &ctx.state,
817 ctx.server_id,
818 Some(id),
819 &event,
820 &actions::RedfishProtocol,
821 )
822 .await
823 {
824 Ok(r) => r,
825 Err(e) => return failed("fail_closed_llm_error", e),
826 };
827 if !result.failures.is_empty() {
828 return failed(
829 "fail_closed_invalid_reply",
830 anyhow::anyhow!("invalid handler answer"),
831 );
832 }
833 let list = answers(
834 result.protocol_results,
835 &[
836 "redfish_resource",
837 "redfish_no_content",
838 "redfish_task",
839 "redfish_error",
840 ],
841 );
842 let answer = match list.as_slice() {
843 [a] => a.clone(),
844 [] => return failed("model_silent", anyhow::anyhow!("no handler answer")),
845 _ => {
846 return failed(
847 "fail_closed_invalid_reply",
848 anyhow::anyhow!("more than one answer"),
849 )
850 }
851 };
852 let built: anyhow::Result<(Reply, &str)> = async {
853 actions::check_answer(&answer)?;
854 match (answer["type"].as_str().unwrap_or_default(), kind) {
855 ("redfish_error", _) => {
856 let (status, mid, default) =
857 model::error_named(answer["error"].as_str().unwrap_or_default())
858 .expect("checked");
859 Ok((
860 error(status, mid, answer["message"].as_str().unwrap_or(default)),
861 "model_reject",
862 ))
863 }
864 ("redfish_resource", "read" | "update") => {
865 let mut resource = answer["resource"].clone();
866 model::check_resource(path, &mut resource)?;
867 Ok((reply(200, Some(&resource), &[]), "model_answer"))
868 }
869 ("redfish_resource", "create") => {
870 let mut resource = answer["resource"].clone();
871 let rid = resource["@odata.id"]
872 .as_str()
873 .and_then(model::normalize)
874 .ok_or_else(|| anyhow::anyhow!("a created resource needs its @odata.id"))?;
875 anyhow::ensure!(
876 rid.strip_prefix(&format!("{path}/"))
877 .is_some_and(|rest| !rest.is_empty() && !rest.contains('/')),
878 "a created resource must be a member of {path}"
879 );
880 model::check_resource(&rid, &mut resource)?;
881 Ok((
882 reply(201, Some(&resource), &[("Location", rid)]),
883 "model_answer",
884 ))
885 }
886 ("redfish_resource", "action") => {
887 let resource = answer["resource"].clone();
888 Ok((reply(200, Some(&resource), &[]), "model_answer"))
889 }
890 ("redfish_no_content", "update" | "action" | "delete") => {
891 Ok((reply(204, None, &[]), "model_answer"))
892 }
893 ("redfish_task", "update" | "action" | "create" | "delete") => {
894 let tid = shared.seq.fetch_add(1, Ordering::Relaxed).to_string();
895 let secs = answer["complete_after_secs"]
896 .as_u64()
897 .unwrap_or(DEFAULT_TASK_SECS);
898 let start = now_rfc3339();
899 let task = Task {
900 final_state: answer["final_state"]
901 .as_str()
902 .unwrap_or("Completed")
903 .to_owned(),
904 done_at: Instant::now() + Duration::from_secs(secs),
905 start: start.clone(),
906 messages: model::messages_from(answer.get("messages"))?,
907 result: answer.get("result").filter(|r| r.is_object()).cloned(),
908 };
909 let mut tasks = shared.tasks.lock().await;
910 if tasks.len() >= MAX_TASKS {
911 let oldest = tasks
912 .keys()
913 .min_by_key(|k| k.parse::<u64>().unwrap_or(0))
914 .cloned();
915 if let Some(k) = oldest {
916 tasks.remove(&k);
917 }
918 }
919 tasks.insert(tid.clone(), task);
920 let monitor = format!("/redfish/v1/TaskService/TaskMonitors/{tid}");
921 let resource = model::task_resource(&tid, "Running", 0, &[], &start);
922 Ok((
923 reply(
924 202,
925 Some(&resource),
926 &[("Location", monitor), ("Retry-After", "1".into())],
927 ),
928 "model_answer",
929 ))
930 }
931 (t, k) => anyhow::bail!("{t} does not answer a {k} request"),
932 }
933 }
934 .await;
935 match built {
936 Ok((r, decision)) => {
937 outcome(ctx, id, kind, decision);
938 r
939 }
940 Err(e) => failed("fail_closed_invalid_reply", e),
941 }
942}