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>
367 lines
17 KiB
Python
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()
|