use crate::{
Boot, BmcEndpoint, BootSpec, BootTarget, Error, ImageSource, Lifecycle, Machine, PowerState,
Result, VirtualMedia,
};
pub const DEFAULT_MEDIA_ID: &str = "CD";
#[derive(Default, Clone)]
pub struct RedfishBoot {
password: String,
insecure: bool,
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
pinned_cert_pem: Option<Vec<u8>>,
media_id: Option<String>,
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
endpoint: Option<BmcEndpoint>,
}
impl std::fmt::Debug for RedfishBoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedfishBoot")
.field(
"password",
&if self.password.is_empty() { "<unset>" } else { "<redacted>" },
)
.field("insecure", &self.insecure)
.field("pinned_cert_pem", &self.pinned_cert_pem.as_ref().map(|_| "<pinned>"))
.field("media_id", &self.media_id)
.field("endpoint", &self.endpoint)
.finish()
}
}
impl RedfishBoot {
pub fn new() -> Self {
Self::default()
}
pub fn for_node(bmc: BmcEndpoint, password: impl Into<String>) -> Self {
Self {
password: password.into(),
insecure: false,
pinned_cert_pem: None,
media_id: None,
endpoint: Some(bmc),
}
}
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = password.into();
self
}
pub fn pin_cert_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.pinned_cert_pem = Some(pem.into());
self
}
pub fn insecure(mut self, insecure: bool) -> Self {
self.insecure = insecure;
self
}
pub fn media_id(mut self, id: impl Into<String>) -> Self {
self.media_id = Some(id.into());
self
}
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
fn slot(&self) -> &str {
self.media_id.as_deref().unwrap_or(DEFAULT_MEDIA_ID)
}
fn target<'a>(&self, spec: &'a BootSpec) -> Result<(&'a BmcEndpoint, &'a str)> {
let bmc = spec
.bmc
.as_ref()
.ok_or_else(|| Error::Spec("Redfish boot needs a BMC endpoint".into()))?;
match &spec.image {
ImageSource::Iso(iso) => Ok((bmc, iso.as_str())),
other => Err(Error::Spec(format!(
"Redfish backend boots an ISO, got {other:?}"
))),
}
}
#[cfg_attr(not(feature = "backend-redfish"), allow(dead_code))]
fn bound(&self) -> Result<&BmcEndpoint> {
self.endpoint.as_ref().ok_or_else(|| {
Error::Backend(
"no BMC endpoint bound: construct with RedfishBoot::for_node(bmc, password)".into(),
)
})
}
}
impl Boot for RedfishBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
spec.validate()?;
let (bmc, iso) = self.target(spec)?;
#[cfg(feature = "backend-redfish")]
{
self.insert_media(bmc, iso)?;
self.set_boot_override(bmc, BootTarget::Cd)?;
self.reset(bmc, "On")?;
Ok(Machine::started(&bmc.system_id, spec))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = (bmc, iso);
Err(Error::Unsupported(
"redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
.into(),
))
}
}
}
impl VirtualMedia for RedfishBoot {
fn insert_media(&self, node: &BmcEndpoint, iso: &str) -> Result<()> {
#[cfg(feature = "backend-redfish")]
{
let url = virtual_media_action_url(node, self.slot(), "InsertMedia");
self.post(node, &url, &insert_media_body(iso))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = (node, iso);
Err(unsupported())
}
}
fn eject_media(&self, node: &BmcEndpoint) -> Result<()> {
#[cfg(feature = "backend-redfish")]
{
let url = virtual_media_action_url(node, self.slot(), "EjectMedia");
self.post(node, &url, &serde_json::json!({}))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = node;
Err(unsupported())
}
}
fn set_boot_override(&self, node: &BmcEndpoint, target: BootTarget) -> Result<()> {
#[cfg(feature = "backend-redfish")]
{
self.patch(node, &system_url(node), &boot_override_body(target))
}
#[cfg(not(feature = "backend-redfish"))]
{
let _ = (node, target);
Err(unsupported())
}
}
}
impl Lifecycle for RedfishBoot {
fn power_on(&self, machine: &Machine) -> Result<()> {
let _ = machine;
#[cfg(feature = "backend-redfish")]
{
self.reset(self.bound()?, "On")
}
#[cfg(not(feature = "backend-redfish"))]
{
Err(unsupported())
}
}
fn power_off(&self, machine: &Machine) -> Result<()> {
let _ = machine;
#[cfg(feature = "backend-redfish")]
{
self.reset(self.bound()?, "ForceOff")
}
#[cfg(not(feature = "backend-redfish"))]
{
Err(unsupported())
}
}
fn status(&self, machine: &Machine) -> Result<PowerState> {
let _ = machine;
#[cfg(feature = "backend-redfish")]
{
self.query_power(self.bound()?)
}
#[cfg(not(feature = "backend-redfish"))]
{
Err(unsupported())
}
}
}
#[cfg(not(feature = "backend-redfish"))]
fn unsupported() -> Error {
Error::Unsupported(
"redfish backend needs the `backend-redfish` feature (pure-Rust ureq/rustls Redfish client)"
.into(),
)
}
#[cfg(feature = "backend-redfish")]
fn base(bmc: &BmcEndpoint) -> &str {
bmc.host.trim_end_matches('/')
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn system_url(bmc: &BmcEndpoint) -> String {
format!("{}/redfish/v1/Systems/{}", base(bmc), bmc.system_id)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn virtual_media_action_url(bmc: &BmcEndpoint, slot: &str, action: &str) -> String {
format!(
"{}/redfish/v1/Systems/{}/VirtualMedia/{}/Actions/VirtualMedia.{}",
base(bmc),
bmc.system_id,
slot,
action
)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_url(bmc: &BmcEndpoint) -> String {
format!(
"{}/redfish/v1/Systems/{}/Actions/ComputerSystem.Reset",
base(bmc),
bmc.system_id
)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn insert_media_body(iso: &str) -> serde_json::Value {
serde_json::json!({ "Image": iso, "Inserted": true, "WriteProtected": true })
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn boot_override_body(target: BootTarget) -> serde_json::Value {
serde_json::json!({
"Boot": {
"BootSourceOverrideEnabled": "Once",
"BootSourceOverrideTarget": target_str(target),
}
})
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_body(reset_type: &str) -> serde_json::Value {
serde_json::json!({ "ResetType": reset_type })
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn target_str(target: BootTarget) -> &'static str {
match target {
BootTarget::Cd => "Cd",
BootTarget::Pxe => "Pxe",
BootTarget::Hdd => "Hdd",
BootTarget::BiosSetup => "BiosSetup",
}
}
#[cfg(feature = "backend-redfish")]
impl RedfishBoot {
fn agent(&self) -> Result<ureq::Agent> {
use ureq::tls::TlsConfig;
if let Some(pem) = &self.pinned_cert_pem {
use ureq::tls::{Certificate, RootCerts};
let cert = Certificate::from_pem(pem)
.map_err(|e| Error::Backend(format!("redfish: invalid pinned BMC cert PEM: {e}")))?;
let tls = TlsConfig::builder().root_certs(RootCerts::from([cert])).build();
Ok(ureq::config::Config::builder().tls_config(tls).build().into())
} else if self.insecure {
let tls = TlsConfig::builder().disable_verification(true).build();
Ok(ureq::config::Config::builder().tls_config(tls).build().into())
} else {
Ok(ureq::Agent::new_with_defaults())
}
}
fn auth(&self, bmc: &BmcEndpoint) -> String {
format!("Basic {}", base64(format!("{}:{}", bmc.username, self.password).as_bytes()))
}
fn post(&self, bmc: &BmcEndpoint, url: &str, body: &serde_json::Value) -> Result<()> {
self.agent()?
.post(url)
.header("Authorization", &self.auth(bmc))
.send_json(body)
.map_err(|e| Error::Backend(format!("redfish POST {url}: {e}")))?;
Ok(())
}
fn patch(&self, bmc: &BmcEndpoint, url: &str, body: &serde_json::Value) -> Result<()> {
self.agent()?
.patch(url)
.header("Authorization", &self.auth(bmc))
.send_json(body)
.map_err(|e| Error::Backend(format!("redfish PATCH {url}: {e}")))?;
Ok(())
}
fn reset(&self, bmc: &BmcEndpoint, reset_type: &str) -> Result<()> {
self.post(bmc, &reset_url(bmc), &reset_body(reset_type))
}
fn query_power(&self, bmc: &BmcEndpoint) -> Result<PowerState> {
let url = system_url(bmc);
let mut resp = self
.agent()?
.get(&url)
.header("Authorization", &self.auth(bmc))
.call()
.map_err(|e| Error::Backend(format!("redfish GET {url}: {e}")))?;
let v: serde_json::Value = resp
.body_mut()
.read_json()
.map_err(|e| Error::Backend(format!("redfish decode {url}: {e}")))?;
Ok(match v.get("PowerState").and_then(|p| p.as_str()) {
Some("On") => PowerState::On,
Some("Off") => PowerState::Off,
_ => PowerState::Unknown,
})
}
}
#[cfg(feature = "backend-redfish")]
fn base64(input: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
let n = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
out.push(T[((n >> 18) & 63) as usize] as char);
out.push(T[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
#[cfg(all(test, feature = "backend-redfish"))]
mod tests {
use super::*;
fn bmc() -> BmcEndpoint {
BmcEndpoint {
host: "https://bmc-42.dc.example/".into(), username: "admin".into(),
system_id: "System.Embedded.1".into(),
}
}
#[test]
fn system_and_reset_urls_are_well_formed() {
let b = bmc();
assert_eq!(
system_url(&b),
"https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1"
);
assert_eq!(
reset_url(&b),
"https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
);
}
#[test]
fn virtual_media_insert_url_targets_the_cd_slot() {
assert_eq!(
virtual_media_action_url(&bmc(), "CD", "InsertMedia"),
"https://bmc-42.dc.example/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia"
);
}
#[test]
fn insert_media_body_mounts_the_iso_read_only() {
let body = insert_media_body("http://srv/installer.iso");
assert_eq!(body["Image"], "http://srv/installer.iso");
assert_eq!(body["Inserted"], true);
assert_eq!(body["WriteProtected"], true);
}
#[test]
fn boot_override_body_is_one_time_to_cd() {
let body = boot_override_body(BootTarget::Cd);
assert_eq!(body["Boot"]["BootSourceOverrideEnabled"], "Once");
assert_eq!(body["Boot"]["BootSourceOverrideTarget"], "Cd");
}
#[test]
fn boot_targets_map_to_redfish_tokens() {
assert_eq!(target_str(BootTarget::Cd), "Cd");
assert_eq!(target_str(BootTarget::Pxe), "Pxe");
assert_eq!(target_str(BootTarget::Hdd), "Hdd");
assert_eq!(target_str(BootTarget::BiosSetup), "BiosSetup");
}
#[test]
fn reset_body_carries_the_reset_type() {
assert_eq!(reset_body("On")["ResetType"], "On");
assert_eq!(reset_body("ForceOff")["ResetType"], "ForceOff");
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"admin:secret"), "YWRtaW46c2VjcmV0");
}
#[test]
fn auth_header_is_basic_base64_of_user_colon_password() {
let rf = RedfishBoot::new().with_password("secret");
assert_eq!(rf.auth(&bmc()), "Basic YWRtaW46c2VjcmV0");
}
const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
MIIDHTCCAgWgAwIBAgIUHkyHfhWhNsGjKB2/NlJVedpan9QwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTYm1jLXRlc3QuZGMuZXhhbXBsZTAeFw0yNjA3MTQxNjAw
MTdaFw0zNjA3MTExNjAwMTdaMB4xHDAaBgNVBAMME2JtYy10ZXN0LmRjLmV4YW1w
bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD4nFGHKjOCUyDnJlkR
UHA2JCReXmGhLzLRvlj70Mcs48A5KRVsCPBfq/VlMVv92iSscGQ5Rnn2dpMLbIu0
F+T6zLl1+mAO8HLv+1BZYRhcHyA6slDKvg6K8G3gUNS9poQvzoFZEptacdn/dLSw
LSuEhTLdxHRzdWWxlIbmDTa2OZwhwjGD9KrKyT0ddg386e8KwcmuT+Yq3tbs7ceq
rblHgvd5k9l5e/fwFLpj1XtHb34XILR838Ba4MvL4Tmfjr2OdmqFCkNOOODew5PF
6MICrj5U92Xtq+WJQEIa2Ug8+EgAxOVrafdM84olvzt9+yAJj8YJVhvT8wyc36Sp
o6WZAgMBAAGjUzBRMB0GA1UdDgQWBBSIqmembS5QXMrP67M0oBYVvqdGCTAfBgNV
HSMEGDAWgBSIqmembS5QXMrP67M0oBYVvqdGCTAPBgNVHRMBAf8EBTADAQH/MA0G
CSqGSIb3DQEBCwUAA4IBAQCWy1V7aJ+E6E9XjozsqTw2jZ+xCjZ1iUyYmHc2wdOP
RHKcqfSwLwJRyFG5+z8StclGRFL6cR1B+WaZ2ffmUbrQZQBddeFhGQpqzEJ6ba6t
cu1sjQKU7d56MqgyJQ8Kqv09jDQAv6cct1wwjGUm/XqzssPR52diL027wCP9zqAC
Qm7HuDq6y2KiOwV23HB6Csx/DTvzco/q2RGFFod48Ey0SUPk6BTMl0EtipTj92w2
w5sUfZNCMzv/Fq9wEnU/UV6qE0n3HEsfbdqwyWJTkkb/orYoeqz6LXV53B2Z0BgD
VrHskWfBU45Hvxz5BWLMeDtGjgiRNF/WcfL6Ix4Aa5NC
-----END CERTIFICATE-----";
#[test]
fn debug_redacts_the_bmc_password() {
let rf = RedfishBoot::new().with_password("hunter2-super-secret");
let dbg = format!("{rf:?}");
assert!(!dbg.contains("hunter2-super-secret"), "Debug leaked the BMC password: {dbg}");
assert!(dbg.contains("<redacted>"), "Debug should mark the set password redacted: {dbg}");
assert!(format!("{:?}", RedfishBoot::new()).contains("<unset>"));
}
#[test]
fn debug_does_not_dump_the_pinned_cert_bytes() {
let rf = RedfishBoot::new().pin_cert_pem(TEST_CERT_PEM.as_bytes().to_vec());
let dbg = format!("{rf:?}");
assert!(dbg.contains("<pinned>"), "Debug should mark the pinned cert present: {dbg}");
assert!(!dbg.contains("BEGIN CERTIFICATE"), "Debug dumped raw pinned-cert bytes: {dbg}");
}
#[test]
fn pinned_cert_builds_an_agent() {
let rf = RedfishBoot::new().pin_cert_pem(TEST_CERT_PEM.as_bytes().to_vec());
assert!(rf.agent().is_ok(), "a well-formed pinned PEM should build an agent");
}
#[test]
fn a_malformed_pinned_cert_is_rejected() {
let rf = RedfishBoot::new().pin_cert_pem(b"-----BEGIN CERTIFICATE-----\nnot-a-real-cert\n-----END CERTIFICATE-----".to_vec());
assert!(matches!(rf.agent(), Err(Error::Backend(_))), "a malformed pinned cert must be rejected");
assert!(RedfishBoot::new().pin_cert_pem(Vec::new()).agent().is_err());
}
#[test]
fn pinned_cert_takes_precedence_over_insecure() {
assert!(RedfishBoot::new().insecure(true).agent().is_ok());
let rf = RedfishBoot::new().insecure(true).pin_cert_pem(b"garbage".to_vec());
assert!(rf.agent().is_err(), "a pinned cert must take precedence over insecure()");
}
#[test]
fn lifecycle_without_a_bound_node_is_an_honest_error() {
let rf = RedfishBoot::new().with_password("x");
let m = Machine {
id: "System.Embedded.1".into(),
spec_name: "n".into(),
backend: crate::Backend::Redfish,
power: PowerState::Unknown,
};
assert!(matches!(rf.power_on(&m), Err(Error::Backend(_))));
}
}