Rollcall: bare-metal commissioning server, agent, image build, lab and CI
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 1m15s

Go server (API, embedded web UI, CLI) backed by PostgreSQL with AES-GCM
sealed BMC secrets; Debian live commissioning agent and live-build recipe;
Proxmox lab with pve-vbmc virtual BMCs; Gitea workflows for CI, releases
and deployment; project website.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ilkerandClaude Fable 5.1 committed 2026-10-27 01:29:53 +01:00
commit d9ae89b295
33 files changed
+3248

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
# Runs on the containerized act_runner (label: ubuntu-latest).
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: cmd/rollcall/go.mod
cache-dependency-path: cmd/rollcall/go.sum
cache: true
- name: gofmt
working-directory: cmd/rollcall
run: |
out=$(gofmt -l .)
if [ -n "$out" ]; then echo "gofmt needed on:"; echo "$out"; exit 1; fi
- name: go vet
working-directory: cmd/rollcall
run: go vet ./...
- name: go test
working-directory: cmd/rollcall
run: go test -race -timeout 5m ./...
- name: Build (static, linux/amd64 and linux/arm64)
working-directory: cmd/rollcall
run: |
for arch in amd64 arm64; do
CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath -ldflags="-s -w" -o rollcall-linux-$arch .
done
ls -la rollcall-linux-*
- uses: actions/upload-artifact@v3
with:
name: rollcall-linux-amd64
path: cmd/rollcall/rollcall-linux-amd64
agent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python syntax + shell syntax
run: |
python3 -m py_compile agent/rollcall-agent.py lab/pve-vbmc/pve_vbmc.py
for f in agent/build-image.sh deploy/install.sh lab/pve-vbmc/install.sh lab/pve-vbmc/pve-vbmc-addrs.sh; do
bash -n "$f" || sh -n "$f"
done
- name: Config examples are valid JSON
run: |
python3 -c 'import json,sys; [json.load(open(f)) for f in sys.argv[1:]]' deploy/config.example.json lab/pve-vbmc/config.example.json
- name: No secrets committed
run: |
# the agent token must stay a placeholder in the tracked PXE menu
grep -q '@AGENT_TOKEN@' deploy/pxelinux-default
! grep -rEn 'rollcall\.token=[0-9a-f]{16,}' --exclude-dir=.git . || { echo "real agent token committed"; exit 1; }
site:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: HTML sanity
run: |
test -f site/index.html
python3 - <<'EOF'
from html.parser import HTMLParser
class P(HTMLParser):
def __init__(self): super().__init__(); self.depth=0
p=P(); p.feed(open('site/index.html').read()); print("ok")
EOF
+101
View File
@@ -0,0 +1,101 @@
# Continuous deployment to the lab PXE server after CI passes on main.
#
# Secrets: PXE_SSH_KEY private key for the deploy user on the PXE server
# Variables: PXE_HOST e.g. 10.0.0.234 PXE_USER e.g. ilker (needs passwordless sudo)
# SITE_HOST / SITE_USER / SITE_PATH optional: rsync target for the website
#
# The image rebuild (agent/**) runs live-build on the PXE server itself (~8 min)
# and only when agent files changed, or when dispatched by hand.
name: deploy
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
rebuild_image:
description: 'Rebuild the commissioning image too'
type: boolean
default: false
concurrency:
group: deploy
cancel-in-progress: false
jobs:
server:
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: cmd/rollcall/go.mod
cache-dependency-path: cmd/rollcall/go.sum
- name: Build
working-directory: cmd/rollcall
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o ../../deploy/rollcall .
- name: SSH setup
env:
KEY: ${{ secrets.PXE_SSH_KEY }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf 'Host *\n StrictHostKeyChecking accept-new\n' > ~/.ssh/config
- name: Ship binary + deploy files and restart
env:
HOST: ${{ vars.PXE_HOST }}
USER: ${{ vars.PXE_USER }}
run: |
set -eu
ssh "$USER@$HOST" 'sudo mkdir -p /opt/rollcall/deploy /opt/rollcall/agent && sudo chown -R "$USER" /opt/rollcall'
scp -q deploy/rollcall deploy/install.sh deploy/rollcall.service deploy/pxelinux-default deploy/config.example.json "$USER@$HOST:/opt/rollcall/deploy/"
scp -q agent/rollcall-agent.py agent/rollcall-agent.service agent/build-image.sh "$USER@$HOST:/opt/rollcall/agent/"
ssh "$USER@$HOST" 'cd /opt/rollcall/deploy && chmod +x install.sh rollcall ../agent/*.sh ../agent/*.py && sudo ./install.sh && sudo rollcall list >/dev/null && echo DEPLOY_OK'
image:
needs: server
if: >
(github.event_name == 'workflow_dispatch' && inputs.rebuild_image) ||
(github.event_name == 'workflow_run' && contains(join(github.event.workflow_run.head_commit.message, ' '), '[image]'))
runs-on: ubuntu-latest
steps:
- name: SSH setup
env:
KEY: ${{ secrets.PXE_SSH_KEY }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf 'Host *\n StrictHostKeyChecking accept-new\n ServerAliveInterval 30\n' > ~/.ssh/config
- name: Rebuild the live image on the PXE server
env:
HOST: ${{ vars.PXE_HOST }}
USER: ${{ vars.PXE_USER }}
run: ssh "$USER@$HOST" 'sudo /opt/rollcall/agent/build-image.sh 2>&1 | tail -20'
site:
needs: server
if: vars.SITE_HOST != ''
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: SSH setup
env:
KEY: ${{ secrets.SITE_SSH_KEY || secrets.PXE_SSH_KEY }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf 'Host *\n StrictHostKeyChecking accept-new\n' > ~/.ssh/config
- name: Publish website
env:
HOST: ${{ vars.SITE_HOST }}
USER: ${{ vars.SITE_USER }}
DEST: ${{ vars.SITE_PATH }}
run: |
set -eu
tar -C site -czf - . | ssh "$USER@$HOST" "mkdir -p '$DEST' && tar -C '$DEST' -xzf - && echo SITE_OK"
+43
View File
@@ -0,0 +1,43 @@
# Tag v* -> build static binaries and publish them as a Gitea release.
name: release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: cmd/rollcall/go.mod
cache-dependency-path: cmd/rollcall/go.sum
- name: Build
working-directory: cmd/rollcall
run: |
set -eu
for arch in amd64 arm64; do
CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -trimpath \
-ldflags="-s -w -X main.version=${GITHUB_REF_NAME}" -o rollcall-linux-$arch .
done
sha256sum rollcall-linux-* > SHA256SUMS
- name: Create release and upload assets
env:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
working-directory: cmd/rollcall
run: |
set -eu
body=$(printf '{"tag_name":"%s","name":"%s","draft":false,"prerelease":false}' "$TAG" "$TAG")
id=$(curl -sfS -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$body" "$API/releases" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
for f in rollcall-linux-amd64 rollcall-linux-arm64 SHA256SUMS; do
curl -sfS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$id/assets?name=$f" >/dev/null
echo "uploaded $f"
done
+6
View File
@@ -0,0 +1,6 @@
/cmd/rollcall/rollcall
/deploy/rollcall
/deploy/config.json
*.key
/site/dist/
.DS_Store
+140
View File
@@ -0,0 +1,140 @@
# Rollcall
Rollcall is a small, self-contained commissioning service for bare-metal servers,
in the spirit of MAAS enlistment and commissioning, without the rest of MAAS.
A server that PXE-boots for the first time gets an ephemeral Debian live image.
The image takes a full hardware inventory (DMI, DIMMs, CPUs, disks, NVMe, NICs,
InfiniBand HCAs, GPUs, PCI, LLDP neighbours, TPM), creates an administrator
account on the server's BMC through in-band IPMI, reports everything to the
Rollcall server and powers the machine off. From then on the server is known:
it shows up in the web UI and the CLI, its BMC can be driven through the API,
and it will not boot the commissioning image again unless you ask for it.
Everything server-side is **one static Go binary**: HTTP API, embedded web UI,
CLI, PostgreSQL schema management and the PXE bookkeeping.
## How it works
```
unknown server ──PXE──▶ dnsmasq (DHCP+TFTP) ──▶ pxelinux default menu
│ 8 s timeout
▼
Debian live image over HTTP (kernel+initrd via TFTP,
squashfs via HTTP, runs entirely from RAM)
│
rollcall-agent: inventory + BMC user + POST /api/v1/commission
│
rollcall server: PostgreSQL ◀─┘──▶ writes pxelinux.cfg/01-<mac>
(next boot: "commissioned → power off")
```
* **Enlist**: a machine is identified by its DMI system UUID (fallback: serial,
then boot MAC). First contact creates it in state `new`.
* **Commission**: the inventory upload moves it to `ready`. The BMC password
the agent generated is sealed with AES-256-GCM (key in
`/etc/rollcall/master.key`) before it touches the database. It never leaves
the server again: power and boot-device operations run `ipmitool` server-side.
* **Re-commission**: removes the per-MAC PXE override; optionally sets
bootdev=pxe and power-cycles the machine through its BMC.
## Repository layout
| Path | What |
|---|---|
| `cmd/rollcall/` | Go server + CLI + embedded web UI (`web/`) |
| `agent/` | `rollcall-agent.py` (runs inside the live image), its systemd unit, `build-image.sh` (live-build recipe) |
| `deploy/` | `install.sh`, `rollcall.service`, `config.example.json`, `pxelinux-default` |
| `lab/` | Proxmox test lab: `pve-vbmc` (virtual IPMI BMC for VMs backed by the Proxmox API), cloud-init for the PXE server VM |
| `site/` | Project website (static) |
| `.gitea/workflows/` | CI (fmt/vet/build/tests), release on tags, deploy on push to `main` |
## Server
```sh
# build
cd cmd/rollcall && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o rollcall .
# PostgreSQL role + database
sudo -u postgres psql -c "CREATE ROLE rollcall LOGIN PASSWORD '...';" -c "CREATE DATABASE rollcall OWNER rollcall;"
# config (see deploy/config.example.json), tokens from `rollcall keygen`
sudo install -d -m 0750 /etc/rollcall && sudo install -m 0640 config.json /etc/rollcall/config.json
# install binary, systemd unit, pxelinux menu, tftp symlinks
cd deploy && sudo ./install.sh
```
`config.json` keys: `listen`, `image_dir` (served under `/rollcall/`), `tftp_root`,
`database_url`, `master_key_file`, `admin_token` (web UI + management API),
`agent_token` (what the live image presents), `commission_append` (kernel
command line baked into generated PXE files), `bmc_hints` (out-of-band BMC
mapping for machines without in-band IPMI, e.g. VMs).
### CLI (same binary)
```sh
rollcall list
rollcall show m-2ef1dab828c8 [-json]
rollcall events m-2ef1dab828c8
rollcall power m-2ef1dab828c8 status|on|off|soft|cycle|reset
rollcall bootdev m-2ef1dab828c8 pxe|disk|cdrom|bios|none
rollcall recommission m-2ef1dab828c8 [-now]
rollcall delete m-2ef1dab828c8
```
Reads `ROLLCALL_URL` / `ROLLCALL_TOKEN`, or the config file when readable.
### API
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/api/v1/commission` | agent token | inventory upload from the live image |
| GET | `/api/v1/bmc-hint?macs=` | agent token | out-of-band BMC hint for VMs |
| GET | `/api/v1/machines` | admin | list (summaries) |
| GET | `/api/v1/machines/{id}` | admin | full record: inventory, BMC (no secret), events |
| POST | `/api/v1/machines/{id}/power` | admin | `{"action":"on"}` etc., runs ipmitool server-side |
| POST | `/api/v1/machines/{id}/bootdev` | admin | `{"device":"pxe"}` |
| POST | `/api/v1/machines/{id}/recommission[?now=1]` | admin | boot the image again on next PXE (`now=1`: via BMC immediately) |
| DELETE | `/api/v1/machines/{id}` | admin | forget the machine |
| GET | `/rollcall/*` | none | image files (kernel, initrd, squashfs) |
| GET | `/` | token in browser | web UI |
Admin auth is `Authorization: Bearer <admin_token>`; the agent sends
`X-Rollcall-Token`.
## Commissioning image
`agent/build-image.sh` builds a Debian 13 live image with live-build (about
8 minutes, ~780 MB squashfs). It contains the full Debian kernel with non-free
firmware, `ipmitool`, `freeipmi`, `rdma-core`, `infiniband-diags`, `mstflint`,
`nvme-cli`, `smartmontools`, `lldpd`, `lshw` and an SSH server (root, key only)
for debugging. Output goes to `image_dir`.
Kernel parameters understood by the agent: `rollcall.server=URL` (required),
`rollcall.token=…`, `rollcall.bmcuser=NAME` (default `commissioner`),
`rollcall.nopoweroff` (stay up after reporting).
### BMC account
In-band (`/dev/ipmi0`): find the LAN channel, pick the existing `commissioner`
slot or the first empty user ID ≥ 3, set name/password, ADMINISTRATOR privilege,
channel access, enable, SOL payload. The password is random (16 chars) and is
reported once to the server. If no in-band IPMI exists the agent asks the server
for a hint (`bmc_hints`) and verifies it out-of-band.
Vendor quirks (iDRAC/iLO password policies, missing empty slots) are reported in
`bmc.errors` and visible in the UI.
## Lab
`lab/` documents a Proxmox SDN test setup: an isolated vnet, a PXE server VM
running dnsmasq + Rollcall, two diskless VMs, and `pve-vbmc`, a tiny
IPMI-over-LAN BMC emulator that drives VMs through the Proxmox API so that
`ipmitool power on` and `chassis bootdev pxe` work against virtual machines.
## Status
Working end to end in the lab (virtual machines, out-of-band BMC hints). The
in-band BMC path is implemented but has not yet been exercised on real
hardware.
Binary file not shown.
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# Build the Debian 13 commissioning live image with live-build.
# Run as root on pxe-server. Output: $OUT/{vmlinuz,initrd.img,filesystem.squashfs}
set -euo pipefail
SRC=$(cd "$(dirname "$0")" && pwd)
BUILD=${BUILD:-/srv/rollcall/build}
OUT=${OUT:-/srv/rollcall/image}
MIRROR=${MIRROR:-http://deb.debian.org/debian}
AUTHKEYS=${AUTHKEYS:-/home/ilker/.ssh/authorized_keys}
mkdir -p "$BUILD" "$OUT"
cd "$BUILD"
if [ -d config ]; then lb clean --purge >/dev/null 2>&1 || true; rm -rf config; fi
lb config \
--distribution trixie --architectures amd64 \
--archive-areas "main contrib non-free non-free-firmware" \
--mirror-bootstrap "$MIRROR" --mirror-binary "$MIRROR" \
--binary-images iso-hybrid --bootloaders syslinux \
--debian-installer none --memtest none \
--firmware-chroot true --firmware-binary false \
--apt-recommends false \
--bootappend-live "boot=live components quiet" \
--linux-flavours amd64 \
--iso-volume "rollcall" --iso-application "rollcall commissioning image" \
--cache-packages true --cache-indices false
# Package set: hardware discovery + BMC + IB + firmware + debugging
cat > config/package-lists/rollcall.list.chroot <<'EOF'
linux-image-amd64
live-boot
live-config
live-config-systemd
systemd-sysv
systemd-timesyncd
busybox
kmod
pciutils
usbutils
dmidecode
util-linux
lshw
ethtool
iproute2
isc-dhcp-client
nvme-cli
smartmontools
hdparm
sg3-utils
lsscsi
ipmitool
freeipmi-tools
rdma-core
ibverbs-utils
infiniband-diags
mstflint
lldpd
python3
curl
wget
ca-certificates
openssh-server
openssh-client
less
vim-tiny
procps
pv
jq
mdadm
lvm2
nvme-cli
efibootmgr
firmware-linux
firmware-linux-nonfree
firmware-misc-nonfree
firmware-bnx2
firmware-bnx2x
firmware-qlogic
firmware-realtek
firmware-cavium
firmware-netronome
amd64-microcode
intel-microcode
EOF
# Files baked into the rootfs
mkdir -p config/includes.chroot/usr/local/bin config/includes.chroot/etc/systemd/system \
config/includes.chroot/root/.ssh config/includes.chroot/etc/modules-load.d \
config/hooks/live
install -m 0755 "$SRC/rollcall-agent.py" config/includes.chroot/usr/local/bin/rollcall-agent.py
install -m 0644 "$SRC/rollcall-agent.service" config/includes.chroot/etc/systemd/system/rollcall-agent.service
printf 'ipmi_msghandler\nipmi_devintf\nipmi_si\nipmi_ssif\n' > config/includes.chroot/etc/modules-load.d/ipmi.conf
if [ -r "$AUTHKEYS" ]; then
install -m 0600 "$AUTHKEYS" config/includes.chroot/root/.ssh/authorized_keys
chmod 0700 config/includes.chroot/root/.ssh
fi
cat > config/hooks/live/0100-rollcall.hook.chroot <<'EOF'
#!/bin/sh
set -e
systemctl enable rollcall-agent.service
systemctl enable lldpd.service
systemctl enable ssh.service
# allow root login with key only
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
# lldpd: speak LLDP + CDP so switch ports are discovered quickly
printf 'configure lldp tx-interval 5\n' > /etc/lldpd.d/rollcall.conf
echo rollcall > /etc/hostname
EOF
chmod +x config/hooks/live/0100-rollcall.hook.chroot
echo "=== lb build started $(date)"
lb build 2>&1 | tee "$BUILD/build.log" | grep -E "^P:|^E:|error" || true
for f in vmlinuz initrd.img filesystem.squashfs; do
src=$(ls binary/live/$f* 2>/dev/null | head -1)
[ -n "$src" ] || { echo "missing $f in binary/live" >&2; exit 1; }
install -m 0644 "$src" "$OUT/$f"
done
ls -la "$OUT"
echo "=== done $(date)"
+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""Commissioning agent: runs once inside the ephemeral live image.
Collects a hardware inventory, configures a BMC account via in-band ipmitool
(falls back to a server-provided out-of-band hint for virtual machines),
posts everything to rollcall and powers the machine off.
Kernel cmdline options:
rollcall.server=http://10.213.77.1 (required) rollcall base URL
rollcall.nopoweroff stay up (debugging)
rollcall.bmcuser=commissioner BMC account name to create
rollcall.token=... agent token (sent as X-Rollcall-Token)
"""
import glob
import json
import os
import re
import secrets
import socket
import string
import subprocess
import sys
import time
import urllib.error
import urllib.request
LOG = open("/dev/console", "w", buffering=1) if os.path.exists("/dev/console") else sys.stdout
def log(msg):
line = f"[commission] {msg}"
print(line, file=LOG)
if LOG is not sys.stdout:
print(line, flush=True)
def run(cmd, timeout=120, text=True):
"""Return (rc, stdout). Never raises."""
try:
p = subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True,
text=text, timeout=timeout)
return p.returncode, p.stdout if text else p.stdout
except FileNotFoundError:
return 127, ""
except subprocess.TimeoutExpired:
return 124, ""
def out(cmd, timeout=120):
rc, o = run(cmd, timeout)
return o.strip() if rc == 0 else ""
def jout(cmd, timeout=120):
o = out(cmd, timeout)
try:
return json.loads(o) if o else None
except json.JSONDecodeError:
return None
def readf(path, default=""):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return default
def cmdline():
opts = {}
for tok in readf("/proc/cmdline").split():
if tok.startswith("rollcall."):
k, _, v = tok[len("rollcall."):].partition("=")
opts[k] = v if v else True
return opts
# --------------------------------------------------------------------------- DMI
def dmi_system():
keys = ["system-manufacturer", "system-product-name", "system-version",
"system-serial-number", "system-uuid", "system-sku-number", "system-family",
"baseboard-manufacturer", "baseboard-product-name", "baseboard-serial-number",
"chassis-manufacturer", "chassis-type", "chassis-serial-number", "chassis-asset-tag",
"bios-vendor", "bios-version", "bios-release-date"]
d = {k: out(["dmidecode", "-s", k]) for k in keys}
# fallbacks via sysfs
d["system-uuid"] = d["system-uuid"] or readf("/sys/class/dmi/id/product_uuid")
d["system-serial-number"] = d["system-serial-number"] or readf("/sys/class/dmi/id/product_serial")
return d
def dmi_blocks(dtype):
"""Parse `dmidecode -t N` into a list of dicts (one per handle)."""
txt = out(["dmidecode", "-t", str(dtype)])
blocks, cur = [], None
for line in txt.splitlines():
if line.startswith("Handle "):
cur = {"_handle": line.split(",")[0].split()[1]}
blocks.append(cur)
elif cur is not None and line.startswith("\t") and ":" in line and not line.startswith("\t\t"):
k, _, v = line.strip().partition(":")
cur[k.strip()] = v.strip()
return blocks
def memory():
dimms = []
for b in dmi_blocks(17):
size = b.get("Size", "")
if not size or size.startswith("No Module"):
dimms.append({"locator": b.get("Locator"), "bank": b.get("Bank Locator"), "populated": False})
continue
dimms.append({
"locator": b.get("Locator"), "bank": b.get("Bank Locator"), "populated": True,
"size": size, "type": b.get("Type"), "type_detail": b.get("Type Detail"),
"speed": b.get("Speed"), "configured_speed": b.get("Configured Memory Speed"),
"manufacturer": b.get("Manufacturer"), "part_number": b.get("Part Number"),
"serial": b.get("Serial Number"), "rank": b.get("Rank"), "form_factor": b.get("Form Factor"),
})
total_kb = 0
m = re.search(r"MemTotal:\s+(\d+)", readf("/proc/meminfo"))
if m:
total_kb = int(m.group(1))
return {"total_bytes": total_kb * 1024, "dimm_slots": len(dimms),
"dimms_populated": sum(1 for d in dimms if d["populated"]), "dimms": dimms}
def cpu():
j = jout(["lscpu", "-J"]) or {}
flat = {}
for e in j.get("lscpu", []):
flat[e["field"].rstrip(":")] = e["data"]
for c in e.get("children", []) or []:
flat[c["field"].rstrip(":")] = c["data"]
want = ["Architecture", "CPU(s)", "Thread(s) per core", "Core(s) per socket", "Socket(s)",
"NUMA node(s)", "Vendor ID", "Model name", "CPU family", "Model", "Stepping",
"CPU max MHz", "CPU min MHz", "L1d cache", "L2 cache", "L3 cache", "Virtualization", "Flags"]
res = {k: flat.get(k) for k in want if k in flat}
flags = res.pop("Flags", "") or ""
res["flags_count"] = len(flags.split())
res["features"] = sorted(f for f in flags.split() if f in
("vmx", "svm", "avx", "avx2", "avx512f", "sse4_2", "aes", "sha_ni", "amx_tile"))
return res
# --------------------------------------------------------------------------- storage
def storage():
lsblk = jout(["lsblk", "-J", "-O", "-b", "-d"]) or {}
disks = []
for d in lsblk.get("blockdevices", []):
if d.get("type") not in ("disk", "nvme") and not d.get("name", "").startswith("nvme"):
continue
if d.get("name", "").startswith(("loop", "ram", "sr", "zram")):
continue
disks.append({k: d.get(k) for k in ("name", "size", "model", "serial", "vendor", "rev", "tran",
"rota", "wwn", "hctl", "subsystems", "log-sec", "phy-sec",
"rm", "ro", "path", "pttype", "fstype")})
nvme = jout(["nvme", "list", "-o", "json"]) or {}
smart = {}
for d in disks:
s = jout(["smartctl", "-i", "-H", "-j", d["path"]], timeout=30) or {}
if s:
smart[d["name"]] = {
"model_family": s.get("model_family"), "firmware": s.get("firmware_version"),
"rotation_rate": s.get("rotation_rate"), "form_factor": (s.get("form_factor") or {}).get("name"),
"healthy": (s.get("smart_status") or {}).get("passed"),
"power_on_hours": (s.get("power_on_time") or {}).get("hours"),
"interface_speed": ((s.get("interface_speed") or {}).get("current") or {}).get("string"),
}
ctrls = [p for p in pci_devices() if p["class_id"][:2] == "01"] # mass storage controllers
return {"disks": disks, "disk_count": len(disks),
"total_bytes": sum(int(d["size"] or 0) for d in disks),
"nvme": nvme.get("Devices"), "smart": smart, "controllers": ctrls}
# --------------------------------------------------------------------------- PCI
_PCI_CACHE = None
def pci_devices():
global _PCI_CACHE
if _PCI_CACHE is not None:
return _PCI_CACHE
txt = out(["lspci", "-Dvmmnnk"])
devs, cur = [], {}
for line in txt.splitlines() + [""]:
if not line.strip():
if cur:
devs.append(cur)
cur = {}
continue
k, _, v = line.partition(":")
cur[k.strip()] = v.strip()
res = []
for d in devs:
m_cls = re.search(r"\[([0-9a-f]{4})\]$", d.get("Class", ""))
m_ven = re.search(r"\[([0-9a-f]{4})\]$", d.get("Vendor", ""))
m_dev = re.search(r"\[([0-9a-f]{4})\]$", d.get("Device", ""))
cls = m_cls.group(1) if m_cls else "0000"
kind = {"01": "storage", "02": "network", "03": "gpu", "0c": "serial-bus", "06": "bridge"}.get(cls[:2], "other")
if cls == "0302" or (cls == "1200"):
kind = "accelerator"
res.append({
"slot": d.get("Slot"), "class": re.sub(r"\s*\[[0-9a-f]{4}\]$", "", d.get("Class", "")),
"class_id": cls, "kind": kind,
"vendor": re.sub(r"\s*\[[0-9a-f]{4}\]$", "", d.get("Vendor", "")),
"device": re.sub(r"\s*\[[0-9a-f]{4}\]$", "", d.get("Device", "")),
"vendor_id": m_ven.group(1) if m_ven else None, "device_id": m_dev.group(1) if m_dev else None,
"subsystem": d.get("SDevice"), "driver": d.get("Driver"), "rev": d.get("Rev"),
})
_PCI_CACHE = res
return res
def gpus():
return [p for p in pci_devices() if p["kind"] in ("gpu", "accelerator")]
# --------------------------------------------------------------------------- network
def ethtool_info(ifn):
info = {}
for line in out(["ethtool", "-i", ifn]).splitlines():
k, _, v = line.partition(":")
if k in ("driver", "version", "firmware-version", "bus-info"):
info[k.replace("-", "_")] = v.strip()
et = out(["ethtool", ifn])
m = re.search(r"Speed:\s*(\S+)", et)
info["speed"] = m.group(1) if m else None
m = re.search(r"Duplex:\s*(\S+)", et)
info["duplex"] = m.group(1) if m else None
m = re.search(r"Link detected:\s*(\S+)", et)
info["link"] = (m.group(1) == "yes") if m else None
m = re.search(r"Supported ports:\s*\[([^\]]*)\]", et)
info["ports"] = m.group(1).split() if m else None
sup = re.search(r"Supported link modes:(.*?)Supported pause", et, re.S)
if sup:
info["supported_link_modes"] = sup.group(1).split()
pm = out(["ethtool", "-P", ifn])
m = re.search(r"([0-9a-f]{2}(?::[0-9a-f]{2}){5})", pm)
info["permanent_mac"] = m.group(1) if m else None
return info
def network():
links = jout(["ip", "-j", "-d", "link"]) or []
addrs = {a["ifname"]: a for a in (jout(["ip", "-j", "addr"]) or [])}
nics = []
for l in links:
ifn = l["ifname"]
if ifn == "lo" or l.get("link_type") not in ("ether", "infiniband"):
continue
sysd = f"/sys/class/net/{ifn}"
if not os.path.exists(f"{sysd}/device"):
continue # virtual (bridge, bond, vlan, veth ...)
pci = os.path.basename(os.path.realpath(f"{sysd}/device"))
nic = {
"name": ifn, "mac": l.get("address"), "mtu": l.get("mtu"), "operstate": l.get("operstate"),
"link_type": l.get("link_type"), "pci": pci,
"sriov_totalvfs": readf(f"{sysd}/device/sriov_totalvfs") or None,
"numa_node": readf(f"{sysd}/device/numa_node") or None,
"ipv4": [a["local"] + "/" + str(a["prefixlen"]) for a in addrs.get(ifn, {}).get("addr_info", [])
if a.get("family") == "inet"],
}
if l.get("link_type") == "ether":
nic.update(ethtool_info(ifn))
nics.append(nic)
return nics
def infiniband():
hcas = []
for d in sorted(glob.glob("/sys/class/infiniband/*")):
name = os.path.basename(d)
ports = []
for p in sorted(glob.glob(f"{d}/ports/*")):
ports.append({
"port": int(os.path.basename(p)), "state": readf(f"{p}/state"),
"phys_state": readf(f"{p}/phys_state"), "rate": readf(f"{p}/rate"),
"link_layer": readf(f"{p}/link_layer"), "lid": readf(f"{p}/lid"),
"sm_lid": readf(f"{p}/sm_lid"), "port_guid": readf(f"{p}/gids/0") or None,
})
hcas.append({
"name": name, "node_guid": readf(f"{d}/node_guid"), "sys_image_guid": readf(f"{d}/sys_image_guid"),
"fw_ver": readf(f"{d}/fw_ver"), "hca_type": readf(f"{d}/hca_type"), "board_id": readf(f"{d}/board_id"),
"node_desc": readf(f"{d}/node_desc"),
"pci": os.path.basename(os.path.realpath(f"{d}/device")) if os.path.exists(f"{d}/device") else None,
"ports": ports,
})
return {"hcas": hcas, "ibstat": out(["ibstat"], timeout=30) or None}
def lldp(wait=25):
if run(["which", "lldpcli"])[0] != 0:
return None
deadline = time.time() + wait
neigh = None
while time.time() < deadline:
j = jout(["lldpcli", "-f", "json", "show", "neighbors", "details"], timeout=20)
if j and j.get("lldp") and j["lldp"].get("interface"):
neigh = j["lldp"]["interface"]
break
time.sleep(3)
return neigh
# --------------------------------------------------------------------------- BMC
def gen_password(n=16):
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(n))
def ipmi(args, timeout=30):
return run(["ipmitool"] + args, timeout=timeout)
def parse_kv(txt):
d = {}
for line in txt.splitlines():
if ":" in line:
k, _, v = line.partition(":")
k = k.strip()
if k and k not in d:
d[k] = v.strip()
return d
def bmc_inband(user_name):
"""Discover the BMC in-band, create/refresh user_name with ADMINISTRATOR on the LAN channel."""
for mod in ("ipmi_msghandler", "ipmi_devintf", "ipmi_si", "ipmi_ssif"):
run(["modprobe", mod])
time.sleep(2)
if not os.path.exists("/dev/ipmi0"):
return None
res = {"source": "inband", "errors": []}
rc, mc = ipmi(["mc", "info"])
res["mc_info"] = parse_kv(mc) if rc == 0 else None
rc, fru = ipmi(["fru", "print", "0"], timeout=60)
res["fru"] = parse_kv(fru) if rc == 0 else None
# find LAN channel
chan, lan = None, None
for c in range(1, 15):
rc, o = ipmi(["lan", "print", str(c)])
if rc == 0 and "IP Address" in o:
kv = parse_kv(o)
if kv.get("IP Address") and kv["IP Address"] != "0.0.0.0":
chan, lan = c, kv
break
chan, lan = chan or c, lan or kv
if chan is None:
res["errors"].append("no LAN channel found")
return res
res["channel"] = chan
res["lan"] = {k: lan.get(k) for k in ("IP Address Source", "IP Address", "Subnet Mask", "MAC Address",
"Default Gateway IP", "802.1q VLAN ID", "Cipher Suite Priv Max")}
res["address"] = lan.get("IP Address")
res["mac"] = lan.get("MAC Address")
# user table
rc, ul = ipmi(["user", "list", str(chan)])
users = []
for line in ul.splitlines()[1:]:
m = re.match(r"^\s*(\d+)\s+(.*?)\s+(true|false)\s+(true|false)\s+(true|false)\s+(\S.*)$", line)
if m:
users.append({"id": int(m.group(1)), "name": m.group(2).strip(), "callin": m.group(3) == "true",
"link": m.group(4) == "true", "ipmi_msg": m.group(5) == "true", "priv": m.group(6).strip()})
res["users_before"] = users
uid = next((u["id"] for u in users if u["name"] == user_name), None)
if uid is None:
uid = next((u["id"] for u in users if u["id"] >= 3 and not u["name"]), None)
if uid is None:
res["errors"].append("no free user slot")
return res
pw = gen_password()
steps = [
["user", "set", "name", str(uid), user_name],
["user", "set", "password", str(uid), pw],
["user", "priv", str(uid), "4", str(chan)],
["channel", "setaccess", str(chan), str(uid), "callin=on", "ipmi=on", "link=on", "privilege=4"],
["user", "enable", str(uid)],
]
ok = True
for s in steps:
rc, o = ipmi(s)
if rc != 0:
ok = False
res["errors"].append(f"ipmitool {' '.join(s[:3])} rc={rc}")
# some BMCs (e.g. iDRAC) also want SOL payload access enabled
ipmi(["sol", "payload", "enable", str(chan), str(uid)])
res.update({"user": user_name, "user_id": uid, "password": pw if ok else None, "configured": ok})
if res["address"] and res["address"] != "0.0.0.0":
rc, _ = ipmi(["-I", "lanplus", "-H", res["address"], "-U", user_name, "-P", pw, "mc", "info"], timeout=20)
res["verified_oob"] = (rc == 0)
return res
AGENT_TOKEN = ""
def agent_headers():
h = {"Content-Type": "application/json"}
if AGENT_TOKEN:
h["X-Rollcall-Token"] = AGENT_TOKEN
return h
def bmc_hint(server, macs):
try:
req = urllib.request.Request(f"{server}/api/v1/bmc-hint?macs={','.join(macs)}", headers=agent_headers())
with urllib.request.urlopen(req, timeout=10) as r:
h = json.load(r)
except (urllib.error.URLError, json.JSONDecodeError, OSError):
return None
if not h or not h.get("address"):
return None
res = {"source": "hint", "address": h["address"], "port": h.get("port", 623),
"user": h.get("user"), "password": h.get("password"), "errors": []}
base = ["-I", "lanplus", "-H", h["address"], "-p", str(h.get("port", 623)), "-U", h.get("user", ""),
"-P", h.get("password", "")]
rc, mc = ipmi(base + ["mc", "info"], timeout=20)
res["mc_info"] = parse_kv(mc) if rc == 0 else None
res["verified_oob"] = (rc == 0)
res["configured"] = (rc == 0)
if rc != 0:
res["errors"].append("hinted BMC did not answer")
return res
# --------------------------------------------------------------------------- main
def post(server, payload, tries=30):
data = json.dumps(payload).encode()
req = urllib.request.Request(f"{server}/api/v1/commission", data=data, headers=agent_headers(), method="POST")
for i in range(tries):
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
except (urllib.error.URLError, OSError) as e:
log(f"post failed ({e}), retry {i + 1}/{tries}")
time.sleep(5)
return None
def main():
opts = cmdline()
server = opts.get("server")
if not server:
log("rollcall.server= missing on kernel cmdline; nothing to do")
return 2
server = server.rstrip("/")
global AGENT_TOKEN
AGENT_TOKEN = opts.get("token") if isinstance(opts.get("token"), str) else ""
user_name = opts.get("bmcuser") or "commissioner"
started = time.time()
log(f"starting, server={server}")
inv = {"agent_version": "1", "started_at": started, "hostname": socket.gethostname(),
"kernel": readf("/proc/version"), "errors": []}
for key, fn in [("system", dmi_system), ("cpu", cpu), ("memory", memory), ("storage", storage),
("network", network), ("infiniband", infiniband), ("pci", pci_devices), ("gpus", gpus)]:
try:
inv[key] = fn()
log(f"collected {key}")
except Exception as e: # noqa: BLE001
inv[key] = None
inv["errors"].append(f"{key}: {e!r}")
log(f"FAILED {key}: {e!r}")
inv["tpm"] = [os.path.basename(p) for p in glob.glob("/sys/class/tpm/tpm*")]
inv["lldp"] = lldp()
log("collected lldp")
macs = [n["mac"] for n in (inv.get("network") or []) if n.get("mac")]
try:
bmc = bmc_inband(user_name)
if bmc is None:
log("no /dev/ipmi0, asking server for an out-of-band hint")
bmc = bmc_hint(server, macs)
except Exception as e: # noqa: BLE001
bmc = {"source": "error", "errors": [repr(e)]}
inv["bmc"] = bmc
log(f"bmc: {json.dumps({k: v for k, v in (bmc or {}).items() if k in ('source', 'address', 'user', 'configured', 'verified_oob', 'errors')})}")
sysinfo = inv.get("system") or {}
inv["identity"] = {"uuid": sysinfo.get("system-uuid"), "serial": sysinfo.get("system-serial-number"),
"macs": macs, "boot_mac": None}
# the interface that got DHCP is the one live-boot used
for n in inv.get("network") or []:
if n.get("ipv4"):
inv["identity"]["boot_mac"] = n["mac"]
break
inv["duration_s"] = round(time.time() - started, 1)
resp = post(server, inv)
if resp:
log(f"server accepted: machine {resp.get('id')} state={resp.get('state')}")
else:
log("could not deliver inventory to server")
with open("/root/inventory.json", "w") as f:
json.dump(inv, f, indent=1)
if opts.get("nopoweroff") or not resp:
log("staying up (nopoweroff or delivery failure)")
return 0 if resp else 1
log("powering off")
time.sleep(3)
run(["systemctl", "poweroff"])
return 0
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Hardware commissioning agent (ephemeral image)
After=network-online.target lldpd.service
Wants=network-online.target lldpd.service
ConditionKernelCommandLine=|rollcall.server
[Service]
Type=oneshot
ExecStart=/usr/local/bin/rollcall-agent.py
StandardOutput=journal+console
StandardError=journal+console
TimeoutStartSec=30min
[Install]
WantedBy=multi-user.target
+300
View File
@@ -0,0 +1,300 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type client struct {
base, token string
http *http.Client
}
func newClient() (*client, error) {
base, token := os.Getenv("ROLLCALL_URL"), os.Getenv("ROLLCALL_TOKEN")
if base == "" || token == "" {
if cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json")); err == nil {
if base == "" {
base = "http://" + strings.Replace(cfg.Listen, "0.0.0.0", "127.0.0.1", 1)
}
if token == "" {
token = cfg.AdminToken
}
}
}
if base == "" || token == "" {
return nil, fmt.Errorf("set ROLLCALL_URL and ROLLCALL_TOKEN (or make the config file readable)")
}
return &client{base: strings.TrimRight(base, "/"), token: token, http: &http.Client{Timeout: 90 * time.Second}}, nil
}
func (c *client) do(method, path string, body any, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.base+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
var e apiError
_ = json.Unmarshal(data, &e)
if e.Error == "" {
e.Error = string(data)
}
return fmt.Errorf("%s: %s", resp.Status, e.Error)
}
if out != nil {
return json.Unmarshal(data, out)
}
return nil
}
func human(n float64) string {
units := []string{"B", "K", "M", "G", "T", "P"}
i := 0
for n >= 1024 && i < len(units)-1 {
n /= 1024
i++
}
if i <= 2 {
return fmt.Sprintf("%.0f%s", n, units[i])
}
return fmt.Sprintf("%.1f%s", n, units[i])
}
func runClient(cmd string, args []string) error {
c, err := newClient()
if err != nil {
return err
}
need := func(n int) error {
if len(args) < n {
usage()
}
return nil
}
switch cmd {
case "list":
var ms []Summary
if err := c.do("GET", "/api/v1/machines", nil, &ms); err != nil {
return err
}
f := "%-16s %-12s %-28s %-16s %4s %7s %3s %7s %3s %2s %3s %s\n"
fmt.Printf(f, "ID", "STATE", "MODEL", "SERIAL", "CPU", "RAM", "DSK", "STORAGE", "NIC", "IB", "GPU", "BMC")
for _, m := range ms {
bmc := "-"
if m.BMC != nil {
b := *m.BMC
ok := "FAIL"
if v, _ := b["configured"].(bool); v {
ok = "ok"
}
bmc = fmt.Sprintf("%s (%s,%s)", str(b["address"]), str(b["source"]), ok)
}
model := strings.TrimSpace(deref(m.Manufacturer) + " " + deref(m.Product))
fmt.Printf(f, m.ID, m.State, trunc(model, 28), trunc(deref(m.Serial), 16), m.CPUs, human(m.MemoryBytes),
fmt.Sprint(int(m.Disks)), human(m.StorageBytes), fmt.Sprint(m.NICs), fmt.Sprint(m.IBHCAs), fmt.Sprint(m.GPUs), bmc)
}
case "show":
_ = need(1)
fs := flag.NewFlagSet("show", flag.ExitOnError)
asJSON := fs.Bool("json", false, "raw json")
_ = fs.Parse(args[1:])
var d obj
if err := c.do("GET", "/api/v1/machines/"+args[0], nil, &d); err != nil {
return err
}
if *asJSON {
b, _ := json.MarshalIndent(d, "", " ")
fmt.Println(string(b))
return nil
}
printMachine(d)
case "events":
_ = need(1)
var d obj
if err := c.do("GET", "/api/v1/machines/"+args[0], nil, &d); err != nil {
return err
}
for _, e := range toList(d["events"]) {
fmt.Printf("%s %-24s %s\n", str(dig(e, "ts")), str(dig(e, "event")), str(dig(e, "detail")))
}
case "power":
_ = need(2)
var d obj
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/power", obj{"action": args[1]}, &d); err != nil {
return err
}
fmt.Println(str(d["output"]))
if ok, _ := d["ok"].(bool); !ok {
return fmt.Errorf("ipmitool failed")
}
case "bootdev":
_ = need(2)
var d obj
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/bootdev", obj{"device": args[1]}, &d); err != nil {
return err
}
fmt.Println(str(d["output"]))
case "recommission":
_ = need(1)
q := ""
if len(args) > 1 && args[1] == "-now" {
q = "?now=1"
}
var d obj
if err := c.do("POST", "/api/v1/machines/"+args[0]+"/recommission"+q, nil, &d); err != nil {
return err
}
fmt.Printf("%s -> %s\n", str(d["id"]), str(d["state"]))
if d["ipmi"] != nil {
fmt.Println(str(d["ipmi"]))
}
case "delete":
_ = need(1)
var d obj
if err := c.do("DELETE", "/api/v1/machines/"+args[0], nil, &d); err != nil {
return err
}
fmt.Println("deleted", str(d["deleted"]))
}
return nil
}
func trunc(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
func toList(v any) []any {
l, _ := v.([]any)
return l
}
func printMachine(d obj) {
s, _ := d["summary"].(obj)
inv, _ := d["inventory"].(obj)
sys, _ := inv["system"].(obj)
cpu, _ := inv["cpu"].(obj)
mem, _ := inv["memory"].(obj)
st, _ := inv["storage"].(obj)
fmt.Printf("%s state=%s hostname=%s\n", str(s["id"]), str(s["state"]), str(s["hostname"]))
fmt.Printf(" %s %s S/N %s UUID %s\n", str(sys["system-manufacturer"]), str(sys["system-product-name"]),
str(sys["system-serial-number"]), str(sys["system-uuid"]))
fmt.Printf(" BIOS %s %s (%s) board %s\n", str(sys["bios-vendor"]), str(sys["bios-version"]),
str(sys["bios-release-date"]), str(sys["baseboard-product-name"]))
fmt.Printf(" CPU: %s sockets=%s cores/socket=%s threads=%s numa=%s\n", str(cpu["Model name"]), str(cpu["Socket(s)"]),
str(cpu["Core(s) per socket"]), str(cpu["CPU(s)"]), str(cpu["NUMA node(s)"]))
fmt.Printf(" RAM: %s DIMMs %s/%s\n", human(num(mem["total_bytes"])), str(mem["dimms_populated"]), str(mem["dimm_slots"]))
for _, dv := range toList(mem["dimms"]) {
if d, _ := dv.(obj); d["populated"] == true {
speed := str(d["configured_speed"])
if speed == "" {
speed = str(d["speed"])
}
fmt.Printf(" %-14s %-8s %s %s %s %s\n", str(d["locator"]), str(d["size"]), str(d["type"]), speed,
str(d["manufacturer"]), str(d["part_number"]))
}
}
fmt.Printf(" Storage: %s disks, %s\n", str(st["disk_count"]), human(num(st["total_bytes"])))
for _, dv := range toList(st["disks"]) {
d, _ := dv.(obj)
kind := "hdd"
if d["rota"] == false {
kind = "ssd"
}
fmt.Printf(" %-10s %8s %-5s %-4s %s S/N %s\n", str(d["name"]), human(num(d["size"])), str(d["tran"]), kind,
str(d["model"]), str(d["serial"]))
}
for _, cv := range toList(st["controllers"]) {
c, _ := cv.(obj)
fmt.Printf(" ctrl %s %s %s [%s]\n", str(c["slot"]), str(c["vendor"]), str(c["device"]), str(c["driver"]))
}
fmt.Println(" NICs:")
for _, nv := range toList(inv["network"]) {
n, _ := nv.(obj)
var ips []string
for _, ip := range toList(n["ipv4"]) {
ips = append(ips, str(ip))
}
fmt.Printf(" %-10s %s %-10s %-10s link=%v fw=%s pci=%s %s\n", str(n["name"]), str(n["mac"]), str(n["driver"]),
str(n["speed"]), n["link"], str(n["firmware_version"]), str(n["pci"]), strings.Join(ips, ","))
}
if hcas := toList(dig(inv, "infiniband", "hcas")); len(hcas) > 0 {
fmt.Println(" InfiniBand:")
for _, hv := range hcas {
h, _ := hv.(obj)
for _, pv := range toList(h["ports"]) {
p, _ := pv.(obj)
fmt.Printf(" %s port%s %s %s %s guid=%s fw=%s %s\n", str(h["name"]), str(p["port"]), str(p["link_layer"]),
str(p["state"]), str(p["rate"]), str(h["node_guid"]), str(h["fw_ver"]), str(h["board_id"]))
}
}
}
if g := toList(inv["gpus"]); len(g) > 0 {
fmt.Println(" GPUs/accelerators:")
for _, gv := range g {
x, _ := gv.(obj)
fmt.Printf(" %s %s %s [%s]\n", str(x["slot"]), str(x["vendor"]), str(x["device"]), str(x["driver"]))
}
}
if lldp, ok := inv["lldp"].(obj); ok && len(lldp) > 0 {
fmt.Println(" LLDP neighbors:")
for ifn, ents := range lldp {
for _, e := range asList(ents) {
eo, _ := e.(obj)
ch, _ := eo["chassis"].(obj)
name := "?"
for k := range ch {
name = k
}
fmt.Printf(" %s: %s port %s\n", ifn, name, str(dig(eo, "port", "id", "value")))
}
}
}
b, _ := d["bmc"].(obj)
fmt.Printf(" BMC: source=%s address=%s user=%s configured=%v verified_oob=%v secret_stored=%v\n", str(b["source"]),
str(b["address"]), str(b["user"]), b["configured"], b["verified_oob"], d["has_bmc_secret"])
if b["lan"] != nil {
lb, _ := json.Marshal(b["lan"])
fmt.Printf(" lan: %s\n", lb)
}
if errs := toList(b["errors"]); len(errs) > 0 {
fmt.Printf(" errors: %v\n", errs)
}
if errs := toList(inv["errors"]); len(errs) > 0 {
fmt.Printf(" agent errors: %v\n", errs)
}
var evs []string
for _, e := range toList(d["events"]) {
evs = append(evs, str(dig(e, "event")))
}
fmt.Printf(" events: %s\n", strings.Join(evs, "; "))
}
func asList(v any) []any {
if l, ok := v.([]any); ok {
return l
}
return []any{v}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"encoding/json"
"fmt"
"os"
)
// Config is /etc/rollcall/config.json.
type Config struct {
Listen string `json:"listen"` // "10.213.77.1:80"
ImageDir string `json:"image_dir"` // live image files (vmlinuz, initrd.img, filesystem.squashfs), served under /rollcall/
TFTPRoot string `json:"tftp_root"` // pxelinux.cfg/01-<mac> files are written here
DatabaseURL string `json:"database_url"` // postgres://user:pw@host/db
MasterKeyFile string `json:"master_key_file"` // 32-byte hex key for AES-GCM at-rest encryption
AdminToken string `json:"admin_token"` // bearer token for the management API / web UI
AgentToken string `json:"agent_token"` // X-Rollcall-Token for the agent endpoints ("" = open)
CommissionAppend string `json:"commission_append"` // kernel cmdline used in generated pxelinux files
IPMITool string `json:"ipmitool"` // path, default "ipmitool"
BMCHints []BMCHint `json:"bmc_hints"` // out-of-band BMC mapping for machines without in-band IPMI
}
// BMCHint maps a machine MAC to a known BMC (virtual lab nodes).
type BMCHint struct {
MAC string `json:"mac"`
Address string `json:"address"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Comment string `json:"comment,omitempty"`
}
func loadConfig(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
c := &Config{Listen: "0.0.0.0:80", IPMITool: "ipmitool", MasterKeyFile: "/etc/rollcall/master.key"}
if err := json.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, k := range []struct{ v, n string }{{c.ImageDir, "image_dir"}, {c.TFTPRoot, "tftp_root"}, {c.DatabaseURL, "database_url"}, {c.AdminToken, "admin_token"}} {
if k.v == "" {
return nil, fmt.Errorf("%s: %s is required", path, k.n)
}
}
return c, nil
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
)
// Sealer encrypts BMC secrets at rest with AES-256-GCM.
type Sealer struct{ aead cipher.AEAD }
// loadOrCreateKey reads a 32-byte hex key from path, creating it (0600) when absent.
func loadOrCreateKey(path string) (*Sealer, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(hex.EncodeToString(raw)+"\n"), 0o600); err != nil {
return nil, fmt.Errorf("create master key: %w", err)
}
b = []byte(hex.EncodeToString(raw))
} else if err != nil {
return nil, err
}
key, err := hex.DecodeString(strings.TrimSpace(string(b)))
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("%s: expected 32-byte hex key", path)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Sealer{aead: aead}, nil
}
// Seal returns nonce||ciphertext; aad binds the secret to a machine id.
func (s *Sealer) Seal(plaintext, aad string) ([]byte, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return append(nonce, s.aead.Seal(nil, nonce, []byte(plaintext), []byte(aad))...), nil
}
func (s *Sealer) Open(blob []byte, aad string) (string, error) {
n := s.aead.NonceSize()
if len(blob) < n {
return "", errors.New("ciphertext too short")
}
pt, err := s.aead.Open(nil, blob[:n], blob[n:], []byte(aad))
if err != nil {
return "", err
}
return string(pt), nil
}
func randomToken(nbytes int) string {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
+14
View File
@@ -0,0 +1,14 @@
module rollcall
go 1.24
require github.com/jackc/pgx/v5 v5.7.5
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// bmcCreds is what the server needs to talk to a machine's BMC.
type bmcCreds struct {
Address string
Port int
User string
Password string
}
var powerActions = map[string][]string{
"status": {"chassis", "power", "status"},
"on": {"chassis", "power", "on"},
"off": {"chassis", "power", "off"},
"soft": {"chassis", "power", "soft"},
"cycle": {"chassis", "power", "cycle"},
"reset": {"chassis", "power", "reset"},
}
var bootDevices = map[string]bool{"pxe": true, "disk": true, "cdrom": true, "bios": true, "none": true, "safe": true}
func (a *App) credsFor(m *Machine) (*bmcCreds, error) {
if m.BMC == nil || m.bmcSecret == nil {
return nil, fmt.Errorf("no BMC credentials stored")
}
var b struct {
Address string `json:"address"`
Port int `json:"port"`
User string `json:"user"`
}
if err := json.Unmarshal(m.BMC, &b); err != nil {
return nil, err
}
if b.Address == "" || b.User == "" {
return nil, fmt.Errorf("BMC record has no address/user")
}
pw, err := a.sealer.Open(m.bmcSecret, m.ID)
if err != nil {
return nil, fmt.Errorf("decrypt BMC secret: %w", err)
}
if b.Port == 0 {
b.Port = 623
}
return &bmcCreds{Address: b.Address, Port: b.Port, User: b.User, Password: pw}, nil
}
// ipmi runs ipmitool server-side; the password never leaves this process.
func (a *App) ipmi(ctx context.Context, c *bmcCreds, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
full := append([]string{"-I", "lanplus", "-H", c.Address, "-p", strconv.Itoa(c.Port), "-U", c.User, "-E"}, args...)
cmd := exec.CommandContext(ctx, a.cfg.IPMITool, full...)
cmd.Env = []string{"IPMI_PASSWORD=" + c.Password, "PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
return strings.TrimSpace(out.String()), err
}
+68
View File
@@ -0,0 +1,68 @@
// rollcall: MAAS-like commissioning server, web UI and CLI in one binary.
//
// rollcall serve [-config /etc/rollcall/config.json]
// rollcall keygen print a random token (admin_token / agent_token)
// rollcall list | show ID [-json] | events ID
// rollcall power ID status|on|off|soft|cycle|reset
// rollcall bootdev ID pxe|disk|cdrom|bios|none
// rollcall recommission ID [-now] (-now: bootdev pxe + power cycle via BMC)
// rollcall delete ID
//
// Client commands read ROLLCALL_URL / ROLLCALL_TOKEN, falling back to the
// config file when it is readable (so root on the server needs no setup).
package main
import (
"flag"
"fmt"
"log"
"os"
)
func usage() {
fmt.Fprintln(os.Stderr, `usage:
rollcall serve [-config PATH]
rollcall keygen
rollcall list
rollcall show ID [-json]
rollcall events ID
rollcall power ID status|on|off|soft|cycle|reset
rollcall bootdev ID pxe|disk|cdrom|bios|none
rollcall recommission ID [-now]
rollcall delete ID
env: ROLLCALL_URL (default from config listen), ROLLCALL_TOKEN, ROLLCALL_CONFIG`)
os.Exit(2)
}
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
if len(os.Args) < 2 {
usage()
}
cmd, args := os.Args[1], os.Args[2:]
switch cmd {
case "serve":
fs := flag.NewFlagSet("serve", flag.ExitOnError)
cfg := fs.String("config", envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json"), "config file")
_ = fs.Parse(args)
if err := serve(*cfg); err != nil {
log.Fatal(err)
}
case "keygen":
fmt.Println(randomToken(24))
case "list", "show", "events", "power", "bootdev", "recommission", "delete":
if err := runClient(cmd, args); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
default:
usage()
}
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
+533
View File
@@ -0,0 +1,533 @@
package main
import (
"context"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
//go:embed web
var webFS embed.FS
type App struct {
cfg *Config
store *Store
sealer *Sealer
}
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func fail(w http.ResponseWriter, code int, err error) {
if errors.Is(err, errNotFound) {
code = http.StatusNotFound
}
writeJSON(w, code, apiError{err.Error()})
}
func tokenEqual(a, b string) bool {
return b != "" && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func (a *App) isAdmin(r *http.Request) bool {
h := r.Header.Get("Authorization")
return strings.HasPrefix(h, "Bearer ") && tokenEqual(strings.TrimPrefix(h, "Bearer "), a.cfg.AdminToken)
}
func (a *App) adminAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.isAdmin(r) {
w.Header().Set("WWW-Authenticate", `Bearer realm="rollcall"`)
fail(w, http.StatusUnauthorized, errors.New("admin token required"))
return
}
next(w, r)
}
}
func (a *App) agentAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if a.cfg.AgentToken != "" && !tokenEqual(r.Header.Get("X-Rollcall-Token"), a.cfg.AgentToken) && !a.isAdmin(r) {
fail(w, http.StatusUnauthorized, errors.New("agent token required"))
return
}
next(w, r)
}
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
if !strings.HasPrefix(r.URL.Path, "/rollcall/") || rw.code >= 400 {
log.Printf("%s %s %s %d %s", r.RemoteAddr, r.Method, r.URL.RequestURI(), rw.code, time.Since(start).Round(time.Millisecond))
}
})
}
type statusWriter struct {
http.ResponseWriter
code int
}
func (s *statusWriter) WriteHeader(c int) { s.code = c; s.ResponseWriter.WriteHeader(c) }
func (a *App) routes() http.Handler {
mux := http.NewServeMux()
// agent endpoints
mux.HandleFunc("POST /api/v1/commission", a.agentAuth(a.handleCommission))
mux.HandleFunc("GET /api/v1/bmc-hint", a.agentAuth(a.handleHint))
// management endpoints
mux.HandleFunc("GET /api/v1/machines", a.adminAuth(a.handleList))
mux.HandleFunc("GET /api/v1/machines/{id}", a.adminAuth(a.handleGet))
mux.HandleFunc("DELETE /api/v1/machines/{id}", a.adminAuth(a.handleDelete))
mux.HandleFunc("POST /api/v1/machines/{id}/recommission", a.adminAuth(a.handleRecommission))
mux.HandleFunc("GET /api/v1/machines/{id}/power", a.adminAuth(a.handlePower))
mux.HandleFunc("POST /api/v1/machines/{id}/power", a.adminAuth(a.handlePower))
mux.HandleFunc("POST /api/v1/machines/{id}/bootdev", a.adminAuth(a.handleBootdev))
mux.HandleFunc("GET /api/v1/whoami", a.adminAuth(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true})
}))
// image files for the live boot (no auth: the kernel/initrd/squashfs must be fetchable)
imgDir := a.cfg.ImageDir
mux.Handle("GET /rollcall/", http.StripPrefix("/rollcall/", http.FileServer(http.Dir(imgDir))))
// embedded web UI
sub, _ := fs.Sub(webFS, "web")
mux.Handle("GET /", http.FileServer(http.FS(sub)))
return logging(mux)
}
// ---------------------------------------------------------------- helpers over inventory JSON
type obj = map[string]any
func dig(m any, path ...string) any {
cur := m
for _, p := range path {
mm, ok := cur.(obj)
if !ok {
return nil
}
cur = mm[p]
}
return cur
}
func str(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case float64:
return fmt.Sprintf("%v", t)
default:
return fmt.Sprintf("%v", t)
}
}
func strPtr(v any) *string {
s := str(v)
if s == "" {
return nil
}
return &s
}
func listLen(v any) int {
if l, ok := v.([]any); ok {
return len(l)
}
return 0
}
func normMAC(s string) string { return strings.ToLower(strings.ReplaceAll(s, "-", ":")) }
var nonAlnum = regexp.MustCompile(`[^a-z0-9]`)
func machineID(uuid, serial string, macs []string) string {
u := strings.ToLower(uuid)
if u != "" && u != "00000000-0000-0000-0000-000000000000" && !strings.Contains(u, "not ") {
u = strings.ReplaceAll(u, "-", "")
if len(u) > 12 {
u = u[:12]
}
return "m-" + u
}
if s := nonAlnum.ReplaceAllString(strings.ToLower(serial), ""); s != "" && !strings.Contains(strings.ToLower(serial), "not ") {
if len(s) > 16 {
s = s[:16]
}
return "m-" + s
}
if len(macs) > 0 {
return "m-" + strings.ReplaceAll(macs[0], ":", "")
}
return "m-" + randomToken(6)
}
// Summary is the list-view projection of a machine (no secrets, no full inventory).
type Summary struct {
ID string `json:"id"`
State string `json:"state"`
Hostname *string `json:"hostname"`
Serial *string `json:"serial"`
UUID *string `json:"uuid"`
Manufacturer *string `json:"manufacturer"`
Product *string `json:"product"`
FirstSeen time.Time `json:"first_seen"`
LastSeen *time.Time `json:"last_seen"`
MACs []string `json:"macs"`
CPU string `json:"cpu"`
CPUs string `json:"cpus"`
MemoryBytes float64 `json:"memory_bytes"`
Disks float64 `json:"disks"`
StorageBytes float64 `json:"storage_bytes"`
NICs int `json:"nics"`
IBHCAs int `json:"ib_hcas"`
GPUs int `json:"gpus"`
BMC *obj `json:"bmc"`
}
func num(v any) float64 {
f, _ := v.(float64)
return f
}
func summarize(m *Machine) Summary {
var inv obj
_ = json.Unmarshal(m.Inventory, &inv)
s := Summary{ID: m.ID, State: m.State, Hostname: m.Hostname, Serial: m.Serial, UUID: m.UUID,
Manufacturer: m.Manufacturer, Product: m.Product, FirstSeen: m.FirstSeen, LastSeen: m.LastSeen, MACs: m.MACs,
CPU: str(dig(inv, "cpu", "Model name")), CPUs: str(dig(inv, "cpu", "CPU(s)")),
MemoryBytes: num(dig(inv, "memory", "total_bytes")), Disks: num(dig(inv, "storage", "disk_count")),
StorageBytes: num(dig(inv, "storage", "total_bytes")), NICs: listLen(inv["network"]),
IBHCAs: listLen(dig(inv, "infiniband", "hcas")), GPUs: listLen(inv["gpus"])}
if m.BMC != nil {
var b obj
if json.Unmarshal(m.BMC, &b) == nil {
s.BMC = &obj{"source": b["source"], "address": b["address"], "user": b["user"],
"configured": b["configured"], "verified_oob": b["verified_oob"]}
}
}
return s
}
// ---------------------------------------------------------------- pxelinux per-MAC files
const commissionedTmpl = `# generated by rollcall %s
# machine %s %s %s serial=%s
DEFAULT menu.c32
PROMPT 0
TIMEOUT 150
MENU TITLE %s - commissioned (%s %s, S/N %s)
LABEL poweroff
MENU LABEL Commissioned: power off (default in 15s)
MENU DEFAULT
KERNEL poweroff.c32
LABEL recommission
MENU LABEL Re-run commissioning
KERNEL rollcall/vmlinuz
INITRD rollcall/initrd.img
APPEND %s
LABEL local
MENU LABEL Boot from local disk
LOCALBOOT 0
`
func (a *App) pxePath(mac string) string {
return filepath.Join(a.cfg.TFTPRoot, "pxelinux.cfg", "01-"+strings.ReplaceAll(normMAC(mac), ":", "-"))
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
func (a *App) writePXE(m *Machine) error {
man, prod, ser := deref(m.Manufacturer), deref(m.Product), deref(m.Serial)
body := fmt.Sprintf(commissionedTmpl, time.Now().Format(time.RFC3339), m.ID, man, prod, ser,
m.ID, man, prod, ser, a.cfg.CommissionAppend)
for _, mac := range m.MACs {
p := a.pxePath(mac)
if err := os.WriteFile(p+".tmp", []byte(body), 0o644); err != nil {
return err
}
if err := os.Rename(p+".tmp", p); err != nil {
return err
}
log.Printf("wrote %s", p)
}
return nil
}
func (a *App) removePXE(m *Machine) {
for _, mac := range m.MACs {
if err := os.Remove(a.pxePath(mac)); err == nil {
log.Printf("removed %s", a.pxePath(mac))
}
}
}
// ---------------------------------------------------------------- handlers
func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
if err != nil {
fail(w, 400, err)
return
}
var inv obj
if err := json.Unmarshal(body, &inv); err != nil {
fail(w, 400, fmt.Errorf("bad json: %w", err))
return
}
ident, _ := inv["identity"].(obj)
var macs []string
if l, ok := ident["macs"].([]any); ok {
for _, v := range l {
if s := normMAC(str(v)); s != "" {
macs = append(macs, s)
}
}
}
if boot := normMAC(str(ident["boot_mac"])); boot != "" {
for i, mac := range macs {
if mac == boot && i != 0 {
macs = append([]string{boot}, append(macs[:i], macs[i+1:]...)...)
break
}
}
}
m := &Machine{
ID: machineID(str(ident["uuid"]), str(ident["serial"]), macs),
UUID: strPtr(ident["uuid"]),
Serial: strPtr(ident["serial"]),
Hostname: strPtr(inv["hostname"]),
Manufacturer: strPtr(dig(inv, "system", "system-manufacturer")),
Product: strPtr(dig(inv, "system", "system-product-name")),
MACs: macs,
}
// Pull the BMC password out of the payload before anything is persisted.
var secret []byte
detail := "bmc=none"
if bmc, ok := inv["bmc"].(obj); ok {
if pw := str(bmc["password"]); pw != "" {
secret, err = a.sealer.Seal(pw, m.ID)
if err != nil {
fail(w, 500, err)
return
}
bmc["password"] = "<stored encrypted>"
}
m.BMC, _ = json.Marshal(bmc)
detail = fmt.Sprintf("bmc=%s:%s configured=%v", str(bmc["source"]), str(bmc["address"]), bmc["configured"])
}
m.Inventory, _ = json.Marshal(inv)
isNew, err := a.store.UpsertCommissioned(r.Context(), m, secret)
if err != nil {
fail(w, 500, err)
return
}
_ = a.store.AddEvent(r.Context(), m.ID, "commissioned", detail)
if err := a.writePXE(m); err != nil {
log.Printf("pxe write failed for %s: %v", m.ID, err)
_ = a.store.AddEvent(r.Context(), m.ID, "pxe-write-failed", err.Error())
}
log.Printf("commissioned %s new=%v (%s %s, serial %s, %d macs) %s", m.ID, isNew, deref(m.Manufacturer),
deref(m.Product), deref(m.Serial), len(macs), detail)
writeJSON(w, 200, map[string]any{"id": m.ID, "state": "ready", "new": isNew})
}
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) {
want := map[string]bool{}
for _, m := range strings.Split(r.URL.Query().Get("macs"), ",") {
want[normMAC(m)] = true
}
for _, h := range a.cfg.BMCHints {
if want[normMAC(h.MAC)] {
port := h.Port
if port == 0 {
port = 623
}
writeJSON(w, 200, obj{"address": h.Address, "port": port, "user": h.User, "password": h.Password})
return
}
}
writeJSON(w, 200, obj{})
}
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
ms, err := a.store.List(r.Context())
if err != nil {
fail(w, 500, err)
return
}
out := make([]Summary, 0, len(ms))
for _, m := range ms {
out = append(out, summarize(m))
}
writeJSON(w, 200, out)
}
func (a *App) handleGet(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
evs, _ := a.store.Events(r.Context(), m.ID)
var bmc, inv any
_ = json.Unmarshal(m.BMC, &bmc)
_ = json.Unmarshal(m.Inventory, &inv)
writeJSON(w, 200, obj{"summary": summarize(m), "bmc": bmc, "has_bmc_secret": m.bmcSecret != nil,
"inventory": inv, "events": evs})
}
func (a *App) handleDelete(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
a.removePXE(m)
if err := a.store.Delete(r.Context(), m.ID); err != nil {
fail(w, 500, err)
return
}
log.Printf("deleted %s", m.ID)
writeJSON(w, 200, obj{"deleted": m.ID})
}
func (a *App) handleRecommission(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
a.removePXE(m)
_ = a.store.SetState(r.Context(), m.ID, "recommission")
_ = a.store.AddEvent(r.Context(), m.ID, "recommission requested", "")
// optional: power-cycle it right away when the caller asks (?now=1)
out := obj{"id": m.ID, "state": "recommission"}
if r.URL.Query().Get("now") == "1" {
if c, err := a.credsFor(m); err == nil {
o, ierr := a.ipmi(r.Context(), c, "chassis", "bootdev", "pxe")
o2, ierr2 := a.ipmi(r.Context(), c, "chassis", "power", "cycle")
if ierr2 != nil && strings.Contains(o2, "off") { // cycle fails when off; power on instead
o2, ierr2 = a.ipmi(r.Context(), c, "chassis", "power", "on")
}
out["ipmi"] = strings.TrimSpace(o + "\n" + o2)
out["ipmi_ok"] = ierr == nil && ierr2 == nil
} else {
out["ipmi"] = err.Error()
}
}
writeJSON(w, 200, out)
}
func (a *App) handlePower(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
action := "status"
if r.Method == http.MethodPost {
var req struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, 400, err)
return
}
action = req.Action
}
args, ok := powerActions[action]
if !ok {
fail(w, 400, fmt.Errorf("unknown power action %q", action))
return
}
c, err := a.credsFor(m)
if err != nil {
fail(w, 409, err)
return
}
out, err := a.ipmi(r.Context(), c, args...)
if action != "status" {
_ = a.store.AddEvent(r.Context(), m.ID, "power "+action, out)
}
writeJSON(w, 200, obj{"id": m.ID, "action": action, "ok": err == nil, "output": out})
}
func (a *App) handleBootdev(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
var req struct {
Device string `json:"device"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !bootDevices[req.Device] {
fail(w, 400, fmt.Errorf("device must be one of pxe, disk, cdrom, bios, none, safe"))
return
}
c, err := a.credsFor(m)
if err != nil {
fail(w, 409, err)
return
}
out, err := a.ipmi(r.Context(), c, "chassis", "bootdev", req.Device)
_ = a.store.AddEvent(r.Context(), m.ID, "bootdev "+req.Device, out)
writeJSON(w, 200, obj{"id": m.ID, "device": req.Device, "ok": err == nil, "output": out})
}
func serve(cfgPath string) error {
cfg, err := loadConfig(cfgPath)
if err != nil {
return err
}
sealer, err := loadOrCreateKey(cfg.MasterKeyFile)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
store, err := openStore(ctx, cfg.DatabaseURL)
cancel()
if err != nil {
return fmt.Errorf("database: %w", err)
}
app := &App{cfg: cfg, store: store, sealer: sealer}
srv := &http.Server{Addr: cfg.Listen, Handler: app.routes(), ReadHeaderTimeout: 10 * time.Second}
log.Printf("rollcall listening on %s (image=%s tftp=%s agent_token=%v)", cfg.Listen, cfg.ImageDir, cfg.TFTPRoot, cfg.AgentToken != "")
return srv.ListenAndServe()
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const schema = `
CREATE TABLE IF NOT EXISTS machines (
id text PRIMARY KEY,
uuid text,
serial text,
hostname text,
manufacturer text,
product text,
state text NOT NULL DEFAULT 'new',
first_seen timestamptz NOT NULL DEFAULT now(),
last_seen timestamptz,
commissioned_at timestamptz,
macs jsonb NOT NULL DEFAULT '[]',
bmc jsonb,
bmc_secret bytea,
inventory jsonb
);
CREATE INDEX IF NOT EXISTS machines_uuid ON machines(uuid);
CREATE INDEX IF NOT EXISTS machines_macs ON machines USING gin(macs);
CREATE TABLE IF NOT EXISTS events (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
machine_id text NOT NULL,
event text NOT NULL,
detail text
);
CREATE INDEX IF NOT EXISTS events_machine ON events(machine_id, ts);
`
var errNotFound = errors.New("not found")
type Store struct{ pool *pgxpool.Pool }
type Machine struct {
ID string `json:"id"`
UUID *string `json:"uuid"`
Serial *string `json:"serial"`
Hostname *string `json:"hostname"`
Manufacturer *string `json:"manufacturer"`
Product *string `json:"product"`
State string `json:"state"`
FirstSeen time.Time `json:"first_seen"`
LastSeen *time.Time `json:"last_seen"`
CommissionedAt *time.Time `json:"commissioned_at"`
MACs []string `json:"macs"`
BMC json.RawMessage `json:"bmc,omitempty"`
Inventory json.RawMessage `json:"inventory,omitempty"`
bmcSecret []byte
}
type Event struct {
TS time.Time `json:"ts"`
Event string `json:"event"`
Detail string `json:"detail"`
}
func openStore(ctx context.Context, url string) (*Store, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
if _, err := pool.Exec(ctx, schema); err != nil {
return nil, fmt.Errorf("schema: %w", err)
}
return &Store{pool: pool}, nil
}
const machineCols = `id, uuid, serial, hostname, manufacturer, product, state, first_seen, last_seen,
commissioned_at, macs, bmc, bmc_secret, inventory`
func scanMachine(row pgx.Row, full bool) (*Machine, error) {
m := &Machine{}
var macs []byte
err := row.Scan(&m.ID, &m.UUID, &m.Serial, &m.Hostname, &m.Manufacturer, &m.Product, &m.State,
&m.FirstSeen, &m.LastSeen, &m.CommissionedAt, &macs, &m.BMC, &m.bmcSecret, &m.Inventory)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errNotFound
}
if err != nil {
return nil, err
}
_ = json.Unmarshal(macs, &m.MACs)
if !full {
m.Inventory = nil
}
return m, nil
}
func (s *Store) List(ctx context.Context) ([]*Machine, error) {
rows, err := s.pool.Query(ctx, `SELECT `+machineCols+` FROM machines ORDER BY first_seen`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Machine
for rows.Next() {
m, err := scanMachine(rows, true)
if err != nil {
return nil, err
}
out = append(out, m)
}
if out == nil {
out = []*Machine{}
}
return out, rows.Err()
}
func (s *Store) Get(ctx context.Context, id string) (*Machine, error) {
return scanMachine(s.pool.QueryRow(ctx, `SELECT `+machineCols+` FROM machines WHERE id=$1`, id), true)
}
func (s *Store) Events(ctx context.Context, id string) ([]Event, error) {
rows, err := s.pool.Query(ctx, `SELECT ts, event, coalesce(detail,'') FROM events WHERE machine_id=$1 ORDER BY ts`, id)
if err != nil {
return nil, err
}
defer rows.Close()
evs := []Event{}
for rows.Next() {
var e Event
if err := rows.Scan(&e.TS, &e.Event, &e.Detail); err != nil {
return nil, err
}
evs = append(evs, e)
}
return evs, rows.Err()
}
func (s *Store) AddEvent(ctx context.Context, id, event, detail string) error {
_, err := s.pool.Exec(ctx, `INSERT INTO events(machine_id, event, detail) VALUES ($1,$2,$3)`, id, event, detail)
return err
}
// UpsertCommissioned stores a fresh inventory; returns true when the machine was new.
func (s *Store) UpsertCommissioned(ctx context.Context, m *Machine, secret []byte) (bool, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer tx.Rollback(ctx)
macs, _ := json.Marshal(m.MACs)
tag, err := tx.Exec(ctx, `INSERT INTO machines(id, state) VALUES ($1,'new') ON CONFLICT (id) DO NOTHING`, m.ID)
if err != nil {
return false, err
}
isNew := tag.RowsAffected() == 1
if isNew {
if _, err := tx.Exec(ctx, `INSERT INTO events(machine_id, event) VALUES ($1,'enlisted')`, m.ID); err != nil {
return false, err
}
}
_, err = tx.Exec(ctx, `UPDATE machines SET uuid=$2, serial=$3, hostname=$4, manufacturer=$5, product=$6,
state='ready', last_seen=now(), commissioned_at=now(), macs=$7, bmc=$8, bmc_secret=$9, inventory=$10 WHERE id=$1`,
m.ID, m.UUID, m.Serial, m.Hostname, m.Manufacturer, m.Product, macs, m.BMC, secret, m.Inventory)
if err != nil {
return false, err
}
return isNew, tx.Commit(ctx)
}
func (s *Store) SetState(ctx context.Context, id, state string) error {
tag, err := s.pool.Exec(ctx, `UPDATE machines SET state=$2 WHERE id=$1`, id, state)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errNotFound
}
return nil
}
func (s *Store) Delete(ctx context.Context, id string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM events WHERE machine_id=$1`, id); err != nil {
return err
}
tag, err := tx.Exec(ctx, `DELETE FROM machines WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errNotFound
}
return tx.Commit(ctx)
}
+158
View File
@@ -0,0 +1,158 @@
'use strict';
const $ = (s, el = document) => el.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const human = n => { n = Number(n || 0); const u = ['B','K','M','G','T','P']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i <= 2 ? n.toFixed(0) : n.toFixed(1)) + u[i]; };
const ago = ts => { if (!ts) return '-'; const s = (Date.now() - new Date(ts)) / 1000; if (s < 90) return `${s | 0}s ago`; if (s < 5400) return `${(s / 60) | 0}m ago`; if (s < 172800) return `${(s / 3600) | 0}h ago`; return `${(s / 86400) | 0}d ago`; };
let token = '';
try { token = localStorage.getItem('rollcall.token') || ''; } catch (e) { /* storage unavailable */ }
let machines = [];
let current = null;
let timer = null;
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
if (r.status === 401) { askToken(); throw new Error('unauthorized'); }
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || r.statusText);
return data;
}
function askToken() {
const dlg = $('#login');
if (!dlg.open) dlg.showModal();
}
$('#login form').addEventListener('submit', () => {
token = $('#token').value.trim();
try { localStorage.setItem('rollcall.token', token); } catch (e) { /* ignore */ }
$('#token').value = '';
loadList();
});
$('#logout').onclick = () => { token = ''; try { localStorage.removeItem('rollcall.token'); } catch (e) { /* ignore */ } askToken(); };
$('#refresh').onclick = () => current ? loadDetail(current) : loadList();
$('#filter').oninput = renderList;
$('#back').onclick = () => { current = null; $('#detail').hidden = true; $('#list').hidden = false; loadList(); };
function setStatus(msg, bad) { const el = $('#status'); el.textContent = msg; el.className = bad ? 'bad' : 'muted'; }
async function loadList() {
if (!token) return askToken();
try {
machines = await api('GET', '/api/v1/machines');
setStatus(`updated ${new Date().toLocaleTimeString()}`);
renderList();
} catch (e) { if (e.message !== 'unauthorized') setStatus(e.message, true); }
}
function renderList() {
const q = $('#filter').value.toLowerCase();
const rows = machines.filter(m => !q || JSON.stringify([m.id, m.state, m.manufacturer, m.product, m.serial, m.macs, m.hostname]).toLowerCase().includes(q));
$('#count').textContent = `${rows.length} / ${machines.length}`;
$('#empty').hidden = machines.length > 0;
$('#machines tbody').innerHTML = rows.map(m => {
const b = m.bmc;
const bmc = b ? `<span class="${b.configured ? 'ok' : 'bad'}">${esc(b.address || '-')}</span> <span class="muted">${esc(b.source)}</span>` : '<span class="muted">-</span>';
return `<tr data-id="${esc(m.id)}">
<td class="mono">${esc(m.id)}</td><td><span class="pill ${esc(m.state)}">${esc(m.state)}</span></td>
<td>${esc((m.manufacturer || '') + ' ' + (m.product || ''))}</td><td class="mono">${esc(m.serial || '')}</td>
<td class="num">${esc(m.cpus)}</td><td class="num">${human(m.memory_bytes)}</td><td class="num">${m.disks | 0}</td>
<td class="num">${human(m.storage_bytes)}</td><td class="num">${m.nics}</td><td class="num">${m.ib_hcas}</td><td class="num">${m.gpus}</td>
<td>${bmc}</td><td class="muted">${ago(m.last_seen)}</td></tr>`;
}).join('');
for (const tr of $('#machines tbody').children) tr.onclick = () => loadDetail(tr.dataset.id);
}
function card(title, inner, wide) { return `<div class="card${wide ? ' wide' : ''}"><h3>${esc(title)}</h3>${inner}</div>`; }
function kv(pairs) { return '<dl class="kv">' + pairs.filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('') + '</dl>'; }
function table(cols, rows) { return rows.length ? `<table><thead><tr>${cols.map(c => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr>${r.map(c => `<td>${c ?? ''}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<span class="muted">none</span>'; }
async function loadDetail(id) {
try {
const d = await api('GET', '/api/v1/machines/' + encodeURIComponent(id));
current = id;
renderDetail(d);
$('#list').hidden = true; $('#detail').hidden = false;
} catch (e) { if (e.message !== 'unauthorized') setStatus(e.message, true); }
}
function renderDetail(d) {
const s = d.summary, inv = d.inventory || {}, sys = inv.system || {}, cpu = inv.cpu || {}, mem = inv.memory || {}, st = inv.storage || {}, b = d.bmc || {};
$('#d-title').textContent = `${s.id} ${sys['system-manufacturer'] || ''} ${sys['system-product-name'] || ''}`;
$('#d-state').textContent = s.state; $('#d-state').className = 'pill ' + s.state;
$('#action-out').textContent = '';
const cards = [];
cards.push(card('System', kv([
['Manufacturer', esc(sys['system-manufacturer'])], ['Product', esc(sys['system-product-name'])], ['Serial', esc(sys['system-serial-number'])],
['UUID', `<span class="mono">${esc(sys['system-uuid'])}</span>`], ['SKU', esc(sys['system-sku-number'])],
['Board', esc([sys['baseboard-manufacturer'], sys['baseboard-product-name'], sys['baseboard-serial-number']].filter(Boolean).join(' / '))],
['Chassis', esc([sys['chassis-type'], sys['chassis-serial-number'], sys['chassis-asset-tag']].filter(Boolean).join(' / '))],
['BIOS', esc([sys['bios-vendor'], sys['bios-version'], sys['bios-release-date']].filter(Boolean).join(' '))],
['Kernel', esc((inv.kernel || '').split(' (')[0])], ['TPM', esc((inv.tpm || []).join(', ') || 'none')],
['Hostname', esc(s.hostname)], ['First seen', esc(new Date(s.first_seen).toLocaleString())], ['Last seen', esc(s.last_seen ? new Date(s.last_seen).toLocaleString() : '')],
['Agent run', esc(inv.duration_s ? inv.duration_s + ' s' : '')],
])));
cards.push(card('CPU', kv([
['Model', esc(cpu['Model name'])], ['Sockets', esc(cpu['Socket(s)'])], ['Cores / socket', esc(cpu['Core(s) per socket'])],
['Threads / core', esc(cpu['Thread(s) per core'])], ['Logical CPUs', esc(cpu['CPU(s)'])], ['NUMA nodes', esc(cpu['NUMA node(s)'])],
['Max MHz', esc(cpu['CPU max MHz'])], ['L3', esc(cpu['L3 cache'])], ['Virtualization', esc(cpu['Virtualization'])],
['Features', esc((cpu.features || []).join(' '))],
])));
cards.push(card(`Memory ${human(mem.total_bytes)} · ${mem.dimms_populated ?? '?'}/${mem.dimm_slots ?? '?'} DIMMs`,
table(['Slot', 'Size', 'Type', 'Speed', 'Vendor', 'Part', 'Serial'],
(mem.dimms || []).filter(x => x.populated).map(x => [esc(x.locator), esc(x.size), esc(x.type), esc(x.configured_speed || x.speed), esc(x.manufacturer), `<span class="mono">${esc(x.part_number)}</span>`, `<span class="mono">${esc(x.serial)}</span>`]))));
cards.push(card(`Storage · ${st.disk_count ?? 0} disks · ${human(st.total_bytes)}`,
table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', 'FW', 'Health'],
(st.disks || []).map(x => { const sm = (st.smart || {})[x.name] || {}; return [esc(x.name), human(x.size), esc(x.tran), x.rota === false ? 'ssd' : 'hdd', esc(x.model), `<span class="mono">${esc(x.serial)}</span>`, esc(sm.firmware), sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; }))
+ (st.controllers && st.controllers.length ? '<p class="muted">Controllers</p>' + table(['PCI', 'Vendor', 'Device', 'Driver'], st.controllers.map(c => [`<span class="mono">${esc(c.slot)}</span>`, esc(c.vendor), esc(c.device), esc(c.driver)])) : ''), true));
cards.push(card(`Network · ${(inv.network || []).length} NICs`,
table(['If', 'MAC', 'Driver', 'FW', 'Speed', 'Link', 'PCI', 'NUMA', 'VFs', 'IPv4'],
(inv.network || []).map(n => [esc(n.name), `<span class="mono">${esc(n.mac)}</span>`, esc(n.driver), esc(n.firmware_version), esc(n.speed), n.link === true ? '<span class="ok">up</span>' : n.link === false ? '<span class="muted">down</span>' : '', `<span class="mono">${esc(n.pci)}</span>`, esc(n.numa_node), esc(n.sriov_totalvfs), esc((n.ipv4 || []).join(' '))])), true));
const hcas = (inv.infiniband || {}).hcas || [];
if (hcas.length) cards.push(card(`InfiniBand · ${hcas.length} HCAs`,
table(['HCA', 'Port', 'Layer', 'State', 'Rate', 'LID', 'Node GUID', 'FW', 'Board', 'PCI'],
hcas.flatMap(h => (h.ports || []).map(p => [esc(h.name), esc(p.port), esc(p.link_layer), esc(p.state), esc(p.rate), esc(p.lid), `<span class="mono">${esc(h.node_guid)}</span>`, esc(h.fw_ver), esc(h.board_id), `<span class="mono">${esc(h.pci)}</span>`]))), true));
if ((inv.gpus || []).length) cards.push(card('GPUs / accelerators', table(['PCI', 'Vendor', 'Device', 'Driver'], inv.gpus.map(g => [`<span class="mono">${esc(g.slot)}</span>`, esc(g.vendor), esc(g.device), esc(g.driver)]))));
if (inv.lldp && Object.keys(inv.lldp).length) {
const rows = [];
for (const [ifn, ents] of Object.entries(inv.lldp)) for (const e of (Array.isArray(ents) ? ents : [ents])) {
const ch = e.chassis || {}; const name = Object.keys(ch)[0] || '?'; const c = ch[name] || {};
rows.push([esc(ifn), esc(name), esc((c.descr || '').slice(0, 60)), esc((e.port || {}).id && (e.port.id.value || e.port.id)), esc((e.port || {}).descr), esc((e.vlan || {})['vlan-id'] || (e.vlan || {}).value)]);
}
cards.push(card('LLDP neighbors (switch ports)', table(['If', 'Switch', 'Descr', 'Port', 'Port descr', 'VLAN'], rows), true));
}
cards.push(card('BMC', kv([
['Source', esc(b.source)], ['Address', `<span class="mono">${esc(b.address)}${b.port && b.port !== 623 ? ':' + esc(b.port) : ''}</span>`], ['MAC', `<span class="mono">${esc(b.mac)}</span>`],
['User', esc(b.user)], ['Password', d.has_bmc_secret ? '<span class="ok">stored encrypted on server</span>' : '<span class="bad">none</span>'],
['Configured', b.configured ? '<span class="ok">yes</span>' : '<span class="bad">no</span>'], ['Verified OOB', b.verified_oob === true ? '<span class="ok">yes</span>' : b.verified_oob === false ? '<span class="bad">no</span>' : ''],
['Channel', esc(b.channel)], ['LAN', b.lan ? `<span class="mono">${esc(JSON.stringify(b.lan))}</span>` : ''],
['Firmware', esc((b.mc_info || {})['Firmware Revision'])], ['Manufacturer', esc((b.mc_info || {})['Manufacturer Name'])],
['Errors', (b.errors || []).length ? `<span class="bad">${esc(b.errors.join('; '))}</span>` : ''],
]) + ((b.users_before || []).length ? '<p class="muted">User table before commissioning</p>' + table(['ID', 'Name', 'Priv', 'IPMI'], b.users_before.map(u => [u.id, esc(u.name), esc(u.priv), u.ipmi_msg ? 'yes' : 'no'])) : '')));
cards.push(card('Events', table(['Time', 'Event', 'Detail'], (d.events || []).slice().reverse().map(e => [esc(new Date(e.ts).toLocaleString()), esc(e.event), `<span class="mono">${esc(e.detail)}</span>`]))));
if ((inv.pci || []).length) cards.push(card(`PCI devices · ${inv.pci.length}`, table(['Slot', 'Class', 'Vendor', 'Device', 'Driver'], inv.pci.map(p => [`<span class="mono">${esc(p.slot)}</span>`, esc(p.class), esc(p.vendor), esc(p.device), esc(p.driver)])), true));
if ((inv.errors || []).length) cards.push(card('Agent errors', `<span class="bad">${esc(inv.errors.join('\n'))}</span>`, true));
$('#d-body').innerHTML = cards.join('');
$('#d-json').textContent = JSON.stringify(d, null, 1);
}
async function action(fn, label) {
const btns = document.querySelectorAll('.actions button');
btns.forEach(b => b.disabled = true);
$('#action-out').textContent = label + ' …';
try { const r = await fn(); $('#action-out').textContent = r; }
catch (e) { $('#action-out').textContent = 'error: ' + e.message; }
finally { btns.forEach(b => b.disabled = false); }
}
document.querySelectorAll('[data-power]').forEach(b => b.onclick = () => action(async () => {
const r = await api('POST', `/api/v1/machines/${current}/power`, { action: b.dataset.power }); return r.output || (r.ok ? 'ok' : 'failed');
}, 'power ' + b.dataset.power));
document.querySelectorAll('[data-bootdev]').forEach(b => b.onclick = () => action(async () => {
const r = await api('POST', `/api/v1/machines/${current}/bootdev`, { device: b.dataset.bootdev }); return r.output || (r.ok ? 'ok' : 'failed');
}, 'bootdev ' + b.dataset.bootdev));
$('#recommission').onclick = () => action(async () => { const r = await api('POST', `/api/v1/machines/${current}/recommission`); await loadDetail(current); return `state ${r.state}: next PXE boot runs commissioning`; }, 'recommission');
$('#recommission-now').onclick = () => { if (confirm('Remove PXE override, set bootdev=pxe and power-cycle this machine via its BMC?')) action(async () => { const r = await api('POST', `/api/v1/machines/${current}/recommission?now=1`); await loadDetail(current); return r.ipmi || r.state; }, 'recommission now'); };
$('#delete').onclick = () => { if (confirm(`Delete ${current}? It will be commissioned again as a new machine on next PXE boot.`)) action(async () => { await api('DELETE', `/api/v1/machines/${current}`); $('#back').click(); return 'deleted'; }, 'delete'); };
function schedule() { clearInterval(timer); timer = setInterval(() => { if (!current && document.visibilityState === 'visible') loadList(); }, 10000); }
schedule();
loadList();
+75
View File
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rollcall</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>rollcall <span class="sub">hardware commissioning</span></h1>
<div class="hdr-right">
<span id="status" class="muted"></span>
<button id="refresh" class="btn">Refresh</button>
<button id="logout" class="btn ghost" title="forget token">Token</button>
</div>
</header>
<dialog id="login">
<form method="dialog">
<h2>Admin token</h2>
<p class="muted">The <code>admin_token</code> from <code>/etc/rollcall/config.json</code>. Stored only in this browser.</p>
<input id="token" type="password" autocomplete="off" placeholder="token" required>
<menu><button class="btn primary" value="ok">Sign in</button></menu>
</form>
</dialog>
<main>
<section id="list">
<div class="toolbar">
<input id="filter" type="search" placeholder="filter: id, model, serial, mac, state…">
<span id="count" class="muted"></span>
</div>
<table id="machines">
<thead><tr>
<th>ID</th><th>State</th><th>Model</th><th>Serial</th><th class="num">CPU</th><th class="num">RAM</th>
<th class="num">Disks</th><th class="num">Storage</th><th class="num">NIC</th><th class="num">IB</th><th class="num">GPU</th>
<th>BMC</th><th>Last seen</th>
</tr></thead>
<tbody></tbody>
</table>
<p id="empty" class="muted" hidden>No machines yet. PXE-boot an unknown server: it commissions itself and appears here.</p>
</section>
<section id="detail" hidden>
<div class="detail-head">
<button id="back" class="btn ghost">← Machines</button>
<h2 id="d-title"></h2>
<span id="d-state" class="pill"></span>
</div>
<div class="actions">
<span class="muted">Power:</span>
<button class="btn" data-power="status">Status</button>
<button class="btn" data-power="on">On</button>
<button class="btn" data-power="off">Off</button>
<button class="btn" data-power="soft">Soft off</button>
<button class="btn" data-power="cycle">Cycle</button>
<button class="btn" data-power="reset">Reset</button>
<span class="sep"></span>
<span class="muted">Boot:</span>
<button class="btn" data-bootdev="pxe">PXE</button>
<button class="btn" data-bootdev="disk">Disk</button>
<span class="sep"></span>
<button id="recommission" class="btn warn">Re-commission</button>
<button id="recommission-now" class="btn warn" title="remove per-MAC PXE config, set bootdev pxe and power-cycle via BMC">Re-commission now</button>
<button id="delete" class="btn danger">Delete</button>
<span id="action-out" class="mono"></span>
</div>
<div id="d-body" class="grid"></div>
<details><summary>Raw JSON</summary><pre id="d-json" class="mono"></pre></details>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
:root {
--bg: #f6f7f9; --panel: #ffffff; --fg: #1d2129; --muted: #6b7280; --line: #e3e6ea;
--accent: #2563eb; --accent-fg: #ffffff; --warn: #b45309; --danger: #b91c1c; --ok: #15803d;
--pill: #e8eefc; --hover: #f0f4ff; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1216; --panel: #171b21; --fg: #e6e8eb; --muted: #9aa3ad; --line: #2a3139;
--accent: #3b82f6; --pill: #1e2a44; --hover: #1b2230; --warn: #f59e0b; --danger: #ef4444; --ok: #22c55e;
}
}
:root[data-theme="dark"] {
--bg: #0f1216; --panel: #171b21; --fg: #e6e8eb; --muted: #9aa3ad; --line: #2a3139;
--accent: #3b82f6; --pill: #1e2a44; --hover: #1b2230; --warn: #f59e0b; --danger: #ef4444; --ok: #22c55e;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; gap: 12px; flex-wrap: wrap; }
h1 { font-size: 18px; margin: 0; } h1 .sub { font-weight: 400; color: var(--muted); font-size: 13px; margin-left: 8px; }
h2 { font-size: 16px; margin: 0; }
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
.muted { color: var(--muted); } .mono { font-family: var(--mono); font-size: 12px; white-space: pre-wrap; }
.btn { border: 1px solid var(--line); background: var(--panel); color: var(--fg); padding: 6px 10px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 13px; }
.btn:hover { background: var(--hover); } .btn.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.btn.ghost { border-color: transparent; } .btn.warn { color: var(--warn); } .btn.danger { color: var(--danger); }
.btn:disabled { opacity: .5; cursor: progress; }
.hdr-right { display: flex; gap: 8px; align-items: center; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
input[type=search], input[type=password] { padding: 7px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--fg); font: inherit; min-width: 280px; }
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr { cursor: pointer; } tbody tr:hover { background: var(--hover); }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: var(--pill); font-size: 12px; }
.pill.ready { color: var(--ok); } .pill.recommission, .pill.new { color: var(--warn); }
.ok { color: var(--ok); } .bad { color: var(--danger); }
.detail-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
.actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; padding: 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 14px; }
.sep { width: 1px; height: 20px; background: var(--line); margin: 0 6px; }
#action-out { margin-left: auto; color: var(--muted); max-width: 50%; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; overflow: auto; }
.card.wide { grid-column: 1 / -1; }
.card h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px; }
.kv dt { color: var(--muted); } .kv dd { margin: 0; word-break: break-all; }
.card table { border: none; } .card th, .card td { padding: 4px 8px; font-size: 13px; }
details { margin-top: 14px; } summary { cursor: pointer; color: var(--muted); }
dialog { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--fg); padding: 20px; min-width: 360px; }
dialog::backdrop { background: rgba(0,0,0,.45); } dialog menu { padding: 0; margin: 12px 0 0; display: flex; justify-content: flex-end; }
dialog input { width: 100%; margin-top: 8px; }
@media (max-width: 720px) { main { padding: 16px 16px; } .grid { grid-template-columns: 1fr; } input[type=search] { min-width: 0; width: 100%; } table { display: block; overflow-x: auto; } }
+15
View File
@@ -0,0 +1,15 @@
{
"listen": "10.213.77.1:80",
"image_dir": "/srv/rollcall/image",
"tftp_root": "/srv/tftp",
"database_url": "postgres://rollcall:<PGPASS>@127.0.0.1:5432/rollcall?sslmode=disable",
"master_key_file": "/etc/rollcall/master.key",
"admin_token": "<ADMIN-TOKEN: rollcall keygen>",
"agent_token": "<AGENT-TOKEN: rollcall keygen>",
"commission_append": "boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs rollcall.server=http://10.213.77.1 rollcall.token=<AGENT-TOKEN> console=tty0 console=ttyS0,115200n8",
"ipmitool": "ipmitool",
"bmc_hints": [
{"mac": "bc:24:11:8e:1e:6c", "address": "10.213.77.11", "port": 623, "user": "admin", "password": "password", "comment": "pxe-node01 (VM 211, pve-vbmc)"},
{"mac": "bc:24:11:36:90:3f", "address": "10.213.77.12", "port": 623, "user": "admin", "password": "password", "comment": "pxe-node02 (VM 212, pve-vbmc)"}
]
}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Install the rollcall binary on pxe-server. Run as root from this directory.
# Prereqs: PostgreSQL running locally with role+db "rollcall";
# /etc/rollcall/config.json present (see config.example.json).
set -eu
cd "$(dirname "$0")"
getent passwd rollcall >/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group rollcall
install -d -m 0750 -o root -g rollcall /etc/rollcall
install -d /srv/rollcall/image
[ -x ./rollcall ] && install -m 0755 rollcall /usr/local/bin/rollcall
install -m 0644 rollcall.service /etc/systemd/system/
[ -e /etc/rollcall/config.json ] || { echo "write /etc/rollcall/config.json first (see config.example.json)" >&2; exit 1; }
chown root:rollcall /etc/rollcall/config.json && chmod 0640 /etc/rollcall/config.json
# master key is created by the service on first start; the dir must be writable for that
chown rollcall:rollcall /etc/rollcall
# rollcall writes per-MAC pxelinux configs
chgrp rollcall /srv/tftp/pxelinux.cfg && chmod 2775 /srv/tftp/pxelinux.cfg
install -m 0644 /usr/lib/syslinux/modules/bios/poweroff.c32 /srv/tftp/poweroff.c32
sed "s/@AGENT_TOKEN@/$(jq -r .agent_token /etc/rollcall/config.json)/g" pxelinux-default > /srv/tftp/pxelinux.cfg/default && chmod 0644 /srv/tftp/pxelinux.cfg/default
[ -e /srv/tftp/rollcall ] || ln -s /srv/rollcall/image /srv/tftp/rollcall
[ -d /srv/tftp/efi64 ] && ln -sfn ../rollcall /srv/tftp/efi64/rollcall
# old python prototype
rm -f /usr/local/bin/rollcall /usr/local/bin/commctl
systemctl daemon-reload
systemctl enable --now rollcall.service
systemctl restart rollcall.service
sleep 1
systemctl --no-pager status rollcall.service | head -5
+27
View File
@@ -0,0 +1,27 @@
# /srv/tftp/pxelinux.cfg/default - unknown machines land here.
# Commissioned machines get a per-MAC file (01-xx-xx-...) written by rollcall.
DEFAULT menu.c32
PROMPT 0
TIMEOUT 80
MENU TITLE pxe-server (10.213.77.1) - unknown machine: commissioning in 8s
LABEL commission
MENU LABEL Commission this machine (inventory + BMC account)
MENU DEFAULT
KERNEL rollcall/vmlinuz
INITRD rollcall/initrd.img
APPEND boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs rollcall.server=http://10.213.77.1 rollcall.token=@AGENT_TOKEN@ console=tty0 console=ttyS0,115200n8
LABEL commission-debug
MENU LABEL Commission, but stay up afterwards (ssh root@<ip>)
KERNEL rollcall/vmlinuz
INITRD rollcall/initrd.img
APPEND boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs rollcall.server=http://10.213.77.1 rollcall.token=@AGENT_TOKEN@ rollcall.nopoweroff console=tty0 console=ttyS0,115200n8
LABEL netbootxyz
MENU LABEL netboot.xyz (needs internet via NAT)
KERNEL netboot.xyz.lkrn
LABEL local
MENU LABEL Boot from local disk
LOCALBOOT 0
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=rollcall - MAAS-like commissioning server (API, web UI)
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
ExecStart=/usr/local/bin/rollcall serve -config /etc/rollcall/config.json
Restart=always
RestartSec=3
User=rollcall
Group=rollcall
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/etc/rollcall /srv/tftp/pxelinux.cfg
[Install]
WantedBy=multi-user.target
+86
View File
@@ -0,0 +1,86 @@
#cloud-config
hostname: pxe-server
fqdn: pxe-server.mynodes.xyz
manage_etc_hosts: true
users:
- name: ilker
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDSAJ4owXlJreHP3sa4HstasxA5pk1ZAPi6vnzOpA5R1 ilker@t440s
package_update: true
packages:
- qemu-guest-agent
- dnsmasq
- pxelinux
- syslinux-common
- syslinux-efi
- nftables
- curl
write_files:
- path: /etc/dnsmasq.d/pxe.conf
content: |
# PXE/DHCP for SDN vnet pxenet (10.213.77.0/24). Only bound to the pxenet leg.
interface=__PXEIF__
bind-interfaces
domain-needed
bogus-priv
domain=pxe.local
local=/pxe.local/
expand-hosts
dhcp-range=10.213.77.100,10.213.77.200,255.255.255.0,12h
dhcp-option=option:router,10.213.77.1
dhcp-option=option:dns-server,10.213.77.1
dhcp-authoritative
enable-tftp
tftp-root=/srv/tftp
dhcp-match=set:efi64,option:client-arch,7
dhcp-match=set:efi64,option:client-arch,9
dhcp-match=set:bios,option:client-arch,0
dhcp-boot=tag:bios,pxelinux.0
dhcp-boot=tag:efi64,efi64/syslinux.efi
log-dhcp
- path: /srv/tftp/pxelinux.cfg/default
content: |
DEFAULT menu.c32
PROMPT 0
TIMEOUT 300
MENU TITLE pxe-server (10.213.77.1) - PXE menu
LABEL netbootxyz
MENU LABEL netboot.xyz (internet gerekir, NAT uzerinden)
KERNEL netboot.xyz.lkrn
LABEL local
MENU LABEL Yerel diskten boot
LOCALBOOT 0
- path: /etc/nftables.conf
permissions: '0755'
content: |
#!/usr/sbin/nft -f
flush ruleset
table ip nat {
chain postrouting {
type nat hook postrouting priority 100;
oifname "__EXTIF__" ip saddr 10.213.77.0/24 masquerade
}
}
- path: /etc/sysctl.d/99-pxe-forward.conf
content: |
net.ipv4.ip_forward=1
runcmd:
- sysctl --system
- PXEIF=$(ip -o -4 addr show | awk '/10\.213\.77\.1\//{print $2}'); sed -i "s/__PXEIF__/$PXEIF/" /etc/dnsmasq.d/pxe.conf
- EXTIF=$(ip -o -4 route show default | awk '{print $5; exit}'); sed -i "s/__EXTIF__/$EXTIF/" /etc/nftables.conf
- mkdir -p /srv/tftp/efi64
- cp /usr/lib/PXELINUX/pxelinux.0 /srv/tftp/
- cp /usr/lib/syslinux/modules/bios/ldlinux.c32 /usr/lib/syslinux/modules/bios/menu.c32 /usr/lib/syslinux/modules/bios/libutil.c32 /usr/lib/syslinux/modules/bios/libcom32.c32 /srv/tftp/
- cp /usr/lib/SYSLINUX.EFI/efi64/syslinux.efi /srv/tftp/efi64/
- cp /usr/lib/syslinux/modules/efi64/ldlinux.e64 /usr/lib/syslinux/modules/efi64/menu.c32 /usr/lib/syslinux/modules/efi64/libutil.c32 /usr/lib/syslinux/modules/efi64/libcom32.c32 /srv/tftp/efi64/
- ln -sfn ../pxelinux.cfg /srv/tftp/efi64/pxelinux.cfg
- curl -fsSL --retry 3 -o /srv/tftp/netboot.xyz.lkrn https://boot.netboot.xyz/ipxe/netboot.xyz.lkrn || true
- systemctl enable --now nftables
- systemctl enable --now qemu-guest-agent
- systemctl restart dnsmasq
- systemctl enable dnsmasq
+12
View File
@@ -0,0 +1,12 @@
{
"api": "https://10.0.0.10:8006",
"node": "pve1",
"token": "root@pam!vbmc=<TOKEN-SECRET>",
"verify_tls": false,
"user": "admin",
"password": "password",
"bmcs": [
{"vmid": 211, "address": "10.213.77.11", "port": 623},
{"vmid": 212, "address": "10.213.77.12", "port": 623}
]
}
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Run on pxe-server as root from the directory containing these files.
# Expects /etc/pve-vbmc/config.json to exist already (see config.example.json).
set -eu
cd "$(dirname "$0")"
getent passwd pve-vbmc >/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group pve-vbmc
install -m 0755 pve_vbmc.py /usr/local/bin/pve_vbmc.py
install -m 0755 pve-vbmc-addrs.sh /usr/local/bin/pve-vbmc-addrs.sh
install -m 0644 pve-vbmc@.service pve-vbmc-addrs.service /etc/systemd/system/
chgrp pve-vbmc /etc/pve-vbmc/config.json && chmod 0640 /etc/pve-vbmc/config.json
systemctl daemon-reload
systemctl enable --now pve-vbmc-addrs.service
for v in $(python3 -c 'import json;print(" ".join(str(b["vmid"]) for b in json.load(open("/etc/pve-vbmc/config.json"))["bmcs"]))'); do
systemctl enable --now "pve-vbmc@$v.service"
done
systemctl --no-pager --type=service | grep pve-vbmc
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=pve-vbmc: add one BMC IP alias per VM on the pxenet interface
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/pve-vbmc-addrs.sh add
ExecStop=/usr/local/bin/pve-vbmc-addrs.sh del
[Install]
WantedBy=multi-user.target
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Adds/removes the BMC listen addresses from /etc/pve-vbmc/config.json
# as secondary IPs on the interface that carries the PXE subnet.
set -eu
CONF=/etc/pve-vbmc/config.json
PXE_IF=$(ip -o -4 addr show | awk '/10\.213\.77\.1\//{print $2; exit}')
[ -n "$PXE_IF" ] || { echo "pxenet interface not found" >&2; exit 1; }
for a in $(python3 -c 'import json;print(" ".join(b["address"] for b in json.load(open("'"$CONF"'"))["bmcs"]))'); do
case "$1" in
add) ip -4 addr replace "$a/24" dev "$PXE_IF" label "$PXE_IF:bmc" ;;
del) ip -4 addr del "$a/24" dev "$PXE_IF" 2>/dev/null || true ;;
esac
done
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=pve-vbmc virtual BMC for Proxmox VM %i
After=network-online.target pve-vbmc-addrs.service
Wants=network-online.target
Requires=pve-vbmc-addrs.service
[Service]
Type=simple
ExecStart=/usr/local/bin/pve_vbmc.py --config /etc/pve-vbmc/config.json --vmid %i
Restart=always
RestartSec=3
AmbientCapabilities=CAP_NET_BIND_SERVICE
User=pve-vbmc
Group=pve-vbmc
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""pve-vbmc: minimal virtual BMC (IPMI-over-LAN) for Proxmox VE VMs.
One process serves one VM. It speaks IPMI 2.0 LAN via pyghmi and translates
chassis power / boot-device commands into Proxmox API calls.
Supported ipmitool commands:
power status|on|off|soft|cycle|reset
chassis bootdev pxe|disk|cdrom|none
chassis status, mc info, mc guid
"""
import argparse
import ipaddress
import json
import logging
import sys
import time
import uuid
import requests
import urllib3
from pyghmi.ipmi.bmc import Bmc
log = logging.getLogger("pve-vbmc")
class PveBmc(Bmc):
def __init__(self, cfg, entry):
self.api = cfg["api"].rstrip("/")
self.node = cfg["node"]
self.vmid = int(entry["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)
self.bootdev = "default"
self.guid = uuid.uuid5(uuid.NAMESPACE_DNS, f"pve-vbmc-{self.node}-{self.vmid}")
addr = ipaddress.ip_address(entry["address"])
listen = f"::ffff:{addr}" if addr.version == 4 else str(addr)
authdata = {entry.get("user", cfg.get("user", "admin")):
entry.get("password", cfg.get("password", "password"))}
super().__init__(authdata, port=int(entry.get("port", 623)), address=listen)
log.info("BMC for VM %s listening on %s:%s", self.vmid, addr, entry.get("port", 623))
# --- Proxmox API helpers -------------------------------------------------
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()
return r.json()["data"]
def _wait_task(self, upid, timeout=60):
url = f"{self.api}/api2/json/nodes/{self.node}/tasks/{upid}/status"
for _ in range(timeout * 2):
r = self.http.get(url, timeout=10)
r.raise_for_status()
if r.json()["data"]["status"] == "stopped":
return r.json()["data"].get("exitstatus")
time.sleep(0.5)
return "timeout"
# --- pyghmi Bmc interface -------------------------------------------------
def get_system_guid(self):
return self.guid
def get_power_state(self):
return "on" if self._get("status/current")["status"] == "running" else "off"
def is_active(self):
return self.get_power_state() == "on"
def power_on(self):
if self.get_power_state() == "off":
log.info("VM %s: power on", self.vmid)
self._post("status/start")
def power_off(self):
if self.get_power_state() == "on":
log.info("VM %s: power off (hard)", self.vmid)
self._post("status/stop")
def power_shutdown(self):
log.info("VM %s: soft shutdown", self.vmid)
self._post("status/shutdown", forceStop=1, timeout=60)
def power_reset(self):
if self.get_power_state() == "on":
log.info("VM %s: reset", self.vmid)
self._post("status/reset")
else:
self.power_on()
def power_cycle(self):
log.info("VM %s: power cycle", self.vmid)
if self.get_power_state() == "on":
self._wait_task(self._post("status/stop"))
self._post("status/start")
def cold_reset(self):
return 0 # BMC reset is a no-op
def pulse_diag(self):
raise NotImplementedError
def get_boot_device(self):
return self.bootdev
def set_boot_device(self, bootdevice):
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())
if bootdevice == "network":
order = nets + disks + cds
elif bootdevice == "hd":
order = disks + nets + cds
elif bootdevice == "optical":
order = cds + disks + nets
else: # default / none / safe / setup ...
order = disks + cds + nets
if order:
log.info("VM %s: bootdev %s -> order=%s", self.vmid, bootdevice, ";".join(order))
self._put("config", boot="order=" + ";".join(order))
self.bootdev = bootdevice
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="/etc/pve-vbmc/config.json")
ap.add_argument("--vmid", type=int, required=True)
ap.add_argument("--debug", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s")
with open(args.config) as f:
cfg = json.load(f)
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)
bmc = PveBmc(cfg, entry)
bmc.listen()
if __name__ == "__main__":
main()
+199
View File
@@ -0,0 +1,199 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rollcall</title>
<meta name="description" content="Rollcall: MAAS-style bare-metal commissioning in one static binary. PXE boot, inventory, BMC account, PostgreSQL, web UI, CLI.">
<style>
:root {
--bg: #f7f8fa; --panel: #ffffff; --fg: #15181e; --muted: #5f6b7a; --line: #e2e6eb;
--accent: #1d4ed8; --accent-soft: #e8efff; --ok: #15803d; --code-bg: #0f172a; --code-fg: #e2e8f0;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #9aa7b4; --line: #2a323c;
--accent: #5b8def; --accent-soft: #15223d; --ok: #3fb950; --code-bg: #0a0f1a; --code-fg: #d7e0ea;
}
}
:root[data-theme="dark"] {
--bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #9aa7b4; --line: #2a323c;
--accent: #5b8def; --accent-soft: #15223d; --ok: #3fb950; --code-bg: #0a0f1a; --code-fg: #d7e0ea;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 16px; }
nav { display: flex; align-items: center; justify-content: space-between; padding: 14px 0; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); z-index: 5; }
nav .brand { font-weight: 700; letter-spacing: .01em; color: var(--fg); display: flex; align-items: center; gap: 10px; }
nav .brand svg { width: 26px; height: 26px; }
nav ul { list-style: none; display: flex; gap: 18px; margin: 0; padding: 0; font-size: 14px; }
@media (max-width: 640px) { nav ul { display: none; } }
header.hero { padding: 64px 0 40px; }
.hero h1 { font-size: clamp(32px, 5vw, 48px); line-height: 1.1; margin: 0 0 14px; letter-spacing: -.02em; }
.hero p.lead { font-size: 19px; color: var(--muted); max-width: 720px; margin: 0 0 24px; }
.cta { display: flex; gap: 12px; flex-wrap: wrap; }
.btn { display: inline-block; padding: 10px 16px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--fg); font-weight: 600; font-size: 15px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn:hover { text-decoration: none; filter: brightness(1.05); }
.badges { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 20px; }
.badge { font-size: 12px; padding: 3px 9px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
section { padding: 36px 0; border-top: 1px solid var(--line); }
h2 { font-size: 24px; margin: 0 0 6px; letter-spacing: -.01em; }
.sub { color: var(--muted); margin: 0 0 22px; }
.flow { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; counter-reset: step; }
.flow .step { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; position: relative; }
.flow .step::before { counter-increment: step; content: counter(step); position: absolute; top: -10px; left: 12px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; }
.flow .step h3 { margin: 6px 0 4px; font-size: 15px; } .flow .step p { margin: 0; color: var(--muted); font-size: 14px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
.card h3 { margin: 0 0 6px; font-size: 16px; } .card p { margin: 0; color: var(--muted); font-size: 14.5px; }
pre { background: var(--code-bg); color: var(--code-fg); padding: 14px 16px; border-radius: 10px; overflow-x: auto; font: 13px/1.5 var(--mono); margin: 0; }
code { font-family: var(--mono); font-size: .92em; background: var(--accent-soft); padding: 1px 5px; border-radius: 4px; }
pre code { background: none; padding: 0; font-size: inherit; }
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; font-size: 14px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; }
td code { white-space: nowrap; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } @media (max-width: 760px) { .two { grid-template-columns: 1fr; } }
.note { border-left: 3px solid var(--accent); padding: 8px 14px; background: var(--accent-soft); border-radius: 0 8px 8px 0; font-size: 14.5px; }
footer { padding: 28px 0 40px; color: var(--muted); font-size: 14px; border-top: 1px solid var(--line); }
.term { font-family: var(--mono); font-size: 13px; }
.term .ok { color: var(--ok); }
</style>
</head>
<body>
<div class="wrap">
<nav>
<a class="brand" href="#">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="4" width="26" height="6" rx="2" fill="var(--accent)"/><rect x="3" y="13" width="26" height="6" rx="2" fill="var(--accent)" opacity=".7"/><rect x="3" y="22" width="26" height="6" rx="2" fill="var(--accent)" opacity=".4"/><circle cx="8" cy="7" r="1.6" fill="#fff"/><circle cx="8" cy="16" r="1.6" fill="#fff"/><circle cx="8" cy="25" r="1.6" fill="#fff"/></svg>
Rollcall
</a>
<ul>
<li><a href="#how">How it works</a></li>
<li><a href="#features">Features</a></li>
<li><a href="#quickstart">Quick start</a></li>
<li><a href="#api">API</a></li>
<li><a href="https://gitea.mynodes.xyz/ilker/rollcall">Source</a></li>
</ul>
</nav>
<header class="hero">
<h1>Bare-metal commissioning,<br>one static binary.</h1>
<p class="lead">Rollcall does what MAAS does when a new server first shows up: PXE-boot an ephemeral image, take a full hardware inventory, create an administrator account on the BMC, and remember the machine. No cloud controller, no agents on your OS, no fleet of services.</p>
<div class="cta">
<a class="btn primary" href="#quickstart">Quick start</a>
<a class="btn" href="https://gitea.mynodes.xyz/ilker/rollcall">Repository</a>
</div>
<div class="badges">
<span class="badge">Go · single binary</span><span class="badge">PostgreSQL</span><span class="badge">Debian live image</span>
<span class="badge">IPMI / ipmitool</span><span class="badge">InfiniBand aware</span><span class="badge">Web UI + CLI + API</span>
</div>
</header>
<section id="how">
<h2>How it works</h2>
<p class="sub">A machine goes from unknown to <em>ready</em> in one boot, without anyone typing anything.</p>
<div class="flow">
<div class="step"><h3>PXE boot</h3><p>dnsmasq hands out DHCP and TFTP. Unknown MACs land on the default pxelinux menu, which starts commissioning after 8 seconds.</p></div>
<div class="step"><h3>Ephemeral image</h3><p>A Debian 13 live image (full kernel, non-free firmware, IB and NVMe tooling) is fetched over HTTP and runs from RAM. Disks are never touched.</p></div>
<div class="step"><h3>Inventory</h3><p>DMI, DIMMs, CPUs, disks with SMART, NVMe, NICs with firmware and link, InfiniBand HCAs, GPUs, every PCI device, LLDP switch ports, TPM.</p></div>
<div class="step"><h3>BMC account</h3><p>In-band <code>ipmitool</code> finds the LAN channel and creates <code>commissioner</code> with ADMINISTRATOR rights and a random password.</p></div>
<div class="step"><h3>Report &amp; power off</h3><p>Everything is posted to the server. The password is sealed with AES-256-GCM before it reaches PostgreSQL. The machine powers off.</p></div>
<div class="step"><h3>Known from now on</h3><p>A per-MAC PXE file stops future boots of the image. Power and boot device are controlled through the API, UI or CLI, with ipmitool running on the server.</p></div>
</div>
</section>
<section id="features">
<h2>Features</h2>
<p class="sub">Small surface, enterprise hardware in mind.</p>
<div class="grid">
<div class="card"><h3>One binary, everything inside</h3><p>HTTP API, embedded web UI, CLI subcommands, schema migrations and PXE bookkeeping ship as a single static Go executable. Deploy with <code>scp</code>.</p></div>
<div class="card"><h3>Secrets stay on the server</h3><p>BMC passwords are encrypted at rest with a local master key, never returned by the API, and only ever used by ipmitool inside the server process.</p></div>
<div class="card"><h3>Real hardware first</h3><p>The image carries firmware for Broadcom, QLogic, Intel and Mellanox NICs, <code>rdma-core</code>, <code>infiniband-diags</code>, <code>mstflint</code>, <code>nvme-cli</code>, <code>smartmontools</code>, <code>lldpd</code>.</p></div>
<div class="card"><h3>Virtual machines too</h3><p>No in-band IPMI? Rollcall can map MACs to known BMCs. The repo ships <code>pve-vbmc</code>, an IPMI BMC emulator for Proxmox VMs, so the whole flow runs in a lab.</p></div>
<div class="card"><h3>Two tokens, two audiences</h3><p>The live image authenticates with an agent token baked into the kernel command line; people and automation use a separate admin bearer token.</p></div>
<div class="card"><h3>Re-commission on demand</h3><p>Drop the PXE override, set <code>bootdev pxe</code> and power-cycle through the BMC with one click or <code>rollcall recommission ID -now</code>.</p></div>
</div>
</section>
<section id="quickstart">
<h2>Quick start</h2>
<p class="sub">On the host that already runs your DHCP/TFTP for the provisioning network.</p>
<div class="two">
<div>
<pre><code># 1. database
sudo -u postgres psql \
-c "CREATE ROLE rollcall LOGIN PASSWORD 'secret';" \
-c "CREATE DATABASE rollcall OWNER rollcall;"
# 2. config
rollcall keygen # admin_token
rollcall keygen # agent_token
sudo install -d -m 0750 /etc/rollcall
sudo cp config.json /etc/rollcall/config.json
# 3. install binary, unit, pxelinux menu
cd deploy &amp;&amp; sudo ./install.sh
# 4. build the live image (~8 min)
sudo ../agent/build-image.sh</code></pre>
</div>
<div>
<pre class="term"><code>$ rollcall list
ID STATE MODEL RAM NIC IB BMC
m-2ef1dab828c8 ready Dell PowerEdge R760 1.0T 4 2 10.4.0.21 (inband,ok)
m-9c01a3bd7e12 ready Supermicro SYS-421GE 2.0T 6 4 10.4.0.22 (inband,ok)
$ rollcall power m-2ef1dab828c8 status
<span class="ok">Chassis Power is on</span>
$ rollcall show m-2ef1dab828c8 | head -3
m-2ef1dab828c8 state=ready
Dell Inc. PowerEdge R760 S/N 7XK2Q94
CPU: Intel Xeon Platinum 8468 sockets=2 ...</code></pre>
<p class="note" style="margin-top:12px">Then point the UI at <code>http://&lt;server&gt;/</code> and paste the admin token. Machines appear as they boot.</p>
</div>
</div>
</section>
<section id="api">
<h2>API</h2>
<p class="sub">JSON over HTTP. Admin calls carry <code>Authorization: Bearer &lt;admin_token&gt;</code>; the agent sends <code>X-Rollcall-Token</code>.</p>
<table>
<thead><tr><th>Method</th><th>Path</th><th>Who</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td>POST</td><td><code>/api/v1/commission</code></td><td>agent</td><td>Inventory upload from the live image</td></tr>
<tr><td>GET</td><td><code>/api/v1/bmc-hint?macs=</code></td><td>agent</td><td>Out-of-band BMC hint for machines without in-band IPMI</td></tr>
<tr><td>GET</td><td><code>/api/v1/machines</code></td><td>admin</td><td>List with summaries (CPU, RAM, disks, NICs, IB, GPUs, BMC state)</td></tr>
<tr><td>GET</td><td><code>/api/v1/machines/{id}</code></td><td>admin</td><td>Full record: inventory, BMC (without secret), events</td></tr>
<tr><td>POST</td><td><code>/api/v1/machines/{id}/power</code></td><td>admin</td><td><code>{"action":"on|off|soft|cycle|reset|status"}</code></td></tr>
<tr><td>POST</td><td><code>/api/v1/machines/{id}/bootdev</code></td><td>admin</td><td><code>{"device":"pxe|disk|cdrom|bios|none"}</code></td></tr>
<tr><td>POST</td><td><code>/api/v1/machines/{id}/recommission[?now=1]</code></td><td>admin</td><td>Boot the image again on next PXE, or right now via the BMC</td></tr>
<tr><td>DELETE</td><td><code>/api/v1/machines/{id}</code></td><td>admin</td><td>Forget the machine</td></tr>
</tbody>
</table>
</section>
<section id="inventory">
<h2>What the inventory contains</h2>
<p class="sub">Collected by <code>rollcall-agent.py</code> inside the live image and stored as JSON per machine.</p>
<div class="grid">
<div class="card"><h3>System</h3><p>Manufacturer, product, serial, UUID, SKU, baseboard, chassis, asset tag, BIOS vendor/version/date, TPM.</p></div>
<div class="card"><h3>Compute</h3><p>CPU model, sockets, cores, threads, NUMA nodes, caches, virtualization flags; memory total plus every DIMM slot with size, type, speed, vendor, part and serial.</p></div>
<div class="card"><h3>Storage</h3><p>All block devices with model, serial, bus, rotational flag, sector sizes, SMART health and firmware, NVMe list, storage controllers from PCI.</p></div>
<div class="card"><h3>Network</h3><p>NICs with driver, firmware, speed, link state, permanent MAC, PCI address, NUMA node, SR-IOV capacity; LLDP neighbours to map switch ports.</p></div>
<div class="card"><h3>InfiniBand</h3><p>HCAs with node and port GUIDs, firmware, board id, per-port state, physical state, rate, LID and link layer.</p></div>
<div class="card"><h3>BMC</h3><p>LAN channel, IP source, MAC, gateway, VLAN, firmware, FRU, user table before commissioning, created account and verification result.</p></div>
</div>
</section>
<footer>
Rollcall is developed in the open at <a href="https://gitea.mynodes.xyz/ilker/rollcall">gitea.mynodes.xyz/ilker/rollcall</a>.
Built with Go, Debian live-build, dnsmasq and ipmitool.
</footer>
</div>
</body>
</html>