use crate::{
BmcEndpoint, Boot, BootSpec, BootTarget, Error, 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.medium_path() {
Some(iso) => Ok((bmc, iso)),
None => Err(Error::Spec(format!(
"Redfish backend boots an ISO medium, got {:?} with no medium",
spec.image
))),
}
}
#[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)?;
let target = spec.boot_order.redfish_target().unwrap_or(BootTarget::Cd);
self.set_boot_override(bmc, target)?;
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(),
)
}
pub mod wire;
#[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!("{}{}", base(bmc), wire::system_path(&bmc.system_id))
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn virtual_media_action_url(bmc: &BmcEndpoint, slot: &str, action: &str) -> String {
format!(
"{}{}",
base(bmc),
wire::virtual_media_action_path(&bmc.system_id, slot, action)
)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_url(bmc: &BmcEndpoint) -> String {
format!("{}{}", base(bmc), wire::reset_path(&bmc.system_id))
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn insert_media_body(iso: &str) -> serde_json::Value {
wire::insert_media_body(iso)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn boot_override_body(target: BootTarget) -> serde_json::Value {
wire::boot_override_body(target)
}
#[cfg(feature = "backend-redfish")]
pub(crate) fn reset_body(reset_type: &str) -> serde_json::Value {
wire::reset_body(reset_type)
}
#[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 {
wire::base64_encode(input)
}
#[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!(wire::target_str(BootTarget::Cd), "Cd");
assert_eq!(wire::target_str(BootTarget::Pxe), "Pxe");
assert_eq!(wire::target_str(BootTarget::Hdd), "Hdd");
assert_eq!(wire::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(_))));
}
#[cfg(feature = "backend-redfish")]
#[test]
fn a_fleet_of_three_shapes_the_same_burn_against_three_distinct_bmcs() {
use crate::{plan_fleet, BootSpec};
let iso_url = "https://depot.site7.example/holger-installer.iso";
let spec = BootSpec::redfish_iso("site7-holger", iso_url, bmc());
let mut members = plan_fleet(&spec, 3);
assert_eq!(members.len(), 3);
for (i, m) in members.iter_mut().enumerate() {
m.bmc = Some(BmcEndpoint {
host: format!("https://bmc-{}.dc.example", i + 1),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
});
}
let rf = RedfishBoot::new().with_password("per-site-secret");
let mut seen_hosts = std::collections::BTreeSet::new();
for (i, member) in members.iter().enumerate() {
assert_eq!(member.name, format!("site7-holger-{}", i + 1));
let (bmc, iso) = rf.target(member).expect("a bound member resolves");
assert_eq!(iso, iso_url, "every machine burns the SAME per-site ISO");
assert!(seen_hosts.insert(bmc.host.clone()), "each member hits its OWN BMC");
let insert = virtual_media_action_url(bmc, DEFAULT_MEDIA_ID, "InsertMedia");
assert_eq!(
insert,
format!(
"https://bmc-{}.dc.example/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia",
i + 1
)
);
let body = insert_media_body(iso);
assert_eq!(body["Image"], iso_url);
assert_eq!(body["WriteProtected"], true, "the install medium is read-only");
let ovr = boot_override_body(BootTarget::Cd);
assert_eq!(ovr["Boot"]["BootSourceOverrideEnabled"], "Once");
assert_eq!(ovr["Boot"]["BootSourceOverrideTarget"], "Cd");
assert_eq!(reset_body("On")["ResetType"], "On");
}
assert_eq!(seen_hosts.len(), 3, "three machines, three BMCs");
}
#[cfg(feature = "backend-redfish")]
#[test]
fn a_member_without_its_own_bmc_refuses_instead_of_borrowing_one() {
use crate::{plan_fleet, BootSpec};
let mut spec = BootSpec::redfish_iso("site7-holger", "https://depot/holger.iso", bmc());
spec.bmc = None; let members = plan_fleet(&spec, 2);
let rf = RedfishBoot::new().with_password("x");
for m in &members {
let err = rf.target(m).unwrap_err();
assert!(
err.to_string().contains("BMC"),
"the refusal names the missing binding: {err}"
);
}
}
}