Security review: fix traversal, identity, injection and takeover paths
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 34s

Findings from a full review (auth/API, CA and rack channel, config
generation, integrations, UI/agents/scripts), each verified in code:

- rack binary endpoint read any region file via arch=../.. (CA key)
- render endpoint traversal; machine-reported MACs used as file paths
- delegated intermediates could mint admin/rack identities and IPs;
  identities now require root-issued certs, intermediates are
  ServerAuth-only with both name types constrained
- rack certs were ServerAuth for rack-chosen SANs: now client-only;
  racks accept only the region's own cert; join checks the CA
  fingerprint; unbound join codes cannot take an existing name; job
  results bound to the rack that took them
- rack self-update: region signs binaries with the CA key, the root
  unit verifies against the join-time CA in a root-owned dir
- dnsmasq reads a checked, root-owned copy limited to generated options
- commissioning could overwrite deployed machines; MAC takeover of PXE
- injection into dnsmasq/cloud-init/PXE/udev/DNS via names and
  machine-reported strings; stored XSS in the UI; CSP and cross-origin
  protection; deploy agent shell injection and seed traversal
- Redfish requests confined to the BMC's /redfish/ tree
- admin_http defaults to loopback; body limits and timeouts; secrets
  off command lines

Also: IPAM claim/reconcile race, sync stopping on one error, watchdog
race, release wiping the wrong disk, SAN LUNs as auto targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 17:58:42 +01:00
1 parent 6e4be736ec
commit a549f01ef5
28 files changed
+782 -154

No files matched your search

+8 -4
View File
@@ -115,9 +115,9 @@ if [ -f "$CONF" ]; then
else
PGPASS=$(head -c 32 /dev/urandom | base64 | tr -dc A-Za-z0-9 | head -c 28)
if [ "$(runuser -u postgres -- psql -tAc "select 1 from pg_roles where rolname='rollcall'")" = 1 ]; then
runuser -u postgres -- psql -q -c "ALTER ROLE rollcall PASSWORD '$PGPASS'"
printf "ALTER ROLE rollcall PASSWORD '%s';\n" "$PGPASS" | runuser -u postgres -- psql -q # stdin: not in ps
else
runuser -u postgres -- psql -q -c "CREATE ROLE rollcall LOGIN PASSWORD '$PGPASS'"
printf "CREATE ROLE rollcall LOGIN PASSWORD '%s';\n" "$PGPASS" | runuser -u postgres -- psql -q
fi
[ "$(runuser -u postgres -- psql -tAc "select 1 from pg_database where datname='rollcall'")" = 1 ] || runuser -u postgres -- psql -q -c "CREATE DATABASE rollcall OWNER rollcall"
ok "role and database rollcall"
@@ -241,7 +241,11 @@ if [ -s $IMG/vmlinuz ] && [ -s $IMG/initrd.img ] && [ -s $IMG/filesystem.squashf
ok "present, kept"
elif [ -n "$IMAGE_URL" ]; then
tmp=$(mktemp -d -p /srv/rollcall)
auth=(); [ -n "${IMAGE_TOKEN:-}" ] && auth=(-H "Authorization: token $IMAGE_TOKEN")
auth=()
if [ -n "${IMAGE_TOKEN:-}" ]; then # the token goes through a file, not the command line
printf 'Authorization: token %s\n' "$IMAGE_TOKEN" > "$tmp/.auth" && chmod 600 "$tmp/.auth"
auth=(-H "@$tmp/.auth")
fi
# a release carries the image as commissioning-* (its plain SHA256SUMS is the binaries');
# a region's /rollcall directory has the plain names
pre=commissioning-
@@ -251,7 +255,7 @@ elif [ -n "$IMAGE_URL" ]; then
fi
for f in vmlinuz initrd.img filesystem.squashfs; do curl -fSL --progress-bar "${auth[@]}" -o "$tmp/$f" "$IMAGE_URL/$pre$f"; done
(cd "$tmp" && sha256sum -c --quiet SHA256SUMS) || die "checksum mismatch"
mv "$tmp"/vmlinuz "$tmp"/initrd.img "$tmp"/filesystem.squashfs "$tmp"/SHA256SUMS $IMG/ && rm -rf "$tmp"
mv "$tmp"/vmlinuz "$tmp"/initrd.img "$tmp"/filesystem.squashfs "$tmp"/SHA256SUMS $IMG/; rm -rf "$tmp"
chmod 0644 $IMG/*
ok "downloaded and verified from $IMAGE_URL"
elif [ $BUILD = 1 ]; then
+4 -2
View File
@@ -15,7 +15,7 @@ set -eu
cd "$(dirname "$0")"
[ -r /etc/rollcall/rack.json ] || { echo "enroll first: ./rollcall rack join CODE -url http://ADDR (code from: rollcall controller join-code on the region)" >&2; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get install -y -q dnsmasq ipmitool pxelinux syslinux-common syslinux-efi jq >/dev/null
apt-get install -y -q dnsmasq ipmitool pxelinux syslinux-common syslinux-efi jq openssl >/dev/null
getent passwd rollcall >/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group rollcall
[ -x ./rollcall ] && install -m 0755 rollcall /usr/local/bin/rollcall
@@ -48,7 +48,8 @@ install -d -o rollcall -g rollcall "$DNS" "$DNS/conf.d"
for f in hosts hosts.dhcp; do [ -e "$DNS/$f" ] || install -m 0644 -o rollcall -g rollcall /dev/null "$DNS/$f"; done
# bind-dynamic binds address by address, so systemd-resolved's 127.0.0.53 stub does not get in the way;
# until the region assigns a VLAN only loopback is served
printf '# rollcall rack controller: the configuration is generated from the region'"'"'s network model\nbind-dynamic\ninterface=lo\nconf-dir=%s/conf.d/,*.conf\n' "$DNS" > /etc/dnsmasq.d/rollcall.conf
printf '# rollcall rack controller: the configuration is generated from the region'"'"'s network model\nbind-dynamic\ninterface=lo\nconf-dir=/var/lib/rollcall-dnsmasq/,*.conf\n' > /etc/dnsmasq.d/rollcall.conf
install -d -m 0755 /var/lib/rollcall-dnsmasq
install -m 0755 rollcall-dnsmasq-reload.sh /usr/local/bin/rollcall-dnsmasq-reload.sh
install -m 0755 rollcall-rack-update.sh /usr/local/bin/rollcall-rack-update.sh
install -d -m 0755 -o rollcall -g rollcall /var/lib/rollcall/update
@@ -57,6 +58,7 @@ install -m 0644 rollcall-dnsmasq.path rollcall-dnsmasq.service rollcall-rack.ser
systemctl daemon-reload
systemctl enable dnsmasq.service rollcall-dnsmasq.path rollcall-rack-update.path >/dev/null 2>&1
systemctl start rollcall-rack-update.path
/usr/local/bin/rollcall-dnsmasq-reload.sh >/dev/null 2>&1 || true
systemctl restart dnsmasq.service || { journalctl -u dnsmasq -n 5 --no-pager >&2; exit 1; }
systemctl start rollcall-dnsmasq.path
systemctl enable --now rollcall-rack.service
+6 -3
View File
@@ -39,9 +39,12 @@ for f in hosts hosts.dhcp; do [ -e /var/lib/rollcall/dnsmasq/$f ] || install -m
install -m 0755 rollcall-dnsmasq-reload.sh /usr/local/bin/rollcall-dnsmasq-reload.sh
install -m 0644 rollcall-dnsmasq.path rollcall-dnsmasq.service /etc/systemd/system/
# the embedded rack controller writes conf.d/rollcall.conf once a VLAN with dhcp is assigned to it;
# a hand-written dnsmasq configuration for the same interface has to go then (see README, Network)
if [ -d /etc/dnsmasq.d ] && [ ! -e /etc/dnsmasq.d/rollcall.conf ]; then
printf '# rollcall: generated from the network model (rollcall subnet / vlan)\nconf-dir=/var/lib/rollcall/dnsmasq/conf.d/,*.conf\n' > /etc/dnsmasq.d/rollcall.conf
# a hand-written dnsmasq configuration for the same interface has to go then (see README, Network).
# dnsmasq (root) reads the checked copy rollcall-dnsmasq-reload.sh keeps in a directory of root's.
install -d -m 0755 /var/lib/rollcall-dnsmasq
if [ -d /etc/dnsmasq.d ]; then
printf '# rollcall: generated from the network model (rollcall subnet / vlan), checked by rollcall-dnsmasq-reload.sh\nconf-dir=/var/lib/rollcall-dnsmasq/,*.conf\n' > /etc/dnsmasq.d/rollcall.conf
/usr/local/bin/rollcall-dnsmasq-reload.sh >/dev/null 2>&1 || true
fi
legacy=$(grep -lE '^(dhcp-range|bind-interfaces|enable-tftp)' /etc/dnsmasq.conf /etc/dnsmasq.d/*.conf 2>/dev/null | grep -v '/rollcall.conf$' || true)
if [ -n "$legacy" ]; then
+6 -4
View File
@@ -8,15 +8,17 @@ TAG=${1:?usage: publish-image.sh TAG [API]}
API=${2:-https://gitea.mynodes.xyz/api/v1/repos/ilker/rollcall}
IMG=${IMG:-/srv/rollcall/image}
: "${GITEA_TOKEN:?set GITEA_TOKEN (write:repository)}"
HDR=$(mktemp); trap 'rm -f "$HDR"' EXIT
printf 'Authorization: token %s\n' "$GITEA_TOKEN" > "$HDR" # not on curl's command line (ps)
cd "$IMG"
sha256sum vmlinuz initrd.img filesystem.squashfs > SHA256SUMS
id=$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
existing=$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$API/releases/$id/assets" | python3 -c 'import json,sys;[print(a["id"],a["name"]) for a in json.load(sys.stdin)]')
id=$(curl -fsS -H "@$HDR" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
existing=$(curl -fsS -H "@$HDR" "$API/releases/$id/assets" | python3 -c 'import json,sys;[print(a["id"],a["name"]) for a in json.load(sys.stdin)]')
for f in vmlinuz initrd.img filesystem.squashfs SHA256SUMS; do
name=commissioning-$f
old=$(echo "$existing" | awk -v n="$name" '$2==n{print $1}')
[ -n "$old" ] && curl -fsS -X DELETE -H "Authorization: token $GITEA_TOKEN" "$API/releases/$id/assets/$old" >/dev/null
[ -n "$old" ] && curl -fsS -X DELETE -H "@$HDR" "$API/releases/$id/assets/$old" >/dev/null
echo "uploading $name ($(du -h "$f" | cut -f1))"
curl -fsS -H "Authorization: token $GITEA_TOKEN" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" >/dev/null
curl -fsS -H "@$HDR" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" >/dev/null
done
echo "done: bootstrap.sh --image-url ${API%/api/v1/repos/*}/$(echo "$API" | sed 's#.*/repos/##')/releases/download/$TAG"
+40 -8
View File
@@ -7,29 +7,61 @@
# some of those), the last configuration that worked is put back and dnsmasq
# is started with it, so DHCP stays up. The rejected content is kept next to it
# and not tried again until it changes.
#
# rollcall (an unprivileged user) writes $SRC; dnsmasq starts as root and reads
# only the copy in $LIVE, a directory of root's. The copy is made without
# following links and holds only the options rollcall generates: nothing that
# runs a program, includes another file or changes dnsmasq's user.
set -u
D=/var/lib/rollcall/dnsmasq
CONF=$D/conf.d/rollcall.conf
GOOD=$D/rollcall.conf.accepted
BAD=$D/rollcall.conf.rejected
SRC=/var/lib/rollcall/dnsmasq/conf.d/rollcall.conf
LIVE=/var/lib/rollcall-dnsmasq
CONF=$LIVE/rollcall.conf
GOOD=$LIVE/rollcall.conf.accepted
BAD=$LIVE/rollcall.conf.rejected
STAMP=/run/rollcall-dnsmasq.sum
ALLOWED='addn-hosts bind-dynamic bogus-priv dhcp-authoritative dhcp-boot dhcp-hostsfile dhcp-match dhcp-option dhcp-range domain domain-needed enable-ra enable-tftp expand-hosts interface local log-dhcp no-resolv pxe-service ra-param server tftp-root'
install -d -m 0755 -o root -g root "$LIVE"
umask 022
sum() { cat "$1" 2>/dev/null | sha256sum | cut -d' ' -f1; }
put() { cp "$1" "$CONF.tmp" && mv -f "$CONF.tmp" "$CONF"; } # within $LIVE: root's
restore() { # put the last working configuration back (or none)
if [ -s "$GOOD" ]; then cp "$GOOD" "$CONF.tmp" && mv -f "$CONF.tmp" "$CONF"; else rm -f "$CONF"; fi
if [ -s "$GOOD" ]; then put "$GOOD"; else rm -f "$CONF"; fi
sum "$CONF" > "$STAMP"
}
new=$(sum "$CONF")
if [ "$new" = "$(cat "$STAMP" 2>/dev/null)" ]; then
# take rollcall's file: a regular file, not a link, at most 1 MB
cand=$LIVE/candidate.tmp
rm -f "$cand"
if [ -e "$SRC" ]; then
if ! dd if="$SRC" of="$cand" iflag=nofollow bs=1M count=1 status=none 2>/dev/null; then
echo "$SRC is not a regular file; ignored" >&2
exit 1
fi
printf '%s\n' $ALLOWED > "$LIVE/allowed.tmp"
bad=$(grep -vE '^[[:space:]]*(#|$)' "$cand" | sed -E 's/^[[:space:]]*([^=[:space:]]*).*/\1/' | sort -u |
grep -vxFf "$LIVE/allowed.tmp" | tr '\n' ' ')
rm -f "$LIVE/allowed.tmp"
if [ -n "$bad" ]; then
rm -f "$cand"
echo "generated dnsmasq configuration has options rollcall does not write ($bad); not used" >&2
exit 1
fi
fi
new=$(sum "$cand")
if [ "$new" = "$(cat "$STAMP" 2>/dev/null)" ] && { [ -e "$CONF" ] || [ ! -e "$cand" ]; }; then
rm -f "$cand"
systemctl kill -s HUP dnsmasq.service
exit 0
fi
if [ -e "$CONF" ] && [ "$new" = "$(sum "$BAD")" ]; then
if [ -e "$cand" ] && [ "$new" = "$(sum "$BAD")" ]; then
rm -f "$cand"
restore
echo "generated dnsmasq configuration was rejected before and has not changed; keeping the last working one" >&2
exit 1
fi
if [ -e "$cand" ]; then mv -f "$cand" "$CONF"; else rm -f "$CONF"; fi
if out=$(dnsmasq --test 2>&1) && systemctl restart dnsmasq.service; then
echo "$new" > "$STAMP"
if [ -e "$CONF" ]; then cp "$CONF" "$GOOD"; else rm -f "$GOOD"; fi
+22 -10
View File
@@ -1,27 +1,39 @@
#!/bin/sh
# Installs a rollcall binary the rack controller staged (rollcall-rack-update.path).
# The rack checked the region's checksum and the binary's version; this checks
# them again, keeps the running binary as .prev, restarts the rack and puts the
# previous binary back if the rack does not register with the region in 60 s.
#
# The staging directory belongs to the rack's unprivileged user, so nothing in
# it is trusted: the binary is copied into a directory of root's and installed
# only if the region's signature over it verifies against the CA certificate
# written at join (root's, from rack.json ca_file). Then the running binary is
# kept as .prev, the rack restarted, and the previous binary put back if the
# rack does not register with the region in 60 s.
set -u
D=/var/lib/rollcall/update
BIN=/usr/local/bin/rollcall
[ -f "$D/ready" ] || exit 0
want=$(cat "$D/ready"); rm -f "$D/ready"
cd "$D" || exit 1
result() { echo "$(date -Is) $*" > "$D/last-result"; echo "$*"; }
sha256sum -c rollcall.new.sha256 >/dev/null 2>&1 || { result "$want: checksum mismatch, not installed"; exit 1; }
got=$(./rollcall.new version 2>/dev/null | awk '{print $2}')
want=$(head -c 64 "$D/ready" | tr -cd 'A-Za-z0-9._+-'); rm -f "$D/ready"
W=$(mktemp -d /run/rollcall-update.XXXXXX) || exit 1
trap 'rm -rf "$W"' EXIT
# last-result is read by the rack (backoff); renamed in, so a link planted there is replaced, not followed
result() { echo "$(date -Is) $*" > "$W/result" && mv -fT "$W/result" "$D/last-result"; echo "$*"; }
CA=$(jq -r '.ca_file // "/etc/rollcall/rack/ca.pem"' /etc/rollcall/rack.json 2>/dev/null)
[ -n "$CA" ] && [ "$(stat -c %u "$CA" 2>/dev/null)" = 0 ] || { result "$want: no root-owned CA certificate ($CA), not installed"; exit 1; }
cp -- "$D/rollcall.new" "$W/bin" && cp -- "$D/rollcall.new.sig" "$W/sig" || { result "$want: staged files missing, not installed"; exit 1; }
openssl x509 -in "$CA" -pubkey -noout > "$W/pub" 2>/dev/null &&
openssl dgst -sha256 -verify "$W/pub" -signature "$W/sig" "$W/bin" >/dev/null 2>&1 ||
{ result "$want: signature does not verify against $CA, not installed"; exit 1; }
chmod 0755 "$W/bin"
got=$("$W/bin" version 2>/dev/null | awk '{print $2}')
[ "$got" = "$want" ] || { result "$want: binary reports '$got', not installed"; exit 1; }
cur=$("$BIN" version 2>/dev/null | awk '{print $2}')
cp -p "$BIN" "$BIN.prev"
install -m 0755 rollcall.new "$BIN.tmp" && mv -f "$BIN.tmp" "$BIN"
install -m 0755 "$W/bin" "$BIN.tmp" && mv -f "$BIN.tmp" "$BIN"
rm -f "$D/rollcall.new" "$D/rollcall.new.sha256" "$D/rollcall.new.sig"
since=$(date +%s)
systemctl restart rollcall-rack.service
for _ in $(seq 1 30); do
sleep 2
if journalctl -u rollcall-rack.service --since "@$since" -o cat 2>/dev/null | grep -q "^.*registered with"; then
rm -f rollcall.new rollcall.new.sha256
result "updated $cur -> $want"
exit 0
fi