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>
184 lines
9.5 KiB
Bash
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/
|