Files
rollcall/lab/netbox/install.sh
T
Ilker ManapandClaude Opus 5.5 0569ecf1cc
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 49s
Review fixes before v0.4.0: NetBox on its own origin, pinned root refresh
From a review of everything since v0.3.0:

- NetBox's UI was served on Rollcall's origin, so a script in a NetBox
  page (stored XSS, a custom link) could read the Rollcall UI's stored
  admin token and call Rollcall's API with the admin certificate. It now
  has a listener of its own (ipam.ui_listen, default :8444): another
  origin. The Rollcall UI builds its NetBox links from that port.
- The proxy dropped X-Rollcall-User but not X-Rollcall_User, which a
  WSGI server reads as the same HTTP_X_ROLLCALL_USER; every spelling is
  dropped now.
- Deployed machines fetched the root bundle over TLS checked against the
  system's trust store: a public CA's certificate for the rack's name
  would have done. The refresh is pinned to the Rollcall roots installed
  on the machine and takes only CA certificates (tried on node001 and
  node002).
- A NetBox device type NetBox will not resize (0U while its devices have
  positions) no longer stops those machines' device sync; it is logged.
- A NetBox cable on a device whose machine is gone here rolled back all
  cable and switch sync; such cables are skipped.
- Switch names are looked up in NetBox 50 at a time (a large site would
  outgrow one URL).
- rack_units: "vendor model" wins over the bare model when both are set
  (map order chose before); cable precedence comments match the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:35:45 +01:00

184 lines
9.5 KiB
Bash

#!/bin/bash
# Lab NetBox for Rollcall's NetBox drivers (IPAM, device sync): a Debian 13
# container on the lab segment, so tests never write to a real NetBox.
# Same install as netops (altyapi hosts/netops/kurulum.sh) without PowerDNS,
# ACME and backups; plain HTTP on :80 (the segment is isolated).
# NetBox lives under /netbox/ (BASE_PATH) so the region can serve its UI
# through Rollcall (cmd/rollcall/nbproxy.go): Rollcall's ipam url is then
# http://$MYIP/netbox/, and ROLLCALL_ORIGINS are where browsers post from.
# The admin certificate signs in to NetBox through that proxy: NetBox takes
# the user from X-Rollcall-User (REMOTE_AUTH), and Caddy drops that header
# unless the request comes from ROLLCALL_IP with ROLLCALL_UI_SECRET (copy it
# to the region's ipam.ui_login_secret_file). Such users land in the group
# rollcall-admins, which may do everything (a lab).
#
# Run as root inside the container; rerunnable. Secrets are generated here:
# /etc/lab-netbox/secrets.env (admin password, Rollcall's API token)
#
# On pve1 (example):
# pct create 225 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst --hostname lab-netbox \
# --cores 2 --memory 2048 --swap 512 --rootfs zfspool:8 --unprivileged 1 \
# --net0 name=eth0,bridge=pxenet,ip=10.213.77.6/24,gw=10.213.77.1 --nameserver 10.213.77.1
# pct start 225 && pct push 225 install.sh /root/install.sh && pct exec 225 -- bash /root/install.sh
set -euo pipefail
NETBOX_VERSION=${NETBOX_VERSION:-4.7.4}
MYIP=${MYIP:-10.213.77.6}
SITE=${SITE:-lab}
ROLLCALL_ORIGINS=${ROLLCALL_ORIGINS:-"https://10.0.0.234:8444 https://10.213.77.1:8444"} # the region's NetBox listener (ui_listen)
ROLLCALL_IP=${ROLLCALL_IP:-10.213.77.1}
PREFIXES=${PREFIXES:-"10.213.77.0/24 10.213.78.0/24 10.213.79.0/24 fd00:213:77::/64 fd00:213:78::/64"}
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -yq postgresql valkey-server caddy \
python3 python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev \
libpq-dev libssl-dev zlib1g-dev git curl jq
install -d -m 700 /etc/lab-netbox
S=/etc/lab-netbox/secrets.env
if [ ! -f $S ]; then
r() { python3 -c "import secrets,sys;print(secrets.token_urlsafe(int(sys.argv[1])))" "$1"; }
cat > $S <<EOF
NETBOX_DB_PASS=$(r 24)
NETBOX_SECRET_KEY=$(r 50)
NETBOX_PEPPER=$(r 50)
NETBOX_ADMIN_PASS=$(r 18)
ROLLCALL_PASS=$(r 18)
EOF
chmod 600 $S
fi
grep -q '^ROLLCALL_UI_SECRET=' $S || echo "ROLLCALL_UI_SECRET=$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))')" >> $S
. $S
# ------------------------------------------------------------------ PostgreSQL, Valkey
sudo() { runuser -u "$2" -- "${@:3}"; } # "sudo -u postgres ..." without sudo
if [ -z "$(sudo -u postgres psql -tAqc "SELECT 1 FROM pg_roles WHERE rolname='netbox'")" ]; then
sudo -u postgres psql -qc "CREATE ROLE netbox LOGIN PASSWORD '$NETBOX_DB_PASS'"
# UTF-8 whatever the container's locale (C in the Proxmox template)
sudo -u postgres psql -qc "CREATE DATABASE netbox OWNER netbox ENCODING 'UTF8' LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8' TEMPLATE template0"
fi
sed -i 's/^# *databases .*/databases 16/' /etc/valkey/valkey.conf || true
systemctl enable --now valkey-server
# ------------------------------------------------------------------ NetBox
id netbox >/dev/null 2>&1 || adduser --system --group --home /opt/netbox netbox
D=/opt/netbox-$NETBOX_VERSION
if [ ! -d $D ]; then
curl -fsSL https://github.com/netbox-community/netbox/archive/refs/tags/v$NETBOX_VERSION.tar.gz | tar -xz -C /opt
fi
[ -L /opt/netbox ] || { [ -d /opt/netbox ] && rmdir /opt/netbox 2>/dev/null || true; }
ln -sfn $D /opt/netbox
cat > /opt/netbox/netbox/netbox/configuration.py <<EOF
# pxestuff lab/netbox/install.sh generates this file
ALLOWED_HOSTS = ['$MYIP', 'localhost', '127.0.0.1']
DATABASES = {'default': {'NAME': 'netbox', 'USER': 'netbox', 'PASSWORD': '$NETBOX_DB_PASS',
'HOST': '127.0.0.1', 'PORT': '', 'CONN_MAX_AGE': 300}}
REDIS = {
'tasks': {'HOST': '127.0.0.1', 'PORT': 6379, 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
'caching': {'HOST': '127.0.0.1', 'PORT': 6379, 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 1, 'SSL': False},
}
SECRET_KEY = '$NETBOX_SECRET_KEY'
API_TOKEN_PEPPERS = {1: '$NETBOX_PEPPER'}
TIME_ZONE = 'Europe/Stockholm'
BASE_PATH = 'netbox/'
REMOTE_AUTH_ENABLED = True
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
REMOTE_AUTH_HEADER = 'HTTP_X_ROLLCALL_USER'
REMOTE_AUTH_AUTO_CREATE_USER = True
REMOTE_AUTH_DEFAULT_GROUPS = ['rollcall-admins']
CSRF_TRUSTED_ORIGINS = ['http://$MYIP'$(for o in $ROLLCALL_ORIGINS; do printf ", '%s'" "$o"; done)]
EOF
chown -R netbox:netbox $D/netbox/media $D/netbox/reports $D/netbox/scripts 2>/dev/null || true
cd /opt/netbox && PYTHON=/usr/bin/python3 ./upgrade.sh >/var/log/netbox-upgrade.log 2>&1 || { tail -40 /var/log/netbox-upgrade.log; exit 1; }
chown -R netbox:netbox /opt/netbox/netbox/media
cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py
sed -i "s/^bind = .*/bind = '127.0.0.1:8001'/" /opt/netbox/gunicorn.py
cp /opt/netbox/contrib/netbox.service /opt/netbox/contrib/netbox-rq.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now netbox netbox-rq
systemctl restart netbox netbox-rq
cat > /etc/caddy/Caddyfile <<EOF
# pxestuff lab/netbox/install.sh
http://$MYIP, http://localhost {
# who signs in comes only from Rollcall's proxy, with the shared secret
@notrollcall not {
remote_ip $ROLLCALL_IP
header X-Rollcall-Proxy-Secret "$ROLLCALL_UI_SECRET"
}
request_header @notrollcall -X-Rollcall-User
request_header -X-Rollcall-Proxy-Secret
redir / /netbox/
handle_path /netbox/static/* {
root * /opt/netbox/netbox/static
file_server
}
reverse_proxy 127.0.0.1:8001
}
EOF
chmod 640 /etc/caddy/Caddyfile && chgrp caddy /etc/caddy/Caddyfile # the secret is in it
systemctl enable caddy && systemctl restart caddy
# ------------------------------------------------------------------ what Rollcall needs
# admin and rollcall users, the ownership tag and field, Rollcall's permissions
# (as altyapi hosts/netops/seed/rollcall-izinler.py on netops), the lab site
# and its prefixes delegated to Rollcall (tagged).
NB_ADMIN_PASS=$NETBOX_ADMIN_PASS NB_ROLLCALL_PASS=$ROLLCALL_PASS NB_SITE=$SITE NB_PREFIXES=$PREFIXES \
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py shell -c '
import os
from django.contrib.contenttypes.models import ContentType
from users.models import User, ObjectPermission
from extras.models import Tag, CustomField
from dcim.models import Site
from ipam.models import Prefix
def user(name, pw, su):
u, _ = User.objects.get_or_create(username=name)
u.set_password(pw); u.is_superuser = su; u.is_active = True; u.save()
return u
user("admin", os.environ["NB_ADMIN_PASS"], True)
rc = user("rollcall", os.environ["NB_ROLLCALL_PASS"], False)
tag, _ = Tag.objects.get_or_create(slug="rollcall", defaults={"name": "rollcall", "color": "2196f3"})
cf, _ = CustomField.objects.get_or_create(name="rollcall_machine", defaults={"type": "text", "label": "Rollcall machine"})
for m in ("ipam.ipaddress", "dcim.device"):
cf.object_types.add(ContentType.objects.get_by_natural_key(*m.split(".")))
perms = {
"rollcall-ipam-write": (["ipam.ipaddress"], ["view", "add", "change", "delete"]),
"rollcall-read": (["ipam.prefix", "ipam.vlan", "ipam.iprange", "extras.tag", "dcim.site"], ["view"]),
"rollcall-dcim-write": (["dcim.device", "dcim.interface", "dcim.macaddress"], ["view", "add", "change", "delete"]),
"rollcall-dcim-types": (["dcim.manufacturer", "dcim.devicetype", "dcim.devicerole"], ["view", "add"]),
"rollcall-dcim-read": (["dcim.location", "dcim.rack", "dcim.cable"], ["view"]),
}
for name, (types, actions) in perms.items():
p, _ = ObjectPermission.objects.get_or_create(name=name, defaults={"actions": actions})
p.actions = actions; p.enabled = True; p.save()
p.object_types.set([ContentType.objects.get_by_natural_key(*t.split(".")) for t in types])
p.users.add(rc)
from users.models import Group
from django.apps import apps
admins, _ = Group.objects.get_or_create(name="rollcall-admins") # who the admin certificate signs in
p, _ = ObjectPermission.objects.get_or_create(name="rollcall-admins-all", defaults={"actions": ["view", "add", "change", "delete"]})
p.actions = ["view", "add", "change", "delete"]; p.enabled = True; p.save()
p.object_types.set([ContentType.objects.get_for_model(m) for a in ("dcim", "ipam", "circuits", "tenancy", "virtualization", "extras", "wireless", "vpn") for m in apps.get_app_config(a).get_models()])
p.groups.add(admins)
site, _ = Site.objects.get_or_create(slug=os.environ["NB_SITE"], defaults={"name": os.environ["NB_SITE"].capitalize()})
for cidr in os.environ["NB_PREFIXES"].split():
p, _ = Prefix.objects.get_or_create(prefix=cidr, defaults={"description": "Rollcall lab", "scope": site})
p.tags.add(tag)
print("rollcall objects ready")
'
# Rollcall's API token (v2), made once through the API with its password
if ! grep -q '^NETBOX_TOKEN_ROLLCALL=' $S; then
curl -fsS http://127.0.0.1:8001/netbox/api/users/tokens/provision/ -H "Host: $MYIP" \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d "{\"username\":\"rollcall\",\"password\":\"$ROLLCALL_PASS\",\"description\":\"rollcall region\",\"write_enabled\":true}" |
python3 -c 'import json,sys;d=json.load(sys.stdin);print("NETBOX_TOKEN_ROLLCALL=nbt_%s.%s"%(d["key"],d["token"]))' >> $S
fi
echo "--- ready"
systemctl is-active postgresql valkey-server netbox netbox-rq caddy | paste -sd' '
. $S
curl -fsS -o /dev/null -w "rollcall token: %{http_code}\n" -H "Authorization: Bearer $NETBOX_TOKEN_ROLLCALL" http://$MYIP/netbox/api/ipam/prefixes/