Files
rollcall/lab/pve-vbmc/pve_redfish.py
T
Ilker ManapandClaude Opus 5.5 9ab081aa5a
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s
BMC certificates pinned on first use (Redfish, Proxmox)
The Redfish and Proxmox clients skipped TLS verification (BMCs ship
self-signed certificates), so a host posing as a BMC on the network got
the Basic-auth password. Before any credentials go to an HTTPS BMC the
region now reads its certificate with a handshake-only operation
(tls-fingerprint, run on the rack controller) and pins the SHA-256 on
the BMC record (extra tls_fingerprint); every later connection must show
the same certificate or nothing is sent. Changing the address, clearing
tls_fingerprint, or a BMC firmware update run by Rollcall (new firmware
often means a new certificate) pins anew. verify_tls=true still uses the
system CAs instead. The lab emulator keeps its certificate like a BMC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:32:38 +01:00

367 lines
17 KiB
Python

#!/usr/bin/env python3
"""pve-redfish: a small Redfish service for one Proxmox VM, for testing BMC drivers.
It answers on the same address as the VM's pve-vbmc (port 443 instead of UDP
623) and shapes itself like a vendor so rollcall's Redfish probe and the
idrac / ilo / generic drivers see what real hardware shows:
idrac Vendor "Dell", system /redfish/v1/Systems/System.Embedded.1, PowerCycle
ilo Vendor "HPE", system /redfish/v1/Systems/1, no PowerCycle, PushPowerButton
generic no vendor, system /redfish/v1/Systems/1, AllowableValues via @Redfish.ActionInfo
Config: the pve-vbmc config.json; a bmcs entry may carry "redfish": "idrac"|"ilo"|"generic".
Basic auth with the same user/password as the IPMI side. Self-signed TLS.
Firmware (UpdateService), for testing rollcall's firmware jobs: idrac takes
multipart uploads and SimpleUpdate, ilo only SimpleUpdate (it downloads the
ImageURI itself), generic multipart. A "package" is any file with a line
"version=X"; a name with bios/rom/flash in it is the BIOS, anything else the
BMC. The BMC version changes when its task completes; the BIOS one at the next
reset of the VM, as on real servers. Versions are kept in
/var/lib/pve-vbmc/fw-<vmid>.json.
"""
import argparse
import base64
import email
import email.policy
import ipaddress
import json
import logging
import os
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import requests
import urllib3
log = logging.getLogger("pve-redfish")
PROFILES = {
"idrac": {"vendor": "Dell", "manufacturer": "Dell Inc.", "model": "PowerEdge R760 (pve)", "id": "System.Embedded.1",
"resets": ["On", "ForceOff", "ForceRestart", "GracefulRestart", "GracefulShutdown", "PushPowerButton", "Nmi", "PowerCycle"],
"actioninfo": False, "etag": True, "fw": {"multipart": "/redfish/v1/UpdateService/MultipartUpload", "simple": True,
"ids": {"bmc": "Installed-25227", "bios": "Installed-159"},
"names": {"bmc": "Integrated Dell Remote Access Controller", "bios": "BIOS"}}},
"ilo": {"vendor": "HPE", "manufacturer": "HPE", "model": "ProLiant DL380 Gen11 (pve)", "id": "1",
"resets": ["On", "ForceOff", "ForceRestart", "Nmi", "PushPowerButton"], "actioninfo": False, "etag": False,
"fw": {"multipart": "", "simple": True, "ids": {"bmc": "1", "bios": "2"}, "names": {"bmc": "iLO 6", "bios": "System ROM"}}},
"generic": {"vendor": "", "manufacturer": "QEMU", "model": "Standard PC (pve)", "id": "1",
"resets": ["On", "ForceOff", "GracefulShutdown", "ForceRestart"], "actioninfo": True, "etag": False,
"fw": {"multipart": "/redfish/v1/UpdateService/update-multipart", "simple": False, "ids": {"bmc": "bmc", "bios": "bios"},
"names": {"bmc": "BMC firmware", "bios": "BIOS"}}},
}
class Pve:
def __init__(self, cfg, vmid):
self.api, self.node, self.vmid = cfg["api"].rstrip("/"), cfg["node"], int(vmid)
self.http = requests.Session()
self.http.headers["Authorization"] = "PVEAPIToken=" + cfg["token"]
self.http.verify = cfg.get("verify_tls", False)
if not self.http.verify:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def _url(self, path):
return f"{self.api}/api2/json/nodes/{self.node}/qemu/{self.vmid}/{path}"
def get(self, path):
r = self.http.get(self._url(path), timeout=10)
r.raise_for_status()
return r.json()["data"]
def post(self, path, **data):
r = self.http.post(self._url(path), data=data, timeout=30)
r.raise_for_status()
return r.json()["data"]
def put(self, path, **data):
r = self.http.put(self._url(path), data=data, timeout=30)
r.raise_for_status()
def running(self):
return self.get("status/current")["status"] == "running"
def boot(self, target):
cfg = self.get("config")
disks = [k for k in ("scsi0", "virtio0", "sata0", "ide0") if k in cfg]
cds = sorted(k for k, v in cfg.items() if "media=cdrom" in str(v))
nets = sorted(k for k in cfg if k.startswith("net") and k[3:].isdigit())
order = {"Pxe": nets + disks + cds, "Cd": cds + disks + nets}.get(target, disks + cds + nets)
self.put("config", boot="order=" + ";".join(order))
return order
class Firmware:
"""Installed versions, staged BIOS and update tasks of one emulated BMC."""
def __init__(self, vmid, prof):
self.path = f"/var/lib/pve-vbmc/fw-{vmid}.json"
self.prof = prof["fw"]
self.lock = threading.Lock()
self.tasks = {}
try:
self.v = json.load(open(self.path))
except (OSError, ValueError):
self.v = {"bmc": "1.0.0", "bios": "1.0.0", "staged_bios": ""}
def save(self):
try:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
json.dump(self.v, open(self.path, "w"))
except OSError as e: # the versions still hold in memory
log.warning("cannot keep firmware state in %s: %s", self.path, e)
def inventory(self):
return [{"@odata.id": f"/redfish/v1/UpdateService/FirmwareInventory/{self.prof['ids'][c]}-{self.v[c]}", "Id": f"{self.prof['ids'][c]}-{self.v[c]}",
"Name": self.prof["names"][c], "Version": self.v[c], "Updateable": True} for c in ("bmc", "bios")]
def start(self, name, data):
"""Begin flashing; returns the task id."""
comp = "bios" if any(w in name.lower() for w in ("bios", "rom", "flash")) else "bmc"
ver = ""
for line in data.decode(errors="replace").splitlines():
if line.startswith("version="):
ver = line.split("=", 1)[1].strip()
with self.lock:
tid = str(len(self.tasks) + 1)
self.tasks[tid] = {"state": "Running", "pct": 0, "msg": f"flashing {comp} {ver}"}
threading.Thread(target=self.flash, args=(tid, comp, ver), daemon=True).start()
log.info("firmware task %s: %s -> %s (%s)", tid, comp, ver or "?", name)
return tid
def flash(self, tid, comp, ver):
for pct in (10, 30, 60, 90):
time.sleep(4)
self.tasks[tid]["pct"] = pct
t = self.tasks[tid]
if not ver:
t.update(state="Exception", msg="the image is not valid for this system")
return
with self.lock:
if comp == "bmc":
self.v["bmc"] = ver
t.update(state="Completed", pct=100, msg=f"BMC firmware {ver} installed")
else:
self.v["staged_bios"] = ver
t.update(state="Completed", pct=100, msg=f"BIOS {ver} staged; it applies at the next reboot")
self.save()
def reset(self):
with self.lock:
if self.v.get("staged_bios"):
log.info("reset: BIOS %s applied", self.v["staged_bios"])
self.v["bios"], self.v["staged_bios"] = self.v["staged_bios"], ""
self.save()
def make_handler(pve, prof, user, password, fw):
sysid = prof["id"]
syspath = f"/redfish/v1/Systems/{sysid}"
state = {"override": "None", "enabled": "Disabled", "etag": 1}
want = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
class H(BaseHTTPRequestHandler):
server_version = "pve-redfish"
def log_message(self, fmt, *args):
log.info("%s %s", self.address_string(), fmt % args)
def send(self, code, body=None, headers=None):
data = json.dumps(body).encode() if body is not None else b""
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("OData-Version", "4.0")
for k, v in (headers or {}).items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def error(self, code, msg):
self.send(code, {"error": {"code": "Base.1.0.GeneralError", "message": msg,
"@Message.ExtendedInfo": [{"Message": msg}]}})
def authed(self):
if self.path.rstrip("/") == "/redfish/v1" or self.path.rstrip("/") == "/redfish":
return True
if self.headers.get("Authorization") != want:
self.error(401, "authentication required")
return False
return True
def body(self):
n = int(self.headers.get("Content-Length") or 0)
return json.loads(self.rfile.read(n) or b"{}")
def system(self):
reset = {"target": syspath + "/Actions/ComputerSystem.Reset"}
if prof["actioninfo"]:
reset["@Redfish.ActionInfo"] = syspath + "/ResetActionInfo"
else:
reset["ResetType@Redfish.AllowableValues"] = prof["resets"]
return {"@odata.id": syspath, "Id": sysid, "Name": "System", "Manufacturer": prof["manufacturer"], "Model": prof["model"],
"PowerState": "On" if pve.running() else "Off",
"Boot": {"BootSourceOverrideTarget": state["override"], "BootSourceOverrideEnabled": state["enabled"],
"BootSourceOverrideTarget@Redfish.AllowableValues": ["None", "Pxe", "Hdd", "Cd", "BiosSetup"]},
"Actions": {"#ComputerSystem.Reset": reset}}
def do_GET(self):
if not self.authed():
return
p = self.path.split("?")[0].rstrip("/")
if p in ("/redfish/v1", "/redfish"):
root = {"@odata.id": "/redfish/v1/", "RedfishVersion": "1.15.0", "Systems": {"@odata.id": "/redfish/v1/Systems"}}
if prof["vendor"]:
root["Vendor"] = prof["vendor"]
root["Oem"] = {prof["vendor"]: {}}
return self.send(200, root)
if p == "/redfish/v1/Systems":
return self.send(200, {"Members": [{"@odata.id": syspath}], "Members@odata.count": 1})
if p == syspath:
hdr = {"ETag": f'W/"{state["etag"]}"'} if prof["etag"] else None
return self.send(200, self.system(), hdr)
if p == syspath + "/ResetActionInfo":
return self.send(200, {"Parameters": [{"Name": "ResetType", "Required": True, "AllowableValues": prof["resets"]}]})
if p == "/redfish/v1/UpdateService":
us = {"@odata.id": p, "ServiceEnabled": True, "FirmwareInventory": {"@odata.id": p + "/FirmwareInventory"}}
if fw.prof["multipart"]:
us["MultipartHttpPushUri"] = fw.prof["multipart"]
if fw.prof["simple"]:
us["Actions"] = {"#UpdateService.SimpleUpdate": {"target": p + "/Actions/UpdateService.SimpleUpdate",
"TransferProtocol@Redfish.AllowableValues": ["HTTP", "HTTPS"]}}
return self.send(200, us)
if p == "/redfish/v1/UpdateService/FirmwareInventory":
return self.send(200, {"Members": [{"@odata.id": e["@odata.id"]} for e in fw.inventory()]})
if p.startswith("/redfish/v1/UpdateService/FirmwareInventory/"):
for e in fw.inventory():
if e["@odata.id"] == p:
return self.send(200, e)
if p.startswith("/redfish/v1/TaskService/Tasks/"):
t = fw.tasks.get(p.rsplit("/", 1)[1])
if t:
return self.send(200, {"@odata.id": p, "TaskState": t["state"], "PercentComplete": t["pct"], "Messages": [{"Message": t["msg"]}]})
self.error(404, "not found")
def do_PATCH(self):
if not self.authed():
return
if self.path.rstrip("/") != syspath:
return self.error(404, "not found")
if prof["etag"] and self.headers.get("If-Match") not in (f'W/"{state["etag"]}"', "*"):
return self.error(412, "If-Match does not match the current ETag")
boot = self.body().get("Boot", {})
if "BootSourceOverrideEnabled" in boot:
state["enabled"] = boot["BootSourceOverrideEnabled"]
if "BootSourceOverrideTarget" in boot:
state["override"] = boot["BootSourceOverrideTarget"]
if state["enabled"] != "Disabled":
order = pve.boot(state["override"])
log.info("boot override %s -> order %s", state["override"], order)
state["etag"] += 1
self.send(204)
def task(self, tid):
loc = f"/redfish/v1/TaskService/Tasks/{tid}"
self.send(202, {"@odata.id": loc, "@odata.type": "#Task.v1_4_0.Task", "TaskState": "Running"}, {"Location": loc})
def do_POST(self):
if not self.authed():
return
p = self.path.split("?")[0].rstrip("/")
if fw.prof["multipart"] and p == fw.prof["multipart"]:
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
return self.error(411, "Content-Length required")
raw = self.rfile.read(n)
msg = email.message_from_bytes(b"Content-Type: " + self.headers["Content-Type"].encode() + b"\r\n\r\n" + raw, policy=email.policy.default)
for part in msg.iter_parts():
if part.get_param("name", header="content-disposition") == "UpdateFile":
return self.task(fw.start(part.get_filename() or "image", part.get_payload(decode=True)))
return self.error(400, "no UpdateFile part")
if fw.prof["simple"] and p == "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate":
uri = self.body().get("ImageURI", "")
try:
r = requests.get(uri, timeout=60)
r.raise_for_status()
except requests.RequestException as e:
return self.error(400, f"cannot download {uri}: {e}")
return self.task(fw.start(uri.rsplit("/", 1)[-1], r.content))
if p != syspath + "/Actions/ComputerSystem.Reset":
return self.error(404, "not found")
t = self.body().get("ResetType", "")
if t not in prof["resets"]:
return self.error(400, f"ResetType {t} is not supported")
on = pve.running()
log.info("reset %s (was %s)", t, "on" if on else "off")
if t == "On" or (t == "PushPowerButton" and not on):
if not on:
pve.post("status/start")
elif t == "ForceOff":
if on:
pve.post("status/stop")
elif t in ("GracefulShutdown",) or (t == "PushPowerButton" and on):
pve.post("status/shutdown", forceStop=1, timeout=60)
elif t in ("ForceRestart", "GracefulRestart", "PowerCycle"):
pve.post("status/reset" if on else "status/start")
if t in ("On", "ForceRestart", "GracefulRestart", "PowerCycle", "PushPowerButton"):
fw.reset() # a staged BIOS applies when the host starts again
self.send(204)
return H
def self_signed(addr, vmid):
"""A self-signed certificate, kept across restarts like a real BMC's (rm the files for a new one)."""
d = "/var/lib/pve-vbmc"
crt, key = os.path.join(d, f"tls-{vmid}.crt"), os.path.join(d, f"tls-{vmid}.key")
if os.path.exists(crt) and os.path.exists(key):
return crt, key
if not os.access(d, os.W_OK):
d = tempfile.mkdtemp()
crt, key = os.path.join(d, "c.pem"), os.path.join(d, "k.pem")
subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", "-days", "3650",
"-subj", "/CN=pve-redfish", "-addext", f"subjectAltName=IP:{addr}", "-keyout", key, "-out", crt],
check=True, capture_output=True)
return crt, key
class Server6(ThreadingHTTPServer):
address_family = socket.AF_INET6
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="/etc/pve-vbmc/config.json")
ap.add_argument("--vmid", type=int, required=True)
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
cfg = json.load(open(args.config))
entry = next((b for b in cfg["bmcs"] if int(b["vmid"]) == args.vmid), None)
if entry is None:
log.error("vmid %s not in config", args.vmid)
sys.exit(2)
prof = PROFILES[entry.get("redfish", "generic")]
addr = entry["address"]
user = entry.get("user", cfg.get("user", "admin"))
password = entry.get("password", cfg.get("password", "password"))
handler = make_handler(Pve(cfg, args.vmid), prof, user, password, Firmware(args.vmid, prof))
ip = ipaddress.ip_address(addr)
cls = Server6 if ip.version == 6 else ThreadingHTTPServer
srv = cls((addr, int(entry.get("redfish_port", 443))), handler)
crt, key = self_signed(addr, args.vmid)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(crt, key)
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
log.info("Redfish (%s profile) for VM %s on https://%s:%s", entry.get("redfish", "generic"), args.vmid, addr, entry.get("redfish_port", 443))
srv.serve_forever()
if __name__ == "__main__":
main()