Skip to main content

netget/server/redfish/
mod.rs

1//! DMTF Redfish service over HTTP/1.1. Rust serves the service root, the OData documents, the
2//! SessionService and the TaskService and owns every envelope rule; the handler owns logins
3//! and every other resource.
4pub 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
338/// Who is asking, or the 401 to send. `Ok(None)` when authentication is off.
339async 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
402/// Ask the handler about credentials. `Some(role)` only on an explicit accept: silence, a
403/// failure or anything invalid is a refusal.
404async 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    // Unauthenticated documents.
527    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
658/// The SessionService and TaskService, which Rust serves itself. `None` for everything else.
659async 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}