From 59a6b618600ee216b57e78bcb5cdb5c7704228f8 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Sun, 25 Oct 2026 13:50:00 -0600 Subject: [PATCH 1/3] feat: disply vendor as fyra stack and product as vm tier in guest SMBIOS --- apps/dashboard/src/lib/remote/vms.remote.ts | 7 +- .../src/lib/server/backends/proxmox/index.ts | 87 +++++++++++++++++++ .../src/lib/server/backends/proxmox/types.ts | 2 + .../src/lib/server/backends/types.ts | 4 + .../src/lib/server/vm-provisioning.ts | 1 + 5 files changed, 100 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/lib/remote/vms.remote.ts b/apps/dashboard/src/lib/remote/vms.remote.ts index ac06d694..72337223 100644 --- a/apps/dashboard/src/lib/remote/vms.remote.ts +++ b/apps/dashboard/src/lib/remote/vms.remote.ts @@ -812,7 +812,12 @@ export const resizeVm = command(resizeParams, async (params) => { try { await backend.resizeVm( row.id, - { cores: target.cores, memoryMb: target.ramCapacity, diskGb: target.storageAmount }, + { + cores: target.cores, + memoryMb: target.ramCapacity, + diskGb: target.storageAmount, + tierName: target.name + }, row.proxmoxId ?? undefined ); } catch (err) { diff --git a/apps/dashboard/src/lib/server/backends/proxmox/index.ts b/apps/dashboard/src/lib/server/backends/proxmox/index.ts index ccbfd45a..fb669e4c 100644 --- a/apps/dashboard/src/lib/server/backends/proxmox/index.ts +++ b/apps/dashboard/src/lib/server/backends/proxmox/index.ts @@ -117,6 +117,87 @@ interface CloudInitVendorConfigParams { enableSshPasswordAuth?: boolean; } +// SMBIOS type 1 (System Information) shown to the guest via dmidecode, +// /sys/class/dmi/id/*, and `hostnamectl` (Hardware Vendor / Hardware Model). +// https://pve.proxmox.com/pve-docs/qm.1.html (smbios1) +// Product is the VM's tier (vm_types.name), so it's rewritten on resize. +const SMBIOS_BRAND = 'Fyra Stack'; + +interface Smbios1 { + fields: Record; + uuid?: string; +} + +function smbios1Branding(stableId: string, tierName: string): Record { + return { + manufacturer: SMBIOS_BRAND, + product: tierName, + family: SMBIOS_BRAND, + serial: stableId.toLowerCase() + }; +} + +function encodeBase64Utf8(value: string) { + return btoa(String.fromCharCode(...new TextEncoder().encode(value))); +} + +function decodeBase64Utf8(value: string) { + return new TextDecoder().decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0))); +} + +function parseSmbios1(raw: string | undefined): Smbios1 { + const entries = (raw ?? '') + .split(',') + .filter(Boolean) + .map((part) => { + const eq = part.indexOf('='); + return eq === -1 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)]; + }); + const base64 = entries.some(([key, value]) => key === 'base64' && value === '1'); + const result: Smbios1 = { fields: {} }; + for (const [key, value] of entries) { + if (key === 'base64') { + continue; + } + if (key === 'uuid') { + result.uuid = value; + } else { + result.fields[key] = base64 ? decodeBase64Utf8(value) : value; + } + } + return result; +} + +function formatSmbios1({ fields, uuid }: Smbios1) { + const encoded = Object.entries(fields).map(([key, value]) => `${key}=${encodeBase64Utf8(value)}`); + return ['base64=1', ...encoded, ...(uuid ? [`uuid=${uuid}`] : [])].join(','); +} + +function smbios1Config(stableId: string, tierName: string) { + // PVE only auto-generates the SMBIOS UUID when smbios1 is omitted, so supply one ourselves. + return formatSmbios1({ + fields: smbios1Branding(stableId, tierName), + uuid: crypto.randomUUID() + }); +} + +/** Returns the updated smbios1 string, or null if the branding already matches. */ +function rebrandSmbios1(raw: string | undefined, stableId: string, tierName: string) { + const current = parseSmbios1(raw); + const branding = smbios1Branding(stableId, tierName); + if ( + current.uuid && + Object.entries(branding).every(([key, value]) => current.fields[key] === value) + ) { + return null; + } + return formatSmbios1({ + fields: { ...current.fields, ...branding }, + // Keep the existing UUID: guests (and licensing/activation) key off it. + uuid: current.uuid ?? crypto.randomUUID() + }); +} + function generateMacAddress() { const bytes = crypto.getRandomValues(new Uint8Array(6)); bytes[0] = (bytes[0] & 0xfe) | 0x02; @@ -773,6 +854,7 @@ export class ProxmoxBackend implements VmBackend { machine: 'q35', efidisk0: `${pvePool}:0,efitype=4m,pre-enrolled-keys=${(params.secureBoot ?? true) ? 1 : 0}`, tpmstate0: `${pvePool}:0,version=v2.0`, + smbios1: smbios1Config(params.id, params.tierName), scsihw: 'virtio-scsi-single', ...(params.imageSource ? {} @@ -1081,6 +1163,11 @@ export class ProxmoxBackend implements VmBackend { if (params.memoryMb !== qemuConfig.memory) { updates.memory = params.memoryMb; } + // SMBIOS changes are applied on the next cold boot (stop/start), not a guest reboot. + const smbios1 = rebrandSmbios1(qemuConfig.smbios1, id, params.tierName); + if (smbios1) { + updates.smbios1 = smbios1; + } if (Object.keys(updates).length > 0) { await this.client.updateQemuConfig(node, vmid, updates); } diff --git a/apps/dashboard/src/lib/server/backends/proxmox/types.ts b/apps/dashboard/src/lib/server/backends/proxmox/types.ts index 32928fbb..ee826a0f 100644 --- a/apps/dashboard/src/lib/server/backends/proxmox/types.ts +++ b/apps/dashboard/src/lib/server/backends/proxmox/types.ts @@ -43,6 +43,7 @@ export interface PveQemuConfig { ostype?: string; scsi0?: string; scsihw?: string; + smbios1?: string; sockets?: number; sshkeys?: string; virtio0?: string; @@ -136,6 +137,7 @@ export interface PveCreateQemuParams { scsi0?: string; scsihw?: string; serial0?: string; + smbios1?: string; sockets?: number; sshkeys?: string; start?: 0 | 1; diff --git a/apps/dashboard/src/lib/server/backends/types.ts b/apps/dashboard/src/lib/server/backends/types.ts index 252903fa..c05560d2 100644 --- a/apps/dashboard/src/lib/server/backends/types.ts +++ b/apps/dashboard/src/lib/server/backends/types.ts @@ -86,6 +86,8 @@ export interface VmCreateParams { registerBackground?: (work: Promise, label?: string) => void; secureBoot?: boolean; sshKeys?: string[]; + /** VM type name, exposed to the guest as the SMBIOS product. */ + tierName: string; userId: string; } @@ -106,6 +108,8 @@ export interface VmResizeParams { cores: number; diskGb: number; memoryMb: number; + /** VM type name, exposed to the guest as the SMBIOS product. */ + tierName: string; } export class VmNotFoundError extends Error { diff --git a/apps/dashboard/src/lib/server/vm-provisioning.ts b/apps/dashboard/src/lib/server/vm-provisioning.ts index 28963f43..2f5fceff 100644 --- a/apps/dashboard/src/lib/server/vm-provisioning.ts +++ b/apps/dashboard/src/lib/server/vm-provisioning.ts @@ -285,6 +285,7 @@ export async function provisionVm(db: Database, input: ProvisionVmInput) { cores: vmType.cores, memoryMb: vmType.ramCapacity, diskGb: vmType.storageAmount, + tierName: vmType.name, imageId: input.imageId, imageSource: baseImage?.filePath, secureBoot: baseImage?.secureBoot ?? true, From 00a74c008b1026358dbc299dce5d027b4fd84c0d Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Sun, 25 Oct 2026 13:52:28 -0600 Subject: [PATCH 2/3] remove some comments --- apps/dashboard/src/lib/server/backends/proxmox/index.ts | 1 - apps/dashboard/src/lib/server/backends/types.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/apps/dashboard/src/lib/server/backends/proxmox/index.ts b/apps/dashboard/src/lib/server/backends/proxmox/index.ts index fb669e4c..e98ddae4 100644 --- a/apps/dashboard/src/lib/server/backends/proxmox/index.ts +++ b/apps/dashboard/src/lib/server/backends/proxmox/index.ts @@ -1163,7 +1163,6 @@ export class ProxmoxBackend implements VmBackend { if (params.memoryMb !== qemuConfig.memory) { updates.memory = params.memoryMb; } - // SMBIOS changes are applied on the next cold boot (stop/start), not a guest reboot. const smbios1 = rebrandSmbios1(qemuConfig.smbios1, id, params.tierName); if (smbios1) { updates.smbios1 = smbios1; diff --git a/apps/dashboard/src/lib/server/backends/types.ts b/apps/dashboard/src/lib/server/backends/types.ts index c05560d2..d21d1359 100644 --- a/apps/dashboard/src/lib/server/backends/types.ts +++ b/apps/dashboard/src/lib/server/backends/types.ts @@ -86,7 +86,6 @@ export interface VmCreateParams { registerBackground?: (work: Promise, label?: string) => void; secureBoot?: boolean; sshKeys?: string[]; - /** VM type name, exposed to the guest as the SMBIOS product. */ tierName: string; userId: string; } @@ -108,7 +107,6 @@ export interface VmResizeParams { cores: number; diskGb: number; memoryMb: number; - /** VM type name, exposed to the guest as the SMBIOS product. */ tierName: string; } From 7744097e943f2bfa00c6c35ceb793e9ab62f1096 Mon Sep 17 00:00:00 2001 From: Willow C Reed Date: Tue, 27 Oct 2026 17:20:49 -0600 Subject: [PATCH 3/3] add smbios backfill script --- scripts/backfill-smbios.py | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 scripts/backfill-smbios.py diff --git a/scripts/backfill-smbios.py b/scripts/backfill-smbios.py new file mode 100644 index 00000000..8633f2fb --- /dev/null +++ b/scripts/backfill-smbios.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Backfill Fyra Stack SMBIOS type 1 branding onto existing VMs. + +Run as root on any PVE cluster node. Dry-run by default; pass --apply to write. + +CSV columns (header optional): stable_id,proxmox_id,tier + 01JABCDEFGHJKMNPQRSTVWXYZ0,1234,s1.small + +Mirrors smbios1Branding()/rebrandSmbios1() in +apps/dashboard/src/lib/server/backends/proxmox/index.ts: sets manufacturer/family +to "Fyra Stack", product to the tier, serial to the stable id, and keeps the +existing SMBIOS UUID (plus any other fields). Takes effect on next cold boot. +""" + +import argparse +import base64 +import csv +import json +import subprocess +import sys +import uuid + +BRAND = "Fyra Stack" +STABLE_ID_TAG_PREFIX = "vmid-" + + +def pvesh(*args: str): + out = subprocess.run( + ["pvesh", *args, "--output-format", "json"], + check=True, + capture_output=True, + text=True, + ).stdout + return json.loads(out) if out.strip() else None + + +def parse_smbios1(raw: str | None) -> tuple[dict[str, str], str | None]: + entries = [] + for part in filter(None, (raw or "").split(",")): + key, _, value = part.partition("=") + entries.append((key, value)) + is_b64 = ("base64", "1") in entries + fields: dict[str, str] = {} + vm_uuid = None + for key, value in entries: + if key == "base64": + continue + if key == "uuid": + vm_uuid = value + else: + fields[key] = base64.b64decode(value).decode() if is_b64 else value + return fields, vm_uuid + + +def format_smbios1(fields: dict[str, str], vm_uuid: str) -> str: + encoded = [ + f"{k}={base64.b64encode(v.encode()).decode()}" for k, v in fields.items() + ] + return ",".join(["base64=1", *encoded, f"uuid={vm_uuid}"]) + + +def read_rows(path: str): + with open(path, newline="") as f: + for lineno, row in enumerate(csv.reader(f), 1): + if not row or row[0].startswith("#"): + continue + if len(row) != 3: + sys.exit(f"{path}:{lineno}: expected 3 columns, got {len(row)}") + stable_id, proxmox_id, tier = (c.strip() for c in row) + if lineno == 1 and not proxmox_id.isdigit(): + continue # header + if not proxmox_id.isdigit(): + sys.exit(f"{path}:{lineno}: proxmox_id {proxmox_id!r} is not a number") + yield lineno, stable_id.lower(), int(proxmox_id), tier + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument("csv", help="path to CSV (stable_id,proxmox_id,tier)") + ap.add_argument( + "--apply", action="store_true", help="actually write changes (default: dry run)" + ) + ap.add_argument( + "--skip-tag-check", + action="store_true", + help=f"don't require the VM's {STABLE_ID_TAG_PREFIX} tag to match the CSV", + ) + args = ap.parse_args() + + resources = { + r["vmid"]: r + for r in pvesh("get", "/cluster/resources", "--type", "vm") + if r.get("type") == "qemu" + } + + failures = changed = 0 + for lineno, stable_id, vmid, tier in read_rows(args.csv): + label = f"[{lineno}] {vmid} ({stable_id})" + res = resources.get(vmid) + if not res: + print(f"{label}: SKIP, no such qemu VM in cluster", file=sys.stderr) + failures += 1 + continue + + tags = set(filter(None, (res.get("tags") or "").replace(",", ";").split(";"))) + if not args.skip_tag_check and f"{STABLE_ID_TAG_PREFIX}{stable_id}" not in tags: + print( + f"{label}: SKIP, tag {STABLE_ID_TAG_PREFIX}{stable_id} not on VM (tags: {sorted(tags)})", + file=sys.stderr, + ) + failures += 1 + continue + + node = res["node"] + config = pvesh("get", f"/nodes/{node}/qemu/{vmid}/config") + current = config.get("smbios1") + fields, vm_uuid = parse_smbios1(current) + branding = { + "manufacturer": BRAND, + "product": tier, + "family": BRAND, + "serial": stable_id, + } + + if vm_uuid and all(fields.get(k) == v for k, v in branding.items()): + print(f"{label}: ok, already branded as {tier!r}") + continue + + if not vm_uuid: + vm_uuid = str(uuid.uuid4()) + print(f"{label}: note, no existing SMBIOS UUID, generated {vm_uuid}") + new = format_smbios1({**fields, **branding}, vm_uuid) + print(f"{label} on {node}: {fields.get('product')!r} -> {tier!r}") + print(f" old: {current}") + print(f" new: {new}") + if args.apply: + try: + pvesh("set", f"/nodes/{node}/qemu/{vmid}/config", "--smbios1", new) + except subprocess.CalledProcessError as e: + print(f"{label}: FAILED: {e.stderr.strip()}", file=sys.stderr) + failures += 1 + continue + changed += 1 + + verb = "updated" if args.apply else "would update" + print( + f"\n{verb} {changed} VM(s), {failures} skipped/failed. Changes apply on next stop/start." + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())