Compare commits
No files matched your search
@@ -56,8 +56,10 @@ jobs:
|
||||
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/"
|
||||
# all of deploy/ and agent/: install.sh installs scripts and units from there,
|
||||
# and a file left out would be installed from a stale copy
|
||||
scp -q -r deploy/* "$USER@$HOST:/opt/rollcall/deploy/"
|
||||
scp -q -r agent/* "$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:
|
||||
@@ -78,7 +80,8 @@ jobs:
|
||||
env:
|
||||
HOST: ${{ vars.PXE_HOST }}
|
||||
USER: ${{ vars.PXE_USER }}
|
||||
run: ssh "$USER@$HOST" 'sudo /opt/rollcall/agent/build-image.sh 2>&1 | tail -20'
|
||||
# the lab image lets the deploy user's keys in as root (debugging); release images do not
|
||||
run: ssh "$USER@$HOST" 'sudo env AUTHKEYS=$HOME/.ssh/authorized_keys /opt/rollcall/agent/build-image.sh 2>&1 | tail -20'
|
||||
|
||||
site:
|
||||
needs: server
|
||||
|
||||
@@ -69,3 +69,57 @@ jobs:
|
||||
-F "attachment=@$f" "$API/releases/$id/assets?name=$f" >/dev/null
|
||||
echo "uploaded $f"
|
||||
done
|
||||
|
||||
# The commissioning image of this tag: built with live-build on the PXE server
|
||||
# (the runner cannot), in a directory of its own (the lab's live image and
|
||||
# build stay as they are), without anyone's SSH keys, and uploaded to the
|
||||
# release as commissioning-* for bootstrap.sh --image-url.
|
||||
image:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: Build the image on the PXE server
|
||||
env:
|
||||
HOST: ${{ vars.PXE_HOST }}
|
||||
USER: ${{ vars.PXE_USER }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -eu
|
||||
W=/var/tmp/rollcall-release-$TAG
|
||||
ssh "$USER@$HOST" "sudo rm -rf $W && mkdir -p $W/src"
|
||||
scp -q -r agent deploy/publish-image.sh "$USER@$HOST:$W/src/"
|
||||
ssh "$USER@$HOST" "sudo env AUTHKEYS= BUILD=$W/build OUT=$W/image bash $W/src/agent/build-image.sh > $W/build.log 2>&1 || { tail -40 $W/build.log; exit 1; }; tail -5 $W/build.log"
|
||||
ssh "$USER@$HOST" "sudo chown -R \$(id -u):\$(id -g) $W/image" # the upload runs as the deploy user
|
||||
# refuse an image that lets someone in (a listing that fails refuses too)
|
||||
ssh "$USER@$HOST" "sudo unsquashfs -l $W/image/filesystem.squashfs > $W/files" ||
|
||||
{ echo "::error::cannot list the image" >&2; exit 1; }
|
||||
if ssh "$USER@$HOST" "grep -E '/\.ssh/authorized_keys' $W/files"; then
|
||||
echo "::error::the image carries SSH authorized_keys" >&2; exit 1
|
||||
fi
|
||||
- name: Upload to the release
|
||||
env:
|
||||
HOST: ${{ vars.PXE_HOST }}
|
||||
USER: ${{ vars.PXE_USER }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
W=/var/tmp/rollcall-release-$TAG
|
||||
# the token over stdin, not on a command line
|
||||
printf '%s\n' "$TOKEN" | ssh "$USER@$HOST" "read -r GITEA_TOKEN && export GITEA_TOKEN && IMG=$W/image sh $W/src/publish-image.sh $TAG $API"
|
||||
- name: Clean up
|
||||
if: always()
|
||||
env:
|
||||
HOST: ${{ vars.PXE_HOST }}
|
||||
USER: ${{ vars.PXE_USER }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: ssh "$USER@$HOST" "sudo rm -rf /var/tmp/rollcall-release-$TAG" || true
|
||||
+220
@@ -6,6 +6,226 @@ publish a tag that has no section here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Intermediates renew themselves: the region keeps the intermediates it signs (`rollcall ca
|
||||
intermediate` goes through the API now and prints a renewal token once; `rollcall ca
|
||||
intermediates` lists them), and `deploy/rollcall-intermediate-renew.sh` with its daily timer on
|
||||
the intermediate's host gets a new certificate in the last third of its life or after a root
|
||||
rotation, always with the first name constraints. An intermediate near its end, failing to renew
|
||||
or whose host went silent shows in `rollcall doctor`, on the overview and in the rotation status.
|
||||
(#14)
|
||||
|
||||
## [0.4.0] - 2026-11-01
|
||||
|
||||
### Added
|
||||
- Rooms in 3D (three.js shipped in the binary, loaded on first use): racks at their places and
|
||||
height, servers drawn from their model's front (drive bays, ears, vents; no photos or logos) with
|
||||
the state on the ear, multi-node sleds side by side, backs with power supplies and the network
|
||||
cards' ports. Drag to turn, double click to go to a rack; selection is shared with the floor plan.
|
||||
- Cabling from LLDP: `GET /api/v1/cabling` (each machine's ports and the switch port they lead to),
|
||||
switches placed from NetBox or by hand (`POST`/`DELETE /api/v1/switches`), cables drawn in the 3D
|
||||
room like busbars (one bus per rack and switch up the back, thicker with more cables, a stub per
|
||||
port, a comb into the switch, trays over the tops), coloured per switch, and listed per rack in
|
||||
the side panel; switches also show in the rack elevation.
|
||||
Where LLDP sees nothing, cables recorded in NetBox (through patch panels) or entered by hand
|
||||
(`POST`/`DELETE /api/v1/cables`); the BMC's port to the out-of-band switch that way, OOB
|
||||
switches marked; NetBox cables that disagree with LLDP are shown.
|
||||
- NetBox's web UI through Rollcall at its `BASE_PATH` on a listener of its own (`ipam.ui_listen`,
|
||||
default `:8444`: another origin than Rollcall's UI), admins only, and links to NetBox from the
|
||||
web UI (sidebar, sites, rooms, racks, switches, machine devices); `ipam.ui_url` for direct links.
|
||||
With `ipam.ui_login_secret_file` the admin certificate signs in to NetBox too (NetBox's remote
|
||||
user header, guarded by a shared secret the web server in front of NetBox checks).
|
||||
- Sites page: sites on an OpenStreetMap map (Leaflet shipped in the binary), a site's rooms, and a
|
||||
room's floor plan with racks on 600 mm tiles coloured by their machines; a rack opens its front.
|
||||
Racks are placed by dragging and rotated in an edit mode; unplaced ones are laid out
|
||||
automatically. With NetBox, sites (with coordinates), locations and racks are read from it;
|
||||
otherwise, or without coordinates there, sites are placed on the map. `/api/v1/sites`,
|
||||
`/api/v1/rooms`, `POST /api/v1/racks/positions`. The web UI's CSP allows OSM tile images.
|
||||
- Server heights without typing them in: SMBIOS chassis height from commissioning, else a model
|
||||
table checked against vendor documentation (Dell, HPE, Lenovo, xFusion, Fujitsu, Inspur/KAYTUS,
|
||||
GIGABYTE; not Supermicro, whose model numbers do not carry it), else `rack_units` per model in
|
||||
`config.json`. Multi-node sleds share their chassis. The rack elevation spans servers over their
|
||||
height, groups sleds, marks unknown heights and warns about overlaps; NetBox device types get the
|
||||
height (0 for sleds, placed without a position). The machine API has `size`.
|
||||
- Network page for large sites: a problems strip (offline rack controllers and the VLANs left
|
||||
without DHCP/PXE, provisioning VLANs without it, subnets over 80%, IPAM errors), filters by fabric,
|
||||
purpose and rack controller, a search that shows a machine's path (interface, address, VLAN,
|
||||
rack controller, region), and a VLAN x rack-controller matrix, the default once a site is large.
|
||||
The topology groups machines more coarsely when boxes would not fit and scrolls instead of
|
||||
shrinking; the overview shows one row per fabric on a large site. Filters can be put on the URL
|
||||
(`?fabric=…&view=matrix&q=…`) to share a view.
|
||||
- Root rotation without re-joining (`rollcall ca rotate prepare | switch | retire`, status without
|
||||
an argument). The trust bundle (all current roots, `/ca-bundle.pem`) reaches rack controllers
|
||||
signed by the active root and is installed by their root unit only when that signature verifies;
|
||||
racks renew client and server certificates from the new root by themselves; `switch` and
|
||||
`retire` wait for every rack. Deployed machines get one file per root and a daily
|
||||
`rollcall-ca-refresh` timer that fetches the bundle from their rack over HTTPS.
|
||||
Upgrading: rerun `deploy/install-rack.sh` on rack hosts (the update unit installs bundles now).
|
||||
|
||||
### Changed
|
||||
- `rollcall ca rotate -yes` (replace the root, racks join again) is gone; the three steps replace it.
|
||||
- The web UI's files are served with `Cache-Control: no-cache` and an ETag per build: browsers
|
||||
revalidate and pick up a new build right after a deploy.
|
||||
- Upgrading: database tables and columns are added at start. For NetBox cables the NetBox user
|
||||
needs `view` on `dcim.cable` and `dcim.interface`; for the UI through Rollcall, NetBox's
|
||||
`BASE_PATH` and `CSRF_TRUSTED_ORIGINS` (README, NetBox's UI through Rollcall).
|
||||
|
||||
### Fixed
|
||||
- The release workflow could not publish the commissioning image (permission denied on
|
||||
`SHA256SUMS`); an existing checksum file is now verified instead of rewritten.
|
||||
- Stored PXE boot entries kept the old `rollcall.ca` after a root rotation; the rack re-pins them.
|
||||
- A rack typed in from a machine's location and the same rack from NetBox in another case showed
|
||||
twice; the layout sync folds it into NetBox's spelling (place, comment and machines follow).
|
||||
- NetBox paging under a `BASE_PATH` doubled the path on the next page.
|
||||
|
||||
## [0.3.0] - 2026-10-30
|
||||
|
||||
Third release: working with a site's existing network services (DNS provider, fixed leases on
|
||||
Kea and OpenWrt, NetBox device sync, Infoblox), Redfish across vendors with firmware updates,
|
||||
UEFI Secure Boot, agents over HTTPS, and a security review of the whole code base. Upgrading:
|
||||
rerun `deploy/install.sh` on the region and `deploy/install-rack.sh` on rack hosts.
|
||||
|
||||
### Added
|
||||
- Proxy DHCP: a VLAN in `proxy` mode (`rollcall vlan add ... -proxy-dhcp`, web UI, `bootstrap.sh
|
||||
--proxy-dhcp`) leaves addresses to the site's DHCP server and only answers PXE; machines keep DHCP
|
||||
unless a static range is set aside; doctor and discover know the mode.
|
||||
- DNS provider: `dns` in `config.json` writes machines' A/AAAA records into the site's DNS, through
|
||||
the PowerDNS API (ownership by RRset comment) or RFC 2136 dynamic update with TSIG (ownership by a
|
||||
`_rollcall.<name>` TXT marker and update prerequisites, so foreign records are never overwritten;
|
||||
drift is detected by querying). `rollcall dns`, `rollcall dns sync [-dry]`, a doctor check.
|
||||
- NetBox device sync (`ipam.device_sync`): machines become dcim devices with serial, device type,
|
||||
role, status, interfaces with MAC objects, assigned addresses and primary IP. Site, location and
|
||||
rack are matched by name from the machine's location and never created; ownership by tag and
|
||||
`rollcall_machine` as for addresses, foreign devices of the same name are conflicts.
|
||||
- Infoblox WAPI driver for the IPAM authority (`ipam.provider: infoblox`): one host record per
|
||||
address (DNS-published in `dns_view`, or IPAM-only), ownership by the `rollcall_machine` extensible
|
||||
attribute (kept with `extattrs+`), leases, fixed addresses and ranges count as taken, delegated
|
||||
networks by the `rollcall` attribute. Not tested against a real grid yet.
|
||||
- The release workflow builds the tag's commissioning image on the PXE server and publishes it as
|
||||
`commissioning-*` assets.
|
||||
- Lab NetBox (`lab/netbox/install.sh`): a container on the lab segment for the NetBox drivers.
|
||||
- Fixed leases on the site's DHCP server for proxy-mode VLANs (`dhcp_reservations`), for Kea with
|
||||
`host_cmds` (Kea 3.0+) and OpenWrt (rpcd). Rollcall's own leases are reconciled; foreign ones are
|
||||
reported. `rollcall reservations`.
|
||||
- Firmware updates over Redfish. A package repository on the region (`rollcall firmware import`
|
||||
from a file or URL, served to BMCs through rack controllers) and each machine's firmware inventory.
|
||||
- Update jobs: hand-over, task watch through BMC restarts, optional power cycle, version check
|
||||
against the inventory. They resume after a region restart.
|
||||
- Vendor plans from documentation or vendor tools: Dell, HPE (`.fwpkg` unpacked), Lenovo,
|
||||
Supermicro, GIGABYTE/AMI/ASRock Rack, OpenBMC, xFusion, KAYTUS, Fujitsu iRMC. Cisco CIMC is
|
||||
refused (SCP only).
|
||||
- `fw_method`, `fw_params` and `firmware_base` on the BMC record.
|
||||
- CLI `rollcall firmware`, a Firmware tab and repository in the web UI.
|
||||
- Redfish vendor profiles: one standard driver plus per-vendor behaviour from public documentation,
|
||||
for Dell iDRAC, HPE iLO, Lenovo XCC, Supermicro, xFusion/Huawei iBMC, KAYTUS/Inspur, Fujitsu iRMC,
|
||||
Cisco CIMC, GIGABYTE, ASRock Rack, OpenBMC and AMI MegaRAC.
|
||||
- Boot mode kept, omitted or tried per vendor.
|
||||
- If-Match with 412/428 handling; future-state (`/SD`) and `@Redfish.Settings` targets.
|
||||
- Busy retries and task following.
|
||||
- Chassis reset fallback and an emulated power cycle.
|
||||
- Session login (`auth=session`).
|
||||
- Probe results (reset types, boot targets, boot mode, profile) are kept on the BMC and shown in
|
||||
the UI, and the UI's driver list comes from the server.
|
||||
|
||||
### Security
|
||||
A review of the whole code base; the fixes below. Upgrading: rerun `deploy/install.sh` on the
|
||||
region and `deploy/install-rack.sh` on rack hosts (new dnsmasq layout, signed rack updates).
|
||||
- **Root SSH keys in published images.** `agent/build-image.sh` took
|
||||
the build user's `authorized_keys` unless told otherwise, so the images published with v0.2.0 and
|
||||
v0.2.1 let three lab keys (the maintainer's laptop, the CI deploy key, the lab hypervisor) log in as
|
||||
root to machines while they commission. Keys are now opt-in (`AUTHKEYS=`, the lab deploy sets it),
|
||||
and the release workflow builds the image of the tag itself and refuses one with `authorized_keys`.
|
||||
- **Region file read (critical).** A rack controller could read any file on the region (CA key,
|
||||
config) through `/api/v1/rack/binary?arch=../..`. `arch` is now a known architecture.
|
||||
- **Path traversal.** In `/api/v1/render/...` (admin) as well.
|
||||
- **Machine-reported MACs.** A MAC could be a path, letting a PXE-booting host make the rack write
|
||||
outside the TFTP directory. Only real hardware addresses are accepted. Rack controllers write only
|
||||
`01-xx-..` file names.
|
||||
- **Delegated intermediates.** An ACME intermediate could mint admin or rack certificates (name
|
||||
constraints do not cover the CN), and IP addresses when only a domain was permitted.
|
||||
- Admin and rack identities must now be signed by the root itself.
|
||||
- Intermediates carry a ServerAuth-only EKU, and the unconstrained name type is closed.
|
||||
- **Rack certificates.** These were ServerAuth certificates for any address the rack reported, so
|
||||
one rack could pose as the region.
|
||||
- They are now client-only with no names.
|
||||
- Racks accept only the region's own certificate.
|
||||
- `rack join` checks the returned CA against the fingerprint in the code.
|
||||
- An unbound join code cannot take over an existing rack's name.
|
||||
- Job results are accepted only from the rack that took the job.
|
||||
- **Rack self-update.** The root unit ran a binary the unprivileged rack user staged.
|
||||
- The region now signs each binary with its CA key.
|
||||
- The root unit verifies the signature against the CA from `rack join`, in a root-owned directory,
|
||||
without following links.
|
||||
- **dnsmasq as root.** dnsmasq (root) read configuration the rollcall user writes, and the reload
|
||||
script copied files through links in rollcall's directory.
|
||||
- It now reads a checked copy in `/var/lib/rollcall-dnsmasq`.
|
||||
- The copy may hold only the options Rollcall generates: nothing that runs a program or includes
|
||||
a file.
|
||||
- **Machine record takeover.** Any host on the provisioning network could overwrite a deployed
|
||||
machine's record (and BMC) by reporting its UUID.
|
||||
- Known machines are re-commissioned only when `ready`, `recommission` or failed.
|
||||
- A MAC another machine owns keeps its boot file.
|
||||
- The rack controller of a ready machine is not changed by whoever reports it.
|
||||
- **Injection into dnsmasq, cloud-init, PXE menus, udev rules and DNS updates.** Subnet domain and
|
||||
gateway, host names, NIC names, reservation names and machine-reported identity strings are now
|
||||
validated or reduced to one line. Machine IDs are letters and digits only.
|
||||
- **Web UI.**
|
||||
- Escaping is fixed for machine-reported inventory fields (stored XSS).
|
||||
- A Content-Security-Policy, `frame-ancestors 'none'`, nosniff and no-referrer are set.
|
||||
- Cross-origin state-changing requests are refused (`http.CrossOriginProtection`), so a page
|
||||
elsewhere cannot use a browser's admin certificate.
|
||||
- `?token=` is no longer logged.
|
||||
- **Deploy agent.**
|
||||
- The image URL no longer goes into a shell command; the checksum is taken over the bytes written.
|
||||
- Seed files cannot leave the target root.
|
||||
- The BMC password is not kept in the fallback inventory file.
|
||||
- **Redfish.** Requests stay on the BMC and under `/redfish/`: paths the BMC returns cannot send the
|
||||
credentials elsewhere, and `scheme://` addresses are reduced to scheme and host.
|
||||
- **Machine tokens.** Job and node tokens are readable from the TFTP pxelinux files. They are now
|
||||
accepted only from the machine's own address (its static lease); through a rack, the rack's
|
||||
`X-Forwarded-For` entry counts. A machine without a fixed address binds a job to its first user.
|
||||
`agent_source_check: false` turns this off.
|
||||
- **Agents over HTTPS.** Rack controllers get a server certificate for their own host and serve
|
||||
the agents' endpoints and images on 443. Agents accept the CA by the fingerprint on the PXE
|
||||
command line and do not fall back to HTTP. Old agents keep working over HTTP. The commissioning
|
||||
image has to be rebuilt to get the new agents.
|
||||
- The stateless overlay client works the same way (rebuild stateless images).
|
||||
- **UEFI and Secure Boot.** UEFI machines PXE-boot through Debian's signed shim and GRUB, so machines
|
||||
with Secure Boot on can boot at all; before, the firmware refused the unsigned `syslinux.efi`.
|
||||
- GRUB menus are made from the pxelinux ones.
|
||||
- The local disk is booted by chainloading the installed OS's signed loader.
|
||||
- Secure Boot verifies the kernel; the initrd and squashfs are not covered.
|
||||
- **BMC certificates.** HTTPS BMCs (Redfish, Proxmox) are pinned on first use. The certificate
|
||||
is read in a handshake that carries no credentials, and a changed certificate gets none
|
||||
(`tls_fingerprint`; cleared on an address change or a BMC firmware update by Rollcall).
|
||||
- **BMC hints.** `bmc-hint` no longer returns the password. The region attaches the configured
|
||||
credentials itself and checks the BMC from the rack controller.
|
||||
- **Rack certificate renewal.** Renewal could not overwrite the root-owned files from `rack join`.
|
||||
Renewed certificates now go atomically to `/var/lib/rollcall/rack-cert`, the previous serial is
|
||||
accepted for 7 days after a renewal, and `rollcall rack renew` renews on demand.
|
||||
- **Defaults and limits.**
|
||||
- `admin_http` defaults to `loopback`.
|
||||
- Request bodies are capped (commissioning 4 MB, all 16 MB), with read and idle timeouts.
|
||||
- `bootstrap.sh` and `publish-image.sh` keep the database password and API tokens off command
|
||||
lines.
|
||||
|
||||
### Fixed
|
||||
- IPAM: a reconcile running during an allocation could delete the record just reserved in NetBox.
|
||||
- IPAM and DNS sync: one failing record no longer stops the rest of the pass, deletes included.
|
||||
Reservations are stored in one spelling.
|
||||
- The job watchdog could fail a job that had just started.
|
||||
- Release without `-disk` wiped the smallest disk, not the one the OS was deployed to. Automatic
|
||||
disk choice skips SAN LUNs (iSCSI, FC, SRP).
|
||||
- RFC 2136 lookups follow the request context. The marker is not removed when the database cannot
|
||||
say whether other records need it.
|
||||
- Smaller fixes:
|
||||
- `seq` in templates is capped.
|
||||
- Layout selector `ib:-1` no longer panics.
|
||||
- Redfish detection no longer runs on a machine that cannot be read.
|
||||
- dnsmasq busy loop in proxy mode (a dnsmasq bug with tagged proxy ranges, found with gdb): proxy
|
||||
ranges are untagged, PXE services limited by interface; `dhcp-boot`/`dhcp-match` only for served
|
||||
subnets, no `dhcp-host` lines for proxied ones.
|
||||
|
||||
## [0.2.1] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
+10
-4
@@ -17,7 +17,7 @@ exists.
|
||||
| Network access | a default route; the internet or a mirror for packages and cloud images |
|
||||
| Later: provisioning network | an interface of this host (or of a rack controller) on the VLAN where machines PXE-boot |
|
||||
| Later: BMC network | the rack controller must reach the machines' BMCs (IPMI, Redfish) |
|
||||
| Ports | DHCP 67/udp and TFTP 69/udp on the provisioning network; HTTP 80 for machines; HTTPS 443 for people and rack controllers |
|
||||
| Ports | DHCP 67/udp and TFTP 69/udp on the provisioning network; HTTP 80 and HTTPS 443 for machines (agents use 443); HTTPS 443 for people and rack controllers |
|
||||
|
||||
The release bundle `rollcall-<version>-linux-amd64.tar.gz` holds the binary,
|
||||
`deploy/` and `agent/`. From a checkout, `deploy/bootstrap.sh` builds the binary
|
||||
@@ -26,8 +26,8 @@ itself when Go is installed.
|
||||
## Day 0: install
|
||||
|
||||
```sh
|
||||
tar xzf rollcall-v0.2.0-linux-amd64.tar.gz && cd rollcall-v0.2.0-linux-amd64
|
||||
sudo ./deploy/bootstrap.sh --image-url https://gitea.example/OWNER/rollcall/releases/download/v0.2.0
|
||||
tar xzf rollcall-v0.2.1-linux-amd64.tar.gz && cd rollcall-v0.2.1-linux-amd64
|
||||
sudo ./deploy/bootstrap.sh --image-url https://gitea.example/OWNER/rollcall/releases/download/v0.2.1
|
||||
```
|
||||
|
||||
- **Packages, database, TFTP.** PostgreSQL with its own role and a random password,
|
||||
@@ -74,7 +74,7 @@ competes with Rollcall for the machines' PXE boot.
|
||||
|---|---|
|
||||
| An interface with an address and no DHCP | the provisioning network: next step |
|
||||
| Link up, no address, LLDP shows the VLAN | give the interface its address on that VLAN first |
|
||||
| Another DHCP server on the provisioning VLAN | the network team turns it off for that VLAN, or keeps it and points it at this host (next-server, or a DHCP relay), then `--no-dhcp` |
|
||||
| Another DHCP server on the provisioning VLAN | keep it: `--proxy-dhcp` makes this host answer only PXE, the site's server keeps the addresses (or the network team turns it off for that VLAN) |
|
||||
| No link | cable it |
|
||||
|
||||
## Add the provisioning network
|
||||
@@ -149,6 +149,12 @@ region's version.
|
||||
## Integrations
|
||||
|
||||
- **NetBox (or Infoblox) as the address authority.** `ipam` in `config.json`: see README, External IPAM.
|
||||
With NetBox, `ipam.device_sync` also keeps the machines as devices (README, Devices in NetBox);
|
||||
`lab/netbox/install.sh` sets up a throwaway NetBox for trying it.
|
||||
- **The site's DHCP server keeps the addresses (proxy DHCP).** `dhcp_reservations` writes machines'
|
||||
planned addresses there as fixed leases (Kea 3.0+, OpenWrt): see README, Fixed leases.
|
||||
- **DNS names without an IPAM authority.** `dns` in `config.json` (PowerDNS API or RFC 2136
|
||||
with TSIG): see README, DNS provider.
|
||||
- **Certificates for machines.** Delegate a name-constrained intermediate to an ACME
|
||||
server: see README, Certificate authority.
|
||||
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -297,6 +297,33 @@ tag rules see `.Node.Building`, `.Node.Room`, `.Node.Rack`, `.Node.Unit`, so a
|
||||
rack can drive a tag (`{{ eq .Node.Rack "R12" }}`) or a Slurm `topology.conf`
|
||||
overlay.
|
||||
|
||||
**Server heights are not typed in.** `unit` is the lowest U a machine
|
||||
occupies; how many it takes comes from the firmware (SMBIOS type 3 "Height",
|
||||
read at commissioning), else from the model (a table of checked heights for
|
||||
Dell, HPE, Lenovo, xFusion, Fujitsu, Inspur/KAYTUS and GIGABYTE models, see
|
||||
`hwsize.go`), else from `rack_units` in `config.json` for models neither
|
||||
knows (`"rack_units": {"SYS-6029P-TRT": 2}`; it also overrides the others).
|
||||
Sleds of a multi-node chassis (Dell C-series, blades) share the chassis's
|
||||
units: the elevation draws them as one block, and NetBox gets a device type
|
||||
of height 0 placed in the rack without a position. The elevation spans each
|
||||
server over its height, marks unknown heights `?U` and warns about overlaps.
|
||||
|
||||
**Sites, rooms and floor plans.** The **Sites** page puts sites on an
|
||||
OpenStreetMap map (Leaflet, shipped in the binary; the browser loads tiles
|
||||
from `tile.openstreetmap.org`), coloured by the worst machine state; a site
|
||||
opens its rooms, a room its floor plan: racks on 600 mm floor tiles, one wide
|
||||
and two deep, a thick edge for the front, coloured by their machines with a
|
||||
bar for the units used; a rack opens its front (the elevation above), and
|
||||
`#/sites/SITE/ROOM/RACK` links to it. **Edit layout** drags racks to their
|
||||
place and rotates them; racks nobody placed are laid out in rows facing
|
||||
alternate aisles (drawn dashed). With NetBox as the IPAM authority, sites
|
||||
(name, coordinates, address), locations (rooms) and racks (name, height) are
|
||||
read from it on every reconcile and never written back; without NetBox, or
|
||||
for a site NetBox has no coordinates for, sites are placed by clicking the
|
||||
map. Rack positions are Rollcall's either way (NetBox has no floor
|
||||
coordinates). API: `/api/v1/sites`, `/api/v1/rooms`, `POST
|
||||
/api/v1/racks/positions`.
|
||||
|
||||
## Network and rack controllers
|
||||
|
||||
### Model
|
||||
@@ -401,6 +428,104 @@ The lab runs both: `pxe-server` (embedded, 10.213.77.0/24) and `pxe-rack2`
|
||||
pxe-rack2 and its virtual BMC on pxe-rack2. Rack ↔ region traffic is mutual
|
||||
TLS (next section), so the BMC passwords of queued commands are encrypted.
|
||||
|
||||
|
||||
### Agents over HTTPS
|
||||
|
||||
Agents talk to their rack controller over HTTPS: the commissioning agent,
|
||||
the deploy agent and the image download. The region gives each rack a
|
||||
server certificate for its own URL's host (`CN=rack-server:NAME`, server
|
||||
use only, never one of the region's addresses). The rack serves the
|
||||
agents' endpoints on `<host>:443` as well as on HTTP; `tls_listen` in
|
||||
`rack.json` changes the port, `off` turns it off. The embedded rack uses
|
||||
the region's own HTTPS listener.
|
||||
|
||||
The pxelinux command line carries `rollcall.tls=https://HOST` and
|
||||
`rollcall.ca=<SHA-256 of the CA>`. An agent fetches `/ca.pem`, accepts it
|
||||
only by that fingerprint, and from then on uses HTTPS with it. When TLS
|
||||
was asked for and fails, it does not fall back to HTTP.
|
||||
|
||||
What stays on HTTP: live-boot fetching the commissioning squashfs, and
|
||||
`/ca.pem` itself (checked by its fingerprint). This keeps BMC passwords
|
||||
and seed files from anyone who only listens, and from an HTTP server
|
||||
posing as the rack. TFTP and the kernel and initrd stay unauthenticated,
|
||||
so an attacker who can rewrite boot files on the wire is not stopped;
|
||||
that needs UEFI Secure Boot with signed images.
|
||||
### DHCP already on the network: proxy DHCP
|
||||
|
||||
Where the site's DHCP server keeps handing out addresses (a corporate DHCP,
|
||||
Infoblox, a router), a VLAN can be served in **proxy** mode: the rack
|
||||
controller does not hand out addresses, it only answers PXE clients with where
|
||||
to boot from (dnsmasq proxy DHCP), and TFTP and HTTP stay on the rack.
|
||||
|
||||
```
|
||||
rollcall vlan add FABRIC VID -proxy-dhcp -rack RACK # web UI: subnet, DHCP / PXE: proxy
|
||||
deploy/bootstrap.sh --iface eth1 --proxy-dhcp # the same at install time
|
||||
```
|
||||
|
||||
- The subnet needs no ranges. Without a static range, deployed machines keep
|
||||
getting their addresses from the site's server (`dhcp` links); set a static
|
||||
range aside, outside the site's pool, to give them fixed ones.
|
||||
- No `dhcp-host` lines are written for proxy subnets, and the router and DNS
|
||||
options come from the site's server.
|
||||
- `rollcall doctor` expects the foreign DHCP server here, and fails if none answers.
|
||||
- dnsmasq (2.91, still in 2.93) loops forever in its proxy code when a proxy
|
||||
range carries a `set:` tag: Rollcall's generated configuration leaves the tag
|
||||
out and limits the PXE services by interface instead.
|
||||
|
||||
Verified in the lab with a separate DHCP server on the provisioning network:
|
||||
commissioning and three deployments in a row through the proxy.
|
||||
|
||||
#### Fixed leases on the site's DHCP server (Kea, OpenWrt)
|
||||
|
||||
With a reservation driver, machines on proxy VLANs still get the addresses
|
||||
Rollcall plans: every such address becomes a fixed lease (the MAC of its
|
||||
interface, the address, the hostname) on the site's server. This is the
|
||||
counterpart of Infoblox's `fixedaddress`. Leases are reconciled on every
|
||||
address change and every `sync_interval`. Rollcall changes or deletes only
|
||||
its own leases; one held by someone else (same MAC or address) is reported
|
||||
as a conflict.
|
||||
|
||||
```json
|
||||
"dhcp_reservations": {
|
||||
"provider": "kea",
|
||||
"url": "http://kea.example:8000",
|
||||
"user": "rollcall",
|
||||
"password_file": "/etc/rollcall/kea.pw"
|
||||
}
|
||||
```
|
||||
|
||||
- **kea:** the control API with the `host_cmds` hook, open source since Kea
|
||||
3.0; Debian's Kea 2.6 lacks it and the driver says so.
|
||||
- Ownership goes in `user-context {"rollcall": machine}`.
|
||||
- With a hosts database the leases are kept there. Without one they go
|
||||
into the running configuration, and `config-write` saves them
|
||||
(`persist: false` turns that off).
|
||||
- **openwrt:** rpcd over HTTP (`url: http://router`). Fixed leases are
|
||||
`host` sections in `/etc/config/dhcp` with `option rollcall`, followed by
|
||||
`uci commit` and a dnsmasq reload. Give Rollcall its own rpcd login with
|
||||
only this:
|
||||
|
||||
```json
|
||||
/usr/share/rpcd/acl.d/rollcall.json
|
||||
{ "rollcall": { "description": "Rollcall fixed DHCP leases",
|
||||
"read": { "ubus": { "uci": ["get"] }, "uci": ["dhcp"] },
|
||||
"write": { "ubus": { "uci": ["add", "delete", "commit"], "rc": ["init"] }, "uci": ["dhcp"] } } }
|
||||
```
|
||||
|
||||
and a `config login` in `/etc/config/rpcd` (username, an MD5-crypt
|
||||
password, `list read/write 'rollcall'`).
|
||||
|
||||
```
|
||||
rollcall reservations # provider, last sync, conflicts
|
||||
rollcall reservations sync -dry
|
||||
```
|
||||
|
||||
IPv6 addresses are skipped: DHCPv6 reservations are keyed on DUIDs, not
|
||||
MACs. So are interfaces without a single MAC, such as bonds.
|
||||
|
||||
Verified in the lab against Kea 3.0.5 and OpenWrt 25.12.6: the machine got
|
||||
its planned address from the site's server and not from the pool.
|
||||
|
||||
## Certificate authority and TLS
|
||||
|
||||
The region is the installation's certificate authority, as the llmgw control
|
||||
@@ -460,10 +585,67 @@ plane is: `ca_dir` (default `/etc/rollcall/ca`) holds `ca.pem` and
|
||||
short-lived certificates to nodes with certbot or acme.sh; everything that
|
||||
trusts the Rollcall CA trusts them, and nothing outside the permitted names
|
||||
can be issued. Roots created before this (path length 0) cannot delegate:
|
||||
`rollcall ca rotate -yes` replaces the root, keeping the old one as a
|
||||
backup; rack controllers then join again and clients trust the new
|
||||
fingerprint. `deploy/tags/acme-cert.yaml` is a tag that fetches a node
|
||||
certificate at first boot and renews it.
|
||||
rotate the root (below). `deploy/tags/acme-cert.yaml` is a tag that fetches
|
||||
a node certificate at first boot and renews it.
|
||||
* **Intermediates renew themselves.** The region keeps every intermediate it
|
||||
signs and prints a renewal token once (`NAME.token` next to `NAME.pem`). On
|
||||
the host with the intermediate's key, `deploy/rollcall-intermediate-renew.sh`
|
||||
(installed as `/usr/local/sbin/rollcall-intermediate-renew`) and the
|
||||
`rollcall-intermediate-renew@NAME.timer` ask the region every day with that
|
||||
token and a CSR from the same key; settings in
|
||||
`/etc/rollcall-intermediate/NAME.env`:
|
||||
|
||||
```
|
||||
URL=https://region.example
|
||||
TOKEN_FILE=/etc/rollcall-intermediate/caddy.token
|
||||
CERT=/etc/caddy/pki/int.pem
|
||||
KEY=/etc/caddy/pki/int-key.pem
|
||||
ROOT=/etc/caddy/pki/root.pem # optional: kept to the active root
|
||||
RELOAD='systemctl restart caddy' # optional: after a new certificate
|
||||
```
|
||||
|
||||
The region signs again in the last third of the certificate's life, or right
|
||||
away when another root signs since a rotation's `switch`, always with the
|
||||
name constraints it was first given; the script checks what comes back (a CA
|
||||
certificate for its key, from a root it holds), keeps the old file as
|
||||
`.prev`, keeps the region's roots current from `/ca-bundle.pem`, and runs
|
||||
`RELOAD`. `rollcall ca intermediates` lists them; one under 30 days from its
|
||||
end, whose renewal failed or whose host has not asked for three days shows in
|
||||
`rollcall doctor`, on the web UI's overview and in the rotation status.
|
||||
`-replace` signs one the region keeps again with a new token (a lost token);
|
||||
`DELETE /api/v1/ca/intermediates?name=` stops keeping it (it stays valid to
|
||||
its end: there is no revocation list).
|
||||
* **Root rotation without re-joining.** In three steps, each followed by a
|
||||
restart of the region (`systemctl restart rollcall`):
|
||||
|
||||
```
|
||||
rollcall ca rotate prepare # a new root; the old one still signs
|
||||
rollcall ca rotate # status: which rack has what
|
||||
rollcall ca rotate switch # the new root signs; the old one stays trusted
|
||||
rollcall ca rotate retire # the old root is no longer trusted
|
||||
```
|
||||
|
||||
Everything that trusts the region gets the *trust bundle* (all current
|
||||
roots, `/ca-bundle.pem`). Rack controllers receive it signed by the active
|
||||
root and their root unit installs it only if that signature verifies
|
||||
against the roots they already trust; after `switch` they renew their
|
||||
client and server certificates from the new root by themselves. `switch`
|
||||
waits until every rack has the bundle, `retire` until every rack runs on
|
||||
certificates from the new root (`-force` goes on; racks that missed it
|
||||
join again). Machines deployed by this version get the roots one per file
|
||||
and a daily `rollcall-ca-refresh` timer that fetches the bundle over HTTPS
|
||||
from their rack, checked against the roots they trust, so they pick up a
|
||||
new root during `prepare` and drop the old one after `retire`: leave at
|
||||
least a day between `prepare` and `switch` (the rotation status cannot see
|
||||
deployed machines). A machine that missed it says so in its journal and
|
||||
needs the bundle by hand. Machines deployed earlier need it once, during
|
||||
`prepare` (`curl --cacert <old root> https://<rack>/ca-bundle.pem`; after
|
||||
`switch` the servers no longer show the old root). Admin
|
||||
certificates and intermediates signed by the old root work until `retire`;
|
||||
issue them again after `switch` (the status lists the admin certificates;
|
||||
an ACME server under an intermediate also needs its stored certificates
|
||||
issued again, e.g. Caddy's `certificates/<ca>` directory moved away). `/ca.pem` stays a single certificate (the
|
||||
root of that server's own certificate), which agents check by fingerprint.
|
||||
|
||||
## Power drivers
|
||||
|
||||
@@ -473,13 +655,77 @@ that reaches the BMC network.
|
||||
| Driver | For |
|
||||
|---|---|
|
||||
| `ipmi` | IPMI 2.0 over LAN (ipmitool lanplus), any vendor; settings `cipher` (17 on newer BMCs), `privilege`, `interface` |
|
||||
| `redfish` | DMTF Redfish, any vendor: system discovered, ResetType chosen from what the BMC allows, boot override once (with If-Match where the BMC wants it) |
|
||||
| `idrac` | Dell iDRAC 8/9 (Redfish, `System.Embedded.1`) |
|
||||
| `ilo` | HPE iLO 4 (2.30+), 5, 6 (Redfish; no PowerCycle, so ForceRestart; PushPowerButton for soft off on older firmware) |
|
||||
| `supermicro`, `xcc`, `openbmc` | Supermicro, Lenovo XClarity, OpenBMC (Redfish) |
|
||||
| `redfish` and the vendor drivers below | DMTF Redfish (see below) |
|
||||
| `proxmox` | Proxmox VE VMs: API token (user = token id, password = secret), settings `node`, `vmid` |
|
||||
| `manual` | no power control; actions say so |
|
||||
|
||||
### Redfish across vendors
|
||||
|
||||
There is one Redfish implementation of the DMTF standard. These rules hold for
|
||||
every vendor:
|
||||
|
||||
- **Reset types and boot targets.** Read from what the BMC advertises
|
||||
(`@Redfish.AllowableValues` or the action's ActionInfo), never assumed.
|
||||
- **Boot override.** Target and enable go in one PATCH.
|
||||
- **If-Match.** Sent with the resource's ETag (header or `@odata.etag`). On
|
||||
412 it is retried with the strong form, then without; on 428 it is fetched
|
||||
first.
|
||||
- **Pending settings.** A BMC that keeps them elsewhere (`.../SD`,
|
||||
`@Redfish.Settings`) gets the same PATCH there.
|
||||
- **Busy answers.** Retried for up to a minute: the server is in POST, 409
|
||||
right after a power change, 503, "Retry in N seconds".
|
||||
- **202 answers.** Followed to their task monitor.
|
||||
- **Reset on the chassis.** Used when the system has no reset of its own.
|
||||
- **Missing power cycle.** ForceOff, wait for Off, then On.
|
||||
- **Paths from the BMC.** Only followed if they stay on the BMC and under
|
||||
`/redfish/`.
|
||||
|
||||
What a vendor documents differently is a profile in
|
||||
`cmd/rollcall/redfish.go`, with its sources. The profile decides the driver
|
||||
name, how the BMC is recognised, its usual system path, the boot mode rule, how
|
||||
to clear an override and the login method.
|
||||
|
||||
| Driver | Vendor | Boot mode in an override | Notes |
|
||||
|---|---|---|---|
|
||||
| `idrac` | Dell iDRAC 8/9/10 | not sent: a change starts a BIOS job | `System.Embedded.1`; iDRAC 10 takes boot settings at `.../Settings` |
|
||||
| `ilo` | HPE iLO 4 (2.30+), 5, 6 | current one, without it if refused | no PowerCycle (ForceRestart); no boot changes during POST (retried) |
|
||||
| `xcc` | Lenovo XCC, XCC2 | current one, without it if refused | override is Once only |
|
||||
| `supermicro` | Supermicro X11+, H12+ | always the current one | left out, some boards switch to Legacy |
|
||||
| `xfusion` | xFusion / Huawei iBMC | current one, without it if refused | session login; If-Match on every PATCH; ForcePowerCycle |
|
||||
| `kaytus` | KAYTUS / Inspur | current one, without it if refused | no public Redfish guide: reset may be on the chassis, a PATCH that worked can answer 400 |
|
||||
| `irmc` | Fujitsu / Fsas iRMC S5, S6 | not sent | `Systems/0`; ETag from the body; clearing is Target None (never Enabled Disabled); no GracefulShutdown (power button) |
|
||||
| `cimc` | Cisco UCS C-series CIMC | current one, without it if refused | `Systems/<serial>`, found through the collection |
|
||||
| `gigabyte` | GIGABYTE (MegaRAC SP-X) | always the current one | `Systems/Self`; If-Match required; short reset list |
|
||||
| `asrockrack` | ASRock Rack (MegaRAC) | always the current one | boot settings in `Systems/Self/SD` |
|
||||
| `openbmc` | OpenBMC (bmcweb) | current one, without it if refused | `Systems/system` |
|
||||
| `ami` | AMI MegaRAC SP-X boards | always the current one | `Systems/Self`; If-Match required |
|
||||
|
||||
Settings a machine can override:
|
||||
|
||||
| Setting | Effect |
|
||||
|---|---|
|
||||
| `system=` | the system path |
|
||||
| `boot_mode=UEFI\|Legacy` | always send this mode |
|
||||
| `auth=session\|basic` | login method |
|
||||
| `verify_tls=true` | check the BMC's certificate |
|
||||
|
||||
Every profile is tested against a fake service that behaves as its sources
|
||||
describe (`redfish_test.go`). The profiles have not yet run against the real
|
||||
machines.
|
||||
|
||||
**HTTPS BMCs (Redfish, Proxmox) are trusted by their certificate.** Before
|
||||
credentials first go to such a BMC, the rack controller reads its
|
||||
certificate with a TLS handshake that sends nothing. The region pins the
|
||||
SHA-256 on the BMC record as `extra tls_fingerprint`. A later connection
|
||||
showing a different certificate sends no credentials: the command fails
|
||||
with both fingerprints, and the machine gets the event
|
||||
`bmc certificate changed`.
|
||||
|
||||
To trust a replaced BMC, clear the pin with `rollcall bmc ID -set
|
||||
tls_fingerprint=`. Changing the address does the same, and so does a BMC
|
||||
firmware update run by Rollcall. To check against the system's CAs
|
||||
instead, set `verify_tls=true`.
|
||||
|
||||
After commissioning, a BMC still on IPMI is asked whether it speaks Redfish
|
||||
with the same account; if it does, the driver for its vendor takes over
|
||||
(event `bmc driver`). An operator's choice is never overridden.
|
||||
@@ -491,6 +737,74 @@ rollcall bmc m-1234 -driver redfish -set system=/redfish/v1/Systems/1 -set verif
|
||||
rollcall bmc m-1234 -probe # detect Redfish again
|
||||
```
|
||||
|
||||
### Firmware updates (Redfish)
|
||||
|
||||
Rollcall keeps firmware packages in a repository on the region, reads each
|
||||
machine's firmware inventory from its BMC, and runs updates as jobs.
|
||||
|
||||
```sh
|
||||
# on the region: a file (or from anywhere: an https URL, which the region downloads)
|
||||
sudo rollcall firmware import BIOS_R760_WN64_2.3.5.EXE -vendor idrac -component bios -version 2.3.5 -models R760 -sha256 ...
|
||||
rollcall firmware list
|
||||
rollcall firmware show m-1234 -refresh # what is installed, what the repository offers
|
||||
rollcall firmware update m-1234 idrac-bios-2.3.5-2c49752f -reboot -watch
|
||||
rollcall firmware update tag:r760 idrac-bmc-7.20.10.50-097a4a54
|
||||
```
|
||||
|
||||
**The package.** A package names the BMC driver it is for (`idrac`, `ilo`,
|
||||
`supermicro`, ...), a component (`bios`, `bmc`, `nic`, `raid`, `cpld`,
|
||||
`psu`, `drive`, `other`), the version the component reports once installed,
|
||||
and optionally the models it fits. An update refuses a package for another
|
||||
vendor or model unless `-force`. The BMC checks the package itself:
|
||||
signature, model and downgrade policy. An HPE `.fwpkg` is unpacked at
|
||||
import to the iLO `.bin` or UEFI `.signed.flash` inside, because iLO's
|
||||
SimpleUpdate accepts a `.fwpkg` and then flashes nothing.
|
||||
|
||||
**The job.** The region drives the update through the rack controller next
|
||||
to the BMC:
|
||||
|
||||
1. It hands the package over: an upload, or the BMC downloads it from the
|
||||
rack controller's `/firmware/`. Set `firmware_base=` on the BMC record
|
||||
when BMCs reach another address.
|
||||
2. It watches the BMC's task. A BMC that restarts after flashing itself is
|
||||
waited for, up to 25 minutes.
|
||||
3. It reads the inventory until the new version shows.
|
||||
|
||||
`-reboot` power-cycles the host when the update waits for a reset (BIOS on
|
||||
most vendors), then verifies. Without it, a staged update ends as
|
||||
"accepted, applies at the next reboot". A running update survives a region
|
||||
restart. It cannot be cancelled once the BMC has the package.
|
||||
|
||||
**How each vendor takes a package.** Each vendor profile in
|
||||
`cmd/rollcall/redfish_fwplans.go` carries its documented request, with
|
||||
sources. Without a profile, the standard methods are tried in this order:
|
||||
multipart push, SimpleUpdate pull, HttpPushUri.
|
||||
|
||||
| Driver | How | Packages | Notes |
|
||||
|---|---|---|---|
|
||||
| `idrac` | MultipartUpload (SimpleUpdate pull on older iDRAC 9) | DUP `.EXE`, `.d9` | OnReset by default; BIOS/RAID/NIC apply at the next reboot |
|
||||
| `ilo` | SimpleUpdate: iLO pulls over HTTP | iLO `.bin`, UEFI `.signed.flash` (from `.fwpkg`) | components only an OS agent flashes need SUM/iSUT |
|
||||
| `xcc` | `/mfwupdate`, after taking `HttpPushUriTargetsBusy` | `.uxz` | update XCC before UEFI |
|
||||
| `supermicro` | `/UpdateService/upload` with targets and preserve options | `.bin` | licence SFT-OOB-LIC/SFT-DCMS-SINGLE; host powered off for BIOS |
|
||||
| `gigabyte`, `ami`, `asrockrack` | `/UpdateService/upload` with `OemParameters` ImageType | `.ima`, `.ima_enc`, `.hpm`, `.bin` | the BMC restarts after a BMC flash; ASRock Rack unverified |
|
||||
| `openbmc` | update-multipart (BMC Immediate, others OnReset) | signed `.tar` | |
|
||||
| `xfusion` | upload to `/tmp/web`, then SimpleUpdate | `.hpm` | BMC, BIOS, CPLD |
|
||||
| `kaytus` | upload, then OEM SimpleUpdate (FlashItem) | `.hpm`, `.bin` | from KAYTUS's own SDK; BMC and BIOS |
|
||||
| `irmc` | OEM actions (FWUpdate, BiosUpdate) | iRMC `.bin`, BIOS `.UPC` | |
|
||||
| `cimc` | not supported | | Cisco documents only SCP pulls |
|
||||
|
||||
Per machine, on the BMC record:
|
||||
|
||||
| Setting | Effect |
|
||||
|---|---|
|
||||
| `fw_method=multipart\|simple\|push` | skip the vendor plan and use this standard method |
|
||||
| `fw_params={...}` | replace the plan's UpdateParameters (e.g. Supermicro Gen 13 BIOS preserve options) |
|
||||
| `firmware_base=http://ADDR` | the address BMCs download from |
|
||||
|
||||
The lab Redfish emulator has an UpdateService of the same shape: iDRAC
|
||||
profile multipart, iLO profile pull. The BMC version applies at once, the
|
||||
BIOS at the next reset.
|
||||
|
||||
The lab has `lab/pve-vbmc/pve_redfish.py`, a Redfish service per Proxmox VM
|
||||
with iDRAC, iLO and generic profiles, next to the IPMI emulator.
|
||||
|
||||
@@ -514,7 +828,7 @@ Verified in the lab: Debian 13 (with a bond and VLAN), Rocky 9 and AlmaLinux 9
|
||||
dual-stack on two segments, AAAA lookups through dnsmasq, the admin CLI over
|
||||
`https://[fdc3:...]`.
|
||||
|
||||
## External IPAM (NetBox; Infoblox-style)
|
||||
## External IPAM (NetBox, Infoblox)
|
||||
|
||||
Rollcall works on its own, but at a site with an established network plan the
|
||||
address authority is somewhere else. With `ipam` in `config.json` every
|
||||
@@ -558,9 +872,204 @@ rollcall ipam sync
|
||||
rollcall ipam prefixes | import
|
||||
```
|
||||
|
||||
### Devices in NetBox
|
||||
|
||||
With `device_sync` in the `ipam` block, the NetBox driver also keeps every
|
||||
commissioned machine as a `dcim` device:
|
||||
|
||||
```json
|
||||
"ipam": { "provider": "netbox", ..., "device_sync": { "site": "dc1", "role": "server" } }
|
||||
```
|
||||
|
||||
- **What is written.** Name (node name, else the machine ID), serial, status
|
||||
(deployed/booted/stateless are `active`, commissioned ones `staged`, failed
|
||||
jobs `failed`), the device type from the DMI manufacturer and product, the
|
||||
role (default `server`), interfaces with their MACs (NetBox 4.2+ MAC objects,
|
||||
set as primary MAC), Rollcall's addresses assigned to those interfaces, and
|
||||
the management address as primary IP. Machines still `new` are skipped.
|
||||
- **Location comes from NetBox.** The machine's building, room and rack are
|
||||
matched by name to a site, a location and a rack (in that site); the unit
|
||||
becomes the rack position, the asset tag the device's. Rollcall never creates
|
||||
sites, locations or racks; a machine whose building matches no site goes to
|
||||
`device_sync.site`, which must exist. Manufacturers, device types and the role
|
||||
are created when missing (a device needs them).
|
||||
- **Ownership as for addresses.** Devices carry the tag `rollcall` and the
|
||||
machine ID in `rollcall_machine` (enable the field for devices too). A device
|
||||
of the same name without them is reported as a conflict and left alone;
|
||||
Rollcall's devices whose machine is gone are deleted. Interfaces are removed
|
||||
only when they carry the tag.
|
||||
- **It runs after the address reconcile**, in the same loop; `rollcall ipam
|
||||
sync` reports both (`devices` in the JSON).
|
||||
|
||||
The NetBox user needs `view/add/change/delete` on `dcim.device`,
|
||||
`dcim.interface` and `dcim.macaddress`, `view/add` on `dcim.manufacturer`,
|
||||
`dcim.devicetype` and `dcim.devicerole`, and `view` on `dcim.location` and
|
||||
`dcim.rack`, on top of the IPAM permissions.
|
||||
|
||||
### NetBox's UI through Rollcall
|
||||
|
||||
When NetBox runs under a path (its `BASE_PATH`, e.g. `BASE_PATH = 'netbox/'`)
|
||||
and `ipam.url` carries it (`"url": "https://netbox.example/netbox/"`), Rollcall
|
||||
serves NetBox's web UI at that same path on a listener of its own,
|
||||
`https://<rollcall>:8444/netbox/` (`ipam.ui_listen`, default `:8444`), for
|
||||
admins only (the admin certificate, as for the API). NetBox keeps its own login.
|
||||
This opens NetBox wherever Rollcall opens, also when the browser cannot reach
|
||||
NetBox itself. The separate port is on purpose: another origin than Rollcall's
|
||||
UI, so a script in a NetBox page (a custom link, an export template) can
|
||||
neither read the Rollcall UI's stored admin token nor call Rollcall's API as
|
||||
the admin. Two settings on the NetBox side:
|
||||
|
||||
```python
|
||||
BASE_PATH = 'netbox/'
|
||||
CSRF_TRUSTED_ORIGINS = ['https://rollcall.example:8444'] # where browsers post from
|
||||
```
|
||||
|
||||
**Signing in with the admin certificate.** With `"ui_login_secret_file"` in the
|
||||
`ipam` block, the proxy tells NetBox who the admin is: `X-Rollcall-User` (the
|
||||
certificate's admin name) with the shared secret in `X-Rollcall-Proxy-Secret`.
|
||||
NetBox takes the user from that header and creates it on first use:
|
||||
|
||||
```python
|
||||
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'] # give this group the permissions admins should have
|
||||
```
|
||||
|
||||
The web server in front of NetBox must drop `X-Rollcall-User` from every
|
||||
request that does not come from Rollcall with the secret, or anyone who reaches
|
||||
NetBox directly could claim a user. With Caddy:
|
||||
|
||||
```
|
||||
@notrollcall not {
|
||||
remote_ip 10.0.0.5 # the region
|
||||
header X-Rollcall-Proxy-Secret "<secret>"
|
||||
}
|
||||
request_header @notrollcall -X-Rollcall-User
|
||||
request_header -X-Rollcall-Proxy-Secret
|
||||
```
|
||||
|
||||
Rollcall drops both headers when a browser sends them, in any spelling a WSGI
|
||||
server would read as the same (`X-Rollcall_User`). An admin with a token
|
||||
instead of a certificate signs in to NetBox by hand. `lab/netbox/install.sh`
|
||||
sets all of this up for the lab NetBox.
|
||||
|
||||
The web UI then links to NetBox: the sidebar, a site, room and rack on the Sites
|
||||
pages, a switch, and a machine's device. `ipam.ui_url` points those links
|
||||
elsewhere (NetBox reached directly) when that is preferred; a NetBox at the root
|
||||
of its host gets links to `ui_url` only.
|
||||
|
||||
### Cabling and switches
|
||||
|
||||
`GET /api/v1/cabling` gives each located machine's ports and the switch port
|
||||
each is plugged into, from the LLDP its commissioning saw (`lldpd` in the
|
||||
commissioning image). A switch's place is NetBox's (the device with that name,
|
||||
its rack and position, read with the layout on every reconcile; the NetBox user
|
||||
needs `view` on `dcim.device`) or set by hand (`POST /api/v1/switches
|
||||
{"name","building","room","rack","unit"}`, which NetBox does not move; `DELETE
|
||||
?name=` forgets it). The 3D room draws the backs of the servers, the switches
|
||||
and the cables; a switch with no place is drawn where most of its cables are,
|
||||
marked as estimated.
|
||||
|
||||
Where LLDP sees nothing, a port's cable comes from NetBox: the cables recorded
|
||||
on Rollcall's devices (through patch panels, to the far interface), read with
|
||||
the layout; the NetBox user needs `view` on `dcim.interface` and `dcim.cable`.
|
||||
Then from one entered by hand (`POST /api/v1/cables
|
||||
{"machine","port","switch","switch_port"}`, the ✎ in the cabling list).
|
||||
The BMC's own port is never seen by the host's LLDP: its cable to the
|
||||
out-of-band switch is NetBox's (an interface marked management-only or named
|
||||
iDRAC, iLO, XCC, IMM, IPMI, BMC, OOB or mgmt) or entered by hand with port
|
||||
`bmc`. When NetBox records another switch port than LLDP sees, the port shows
|
||||
what NetBox says. A switch whose NetBox role names out-of-band or management,
|
||||
or that only BMCs are cabled to, is marked OOB.
|
||||
|
||||
### Infoblox
|
||||
|
||||
The same contract over Infoblox WAPI (v2.12 by default; give a full
|
||||
`.../wapi/vX.Y` URL for another):
|
||||
|
||||
```json
|
||||
"ipam": {
|
||||
"provider": "infoblox",
|
||||
"url": "https://gm.example.com",
|
||||
"user": "rollcall",
|
||||
"password_file": "/etc/rollcall/infoblox.pw",
|
||||
"ca_file": "/etc/rollcall/infoblox-ca.pem",
|
||||
"dns_zone": "int.example.com",
|
||||
"network_view": "default",
|
||||
"dns_view": "default"
|
||||
}
|
||||
```
|
||||
|
||||
- **One host record per address.** Named by its DNS name (`rollcall-<address>`
|
||||
without one) and configured for DNS in `dns_view`, so Infoblox publishes the
|
||||
A/AAAA record itself; `"dns_view": "-"` keeps them IPAM-only. The zone must
|
||||
exist in that view.
|
||||
- **Ownership** is the extensible attribute `rollcall_machine` (define it in
|
||||
the grid as String). Rollcall changes it with `extattrs+`, so a host's other
|
||||
attributes stay; hosts without it are never changed or deleted.
|
||||
- **Taken** is anything else on the address: another host, a lease, a fixed
|
||||
address, a DHCP or reserved range, the network or broadcast address.
|
||||
- **Delegated networks** carry the extensible attribute `rollcall` (any value;
|
||||
define it as String); `Site` and `VLAN` attributes are shown when present.
|
||||
- The WAPI user needs read/write on host records and read on networks and
|
||||
IPv4/IPv6 addresses in the network view (and the DNS view when publishing).
|
||||
|
||||
Device sync (above) is NetBox-only.
|
||||
|
||||
The provider is an interface (reserve, owned records, update, delete,
|
||||
delegated prefixes); the NetBox driver is the first. An Infoblox WAPI driver
|
||||
maps them to `record:host`/`fixedaddress` with extensible attributes.
|
||||
delegated prefixes) with the NetBox and Infoblox drivers.
|
||||
|
||||
## DNS provider (PowerDNS, RFC 2136)
|
||||
|
||||
Without an IPAM authority whose DNS sync publishes names, Rollcall can write
|
||||
its machines' A/AAAA records into the site's DNS itself. Names follow the
|
||||
IPAM rule above (`<host>.<zone>`, `<host>-<iface>.<zone>`).
|
||||
|
||||
```json
|
||||
"dns": {
|
||||
"provider": "powerdns",
|
||||
"zone": "int.example.com",
|
||||
"url": "http://dns.int.example.com:8081",
|
||||
"api_key_file": "/etc/rollcall/pdns.key"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
"dns": {
|
||||
"provider": "rfc2136",
|
||||
"zone": "int.example.com",
|
||||
"server": "ns1.int.example.com:53",
|
||||
"tsig_name": "rollcall-key",
|
||||
"tsig_algorithm": "hmac-sha256",
|
||||
"tsig_secret_file": "/etc/rollcall/tsig.secret"
|
||||
}
|
||||
```
|
||||
|
||||
- **powerdns** uses the HTTP API. Rollcall's RRsets carry a comment with
|
||||
account `rollcall`; RRsets without it are never changed or deleted.
|
||||
- **rfc2136** is a TSIG-signed dynamic update, for BIND, Knot, PowerDNS,
|
||||
Windows DNS and Infoblox. Next to each name Rollcall writes a marker
|
||||
`_rollcall.<name> TXT "heritage=rollcall"`. A change to an existing record
|
||||
needs the marker as a prerequisite; a new record needs the name to be
|
||||
free. The server checks both atomically, so a record someone else owns is
|
||||
never overwritten. What Rollcall wrote is kept in the database, and each
|
||||
sync queries those records. A record changed behind Rollcall's back is
|
||||
repaired when the marker is still there. When someone else's record has
|
||||
taken the name, it is reported as a conflict.
|
||||
For BIND, grant the key the zone: `update-policy { grant rollcall-key zonesub ANY; };`.
|
||||
|
||||
A sync runs on every allocation and release and every `sync_interval`
|
||||
(default 5m), with `ttl` defaulting to 300.
|
||||
|
||||
```
|
||||
rollcall dns # provider, last sync, conflicts
|
||||
rollcall dns sync -dry # what would change
|
||||
rollcall dns sync
|
||||
```
|
||||
|
||||
Conflicts and errors appear there, in the log and in `rollcall doctor`.
|
||||
|
||||
## Lab
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ 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}
|
||||
# root's SSH keys in the commissioning environment: none unless asked for (a lab
|
||||
# debugging aid; a published image must not carry anyone's keys)
|
||||
AUTHKEYS=${AUTHKEYS:-}
|
||||
|
||||
mkdir -p "$BUILD" "$OUT"
|
||||
cd "$BUILD"
|
||||
@@ -98,7 +100,7 @@ install -m 0755 "$SRC/rollcall-agent.py" config/includes.chroot/usr/local/bin/ro
|
||||
install -m 0755 "$SRC/rollcall-deploy.py" config/includes.chroot/usr/local/bin/rollcall-deploy.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
|
||||
if [ -n "$AUTHKEYS" ] && [ -r "$AUTHKEYS" ]; then
|
||||
install -m 0600 "$AUTHKEYS" config/includes.chroot/root/.ssh/authorized_keys
|
||||
chmod 0700 config/includes.chroot/root/.ssh
|
||||
fi
|
||||
@@ -125,5 +127,7 @@ for f in vmlinuz initrd.img filesystem.squashfs; do
|
||||
[ -n "$src" ] || { echo "missing $f in binary/live" >&2; exit 1; }
|
||||
install -m 0644 "$src" "$OUT/$f"
|
||||
done
|
||||
# checked by bootstrap.sh --image-url when another region copies this one
|
||||
(cd "$OUT" && sha256sum vmlinuz initrd.img filesystem.squashfs > SHA256SUMS)
|
||||
ls -la "$OUT"
|
||||
echo "=== done $(date)"
|
||||
+54
-6
@@ -13,11 +13,13 @@ Kernel cmdline options:
|
||||
rollcall.mode=deploy rollcall.job=.. run rollcall-deploy.py instead (OS deployment / release job)
|
||||
"""
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -88,6 +90,13 @@ def dmi_system():
|
||||
# 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")
|
||||
# SMBIOS type 3: the enclosure's height in rack units ("2 U"; often "Unspecified")
|
||||
# and power cords; the region falls back to the model when the height is missing
|
||||
for b in dmi_blocks(3)[:1]:
|
||||
m = re.match(r"(\d+)\s*U\b", b.get("Height", ""))
|
||||
d["chassis-height-u"] = int(m.group(1)) if m else None
|
||||
cords = b.get("Number Of Power Cords", "")
|
||||
d["chassis-power-cords"] = int(cords) if cords.isdigit() else None
|
||||
return d
|
||||
|
||||
|
||||
@@ -399,6 +408,36 @@ def bmc_inband(user_name):
|
||||
AGENT_TOKEN = ""
|
||||
|
||||
|
||||
def tls_setup(opts, server):
|
||||
"""rollcall.tls=URL rollcall.ca=SHA256: the CA certificate is fetched and
|
||||
accepted only by that fingerprint; from then on everything goes over HTTPS
|
||||
with it. When TLS was asked for and does not work, nothing falls back to HTTP."""
|
||||
url, want = opts.get("tls"), str(opts.get("ca") or "").replace(":", "").lower()
|
||||
if not isinstance(url, str) or not url.startswith("https://") or len(want) != 64:
|
||||
return server, None, None
|
||||
pem = None
|
||||
for i in range(10):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{server}/ca.pem", timeout=10) as r:
|
||||
pem = r.read().decode()
|
||||
break
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
log(f"ca.pem: {e}, retry {i + 1}/10")
|
||||
time.sleep(3)
|
||||
if pem is None:
|
||||
raise RuntimeError("cannot fetch the CA certificate")
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != want:
|
||||
raise RuntimeError("the CA certificate does not match rollcall.ca: not talking to this server")
|
||||
path = "/run/rollcall-ca.pem"
|
||||
with open(path, "w") as f:
|
||||
f.write(pem)
|
||||
log(f"talking to {url} over HTTPS (CA {want[:16]}...)")
|
||||
return url.rstrip("/"), ssl.create_default_context(cafile=path), path
|
||||
|
||||
|
||||
TLS = None # ssl context once tls_setup ran
|
||||
|
||||
|
||||
def agent_headers():
|
||||
h = {"Content-Type": "application/json"}
|
||||
if AGENT_TOKEN:
|
||||
@@ -409,14 +448,19 @@ def agent_headers():
|
||||
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:
|
||||
with urllib.request.urlopen(req, timeout=10, context=TLS) 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": []}
|
||||
"user": h.get("user"), "errors": []}
|
||||
if not h.get("password"):
|
||||
# the region keeps the credentials and checks the BMC itself (rollcall >= 0.3)
|
||||
res["configured"] = True
|
||||
return res
|
||||
res["password"] = h["password"]
|
||||
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)
|
||||
@@ -434,7 +478,7 @@ def post(server, payload, tries=30):
|
||||
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:
|
||||
with urllib.request.urlopen(req, timeout=30, context=TLS) as r:
|
||||
return json.load(r)
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
log(f"post failed ({e}), retry {i + 1}/{tries}")
|
||||
@@ -452,7 +496,8 @@ def main():
|
||||
log("rollcall.server= missing on kernel cmdline; nothing to do")
|
||||
return 2
|
||||
server = server.rstrip("/")
|
||||
global AGENT_TOKEN
|
||||
global AGENT_TOKEN, TLS
|
||||
server, TLS, _ = tls_setup(opts, server)
|
||||
AGENT_TOKEN = opts.get("token") if isinstance(opts.get("token"), str) else ""
|
||||
user_name = opts.get("bmcuser") or "commissioner"
|
||||
started = time.time()
|
||||
@@ -499,8 +544,11 @@ def main():
|
||||
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)
|
||||
keep = json.loads(json.dumps(inv))
|
||||
if (keep.get("bmc") or {}).get("password"):
|
||||
keep["bmc"]["password"] = "<not kept>"
|
||||
with open(os.open("/root/inventory.json", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
|
||||
json.dump(keep, f, indent=1)
|
||||
if opts.get("nopoweroff") or not resp:
|
||||
log("staying up (nopoweroff or delivery failure)")
|
||||
return 0 if resp else 1
|
||||
|
||||
+81
-27
@@ -12,9 +12,11 @@ Everything the agent needs (image URL, checksum, disk, seed files) comes from
|
||||
GET /api/v1/jobs/self; it never decides policy itself.
|
||||
"""
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -61,6 +63,35 @@ def cmdline():
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- server
|
||||
TLS, CAFILE = None, None
|
||||
|
||||
def tls_setup(opts, server):
|
||||
"""rollcall.tls=URL rollcall.ca=SHA256: the CA certificate is fetched and
|
||||
accepted only by that fingerprint; from then on everything goes over HTTPS
|
||||
with it. When TLS was asked for and does not work, nothing falls back to HTTP."""
|
||||
url, want = opts.get("tls"), str(opts.get("ca") or "").replace(":", "").lower()
|
||||
if not isinstance(url, str) or not url.startswith("https://") or len(want) != 64:
|
||||
return server, None, None
|
||||
pem = None
|
||||
for i in range(10):
|
||||
try:
|
||||
with urllib.request.urlopen(f"{server}/ca.pem", timeout=10) as r:
|
||||
pem = r.read().decode()
|
||||
break
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
log(f"ca.pem: {e}, retry {i + 1}/10")
|
||||
time.sleep(3)
|
||||
if pem is None:
|
||||
raise RuntimeError("cannot fetch the CA certificate")
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != want:
|
||||
raise RuntimeError("the CA certificate does not match rollcall.ca: not talking to this server")
|
||||
path = "/run/rollcall-ca.pem"
|
||||
with open(path, "w") as f:
|
||||
f.write(pem)
|
||||
log(f"talking to {url} over HTTPS (CA {want[:16]}...)")
|
||||
return url.rstrip("/"), ssl.create_default_context(cafile=path), path
|
||||
|
||||
|
||||
def api(method, path, body=None, tries=20):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(SERVER + path, data=data, method=method,
|
||||
@@ -68,7 +99,7 @@ def api(method, path, body=None, tries=20):
|
||||
last = None
|
||||
for i in range(tries):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
with urllib.request.urlopen(req, timeout=30, context=TLS) as r:
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
if 400 <= e.code < 500:
|
||||
@@ -156,7 +187,9 @@ def find_disk(spec):
|
||||
log(f"{want} not found; serial {serial} is {d['path']}")
|
||||
return d
|
||||
raise RuntimeError(f"target disk {want} not present")
|
||||
cands = [d for d in disks if not d.get("rm") and d.get("tran") != "usb" and int(d["size"]) >= need]
|
||||
# never a USB stick or a SAN LUN by itself (the region picks the same way)
|
||||
remote = {"usb", "iscsi", "fc", "fcoe", "srp", "iser"}
|
||||
cands = [d for d in disks if not d.get("rm") and d.get("tran") not in remote and int(d["size"]) >= need]
|
||||
if not cands:
|
||||
raise RuntimeError(f"no disk >= {need} bytes")
|
||||
cands.sort(key=lambda d: (int(d["size"]), d["name"]))
|
||||
@@ -182,31 +215,48 @@ def release_disk(path):
|
||||
# --------------------------------------------------------------------------- deploy
|
||||
def write_image(spec, disk, prog):
|
||||
url, want, size = spec["image_url"], spec["sha256"], int(spec.get("size") or 0)
|
||||
sumf, pvf = "/run/rollcall.sum", "/run/rollcall.pv"
|
||||
for f in (sumf, pvf):
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
pipeline = (f"set -o pipefail; curl -fsSL --retry 5 --retry-connrefused '{url}' "
|
||||
f"| tee >(sha256sum > {sumf}) "
|
||||
f"| pv -n -f -i 2 -s {size} 2>{pvf} "
|
||||
f"| zstd -dc -T0 "
|
||||
f"| dd of='{disk}' bs=4M iflag=fullblock oflag=direct conv=fsync status=none")
|
||||
if not re.match(r"^https?://", url):
|
||||
raise RuntimeError(f"image url {url!r} is not http(s)")
|
||||
pvf = "/run/rollcall.pv"
|
||||
if os.path.exists(pvf):
|
||||
os.remove(pvf)
|
||||
# values go in as arguments, never into the shell text; the checksum is taken
|
||||
# here, over exactly the bytes handed to the writer
|
||||
down = subprocess.Popen(
|
||||
["bash", "-c", 'set -o pipefail; pv -n -f -i 2 -s "$1" 2>"$2" | zstd -dc -T0 '
|
||||
'| dd of="$3" bs=4M iflag=fullblock oflag=direct conv=fsync status=none', "write", str(size), pvf, disk],
|
||||
stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
cacert = ["--cacert", CAFILE] if CAFILE and url.startswith("https://") else []
|
||||
curl = subprocess.Popen(["curl", "-fsSL", "--retry", "5", "--retry-connrefused"] + cacert + ["--", url],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
log(f"writing {url} -> {disk}")
|
||||
proc = subprocess.Popen(["bash", "-c", pipeline], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
pct = 0
|
||||
while proc.poll() is None:
|
||||
time.sleep(2)
|
||||
try:
|
||||
with open(pvf) as f:
|
||||
lines = f.read().split()
|
||||
if lines:
|
||||
pct = int(lines[-1])
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
prog.set("writing", min(pct, 99), f"{pct}% of {size // (1 << 20)} MiB written to {disk}")
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"image write failed rc={proc.returncode}: {proc.stderr.read().strip()[-400:]}")
|
||||
got = readf(sumf).split()[0] if readf(sumf) else ""
|
||||
h, pct, shown = hashlib.sha256(), 0, 0.0
|
||||
try:
|
||||
while True:
|
||||
b = curl.stdout.read(1 << 20)
|
||||
if not b:
|
||||
break
|
||||
h.update(b)
|
||||
down.stdin.write(b)
|
||||
if time.time() - shown >= 2:
|
||||
shown = time.time()
|
||||
try:
|
||||
with open(pvf) as f:
|
||||
lines = f.read().split()
|
||||
if lines:
|
||||
pct = int(lines[-1])
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
prog.set("writing", min(pct, 99), f"{pct}% of {size // (1 << 20)} MiB written to {disk}")
|
||||
down.stdin.close()
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
crc, drc = curl.wait(), down.wait()
|
||||
if crc != 0:
|
||||
raise RuntimeError(f"download failed rc={crc}: {curl.stderr.read().decode(errors='replace').strip()[-400:]}")
|
||||
if drc != 0:
|
||||
raise RuntimeError(f"image write failed rc={drc}: {down.stderr.read().decode(errors='replace').strip()[-400:]}")
|
||||
got = h.hexdigest()
|
||||
if got != want:
|
||||
raise RuntimeError(f"sha256 mismatch: got {got} want {want}")
|
||||
run(["sync"])
|
||||
@@ -244,7 +294,9 @@ def seed(root, files):
|
||||
if not os.path.isdir(f"{mnt}/etc"):
|
||||
raise RuntimeError(f"{root['path']} has no /etc; not a root filesystem")
|
||||
for f in files:
|
||||
dst = os.path.join(mnt, f["path"].lstrip("/"))
|
||||
dst = os.path.realpath(os.path.join(mnt, f["path"].lstrip("/")))
|
||||
if not dst.startswith(mnt + "/"):
|
||||
raise RuntimeError(f"seed file {f['path']!r} is outside the root filesystem")
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
with open(dst, "w") as fh:
|
||||
fh.write(f["content"])
|
||||
@@ -349,6 +401,8 @@ def main():
|
||||
global SERVER, TOKEN
|
||||
opts = cmdline()
|
||||
SERVER = (opts.get("server") or "").rstrip("/")
|
||||
global TLS, CAFILE
|
||||
SERVER, TLS, CAFILE = tls_setup(opts, SERVER)
|
||||
TOKEN = opts.get("job") if isinstance(opts.get("job"), str) else ""
|
||||
if not SERVER or not TOKEN:
|
||||
log("rollcall.server= and rollcall.job= required")
|
||||
|
||||
@@ -117,12 +117,28 @@ func (a *App) dropAdminCertCache() {
|
||||
a.adminCC.mu.Unlock()
|
||||
}
|
||||
|
||||
// rootIssued is the client certificate when the Rollcall root signed it
|
||||
// itself. Admin and rack identities are never accepted through an
|
||||
// intermediate: a delegated (ACME) intermediate could otherwise mint
|
||||
// CN=admin:... - its name constraints cover SANs, not the CN.
|
||||
func rootIssued(r *http.Request) *x509.Certificate {
|
||||
if r.TLS == nil {
|
||||
return nil
|
||||
}
|
||||
for _, ch := range r.TLS.VerifiedChains {
|
||||
if len(ch) == 2 {
|
||||
return ch[0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// peerAdmin is the admin name of a verified client certificate, "" otherwise.
|
||||
func (a *App) peerAdmin(r *http.Request) string {
|
||||
if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 || len(r.TLS.PeerCertificates) == 0 {
|
||||
c := rootIssued(r)
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c := r.TLS.PeerCertificates[0]
|
||||
name, ok := strings.CutPrefix(c.Subject.CommonName, "admin:")
|
||||
if !ok || name == "" {
|
||||
return ""
|
||||
@@ -198,7 +214,7 @@ func (a *App) handleAdminCerts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
a.dropAdminCertCache()
|
||||
log.Printf("admin certificate %q (serial %s, until %s) issued by %s", req.Name, cert.SerialNumber.Text(16), cert.NotAfter.Format("2006-01-02"), by)
|
||||
writeJSON(w, 200, obj{"name": req.Name, "serial": cert.SerialNumber.Text(16), "expires": cert.NotAfter, "cert": string(certPEM), "ca": string(a.ca.PEM)})
|
||||
writeJSON(w, 200, obj{"name": req.Name, "serial": cert.SerialNumber.Text(16), "expires": cert.NotAfter, "cert": string(certPEM), "ca": string(a.ca.TrustPEM)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
// Where machine-side tokens may be used from. Job tokens (rollcall.job=) and
|
||||
// node tokens (rollcall.node=) travel in the per-MAC pxelinux file, which any
|
||||
// host on the provisioning network can fetch over TFTP; without a hardware
|
||||
// root of trust the machine holds no secret of its own. What the region does
|
||||
// know is the address it gives the machine (a static DHCP lease from the
|
||||
// network model), so a token is only accepted from there. A machine without
|
||||
// a fixed address (proxy DHCP, a site's DHCP server) binds a job to the
|
||||
// first address that uses it. Refusals are events on the machine.
|
||||
//
|
||||
// agent_source_check: false in config.json turns this off (NAT between the
|
||||
// machines and Rollcall).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// clientIP is the machine's address: a remote rack controller proxies the
|
||||
// node API and appends it to X-Forwarded-For (the last entry is the rack's).
|
||||
func (a *App) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
if rack := a.fromRack(r); rack != "" && rack != a.cfg.RackName {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
host = strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
}
|
||||
if ad, ok := parseAddr(host); ok {
|
||||
return ad.String()
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// expectedIPs are the addresses the region hands this machine itself.
|
||||
func (a *App) expectedIPs(ctx context.Context, m *Machine) []string {
|
||||
var out []string
|
||||
add := func(ip string) {
|
||||
if ad, ok := parseAddr(ip); ok && !hasTag(out, ad.String()) {
|
||||
out = append(out, ad.String())
|
||||
}
|
||||
}
|
||||
subnets := a.subnetsSnapshot()
|
||||
if ips, err := a.store.IPs(ctx, "", m.ID); err == nil {
|
||||
for _, x := range ips {
|
||||
if sn := subnetFor(subnets, x.IP); sn == nil || sn.DHCPMode != "proxy" {
|
||||
add(x.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mg := deref(m.MgmtIP); mg != "" {
|
||||
if sn := subnetFor(subnets, mg); sn == nil || sn.DHCPMode != "proxy" {
|
||||
add(mg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) bindJobIP(ctx context.Context, id, ip string) (string, error) {
|
||||
var bound string
|
||||
err := s.pool.QueryRow(ctx, `UPDATE jobs SET bound_ip=coalesce(bound_ip, $2) WHERE id=$1 RETURNING bound_ip`, id, ip).Scan(&bound)
|
||||
return bound, err
|
||||
}
|
||||
|
||||
// tokenSource checks where a machine token is used from. job is nil for node
|
||||
// tokens and phone-home, which are checked against fixed addresses only.
|
||||
func (a *App) tokenSource(ctx context.Context, r *http.Request, m *Machine, j *Job, what string) error {
|
||||
if a.cfg.AgentSourceCheck != nil && !*a.cfg.AgentSourceCheck {
|
||||
return nil
|
||||
}
|
||||
ip := a.clientIP(r)
|
||||
want := a.expectedIPs(ctx, m)
|
||||
var err error
|
||||
switch {
|
||||
case len(want) > 0:
|
||||
if !hasTag(want, ip) {
|
||||
err = fmt.Errorf("%s token used from %s; this machine is %s", what, ip, strings.Join(want, ", "))
|
||||
}
|
||||
case j != nil:
|
||||
bound, berr := a.store.bindJobIP(ctx, j.ID, ip)
|
||||
if berr != nil {
|
||||
return berr
|
||||
}
|
||||
if bound != ip {
|
||||
err = fmt.Errorf("%s token used from %s; job %s is bound to %s, where it was first used", what, ip, j.ID, bound)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
_ = a.store.AddEvent(ctx, m.ID, "token refused", err.Error())
|
||||
log.Printf("%s: %v", m.ID, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
// TLS for the agents (commissioning, deploy, stateless nodes). Rack
|
||||
// controllers serve the node API and images over HTTPS too, with a server
|
||||
// certificate the region issues for the rack's own address only (never one
|
||||
// of the region's). The pxelinux command line names the HTTPS URL and the
|
||||
// CA's fingerprint (rollcall.tls=, rollcall.ca=); an agent fetches the CA
|
||||
// certificate, accepts it only by that fingerprint, and then talks HTTPS
|
||||
// with it. The embedded rack uses the region's own certificate.
|
||||
//
|
||||
// This keeps what agents send and receive (BMC passwords, seed files) from
|
||||
// anyone who can only listen, and from an HTTP server posing as the rack.
|
||||
// PXE itself (TFTP, the kernel, the initrd) stays unauthenticated: an
|
||||
// attacker who can rewrite the boot files on the wire is not stopped here;
|
||||
// that takes UEFI Secure Boot with signed images.
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// rackServerCN is the subject of a rack controller's server certificate;
|
||||
// regionLeaf and the CLI refuse it as the region.
|
||||
const rackServerCN = "rack-server:"
|
||||
|
||||
// handleRackServerCert issues a rack's server certificate for the host of its
|
||||
// registered URL: {"csr"} -> {"cert"}.
|
||||
func (a *App) handleRackServerCert(w http.ResponseWriter, r *http.Request, rack string) {
|
||||
if a.peerRack(r) != rack || a.ca == nil {
|
||||
fail(w, 401, errors.New("a server certificate needs the rack's own certificate"))
|
||||
return
|
||||
}
|
||||
rc, ok := a.racksSnapshot()[rack]
|
||||
if !ok {
|
||||
fail(w, 409, fmt.Errorf("rack controller %s is not registered", rack))
|
||||
return
|
||||
}
|
||||
host, err := rackHost(rc.URL)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
if a.isRegionName(host) {
|
||||
fail(w, 403, fmt.Errorf("%s is the region's own address: no rack certificate for it", host))
|
||||
return
|
||||
}
|
||||
var req enrollRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
csr, err := parseCSR(req.CSR)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
certPEM, cert, err := a.ca.sign(csr.PublicKey, rackServerCN+rack, []string{host}, x509.ExtKeyUsageServerAuth)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
log.Printf("rack controller %s: server certificate for %s until %s", rack, host, cert.NotAfter.Format("2006-01-02"))
|
||||
writeJSON(w, 200, obj{"cert": string(certPEM), "ca": string(a.ca.TrustPEM)})
|
||||
}
|
||||
|
||||
// rackHost is the host part of a rack URL (an address or a name).
|
||||
func rackHost(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return "", fmt.Errorf("rack URL %q has no host", raw)
|
||||
}
|
||||
return u.Hostname(), nil
|
||||
}
|
||||
|
||||
// isRegionName: one of the names or addresses the region's own certificate covers.
|
||||
func (a *App) isRegionName(host string) bool {
|
||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if ad, ok := parseAddr(h); ok {
|
||||
h = ad.String()
|
||||
}
|
||||
for _, n := range append(hostAddresses(), a.cfg.TLSNames...) {
|
||||
n = strings.ToLower(n)
|
||||
if ad, ok := parseAddr(n); ok {
|
||||
n = ad.String()
|
||||
}
|
||||
if n == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if su, err := url.Parse(a.cfg.ServerURL); err == nil && strings.EqualFold(su.Hostname(), h) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validTLSURL: what a rack reports must be https on its own URL's host.
|
||||
func validTLSURL(rc RackController) string {
|
||||
if rc.TLSURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rc.TLSURL)
|
||||
host, herr := rackHost(rc.URL)
|
||||
if err != nil || herr != nil || u.Scheme != "https" || !strings.EqualFold(u.Hostname(), host) || u.Path != "" && u.Path != "/" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(rc.TLSURL, "/")
|
||||
}
|
||||
|
||||
// agentTLS is the HTTPS base agents behind this base URL use, "" when none.
|
||||
func (a *App) agentTLS(base string) string {
|
||||
if a.ca == nil {
|
||||
return ""
|
||||
}
|
||||
if base == a.cfg.ServerURL { // the embedded rack: the region's own listener
|
||||
if a.cfg.TLSListen == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(a.cfg.TLSListen)
|
||||
if port == "" || port == "443" {
|
||||
return "https://" + hostPort(u.Hostname(), "")
|
||||
}
|
||||
return "https://" + hostPort(u.Hostname(), port)
|
||||
}
|
||||
for _, rc := range a.racksSnapshot() {
|
||||
if strings.TrimRight(rc.URL, "/") == base {
|
||||
return validTLSURL(rc)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hostPort(h, port string) string {
|
||||
if strings.Contains(h, ":") {
|
||||
h = "[" + h + "]"
|
||||
}
|
||||
if port == "" {
|
||||
return h
|
||||
}
|
||||
return h + ":" + port
|
||||
}
|
||||
|
||||
// agentTLSArgs are the command-line words that switch an agent to HTTPS.
|
||||
func (a *App) agentTLSArgs(base string) []string {
|
||||
t := a.agentTLS(base)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
fp := certFingerprint(a.serverCAFor(base)) // the root the rack serves as /ca.pem
|
||||
return []string{"rollcall.tls=" + t, "rollcall.ca=" + hex.EncodeToString(fp[:])}
|
||||
}
|
||||
|
||||
// overTLS: the agent's request came over HTTPS (to the region, or to a rack
|
||||
// controller that says so in X-Forwarded-Proto).
|
||||
func (a *App) overTLS(r *http.Request) bool {
|
||||
if rack := a.fromRack(r); rack != "" && rack != a.cfg.RackName {
|
||||
return r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
}
|
||||
return r.TLS != nil
|
||||
}
|
||||
+104
-144
@@ -55,7 +55,11 @@ const (
|
||||
type CA struct {
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
PEM []byte
|
||||
PEM []byte // the active root
|
||||
// all trusted roots, active first: during a rotation also the next or
|
||||
// previous one (carot.go); what clients and machines are given to trust
|
||||
Trust []*x509.Certificate
|
||||
TrustPEM []byte
|
||||
}
|
||||
|
||||
func randomSerial() (*big.Int, error) {
|
||||
@@ -121,7 +125,11 @@ func loadOrCreateCA(dir, cn string) (*CA, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", kp, err)
|
||||
}
|
||||
return &CA{Cert: cert, Key: key, PEM: cb}, nil
|
||||
ca := &CA{Cert: cert, Key: key, PEM: cb}
|
||||
if err := ca.loadTrust(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
ca, err := newCA(cn)
|
||||
if err != nil {
|
||||
@@ -138,6 +146,7 @@ func loadOrCreateCA(dir, cn string) (*CA, error) {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("created certificate authority %q in %s (fingerprint %s)", cn, dir, ca.Fingerprint())
|
||||
ca.Trust, ca.TrustPEM = []*x509.Certificate{ca.Cert}, ca.PEM
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
@@ -209,7 +218,14 @@ func parsePermits(list string) (dns []string, nets []*net.IPNet, err error) {
|
||||
if net.ParseIP(p) != nil {
|
||||
return nil, nil, fmt.Errorf("%s: give an address range as CIDR", p)
|
||||
}
|
||||
dns = append(dns, strings.TrimPrefix(strings.ToLower(p), "*."))
|
||||
d := strings.Trim(strings.TrimPrefix(strings.ToLower(p), "*."), ".")
|
||||
if !validDomain(d) {
|
||||
return nil, nil, fmt.Errorf("%s: give a domain (int.example.com) or a CIDR", p)
|
||||
}
|
||||
dns = append(dns, d)
|
||||
}
|
||||
if len(dns)+len(nets) == 0 {
|
||||
return nil, nil, errors.New("give at least one permitted domain or CIDR")
|
||||
}
|
||||
return dns, nets, nil
|
||||
}
|
||||
@@ -233,7 +249,16 @@ func (ca *CA) intermediate(pub crypto.PublicKey, cn string, dns []string, nets [
|
||||
tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: cn, Organization: []string{"rollcall"}},
|
||||
NotBefore: time.Now().Add(-backdate), NotAfter: time.Now().Add(validity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, IsCA: true, MaxPathLenZero: true,
|
||||
PermittedDNSDomainsCritical: len(dns)+len(nets) > 0, PermittedDNSDomains: dns, PermittedIPRanges: nets}
|
||||
PermittedDNSDomainsCritical: true, PermittedDNSDomains: dns, PermittedIPRanges: nets,
|
||||
// server certificates only: nothing it issues can be a client (admin, rack) identity
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
// a constraint on one name type leaves the other open: close it
|
||||
if len(nets) == 0 {
|
||||
tmpl.ExcludedIPRanges = []*net.IPNet{{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, {IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}}
|
||||
}
|
||||
if len(dns) == 0 {
|
||||
tmpl.PermittedDNSDomains = []string{"invalid"} // RFC 6761: no name below it exists
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, pub, ca.Key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -357,6 +382,17 @@ func parseJoinCode(s string) (joinCode, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// regionLeaf accepts the region's own certificate only: "rollcall region ...",
|
||||
// signed by the root itself - not a rack's, not one from a delegated intermediate.
|
||||
func regionLeaf(cs tls.ConnectionState) error {
|
||||
for _, ch := range cs.VerifiedChains {
|
||||
if len(ch) == 2 && strings.HasPrefix(ch[0].Subject.CommonName, "rollcall region ") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("the server's certificate is not the region's")
|
||||
}
|
||||
|
||||
// pinnedTLS trusts a server whose chain carries the CA with this fingerprint.
|
||||
func pinnedTLS(fp [16]byte) *tls.Config {
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, //nolint:gosec // verified below against the pinned CA
|
||||
@@ -374,8 +410,11 @@ func pinnedTLS(fp [16]byte) *tls.Config {
|
||||
if c.IsCA && string(sum[:16]) == string(fp[:]) {
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(c)
|
||||
_, err := leaf.Verify(x509.VerifyOptions{Roots: pool})
|
||||
return err
|
||||
chains, err := leaf.Verify(x509.VerifyOptions{Roots: pool})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return regionLeaf(tls.ConnectionState{VerifiedChains: chains})
|
||||
}
|
||||
}
|
||||
return errors.New("the server's certificate is not from the authority this code is for")
|
||||
@@ -397,15 +436,30 @@ func (s *Store) UseJoinToken(ctx context.Context, hash string) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRackCert(ctx context.Context, name, serial string, expires time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET cert_serial=$2, cert_expires=$3 WHERE name=$1`, name, serial, expires)
|
||||
// rackCertGrace: after a renewal the previous certificate still works this
|
||||
// long, so a renewal answer lost on the way does not lock the rack out.
|
||||
const rackCertGrace = 7 * 24 * time.Hour
|
||||
|
||||
// SetRackCert records a rack's certificate. A renewal keeps the previous
|
||||
// serial for rackCertGrace; an enrollment (a new join) does not.
|
||||
func (s *Store) SetRackCert(ctx context.Context, name, serial string, expires time.Time, renewal bool) error {
|
||||
if renewal {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET prev_cert_serial=cert_serial, prev_cert_until=now()+$4::interval,
|
||||
cert_serial=$2, cert_expires=$3 WHERE name=$1`, name, serial, expires, fmt.Sprintf("%d seconds", int(rackCertGrace.Seconds())))
|
||||
return err
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET cert_serial=$2, cert_expires=$3, prev_cert_serial=NULL, prev_cert_until=NULL WHERE name=$1`, name, serial, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RackCertSerial(ctx context.Context, name string) string {
|
||||
var serial string
|
||||
_ = s.pool.QueryRow(ctx, `SELECT coalesce(cert_serial,'') FROM rack_controllers WHERE name=$1`, name).Scan(&serial)
|
||||
return serial
|
||||
// rackCertOK: the serial is the rack's current certificate, or the previous one within the grace period.
|
||||
func (s *Store) rackCertOK(ctx context.Context, name, serial string) bool {
|
||||
var cur, prev string
|
||||
var until *time.Time
|
||||
if err := s.pool.QueryRow(ctx, `SELECT coalesce(cert_serial,''), coalesce(prev_cert_serial,''), prev_cert_until FROM rack_controllers WHERE name=$1`, name).Scan(&cur, &prev, &until); err != nil {
|
||||
return false
|
||||
}
|
||||
return serial != "" && (serial == cur || (serial == prev && until != nil && time.Now().Before(*until)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP
|
||||
@@ -413,15 +467,15 @@ func (s *Store) RackCertSerial(ctx context.Context, name string) string {
|
||||
// peerRack is the rack controller a mutual-TLS request comes from, if any:
|
||||
// a verified client certificate whose serial is the one on record.
|
||||
func (a *App) peerRack(r *http.Request) string {
|
||||
if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 || len(r.TLS.PeerCertificates) == 0 {
|
||||
c := rootIssued(r)
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c := r.TLS.PeerCertificates[0]
|
||||
name := strings.TrimPrefix(c.Subject.CommonName, "rack:")
|
||||
if name == c.Subject.CommonName { // only rack certificates
|
||||
return ""
|
||||
}
|
||||
if a.store.RackCertSerial(r.Context(), name) != c.SerialNumber.Text(16) {
|
||||
if !a.store.rackCertOK(r.Context(), name, c.SerialNumber.Text(16)) {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
@@ -487,32 +541,26 @@ type enrollRequest struct {
|
||||
Rack RackController `json:"rack"`
|
||||
}
|
||||
|
||||
func (a *App) signRackCSR(csrPEM, name string, rc RackController) ([]byte, *x509.Certificate, error) {
|
||||
// parseCSR reads a PEM certificate request; only its key is used.
|
||||
func parseCSR(csrPEM string) (*x509.CertificateRequest, error) {
|
||||
blk, _ := pem.Decode([]byte(csrPEM))
|
||||
if blk == nil {
|
||||
return nil, nil, errors.New("csr is not PEM")
|
||||
return nil, errors.New("csr is not PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(blk.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return csr, csr.CheckSignature()
|
||||
}
|
||||
|
||||
func (a *App) signRackCSR(csrPEM, name string) ([]byte, *x509.Certificate, error) {
|
||||
csr, err := parseCSR(csrPEM)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hosts := []string{name}
|
||||
if u := strings.TrimPrefix(strings.TrimPrefix(rc.URL, "http://"), "https://"); u != "" {
|
||||
if h, _, err := net.SplitHostPort(u); err == nil {
|
||||
hosts = append(hosts, h)
|
||||
} else {
|
||||
hosts = append(hosts, strings.Trim(u, "/[]"))
|
||||
}
|
||||
}
|
||||
for _, i := range rc.Interfaces {
|
||||
for _, ad := range i.Addrs {
|
||||
hosts = append(hosts, stripPrefix(ad))
|
||||
}
|
||||
}
|
||||
return a.ca.sign(csr.PublicKey, "rack:"+name, hosts, x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth)
|
||||
// racks serve no TLS: a client identity only, no names, so a rack cannot pose as the region
|
||||
return a.ca.sign(csr.PublicKey, "rack:"+name, nil, x509.ExtKeyUsageClientAuth)
|
||||
}
|
||||
|
||||
// handleRackEnroll: a rack controller presents a join code's secret and a CSR
|
||||
@@ -546,7 +594,13 @@ func (a *App) handleRackEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, fmt.Errorf("bad rack controller name %q", name))
|
||||
return
|
||||
}
|
||||
certPEM, cert, err := a.signRackCSR(req.CSR, name, req.Rack)
|
||||
// a code made for one name may re-enroll that rack; an unbound code may not
|
||||
// take over a name in use (its machines, BMC jobs and certificate)
|
||||
if _, exists := a.racksSnapshot()[name]; exists && bound == "" {
|
||||
fail(w, 409, fmt.Errorf("rack controller %s exists; make a code for it: rollcall controller join-code -name %s", name, name))
|
||||
return
|
||||
}
|
||||
certPEM, cert, err := a.signRackCSR(req.CSR, name)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
@@ -557,14 +611,14 @@ func (a *App) handleRackEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
if err := a.store.SetRackCert(r.Context(), name, cert.SerialNumber.Text(16), cert.NotAfter); err != nil {
|
||||
if err := a.store.SetRackCert(r.Context(), name, cert.SerialNumber.Text(16), cert.NotAfter, false); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
a.reloadRacks(r.Context())
|
||||
a.hub.bump()
|
||||
log.Printf("rack controller %s enrolled from %s, certificate until %s", name, r.RemoteAddr, cert.NotAfter.Format("2006-01-02"))
|
||||
writeJSON(w, 200, obj{"name": name, "cert": string(certPEM), "ca": string(a.ca.PEM)})
|
||||
writeJSON(w, 200, obj{"name": name, "cert": string(certPEM), "ca": string(a.ca.TrustPEM)})
|
||||
}
|
||||
|
||||
// handleRackRenew issues a new certificate to a rack controller that still has a valid one.
|
||||
@@ -578,18 +632,17 @@ func (a *App) handleRackRenew(w http.ResponseWriter, r *http.Request, rack strin
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
rc := a.racksSnapshot()[rack]
|
||||
certPEM, cert, err := a.signRackCSR(req.CSR, rack, rc)
|
||||
certPEM, cert, err := a.signRackCSR(req.CSR, rack)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
if err := a.store.SetRackCert(r.Context(), rack, cert.SerialNumber.Text(16), cert.NotAfter); err != nil {
|
||||
if err := a.store.SetRackCert(r.Context(), rack, cert.SerialNumber.Text(16), cert.NotAfter, true); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
log.Printf("rack controller %s renewed its certificate, until %s", rack, cert.NotAfter.Format("2006-01-02"))
|
||||
writeJSON(w, 200, obj{"name": rack, "cert": string(certPEM), "ca": string(a.ca.PEM)})
|
||||
writeJSON(w, 200, obj{"name": rack, "cert": string(certPEM), "ca": string(a.ca.TrustPEM)})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CLI: rollcall ca (on the region host)
|
||||
@@ -610,6 +663,13 @@ func caCmd(args []string) error {
|
||||
}
|
||||
fmt.Printf("%s\n subject %s\n valid until %s\n fingerprint %s\n", filepath.Join(cfg.CADir, "ca.pem"), c.Subject.CommonName,
|
||||
c.NotAfter.Format("2006-01-02"), fpString(certFingerprint(c)))
|
||||
for _, f := range []string{"ca-next.pem", "ca-prev.pem"} {
|
||||
if b, err := os.ReadFile(filepath.Join(cfg.CADir, f)); err == nil {
|
||||
if o, err := parseCertPEM(b); err == nil {
|
||||
fmt.Printf("%s (also trusted, rotation)\n fingerprint %s\n", f, fpString(certFingerprint(o)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if rb, err := os.ReadFile(filepath.Join(cfg.CADir, "region.pem")); err == nil {
|
||||
if rc, err := parseCertPEM(rb); err == nil {
|
||||
names := append([]string{}, rc.DNSNames...)
|
||||
@@ -624,7 +684,9 @@ func caCmd(args []string) error {
|
||||
}
|
||||
switch {
|
||||
case args[0] == "intermediate" && len(args) >= 2:
|
||||
return caIntermediateCmd(cfg, args[1], args[2:])
|
||||
return caIntermediateCmd(args[1], args[2:])
|
||||
case args[0] == "intermediates":
|
||||
return caIntermediatesCmd()
|
||||
case args[0] == "rotate":
|
||||
return caRotateCmd(cfg, args[1:])
|
||||
case args[0] != "issue" || len(args) < 2:
|
||||
@@ -663,108 +725,6 @@ func caCmd(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollcall ca intermediate NAME -permit DOMAIN,CIDR [-csr FILE] [-days N] [-out DIR]
|
||||
func caIntermediateCmd(cfg *Config, name string, args []string) error {
|
||||
fs := newFlags("ca intermediate")
|
||||
permit := fs.String("permit", "", "comma-separated DNS domains and CIDRs it may issue for (required)")
|
||||
csrFile := fs.String("csr", "", "sign this CSR (the key stays where it was made); without it a key is generated")
|
||||
days := fs.Int("days", 365, "validity in days")
|
||||
out := fs.String("out", ".", "directory for NAME.pem (+ NAME-key.pem without -csr)")
|
||||
unconstrained := fs.Bool("unconstrained", false, "allow an intermediate without name constraints")
|
||||
_ = fs.Parse(args)
|
||||
dns, nets, err := parsePermits(*permit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(dns)+len(nets) == 0 && !*unconstrained {
|
||||
return errors.New("an intermediate needs -permit (e.g. -permit int.example.com,10.213.0.0/16); -unconstrained to override")
|
||||
}
|
||||
ca, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pub crypto.PublicKey
|
||||
var keyPEM []byte
|
||||
if *csrFile != "" {
|
||||
b, err := os.ReadFile(*csrFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk, _ := pem.Decode(b)
|
||||
if blk == nil {
|
||||
return fmt.Errorf("%s: not PEM", *csrFile)
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(blk.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return fmt.Errorf("CSR signature: %w", err)
|
||||
}
|
||||
pub = csr.PublicKey
|
||||
} else {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub = &key.PublicKey
|
||||
kder, _ := x509.MarshalECPrivateKey(key)
|
||||
keyPEM = pemEncode("EC PRIVATE KEY", kder)
|
||||
}
|
||||
certPEM, cert, err := ca.intermediate(pub, name, dns, nets, time.Duration(*days)*24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if keyPEM != nil {
|
||||
if err := os.WriteFile(filepath.Join(*out, name+"-key.pem"), keyPEM, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(*out, name+".pem"), certPEM, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
var permits []string
|
||||
permits = append(permits, dns...)
|
||||
for _, n := range nets {
|
||||
permits = append(permits, n.String())
|
||||
}
|
||||
fmt.Printf("wrote %s.pem in %s: intermediate CA %q until %s, may issue for %s\nroot: %s (%s)\n", name, *out, name,
|
||||
cert.NotAfter.Format("2006-01-02"), orDash(strings.Join(permits, " ")), filepath.Join(cfg.CADir, "ca.pem"), ca.Fingerprint())
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollcall ca rotate -yes: a new root (the old one is kept as a backup). Racks
|
||||
// must join again and clients trust the new fingerprint.
|
||||
func caRotateCmd(cfg *Config, args []string) error {
|
||||
fs := newFlags("ca rotate")
|
||||
yes := fs.Bool("yes", false, "really replace the root")
|
||||
_ = fs.Parse(args)
|
||||
old, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !*yes {
|
||||
return fmt.Errorf("this replaces the root %s (delegates: %v); rack controllers then join again and clients trust the new one. Run with -yes", old.Fingerprint(), old.canDelegate())
|
||||
}
|
||||
bak := cfg.CADir + ".bak-" + time.Now().Format("20060102-150405")
|
||||
if err := os.Rename(cfg.CADir, bak); err != nil {
|
||||
return err
|
||||
}
|
||||
ca, err := loadOrCreateCA(cfg.CADir, "rollcall CA")
|
||||
if err != nil {
|
||||
_ = os.Rename(bak, cfg.CADir)
|
||||
return err
|
||||
}
|
||||
if st, err := os.Stat(bak); err == nil {
|
||||
_ = os.Chmod(cfg.CADir, st.Mode().Perm())
|
||||
if own, ok := fileOwner(bak); ok {
|
||||
_ = chownTree(cfg.CADir, own[0], own[1])
|
||||
}
|
||||
}
|
||||
fmt.Printf("new root %s\nold root kept in %s\nnext: restart rollcall; on each rack controller `rollcall rack join` with a new join code; clients trust the new fingerprint\n", ca.Fingerprint(), bak)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileOwner returns uid, gid of path (unix).
|
||||
func fileOwner(path string) ([2]int, bool) {
|
||||
st, err := os.Stat(path)
|
||||
|
||||
+79
-2
@@ -9,6 +9,8 @@ import (
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -91,7 +93,7 @@ func TestSignRackCSR(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, cert, err := a.signRackCSR(csr, "hall-b", RackController{URL: "http://10.213.78.1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"fd00:213:78::1/64"}}}})
|
||||
_, cert, err := a.signRackCSR(csr, "hall-b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -100,9 +102,13 @@ func TestSignRackCSR(t *testing.T) {
|
||||
if _, err := cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
t.Errorf("rack certificate is no client certificate: %v", err)
|
||||
}
|
||||
if cert.Subject.CommonName != "rack:hall-b" || cert.VerifyHostname("10.213.78.1") != nil || cert.VerifyHostname("fd00:213:78::1") != nil {
|
||||
// a rack is a client only: no names, no server use, so it cannot pose as the region
|
||||
if cert.Subject.CommonName != "rack:hall-b" || len(cert.IPAddresses)+len(cert.DNSNames) > 0 {
|
||||
t.Errorf("subject %s, SANs %v %v", cert.Subject.CommonName, cert.IPAddresses, cert.DNSNames)
|
||||
}
|
||||
if _, err := cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err == nil {
|
||||
t.Error("rack certificate verifies as a server certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntermediate(t *testing.T) {
|
||||
@@ -146,6 +152,30 @@ func TestIntermediate(t *testing.T) {
|
||||
if err := verify("node001.int.example.com", "192.168.1.1"); err == nil {
|
||||
t.Error("leaf outside the IP constraint verified")
|
||||
}
|
||||
// nothing it issues is a client identity (admin:, rack:)
|
||||
if _, leaf, err := sub.sign(&ikey.PublicKey, "admin:mallory", nil, x509.ExtKeyUsageClientAuth); err == nil {
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inters, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err == nil {
|
||||
t.Error("the intermediate issued a client certificate that verifies")
|
||||
}
|
||||
}
|
||||
// DNS only: no address may be certified (the region's IP, say)
|
||||
dnsOnly, _, _ := parsePermits("int.example.com")
|
||||
_, ic2, err := root.intermediate(&ikey.PublicKey, "dns-only", dnsOnly, nil, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inters2 := x509.NewCertPool()
|
||||
inters2.AddCert(ic2)
|
||||
if _, leaf, err := (&CA{Cert: ic2, Key: ikey}).sign(&ikey.PublicKey, "x", []string{"10.0.0.26"}, x509.ExtKeyUsageServerAuth); err == nil {
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inters2}); err == nil {
|
||||
t.Error("a DNS-only intermediate certified an IP address")
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"*.", "", "foo bar"} {
|
||||
if _, _, err := parsePermits(bad); err == nil {
|
||||
t.Errorf("permit %q accepted", bad)
|
||||
}
|
||||
}
|
||||
// a second level below the intermediate is refused by path length 0
|
||||
k2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
_, c2, err := sub.intermediate(&k2.PublicKey, "deeper", dns, nil, time.Hour)
|
||||
@@ -234,3 +264,50 @@ func TestAdminClientCertificate(t *testing.T) {
|
||||
t.Error("token no longer works")
|
||||
}
|
||||
}
|
||||
|
||||
// A renewed certificate is kept in the rack's own directory and preferred
|
||||
// when it is this rack's, from the CA and newer.
|
||||
func TestRackRenewedCertificate(t *testing.T) {
|
||||
ca, _ := newCA("test CA")
|
||||
a := &App{ca: ca, cfg: &Config{}}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.Cert)
|
||||
issue := func(name string) ([]byte, []byte, *x509.Certificate) {
|
||||
key, csr, _ := newCSR("rack:" + name)
|
||||
certPEM, cert, err := a.signRackCSR(csr, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certPEM, key, cert
|
||||
}
|
||||
_, _, joined := issue("hall-b")
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "rack.pem")
|
||||
if loadRenewed(p, pool, "hall-b", joined) != nil {
|
||||
t.Error("nothing renewed yet")
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond) // a later NotAfter
|
||||
certPEM, key, renewed := issue("hall-b")
|
||||
if err := writeAtomic(p, append(certPEM, key...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := loadRenewed(p, pool, "hall-b", joined)
|
||||
if got == nil || got.Leaf.SerialNumber.Cmp(renewed.SerialNumber) != 0 {
|
||||
t.Fatalf("renewed certificate not used: %v", got)
|
||||
}
|
||||
if loadRenewed(p, pool, "hall-c", joined) != nil {
|
||||
t.Error("another rack's certificate was used")
|
||||
}
|
||||
if loadRenewed(p, pool, "hall-b", renewed) != nil {
|
||||
t.Error("an older or equal certificate replaced the current one")
|
||||
}
|
||||
other, _ := newCA("other CA")
|
||||
op := x509.NewCertPool()
|
||||
op.AddCert(other.Cert)
|
||||
if loadRenewed(p, op, "hall-b", joined) != nil {
|
||||
t.Error("a certificate from another CA was used")
|
||||
}
|
||||
if st, _ := os.Stat(p); st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode %v", st.Mode())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package main
|
||||
|
||||
// Cabling: what each machine's ports are plugged into, and where the
|
||||
// switches at the other end stand. A port's switch port comes from, first,
|
||||
// the LLDP its commissioning saw (lldpcli's neighbours per interface): what
|
||||
// is really there; then one entered here; then a cable recorded in NetBox on
|
||||
// the machine's device (dcim interfaces, through patch panels). The BMC's port
|
||||
// is not seen by the host's LLDP: its cable (to the out-of-band switch) is
|
||||
// NetBox's or entered here. When NetBox records another switch port than
|
||||
// LLDP sees, the port says so (netbox_says).
|
||||
//
|
||||
// A switch's place comes from NetBox (the device's site, location, rack and
|
||||
// position, read with the layout) or is set here; a switch with neither is
|
||||
// drawn where most of its machines are. A switch whose NetBox role names
|
||||
// out-of-band or management, or that only BMCs are cabled to, is "oob".
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Port struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
Speed string `json:"speed,omitempty"`
|
||||
Link *bool `json:"link,omitempty"`
|
||||
Card string `json:"card,omitempty"` // PCI device without the function: the ports of one card
|
||||
Switch string `json:"switch,omitempty"`
|
||||
SwitchPort string `json:"switch_port,omitempty"`
|
||||
PortDescr string `json:"port_descr,omitempty"`
|
||||
VLAN string `json:"vlan,omitempty"`
|
||||
Kind string `json:"kind,omitempty"` // bmc for the BMC's own port
|
||||
Source string `json:"source,omitempty"` // lldp | netbox | local
|
||||
NetBoxSays string `json:"netbox_says,omitempty"` // NetBox's switch port when LLDP sees another
|
||||
}
|
||||
|
||||
// Cable is one recorded port-to-switch-port connection (NetBox's or entered here).
|
||||
type Cable struct {
|
||||
Machine string `json:"machine"`
|
||||
Port string `json:"port"` // an interface name, or "bmc"
|
||||
Switch string `json:"switch"`
|
||||
SwitchPort string `json:"switch_port"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type MachinePorts struct {
|
||||
ID string `json:"id"`
|
||||
Ports []Port `json:"ports"`
|
||||
}
|
||||
|
||||
type Switch struct {
|
||||
Name string `json:"name"`
|
||||
Descr string `json:"descr,omitempty"`
|
||||
MgmtIP string `json:"mgmt_ip,omitempty"`
|
||||
Building string `json:"building,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Rack string `json:"rack,omitempty"`
|
||||
Unit int `json:"unit,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Role string `json:"role,omitempty"` // oob for an out-of-band (BMC) switch
|
||||
Source string `json:"source,omitempty"` // netbox | local | "" (not placed)
|
||||
Links int `json:"links"`
|
||||
}
|
||||
|
||||
type lldpNeighbour struct{ Switch, Port, PortDescr, VLAN, Descr, MgmtIP string }
|
||||
|
||||
// lldpOf reads inventory "lldp": lldpcli's "interface", an object of
|
||||
// interfaces or a list of one-interface objects, each one entry or a list.
|
||||
func lldpOf(inv obj) map[string]lldpNeighbour {
|
||||
out := map[string]lldpNeighbour{}
|
||||
add := func(ifn string, v any) {
|
||||
for _, e := range asList(v) {
|
||||
eo := asObj(e)
|
||||
ch := asObj(eo["chassis"])
|
||||
var name string
|
||||
var c obj
|
||||
for k, v := range ch {
|
||||
name, c = k, asObj(v)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
nb := lldpNeighbour{Switch: name, Descr: str(c["descr"]), PortDescr: str(dig(eo, "port", "descr"))}
|
||||
nb.Port = str(dig(eo, "port", "id", "value"))
|
||||
if nb.Port == "" {
|
||||
nb.Port = str(dig(eo, "port", "id"))
|
||||
}
|
||||
for _, ip := range asList(c["mgmt-ip"]) {
|
||||
if s := str(ip); s != "" && nb.MgmtIP == "" {
|
||||
nb.MgmtIP = s
|
||||
}
|
||||
}
|
||||
for _, v := range asList(eo["vlan"]) {
|
||||
if vo := asObj(v); vo != nil && nb.VLAN == "" {
|
||||
nb.VLAN = str(vo["vlan-id"])
|
||||
}
|
||||
}
|
||||
if _, seen := out[ifn]; !seen {
|
||||
out[ifn] = nb
|
||||
}
|
||||
}
|
||||
}
|
||||
switch l := inv["lldp"].(type) {
|
||||
case obj:
|
||||
for ifn, v := range l {
|
||||
add(ifn, v)
|
||||
}
|
||||
case []any:
|
||||
for _, x := range l {
|
||||
for ifn, v := range asObj(x) {
|
||||
add(ifn, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// portsOf: a machine's ports from its inventory, with the switch port each is cabled to
|
||||
func portsOf(m *Machine) ([]Port, map[string]lldpNeighbour) {
|
||||
inv := invOf(m)
|
||||
nb := lldpOf(inv)
|
||||
var out []Port
|
||||
for _, x := range toList(inv["network"]) {
|
||||
n := asObj(x)
|
||||
p := Port{Name: str(n["name"]), MAC: str(n["mac"]), Speed: str(n["speed"])}
|
||||
if p.Name == "" {
|
||||
continue
|
||||
}
|
||||
if b, ok := n["link"].(bool); ok {
|
||||
p.Link = &b
|
||||
}
|
||||
if p.Speed == "Unknown!" {
|
||||
p.Speed = ""
|
||||
}
|
||||
if bus := str(n["bus_info"]); strings.Count(bus, ":") == 2 { // 0000:3b:00.1 → 0000:3b:00
|
||||
p.Card, _, _ = strings.Cut(bus, ".")
|
||||
}
|
||||
if l, ok := nb[p.Name]; ok {
|
||||
p.Switch, p.SwitchPort, p.PortDescr, p.VLAN, p.Source = l.Switch, l.Port, l.PortDescr, l.VLAN, "lldp"
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Card < out[j].Card })
|
||||
return out, nb
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
func (s *Store) Switches(ctx context.Context) ([]Switch, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(building,''), coalesce(room,''), coalesce(rack,''), coalesce(unit,0), coalesce(height,1), coalesce(model,''), coalesce(role,''), source FROM switches ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Switch{}
|
||||
for rows.Next() {
|
||||
var x Switch
|
||||
if err := rows.Scan(&x.Name, &x.Building, &x.Room, &x.Rack, &x.Unit, &x.Height, &x.Model, &x.Role, &x.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutSwitch places a switch; one placed by hand is not moved by NetBox.
|
||||
func (s *Store) PutSwitch(ctx context.Context, x Switch) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO switches(name, building, room, rack, unit, height, model, role, source) VALUES ($1,$2,$3,$4,nullif($5,0),nullif($6,0),nullif($7,''),nullif($8,''),$9)
|
||||
ON CONFLICT (name) DO UPDATE SET building=$2, room=$3, rack=$4, unit=nullif($5,0), height=nullif($6,0), model=coalesce(nullif($7,''), switches.model),
|
||||
role=coalesce(nullif($8,''), switches.role), source=$9
|
||||
WHERE switches.source <> 'local' OR $9 = 'local'`, x.Name, x.Building, x.Room, x.Rack, x.Unit, x.Height, x.Model, x.Role, orDefault(x.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Cables(ctx context.Context) ([]Cable, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT machine_id, port, switch, switch_port, source FROM cables ORDER BY machine_id, port`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Cable{}
|
||||
for rows.Next() {
|
||||
var c Cable
|
||||
if err := rows.Scan(&c.Machine, &c.Port, &c.Switch, &c.SwitchPort, &c.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutCable records a cable; one entered here is not replaced by NetBox's.
|
||||
func (s *Store) PutCable(ctx context.Context, c Cable) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO cables(machine_id, port, switch, switch_port, source) VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (machine_id, port, source) DO UPDATE SET switch=$3, switch_port=$4`, c.Machine, c.Port, c.Switch, c.SwitchPort, orDefault(c.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCable(ctx context.Context, machine, port, source string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM cables WHERE machine_id=$1 AND port=$2 AND source=$3`, machine, port, source)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceNetBoxCables puts NetBox's cables in place of the ones read before.
|
||||
func (s *Store) ReplaceNetBoxCables(ctx context.Context, cs []Cable) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM cables WHERE source='netbox'`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cs {
|
||||
// a device whose rollcall_machine names no machine here (deleted, typed in) is skipped
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO cables(machine_id, port, switch, switch_port, source) SELECT $1,$2,$3,$4,'netbox'
|
||||
WHERE EXISTS (SELECT 1 FROM machines WHERE id=$1) ON CONFLICT DO NOTHING`, c.Machine, c.Port, c.Switch, c.SwitchPort); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSwitch(ctx context.Context, name string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM switches WHERE name=$1`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
// handleCabling: every located machine's ports and the switches they lead to.
|
||||
func (a *App) handleCabling(w http.ResponseWriter, r *http.Request) {
|
||||
ms, err := a.store.List(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
placed, _ := a.store.Switches(r.Context())
|
||||
sw := map[string]*Switch{}
|
||||
key := func(name string) string { // an LLDP name may be the FQDN of a NetBox name
|
||||
k, _, _ := strings.Cut(strings.ToLower(name), ".")
|
||||
return k
|
||||
}
|
||||
for i := range placed {
|
||||
sw[key(placed[i].Name)] = &placed[i]
|
||||
}
|
||||
recorded, _ := a.store.Cables(r.Context())
|
||||
byMachine := map[string][]Cable{}
|
||||
for _, c := range recorded {
|
||||
byMachine[c.Machine] = append(byMachine[c.Machine], c)
|
||||
}
|
||||
nonBMC := map[string]bool{} // switches something other than a BMC is cabled to
|
||||
out := []MachinePorts{}
|
||||
for _, m := range ms {
|
||||
if m.Location.Rack == "" {
|
||||
continue
|
||||
}
|
||||
ports, nb := portsOf(m)
|
||||
ports = mergeCables(ports, byMachine[m.ID])
|
||||
get := func(name string) *Switch {
|
||||
s := sw[key(name)]
|
||||
if s == nil {
|
||||
s = &Switch{Name: name}
|
||||
sw[key(name)] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
for _, l := range nb { // what the switch says of itself
|
||||
s := get(l.Switch)
|
||||
s.Descr = orDefault(s.Descr, l.Descr)
|
||||
s.MgmtIP = orDefault(s.MgmtIP, l.MgmtIP)
|
||||
}
|
||||
for _, p := range ports {
|
||||
if p.Switch == "" {
|
||||
continue
|
||||
}
|
||||
s := get(p.Switch)
|
||||
s.Links++
|
||||
if p.Kind != "bmc" {
|
||||
nonBMC[key(p.Switch)] = true
|
||||
}
|
||||
}
|
||||
if len(ports) > 0 {
|
||||
out = append(out, MachinePorts{ID: m.ID, Ports: ports})
|
||||
}
|
||||
}
|
||||
for k, s := range sw {
|
||||
if s.Role == "" && s.Links > 0 && !nonBMC[k] {
|
||||
s.Role = "oob"
|
||||
}
|
||||
}
|
||||
for _, mp := range out { // switch names as the machines saw them, the place as stored
|
||||
for i := range mp.Ports {
|
||||
if s := sw[key(mp.Ports[i].Switch)]; s != nil && mp.Ports[i].Switch != "" {
|
||||
mp.Ports[i].Switch = s.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
list := []Switch{}
|
||||
for _, s := range sw {
|
||||
list = append(list, *s)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
writeJSON(w, 200, obj{"machines": out, "switches": list})
|
||||
}
|
||||
|
||||
// mergeCables adds the recorded cables to the ports LLDP filled: a port
|
||||
// without a neighbour takes the one entered here, else NetBox's; the BMC's
|
||||
// port is added when a cable is recorded for it. A NetBox cable that
|
||||
// disagrees with LLDP is kept as netbox_says.
|
||||
func mergeCables(ports []Port, cs []Cable) []Port {
|
||||
pick := func(port string) (nb, local *Cable) {
|
||||
for i := range cs {
|
||||
if strings.EqualFold(cs[i].Port, port) {
|
||||
if cs[i].Source == "netbox" {
|
||||
nb = &cs[i]
|
||||
} else {
|
||||
local = &cs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
fill := func(p *Port) {
|
||||
nb, local := pick(p.Name)
|
||||
switch {
|
||||
case p.Source == "lldp":
|
||||
if nb != nil && (!strings.EqualFold(nb.SwitchPort, p.SwitchPort) || !sameSwitch(nb.Switch, p.Switch)) {
|
||||
p.NetBoxSays = nb.Switch + " " + nb.SwitchPort
|
||||
}
|
||||
case local != nil:
|
||||
p.Switch, p.SwitchPort, p.Source = local.Switch, local.SwitchPort, "local"
|
||||
case nb != nil:
|
||||
p.Switch, p.SwitchPort, p.Source = nb.Switch, nb.SwitchPort, "netbox"
|
||||
}
|
||||
}
|
||||
for i := range ports {
|
||||
fill(&ports[i])
|
||||
}
|
||||
bmc := Port{Name: "bmc", Kind: "bmc"}
|
||||
if fill(&bmc); bmc.Switch != "" {
|
||||
ports = append(ports, bmc)
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// sameSwitch: an LLDP name may be the FQDN of a NetBox name
|
||||
func sameSwitch(a, b string) bool {
|
||||
a, _, _ = strings.Cut(strings.ToLower(a), ".")
|
||||
b, _, _ = strings.Cut(strings.ToLower(b), ".")
|
||||
return a == b
|
||||
}
|
||||
|
||||
// handleCables records a cable by hand: {"machine","port","switch","switch_port"}
|
||||
// (port "bmc" for the BMC's); DELETE ?machine=&port= removes it.
|
||||
func (a *App) handleCables(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
q := r.URL.Query()
|
||||
if err := a.store.DeleteCable(r.Context(), q.Get("machine"), q.Get("port"), "local"); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": q.Get("machine") + " " + q.Get("port")})
|
||||
return
|
||||
}
|
||||
var c Cable
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&c); err != nil || c.Machine == "" || c.Port == "" || strings.TrimSpace(c.Switch) == "" {
|
||||
fail(w, 400, errors.New("machine, port and switch are required"))
|
||||
return
|
||||
}
|
||||
if _, err := a.store.Get(r.Context(), c.Machine); err != nil {
|
||||
fail(w, 404, fmt.Errorf("machine %s: %w", c.Machine, err))
|
||||
return
|
||||
}
|
||||
c.Switch, c.SwitchPort, c.Source = strings.TrimSpace(c.Switch), strings.TrimSpace(c.SwitchPort), "local"
|
||||
if err := a.store.PutCable(r.Context(), c); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, c)
|
||||
}
|
||||
|
||||
// handleSwitches places a switch by hand: {"name","building","room","rack","unit","height"};
|
||||
// DELETE ?name= forgets the place (NetBox's comes back on the next sync).
|
||||
func (a *App) handleSwitches(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
if err := a.store.DeleteSwitch(r.Context(), r.URL.Query().Get("name")); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": r.URL.Query().Get("name")})
|
||||
return
|
||||
}
|
||||
var x Switch
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Name) == "" || x.Rack == "" {
|
||||
fail(w, 400, errors.New("name and rack are required"))
|
||||
return
|
||||
}
|
||||
if x.Unit < 0 || x.Unit > 60 || x.Height < 0 || x.Height > 10 {
|
||||
fail(w, 400, errors.New("unit 0..60, height 0..10"))
|
||||
return
|
||||
}
|
||||
x.Source = "local"
|
||||
if err := a.store.PutSwitch(r.Context(), x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- NetBox
|
||||
|
||||
// syncSwitches reads the cables NetBox records on Rollcall's devices, then
|
||||
// copies the place of every switch those and the machines' LLDP name that
|
||||
// NetBox has as a device with a rack.
|
||||
func (a *App) syncSwitches(ctx context.Context, nb *netBox) error {
|
||||
ms, err := a.store.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[string]bool{}
|
||||
cables, err := nb.cables(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cables: %w", err)
|
||||
}
|
||||
if err := a.store.ReplaceNetBoxCables(ctx, cables); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cables {
|
||||
names[c.Switch] = true
|
||||
}
|
||||
for _, m := range ms {
|
||||
for _, l := range lldpOf(invOf(m)) {
|
||||
names[l.Switch] = true
|
||||
if short, _, ok := strings.Cut(l.Switch, "."); ok {
|
||||
names[short] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
var list []string
|
||||
for n := range names {
|
||||
list = append(list, n)
|
||||
}
|
||||
sort.Strings(list)
|
||||
var devs []obj
|
||||
for len(list) > 0 { // 50 names per request: a large site would outgrow a URL
|
||||
batch := list[:min(50, len(list))]
|
||||
list = list[len(batch):]
|
||||
got, err := nb.all(ctx, "dcim/devices/", url.Values{"name": batch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
devs = append(devs, got...)
|
||||
}
|
||||
for _, x := range switchPlaces(devs) {
|
||||
if err := a.store.PutSwitch(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// switchPlaces: the devices that stand in a rack, as switches
|
||||
func switchPlaces(devs []obj) []Switch {
|
||||
var out []Switch
|
||||
for _, d := range devs {
|
||||
rack := str(dig(d, "rack", "name"))
|
||||
if rack == "" {
|
||||
continue
|
||||
}
|
||||
x := Switch{Name: str(d["name"]), Building: str(dig(d, "site", "name")), Room: str(dig(d, "location", "name")), Rack: rack,
|
||||
Unit: int(num(d["position"])), Height: 1, Model: str(dig(d, "device_type", "model")), Source: "netbox"}
|
||||
if role := strings.ToLower(str(dig(d, "role", "name")) + " " + str(dig(d, "role", "slug"))); strings.Contains(role, "oob") || strings.Contains(role, "out-of-band") || strings.Contains(role, "mgmt") || strings.Contains(role, "management") {
|
||||
x.Role = "oob"
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bmcName: an interface NetBox models for the BMC (iDRAC, iLO, XCC, IMM, IPMI, BMC, OOB, mgmt)
|
||||
var bmcName = regexp.MustCompile(`(?i)^(idrac|ilo|xcc|imm|ipmi|bmc|oob|mgmt|management)`)
|
||||
|
||||
// cables: the cables on Rollcall's devices (tag and machine field) to
|
||||
// another device's interface, through patch panels (NetBox's connected
|
||||
// endpoint), as machine port → switch port; an interface
|
||||
// marked management-only or named like a BMC is the machine's "bmc".
|
||||
func (n *netBox) cables(ctx context.Context) ([]Cable, error) {
|
||||
devs, err := n.all(ctx, "dcim/devices/", url.Values{"tag": {n.tag}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
machineOf := map[int64]string{}
|
||||
var ids []string
|
||||
for _, d := range devs {
|
||||
if id := str(dig(d, "custom_fields", n.field)); id != "" && id != "-" {
|
||||
machineOf[nbID(d)] = id
|
||||
ids = append(ids, fmt.Sprint(nbID(d)))
|
||||
}
|
||||
}
|
||||
var out []Cable
|
||||
for len(ids) > 0 { // a page of devices per request
|
||||
batch := ids[:min(50, len(ids))]
|
||||
ids = ids[len(batch):]
|
||||
ifs, err := n.all(ctx, "dcim/interfaces/", url.Values{"device_id": batch, "cabled": {"true"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range ifs {
|
||||
m := machineOf[nbRef(f, "device")]
|
||||
peers, kind := f["connected_endpoints"], str(f["connected_endpoints_type"]) // the far end, through patch panels
|
||||
if kind != "dcim.interface" {
|
||||
peers, kind = f["link_peers"], str(f["link_peers_type"])
|
||||
}
|
||||
for _, p := range toList(peers) {
|
||||
po := asObj(p)
|
||||
sw := str(dig(po, "device", "name"))
|
||||
if m == "" || sw == "" || kind != "dcim.interface" {
|
||||
continue
|
||||
}
|
||||
port := str(f["name"])
|
||||
if f["mgmt_only"] == true || bmcName.MatchString(port) {
|
||||
port = "bmc"
|
||||
}
|
||||
out = append(out, Cable{Machine: m, Port: port, Switch: sw, SwitchPort: str(po["name"]), Source: "netbox"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lldpcli -f json show neighbors details: one interface is an object, several a list
|
||||
const lldpOne = `{"lldp": {"eno1": {"via": "LLDP", "chassis": {"leaf-1.dc1.example": {"id": {"type": "mac", "value": "0c:29:ef:00:00:01"},
|
||||
"descr": "Dell EMC Networking OS10", "mgmt-ip": ["10.0.0.11", "fe80::1"]}}, "port": {"id": {"type": "ifname", "value": "ethernet1/1/12"}, "descr": "node7 eno1"},
|
||||
"vlan": {"vlan-id": "100", "pvid": true}}}}`
|
||||
const lldpList = `{"lldp": [{"eno1": {"chassis": {"leaf-1": {"descr": "x"}}, "port": {"id": {"value": "Ethernet12"}}}},
|
||||
{"eno2": [{"chassis": {"leaf-2": {"mgmt-ip": "10.0.0.12"}}, "port": {"id": {"value": "Ethernet12"}}, "vlan": [{"vlan-id": "100"}, {"vlan-id": "200"}]}]}]}`
|
||||
|
||||
func TestLLDPShapes(t *testing.T) {
|
||||
var inv obj
|
||||
if err := json.Unmarshal([]byte(lldpOne), &inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := lldpOf(inv)
|
||||
if n := got["eno1"]; n.Switch != "leaf-1.dc1.example" || n.Port != "ethernet1/1/12" || n.VLAN != "100" || n.MgmtIP != "10.0.0.11" || n.PortDescr != "node7 eno1" {
|
||||
t.Fatalf("one: %+v", n)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lldpList), &inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = lldpOf(inv)
|
||||
if got["eno1"].Switch != "leaf-1" || got["eno2"].Switch != "leaf-2" || got["eno2"].VLAN != "100" || got["eno2"].MgmtIP != "10.0.0.12" {
|
||||
t.Fatalf("list: %+v", got)
|
||||
}
|
||||
if len(lldpOf(obj{"lldp": nil})) != 0 {
|
||||
t.Fatal("no lldp should be no neighbours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortsOf(t *testing.T) {
|
||||
inv := `{"network": [{"name": "eno2", "mac": "aa:00:00:00:00:02", "bus_info": "0000:3b:00.1", "speed": "25000Mb/s", "link": true},
|
||||
{"name": "ens18", "bus_info": "virtio1", "speed": "Unknown!"}, {"name": "eno1", "bus_info": "0000:3b:00.0", "link": false}],
|
||||
"lldp": {"eno2": {"chassis": {"leaf-1": {}}, "port": {"id": {"value": "Ethernet3"}}}}}`
|
||||
ports, _ := portsOf(&Machine{Inventory: json.RawMessage(inv)})
|
||||
if len(ports) != 3 {
|
||||
t.Fatalf("ports: %+v", ports)
|
||||
}
|
||||
byName := map[string]Port{}
|
||||
for _, p := range ports {
|
||||
byName[p.Name] = p
|
||||
}
|
||||
if p := byName["eno2"]; p.Card != "0000:3b:00" || p.Switch != "leaf-1" || p.SwitchPort != "Ethernet3" || p.Link == nil || !*p.Link {
|
||||
t.Fatalf("eno2: %+v", p)
|
||||
}
|
||||
if p := byName["ens18"]; p.Card != "" || p.Speed != "" {
|
||||
t.Fatalf("ens18: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPlaces(t *testing.T) {
|
||||
var devs []obj
|
||||
_ = json.Unmarshal([]byte(`[{"name": "leaf-1", "site": {"name": "dc1"}, "location": {"name": "Hall 1"}, "rack": {"name": "R01"}, "position": 42.0, "device_type": {"model": "S5248F-ON"}},
|
||||
{"name": "spare", "site": {"name": "dc1"}, "rack": null}]`), &devs)
|
||||
got := switchPlaces(devs)
|
||||
if len(got) != 1 || got[0] != (Switch{Name: "leaf-1", Building: "dc1", Room: "Hall 1", Rack: "R01", Unit: 42, Height: 1, Model: "S5248F-ON", Source: "netbox"}) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCables(t *testing.T) {
|
||||
ports := []Port{{Name: "eno1", Switch: "leaf-1.dc1", SwitchPort: "Ethernet3", Source: "lldp"}, {Name: "eno2"}, {Name: "eno3"}}
|
||||
got := mergeCables(ports, []Cable{
|
||||
{Port: "eno1", Switch: "leaf-1", SwitchPort: "Ethernet4", Source: "netbox"}, // NetBox thinks otherwise
|
||||
{Port: "eno2", Switch: "leaf-2", SwitchPort: "Ethernet3", Source: "netbox"},
|
||||
{Port: "eno2", Switch: "leaf-9", SwitchPort: "Ethernet9", Source: "local"}, // entered by hand: wins over NetBox
|
||||
{Port: "bmc", Switch: "oob-1", SwitchPort: "Gi1/0/7", Source: "netbox"},
|
||||
})
|
||||
if got[0].Switch != "leaf-1.dc1" || got[0].NetBoxSays != "leaf-1 Ethernet4" {
|
||||
t.Fatalf("lldp stays, netbox noted: %+v", got[0])
|
||||
}
|
||||
if got[1].Switch != "leaf-9" || got[1].Source != "local" || got[2].Switch != "" {
|
||||
t.Fatalf("recorded: %+v %+v", got[1], got[2])
|
||||
}
|
||||
if len(got) != 4 || got[3].Kind != "bmc" || got[3].Switch != "oob-1" || got[3].Source != "netbox" {
|
||||
t.Fatalf("bmc: %+v", got)
|
||||
}
|
||||
if n := mergeCables([]Port{{Name: "eno1", Switch: "a", SwitchPort: "1", Source: "lldp"}}, []Cable{{Port: "eno1", Switch: "A.example", SwitchPort: "1", Source: "netbox"}}); n[0].NetBoxSays != "" {
|
||||
t.Fatalf("the same switch by FQDN is no mismatch: %+v", n[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxCables(t *testing.T) {
|
||||
nb, srv := fakeNB(t, map[string]string{
|
||||
"/api/dcim/devices/": `{"results": [{"id": 7, "name": "node7", "custom_fields": {"rollcall_machine": "m-7"}}, {"id": 8, "name": "x", "custom_fields": {"rollcall_machine": "-"}}], "next": null}`,
|
||||
"/api/dcim/interfaces/": `{"results": [
|
||||
{"name": "eno1", "device": {"id": 7}, "mgmt_only": false, "link_peers_type": "dcim.interface", "link_peers": [{"name": "Ethernet3", "device": {"name": "leaf-1"}}]},
|
||||
{"name": "iDRAC", "device": {"id": 7}, "mgmt_only": false, "link_peers_type": "dcim.interface", "link_peers": [{"name": "Gi1/0/7", "device": {"name": "oob-1"}}]},
|
||||
{"name": "eno2", "device": {"id": 7}, "link_peers_type": "dcim.frontport", "link_peers": [{"name": "1", "device": {"name": "patch-1"}}],
|
||||
"connected_endpoints_type": "dcim.interface", "connected_endpoints": [{"name": "Ethernet9", "device": {"name": "leaf-2"}}]},
|
||||
{"name": "eno3", "device": {"id": 7}, "link_peers_type": "dcim.frontport", "link_peers": [{"name": "2", "device": {"name": "patch-1"}}]}], "next": null}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
got, err := nb.cables(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Cable{{Machine: "m-7", Port: "eno1", Switch: "leaf-1", SwitchPort: "Ethernet3", Source: "netbox"}, {Machine: "m-7", Port: "bmc", Switch: "oob-1", SwitchPort: "Gi1/0/7", Source: "netbox"},
|
||||
{Machine: "m-7", Port: "eno2", Switch: "leaf-2", SwitchPort: "Ethernet9", Source: "netbox"}} // through the patch panel; eno3 ends on one
|
||||
if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNB: a NetBox answering fixed JSON per path
|
||||
func fakeNB(t *testing.T, pages map[string]string) (*netBox, *httptest.Server) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := pages[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, b)
|
||||
}))
|
||||
return &netBox{base: srv.URL, token: "t", tag: "rollcall", field: "rollcall_machine", client: srv.Client()}, srv
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
package main
|
||||
|
||||
// Root rotation without re-joining. The CA directory holds the active root
|
||||
// (ca.pem, ca-key.pem) and, during a rotation, the next one (ca-next.pem,
|
||||
// ca-next-key.pem) or the previous one (ca-prev.pem). Everything that trusts
|
||||
// the region gets the trust bundle (all of them, active first):
|
||||
//
|
||||
// prepare a new root is made; the old one still signs. Rack controllers
|
||||
// install the bundle (signed by the active root, installed by their
|
||||
// root unit), machines deployed from now on trust both.
|
||||
// switch the new root signs: the region's certificate, rack and admin
|
||||
// certificates from now on. Racks renew their client and server
|
||||
// certificates by themselves; the old root stays trusted.
|
||||
// retire the old root leaves the bundle once every rack runs on the new one.
|
||||
//
|
||||
// switch and retire refuse while a rack is not ready (-force overrides).
|
||||
// /ca.pem stays one certificate (the root of the server's own certificate:
|
||||
// agents in older images read one); /ca-bundle.pem is the bundle.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loadTrust adds the next and previous roots of a rotation to ca.Trust.
|
||||
func (ca *CA) loadTrust(dir string) error {
|
||||
ca.Trust = []*x509.Certificate{ca.Cert}
|
||||
ca.TrustPEM = append([]byte{}, ca.PEM...)
|
||||
for _, f := range []string{"ca-next.pem", "ca-prev.pem"} {
|
||||
b, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := parseCertPEM(b)
|
||||
if err != nil || !c.IsCA {
|
||||
return fmt.Errorf("%s: not a CA certificate", f)
|
||||
}
|
||||
ca.Trust = append(ca.Trust, c)
|
||||
ca.TrustPEM = append(ca.TrustPEM, pemEncode("CERTIFICATE", c.Raw)...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fpHex(c *x509.Certificate) string {
|
||||
sum := certFingerprint(c)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (ca *CA) pool() *x509.CertPool {
|
||||
p := x509.NewCertPool()
|
||||
for _, c := range ca.Trust {
|
||||
p.AddCert(c)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// signBytes is the active root's signature over data's SHA-256 (base64 DER),
|
||||
// as for rack binaries: openssl dgst -sha256 -verify checks it.
|
||||
func (ca *CA) signBytes(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
der, err := ecdsa.SignASN1(rand.Reader, ca.Key, sum[:])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der)
|
||||
}
|
||||
|
||||
// rootOf is the root among trust that signed c, nil when none did.
|
||||
func rootOf(c *x509.Certificate, trust []*x509.Certificate) *x509.Certificate {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
for _, r := range trust {
|
||||
if bytes.Equal(c.RawIssuer, r.RawSubject) && c.CheckSignatureFrom(r) == nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyBundle: a bundle the region sent, signed by one of the roots trusted
|
||||
// now, holding CA certificates only.
|
||||
func verifyBundle(bundle []byte, sig string, trust []*x509.Certificate) ([]*x509.Certificate, error) {
|
||||
der, err := base64.StdEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bundle signature: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(bundle)
|
||||
ok := false
|
||||
for _, r := range trust {
|
||||
if pub, isEC := r.PublicKey.(*ecdsa.PublicKey); isEC && ecdsa.VerifyASN1(pub, sum[:], der) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("the bundle is not signed by a root trusted here")
|
||||
}
|
||||
certs, err := parsePEMCerts(bundle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range certs {
|
||||
if !c.IsCA {
|
||||
return nil, fmt.Errorf("bundle: %q is not a CA certificate", c.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
func parsePEMCerts(b []byte) ([]*x509.Certificate, error) {
|
||||
var out []*x509.Certificate
|
||||
for {
|
||||
var blk *pem.Block
|
||||
blk, b = pem.Decode(b)
|
||||
if blk == nil {
|
||||
break
|
||||
}
|
||||
if blk.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("unexpected PEM block %s", blk.Type)
|
||||
}
|
||||
c, err := x509.ParseCertificate(blk.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("no certificate")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
// SetRackTrust records what a rack runs on: the hash of its trust bundle and
|
||||
// the roots of its client and server certificates.
|
||||
func (s *Store) SetRackTrust(ctx context.Context, name, bundleSHA, clientCA, serverCA string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE rack_controllers SET ca_bundle_sha256=nullif($2,''), client_ca=nullif($3,''),
|
||||
server_ca=coalesce(nullif($4,''), server_ca) WHERE name=$1`, name, bundleSHA, clientCA, serverCA)
|
||||
return err
|
||||
}
|
||||
|
||||
type rackTrust struct {
|
||||
Name string `json:"name"`
|
||||
Bundle string `json:"ca_bundle_sha256"`
|
||||
ClientCA string `json:"client_ca"`
|
||||
ServerCA string `json:"server_ca"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
func (s *Store) RackTrust(ctx context.Context) ([]rackTrust, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(ca_bundle_sha256,''), coalesce(client_ca,''), coalesce(server_ca,''), last_seen
|
||||
FROM rack_controllers WHERE kind='remote' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []rackTrust{}
|
||||
for rows.Next() {
|
||||
var t rackTrust
|
||||
if err := rows.Scan(&t.Name, &t.Bundle, &t.ClientCA, &t.ServerCA, &t.LastSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- region HTTP
|
||||
|
||||
func (a *App) handleCABundle(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ca == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
_, _ = w.Write(a.ca.TrustPEM)
|
||||
}
|
||||
|
||||
type rotationStatus struct {
|
||||
Phase string `json:"phase"` // "" | prepared | switched
|
||||
Active string `json:"active"`
|
||||
Next string `json:"next,omitempty"`
|
||||
Prev string `json:"prev,omitempty"`
|
||||
Bundle string `json:"bundle_sha256"`
|
||||
Racks []rackTrust `json:"racks"`
|
||||
// what blocks the next step
|
||||
NotReady []string `json:"not_ready"`
|
||||
// what stops working at retire but does not block it
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
func (a *App) rotation(ctx context.Context) (rotationStatus, error) {
|
||||
st := rotationStatus{Active: fpHex(a.ca.Cert), NotReady: []string{}, Warnings: []string{}}
|
||||
sum := sha256.Sum256(a.ca.TrustPEM)
|
||||
st.Bundle = hex.EncodeToString(sum[:])
|
||||
if b, err := os.ReadFile(filepath.Join(a.cfg.CADir, "ca-next.pem")); err == nil {
|
||||
if c, err := parseCertPEM(b); err == nil {
|
||||
st.Phase, st.Next = "prepared", fpHex(c)
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(a.cfg.CADir, "ca-prev.pem")); err == nil {
|
||||
if c, err := parseCertPEM(b); err == nil {
|
||||
st.Phase, st.Prev = "switched", fpHex(c)
|
||||
// admin certificates from before the switch come from the old root
|
||||
if fi, err := os.Stat(filepath.Join(a.cfg.CADir, "ca-prev.pem")); err == nil {
|
||||
if cs, err := a.store.AdminCerts(ctx); err == nil {
|
||||
for _, x := range cs {
|
||||
if x.Revoked == nil && time.Now().Before(x.Expires) && x.Created.Before(fi.ModTime()) {
|
||||
st.Warnings = append(st.Warnings, fmt.Sprintf("admin certificate %s (serial %s) is from the old root: rollcall admin-cert request %s", x.Name, x.Serial, x.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if xs, err := a.store.Intermediates(ctx); err == nil { // their hosts renew them from the new root on their own
|
||||
for _, x := range xs {
|
||||
if x.Issuer != st.Active {
|
||||
st.Warnings = append(st.Warnings, fmt.Sprintf("intermediate %s is from the old root: its host renews it on its next daily request (or now: rollcall-intermediate-renew -force there)", x.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
racks, err := a.store.RackTrust(ctx)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Racks = racks
|
||||
for _, r := range racks {
|
||||
switch st.Phase {
|
||||
case "prepared": // switch needs the new root at every rack
|
||||
if r.Bundle != st.Bundle {
|
||||
st.NotReady = append(st.NotReady, r.Name+": has not installed the bundle with the new root")
|
||||
}
|
||||
case "switched": // retire needs every rack on certificates from the new root
|
||||
if r.ClientCA != st.Active || r.ServerCA != "" && r.ServerCA != st.Active {
|
||||
st.NotReady = append(st.NotReady, r.Name+": still has a certificate from the old root")
|
||||
}
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (a *App) handleCARotation(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ca == nil {
|
||||
fail(w, 409, errors.New("no CA"))
|
||||
return
|
||||
}
|
||||
st, err := a.rotation(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
// noteRackTrust records, at registration, the root of the client certificate
|
||||
// the rack connected with and what it says about its bundle and server certificate.
|
||||
func (a *App) noteRackTrust(r *http.Request, rack string, rc RackController) {
|
||||
if a.ca == nil || r.TLS == nil {
|
||||
return
|
||||
}
|
||||
var client string
|
||||
for _, ch := range r.TLS.VerifiedChains {
|
||||
if len(ch) == 2 {
|
||||
client = fpHex(ch[1])
|
||||
}
|
||||
}
|
||||
_ = a.store.SetRackTrust(r.Context(), rack, rc.CABundleSHA256, client, rc.ServerCA)
|
||||
}
|
||||
|
||||
// serverCAFor is the root an agent behind this rack must accept: that of the
|
||||
// rack's server certificate (it serves that one as /ca.pem).
|
||||
func (a *App) serverCAFor(base string) *x509.Certificate {
|
||||
if base != a.cfg.ServerURL {
|
||||
for _, rc := range a.racksSnapshot() {
|
||||
if strings.TrimRight(rc.URL, "/") == base && rc.ServerCA != "" {
|
||||
for _, c := range a.ca.Trust {
|
||||
if fpHex(c) == rc.ServerCA {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.ca.Cert
|
||||
}
|
||||
|
||||
var trustSig struct {
|
||||
sync.Mutex
|
||||
bundle, sig string
|
||||
}
|
||||
|
||||
// trustState is signed once per bundle: the rack state stays the same between polls.
|
||||
func (a *App) trustState() *rackTrustState {
|
||||
trustSig.Lock()
|
||||
defer trustSig.Unlock()
|
||||
if trustSig.bundle != string(a.ca.TrustPEM) {
|
||||
trustSig.bundle, trustSig.sig = string(a.ca.TrustPEM), a.ca.signBytes(a.ca.TrustPEM)
|
||||
}
|
||||
return &rackTrustState{Bundle: trustSig.bundle, Sig: trustSig.sig, Active: fpHex(a.ca.Cert)}
|
||||
}
|
||||
|
||||
// caRefreshFiles keep a deployed machine's roots current: the bundle comes
|
||||
// over HTTPS from the rack, checked against the Rollcall roots the machine
|
||||
// trusts now (pinned: a certificate from a public CA for the rack's name is
|
||||
// not enough), so a rotation reaches it during prepare (before the new root
|
||||
// signs) and the old root leaves it after retire. Only CA certificates are
|
||||
// taken from the bundle.
|
||||
func caRefreshFiles(tlsBase string) []seedFile {
|
||||
script := `#!/bin/sh
|
||||
# rollcall: keep the installation's roots current (written at deploy time)
|
||||
set -eu
|
||||
URL=` + shQuote(tlsBase+"/ca-bundle.pem") + `
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
cat /usr/local/share/ca-certificates/rollcall-ca*.crt /etc/pki/ca-trust/source/anchors/rollcall-ca*.crt > "$T/pin" 2>/dev/null || true
|
||||
[ -s "$T/pin" ] || { echo "rollcall: no Rollcall root installed here to check $URL with" >&2; exit 1; }
|
||||
rc=0; curl -fsS --max-time 30 --cacert "$T/pin" -o "$T/bundle" "$URL" || rc=$?
|
||||
if [ $rc -eq 60 ]; then # the server's certificate is from a root not trusted here
|
||||
echo "rollcall: $URL is not trusted here: the root rotation went past prepare before this machine refreshed; install the bundle by hand" >&2; exit 1
|
||||
fi
|
||||
[ $rc -eq 0 ] || exit 0 # unreachable: the timer tries again
|
||||
awk -v d="$T" '/-----BEGIN CERTIFICATE-----/{n++} n{print > (d "/c" n)} /-----END CERTIFICATE-----/{close(d "/c" n)}' "$T/bundle"
|
||||
[ -f "$T/c1" ] || exit 0
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
for c in "$T"/c[0-9]*; do
|
||||
openssl x509 -in "$c" -noout -text 2>/dev/null | grep -q 'CA:TRUE' || { echo "rollcall: $URL sent a certificate that is not a CA; nothing changed" >&2; exit 1; }
|
||||
done
|
||||
fi
|
||||
changed=
|
||||
for dir in /usr/local/share/ca-certificates /etc/pki/ca-trust/source/anchors; do
|
||||
[ -d "$dir" ] || continue
|
||||
i=0; mkdir -p "$T/want"; rm -f "$T"/want/*
|
||||
for c in "$T"/c[0-9]*; do
|
||||
i=$((i+1)); n=rollcall-ca.crt; [ $i -gt 1 ] && n=rollcall-ca-$i.crt
|
||||
cp "$c" "$T/want/$n"
|
||||
done
|
||||
if [ "$(cat "$dir"/rollcall-ca*.crt 2>/dev/null)" != "$(cat "$T"/want/*)" ]; then
|
||||
rm -f "$dir"/rollcall-ca*.crt
|
||||
for f in "$T"/want/*; do install -m 0644 "$f" "$dir/$(basename "$f")"; done
|
||||
changed=1
|
||||
fi
|
||||
done
|
||||
[ -n "$changed" ] || exit 0
|
||||
if command -v update-ca-trust >/dev/null 2>&1; then update-ca-trust extract; elif command -v update-ca-certificates >/dev/null 2>&1; then update-ca-certificates; fi
|
||||
echo "rollcall: trusted roots updated from $URL"
|
||||
`
|
||||
return []seedFile{
|
||||
{Path: "usr/local/sbin/rollcall-ca-refresh", Mode: "0755", Content: script},
|
||||
{Path: "etc/systemd/system/rollcall-ca-refresh.service", Mode: "0644", Content: "[Unit]\nDescription=rollcall: refresh the installation's trusted roots\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/sbin/rollcall-ca-refresh\n"},
|
||||
{Path: "etc/systemd/system/rollcall-ca-refresh.timer", Mode: "0644", Content: "[Unit]\nDescription=rollcall: refresh the installation's trusted roots daily\n\n[Timer]\nOnBootSec=10min\nOnUnitActiveSec=1d\nRandomizedDelaySec=1h\n\n[Install]\nWantedBy=timers.target\n"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CLI
|
||||
|
||||
// rollcall ca rotate [status | prepare | switch | retire] [-force]
|
||||
func caRotateCmd(cfg *Config, args []string) error {
|
||||
sub := "status"
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
sub, args = args[0], args[1:]
|
||||
}
|
||||
fs := newFlags("ca rotate " + sub)
|
||||
force := fs.Bool("force", false, "go on although rack controllers are not ready (they then join again)")
|
||||
_ = fs.Parse(args)
|
||||
dir := cfg.CADir
|
||||
have := func(f string) bool { _, err := os.Stat(filepath.Join(dir, f)); return err == nil }
|
||||
status := func() (*rotationStatus, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var st rotationStatus
|
||||
if err := c.do("GET", "/api/v1/ca/rotation", nil, &st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
ready := func() error {
|
||||
st, err := status()
|
||||
if err != nil {
|
||||
if *force {
|
||||
fmt.Fprintf(os.Stderr, "cannot ask the region (%v); -force: going on\n", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot ask the region whether rack controllers are ready: %w (-force to go on)", err)
|
||||
}
|
||||
if len(st.NotReady) > 0 && !*force {
|
||||
return fmt.Errorf("not ready:\n %s\nwait for them (they pick it up within minutes when online) or -force (they then join again)", strings.Join(st.NotReady, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
restart := "next: restart rollcall (systemctl restart rollcall); `rollcall ca rotate` shows the progress"
|
||||
switch sub {
|
||||
case "status":
|
||||
st, err := status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phase := map[string]string{"": "no rotation", "prepared": "prepared (next: switch)", "switched": "switched (next: retire)"}[st.Phase]
|
||||
fmt.Printf("phase %s\nactive %s\n", phase, fpString(mustFP(st.Active)))
|
||||
if st.Next != "" {
|
||||
fmt.Printf("next %s\n", fpString(mustFP(st.Next)))
|
||||
}
|
||||
if st.Prev != "" {
|
||||
fmt.Printf("prev %s (still trusted)\n", fpString(mustFP(st.Prev)))
|
||||
}
|
||||
short := func(fp string) string {
|
||||
switch {
|
||||
case fp == "":
|
||||
return "-"
|
||||
case fp == st.Active:
|
||||
return "active"
|
||||
case fp == st.Next:
|
||||
return "next"
|
||||
case fp == st.Prev:
|
||||
return "prev"
|
||||
}
|
||||
return fp[:12]
|
||||
}
|
||||
fmt.Printf("\n%-16s %-8s %-8s %-8s %s\n", "RACK", "BUNDLE", "CLIENT", "SERVER", "LAST SEEN")
|
||||
for _, r := range st.Racks {
|
||||
b := "old"
|
||||
if r.Bundle == st.Bundle {
|
||||
b = "current"
|
||||
}
|
||||
seen := "-"
|
||||
if r.LastSeen != nil {
|
||||
seen = r.LastSeen.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Printf("%-16s %-8s %-8s %-8s %s\n", r.Name, b, short(r.ClientCA), short(r.ServerCA), seen)
|
||||
}
|
||||
for _, n := range st.NotReady {
|
||||
fmt.Println("waiting:", n)
|
||||
}
|
||||
for _, n := range st.Warnings {
|
||||
fmt.Println("note:", n)
|
||||
}
|
||||
return nil
|
||||
case "prepare":
|
||||
if have("ca-next.pem") || have("ca-prev.pem") {
|
||||
return errors.New("a rotation is under way (rollcall ca rotate status)")
|
||||
}
|
||||
next, err := newCA("rollcall CA " + time.Now().Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCA(dir, "ca-next", next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("next root %s\nthe active root still signs; rack controllers and newly deployed machines get both\n%s; then `rollcall ca rotate switch`\n", next.Fingerprint(), restart)
|
||||
return nil
|
||||
case "switch":
|
||||
if !have("ca-next.pem") {
|
||||
return errors.New("no prepared root (rollcall ca rotate prepare)")
|
||||
}
|
||||
if err := ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
bak := filepath.Join(dir, "rotated-"+time.Now().Format("20060102-150405"))
|
||||
if err := os.Mkdir(bak, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
steps := [][2]string{{"ca.pem", "ca-prev.pem"}, {"ca-key.pem", filepath.Join(filepath.Base(bak), "ca-key.pem")},
|
||||
{"ca-next-key.pem", "ca-key.pem"}, {"ca-next.pem", "ca.pem"}}
|
||||
for _, s := range steps {
|
||||
if err := copyKeepOwner(filepath.Join(dir, s[0]), filepath.Join(dir, s[1])); err != nil {
|
||||
return fmt.Errorf("%s -> %s: %w (the CA directory may be half switched; the old files are in %s)", s[0], s[1], err, bak)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"ca-next.pem", "ca-next-key.pem"} {
|
||||
_ = os.Remove(filepath.Join(dir, f))
|
||||
}
|
||||
fmt.Printf("the new root signs now; the old one stays trusted (its key: %s)\nrack controllers renew their certificates by themselves; admin certificates and intermediates from the old root work until retire: issue them again\n%s; then `rollcall ca rotate retire`\n", bak, restart)
|
||||
return nil
|
||||
case "retire":
|
||||
if !have("ca-prev.pem") {
|
||||
return errors.New("no previous root to retire")
|
||||
}
|
||||
if err := ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
if st, err := status(); err == nil && len(st.Warnings) > 0 {
|
||||
fmt.Printf("stops working now (intermediates from the old root too):\n %s\n", strings.Join(st.Warnings, "\n "))
|
||||
}
|
||||
bak := filepath.Join(dir, "retired-"+time.Now().Format("20060102-150405")+".pem")
|
||||
if err := os.Rename(filepath.Join(dir, "ca-prev.pem"), bak); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("the old root is no longer trusted (kept in %s)\n%s\n", bak, restart)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ca rotate %s: unknown (status, prepare, switch, retire)", sub)
|
||||
}
|
||||
|
||||
func mustFP(h string) [32]byte {
|
||||
var out [32]byte
|
||||
b, _ := hex.DecodeString(h)
|
||||
copy(out[:], b)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeCA writes a root as <name>.pem / <name>-key.pem, owned like ca.pem.
|
||||
func writeCA(dir, name string, ca *CA) error {
|
||||
kder, err := x509.MarshalECPrivateKey(ca.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kp, cp := filepath.Join(dir, name+"-key.pem"), filepath.Join(dir, name+".pem")
|
||||
if err := os.WriteFile(kp, pemEncode("EC PRIVATE KEY", kder), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cp, ca.PEM, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if own, ok := fileOwner(filepath.Join(dir, "ca.pem")); ok {
|
||||
_ = os.Chown(kp, own[0], own[1])
|
||||
_ = os.Chown(cp, own[0], own[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyKeepOwner copies src over dst (atomically) with src's mode and owner.
|
||||
func copyKeepOwner(src, dst string) error {
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(dst, b, st.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if own, ok := fileOwner(src); ok {
|
||||
_ = os.Chown(dst, own[0], own[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shQuote is s as one single-quoted shell word.
|
||||
func shQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
||||
|
||||
// ---------------------------------------------------------------- rack controller
|
||||
|
||||
// rackTrust is what the rack trusts now (its ca_file, written at join and by
|
||||
// the root update unit).
|
||||
func (r *rackd) rackTrust() ([]byte, []*x509.Certificate) {
|
||||
b, err := os.ReadFile(r.cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
certs, _ := parsePEMCerts(b)
|
||||
return b, certs
|
||||
}
|
||||
|
||||
// trustReport: the hash of the rack's roots and the root of its server certificate.
|
||||
func (r *rackd) trustReport() (bundleSHA, serverCA string) {
|
||||
b, trust := r.rackTrust()
|
||||
if b == nil {
|
||||
return "", ""
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
r.srvMu.Lock()
|
||||
c := r.srvCert
|
||||
r.srvMu.Unlock()
|
||||
if c != nil {
|
||||
if root := rootOf(c.Leaf, trust); root != nil {
|
||||
serverCA = fpHex(root)
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(sum[:]), serverCA
|
||||
}
|
||||
|
||||
// serveCA answers /ca.pem with the root of the server certificate (agents
|
||||
// pin it by fingerprint and read one certificate) and /ca-bundle.pem with all.
|
||||
func (r *rackd) serveCA(bundle bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
b, trust := r.rackTrust()
|
||||
if b == nil {
|
||||
http.Error(w, "no CA", 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
if bundle || len(trust) < 2 {
|
||||
_, _ = w.Write(b)
|
||||
return
|
||||
}
|
||||
r.srvMu.Lock()
|
||||
c := r.srvCert
|
||||
r.srvMu.Unlock()
|
||||
root := trust[0]
|
||||
if c != nil {
|
||||
if o := rootOf(c.Leaf, trust); o != nil {
|
||||
root = o
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(pemEncode("CERTIFICATE", root.Raw))
|
||||
}
|
||||
}
|
||||
|
||||
var rackTrustMu sync.Mutex
|
||||
var rackTrustTried = map[string]time.Time{}
|
||||
|
||||
// maybeTrust follows the region's roots: a new bundle is staged for the root
|
||||
// unit, and certificates from a root that no longer signs are renewed.
|
||||
func (r *rackd) maybeTrust(t *rackTrustState) {
|
||||
if t == nil || r.cert == nil {
|
||||
return
|
||||
}
|
||||
rackTrustMu.Lock()
|
||||
defer rackTrustMu.Unlock()
|
||||
cur, trust := r.rackTrust()
|
||||
if t.Bundle != "" && t.Bundle != string(cur) {
|
||||
sum := sha256.Sum256([]byte(t.Bundle))
|
||||
key := "bundle " + hex.EncodeToString(sum[:])
|
||||
if time.Since(rackTrustTried[key]) > time.Hour {
|
||||
rackTrustTried[key] = time.Now()
|
||||
if err := r.stageBundle(t, trust); err != nil {
|
||||
log.Printf("trust bundle: %v", err)
|
||||
} else {
|
||||
log.Printf("new trust bundle staged in %s; rollcall-rack-update installs it", r.updateDir())
|
||||
}
|
||||
}
|
||||
}
|
||||
var active *x509.Certificate
|
||||
for _, c := range trust {
|
||||
if fpHex(c) == t.Active {
|
||||
active = c
|
||||
}
|
||||
}
|
||||
if active == nil {
|
||||
return // the signing root is not trusted here yet: the bundle comes first
|
||||
}
|
||||
renewed := false
|
||||
r.certMu.Lock()
|
||||
leaf := r.cert.Leaf
|
||||
r.certMu.Unlock()
|
||||
if rootOf(leaf, trust) != active && time.Since(rackTrustTried["client"]) > 10*time.Minute {
|
||||
rackTrustTried["client"] = time.Now()
|
||||
if err := r.renew(); err != nil {
|
||||
log.Printf("certificate from the new root: %v", err)
|
||||
} else {
|
||||
renewed = true
|
||||
}
|
||||
}
|
||||
r.srvMu.Lock()
|
||||
srv := r.srvCert
|
||||
r.srvMu.Unlock()
|
||||
if srv != nil && rootOf(srv.Leaf, trust) != active && time.Since(rackTrustTried["server"]) > 10*time.Minute {
|
||||
rackTrustTried["server"] = time.Now()
|
||||
if c, err := r.newServerCert(); err != nil {
|
||||
log.Printf("server certificate from the new root: %v", err)
|
||||
} else {
|
||||
r.srvMu.Lock()
|
||||
r.srvCert = c
|
||||
r.srvMu.Unlock()
|
||||
renewed = true
|
||||
}
|
||||
}
|
||||
if renewed {
|
||||
for _, hc := range []*http.Client{r.hc, r.fast, r.big} { // new connections present the new certificate
|
||||
if tr, ok := hc.Transport.(*http.Transport); ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
if err := r.register(); err != nil {
|
||||
log.Printf("register after renewal: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stageBundle leaves a verified bundle and its signature for the root unit,
|
||||
// which checks the signature again against its own copy of the roots.
|
||||
func (r *rackd) stageBundle(t *rackTrustState, trust []*x509.Certificate) error {
|
||||
if _, err := verifyBundle([]byte(t.Bundle), t.Sig, trust); err != nil {
|
||||
return err
|
||||
}
|
||||
sig, _ := base64.StdEncoding.DecodeString(t.Sig)
|
||||
dir := r.updateDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(filepath.Join(dir, "ca-bundle.pem"), []byte(t.Bundle), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(filepath.Join(dir, "ca-bundle.sig"), sig, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(filepath.Join(dir, "ca-ready"), []byte("1\n"), 0o644)
|
||||
}
|
||||
|
||||
var caWordRe = regexp.MustCompile(`\brollcall\.ca=[0-9a-fA-F]{64}\b`)
|
||||
|
||||
// pinCA points the rollcall.ca= words of a boot entry at the root fp.
|
||||
func pinCA(entry, fp string) string { return caWordRe.ReplaceAllString(entry, "rollcall.ca="+fp) }
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rotTestCA(t *testing.T) (string, *Config) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "ca")
|
||||
if _, err := loadOrCreateCA(dir, "rollcall CA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir, &Config{CADir: dir}
|
||||
}
|
||||
|
||||
func TestCARotationPhases(t *testing.T) {
|
||||
dir, cfg := rotTestCA(t)
|
||||
old, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
if len(old.Trust) != 1 || !bytes.Equal(old.TrustPEM, old.PEM) {
|
||||
t.Fatalf("no rotation: trust %d", len(old.Trust))
|
||||
}
|
||||
leafOld, _, err := old.issue("rack:r1", nil, x509.ExtKeyUsageClientAuth)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// prepare: the old root still signs, both are trusted
|
||||
if err := caRotateCmd(cfg, []string{"prepare"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := caRotateCmd(cfg, []string{"prepare"}); err == nil {
|
||||
t.Fatal("a second prepare during a rotation")
|
||||
}
|
||||
p, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
if fpHex(p.Cert) != fpHex(old.Cert) || len(p.Trust) != 2 {
|
||||
t.Fatalf("prepared: active %s trust %d", fpHex(p.Cert)[:8], len(p.Trust))
|
||||
}
|
||||
next := p.Trust[1]
|
||||
// switch (no region to ask: -force)
|
||||
if err := caRotateCmd(cfg, []string{"switch", "-force"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := loadOrCreateCA(dir, "rollcall CA")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fpHex(s.Cert) != fpHex(next) || len(s.Trust) != 2 || fpHex(s.Trust[1]) != fpHex(old.Cert) {
|
||||
t.Fatalf("switched: active should be the prepared root, the old one trusted")
|
||||
}
|
||||
if !s.Key.PublicKey.Equal(s.Cert.PublicKey) {
|
||||
t.Fatal("the active key is not the active root's")
|
||||
}
|
||||
// a certificate from the old root still verifies, a new one comes from the new root
|
||||
lc, _ := parseCertPEM(leafOld)
|
||||
if _, err := lc.Verify(x509.VerifyOptions{Roots: s.pool(), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
t.Fatalf("old certificate after switch: %v", err)
|
||||
}
|
||||
nb, _, _ := s.issue("rack:r1", nil, x509.ExtKeyUsageClientAuth)
|
||||
nc, _ := parseCertPEM(nb)
|
||||
if rootOf(nc, s.Trust) != s.Cert || rootOf(lc, s.Trust) == s.Cert {
|
||||
t.Fatal("rootOf")
|
||||
}
|
||||
// the region's certificate is issued again by the new root
|
||||
if _, err := regionCert(old, dir, []string{"region.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := regionCert(s, dir, []string{"region.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, _ := x509.ParseCertificate(pair.Certificate[0])
|
||||
if rc.CheckSignatureFrom(s.Cert) != nil {
|
||||
t.Fatal("region certificate not from the new root after switch")
|
||||
}
|
||||
if err := caRotateCmd(cfg, []string{"switch", "-force"}); err == nil {
|
||||
t.Fatal("switch without a prepared root")
|
||||
}
|
||||
// retire: only the new root
|
||||
if err := caRotateCmd(cfg, []string{"retire", "-force"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
if len(r.Trust) != 1 || fpHex(r.Cert) != fpHex(next) {
|
||||
t.Fatal("retired: trust should be the new root only")
|
||||
}
|
||||
if _, err := lc.Verify(x509.VerifyOptions{Roots: r.pool(), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err == nil {
|
||||
t.Fatal("a certificate from the retired root still verifies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustBundleSignature(t *testing.T) {
|
||||
dir, cfg := rotTestCA(t)
|
||||
if err := caRotateCmd(cfg, []string{"prepare"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
sig := ca.signBytes(ca.TrustPEM)
|
||||
got, err := verifyBundle(ca.TrustPEM, sig, []*x509.Certificate{ca.Cert})
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("valid bundle: %v (%d)", err, len(got))
|
||||
}
|
||||
stranger, _ := newCA("someone")
|
||||
if _, err := verifyBundle(ca.TrustPEM, sig, []*x509.Certificate{stranger.Cert}); err == nil {
|
||||
t.Fatal("accepted a bundle signed by a root not trusted here")
|
||||
}
|
||||
if _, err := verifyBundle(append(append([]byte{}, ca.TrustPEM...), stranger.PEM...), sig, ca.Trust); err == nil {
|
||||
t.Fatal("accepted a bundle with a root added after signing")
|
||||
}
|
||||
leaf, _, _ := ca.issue("x", []string{"x.example"}, x509.ExtKeyUsageServerAuth)
|
||||
withLeaf := append(append([]byte{}, ca.TrustPEM...), leaf...)
|
||||
if _, err := verifyBundle(withLeaf, ca.signBytes(withLeaf), ca.Trust); err == nil || !strings.Contains(err.Error(), "not a CA") {
|
||||
t.Fatalf("a leaf in the bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRackStagesBundle(t *testing.T) {
|
||||
dir, cfg := rotTestCA(t)
|
||||
ca, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
rd := t.TempDir()
|
||||
caFile := filepath.Join(rd, "ca.pem")
|
||||
_ = os.WriteFile(caFile, ca.PEM, 0o644)
|
||||
certPEM, keyPEM, _ := ca.issue("rack:r1", nil, x509.ExtKeyUsageClientAuth)
|
||||
pair, _ := tls.X509KeyPair(certPEM, keyPEM)
|
||||
pair.Leaf, _ = x509.ParseCertificate(pair.Certificate[0])
|
||||
r := &rackd{cfg: RackConfig{Name: "r1", CAFile: caFile, UpdateDir: filepath.Join(rd, "update")}, cert: &pair}
|
||||
_ = caRotateCmd(cfg, []string{"prepare"})
|
||||
ca, _ = loadOrCreateCA(dir, "rollcall CA")
|
||||
app := &App{ca: ca}
|
||||
r.maybeTrust(app.trustState())
|
||||
b, err := os.ReadFile(filepath.Join(rd, "update", "ca-bundle.pem"))
|
||||
if err != nil || !bytes.Equal(b, ca.TrustPEM) {
|
||||
t.Fatalf("bundle not staged: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(rd, "update", "ca-ready")); err != nil {
|
||||
t.Fatal("no ca-ready marker")
|
||||
}
|
||||
// the signature file is DER, for openssl dgst -verify
|
||||
sig, _ := os.ReadFile(filepath.Join(rd, "update", "ca-bundle.sig"))
|
||||
if len(sig) == 0 || sig[0] != 0x30 {
|
||||
t.Fatal("signature not DER")
|
||||
}
|
||||
// a forged bundle is not staged
|
||||
_ = os.RemoveAll(filepath.Join(rd, "update"))
|
||||
stranger, _ := newCA("someone")
|
||||
forged := append(append([]byte{}, ca.TrustPEM...), stranger.PEM...)
|
||||
rackTrustTried = map[string]time.Time{}
|
||||
r.maybeTrust(&rackTrustState{Bundle: string(forged), Sig: stranger.signBytes(forged), Active: fpHex(ca.Cert)})
|
||||
if _, err := os.Stat(filepath.Join(rd, "update", "ca-ready")); err == nil {
|
||||
t.Fatal("staged a bundle signed by a stranger")
|
||||
}
|
||||
// the rack serves one certificate as /ca.pem: agents in older images read one
|
||||
_ = os.WriteFile(caFile, ca.TrustPEM, 0o644)
|
||||
if _, trust := r.rackTrust(); len(trust) != 2 {
|
||||
t.Fatal("rack trust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCARefreshScript(t *testing.T) {
|
||||
fs := caRefreshFiles("https://10.213.78.1")
|
||||
if len(fs) != 3 || !strings.Contains(fs[0].Content, "URL='https://10.213.78.1/ca-bundle.pem'") {
|
||||
t.Fatalf("%+v", fs[0])
|
||||
}
|
||||
// the rack is checked against the Rollcall roots installed, not the system's; only CAs are taken
|
||||
if !strings.Contains(fs[0].Content, `--cacert "$T/pin"`) || !strings.Contains(fs[0].Content, "CA:TRUE") {
|
||||
t.Fatal("refresh is not pinned to the installed roots or takes any certificate")
|
||||
}
|
||||
f := filepath.Join(t.TempDir(), "s")
|
||||
_ = os.WriteFile(f, []byte(fs[0].Content), 0o755)
|
||||
if out, err := exec.Command("sh", "-n", f).CombinedOutput(); err != nil {
|
||||
t.Fatalf("sh -n: %v %s", err, out)
|
||||
}
|
||||
if shQuote("a'b") != `'a'\''b'` {
|
||||
t.Fatal("shQuote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinCA(t *testing.T) {
|
||||
old, nw := strings.Repeat("7e", 32), strings.Repeat("a1", 32)
|
||||
in := "LABEL deploy\n APPEND initrd=x rollcall.tls=https://10.213.78.1 rollcall.ca=" + old + " quiet\n APPEND rollcall.ca=" + old + "\n"
|
||||
got := pinCA(in, nw)
|
||||
if strings.Contains(got, old) || strings.Count(got, "rollcall.ca="+nw) != 2 || !strings.Contains(got, "rollcall.tls=https://10.213.78.1 rollcall.ca="+nw+" quiet") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
if pinCA("APPEND rollcall.server=x", nw) != "APPEND rollcall.server=x" {
|
||||
t.Fatal("changed an entry without rollcall.ca")
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -39,6 +39,16 @@ func clientTLS(caPEM string) *tls.Config {
|
||||
}
|
||||
}
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
// a rack controller's certificate (its client one, or the server one it
|
||||
// shows agents) is never the region, whatever names it carries
|
||||
tc.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
if len(cs.PeerCertificates) > 0 {
|
||||
if cn := cs.PeerCertificates[0].Subject.CommonName; strings.HasPrefix(cn, "rack:") || strings.HasPrefix(cn, rackServerCN) {
|
||||
return fmt.Errorf("the server shows the certificate of a rack controller (%s), not the region's", cn)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cert := clientCertificate(); cert != nil {
|
||||
tc.Certificates = []tls.Certificate{*cert}
|
||||
}
|
||||
@@ -232,13 +242,16 @@ func runClient(cmd string, args []string) error {
|
||||
if len(ids) > 1 {
|
||||
fmt.Printf("%s: ", id)
|
||||
}
|
||||
fmt.Println(str(d["output"]))
|
||||
if o := str(d["output"]); o != "" {
|
||||
fmt.Println(o)
|
||||
}
|
||||
if ok, _ := d["ok"].(bool); !ok {
|
||||
fmt.Println("error:", orDefault(str(d["error"]), "the BMC command failed"))
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("ipmitool failed on %d machine(s)", failed)
|
||||
return fmt.Errorf("power %s failed on %d machine(s)", args[1], failed)
|
||||
}
|
||||
case "bootdev":
|
||||
_ = need(2)
|
||||
@@ -276,7 +289,7 @@ func runClient(cmd string, args []string) error {
|
||||
return c.locationCmd(args)
|
||||
case "bmc":
|
||||
return c.bmcCmd(args)
|
||||
case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam":
|
||||
case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns", "reservations":
|
||||
return c.netCmd(cmd, args)
|
||||
case "admin-cert":
|
||||
return c.adminCertCmd(args)
|
||||
|
||||
+20
-5
@@ -31,8 +31,13 @@ type Config struct {
|
||||
ClusterVars map[string]any `json:"cluster_vars"` // available to overlay templates as .Vars (profile vars override)
|
||||
|
||||
// disk-based deployment
|
||||
OSDir string `json:"os_dir"` // <os_dir>/<name>/<version>/{disk.raw.zst,meta.json}, served under /os/
|
||||
OSSources string `json:"os_sources"` // JSON list of upstream cloud images for `rollcall os sync`
|
||||
OSDir string `json:"os_dir"` // <os_dir>/<name>/<version>/{disk.raw.zst,meta.json}, served under /os/
|
||||
// FirmwareDir: <firmware_dir>/<id>/{meta.json,<package>}, served under /firmware/<id>/<package>
|
||||
FirmwareDir string `json:"firmware_dir"`
|
||||
// RackUnits: height in rack units per model ("PowerEdge R750" or "Dell Inc. PowerEdge R750"),
|
||||
// for models the firmware and the built-in table do not know (hwsize.go)
|
||||
RackUnits map[string]int `json:"rack_units,omitempty"`
|
||||
OSSources string `json:"os_sources"` // JSON list of upstream cloud images for `rollcall os sync`
|
||||
|
||||
// region/rack: this server runs an embedded rack controller named RackName;
|
||||
// remote rack controllers (rollcall rack) authenticate with RackSecret
|
||||
@@ -45,12 +50,19 @@ type Config struct {
|
||||
CADir string `json:"ca_dir"` // ca.pem, ca-key.pem, region.pem, region-key.pem (default /etc/rollcall/ca)
|
||||
TLSListen string `json:"tls_listen"` // HTTPS listener, default ":443"; "off" disables
|
||||
TLSNames []string `json:"tls_names"` // extra names/addresses for the region certificate
|
||||
AdminHTTP string `json:"admin_http"` // admin API over plain HTTP: allow (default) | loopback | deny
|
||||
// AgentSourceCheck: machine tokens only from the machine's own address (default true; see agentsrc.go)
|
||||
AgentSourceCheck *bool `json:"agent_source_check,omitempty"`
|
||||
AdminHTTP string `json:"admin_http"` // admin API over plain HTTP: loopback (default) | allow | deny
|
||||
|
||||
// a deploy/release job the machine has not started by then fails (default 30m;
|
||||
// servers can take 4-5 minutes to POST, longer when many boot together)
|
||||
JobStartTimeout string `json:"job_start_timeout"`
|
||||
|
||||
// DNS records for machines written directly (PowerDNS API or RFC 2136)
|
||||
DNS DNSConfig `json:"dns"`
|
||||
// fixed leases on the site's DHCP server for machines on proxy-mode VLANs (Kea, OpenWrt)
|
||||
DHCPRes DHCPResConfig `json:"dhcp_reservations"`
|
||||
|
||||
// external IPAM authority (NetBox; Infoblox at sites that run it)
|
||||
IPAM IPAMConfig `json:"ipam"`
|
||||
}
|
||||
@@ -100,6 +112,9 @@ func loadConfig(path string) (*Config, error) {
|
||||
if c.OSDir == "" {
|
||||
c.OSDir = filepath.Join(filepath.Dir(c.ImageDir), "os")
|
||||
}
|
||||
if c.FirmwareDir == "" {
|
||||
c.FirmwareDir = filepath.Join(filepath.Dir(c.ImageDir), "firmware")
|
||||
}
|
||||
if c.OSSources == "" {
|
||||
c.OSSources = "/etc/rollcall/os-sources.json"
|
||||
}
|
||||
@@ -115,8 +130,8 @@ func loadConfig(path string) (*Config, error) {
|
||||
case "off", "none":
|
||||
c.TLSListen = ""
|
||||
}
|
||||
if c.AdminHTTP == "" {
|
||||
c.AdminHTTP = "allow"
|
||||
if c.AdminHTTP == "" { // the token must not cross the provisioning network in clear
|
||||
c.AdminHTTP = "loopback"
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
+97
-13
@@ -161,6 +161,12 @@ func (s *Store) FinishJob(ctx context.Context, id, state, errMsg string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// FailPendingJob fails a job that has not started; false when it started meanwhile.
|
||||
func (s *Store) FailPendingJob(ctx context.Context, id, errMsg string) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE jobs SET state='failed', finished=now(), started=coalesce(started, now()), error=nullif($2,'') WHERE id=$1 AND state='pending'`, id, errMsg)
|
||||
return err == nil && tag.RowsAffected() == 1, err
|
||||
}
|
||||
|
||||
func (s *Store) SetDeployed(ctx context.Context, id, os, version string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE machines SET state='deployed', os=$2, os_version=$3, deployed_at=now(), booted_at=NULL, last_seen=now() WHERE id=$1`, id, os, version)
|
||||
return err
|
||||
@@ -176,6 +182,9 @@ func (s *Store) SetReleased(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (s *Store) SetNodeAddr(ctx context.Context, id, name, mgmt, ib string) error {
|
||||
if name != "" && !validDomain(name) {
|
||||
return fmt.Errorf("node name %q is not a host name (letters, digits, hyphens)", name)
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `UPDATE machines SET node_name=$2, mgmt_ip=$3, ib_ip=$4 WHERE id=$1`, id, name, nilIfEmpty(mgmt), nilIfEmpty(ib))
|
||||
return err
|
||||
}
|
||||
@@ -213,6 +222,9 @@ func inventoryDisks(m *Machine) []invDisk {
|
||||
|
||||
// pickDisk: the smallest non-removable, non-USB disk that fits the image (boot/OS disks are
|
||||
// usually the small ones on servers; data disks stay untouched). Explicit choice wins.
|
||||
// remoteTran: transports of disks that are not the machine's own (lsblk TRAN).
|
||||
var remoteTran = map[string]bool{"usb": true, "iscsi": true, "fc": true, "fcoe": true, "srp": true, "iser": true}
|
||||
|
||||
func pickDisk(disks []invDisk, want string, need int64) (*invDisk, error) {
|
||||
if want != "" {
|
||||
for i := range disks {
|
||||
@@ -230,7 +242,7 @@ func pickDisk(disks []invDisk, want string, need int64) (*invDisk, error) {
|
||||
}
|
||||
var cands []invDisk
|
||||
for _, d := range disks {
|
||||
if d.Removable || d.Tran == "usb" || d.Size < need {
|
||||
if d.Removable || remoteTran[d.Tran] || d.Size < need { // never a USB stick or a SAN LUN by itself
|
||||
continue
|
||||
}
|
||||
cands = append(cands, d)
|
||||
@@ -285,6 +297,9 @@ func (a *App) renderSeed(j *Job, sp DeploySpec, token string) []seedFile {
|
||||
if len(sp.Packages) > 0 {
|
||||
fmt.Fprintf(&b, "packages: %s\n", yamlList(sp.Packages))
|
||||
}
|
||||
// plain HTTP on purpose: nothing in it is secret (the token only works from this
|
||||
// machine's address), and cloud-init spends its 10 tries within milliseconds, so a
|
||||
// TLS hiccup on first boot (seen once on Debian: bad record MAC) loses "booted"
|
||||
fmt.Fprintf(&b, "phone_home:\n url: %s/api/v1/phone-home/%s\n post: [instance_id, hostname, fqdn, pub_key_ed25519]\n tries: 10\n", base, token)
|
||||
b.WriteString("final_message: \"rollcall: deployed, up after $UPTIME s\"\n") // only $UPTIME/$TIMESTAMP/$DATASOURCE/$VERSION are expanded
|
||||
ud = b.String()
|
||||
@@ -296,7 +311,8 @@ func (a *App) renderSeed(j *Job, sp DeploySpec, token string) []seedFile {
|
||||
// cloud-init's ca_certs runs update-ca-certificates even on RHEL-family systems
|
||||
// and fails there, so it is not used.
|
||||
frags = append(frags, TagDef{Name: "rollcall-ca", CloudConfig: "#cloud-config\n# rollcall: trust the installation's certificate authority\nbootcmd:\n" +
|
||||
" - [sh, -c, 'if command -v update-ca-trust >/dev/null 2>&1; then update-ca-trust extract; elif command -v update-ca-certificates >/dev/null 2>&1; then update-ca-certificates; fi']\n"})
|
||||
" - [sh, -c, 'if command -v update-ca-trust >/dev/null 2>&1; then update-ca-trust extract; elif command -v update-ca-certificates >/dev/null 2>&1; then update-ca-certificates; fi']\n" +
|
||||
" - [sh, -c, 'if [ -f /etc/systemd/system/rollcall-ca-refresh.timer ]; then systemctl enable --now rollcall-ca-refresh.timer || true; fi']\n"})
|
||||
}
|
||||
if merr == nil {
|
||||
if tf, err := a.tagFragments(context.Background(), m); err == nil {
|
||||
@@ -312,9 +328,19 @@ func (a *App) renderSeed(j *Job, sp DeploySpec, token string) []seedFile {
|
||||
{Path: "etc/cloud/cloud.cfg.d/99-rollcall.cfg", Mode: "0644", Content: "# written by rollcall at deploy time\ndatasource_list: [ NoCloud, None ]\n"},
|
||||
}
|
||||
if a.ca != nil { // both layouts; the bootcmd above picks the one the distribution uses
|
||||
files = append(files,
|
||||
seedFile{Path: "usr/local/share/ca-certificates/rollcall-ca.crt", Mode: "0644", Content: string(a.ca.PEM)},
|
||||
seedFile{Path: "etc/pki/ca-trust/source/anchors/rollcall-ca.crt", Mode: "0644", Content: string(a.ca.PEM)})
|
||||
for i, c := range a.ca.Trust { // one root per file (update-ca-certificates reads one)
|
||||
name := "rollcall-ca.crt"
|
||||
if i > 0 {
|
||||
name = fmt.Sprintf("rollcall-ca-%d.crt", i+1)
|
||||
}
|
||||
pemC := string(pemEncode("CERTIFICATE", c.Raw))
|
||||
files = append(files,
|
||||
seedFile{Path: "usr/local/share/ca-certificates/" + name, Mode: "0644", Content: pemC},
|
||||
seedFile{Path: "etc/pki/ca-trust/source/anchors/" + name, Mode: "0644", Content: pemC})
|
||||
}
|
||||
if t := a.agentTLS(base); t != "" { // a root rotation reaches the machine later on
|
||||
files = append(files, caRefreshFiles(t)...)
|
||||
}
|
||||
}
|
||||
if sp.NetworkConfig != "" {
|
||||
files = append(files, seedFile{Path: "var/lib/cloud/seed/nocloud/network-config", Mode: "0600", Content: sp.NetworkConfig})
|
||||
@@ -468,6 +494,20 @@ func (a *App) handleDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, fmt.Errorf("os is required"))
|
||||
return
|
||||
}
|
||||
req.Hostname = strings.ToLower(req.Hostname)
|
||||
if req.Hostname != "" && !validDomain(req.Hostname) {
|
||||
fail(w, 400, fmt.Errorf("hostname %q is not a host name (letters, digits, hyphens)", req.Hostname))
|
||||
return
|
||||
}
|
||||
// refuse before anything is allocated or renamed
|
||||
if m.Profile != nil {
|
||||
fail(w, 409, fmt.Errorf("machine is a stateless node (profile %s); unassign it first", *m.Profile))
|
||||
return
|
||||
}
|
||||
if j, err := a.store.ActiveJob(r.Context(), m.ID); err == nil {
|
||||
fail(w, 409, fmt.Errorf("job %s (%s, %s) is still active; cancel it first", j.ID, j.Kind, j.State))
|
||||
return
|
||||
}
|
||||
meta, err := osResolve(a.cfg.OSDir, req.OS, req.Version)
|
||||
if err != nil {
|
||||
fail(w, 404, err)
|
||||
@@ -550,6 +590,17 @@ func (a *App) handleRelease(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Wipe == "" {
|
||||
req.Wipe = "quick"
|
||||
}
|
||||
if req.Disk == "" { // the disk the OS went to, not "the smallest one"
|
||||
if jobs, err := a.store.Jobs(r.Context(), m.ID, 50); err == nil {
|
||||
for _, j := range jobs {
|
||||
var sp DeploySpec
|
||||
if j.Kind == "deploy" && j.State == "done" && json.Unmarshal(j.Spec, &sp) == nil && sp.Disk != "" {
|
||||
req.Disk = sp.Disk
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
disk, err := pickDisk(inventoryDisks(m), req.Disk, 0)
|
||||
if err != nil {
|
||||
fail(w, 409, err)
|
||||
@@ -589,6 +640,10 @@ func (a *App) handleJobCancel(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 404, fmt.Errorf("no active job"))
|
||||
return
|
||||
}
|
||||
if j.Kind == "firmware" {
|
||||
fail(w, 409, errors.New("a firmware update cannot be stopped from here once the BMC has it (it would leave the component half-flashed); it ends by itself"))
|
||||
return
|
||||
}
|
||||
_ = a.store.FinishJob(r.Context(), j.ID, "cancelled", "cancelled by operator")
|
||||
a.afterJob(r.Context(), m, j, "cancelled", "cancelled by operator")
|
||||
writeJSON(w, 200, obj{"id": m.ID, "job": j.ID, "state": "cancelled"})
|
||||
@@ -648,16 +703,41 @@ func (a *App) jobFromRequest(w http.ResponseWriter, r *http.Request) (*Job, stri
|
||||
return j, tok
|
||||
}
|
||||
|
||||
// jobMachine checks where the job token came from; nil when refused (answered).
|
||||
func (a *App) jobMachine(w http.ResponseWriter, r *http.Request, j *Job) *Machine {
|
||||
m, err := a.store.Get(r.Context(), j.MachineID)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return nil
|
||||
}
|
||||
if err := a.tokenSource(r.Context(), r, m, j, "job"); err != nil {
|
||||
fail(w, 403, err)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (a *App) handleJobSelf(w http.ResponseWriter, r *http.Request) {
|
||||
j, tok := a.jobFromRequest(w, r)
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
if a.jobMachine(w, r, j) == nil {
|
||||
return
|
||||
}
|
||||
if j.State != "pending" && j.State != "running" {
|
||||
fail(w, 409, fmt.Errorf("job %s is %s", j.ID, j.State))
|
||||
return
|
||||
}
|
||||
sp := j.spec()
|
||||
if a.overTLS(r) { // an agent speaking HTTPS gets the image over HTTPS too
|
||||
if m, err := a.store.Get(r.Context(), j.MachineID); err == nil {
|
||||
base := a.urlFor(m)
|
||||
if t := a.agentTLS(base); t != "" && strings.HasPrefix(sp.ImageURL, base+"/") {
|
||||
sp.ImageURL = t + strings.TrimPrefix(sp.ImageURL, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := obj{"id": j.ID, "machine_id": j.MachineID, "kind": j.Kind, "spec": sp}
|
||||
if j.Kind == "deploy" {
|
||||
out["files"] = a.renderSeed(j, sp, tok)
|
||||
@@ -690,13 +770,12 @@ func (a *App) handleJobProgress(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, obj{"job": j.ID, "state": j.State, "next": "poweroff"})
|
||||
return
|
||||
}
|
||||
prog, _ := json.Marshal(obj{"phase": p.Phase, "pct": p.Pct, "msg": p.Msg, "details": p.Details, "updated": time.Now()})
|
||||
_ = a.store.JobProgress(r.Context(), j.ID, prog)
|
||||
m, err := a.store.Get(r.Context(), j.MachineID)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
m := a.jobMachine(w, r, j)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
prog, _ := json.Marshal(obj{"phase": p.Phase, "pct": p.Pct, "msg": p.Msg, "details": p.Details, "updated": time.Now()})
|
||||
_ = a.store.JobProgress(r.Context(), j.ID, prog)
|
||||
next := "continue"
|
||||
switch p.State {
|
||||
case "done":
|
||||
@@ -725,6 +804,10 @@ func (a *App) handlePhoneHome(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 404, errNotFound)
|
||||
return
|
||||
}
|
||||
if m, err := a.store.Get(r.Context(), j.MachineID); err != nil || a.tokenSource(r.Context(), r, m, nil, "phone-home") != nil {
|
||||
fail(w, 403, errors.New("not from this machine"))
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
_ = a.store.MarkPhoneHome(r.Context(), j.MachineID)
|
||||
detail := fmt.Sprintf("job=%s from %s hostname=%s fqdn=%s", j.ID, r.RemoteAddr, r.Form.Get("hostname"), r.Form.Get("fqdn"))
|
||||
@@ -755,7 +838,7 @@ func (a *App) jobStartTimeout() time.Duration {
|
||||
func staleJobs(active map[string]*Job, cutoff time.Time) []*Job {
|
||||
var out []*Job
|
||||
for _, j := range active {
|
||||
if j.State == "pending" && j.Created.Before(cutoff) {
|
||||
if j.State == "pending" && j.Kind != "firmware" && j.Created.Before(cutoff) { // firmware jobs have their own watch
|
||||
out = append(out, j)
|
||||
}
|
||||
}
|
||||
@@ -775,8 +858,9 @@ func (a *App) jobWatchdog() {
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("the machine did not start the job within %s: check its power, that it PXE-boots on the provisioning network, and its console for a hung POST (events show the power command's result; job_start_timeout raises the limit)", limit)
|
||||
_ = a.store.FinishJob(ctx, j.ID, "failed", msg)
|
||||
a.afterJob(ctx, m, j, "failed", msg)
|
||||
if ok, _ := a.store.FailPendingJob(ctx, j.ID, msg); ok { // not if it reported progress since the snapshot
|
||||
a.afterJob(ctx, m, j, "failed", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
package main
|
||||
|
||||
// DHCP reservations on the site's DHCP server. On a VLAN in proxy mode the
|
||||
// site's server hands out addresses and Rollcall only answers PXE; with a
|
||||
// reservation driver the machine still gets the address Rollcall planned for
|
||||
// it, as a fixed lease (MAC -> IP) on that server (Infoblox's fixedaddress).
|
||||
//
|
||||
// kea ISC Kea with the host_cmds hook (open source since Kea 3.0):
|
||||
// reservation-add/-del/-get-all over the control API; Rollcall's
|
||||
// reservations carry user-context {"rollcall": <machine>}
|
||||
// openwrt rpcd/ubus: "host" sections in /etc/config/dhcp with an
|
||||
// option rollcall '<machine>'
|
||||
//
|
||||
// Reservations Rollcall did not make are never changed or deleted; one in
|
||||
// the way (same MAC or address) is reported as a conflict.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DHCPResConfig struct {
|
||||
Provider string `json:"provider"` // "" | kea | openwrt
|
||||
URL string `json:"url"` // kea: control API (http://kea:8000); openwrt: http(s)://router (rpcd at /ubus)
|
||||
User string `json:"user"`
|
||||
PasswordFile string `json:"password_file"`
|
||||
CAFile string `json:"ca_file"`
|
||||
SyncInterval string `json:"sync_interval"`
|
||||
// kea: write the configuration file after changes (reservations kept in
|
||||
// the server's memory, i.e. no hosts database); default true
|
||||
Persist *bool `json:"persist,omitempty"`
|
||||
}
|
||||
|
||||
// dhcpRes is one fixed lease.
|
||||
type dhcpRes struct {
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Subnet string `json:"subnet"` // CIDR
|
||||
ref string // provider's handle (OpenWrt section)
|
||||
}
|
||||
|
||||
func (r dhcpRes) key() string { return r.Subnet + " " + r.MAC }
|
||||
|
||||
type dhcpResProvider interface {
|
||||
Name() string
|
||||
// Owned: Rollcall's reservations in these subnets, and the MAC/IP pairs others hold.
|
||||
Owned(ctx context.Context, subnets []string) (owned []dhcpRes, foreign []dhcpRes, err error)
|
||||
Put(ctx context.Context, r dhcpRes) error
|
||||
Delete(ctx context.Context, r dhcpRes) error
|
||||
// Done is called after a pass that changed something (Kea: config-write).
|
||||
Done(ctx context.Context) error
|
||||
}
|
||||
|
||||
type dhcpResReport struct {
|
||||
Provider string `json:"provider"`
|
||||
At time.Time `json:"at"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Put []string `json:"put"`
|
||||
Deleted []string `json:"deleted"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Skipped []string `json:"skipped,omitempty"` // addresses without a known MAC
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r *dhcpResReport) fail(e string) { r.Error = joinErr(r.Error, e) }
|
||||
|
||||
type dhcpResState struct {
|
||||
mu sync.Mutex
|
||||
last dhcpResReport
|
||||
kick chan struct{}
|
||||
}
|
||||
|
||||
func newDHCPResProvider(c DHCPResConfig) (dhcpResProvider, error) {
|
||||
if c.Provider == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if c.URL == "" {
|
||||
return nil, errors.New("dhcp_reservations.url is required")
|
||||
}
|
||||
pw := ""
|
||||
if c.PasswordFile != "" {
|
||||
b, err := os.ReadFile(c.PasswordFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dhcp_reservations.password_file: %w", err)
|
||||
}
|
||||
pw = strings.TrimSpace(string(b))
|
||||
}
|
||||
hc, err := httpClientCA(c.CAFile, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dhcp_reservations.ca_file: %w", err)
|
||||
}
|
||||
switch c.Provider {
|
||||
case "kea":
|
||||
return &keaProvider{url: strings.TrimRight(c.URL, "/"), user: c.User, pass: pw, hc: hc, persist: c.Persist == nil || *c.Persist}, nil
|
||||
case "openwrt":
|
||||
return &openwrtProvider{url: strings.TrimRight(c.URL, "/") + "/ubus", user: orDefault(c.User, "root"), pass: pw, hc: hc}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("dhcp_reservations.provider %q: kea or openwrt", c.Provider)
|
||||
}
|
||||
|
||||
func (a *App) startDHCPRes() error {
|
||||
p, err := newDHCPResProvider(a.cfg.DHCPRes)
|
||||
if err != nil || p == nil {
|
||||
return err
|
||||
}
|
||||
a.dhcpRes = p
|
||||
a.dhcpResSt = &dhcpResState{kick: make(chan struct{}, 1)}
|
||||
every := 5 * time.Minute
|
||||
if d, err := time.ParseDuration(a.cfg.DHCPRes.SyncInterval); err == nil && d >= 10*time.Second {
|
||||
every = d
|
||||
}
|
||||
go func() {
|
||||
a.kickDHCPRes()
|
||||
for {
|
||||
select {
|
||||
case <-a.dhcpResSt.kick:
|
||||
case <-time.After(every):
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
rep := a.reconcileDHCPRes(ctx, false)
|
||||
cancel()
|
||||
if rep.Error != "" {
|
||||
log.Printf("dhcp reservations %s: %s", p.Name(), rep.Error)
|
||||
} else if n := len(rep.Put) + len(rep.Deleted) + len(rep.Conflicts); n > 0 {
|
||||
log.Printf("dhcp reservations %s: put %d, deleted %d, conflicts %d %v", p.Name(), len(rep.Put), len(rep.Deleted), len(rep.Conflicts), rep.Conflicts)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("dhcp reservations: %s at %s", p.Name(), a.cfg.DHCPRes.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) kickDHCPRes() {
|
||||
if a.dhcpResSt == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case a.dhcpResSt.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// desiredDHCPRes: every address Rollcall gave a machine on a proxy-mode
|
||||
// subnet, with the MAC of the interface it belongs to.
|
||||
func desiredDHCPRes(ips []IPAddr, subnets []Subnet, machines map[string]*Machine) (map[string]dhcpRes, []string) {
|
||||
want := map[string]dhcpRes{}
|
||||
var skipped []string
|
||||
for _, x := range ips {
|
||||
sn := subnetFor(subnets, x.IP)
|
||||
m := machines[x.MachineID]
|
||||
if sn == nil || sn.DHCPMode != "proxy" || m == nil || len(m.MACs) == 0 {
|
||||
continue
|
||||
}
|
||||
ad, ok := parseAddr(x.IP)
|
||||
if !ok || !ad.Is4() { // DHCPv6 reservations key on DUIDs, not MACs
|
||||
continue
|
||||
}
|
||||
mac := ""
|
||||
if x.IP == deref(m.MgmtIP) || x.Iface == "" {
|
||||
mac = m.MACs[0]
|
||||
}
|
||||
for _, n := range machineNICs(m) {
|
||||
if n.Name == x.Iface && n.MAC != "" {
|
||||
mac = n.MAC
|
||||
}
|
||||
}
|
||||
if mac == "" {
|
||||
skipped = append(skipped, x.IP+" ("+x.Iface+": no single MAC)")
|
||||
continue
|
||||
}
|
||||
r := dhcpRes{MAC: mac, IP: ad.String(), Hostname: deref(m.NodeName), Machine: m.ID, Subnet: sn.CIDR}
|
||||
want[r.key()] = r
|
||||
}
|
||||
return want, skipped
|
||||
}
|
||||
|
||||
func (a *App) reconcileDHCPRes(ctx context.Context, dry bool) dhcpResReport {
|
||||
rep := dhcpResReport{At: time.Now().UTC(), DryRun: dry, Put: []string{}, Deleted: []string{}, Conflicts: []string{}}
|
||||
if a.dhcpRes == nil {
|
||||
rep.Error = "no DHCP reservation provider configured (dhcp_reservations.provider in config.json)"
|
||||
return rep
|
||||
}
|
||||
rep.Provider = a.dhcpRes.Name()
|
||||
a.dhcpResSt.mu.Lock()
|
||||
defer a.dhcpResSt.mu.Unlock()
|
||||
defer func() {
|
||||
if !dry {
|
||||
a.dhcpResSt.last = rep
|
||||
}
|
||||
}()
|
||||
ips, err := a.store.IPs(ctx, "", "")
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
list, err := a.store.List(ctx)
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
machines := map[string]*Machine{}
|
||||
for _, m := range list {
|
||||
machines[m.ID] = m
|
||||
}
|
||||
subnets := a.subnetsSnapshot()
|
||||
want, skipped := desiredDHCPRes(ips, subnets, machines)
|
||||
rep.Skipped = skipped
|
||||
var proxied []string
|
||||
for _, sn := range subnets {
|
||||
if sn.DHCPMode == "proxy" && familyOf(sn.CIDR) == 4 {
|
||||
proxied = append(proxied, sn.CIDR)
|
||||
}
|
||||
}
|
||||
if len(proxied) == 0 {
|
||||
return rep
|
||||
}
|
||||
a.dhcpResWith(ctx, want, proxied, &rep)
|
||||
return rep
|
||||
}
|
||||
|
||||
func (a *App) dhcpResWith(ctx context.Context, want map[string]dhcpRes, subnets []string, rep *dhcpResReport) {
|
||||
owned, foreign, err := a.dhcpRes.Owned(ctx, subnets)
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return
|
||||
}
|
||||
have := map[string]dhcpRes{}
|
||||
for _, o := range owned {
|
||||
have[o.key()] = o
|
||||
}
|
||||
keys := make([]string, 0, len(want))
|
||||
for k := range want {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
changed := false
|
||||
for _, k := range keys {
|
||||
w := want[k]
|
||||
if o, ok := have[k]; ok {
|
||||
delete(have, k)
|
||||
if o.IP == w.IP && o.Hostname == w.Hostname && o.Machine == w.Machine {
|
||||
continue
|
||||
}
|
||||
if rep.DryRun {
|
||||
rep.Put = append(rep.Put, w.MAC+" "+w.IP)
|
||||
continue
|
||||
}
|
||||
if err := a.dhcpRes.Delete(ctx, o); err != nil { // replaced: the old one goes first
|
||||
rep.fail(fmt.Sprintf("replace %s: %v", w.MAC, err))
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
} else if c := clash(foreign, w); c != "" {
|
||||
rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("%s %s: %s", w.MAC, w.IP, c))
|
||||
continue
|
||||
}
|
||||
if rep.DryRun {
|
||||
rep.Put = append(rep.Put, w.MAC+" "+w.IP)
|
||||
continue
|
||||
}
|
||||
if err := a.dhcpRes.Put(ctx, w); err != nil {
|
||||
rep.fail(fmt.Sprintf("put %s %s: %v", w.MAC, w.IP, err))
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
rep.Put = append(rep.Put, w.MAC+" "+w.IP)
|
||||
}
|
||||
gone := make([]dhcpRes, 0, len(have))
|
||||
for _, o := range have {
|
||||
gone = append(gone, o)
|
||||
}
|
||||
sort.Slice(gone, func(i, j int) bool { return gone[i].key() < gone[j].key() })
|
||||
for _, o := range gone {
|
||||
if !rep.DryRun {
|
||||
if err := a.dhcpRes.Delete(ctx, o); err != nil {
|
||||
rep.fail(fmt.Sprintf("delete %s: %v", o.MAC, err))
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
rep.Deleted = append(rep.Deleted, o.MAC+" "+o.IP)
|
||||
}
|
||||
if changed {
|
||||
if err := a.dhcpRes.Done(ctx); err != nil {
|
||||
rep.fail("save: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clash: a reservation someone else holds for this MAC or this address.
|
||||
func clash(foreign []dhcpRes, w dhcpRes) string {
|
||||
for _, f := range foreign {
|
||||
if f.Subnet != w.Subnet {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(f.MAC, w.MAC):
|
||||
return "the MAC is reserved there for " + f.IP + " (not Rollcall's)"
|
||||
case f.IP == w.IP:
|
||||
return "the address is reserved there for " + f.MAC + " (not Rollcall's)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) handleDHCPRes(w http.ResponseWriter, r *http.Request) {
|
||||
if a.dhcpRes == nil {
|
||||
writeJSON(w, 200, obj{"provider": "", "comment": "no DHCP reservation provider (dhcp_reservations in config.json)"})
|
||||
return
|
||||
}
|
||||
a.dhcpResSt.mu.Lock()
|
||||
last := a.dhcpResSt.last
|
||||
a.dhcpResSt.mu.Unlock()
|
||||
writeJSON(w, 200, obj{"provider": a.dhcpRes.Name(), "url": a.cfg.DHCPRes.URL, "last_sync": last})
|
||||
}
|
||||
|
||||
func (a *App) handleDHCPResSync(w http.ResponseWriter, r *http.Request) {
|
||||
if a.dhcpRes == nil {
|
||||
fail(w, 409, errors.New("no DHCP reservation provider configured"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
writeJSON(w, 200, a.reconcileDHCPRes(ctx, r.URL.Query().Get("dry") == "1"))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package main
|
||||
|
||||
// Kea: the control API (Kea 3.0's own HTTP control socket, or the Control
|
||||
// Agent before it) with the host_cmds hook. Subnet ids come from
|
||||
// config-get; reservations carry user-context {"rollcall": <machine>}.
|
||||
// A server with a hosts database keeps them there; one without answers
|
||||
// "Host database not available", and then they go into its running
|
||||
// configuration (operation-target memory), which config-write saves.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type keaProvider struct {
|
||||
url, user, pass string
|
||||
hc *http.Client
|
||||
persist bool
|
||||
service *bool // send "service": ["dhcp4"] (a Control Agent wants it; Kea 3.0's own socket refuses it)
|
||||
memory bool // no hosts database: reservations go into the running configuration
|
||||
}
|
||||
|
||||
func (k *keaProvider) Name() string { return "kea" }
|
||||
|
||||
type keaAnswer struct {
|
||||
Result int `json:"result"`
|
||||
Text string `json:"text"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
// call sends one command; result 0 (success) and 3 (empty) are not errors.
|
||||
func (k *keaProvider) call(ctx context.Context, command string, args any) (*keaAnswer, error) {
|
||||
try := func(service bool) (*keaAnswer, error) {
|
||||
body := map[string]any{"command": command}
|
||||
if args != nil {
|
||||
body["arguments"] = args
|
||||
}
|
||||
if service {
|
||||
body["service"] = []string{"dhcp4"}
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", k.url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if k.user != "" {
|
||||
req.SetBasicAuth(k.user, k.pass)
|
||||
}
|
||||
resp, err := k.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("kea %s: %s %s", command, resp.Status, oneLine(string(data)))
|
||||
}
|
||||
var many []keaAnswer // the Control Agent answers with a list, one per service
|
||||
if json.Unmarshal(data, &many) == nil && len(many) > 0 {
|
||||
return &many[0], nil
|
||||
}
|
||||
var one keaAnswer
|
||||
if err := json.Unmarshal(data, &one); err != nil {
|
||||
return nil, fmt.Errorf("kea %s: %w", command, err)
|
||||
}
|
||||
return &one, nil
|
||||
}
|
||||
if k.service == nil { // learn once whether this endpoint wants "service"
|
||||
ans, err := try(false)
|
||||
if err == nil && !(ans.Result == 1 && strings.Contains(strings.ToLower(ans.Text), "service")) {
|
||||
f := false
|
||||
k.service = &f
|
||||
return ans, nil
|
||||
}
|
||||
t := true
|
||||
k.service = &t
|
||||
}
|
||||
return try(*k.service)
|
||||
}
|
||||
|
||||
func (k *keaProvider) ok(ctx context.Context, command string, args any) (*keaAnswer, error) {
|
||||
ans, err := k.call(ctx, command, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch ans.Result {
|
||||
case 0, 3:
|
||||
return ans, nil
|
||||
case 2:
|
||||
return nil, fmt.Errorf("kea does not know %s (the host_cmds hook is needed: open source since Kea 3.0)", command)
|
||||
}
|
||||
return nil, fmt.Errorf("kea %s: %s", command, ans.Text)
|
||||
}
|
||||
|
||||
// subnetIDs maps CIDR -> Kea subnet id (also inside shared networks).
|
||||
func (k *keaProvider) subnetIDs(ctx context.Context) (map[string]int, error) {
|
||||
ans, err := k.ok(ctx, "config-get", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type sub struct {
|
||||
ID int `json:"id"`
|
||||
Subnet string `json:"subnet"`
|
||||
}
|
||||
var cfg struct {
|
||||
Dhcp4 struct {
|
||||
Subnet4 []sub `json:"subnet4"`
|
||||
SharedNetworks []struct {
|
||||
Subnet4 []sub `json:"subnet4"`
|
||||
} `json:"shared-networks"`
|
||||
} `json:"Dhcp4"`
|
||||
}
|
||||
if err := json.Unmarshal(ans.Arguments, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("kea config-get: %w", err)
|
||||
}
|
||||
out := map[string]int{}
|
||||
add := func(s sub) {
|
||||
if p, err := netip.ParsePrefix(s.Subnet); err == nil {
|
||||
out[p.Masked().String()] = s.ID
|
||||
}
|
||||
}
|
||||
for _, s := range cfg.Dhcp4.Subnet4 {
|
||||
add(s)
|
||||
}
|
||||
for _, n := range cfg.Dhcp4.SharedNetworks {
|
||||
for _, s := range n.Subnet4 {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *keaProvider) Owned(ctx context.Context, subnets []string) ([]dhcpRes, []dhcpRes, error) {
|
||||
ids, err := k.subnetIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var owned, foreign []dhcpRes
|
||||
for _, cidr := range subnets {
|
||||
id, ok := ids[cidr]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("kea serves no subnet %s", cidr)
|
||||
}
|
||||
ans, err := k.ok(ctx, "reservation-get-all", map[string]any{"subnet-id": id})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var got struct {
|
||||
Hosts []struct {
|
||||
HWAddress string `json:"hw-address"`
|
||||
IPAddress string `json:"ip-address"`
|
||||
Hostname string `json:"hostname"`
|
||||
UserContext map[string]any `json:"user-context"`
|
||||
} `json:"hosts"`
|
||||
}
|
||||
_ = json.Unmarshal(ans.Arguments, &got)
|
||||
for _, h := range got.Hosts {
|
||||
r := dhcpRes{MAC: normMAC(h.HWAddress), IP: h.IPAddress, Hostname: h.Hostname, Subnet: cidr}
|
||||
if m, ok := h.UserContext["rollcall"].(string); ok && m != "" {
|
||||
r.Machine = m
|
||||
owned = append(owned, r)
|
||||
} else {
|
||||
foreign = append(foreign, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned, foreign, nil
|
||||
}
|
||||
|
||||
func (k *keaProvider) subnetID(ctx context.Context, cidr string) (int, error) {
|
||||
ids, err := k.subnetIDs(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, ok := ids[cidr]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("kea serves no subnet %s", cidr)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (k *keaProvider) Put(ctx context.Context, r dhcpRes) error {
|
||||
id, err := k.subnetID(ctx, r.Subnet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := map[string]any{"subnet-id": id, "hw-address": r.MAC, "ip-address": r.IP, "user-context": map[string]any{"rollcall": r.Machine}}
|
||||
if r.Hostname != "" {
|
||||
res["hostname"] = r.Hostname
|
||||
}
|
||||
return k.change(ctx, "reservation-add", map[string]any{"reservation": res})
|
||||
}
|
||||
|
||||
// change runs reservation-add/-del against the hosts database, or the
|
||||
// configuration in memory when the server has no hosts database.
|
||||
func (k *keaProvider) change(ctx context.Context, command string, args map[string]any) error {
|
||||
if k.memory {
|
||||
args["operation-target"] = "memory"
|
||||
}
|
||||
_, err := k.ok(ctx, command, args)
|
||||
if err != nil && !k.memory && strings.Contains(err.Error(), "database not available") {
|
||||
k.memory = true
|
||||
args["operation-target"] = "memory"
|
||||
_, err = k.ok(ctx, command, args)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (k *keaProvider) Delete(ctx context.Context, r dhcpRes) error {
|
||||
id, err := k.subnetID(ctx, r.Subnet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return k.change(ctx, "reservation-del", map[string]any{"subnet-id": id, "identifier-type": "hw-address", "identifier": r.MAC})
|
||||
}
|
||||
|
||||
func (k *keaProvider) Done(ctx context.Context) error {
|
||||
if !k.persist || !k.memory { // a hosts database keeps them by itself
|
||||
return nil
|
||||
}
|
||||
if _, err := k.ok(ctx, "config-write", map[string]any{}); err != nil {
|
||||
return errors.New("reservations are in effect but not saved: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
// OpenWrt: static leases are "host" sections of /etc/config/dhcp, reached
|
||||
// through rpcd's ubus JSON-RPC (POST /ubus). Rollcall's carry option
|
||||
// rollcall '<machine>'. The rpcd user needs uci read/write on "dhcp" (and rc
|
||||
// to reload dnsmasq): see the README for the ACL.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type openwrtProvider struct {
|
||||
url, user, pass string
|
||||
hc *http.Client
|
||||
mu sync.Mutex
|
||||
sid string
|
||||
}
|
||||
|
||||
func (o *openwrtProvider) Name() string { return "openwrt" }
|
||||
|
||||
var errUbusDenied = errors.New("ubus: access denied")
|
||||
|
||||
// rpc is one ubus call; it logs in (again) when the session is missing or expired.
|
||||
func (o *openwrtProvider) rpc(ctx context.Context, object, method string, args map[string]any) (json.RawMessage, error) {
|
||||
o.mu.Lock()
|
||||
sid := o.sid
|
||||
o.mu.Unlock()
|
||||
if sid == "" {
|
||||
if err := o.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sid = o.sid
|
||||
}
|
||||
res, err := o.raw(ctx, sid, object, method, args)
|
||||
if errors.Is(err, errUbusDenied) { // expired session
|
||||
if err := o.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err = o.raw(ctx, o.sid, object, method, args)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (o *openwrtProvider) login(ctx context.Context) error {
|
||||
res, err := o.raw(ctx, "00000000000000000000000000000000", "session", "login", map[string]any{"username": o.user, "password": o.pass})
|
||||
if err != nil {
|
||||
return fmt.Errorf("openwrt login: %w", err)
|
||||
}
|
||||
var s struct {
|
||||
SID string `json:"ubus_rpc_session"`
|
||||
}
|
||||
if json.Unmarshal(res, &s) != nil || s.SID == "" {
|
||||
return errors.New("openwrt login: no session")
|
||||
}
|
||||
o.mu.Lock()
|
||||
o.sid = s.SID
|
||||
o.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
var ubusErrors = map[int]string{1: "invalid command", 2: "invalid argument", 3: "method not found", 4: "not found", 5: "no data", 6: "permission denied", 7: "timeout", 8: "not supported", 9: "unknown error", 10: "connection failed"}
|
||||
|
||||
func (o *openwrtProvider) raw(ctx context.Context, sid, object, method string, args map[string]any) (json.RawMessage, error) {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "call", "params": []any{sid, object, method, args}})
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", o.url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := o.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
var out struct {
|
||||
Result []json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, fmt.Errorf("ubus %s %s: %s %s", object, method, resp.Status, oneLine(string(data)))
|
||||
}
|
||||
if out.Error != nil {
|
||||
if out.Error.Code == -32002 {
|
||||
return nil, errUbusDenied
|
||||
}
|
||||
return nil, fmt.Errorf("ubus %s %s: %s", object, method, out.Error.Message)
|
||||
}
|
||||
if len(out.Result) == 0 {
|
||||
return nil, fmt.Errorf("ubus %s %s: empty answer", object, method)
|
||||
}
|
||||
var code int
|
||||
_ = json.Unmarshal(out.Result[0], &code)
|
||||
if code != 0 {
|
||||
if code == 6 {
|
||||
return nil, fmt.Errorf("ubus %s %s: permission denied (rpcd ACL for this user)", object, method)
|
||||
}
|
||||
return nil, fmt.Errorf("ubus %s %s: %s", object, method, orDefault(ubusErrors[code], fmt.Sprint(code)))
|
||||
}
|
||||
if len(out.Result) < 2 {
|
||||
return json.RawMessage("{}"), nil
|
||||
}
|
||||
return out.Result[1], nil
|
||||
}
|
||||
|
||||
func (o *openwrtProvider) Owned(ctx context.Context, subnets []string) ([]dhcpRes, []dhcpRes, error) {
|
||||
res, err := o.rpc(ctx, "uci", "get", map[string]any{"config": "dhcp", "type": "host"})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") { // no host sections yet
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
var got struct {
|
||||
Values map[string]map[string]any `json:"values"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &got); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var prefixes []netip.Prefix
|
||||
for _, s := range subnets {
|
||||
if p, err := netip.ParsePrefix(s); err == nil {
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
}
|
||||
var owned, foreign []dhcpRes
|
||||
for name, sec := range got.Values {
|
||||
ip, _ := sec["ip"].(string)
|
||||
ad, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subnet := ""
|
||||
for _, p := range prefixes {
|
||||
if p.Contains(ad) {
|
||||
subnet = p.Masked().String()
|
||||
}
|
||||
}
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
var mac string
|
||||
switch v := sec["mac"].(type) { // a string, or a list of MACs
|
||||
case string:
|
||||
mac = strings.Fields(v)[0]
|
||||
case []any:
|
||||
if len(v) > 0 {
|
||||
mac, _ = v[0].(string)
|
||||
}
|
||||
}
|
||||
r := dhcpRes{MAC: normMAC(mac), IP: ad.String(), Subnet: subnet, ref: name}
|
||||
r.Hostname, _ = sec["name"].(string)
|
||||
if m, _ := sec["rollcall"].(string); m != "" {
|
||||
r.Machine = m
|
||||
owned = append(owned, r)
|
||||
} else {
|
||||
foreign = append(foreign, r)
|
||||
}
|
||||
}
|
||||
return owned, foreign, nil
|
||||
}
|
||||
|
||||
func (o *openwrtProvider) Put(ctx context.Context, r dhcpRes) error {
|
||||
values := map[string]any{"mac": r.MAC, "ip": r.IP, "rollcall": r.Machine}
|
||||
if r.Hostname != "" {
|
||||
values["name"] = r.Hostname
|
||||
}
|
||||
_, err := o.rpc(ctx, "uci", "add", map[string]any{"config": "dhcp", "type": "host", "values": values})
|
||||
return err
|
||||
}
|
||||
|
||||
func (o *openwrtProvider) Delete(ctx context.Context, r dhcpRes) error {
|
||||
if r.ref == "" {
|
||||
return errors.New("no section to delete")
|
||||
}
|
||||
_, err := o.rpc(ctx, "uci", "delete", map[string]any{"config": "dhcp", "section": r.ref})
|
||||
return err
|
||||
}
|
||||
|
||||
// Done commits /etc/config/dhcp and reloads dnsmasq.
|
||||
func (o *openwrtProvider) Done(ctx context.Context) error {
|
||||
if _, err := o.rpc(ctx, "uci", "commit", map[string]any{"config": "dhcp"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := o.rpc(ctx, "rc", "init", map[string]any{"name": "dnsmasq", "action": "reload"}); err != nil {
|
||||
return fmt.Errorf("committed, but dnsmasq not reloaded: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDesiredDHCPRes(t *testing.T) {
|
||||
inv, _ := json.Marshal(obj{"network": []obj{{"name": "eth0", "mac": "52:54:00:00:00:01"}, {"name": "eth1", "mac": "52:54:00:00:00:02"}}})
|
||||
mg := "10.214.0.50"
|
||||
name := "fresh01"
|
||||
m := &Machine{ID: "m-1", MACs: []string{"52:54:00:00:00:01", "52:54:00:00:00:02"}, MgmtIP: &mg, NodeName: &name, Inventory: inv}
|
||||
subnets := []Subnet{{CIDR: "10.214.0.0/24", DHCPMode: "proxy"}, {CIDR: "10.215.0.0/24", DHCPMode: "proxy"}, {CIDR: "10.216.0.0/24"}, {CIDR: "fd00:214::/64", DHCPMode: "proxy"}}
|
||||
ips := []IPAddr{
|
||||
{IP: "10.214.0.50", MachineID: "m-1", Iface: "eth0"},
|
||||
{IP: "10.215.0.50", MachineID: "m-1", Iface: "eth1"},
|
||||
{IP: "10.216.0.50", MachineID: "m-1", Iface: "eth1"}, // served by Rollcall itself: no reservation
|
||||
{IP: "fd00:214::50", MachineID: "m-1", Iface: "eth0"}, // DHCPv6 keys on DUIDs
|
||||
{IP: "10.215.0.60", MachineID: "m-1", Iface: "bond0"}, // no single MAC
|
||||
{IP: "10.214.0.70", MachineID: "", Kind: "reserved"}, // not a machine's
|
||||
}
|
||||
want, skipped := desiredDHCPRes(ips, subnets, map[string]*Machine{"m-1": m})
|
||||
var got []string
|
||||
for _, r := range want {
|
||||
got = append(got, r.MAC+"="+r.IP+"/"+r.Hostname)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if strings.Join(got, " ") != "52:54:00:00:00:01=10.214.0.50/fresh01 52:54:00:00:00:02=10.215.0.50/fresh01" || len(skipped) != 1 {
|
||||
t.Errorf("want %v skipped %v", got, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKea answers the control API like Kea 3.0's own socket (no "service")
|
||||
// or a Control Agent (wants "service", answers with a list).
|
||||
type fakeKea struct {
|
||||
mu sync.Mutex
|
||||
agent bool
|
||||
noHooks bool
|
||||
noHostDB bool // like Kea with a configuration file only: add/del need operation-target memory
|
||||
hosts []obj
|
||||
commands []string
|
||||
written int
|
||||
}
|
||||
|
||||
func (f *fakeKea) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if u, p, ok := r.BasicAuth(); !ok || u != "rollcall" || p != "pw" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
Service []string `json:"service"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
f.commands = append(f.commands, req.Command)
|
||||
answer := func(o obj) {
|
||||
if f.agent {
|
||||
_ = json.NewEncoder(w).Encode([]obj{o})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(o)
|
||||
}
|
||||
if f.agent && len(req.Service) == 0 {
|
||||
answer(obj{"result": 1, "text": "service not specified"})
|
||||
return
|
||||
}
|
||||
if !f.agent && len(req.Service) > 0 {
|
||||
answer(obj{"result": 1, "text": "'service' parameter is not supported here"})
|
||||
return
|
||||
}
|
||||
var args obj
|
||||
_ = json.Unmarshal(req.Arguments, &args)
|
||||
switch req.Command {
|
||||
case "config-get":
|
||||
answer(obj{"result": 0, "arguments": obj{"Dhcp4": obj{"subnet4": []obj{{"id": 7, "subnet": "10.214.0.0/24"}}}}})
|
||||
case "reservation-get-all":
|
||||
if f.noHooks {
|
||||
answer(obj{"result": 2, "text": "'reservation-get-all' command not supported."})
|
||||
return
|
||||
}
|
||||
answer(obj{"result": 0, "arguments": obj{"hosts": f.hosts}})
|
||||
case "reservation-add", "reservation-del":
|
||||
if f.noHostDB && args["operation-target"] != "memory" {
|
||||
answer(obj{"result": 1, "text": "Host database not available, cannot add host."})
|
||||
return
|
||||
}
|
||||
}
|
||||
switch req.Command {
|
||||
case "reservation-add":
|
||||
res := args["reservation"].(map[string]any)
|
||||
for _, h := range f.hosts {
|
||||
if h["hw-address"] == res["hw-address"] || h["ip-address"] == res["ip-address"] {
|
||||
answer(obj{"result": 1, "text": "Host already exists."})
|
||||
return
|
||||
}
|
||||
}
|
||||
f.hosts = append(f.hosts, res)
|
||||
answer(obj{"result": 0, "text": "Host added."})
|
||||
case "reservation-del":
|
||||
for i, h := range f.hosts {
|
||||
if h["hw-address"] == args["identifier"] {
|
||||
f.hosts = append(f.hosts[:i], f.hosts[i+1:]...)
|
||||
answer(obj{"result": 0, "text": "Host deleted."})
|
||||
return
|
||||
}
|
||||
}
|
||||
answer(obj{"result": 3, "text": "Host not deleted (not found)."})
|
||||
case "config-write":
|
||||
f.written++
|
||||
answer(obj{"result": 0})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func keaFor(t *testing.T, f *fakeKea) (*keaProvider, func()) {
|
||||
srv := httptest.NewServer(f.handler())
|
||||
return &keaProvider{url: srv.URL, user: "rollcall", pass: "pw", hc: srv.Client(), persist: true}, srv.Close
|
||||
}
|
||||
|
||||
func TestKeaReservations(t *testing.T) {
|
||||
for _, agent := range []bool{false, true} {
|
||||
f := &fakeKea{agent: agent, noHostDB: agent, hosts: []obj{
|
||||
{"hw-address": "52:54:00:aa:aa:aa", "ip-address": "10.214.0.9", "hostname": "printer"}, // someone else's
|
||||
{"hw-address": "52:54:00:00:00:09", "ip-address": "10.214.0.99", "user-context": obj{"rollcall": "m-old"}},
|
||||
}}
|
||||
k, done := keaFor(t, f)
|
||||
a := &App{dhcpRes: k, dhcpResSt: &dhcpResState{}}
|
||||
want := map[string]dhcpRes{}
|
||||
for _, r := range []dhcpRes{
|
||||
{MAC: "52:54:00:00:00:01", IP: "10.214.0.50", Hostname: "fresh01", Machine: "m-1", Subnet: "10.214.0.0/24"},
|
||||
{MAC: "52:54:00:00:00:02", IP: "10.214.0.9", Machine: "m-2", Subnet: "10.214.0.0/24"}, // the printer's address
|
||||
} {
|
||||
want[r.key()] = r
|
||||
}
|
||||
rep := dhcpResReport{}
|
||||
a.dhcpResWith(context.Background(), want, []string{"10.214.0.0/24"}, &rep)
|
||||
if rep.Error != "" || strings.Join(rep.Put, ",") != "52:54:00:00:00:01 10.214.0.50" || len(rep.Conflicts) != 1 || strings.Join(rep.Deleted, ",") != "52:54:00:00:00:09 10.214.0.99" {
|
||||
t.Errorf("agent=%v report %+v", agent, rep)
|
||||
}
|
||||
var added obj
|
||||
for _, h := range f.hosts {
|
||||
if h["hw-address"] == "52:54:00:00:00:01" {
|
||||
added = h
|
||||
}
|
||||
}
|
||||
wantWrites := 0 // a hosts database keeps reservations by itself
|
||||
if f.noHostDB {
|
||||
wantWrites = 1 // in memory: config-write saves them
|
||||
}
|
||||
if dig(added, "user-context", "rollcall") != "m-1" || added["hostname"] != "fresh01" || added["subnet-id"] != float64(7) || f.written != wantWrites {
|
||||
t.Errorf("agent=%v added %v written %d", agent, added, f.written)
|
||||
}
|
||||
// a second pass changes nothing
|
||||
rep = dhcpResReport{}
|
||||
a.dhcpResWith(context.Background(), want, []string{"10.214.0.0/24"}, &rep)
|
||||
if len(rep.Put)+len(rep.Deleted) != 0 || f.written != wantWrites {
|
||||
t.Errorf("agent=%v second pass %+v", agent, rep)
|
||||
}
|
||||
done()
|
||||
}
|
||||
f := &fakeKea{noHooks: true}
|
||||
k, done := keaFor(t, f)
|
||||
defer done()
|
||||
if _, _, err := k.Owned(context.Background(), []string{"10.214.0.0/24"}); err == nil || !strings.Contains(err.Error(), "host_cmds") {
|
||||
t.Errorf("without host_cmds: %v", err)
|
||||
}
|
||||
if _, _, err := k.Owned(context.Background(), []string{"10.99.0.0/24"}); err == nil || !strings.Contains(err.Error(), "no subnet") {
|
||||
t.Errorf("unknown subnet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeUbus is rpcd's JSON-RPC with uci on "dhcp" and rc.
|
||||
type fakeUbus struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]bool
|
||||
sections map[string]map[string]any
|
||||
n int
|
||||
commits int
|
||||
reloads int
|
||||
logins int
|
||||
}
|
||||
|
||||
func (f *fakeUbus) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
Params []json.RawMessage `json:"params"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &req)
|
||||
var sid, object, method string
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal(req.Params[0], &sid)
|
||||
_ = json.Unmarshal(req.Params[1], &object)
|
||||
_ = json.Unmarshal(req.Params[2], &method)
|
||||
_ = json.Unmarshal(req.Params[3], &args)
|
||||
reply := func(v ...any) { _ = json.NewEncoder(w).Encode(obj{"jsonrpc": "2.0", "id": 1, "result": v}) }
|
||||
if object == "session" && method == "login" {
|
||||
if args["username"] != "rollcall" || args["password"] != "pw" {
|
||||
reply(6)
|
||||
return
|
||||
}
|
||||
f.logins++
|
||||
s := "s" + strings.Repeat("x", f.logins)
|
||||
f.sessions[s] = true
|
||||
reply(0, obj{"ubus_rpc_session": s})
|
||||
return
|
||||
}
|
||||
if !f.sessions[sid] {
|
||||
_ = json.NewEncoder(w).Encode(obj{"jsonrpc": "2.0", "id": 1, "error": obj{"code": -32002, "message": "Access denied"}})
|
||||
return
|
||||
}
|
||||
switch object + "." + method {
|
||||
case "uci.get":
|
||||
vals := obj{}
|
||||
for k, v := range f.sections {
|
||||
vals[k] = v
|
||||
}
|
||||
reply(0, obj{"values": vals})
|
||||
case "uci.add":
|
||||
f.n++
|
||||
name := "cfg" + strings.Repeat("0", f.n)
|
||||
sec := map[string]any{".type": "host"}
|
||||
for k, v := range args["values"].(map[string]any) {
|
||||
sec[k] = v
|
||||
}
|
||||
f.sections[name] = sec
|
||||
reply(0, obj{"section": name})
|
||||
case "uci.delete":
|
||||
delete(f.sections, args["section"].(string))
|
||||
reply(0)
|
||||
case "uci.commit":
|
||||
f.commits++
|
||||
reply(0)
|
||||
case "rc.init":
|
||||
f.reloads++
|
||||
reply(0)
|
||||
default:
|
||||
reply(3)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenWrtReservations(t *testing.T) {
|
||||
f := &fakeUbus{sessions: map[string]bool{}, sections: map[string]map[string]any{
|
||||
"lan_printer": {".type": "host", "name": "printer", "mac": "52:54:00:aa:aa:aa", "ip": "10.214.0.9"},
|
||||
"other_net": {".type": "host", "mac": []any{"52:54:00:bb:bb:bb"}, "ip": "192.168.9.9"},
|
||||
}}
|
||||
srv := httptest.NewServer(f.handler())
|
||||
defer srv.Close()
|
||||
o := &openwrtProvider{url: srv.URL + "/ubus", user: "rollcall", pass: "pw", hc: srv.Client()}
|
||||
a := &App{dhcpRes: o, dhcpResSt: &dhcpResState{}}
|
||||
want := map[string]dhcpRes{}
|
||||
r := dhcpRes{MAC: "52:54:00:00:00:01", IP: "10.214.0.50", Hostname: "fresh01", Machine: "m-1", Subnet: "10.214.0.0/24"}
|
||||
want[r.key()] = r
|
||||
rep := dhcpResReport{}
|
||||
a.dhcpResWith(context.Background(), want, []string{"10.214.0.0/24"}, &rep)
|
||||
if rep.Error != "" || len(rep.Put) != 1 || f.commits != 1 || f.reloads != 1 {
|
||||
t.Fatalf("report %+v commits %d reloads %d", rep, f.commits, f.reloads)
|
||||
}
|
||||
var mine map[string]any
|
||||
for _, s := range f.sections {
|
||||
if s["rollcall"] == "m-1" {
|
||||
mine = s
|
||||
}
|
||||
}
|
||||
if mine == nil || mine["ip"] != "10.214.0.50" || mine["name"] != "fresh01" {
|
||||
t.Errorf("sections %v", f.sections)
|
||||
}
|
||||
// session expired: logs in again; the machine goes away: its section is deleted, the printer stays
|
||||
f.sessions = map[string]bool{}
|
||||
rep = dhcpResReport{}
|
||||
a.dhcpResWith(context.Background(), map[string]dhcpRes{}, []string{"10.214.0.0/24"}, &rep)
|
||||
if rep.Error != "" || len(rep.Deleted) != 1 || f.logins != 2 || f.sections["lan_printer"] == nil || f.sections["other_net"] == nil {
|
||||
t.Errorf("report %+v logins %d sections %v", rep, f.logins, f.sections)
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func discoSuggest(d discoIface) string {
|
||||
case v4 == "":
|
||||
return "link up, no IPv4 address" + vlan + ": give it its address on the provisioning network, then bootstrap.sh --iface " + d.Name
|
||||
case foreign > 0:
|
||||
return v4 + " already has DHCP (" + strings.Join(d.DHCP, ",") + "): either the network team turns it off for this VLAN, or keep it and point it at this host (next-server " + strings.Split(d.Addrs[0], "/")[0] + ", or a DHCP relay); then bootstrap.sh --iface " + d.Name + " --no-dhcp"
|
||||
return v4 + " already has DHCP (" + strings.Join(d.DHCP, ",") + "): keep it and answer only PXE from here - bootstrap.sh --iface " + d.Name + " --proxy-dhcp - or the network team turns it off for this VLAN"
|
||||
default:
|
||||
return "candidate provisioning network " + v4 + vlan + ": bootstrap.sh --iface " + d.Name + " (or Network -> New subnet in the web UI)"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
package main
|
||||
|
||||
// DNS provider: Rollcall writes its machines' A/AAAA records into the site's
|
||||
// DNS itself - for sites without an IPAM authority whose DNS sync would do it.
|
||||
//
|
||||
// - powerdns: the HTTP API; Rollcall's RRsets carry a comment with
|
||||
// account "rollcall" and only those are changed or deleted.
|
||||
// - rfc2136: dynamic update with TSIG (BIND, Infoblox, Windows DNS,
|
||||
// PowerDNS, Knot). Next to each name Rollcall writes a marker TXT at
|
||||
// _rollcall.<name>; every change of an existing record requires that
|
||||
// marker (a prerequisite), a new record requires the name to be free,
|
||||
// so a record someone else owns is never overwritten. What Rollcall
|
||||
// wrote is kept in dns_records, since a zone cannot be listed; each
|
||||
// sync queries those records, so changes made behind its back are seen.
|
||||
//
|
||||
// Names follow the IPAM rule: <host>.<zone> for a machine's main address,
|
||||
// <host>-<iface>.<zone> elsewhere.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
type DNSConfig struct {
|
||||
Provider string `json:"provider"` // "" | powerdns | rfc2136
|
||||
Zone string `json:"zone"` // e.g. int.example.com
|
||||
TTL int `json:"ttl"` // default 300
|
||||
SyncInterval string `json:"sync_interval"`
|
||||
// powerdns
|
||||
URL string `json:"url"` // http://dns:8081 (server "localhost")
|
||||
APIKeyFile string `json:"api_key_file"` // X-API-Key
|
||||
// rfc2136
|
||||
Server string `json:"server"` // host:53
|
||||
TSIGName string `json:"tsig_name"` // key name
|
||||
TSIGAlgorithm string `json:"tsig_algorithm"` // hmac-sha256 (default), hmac-sha512 ...
|
||||
TSIGSecretFile string `json:"tsig_secret_file"` // base64 secret
|
||||
}
|
||||
|
||||
const dnsMarkerPrefix = "_rollcall."
|
||||
const dnsMarkerValue = "heritage=rollcall"
|
||||
|
||||
type dnsRecord struct {
|
||||
Name string `json:"name"` // fqdn with the trailing dot
|
||||
Type string `json:"type"` // A | AAAA
|
||||
Values []string `json:"values"`
|
||||
}
|
||||
|
||||
func (r dnsRecord) key() string { return r.Name + " " + r.Type }
|
||||
|
||||
type dnsReport struct {
|
||||
Provider string `json:"provider"`
|
||||
Zone string `json:"zone"`
|
||||
At time.Time `json:"at"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Upserted []string `json:"upserted"`
|
||||
Deleted []string `json:"deleted"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r *dnsReport) fail(e string) { r.Error = joinErr(r.Error, e) }
|
||||
|
||||
type dnsProvider interface {
|
||||
Name() string
|
||||
// Owned: Rollcall's records in the zone, and the names+types someone else holds.
|
||||
Owned(ctx context.Context) (owned map[string]dnsRecord, foreign map[string]bool, err error)
|
||||
// Upsert writes one RRset; conflict when the provider refuses because it is not ours.
|
||||
Upsert(ctx context.Context, r dnsRecord, existed bool) (conflict bool, err error)
|
||||
Delete(ctx context.Context, r dnsRecord) error
|
||||
}
|
||||
|
||||
type dnsState struct {
|
||||
mu sync.Mutex
|
||||
kick chan struct{}
|
||||
last dnsReport
|
||||
}
|
||||
|
||||
func (c DNSConfig) zone() string { return dns.Fqdn(strings.ToLower(strings.TrimSuffix(c.Zone, "."))) }
|
||||
func (c DNSConfig) ttl() int {
|
||||
if c.TTL > 0 {
|
||||
return c.TTL
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
func newDNSProvider(c DNSConfig, st *Store) (dnsProvider, error) {
|
||||
if c.Provider == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if c.Zone == "" {
|
||||
return nil, errors.New("dns.zone is required")
|
||||
}
|
||||
switch c.Provider {
|
||||
case "powerdns":
|
||||
key, err := os.ReadFile(c.APIKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns.api_key_file: %w", err)
|
||||
}
|
||||
if c.URL == "" {
|
||||
return nil, errors.New("dns.url is required")
|
||||
}
|
||||
return &pdnsProvider{base: strings.TrimRight(c.URL, "/") + "/api/v1/servers/localhost", key: strings.TrimSpace(string(key)),
|
||||
zone: c.zone(), ttl: c.ttl(), hc: &http.Client{Timeout: 30 * time.Second}}, nil
|
||||
case "rfc2136":
|
||||
sec, err := os.ReadFile(c.TSIGSecretFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns.tsig_secret_file: %w", err)
|
||||
}
|
||||
alg := strings.ToLower(orDefault(c.TSIGAlgorithm, "hmac-sha256"))
|
||||
if !strings.HasSuffix(alg, ".") {
|
||||
alg += "."
|
||||
}
|
||||
if c.Server == "" || c.TSIGName == "" {
|
||||
return nil, errors.New("dns.server and dns.tsig_name are required")
|
||||
}
|
||||
return &rfc2136Provider{server: c.Server, zone: c.zone(), ttl: c.ttl(), keyName: dns.Fqdn(c.TSIGName), alg: alg,
|
||||
secret: strings.TrimSpace(string(sec)), store: st}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("dns.provider %q: powerdns or rfc2136", c.Provider)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- App side
|
||||
|
||||
func (a *App) startDNS() error {
|
||||
p, err := newDNSProvider(a.cfg.DNS, a.store)
|
||||
if err != nil || p == nil {
|
||||
return err
|
||||
}
|
||||
a.dnsp = p
|
||||
a.dnsSt = &dnsState{kick: make(chan struct{}, 1)}
|
||||
every := 5 * time.Minute
|
||||
if d, err := time.ParseDuration(a.cfg.DNS.SyncInterval); err == nil && d >= 10*time.Second {
|
||||
every = d
|
||||
}
|
||||
go func() {
|
||||
a.kickDNS()
|
||||
for {
|
||||
select {
|
||||
case <-a.dnsSt.kick:
|
||||
case <-time.After(every):
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
rep := a.reconcileDNS(ctx, false)
|
||||
cancel()
|
||||
if rep.Error != "" {
|
||||
log.Printf("dns %s: %s", p.Name(), rep.Error)
|
||||
} else if n := len(rep.Upserted) + len(rep.Deleted) + len(rep.Conflicts); n > 0 {
|
||||
log.Printf("dns %s: wrote %d, deleted %d, conflicts %d %v", p.Name(), len(rep.Upserted), len(rep.Deleted), len(rep.Conflicts), rep.Conflicts)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("dns: %s for zone %s", p.Name(), a.cfg.DNS.zone())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) kickDNS() {
|
||||
if a.dnsSt == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case a.dnsSt.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// desiredDNS groups Rollcall's addresses into RRsets inside the zone.
|
||||
func desiredDNS(local []IPAddr, subnets []Subnet, zone string) map[string]dnsRecord {
|
||||
want := map[string]dnsRecord{}
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
for _, x := range local {
|
||||
name := dnsNameIn(x, subnetByRef(subnets, x.Subnet), z)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
fq := dns.Fqdn(name)
|
||||
if !dns.IsSubDomain(dns.Fqdn(z), fq) {
|
||||
continue
|
||||
}
|
||||
ip, ok := parseAddr(x.IP)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t := "A"
|
||||
if ip.Is6() {
|
||||
t = "AAAA"
|
||||
}
|
||||
r := want[fq+" "+t]
|
||||
r.Name, r.Type = fq, t
|
||||
if !hasTag(r.Values, ip.String()) {
|
||||
r.Values = append(r.Values, ip.String())
|
||||
}
|
||||
sort.Strings(r.Values)
|
||||
want[r.key()] = r
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
func (a *App) reconcileDNS(ctx context.Context, dry bool) dnsReport {
|
||||
rep := dnsReport{At: time.Now().UTC(), DryRun: dry, Upserted: []string{}, Deleted: []string{}, Conflicts: []string{}}
|
||||
if a.dnsp == nil {
|
||||
rep.Error = "no DNS provider configured (dns.provider in config.json)"
|
||||
return rep
|
||||
}
|
||||
rep.Provider, rep.Zone = a.dnsp.Name(), a.cfg.DNS.zone()
|
||||
a.dnsSt.mu.Lock()
|
||||
defer a.dnsSt.mu.Unlock()
|
||||
defer func() {
|
||||
if !dry {
|
||||
a.dnsSt.last = rep
|
||||
}
|
||||
}()
|
||||
local, err := a.store.IPs(ctx, "", "")
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
a.reconcileDNSWith(ctx, desiredDNS(local, a.subnetsSnapshot(), a.cfg.DNS.zone()), &rep)
|
||||
return rep
|
||||
}
|
||||
|
||||
func (a *App) reconcileDNSWith(ctx context.Context, want map[string]dnsRecord, rep *dnsReport) {
|
||||
owned, foreign, err := a.dnsp.Owned(ctx)
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(want))
|
||||
for k := range want {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
w := want[k]
|
||||
o, mine := owned[k]
|
||||
if mine && strings.Join(o.Values, ",") == strings.Join(w.Values, ",") {
|
||||
continue
|
||||
}
|
||||
if !mine && foreign[k] {
|
||||
rep.Conflicts = append(rep.Conflicts, strings.TrimSuffix(k, " A")+" exists and is not Rollcall's")
|
||||
continue
|
||||
}
|
||||
if rep.DryRun {
|
||||
rep.Upserted = append(rep.Upserted, k)
|
||||
continue
|
||||
}
|
||||
conflict, err := a.dnsp.Upsert(ctx, w, mine)
|
||||
switch {
|
||||
case err != nil:
|
||||
rep.fail(fmt.Sprintf("%s: %v", k, err))
|
||||
continue
|
||||
case conflict:
|
||||
rep.Conflicts = append(rep.Conflicts, k+" exists and is not Rollcall's")
|
||||
default:
|
||||
rep.Upserted = append(rep.Upserted, k)
|
||||
}
|
||||
}
|
||||
var gone []string
|
||||
for k := range owned {
|
||||
if _, ok := want[k]; !ok {
|
||||
gone = append(gone, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(gone)
|
||||
for _, k := range gone {
|
||||
rep.Deleted = append(rep.Deleted, k)
|
||||
if !rep.DryRun {
|
||||
if err := a.dnsp.Delete(ctx, owned[k]); err != nil {
|
||||
rep.fail(fmt.Sprintf("delete %s: %v", k, err))
|
||||
rep.Deleted = rep.Deleted[:len(rep.Deleted)-1]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleDNS(w http.ResponseWriter, r *http.Request) {
|
||||
if a.dnsp == nil {
|
||||
writeJSON(w, 200, obj{"provider": "", "comment": "no DNS provider (an IPAM authority's DNS sync, or dnsmasq host files only)"})
|
||||
return
|
||||
}
|
||||
a.dnsSt.mu.Lock()
|
||||
last := a.dnsSt.last
|
||||
a.dnsSt.mu.Unlock()
|
||||
writeJSON(w, 200, obj{"provider": a.dnsp.Name(), "zone": a.cfg.DNS.zone(), "last_sync": last})
|
||||
}
|
||||
|
||||
func (a *App) handleDNSSync(w http.ResponseWriter, r *http.Request) {
|
||||
rep := a.reconcileDNS(r.Context(), r.URL.Query().Get("dry") != "")
|
||||
code := 200
|
||||
if rep.Error != "" {
|
||||
code = 502
|
||||
if a.dnsp == nil {
|
||||
code = 400
|
||||
}
|
||||
}
|
||||
writeJSON(w, code, rep)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PowerDNS
|
||||
|
||||
type pdnsProvider struct {
|
||||
base, key, zone string
|
||||
ttl int
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func (p *pdnsProvider) Name() string { return "powerdns" }
|
||||
|
||||
func (p *pdnsProvider) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-API-Key", p.key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, trunc(strings.TrimSpace(string(b)), 300))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pdnsProvider) Owned(ctx context.Context) (map[string]dnsRecord, map[string]bool, error) {
|
||||
var z struct {
|
||||
RRsets []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Records []struct{ Content string } `json:"records"`
|
||||
Comments []struct{ Account string } `json:"comments"`
|
||||
} `json:"rrsets"`
|
||||
}
|
||||
if err := p.do(ctx, "GET", "/zones/"+url.PathEscape(p.zone), nil, &z); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
owned, foreign := map[string]dnsRecord{}, map[string]bool{}
|
||||
for _, rr := range z.RRsets {
|
||||
if rr.Type != "A" && rr.Type != "AAAA" {
|
||||
continue
|
||||
}
|
||||
r := dnsRecord{Name: strings.ToLower(rr.Name), Type: rr.Type}
|
||||
for _, x := range rr.Records {
|
||||
r.Values = append(r.Values, x.Content)
|
||||
}
|
||||
sort.Strings(r.Values)
|
||||
mine := false
|
||||
for _, c := range rr.Comments {
|
||||
mine = mine || c.Account == "rollcall"
|
||||
}
|
||||
if mine {
|
||||
owned[r.key()] = r
|
||||
} else {
|
||||
foreign[r.key()] = true
|
||||
}
|
||||
}
|
||||
return owned, foreign, nil
|
||||
}
|
||||
|
||||
func (p *pdnsProvider) Upsert(ctx context.Context, r dnsRecord, _ bool) (bool, error) {
|
||||
recs := []obj{}
|
||||
for _, v := range r.Values {
|
||||
recs = append(recs, obj{"content": v, "disabled": false})
|
||||
}
|
||||
return false, p.do(ctx, "PATCH", "/zones/"+url.PathEscape(p.zone), obj{"rrsets": []obj{{"name": r.Name, "type": r.Type, "ttl": p.ttl,
|
||||
"changetype": "REPLACE", "records": recs, "comments": []obj{{"content": "rollcall", "account": "rollcall"}}}}}, nil)
|
||||
}
|
||||
|
||||
func (p *pdnsProvider) Delete(ctx context.Context, r dnsRecord) error {
|
||||
return p.do(ctx, "PATCH", "/zones/"+url.PathEscape(p.zone), obj{"rrsets": []obj{{"name": r.Name, "type": r.Type, "changetype": "DELETE"}}}, nil)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RFC 2136
|
||||
|
||||
const dnsRecordsSchema = `
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
zone text NOT NULL,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
vals jsonb NOT NULL,
|
||||
updated timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (zone, name, type)
|
||||
);
|
||||
`
|
||||
|
||||
type rfc2136Provider struct {
|
||||
server, zone, keyName, alg, secret string
|
||||
ttl int
|
||||
store *Store
|
||||
}
|
||||
|
||||
func (p *rfc2136Provider) Name() string { return "rfc2136" }
|
||||
|
||||
func (p *rfc2136Provider) Owned(ctx context.Context) (map[string]dnsRecord, map[string]bool, error) {
|
||||
rows, err := p.store.pool.Query(ctx, `SELECT name, type, vals FROM dns_records WHERE zone=$1`, p.zone)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
owned := map[string]dnsRecord{}
|
||||
for rows.Next() {
|
||||
var r dnsRecord
|
||||
var vals []byte
|
||||
if err := rows.Scan(&r.Name, &r.Type, &vals); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_ = json.Unmarshal(vals, &r.Values)
|
||||
owned[r.key()] = r
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows.Close()
|
||||
// the table says what we wrote; the server says what is there now. A record
|
||||
// changed or a marker removed behind our back shows up as a difference, and
|
||||
// the next Upsert either repairs it or finds someone else's record (conflict).
|
||||
for k, r := range owned {
|
||||
live, err := p.lookup(ctx, r.Name, r.Type)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
marker, err := p.lookup(ctx, dnsMarkerPrefix+r.Name, "TXT")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !slices.Contains(marker, strconv.Quote(dnsMarkerValue)) {
|
||||
live = nil
|
||||
}
|
||||
r.Values = live
|
||||
owned[k] = r
|
||||
}
|
||||
return owned, map[string]bool{}, nil // foreign names show up as refused updates
|
||||
}
|
||||
|
||||
// lookup asks the primary for one RRset; it returns the values, sorted, nil if none.
|
||||
func (p *rfc2136Provider) lookup(ctx context.Context, name, typ string) ([]string, error) {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(name, dns.StringToType[typ])
|
||||
m.SetTsig(p.keyName, p.alg, 300, time.Now().Unix())
|
||||
c := &dns.Client{Net: "tcp", Timeout: 10 * time.Second, TsigSecret: map[string]string{p.keyName: p.secret}}
|
||||
r, _, err := c.ExchangeContext(ctx, m, p.server)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query %s %s: %w", name, typ, err)
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess && r.Rcode != dns.RcodeNameError {
|
||||
return nil, fmt.Errorf("query %s %s: %s", name, typ, dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range r.Answer {
|
||||
if rr.Header().Rrtype == dns.StringToType[typ] && strings.EqualFold(rr.Header().Name, name) {
|
||||
out = append(out, strings.TrimPrefix(rr.String(), rr.Header().String()))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *rfc2136Provider) marker(name string) dns.RR {
|
||||
rr, _ := dns.NewRR(fmt.Sprintf("%s%s %d IN TXT %q", dnsMarkerPrefix, name, p.ttl, dnsMarkerValue))
|
||||
return rr
|
||||
}
|
||||
|
||||
func (p *rfc2136Provider) rrs(r dnsRecord) []dns.RR {
|
||||
var out []dns.RR
|
||||
for _, v := range r.Values {
|
||||
if rr, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", r.Name, p.ttl, r.Type, v)); err == nil {
|
||||
out = append(out, rr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// send signs and sends an update; it returns the response code.
|
||||
func (p *rfc2136Provider) send(m *dns.Msg) (int, error) {
|
||||
m.SetTsig(p.keyName, p.alg, 300, time.Now().Unix())
|
||||
c := &dns.Client{Net: "tcp", Timeout: 10 * time.Second, TsigSecret: map[string]string{p.keyName: p.secret}}
|
||||
r, _, err := c.Exchange(m, p.server)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.Rcode, nil
|
||||
}
|
||||
|
||||
func (p *rfc2136Provider) Upsert(ctx context.Context, r dnsRecord, existed bool) (bool, error) {
|
||||
conflict, err := p.upsert(ctx, r, existed)
|
||||
if conflict && existed && err == nil {
|
||||
// our marker is gone: if the name is free now, write it again as new;
|
||||
// if someone else's record is there, that is a real conflict
|
||||
return p.upsert(ctx, r, false)
|
||||
}
|
||||
return conflict, err
|
||||
}
|
||||
|
||||
func (p *rfc2136Provider) upsert(ctx context.Context, r dnsRecord, existed bool) (bool, error) {
|
||||
m := new(dns.Msg)
|
||||
m.SetUpdate(p.zone)
|
||||
set := &dns.ANY{Hdr: dns.RR_Header{Name: r.Name, Rrtype: dns.StringToType[r.Type], Class: dns.ClassINET}}
|
||||
if existed {
|
||||
m.Used([]dns.RR{p.marker(r.Name)}) // our marker must still be there
|
||||
m.RemoveRRset([]dns.RR{set})
|
||||
} else {
|
||||
m.RRsetNotUsed([]dns.RR{set}) // and the name must be free of this type
|
||||
}
|
||||
m.Insert(p.rrs(r))
|
||||
m.Insert([]dns.RR{p.marker(r.Name)})
|
||||
rc, err := p.send(m)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch rc {
|
||||
case dns.RcodeSuccess:
|
||||
vals, _ := json.Marshal(r.Values)
|
||||
_, err := p.store.pool.Exec(ctx, `INSERT INTO dns_records(zone, name, type, vals) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (zone, name, type) DO UPDATE SET vals=$4, updated=now()`, p.zone, r.Name, r.Type, vals)
|
||||
return false, err
|
||||
case dns.RcodeYXRrset, dns.RcodeNXRrset, dns.RcodeYXDomain, dns.RcodeNameError:
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("update refused: %s", dns.RcodeToString[rc])
|
||||
}
|
||||
|
||||
func (p *rfc2136Provider) Delete(ctx context.Context, r dnsRecord) error {
|
||||
m := new(dns.Msg)
|
||||
m.SetUpdate(p.zone)
|
||||
m.Used([]dns.RR{p.marker(r.Name)})
|
||||
m.RemoveRRset([]dns.RR{&dns.ANY{Hdr: dns.RR_Header{Name: r.Name, Rrtype: dns.StringToType[r.Type], Class: dns.ClassINET}}})
|
||||
var others int
|
||||
if err := p.store.pool.QueryRow(ctx, `SELECT count(*) FROM dns_records WHERE zone=$1 AND name=$2 AND type<>$3`, p.zone, r.Name, r.Type).Scan(&others); err != nil {
|
||||
return err // not knowing, the marker could go while an AAAA of ours still needs it
|
||||
}
|
||||
if others == 0 { // the last of our types at this name: the marker goes too
|
||||
m.RemoveRRset([]dns.RR{&dns.ANY{Hdr: dns.RR_Header{Name: dnsMarkerPrefix + r.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET}}})
|
||||
}
|
||||
rc, err := p.send(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rc != dns.RcodeSuccess && rc != dns.RcodeNXRrset { // NXRRSET: our marker is gone, it is not ours any more
|
||||
return fmt.Errorf("update refused: %s", dns.RcodeToString[rc])
|
||||
}
|
||||
_, err = p.store.pool.Exec(ctx, `DELETE FROM dns_records WHERE zone=$1 AND name=$2 AND type=$3`, p.zone, r.Name, r.Type)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDesiredDNS(t *testing.T) {
|
||||
subnets := []Subnet{{CIDR: "10.0.0.0/24", Purpose: "provisioning"}, {CIDR: "10.20.0.0/24", Purpose: "storage"}, {CIDR: "fd00::/64", Purpose: "provisioning"}}
|
||||
local := []IPAddr{
|
||||
{IP: "10.0.0.50", Subnet: "10.0.0.0/24", MachineID: "m1", Hostname: "node01", Iface: "eno1", Kind: "auto"},
|
||||
{IP: "fd00::50", Subnet: "fd00::/64", MachineID: "m1", Hostname: "node01", Iface: "eno1", Kind: "auto"},
|
||||
{IP: "10.20.0.50", Subnet: "10.20.0.0/24", MachineID: "m1", Hostname: "node01", Iface: "bond0.200", Kind: "auto"},
|
||||
{IP: "10.0.0.5", Subnet: "10.0.0.0/24", Hostname: "core switch", Kind: "reserved"}, // not a DNS label: skipped
|
||||
{IP: "10.0.0.6", Subnet: "10.0.0.0/24", Hostname: "vip", Kind: "reserved"},
|
||||
}
|
||||
got := desiredDNS(local, subnets, "int.example.")
|
||||
var keys []string
|
||||
for k, r := range got {
|
||||
keys = append(keys, k+"="+strings.Join(r.Values, ","))
|
||||
}
|
||||
sort.Strings(keys)
|
||||
want := "node01-bond0-200.int.example. A=10.20.0.50 node01.int.example. A=10.0.0.50 node01.int.example. AAAA=fd00::50 vip.int.example. A=10.0.0.6"
|
||||
if strings.Join(keys, " ") != want {
|
||||
t.Errorf("desired:\n got %s\nwant %s", strings.Join(keys, " "), want)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePDNS: a zone held in memory behind the PowerDNS zone API.
|
||||
type fakePDNS struct {
|
||||
mu sync.Mutex
|
||||
rrsets map[string]map[string]any // name type -> rrset
|
||||
}
|
||||
|
||||
func (f *fakePDNS) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.Header.Get("X-API-Key") != "k" {
|
||||
http.Error(w, "auth", 401)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
out := []any{}
|
||||
for _, v := range f.rrsets {
|
||||
out = append(out, v)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"rrsets": out})
|
||||
case "PATCH":
|
||||
var b struct{ RRsets []map[string]any }
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
for _, rr := range b.RRsets {
|
||||
k := rr["name"].(string) + " " + rr["type"].(string)
|
||||
if rr["changetype"] == "DELETE" {
|
||||
delete(f.rrsets, k)
|
||||
} else {
|
||||
f.rrsets[k] = rr
|
||||
}
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}
|
||||
|
||||
func rrset(name, typ string, account string, vals ...string) map[string]any {
|
||||
recs := []any{}
|
||||
for _, v := range vals {
|
||||
recs = append(recs, map[string]any{"content": v})
|
||||
}
|
||||
c := []any{}
|
||||
if account != "" {
|
||||
c = append(c, map[string]any{"account": account})
|
||||
}
|
||||
return map[string]any{"name": name, "type": typ, "records": recs, "comments": c}
|
||||
}
|
||||
|
||||
func TestDNSReconcilePowerDNS(t *testing.T) {
|
||||
f := &fakePDNS{rrsets: map[string]map[string]any{
|
||||
"printer.int.example. A": rrset("printer.int.example.", "A", "", "10.0.0.9"), // someone else's
|
||||
"node02.int.example. A": rrset("node02.int.example.", "A", "", "10.0.0.99"), // someone else's, same name as ours
|
||||
"node01.int.example. A": rrset("node01.int.example.", "A", "rollcall", "10.0.0.40"), // ours, outdated
|
||||
"gone.int.example. A": rrset("gone.int.example.", "A", "rollcall", "10.0.0.41"), // ours, released
|
||||
"other.int.example. A": rrset("other.int.example.", "A", "netbox", "10.0.0.42"), // another tool's
|
||||
}}
|
||||
srv := httptest.NewServer(f)
|
||||
defer srv.Close()
|
||||
keyFile := filepath.Join(t.TempDir(), "key")
|
||||
_ = os.WriteFile(keyFile, []byte("k\n"), 0o600)
|
||||
p, err := newDNSProvider(DNSConfig{Provider: "powerdns", Zone: "int.example", URL: srv.URL, APIKeyFile: keyFile}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{cfg: &Config{}, dnsp: p, dnsSt: &dnsState{kick: make(chan struct{}, 1)}}
|
||||
want := map[string]dnsRecord{
|
||||
"node01.int.example. A": {Name: "node01.int.example.", Type: "A", Values: []string{"10.0.0.50"}},
|
||||
"node02.int.example. A": {Name: "node02.int.example.", Type: "A", Values: []string{"10.0.0.51"}},
|
||||
"node03.int.example. A": {Name: "node03.int.example.", Type: "A", Values: []string{"10.0.0.52"}},
|
||||
}
|
||||
rep := dnsReport{}
|
||||
a.reconcileDNSWith(context.Background(), want, &rep)
|
||||
if rep.Error != "" {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
if strings.Join(rep.Upserted, ",") != "node01.int.example. A,node03.int.example. A" || strings.Join(rep.Deleted, ",") != "gone.int.example. A" || len(rep.Conflicts) != 1 {
|
||||
t.Errorf("report: %+v", rep)
|
||||
}
|
||||
vals := func(k string) string {
|
||||
rr, ok := f.rrsets[k]
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
var v []string
|
||||
for _, r := range rr["records"].([]any) {
|
||||
v = append(v, r.(map[string]any)["content"].(string))
|
||||
}
|
||||
return strings.Join(v, ",")
|
||||
}
|
||||
for k, w := range map[string]string{"printer.int.example. A": "10.0.0.9", "node02.int.example. A": "10.0.0.99", "node01.int.example. A": "10.0.0.50",
|
||||
"node03.int.example. A": "10.0.0.52", "gone.int.example. A": "-", "other.int.example. A": "10.0.0.42"} {
|
||||
if got := vals(k); got != w {
|
||||
t.Errorf("%s: %s, want %s", k, got, w)
|
||||
}
|
||||
}
|
||||
again := dnsReport{}
|
||||
a.reconcileDNSWith(context.Background(), want, &again)
|
||||
if len(again.Upserted)+len(again.Deleted) != 0 {
|
||||
t.Errorf("second pass changed things: %+v", again)
|
||||
}
|
||||
}
|
||||
+48
-3
@@ -83,6 +83,24 @@ func doctorCmd(args []string) error {
|
||||
d.fail("database", err.Error(), "systemctl status postgresql; check database_url (role/password, sslmode=disable)")
|
||||
} else {
|
||||
d.ok("database", "connected, schema current")
|
||||
// delegated intermediates the region renews: ending, failing, or a silent host
|
||||
active := ""
|
||||
if b, err := os.ReadFile(filepath.Join(cfg.CADir, "ca.pem")); err == nil {
|
||||
if c, err := parseCertPEM(b); err == nil {
|
||||
active = fpHex(c)
|
||||
}
|
||||
}
|
||||
ictx, icancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if xs, err := st.Intermediates(ictx); err == nil {
|
||||
for _, x := range xs {
|
||||
if p := x.problem(active, time.Now()); p != "" {
|
||||
d.warn("intermediate "+x.Name, p, "on its host: systemctl status rollcall-intermediate-renew; rollcall ca intermediates")
|
||||
} else {
|
||||
d.ok("intermediate "+x.Name, "until "+x.NotAfter.Format("2006-01-02")+", renewed by its host")
|
||||
}
|
||||
}
|
||||
}
|
||||
icancel()
|
||||
st.pool.Close()
|
||||
}
|
||||
|
||||
@@ -178,7 +196,7 @@ func doctorCmd(args []string) error {
|
||||
// dnsmasq
|
||||
if out, _ := exec.Command("systemctl", "is-active", "dnsmasq").Output(); strings.TrimSpace(string(out)) != "active" {
|
||||
d.fail("dnsmasq", "not running", "journalctl -u dnsmasq -n 20")
|
||||
} else if _, err := os.Stat(filepath.Join(cfg.DnsmasqDir, "rollcall.conf.rejected")); err == nil {
|
||||
} else if _, err := os.Stat("/var/lib/rollcall-dnsmasq/rollcall.conf.rejected"); err == nil {
|
||||
d.fail("dnsmasq", "the generated configuration was rejected; the last working one is in use",
|
||||
"journalctl -u rollcall-dnsmasq; a hand-written DHCP/TFTP file in /etc/dnsmasq.d usually conflicts")
|
||||
} else {
|
||||
@@ -212,7 +230,18 @@ func doctorCmd(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// other DHCP servers on the provisioning network
|
||||
// other DHCP servers on the provisioning network (expected when this rack proxies)
|
||||
proxying := false
|
||||
if c, err := newClient(); err == nil {
|
||||
var subs []Subnet
|
||||
if c.do("GET", "/api/v1/subnets", nil, &subs) == nil {
|
||||
for _, s := range subs {
|
||||
if s.DHCPMode == "proxy" && (s.PrimaryRack == cfg.RackName || s.SecondaryRack == cfg.RackName) {
|
||||
proxying = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if iface != "" && !*noProbe && !ports["udp/67"] {
|
||||
d.ok("dhcp probe", "skipped: this host serves no DHCP yet")
|
||||
} else if iface != "" && !*noProbe {
|
||||
@@ -228,9 +257,13 @@ func doctorCmd(args []string) error {
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case proxying && len(foreign) > 0:
|
||||
d.ok("dhcp probe", "the site's DHCP server "+strings.Join(foreign, ", ")+" hands out addresses; this host answers PXE (proxy)")
|
||||
case proxying:
|
||||
d.fail("dhcp probe", "proxy DHCP, but no DHCP server answers on "+iface, "machines would get no address: the site's DHCP server must serve this VLAN (or a relay must reach it)")
|
||||
case len(foreign) > 0:
|
||||
d.fail("dhcp probe", "another DHCP server answers on "+iface+": "+strings.Join(foreign, ", "),
|
||||
"machines may PXE-boot from it; switch it off for this VLAN, or use --no-dhcp and let it hand out next-server "+srvHost)
|
||||
"machines may PXE-boot from it: switch it off for this VLAN, or keep it and proxy (bootstrap.sh --iface IFACE --proxy-dhcp, or DHCP mode proxy in the web UI)")
|
||||
case len(servers) == 0:
|
||||
d.warn("dhcp probe", "no DHCP server answered on "+iface, "is a VLAN with dhcp assigned to this rack? (rollcall vlan add ... -dhcp)")
|
||||
default:
|
||||
@@ -268,6 +301,18 @@ func doctorCmd(args []string) error {
|
||||
d.ok("os images", fmt.Sprintf("%d in the catalog", len(oss)))
|
||||
}
|
||||
}
|
||||
var dnsSt map[string]any
|
||||
if err := c.do("GET", "/api/v1/dns", nil, &dnsSt); err == nil && dnsSt["provider"] != "" && dnsSt["provider"] != nil {
|
||||
last, _ := dnsSt["last_sync"].(map[string]any)
|
||||
switch e, _ := last["error"].(string); {
|
||||
case e != "":
|
||||
d.fail("dns provider", e, "check dns.* in config.json; rollcall dns sync")
|
||||
case last["conflicts"] != nil && len(last["conflicts"].([]any)) > 0:
|
||||
d.warn("dns provider", fmt.Sprintf("%v: %d names held by someone else", dnsSt["provider"], len(last["conflicts"].([]any))), "rollcall dns: those records are left alone")
|
||||
default:
|
||||
d.ok("dns provider", fmt.Sprintf("%v, zone %v, in sync", dnsSt["provider"], dnsSt["zone"]))
|
||||
}
|
||||
}
|
||||
var ipam map[string]any
|
||||
if err := c.do("GET", "/api/v1/ipam", nil, &ipam); err == nil && ipam["provider"] != "" && ipam["provider"] != nil {
|
||||
last, _ := ipam["last_sync"].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,893 @@
|
||||
package main
|
||||
|
||||
// Firmware: a repository of vendor packages on the region, each machine's
|
||||
// firmware inventory read from its BMC, and update jobs.
|
||||
//
|
||||
// <firmware_dir>/<id>/meta.json + the package file, served under /firmware/
|
||||
//
|
||||
// A package is for one vendor profile (the Redfish driver: idrac, ilo, ...),
|
||||
// one component (bios, bmc, nic, raid, cpld, psu, drive, other), a version,
|
||||
// and optionally models. An update is a job (kind "firmware") that the region
|
||||
// drives with short rack commands: fw-start hands the package to the BMC,
|
||||
// fw-poll watches the BMC's task until it ends, fw-inventory reads the
|
||||
// versions afterwards. The task is kept in the job, so a region restart
|
||||
// resumes watching instead of losing the update.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FWPackage struct {
|
||||
ID string `json:"id"`
|
||||
Vendor string `json:"vendor"` // a Redfish driver: idrac, ilo, xcc, supermicro, ...
|
||||
Component string `json:"component"` // bios | bmc | nic | raid | cpld | psu | drive | other
|
||||
Version string `json:"version"`
|
||||
Models []string `json:"models,omitempty"` // model substrings it is for; none = any of the vendor's
|
||||
File string `json:"file"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Imported time.Time `json:"imported"`
|
||||
}
|
||||
|
||||
var fwComponents = []string{"bios", "bmc", "nic", "raid", "cpld", "psu", "drive", "other"}
|
||||
|
||||
var fwIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,80}$`)
|
||||
var fwFileRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,120}$`)
|
||||
|
||||
const firmwareSchema = `
|
||||
CREATE TABLE IF NOT EXISTS firmware_inventory (
|
||||
machine_id text PRIMARY KEY REFERENCES machines(id) ON DELETE CASCADE,
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
components jsonb NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------- repository
|
||||
|
||||
func fwList(dir string) ([]FWPackage, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []FWPackage{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []FWPackage{}
|
||||
for _, e := range ents {
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name(), "meta.json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var p FWPackage
|
||||
if json.Unmarshal(b, &p) == nil && p.ID == e.Name() {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.Vendor != b.Vendor {
|
||||
return a.Vendor < b.Vendor
|
||||
}
|
||||
if a.Component != b.Component {
|
||||
return a.Component < b.Component
|
||||
}
|
||||
return a.Imported.After(b.Imported)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fwGet(dir, id string) (FWPackage, error) {
|
||||
if !fwIDRe.MatchString(id) {
|
||||
return FWPackage{}, fmt.Errorf("no firmware package %q", id)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, id, "meta.json"))
|
||||
if err != nil {
|
||||
return FWPackage{}, fmt.Errorf("no firmware package %q", id)
|
||||
}
|
||||
var p FWPackage
|
||||
return p, json.Unmarshal(b, &p)
|
||||
}
|
||||
|
||||
// fwCheck validates a package description against what Rollcall knows.
|
||||
func fwCheck(p *FWPackage) error {
|
||||
p.Vendor, p.Component = strings.ToLower(p.Vendor), strings.ToLower(p.Component)
|
||||
if d, ok := driverByName(p.Vendor); !ok || d.Family != "redfish" {
|
||||
var names []string
|
||||
for _, r := range redfishProfiles {
|
||||
names = append(names, r.Driver)
|
||||
}
|
||||
return fmt.Errorf("vendor is a Redfish driver: %s", strings.Join(names, ", "))
|
||||
}
|
||||
if !hasTag(fwComponents, p.Component) {
|
||||
return fmt.Errorf("component is one of %s", strings.Join(fwComponents, ", "))
|
||||
}
|
||||
if strings.TrimSpace(p.Version) == "" || strings.ContainsAny(p.Version, "\r\n") {
|
||||
return errors.New("version is required")
|
||||
}
|
||||
if !fwFileRe.MatchString(p.File) {
|
||||
return fmt.Errorf("file name %q: letters, digits and ._+- only", p.File)
|
||||
}
|
||||
if fs := redfishProfileFor(p.Vendor).FWFormats; len(fs) > 0 {
|
||||
ok := false
|
||||
for _, ext := range fs {
|
||||
ok = ok || strings.HasSuffix(strings.ToLower(p.File), ext)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%s takes %s packages, not %s", p.Vendor, strings.Join(fs, " "), p.File)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fwImport copies (or downloads) a package into the repository.
|
||||
func fwImport(ctx context.Context, dir, src string, p FWPackage, wantSum string) (*FWPackage, error) {
|
||||
if p.File == "" {
|
||||
p.File = path.Base(strings.SplitN(src, "?", 2)[0])
|
||||
}
|
||||
if err := fwCheck(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in io.ReadCloser
|
||||
if strings.HasPrefix(src, "https://") || strings.HasPrefix(src, "http://") {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", src, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%s: %s", src, resp.Status)
|
||||
}
|
||||
in = resp.Body
|
||||
} else {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in = f
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmp, err := os.MkdirTemp(dir, ".import-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
f, err := os.Create(filepath.Join(tmp, p.File))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(f, h), in)
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Size, p.SHA256 = n, hex.EncodeToString(h.Sum(nil))
|
||||
if wantSum != "" && !strings.EqualFold(wantSum, p.SHA256) {
|
||||
return nil, fmt.Errorf("sha256 %s, expected %s", p.SHA256, wantSum)
|
||||
}
|
||||
if p.Vendor == "ilo" && strings.HasSuffix(strings.ToLower(p.File), ".fwpkg") {
|
||||
if err := fwpkgExtract(tmp, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if p.Source == "" {
|
||||
p.Source = src
|
||||
}
|
||||
p.Imported = time.Now().UTC()
|
||||
slug := strings.Trim(regexp.MustCompile(`[^a-z0-9.]+`).ReplaceAllString(strings.ToLower(p.Vendor+"-"+p.Component+"-"+p.Version), "-"), "-")
|
||||
if len(slug) > 60 {
|
||||
slug = slug[:60]
|
||||
}
|
||||
p.ID = slug + "-" + p.SHA256[:8]
|
||||
mb, _ := json.MarshalIndent(p, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(tmp, "meta.json"), append(mb, '\n'), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := filepath.Join(dir, p.ID)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return &p, nil // the same package again
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// fwpkgExtract: iLO's SimpleUpdate flashes the iLO .bin or UEFI .signed.flash
|
||||
// inside an HPE Smart Component; given the .fwpkg it answers 200 and flashes
|
||||
// nothing. The .fwpkg is a zip: the one flashable file is kept instead.
|
||||
func fwpkgExtract(dir string, p *FWPackage) error {
|
||||
src := filepath.Join(dir, p.File)
|
||||
zr, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is not a readable .fwpkg: %w", p.File, err)
|
||||
}
|
||||
defer zr.Close()
|
||||
var cands []*zip.File
|
||||
for _, f := range zr.File {
|
||||
n := strings.ToLower(path.Base(f.Name))
|
||||
if !f.FileInfo().IsDir() && (strings.HasSuffix(n, ".bin") || strings.HasSuffix(n, ".flash")) && fwFileRe.MatchString(path.Base(f.Name)) {
|
||||
cands = append(cands, f)
|
||||
}
|
||||
}
|
||||
if len(cands) != 1 {
|
||||
var names []string
|
||||
for _, f := range cands {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
return fmt.Errorf("%s holds %d files iLO flashes directly (%s); a component only UEFI or an OS agent installs needs SUM/iLOrest", p.File, len(cands), strings.Join(names, ", "))
|
||||
}
|
||||
in, err := cands[0].Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
name := path.Base(cands[0].Name)
|
||||
out, err := os.Create(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(out, h), io.LimitReader(in, 1<<30))
|
||||
if cerr := out.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(src)
|
||||
p.Notes = strings.TrimSpace(p.Notes + fmt.Sprintf(" extracted from %s (sha256 %s)", p.File, p.SHA256))
|
||||
p.File, p.Size, p.SHA256 = name, n, hex.EncodeToString(h.Sum(nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
func fwRemove(dir, id string) error {
|
||||
if !fwIDRe.MatchString(id) {
|
||||
return fmt.Errorf("no firmware package %q", id)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, id, "meta.json")); err != nil {
|
||||
return fmt.Errorf("no firmware package %q", id)
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(dir, id))
|
||||
}
|
||||
|
||||
// fwFor: the packages for this machine (vendor, model), newest first per component.
|
||||
func fwFor(pkgs []FWPackage, vendor, model string) []FWPackage {
|
||||
var out []FWPackage
|
||||
for _, p := range pkgs {
|
||||
if p.Vendor != vendor {
|
||||
continue
|
||||
}
|
||||
if len(p.Models) > 0 {
|
||||
ok := false
|
||||
for _, m := range p.Models {
|
||||
ok = ok || strings.Contains(strings.ToLower(model), strings.ToLower(m))
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- inventory
|
||||
|
||||
type fwInventory struct {
|
||||
At time.Time `json:"at"`
|
||||
Components []fwComponent `json:"components"`
|
||||
}
|
||||
|
||||
func (s *Store) FirmwareInventory(ctx context.Context, id string) (*fwInventory, error) {
|
||||
var inv fwInventory
|
||||
var b []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT at, components FROM firmware_inventory WHERE machine_id=$1`, id).Scan(&inv.At, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inv, json.Unmarshal(b, &inv.Components)
|
||||
}
|
||||
|
||||
func (s *Store) PutFirmwareInventory(ctx context.Context, id string, cs []fwComponent) error {
|
||||
b, _ := json.Marshal(cs)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO firmware_inventory(machine_id, at, components) VALUES ($1, now(), $2)
|
||||
ON CONFLICT (machine_id) DO UPDATE SET at=now(), components=$2`, id, b)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) readFirmware(ctx context.Context, m *Machine) ([]fwComponent, error) {
|
||||
out, err := a.firmwareVia(ctx, m, "fw-inventory", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cs []fwComponent
|
||||
if err := json.Unmarshal([]byte(out), &cs); err != nil {
|
||||
return nil, fmt.Errorf("inventory: %w", err)
|
||||
}
|
||||
return cs, a.store.PutFirmwareInventory(ctx, m.ID, cs)
|
||||
}
|
||||
|
||||
// bmcVendor is the machine's Redfish driver and model, as probed.
|
||||
func bmcVendor(m *Machine) (driver, model string) {
|
||||
var b bmcRecord
|
||||
_ = json.Unmarshal(m.BMC, &b)
|
||||
var rf struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
var raw obj
|
||||
_ = json.Unmarshal(m.BMC, &raw)
|
||||
if x, ok := raw["redfish"]; ok {
|
||||
bb, _ := json.Marshal(x)
|
||||
_ = json.Unmarshal(bb, &rf)
|
||||
}
|
||||
model = rf.Model
|
||||
if model == "" {
|
||||
model = deref(m.Product)
|
||||
}
|
||||
return b.Driver, model
|
||||
}
|
||||
|
||||
// fwCompare marks, per component, whether a package in the repository is newer than what is installed.
|
||||
type fwOffer struct {
|
||||
Package FWPackage `json:"package"`
|
||||
Installed string `json:"installed"` // the version now on the component ("" when not seen)
|
||||
Same bool `json:"same"` // the package's version is what is installed
|
||||
}
|
||||
|
||||
func fwOffers(pkgs []FWPackage, inv []fwComponent) []fwOffer {
|
||||
var out []fwOffer
|
||||
for _, p := range pkgs {
|
||||
o := fwOffer{Package: p}
|
||||
for _, c := range inv {
|
||||
if c.Component == p.Component {
|
||||
if o.Installed == "" {
|
||||
o.Installed = c.Version
|
||||
}
|
||||
if fwSameVersion(c.Version, p.Version) {
|
||||
o.Installed, o.Same = c.Version, true
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fwSameVersion compares loosely: "2.19.1" matches "2.19.1", "v2.19.1" and "2.19.1 (Build 4)".
|
||||
func fwSameVersion(installed, pkg string) bool {
|
||||
norm := func(s string) string { return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s)), "v") }
|
||||
i, p := norm(installed), norm(pkg)
|
||||
return i == p || (p != "" && (strings.HasPrefix(i, p+" ") || strings.HasPrefix(i, p+"(") || strings.HasPrefix(i, p+"-")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- jobs
|
||||
|
||||
type fwSpec struct {
|
||||
Package string `json:"package"`
|
||||
Component string `json:"component"`
|
||||
Version string `json:"version"`
|
||||
Apply string `json:"apply,omitempty"` // Immediate | OnReset
|
||||
Method string `json:"method,omitempty"` // forced upload method
|
||||
Reboot bool `json:"reboot,omitempty"` // power-cycle the host when the update waits for a reset
|
||||
}
|
||||
|
||||
type fwProgress struct {
|
||||
Phase string `json:"phase"` // uploading | flashing | rebooting | verifying
|
||||
Pct int `json:"pct"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) setFWProgress(ctx context.Context, j *Job, p fwProgress) {
|
||||
b, _ := json.Marshal(p)
|
||||
_ = a.store.JobProgress(ctx, j.ID, b)
|
||||
}
|
||||
|
||||
// firmwareURL is where the BMC downloads the package: the machine's rack
|
||||
// controller, or firmware_base on the BMC record when BMCs reach another address.
|
||||
func (a *App) firmwareURL(m *Machine, p FWPackage) string {
|
||||
base := a.urlFor(m)
|
||||
var b bmcRecord
|
||||
if json.Unmarshal(m.BMC, &b) == nil && b.Extra["firmware_base"] != "" {
|
||||
base = strings.TrimRight(b.Extra["firmware_base"], "/")
|
||||
}
|
||||
return base + "/firmware/" + p.ID + "/" + p.File
|
||||
}
|
||||
|
||||
func (a *App) startFirmware(ctx context.Context, m *Machine, p FWPackage, sp fwSpec, force bool) (*Job, error) {
|
||||
drv, model := bmcVendor(m)
|
||||
if d, _ := driverByName(drv); d.Family != "redfish" {
|
||||
return nil, fmt.Errorf("%s: firmware updates need a Redfish BMC driver (now %s): rollcall bmc %s -probe", m.ID, orDefault(drv, "ipmi"), m.ID)
|
||||
}
|
||||
if !force {
|
||||
if p.Vendor != drv {
|
||||
return nil, fmt.Errorf("%s is a %s package; this BMC is %s (-force to send it anyway)", p.ID, p.Vendor, drv)
|
||||
}
|
||||
if len(fwFor([]FWPackage{p}, drv, model)) == 0 {
|
||||
return nil, fmt.Errorf("%s is for models %v, this is %q (-force to send it anyway)", p.ID, p.Models, model)
|
||||
}
|
||||
}
|
||||
if j, err := a.store.ActiveJob(ctx, m.ID); err == nil {
|
||||
return nil, fmt.Errorf("job %s (%s, %s) is still active", j.ID, j.Kind, j.State)
|
||||
}
|
||||
sp.Package, sp.Component, sp.Version = p.ID, p.Component, p.Version
|
||||
specB, _ := json.Marshal(sp)
|
||||
j := &Job{ID: "j-" + randomToken(5), MachineID: m.ID, Kind: "firmware", State: "pending", Spec: specB}
|
||||
if err := a.store.CreateJob(ctx, j, "", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.setFWProgress(ctx, j, fwProgress{Phase: "uploading", Msg: "handing " + p.File + " to the BMC"})
|
||||
_ = a.store.AddEvent(ctx, m.ID, "firmware requested", fmt.Sprintf("job=%s %s %s %s apply=%s", j.ID, p.Component, p.Version, p.File, orDefault(sp.Apply, "default")))
|
||||
go a.runFirmware(j.ID, m.ID, false)
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// runFirmware drives one update to its end; resume picks up a task already started.
|
||||
func (a *App) runFirmware(jobID, machineID string, resume bool) {
|
||||
ctx := context.Background()
|
||||
j, err := a.store.Job(ctx, jobID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m, err := a.store.Get(ctx, machineID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var sp fwSpec
|
||||
_ = json.Unmarshal(j.Spec, &sp)
|
||||
var pr fwProgress
|
||||
_ = json.Unmarshal(j.Progress, &pr)
|
||||
// every call reads the record again: the BMC certificate pin may change on the way
|
||||
via := func(op, arg string) (string, error) {
|
||||
if mm, err := a.store.Get(ctx, machineID); err == nil {
|
||||
m = mm
|
||||
}
|
||||
out, err := a.firmwareVia(ctx, m, op, arg)
|
||||
if err != nil && sp.Component == "bmc" && strings.Contains(err.Error(), "certificate changed") {
|
||||
// the BMC we are updating came back with a new certificate: expected, pin it anew
|
||||
_ = a.setBMCExtra(ctx, machineID, "tls_fingerprint", "")
|
||||
_ = a.store.AddEvent(ctx, machineID, "bmc certificate", "changed during the BMC update; pinned again")
|
||||
if mm, gerr := a.store.Get(ctx, machineID); gerr == nil {
|
||||
m = mm
|
||||
}
|
||||
out, err = a.firmwareVia(ctx, m, op, arg)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
finish := func(state, msg string) {
|
||||
_ = a.store.FinishJob(ctx, j.ID, state, map[bool]string{true: "", false: msg}[state == "done"])
|
||||
ev := "firmware " + state
|
||||
_ = a.store.AddEvent(ctx, m.ID, ev, fmt.Sprintf("job=%s %s %s: %s", j.ID, sp.Component, sp.Version, msg))
|
||||
log.Printf("%s: firmware %s %s %s: %s", m.ID, sp.Component, sp.Version, state, msg)
|
||||
}
|
||||
if !resume || pr.Task == "" && pr.Phase == "uploading" {
|
||||
if resume { // the region went away while the upload was running: the BMC may or may not have it
|
||||
finish("failed", "the region restarted during the upload; check the BMC, then try again")
|
||||
return
|
||||
}
|
||||
p, err := fwGet(a.cfg.FirmwareDir, sp.Package)
|
||||
if err != nil {
|
||||
finish("failed", err.Error())
|
||||
return
|
||||
}
|
||||
arg, _ := json.Marshal(fwStartArg{URL: a.firmwareURL(m, p), File: p.File, Component: p.Component, Method: sp.Method, Apply: sp.Apply})
|
||||
out, err := via("fw-start", string(arg))
|
||||
if err != nil {
|
||||
finish("failed", err.Error())
|
||||
return
|
||||
}
|
||||
var res fwStartResult
|
||||
_ = json.Unmarshal([]byte(out), &res)
|
||||
pr = fwProgress{Phase: "flashing", Method: res.Method, Task: res.Task, Msg: "the BMC has the package"}
|
||||
a.setFWProgress(ctx, j, pr)
|
||||
}
|
||||
// watch the task; a BMC flashing itself drops off the network for minutes
|
||||
if pr.Task != "" && pr.Phase == "flashing" {
|
||||
var lastOK time.Time = time.Now()
|
||||
for {
|
||||
out, err := via("fw-poll", pr.Task)
|
||||
if err != nil {
|
||||
if time.Since(lastOK) > 25*time.Minute {
|
||||
finish("failed", "the BMC has not answered for 25 minutes: "+err.Error())
|
||||
return
|
||||
}
|
||||
pr.Msg = "BMC not answering (it may be restarting): " + oneLine(err.Error())
|
||||
a.setFWProgress(ctx, j, pr)
|
||||
time.Sleep(20 * time.Second)
|
||||
continue
|
||||
}
|
||||
lastOK = time.Now()
|
||||
var pl fwPollResult
|
||||
_ = json.Unmarshal([]byte(out), &pl)
|
||||
pr.Pct, pr.Msg = pl.Pct, pl.Msg
|
||||
a.setFWProgress(ctx, j, pr)
|
||||
if pl.State == "failed" {
|
||||
_, _ = via("fw-finish", "")
|
||||
finish("failed", orDefault(pl.Msg, "the BMC reported the update failed"))
|
||||
return
|
||||
}
|
||||
if pl.State == "done" || pl.State == "unknown" {
|
||||
break
|
||||
}
|
||||
if time.Since(*orNow(j.Started)) > 3*time.Hour {
|
||||
finish("failed", "the BMC task did not end within 3 hours")
|
||||
return
|
||||
}
|
||||
time.Sleep(15 * time.Second)
|
||||
}
|
||||
}
|
||||
_, _ = via("fw-finish", "") // e.g. Lenovo's busy flag
|
||||
// a component that applies at the next reset (BIOS, most NICs/RAID) waits for it
|
||||
if sp.Reboot && pr.Phase != "verifying" {
|
||||
pr.Phase, pr.Msg = "rebooting", "power cycle so the update applies"
|
||||
a.setFWProgress(ctx, j, pr)
|
||||
if _, err := a.power(ctx, m, "cycle", ""); err != nil {
|
||||
finish("failed", "update staged, but the power cycle failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
pr.Phase, pr.Msg = "verifying", "reading the firmware inventory"
|
||||
a.setFWProgress(ctx, j, pr)
|
||||
deadline := time.Now().Add(20 * time.Minute) // servers take minutes to POST, BMCs to come back
|
||||
for {
|
||||
if mm, gerr := a.store.Get(ctx, machineID); gerr == nil {
|
||||
m = mm
|
||||
}
|
||||
inv, err := a.readFirmware(ctx, m)
|
||||
if err != nil && sp.Component == "bmc" && strings.Contains(err.Error(), "certificate changed") {
|
||||
_ = a.setBMCExtra(ctx, machineID, "tls_fingerprint", "")
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
for _, c := range inv {
|
||||
if c.Component == sp.Component && fwSameVersion(c.Version, sp.Version) {
|
||||
finish("done", "installed "+c.Name+" "+c.Version)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) || (!sp.Reboot && err == nil && pr.Task != "") {
|
||||
break
|
||||
}
|
||||
time.Sleep(60 * time.Second)
|
||||
}
|
||||
if sp.Reboot {
|
||||
finish("failed", "the inventory does not show "+sp.Version+" after the update and a reboot")
|
||||
return
|
||||
}
|
||||
// staged: most components switch at the next reset of the host
|
||||
finish("done", "accepted by the BMC; "+sp.Version+" is not in the inventory yet (applies at the next reboot, or rerun with -reboot)")
|
||||
}
|
||||
|
||||
func orNow(t *time.Time) *time.Time {
|
||||
if t == nil {
|
||||
n := time.Now()
|
||||
return &n
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resumeFirmware watches the updates that were running when the region stopped.
|
||||
func (a *App) resumeFirmware() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
active, err := a.store.ActiveJobs(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, j := range active {
|
||||
if j.Kind == "firmware" {
|
||||
log.Printf("%s: resuming firmware job %s", j.MachineID, j.ID)
|
||||
go a.runFirmware(j.ID, j.MachineID, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP
|
||||
|
||||
func (a *App) handleFirmwareRepo(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
pkgs, err := fwList(a.cfg.FirmwareDir)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, pkgs)
|
||||
case http.MethodPost: // import from a URL: {"url", "vendor", "component", "version", "models", "sha256", "notes"}
|
||||
var req struct {
|
||||
FWPackage
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !(strings.HasPrefix(req.URL, "https://") || strings.HasPrefix(req.URL, "http://")) {
|
||||
fail(w, 400, errors.New("url (http or https), vendor, component and version are required"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
|
||||
defer cancel()
|
||||
p, err := fwImport(ctx, a.cfg.FirmwareDir, req.URL, req.FWPackage, req.SHA256)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
log.Printf("firmware %s imported by %s from %s", p.ID, a.who(r), req.URL)
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleFirmwareRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if err := fwRemove(a.cfg.FirmwareDir, r.PathValue("pkg")); err != nil {
|
||||
fail(w, 404, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"removed": r.PathValue("pkg")})
|
||||
}
|
||||
|
||||
// handleFirmwareFile serves one package file (no listings): BMCs download from here.
|
||||
func (a *App) handleFirmwareFile(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := fwGet(a.cfg.FirmwareDir, r.PathValue("pkg"))
|
||||
if err != nil || r.PathValue("file") != p.File {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.FirmwareDir, p.ID, p.File))
|
||||
}
|
||||
|
||||
// handleMachineFirmware: GET the stored inventory and what the repository offers; POST ?refresh reads it again.
|
||||
func (a *App) handleMachineFirmware(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, 404, err)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 6*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := a.readFirmware(ctx, m); err != nil {
|
||||
fail(w, 502, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
inv, err := a.store.FirmwareInventory(r.Context(), m.ID)
|
||||
if err != nil {
|
||||
inv = &fwInventory{Components: []fwComponent{}}
|
||||
}
|
||||
drv, model := bmcVendor(m)
|
||||
pkgs, _ := fwList(a.cfg.FirmwareDir)
|
||||
offers := fwOffers(fwFor(pkgs, drv, model), inv.Components)
|
||||
if offers == nil {
|
||||
offers = []fwOffer{}
|
||||
}
|
||||
writeJSON(w, 200, obj{"id": m.ID, "driver": drv, "model": model, "inventory": inv, "offers": offers})
|
||||
}
|
||||
|
||||
// handleFirmwareUpdate starts one update: {"package", "apply", "reboot", "method", "force"}.
|
||||
func (a *App) handleFirmwareUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(w, 404, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
fwSpec
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Package == "" {
|
||||
fail(w, 400, errors.New("package is required"))
|
||||
return
|
||||
}
|
||||
switch req.Apply {
|
||||
case "", "Immediate", "OnReset":
|
||||
default:
|
||||
fail(w, 400, errors.New("apply is Immediate or OnReset"))
|
||||
return
|
||||
}
|
||||
switch req.Method {
|
||||
case "", "multipart", "simple", "push":
|
||||
default:
|
||||
fail(w, 400, errors.New("method is multipart, simple or push"))
|
||||
return
|
||||
}
|
||||
p, err := fwGet(a.cfg.FirmwareDir, req.Package)
|
||||
if err != nil {
|
||||
fail(w, 404, err)
|
||||
return
|
||||
}
|
||||
j, err := a.startFirmware(r.Context(), m, p, req.fwSpec, req.Force)
|
||||
if err != nil {
|
||||
fail(w, 409, err)
|
||||
return
|
||||
}
|
||||
log.Printf("%s: firmware %s requested by %s (job %s)", m.ID, p.ID, a.who(r), j.ID)
|
||||
writeJSON(w, 200, obj{"id": m.ID, "job": j.ID, "package": p.ID, "component": p.Component, "version": p.Version})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CLI
|
||||
|
||||
func firmwareCmd(args []string) error {
|
||||
if len(args) == 0 {
|
||||
args = []string{"list"}
|
||||
}
|
||||
switch args[0] {
|
||||
case "import":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
}
|
||||
fs := newFlags("firmware import")
|
||||
vendor := fs.String("vendor", "", "Redfish driver the package is for: idrac, ilo, xcc, supermicro, xfusion, kaytus, irmc, cimc, gigabyte, asrockrack, openbmc, ami")
|
||||
comp := fs.String("component", "", strings.Join(fwComponents, " | "))
|
||||
ver := fs.String("version", "", "the version the component reports once installed")
|
||||
models := fs.String("models", "", "comma-separated model substrings it is for (default: any)")
|
||||
sum := fs.String("sha256", "", "expected SHA-256 (from the vendor's download page)")
|
||||
notes := fs.String("notes", "", "notes")
|
||||
_ = fs.Parse(args[2:])
|
||||
p := FWPackage{Vendor: *vendor, Component: *comp, Version: *ver, Notes: *notes}
|
||||
if *models != "" {
|
||||
p.Models = strings.Split(*models, ",")
|
||||
}
|
||||
src := args[1]
|
||||
if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") { // a local file: on the region host
|
||||
cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("importing a file needs the region's config (run on the region, or give an https URL): %w", err)
|
||||
}
|
||||
got, err := fwImport(context.Background(), cfg.FirmwareDir, src, p, *sum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("imported %s (%s, %s)\n", got.ID, human(float64(got.Size)), got.SHA256)
|
||||
return nil
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var got FWPackage
|
||||
req := obj{"url": src, "vendor": p.Vendor, "component": p.Component, "version": p.Version, "models": p.Models, "sha256": *sum, "notes": p.Notes}
|
||||
if err := c.do("POST", "/api/v1/firmware", req, &got); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("imported %s (%s, %s)\n", got.ID, human(float64(got.Size)), got.SHA256)
|
||||
return nil
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
var pkgs []FWPackage
|
||||
if err := c.do("GET", "/api/v1/firmware", nil, &pkgs); err != nil {
|
||||
return err
|
||||
}
|
||||
f := "%-44s %-11s %-9s %-22s %9s %s\n"
|
||||
fmt.Printf(f, "PACKAGE", "VENDOR", "COMPONENT", "VERSION", "SIZE", "FILE / MODELS")
|
||||
for _, p := range pkgs {
|
||||
ms := ""
|
||||
if len(p.Models) > 0 {
|
||||
ms = " [" + strings.Join(p.Models, ", ") + "]"
|
||||
}
|
||||
fmt.Printf(f, p.ID, p.Vendor, p.Component, p.Version, human(float64(p.Size)), p.File+ms)
|
||||
}
|
||||
case "rm":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
}
|
||||
return c.do("DELETE", "/api/v1/firmware/"+args[1], nil, nil)
|
||||
case "show":
|
||||
if len(args) < 2 {
|
||||
usage()
|
||||
}
|
||||
fs := newFlags("firmware show")
|
||||
refresh := fs.Bool("refresh", false, "read the inventory from the BMC now")
|
||||
_ = fs.Parse(args[2:])
|
||||
method := "GET"
|
||||
if *refresh {
|
||||
method = "POST"
|
||||
}
|
||||
var d struct {
|
||||
Driver string `json:"driver"`
|
||||
Model string `json:"model"`
|
||||
Inventory fwInventory `json:"inventory"`
|
||||
Offers []fwOffer `json:"offers"`
|
||||
}
|
||||
if err := c.do(method, "/api/v1/machines/"+args[1]+"/firmware", nil, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s driver %s model %s inventory %s\n", args[1], orDash(d.Driver), orDash(d.Model), orDash(fwWhen(d.Inventory.At)))
|
||||
if len(d.Inventory.Components) == 0 {
|
||||
fmt.Println("no inventory yet: rollcall firmware show " + args[1] + " -refresh")
|
||||
}
|
||||
for _, x := range d.Inventory.Components {
|
||||
up := ""
|
||||
if !x.Updateable {
|
||||
up = " (not updateable)"
|
||||
}
|
||||
fmt.Printf(" %-9s %-40s %s%s\n", x.Component, trunc(x.Name, 40), x.Version, up)
|
||||
}
|
||||
if len(d.Offers) > 0 {
|
||||
fmt.Println("repository:")
|
||||
for _, o := range d.Offers {
|
||||
st := "update"
|
||||
if o.Same {
|
||||
st = "installed"
|
||||
}
|
||||
fmt.Printf(" %-9s %-22s %-9s (now %s) %s\n", o.Package.Component, o.Package.Version, st, orDash(o.Installed), o.Package.ID)
|
||||
}
|
||||
}
|
||||
case "update":
|
||||
if len(args) < 3 {
|
||||
usage()
|
||||
}
|
||||
fs := newFlags("firmware update")
|
||||
apply := fs.String("apply", "", "Immediate | OnReset (default: the BMC's)")
|
||||
reboot := fs.Bool("reboot", false, "power-cycle the host afterwards so a staged update applies, then verify")
|
||||
method := fs.String("method", "", "multipart | simple | push (default: the vendor profile's order)")
|
||||
force := fs.Bool("force", false, "send a package for another vendor or model")
|
||||
watch := fs.Bool("watch", false, "follow the job")
|
||||
_ = fs.Parse(args[3:])
|
||||
ids, err := c.targets(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := obj{"package": args[2], "apply": *apply, "reboot": *reboot, "method": *method, "force": *force}
|
||||
for _, id := range ids {
|
||||
var d obj
|
||||
if err := c.do("POST", "/api/v1/machines/"+id+"/firmware/update", req, &d); err != nil {
|
||||
if len(ids) == 1 {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s: error: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s: job %s %s %s\n", id, str(d["job"]), str(d["component"]), str(d["version"]))
|
||||
}
|
||||
if *watch && len(ids) == 1 {
|
||||
return c.watchJob(ids[0])
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fwWhen(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fwBMC is an UpdateService: it takes packages the ways the flags allow and
|
||||
// keeps a task per upload.
|
||||
type fwBMC struct {
|
||||
mu sync.Mutex
|
||||
multipart, simple, push bool
|
||||
protocols []string
|
||||
got []string // method:file-content or method:ImageURI
|
||||
params []obj
|
||||
chunked bool
|
||||
polls int
|
||||
}
|
||||
|
||||
func (f *fwBMC) handler(t *testing.T) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if u, p, ok := r.BasicAuth(); !ok || u != "admin" || p != "secret" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
task := func() {
|
||||
w.Header().Set("Location", "/redfish/v1/TaskService/Tasks/3")
|
||||
w.WriteHeader(202)
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/redfish/v1/UpdateService":
|
||||
us := obj{}
|
||||
if f.multipart {
|
||||
us["MultipartHttpPushUri"] = "/redfish/v1/UpdateService/upload"
|
||||
}
|
||||
if f.push {
|
||||
us["HttpPushUri"] = "/redfish/v1/UpdateService/push"
|
||||
}
|
||||
if f.simple {
|
||||
act := obj{"target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate"}
|
||||
if f.protocols != nil {
|
||||
act["TransferProtocol@Redfish.AllowableValues"] = f.protocols
|
||||
}
|
||||
us["Actions"] = obj{"#UpdateService.SimpleUpdate": act}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(us)
|
||||
case r.URL.Path == "/redfish/v1/UpdateService/upload":
|
||||
f.chunked = f.chunked || r.ContentLength < 0 || len(r.TransferEncoding) > 0
|
||||
_, ps, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
mr := multipart.NewReader(r.Body, ps["boundary"])
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
b, _ := io.ReadAll(p)
|
||||
switch p.FormName() {
|
||||
case "UpdateParameters":
|
||||
var o obj
|
||||
_ = json.Unmarshal(b, &o)
|
||||
f.params = append(f.params, o)
|
||||
case "UpdateFile":
|
||||
f.got = append(f.got, "multipart:"+p.FileName()+":"+string(b))
|
||||
}
|
||||
}
|
||||
task()
|
||||
case r.URL.Path == "/redfish/v1/UpdateService/push":
|
||||
f.chunked = f.chunked || r.ContentLength < 0
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f.got = append(f.got, "push:"+string(b))
|
||||
task()
|
||||
case r.URL.Path == "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate":
|
||||
var o obj
|
||||
_ = json.NewDecoder(r.Body).Decode(&o)
|
||||
f.got = append(f.got, "simple:"+str(o["ImageURI"])+":"+str(o["TransferProtocol"]))
|
||||
task()
|
||||
case r.URL.Path == "/redfish/v1/TaskService/Tasks/3":
|
||||
f.polls++
|
||||
st := obj{"TaskState": "Running", "PercentComplete": 40}
|
||||
if f.polls > 1 {
|
||||
st = obj{"TaskState": "Completed", "PercentComplete": 100, "Messages": []obj{{"Message": "Firmware update completed"}}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(st)
|
||||
case r.URL.Path == "/redfish/v1/UpdateService/FirmwareInventory":
|
||||
_ = json.NewEncoder(w).Encode(obj{"Members": []obj{
|
||||
{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Installed-159-2.19.1"},
|
||||
{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Previous-159-2.18.0"},
|
||||
{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Installed-25227-7.10.30.00"},
|
||||
}})
|
||||
case strings.HasPrefix(r.URL.Path, "/redfish/v1/UpdateService/FirmwareInventory/Installed-159"):
|
||||
_ = json.NewEncoder(w).Encode(obj{"Id": "Installed-159-2.19.1", "Name": "BIOS", "Version": "2.19.1", "Updateable": true})
|
||||
case strings.HasPrefix(r.URL.Path, "/redfish/v1/UpdateService/FirmwareInventory/Installed-25227"):
|
||||
_ = json.NewEncoder(w).Encode(obj{"Id": "Installed-25227-7.10.30.00", "Name": "Integrated Dell Remote Access Controller", "Version": "7.10.30.00", "Updateable": true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runFW(t *testing.T, f *fwBMC, op string, arg any) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(f.handler(t))
|
||||
defer srv.Close()
|
||||
pkg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("FIRMWARE-BYTES")) }))
|
||||
defer pkg.Close()
|
||||
a := ""
|
||||
if s, ok := arg.(string); ok {
|
||||
a = s
|
||||
} else if arg != nil {
|
||||
sa := arg.(fwStartArg)
|
||||
if sa.URL == "" {
|
||||
sa.URL = pkg.URL + "/firmware/p/BIOS_X.EXE"
|
||||
}
|
||||
b, _ := json.Marshal(sa)
|
||||
a = string(b)
|
||||
}
|
||||
out, err := powerRun(context.Background(), "", creds(srv.URL, "redfish"), op, a)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", op, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFirmwareMultipart(t *testing.T) {
|
||||
f := &fwBMC{multipart: true, simple: true}
|
||||
var res fwStartResult
|
||||
_ = json.Unmarshal([]byte(runFW(t, f, "fw-start", fwStartArg{File: "BIOS_X.EXE", Apply: "OnReset"})), &res)
|
||||
if res.Method != "multipart" || res.Task != "/redfish/v1/TaskService/Tasks/3" {
|
||||
t.Errorf("result %+v", res)
|
||||
}
|
||||
if len(f.got) != 1 || f.got[0] != "multipart:BIOS_X.EXE:FIRMWARE-BYTES" || f.chunked {
|
||||
t.Errorf("got %v chunked %v", f.got, f.chunked)
|
||||
}
|
||||
if f.params[0]["@Redfish.OperationApplyTime"] != "OnReset" {
|
||||
t.Errorf("params %v", f.params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmwareSimpleAndPush(t *testing.T) {
|
||||
f := &fwBMC{simple: true, protocols: []string{"HTTP", "HTTPS", "NFS"}}
|
||||
_ = runFW(t, f, "fw-start", fwStartArg{File: "x.bin"})
|
||||
if len(f.got) != 1 || !strings.HasPrefix(f.got[0], "simple:http://") || !strings.HasSuffix(f.got[0], ":HTTP") {
|
||||
t.Errorf("simple %v", f.got)
|
||||
}
|
||||
g := &fwBMC{push: true}
|
||||
_ = runFW(t, g, "fw-start", fwStartArg{File: "x.bin"})
|
||||
if len(g.got) != 1 || g.got[0] != "push:FIRMWARE-BYTES" || g.chunked {
|
||||
t.Errorf("push %v chunked %v", g.got, g.chunked)
|
||||
}
|
||||
// a service that pulls only over NFS cannot take our HTTP URL: refused clearly
|
||||
h := &fwBMC{simple: true, protocols: []string{"NFS"}}
|
||||
srv := httptest.NewTLSServer(h.handler(t))
|
||||
defer srv.Close()
|
||||
b, _ := json.Marshal(fwStartArg{URL: "http://rack/firmware/p/x.bin", File: "x.bin"})
|
||||
if _, err := powerRun(context.Background(), "", creds(srv.URL, "redfish"), "fw-start", string(b)); err == nil || !strings.Contains(err.Error(), "not HTTP") {
|
||||
t.Errorf("nfs-only: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmwarePollAndInventory(t *testing.T) {
|
||||
f := &fwBMC{}
|
||||
var p fwPollResult
|
||||
_ = json.Unmarshal([]byte(runFW(t, f, "fw-poll", "/redfish/v1/TaskService/Tasks/3")), &p)
|
||||
if p.State != "running" || p.Pct != 40 {
|
||||
t.Errorf("first poll %+v", p)
|
||||
}
|
||||
var inv []fwComponent
|
||||
_ = json.Unmarshal([]byte(runFW(t, f, "fw-inventory", nil)), &inv)
|
||||
if len(inv) != 2 || inv[0].Component != "bios" || inv[1].Component != "bmc" || inv[0].Version != "2.19.1" {
|
||||
t.Errorf("inventory %+v", inv)
|
||||
}
|
||||
pkgs := []FWPackage{{ID: "a", Vendor: "idrac", Component: "bios", Version: "2.19.1"}, {ID: "b", Vendor: "idrac", Component: "bmc", Version: "7.20.10.50"}}
|
||||
offers := fwOffers(pkgs, inv)
|
||||
if !offers[0].Same || offers[1].Same || offers[1].Installed != "7.10.30.00" {
|
||||
t.Errorf("offers %+v", offers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmwareNeedsRedfish(t *testing.T) {
|
||||
if _, err := powerRun(context.Background(), "", &bmcCreds{Driver: "ipmi"}, "fw-inventory", ""); err == nil || !strings.Contains(err.Error(), "Redfish") {
|
||||
t.Errorf("ipmi: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmwareRepository(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(t.TempDir(), "BIOS_ABC_WN64_2.19.1.EXE")
|
||||
_ = os.WriteFile(src, []byte("dup"), 0o644)
|
||||
p, err := fwImport(context.Background(), dir, src, FWPackage{Vendor: "idrac", Component: "bios", Version: "2.19.1", Models: []string{"R650"}}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fwIDRe.MatchString(p.ID) || p.Size != 3 || p.File != "BIOS_ABC_WN64_2.19.1.EXE" {
|
||||
t.Errorf("package %+v", p)
|
||||
}
|
||||
if _, err := fwImport(context.Background(), dir, src, FWPackage{Vendor: "idrac", Component: "bios", Version: "x"}, "00"); err == nil {
|
||||
t.Error("a wrong sha256 was accepted")
|
||||
}
|
||||
for _, bad := range []FWPackage{{Vendor: "ipmi", Component: "bios", Version: "1"}, {Vendor: "idrac", Component: "toaster", Version: "1"}, {Vendor: "idrac", Component: "bios"}} {
|
||||
if _, err := fwImport(context.Background(), dir, src, bad, ""); err == nil {
|
||||
t.Errorf("accepted %+v", bad)
|
||||
}
|
||||
}
|
||||
list, _ := fwList(dir)
|
||||
if len(list) != 1 || len(fwFor(list, "idrac", "PowerEdge R650")) != 1 || len(fwFor(list, "idrac", "PowerEdge R760")) != 0 || len(fwFor(list, "ilo", "R650")) != 0 {
|
||||
t.Errorf("list %+v", list)
|
||||
}
|
||||
if _, err := fwGet(dir, "../etc"); err == nil {
|
||||
t.Error("a path as package id")
|
||||
}
|
||||
if err := fwRemove(dir, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range [][2]string{{"2.19.1", "2.19.1"}, {"v2.19.1", "2.19.1"}, {"2.19.1 (Build 4)", "2.19.1"}} {
|
||||
if !fwSameVersion(c[0], c[1]) {
|
||||
t.Errorf("%q vs %q", c[0], c[1])
|
||||
}
|
||||
}
|
||||
if fwSameVersion("2.19.10", "2.19.1") {
|
||||
t.Error("2.19.10 is not 2.19.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmwareKinds(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Installed-159-2.19.1 BIOS": "bios",
|
||||
"Integrated Dell Remote Access Controller": "bmc",
|
||||
"PERC H755 Front": "raid",
|
||||
"RAID Controller": "raid",
|
||||
"iLO 6": "bmc",
|
||||
"Broadcom Adv. Dual 25Gb Ethernet": "nic",
|
||||
"Mellanox ConnectX-6": "nic",
|
||||
"PSU.Slot.1 Power Supply": "psu",
|
||||
"System CPLD": "cpld",
|
||||
"Dell Ent NVMe P5600": "drive",
|
||||
"Lifecycle Controller": "other",
|
||||
"OS Collector": "other",
|
||||
} {
|
||||
if got := fwKind("", in); got != want {
|
||||
t.Errorf("%q: %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmwareFwpkg(t *testing.T) {
|
||||
mk := func(files ...string) string {
|
||||
p := filepath.Join(t.TempDir(), "cp056789.fwpkg")
|
||||
f, _ := os.Create(p)
|
||||
zw := zip.NewWriter(f)
|
||||
for _, n := range files {
|
||||
w, _ := zw.Create(n)
|
||||
_, _ = w.Write([]byte("payload of " + n))
|
||||
}
|
||||
_ = zw.Close()
|
||||
_ = f.Close()
|
||||
return p
|
||||
}
|
||||
dir := t.TempDir()
|
||||
got, err := fwImport(context.Background(), dir, mk("payload.json", "ilo6_166.bin"), FWPackage{Vendor: "ilo", Component: "bmc", Version: "1.66"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.File != "ilo6_166.bin" || got.Size != int64(len("payload of ilo6_166.bin")) || !strings.Contains(got.Notes, "cp056789.fwpkg") {
|
||||
t.Errorf("extracted %+v", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, got.ID, "ilo6_166.bin")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if _, err := fwImport(context.Background(), dir, mk("payload.json", "setup.exe"), FWPackage{Vendor: "ilo", Component: "nic", Version: "1"}, ""); err == nil || !strings.Contains(err.Error(), "0 files") {
|
||||
t.Errorf("agent-only component: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// recBMC records what a firmware update sends: every request, the multipart
|
||||
// parts (JSON parsed), plain form fields and the file's field name.
|
||||
type recBMC struct {
|
||||
mu sync.Mutex
|
||||
vendor string
|
||||
us obj // the UpdateService resource
|
||||
inventory []string // FirmwareInventory ids
|
||||
missing []string // paths that answer 404
|
||||
reqs []recReq
|
||||
}
|
||||
|
||||
type recReq struct {
|
||||
method, path, ifMatch string
|
||||
json obj // JSON body, or the UpdateParameters part
|
||||
parts map[string]obj // JSON parts by name
|
||||
fields map[string]string
|
||||
fileField, fileName string
|
||||
fileBody string
|
||||
}
|
||||
|
||||
func (f *recBMC) find(method, pathPrefix string) *recReq {
|
||||
for i := range f.reqs {
|
||||
if f.reqs[i].method == method && strings.HasPrefix(f.reqs[i].path, pathPrefix) {
|
||||
return &f.reqs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *recBMC) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, m := range f.missing {
|
||||
if r.URL.Path == m {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.URL.Path == "/redfish/v1/SessionService/Sessions" && r.Method == "POST" {
|
||||
w.Header().Set("X-Auth-Token", "tok")
|
||||
w.Header().Set("Location", "/redfish/v1/SessionService/Sessions/1")
|
||||
w.WriteHeader(201)
|
||||
return
|
||||
}
|
||||
if r.Method == "DELETE" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
rq := recReq{method: r.Method, path: r.URL.Path, ifMatch: r.Header.Get("If-Match"), parts: map[string]obj{}, fields: map[string]string{}}
|
||||
if mt, ps, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err == nil && mt == "multipart/form-data" {
|
||||
mr := multipart.NewReader(r.Body, ps["boundary"])
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
b, _ := io.ReadAll(p)
|
||||
switch {
|
||||
case p.FileName() != "":
|
||||
rq.fileField, rq.fileName, rq.fileBody = p.FormName(), p.FileName(), string(b)
|
||||
case strings.HasPrefix(p.Header.Get("Content-Type"), "application/json"):
|
||||
var o obj
|
||||
_ = json.Unmarshal(b, &o)
|
||||
rq.parts[p.FormName()] = o
|
||||
default:
|
||||
rq.fields[p.FormName()] = string(b)
|
||||
}
|
||||
}
|
||||
rq.json = rq.parts["UpdateParameters"]
|
||||
} else if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&rq.json)
|
||||
}
|
||||
if r.Method != "GET" {
|
||||
f.reqs = append(f.reqs, rq)
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/redfish/v1/":
|
||||
_ = json.NewEncoder(w).Encode(obj{"Vendor": f.vendor})
|
||||
case r.URL.Path == "/redfish/v1/UpdateService" && r.Method == "GET":
|
||||
w.Header().Set("ETag", `"us1"`)
|
||||
_ = json.NewEncoder(w).Encode(f.us)
|
||||
case r.URL.Path == "/redfish/v1/UpdateService" && r.Method == "PATCH":
|
||||
for k, v := range rq.json {
|
||||
f.us[k] = v
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
case r.URL.Path == "/redfish/v1/UpdateService/FirmwareInventory" && r.Method == "GET":
|
||||
var ms []obj
|
||||
for _, id := range f.inventory {
|
||||
ms = append(ms, obj{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/" + id})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(obj{"Members": ms})
|
||||
case strings.HasPrefix(r.URL.Path, "/redfish/v1/TaskService/Tasks/"):
|
||||
_ = json.NewEncoder(w).Encode(obj{"TaskState": "Completed", "PercentComplete": 100})
|
||||
case r.Method == "POST":
|
||||
w.Header().Set("Location", "/redfish/v1/TaskService/Tasks/9")
|
||||
w.WriteHeader(202)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fwStartWith runs fw-start for a vendor driver and returns the result and the recorder.
|
||||
func fwStartWith(t *testing.T, f *recBMC, driver string, a fwStartArg, extra map[string]string) (*fwStartResult, error) {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(f.handler())
|
||||
defer srv.Close()
|
||||
pkg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("IMG")) }))
|
||||
defer pkg.Close()
|
||||
if a.URL == "" {
|
||||
a.URL = pkg.URL + "/firmware/p/" + a.File
|
||||
}
|
||||
c := creds(srv.URL, driver)
|
||||
for k, v := range extra {
|
||||
c.Extra[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(a)
|
||||
out, err := powerRun(context.Background(), "", c, "fw-start", string(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res fwStartResult
|
||||
_ = json.Unmarshal([]byte(out), &res)
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func mustStart(t *testing.T, f *recBMC, driver string, a fwStartArg) *fwStartResult {
|
||||
t.Helper()
|
||||
res, err := fwStartWith(t, f, driver, a, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", driver, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestFWPlanDell(t *testing.T) {
|
||||
f := &recBMC{vendor: "Dell", us: obj{"MultipartHttpPushUri": "/redfish/v1/UpdateService/MultipartUpload"}}
|
||||
mustStart(t, f, "idrac", fwStartArg{File: "BIOS_X_WN64_2.19.1.EXE", Component: "bios"})
|
||||
rq := f.find("POST", "/redfish/v1/UpdateService/MultipartUpload")
|
||||
if rq == nil || rq.json["@Redfish.OperationApplyTime"] != "OnReset" || rq.fileField != "UpdateFile" || rq.fileBody != "IMG" {
|
||||
t.Errorf("dell %+v", rq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanSupermicro(t *testing.T) {
|
||||
f := &recBMC{vendor: "Supermicro", us: obj{"MultipartHttpPushUri": "/redfish/v1/UpdateService/upload"}, inventory: []string{"BMC", "BIOS", "Motherboard_CPLD_1"}}
|
||||
mustStart(t, f, "supermicro", fwStartArg{File: "BIOS.bin", Component: "bios"})
|
||||
rq := f.find("POST", "/redfish/v1/UpdateService/upload")
|
||||
if rq == nil || dig(rq.json, "Oem", "Supermicro", "BIOS", "PreserveNVRAM") != true || toList(rq.json["Targets"])[0] != "/redfish/v1/Systems/1/Bios" {
|
||||
t.Errorf("bios params %v", rq.json)
|
||||
}
|
||||
f.reqs = nil
|
||||
mustStart(t, f, "supermicro", fwStartArg{File: "CPLD.bin", Component: "cpld"})
|
||||
if rq := f.find("POST", "/redfish/v1/UpdateService/upload"); toList(rq.json["Targets"])[0] != "/redfish/v1/UpdateService/FirmwareInventory/Motherboard_CPLD_1" {
|
||||
t.Errorf("cpld params %v", rq.json)
|
||||
}
|
||||
// the operator's own parameters (Gen 13 preserve flags)
|
||||
f.reqs = nil
|
||||
if _, err := fwStartWith(t, f, "supermicro", fwStartArg{File: "BIOS.bin", Component: "bios"}, map[string]string{"fw_params": `{"Targets":["/redfish/v1/Systems/1/Bios"],"Oem":{"Supermicro":{"BIOS":{"PreserveOA":true}}}}`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rq := f.find("POST", "/redfish/v1/UpdateService/upload"); dig(rq.json, "Oem", "Supermicro", "BIOS", "PreserveOA") != true {
|
||||
t.Errorf("fw_params %v", rq.json)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanAMI(t *testing.T) {
|
||||
f := &recBMC{us: obj{"MultipartHttpPushUri": "/redfish/v1/UpdateService/upload"}, inventory: []string{"BMC", "BMCImage1", "BIOS", "BIOS2", "MB_CPLD1"}}
|
||||
mustStart(t, f, "gigabyte", fwStartArg{File: "rom.ima_enc", Component: "bmc"})
|
||||
rq := f.find("POST", "/redfish/v1/UpdateService/upload")
|
||||
if rq.parts["OemParameters"]["ImageType"] != "BMC" || toList(rq.json["Targets"])[0] != "/redfish/v1/UpdateService/FirmwareInventory/BMCImage1" {
|
||||
t.Errorf("gigabyte bmc %+v", rq)
|
||||
}
|
||||
f.reqs = nil
|
||||
mustStart(t, f, "gigabyte", fwStartArg{File: "bios.hpm", Component: "bios"})
|
||||
if rq := f.find("POST", "/redfish/v1/UpdateService/upload"); rq.parts["OemParameters"]["ImageType"] != "HPM_BIOS" {
|
||||
t.Errorf("gigabyte hpm bios %+v", rq.parts)
|
||||
}
|
||||
if _, err := fwStartWith(t, &recBMC{us: obj{}, inventory: []string{"BMC"}}, "ami", fwStartArg{File: "x.cap", Component: "bios"}, nil); err == nil || !strings.Contains(err.Error(), "no firmware BIOS") {
|
||||
t.Errorf("missing target: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanXFusion(t *testing.T) {
|
||||
f := &recBMC{us: obj{"Actions": obj{"#UpdateService.SimpleUpdate": obj{"target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate"}}}}
|
||||
mustStart(t, f, "xfusion", fwStartArg{File: "image.hpm", Component: "bmc"})
|
||||
up, su := f.find("POST", "/redfish/v1/UpdateService/FirmwareInventory"), f.find("POST", "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate")
|
||||
if up == nil || up.fileField != "file" || len(up.parts) != 0 || su == nil || su.json["ImageURI"] != "/tmp/web/image.hpm" {
|
||||
t.Errorf("upload %+v simple %+v", up, su)
|
||||
}
|
||||
if _, err := fwStartWith(t, f, "xfusion", fwStartArg{File: "nic.hpm", Component: "nic"}, nil); err == nil {
|
||||
t.Error("in-band NIC firmware accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanKaytus(t *testing.T) {
|
||||
f := &recBMC{us: obj{"ServiceEnabled": true, "Actions": obj{"#UpdateService.SimpleUpdate": obj{"target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate"}}}}
|
||||
res := mustStart(t, f, "kaytus", fwStartArg{File: "bios.bin", Component: "bios"})
|
||||
up, su := f.find("POST", "/redfish/v1/UpdateService/FirmwareInventory"), f.find("POST", "/redfish/v1/UpdateService/Actions")
|
||||
if up.fileField != "fwimage" || up.fields["CurrentPassword"] != "secret" || up.fields["EncryptFlag"] != "False" {
|
||||
t.Errorf("upload %+v", up)
|
||||
}
|
||||
if dig(su.json, "Oem", "Public", "FlashItem") != "BIOS" || dig(su.json, "Oem", "Public", "BiosFlash") != "Both" {
|
||||
t.Errorf("flash %v", su.json)
|
||||
}
|
||||
if res.Task == "" {
|
||||
t.Errorf("no task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanIRMC(t *testing.T) {
|
||||
f := &recBMC{vendor: "ts_fujitsu", us: obj{}, missing: []string{"/redfish/v1/Managers/iRMC/Actions/FTSManager.FWUpdate"}}
|
||||
mustStart(t, f, "irmc", fwStartArg{File: "D3279_09.09F.bin", Component: "bmc"})
|
||||
rq := f.find("POST", "/redfish/v1/Managers/iRMC/Actions/Oem/FTSManager.FWUpdate")
|
||||
if rq == nil || rq.fileField != "data" || rq.json != nil {
|
||||
t.Errorf("irmc %+v (all %+v)", rq, f.reqs)
|
||||
}
|
||||
g := &recBMC{vendor: "Fsas", us: obj{}}
|
||||
mustStart(t, g, "irmc", fwStartArg{File: "D3279-B1x.R1.20.0.UPC", Component: "bios"})
|
||||
if g.find("POST", "/redfish/v1/Systems/0/Bios/Actions/Oem/FsasBios.BiosUpdate") == nil {
|
||||
t.Errorf("fsas bios %+v", g.reqs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanLenovoClaims(t *testing.T) {
|
||||
f := &recBMC{vendor: "Lenovo", us: obj{"MultipartHttpPushUri": "/mfwupdate", "HttpPushUriTargetsBusy": false}}
|
||||
mustStart(t, f, "xcc", fwStartArg{File: "lnvgy_fw_xcc.uxz", Component: "bmc"})
|
||||
if f.us["HttpPushUriTargetsBusy"] != true || f.find("POST", "/mfwupdate") == nil || f.find("PATCH", "/redfish/v1/UpdateService").ifMatch != `"us1"` {
|
||||
t.Errorf("claim %v %+v", f.us, f.reqs)
|
||||
}
|
||||
if _, err := fwStartWith(t, f, "xcc", fwStartArg{File: "x.uxz", Component: "bmc"}, nil); err == nil || !strings.Contains(err.Error(), "busy") {
|
||||
t.Errorf("second upload while busy: %v", err)
|
||||
}
|
||||
srv := httptest.NewTLSServer(f.handler())
|
||||
defer srv.Close()
|
||||
if _, err := powerRun(context.Background(), "", creds(srv.URL, "xcc"), "fw-finish", ""); err != nil || f.us["HttpPushUriTargetsBusy"] != false {
|
||||
t.Errorf("release: %v %v", err, f.us)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPlanILOAndCisco(t *testing.T) {
|
||||
f := &recBMC{vendor: "HPE", us: obj{"Actions": obj{"#UpdateService.SimpleUpdate": obj{"target": "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate"}}}}
|
||||
mustStart(t, f, "ilo", fwStartArg{File: "ilo6_166.bin", Component: "bmc"})
|
||||
if rq := f.find("POST", "/redfish/v1/UpdateService/Actions"); rq == nil || !strings.HasSuffix(str(rq.json["ImageURI"]), "/ilo6_166.bin") {
|
||||
t.Errorf("ilo %+v", f.reqs)
|
||||
}
|
||||
if _, err := fwStartWith(t, &recBMC{us: obj{}}, "cimc", fwStartArg{File: "x.bin", Component: "bios"}, nil); err == nil || !strings.Contains(err.Error(), "SCP") {
|
||||
t.Errorf("cimc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFWPollStaged(t *testing.T) {
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/redfish/v1/TaskService/Tasks/JID_1":
|
||||
_ = json.NewEncoder(w).Encode(obj{"TaskState": "Running", "PercentComplete": 100, "Messages": []obj{{"Message": "Job scheduled successfully."}}})
|
||||
case "/redfish/v1/TaskService/Tasks/BIOS0":
|
||||
_ = json.NewEncoder(w).Encode(obj{"TaskState": "Completed", "PercentComplete": 100})
|
||||
case "/redfish/v1/TaskService/Tasks/BIOS1":
|
||||
_ = json.NewEncoder(w).Encode(obj{"TaskState": "Running", "PercentComplete": 30})
|
||||
case "/redfish/v1/JobService/Jobs/JID_2":
|
||||
_ = json.NewEncoder(w).Encode(obj{"JobState": "Completed", "PercentComplete": 100})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
poll := func(task string) fwPollResult {
|
||||
out, err := powerRun(context.Background(), "", creds(srv.URL, "idrac"), "fw-poll", task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p fwPollResult
|
||||
_ = json.Unmarshal([]byte(out), &p)
|
||||
return p
|
||||
}
|
||||
if p := poll("/redfish/v1/TaskService/Tasks/JID_1"); p.State != "done" {
|
||||
t.Errorf("scheduled job: %+v", p)
|
||||
}
|
||||
if p := poll("/redfish/v1/TaskService/Tasks/BIOS0,/redfish/v1/TaskService/Tasks/BIOS1"); p.State != "running" || p.Pct != 30 {
|
||||
t.Errorf("second of two: %+v", p)
|
||||
}
|
||||
if p := poll("/redfish/v1/TaskService/Tasks/JID_2"); p.State != "done" { // moved to the job service
|
||||
t.Errorf("job service: %+v", p)
|
||||
}
|
||||
if p := poll("/redfish/v1/TaskService/Tasks/GONE"); p.State != "unknown" {
|
||||
t.Errorf("gone: %+v", p)
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
module rollcall
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
@@ -9,7 +9,8 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/mattn/go-isatty v0.0.24
|
||||
golang.org/x/crypto v0.37.0
|
||||
github.com/miekg/dns v1.1.73
|
||||
golang.org/x/crypto v0.54.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.4
|
||||
)
|
||||
|
||||
@@ -33,7 +34,8 @@ require (
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
+14
-10
@@ -41,6 +41,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
@@ -58,19 +60,21 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
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/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
// UEFI machines boot through shim and GRUB, both signed (Debian's
|
||||
// shim-signed and grub-efi-amd64-signed), so a machine with Secure Boot on
|
||||
// PXE-boots at all (syslinux.efi is unsigned: the firmware refuses it) and
|
||||
// GRUB loads only kernels that shim verifies. BIOS machines keep pxelinux.
|
||||
//
|
||||
// TFTP root:
|
||||
//
|
||||
// bootx64.efi shim (shimx64.efi.signed)
|
||||
// grubx64.efi GRUB for network boot (grubnetx64.efi.signed)
|
||||
// grub/grub.cfg picks grub.cfg-<mac>, else grub.cfg-default
|
||||
// grub/grub.cfg-<mac> per machine, made from its pxelinux file
|
||||
//
|
||||
// The rack controller writes the GRUB files from the pxelinux ones it gets,
|
||||
// so both menus always say the same. Secure Boot checks the kernel; the
|
||||
// initrd and the squashfs it fetches are not covered by it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const grubDispatch = `# generated by rollcall: the machine's own menu, else the one for unknown machines
|
||||
terminal_output console
|
||||
set timeout=8
|
||||
configfile $prefix/grub.cfg-$net_default_mac
|
||||
configfile $prefix/grub.cfg-default
|
||||
`
|
||||
|
||||
var grubNameRe = regexp.MustCompile(`^grub\.cfg-([0-9a-f]{2}:){5}[0-9a-f]{2}$`)
|
||||
|
||||
// grubName is the GRUB file for a pxelinux one: 01-aa-bb-cc-dd-ee-ff -> grub.cfg-aa:bb:cc:dd:ee:ff.
|
||||
func grubName(pxe string) string {
|
||||
if !strings.HasPrefix(pxe, "01-") {
|
||||
return ""
|
||||
}
|
||||
n := "grub.cfg-" + strings.ReplaceAll(strings.TrimPrefix(pxe, "01-"), "-", ":")
|
||||
if !grubNameRe.MatchString(n) {
|
||||
return ""
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type pxeEntry struct {
|
||||
label, kernel, initrd, append string
|
||||
local, poweroff bool
|
||||
}
|
||||
|
||||
// pxelinuxToGrub turns one of rollcall's pxelinux menus into the same GRUB menu.
|
||||
func pxelinuxToGrub(body string) string {
|
||||
var entries []*pxeEntry
|
||||
def, timeout := 0, 5
|
||||
var cur *pxeEntry
|
||||
for _, raw := range strings.Split(body, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key := strings.ToUpper(f[0])
|
||||
rest := strings.TrimSpace(line[len(f[0]):])
|
||||
switch {
|
||||
case key == "TIMEOUT":
|
||||
if n, err := strconv.Atoi(rest); err == nil { // tenths of a second
|
||||
timeout = (n + 9) / 10
|
||||
}
|
||||
case key == "LABEL":
|
||||
cur = &pxeEntry{label: rest}
|
||||
entries = append(entries, cur)
|
||||
case cur == nil:
|
||||
case key == "MENU" && len(f) > 1 && strings.EqualFold(f[1], "LABEL"):
|
||||
cur.label = strings.TrimSpace(rest[len(f[1]):])
|
||||
case key == "MENU" && len(f) > 1 && strings.EqualFold(f[1], "DEFAULT"):
|
||||
def = len(entries) - 1
|
||||
case key == "KERNEL":
|
||||
if strings.HasSuffix(rest, "poweroff.c32") {
|
||||
cur.poweroff = true
|
||||
} else {
|
||||
cur.kernel = rest
|
||||
}
|
||||
case key == "INITRD":
|
||||
cur.initrd = rest
|
||||
case key == "APPEND":
|
||||
cur.append = rest
|
||||
case key == "LOCALBOOT":
|
||||
cur.local = true
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# generated by rollcall from the pxelinux menu\n")
|
||||
// the firmware's text console: GRUB does not switch the display mode, so the OS
|
||||
// it starts gets the firmware's own (OVMF showed a garbled console after a switch),
|
||||
// and serial/SOL consoles see the menu
|
||||
b.WriteString("terminal_output console\n")
|
||||
fmt.Fprintf(&b, "set default=%d\nset timeout=%d\n", def, timeout)
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "menuentry %s {\n", grubQuote(e.label))
|
||||
switch {
|
||||
case e.poweroff:
|
||||
b.WriteString(" halt\n")
|
||||
case e.local:
|
||||
b.WriteString(grubLocalBoot)
|
||||
case e.kernel != "":
|
||||
fmt.Fprintf(&b, " linux %s", grubQuote("/"+strings.TrimPrefix(e.kernel, "/")))
|
||||
for _, w := range strings.Fields(e.append) {
|
||||
b.WriteString(" " + grubQuote(w))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if e.initrd != "" {
|
||||
fmt.Fprintf(&b, " initrd %s\n", grubQuote("/"+strings.TrimPrefix(e.initrd, "/")))
|
||||
}
|
||||
default:
|
||||
b.WriteString(" exit 1\n")
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// grubLocalBoot starts the installed OS by chainloading its signed loader
|
||||
// from the disk's EFI partition (shim, which Secure Boot accepts). Handing
|
||||
// back to the firmware with "exit" is not enough: firmware then tries its
|
||||
// other network boot options and ends in its own menu (OVMF does).
|
||||
const grubLocalBoot = ` insmod part_gpt
|
||||
insmod fat
|
||||
insmod chain
|
||||
for f in /EFI/debian/shimx64.efi /EFI/ubuntu/shimx64.efi /EFI/rocky/shimx64.efi /EFI/almalinux/shimx64.efi /EFI/redhat/shimx64.efi /EFI/centos/shimx64.efi /EFI/opensuse/shim.efi /EFI/sles/shim.efi /EFI/BOOT/BOOTX64.EFI; do
|
||||
if search --no-floppy --set=root --file $f; then
|
||||
chainloader $f
|
||||
boot
|
||||
fi
|
||||
done
|
||||
echo "no EFI loader found on the disks"
|
||||
sleep 10
|
||||
exit 1
|
||||
`
|
||||
|
||||
var grubPlain = regexp.MustCompile(`^[A-Za-z0-9_./:=,+@%-]+$`)
|
||||
|
||||
// grubQuote makes a word safe in GRUB's script language (no $ expansion, no ;).
|
||||
func grubQuote(s string) string {
|
||||
if grubPlain.MatchString(s) {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPxelinuxToGrub(t *testing.T) {
|
||||
commissioned := fmt.Sprintf(commissionedTmpl, "now", "m-1", "QEMU", "PC", "S1", "m-1", "QEMU", "PC", "S1",
|
||||
"boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs rollcall.server=http://10.213.77.1 rollcall.token=abc")
|
||||
g := pxelinuxToGrub(commissioned)
|
||||
for _, want := range []string{
|
||||
"set default=0\nset timeout=15\n",
|
||||
"menuentry 'Commissioned: power off (default in 15s)' {\n halt\n}",
|
||||
"menuentry 'Re-run commissioning' {\n linux /rollcall/vmlinuz boot=live components fetch=http://10.213.77.1/rollcall/filesystem.squashfs",
|
||||
" initrd /rollcall/initrd.img\n",
|
||||
"menuentry 'Boot from local disk' {\n insmod part_gpt\n",
|
||||
"chainloader $f",
|
||||
} {
|
||||
if !strings.Contains(g, want) {
|
||||
t.Errorf("missing %q in\n%s", want, g)
|
||||
}
|
||||
}
|
||||
deployed := fmt.Sprintf(deployedTmpl, "now", "m-1", "debian-13", "1", "node01", "debian-13", "m-1", "debian-13", "boot=live")
|
||||
if g := pxelinuxToGrub(deployed); !strings.Contains(g, "set default=0\n") || !strings.Contains(g, "/EFI/debian/shimx64.efi") {
|
||||
t.Errorf("deployed:\n%s", g)
|
||||
}
|
||||
// nothing from a cmdline can break out of the menu entry
|
||||
g = pxelinuxToGrub("LABEL x\n MENU DEFAULT\n KERNEL k\n APPEND a=$(x) b;reboot c='q'\n")
|
||||
if !strings.Contains(g, `'a=$(x)' 'b;reboot' 'c='\''q'\'''`) {
|
||||
t.Errorf("quoting:\n%s", g)
|
||||
}
|
||||
if n := grubName("01-bc-24-11-f7-53-6b"); n != "grub.cfg-bc:24:11:f7:53:6b" {
|
||||
t.Errorf("name %q", n)
|
||||
}
|
||||
if grubName("01-../x") != "" || grubName("default") != "" {
|
||||
t.Error("a non-MAC file name became a GRUB file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
// How many rack units a machine takes, so nobody has to type it in: the
|
||||
// operator's rack_units for the model, else what the firmware says (SMBIOS
|
||||
// type 3 "Height", read at commissioning), else the model. A sled in a
|
||||
// multi-node chassis (Dell C-series, blades) takes no unit of its own: its
|
||||
// height is the chassis's, and it is marked as a node.
|
||||
//
|
||||
// The model table holds only heights checked against the vendor's or a
|
||||
// reliable listing (October 2026):
|
||||
// Dell: PowerEdge Rack Quick Reference Guide (R6xx 1U, R7xx 2U, R860 2U,
|
||||
// R940 3U, R960 4U); C6400/C6600: four sleds in 2U.
|
||||
// HPE: QuickSpecs (DL325 Gen11 1U, DL345 Gen11 2U, DL560 Gen11 2U; DL360 1U,
|
||||
// DL380 2U).
|
||||
// Lenovo: Lenovo Press (SR850 V3 2U, SR860 V3 4U, SR950 V3 8U, SR950 4U;
|
||||
// SR630 1U, SR650 2U).
|
||||
// xFusion: product pages (1288H 1U, 2288H 2U, 2488H 2U).
|
||||
// Fujitsu: datasheets (RX2530 1U, RX2540 2U), Red Hat catalog (RX4770 M7 3U).
|
||||
// Inspur/KAYTUS: heights in mm from a vendor-sourced table (NF5180M6 43 mm,
|
||||
// NF5280M6 87 mm, NF5468M6 175.5 mm).
|
||||
// GIGABYTE: R1xx 1U, R2xx 2U, G2xx 2U, G4xx 4U, from the vendor's listings.
|
||||
// Supermicro's first digit is not the height (SYS-6029 comes in 1U and 2U),
|
||||
// so Supermicro is left to SMBIOS or rack_units.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type rackSize struct {
|
||||
U int `json:"u"` // rack units, 0 = unknown
|
||||
Node bool `json:"node"` // a sled sharing a chassis of U units
|
||||
Source string `json:"source"` // config | smbios | model | ""
|
||||
}
|
||||
|
||||
type sizeRule struct {
|
||||
vendor *regexp.Regexp
|
||||
model *regexp.Regexp
|
||||
u int
|
||||
node bool
|
||||
}
|
||||
|
||||
func rx(s string) *regexp.Regexp { return regexp.MustCompile(`(?i)` + s) }
|
||||
|
||||
var sizeRules = []sizeRule{
|
||||
{rx(`dell`), rx(`\bC6[456][0-9]{2}\b`), 2, true}, // C6420, C6520, C6525, C6620, C6615 sleds
|
||||
{rx(`dell`), rx(`\bR6[0-9]{2,3}\b`), 1, false}, // R640, R650, R660, R6515, R6625
|
||||
{rx(`dell`), rx(`\bR7[0-9]{2,3}\b`), 2, false}, // R740, R750, R760, R7525, R7625
|
||||
{rx(`dell`), rx(`\bR860\b`), 2, false}, //
|
||||
{rx(`dell`), rx(`\bR940\b`), 3, false}, //
|
||||
{rx(`dell`), rx(`\bR960\b`), 4, false}, //
|
||||
{rx(`hpe?\b|hewlett`), rx(`\bDL3(25|60)\b`), 1, false}, //
|
||||
{rx(`hpe?\b|hewlett`), rx(`\bDL3(45|80)\b|\bDL560\b`), 2, false},
|
||||
{rx(`lenovo`), rx(`\bSR950 V3\b`), 8, false}, // before the plain SR950
|
||||
{rx(`lenovo`), rx(`\bSR950\b`), 4, false}, //
|
||||
{rx(`lenovo`), rx(`\bSR860 V3\b`), 4, false}, //
|
||||
{rx(`lenovo`), rx(`\bSR850 V3\b`), 2, false}, //
|
||||
{rx(`lenovo`), rx(`\bSR630\b`), 1, false}, // SR630, SR630 V2, V3
|
||||
{rx(`lenovo`), rx(`\bSR650\b`), 2, false}, //
|
||||
{rx(`xfusion|huawei`), rx(`\b1288H\b`), 1, false}, //
|
||||
{rx(`xfusion|huawei`), rx(`\b2[24]88H\b`), 2, false}, //
|
||||
{rx(`fujitsu`), rx(`\bRX2530\b`), 1, false}, //
|
||||
{rx(`fujitsu`), rx(`\bRX2540\b`), 2, false}, //
|
||||
{rx(`fujitsu`), rx(`\bRX4770 M7\b`), 3, false}, //
|
||||
{rx(`inspur|kaytus`), rx(`\bNF5180M6\b`), 1, false}, //
|
||||
{rx(`inspur|kaytus`), rx(`\bNF5280M6\b`), 2, false}, //
|
||||
{rx(`inspur|kaytus`), rx(`\bNF5468M6\b`), 4, false}, //
|
||||
{rx(`giga-?byte`), rx(`^R1[0-9]{2}-`), 1, false}, // R183-Z94-…
|
||||
{rx(`giga-?byte`), rx(`^[RG]2[0-9]{2}-`), 2, false}, // R283-…, G293-…
|
||||
{rx(`giga-?byte`), rx(`^G4[0-9]{2}-`), 4, false}, // G493-…
|
||||
}
|
||||
|
||||
// machineSize: the machine's height in rack units and where it came from.
|
||||
func machineSize(m *Machine, override map[string]int) rackSize {
|
||||
mfr, model := dmiValue(deref(m.Manufacturer)), dmiValue(deref(m.Product))
|
||||
sys := asObj(asObj(invOf(m))["system"])
|
||||
node := false
|
||||
if ct := strings.ToLower(str(sys["chassis-type"])); strings.Contains(ct, "multi-system") || strings.Contains(ct, "blade") {
|
||||
node = true
|
||||
}
|
||||
if model != "" { // "vendor model" before the bare model: both may be set, map order must not choose
|
||||
for _, want := range []string{mfr + " " + model, model} {
|
||||
for k, u := range override {
|
||||
if strings.EqualFold(k, want) {
|
||||
return rackSize{U: u, Node: node, Source: "config"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, ok := sys["chassis-height-u"].(float64); ok && u > 0 && u <= 60 {
|
||||
return rackSize{U: int(u), Node: node, Source: "smbios"}
|
||||
}
|
||||
for _, r := range sizeRules {
|
||||
if r.vendor.MatchString(mfr) && r.model.MatchString(model) {
|
||||
return rackSize{U: r.u, Node: node || r.node, Source: "model"}
|
||||
}
|
||||
}
|
||||
return rackSize{Node: node}
|
||||
}
|
||||
|
||||
func invOf(m *Machine) obj {
|
||||
var inv obj
|
||||
_ = json.Unmarshal(m.Inventory, &inv)
|
||||
return inv
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sizedMachine(mfr, product string, sys map[string]any) *Machine {
|
||||
inv, _ := json.Marshal(map[string]any{"system": sys})
|
||||
return &Machine{ID: "m-1", Manufacturer: &mfr, Product: &product, Inventory: inv}
|
||||
}
|
||||
|
||||
func TestMachineSize(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
mfr, product string
|
||||
sys map[string]any
|
||||
want rackSize
|
||||
}{
|
||||
{"Dell Inc.", "PowerEdge R750", nil, rackSize{2, false, "model"}},
|
||||
{"Dell Inc.", "PowerEdge R6515", nil, rackSize{1, false, "model"}},
|
||||
{"Dell Inc.", "PowerEdge R940", nil, rackSize{3, false, "model"}},
|
||||
{"Dell Inc.", "PowerEdge C6620", nil, rackSize{2, true, "model"}},
|
||||
{"HPE", "ProLiant DL380 Gen10", nil, rackSize{2, false, "model"}},
|
||||
{"HPE", "ProLiant DL325 Gen11", nil, rackSize{1, false, "model"}},
|
||||
{"Lenovo", "ThinkSystem SR950 V3 -[7DC5CTO1WW]-", nil, rackSize{8, false, "model"}},
|
||||
{"Lenovo", "ThinkSystem SR950 -[7X12CTO1WW]-", nil, rackSize{4, false, "model"}},
|
||||
{"Lenovo", "ThinkSystem SR650 V2 -[7Z73CTO1WW]-", nil, rackSize{2, false, "model"}},
|
||||
{"XFUSION", "2288H V7", nil, rackSize{2, false, "model"}},
|
||||
{"Inspur", "NF5468M6", nil, rackSize{4, false, "model"}},
|
||||
{"GIGABYTE", "R283-Z92-AAD1-000", nil, rackSize{2, false, "model"}},
|
||||
{"GIGABYTE", "G493-SB3-AAP1-000", nil, rackSize{4, false, "model"}},
|
||||
{"Supermicro", "SYS-2029P-TRT", nil, rackSize{}}, // first digit is not the height
|
||||
{"QEMU", "Standard PC (i440FX + PIIX, 1996)", nil, rackSize{}}, // unknown
|
||||
{"Supermicro", "Super Server", map[string]any{"chassis-height-u": 2}, rackSize{2, false, "smbios"}},
|
||||
{"Dell Inc.", "PowerEdge R650", map[string]any{"chassis-height-u": 0}, rackSize{1, false, "model"}}, // 0 = unspecified
|
||||
{"Acme", "X1", map[string]any{"chassis-type": "Multi-system chassis"}, rackSize{0, true, ""}},
|
||||
} {
|
||||
if got := machineSize(sizedMachine(c.mfr, c.product, c.sys), nil); got != c.want {
|
||||
t.Errorf("%s %s: %+v, want %+v", c.mfr, c.product, got, c.want)
|
||||
}
|
||||
}
|
||||
// the operator's word wins over the firmware's, the firmware's over the table's
|
||||
m := sizedMachine("Dell Inc.", "PowerEdge R750", map[string]any{"chassis-height-u": 1})
|
||||
if got := machineSize(m, nil); got.U != 1 || got.Source != "smbios" {
|
||||
t.Errorf("smbios before the table: %+v", got)
|
||||
}
|
||||
if got := machineSize(m, map[string]int{"Dell Inc. PowerEdge R750": 3}); got.U != 3 || got.Source != "config" {
|
||||
t.Errorf("config first: %+v", got)
|
||||
}
|
||||
if typeHeight(rackSize{2, true, "model"}) != 0 || typeHeight(rackSize{}) != 1 || typeHeight(rackSize{U: 4}) != 4 {
|
||||
t.Error("typeHeight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxDeviceHeights(t *testing.T) {
|
||||
nb, f := testDCIM(t)
|
||||
tagged := []any{map[string]any{"name": "rollcall"}}
|
||||
mid := f.put("dcim/manufacturers/", map[string]any{"name": "Dell Inc.", "slug": "dell-inc"})
|
||||
r750 := f.put("dcim/device-types/", map[string]any{"manufacturer": float64(mid), "model": "PowerEdge R750", "slug": "poweredge-r750", "u_height": float64(1), "tags": tagged})
|
||||
s := func(v string) *string { return &v }
|
||||
mk := func(id, product string) *Machine {
|
||||
inv, _ := json.Marshal(obj{"network": []obj{}})
|
||||
return &Machine{ID: id, State: "deployed", NodeName: s(id), Manufacturer: s("Dell Inc."), Product: s(product), Inventory: inv,
|
||||
Location: Location{Building: "DC1", Rack: "R12", Unit: 10}}
|
||||
}
|
||||
rep := nb.syncDevices(t.Context(), DeviceSyncConfig{Site: "ev"}, []*Machine{mk("big", "PowerEdge R750"), mk("sled", "PowerEdge C6620")}, nil, nil)
|
||||
if rep.Error != "" {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
if f.objs["dcim/device-types/"][r750]["u_height"] != float64(2) {
|
||||
t.Fatalf("Rollcall's R750 type kept u_height %v", f.objs["dcim/device-types/"][r750]["u_height"])
|
||||
}
|
||||
sledType := f.find("dcim/device-types/", "model", "PowerEdge C6620")
|
||||
sled := f.find("dcim/devices/", "name", "sled")
|
||||
if sledType["u_height"] != float64(0) || sled["rack"] == nil || sled["position"] != nil {
|
||||
t.Fatalf("sled: type %v device %v (in the rack, no position)", sledType["u_height"], sled)
|
||||
}
|
||||
if f.find("dcim/devices/", "name", "big")["position"] != float64(10) {
|
||||
t.Fatal("a whole server keeps its position")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
// Infoblox WAPI driver for the IPAM authority. Each Rollcall address is one
|
||||
// record:host holding that address, named by its DNS name (rollcall-<address>
|
||||
// without one) and configured for DNS when it has a name and dns_view is not
|
||||
// "-" (Infoblox then publishes the A/AAAA record itself). Ownership is the extensible
|
||||
// attribute machine_field (default rollcall_machine), which must be defined
|
||||
// in the grid (String). Delegated networks carry the extensible attribute
|
||||
// named by tag (default rollcall). An address is taken when anything that is
|
||||
// not Rollcall's host uses it: a lease, a fixed address, a DHCP or reserved
|
||||
// range, another host, the network or broadcast address.
|
||||
//
|
||||
// Documentation: Infoblox WAPI reference (v2.12): record:host, ipv4address,
|
||||
// ipv6address, network, ipv6network; extensible attribute search (*EA~=),
|
||||
// "extattrs+" to change attributes without dropping others, paging
|
||||
// (_paging, _return_as_object, _page_id).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type infoblox struct {
|
||||
base string // https://gm.example.com/wapi/v2.12
|
||||
user, pass string
|
||||
field, tag string
|
||||
networkView string
|
||||
dnsView string // "" = no DNS (IPAM-only hosts)
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newInfoblox(c IPAMConfig) (*infoblox, error) {
|
||||
if c.URL == "" || c.User == "" || c.PasswordFile == "" {
|
||||
return nil, fmt.Errorf("infoblox: url, user and password_file are required")
|
||||
}
|
||||
pw, err := os.ReadFile(c.PasswordFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("infoblox password: %w", err)
|
||||
}
|
||||
hc, err := httpClientCA(c.CAFile, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("infoblox ca_file: %w", err)
|
||||
}
|
||||
base := strings.TrimRight(c.URL, "/")
|
||||
if !strings.Contains(base, "/wapi/") {
|
||||
base += "/wapi/v2.12"
|
||||
}
|
||||
dv := orDefault(c.DNSView, "default")
|
||||
if dv == "-" {
|
||||
dv = ""
|
||||
}
|
||||
return &infoblox{base: base, user: c.User, pass: strings.TrimSpace(string(pw)), field: c.field(), tag: c.tag(),
|
||||
networkView: orDefault(c.NetworkView, "default"), dnsView: dv, client: hc}, nil
|
||||
}
|
||||
|
||||
func (ib *infoblox) Name() string { return "infoblox" }
|
||||
|
||||
type ibError struct {
|
||||
Code int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *ibError) Error() string {
|
||||
var x struct{ Text, Code string }
|
||||
if json.Unmarshal([]byte(e.Body), &x) == nil && x.Text != "" {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.Code, trunc(x.Text, 300))
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d: %s", e.Code, trunc(e.Body, 300))
|
||||
}
|
||||
|
||||
// do calls WAPI; path is an object type or reference with its query.
|
||||
func (ib *infoblox) do(ctx context.Context, method, path string, q url.Values, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
u := ib.base + "/" + strings.TrimPrefix(path, "/")
|
||||
if len(q) > 0 {
|
||||
u += "?" + q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(ib.user, ib.pass)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := ib.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return &ibError{resp.StatusCode, string(b)}
|
||||
}
|
||||
if out != nil && len(b) > 0 {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// all reads every object of a search through WAPI paging.
|
||||
func (ib *infoblox) all(ctx context.Context, objType string, q url.Values) ([]obj, error) {
|
||||
q.Set("_paging", "1")
|
||||
q.Set("_return_as_object", "1")
|
||||
q.Set("_max_results", "1000")
|
||||
var out []obj
|
||||
for {
|
||||
var p struct {
|
||||
Result []obj `json:"result"`
|
||||
NextPageID string `json:"next_page_id"`
|
||||
}
|
||||
if err := ib.do(ctx, "GET", objType, q, nil, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p.Result...)
|
||||
if p.NextPageID == "" {
|
||||
return out, nil
|
||||
}
|
||||
q = url.Values{"_page_id": {p.NextPageID}}
|
||||
}
|
||||
}
|
||||
|
||||
const ibHostFields = "name,comment,extattrs,configure_for_dns,ipv4addrs,ipv6addrs,view"
|
||||
|
||||
func ibIs6(ip string) bool {
|
||||
a, ok := parseAddr(ip)
|
||||
return ok && a.Is6()
|
||||
}
|
||||
|
||||
// owner is the machine of a host record, "" when it is not Rollcall's.
|
||||
func (ib *infoblox) owner(h obj) string {
|
||||
return str(asObj(asObj(h["extattrs"])[ib.field])["value"])
|
||||
}
|
||||
|
||||
func (ib *infoblox) hostIP(h obj) string {
|
||||
for _, k := range []string{"ipv4addrs", "ipv6addrs"} {
|
||||
for _, a := range toList(h[k]) {
|
||||
for _, f := range []string{"ipv4addr", "ipv6addr"} {
|
||||
if s := str(asObj(a)[f]); s != "" {
|
||||
if p, ok := parseAddr(s); ok {
|
||||
return p.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ib *infoblox) toExt(h obj) extAddr {
|
||||
e := extAddr{ID: str(h["_ref"]), IP: ib.hostIP(h), Machine: ib.owner(h), Desc: str(h["comment"])}
|
||||
if n := strings.ToLower(str(h["name"])); n != ibHostName(e) {
|
||||
e.DNSName = n
|
||||
}
|
||||
return e // Bits and Status are not kept by Infoblox (0 and "": not compared)
|
||||
}
|
||||
|
||||
func ibHostName(a extAddr) string {
|
||||
return "rollcall-" + strings.NewReplacer(".", "-", ":", "-").Replace(a.IP)
|
||||
}
|
||||
|
||||
func (ib *infoblox) hostBody(a extAddr, create bool) obj {
|
||||
dns := a.DNSName != "" && ib.dnsView != ""
|
||||
// the name is the DNS name even when Infoblox does not publish it (IPAM only)
|
||||
b := obj{"name": orDefault(a.DNSName, ibHostName(a)), "comment": trunc(a.Desc, 256), "configure_for_dns": dns}
|
||||
if create {
|
||||
addr := "ipv4addr"
|
||||
if ibIs6(a.IP) {
|
||||
addr = "ipv6addr"
|
||||
}
|
||||
b[addr+"s"] = []obj{{addr: a.IP, "configure_for_dhcp": false}}
|
||||
b["extattrs"] = obj{ib.field: obj{"value": a.Machine}}
|
||||
b["network_view"] = ib.networkView
|
||||
if dns {
|
||||
b["view"] = ib.dnsView
|
||||
}
|
||||
} else {
|
||||
b["extattrs+"] = obj{ib.field: obj{"value": a.Machine}} // other attributes stay
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (ib *infoblox) hostsAt(ctx context.Context, ip string) ([]obj, error) {
|
||||
k := "ipv4addr"
|
||||
if ibIs6(ip) {
|
||||
k = "ipv6addr"
|
||||
}
|
||||
return ib.all(ctx, "record:host", url.Values{k: {ip}, "network_view": {ib.networkView}, "_return_fields": {ibHostFields}})
|
||||
}
|
||||
|
||||
// used: references of whatever uses ip (empty when Infoblox has no network for it).
|
||||
func (ib *infoblox) used(ctx context.Context, ip string) (status string, refs []string, types []string, err error) {
|
||||
t := "ipv4address"
|
||||
if ibIs6(ip) {
|
||||
t = "ipv6address"
|
||||
}
|
||||
var res []obj
|
||||
err = ib.do(ctx, "GET", t, url.Values{"ip_address": {ip}, "network_view": {ib.networkView}, "_return_fields": {"status,types,objects"}}, nil, &res)
|
||||
if err != nil || len(res) == 0 {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
for _, r := range toList(res[0]["objects"]) {
|
||||
refs = append(refs, str(r))
|
||||
}
|
||||
for _, x := range toList(res[0]["types"]) {
|
||||
types = append(types, str(x))
|
||||
}
|
||||
return str(res[0]["status"]), refs, types, nil
|
||||
}
|
||||
|
||||
func (ib *infoblox) Reserve(ctx context.Context, a extAddr) (bool, error) {
|
||||
hosts, err := ib.hostsAt(ctx, a.IP)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
mine := map[string]bool{}
|
||||
var cur obj
|
||||
for _, h := range hosts {
|
||||
if ib.owner(h) == "" {
|
||||
return true, nil // someone else's host
|
||||
}
|
||||
mine[str(h["_ref"])] = true
|
||||
cur = h
|
||||
}
|
||||
status, refs, types, err := ib.used(ctx, a.IP)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, t := range types {
|
||||
switch t {
|
||||
case "NETWORK", "BROADCAST", "DHCP_RANGE", "RESERVED_RANGE":
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
others := 0
|
||||
for _, r := range refs {
|
||||
if !mine[r] {
|
||||
others++
|
||||
}
|
||||
}
|
||||
if others > 0 || (status == "USED" && len(refs) == 0 && len(mine) == 0) {
|
||||
return true, nil // a lease, a fixed address, a discovered or conflicting use
|
||||
}
|
||||
if cur != nil {
|
||||
e := ib.toExt(cur)
|
||||
a.ID = e.ID
|
||||
if e.DNSName != a.DNSName || e.Machine != a.Machine || e.Desc != a.Desc {
|
||||
return false, ib.Update(ctx, a)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return false, ib.do(ctx, "POST", "record:host", nil, ib.hostBody(a, true), nil)
|
||||
}
|
||||
|
||||
func (ib *infoblox) Owned(ctx context.Context) ([]extAddr, error) {
|
||||
hs, err := ib.all(ctx, "record:host", url.Values{"*" + ib.field + "~": {"."}, "network_view": {ib.networkView}, "_return_fields": {ibHostFields}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []extAddr{}
|
||||
for _, h := range hs {
|
||||
if e := ib.toExt(h); e.Machine != "" && e.IP != "" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Update renames, re-owns and re-describes a host; switching DNS on or off
|
||||
// re-creates it (a host's DNS view is fixed at creation).
|
||||
func (ib *infoblox) Update(ctx context.Context, a extAddr) error {
|
||||
var cur obj
|
||||
if err := ib.do(ctx, "GET", a.ID, url.Values{"_return_fields": {ibHostFields}}, nil, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
if ib.owner(cur) == "" {
|
||||
return fmt.Errorf("%s is no longer Rollcall's", a.IP)
|
||||
}
|
||||
if (cur["configure_for_dns"] == true) != (a.DNSName != "" && ib.dnsView != "") {
|
||||
if err := ib.do(ctx, "DELETE", a.ID, nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return ib.do(ctx, "POST", "record:host", nil, ib.hostBody(a, true), nil)
|
||||
}
|
||||
return ib.do(ctx, "PUT", a.ID, nil, ib.hostBody(a, false), nil)
|
||||
}
|
||||
|
||||
func (ib *infoblox) Delete(ctx context.Context, a extAddr) error {
|
||||
var cur obj // re-read: only delete what is still ours
|
||||
if err := ib.do(ctx, "GET", a.ID, url.Values{"_return_fields": {ibHostFields}}, nil, &cur); err != nil {
|
||||
if e, ok := err.(*ibError); ok && (e.Code == 404 || e.Code == 400) {
|
||||
return nil // gone (WAPI answers 400 for a reference that no longer exists)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ib.owner(cur) == "" {
|
||||
return nil
|
||||
}
|
||||
return ib.do(ctx, "DELETE", a.ID, nil, nil, nil)
|
||||
}
|
||||
|
||||
func (ib *infoblox) Prefixes(ctx context.Context) ([]extPrefix, error) {
|
||||
out := []extPrefix{}
|
||||
for _, t := range []string{"network", "ipv6network"} {
|
||||
ns, err := ib.all(ctx, t, url.Values{"*" + ib.tag + "~": {"."}, "network_view": {ib.networkView}, "_return_fields": {"network,comment,extattrs"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range ns {
|
||||
p, err := netip.ParsePrefix(str(n["network"]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ea := asObj(n["extattrs"])
|
||||
e := extPrefix{Prefix: p.Masked().String(), Desc: str(n["comment"]), Site: str(asObj(ea["Site"])["value"])}
|
||||
e.VID, _ = strconv.Atoi(fmt.Sprint(asObj(ea["VLAN"])["value"]))
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeWAPI implements the slice of Infoblox WAPI v2.12 the driver uses:
|
||||
// record:host (search, create, read, update with extattrs+, delete), address
|
||||
// usage (ipv4address/ipv6address), networks and paging.
|
||||
type fakeWAPI struct {
|
||||
mu sync.Mutex
|
||||
next int
|
||||
hosts map[string]map[string]any // ref -> host
|
||||
uses map[string][]string // ip -> other objects using it (leases, fixed addresses)
|
||||
types map[string][]string // ip -> address types (DHCP_RANGE, ...)
|
||||
networks []map[string]any
|
||||
pages map[string][]any
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newFakeWAPI() *fakeWAPI {
|
||||
return &fakeWAPI{hosts: map[string]map[string]any{}, uses: map[string][]string{}, types: map[string][]string{}, pages: map[string][]any{}}
|
||||
}
|
||||
|
||||
func (f *fakeWAPI) host(name, ip string, ea map[string]any, dns bool) string {
|
||||
f.next++
|
||||
ref := fmt.Sprintf("record:host/ZG5zLmhvc3Qk%d:%s/default", f.next, name)
|
||||
k := "ipv4addrs"
|
||||
addr := map[string]any{"ipv4addr": ip}
|
||||
if strings.Contains(ip, ":") {
|
||||
k, addr = "ipv6addrs", map[string]any{"ipv6addr": ip}
|
||||
}
|
||||
eas := map[string]any{}
|
||||
for n, v := range ea {
|
||||
eas[n] = map[string]any{"value": v}
|
||||
}
|
||||
f.hosts[ref] = map[string]any{"_ref": ref, "name": name, "comment": "", "configure_for_dns": dns, k: []any{addr}, "extattrs": eas}
|
||||
return ref
|
||||
}
|
||||
|
||||
func hostIPs(h map[string]any) []string {
|
||||
var out []string
|
||||
for _, k := range []string{"ipv4addrs", "ipv6addrs"} {
|
||||
for _, a := range toList(h[k]) {
|
||||
out = append(out, str(asObj(a)["ipv4addr"])+str(asObj(a)["ipv6addr"]))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeWAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
write := func(code int, v any) { w.WriteHeader(code); _ = json.NewEncoder(w).Encode(v) }
|
||||
if u, p, ok := r.BasicAuth(); !ok || u != "rollcall" || p != "s3cret" {
|
||||
write(401, map[string]any{"text": "Authorization Required"})
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/wapi/v2.12/")
|
||||
q := r.URL.Query()
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if r.Method != "GET" {
|
||||
f.writes = append(f.writes, r.Method+" "+path)
|
||||
}
|
||||
page := func(res []any) {
|
||||
if id := q.Get("_page_id"); id != "" {
|
||||
res = f.pages[id]
|
||||
}
|
||||
const size = 2 // small pages so paging is exercised
|
||||
out := map[string]any{"result": res}
|
||||
if len(res) > size {
|
||||
id := strconv.Itoa(len(f.pages) + 1)
|
||||
f.pages[id] = res[size:]
|
||||
out = map[string]any{"result": res[:size], "next_page_id": id}
|
||||
}
|
||||
write(200, out)
|
||||
}
|
||||
switch {
|
||||
case path == "record:host" && r.Method == "GET":
|
||||
refs := []string{}
|
||||
for ref := range f.hosts {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
sort.Strings(refs)
|
||||
res := []any{}
|
||||
for _, ref := range refs {
|
||||
h := f.hosts[ref]
|
||||
ok := true
|
||||
for k, v := range q {
|
||||
switch {
|
||||
case k == "ipv4addr" || k == "ipv6addr":
|
||||
ok = ok && strings.Contains(" "+strings.Join(hostIPs(h), " ")+" ", " "+v[0]+" ")
|
||||
case strings.HasPrefix(k, "*") && strings.HasSuffix(k, "~"):
|
||||
_, has := asObj(h["extattrs"])[strings.TrimSuffix(k[1:], "~")]
|
||||
ok = ok && has
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
res = append(res, h)
|
||||
}
|
||||
}
|
||||
page(res)
|
||||
case path == "record:host" && r.Method == "POST":
|
||||
ea := map[string]any{}
|
||||
for k, v := range asObj(body["extattrs"]) {
|
||||
ea[k] = asObj(v)["value"]
|
||||
}
|
||||
ip := ""
|
||||
if a := toList(body["ipv4addrs"]); len(a) > 0 {
|
||||
ip = str(asObj(a[0])["ipv4addr"])
|
||||
} else if a := toList(body["ipv6addrs"]); len(a) > 0 {
|
||||
ip = str(asObj(a[0])["ipv6addr"])
|
||||
}
|
||||
if body["configure_for_dns"] == true && body["view"] != "default" {
|
||||
write(400, map[string]any{"Error": "AdmConDataNotFoundError", "code": "Client.Ibap.Data.NotFound", "text": "view not found"})
|
||||
return
|
||||
}
|
||||
ref := f.host(str(body["name"]), ip, ea, body["configure_for_dns"] == true)
|
||||
f.hosts[ref]["comment"] = body["comment"]
|
||||
write(201, ref)
|
||||
case path == "ipv4address" || path == "ipv6address":
|
||||
ip := q.Get("ip_address")
|
||||
objs := append([]string{}, f.uses[ip]...)
|
||||
for ref, h := range f.hosts {
|
||||
for _, x := range hostIPs(h) {
|
||||
if x == ip {
|
||||
objs = append(objs, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
status := "UNUSED"
|
||||
if len(objs) > 0 {
|
||||
status = "USED"
|
||||
}
|
||||
write(200, []any{map[string]any{"ip_address": ip, "status": status, "objects": objs, "types": f.types[ip]}})
|
||||
case path == "network" || path == "ipv6network":
|
||||
res := []any{}
|
||||
for _, n := range f.networks {
|
||||
if strings.Contains(str(n["network"]), ":") == (path == "ipv6network") {
|
||||
res = append(res, n)
|
||||
}
|
||||
}
|
||||
page(res)
|
||||
case strings.HasPrefix(path, "record:host/"):
|
||||
h, ok := f.hosts[path]
|
||||
if !ok {
|
||||
write(400, map[string]any{"Error": "AdmConProtoError: Unknown object", "text": "Unknown object"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
write(200, h)
|
||||
case "PUT":
|
||||
for k, v := range body {
|
||||
if k == "extattrs+" {
|
||||
for n, x := range asObj(v) {
|
||||
asObj(h["extattrs"])[n] = x
|
||||
}
|
||||
continue
|
||||
}
|
||||
h[k] = v
|
||||
}
|
||||
write(200, path)
|
||||
case "DELETE":
|
||||
delete(f.hosts, path)
|
||||
write(200, path)
|
||||
}
|
||||
default:
|
||||
write(400, map[string]any{"text": "unknown " + path})
|
||||
}
|
||||
}
|
||||
|
||||
func testInfoblox(t *testing.T, dnsView string) (*App, *fakeWAPI) {
|
||||
t.Helper()
|
||||
f := newFakeWAPI()
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
pw := filepath.Join(t.TempDir(), "pw")
|
||||
if err := os.WriteFile(pw, []byte("s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &Config{IPAM: IPAMConfig{Provider: "infoblox", URL: srv.URL, User: "rollcall", PasswordFile: pw, DNSZone: "int.example", DNSView: dnsView}}
|
||||
p, err := newIPAMProvider(cfg.IPAM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{cfg: cfg, ipam: p, ipamSt: &ipamState{kick: make(chan struct{}, 1)}}
|
||||
a.subnets = []Subnet{{CIDR: "10.213.77.0/24", Purpose: "provisioning"}, {CIDR: "fd00:213:77::/64", Purpose: "provisioning"}}
|
||||
return a, f
|
||||
}
|
||||
|
||||
func TestInfobloxReserve(t *testing.T) {
|
||||
a, f := testInfoblox(t, "")
|
||||
ctx := context.Background()
|
||||
f.host("printer.int.example", "10.213.77.20", nil, true) // someone else's host
|
||||
f.uses["10.213.77.21"] = []string{"lease/ZG5zLmxlYXNl:10.213.77.21/default"} // a lease
|
||||
f.types["10.213.77.22"] = []string{"DHCP_RANGE"} // inside a DHCP range
|
||||
mine := f.host("old.int.example", "10.213.77.23", map[string]any{"rollcall_machine": "m-1", "Owner": "ops"}, true)
|
||||
f.uses["10.213.77.24"] = []string{"fixedaddress/ZG5z:10.213.77.24/default"} // a fixed address
|
||||
for ip, want := range map[string]bool{"10.213.77.20": true, "10.213.77.21": true, "10.213.77.22": true, "10.213.77.23": false, "10.213.77.24": true, "10.213.77.25": false} {
|
||||
taken, err := a.ipam.Reserve(ctx, a.extFor(IPAddr{IP: ip, Subnet: "10.213.77.0/24", MachineID: "m-2", Hostname: "node002", Iface: "eth0"}, &a.subnets[0]))
|
||||
if err != nil {
|
||||
t.Fatal(ip, err)
|
||||
}
|
||||
if taken != want {
|
||||
t.Errorf("%s: taken=%v want %v", ip, taken, want)
|
||||
}
|
||||
}
|
||||
h := f.hosts[mine]
|
||||
if h["name"] != "node002.int.example" || asObj(asObj(h["extattrs"])["rollcall_machine"])["value"] != "m-2" ||
|
||||
asObj(asObj(h["extattrs"])["Owner"])["value"] != "ops" {
|
||||
t.Errorf("re-owned host %v (other extensible attributes must stay)", h)
|
||||
}
|
||||
var made map[string]any
|
||||
for _, x := range f.hosts {
|
||||
if hostIPs(x)[0] == "10.213.77.25" {
|
||||
made = x
|
||||
}
|
||||
}
|
||||
if made == nil || made["name"] != "node002.int.example" || made["configure_for_dns"] != true || made["comment"] != "rollcall m-2 eth0" {
|
||||
t.Fatalf("new host %v", made)
|
||||
}
|
||||
// v6 and a name-less reservation
|
||||
if _, err := a.ipam.Reserve(ctx, a.extFor(IPAddr{IP: "fd00:213:77::50", Subnet: "fd00:213:77::/64", Kind: "reserved", Hostname: "core switch"}, &a.subnets[1])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var v6 map[string]any
|
||||
for _, x := range f.hosts {
|
||||
if hostIPs(x)[0] == "fd00:213:77::50" {
|
||||
v6 = x
|
||||
}
|
||||
}
|
||||
if v6 == nil || v6["name"] != "rollcall-fd00-213-77--50" || v6["configure_for_dns"] != false {
|
||||
t.Fatalf("v6 reservation %v", v6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfobloxReconcile(t *testing.T) {
|
||||
a, f := testInfoblox(t, "")
|
||||
ctx := context.Background()
|
||||
foreign := f.host("printer.int.example", "10.213.77.20", nil, true)
|
||||
f.host("gone.int.example", "10.213.77.30", map[string]any{"rollcall_machine": "m-9"}, true)
|
||||
f.host("wrong.int.example", "10.213.77.50", map[string]any{"rollcall_machine": "m-1"}, true)
|
||||
f.host("a.int.example", "10.213.77.60", map[string]any{"rollcall_machine": "m-5"}, true)
|
||||
f.host("b.int.example", "10.213.77.61", map[string]any{"rollcall_machine": "m-5"}, true) // paging: 5 hosts, pages of 2
|
||||
local := []IPAddr{
|
||||
{IP: "10.213.77.20", Subnet: "10.213.77.0/24", MachineID: "m-2", Hostname: "node002", Kind: "auto"},
|
||||
{IP: "10.213.77.50", Subnet: "10.213.77.0/24", MachineID: "m-1", Hostname: "node001", Kind: "auto"},
|
||||
{IP: "10.213.77.51", Subnet: "10.213.77.0/24", MachineID: "m-4", Hostname: "node004", Kind: "static"},
|
||||
{IP: "10.213.77.60", Subnet: "10.213.77.0/24", MachineID: "m-5", Hostname: "a", Kind: "auto"},
|
||||
{IP: "10.213.77.61", Subnet: "10.213.77.0/24", MachineID: "m-5", Hostname: "b", Kind: "auto"},
|
||||
}
|
||||
for _, x := range f.hosts { // descriptions as Rollcall writes them
|
||||
if m := ib(a).owner(x); m != "" {
|
||||
x["comment"] = "rollcall " + m
|
||||
}
|
||||
}
|
||||
rep := ipamReport{}
|
||||
a.reconcileAddrs(ctx, local, &rep)
|
||||
if rep.Error != "" {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
if fmt.Sprint(rep.Created, rep.Updated, rep.Deleted, len(rep.Conflicts)) != "[10.213.77.51] [10.213.77.50] [10.213.77.30] 1" {
|
||||
t.Errorf("report: %+v", rep)
|
||||
}
|
||||
if f.hosts[foreign]["name"] != "printer.int.example" {
|
||||
t.Error("foreign host touched")
|
||||
}
|
||||
f.writes = nil
|
||||
again := ipamReport{}
|
||||
a.reconcileAddrs(ctx, local, &again)
|
||||
if len(f.writes) != 0 || again.Error != "" {
|
||||
t.Errorf("second pass wrote %v (%+v): bits and status are not kept by Infoblox and must not count", f.writes, again)
|
||||
}
|
||||
// the delete re-checks ownership
|
||||
for ref, x := range f.hosts {
|
||||
if x["name"] == "node004.int.example" {
|
||||
delete(asObj(x["extattrs"]), "rollcall_machine")
|
||||
if err := a.ipam.Delete(ctx, extAddr{ID: ref}); err != nil || f.hosts[ref] == nil {
|
||||
t.Fatalf("deleted a host that is no longer Rollcall's: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.ipam.Delete(ctx, extAddr{ID: "record:host/gone:x/default"}); err != nil {
|
||||
t.Errorf("deleting a vanished host: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfobloxDNSView(t *testing.T) {
|
||||
a, f := testInfoblox(t, "internal")
|
||||
_, err := a.ipam.Reserve(context.Background(), a.extFor(IPAddr{IP: "10.213.77.40", Subnet: "10.213.77.0/24", MachineID: "m-1", Hostname: "n1"}, &a.subnets[0]))
|
||||
if err == nil || !strings.Contains(err.Error(), "view not found") {
|
||||
t.Fatalf("WAPI error not passed on: %v", err)
|
||||
}
|
||||
if len(f.hosts) != 0 {
|
||||
t.Fatal("host made")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfobloxPrefixes(t *testing.T) {
|
||||
a, f := testInfoblox(t, "")
|
||||
f.networks = []map[string]any{
|
||||
{"network": "10.213.80.0/24", "comment": "new hall", "extattrs": map[string]any{"Site": map[string]any{"value": "DC1"}, "VLAN": map[string]any{"value": "300"}}},
|
||||
{"network": "fd00:213:80::/64", "comment": "new hall v6", "extattrs": map[string]any{}},
|
||||
}
|
||||
ps, err := a.ipam.Prefixes(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(ps) != "[{10.213.80.0/24 new hall 300 DC1 false } {fd00:213:80::/64 new hall v6 0 false }]" {
|
||||
t.Errorf("%v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfobloxAuth(t *testing.T) {
|
||||
a, _ := testInfoblox(t, "")
|
||||
ib(a).pass = "wrong"
|
||||
_, err := a.ipam.Owned(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ib(a *App) *infoblox { return a.ipam.(*infoblox) }
|
||||
@@ -0,0 +1,425 @@
|
||||
package main
|
||||
|
||||
// Delegated intermediates kept current. The region keeps every intermediate
|
||||
// it signs (its name constraints, certificate and a renewal token shown
|
||||
// once), and the host that holds the intermediate's key asks every day with
|
||||
// that token and a CSR (POST /api/v1/ca/intermediates/NAME/renew; the
|
||||
// deploy/rollcall-intermediate-renew script and timer). The region signs
|
||||
// again when the certificate is in the last third of its life or comes from
|
||||
// a root that is no longer the active one (after a rotation's switch),
|
||||
// always with the constraints it was first given: a renewal never widens
|
||||
// what the intermediate may issue. Otherwise it answers that nothing is due.
|
||||
//
|
||||
// An intermediate under 30 days from its end, whose last renewal failed, or
|
||||
// whose host has not asked for three days needs attention: rollcall doctor,
|
||||
// GET /api/v1/ca/intermediates, the web UI's overview, and the rotation
|
||||
// status for one from the old root.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Intermediate struct {
|
||||
Name string `json:"name"`
|
||||
Permits []string `json:"permits"`
|
||||
Days int `json:"days"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Issuer string `json:"issuer"` // fingerprint of the root that signed it
|
||||
Created time.Time `json:"created"`
|
||||
Renewed *time.Time `json:"renewed,omitempty"`
|
||||
LastSeen *time.Time `json:"last_seen,omitempty"` // the host's last renewal request
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
cert string
|
||||
tokenHash string
|
||||
}
|
||||
|
||||
const (
|
||||
interWarnLeft = 30 * 24 * time.Hour // under this to its end: attention
|
||||
interSilent = 72 * time.Hour // the host has not asked for this long: attention
|
||||
)
|
||||
|
||||
// problem: what needs attention about x, "" when nothing does
|
||||
func (x *Intermediate) problem(activeRoot string, now time.Time) string {
|
||||
var p []string
|
||||
if left := x.NotAfter.Sub(now); left < interWarnLeft {
|
||||
if left <= 0 {
|
||||
p = append(p, "expired "+x.NotAfter.Format("2006-01-02"))
|
||||
} else {
|
||||
p = append(p, fmt.Sprintf("expires in %d days (%s)", int(math.Ceil(left.Hours()/24-1e-9)), x.NotAfter.Format("2006-01-02")))
|
||||
}
|
||||
}
|
||||
if x.LastError != "" {
|
||||
p = append(p, "last renewal failed: "+x.LastError)
|
||||
}
|
||||
switch {
|
||||
case x.LastSeen == nil && now.Sub(x.Created) > interSilent:
|
||||
p = append(p, "its host never asked for renewal: install deploy/rollcall-intermediate-renew there")
|
||||
case x.LastSeen != nil && now.Sub(*x.LastSeen) > interSilent:
|
||||
p = append(p, "its host has not asked since "+x.LastSeen.Format("2006-01-02 15:04"))
|
||||
}
|
||||
if activeRoot != "" && x.Issuer != activeRoot {
|
||||
p = append(p, "signed by a root that no longer signs: renewed on its host's next request")
|
||||
}
|
||||
return strings.Join(p, "; ")
|
||||
}
|
||||
|
||||
// due: whether a renewal request gets a new certificate
|
||||
func (x *Intermediate) due(activeRoot string, now time.Time) bool {
|
||||
c, err := parseCertPEM([]byte(x.cert))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
life := c.NotAfter.Sub(c.NotBefore)
|
||||
return c.NotAfter.Sub(now) < life/3 || x.Issuer != activeRoot
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
const interCols = `name, permits, days, cert, not_after, issuer, created, renewed, last_seen, coalesce(last_error,''), token_hash`
|
||||
|
||||
func scanInter(row interface{ Scan(...any) error }) (*Intermediate, error) {
|
||||
var x Intermediate
|
||||
var permits string
|
||||
if err := row.Scan(&x.Name, &permits, &x.Days, &x.cert, &x.NotAfter, &x.Issuer, &x.Created, &x.Renewed, &x.LastSeen, &x.LastError, &x.tokenHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.Permits = splitList(permits)
|
||||
return &x, nil
|
||||
}
|
||||
|
||||
func (s *Store) Intermediates(ctx context.Context) ([]*Intermediate, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+interCols+` FROM intermediates ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*Intermediate{}
|
||||
for rows.Next() {
|
||||
x, err := scanInter(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Intermediate(ctx context.Context, name string) (*Intermediate, error) {
|
||||
x, err := scanInter(s.pool.QueryRow(ctx, `SELECT `+interCols+` FROM intermediates WHERE name=$1`, name))
|
||||
if err != nil && strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return x, err
|
||||
}
|
||||
|
||||
func (s *Store) PutIntermediate(ctx context.Context, x *Intermediate) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO intermediates(name, permits, days, cert, not_after, issuer, token_hash) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (name) DO UPDATE SET permits=$2, days=$3, cert=$4, not_after=$5, issuer=$6, token_hash=$7, created=now(), renewed=NULL, last_seen=NULL, last_error=NULL`,
|
||||
x.Name, strings.Join(x.Permits, ","), x.Days, x.cert, x.NotAfter, x.Issuer, x.tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// RenewedIntermediate stores a renewal (or, with cert "", only that the host asked, and how it went).
|
||||
func (s *Store) RenewedIntermediate(ctx context.Context, name, cert string, notAfter time.Time, issuer, lastErr string) error {
|
||||
if cert == "" {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE intermediates SET last_seen=now(), last_error=nullif($2,'') WHERE name=$1`, name, lastErr)
|
||||
return err
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `UPDATE intermediates SET cert=$2, not_after=$3, issuer=$4, renewed=now(), last_seen=now(), last_error=NULL WHERE name=$1`, name, cert, notAfter, issuer)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteIntermediate(ctx context.Context, name string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM intermediates WHERE name=$1`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- signing
|
||||
|
||||
// csrKey: the public key of a PEM CSR whose signature holds
|
||||
func csrKey(b []byte) (any, error) {
|
||||
blk, _ := pem.Decode(b)
|
||||
if blk == nil || blk.Type != "CERTIFICATE REQUEST" && blk.Type != "NEW CERTIFICATE REQUEST" {
|
||||
return nil, errors.New("not a PEM certificate request")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(blk.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("CSR signature: %w", err)
|
||||
}
|
||||
return csr.PublicKey, nil
|
||||
}
|
||||
|
||||
func (a *App) signIntermediate(name string, permits []string, days int, csrPEM []byte) (string, *x509.Certificate, error) {
|
||||
pub, err := csrKey(csrPEM)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dns, nets, err := parsePermits(strings.Join(permits, ","))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
certPEM, cert, err := a.ca.intermediate(pub, name, dns, nets, time.Duration(days)*24*time.Hour)
|
||||
return string(certPEM), cert, err
|
||||
}
|
||||
|
||||
var interNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$`)
|
||||
|
||||
// splitList: the items of a comma-separated list, trimmed, without empty ones
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, x := range strings.Split(s, ",") {
|
||||
if x = strings.TrimSpace(x); x != "" {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hashToken(t string) string { s := sha256.Sum256([]byte(t)); return hex.EncodeToString(s[:]) }
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
// handleIntermediates: GET lists them with what needs attention; POST signs
|
||||
// one ({"name","permits","csr","days","replace"}) and returns its
|
||||
// certificate, the root and the renewal token (shown this once); DELETE
|
||||
// ?name= stops keeping it (it stays valid until its end: there is no CRL).
|
||||
func (a *App) handleIntermediates(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
xs, err := a.store.Intermediates(ctx)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
for _, x := range xs {
|
||||
x.Problem = x.problem(fpHex(a.ca.Cert), time.Now())
|
||||
}
|
||||
writeJSON(w, 200, xs)
|
||||
case http.MethodDelete:
|
||||
if err := a.store.DeleteIntermediate(ctx, r.URL.Query().Get("name")); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": r.URL.Query().Get("name")})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name, CSR string
|
||||
Permits []string
|
||||
Days int
|
||||
Replace bool
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil || !interNameRe.MatchString(req.Name) || req.CSR == "" {
|
||||
fail(w, 400, errors.New("name (letters, digits, - . _) and csr are required"))
|
||||
return
|
||||
}
|
||||
if len(req.Permits) == 0 {
|
||||
fail(w, 400, errors.New("an intermediate needs permits (DNS domains and CIDRs it may issue for)"))
|
||||
return
|
||||
}
|
||||
if req.Days == 0 {
|
||||
req.Days = 365
|
||||
}
|
||||
req.Days = max(1, min(req.Days, 1825))
|
||||
if _, err := a.store.Intermediate(ctx, req.Name); err == nil && !req.Replace {
|
||||
fail(w, 409, fmt.Errorf("%s is kept already: replace to sign it again with a new token", req.Name))
|
||||
return
|
||||
}
|
||||
cert, c, err := a.signIntermediate(req.Name, req.Permits, req.Days, []byte(req.CSR))
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 32)
|
||||
_, _ = rand.Read(tok)
|
||||
token := hex.EncodeToString(tok)
|
||||
x := &Intermediate{Name: req.Name, Permits: req.Permits, Days: req.Days, NotAfter: c.NotAfter, Issuer: fpHex(a.ca.Cert), cert: cert, tokenHash: hashToken(token)}
|
||||
if err := a.store.PutIntermediate(ctx, x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"name": x.Name, "cert": cert, "root": string(a.ca.PEM), "not_after": x.NotAfter, "token": token})
|
||||
}
|
||||
}
|
||||
|
||||
// handleIntermediateRenew: the intermediate's host, with its token and a CSR
|
||||
// in the body; a new certificate when one is due (?force=1: now), else
|
||||
// {"renewed": false}. With Accept: application/x-pem-file the answer is the
|
||||
// certificate itself, or 204 when nothing is due (for a shell script). HTTPS only.
|
||||
func (a *App) handleIntermediateRenew(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, name := r.Context(), r.PathValue("name")
|
||||
if r.TLS == nil && a.cfg.AdminHTTP != "allow" {
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if ip := net.ParseIP(host); a.cfg.AdminHTTP != "loopback" || ip == nil || !ip.IsLoopback() {
|
||||
fail(w, 403, errors.New("renewal is HTTPS-only"))
|
||||
return
|
||||
}
|
||||
}
|
||||
x, err := a.store.Intermediate(ctx, name)
|
||||
tok, _ := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if err != nil || !tokenEqual(hashToken(tok), x.tokenHash) {
|
||||
fail(w, 401, errors.New("unknown intermediate or wrong token")) // one answer for both: names are not probed
|
||||
return
|
||||
}
|
||||
csr, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<16))
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
active := fpHex(a.ca.Cert)
|
||||
asPEM := strings.Contains(r.Header.Get("Accept"), "application/x-pem-file")
|
||||
if !x.due(active, time.Now()) && r.URL.Query().Get("force") != "1" {
|
||||
_ = a.store.RenewedIntermediate(ctx, name, "", time.Time{}, "", "")
|
||||
if asPEM {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"renewed": false, "not_after": x.NotAfter})
|
||||
return
|
||||
}
|
||||
cert, c, err := a.signIntermediate(x.Name, x.Permits, x.Days, csr) // the constraints it was given, never others
|
||||
if err != nil {
|
||||
_ = a.store.RenewedIntermediate(ctx, name, "", time.Time{}, "", err.Error())
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
if err := a.store.RenewedIntermediate(ctx, name, cert, c.NotAfter, active, ""); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
log.Printf("ca: intermediate %s renewed until %s", name, c.NotAfter.Format("2006-01-02"))
|
||||
if asPEM {
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
_, _ = io.WriteString(w, cert)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"renewed": true, "cert": cert, "root": string(a.ca.PEM), "not_after": c.NotAfter})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CLI
|
||||
|
||||
// rollcall ca intermediate NAME -permit DOMAIN,CIDR [-csr FILE] [-days N] [-out DIR] [-replace]
|
||||
func caIntermediateCmd(name string, args []string) error {
|
||||
fs := newFlags("ca intermediate")
|
||||
permit := fs.String("permit", "", "comma-separated DNS domains and CIDRs it may issue for (required)")
|
||||
csrFile := fs.String("csr", "", "sign this CSR (the key stays where it was made); without it a key is generated here")
|
||||
days := fs.Int("days", 365, "validity in days")
|
||||
out := fs.String("out", ".", "directory for NAME.pem, NAME.token (+ NAME-key.pem without -csr)")
|
||||
replace := fs.Bool("replace", false, "sign again an intermediate the region keeps already (a new renewal token)")
|
||||
_ = fs.Parse(args)
|
||||
permits := splitList(*permit)
|
||||
if len(permits) == 0 {
|
||||
return errors.New("an intermediate needs -permit (e.g. -permit int.example.com,10.213.0.0/16)")
|
||||
}
|
||||
if _, _, err := parsePermits(*permit); err != nil {
|
||||
return err
|
||||
}
|
||||
var csrPEM, keyPEM []byte
|
||||
var err error
|
||||
if *csrFile != "" {
|
||||
if csrPEM, err = os.ReadFile(*csrFile); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var csr string
|
||||
if keyPEM, csr, err = newCSR(name); err != nil {
|
||||
return err
|
||||
}
|
||||
csrPEM = []byte(csr)
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res struct {
|
||||
Cert, Root, Token string
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
}
|
||||
if err := c.do("POST", "/api/v1/ca/intermediates", obj{"name": name, "permits": permits, "csr": string(csrPEM), "days": *days, "replace": *replace}, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
write := func(f string, b []byte, mode os.FileMode) error { return os.WriteFile(filepath.Join(*out, f), b, mode) }
|
||||
if keyPEM != nil {
|
||||
if err := write(name+"-key.pem", keyPEM, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := write(name+".pem", []byte(res.Cert), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := write(name+".token", []byte(res.Token+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s.pem and %s.token in %s: intermediate CA %q until %s, may issue for %s\n", name, name, *out, name, res.NotAfter.Format("2006-01-02"), strings.Join(permits, " "))
|
||||
fmt.Printf("the region renews it: on the host with its key, install deploy/rollcall-intermediate-renew with\n URL=%s NAME=%s TOKEN_FILE=<%s.token there> CERT=<the certificate file> KEY=<the key file>\n", regionHTTPS(c.base), name, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollcall ca intermediates: the intermediates the region keeps
|
||||
func caIntermediatesCmd() error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var xs []Intermediate
|
||||
if err := c.do("GET", "/api/v1/ca/intermediates", nil, &xs); err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "NAME\tUNTIL\tLAST ASKED\tPERMITS\tATTENTION")
|
||||
for _, x := range xs {
|
||||
seen := "never"
|
||||
if x.LastSeen != nil {
|
||||
seen = x.LastSeen.Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", x.Name, x.NotAfter.Format("2006-01-02"), seen, strings.Join(x.Permits, " "), orDash(x.Problem))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// regionHTTPS: the region's HTTPS address for another host, when the CLI
|
||||
// talks to it over loopback HTTP on the region itself
|
||||
func regionHTTPS(base string) string {
|
||||
if !strings.HasPrefix(base, "http://") {
|
||||
return base
|
||||
}
|
||||
cfg, err := loadConfig(envOr("ROLLCALL_CONFIG", "/etc/rollcall/config.json"))
|
||||
if err != nil || cfg.TLSListen == "" {
|
||||
return base
|
||||
}
|
||||
u, err := url.Parse(cfg.ServerURL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return base
|
||||
}
|
||||
host := u.Hostname()
|
||||
if _, port, _ := net.SplitHostPort(cfg.TLSListen); port != "" && port != "443" {
|
||||
host = net.JoinHostPort(host, port)
|
||||
} else if strings.Contains(host, ":") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
return "https://" + host
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// a renewal signs with the constraints the region keeps, whatever the CSR asks for
|
||||
func TestIntermediateRenewalKeepsConstraints(t *testing.T) {
|
||||
ca, err := loadOrCreateCA(t.TempDir(), "test CA")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{ca: ca}
|
||||
_, csr, err := newCSR("evil.example.org") // the name in the CSR does not matter
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemCert, c, err := a.signIntermediate("caddy", []string{"int.example.com", "10.213.0.0/16"}, 90, []byte(csr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.IsCA || c.Subject.CommonName != "caddy" || len(c.PermittedDNSDomains) != 1 || c.PermittedDNSDomains[0] != "int.example.com" ||
|
||||
len(c.PermittedIPRanges) != 1 || c.PermittedIPRanges[0].String() != "10.213.0.0/16" || !c.PermittedDNSDomainsCritical {
|
||||
t.Fatalf("constraints: %+v %v %v", c.Subject, c.PermittedDNSDomains, c.PermittedIPRanges)
|
||||
}
|
||||
if d := c.NotAfter.Sub(c.NotBefore); d < 89*24*time.Hour || d > 91*24*time.Hour {
|
||||
t.Fatalf("validity %v", d)
|
||||
}
|
||||
if _, _, err := a.signIntermediate("caddy", []string{"int.example.com"}, 90, []byte("not a csr")); err == nil {
|
||||
t.Fatal("signed something that is not a CSR")
|
||||
}
|
||||
|
||||
// due in the last third of its life, or when another root signs now
|
||||
x := &Intermediate{Name: "caddy", cert: pemCert, Issuer: fpHex(ca.Cert)}
|
||||
if x.due(fpHex(ca.Cert), time.Now()) {
|
||||
t.Fatal("due right after signing")
|
||||
}
|
||||
if !x.due(fpHex(ca.Cert), time.Now().Add(61*24*time.Hour)) {
|
||||
t.Fatal("not due with a third of its life left")
|
||||
}
|
||||
if !x.due("another-root", time.Now()) {
|
||||
t.Fatal("not due after a rotation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntermediateProblems(t *testing.T) {
|
||||
now := time.Now()
|
||||
ago := func(d time.Duration) *time.Time { x := now.Add(-d); return &x }
|
||||
for _, c := range []struct {
|
||||
x Intermediate
|
||||
want string // a fragment, "" for none
|
||||
}{
|
||||
{Intermediate{NotAfter: now.Add(200 * 24 * time.Hour), Issuer: "r", Created: now.Add(-time.Hour)}, ""},
|
||||
{Intermediate{NotAfter: now.Add(200 * 24 * time.Hour), Issuer: "r", Created: now.Add(-100 * time.Hour), LastSeen: ago(time.Hour)}, ""},
|
||||
{Intermediate{NotAfter: now.Add(10 * 24 * time.Hour), Issuer: "r", LastSeen: ago(time.Hour)}, "expires in 10 days"},
|
||||
{Intermediate{NotAfter: now.Add(-time.Hour), Issuer: "r", LastSeen: ago(time.Hour)}, "expired"},
|
||||
{Intermediate{NotAfter: now.Add(200 * 24 * time.Hour), Issuer: "r", Created: now.Add(-100 * time.Hour)}, "never asked"},
|
||||
{Intermediate{NotAfter: now.Add(200 * 24 * time.Hour), Issuer: "r", LastSeen: ago(100 * time.Hour)}, "has not asked since"},
|
||||
{Intermediate{NotAfter: now.Add(200 * 24 * time.Hour), Issuer: "r", LastSeen: ago(time.Hour), LastError: "CSR signature"}, "last renewal failed"},
|
||||
{Intermediate{NotAfter: now.Add(200 * 24 * time.Hour), Issuer: "old", LastSeen: ago(time.Hour)}, "no longer signs"},
|
||||
} {
|
||||
got := c.x.problem("r", now)
|
||||
if (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
|
||||
t.Errorf("%+v: %q, want %q", c.x, got, c.want)
|
||||
}
|
||||
}
|
||||
if hashToken("a") == hashToken("b") || len(hashToken("a")) != 64 {
|
||||
t.Fatal("hashToken")
|
||||
}
|
||||
if got := splitList(" a, ,b ,"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("splitList %q", got)
|
||||
}
|
||||
}
|
||||
+137
-35
@@ -24,6 +24,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
@@ -37,14 +38,23 @@ import (
|
||||
)
|
||||
|
||||
type IPAMConfig struct {
|
||||
Provider string `json:"provider"` // "" = built-in only | netbox
|
||||
URL string `json:"url"` // https://netbox.example
|
||||
TokenFile string `json:"token_file"` // API token (NetBox v2 "nbt_..." or v1)
|
||||
CAFile string `json:"ca_file"` // CA that signed the server certificate ("" = system roots)
|
||||
DNSZone string `json:"dns_zone"` // dns_name = <host>.<zone> unless the subnet has a domain
|
||||
Tag string `json:"tag"` // ownership tag, default "rollcall"
|
||||
MachineField string `json:"machine_field"` // custom field holding the machine ID, default "rollcall_machine"
|
||||
SyncInterval string `json:"sync_interval"` // reconcile period, default 5m
|
||||
Provider string `json:"provider"` // "" = built-in only | netbox | infoblox
|
||||
URL string `json:"url"` // https://netbox.example; with NetBox's BASE_PATH (…/netbox/) its UI is served here too (nbproxy.go)
|
||||
UIURL string `json:"ui_url"` // where "open in NetBox" links go when not through Rollcall
|
||||
UILoginFile string `json:"ui_login_secret_file"` // shared with NetBox's front: the admin certificate signs in to NetBox (nbproxy.go)
|
||||
UIListen string `json:"ui_listen"` // the NetBox UI's own HTTPS listener (its own origin), default ":8444"
|
||||
TokenFile string `json:"token_file"` // API token (NetBox v2 "nbt_..." or v1)
|
||||
User string `json:"user"` // Infoblox WAPI user
|
||||
PasswordFile string `json:"password_file"` // Infoblox WAPI password
|
||||
NetworkView string `json:"network_view"` // Infoblox, default "default"
|
||||
DNSView string `json:"dns_view"` // Infoblox host records' DNS view, default "default"; "-" = IPAM only
|
||||
CAFile string `json:"ca_file"` // CA that signed the server certificate ("" = system roots)
|
||||
DNSZone string `json:"dns_zone"` // dns_name = <host>.<zone> unless the subnet has a domain
|
||||
Tag string `json:"tag"` // ownership tag, default "rollcall"
|
||||
MachineField string `json:"machine_field"` // custom field holding the machine ID, default "rollcall_machine"
|
||||
SyncInterval string `json:"sync_interval"` // reconcile period, default 5m
|
||||
// DeviceSync mirrors machines as NetBox devices (nbdevices.go); nil = off
|
||||
DeviceSync *DeviceSyncConfig `json:"device_sync,omitempty"`
|
||||
}
|
||||
|
||||
// extAddr is one address as the authority holds it.
|
||||
@@ -79,14 +89,31 @@ type ipamProvider interface {
|
||||
}
|
||||
|
||||
type ipamReport struct {
|
||||
Provider string `json:"provider"`
|
||||
At time.Time `json:"at"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Created []string `json:"created"`
|
||||
Updated []string `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
At time.Time `json:"at"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Created []string `json:"created"`
|
||||
Updated []string `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Devices *devReport `json:"devices,omitempty"` // ipam.device_sync
|
||||
}
|
||||
|
||||
// fail notes one record's error; the reconcile goes on with the others.
|
||||
func (r *ipamReport) fail(e string) { r.Error = joinErr(r.Error, e) }
|
||||
|
||||
// joinErr keeps the first few errors of a pass readable.
|
||||
func joinErr(all, e string) string {
|
||||
switch n := strings.Count(all, "; ") + 1; {
|
||||
case all == "":
|
||||
return e
|
||||
case n < 5:
|
||||
return all + "; " + e
|
||||
case n == 5:
|
||||
return all + "; ..."
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
type ipamState struct {
|
||||
@@ -101,8 +128,10 @@ func newIPAMProvider(c IPAMConfig) (ipamProvider, error) {
|
||||
return nil, nil
|
||||
case "netbox":
|
||||
return newNetBox(c)
|
||||
case "infoblox":
|
||||
return newInfoblox(c)
|
||||
default:
|
||||
return nil, fmt.Errorf("ipam provider %q: unknown (netbox)", c.Provider)
|
||||
return nil, fmt.Errorf("ipam provider %q: unknown (netbox, infoblox)", c.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +148,20 @@ func orDefault(s, d string) string {
|
||||
|
||||
var dnsLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
|
||||
// validDomain: lower-case dot-separated DNS labels, no trailing dot. Names end
|
||||
// up in dnsmasq lines, cloud-init, PXE menus and DNS updates, so nothing else.
|
||||
func validDomain(s string) bool {
|
||||
if s == "" || len(s) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, l := range strings.Split(s, ".") {
|
||||
if !dnsLabel.MatchString(l) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extFor maps a Rollcall address to the record the authority should hold.
|
||||
func (a *App) extFor(x IPAddr, sn *Subnet) extAddr {
|
||||
bits := 32
|
||||
@@ -152,8 +195,11 @@ func (a *App) extFor(x IPAddr, sn *Subnet) extAddr {
|
||||
// extDNSName: the machine's main address gets <host>.<zone>; addresses on
|
||||
// other subnets (storage, IB, a tagged VLAN) get <host>-<iface>.<zone>. A
|
||||
// subnet's domain replaces the zone when it lies inside it.
|
||||
func (a *App) extDNSName(x IPAddr, sn *Subnet) string {
|
||||
zone := strings.TrimSuffix(strings.ToLower(a.cfg.IPAM.DNSZone), ".")
|
||||
func (a *App) extDNSName(x IPAddr, sn *Subnet) string { return dnsNameIn(x, sn, a.cfg.IPAM.DNSZone) }
|
||||
|
||||
// dnsNameIn is the naming rule shared by the IPAM authority and the DNS provider.
|
||||
func dnsNameIn(x IPAddr, sn *Subnet, zone string) string {
|
||||
zone = strings.TrimSuffix(strings.ToLower(zone), ".")
|
||||
if sn != nil && sn.Domain != "" { // a subnet domain counts only inside the authority's zone; "pxe.local" stays local
|
||||
d := strings.TrimSuffix(strings.ToLower(sn.Domain), ".")
|
||||
if zone == "" || d == zone || strings.HasSuffix(d, "."+zone) {
|
||||
@@ -165,6 +211,9 @@ func (a *App) extDNSName(x IPAddr, sn *Subnet) string {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(host, "."+zone) {
|
||||
if !validDomain(host) {
|
||||
return "" // a wildcard or anything that is not a plain name never reaches the DNS
|
||||
}
|
||||
return host
|
||||
}
|
||||
if !dnsLabel.MatchString(host) {
|
||||
@@ -183,6 +232,12 @@ func (a *App) extDNSName(x IPAddr, sn *Subnet) string {
|
||||
// claimIP records an address in the authority (when one is configured) and
|
||||
// then locally; false when it is taken in either.
|
||||
func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) {
|
||||
if a.ipam != nil && a.ipamSt != nil {
|
||||
// not while a reconcile runs: it read the address table before this
|
||||
// claim and would delete the record Reserve is about to make
|
||||
a.ipamSt.mu.Lock()
|
||||
defer a.ipamSt.mu.Unlock()
|
||||
}
|
||||
if a.ipam != nil {
|
||||
sn := subnetByRef(a.subnetsSnapshot(), x.Subnet)
|
||||
taken, err := a.ipam.Reserve(ctx, a.extFor(x, sn))
|
||||
@@ -194,6 +249,10 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) {
|
||||
}
|
||||
}
|
||||
ok, err := a.store.ClaimIP(ctx, x)
|
||||
if ok {
|
||||
a.kickDNS()
|
||||
a.kickDHCPRes()
|
||||
}
|
||||
if a.ipam != nil && (err != nil || !ok) {
|
||||
a.kickIPAM() // the record we just made is reconciled away or re-owned
|
||||
}
|
||||
@@ -201,6 +260,8 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) {
|
||||
}
|
||||
|
||||
func (a *App) kickIPAM() {
|
||||
a.kickDNS() // the same address changes move DNS records
|
||||
a.kickDHCPRes()
|
||||
if a.ipamSt == nil {
|
||||
return
|
||||
}
|
||||
@@ -217,6 +278,9 @@ func (a *App) startIPAM() error {
|
||||
}
|
||||
a.ipam = p
|
||||
a.ipamSt = &ipamState{kick: make(chan struct{}, 1)}
|
||||
if _, nb := p.(*netBox); a.cfg.IPAM.DeviceSync != nil && !nb {
|
||||
log.Printf("ipam: device_sync needs the netbox provider; ignored for %s", p.Name())
|
||||
}
|
||||
every := 5 * time.Minute
|
||||
if d, err := time.ParseDuration(a.cfg.IPAM.SyncInterval); err == nil && d >= 10*time.Second {
|
||||
every = d
|
||||
@@ -231,6 +295,9 @@ func (a *App) startIPAM() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
rep := a.reconcileIPAM(ctx, false)
|
||||
cancel()
|
||||
if d := rep.Devices; d != nil && (d.Error != "" || len(d.Created)+len(d.Deleted)+len(d.Conflicts) > 0) {
|
||||
log.Printf("ipam %s devices: created %v deleted %v conflicts %v %s", p.Name(), d.Created, d.Deleted, d.Conflicts, d.Error)
|
||||
}
|
||||
if rep.Error != "" {
|
||||
log.Printf("ipam %s: %s", p.Name(), rep.Error)
|
||||
} else if n := len(rep.Created) + len(rep.Updated) + len(rep.Deleted) + len(rep.Conflicts); n > 0 {
|
||||
@@ -264,6 +331,19 @@ func (a *App) reconcileIPAM(ctx context.Context, dry bool) ipamReport {
|
||||
return rep
|
||||
}
|
||||
a.reconcileAddrs(ctx, local, &rep)
|
||||
if !dry { // the map and floor plans follow NetBox's sites, locations and racks
|
||||
if err := a.syncLayout(ctx); err != nil {
|
||||
rep.fail("layout: " + err.Error())
|
||||
}
|
||||
}
|
||||
if nb, ok := a.ipam.(*netBox); ok && a.cfg.IPAM.DeviceSync != nil && !dry {
|
||||
// after the addresses: the sync assigns them to the interfaces
|
||||
if ms, err := a.store.List(ctx); err != nil {
|
||||
rep.Devices = &devReport{Error: err.Error()}
|
||||
} else {
|
||||
rep.Devices = nb.syncDevices(ctx, *a.cfg.IPAM.DeviceSync, ms, local, a.cfg.RackUnits)
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
@@ -284,13 +364,16 @@ func (a *App) reconcileAddrs(ctx context.Context, local []IPAddr, rep *ipamRepor
|
||||
want := a.extFor(x, subnetByRef(subnets, x.Subnet))
|
||||
if o, ok := have[x.IP]; ok {
|
||||
delete(have, x.IP)
|
||||
if o.DNSName != want.DNSName || o.Machine != want.Machine || o.Status != want.Status || o.Bits != want.Bits || o.Desc != want.Desc {
|
||||
// an authority that does not keep a field answers it empty (Infoblox: bits, status)
|
||||
if o.DNSName != want.DNSName || o.Machine != want.Machine || (o.Status != "" && o.Status != want.Status) ||
|
||||
(o.Bits != 0 && o.Bits != want.Bits) || o.Desc != want.Desc {
|
||||
want.ID = o.ID
|
||||
rep.Updated = append(rep.Updated, x.IP)
|
||||
if !dry {
|
||||
if err := a.ipam.Update(ctx, want); err != nil {
|
||||
rep.Error = fmt.Sprintf("update %s: %v", x.IP, err)
|
||||
return
|
||||
rep.fail(fmt.Sprintf("update %s: %v", x.IP, err))
|
||||
rep.Updated = rep.Updated[:len(rep.Updated)-1]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,8 +385,8 @@ func (a *App) reconcileAddrs(ctx context.Context, local []IPAddr, rep *ipamRepor
|
||||
}
|
||||
taken, err := a.ipam.Reserve(ctx, want)
|
||||
if err != nil {
|
||||
rep.Error = fmt.Sprintf("create %s: %v", x.IP, err)
|
||||
return
|
||||
rep.fail(fmt.Sprintf("create %s: %v", x.IP, err))
|
||||
continue
|
||||
}
|
||||
if taken {
|
||||
rep.Conflicts = append(rep.Conflicts, x.IP+" exists there and is not Rollcall's")
|
||||
@@ -320,8 +403,9 @@ func (a *App) reconcileAddrs(ctx context.Context, local []IPAddr, rep *ipamRepor
|
||||
rep.Deleted = append(rep.Deleted, o.IP)
|
||||
if !dry {
|
||||
if err := a.ipam.Delete(ctx, o); err != nil {
|
||||
rep.Error = fmt.Sprintf("delete %s: %v", o.IP, err)
|
||||
return
|
||||
rep.fail(fmt.Sprintf("delete %s: %v", o.IP, err))
|
||||
rep.Deleted = rep.Deleted[:len(rep.Deleted)-1]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +419,14 @@ func (a *App) handleIPAM(w http.ResponseWriter, r *http.Request) {
|
||||
a.ipamSt.mu.Lock()
|
||||
last := a.ipamSt.last
|
||||
a.ipamSt.mu.Unlock()
|
||||
writeJSON(w, 200, obj{"provider": a.ipam.Name(), "url": a.cfg.IPAM.URL, "dns_zone": a.cfg.IPAM.DNSZone,
|
||||
ui := obj{}
|
||||
if a.cfg.IPAM.UIURL != "" {
|
||||
ui["url"] = strings.TrimRight(a.cfg.IPAM.UIURL, "/")
|
||||
} else if p := a.netboxUIPath(); p != "" && a.cfg.TLSListen != "" {
|
||||
_, port, _ := net.SplitHostPort(a.cfg.IPAM.uiListen())
|
||||
ui["port"], ui["path"] = port, p // the browser puts its own host in front
|
||||
}
|
||||
writeJSON(w, 200, obj{"provider": a.ipam.Name(), "url": a.cfg.IPAM.URL, "ui": ui, "dns_zone": a.cfg.IPAM.DNSZone,
|
||||
"tag": a.cfg.IPAM.tag(), "machine_field": a.cfg.IPAM.field(), "last_sync": last})
|
||||
}
|
||||
|
||||
@@ -415,20 +506,30 @@ func newNetBox(c IPAMConfig) (*netBox, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netbox token: %w", err)
|
||||
}
|
||||
hc, err := httpClientCA(c.CAFile, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netbox ca_file: %w", err)
|
||||
}
|
||||
return &netBox{base: strings.TrimRight(c.URL, "/"), token: strings.TrimSpace(string(tok)),
|
||||
tag: c.tag(), field: c.field(), client: hc}, nil
|
||||
}
|
||||
|
||||
// httpClientCA is an HTTP client that trusts caFile (PEM) instead of the
|
||||
// system's CAs when it is given.
|
||||
func httpClientCA(caFile string, timeout time.Duration) (*http.Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if c.CAFile != "" {
|
||||
pem, err := os.ReadFile(c.CAFile)
|
||||
if caFile != "" {
|
||||
pem, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netbox ca_file: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("netbox ca_file %s: no certificates", c.CAFile)
|
||||
return nil, fmt.Errorf("%s: no certificates", caFile)
|
||||
}
|
||||
tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return &netBox{base: strings.TrimRight(c.URL, "/"), token: strings.TrimSpace(string(tok)),
|
||||
tag: c.tag(), field: c.field(), client: &http.Client{Transport: tr, Timeout: 30 * time.Second}}, nil
|
||||
return &http.Client{Transport: tr, Timeout: timeout}, nil
|
||||
}
|
||||
|
||||
func (n *netBox) Name() string { return "netbox" }
|
||||
@@ -447,9 +548,10 @@ func (n *netBox) do(ctx context.Context, method, path string, body, out any) err
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
u := n.base + "/api/" + strings.TrimPrefix(path, "/")
|
||||
if strings.HasPrefix(path, "http") { // a "next" link: keep our base, the server may name itself differently
|
||||
if strings.HasPrefix(path, "http") { // a "next" link: keep our scheme and host, the server may name itself differently
|
||||
if p, err := url.Parse(path); err == nil {
|
||||
u = n.base + p.Path
|
||||
b, _ := url.Parse(n.base)
|
||||
u = b.Scheme + "://" + b.Host + p.Path // p.Path has NetBox's BASE_PATH already
|
||||
if p.RawQuery != "" {
|
||||
u += "?" + p.RawQuery
|
||||
}
|
||||
|
||||
+130
-4
@@ -11,6 +11,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// bmcCreds is what a power driver needs.
|
||||
@@ -68,7 +69,73 @@ func (a *App) power(ctx context.Context, m *Machine, op, arg string) (string, er
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", errNoBMC, err)
|
||||
}
|
||||
return a.powerVia(ctx, a.rackOf(m), c, op, arg)
|
||||
if c, err = a.pinBMC(ctx, m, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := a.powerVia(ctx, a.rackOf(m), c, op, arg)
|
||||
a.noteCertChange(ctx, m, err)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// noteCertChange puts a refused BMC certificate on the machine's events.
|
||||
func (a *App) noteCertChange(ctx context.Context, m *Machine, err error) {
|
||||
if err != nil && strings.Contains(err.Error(), "certificate changed") {
|
||||
_ = a.store.AddEvent(ctx, m.ID, "bmc certificate changed", oneLine(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
// pinBMC records the certificate of an HTTPS BMC (Redfish, Proxmox) before
|
||||
// any credentials go to it: trust on first use. From then on every connection
|
||||
// must show the same certificate (bmcHTTPClient). An address change or a BMC
|
||||
// firmware update through Rollcall clears the pin; so does tls_fingerprint=.
|
||||
func (a *App) pinBMC(ctx context.Context, m *Machine, c *bmcCreds) (*bmcCreds, error) {
|
||||
d, _ := driverByName(c.Driver)
|
||||
if (d.Family != "redfish" && d.Family != "api") || c.Extra["tls_fingerprint"] != "" || c.Extra["verify_tls"] == "true" {
|
||||
return c, nil
|
||||
}
|
||||
fp, err := a.powerVia(ctx, a.rackOf(m), c, "tls-fingerprint", "")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "unknown power operation") { // a rack controller not updated yet
|
||||
return c, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading the BMC's certificate: %w", err)
|
||||
}
|
||||
fp = strings.TrimSpace(fp)
|
||||
if err := a.setBMCExtra(ctx, m.ID, "tls_fingerprint", fp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.store.AddEvent(ctx, m.ID, "bmc certificate pinned", fp)
|
||||
c2 := *c
|
||||
c2.Extra = map[string]string{}
|
||||
for k, v := range c.Extra {
|
||||
c2.Extra[k] = v
|
||||
}
|
||||
c2.Extra["tls_fingerprint"] = fp
|
||||
return &c2, nil
|
||||
}
|
||||
|
||||
// setBMCExtra sets (or with "" removes) one setting on a machine's BMC record.
|
||||
func (a *App) setBMCExtra(ctx context.Context, id, k, v string) error {
|
||||
m, err := a.store.Get(ctx, id)
|
||||
if err != nil || m.BMC == nil {
|
||||
return err
|
||||
}
|
||||
var b obj
|
||||
if err := json.Unmarshal(m.BMC, &b); err != nil {
|
||||
return err
|
||||
}
|
||||
ex, _ := b["extra"].(map[string]any)
|
||||
if ex == nil {
|
||||
ex = map[string]any{}
|
||||
}
|
||||
if v == "" {
|
||||
delete(ex, k)
|
||||
} else {
|
||||
ex[k] = v
|
||||
}
|
||||
b["extra"] = ex
|
||||
nb, _ := json.Marshal(b)
|
||||
return a.store.SetBMC(ctx, id, nb, nil)
|
||||
}
|
||||
|
||||
func (a *App) powerVia(ctx context.Context, rack string, c *bmcCreds, op, arg string) (string, error) {
|
||||
@@ -83,6 +150,27 @@ func (a *App) powerVia(ctx context.Context, rack string, c *bmcCreds, op, arg st
|
||||
Password: c.Password, Extra: c.Extra, Op: op, Arg: arg})
|
||||
}
|
||||
|
||||
// firmwareVia runs a firmware operation (fw-*) where the BMC is reachable.
|
||||
func (a *App) firmwareVia(ctx context.Context, m *Machine, op, arg string) (string, error) {
|
||||
c, err := a.credsFor(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", errNoBMC, err)
|
||||
}
|
||||
if c, err = a.pinBMC(ctx, m, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rack := a.rackOf(m)
|
||||
if rack == a.cfg.RackName || rack == "" {
|
||||
return powerRun(ctx, a.cfg.IPMITool, c, op, arg)
|
||||
}
|
||||
r, ok := a.racksSnapshot()[rack]
|
||||
if !ok || !rackOnline(r, a.cfg.RackName) {
|
||||
return "", fmt.Errorf("rack controller %s is offline", rack)
|
||||
}
|
||||
return a.hub.dispatch(ctx, rack, &rackJob{Kind: "power", Driver: c.Driver, Address: c.Address, Port: c.Port, User: c.User,
|
||||
Password: c.Password, Extra: c.Extra, Op: op, Arg: arg, Timeout: int((fwTimeouts[op] + time.Minute).Seconds())})
|
||||
}
|
||||
|
||||
func (s *Store) SetBMC(ctx context.Context, id string, bmc []byte, secret []byte) error {
|
||||
if secret != nil {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE machines SET bmc=$2, bmc_secret=$3 WHERE id=$1`, id, bmc, secret)
|
||||
@@ -95,6 +183,27 @@ func (s *Store) SetBMC(ctx context.Context, id string, bmc []byte, secret []byte
|
||||
// probeBMC: after commissioning, a BMC still on plain IPMI is asked whether it
|
||||
// speaks Redfish with the same account; if it does, the vendor's driver takes over.
|
||||
// An operator's explicit choice (driver_set) is left alone.
|
||||
// verifyHintedBMC runs a status call against a BMC from bmc_hints (the agent no
|
||||
// longer gets its password) and records whether it answered.
|
||||
func (a *App) verifyHintedBMC(m *Machine) {
|
||||
var b obj
|
||||
if m.BMC == nil || json.Unmarshal(m.BMC, &b) != nil || str(b["source"]) != "hint" || b["driver_set"] == true {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
out, err := a.power(ctx, m, "status", "")
|
||||
b["verified_oob"] = err == nil
|
||||
if err != nil {
|
||||
b["errors"] = []string{"hinted BMC did not answer: " + oneLine(err.Error())}
|
||||
} else {
|
||||
delete(b, "errors")
|
||||
}
|
||||
nb, _ := json.Marshal(b)
|
||||
_ = a.store.SetBMC(ctx, m.ID, nb, nil)
|
||||
_ = a.store.AddEvent(ctx, m.ID, "bmc hint", fmt.Sprintf("%s: %s", str(b["address"]), oneLine(orDefault(out, fmt.Sprint(err)))))
|
||||
}
|
||||
|
||||
func (a *App) probeBMC(m *Machine) {
|
||||
if m.BMC == nil || m.bmcSecret == nil {
|
||||
return
|
||||
@@ -112,6 +221,12 @@ func (a *App) probeBMC(m *Machine) {
|
||||
}
|
||||
probe := *c
|
||||
probe.Driver, probe.Port = "redfish", 0
|
||||
pinned, err := a.pinBMC(context.Background(), m, &probe)
|
||||
if err != nil {
|
||||
_ = a.store.AddEvent(context.Background(), m.ID, "bmc probe", "staying on ipmi: "+oneLine(err.Error()))
|
||||
return
|
||||
}
|
||||
probe = *pinned
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90e9)
|
||||
defer cancel()
|
||||
out, err := a.powerVia(ctx, a.rackOf(m), &probe, "probe", "")
|
||||
@@ -174,9 +289,15 @@ func (a *App) handleBMC(w http.ResponseWriter, r *http.Request) {
|
||||
b["driver"] = "ipmi"
|
||||
nb, _ := json.Marshal(b)
|
||||
_ = a.store.SetBMC(r.Context(), m.ID, nb, nil)
|
||||
m, _ = a.store.Get(r.Context(), m.ID)
|
||||
if m, err = a.store.Get(r.Context(), m.ID); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
a.probeBMC(m)
|
||||
m, _ = a.store.Get(r.Context(), m.ID)
|
||||
if m, err = a.store.Get(r.Context(), m.ID); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(m.BMC, &b)
|
||||
writeJSON(w, 200, obj{"id": m.ID, "bmc": b})
|
||||
return
|
||||
@@ -189,7 +310,12 @@ func (a *App) handleBMC(w http.ResponseWriter, r *http.Request) {
|
||||
b["driver"] = *req.Driver
|
||||
}
|
||||
if req.Address != nil {
|
||||
b["address"] = strings.TrimSpace(*req.Address)
|
||||
if a := strings.TrimSpace(*req.Address); a != str(b["address"]) {
|
||||
if ex, _ := b["extra"].(map[string]any); ex != nil {
|
||||
delete(ex, "tls_fingerprint") // another BMC: its certificate is pinned anew
|
||||
}
|
||||
b["address"] = a
|
||||
}
|
||||
}
|
||||
if req.Port != nil {
|
||||
b["port"] = *req.Port
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package main
|
||||
|
||||
// Physical layout: sites with coordinates (the map), rooms and where each
|
||||
// rack stands in its room (the floor plan). With NetBox as the IPAM
|
||||
// authority, sites (name, coordinates, address), locations (rooms) and racks
|
||||
// (name, height) are read from it on every reconcile and never written back;
|
||||
// otherwise they are entered here. Rack positions are Rollcall's either way:
|
||||
// NetBox has no floor coordinates. A position is in floor tiles (600 mm): a
|
||||
// rack is one tile wide and two deep, its front facing N, E, S or W.
|
||||
//
|
||||
// A machine's building is matched to a site by name or slug, ignoring case.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Site struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Source string `json:"source"` // local | netbox
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
Building string `json:"building"`
|
||||
Name string `json:"name"`
|
||||
Width int `json:"width"` // floor tiles; 0 = from the racks
|
||||
Depth int `json:"depth"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
func (s *Store) Sites(ctx context.Context) ([]Site, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(slug,''), lat, lon, coalesce(address,''), source FROM sites ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Site{}
|
||||
for rows.Next() {
|
||||
var x Site
|
||||
if err := rows.Scan(&x.Name, &x.Slug, &x.Lat, &x.Lon, &x.Address, &x.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutSite stores a site; coordinates that are nil keep the stored ones.
|
||||
func (s *Store) PutSite(ctx context.Context, x Site) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO sites(name, slug, lat, lon, address, source) VALUES ($1, nullif($2,''), $3, $4, nullif($5,''), $6)
|
||||
ON CONFLICT (name) DO UPDATE SET slug=coalesce(nullif($2,''), sites.slug), lat=coalesce($3, sites.lat), lon=coalesce($4, sites.lon),
|
||||
address=coalesce(nullif($5,''), sites.address), source=$6`, x.Name, x.Slug, x.Lat, x.Lon, x.Address, orDefault(x.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSite(ctx context.Context, name string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM sites WHERE name=$1`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Rooms(ctx context.Context) ([]Room, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT building, name, width, depth, source FROM rooms ORDER BY building, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Room{}
|
||||
for rows.Next() {
|
||||
var x Room
|
||||
if err := rows.Scan(&x.Building, &x.Name, &x.Width, &x.Depth, &x.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutRoom stores a room; a size of 0 keeps the stored one.
|
||||
func (s *Store) PutRoom(ctx context.Context, x Room) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO rooms(building, name, width, depth, source) VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (building, name) DO UPDATE SET width=CASE WHEN $3>0 THEN $3 ELSE rooms.width END,
|
||||
depth=CASE WHEN $4>0 THEN $4 ELSE rooms.depth END, source=$5`, x.Building, x.Name, x.Width, x.Depth, orDefault(x.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRackPosition places a rack on its room's floor (nil x/y: back to automatic).
|
||||
func (s *Store) SetRackPosition(ctx context.Context, building, room, name string, x, y *int, facing string) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE racks SET x=$4, y=$5, facing=nullif($6,'') WHERE building=$1 AND room=$2 AND name=$3`, building, room, name, x, y, facing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespellRack folds rack `from` into `to`, the same rack spelled otherwise
|
||||
// (NetBox's spelling of a rack typed in by hand): its place and comment go to
|
||||
// `to` where that has none, and machines located in `from` move to `to`.
|
||||
func (s *Store) RespellRack(ctx context.Context, from, to Rack) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO racks(building, room, name, height, comment, x, y, facing)
|
||||
SELECT $4, $5, $6, height, comment, x, y, facing FROM racks WHERE building=$1 AND room=$2 AND name=$3
|
||||
ON CONFLICT (building, room, name) DO UPDATE SET comment=coalesce(nullif(racks.comment,''), excluded.comment),
|
||||
x=coalesce(racks.x, excluded.x), y=CASE WHEN racks.x IS NULL THEN excluded.y ELSE racks.y END,
|
||||
facing=CASE WHEN racks.x IS NULL THEN excluded.facing ELSE racks.facing END`,
|
||||
from.Building, from.Room, from.Name, to.Building, to.Room, to.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM racks WHERE building=$1 AND room=$2 AND name=$3`, from.Building, from.Room, from.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE machines SET building=nullif($4,''), room=nullif($5,''), rack=$6
|
||||
WHERE coalesce(building,'')=$1 AND coalesce(room,'')=$2 AND rack=$3`, from.Building, from.Room, from.Name, to.Building, to.Room, to.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// sameRack: one rack whatever the case of its building, room and name
|
||||
func sameRack(a, b Rack) bool {
|
||||
return strings.EqualFold(a.Building, b.Building) && strings.EqualFold(a.Room, b.Room) && strings.EqualFold(a.Name, b.Name)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
func (a *App) handleSites(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
xs, err := a.store.Sites(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, xs)
|
||||
case http.MethodPost:
|
||||
var x Site
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Name) == "" {
|
||||
fail(w, 400, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
x.Name, x.Source = strings.TrimSpace(x.Name), "local"
|
||||
if (x.Lat == nil) != (x.Lon == nil) || x.Lat != nil && (*x.Lat < -90 || *x.Lat > 90 || *x.Lon < -180 || *x.Lon > 180) {
|
||||
fail(w, 400, errors.New("lat and lon go together: -90..90, -180..180"))
|
||||
return
|
||||
}
|
||||
if cur, _ := a.store.Sites(r.Context()); a.ipamIsNetBox() {
|
||||
for _, c := range cur {
|
||||
if c.Name == x.Name && c.Source == "netbox" {
|
||||
x.Source = "netbox" // coordinates set here stand until NetBox has its own
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.store.PutSite(r.Context(), x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
case http.MethodDelete:
|
||||
if err := a.store.DeleteSite(r.Context(), r.URL.Query().Get("name")); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": r.URL.Query().Get("name")})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleRooms(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
xs, err := a.store.Rooms(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, xs)
|
||||
case http.MethodPost:
|
||||
var x Room
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Building) == "" {
|
||||
fail(w, 400, errors.New("building is required"))
|
||||
return
|
||||
}
|
||||
if x.Width < 0 || x.Depth < 0 || x.Width > 500 || x.Depth > 500 {
|
||||
fail(w, 400, errors.New("width and depth are floor tiles, 0..500"))
|
||||
return
|
||||
}
|
||||
x.Source = "local"
|
||||
if err := a.store.PutRoom(r.Context(), x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRackPositions places racks: [{"building","room","name","x","y","facing"}].
|
||||
func (a *App) handleRackPositions(w http.ResponseWriter, r *http.Request) {
|
||||
var req []struct {
|
||||
Building, Room, Name string
|
||||
X, Y *int
|
||||
Facing string
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
for _, p := range req {
|
||||
if p.Facing != "" && !strings.Contains("NESW", p.Facing) || len(p.Facing) > 1 {
|
||||
fail(w, 400, fmt.Errorf("%s: facing is N, E, S or W", p.Name))
|
||||
return
|
||||
}
|
||||
if (p.X == nil) != (p.Y == nil) || p.X != nil && (*p.X < 0 || *p.Y < 0 || *p.X > 500 || *p.Y > 500) {
|
||||
fail(w, 400, fmt.Errorf("%s: x and y go together, 0..500 tiles", p.Name))
|
||||
return
|
||||
}
|
||||
if err := a.store.SetRackPosition(r.Context(), p.Building, p.Room, p.Name, p.X, p.Y, p.Facing); err != nil {
|
||||
fail(w, 400, fmt.Errorf("%s: %w", p.Name, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, obj{"placed": len(req)})
|
||||
}
|
||||
|
||||
func (a *App) ipamIsNetBox() bool { _, ok := a.ipam.(*netBox); return ok }
|
||||
|
||||
// ---------------------------------------------------------------- NetBox
|
||||
|
||||
// syncLayout copies NetBox's sites, locations and racks: what Rollcall shows
|
||||
// on the map and the floor plans. Nothing is deleted here: a site, room or
|
||||
// rack that left NetBox stays until removed by hand.
|
||||
func (a *App) syncLayout(ctx context.Context) error {
|
||||
nb, ok := a.ipam.(*netBox)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sites, rooms, racks, err := nb.layout(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, x := range sites {
|
||||
if err := a.store.PutSite(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, x := range rooms {
|
||||
if err := a.store.PutRoom(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
have, _ := a.store.Racks(ctx)
|
||||
for _, x := range racks {
|
||||
for _, h := range have { // keep the comment written here
|
||||
if h.Building == x.Building && h.Room == x.Room && h.Name == x.Name {
|
||||
x.Comment = h.Comment
|
||||
}
|
||||
}
|
||||
if err := a.store.PutRack(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range have { // the same rack typed in otherwise: NetBox's spelling wins
|
||||
if sameRack(h, x) && (h.Building != x.Building || h.Room != x.Room || h.Name != x.Name) {
|
||||
if err := a.store.RespellRack(ctx, h, x); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("layout: rack %s / %s / %s is %s / %s / %s in NetBox: merged", h.Building, h.Room, h.Name, x.Building, x.Room, x.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.syncSwitches(ctx, nb); err != nil {
|
||||
return fmt.Errorf("switches: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// layout reads NetBox's sites (coordinates when set), locations as rooms and racks.
|
||||
func (n *netBox) layout(ctx context.Context) ([]Site, []Room, []Rack, error) {
|
||||
ss, err := n.all(ctx, "dcim/sites/", nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var sites []Site
|
||||
byID := map[int64]string{}
|
||||
for _, s := range ss {
|
||||
name := str(s["name"])
|
||||
byID[nbID(s)] = name
|
||||
x := Site{Name: name, Slug: str(s["slug"]), Address: oneLine(str(s["physical_address"])), Source: "netbox"}
|
||||
if lat, ok := s["latitude"].(float64); ok {
|
||||
if lon, ok := s["longitude"].(float64); ok {
|
||||
x.Lat, x.Lon = &lat, &lon
|
||||
}
|
||||
}
|
||||
sites = append(sites, x)
|
||||
}
|
||||
ls, err := n.all(ctx, "dcim/locations/", nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("locations: %w", err)
|
||||
}
|
||||
var rooms []Room
|
||||
locName := map[int64]string{}
|
||||
for _, l := range ls {
|
||||
locName[nbID(l)] = str(l["name"])
|
||||
if site := byID[nbRef(l, "site")]; site != "" {
|
||||
rooms = append(rooms, Room{Building: site, Name: str(l["name"]), Source: "netbox"})
|
||||
}
|
||||
}
|
||||
rs, err := n.all(ctx, "dcim/racks/", nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("racks: %w", err)
|
||||
}
|
||||
var racks []Rack
|
||||
for _, rk := range rs {
|
||||
if site := byID[nbRef(rk, "site")]; site != "" {
|
||||
racks = append(racks, Rack{Building: site, Room: locName[nbRef(rk, "location")], Name: str(rk["name"]), Height: int(num(rk["u_height"]))})
|
||||
}
|
||||
}
|
||||
return sites, rooms, racks, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetBoxLayout(t *testing.T) {
|
||||
nb, f := testDCIM(t) // sites Ev and DC1, location Hall A (DC1), rack R12 in both
|
||||
dc := f.find("dcim/sites/", "slug", "dc1")
|
||||
dc["latitude"], dc["longitude"], dc["physical_address"] = 41.0082, 28.9784, "Hall Street 1\nIstanbul"
|
||||
for _, r := range f.objs["dcim/racks/"] {
|
||||
r["u_height"] = float64(48)
|
||||
if r["site"] == dc["id"] {
|
||||
r["location"] = f.find("dcim/locations/", "name", "Hall A")["id"]
|
||||
}
|
||||
}
|
||||
sites, rooms, racks, err := nb.layout(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]Site{}
|
||||
for _, s := range sites {
|
||||
got[s.Name] = s
|
||||
}
|
||||
if d := got["DC1"]; d.Lat == nil || *d.Lat != 41.0082 || *d.Lon != 28.9784 || d.Address != "Hall Street 1 · Istanbul" || d.Slug != "dc1" || d.Source != "netbox" {
|
||||
t.Fatalf("DC1: %+v", d)
|
||||
}
|
||||
if e := got["Ev"]; e.Lat != nil {
|
||||
t.Fatalf("a site without coordinates got some: %+v", e)
|
||||
}
|
||||
if fmt.Sprint(rooms) != "[{DC1 Hall A 0 0 netbox}]" {
|
||||
t.Fatalf("rooms %v", rooms)
|
||||
}
|
||||
want := map[string]string{"DC1": "Hall A", "Ev": ""}
|
||||
for _, r := range racks {
|
||||
if r.Name != "R12" || r.Height != 48 || want[r.Building] != r.Room {
|
||||
t.Fatalf("rack %+v", r)
|
||||
}
|
||||
}
|
||||
if len(racks) != 2 {
|
||||
t.Fatalf("racks %v", racks)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ type Rack struct {
|
||||
Height int `json:"height"`
|
||||
Comment string `json:"comment"`
|
||||
Machines int `json:"machines,omitempty"`
|
||||
// on the room's floor (layout.go): tiles from the room's corner, front facing N/E/S/W
|
||||
X *int `json:"x"`
|
||||
Y *int `json:"y"`
|
||||
Facing string `json:"facing,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
@@ -67,7 +71,7 @@ func (s *Store) SetLocation(ctx context.Context, id string, l Location) error {
|
||||
}
|
||||
|
||||
func (s *Store) Racks(ctx context.Context) ([]Rack, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT building, room, name, height, coalesce(comment,'') FROM racks ORDER BY building, room, name`)
|
||||
rows, err := s.pool.Query(ctx, `SELECT building, room, name, height, coalesce(comment,''), x, y, coalesce(facing,'') FROM racks ORDER BY building, room, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,7 +79,7 @@ func (s *Store) Racks(ctx context.Context) ([]Rack, error) {
|
||||
out := []Rack{}
|
||||
for rows.Next() {
|
||||
var r Rack
|
||||
if err := rows.Scan(&r.Building, &r.Room, &r.Name, &r.Height, &r.Comment); err != nil {
|
||||
if err := rows.Scan(&r.Building, &r.Room, &r.Name, &r.Height, &r.Comment, &r.X, &r.Y, &r.Facing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
@@ -136,6 +140,7 @@ func (a *App) handleLocation(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.store.AddEvent(r.Context(), m.ID, "location", l.String())
|
||||
m, _ = a.store.Get(r.Context(), m.ID)
|
||||
a.retag(r.Context(), []*Machine{m}) // rules may look at the rack
|
||||
a.kickIPAM() // the NetBox device follows (ipam.device_sync)
|
||||
writeJSON(w, 200, obj{"id": m.ID, "location": m.Location})
|
||||
}
|
||||
|
||||
@@ -146,7 +151,7 @@ func (a *App) ensureRack(ctx context.Context, l Location) {
|
||||
}
|
||||
racks, _ := a.store.Racks(ctx)
|
||||
for _, r := range racks {
|
||||
if r.Building == l.Building && r.Room == l.Room && r.Name == l.Rack {
|
||||
if sameRack(r, Rack{Building: l.Building, Room: l.Room, Name: l.Rack}) { // "lab" is the rack NetBox calls "Lab"
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -164,7 +169,7 @@ func (a *App) handleRacks(w http.ResponseWriter, r *http.Request) {
|
||||
ms, _ := a.store.List(r.Context())
|
||||
for i := range racks {
|
||||
for _, m := range ms {
|
||||
if m.Location.Building == racks[i].Building && m.Location.Room == racks[i].Room && m.Location.Rack == racks[i].Name {
|
||||
if sameRack(racks[i], Rack{Building: m.Location.Building, Room: m.Location.Room, Name: m.Location.Rack}) {
|
||||
racks[i].Machines++
|
||||
}
|
||||
}
|
||||
@@ -302,6 +307,7 @@ func (a *App) handleLocationImport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if !dry {
|
||||
a.retag(r.Context(), nil)
|
||||
a.kickIPAM()
|
||||
}
|
||||
if matched == nil {
|
||||
matched = []obj{}
|
||||
|
||||
+22
-4
@@ -32,7 +32,9 @@ func usage() {
|
||||
rollcall rack [-config /etc/rollcall/rack.json] a rack controller next to the machines
|
||||
rollcall rack join CODE -url http://ADDR [-name N] [-listen ADDR:80] enroll a rack controller (writes rack.json + certificate)
|
||||
rollcall ca show | issue NAME -host h1,h2 [-client] [-out DIR] the installation's certificate authority (region host)
|
||||
rollcall ca intermediate NAME -permit DOMAIN,CIDR [-csr FILE] [-days 365] | rotate -yes delegate to an ACME CA / replace the root
|
||||
rollcall ca intermediate NAME -permit DOMAIN,CIDR [-csr FILE] [-days 365] delegate to an ACME CA (the region renews it)
|
||||
rollcall ca intermediates the intermediates the region keeps, and what needs attention
|
||||
rollcall ca rotate [status | prepare | switch | retire] [-force] replace the root without re-joining racks
|
||||
rollcall version
|
||||
rollcall keygen
|
||||
rollcall list
|
||||
@@ -49,6 +51,9 @@ func usage() {
|
||||
rollcall unassign ID
|
||||
rollcall overlay ID [-stage system|runtime] [-extract DIR]
|
||||
rollcall render PROFILE PATH [-stage system|runtime]
|
||||
rollcall reservations [status] | sync [-dry] fixed leases on the site's DHCP server (Kea, OpenWrt) for proxy-mode VLANs
|
||||
rollcall firmware list | import FILE|URL -vendor V -component C -version X [-models M,..] [-sha256 S] | rm PKG
|
||||
rollcall firmware show MACHINE [-refresh] | update MACHINE|tag:T PKG [-apply OnReset|Immediate] [-reboot] [-method M] [-force] [-watch]
|
||||
rollcall os list | sync [NAME...] [-force] | import NAME FILE [-family F] [-version V] | promote NAME VERSION | rm NAME VERSION
|
||||
rollcall deploy ID OS [-version V] [-disk /dev/sdX] [-hostname H] [-upgrade] [-user-data FILE] [-boot] [-watch]
|
||||
rollcall release ID [-boot] [-watch]
|
||||
@@ -60,12 +65,13 @@ func usage() {
|
||||
rollcall location racks | rack NAME -building B -room R [-height 42]
|
||||
rollcall list [-tag T] [-building B] [-room R] [-rack K]
|
||||
rollcall fabric list | add NAME [-kind ethernet|infiniband] | rm NAME
|
||||
rollcall vlan list | add FABRIC VID [-dhcp] [-rack R] [-secondary R] [-mtu N] [-name N] | rm FABRIC VID
|
||||
rollcall vlan list | add FABRIC VID [-dhcp | -proxy-dhcp] [-rack R] [-secondary R] [-mtu N] [-name N] | rm FABRIC VID
|
||||
rollcall subnet list | show CIDR | add CIDR -fabric F [-vid N] [-purpose P] [-gateway G] [-dns a,b] [-domain D] | rm CIDR [-force]
|
||||
rollcall range add CIDR dynamic|reserved|static START END | rm ID
|
||||
rollcall ip list [-subnet CIDR] | reserve IP [-name N] | release IP
|
||||
rollcall admin-cert request NAME | list | revoke NAME|SERIAL admin client certificate (browser/CLI without the token)
|
||||
rollcall ipam [status] | sync [-dry] | prefixes | import external IPAM (NetBox): state, reconcile, delegated prefixes
|
||||
rollcall dns [status] | sync [-dry] DNS records written directly (PowerDNS API / RFC 2136)
|
||||
rollcall ipam [status] | sync [-dry] | prefixes | import external IPAM (NetBox, Infoblox): state, reconcile, delegated prefixes
|
||||
rollcall layout list | show NAME | add NAME FILE.json | rm NAME
|
||||
rollcall net show ID | set ID [-layout L] [-rack R] [-static IFACE=IP] [-unstatic IP]
|
||||
rollcall controllers | controller rm NAME | controller join-code [-name N] [-ttl 60] [-addr HOST:443] | dhcp CONTROLLER
|
||||
@@ -97,6 +103,13 @@ func main() {
|
||||
cmd, args := os.Args[1], os.Args[2:]
|
||||
switch cmd {
|
||||
case "rack":
|
||||
if len(args) > 0 && args[0] == "renew" {
|
||||
if err := rackRenew(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) > 0 && args[0] == "join" {
|
||||
if err := rackJoin(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
@@ -141,11 +154,16 @@ func main() {
|
||||
case "list", "show", "events", "power", "bootdev", "recommission", "delete",
|
||||
"images", "profile", "assign", "unassign", "overlay", "render",
|
||||
"deploy", "release", "jobs", "job", "tag", "tags", "location",
|
||||
"bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "admin-cert":
|
||||
"bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns", "reservations", "admin-cert":
|
||||
if err := runClient(cmd, args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "firmware":
|
||||
if err := firmwareCmd(args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "os":
|
||||
if err := osCmd(args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
package main
|
||||
|
||||
// NetBox device sync (ipam.device_sync): each machine becomes a dcim device
|
||||
// with its serial, interfaces (MACs as dcim mac-addresses) and location, and
|
||||
// Rollcall's IP addresses in NetBox are assigned to those interfaces (the
|
||||
// management address becomes the device's primary IP).
|
||||
//
|
||||
// Sites, locations and racks are NetBox's: Rollcall matches the machine's
|
||||
// building, room and rack to them by name and never creates them (a machine
|
||||
// whose building matches no site goes to device_sync.site). Manufacturers,
|
||||
// device types and the role are created when missing, since a device needs
|
||||
// them. Devices carry the tag and the rollcall_machine custom field like
|
||||
// addresses; devices without them are never changed or deleted, and one
|
||||
// holding a machine's name is a conflict.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DeviceSyncConfig struct {
|
||||
Site string `json:"site"` // site slug for machines whose building matches no site (required)
|
||||
Role string `json:"role"` // device role slug, default "server" (created when missing)
|
||||
}
|
||||
|
||||
type devReport struct {
|
||||
Created []string `json:"created"`
|
||||
Updated []string `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r *devReport) fail(e string) { r.Error = joinErr(r.Error, e) }
|
||||
|
||||
var nbSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func nbSlug(s string) string {
|
||||
s = strings.Trim(nbSlugRe.ReplaceAllString(strings.ToLower(s), "-"), "-")
|
||||
if len(s) > 100 {
|
||||
s = s[:100]
|
||||
}
|
||||
return orDefault(s, "unknown")
|
||||
}
|
||||
|
||||
// all reads every object of a list endpoint.
|
||||
func (n *netBox) all(ctx context.Context, path string, q url.Values) ([]obj, error) {
|
||||
if q == nil {
|
||||
q = url.Values{}
|
||||
}
|
||||
q.Set("limit", "500")
|
||||
next := path + "?" + q.Encode()
|
||||
var out []obj
|
||||
for next != "" {
|
||||
var p nbPage[obj]
|
||||
if err := n.do(ctx, "GET", next, nil, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p.Results...)
|
||||
next = ""
|
||||
if p.Next != nil {
|
||||
next = *p.Next
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func nbID(o obj) int64 {
|
||||
f, _ := o["id"].(float64)
|
||||
return int64(f)
|
||||
}
|
||||
|
||||
// nbRef is the id of a nested object field ({"id": 3, ...}), 0 when unset.
|
||||
func nbRef(o obj, field string) int64 {
|
||||
if m, ok := o[field].(map[string]any); ok {
|
||||
return nbID(m)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mine is the machine a NetBox object belongs to, "" when it is not Rollcall's.
|
||||
func (n *netBox) mine(o obj) string { return n.owner(nbIPFromObj(o)) }
|
||||
|
||||
// ensure finds an object by a query or creates it.
|
||||
func (n *netBox) ensure(ctx context.Context, path string, q url.Values, create obj) (int64, error) {
|
||||
got, err := n.all(ctx, path, q)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(got) > 0 {
|
||||
return nbID(got[0]), nil
|
||||
}
|
||||
var made obj
|
||||
if err := n.do(ctx, "POST", path, create, &made); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return nbID(made), nil
|
||||
}
|
||||
|
||||
func nbStatus(state string) string {
|
||||
switch {
|
||||
case state == "deployed" || state == "booted" || state == "stateless" || state == "deploying" || state == "releasing":
|
||||
return "active"
|
||||
case strings.HasSuffix(state, "-failed"):
|
||||
return "failed"
|
||||
case state == "new":
|
||||
return "planned"
|
||||
}
|
||||
return "staged" // commissioned, ready to deploy
|
||||
}
|
||||
|
||||
func nbIfaceType(n invNIC) string {
|
||||
switch n.Speed {
|
||||
case 1000:
|
||||
return "1000base-t"
|
||||
case 10000:
|
||||
return "10gbase-x-sfpp"
|
||||
case 25000:
|
||||
return "25gbase-x-sfp28"
|
||||
case 40000:
|
||||
return "40gbase-x-qsfpp"
|
||||
case 100000:
|
||||
return "100gbase-x-qsfp28"
|
||||
case 200000:
|
||||
return "200gbase-x-qsfp56"
|
||||
case 400000:
|
||||
return "400gbase-x-qsfpdd"
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
// syncDevices brings NetBox's devices in line with the machines.
|
||||
func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines []*Machine, ips []IPAddr, units map[string]int) *devReport {
|
||||
rep := &devReport{Created: []string{}, Updated: []string{}, Deleted: []string{}, Conflicts: []string{}}
|
||||
if cfg.Site == "" {
|
||||
rep.Error = "ipam.device_sync.site (a site slug) is required"
|
||||
return rep
|
||||
}
|
||||
sites, err := n.all(ctx, "dcim/sites/", nil)
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
siteBy := map[string]int64{}
|
||||
for _, s := range sites {
|
||||
siteBy[strings.ToLower(str(s["slug"]))] = nbID(s)
|
||||
siteBy[strings.ToLower(str(s["name"]))] = nbID(s)
|
||||
}
|
||||
defSite, ok := siteBy[strings.ToLower(cfg.Site)]
|
||||
if !ok {
|
||||
rep.Error = fmt.Sprintf("NetBox has no site %q (ipam.device_sync.site)", cfg.Site)
|
||||
return rep
|
||||
}
|
||||
type siteName struct {
|
||||
site int64
|
||||
name string
|
||||
}
|
||||
locBy, rackBy := map[siteName]int64{}, map[siteName]int64{}
|
||||
if locs, err := n.all(ctx, "dcim/locations/", nil); err == nil {
|
||||
for _, l := range locs {
|
||||
locBy[siteName{nbRef(l, "site"), strings.ToLower(str(l["name"]))}] = nbID(l)
|
||||
}
|
||||
}
|
||||
if racks, err := n.all(ctx, "dcim/racks/", nil); err == nil {
|
||||
for _, r := range racks {
|
||||
rackBy[siteName{nbRef(r, "site"), strings.ToLower(str(r["name"]))}] = nbID(r)
|
||||
}
|
||||
}
|
||||
tags := []obj{{"name": n.tag}}
|
||||
role := orDefault(cfg.Role, "server")
|
||||
roleID, err := n.ensure(ctx, "dcim/device-roles/", url.Values{"slug": {nbSlug(role)}},
|
||||
obj{"name": strings.ToUpper(role[:1]) + role[1:], "slug": nbSlug(role), "color": "2196f3", "tags": tags})
|
||||
if err != nil {
|
||||
rep.Error = "device role: " + err.Error()
|
||||
return rep
|
||||
}
|
||||
owned := map[string]obj{}
|
||||
devs, err := n.all(ctx, "dcim/devices/", url.Values{"tag": {n.tag}})
|
||||
if err != nil {
|
||||
rep.Error = err.Error()
|
||||
return rep
|
||||
}
|
||||
for _, d := range devs {
|
||||
if m := n.mine(d); m != "" {
|
||||
owned[m] = d
|
||||
}
|
||||
}
|
||||
byMachine := map[string][]IPAddr{}
|
||||
for _, x := range ips {
|
||||
if x.MachineID != "" {
|
||||
byMachine[x.MachineID] = append(byMachine[x.MachineID], x)
|
||||
}
|
||||
}
|
||||
nbIPs := map[string]obj{} // Rollcall's addresses in NetBox, by IP
|
||||
if all, err := n.all(ctx, "ipam/ip-addresses/", url.Values{"tag": {n.tag}}); err == nil {
|
||||
for _, a := range all {
|
||||
if p, ok := parseAddr(str(a["address"])); ok {
|
||||
nbIPs[p.String()] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
types := map[string]int64{} // manufacturer|model -> device type id
|
||||
sort.Slice(machines, func(i, j int) bool { return machines[i].ID < machines[j].ID })
|
||||
for _, m := range machines {
|
||||
if m.State == "new" {
|
||||
continue
|
||||
}
|
||||
name := orDefault(deref(m.NodeName), m.ID)
|
||||
mfr, model := orDefault(deref(m.Manufacturer), "Unknown"), orDefault(deref(m.Product), "Unknown")
|
||||
tk := mfr + "|" + model
|
||||
size := machineSize(m, units)
|
||||
if _, ok := types[tk]; !ok {
|
||||
mid, err := n.ensure(ctx, "dcim/manufacturers/", url.Values{"slug": {nbSlug(mfr)}}, obj{"name": mfr, "slug": nbSlug(mfr), "tags": tags})
|
||||
if err != nil {
|
||||
rep.fail(fmt.Sprintf("%s: manufacturer: %v", name, err))
|
||||
continue
|
||||
}
|
||||
tid, err := n.ensureType(ctx, mid, model, size, tags)
|
||||
if err != nil {
|
||||
rep.fail(fmt.Sprintf("%s: device type: %v", name, err))
|
||||
continue
|
||||
}
|
||||
types[tk] = tid
|
||||
}
|
||||
site := defSite
|
||||
if id, ok := siteBy[strings.ToLower(m.Location.Building)]; ok && m.Location.Building != "" {
|
||||
site = id
|
||||
}
|
||||
body := obj{"name": name, "device_type": types[tk], "role": roleID, "site": site, "status": nbStatus(m.State),
|
||||
"serial": trunc(dmiValue(deref(m.Serial)), 50), "tags": tags, "custom_fields": obj{n.field: m.ID},
|
||||
"location": nil, "rack": nil, "position": nil, "face": nil}
|
||||
if id, ok := locBy[siteName{site, strings.ToLower(m.Location.Room)}]; ok && m.Location.Room != "" {
|
||||
body["location"] = id
|
||||
}
|
||||
if id, ok := rackBy[siteName{site, strings.ToLower(m.Location.Rack)}]; ok && m.Location.Rack != "" {
|
||||
body["rack"] = id
|
||||
if m.Location.Unit > 0 && !size.Node { // a sled takes no unit of its own: in the rack, without a position
|
||||
body["position"], body["face"] = m.Location.Unit, "front"
|
||||
}
|
||||
}
|
||||
if m.Location.AssetTag != "" {
|
||||
body["asset_tag"] = m.Location.AssetTag
|
||||
}
|
||||
var dev obj
|
||||
if d, ok := owned[m.ID]; ok {
|
||||
delete(owned, m.ID)
|
||||
if deviceDiffers(d, body) {
|
||||
if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/devices/%d/", nbID(d)), body, &dev); err != nil {
|
||||
rep.fail(fmt.Sprintf("%s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
rep.Updated = append(rep.Updated, name)
|
||||
} else {
|
||||
dev = d
|
||||
}
|
||||
} else {
|
||||
if same, err := n.all(ctx, "dcim/devices/", url.Values{"name": {name}, "site_id": {strconv.FormatInt(site, 10)}}); err != nil {
|
||||
rep.fail(fmt.Sprintf("%s: %v", name, err))
|
||||
continue
|
||||
} else if len(same) > 0 {
|
||||
rep.Conflicts = append(rep.Conflicts, name+": NetBox has a device of that name (not Rollcall's)")
|
||||
continue
|
||||
}
|
||||
if err := n.do(ctx, "POST", "dcim/devices/", body, &dev); err != nil {
|
||||
rep.fail(fmt.Sprintf("%s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
rep.Created = append(rep.Created, name)
|
||||
}
|
||||
if err := n.syncInterfaces(ctx, dev, m, byMachine[m.ID], nbIPs, tags); err != nil {
|
||||
rep.fail(fmt.Sprintf("%s interfaces: %v", name, err))
|
||||
}
|
||||
}
|
||||
gone := make([]string, 0, len(owned))
|
||||
for k := range owned {
|
||||
gone = append(gone, k)
|
||||
}
|
||||
sort.Strings(gone)
|
||||
for _, k := range gone { // machines no longer in Rollcall
|
||||
d := owned[k]
|
||||
if err := n.do(ctx, "DELETE", fmt.Sprintf("dcim/devices/%d/", nbID(d)), nil, nil); err != nil {
|
||||
rep.fail(fmt.Sprintf("delete %s: %v", str(d["name"]), err))
|
||||
continue
|
||||
}
|
||||
rep.Deleted = append(rep.Deleted, str(d["name"]))
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// deviceDiffers compares what Rollcall sets with what NetBox holds.
|
||||
func deviceDiffers(d, want obj) bool {
|
||||
ref := func(f string) int64 { return nbRef(d, f) }
|
||||
num := func(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case float64:
|
||||
return int64(x)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if str(d["name"]) != str(want["name"]) || str(d["serial"]) != str(want["serial"]) || str(asObj(d["status"])["value"]) != str(want["status"]) {
|
||||
return true
|
||||
}
|
||||
for _, f := range []string{"device_type", "role", "site", "location", "rack"} {
|
||||
if ref(f) != num(want[f]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if num(d["position"]) != num(want["position"]) {
|
||||
return true
|
||||
}
|
||||
return str(d["asset_tag"]) != str(want["asset_tag"])
|
||||
}
|
||||
|
||||
// syncInterfaces keeps a device's interfaces, MACs and assigned addresses.
|
||||
func (n *netBox) syncInterfaces(ctx context.Context, dev obj, m *Machine, ips []IPAddr, nbIPs map[string]obj, tags []obj) error {
|
||||
devID := nbID(dev)
|
||||
have := map[string]obj{}
|
||||
got, err := n.all(ctx, "dcim/interfaces/", url.Values{"device_id": {strconv.FormatInt(devID, 10)}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, i := range got {
|
||||
have[str(i["name"])] = i
|
||||
}
|
||||
ifaceID := map[string]int64{}
|
||||
nics := machineNICs(m)
|
||||
for _, nic := range nics {
|
||||
cur, ok := have[nic.Name]
|
||||
delete(have, nic.Name)
|
||||
if !ok {
|
||||
if err := n.do(ctx, "POST", "dcim/interfaces/", obj{"device": devID, "name": nic.Name, "type": nbIfaceType(nic), "tags": tags}, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ifaceID[nic.Name] = nbID(cur)
|
||||
if nic.MAC == "" {
|
||||
continue
|
||||
}
|
||||
if pm := asObj(cur["primary_mac_address"]); strings.EqualFold(str(pm["mac_address"]), nic.MAC) {
|
||||
continue
|
||||
}
|
||||
macs, err := n.all(ctx, "dcim/mac-addresses/", url.Values{"interface_id": {strconv.FormatInt(nbID(cur), 10)}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var macID int64
|
||||
for _, x := range macs {
|
||||
if strings.EqualFold(str(x["mac_address"]), nic.MAC) {
|
||||
macID = nbID(x)
|
||||
}
|
||||
}
|
||||
if macID == 0 {
|
||||
var made obj
|
||||
if err := n.do(ctx, "POST", "dcim/mac-addresses/", obj{"mac_address": nic.MAC, "assigned_object_type": "dcim.interface", "assigned_object_id": nbID(cur), "tags": tags}, &made); err != nil {
|
||||
return err
|
||||
}
|
||||
macID = nbID(made)
|
||||
}
|
||||
if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/interfaces/%d/", nbID(cur)), obj{"primary_mac_address": macID}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for name, i := range have { // interfaces Rollcall made that the machine no longer has
|
||||
if n.tagged(i) {
|
||||
if err := n.do(ctx, "DELETE", fmt.Sprintf("dcim/interfaces/%d/", nbID(i)), nil, nil); err != nil {
|
||||
return fmt.Errorf("delete interface %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// addresses on their interfaces; the primary IP of each family is the
|
||||
// management address, else the first address on the management (or
|
||||
// boot) interface
|
||||
var boot string
|
||||
if b := bootNIC(nics, m.MACs); b != nil {
|
||||
boot = b.Name
|
||||
}
|
||||
mgmt := deref(m.MgmtIP)
|
||||
type cand struct {
|
||||
id, iface int64
|
||||
fam int
|
||||
}
|
||||
var cands []cand
|
||||
primary := map[int]int64{}
|
||||
mgmtIface := ifaceID[boot]
|
||||
for _, x := range ips {
|
||||
a, ok := nbIPs[x.IP]
|
||||
if !ok || n.mine(a) != m.ID {
|
||||
continue
|
||||
}
|
||||
iid, ok := ifaceID[orDefault(x.Iface, boot)]
|
||||
if !ok && x.IP == mgmt {
|
||||
iid, ok = ifaceID[boot]
|
||||
}
|
||||
if !ok {
|
||||
continue // a bond or VLAN interface: not in the inventory
|
||||
}
|
||||
if str(a["assigned_object_type"]) != "dcim.interface" || int64(num(a["assigned_object_id"])) != iid {
|
||||
if err := n.do(ctx, "PATCH", fmt.Sprintf("ipam/ip-addresses/%d/", nbID(a)), obj{"assigned_object_type": "dcim.interface", "assigned_object_id": iid}, nil); err != nil {
|
||||
return fmt.Errorf("assign %s: %w", x.IP, err)
|
||||
}
|
||||
}
|
||||
fam := 4
|
||||
if strings.Contains(x.IP, ":") {
|
||||
fam = 6
|
||||
}
|
||||
if x.IP == mgmt {
|
||||
primary[fam], mgmtIface = nbID(a), iid
|
||||
}
|
||||
cands = append(cands, cand{nbID(a), iid, fam})
|
||||
}
|
||||
for _, c := range cands {
|
||||
if _, set := primary[c.fam]; !set && c.iface == mgmtIface {
|
||||
primary[c.fam] = c.id
|
||||
}
|
||||
}
|
||||
body := obj{}
|
||||
if id, ok := primary[4]; ok && nbRef(dev, "primary_ip4") != id {
|
||||
body["primary_ip4"] = id
|
||||
}
|
||||
if id, ok := primary[6]; ok && nbRef(dev, "primary_ip6") != id {
|
||||
body["primary_ip6"] = id
|
||||
}
|
||||
if len(body) > 0 {
|
||||
if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/devices/%d/", devID), body, nil); err != nil {
|
||||
return fmt.Errorf("primary ip: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tagged: an object Rollcall created (it carries the tag).
|
||||
func (n *netBox) tagged(o obj) bool {
|
||||
for _, t := range toList(o["tags"]) {
|
||||
if str(asObj(t)["name"]) == n.tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nbIPFromObj(a obj) nbIP {
|
||||
x := nbIP{CustomFields: asObj(a["custom_fields"])}
|
||||
for _, t := range toList(a["tags"]) {
|
||||
x.Tags = append(x.Tags, struct{ Name string }{str(asObj(t)["name"])})
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// dmiValue drops the placeholders firmware leaves in DMI fields.
|
||||
func dmiValue(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "not specified", "to be filled by o.e.m.", "default string", "system serial number", "0123456789", "none", "n/a":
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// typeHeight is a device type's u_height for a machine size: 0 for a sled in
|
||||
// a shared chassis, 1 when the height is unknown.
|
||||
func typeHeight(sz rackSize) int {
|
||||
switch {
|
||||
case sz.Node:
|
||||
return 0
|
||||
case sz.U > 0:
|
||||
return sz.U
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// ensureType finds or creates the device type; a type Rollcall created gets
|
||||
// its height corrected once the machine's is known.
|
||||
func (n *netBox) ensureType(ctx context.Context, mfr int64, model string, sz rackSize, tags []obj) (int64, error) {
|
||||
got, err := n.all(ctx, "dcim/device-types/", url.Values{"manufacturer_id": {strconv.FormatInt(mfr, 10)}, "slug": {nbSlug(model)}})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
uh := typeHeight(sz)
|
||||
if len(got) > 0 {
|
||||
t := got[0]
|
||||
if n.tagged(t) && sz.Source != "" && int(num(t["u_height"])) != uh {
|
||||
// NetBox refuses a height its racked devices do not fit (0 while a device has
|
||||
// a position, more units than are free): the type stays, the devices sync on
|
||||
if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/device-types/%d/", nbID(t)), obj{"u_height": uh}, nil); err != nil {
|
||||
log.Printf("netbox: device type %s stays %vU (wanted %dU): %v", model, t["u_height"], uh, err)
|
||||
}
|
||||
}
|
||||
return nbID(t), nil
|
||||
}
|
||||
var made obj
|
||||
if err := n.do(ctx, "POST", "dcim/device-types/", obj{"manufacturer": mfr, "model": model, "slug": nbSlug(model), "u_height": uh, "tags": tags}, &made); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return nbID(made), nil
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeDCIM keeps NetBox collections as plain objects; references are stored
|
||||
// as ids and served nested ({"id": n}), as NetBox does.
|
||||
type fakeDCIM struct {
|
||||
mu sync.Mutex
|
||||
next int64
|
||||
objs map[string]map[int64]map[string]any // collection path -> id -> object
|
||||
reqs []string
|
||||
}
|
||||
|
||||
var nbNested = []string{"site", "location", "rack", "role", "device_type", "manufacturer", "device", "primary_ip4", "primary_ip6", "primary_mac_address"}
|
||||
|
||||
func newFakeDCIM() *fakeDCIM { return &fakeDCIM{next: 1, objs: map[string]map[int64]map[string]any{}} }
|
||||
|
||||
func (f *fakeDCIM) put(coll string, o map[string]any) int64 {
|
||||
if f.objs[coll] == nil {
|
||||
f.objs[coll] = map[int64]map[string]any{}
|
||||
}
|
||||
id := f.next
|
||||
f.next++
|
||||
o["id"] = float64(id)
|
||||
if s, ok := o["status"].(string); ok {
|
||||
o["status"] = map[string]any{"value": s}
|
||||
}
|
||||
f.objs[coll][id] = o
|
||||
return id
|
||||
}
|
||||
|
||||
func (f *fakeDCIM) out(coll string, o map[string]any) map[string]any {
|
||||
c := map[string]any{}
|
||||
for k, v := range o {
|
||||
c[k] = v
|
||||
}
|
||||
for _, k := range nbNested {
|
||||
if id, ok := tnum(c[k]); ok && id > 0 {
|
||||
n := map[string]any{"id": float64(id)}
|
||||
if k == "primary_mac_address" {
|
||||
n["mac_address"] = f.objs["dcim/mac-addresses/"][id]["mac_address"]
|
||||
}
|
||||
c[k] = n
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func tnum(v any) (int64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x), true
|
||||
case int64:
|
||||
return x, true
|
||||
case int:
|
||||
return int64(x), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (f *fakeDCIM) match(o map[string]any, k, want string) bool {
|
||||
switch k {
|
||||
case "limit":
|
||||
return true
|
||||
case "tag":
|
||||
for _, t := range toList(o["tags"]) {
|
||||
if asObj(t)["name"] == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "interface_id":
|
||||
k = "assigned_object_id"
|
||||
}
|
||||
if strings.HasSuffix(k, "_id") && k != "assigned_object_id" {
|
||||
k = strings.TrimSuffix(k, "_id")
|
||||
}
|
||||
if id, ok := tnum(o[k]); ok {
|
||||
return fmt.Sprint(id) == want
|
||||
}
|
||||
return fmt.Sprint(o[k]) == want
|
||||
}
|
||||
|
||||
func (f *fakeDCIM) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.reqs = append(f.reqs, r.Method+" "+r.URL.Path)
|
||||
write := func(code int, v any) { w.WriteHeader(code); _ = json.NewEncoder(w).Encode(v) }
|
||||
p := strings.TrimPrefix(r.URL.Path, "/api/")
|
||||
var coll string
|
||||
var id int64
|
||||
if i := strings.LastIndex(strings.TrimSuffix(p, "/"), "/"); i > 0 {
|
||||
if _, err := fmt.Sscanf(p[i+1:], "%d/", &id); err == nil {
|
||||
coll = p[:i+1]
|
||||
}
|
||||
}
|
||||
var body map[string]any
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
if coll == "" {
|
||||
coll = p
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
ids := []int64{}
|
||||
for i := range f.objs[coll] {
|
||||
ids = append(ids, i)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
res := []any{}
|
||||
next:
|
||||
for _, i := range ids {
|
||||
o := f.objs[coll][i]
|
||||
for k, v := range r.URL.Query() {
|
||||
if !f.match(o, k, v[0]) {
|
||||
continue next
|
||||
}
|
||||
}
|
||||
res = append(res, f.out(coll, o))
|
||||
}
|
||||
write(200, map[string]any{"next": nil, "results": res})
|
||||
case "POST":
|
||||
if coll == "dcim/devices/" {
|
||||
for _, o := range f.objs[coll] {
|
||||
if o["name"] == body["name"] && fmt.Sprint(o["site"]) == fmt.Sprint(body["site"]) {
|
||||
write(400, map[string]any{"__all__": []string{"Device name must be unique per site."}})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
id := f.put(coll, body)
|
||||
write(201, f.out(coll, f.objs[coll][id]))
|
||||
}
|
||||
return
|
||||
}
|
||||
o, ok := f.objs[coll][id]
|
||||
if !ok {
|
||||
write(404, map[string]any{"detail": "not found"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "PATCH":
|
||||
for k, v := range body {
|
||||
o[k] = v
|
||||
}
|
||||
if s, ok := o["status"].(string); ok {
|
||||
o["status"] = map[string]any{"value": s}
|
||||
}
|
||||
write(200, f.out(coll, o))
|
||||
case "DELETE":
|
||||
delete(f.objs[coll], id)
|
||||
if coll == "dcim/devices/" { // interfaces go with the device
|
||||
for i, x := range f.objs["dcim/interfaces/"] {
|
||||
if n, _ := tnum(x["device"]); n == id {
|
||||
delete(f.objs["dcim/interfaces/"], i)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
default:
|
||||
write(200, f.out(coll, o))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeDCIM) find(coll, k, v string) map[string]any {
|
||||
for _, o := range f.objs[coll] {
|
||||
if fmt.Sprint(o[k]) == v {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeDCIM) count(coll string) int { return len(f.objs[coll]) }
|
||||
|
||||
func testDCIM(t *testing.T) (*netBox, *fakeDCIM) {
|
||||
t.Helper()
|
||||
f := newFakeDCIM()
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
tok := filepath.Join(t.TempDir(), "token")
|
||||
if err := os.WriteFile(tok, []byte("nbt_k.secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := newIPAMProvider(IPAMConfig{Provider: "netbox", URL: srv.URL, TokenFile: tok})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := f.put("dcim/sites/", map[string]any{"name": "Ev", "slug": "ev"})
|
||||
dc := f.put("dcim/sites/", map[string]any{"name": "DC1", "slug": "dc1"})
|
||||
f.put("dcim/locations/", map[string]any{"name": "Hall A", "site": float64(dc)})
|
||||
f.put("dcim/racks/", map[string]any{"name": "R12", "site": float64(dc)})
|
||||
f.put("dcim/racks/", map[string]any{"name": "R12", "site": float64(ev)}) // same name, other site
|
||||
return p.(*netBox), f
|
||||
}
|
||||
|
||||
func dcimMachine(id, name, state string, loc Location, nics ...[2]string) *Machine {
|
||||
var net []obj
|
||||
for _, n := range nics {
|
||||
net = append(net, obj{"name": n[0], "mac": n[1], "speed": "25000Mb/s"})
|
||||
}
|
||||
inv, _ := json.Marshal(obj{"network": net})
|
||||
s := func(v string) *string { return &v }
|
||||
m := &Machine{ID: id, State: state, Serial: s("SN-" + id), Manufacturer: s("Dell Inc."), Product: s("PowerEdge R760"),
|
||||
Inventory: inv, Location: loc}
|
||||
if name != "" {
|
||||
m.NodeName = s(name)
|
||||
}
|
||||
if len(nics) > 0 {
|
||||
m.MACs = []string{nics[0][1]}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestNetBoxDeviceSync(t *testing.T) {
|
||||
nb, f := testDCIM(t)
|
||||
ctx := context.Background()
|
||||
cfg := DeviceSyncConfig{Site: "ev"}
|
||||
tagged := []any{map[string]any{"name": "rollcall"}}
|
||||
ipID := f.put("ipam/ip-addresses/", map[string]any{"address": "10.213.77.50/24", "tags": tagged,
|
||||
"custom_fields": map[string]any{"rollcall_machine": "m-a"}})
|
||||
ip6ID := f.put("ipam/ip-addresses/", map[string]any{"address": "fd00:213:77::50/64", "tags": tagged,
|
||||
"custom_fields": map[string]any{"rollcall_machine": "m-a"}})
|
||||
foreignIP := f.put("ipam/ip-addresses/", map[string]any{"address": "10.213.77.51/24", "tags": []any{},
|
||||
"custom_fields": map[string]any{}})
|
||||
// a device someone else made with a machine's name
|
||||
f.put("dcim/devices/", map[string]any{"name": "taken", "site": float64(1), "tags": []any{}, "custom_fields": map[string]any{}})
|
||||
|
||||
a := dcimMachine("m-a", "node001", "deployed", Location{Building: "DC1", Room: "hall a", Rack: "R12", Unit: 10, AssetTag: "AT-1"},
|
||||
[2]string{"eno1", "aa:bb:cc:00:00:01"}, [2]string{"eno2", "aa:bb:cc:00:00:02"})
|
||||
b := dcimMachine("m-b", "", "ready", Location{Building: "nowhere", Rack: "R12"}, [2]string{"eth0", "aa:bb:cc:00:00:03"})
|
||||
c := dcimMachine("m-c", "taken", "commission-failed", Location{})
|
||||
n := dcimMachine("m-n", "", "new", Location{})
|
||||
ips := []IPAddr{{IP: "10.213.77.50", MachineID: "m-a", Iface: "eno1"}, {IP: "fd00:213:77::50", MachineID: "m-a", Iface: "eno1"},
|
||||
{IP: "10.213.77.51", MachineID: "m-b"}}
|
||||
mgmt := "10.213.77.50"
|
||||
a.MgmtIP = &mgmt
|
||||
ns := "Not Specified"
|
||||
b.Serial = &ns
|
||||
|
||||
rep := nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips, nil)
|
||||
if rep.Error != "" {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
if fmt.Sprint(rep.Created) != "[node001 m-b]" || len(rep.Conflicts) != 1 || !strings.Contains(rep.Conflicts[0], "taken") {
|
||||
t.Fatalf("report %+v", rep)
|
||||
}
|
||||
d := f.find("dcim/devices/", "name", "node001")
|
||||
dc := f.find("dcim/sites/", "slug", "dc1")["id"]
|
||||
hall := f.find("dcim/locations/", "name", "Hall A")["id"]
|
||||
if d["site"] != dc || d["location"] != hall || d["position"] != float64(10) || d["asset_tag"] != "AT-1" ||
|
||||
d["serial"] != "SN-m-a" || asObj(d["status"])["value"] != "active" || asObj(d["custom_fields"])["rollcall_machine"] != "m-a" {
|
||||
t.Fatalf("device %v", d)
|
||||
}
|
||||
if r := f.objs["dcim/racks/"]; r[int64(num(d["rack"]))]["site"] != dc {
|
||||
t.Fatalf("rack from another site: %v", d["rack"])
|
||||
}
|
||||
db := f.find("dcim/devices/", "name", "m-b")
|
||||
if num(db["site"]) != 1 || num(db["rack"]) != 5 || asObj(db["status"])["value"] != "staged" {
|
||||
t.Fatalf("default-site device %v", db) // unknown building: the default site, and its own R12
|
||||
}
|
||||
if f.find("dcim/devices/", "name", "m-n") != nil {
|
||||
t.Fatal("a new machine (not commissioned) was synced")
|
||||
}
|
||||
if f.count("dcim/manufacturers/") != 1 || f.count("dcim/device-types/") != 1 || f.count("dcim/device-roles/") != 1 {
|
||||
t.Fatal("types not shared")
|
||||
}
|
||||
// interfaces with primary MACs, the address on eno1 and as primary IP
|
||||
eno1 := f.find("dcim/interfaces/", "name", "eno1")
|
||||
if eno1["type"] != "25gbase-x-sfp28" {
|
||||
t.Fatalf("iface %v", eno1)
|
||||
}
|
||||
mac := f.objs["dcim/mac-addresses/"][int64(num(eno1["primary_mac_address"]))]
|
||||
if mac["mac_address"] != "aa:bb:cc:00:00:01" {
|
||||
t.Fatalf("primary mac %v", eno1["primary_mac_address"])
|
||||
}
|
||||
ip := f.objs["ipam/ip-addresses/"][ipID]
|
||||
if ip["assigned_object_type"] != "dcim.interface" || ip["assigned_object_id"] != eno1["id"] || d["primary_ip4"] != float64(ipID) {
|
||||
t.Fatalf("ip %v primary %v", ip, d["primary_ip4"])
|
||||
}
|
||||
if d["primary_ip6"] != float64(ip6ID) {
|
||||
t.Fatalf("primary ip6 %v (the v6 address on the management interface)", d["primary_ip6"])
|
||||
}
|
||||
if db["serial"] != "" {
|
||||
t.Fatalf("DMI placeholder as serial: %v", db["serial"])
|
||||
}
|
||||
if f.objs["ipam/ip-addresses/"][foreignIP]["assigned_object_id"] != nil {
|
||||
t.Fatal("assigned an address that is not Rollcall's")
|
||||
}
|
||||
|
||||
// a second pass changes nothing
|
||||
f.reqs = nil
|
||||
rep = nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips, nil)
|
||||
for _, r := range f.reqs {
|
||||
if !strings.HasPrefix(r, "GET ") {
|
||||
t.Fatalf("second pass wrote: %v (%+v)", f.reqs, rep)
|
||||
}
|
||||
}
|
||||
|
||||
// moves, a lost NIC, a removed machine
|
||||
a.State = "releasing"
|
||||
a.Location.Unit = 20
|
||||
a.Inventory, _ = json.Marshal(obj{"network": []obj{{"name": "eno1", "mac": "aa:bb:cc:00:00:01", "speed": "25000Mb/s"}}})
|
||||
rep = nb.syncDevices(ctx, cfg, []*Machine{a, c}, ips, nil)
|
||||
if fmt.Sprint(rep.Updated) != "[node001]" || fmt.Sprint(rep.Deleted) != "[m-b]" || rep.Error != "" {
|
||||
t.Fatalf("report %+v", rep)
|
||||
}
|
||||
if d["position"] != float64(20) || f.find("dcim/interfaces/", "name", "eno2") != nil || f.find("dcim/devices/", "name", "taken") == nil {
|
||||
t.Fatalf("after update: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxDeviceSyncSite(t *testing.T) {
|
||||
nb, _ := testDCIM(t)
|
||||
if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{}, nil, nil, nil); !strings.Contains(rep.Error, "required") {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{Site: "mars"}, nil, nil, nil); !strings.Contains(rep.Error, "no site") {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNBSlug(t *testing.T) {
|
||||
for in, want := range map[string]string{"Dell Inc.": "dell-inc", "PowerEdge R760": "poweredge-r760", " ": "unknown", "xFusion/2288H V7": "xfusion-2288h-v7"} {
|
||||
if got := nbSlug(in); got != want {
|
||||
t.Errorf("%q: %q", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
// The NetBox web UI through Rollcall, so it opens wherever Rollcall's does
|
||||
// (a lab NetBox on a network the browser cannot reach, say). With NetBox as
|
||||
// the IPAM and its url carrying a path (NetBox's BASE_PATH:
|
||||
// "url": "https://netbox.example/netbox/"), that path on Rollcall's own
|
||||
// NetBox listener (ui_listen, default :8444: another origin than the
|
||||
// Rollcall UI) is NetBox, for admins only (the admin certificate, as for
|
||||
// the API). NetBox keeps its
|
||||
// own login and session. Its CSRF_TRUSTED_ORIGINS must list Rollcall's
|
||||
// address for its forms to work: the browser posts from there.
|
||||
//
|
||||
// ui_url, when set, is where links to NetBox point instead (NetBox reached
|
||||
// directly); the proxy stays.
|
||||
//
|
||||
// Signing in with the admin certificate: with ui_login_secret_file, the
|
||||
// proxy tells NetBox who the admin is (X-Rollcall-User: the certificate's
|
||||
// admin name) together with the shared secret (X-Rollcall-Proxy-Secret).
|
||||
// NetBox takes the user from that header (REMOTE_AUTH_HEADER), and the web
|
||||
// server in front of NetBox must drop it from any request without the
|
||||
// secret, or anyone reaching NetBox directly could claim a user. Both
|
||||
// headers coming from the browser are always dropped here. An admin with a
|
||||
// token instead of a certificate signs in to NetBox by hand.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// netboxUIPath: the path NetBox is served under here ("/netbox"), "" when there is no proxy
|
||||
func (a *App) netboxUIPath() string {
|
||||
if _, ok := a.ipam.(*netBox); !ok {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(a.cfg.IPAM.URL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
p := strings.TrimRight(u.Path, "/")
|
||||
if p == "" || strings.HasPrefix(p, "/api") || strings.HasPrefix(p, "/rollcall") || strings.HasPrefix(p, "/images") || strings.HasPrefix(p, "/os") {
|
||||
return "" // NetBox at the root would shadow Rollcall's own paths
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (c IPAMConfig) uiListen() string { return orDefault(c.UIListen, ":8444") }
|
||||
|
||||
// netboxUIHandler serves NetBox's UI on its own listener (ui_listen): its
|
||||
// own origin, so a script in a NetBox page can neither read the Rollcall
|
||||
// UI's storage (the admin token) nor call Rollcall's API as the admin. Only
|
||||
// the proxy is there; "/" leads to it.
|
||||
func (a *App) netboxUIHandler() (http.Handler, error) {
|
||||
px, err := a.netboxProxy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, mux := a.netboxUIPath(), http.NewServeMux()
|
||||
h := a.adminAuth(px.ServeHTTP)
|
||||
for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} {
|
||||
mux.HandleFunc(m+" "+p+"/", h)
|
||||
}
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, p+"/", http.StatusFound) })
|
||||
return logging(http.NewCrossOriginProtection().Handler(mux)), nil
|
||||
}
|
||||
|
||||
func (a *App) netboxProxy() (http.Handler, error) {
|
||||
nb := a.ipam.(*netBox)
|
||||
target, _ := url.Parse(nb.base)
|
||||
origin := target.Scheme + "://" + target.Host
|
||||
secret := ""
|
||||
if f := a.cfg.IPAM.UILoginFile; f != "" {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipam ui_login_secret_file: %w", err)
|
||||
}
|
||||
if secret = strings.TrimSpace(string(b)); len(secret) < 16 {
|
||||
return nil, fmt.Errorf("ipam ui_login_secret_file: under 16 characters")
|
||||
}
|
||||
}
|
||||
return &httputil.ReverseProxy{
|
||||
Transport: nb.client.Transport,
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.Out.URL.Scheme, pr.Out.URL.Host = target.Scheme, target.Host
|
||||
pr.Out.Host = target.Host
|
||||
pr.Out.Header.Del("Authorization") // Rollcall's credentials are not NetBox's
|
||||
for k := range pr.Out.Header { // also X-Rollcall_User: the WSGI server makes HTTP_X_ROLLCALL_USER of both
|
||||
if n := strings.ToLower(strings.ReplaceAll(k, "_", "-")); n == "x-rollcall-user" || n == "x-rollcall-proxy-secret" {
|
||||
delete(pr.Out.Header, k)
|
||||
}
|
||||
}
|
||||
if name := a.peerAdmin(pr.In); name != "" && secret != "" {
|
||||
pr.Out.Header.Set("X-Rollcall-User", name)
|
||||
pr.Out.Header.Set("X-Rollcall-Proxy-Secret", secret)
|
||||
}
|
||||
pr.SetXForwarded()
|
||||
},
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
if loc := resp.Header.Get("Location"); strings.HasPrefix(loc, origin+"/") { // keep the browser here
|
||||
resp.Header.Set("Location", strings.TrimPrefix(loc, origin))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetBoxUIPath(t *testing.T) {
|
||||
for url, want := range map[string]string{"https://nb.example/netbox/": "/netbox", "https://nb.example": "", "https://nb.example/api/": "", "http://10.0.0.6/ipam/nb": "/ipam/nb"} {
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: url}}, ipam: &netBox{base: url}}
|
||||
if got := a.netboxUIPath(); got != want {
|
||||
t.Errorf("%s: %q, want %q", url, got, want)
|
||||
}
|
||||
}
|
||||
if (&App{cfg: &Config{IPAM: IPAMConfig{URL: "https://x/netbox/"}}}).netboxUIPath() != "" {
|
||||
t.Error("no NetBox IPAM, no proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxProxy(t *testing.T) {
|
||||
var seen *http.Request
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r
|
||||
if r.URL.Path == "/netbox/" {
|
||||
http.Redirect(w, r, "http://"+r.Host+"/netbox/login/?next=/netbox/", http.StatusFound) // NetBox names itself
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, "netbox page")
|
||||
}))
|
||||
defer up.Close()
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: up.URL + "/netbox/"}}, ipam: &netBox{base: up.URL + "/netbox", client: up.Client()}}
|
||||
h, err := a.netboxProxy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "https://rollcall.example/netbox/", nil)
|
||||
req.Header.Set("Authorization", "Bearer rollcall-admin")
|
||||
req.Header.Set("X-Rollcall-User", "admin") // a browser claiming a NetBox user
|
||||
req.Header.Set("X-Rollcall-Proxy-Secret", "guessed") // and the secret
|
||||
req.Header["X-Rollcall_user"] = []string{"admin"} // spelled so the WSGI server still reads it as HTTP_X_ROLLCALL_USER
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/netbox/login/?next=/netbox/" {
|
||||
t.Fatalf("redirect: %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if seen.Header.Get("Authorization") != "" {
|
||||
t.Fatal("Rollcall's credentials went to NetBox")
|
||||
}
|
||||
if seen.Header.Get("X-Rollcall-User") != "" || seen.Header.Get("X-Rollcall-Proxy-Secret") != "" || len(seen.Header["X-Rollcall_user"]) != 0 {
|
||||
t.Fatal("the browser's user header went to NetBox")
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "https://rollcall.example/netbox/dcim/sites/?q=lab", nil))
|
||||
if rec.Body.String() != "netbox page" || seen.URL.RequestURI() != "/netbox/dcim/sites/?q=lab" || seen.Header.Get("X-Forwarded-Host") != "rollcall.example" {
|
||||
t.Fatalf("page: %q %s %q", rec.Body.String(), seen.URL.RequestURI(), seen.Header.Get("X-Forwarded-Host"))
|
||||
}
|
||||
}
|
||||
|
||||
// a NetBox under a BASE_PATH names its next pages with that path: it must not be doubled
|
||||
func TestNetBoxNextLinkBasePath(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
if r.URL.Query().Get("offset") == "" {
|
||||
fmt.Fprintf(w, `{"results": [{"id": 1}], "next": "http://elsewhere%s?limit=500&offset=500"}`, r.URL.Path)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"results": [{"id": 2}], "next": null}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
n := &netBox{base: srv.URL + "/netbox", token: "t", client: srv.Client()}
|
||||
got, err := n.all(context.Background(), "dcim/sites/", nil)
|
||||
if err != nil || len(got) != 2 || paths[1] != "/netbox/api/dcim/sites/" {
|
||||
t.Fatalf("%v %d %v", err, len(got), paths)
|
||||
}
|
||||
}
|
||||
|
||||
// NetBox's UI is on its own listener, admins only; the Rollcall UI's routes do not carry it
|
||||
func TestNetBoxProxyRoutes(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "nb "+r.Method+" "+r.URL.Path) }))
|
||||
defer up.Close()
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: up.URL + "/netbox/"}, AdminHTTP: "allow"}, ipam: &netBox{base: up.URL + "/netbox", client: up.Client()}}
|
||||
h, err := a.netboxUIHandler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
a.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/netbox/dcim/", nil))
|
||||
if rec.Code == http.StatusUnauthorized || strings.Contains(rec.Body.String(), "nb GET") {
|
||||
t.Fatalf("the Rollcall UI's origin serves NetBox: %d", rec.Code)
|
||||
}
|
||||
for _, m := range []string{"GET", "POST"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(m, "/netbox/dcim/", nil))
|
||||
if rec.Code != http.StatusUnauthorized { // admins only
|
||||
t.Fatalf("%s without admin: %d %s", m, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// with the shared secret set, a request without an admin certificate signs no one in
|
||||
func TestNetBoxProxyLoginNeedsCertificate(t *testing.T) {
|
||||
var seen *http.Request
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r }))
|
||||
defer up.Close()
|
||||
f := t.TempDir() + "/secret"
|
||||
if err := os.WriteFile(f, []byte("0123456789abcdef0123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: up.URL + "/netbox/", UILoginFile: f}}, ipam: &netBox{base: up.URL + "/netbox", client: up.Client()}}
|
||||
h, err := a.netboxProxy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "https://rollcall.example/netbox/", nil))
|
||||
if seen.Header.Get("X-Rollcall-User") != "" || seen.Header.Get("X-Rollcall-Proxy-Secret") != "" {
|
||||
t.Fatal("signed in without a certificate")
|
||||
}
|
||||
if err := os.WriteFile(f, []byte("short"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.netboxProxy(); err == nil {
|
||||
t.Fatal("a short secret was taken")
|
||||
}
|
||||
}
|
||||
+178
-8
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -71,6 +72,12 @@ func (a *App) handleVLANs(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, errors.New("fabric and vid (0 untagged, 1-4094, or an IB pkey) are required"))
|
||||
return
|
||||
}
|
||||
if v.DHCPMode == "proxy" {
|
||||
v.DHCP = true
|
||||
} else if v.DHCPMode != "" && v.DHCPMode != "serve" {
|
||||
fail(w, 400, fmt.Errorf("dhcp_mode %q: serve or proxy", v.DHCPMode))
|
||||
return
|
||||
}
|
||||
if v.DHCP && v.PrimaryRack == "" {
|
||||
v.PrimaryRack = a.cfg.RackName
|
||||
}
|
||||
@@ -128,8 +135,17 @@ func (a *App) handleSubnets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if sn.Gateway != "" && !sn.contains(sn.Gateway) {
|
||||
fail(w, 400, fmt.Errorf("gateway %s is outside %s", sn.Gateway, sn.CIDR))
|
||||
if sn.Gateway != "" {
|
||||
gw, err := netip.ParseAddr(sn.Gateway)
|
||||
if err != nil || !sn.contains(gw.String()) {
|
||||
fail(w, 400, fmt.Errorf("gateway %q is not an address in %s", sn.Gateway, sn.CIDR))
|
||||
return
|
||||
}
|
||||
sn.Gateway = gw.String()
|
||||
}
|
||||
sn.Domain = strings.ToLower(strings.TrimSuffix(sn.Domain, "."))
|
||||
if sn.Domain != "" && !validDomain(sn.Domain) {
|
||||
fail(w, 400, fmt.Errorf("domain %q is not a DNS name", sn.Domain))
|
||||
return
|
||||
}
|
||||
switch sn.Purpose {
|
||||
@@ -259,6 +275,16 @@ func (a *App) handleIPs(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
ip, ok := parseAddr(x.IP)
|
||||
if !ok || strings.Contains(x.IP, "/") {
|
||||
fail(w, 400, fmt.Errorf("%q is not an address", x.IP))
|
||||
return
|
||||
}
|
||||
x.IP = ip.String() // one spelling: the table's key, NetBox's and DNS's
|
||||
if strings.IndexFunc(x.Hostname, func(c rune) bool { return c < ' ' || c == 0x7f }) >= 0 || len(x.Hostname) > 200 {
|
||||
fail(w, 400, errors.New("hostname: one line of text"))
|
||||
return
|
||||
}
|
||||
sn := subnetFor(a.subnetsSnapshot(), x.IP)
|
||||
if sn == nil {
|
||||
fail(w, 404, fmt.Errorf("%s is in no subnet", x.IP))
|
||||
@@ -511,9 +537,9 @@ func (c *client) netCmd(cmd string, args []string) error {
|
||||
if err := c.do("GET", "/api/v1/vlans", nil, &vs); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%-14s %5s %-14s %5s %-5s %-14s %s\n", "FABRIC", "VID", "NAME", "MTU", "DHCP", "RACK", "SECONDARY")
|
||||
fmt.Printf("%-14s %5s %-14s %5s %-6s %-14s %s\n", "FABRIC", "VID", "NAME", "MTU", "DHCP", "RACK", "SECONDARY")
|
||||
for _, v := range vs {
|
||||
fmt.Printf("%-14s %5d %-14s %5d %-5v %-14s %s\n", v.Fabric, v.VID, v.Name, v.MTU, v.DHCP, v.PrimaryRack, v.SecondaryRack)
|
||||
fmt.Printf("%-14s %5d %-14s %5d %-6s %-14s %s\n", v.Fabric, v.VID, v.Name, v.MTU, orDash(v.DHCPMode), v.PrimaryRack, v.SecondaryRack)
|
||||
}
|
||||
case "add", "set":
|
||||
need(3)
|
||||
@@ -521,15 +547,20 @@ func (c *client) netCmd(cmd string, args []string) error {
|
||||
name := fs.String("name", "", "name")
|
||||
mtu := fs.Int("mtu", 1500, "MTU")
|
||||
dhcp := fs.Bool("dhcp", false, "serve DHCP/PXE on this VLAN")
|
||||
proxy := fs.Bool("proxy-dhcp", false, "another DHCP server hands out addresses here; only answer PXE (proxy DHCP)")
|
||||
rack := fs.String("rack", "", "rack controller serving it (default: the region's embedded one)")
|
||||
sec := fs.String("secondary", "", "second rack controller (the dynamic range is split)")
|
||||
_ = fs.Parse(args[3:])
|
||||
vid, _ := strconv.Atoi(args[2])
|
||||
var v VLAN
|
||||
if err := c.do("POST", "/api/v1/vlans", VLAN{Fabric: args[1], VID: vid, Name: *name, MTU: *mtu, DHCP: *dhcp, PrimaryRack: *rack, SecondaryRack: *sec}, &v); err != nil {
|
||||
mode := ""
|
||||
if *proxy {
|
||||
mode = "proxy"
|
||||
}
|
||||
if err := c.do("POST", "/api/v1/vlans", VLAN{Fabric: args[1], VID: vid, Name: *name, MTU: *mtu, DHCP: *dhcp || *proxy, DHCPMode: mode, PrimaryRack: *rack, SecondaryRack: *sec}, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("vlan %s/%d: dhcp=%v rack=%s\n", v.Fabric, v.VID, v.DHCP, v.PrimaryRack)
|
||||
fmt.Printf("vlan %s/%d: dhcp=%s rack=%s\n", v.Fabric, v.VID, orDash(v.DHCPMode), v.PrimaryRack)
|
||||
case "rm":
|
||||
need(3)
|
||||
return c.do("DELETE", "/api/v1/vlans/"+args[1]+"/"+args[2], nil, nil)
|
||||
@@ -792,6 +823,68 @@ func (c *client) netCmd(cmd string, args []string) error {
|
||||
os.Stdout.Write(data)
|
||||
case "ipam":
|
||||
return c.ipamCmd(sub, args)
|
||||
case "dns":
|
||||
return c.dnsCmd(sub, args)
|
||||
case "reservations":
|
||||
return c.reservationsCmd(sub, args)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) reservationsCmd(sub string, args []string) error {
|
||||
show := func(rep dhcpResReport) {
|
||||
for _, x := range []struct {
|
||||
what string
|
||||
list []string
|
||||
}{{"put", rep.Put}, {"delete", rep.Deleted}, {"conflict", rep.Conflicts}, {"skipped", rep.Skipped}} {
|
||||
for _, v := range x.list {
|
||||
fmt.Printf("%-9s %s\n", x.what, v)
|
||||
}
|
||||
}
|
||||
dry := ""
|
||||
if rep.DryRun {
|
||||
dry = " (dry run, nothing changed)"
|
||||
}
|
||||
fmt.Printf("%s: %d put, %d deleted, %d conflicts%s\n", orDash(rep.Provider), len(rep.Put), len(rep.Deleted), len(rep.Conflicts), dry)
|
||||
if rep.Error != "" {
|
||||
fmt.Println("error:", rep.Error)
|
||||
}
|
||||
}
|
||||
switch sub {
|
||||
case "", "status":
|
||||
var st struct {
|
||||
Provider string `json:"provider"`
|
||||
URL string `json:"url"`
|
||||
Last dhcpResReport `json:"last_sync"`
|
||||
}
|
||||
if err := c.do("GET", "/api/v1/dhcp-reservations", nil, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Provider == "" {
|
||||
fmt.Println("no DHCP reservation provider (dhcp_reservations.provider in config.json: kea or openwrt)")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("provider %s at %s\n", st.Provider, st.URL)
|
||||
if !st.Last.At.IsZero() {
|
||||
fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05"))
|
||||
show(st.Last)
|
||||
}
|
||||
case "sync":
|
||||
fs := newFlags("reservations sync")
|
||||
dry := fs.Bool("dry", false, "show what would change")
|
||||
_ = fs.Parse(args[min(1, len(args)):])
|
||||
q := ""
|
||||
if *dry {
|
||||
q = "?dry=1"
|
||||
}
|
||||
var rep dhcpResReport
|
||||
err := c.do("POST", "/api/v1/dhcp-reservations/sync"+q, nil, &rep)
|
||||
if rep.Provider != "" || rep.Error != "" {
|
||||
show(rep)
|
||||
}
|
||||
return err
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -815,6 +908,20 @@ func (c *client) ipamCmd(sub string, args []string) error {
|
||||
if rep.Error != "" {
|
||||
fmt.Println("error:", rep.Error)
|
||||
}
|
||||
if d := rep.Devices; d != nil {
|
||||
for _, x := range []struct {
|
||||
k string
|
||||
v []string
|
||||
}{{"created", d.Created}, {"updated", d.Updated}, {"deleted", d.Deleted}, {"conflict", d.Conflicts}} {
|
||||
for _, n := range x.v {
|
||||
fmt.Printf("%-9s device %s\n", x.k, n)
|
||||
}
|
||||
}
|
||||
fmt.Printf("devices: %d created, %d updated, %d deleted, %d conflicts\n", len(d.Created), len(d.Updated), len(d.Deleted), len(d.Conflicts))
|
||||
if d.Error != "" {
|
||||
fmt.Println("error:", d.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch sub {
|
||||
case "", "status":
|
||||
@@ -830,10 +937,14 @@ func (c *client) ipamCmd(sub string, args []string) error {
|
||||
return err
|
||||
}
|
||||
if st.Provider == "" {
|
||||
fmt.Println("built-in IPAM only (set ipam.provider in config.json for NetBox)")
|
||||
fmt.Println("built-in IPAM only (set ipam.provider in config.json: netbox or infoblox)")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("provider %s %s\nzone %s\nowner tag %q, custom field %q\n", st.Provider, st.URL, orDash(st.Zone), st.Tag, st.Field)
|
||||
owner := fmt.Sprintf("tag %q, custom field %q", st.Tag, st.Field)
|
||||
if st.Provider == "infoblox" {
|
||||
owner = fmt.Sprintf("extensible attribute %q (delegated networks: %q)", st.Field, st.Tag)
|
||||
}
|
||||
fmt.Printf("provider %s %s\nzone %s\nowner %s\n", st.Provider, st.URL, orDash(st.Zone), owner)
|
||||
if st.Last.At.IsZero() {
|
||||
fmt.Println("last sync never")
|
||||
return nil
|
||||
@@ -973,3 +1084,62 @@ func printNet(d obj) {
|
||||
b, _ := json.MarshalIndent(d["network_config"], " ", " ")
|
||||
fmt.Printf(" network-config:\n %s\n", b)
|
||||
}
|
||||
|
||||
func (c *client) dnsCmd(sub string, args []string) error {
|
||||
show := func(rep dnsReport) {
|
||||
for _, x := range rep.Upserted {
|
||||
fmt.Printf("%-9s %s\n", "write", x)
|
||||
}
|
||||
for _, x := range rep.Deleted {
|
||||
fmt.Printf("%-9s %s\n", "delete", x)
|
||||
}
|
||||
for _, x := range rep.Conflicts {
|
||||
fmt.Printf("%-9s %s\n", "conflict", x)
|
||||
}
|
||||
dry := ""
|
||||
if rep.DryRun {
|
||||
dry = " (dry run, nothing changed)"
|
||||
}
|
||||
fmt.Printf("%s %s: %d written, %d deleted, %d conflicts%s\n", orDash(rep.Provider), rep.Zone, len(rep.Upserted), len(rep.Deleted), len(rep.Conflicts), dry)
|
||||
if rep.Error != "" {
|
||||
fmt.Println("error:", rep.Error)
|
||||
}
|
||||
}
|
||||
switch sub {
|
||||
case "", "status":
|
||||
var st struct {
|
||||
Provider string `json:"provider"`
|
||||
Zone string `json:"zone"`
|
||||
Last dnsReport `json:"last_sync"`
|
||||
}
|
||||
if err := c.do("GET", "/api/v1/dns", nil, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Provider == "" {
|
||||
fmt.Println("no DNS provider (dns.provider in config.json: powerdns or rfc2136)")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("provider %s, zone %s\n", st.Provider, st.Zone)
|
||||
if !st.Last.At.IsZero() {
|
||||
fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05"))
|
||||
show(st.Last)
|
||||
}
|
||||
case "sync":
|
||||
fs := newFlags("dns sync")
|
||||
dry := fs.Bool("dry", false, "show what would change")
|
||||
_ = fs.Parse(args[min(1, len(args)):])
|
||||
q := ""
|
||||
if *dry {
|
||||
q = "?dry=1"
|
||||
}
|
||||
var rep dnsReport
|
||||
err := c.do("POST", "/api/v1/dns/sync"+q, nil, &rep)
|
||||
if rep.Provider != "" || rep.Error != "" {
|
||||
show(rep)
|
||||
}
|
||||
return err
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+102
-17
@@ -33,7 +33,8 @@ type VLAN struct {
|
||||
VID int `json:"vid"` // 0 = untagged; an InfiniBand partition key on IB fabrics
|
||||
Name string `json:"name"`
|
||||
MTU int `json:"mtu"`
|
||||
DHCP bool `json:"dhcp"` // rollcall serves DHCP/PXE on it
|
||||
DHCP bool `json:"dhcp"` // a rack controller answers DHCP/PXE on it
|
||||
DHCPMode string `json:"dhcp_mode"` // serve (addresses + PXE) | proxy (PXE only; another server hands out addresses)
|
||||
PrimaryRack string `json:"primary_rack"` // rack controller that serves it
|
||||
SecondaryRack string `json:"secondary_rack"` // second one; the dynamic range is split between them
|
||||
}
|
||||
@@ -55,6 +56,7 @@ type Subnet struct {
|
||||
VID int `json:"vid"`
|
||||
MTU int `json:"mtu"`
|
||||
DHCP bool `json:"dhcp"`
|
||||
DHCPMode string `json:"dhcp_mode"` // serve | proxy (from the VLAN)
|
||||
PrimaryRack string `json:"primary_rack"`
|
||||
SecondaryRack string `json:"secondary_rack"`
|
||||
Purpose string `json:"purpose"` // provisioning | mgmt | bmc | storage | ib | other
|
||||
@@ -140,7 +142,7 @@ func (s *Store) DeleteFabric(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
func (s *Store) VLANs(ctx context.Context) ([]VLAN, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, fabric, vid, coalesce(name,''), mtu, dhcp, coalesce(primary_rack,''), coalesce(secondary_rack,'') FROM vlans ORDER BY fabric, vid`)
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, fabric, vid, coalesce(name,''), mtu, dhcp, dhcp_mode, coalesce(primary_rack,''), coalesce(secondary_rack,'') FROM vlans ORDER BY fabric, vid`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,23 +150,35 @@ func (s *Store) VLANs(ctx context.Context) ([]VLAN, error) {
|
||||
out := []VLAN{}
|
||||
for rows.Next() {
|
||||
var v VLAN
|
||||
if err := rows.Scan(&v.ID, &v.Fabric, &v.VID, &v.Name, &v.MTU, &v.DHCP, &v.PrimaryRack, &v.SecondaryRack); err != nil {
|
||||
if err := rows.Scan(&v.ID, &v.Fabric, &v.VID, &v.Name, &v.MTU, &v.DHCP, &v.DHCPMode, &v.PrimaryRack, &v.SecondaryRack); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.DHCPMode = dhcpMode(v.DHCP, v.DHCPMode)
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// dhcpMode normalises: no DHCP -> "", DHCP without a mode -> serve.
|
||||
func dhcpMode(dhcp bool, mode string) string {
|
||||
switch {
|
||||
case !dhcp:
|
||||
return ""
|
||||
case mode == "proxy":
|
||||
return "proxy"
|
||||
}
|
||||
return "serve"
|
||||
}
|
||||
|
||||
// PutVLAN upserts by (fabric, vid) and returns the id.
|
||||
func (s *Store) PutVLAN(ctx context.Context, v VLAN) (int64, error) {
|
||||
if v.MTU <= 0 {
|
||||
v.MTU = 1500
|
||||
}
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO vlans(fabric, vid, name, mtu, dhcp, primary_rack, secondary_rack) VALUES ($1,$2,$3,$4,$5,nullif($6,''),nullif($7,''))
|
||||
ON CONFLICT (fabric, vid) DO UPDATE SET name=$3, mtu=$4, dhcp=$5, primary_rack=nullif($6,''), secondary_rack=nullif($7,'') RETURNING id`,
|
||||
v.Fabric, v.VID, v.Name, v.MTU, v.DHCP, v.PrimaryRack, v.SecondaryRack).Scan(&id)
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO vlans(fabric, vid, name, mtu, dhcp, primary_rack, secondary_rack, dhcp_mode) VALUES ($1,$2,$3,$4,$5,nullif($6,''),nullif($7,''),$8)
|
||||
ON CONFLICT (fabric, vid) DO UPDATE SET name=$3, mtu=$4, dhcp=$5, primary_rack=nullif($6,''), secondary_rack=nullif($7,''), dhcp_mode=$8 RETURNING id`,
|
||||
v.Fabric, v.VID, v.Name, v.MTU, v.DHCP, v.PrimaryRack, v.SecondaryRack, dhcpMode(v.DHCP, v.DHCPMode)).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -175,7 +189,7 @@ func (s *Store) DeleteVLAN(ctx context.Context, fabric string, vid int) error {
|
||||
|
||||
func (s *Store) Subnets(ctx context.Context) ([]Subnet, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT s.cidr, coalesce(s.name,''), coalesce(s.vlan_id,0), coalesce(v.fabric,''), coalesce(v.vid,0), coalesce(v.mtu,1500),
|
||||
coalesce(v.dhcp,false), coalesce(v.primary_rack,''), coalesce(v.secondary_rack,''), s.purpose, coalesce(s.gateway,''), s.dns,
|
||||
coalesce(v.dhcp,false), coalesce(v.dhcp_mode,''), coalesce(v.primary_rack,''), coalesce(v.secondary_rack,''), s.purpose, coalesce(s.gateway,''), s.dns,
|
||||
coalesce(s.domain,''), coalesce(s.comment,''), (SELECT count(*) FROM ip_addresses a WHERE a.subnet=s.cidr)
|
||||
FROM subnets s LEFT JOIN vlans v ON v.id = s.vlan_id ORDER BY s.cidr`)
|
||||
if err != nil {
|
||||
@@ -186,11 +200,12 @@ func (s *Store) Subnets(ctx context.Context) ([]Subnet, error) {
|
||||
for rows.Next() {
|
||||
var sn Subnet
|
||||
var dns []byte
|
||||
if err := rows.Scan(&sn.CIDR, &sn.Name, &sn.VLANID, &sn.Fabric, &sn.VID, &sn.MTU, &sn.DHCP, &sn.PrimaryRack, &sn.SecondaryRack,
|
||||
if err := rows.Scan(&sn.CIDR, &sn.Name, &sn.VLANID, &sn.Fabric, &sn.VID, &sn.MTU, &sn.DHCP, &sn.DHCPMode, &sn.PrimaryRack, &sn.SecondaryRack,
|
||||
&sn.Purpose, &sn.Gateway, &dns, &sn.Domain, &sn.Comment, &sn.Used); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(dns, &sn.DNS)
|
||||
sn.DHCPMode = dhcpMode(sn.DHCP, sn.DHCPMode)
|
||||
if sn.DNS == nil {
|
||||
sn.DNS = []string{}
|
||||
}
|
||||
@@ -568,6 +583,8 @@ type invNIC struct {
|
||||
|
||||
var speedRe = regexp.MustCompile(`(\d+)`)
|
||||
|
||||
var ifnameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,14}$`)
|
||||
|
||||
func machineNICs(m *Machine) []invNIC {
|
||||
var inv obj
|
||||
_ = json.Unmarshal(m.Inventory, &inv)
|
||||
@@ -581,7 +598,7 @@ func machineNICs(m *Machine) []invNIC {
|
||||
for _, ip := range toList(n["ipv4"]) {
|
||||
nic.IPv4 = append(nic.IPv4, str(ip))
|
||||
}
|
||||
if nic.Name != "" {
|
||||
if ifnameRe.MatchString(nic.Name) { // the machine reports it; it lands in udev rules and dnsmasq files
|
||||
out = append(out, nic)
|
||||
}
|
||||
}
|
||||
@@ -666,8 +683,8 @@ func selectNICs(nics []invNIC, boot *invNIC, sel string) []invNIC {
|
||||
}
|
||||
}
|
||||
sort.Slice(ib, func(i, j int) bool { return ib[i].Name < ib[j].Name })
|
||||
k, _ := strconv.Atoi(arg)
|
||||
if k < len(ib) {
|
||||
k, err := strconv.Atoi(arg)
|
||||
if err == nil && k >= 0 && k < len(ib) {
|
||||
return []invNIC{ib[k]}
|
||||
}
|
||||
default:
|
||||
@@ -719,6 +736,11 @@ type NetPlan struct {
|
||||
// VLAN (an IPv6 prefix next to the IPv4 one makes the machine dual-stack).
|
||||
func defaultLayout(bootSubnet *Subnet, subnets []Subnet) *NetLayout {
|
||||
links := []LayoutLink{{Subnet: "boot", Mode: "auto", Gateway: true}}
|
||||
if bootSubnet != nil && bootSubnet.DHCPMode == "proxy" && !hasStaticRange(*bootSubnet) {
|
||||
// the site's DHCP server owns the addresses; without a static range set aside
|
||||
// for Rollcall, machines keep getting theirs from it
|
||||
links = []LayoutLink{{Subnet: "boot", Mode: "dhcp"}}
|
||||
}
|
||||
if bootSubnet == nil {
|
||||
links = []LayoutLink{{Subnet: "boot", Mode: "dhcp"}}
|
||||
} else if bootSubnet.VLANID != 0 {
|
||||
@@ -1193,6 +1215,9 @@ func (a *App) netPlan(ctx context.Context, m *Machine, allocate bool) (*NetPlan,
|
||||
|
||||
// ---------------------------------------------------------------- dnsmasq
|
||||
|
||||
// uefiBoot is what UEFI machines load: signed shim, then signed GRUB (grub.go).
|
||||
const uefiBoot = "bootx64.efi"
|
||||
|
||||
const dnsmasqBase = `# generated by rollcall for rack controller %s; do not edit (rollcall subnet/vlan commands change it)
|
||||
bind-dynamic
|
||||
%sdomain-needed
|
||||
@@ -1201,11 +1226,6 @@ expand-hosts
|
||||
log-dhcp
|
||||
enable-tftp
|
||||
tftp-root=%s
|
||||
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
|
||||
dhcp-hostsfile=%s/hosts.dhcp
|
||||
addn-hosts=%s/hosts
|
||||
`
|
||||
@@ -1259,7 +1279,7 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string,
|
||||
shared := false
|
||||
domains := []string{}
|
||||
for _, sn := range served {
|
||||
shared = shared || sn.SecondaryRack != ""
|
||||
shared = shared || sn.SecondaryRack != "" || sn.DHCPMode == "proxy"
|
||||
if sn.Domain != "" && !hasTag(domains, sn.Domain) {
|
||||
domains = append(domains, sn.Domain)
|
||||
}
|
||||
@@ -1297,10 +1317,66 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string,
|
||||
for _, d := range domains {
|
||||
fmt.Fprintf(&b, "local=/%s/\n", d)
|
||||
}
|
||||
// architecture tags for the boot files of served subnets; left out when every
|
||||
// subnet is proxied (pxe-service picks by architecture itself, and these shared
|
||||
// tags are part of the cyclic tag list dnsmasq 2.91 builds in proxy mode)
|
||||
for _, sn := range served {
|
||||
if sn.DHCPMode != "proxy" && familyOf(sn.CIDR) == 4 {
|
||||
b.WriteString("dhcp-match=set:efi64,option:client-arch,7\ndhcp-match=set:efi64,option:client-arch,9\ndhcp-match=set:bios,option:client-arch,0\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
pxeDone := map[string]bool{}
|
||||
for i, sn := range served {
|
||||
tag := fmt.Sprintf("s%d", i)
|
||||
v6 := familyOf(sn.CIDR) == 6
|
||||
if sn.DHCPMode == "proxy" {
|
||||
// another DHCP server hands out addresses (and router, DNS); this rack only
|
||||
// answers PXE clients with where to boot from. Proxy DHCP is IPv4 (PXE is).
|
||||
if v6 {
|
||||
continue
|
||||
}
|
||||
_, n, _ := net.ParseCIDR(sn.CIDR)
|
||||
fmt.Fprintf(&b, "\n# %s · %s vlan %d · %s · proxy DHCP (addresses from the site's DHCP server)\n", sn.CIDR, orDash(sn.Fabric), sn.VID, sn.Purpose)
|
||||
// No set: tag on a proxy range. dnsmasq (2.91, still in 2.93) appends a request's
|
||||
// tags to the context's persistent tag list in its proxy path without resetting it:
|
||||
// the next request's stack-allocated interface tag ends up pointing at itself and
|
||||
// dnsmasq loops forever (option_filter, rfc2131.c proxy branch). The PXE services
|
||||
// are limited by the interface tag instead, which is rebuilt for every request.
|
||||
fmt.Fprintf(&b, "dhcp-range=%s,proxy,%s\n", n.IP.String(), sn.netmask())
|
||||
var on []string
|
||||
for _, ri := range rc.Interfaces {
|
||||
for _, ad := range ri.Addrs {
|
||||
if sn.contains(ad) && !hasTag(on, ri.Name) {
|
||||
on = append(on, ri.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(on) == 0 {
|
||||
on = []string{""} // relayed: untagged
|
||||
}
|
||||
for _, i := range on {
|
||||
if pxeDone[i] {
|
||||
continue
|
||||
}
|
||||
pxeDone[i] = true
|
||||
t := ""
|
||||
if i != "" {
|
||||
t = "tag:" + i + ","
|
||||
}
|
||||
fmt.Fprintf(&b, "pxe-service=%sx86PC,\"Rollcall\",pxelinux\n", t)
|
||||
fmt.Fprintf(&b, "pxe-service=%sBC_EFI,\"Rollcall\",%s\n", t, uefiBoot)
|
||||
fmt.Fprintf(&b, "pxe-service=%sX86-64_EFI,\"Rollcall\",%s\n", t, uefiBoot)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n# %s · %s vlan %d · %s\n", sn.CIDR, orDash(sn.Fabric), sn.VID, sn.Purpose)
|
||||
if !v6 {
|
||||
// boot files per served subnet: dhcp-boot matching a proxy subnet's clients as
|
||||
// well as its pxe-service sends dnsmasq 2.91 into a busy loop (lab, 2026-10-29)
|
||||
fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:bios,pxelinux.0\n", tag)
|
||||
fmt.Fprintf(&b, "dhcp-boot=tag:%s,tag:efi64,%s\n", tag, uefiBoot)
|
||||
}
|
||||
if sn.Domain != "" {
|
||||
fmt.Fprintf(&b, "domain=%s,%s\n", sn.Domain, sn.CIDR)
|
||||
}
|
||||
@@ -1378,3 +1454,12 @@ func dnsmasqConfig(rc RackController, subnets []Subnet, dnsDir string) (string,
|
||||
}
|
||||
|
||||
var errSubnetUnknown = errors.New("subnet not found")
|
||||
|
||||
func hasStaticRange(sn Subnet) bool {
|
||||
for _, r := range sn.Ranges {
|
||||
if r.Kind == "static" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -289,3 +289,69 @@ func TestStaleJobs(t *testing.T) {
|
||||
t.Errorf("stale jobs: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyDHCP(t *testing.T) {
|
||||
sn := labSubnet()
|
||||
sn.DHCPMode = "proxy"
|
||||
sn.Ranges = nil
|
||||
rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}}}
|
||||
conf, ok := dnsmasqConfig(rc, []Subnet{sn}, "/d")
|
||||
if !ok {
|
||||
t.Fatal("no config")
|
||||
}
|
||||
for _, want := range []string{"interface=eth1", "dhcp-range=10.0.0.0,proxy,255.255.255.0", `pxe-service=tag:eth1,x86PC,"Rollcall",pxelinux`, `pxe-service=tag:eth1,X86-64_EFI,"Rollcall",bootx64.efi`} {
|
||||
if !strings.Contains(conf, want) {
|
||||
t.Errorf("proxy config lacks %s:\n%s", want, conf)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"option:router", "option:dns-server", "dhcp-authoritative", ",12h", "set:s0,10.0.0.0,proxy"} {
|
||||
if strings.Contains(conf, bad) {
|
||||
t.Errorf("proxy config has %q (the site's server owns that):\n%s", bad, conf)
|
||||
}
|
||||
}
|
||||
// machines keep DHCP from the site's server unless a static range is set aside
|
||||
if l := defaultLayout(&sn, []Subnet{sn}); l.Interfaces[0].Links[0].Mode != "dhcp" {
|
||||
t.Errorf("proxy subnet without a static range: %+v", l.Interfaces[0].Links)
|
||||
}
|
||||
sn.Ranges = []IPRange{{Kind: "static", Start: "10.0.0.50", End: "10.0.0.99"}}
|
||||
if l := defaultLayout(&sn, []Subnet{sn}); l.Interfaces[0].Links[0].Mode != "auto" {
|
||||
t.Errorf("proxy subnet with a static range: %+v", l.Interfaces[0].Links)
|
||||
}
|
||||
if dhcpMode(true, "") != "serve" || dhcpMode(false, "proxy") != "" || dhcpMode(true, "proxy") != "proxy" {
|
||||
t.Error("dhcpMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDhcpBootOnlyForServedSubnets(t *testing.T) {
|
||||
serve := labSubnet()
|
||||
proxy := labSubnet()
|
||||
proxy.CIDR, proxy.Gateway, proxy.DHCPMode, proxy.Ranges = "10.9.0.0/24", "", "proxy", nil
|
||||
rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}, {Name: "eth2", Addrs: []string{"10.9.0.2/24"}}}}
|
||||
conf, _ := dnsmasqConfig(rc, []Subnet{serve, proxy}, "/d")
|
||||
if !strings.Contains(conf, "dhcp-boot=tag:s0,tag:bios,pxelinux.0") || !strings.Contains(conf, "dhcp-boot=tag:s0,tag:efi64,bootx64.efi") {
|
||||
t.Errorf("served subnet lacks its boot files:\n%s", conf)
|
||||
}
|
||||
for _, l := range strings.Split(conf, "\n") {
|
||||
if strings.HasPrefix(l, "dhcp-boot=") && !strings.HasPrefix(l, "dhcp-boot=tag:s0,") {
|
||||
t.Errorf("dhcp-boot not limited to the served subnet: %s", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(conf, "pxe-service=tag:eth2,x86PC") || strings.Contains(conf, "set:s1,10.9.0.0,proxy") {
|
||||
t.Errorf("proxy subnet lacks pxe-service:\n%s", conf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyOnlyConfigHasNoArchTags(t *testing.T) {
|
||||
sn := labSubnet()
|
||||
sn.DHCPMode, sn.Ranges = "proxy", nil
|
||||
rc := RackController{Name: "r1", Interfaces: []RackIface{{Name: "eth1", Addrs: []string{"10.0.0.2/24"}}}}
|
||||
conf, _ := dnsmasqConfig(rc, []Subnet{sn}, "/d")
|
||||
if strings.Contains(conf, "dhcp-match=") || strings.Contains(conf, "dhcp-boot=") {
|
||||
t.Errorf("proxy-only config has arch tags or dhcp-boot:\n%s", conf)
|
||||
}
|
||||
serve := labSubnet()
|
||||
conf, _ = dnsmasqConfig(rc, []Subnet{serve}, "/d")
|
||||
if !strings.Contains(conf, "dhcp-match=set:bios,option:client-arch,0") {
|
||||
t.Errorf("served subnet lacks arch tags:\n%s", conf)
|
||||
}
|
||||
}
|
||||
+84
-272
@@ -12,7 +12,11 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -34,17 +38,10 @@ type powerDriver struct {
|
||||
DefaultPort int `json:"default_port"`
|
||||
}
|
||||
|
||||
var powerDrivers = []powerDriver{
|
||||
{"ipmi", "IPMI 2.0 over LAN (ipmitool lanplus); any vendor", "ipmi", 623},
|
||||
{"redfish", "DMTF Redfish, any vendor", "redfish", 443},
|
||||
{"idrac", "Dell iDRAC 8/9 (Redfish)", "redfish", 443},
|
||||
{"ilo", "HPE iLO 4 (2.30+), 5, 6 (Redfish)", "redfish", 443},
|
||||
{"supermicro", "Supermicro X11 and later (Redfish)", "redfish", 443},
|
||||
{"xcc", "Lenovo XClarity Controller (Redfish)", "redfish", 443},
|
||||
{"openbmc", "OpenBMC (Redfish)", "redfish", 443},
|
||||
{"proxmox", "Proxmox VE virtual machine (API token: user = token id, password = secret; extra node, vmid)", "api", 8006},
|
||||
{"manual", "no power control: someone presses the button", "none", 0},
|
||||
}
|
||||
var powerDrivers = append(append([]powerDriver{{"ipmi", "IPMI 2.0 over LAN (ipmitool lanplus); any vendor", "ipmi", 623}},
|
||||
redfishDrivers()...),
|
||||
powerDriver{"proxmox", "Proxmox VE virtual machine (API token: user = token id, password = secret; extra node, vmid)", "api", 8006},
|
||||
powerDriver{"manual", "no power control: someone presses the button", "none", 0})
|
||||
|
||||
func driverByName(n string) (powerDriver, bool) {
|
||||
if n == "" {
|
||||
@@ -64,6 +61,16 @@ var errManualPower = errors.New("this machine has manual power control: do it by
|
||||
|
||||
// powerRun executes one operation with a driver.
|
||||
func powerRun(ctx context.Context, ipmitool string, c *bmcCreds, op, arg string) (string, error) {
|
||||
if op == "tls-fingerprint" {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
return bmcTLSFingerprint(ctx, c)
|
||||
}
|
||||
if t, ok := fwTimeouts[op]; ok { // firmware: Redfish UpdateService (redfish_fw.go)
|
||||
ctx, cancel := context.WithTimeout(ctx, t)
|
||||
defer cancel()
|
||||
return firmwareRun(ctx, c, op, arg)
|
||||
}
|
||||
if !powerOps[op] {
|
||||
return "", fmt.Errorf("unknown power operation %q", op)
|
||||
}
|
||||
@@ -136,26 +143,80 @@ func ipmiExec(ctx context.Context, tool string, c *bmcCreds, args ...string) (st
|
||||
return strings.TrimSpace(out.String()), err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Redfish
|
||||
// ---------------------------------------------------------------- BMC HTTP (Redfish, Proxmox)
|
||||
|
||||
type rfClient struct {
|
||||
base, user, pass string
|
||||
hc *http.Client
|
||||
etag string
|
||||
// bmcHTTPClient: BMCs ship self-signed certificates, so a BMC is trusted by
|
||||
// the fingerprint of the certificate it showed first (extra tls_fingerprint,
|
||||
// pinned by the region before credentials are ever sent; see pinBMC).
|
||||
// verify_tls=true checks it against the system's CAs instead.
|
||||
func bmcHTTPClient(c *bmcCreds) *http.Client {
|
||||
tc := &tls.Config{InsecureSkipVerify: c.Extra["verify_tls"] != "true"} //nolint:gosec // pinned below
|
||||
if pin := strings.ToLower(c.Extra["tls_fingerprint"]); pin != "" && c.Extra["verify_tls"] != "true" {
|
||||
tc.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return errors.New("the BMC sent no certificate")
|
||||
}
|
||||
got := certSHA256(cs.PeerCertificates[0])
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(pin)) != 1 {
|
||||
return fmt.Errorf("the BMC's certificate changed (pinned %s, now %s): not sending credentials; if the BMC was replaced or got new firmware, clear tls_fingerprint to trust the new one", short(pin), short(got))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tr := &http.Transport{TLSClientConfig: tc, DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext}
|
||||
return &http.Client{Timeout: 45 * time.Second, Transport: tr}
|
||||
}
|
||||
|
||||
func bmcHTTPClient(c *bmcCreds) *http.Client {
|
||||
// BMCs ship self-signed certificates; checking them is opt-in (extra verify_tls=true)
|
||||
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: c.Extra["verify_tls"] != "true"}, //nolint:gosec
|
||||
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext}
|
||||
return &http.Client{Timeout: 45 * time.Second, Transport: tr}
|
||||
func certSHA256(c *x509.Certificate) string {
|
||||
s := sha256.Sum256(c.Raw)
|
||||
return hex.EncodeToString(s[:])
|
||||
}
|
||||
|
||||
func short(fp string) string {
|
||||
if len(fp) > 16 {
|
||||
return fp[:16] + "…"
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// bmcTLSFingerprint connects to the BMC's HTTPS port and returns its
|
||||
// certificate's SHA-256 (op tls-fingerprint): a handshake only, no request,
|
||||
// no credentials.
|
||||
func bmcTLSFingerprint(ctx context.Context, c *bmcCreds) (string, error) {
|
||||
port := 443
|
||||
if d, _ := driverByName(c.Driver); d.Family == "api" {
|
||||
port = 8006
|
||||
}
|
||||
u, err := url.Parse(bmcBase(c, "https", port))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := u.Host
|
||||
if u.Port() == "" {
|
||||
host = net.JoinHostPort(u.Hostname(), "443")
|
||||
}
|
||||
d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: 10 * time.Second}, Config: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // reading the certificate is the point
|
||||
conn, err := d.DialContext(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
cs := conn.(*tls.Conn).ConnectionState()
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return "", errors.New("the BMC sent no certificate")
|
||||
}
|
||||
return certSHA256(cs.PeerCertificates[0]), nil
|
||||
}
|
||||
|
||||
// bmcBase builds https://host[:port] from the record; IPMI's port is not Redfish's.
|
||||
func bmcBase(c *bmcCreds, scheme string, defPort int) string {
|
||||
a := strings.TrimRight(c.Address, "/")
|
||||
if strings.Contains(a, "://") {
|
||||
return a
|
||||
if strings.Contains(a, "://") { // an operator's https://host:port: scheme and authority only
|
||||
u, err := url.Parse(a)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
|
||||
return "https://invalid.invalid"
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
port := c.Port
|
||||
if port == 623 || port == 0 {
|
||||
@@ -171,255 +232,6 @@ func bmcBase(c *bmcCreds, scheme string, defPort int) string {
|
||||
return fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
}
|
||||
|
||||
func newRF(c *bmcCreds) *rfClient {
|
||||
return &rfClient{base: bmcBase(c, "https", 443), user: c.User, pass: c.Password, hc: bmcHTTPClient(c)}
|
||||
}
|
||||
|
||||
func (r *rfClient) do(ctx context.Context, method, path string, body, out any) (int, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, r.base+path, rd)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if r.user != "" {
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if method == "PATCH" && r.etag != "" {
|
||||
req.Header.Set("If-Match", r.etag) // some services (Supermicro, newer iDRAC) insist
|
||||
}
|
||||
resp, err := r.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if method == "GET" {
|
||||
if et := resp.Header.Get("ETag"); et != "" {
|
||||
r.etag = et
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, fmt.Errorf("%s %s: %s %s", method, path, resp.Status, redfishMessage(data))
|
||||
}
|
||||
if out != nil && len(data) > 0 {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return resp.StatusCode, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// redfishMessage pulls the human message out of a Redfish error body.
|
||||
func redfishMessage(data []byte) string {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Ext []struct {
|
||||
Message string `json:"Message"`
|
||||
} `json:"@Message.ExtendedInfo"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(data, &e) == nil {
|
||||
if len(e.Error.Ext) > 0 && e.Error.Ext[0].Message != "" {
|
||||
return e.Error.Ext[0].Message
|
||||
}
|
||||
if e.Error.Message != "" {
|
||||
return e.Error.Message
|
||||
}
|
||||
}
|
||||
return oneLine(string(data))
|
||||
}
|
||||
|
||||
// redfishSystemPath: extra system=…, else the vendor's usual one if it exists, else the first member.
|
||||
func (r *rfClient) system(ctx context.Context, c *bmcCreds) (string, obj, error) {
|
||||
var cands []string
|
||||
if s := c.Extra["system"]; s != "" {
|
||||
cands = append(cands, s)
|
||||
}
|
||||
switch c.Driver {
|
||||
case "idrac":
|
||||
cands = append(cands, "/redfish/v1/Systems/System.Embedded.1")
|
||||
case "ilo", "xcc", "supermicro":
|
||||
cands = append(cands, "/redfish/v1/Systems/1")
|
||||
}
|
||||
for _, p := range cands {
|
||||
var sys obj
|
||||
if _, err := r.do(ctx, "GET", p, nil, &sys); err == nil {
|
||||
return p, sys, nil
|
||||
} else if c.Extra["system"] != "" {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
var coll struct {
|
||||
Members []struct {
|
||||
ID string `json:"@odata.id"`
|
||||
} `json:"Members"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/Systems", nil, &coll); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(coll.Members) == 0 {
|
||||
return "", nil, errors.New("the BMC lists no systems")
|
||||
}
|
||||
p := coll.Members[0].ID
|
||||
var sys obj
|
||||
_, err := r.do(ctx, "GET", p, nil, &sys)
|
||||
return p, sys, err
|
||||
}
|
||||
|
||||
// resetTypes are the ResetType values the system accepts.
|
||||
func (r *rfClient) resetTypes(ctx context.Context, sys obj) []string {
|
||||
act, _ := dig(sys, "Actions", "#ComputerSystem.Reset").(obj)
|
||||
var out []string
|
||||
for _, v := range toList(act["ResetType@Redfish.AllowableValues"]) {
|
||||
out = append(out, str(v))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if info := str(act["@Redfish.ActionInfo"]); info != "" {
|
||||
var ai struct {
|
||||
Parameters []struct {
|
||||
Name string `json:"Name"`
|
||||
AllowableValues []string `json:"AllowableValues"`
|
||||
} `json:"Parameters"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", info, nil, &ai); err == nil {
|
||||
for _, p := range ai.Parameters {
|
||||
if p.Name == "ResetType" {
|
||||
out = p.AllowableValues
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickReset(allowed []string, prefs ...string) string {
|
||||
if len(allowed) == 0 {
|
||||
return prefs[0]
|
||||
}
|
||||
for _, p := range prefs {
|
||||
if hasTag(allowed, p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var redfishBootTargets = map[string]string{"pxe": "Pxe", "disk": "Hdd", "bios": "BiosSetup", "cdrom": "Cd", "none": "None", "safe": "None"}
|
||||
|
||||
func redfishOp(ctx context.Context, c *bmcCreds, op, arg string) (string, error) {
|
||||
r := newRF(c)
|
||||
path, sys, err := r.system(ctx, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state := strings.ToLower(str(sys["PowerState"])) // On | Off | PoweringOn | PoweringOff
|
||||
on := state == "on" || state == "poweringon"
|
||||
reset := func(prefs ...string) (string, error) {
|
||||
t := pickReset(r.resetTypes(ctx, sys), prefs...)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("the BMC accepts none of %v", prefs)
|
||||
}
|
||||
target := str(dig(sys, "Actions", "#ComputerSystem.Reset", "target"))
|
||||
if target == "" {
|
||||
target = path + "/Actions/ComputerSystem.Reset"
|
||||
}
|
||||
if _, err := r.do(ctx, "POST", target, obj{"ResetType": t}, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Chassis Power Control: " + t + " (redfish)", nil
|
||||
}
|
||||
switch op {
|
||||
case "status":
|
||||
if on {
|
||||
return "Chassis Power is on", nil
|
||||
}
|
||||
return "Chassis Power is off", nil
|
||||
case "on":
|
||||
if on {
|
||||
return "Chassis Power is on already", nil
|
||||
}
|
||||
return reset("On", "PushPowerButton")
|
||||
case "off":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("ForceOff", "PushPowerButton")
|
||||
case "soft":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("GracefulShutdown", "PushPowerButton")
|
||||
case "cycle":
|
||||
if !on {
|
||||
return reset("On", "PushPowerButton")
|
||||
}
|
||||
return reset("PowerCycle", "ForceRestart", "GracefulRestart")
|
||||
case "reset":
|
||||
if !on {
|
||||
return reset("On", "PushPowerButton")
|
||||
}
|
||||
return reset("ForceRestart", "PowerCycle", "GracefulRestart")
|
||||
case "bootdev":
|
||||
boot := obj{"BootSourceOverrideTarget": redfishBootTargets[arg], "BootSourceOverrideEnabled": "Once"}
|
||||
if arg == "none" || arg == "safe" {
|
||||
boot = obj{"BootSourceOverrideEnabled": "Disabled"}
|
||||
}
|
||||
if _, err := r.do(ctx, "PATCH", path, obj{"Boot": boot}, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Set Boot Device to " + arg + " (redfish " + str(boot["BootSourceOverrideTarget"]) + ", once)", nil
|
||||
}
|
||||
return "", fmt.Errorf("redfish: %s is not supported", op)
|
||||
}
|
||||
|
||||
// redfishProbe asks whether the BMC speaks Redfish with these credentials and
|
||||
// suggests the driver for its vendor. The answer is JSON.
|
||||
func redfishProbe(ctx context.Context, c *bmcCreds) (string, error) {
|
||||
r := newRF(c)
|
||||
var root obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err != nil {
|
||||
return "", fmt.Errorf("no Redfish service: %w", err)
|
||||
}
|
||||
res := obj{"redfish": str(root["RedfishVersion"]), "vendor": str(root["Vendor"])}
|
||||
if res["vendor"] == "" {
|
||||
for k := range asObj(root["Oem"]) {
|
||||
res["vendor"] = k
|
||||
}
|
||||
}
|
||||
path, sys, err := r.system(ctx, &bmcCreds{Driver: "redfish", Extra: c.Extra, User: c.User, Password: c.Password, Address: c.Address, Port: c.Port})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err)
|
||||
}
|
||||
res["system"], res["manufacturer"], res["model"], res["power"] = path, str(sys["Manufacturer"]), str(sys["Model"]), str(sys["PowerState"])
|
||||
v := strings.ToLower(str(res["vendor"]) + " " + str(res["manufacturer"]))
|
||||
switch {
|
||||
case strings.Contains(v, "dell"):
|
||||
res["driver"] = "idrac"
|
||||
case strings.Contains(v, "hpe"), strings.Contains(v, "hewlett"), strings.Contains(v, "hp "):
|
||||
res["driver"] = "ilo"
|
||||
case strings.Contains(v, "supermicro"):
|
||||
res["driver"] = "supermicro"
|
||||
case strings.Contains(v, "lenovo"):
|
||||
res["driver"] = "xcc"
|
||||
case strings.Contains(v, "openbmc"):
|
||||
res["driver"] = "openbmc"
|
||||
default:
|
||||
res["driver"] = "redfish"
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func asObj(v any) obj {
|
||||
o, _ := v.(obj)
|
||||
return o
|
||||
|
||||
+111
-19
@@ -15,6 +15,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -39,8 +41,9 @@ type RackIface struct {
|
||||
|
||||
type RackController struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"` // how machines reach it, e.g. http://10.213.78.1
|
||||
Kind string `json:"kind"` // embedded | remote
|
||||
URL string `json:"url"` // how machines reach it, e.g. http://10.213.78.1
|
||||
TLSURL string `json:"tls_url,omitempty"` // the same host over HTTPS, when it serves it (agents)
|
||||
Kind string `json:"kind"` // embedded | remote
|
||||
Version string `json:"version"`
|
||||
Arch string `json:"arch,omitempty"` // GOARCH, for self-update
|
||||
Interfaces []RackIface `json:"interfaces"`
|
||||
@@ -50,6 +53,9 @@ type RackController struct {
|
||||
Registered time.Time `json:"registered"`
|
||||
Online bool `json:"online"`
|
||||
Machines int `json:"machines"`
|
||||
// trust (carot.go): what the rack reports at registration
|
||||
CABundleSHA256 string `json:"ca_bundle_sha256,omitempty"` // SHA-256 of the roots it trusts
|
||||
ServerCA string `json:"server_ca,omitempty"` // root of its server certificate (agents)
|
||||
}
|
||||
|
||||
// rackState is everything a rack controller writes to disk.
|
||||
@@ -62,6 +68,15 @@ type rackState struct {
|
||||
Hosts string `json:"hosts"`
|
||||
BootFiles []string `json:"boot_files"` // kernels and initrds the rack keeps in its cache for TFTP
|
||||
Update *rackUpdate `json:"update,omitempty"` // the binary the region wants this rack to run
|
||||
Trust *rackTrustState `json:"trust,omitempty"` // the roots to trust (carot.go)
|
||||
}
|
||||
|
||||
// rackTrustState: the trust bundle signed by the active root, and which root
|
||||
// signs now (a rack renews certificates from another one).
|
||||
type rackTrustState struct {
|
||||
Bundle string `json:"bundle"`
|
||||
Sig string `json:"sig"`
|
||||
Active string `json:"active"` // SHA-256 of the active root
|
||||
}
|
||||
|
||||
type rackJob struct {
|
||||
@@ -75,7 +90,9 @@ type rackJob struct {
|
||||
Extra map[string]string `json:"extra"`
|
||||
Op string `json:"op"`
|
||||
Arg string `json:"arg"`
|
||||
Timeout int `json:"timeout,omitempty"` // seconds the region waits (default 90)
|
||||
done chan rackResult
|
||||
rack string // who took it: only that rack may answer
|
||||
}
|
||||
|
||||
type rackResult struct {
|
||||
@@ -87,7 +104,7 @@ type rackResult struct {
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
func (s *Store) RackControllers(ctx context.Context) ([]RackController, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(url,''), kind, coalesce(version,''), coalesce(arch,''), interfaces, dhcp_interfaces, coalesce(tftp_root,''), last_seen, registered FROM rack_controllers ORDER BY name`)
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(url,''), coalesce(tls_url,''), kind, coalesce(version,''), coalesce(arch,''), interfaces, dhcp_interfaces, coalesce(tftp_root,''), last_seen, registered, coalesce(server_ca,'') FROM rack_controllers ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,7 +113,7 @@ func (s *Store) RackControllers(ctx context.Context) ([]RackController, error) {
|
||||
for rows.Next() {
|
||||
var r RackController
|
||||
var ifs, dif []byte
|
||||
if err := rows.Scan(&r.Name, &r.URL, &r.Kind, &r.Version, &r.Arch, &ifs, &dif, &r.TFTPRoot, &r.LastSeen, &r.Registered); err != nil {
|
||||
if err := rows.Scan(&r.Name, &r.URL, &r.TLSURL, &r.Kind, &r.Version, &r.Arch, &ifs, &dif, &r.TFTPRoot, &r.LastSeen, &r.Registered, &r.ServerCA); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(ifs, &r.Interfaces)
|
||||
@@ -115,9 +132,9 @@ func (s *Store) PutRackController(ctx context.Context, r RackController) error {
|
||||
}
|
||||
ifs, _ := json.Marshal(r.Interfaces)
|
||||
dif, _ := json.Marshal(r.DHCPInterfaces)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO rack_controllers(name, url, kind, version, interfaces, dhcp_interfaces, tftp_root, last_seen, arch) VALUES ($1,$2,$3,$4,$5,$6,$7,now(),nullif($8,''))
|
||||
ON CONFLICT (name) DO UPDATE SET url=$2, kind=$3, version=$4, interfaces=$5, dhcp_interfaces=$6, tftp_root=$7, last_seen=now(), arch=nullif($8,'')`,
|
||||
r.Name, r.URL, r.Kind, r.Version, ifs, dif, r.TFTPRoot, r.Arch)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO rack_controllers(name, url, kind, version, interfaces, dhcp_interfaces, tftp_root, last_seen, arch, tls_url) VALUES ($1,$2,$3,$4,$5,$6,$7,now(),nullif($8,''),nullif($9,''))
|
||||
ON CONFLICT (name) DO UPDATE SET url=$2, kind=$3, version=$4, interfaces=$5, dhcp_interfaces=$6, tftp_root=$7, last_seen=now(), arch=nullif($8,''), tls_url=nullif($9,'')`,
|
||||
r.Name, r.URL, r.Kind, r.Version, ifs, dif, r.TFTPRoot, r.Arch, r.TLSURL)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -137,8 +154,13 @@ func (s *Store) SetRackController(ctx context.Context, id, rack string) error {
|
||||
}
|
||||
|
||||
func (s *Store) PutPXE(ctx context.Context, mac, machineID, rack, content string) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO pxe_files(mac, machine_id, rack, content) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (mac) DO UPDATE SET machine_id=$2, rack=$3, content=$4, updated=now()`, mac, machineID, rack, content)
|
||||
// a MAC another machine owns is not taken over: a box claiming a deployed
|
||||
// machine's MAC would otherwise replace that machine's boot file
|
||||
tag, err := s.pool.Exec(ctx, `INSERT INTO pxe_files(mac, machine_id, rack, content) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (mac) DO UPDATE SET machine_id=$2, rack=$3, content=$4, updated=now() WHERE pxe_files.machine_id=$2`, mac, machineID, rack, content)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("MAC %s belongs to another machine; delete that machine first", mac)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -164,6 +186,8 @@ func (s *Store) PXEFiles(ctx context.Context, rack string) (map[string]string, e
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
var pxeNameRe = regexp.MustCompile(`^01(-[0-9a-f]{2}){6,20}$`)
|
||||
|
||||
func pxeFileName(mac string) string { return "01-" + strings.ReplaceAll(normMAC(mac), ":", "-") }
|
||||
|
||||
// ---------------------------------------------------------------- hub: state generations and the command queue
|
||||
@@ -233,7 +257,11 @@ func (h *rackHub) dispatch(ctx context.Context, rack string, j *rackJob) (string
|
||||
delete(h.wake, rack)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
t := time.NewTimer(90 * time.Second)
|
||||
wait := 90 * time.Second
|
||||
if j.Timeout > 0 {
|
||||
wait = time.Duration(j.Timeout) * time.Second
|
||||
}
|
||||
t := time.NewTimer(wait)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case r := <-j.done:
|
||||
@@ -264,6 +292,7 @@ func (h *rackHub) take(ctx context.Context, rack string, wait time.Duration) []*
|
||||
if q := h.work[rack]; len(q) > 0 {
|
||||
h.work[rack] = nil
|
||||
for _, j := range q {
|
||||
j.rack = rack
|
||||
h.flight[j.ID] = j
|
||||
}
|
||||
h.mu.Unlock()
|
||||
@@ -291,10 +320,14 @@ func (h *rackHub) take(ctx context.Context, rack string, wait time.Duration) []*
|
||||
}
|
||||
}
|
||||
|
||||
func (h *rackHub) finish(r rackResult) bool {
|
||||
func (h *rackHub) finish(rack string, r rackResult) bool {
|
||||
h.mu.Lock()
|
||||
j, ok := h.flight[r.ID]
|
||||
delete(h.flight, r.ID)
|
||||
if ok && j.rack != rack {
|
||||
ok = false
|
||||
} else {
|
||||
delete(h.flight, r.ID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if ok {
|
||||
j.done <- r
|
||||
@@ -390,10 +423,14 @@ func (a *App) urlFor(m *Machine) string { return a.rackURL(a.rackOf(m)) }
|
||||
|
||||
// appendFor is commission_append with the server URL of the machine's rack.
|
||||
func (a *App) appendFor(base string) string {
|
||||
if base == a.cfg.ServerURL {
|
||||
return a.cfg.CommissionAppend
|
||||
s := a.cfg.CommissionAppend
|
||||
if base != a.cfg.ServerURL {
|
||||
s = strings.ReplaceAll(s, a.cfg.ServerURL, base)
|
||||
}
|
||||
return strings.ReplaceAll(a.cfg.CommissionAppend, a.cfg.ServerURL, base)
|
||||
if t := a.agentTLSArgs(base); len(t) > 0 && !strings.Contains(s, "rollcall.tls=") {
|
||||
s += " " + strings.Join(t, " ")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rackAddresses are the rack controllers' own addresses, never handed to machines.
|
||||
@@ -441,6 +478,9 @@ func (a *App) rackState(ctx context.Context, name string) (*rackState, error) {
|
||||
st := &rackState{Gen: gen}
|
||||
if rc, ok := a.racksSnapshot()[name]; ok {
|
||||
st.Update = a.rackUpdateFor(rc)
|
||||
if a.ca != nil && rc.Kind == "remote" {
|
||||
st.Trust = a.trustState()
|
||||
}
|
||||
}
|
||||
base := a.rackURL(name)
|
||||
app := a.appendFor(base)
|
||||
@@ -449,6 +489,12 @@ func (a *App) rackState(ctx context.Context, name string) (*rackState, error) {
|
||||
if st.PXE, err = a.store.PXEFiles(ctx, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.ca != nil { // entries keep the root they were written with; a rotation moves it
|
||||
fp := certFingerprint(a.serverCAFor(base))
|
||||
for k, v := range st.PXE {
|
||||
st.PXE[k] = pinCA(v, hex.EncodeToString(fp[:]))
|
||||
}
|
||||
}
|
||||
subnets, err := a.store.Subnets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -479,6 +525,8 @@ func (a *App) rackState(ctx context.Context, name string) (*rackState, error) {
|
||||
// hostsFiles: static DHCP leases for the machines this rack serves, and names
|
||||
// for every machine: the addresses on the interface carrying the management
|
||||
// address get the plain name (A and AAAA), others name-<interface>, IB name-ib.
|
||||
var nonHostChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (string, string, error) {
|
||||
ms, err := a.store.List(ctx)
|
||||
if err != nil {
|
||||
@@ -507,6 +555,13 @@ func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (st
|
||||
fmt.Fprintf(&hosts, "%s %s\n", ip, name)
|
||||
}
|
||||
}
|
||||
// proxy subnets: the site's DHCP server hands out the addresses, so no dhcp-host
|
||||
// lines for them (they would do nothing, and the "known" tag they give a client
|
||||
// helps dnsmasq 2.91 build a cyclic tag list in its PXE proxy code: busy loop)
|
||||
proxied := func(ip string) bool {
|
||||
sn := subnetFor(subnets, ip)
|
||||
return sn != nil && sn.DHCPMode == "proxy"
|
||||
}
|
||||
sort.Slice(ms, func(i, j int) bool { return deref(ms[i].NodeName) < deref(ms[j].NodeName) })
|
||||
for _, m := range ms {
|
||||
if m.NodeName == nil || len(m.MACs) == 0 {
|
||||
@@ -522,7 +577,7 @@ func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (st
|
||||
}
|
||||
if mgmt != "" {
|
||||
line(mgmt, name)
|
||||
if mine && familyOf(mgmt+"/32") == 4 {
|
||||
if mine && familyOf(mgmt+"/32") == 4 && !proxied(mgmt) {
|
||||
fmt.Fprintf(&dhcp, "%s,%s,%s\n", m.MACs[0], mgmt, name)
|
||||
}
|
||||
}
|
||||
@@ -534,11 +589,13 @@ func (a *App) hostsFiles(ctx context.Context, rack string, subnets []Subnet) (st
|
||||
switch {
|
||||
case x.Iface == mgmtIface && mgmtIface != "":
|
||||
line(x.IP, name)
|
||||
if v6 && mine {
|
||||
if v6 && mine && !proxied(x.IP) {
|
||||
fmt.Fprintf(&dhcp, "%s,[%s],%s\n", m.MACs[0], x.IP, name)
|
||||
}
|
||||
case x.Iface != "":
|
||||
line(x.IP, name+"-"+strings.NewReplacer(".", "-", ":", "-").Replace(x.Iface))
|
||||
if sfx := strings.Trim(nonHostChars.ReplaceAllString(strings.ToLower(x.Iface), "-"), "-"); sfx != "" {
|
||||
line(x.IP, name+"-"+sfx)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ibip != "" {
|
||||
@@ -579,6 +636,10 @@ func applyRackState(st *rackState, tftp, dnsDir string) (changed []string, err e
|
||||
note("default", ch, e)
|
||||
}
|
||||
for name, body := range st.PXE {
|
||||
if !pxeNameRe.MatchString(name) {
|
||||
note(name, false, fmt.Errorf("not a pxelinux file name"))
|
||||
continue
|
||||
}
|
||||
ch, e := writeIfChanged(filepath.Join(dir, name), body, 0o644)
|
||||
note(name, ch, e)
|
||||
}
|
||||
@@ -589,6 +650,30 @@ func applyRackState(st *rackState, tftp, dnsDir string) (changed []string, err e
|
||||
}
|
||||
}
|
||||
}
|
||||
// the same menus for UEFI (shim + GRUB, grub.go), where the install laid that out
|
||||
if gdir := filepath.Join(tftp, "grub"); dirExists(gdir) {
|
||||
ch, e := writeIfChanged(filepath.Join(gdir, "grub.cfg"), grubDispatch, 0o644)
|
||||
note("grub.cfg", ch, e)
|
||||
if st.DefaultPXE != "" {
|
||||
ch, e := writeIfChanged(filepath.Join(gdir, "grub.cfg-default"), pxelinuxToGrub(st.DefaultPXE), 0o644)
|
||||
note("grub.cfg-default", ch, e)
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for name, body := range st.PXE {
|
||||
if g := grubName(name); g != "" {
|
||||
want[g] = true
|
||||
ch, e := writeIfChanged(filepath.Join(gdir, g), pxelinuxToGrub(body), 0o644)
|
||||
note(g, ch, e)
|
||||
}
|
||||
}
|
||||
if olds, e := filepath.Glob(filepath.Join(gdir, "grub.cfg-*:*")); e == nil {
|
||||
for _, p := range olds {
|
||||
if !want[filepath.Base(p)] && !strings.HasSuffix(p, ".tmp") {
|
||||
note("-"+filepath.Base(p), true, os.Remove(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dnsDir != "" {
|
||||
ch, e := writeIfChanged(filepath.Join(dnsDir, "hosts.dhcp"), st.HostsDHCP, 0o644)
|
||||
note("hosts.dhcp", ch, e)
|
||||
@@ -788,10 +873,12 @@ func (a *App) handleRackRegister(w http.ResponseWriter, r *http.Request, rack st
|
||||
fail(w, 400, errors.New("url is required"))
|
||||
return
|
||||
}
|
||||
rc.TLSURL = validTLSURL(rc) // its own host only
|
||||
if err := a.store.PutRackController(r.Context(), rc); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
a.noteRackTrust(r, rack, rc)
|
||||
a.reloadRacks(r.Context())
|
||||
a.hub.bump()
|
||||
log.Printf("rack controller %s registered from %s (%s, %d interfaces)", rack, r.RemoteAddr, rc.URL, len(rc.Interfaces))
|
||||
@@ -830,7 +917,7 @@ func (a *App) handleRackResult(w http.ResponseWriter, r *http.Request, rack stri
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"accepted": a.hub.finish(res)})
|
||||
writeJSON(w, 200, obj{"accepted": a.hub.finish(rack, res)})
|
||||
}
|
||||
|
||||
func (a *App) handleControllers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -929,3 +1016,8 @@ func localAddrs() []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
+262
-21
@@ -13,6 +13,7 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
@@ -53,19 +54,34 @@ type RackConfig struct {
|
||||
KeyFile string `json:"key_file,omitempty"` // and key; with them the secret is not needed
|
||||
AutoUpdate *bool `json:"auto_update,omitempty"` // follow the region's version (default true)
|
||||
UpdateDir string `json:"update_dir,omitempty"` // default /var/lib/rollcall/update
|
||||
// CertStateDir: where a renewed certificate goes (cert and key in one file,
|
||||
// rack.pem). The files from rack join are root's; this directory is the
|
||||
// service user's. Default /var/lib/rollcall/rack-cert.
|
||||
CertStateDir string `json:"cert_state_dir,omitempty"`
|
||||
// TLSListen: HTTPS for the agents (node API, images), with a server
|
||||
// certificate from the region for the URL's host. Default <url host>:443;
|
||||
// "off" disables it.
|
||||
TLSListen string `json:"tls_listen,omitempty"`
|
||||
}
|
||||
|
||||
func (c RackConfig) renewedPath() string {
|
||||
return filepath.Join(orDefault(c.CertStateDir, "/var/lib/rollcall/rack-cert"), "rack.pem")
|
||||
}
|
||||
|
||||
type rackd struct {
|
||||
cfg RackConfig
|
||||
region *url.URL
|
||||
hc *http.Client // long polls
|
||||
fast *http.Client
|
||||
big *http.Client // image downloads, no timeout
|
||||
gen int64
|
||||
tlsc *tls.Config
|
||||
certMu sync.Mutex
|
||||
upd rackUpdater
|
||||
cert *tls.Certificate
|
||||
srvMu sync.Mutex
|
||||
srvCert *tls.Certificate // server certificate for agents (HTTPS)
|
||||
tlsURL string
|
||||
cfg RackConfig
|
||||
region *url.URL
|
||||
hc *http.Client // long polls
|
||||
fast *http.Client
|
||||
big *http.Client // image downloads, no timeout
|
||||
gen int64
|
||||
tlsc *tls.Config
|
||||
certMu sync.Mutex
|
||||
upd rackUpdater
|
||||
cert *tls.Certificate
|
||||
|
||||
headMu sync.Mutex
|
||||
heads map[string]headInfo
|
||||
@@ -136,7 +152,11 @@ func (r *rackd) clients() error {
|
||||
}
|
||||
pair.Leaf, _ = x509.ParseCertificate(pair.Certificate[0])
|
||||
r.cert = &pair
|
||||
if renewed := loadRenewed(r.cfg.renewedPath(), pool, r.cfg.Name, pair.Leaf); renewed != nil {
|
||||
r.cert = renewed
|
||||
}
|
||||
r.tlsc.RootCAs = pool
|
||||
r.tlsc.VerifyConnection = regionLeaf
|
||||
r.tlsc.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
||||
r.certMu.Lock()
|
||||
defer r.certMu.Unlock()
|
||||
@@ -184,11 +204,9 @@ func (r *rackd) renew() error {
|
||||
return err
|
||||
}
|
||||
pair.Leaf, _ = x509.ParseCertificate(pair.Certificate[0])
|
||||
if err := os.WriteFile(r.cfg.KeyFile, key, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(r.cfg.CertFile, []byte(resp.Cert), 0o644); err != nil {
|
||||
return err
|
||||
// one file, renamed into place: a crash never leaves a key without its certificate
|
||||
if err := writeAtomic(r.cfg.renewedPath(), append([]byte(resp.Cert), key...), 0o600); err != nil {
|
||||
return fmt.Errorf("keep the renewed certificate (the region already switched to it; until this works the previous one is accepted for 7 days): %w; create the directory: install -d -m 0700 -o rollcall -g rollcall %s", err, filepath.Dir(r.cfg.renewedPath()))
|
||||
}
|
||||
r.certMu.Lock()
|
||||
r.cert = &pair
|
||||
@@ -197,6 +215,84 @@ func (r *rackd) renew() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRenewed is the certificate renew() kept, when it belongs to this rack,
|
||||
// comes from the CA and outlives the one from rack join.
|
||||
func loadRenewed(p string, pool *x509.CertPool, name string, current *x509.Certificate) *tls.Certificate {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
pair, err := tls.X509KeyPair(b, b)
|
||||
if err != nil {
|
||||
log.Printf("%s: %v (ignored)", p, err)
|
||||
return nil
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil || leaf.Subject.CommonName != "rack:"+name {
|
||||
return nil
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
log.Printf("%s: %v (ignored)", p, err)
|
||||
return nil
|
||||
}
|
||||
if current != nil && !leaf.NotAfter.After(current.NotAfter) {
|
||||
return nil
|
||||
}
|
||||
pair.Leaf = leaf
|
||||
return &pair
|
||||
}
|
||||
|
||||
func writeAtomic(p string, data []byte, mode os.FileMode) error {
|
||||
f, err := os.CreateTemp(filepath.Dir(p), ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Chmod(mode); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), p)
|
||||
}
|
||||
|
||||
// rackRenew renews the certificate now (rollcall rack renew), as the service user.
|
||||
func rackRenew(args []string) error {
|
||||
fs := newFlags("rack renew")
|
||||
cfgPath := fs.String("config", "/etc/rollcall/rack.json", "rack controller configuration")
|
||||
_ = fs.Parse(args)
|
||||
cfg, err := loadRackConfig(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ru, err := url.Parse(cfg.Region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r := &rackd{cfg: cfg, region: ru, heads: map[string]headInfo{}}
|
||||
if err := r.clients(); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.cert == nil {
|
||||
return errors.New("this rack controller has no certificate (rollcall rack join)")
|
||||
}
|
||||
if err := r.renew(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("renewed: %s, valid until %s; restart rollcall-rack to use it\n", cfg.renewedPath(), r.cert.Leaf.NotAfter.Format("2006-01-02"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func newCSR(cn string) (keyPEM []byte, csrPEM string, err error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
@@ -272,6 +368,28 @@ func rackJoin(args []string) error {
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
// what is written here is trusted for good: the CA must be the one the code
|
||||
// names, and the certificate ours from it
|
||||
caCert, err := parseCertPEM([]byte(out.CA))
|
||||
if err != nil {
|
||||
return fmt.Errorf("the region sent no CA: %w", err)
|
||||
}
|
||||
if fp := sha256.Sum256(caCert.Raw); string(fp[:16]) != string(code.Fingerprint[:]) || !caCert.IsCA {
|
||||
return errors.New("the region sent a CA that is not the one in the join code; not trusting it")
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(out.Cert), key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the certificate does not match our key: %w", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(pair.Certificate[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(caCert)
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
return fmt.Errorf("the certificate is not from the code's CA: %w", err)
|
||||
}
|
||||
cfg.Name = out.Name
|
||||
if err := os.MkdirAll(*dir, 0o750); err != nil {
|
||||
return err
|
||||
@@ -334,7 +452,7 @@ func runRack(args []string) error {
|
||||
}
|
||||
go r.loop()
|
||||
mux := http.NewServeMux()
|
||||
for _, p := range []string{"/rollcall/", "/images/", "/os/"} {
|
||||
for _, p := range []string{"/rollcall/", "/images/", "/os/", "/firmware/"} { // firmware: BMCs pull packages from here
|
||||
mux.HandleFunc("GET "+p, r.serveCached)
|
||||
mux.HandleFunc("HEAD "+p, r.serveCached)
|
||||
}
|
||||
@@ -358,7 +476,12 @@ func runRack(args []string) error {
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, 200, obj{"rack": cfg.Name, "gen": r.gen, "version": version})
|
||||
})
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||
mux.HandleFunc("GET /ca.pem", r.serveCA(false)) // agents check it by its fingerprint
|
||||
mux.HandleFunc("GET /ca-bundle.pem", r.serveCA(true))
|
||||
if r.cert != nil && cfg.TLSListen != "off" {
|
||||
go r.serveTLS(logging(mux))
|
||||
}
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
|
||||
log.Printf("rack controller %s: region %s, serving %s on %s (tftp %s, cache %s)", cfg.Name, cfg.Region, cfg.URL, cfg.Listen, cfg.TFTPRoot, cfg.CacheDir)
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
@@ -407,8 +530,13 @@ func (r *rackd) call(c *http.Client, method, p string, body, out any) (int, erro
|
||||
}
|
||||
|
||||
func (r *rackd) register() error {
|
||||
_, err := r.call(r.fast, "POST", "/api/v1/rack/register", RackController{URL: r.cfg.URL, Version: version, Arch: runtime.GOARCH,
|
||||
Interfaces: localInterfaces(), DHCPInterfaces: r.cfg.DHCPInterfaces, TFTPRoot: r.cfg.TFTPRoot}, nil)
|
||||
r.srvMu.Lock()
|
||||
tlsURL := r.tlsURL
|
||||
r.srvMu.Unlock()
|
||||
bundle, serverCA := r.trustReport()
|
||||
_, err := r.call(r.fast, "POST", "/api/v1/rack/register", RackController{URL: r.cfg.URL, TLSURL: tlsURL, Version: version, Arch: runtime.GOARCH,
|
||||
Interfaces: localInterfaces(), DHCPInterfaces: r.cfg.DHCPInterfaces, TFTPRoot: r.cfg.TFTPRoot,
|
||||
CABundleSHA256: bundle, ServerCA: serverCA}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -444,6 +572,7 @@ func (r *rackd) loop() {
|
||||
continue
|
||||
}
|
||||
go r.maybeUpdate(st.Update)
|
||||
go r.maybeTrust(st.Trust)
|
||||
if st.Gen == r.gen {
|
||||
continue
|
||||
}
|
||||
@@ -522,8 +651,8 @@ func (r *rackd) head(rel string) (headInfo, error) {
|
||||
// fresh says whether the cached copy matches the region's file. OS images live
|
||||
// under versioned paths and never change; the rest is checked by size and time.
|
||||
func (r *rackd) fresh(rel string, fi os.FileInfo) bool {
|
||||
if strings.HasPrefix(rel, "/os/") && !strings.Contains(rel, "/current/") {
|
||||
return true
|
||||
if (strings.HasPrefix(rel, "/os/") && !strings.Contains(rel, "/current/")) || strings.HasPrefix(rel, "/firmware/") {
|
||||
return true // packages live under their id and never change
|
||||
}
|
||||
h, err := r.head(rel)
|
||||
if err != nil {
|
||||
@@ -640,3 +769,115 @@ func (r *rackd) prefetch(files []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTPS for agents
|
||||
|
||||
func (c RackConfig) serverCertPath() string {
|
||||
return filepath.Join(orDefault(c.CertStateDir, "/var/lib/rollcall/rack-cert"), "server.pem")
|
||||
}
|
||||
|
||||
// serverCert keeps a server certificate for the URL's host, from the region.
|
||||
func (r *rackd) serverCert() (*tls.Certificate, error) {
|
||||
host, err := rackHost(r.cfg.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := r.cfg.serverCertPath()
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
if pair, err := tls.X509KeyPair(b, b); err == nil {
|
||||
leaf, _ := x509.ParseCertificate(pair.Certificate[0])
|
||||
if leaf != nil && time.Until(leaf.NotAfter) > renewBefore && leaf.VerifyHostname(host) == nil {
|
||||
pair.Leaf = leaf
|
||||
return &pair, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return r.newServerCert()
|
||||
}
|
||||
|
||||
// newServerCert asks the region for a server certificate and keeps it.
|
||||
func (r *rackd) newServerCert() (*tls.Certificate, error) {
|
||||
p := r.cfg.serverCertPath()
|
||||
key, csr, err := newCSR(rackServerCN + r.cfg.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Cert string `json:"cert"`
|
||||
}
|
||||
if _, err := r.call(r.fast, "POST", "/api/v1/rack/server-cert", enrollRequest{CSR: csr}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(resp.Cert), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pair.Leaf, _ = x509.ParseCertificate(pair.Certificate[0])
|
||||
if err := writeAtomic(p, append([]byte(resp.Cert), key...), 0o600); err != nil {
|
||||
log.Printf("keep the server certificate: %v (it is used, but asked for again at the next start)", err)
|
||||
}
|
||||
return &pair, nil
|
||||
}
|
||||
|
||||
// serveTLS serves the agents' endpoints over HTTPS once the region issued a
|
||||
// certificate, re-registers with the HTTPS URL and renews it when it ages.
|
||||
func (r *rackd) serveTLS(h http.Handler) {
|
||||
host, err := rackHost(r.cfg.URL)
|
||||
if err != nil {
|
||||
log.Printf("https for agents: %v", err)
|
||||
return
|
||||
}
|
||||
addr := r.cfg.TLSListen
|
||||
if addr == "" {
|
||||
addr = net.JoinHostPort(host, "443")
|
||||
}
|
||||
for {
|
||||
c, err := r.serverCert()
|
||||
if err == nil {
|
||||
r.srvMu.Lock()
|
||||
r.srvCert = c
|
||||
r.srvMu.Unlock()
|
||||
break
|
||||
}
|
||||
log.Printf("https for agents: no server certificate yet: %v", err)
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute,
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
r.srvMu.Lock()
|
||||
defer r.srvMu.Unlock()
|
||||
return r.srvCert, nil
|
||||
}}}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("https for agents on %s: %v", addr, err)
|
||||
return
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
u := "https://" + hostPort(host, "")
|
||||
if port != "443" {
|
||||
u = "https://" + hostPort(host, port)
|
||||
}
|
||||
r.srvMu.Lock()
|
||||
r.tlsURL = u
|
||||
r.srvMu.Unlock()
|
||||
if err := r.register(); err != nil {
|
||||
log.Printf("register with %s: %v", u, err)
|
||||
}
|
||||
log.Printf("serving agents over HTTPS on %s (%s)", addr, u)
|
||||
go func() {
|
||||
for range time.Tick(12 * time.Hour) {
|
||||
r.srvMu.Lock()
|
||||
leaf := r.srvCert.Leaf
|
||||
r.srvMu.Unlock()
|
||||
if leaf != nil && time.Until(leaf.NotAfter) < renewBefore {
|
||||
if c, err := r.serverCert(); err == nil {
|
||||
r.srvMu.Lock()
|
||||
r.srvCert = c
|
||||
r.srvMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("https for agents: %v", srv.ServeTLS(ln, "", ""))
|
||||
}
|
||||
@@ -7,10 +7,18 @@ package main
|
||||
// expected version, and leaves a "ready" marker. A root path unit
|
||||
// (rollcall-rack-update.path) installs it, restarts the rack and puts the
|
||||
// previous binary back if the rack does not register again within a minute.
|
||||
//
|
||||
// The rack process is not trusted with root: it faces the provisioning
|
||||
// networks. The region signs each binary with its CA key, and the root unit
|
||||
// installs nothing that does not verify against the CA certificate written at
|
||||
// join (/etc/rollcall/rack/ca.pem, root's).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -29,6 +37,7 @@ type rackUpdate struct {
|
||||
Version string `json:"version"`
|
||||
Arch string `json:"arch"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Sig string `json:"sig"` // base64 DER ECDSA over the binary's SHA-256, by the region CA
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- region
|
||||
@@ -37,14 +46,36 @@ type binCache struct {
|
||||
mu sync.Mutex
|
||||
key map[string]string // path -> "size/mtime"
|
||||
sums map[string]string // path -> sha256
|
||||
sigs map[string]string // sha256 -> signature (kept: a new one each time would change the state)
|
||||
}
|
||||
|
||||
var rackBins = &binCache{key: map[string]string{}, sums: map[string]string{}}
|
||||
var rackBins = &binCache{key: map[string]string{}, sums: map[string]string{}, sigs: map[string]string{}}
|
||||
|
||||
// binarySig signs a binary's SHA-256 with the region CA key.
|
||||
func (a *App) binarySig(sum string) string {
|
||||
digest, err := hex.DecodeString(sum)
|
||||
if err != nil || a.ca == nil {
|
||||
return ""
|
||||
}
|
||||
rackBins.mu.Lock()
|
||||
defer rackBins.mu.Unlock()
|
||||
if s, ok := rackBins.sigs[sum]; ok {
|
||||
return s
|
||||
}
|
||||
der, err := ecdsa.SignASN1(rand.Reader, a.ca.Key, digest)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rackBins.sigs[sum] = base64.StdEncoding.EncodeToString(der)
|
||||
return rackBins.sigs[sum]
|
||||
}
|
||||
|
||||
// rackBinary: the binary a rack of this architecture should run: the region's
|
||||
// own for the same architecture, else rack_binaries/rollcall-linux-<arch>.
|
||||
func (a *App) rackBinary(arch string) (path, sum string, ok bool) {
|
||||
if arch == "" {
|
||||
switch arch { // a file name part: never a path (the rack chooses it)
|
||||
case "amd64", "arm64", "386", "arm", "riscv64", "ppc64le", "s390x", "loong64":
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
if arch == runtime.GOARCH && runtime.GOOS == "linux" {
|
||||
@@ -87,7 +118,7 @@ func (a *App) rackUpdateFor(rc RackController) *rackUpdate {
|
||||
return nil
|
||||
}
|
||||
if _, sum, ok := a.rackBinary(rc.Arch); ok {
|
||||
return &rackUpdate{Version: version, Arch: rc.Arch, SHA256: sum}
|
||||
return &rackUpdate{Version: version, Arch: rc.Arch, SHA256: sum, Sig: a.binarySig(sum)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -135,6 +166,10 @@ func (r *rackd) maybeUpdate(up *rackUpdate) {
|
||||
if up == nil || up.Version == "" || up.Version == version || !r.autoUpdate() || up.Arch != runtime.GOARCH {
|
||||
return
|
||||
}
|
||||
if _, err := hex.DecodeString(up.SHA256); err != nil || len(up.SHA256) != 64 || up.Sig == "" {
|
||||
log.Printf("update to %s: the region sent no valid checksum and signature", up.Version)
|
||||
return
|
||||
}
|
||||
r.upd.mu.Lock()
|
||||
if t, ok := r.upd.tried[up.Version]; ok && time.Since(t) < time.Hour {
|
||||
r.upd.mu.Unlock()
|
||||
@@ -205,6 +240,13 @@ func (r *rackd) stageUpdate(up *rackUpdate) error {
|
||||
if err := os.WriteFile(filepath.Join(dir, "rollcall.new.sha256"), []byte(up.SHA256+" rollcall.new\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(up.Sig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("signature: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "rollcall.new.sig"), sig, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
ready := filepath.Join(dir, "ready")
|
||||
if err := os.WriteFile(ready+".tmp", []byte(up.Version+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
package main
|
||||
|
||||
// Redfish power driver: one implementation of the DMTF standard, and what each
|
||||
// vendor documents differently in redfishProfiles (with the sources).
|
||||
//
|
||||
// For every vendor:
|
||||
// - ResetType values and boot targets come from @Redfish.AllowableValues
|
||||
// (or the action's ActionInfo); nothing is assumed.
|
||||
// - A one-time boot override sends target and enable in one PATCH, the boot
|
||||
// mode as the profile says (keep / omit / try), and only writable fields.
|
||||
// - PATCH carries If-Match with the resource's ETag (header or
|
||||
// @odata.etag). On 412 the strong form and then none are tried; on 428
|
||||
// the ETag is fetched first.
|
||||
// - A service that keeps pending settings elsewhere (AMI's .../SD,
|
||||
// @Redfish.Settings) gets the same PATCH there.
|
||||
// - "Busy" answers are retried for up to a minute: the server is in POST,
|
||||
// 409 right after a power change, 503, "Retry in N seconds".
|
||||
// - 202 answers are followed to their task monitor.
|
||||
// - A power cycle the system does not offer is ForceOff, wait for Off, On.
|
||||
//
|
||||
// Field notes behind these rules: OpenStack Ironic/sushy, MAAS and bmclib.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type redfishProfile struct {
|
||||
Driver string // the power driver name
|
||||
Title string // for people
|
||||
Vendors []string // lower-case substrings of ServiceRoot Vendor or the system's Manufacturer
|
||||
OemKeys []string // ServiceRoot Oem keys that identify it
|
||||
Systems []string // its usual ComputerSystem, tried before the collection
|
||||
// BootMode: BootSourceOverrideMode in a boot override.
|
||||
// keep: always send the current mode (left out, the BMC may switch UEFI to Legacy)
|
||||
// omit: never send it unless the operator sets boot_mode (a change starts a BIOS job)
|
||||
// try: send the current mode; again without it if the BMC refuses the body
|
||||
BootMode string
|
||||
// Clear: bodies that clear a boot override, tried in order (default: Enabled Disabled)
|
||||
Clear []obj
|
||||
// Auth: "session" logs in through the SessionService (X-Auth-Token); default basic.
|
||||
Auth string
|
||||
// Firmware (UpdateService, redfish_fw.go). FWPlan builds the vendor's
|
||||
// documented request for a component; without one the standard methods are
|
||||
// tried in FWMethods order (default multipart, simple, push). FWUnsupported
|
||||
// says why Rollcall cannot update this vendor's firmware. FWFormats are the
|
||||
// package files it takes, checked at import.
|
||||
FWPlan func(fwPlanCtx) (*fwRequest, error)
|
||||
FWMethods []string
|
||||
FWUnsupported string
|
||||
FWClaims bool // the plan takes HttpPushUriTargetsBusy; fw-finish gives it back
|
||||
FWFormats []string
|
||||
FWNotes string
|
||||
FWSources []string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
// redfishProfiles, most specific first: the AMI-based ones (Gigabyte, ASRock
|
||||
// Rack) also carry Oem.Ami, so they come before the generic AMI entry.
|
||||
var redfishProfiles = []redfishProfile{
|
||||
{Driver: "idrac", Title: "Dell iDRAC 8/9/10", Vendors: []string{"dell"}, OemKeys: []string{"Dell"},
|
||||
Systems: []string{"/redfish/v1/Systems/System.Embedded.1"}, BootMode: "omit",
|
||||
Sources: []string{
|
||||
"https://dl.dell.com/topicspdf/idrac9-lifecycle-controller-v33-series_api-guide2_en-us.pdf",
|
||||
"https://github.com/dell/iDRAC-Redfish-Scripting/blob/master/Redfish%20Python/SetNextOneTimeBootDeviceREDFISH.py",
|
||||
}},
|
||||
{Driver: "ilo", Title: "HPE iLO 4 (2.30+), 5, 6", Vendors: []string{"hpe", "hewlett"}, OemKeys: []string{"Hpe", "Hp"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try",
|
||||
Sources: []string{
|
||||
"https://servermanagementportal.ext.hpe.com/docs/redfishservices/ilos/ilo5/ilo5_307/ilo5_computersystem_resourcedefns307",
|
||||
"https://servermanagementportal.ext.hpe.com/docs/references_and_material/blogposts/etc/masterserverstates/masterserverstates.md",
|
||||
"https://servermanagementportal.ext.hpe.com/docs/concepts/errorresponses",
|
||||
}},
|
||||
{Driver: "xcc", Title: "Lenovo XClarity Controller (XCC, XCC2)", Vendors: []string{"lenovo"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try",
|
||||
Sources: []string{
|
||||
"https://pubs.lenovo.com/xcc-restapi/update_next_onetime_bootconfig_patch",
|
||||
"https://pubs.lenovo.com/xcc-restapi/server_reset_operations_post",
|
||||
}},
|
||||
{Driver: "supermicro", Title: "Supermicro X11 and later, H12 and later", Vendors: []string{"supermicro"}, OemKeys: []string{"Supermicro"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "keep",
|
||||
Sources: []string{
|
||||
"https://www.supermicro.com/manuals/other/RedfishUserGuide.pdf",
|
||||
"https://storyboard.openstack.org/#!/story/2008252",
|
||||
}},
|
||||
{Driver: "xfusion", Title: "xFusion / Huawei FusionServer iBMC", Vendors: []string{"xfusion", "huawei"}, OemKeys: []string{"xFusion", "Huawei"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try", Auth: "session",
|
||||
Sources: []string{
|
||||
"https://www.xfusion.com/wp-content/uploads/2025/08/FusionServer-iBMC-User-Guide.pdf",
|
||||
"https://github.com/Open-xFusion/Server_Plugin_Ansible/blob/HEAD/ibmc_ansible/ibmc_redfish_api/api_manage_boot_device.py",
|
||||
"https://github.com/Huawei/Huawei-iBMC-Cmdlets/blob/HEAD/code/common/Types.ps1",
|
||||
}},
|
||||
{Driver: "kaytus", Title: "KAYTUS / Inspur (no public Redfish guide: check on hardware)", Vendors: []string{"kaytus", "inspur"}, OemKeys: []string{"Public"},
|
||||
Systems: []string{"/redfish/v1/Systems/1"}, BootMode: "try",
|
||||
Sources: []string{
|
||||
"https://github.com/ilkermanap/pyredfish/blob/HEAD/pyredfish/vendors/kaytus.py",
|
||||
"https://github.com/erelbi/kaytus-bmc-sdk",
|
||||
"https://www.commoncriteriaportal.org/files/epfiles/Kaytus%20ST%20v27a.pdf",
|
||||
}},
|
||||
{Driver: "irmc", Title: "Fujitsu / Fsas iRMC S5, S6", Vendors: []string{"fujitsu", "ts_fujitsu", "fsas"}, OemKeys: []string{"ts_fujitsu"},
|
||||
Systems: []string{"/redfish/v1/Systems/0"}, BootMode: "omit",
|
||||
// Enabled "Disabled" is refused on every firmware; 1.20 wants Continuous with None
|
||||
Clear: []obj{{"BootSourceOverrideTarget": "None"}, {"BootSourceOverrideTarget": "None", "BootSourceOverrideEnabled": "Continuous"}},
|
||||
Sources: []string{
|
||||
"https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_setnextboot.py",
|
||||
"https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_powerstate.py",
|
||||
}},
|
||||
{Driver: "cimc", Title: "Cisco UCS C-series CIMC (standalone)", Vendors: []string{"cisco"}, OemKeys: []string{"Cisco"},
|
||||
BootMode: "try", // the system is /Systems/<serial>: found through the collection
|
||||
Sources: []string{"https://www.cisco.com/c/en/us/td/docs/unified_computing/ucs/c/sw/api/4_3/b-cisco-ucs-c-series-servers-rest-api-programmer-s-guide-release-4-3.pdf"}},
|
||||
{Driver: "gigabyte", Title: "GIGABYTE servers (MegaRAC SP-X BMC)", Vendors: []string{"giga-byte", "gigabyte"},
|
||||
Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep",
|
||||
Sources: []string{"https://download.gigabyte.com/FileList/Manual/server_manual_redfish_v1.11.0.pdf"}},
|
||||
{Driver: "asrockrack", Title: "ASRock Rack (MegaRAC BMC)", Vendors: []string{"asrockrack", "asrock rack", "asrock"},
|
||||
Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep",
|
||||
Sources: []string{"https://bugs.launchpad.net/ironic/+bug/2073518", "https://opendev.org/openstack/ironic/commit/54977a14"}},
|
||||
{Driver: "openbmc", Title: "OpenBMC (bmcweb)", Vendors: []string{"openbmc"},
|
||||
Systems: []string{"/redfish/v1/Systems/system"}, BootMode: "try",
|
||||
Sources: []string{"https://github.com/openbmc/bmcweb/blob/master/redfish-core/lib/systems.hpp"}},
|
||||
{Driver: "ami", Title: "AMI MegaRAC SP-X (many ODM boards)", Vendors: []string{"american megatrends"}, OemKeys: []string{"Ami"},
|
||||
Systems: []string{"/redfish/v1/Systems/Self"}, BootMode: "keep",
|
||||
Sources: []string{"https://download-2.msi.com/archive/mnu_exe/server/MSI-Redfish-API-v1.0-UG-250801.pdf"}},
|
||||
{Driver: "redfish", Title: "DMTF Redfish, any vendor", BootMode: "try",
|
||||
Sources: []string{"https://www.dmtf.org/standards/redfish"}},
|
||||
}
|
||||
|
||||
func redfishProfileFor(driver string) redfishProfile {
|
||||
for _, p := range redfishProfiles {
|
||||
if p.Driver == driver {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return redfishProfiles[len(redfishProfiles)-1]
|
||||
}
|
||||
|
||||
// matchRedfishProfile picks the profile for what a service says about itself.
|
||||
func matchRedfishProfile(vendor, manufacturer string, oemKeys []string) redfishProfile {
|
||||
v := strings.ToLower(vendor + " " + manufacturer)
|
||||
for _, p := range redfishProfiles {
|
||||
for _, s := range p.Vendors {
|
||||
if strings.Contains(v, s) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
for _, k := range p.OemKeys {
|
||||
if hasTag(oemKeys, k) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return redfishProfiles[len(redfishProfiles)-1]
|
||||
}
|
||||
|
||||
func redfishDrivers() []powerDriver {
|
||||
var out []powerDriver
|
||||
for _, p := range redfishProfiles {
|
||||
if p.Driver == "redfish" { // the generic one first
|
||||
out = append([]powerDriver{{p.Driver, p.Title, "redfish", 443}}, out...)
|
||||
continue
|
||||
}
|
||||
out = append(out, powerDriver{p.Driver, p.Title + " (Redfish)", "redfish", 443})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- client
|
||||
|
||||
type rfClient struct {
|
||||
base, user, pass string
|
||||
hc *http.Client
|
||||
etags map[string]string // resource -> ETag from its last GET
|
||||
token, session string // X-Auth-Token and the session to delete, when logged in
|
||||
pushURIs map[string]bool // upload URIs the UpdateService advertised (Lenovo: /mfwupdate)
|
||||
}
|
||||
|
||||
// login opens a session (profile or extra auth=session); logout closes it.
|
||||
func (r *rfClient) login(ctx context.Context) error {
|
||||
resp, err := r.call(ctx, "POST", "/redfish/v1/SessionService/Sessions", obj{"UserName": r.user, "Password": r.pass}, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("session login: %w", err)
|
||||
}
|
||||
if r.token = resp.hdr.Get("X-Auth-Token"); r.token == "" {
|
||||
return errors.New("session login: no X-Auth-Token in the answer")
|
||||
}
|
||||
r.session = resp.hdr.Get("Location")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rfClient) logout() {
|
||||
if r.session == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, _ = r.call(ctx, "DELETE", r.session, nil, "") // sessions are few: never leave one behind
|
||||
r.token, r.session = "", ""
|
||||
}
|
||||
|
||||
// open logs in when the profile or the operator wants sessions.
|
||||
func (r *rfClient) open(ctx context.Context, c *bmcCreds, prof redfishProfile) error {
|
||||
if a := c.Extra["auth"]; a == "session" || (a == "" && prof.Auth == "session") {
|
||||
return r.login(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rfSleep waits between retries; tests make it instant.
|
||||
var rfSleep = func(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func newRF(c *bmcCreds) *rfClient {
|
||||
return &rfClient{base: bmcBase(c, "https", 443), user: c.User, pass: c.Password, hc: bmcHTTPClient(c), etags: map[string]string{}}
|
||||
}
|
||||
|
||||
// resolve turns a path the BMC handed us (@odata.id, an action target, a task
|
||||
// monitor) into one on this BMC. Vendors return relative paths, full URLs and
|
||||
// trailing slashes; anything that would leave the BMC or the Redfish tree (and
|
||||
// take the credentials with it) is refused.
|
||||
func (r *rfClient) resolve(p string) (string, error) {
|
||||
if strings.Contains(p, "://") {
|
||||
u, err := url.Parse(p)
|
||||
b, _ := url.Parse(r.base)
|
||||
if err != nil || b == nil || !strings.EqualFold(u.Host, b.Host) || u.User != nil {
|
||||
return "", fmt.Errorf("the BMC pointed at %q, which is not this BMC", p)
|
||||
}
|
||||
p = u.RequestURI()
|
||||
}
|
||||
if r.pushURIs[p] && strings.HasPrefix(p, "/") && !strings.Contains(p, "..") {
|
||||
return p, nil // the BMC's own upload URI, on this BMC (outside /redfish/ on some)
|
||||
}
|
||||
if !strings.HasPrefix(p, "/redfish/") || strings.Contains(p, "/../") || strings.HasSuffix(p, "/..") {
|
||||
return "", fmt.Errorf("the BMC pointed at %q, outside /redfish/", p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type rfResp struct {
|
||||
code int
|
||||
hdr http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// rfError is a refused request, with the registry message IDs it carried.
|
||||
type rfError struct {
|
||||
method, path string
|
||||
code int
|
||||
ids []string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *rfError) Error() string {
|
||||
return fmt.Sprintf("%s %s: %d %s", e.method, e.path, e.code, e.msg)
|
||||
}
|
||||
|
||||
// has: one of the message IDs ends in key ("Ami.1.0.OperationSupportedInFutureStateURI").
|
||||
func (e *rfError) has(key string) bool {
|
||||
for _, id := range e.ids {
|
||||
if strings.HasSuffix(id, "."+key) || id == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(e.msg, key)
|
||||
}
|
||||
|
||||
// succeeded: the error body says the request worked (only Success messages).
|
||||
func (e *rfError) succeeded() bool {
|
||||
if len(e.ids) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, id := range e.ids {
|
||||
if !strings.HasSuffix(id, ".Success") && !strings.HasSuffix(id, "GeneralError") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return e.has("Success") || strings.Contains(strings.ToLower(e.msg), "completed successfully")
|
||||
}
|
||||
|
||||
var retryInRe = regexp.MustCompile(`(?i)retry in (\d+) seconds?`)
|
||||
|
||||
// busy: the service will take the same request a little later.
|
||||
func (e *rfError) busy() (time.Duration, bool) {
|
||||
if m := retryInRe.FindStringSubmatch(e.msg); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return time.Duration(min(max(n, 1), 30)) * time.Second, true
|
||||
}
|
||||
switch {
|
||||
case e.has("UnableToModifyDuringSystemPOST"): // HPE: boot settings only when off or past POST
|
||||
return 10 * time.Second, true
|
||||
case e.code == http.StatusConflict, e.code == http.StatusServiceUnavailable: // right after a power change
|
||||
return 5 * time.Second, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func asRFError(err error) *rfError {
|
||||
var e *rfError
|
||||
if errors.As(err, &e) {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// call sends one request; ifMatch "" sends none.
|
||||
func (r *rfClient) call(ctx context.Context, method, path string, body any, ifMatch string) (*rfResp, error) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
path, err := r.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, r.base+path, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.token != "" {
|
||||
req.Header.Set("X-Auth-Token", r.token)
|
||||
} else if r.user != "" && !(method == "POST" && strings.HasSuffix(path, "/SessionService/Sessions")) {
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if ifMatch != "" {
|
||||
req.Header.Set("If-Match", ifMatch)
|
||||
}
|
||||
resp, err := r.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
out := &rfResp{code: resp.StatusCode, hdr: resp.Header, body: data}
|
||||
if resp.StatusCode >= 300 {
|
||||
ids, msg := redfishMessage(data)
|
||||
e := &rfError{method: method, path: path, code: resp.StatusCode, ids: ids, msg: resp.Status + " " + msg}
|
||||
if resp.StatusCode == http.StatusBadRequest && method != "GET" && e.succeeded() { // KAYTUS: 400 with "completed successfully"
|
||||
return out, nil
|
||||
}
|
||||
return out, e
|
||||
}
|
||||
if method == "GET" {
|
||||
et := resp.Header.Get("ETag")
|
||||
if et == "" {
|
||||
var o struct {
|
||||
ETag json.RawMessage `json:"@odata.etag"` // iRMC: a bare number
|
||||
}
|
||||
_ = json.Unmarshal(data, &o)
|
||||
et = strings.Trim(string(o.ETag), `"`)
|
||||
if len(o.ETag) > 0 && o.ETag[0] == '"' {
|
||||
var s string
|
||||
_ = json.Unmarshal(o.ETag, &s)
|
||||
et = s
|
||||
}
|
||||
}
|
||||
if et != "" {
|
||||
r.etags[path] = et
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// do is call plus decoding; GETs record the resource's ETag.
|
||||
func (r *rfClient) do(ctx context.Context, method, path string, body, out any) (int, error) {
|
||||
resp, err := r.call(ctx, method, path, body, "")
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
return resp.code, err
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if out != nil && len(resp.body) > 0 {
|
||||
if err := json.Unmarshal(resp.body, out); err != nil {
|
||||
return resp.code, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return resp.code, nil
|
||||
}
|
||||
|
||||
// redfishMessage pulls the message IDs and the human message out of an error body.
|
||||
func redfishMessage(data []byte) ([]string, string) {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Ext []struct {
|
||||
ID string `json:"MessageId"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"@Message.ExtendedInfo"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(data, &e) != nil {
|
||||
return nil, oneLine(string(data)) // some BMCs' web servers answer errors in HTML
|
||||
}
|
||||
var ids, msgs []string
|
||||
for _, x := range e.Error.Ext {
|
||||
if x.ID != "" {
|
||||
ids = append(ids, x.ID)
|
||||
}
|
||||
if x.Message != "" {
|
||||
msgs = append(msgs, x.Message)
|
||||
} else if x.ID != "" { // HPE often sends only the ID
|
||||
msgs = append(msgs, x.ID)
|
||||
}
|
||||
}
|
||||
if e.Error.Code != "" {
|
||||
ids = append(ids, e.Error.Code)
|
||||
}
|
||||
if len(msgs) == 0 && e.Error.Message != "" {
|
||||
msgs = append(msgs, e.Error.Message)
|
||||
}
|
||||
return ids, strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// patch sends a PATCH the way picky services want it (If-Match, 412, 428).
|
||||
func (r *rfClient) patch(ctx context.Context, path string, body obj) (*rfResp, error) {
|
||||
et := r.etags[path]
|
||||
resp, err := r.call(ctx, "PATCH", path, body, et)
|
||||
e := asRFError(err)
|
||||
switch {
|
||||
case e == nil:
|
||||
case e.code == http.StatusPreconditionRequired: // 428: wants If-Match (AMI)
|
||||
if _, gerr := r.call(ctx, "GET", path, nil, ""); gerr == nil && r.etags[path] != "" {
|
||||
resp, err = r.call(ctx, "PATCH", path, body, r.etags[path])
|
||||
}
|
||||
case e.code == http.StatusPreconditionFailed && et != "": // 412: weak ETag refused, or stale
|
||||
if strong := strings.TrimPrefix(et, "W/"); strong != et {
|
||||
resp, err = r.call(ctx, "PATCH", path, body, strong)
|
||||
}
|
||||
if e2 := asRFError(err); e2 != nil && e2.code == http.StatusPreconditionFailed {
|
||||
resp, err = r.call(ctx, "PATCH", path, body, "")
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
delete(r.etags, path) // changed: never reuse it
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// retry runs f again while the service says it is busy, for up to a minute.
|
||||
func retry(ctx context.Context, f func() error) error {
|
||||
deadline := time.Now().Add(time.Minute)
|
||||
for {
|
||||
err := f()
|
||||
e := asRFError(err)
|
||||
if e == nil {
|
||||
return err
|
||||
}
|
||||
d, ok := e.busy()
|
||||
if !ok || time.Now().Add(d).After(deadline) {
|
||||
return err
|
||||
}
|
||||
if serr := rfSleep(ctx, d); serr != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// follow waits for a 202's task (or Dell job) to finish.
|
||||
func (r *rfClient) follow(ctx context.Context, resp *rfResp) error {
|
||||
if resp == nil || resp.code != http.StatusAccepted {
|
||||
return nil
|
||||
}
|
||||
loc := resp.hdr.Get("Location")
|
||||
if loc == "" {
|
||||
var t struct {
|
||||
ID string `json:"@odata.id"`
|
||||
}
|
||||
_ = json.Unmarshal(resp.body, &t)
|
||||
loc = t.ID
|
||||
}
|
||||
if loc == "" {
|
||||
return nil
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
res, err := r.call(ctx, "GET", loc, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var t obj
|
||||
_ = json.Unmarshal(res.body, &t)
|
||||
st := str(t["TaskState"]) + str(t["JobState"])
|
||||
switch st {
|
||||
case "Completed":
|
||||
return nil
|
||||
case "Exception", "Killed", "Cancelled", "Failed", "CompletedWithErrors":
|
||||
msg := ""
|
||||
if ms := toList(t["Messages"]); len(ms) > 0 {
|
||||
msg = str(asObj(ms[0])["Message"])
|
||||
}
|
||||
return fmt.Errorf("task %s: %s %s", loc, st, msg)
|
||||
}
|
||||
if res.code == http.StatusOK && st == "" { // the monitor answered with the result itself
|
||||
return nil
|
||||
}
|
||||
if err := rfSleep(ctx, 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("task %s still running after 2 minutes", loc)
|
||||
}
|
||||
|
||||
// system finds the ComputerSystem: extra system=..., else the vendor's usual
|
||||
// one if it exists, else the first member of the collection.
|
||||
func (r *rfClient) system(ctx context.Context, c *bmcCreds, prof redfishProfile) (string, obj, error) {
|
||||
cands := append([]string(nil), prof.Systems...)
|
||||
if s := c.Extra["system"]; s != "" {
|
||||
cands = []string{s}
|
||||
}
|
||||
for _, p := range cands {
|
||||
var sys obj
|
||||
if _, err := r.do(ctx, "GET", p, nil, &sys); err == nil {
|
||||
return p, sys, nil
|
||||
} else if c.Extra["system"] != "" {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
var coll struct {
|
||||
Members []struct {
|
||||
ID string `json:"@odata.id"`
|
||||
} `json:"Members"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/Systems", nil, &coll); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(coll.Members) == 0 {
|
||||
return "", nil, errors.New("the BMC lists no systems")
|
||||
}
|
||||
p := strings.TrimRight(coll.Members[0].ID, "/") // iLO lists members with a trailing slash
|
||||
var sys obj
|
||||
_, err := r.do(ctx, "GET", p, nil, &sys)
|
||||
return p, sys, err
|
||||
}
|
||||
|
||||
// resetTypes are the ResetType values the system accepts.
|
||||
func (r *rfClient) resetTypes(ctx context.Context, sys obj) []string {
|
||||
act := asObj(dig(sys, "Actions", "#ComputerSystem.Reset"))
|
||||
var out []string
|
||||
for _, v := range toList(act["ResetType@Redfish.AllowableValues"]) {
|
||||
out = append(out, str(v))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if info := str(act["@Redfish.ActionInfo"]); info != "" {
|
||||
var ai struct {
|
||||
Parameters []struct {
|
||||
Name string `json:"Name"`
|
||||
AllowableValues []string `json:"AllowableValues"`
|
||||
} `json:"Parameters"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", info, nil, &ai); err == nil {
|
||||
for _, p := range ai.Parameters {
|
||||
if p.Name == "ResetType" {
|
||||
out = p.AllowableValues
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickReset(allowed []string, prefs ...string) string {
|
||||
if len(allowed) == 0 {
|
||||
return prefs[0]
|
||||
}
|
||||
for _, p := range prefs {
|
||||
if hasTag(allowed, p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strList(v any) []string {
|
||||
var out []string
|
||||
for _, x := range toList(v) {
|
||||
out = append(out, str(x))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var redfishBootTargets = map[string]string{"pxe": "Pxe", "disk": "Hdd", "bios": "BiosSetup", "cdrom": "Cd", "none": "", "safe": ""}
|
||||
|
||||
// bootOverride sets the next boot (target "" clears the override).
|
||||
func (r *rfClient) bootOverride(ctx context.Context, c *bmcCreds, prof redfishProfile, path string, sys obj, target string) error {
|
||||
boot := asObj(sys["Boot"])
|
||||
if allowed := strList(boot["BootSourceOverrideTarget@Redfish.AllowableValues"]); target != "" && len(allowed) > 0 && !hasTag(allowed, target) {
|
||||
return fmt.Errorf("the BMC offers boot targets %s, not %s", strings.Join(allowed, ", "), target)
|
||||
}
|
||||
if target == "" {
|
||||
clears := prof.Clear
|
||||
if len(clears) == 0 {
|
||||
clears = []obj{{"BootSourceOverrideEnabled": "Disabled"}}
|
||||
}
|
||||
var err error
|
||||
for _, b := range clears {
|
||||
if err = retry(ctx, func() error { _, err := r.patch(ctx, path, obj{"Boot": b}); return err }); err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, gerr := r.call(ctx, "GET", path, nil, ""); gerr != nil { // fresh ETag for the next try
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
body := obj{"BootSourceOverrideTarget": target, "BootSourceOverrideEnabled": "Once"}
|
||||
mode, asked := str(boot["BootSourceOverrideMode"]), c.Extra["boot_mode"] // UEFI | Legacy
|
||||
if asked != "" {
|
||||
mode = asked
|
||||
}
|
||||
withMode := mode != "" && (prof.BootMode != "omit" || asked != "")
|
||||
full := obj{}
|
||||
for k, v := range body {
|
||||
full[k] = v
|
||||
}
|
||||
if withMode {
|
||||
full["BootSourceOverrideMode"] = mode
|
||||
}
|
||||
send := func(p string, b obj) error {
|
||||
return retry(ctx, func() error {
|
||||
resp, err := r.patch(ctx, p, obj{"Boot": b})
|
||||
if err == nil {
|
||||
err = r.follow(ctx, resp)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
err := send(path, full)
|
||||
e := asRFError(err)
|
||||
if e != nil && e.has("OperationSupportedInFutureStateURI") { // AMI: pending settings live in .../SD
|
||||
sd := path + "/SD"
|
||||
if _, gerr := r.call(ctx, "GET", sd, nil, ""); gerr == nil {
|
||||
err = send(sd, full)
|
||||
}
|
||||
e = asRFError(err)
|
||||
}
|
||||
if settings := str(dig(sys, "@Redfish.Settings", "SettingsObject", "@odata.id")); e != nil && settings != "" &&
|
||||
(e.code == http.StatusBadRequest || e.code == http.StatusMethodNotAllowed) { // e.g. iDRAC 10: .../Settings
|
||||
if _, gerr := r.call(ctx, "GET", settings, nil, ""); gerr == nil {
|
||||
err = send(settings, full)
|
||||
}
|
||||
e = asRFError(err)
|
||||
}
|
||||
if e != nil && withMode && prof.BootMode == "try" && asked == "" && e.code == http.StatusBadRequest {
|
||||
err = send(path, body) // some refuse the mode in the body (HPE DL160 Gen10)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func redfishOp(ctx context.Context, c *bmcCreds, op, arg string) (string, error) {
|
||||
prof := redfishProfileFor(c.Driver)
|
||||
r := newRF(c)
|
||||
if err := r.open(ctx, c, prof); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer r.logout()
|
||||
path, sys, err := r.system(ctx, c, prof)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
powerState := func(sys obj) string { return strings.ToLower(str(sys["PowerState"])) } // On | Off | PoweringOn | PoweringOff | Reset (iLO, briefly)
|
||||
on := powerState(sys) == "on" || powerState(sys) == "poweringon"
|
||||
allowed := r.resetTypes(ctx, sys)
|
||||
target := str(dig(sys, "Actions", "#ComputerSystem.Reset", "target"))
|
||||
if target == "" && dig(sys, "Actions", "#ComputerSystem.Reset") == nil {
|
||||
// no reset on the system (seen on KAYTUS): the chassis' one, if it has
|
||||
if ch := toList(dig(sys, "Links", "Chassis")); len(ch) > 0 {
|
||||
var chassis obj
|
||||
if _, err := r.do(ctx, "GET", str(asObj(ch[0])["@odata.id"]), nil, &chassis); err == nil {
|
||||
if t := str(dig(chassis, "Actions", "#Chassis.Reset", "target")); t != "" {
|
||||
target = t
|
||||
allowed = strList(dig(chassis, "Actions", "#Chassis.Reset", "ResetType@Redfish.AllowableValues"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
target = path + "/Actions/ComputerSystem.Reset"
|
||||
}
|
||||
post := func(t string) error {
|
||||
return retry(ctx, func() error {
|
||||
resp, err := r.call(ctx, "POST", target, obj{"ResetType": t}, "")
|
||||
if err == nil {
|
||||
err = r.follow(ctx, resp)
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
reset := func(prefs ...string) (string, error) {
|
||||
t := pickReset(allowed, prefs...)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("the BMC accepts none of %v (it offers %v)", prefs, allowed)
|
||||
}
|
||||
if err := post(t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Chassis Power Control: " + t + " (redfish)", nil
|
||||
}
|
||||
// waitOff: ForceOff is not instant; On before the system is off is refused
|
||||
waitOff := func() error {
|
||||
for i := 0; i < 30; i++ {
|
||||
var s obj
|
||||
if _, err := r.do(ctx, "GET", path, nil, &s); err == nil && powerState(s) == "off" {
|
||||
return nil
|
||||
}
|
||||
if err := rfSleep(ctx, 2*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return errors.New("the system did not power off within a minute")
|
||||
}
|
||||
switch op {
|
||||
case "status":
|
||||
if on {
|
||||
return "Chassis Power is on", nil
|
||||
}
|
||||
return "Chassis Power is off", nil
|
||||
case "on":
|
||||
if on {
|
||||
return "Chassis Power is on already", nil
|
||||
}
|
||||
return reset("On", "ForceOn", "PushPowerButton")
|
||||
case "off":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("ForceOff", "PushPowerButton")
|
||||
case "soft":
|
||||
if !on {
|
||||
return "Chassis Power is off already", nil
|
||||
}
|
||||
return reset("GracefulShutdown", "PushPowerButton")
|
||||
case "cycle", "reset":
|
||||
if !on {
|
||||
return reset("On", "ForceOn", "PushPowerButton")
|
||||
}
|
||||
prefs := []string{"PowerCycle", "ForcePowerCycle", "ForceRestart", "GracefulRestart"} // ForcePowerCycle: iBMC
|
||||
if op == "reset" {
|
||||
prefs = []string{"ForceRestart", "PowerCycle", "ForcePowerCycle", "GracefulRestart"}
|
||||
}
|
||||
if pickReset(allowed, prefs...) == "" && hasTag(allowed, "ForceOff") && hasTag(allowed, "On") {
|
||||
if err := post("ForceOff"); err != nil { // offered no restart at all (some AMI builds)
|
||||
return "", err
|
||||
}
|
||||
if err := waitOff(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := post("On"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Chassis Power Control: ForceOff, On (redfish)", nil
|
||||
}
|
||||
return reset(prefs...)
|
||||
case "bootdev":
|
||||
t := redfishBootTargets[arg]
|
||||
if err := r.bootOverride(ctx, c, prof, path, sys, t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if t == "" {
|
||||
return "Boot override cleared (redfish)", nil
|
||||
}
|
||||
return "Set Boot Device to " + arg + " (redfish " + t + ", once)", nil
|
||||
}
|
||||
return "", fmt.Errorf("redfish: %s is not supported", op)
|
||||
}
|
||||
|
||||
// redfishProbe asks whether the BMC speaks Redfish with these credentials,
|
||||
// which vendor profile fits, and what the system offers. The answer is JSON
|
||||
// and is kept on the BMC record (shown in the UI).
|
||||
func redfishProbe(ctx context.Context, c *bmcCreds) (string, error) {
|
||||
r := newRF(c)
|
||||
var root obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err != nil { // the service root needs no login
|
||||
return "", fmt.Errorf("no Redfish service: %w", err)
|
||||
}
|
||||
var oemKeys []string
|
||||
for k := range asObj(root["Oem"]) {
|
||||
oemKeys = append(oemKeys, k)
|
||||
}
|
||||
vendor := str(root["Vendor"])
|
||||
if vendor == "" && len(oemKeys) > 0 {
|
||||
vendor = oemKeys[0]
|
||||
}
|
||||
res := obj{"redfish": str(root["RedfishVersion"]), "vendor": vendor}
|
||||
// the system is looked up with the profile the root suggests; the
|
||||
// manufacturer then settles it
|
||||
guess := matchRedfishProfile(vendor, "", oemKeys)
|
||||
if err := r.open(ctx, c, guess); err != nil {
|
||||
return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err)
|
||||
}
|
||||
defer r.logout()
|
||||
path, sys, err := r.system(ctx, &bmcCreds{Extra: c.Extra}, guess)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Redfish %s answers, but not to these credentials: %w", res["redfish"], err)
|
||||
}
|
||||
prof := matchRedfishProfile(vendor, str(sys["Manufacturer"]), oemKeys)
|
||||
boot := asObj(sys["Boot"])
|
||||
res["system"], res["manufacturer"], res["model"], res["power"] = path, str(sys["Manufacturer"]), str(sys["Model"]), str(sys["PowerState"])
|
||||
res["driver"], res["profile"] = prof.Driver, prof.Title
|
||||
if prof.Auth == "session" {
|
||||
res["auth"] = "session"
|
||||
}
|
||||
res["reset_types"] = r.resetTypes(ctx, sys)
|
||||
res["boot_targets"] = strList(boot["BootSourceOverrideTarget@Redfish.AllowableValues"])
|
||||
res["boot_mode"] = str(boot["BootSourceOverrideMode"])
|
||||
b, _ := json.Marshal(res)
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package main
|
||||
|
||||
// Firmware over Redfish (UpdateService), run where the BMC is reachable (the
|
||||
// rack controller). Three operations, each short enough for one rack command:
|
||||
//
|
||||
// fw-inventory what is installed (FirmwareInventory)
|
||||
// fw-start hand a package to the BMC; returns the task to watch
|
||||
// fw-poll one look at that task
|
||||
//
|
||||
// The package reaches the BMC one of three ways, chosen from what the
|
||||
// UpdateService offers and the vendor profile's order:
|
||||
//
|
||||
// multipart POST MultipartHttpPushUri: UpdateParameters (JSON) + UpdateFile
|
||||
// simple UpdateService.SimpleUpdate with an ImageURI the BMC downloads
|
||||
// from the rack controller (or firmware_base)
|
||||
// push POST HttpPushUri with the file as the body
|
||||
//
|
||||
// The rack controller fetches the package from its own cache (filled from the
|
||||
// region) and sends it with a known length: many BMC web servers refuse
|
||||
// chunked uploads.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fwComponent is one entry of a machine's firmware inventory.
|
||||
type fwComponent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Component string `json:"component"` // bios | bmc | nic | raid | cpld | psu | drive | other
|
||||
Updateable bool `json:"updateable"`
|
||||
SoftwareID string `json:"software_id,omitempty"`
|
||||
}
|
||||
|
||||
// fwStartArg is what fw-start receives.
|
||||
type fwStartArg struct {
|
||||
URL string `json:"url"` // where the package is (the BMC pulls it, or the rack downloads it to push)
|
||||
Component string `json:"component"` // bios | bmc | ... (vendor plans pick targets by it)
|
||||
File string `json:"file"` // its file name
|
||||
Method string `json:"method,omitempty"` // multipart | simple | push; "" = profile order
|
||||
Apply string `json:"apply,omitempty"` // Immediate | OnReset; "" = the BMC's default
|
||||
Targets []string `json:"targets,omitempty"` // inventory resources to update; usually empty
|
||||
}
|
||||
|
||||
type fwStartResult struct {
|
||||
Method string `json:"method"`
|
||||
Task string `json:"task,omitempty"` // task monitor or task resource; "" when the BMC answered synchronously
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// fwTimeouts bound each firmware operation; an upload to a slow BMC takes long.
|
||||
var fwTimeouts = map[string]time.Duration{"fw-inventory": 5 * time.Minute, "fw-start": 45 * time.Minute, "fw-poll": 2 * time.Minute, "fw-finish": time.Minute}
|
||||
|
||||
type fwPollResult struct {
|
||||
State string `json:"state"` // running | done | failed | unknown
|
||||
Pct int `json:"pct"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
// fwKinds sorts inventory entries by id and name, most specific first: a RAID
|
||||
// "controller" is not the management controller.
|
||||
var fwKinds = []struct {
|
||||
kind string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{"bios", regexp.MustCompile(`(?i)\b(bios|uefi|system rom)\b`)},
|
||||
{"raid", regexp.MustCompile(`(?i)\b(raid|perc|smart ?array|megaraid|hba|storage controller|boss)\b`)},
|
||||
{"nic", regexp.MustCompile(`(?i)\b(nic|ethernet|network|connectx|mellanox|infiniband|e810|x710|bcm\d*|broadcom adv)`)},
|
||||
{"psu", regexp.MustCompile(`(?i)\b(psu|power supply)\b`)},
|
||||
{"cpld", regexp.MustCompile(`(?i)\b(cpld|fpga)\b`)},
|
||||
{"drive", regexp.MustCompile(`(?i)\b(drive|disk|ssd|nvme|hdd)\b`)},
|
||||
{"bmc", regexp.MustCompile(`(?i)\b(bmc|idrac|ilo ?\d?|xcc2?|imm2?|ibmc|irmc|cimc|remote access controller|management controller|baseboard management)\b`)},
|
||||
}
|
||||
|
||||
// fwKind sorts an inventory entry into a component class by its id and name.
|
||||
func fwKind(id, name string) string {
|
||||
s := id + " " + name
|
||||
for _, k := range fwKinds {
|
||||
if k.re.MatchString(s) {
|
||||
return k.kind
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func firmwareRun(ctx context.Context, c *bmcCreds, op, arg string) (string, error) {
|
||||
d, _ := driverByName(c.Driver)
|
||||
if d.Family != "redfish" {
|
||||
return "", fmt.Errorf("firmware updates need a Redfish driver (this BMC uses %s)", orDefault(c.Driver, "ipmi"))
|
||||
}
|
||||
prof := redfishProfileFor(c.Driver)
|
||||
r := newRF(c)
|
||||
r.hc.Timeout = 0 // uploads take minutes; the context bounds each call
|
||||
if err := r.open(ctx, c, prof); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer r.logout()
|
||||
var out any
|
||||
var err error
|
||||
switch op {
|
||||
case "fw-inventory":
|
||||
out, err = r.fwInventory(ctx)
|
||||
case "fw-start":
|
||||
var a fwStartArg
|
||||
if err := json.Unmarshal([]byte(arg), &a); err != nil {
|
||||
return "", fmt.Errorf("fw-start: %w", err)
|
||||
}
|
||||
out, err = r.fwStart(ctx, c, prof, a)
|
||||
case "fw-poll":
|
||||
out, err = r.fwPoll(ctx, arg)
|
||||
case "fw-finish": // after the task ended: give back what fw-start claimed
|
||||
if prof.FWClaims {
|
||||
r.fwRelease(ctx)
|
||||
}
|
||||
out = obj{"ok": true}
|
||||
default:
|
||||
return "", fmt.Errorf("unknown firmware operation %q", op)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (r *rfClient) fwInventory(ctx context.Context) ([]fwComponent, error) {
|
||||
var coll struct {
|
||||
Members []struct {
|
||||
ID string `json:"@odata.id"`
|
||||
} `json:"Members"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/UpdateService/FirmwareInventory", nil, &coll); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []fwComponent{}
|
||||
for i, mem := range coll.Members {
|
||||
if i >= 300 {
|
||||
break
|
||||
}
|
||||
id := path.Base(strings.TrimRight(mem.ID, "/"))
|
||||
// Dell lists what is installed and what was before or is staged
|
||||
if strings.HasPrefix(id, "Previous-") || strings.HasPrefix(id, "Available-") {
|
||||
continue
|
||||
}
|
||||
var e obj
|
||||
if _, err := r.do(ctx, "GET", mem.ID, nil, &e); err != nil {
|
||||
continue
|
||||
}
|
||||
up, _ := e["Updateable"].(bool)
|
||||
fc := fwComponent{ID: str(e["Id"]), Name: str(e["Name"]), Version: str(e["Version"]), Updateable: up, SoftwareID: str(e["SoftwareId"])}
|
||||
if fc.ID == "" {
|
||||
fc.ID = id
|
||||
}
|
||||
fc.Component = fwKind(fc.ID, fc.Name)
|
||||
out = append(out, fc)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Component != out[j].Component {
|
||||
return out[i].Component < out[j].Component
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fwCaps is what an UpdateService offers.
|
||||
type fwCaps struct {
|
||||
simple, multipart, push string
|
||||
protocols []string
|
||||
}
|
||||
|
||||
func (r *rfClient) fwCaps(ctx context.Context) (fwCaps, error) {
|
||||
var us obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/UpdateService", nil, &us); err != nil {
|
||||
return fwCaps{}, err
|
||||
}
|
||||
act := asObj(dig(us, "Actions", "#UpdateService.SimpleUpdate"))
|
||||
if r.pushURIs == nil {
|
||||
r.pushURIs = map[string]bool{}
|
||||
}
|
||||
for _, k := range []string{"MultipartHttpPushUri", "HttpPushUri"} {
|
||||
if u := str(us[k]); u != "" && !strings.Contains(u, "://") {
|
||||
r.pushURIs[u] = true
|
||||
}
|
||||
}
|
||||
return fwCaps{
|
||||
simple: str(act["target"]),
|
||||
protocols: strList(act["TransferProtocol@Redfish.AllowableValues"]),
|
||||
multipart: str(us["MultipartHttpPushUri"]),
|
||||
push: str(us["HttpPushUri"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fwRequest is one way to hand a package over, as a vendor documents it.
|
||||
type fwRequest struct {
|
||||
Method string // multipart | simple | push: how this request carries the package
|
||||
URI string // where it goes
|
||||
AltURI string // tried when URI answers 404 (paths that moved between firmware generations)
|
||||
Params obj // multipart UpdateParameters, or the JSON body of a simple request
|
||||
NoParams bool // multipart without an UpdateParameters part
|
||||
Parts []fwPart // more parts before the file (AMI OemParameters)
|
||||
Field string // the file's form field (default UpdateFile)
|
||||
Form map[string]string // text fields after the file
|
||||
FailText string // a 2xx answer that contains this is a refusal (KAYTUS "Verification Failed")
|
||||
Claim bool // take HttpPushUriTargetsBusy first (Lenovo); fw-finish gives it back
|
||||
Then *fwRequest // a second call naming the uploaded file (xFusion, KAYTUS)
|
||||
Tasks []string // fixed tasks to watch when the answers name none (KAYTUS)
|
||||
}
|
||||
|
||||
type fwPart struct {
|
||||
Name string
|
||||
Body obj
|
||||
}
|
||||
|
||||
// fwPlanCtx is what a vendor plan decides from.
|
||||
type fwPlanCtx struct {
|
||||
Component, File, URL, Apply, Password string
|
||||
Vendor string // ServiceRoot Vendor
|
||||
Caps fwCaps // what the UpdateService offers
|
||||
Inventory []string // FirmwareInventory member ids
|
||||
}
|
||||
|
||||
func (x fwPlanCtx) has(id string) bool { return hasTag(x.Inventory, id) }
|
||||
|
||||
func (x fwPlanCtx) first(ids ...string) string {
|
||||
for _, id := range ids {
|
||||
if x.has(id) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x fwPlanCtx) applyOr(def string) string { return orDefault(x.Apply, def) }
|
||||
|
||||
func (r *rfClient) fwStart(ctx context.Context, c *bmcCreds, prof redfishProfile, a fwStartArg) (*fwStartResult, error) {
|
||||
caps, err := r.fwCaps(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no UpdateService: %w", err)
|
||||
}
|
||||
forced := orDefault(a.Method, c.Extra["fw_method"])
|
||||
if prof.FWUnsupported != "" && forced == "" {
|
||||
return nil, errors.New(prof.FWUnsupported + " (to try a standard method anyway: -method multipart|simple|push)")
|
||||
}
|
||||
if prof.FWPlan != nil && forced == "" {
|
||||
x := fwPlanCtx{Component: a.Component, File: a.File, URL: a.URL, Apply: a.Apply, Password: r.pass, Caps: caps}
|
||||
var root obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/", nil, &root); err == nil {
|
||||
x.Vendor = str(root["Vendor"])
|
||||
}
|
||||
var coll struct {
|
||||
Members []struct {
|
||||
ID string `json:"@odata.id"`
|
||||
} `json:"Members"`
|
||||
}
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/UpdateService/FirmwareInventory", nil, &coll); err == nil {
|
||||
for _, m := range coll.Members {
|
||||
x.Inventory = append(x.Inventory, path.Base(strings.TrimRight(m.ID, "/")))
|
||||
}
|
||||
}
|
||||
req, err := prof.FWPlan(x)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw := c.Extra["fw_params"]; raw != "" { // the operator's own UpdateParameters / body
|
||||
var o obj
|
||||
if err := json.Unmarshal([]byte(raw), &o); err != nil {
|
||||
return nil, fmt.Errorf("fw_params is not JSON: %w", err)
|
||||
}
|
||||
req.Params = o
|
||||
}
|
||||
return r.fwExec(ctx, req, a)
|
||||
}
|
||||
order := prof.FWMethods
|
||||
if len(order) == 0 {
|
||||
order = []string{"multipart", "simple", "push"}
|
||||
}
|
||||
if forced != "" {
|
||||
order = []string{forced}
|
||||
}
|
||||
var tried []string
|
||||
for _, m := range order {
|
||||
req := fwStandard(caps, m, a)
|
||||
if req == nil {
|
||||
continue
|
||||
}
|
||||
res, err := r.fwExec(ctx, req, a)
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
// a BMC that does not like this way gets the next one; a refused
|
||||
// package (wrong model, signature, downgrade) stops here
|
||||
if e := asRFError(err); e == nil || (e.code != http.StatusMethodNotAllowed && e.code != http.StatusNotImplemented && e.code != http.StatusUnsupportedMediaType) {
|
||||
return nil, fmt.Errorf("%s: %w", m, err)
|
||||
}
|
||||
tried = append(tried, m+": "+err.Error())
|
||||
}
|
||||
if len(tried) > 0 {
|
||||
return nil, fmt.Errorf("the BMC took no upload method: %s", strings.Join(tried, "; "))
|
||||
}
|
||||
return nil, fmt.Errorf("the BMC's UpdateService offers none of %v", order)
|
||||
}
|
||||
|
||||
// fwStandard is the DMTF request for a method, nil when the service lacks it.
|
||||
func fwStandard(caps fwCaps, method string, a fwStartArg) *fwRequest {
|
||||
switch method {
|
||||
case "multipart":
|
||||
if caps.multipart == "" {
|
||||
return nil
|
||||
}
|
||||
params := obj{"Targets": orEmpty(a.Targets)}
|
||||
if a.Apply != "" {
|
||||
params["@Redfish.OperationApplyTime"] = a.Apply
|
||||
}
|
||||
return &fwRequest{Method: "multipart", URI: caps.multipart, Params: params}
|
||||
case "simple":
|
||||
if caps.simple == "" {
|
||||
return nil
|
||||
}
|
||||
body := obj{"ImageURI": a.URL}
|
||||
if len(caps.protocols) > 0 { // only when the service lists them: others want the scheme in the URI
|
||||
body["TransferProtocol"] = strings.ToUpper(strings.SplitN(a.URL, "://", 2)[0])
|
||||
}
|
||||
if len(a.Targets) > 0 {
|
||||
body["Targets"] = a.Targets
|
||||
}
|
||||
return &fwRequest{Method: "simple", URI: caps.simple, Params: body}
|
||||
case "push":
|
||||
if caps.push == "" {
|
||||
return nil
|
||||
}
|
||||
return &fwRequest{Method: "push", URI: caps.push}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fwExec carries out a request (and its follow-up) and names the task to watch.
|
||||
func (r *rfClient) fwExec(ctx context.Context, req *fwRequest, a fwStartArg) (*fwStartResult, error) {
|
||||
if req.Claim {
|
||||
var us obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/UpdateService", nil, &us); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if us["HttpPushUriTargetsBusy"] == true {
|
||||
return nil, errors.New("the BMC's update service is busy with another upload (HttpPushUriTargetsBusy)")
|
||||
}
|
||||
if _, err := r.patch(ctx, "/redfish/v1/UpdateService", obj{"HttpPushUriTargetsBusy": true}); err != nil {
|
||||
return nil, fmt.Errorf("claim the update service: %w", err)
|
||||
}
|
||||
}
|
||||
resp, err := r.fwSend(ctx, req, a)
|
||||
if err != nil {
|
||||
if req.Claim {
|
||||
r.fwRelease(ctx)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
res := &fwStartResult{Method: req.Method, Task: fwTaskOf(resp)}
|
||||
if req.Then != nil {
|
||||
tresp, err := r.fwSend(ctx, req.Then, a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the BMC has the file, but did not start the update: %w", err)
|
||||
}
|
||||
res.Task = fwTaskOf(tresp)
|
||||
}
|
||||
if res.Task == "" && len(req.Tasks) > 0 {
|
||||
res.Task = strings.Join(req.Tasks, ",")
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *rfClient) fwRelease(ctx context.Context) {
|
||||
_, _ = r.call(ctx, "GET", "/redfish/v1/UpdateService", nil, "")
|
||||
_, _ = r.patch(ctx, "/redfish/v1/UpdateService", obj{"HttpPushUriTargetsBusy": false})
|
||||
}
|
||||
|
||||
func (r *rfClient) fwSend(ctx context.Context, req *fwRequest, a fwStartArg) (*rfResp, error) {
|
||||
var resp *rfResp
|
||||
var err error
|
||||
send := func(uri string) (*rfResp, error) {
|
||||
switch req.Method {
|
||||
case "simple":
|
||||
if proto := str(req.Params["TransferProtocol"]); proto != "" && len(a.URL) > 0 {
|
||||
// checked here so a service that pulls only over NFS/SCP says so clearly
|
||||
if caps, cerr := r.fwCaps(ctx); cerr == nil && len(caps.protocols) > 0 && !hasTag(caps.protocols, proto) {
|
||||
return nil, &rfError{method: "POST", path: uri, code: http.StatusNotImplemented, msg: fmt.Sprintf("transfer protocols %v, not %s", caps.protocols, proto)}
|
||||
}
|
||||
}
|
||||
return r.call(ctx, "POST", uri, req.Params, "")
|
||||
case "multipart", "push":
|
||||
f, size, ferr := fwFetch(ctx, a.URL)
|
||||
if ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
if req.Method == "push" {
|
||||
return r.upload(ctx, uri, "application/octet-stream", f, size, r.etags[uri])
|
||||
}
|
||||
return r.multipartUpload(ctx, uri, req, a.File, f, size)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown method %q", req.Method)
|
||||
}
|
||||
resp, err = send(req.URI)
|
||||
if e := asRFError(err); e != nil && e.code == http.StatusNotFound && req.AltURI != "" {
|
||||
resp, err = send(req.AltURI)
|
||||
}
|
||||
if err == nil && req.FailText != "" && strings.Contains(string(resp.body), req.FailText) {
|
||||
return nil, fmt.Errorf("the BMC refused the package: %s", req.FailText)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func orEmpty(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fwTaskOf finds the task in an answer: Location, a Task body, or a Dell job id.
|
||||
func fwTaskOf(resp *rfResp) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
}
|
||||
if l := resp.hdr.Get("Location"); l != "" {
|
||||
return l
|
||||
}
|
||||
var t struct {
|
||||
ID string `json:"@odata.id"`
|
||||
Type string `json:"@odata.type"`
|
||||
}
|
||||
if json.Unmarshal(resp.body, &t) == nil && strings.Contains(t.Type, "Task") {
|
||||
return t.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fwFetch downloads the package to a temporary file (the rack's cache serves it).
|
||||
func fwFetch(ctx context.Context, url string) (*os.File, int64, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("package: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, 0, fmt.Errorf("package %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.CreateTemp("", "rollcall-fw-*")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if err == nil {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, n, nil
|
||||
}
|
||||
|
||||
// upload POSTs a body of known length (no chunked encoding).
|
||||
func (r *rfClient) upload(ctx context.Context, target, ctype string, body io.Reader, size int64, ifMatch string) (*rfResp, error) {
|
||||
p, err := r.resolve(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", r.base+p, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.ContentLength = size
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if ifMatch != "" {
|
||||
req.Header.Set("If-Match", ifMatch)
|
||||
}
|
||||
if r.token != "" {
|
||||
req.Header.Set("X-Auth-Token", r.token)
|
||||
} else if r.user != "" {
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
}
|
||||
resp, err := r.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
out := &rfResp{code: resp.StatusCode, hdr: resp.Header, body: data}
|
||||
if resp.StatusCode >= 300 {
|
||||
ids, msg := redfishMessage(data)
|
||||
return out, &rfError{method: "POST", path: p, code: resp.StatusCode, ids: ids, msg: resp.Status + " " + msg}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// multipartUpload sends UpdateParameters, any OEM parts, the file and text
|
||||
// fields (DSP0266 "multipart HTTP push", plus what vendors add to it).
|
||||
func (r *rfClient) multipartUpload(ctx context.Context, target string, req *fwRequest, name string, f io.Reader, size int64) (*rfResp, error) {
|
||||
var head, after bytes.Buffer
|
||||
mw := multipart.NewWriter(&head)
|
||||
jsonPart := func(n string, v any) {
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q`, n))
|
||||
h.Set("Content-Type", "application/json")
|
||||
w, _ := mw.CreatePart(h)
|
||||
b, _ := json.Marshal(v)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
if !req.NoParams {
|
||||
p := req.Params
|
||||
if p == nil {
|
||||
p = obj{"Targets": []string{}}
|
||||
}
|
||||
jsonPart("UpdateParameters", p)
|
||||
}
|
||||
for _, p := range req.Parts {
|
||||
jsonPart(p.Name, p.Body)
|
||||
}
|
||||
fh := textproto.MIMEHeader{}
|
||||
fh.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, orDefault(req.Field, "UpdateFile"), path.Base(name)))
|
||||
fh.Set("Content-Type", "application/octet-stream")
|
||||
_, _ = mw.CreatePart(fh)
|
||||
// text fields and the closing boundary come after the file
|
||||
keys := make([]string, 0, len(req.Form))
|
||||
for k := range req.Form {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&after, "\r\n--%s\r\nContent-Disposition: form-data; name=%q\r\n\r\n%s", mw.Boundary(), k, req.Form[k])
|
||||
}
|
||||
fmt.Fprintf(&after, "\r\n--%s--\r\n", mw.Boundary())
|
||||
body := io.MultiReader(&head, f, &after)
|
||||
return r.upload(ctx, target, mw.FormDataContentType(), body, int64(head.Len())+size+int64(after.Len()), r.etags[target])
|
||||
}
|
||||
|
||||
// fwPoll looks at the task(s) of an update; several (comma-separated) are
|
||||
// watched in order and count as done when all are.
|
||||
func (r *rfClient) fwPoll(ctx context.Context, tasks string) (*fwPollResult, error) {
|
||||
if tasks == "" {
|
||||
return &fwPollResult{State: "unknown"}, nil
|
||||
}
|
||||
var last *fwPollResult
|
||||
seen := false
|
||||
for _, t := range strings.Split(tasks, ",") {
|
||||
res, err := r.fwPollOne(ctx, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.State == "unknown" { // not (or no longer) listed: the others decide
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if res.State != "done" {
|
||||
return res, nil
|
||||
}
|
||||
last = res
|
||||
}
|
||||
if !seen {
|
||||
return &fwPollResult{State: "unknown", Msg: "the BMC no longer lists the task"}, nil
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
var fwScheduledRe = regexp.MustCompile(`(?i)\bscheduled\b|completed successfully`)
|
||||
|
||||
func (r *rfClient) fwPollOne(ctx context.Context, task string) (*fwPollResult, error) {
|
||||
resp, err := r.call(ctx, "GET", task, nil, "")
|
||||
if e := asRFError(err); e != nil && e.code == http.StatusNotFound {
|
||||
// iDRAC: while it updates itself the task moves to the job service
|
||||
if i := strings.Index(task, "/TaskService/Tasks/"); i >= 0 {
|
||||
resp, err = r.call(ctx, "GET", "/redfish/v1/JobService/Jobs/"+path.Base(task), nil, "")
|
||||
}
|
||||
if e := asRFError(err); e != nil && e.code == http.StatusNotFound {
|
||||
return &fwPollResult{State: "unknown", Msg: "the BMC no longer lists the task"}, nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t obj
|
||||
_ = json.Unmarshal(resp.body, &t)
|
||||
st := str(t["TaskState"]) + str(t["JobState"])
|
||||
res := &fwPollResult{State: "running", Pct: -1}
|
||||
if p, ok := t["PercentComplete"].(float64); ok {
|
||||
res.Pct = int(p)
|
||||
}
|
||||
for _, v := range asObj(t["Oem"]) { // iBMC TaskPercentage "40%", iRMC TotalProgressPercent
|
||||
o := asObj(v)
|
||||
if p := strings.TrimSuffix(str(o["TaskPercentage"]), "%"); p != "" {
|
||||
res.Pct, _ = strconv.Atoi(p)
|
||||
}
|
||||
if p, ok := o["TotalProgressPercent"].(float64); ok {
|
||||
res.Pct = int(p)
|
||||
}
|
||||
}
|
||||
if ms := toList(t["Messages"]); len(ms) > 0 {
|
||||
res.Msg = str(asObj(ms[len(ms)-1])["Message"])
|
||||
}
|
||||
if res.Msg == "" {
|
||||
res.Msg = str(t["Message"]) // Dell jobs
|
||||
}
|
||||
if res.Pct < 0 { // no percentage on the task: some keep it on the update service
|
||||
res.Pct = 0
|
||||
var us obj
|
||||
if _, err := r.do(ctx, "GET", "/redfish/v1/UpdateService", nil, &us); err == nil {
|
||||
if h := asObj(dig(us, "Oem", "Hpe")); len(h) > 0 { // iLO: State, FlashProgressPercent, Result
|
||||
if p, ok := h["FlashProgressPercent"].(float64); ok {
|
||||
res.Pct = int(p)
|
||||
}
|
||||
if str(h["State"]) == "Error" {
|
||||
res.State, res.Msg = "failed", "iLO update service: "+oneLine(fmt.Sprint(h["Result"]))
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
if a := asObj(dig(us, "Oem", "AMIUpdateService")); len(a) > 0 { // "3% done."
|
||||
if m := regexp.MustCompile(`(\d+)%`).FindStringSubmatch(str(a["FlashPercentage"])); m != nil {
|
||||
res.Pct, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
if res.Msg == "" {
|
||||
res.Msg = str(a["UpdateStatus"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch st {
|
||||
case "Completed":
|
||||
res.State, res.Pct = "done", 100
|
||||
case "Exception", "Killed", "Cancelled", "Failed", "CompletedWithErrors", "Interrupted":
|
||||
res.State = "failed"
|
||||
case "Scheduled", "UserIntervention": // Dell: staged until the host reboots
|
||||
res.State = "done"
|
||||
case "":
|
||||
if resp.code == http.StatusOK && t["TaskState"] == nil && t["JobState"] == nil {
|
||||
res.State = "done" // a task monitor answers with the result once finished
|
||||
}
|
||||
}
|
||||
if res.State == "running" && res.Pct >= 100 && fwScheduledRe.MatchString(res.Msg) {
|
||||
res.State = "done" // staged for the next reboot
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
// Firmware update plans per vendor: the request each one documents for a
|
||||
// component. Sources are on each entry; "oss" ones come from the vendor's own
|
||||
// tools (Ansible collections, PowerShell modules, SDKs) where its API guide is
|
||||
// not public. An operator can replace a plan's UpdateParameters with
|
||||
// fw_params={...} on the BMC record, or skip the plan with fw_method=.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type fwVendor struct {
|
||||
plan func(fwPlanCtx) (*fwRequest, error)
|
||||
methods []string
|
||||
claims bool
|
||||
unsupported string
|
||||
formats []string
|
||||
notes string
|
||||
sources []string
|
||||
}
|
||||
|
||||
var fwVendors = map[string]fwVendor{
|
||||
"idrac": {
|
||||
// MultipartUpload; older iDRAC 9 without it pulls with SimpleUpdate.
|
||||
// OnReset by default: BIOS-type jobs wait for a reboot anyway, and an
|
||||
// iDRAC or CPLD update must not surprise anyone.
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
apply := x.applyOr("OnReset")
|
||||
if x.Caps.multipart != "" {
|
||||
return &fwRequest{Method: "multipart", URI: x.Caps.multipart,
|
||||
Params: obj{"Targets": []string{}, "@Redfish.OperationApplyTime": apply, "Oem": obj{}}}, nil
|
||||
}
|
||||
if x.Caps.simple != "" {
|
||||
return &fwRequest{Method: "simple", URI: x.Caps.simple, Params: simpleBody(x, obj{"@Redfish.OperationApplyTime": apply})}, nil
|
||||
}
|
||||
return nil, errors.New("this iDRAC offers neither MultipartHttpPushUri nor SimpleUpdate")
|
||||
},
|
||||
formats: []string{".exe", ".d9", ".d7", ".pm"},
|
||||
notes: "Dell Update Packages (Windows .EXE). BIOS, RAID, NIC jobs apply at the next reboot (-reboot); iDRAC, Diags apply by themselves; a CPLD update does a virtual A/C cycle.",
|
||||
sources: []string{
|
||||
"https://github.com/dell/iDRAC-Redfish-Scripting/blob/master/Redfish%20Python/DeviceFirmwareMultipartUploadREDFISH.py",
|
||||
"https://github.com/dell/iDRAC-Redfish-Scripting/blob/master/Redfish%20Python/DeviceFirmwareSimpleUpdateTransferProtocolREDFISH.py",
|
||||
},
|
||||
},
|
||||
"ilo": {
|
||||
// SimpleUpdate over HTTP(S) flashes iLO .bin and UEFI .signed.flash;
|
||||
// a Smart Component (.fwpkg) answers 200 and flashes nothing, so
|
||||
// import extracts the flashable file from it.
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
if x.Caps.simple == "" {
|
||||
return nil, errors.New("this iLO offers no SimpleUpdate")
|
||||
}
|
||||
return &fwRequest{Method: "simple", URI: x.Caps.simple, Params: obj{"ImageURI": x.URL}}, nil
|
||||
},
|
||||
formats: []string{".bin", ".signed.flash", ".flash", ".fwpkg"},
|
||||
notes: "iLO firmware flashes at once (iLO restarts); UEFI/BIOS is staged and flashed at the next reboot (-reboot). Components only a runtime agent can flash (some NICs) need SUM/iSUT in the OS. Downgrades follow the iLO downgrade policy.",
|
||||
sources: []string{
|
||||
"https://servermanagementportal.ext.hpe.com/docs/references_and_material/blogposts/firmware_updates/part3/firmware_update_part3",
|
||||
"https://servermanagementportal.ext.hpe.com/docs/redfishservices/ilos/supplementdocuments/updateservice/",
|
||||
},
|
||||
},
|
||||
"xcc": {
|
||||
// /mfwupdate, after taking the push service (HttpPushUriTargetsBusy).
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
params := obj{"Targets": []string{}}
|
||||
if x.Apply != "" {
|
||||
params["@Redfish.OperationApplyTime"] = x.Apply
|
||||
}
|
||||
if x.Caps.multipart != "" {
|
||||
return &fwRequest{Method: "multipart", URI: x.Caps.multipart, Params: params, Claim: true}, nil
|
||||
}
|
||||
if x.Caps.push != "" {
|
||||
return &fwRequest{Method: "push", URI: x.Caps.push, Claim: true}, nil
|
||||
}
|
||||
return nil, errors.New("this XCC offers no push update URI")
|
||||
},
|
||||
claims: true,
|
||||
formats: []string{".uxz", ".zip", ".bin"},
|
||||
notes: "Update XCC before UEFI (Lenovo). One push at a time; the busy flag is released after the update.",
|
||||
sources: []string{
|
||||
"https://pubs.lenovo.com/xcc-restapi/simple_update_firmware_post_multi_httppush",
|
||||
"https://pubs.lenovo.com/xcc2-restapi/prop_firmware_update_service_get",
|
||||
"https://pubs.lenovo.com/xcc/updating_firmware_overview",
|
||||
},
|
||||
},
|
||||
"supermicro": {
|
||||
// /UpdateService/upload with the documented targets and preserve options.
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
uri := orDefault(x.Caps.multipart, "/redfish/v1/UpdateService/upload")
|
||||
apply := x.applyOr("Immediate")
|
||||
var params obj
|
||||
switch x.Component {
|
||||
case "bios":
|
||||
params = obj{"Targets": []string{"/redfish/v1/Systems/1/Bios"}, "@Redfish.OperationApplyTime": apply,
|
||||
"Oem": obj{"Supermicro": obj{"BIOS": obj{"PreserveME": true, "PreserveNVRAM": true, "PreserveSMBIOS": true, "BackupBIOS": false}}}}
|
||||
case "bmc":
|
||||
params = obj{"Targets": []string{"/redfish/v1/Managers/1"}, "@Redfish.OperationApplyTime": apply,
|
||||
"Oem": obj{"Supermicro": obj{"BMC": obj{"PreserveCfg": true, "PreserveSdr": true, "PreserveSsl": true, "BackupBMC": true}}}}
|
||||
case "cpld":
|
||||
id := ""
|
||||
for _, i := range x.Inventory {
|
||||
if strings.HasPrefix(i, "Motherboard_CPLD_") && id == "" {
|
||||
id = i
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return nil, errors.New("the BMC lists no Motherboard_CPLD_* firmware")
|
||||
}
|
||||
params = obj{"Targets": []string{"/redfish/v1/UpdateService/FirmwareInventory/" + id}, "@Redfish.OperationApplyTime": apply}
|
||||
default: // NIC, storage AOC, PSU: the BMC picks the device (SFT-DCMS-SINGLE licence)
|
||||
params = obj{"Targets": []string{}, "@Redfish.OperationApplyTime": apply}
|
||||
}
|
||||
return &fwRequest{Method: "multipart", URI: uri, Params: params}, nil
|
||||
},
|
||||
notes: "A BIOS update powers the host off first. Gen 12/13 boards take other BIOS preserve options (PreserveOA, PreserveSETUPCONF, ...): set fw_params on the BMC if the BMC refuses these. Redfish updates need the SFT-OOB-LIC or SFT-DCMS-SINGLE licence; NIC/storage/PSU need DCMS. A CPLD update resets the BMC.",
|
||||
sources: []string{
|
||||
"https://www.supermicro.com/manuals/other/RedfishUserGuide.pdf",
|
||||
"https://github.com/bmc-toolbox/bmclib/blob/main/providers/supermicro/x12.go",
|
||||
},
|
||||
},
|
||||
"gigabyte": {plan: amiPlan, formats: []string{".hpm", ".rcu", ".ima_enc", ".ima", ".rbu", ".bin"}, notes: amiNotes, sources: []string{"https://download.gigabyte.com/FileList/Manual/server_manual_redfish_v1.11.0.pdf", "GIGABYTE Firmware Upgrade Guide v0.04 (2022)"}},
|
||||
"ami": {plan: amiPlan, notes: amiNotes, sources: []string{"https://download2.mitaccomputing.com/pub/doc/MiTAC_AST2600_Redfish_API_Doc_v.1_051024.pdf", "https://support.blackcoretech.com/support/solutions/articles/70000680445-update-bios-bmc-via-redfish-multipart-push"}},
|
||||
"asrockrack": {plan: amiPlan, notes: amiNotes + " ASRock Rack's own Redfish firmware notes could not be read: this is the AMI MegaRAC request, check it on one machine first.", sources: []string{"https://download.asrock.com/TSD/SMU/Manual/ASRock%20Rack%20Server%20Management_v2.8.0.pdf"}},
|
||||
"openbmc": {
|
||||
// update-multipart; the BMC image applies at once (it reboots), the rest OnReset.
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
params := obj{"Targets": []string{}}
|
||||
if x.Component == "bmc" {
|
||||
params = obj{"Targets": []string{"/redfish/v1/Managers/bmc"}, "@Redfish.OperationApplyTime": x.applyOr("Immediate")}
|
||||
} else if x.Apply != "" {
|
||||
params["@Redfish.OperationApplyTime"] = x.Apply
|
||||
}
|
||||
if x.Caps.multipart != "" {
|
||||
return &fwRequest{Method: "multipart", URI: x.Caps.multipart, Params: params}, nil
|
||||
}
|
||||
if x.Caps.push != "" && x.Component == "bmc" {
|
||||
return &fwRequest{Method: "push", URI: x.Caps.push}, nil
|
||||
}
|
||||
return nil, errors.New("this OpenBMC offers no multipart update (SimpleUpdate is off in bmcweb by default)")
|
||||
},
|
||||
formats: []string{".tar"},
|
||||
notes: "Signed image tarballs whose MANIFEST MachineName matches the BMC.",
|
||||
sources: []string{"https://github.com/openbmc/bmcweb/blob/master/redfish-core/lib/update_service.hpp", "https://github.com/openbmc/docs/blob/master/REDFISH-cheatsheet.md"},
|
||||
},
|
||||
"xfusion": {
|
||||
// upload to the BMC's /tmp/web, then SimpleUpdate naming the file
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
if x.Component != "bmc" && x.Component != "bios" && x.Component != "cpld" {
|
||||
return nil, fmt.Errorf("iBMC updates BMC, BIOS and CPLD out of band; %s firmware goes through Smart Provisioning in-band (not supported)", x.Component)
|
||||
}
|
||||
if x.Caps.simple == "" {
|
||||
return nil, errors.New("this iBMC offers no SimpleUpdate")
|
||||
}
|
||||
return &fwRequest{Method: "multipart", URI: "/redfish/v1/UpdateService/FirmwareInventory", NoParams: true, Field: "file",
|
||||
Then: &fwRequest{Method: "simple", URI: x.Caps.simple, Params: obj{"ImageURI": "/tmp/web/" + path.Base(x.File)}}}, nil
|
||||
},
|
||||
formats: []string{".hpm"},
|
||||
notes: "The BMC restarts after a BMC update.",
|
||||
sources: []string{
|
||||
"https://github.com/Open-xFusion/Server_Plugin_Ansible/blob/HEAD/ibmc_ansible/ibmc_redfish_api/api_manage_file.py",
|
||||
"https://github.com/Open-xFusion/Server_Plugin_Ansible/blob/HEAD/ibmc_ansible/ibmc_redfish_api/api_outband_fw_update.py",
|
||||
},
|
||||
},
|
||||
"kaytus": {
|
||||
// upload (with the BMC password), then an OEM SimpleUpdate; the tasks have fixed names
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
oem := obj{"PreserveConf": true}
|
||||
var tasks []string
|
||||
switch x.Component {
|
||||
case "bmc":
|
||||
oem["FlashItem"], tasks = "OpenBMC_ENC", []string{"/redfish/v1/TaskService/Tasks/BMC"}
|
||||
case "bios":
|
||||
oem["FlashItem"], oem["BiosFlash"] = "BIOS", "Both"
|
||||
tasks = []string{"/redfish/v1/TaskService/Tasks/BIOS0", "/redfish/v1/TaskService/Tasks/BIOS1"}
|
||||
default:
|
||||
return nil, fmt.Errorf("KAYTUS: BMC and BIOS only (not %s)", x.Component)
|
||||
}
|
||||
return &fwRequest{Method: "multipart", URI: "/redfish/v1/UpdateService/FirmwareInventory", NoParams: true, Field: "fwimage",
|
||||
Form: map[string]string{"CurrentPassword": x.Password, "EncryptFlag": "False"}, FailText: "Verification Failed",
|
||||
Then: &fwRequest{Method: "simple", URI: orDefault(x.Caps.simple, "/redfish/v1/UpdateService/Actions/UpdateService.SimpleUpdate"), Params: obj{"Oem": obj{"Public": oem}}},
|
||||
Tasks: tasks}, nil
|
||||
},
|
||||
formats: []string{".hpm", ".bin"},
|
||||
notes: "No public Redfish guide: this is what KAYTUS's own SDK (ksManage) does on X3-generation BMCs; older generations use a non-Redfish API. A BIOS update with the host on waits for a power cycle (-reboot); the BMC restarts after a BMC update.",
|
||||
sources: []string{"https://github.com/KSManageOSS/ksManage/blob/HEAD/ksmanage_sdk/interface/CommonX3.py", "https://github.com/ieisystem/kaytus.ksmanage"},
|
||||
},
|
||||
"irmc": {
|
||||
// OEM actions per component, file in the "data" part
|
||||
plan: func(x fwPlanCtx) (*fwRequest, error) {
|
||||
p := "Fsas" // FTS on S5/S6 (Vendor ts_fujitsu or none), Fsas on newer firmware
|
||||
if x.Vendor == "" || strings.EqualFold(x.Vendor, "ts_fujitsu") {
|
||||
p = "FTS"
|
||||
}
|
||||
switch x.Component {
|
||||
case "bmc":
|
||||
return &fwRequest{Method: "multipart", URI: "/redfish/v1/Managers/iRMC/Actions/" + p + "Manager.FWUpdate",
|
||||
AltURI: "/redfish/v1/Managers/iRMC/Actions/Oem/" + p + "Manager.FWUpdate", NoParams: true, Field: "data"}, nil
|
||||
case "bios":
|
||||
return &fwRequest{Method: "multipart", URI: "/redfish/v1/Systems/0/Bios/Actions/Oem/" + p + "Bios.BiosUpdate", NoParams: true, Field: "data"}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("iRMC: iRMC and BIOS only (not %s)", x.Component)
|
||||
},
|
||||
formats: []string{".bin", ".upc"},
|
||||
notes: "iRMC .bin, BIOS .UPC. With the host on, a BIOS update waits for a reboot (-reboot); the iRMC restarts after its own update.",
|
||||
sources: []string{"https://github.com/fujitsu/ansible-irmc-integration/blob/HEAD/plugins/modules/irmc_fwbios_update.py", "https://github.com/mmurayama/fujitsu-redfish-samples/blob/master/scripts/update_irmc_firmware.py"},
|
||||
},
|
||||
"cimc": {
|
||||
unsupported: "Cisco documents CIMC firmware updates over Redfish only as SimpleUpdate pulls over SCP (and HSU/HUU from NFS, CIFS or HTTPS shares); Rollcall serves packages over HTTP",
|
||||
sources: []string{"https://www.cisco.com/c/en/us/td/docs/unified_computing/ucs/c/sw/api/4_3/b-cisco-ucs-c-series-servers-rest-api-programmer-s-guide-release-4-3.pdf"},
|
||||
},
|
||||
}
|
||||
|
||||
const amiNotes = "MegaRAC: the BMC restarts after a BMC flash and accepts no logins while flashing; do not use its web UI meanwhile."
|
||||
|
||||
// amiPlan: /UpdateService/upload with UpdateParameters (the inventory target)
|
||||
// and OemParameters (ImageType); .hpm packages have their own targets.
|
||||
func amiPlan(x fwPlanCtx) (*fwRequest, error) {
|
||||
hpm := strings.HasSuffix(strings.ToLower(x.File), ".hpm")
|
||||
var target, typ string
|
||||
switch x.Component {
|
||||
case "bmc":
|
||||
if hpm {
|
||||
target, typ = "BMC", "HPM_BMC"
|
||||
} else {
|
||||
target, typ = orDefault(x.first("BMCImage1", "BMC"), "BMC"), "BMC"
|
||||
}
|
||||
case "bios":
|
||||
if hpm {
|
||||
target, typ = "BIOS2", "HPM_BIOS"
|
||||
} else {
|
||||
target, typ = "BIOS", "BIOS"
|
||||
}
|
||||
case "cpld":
|
||||
target, typ = "MB_CPLD1", "MB_CPLD"
|
||||
default:
|
||||
return nil, fmt.Errorf("MegaRAC: BMC, BIOS and CPLD (not %s)", x.Component)
|
||||
}
|
||||
if len(x.Inventory) > 0 && !x.has(target) {
|
||||
return nil, fmt.Errorf("the BMC lists no firmware %s (it has %s)", target, strings.Join(x.Inventory, ", "))
|
||||
}
|
||||
params := obj{"Targets": []string{"/redfish/v1/UpdateService/FirmwareInventory/" + target}}
|
||||
if x.Apply != "" {
|
||||
params["@Redfish.OperationApplyTime"] = x.Apply
|
||||
}
|
||||
return &fwRequest{Method: "multipart", URI: orDefault(x.Caps.multipart, "/redfish/v1/UpdateService/upload"), Params: params,
|
||||
Parts: []fwPart{{Name: "OemParameters", Body: obj{"ImageType": typ}}}}, nil
|
||||
}
|
||||
|
||||
// simpleBody is a SimpleUpdate body for the package URL, plus extra fields.
|
||||
func simpleBody(x fwPlanCtx, extra obj) obj {
|
||||
b := obj{"ImageURI": x.URL}
|
||||
if len(x.Caps.protocols) > 0 {
|
||||
b["TransferProtocol"] = strings.ToUpper(strings.SplitN(x.URL, "://", 2)[0])
|
||||
}
|
||||
for k, v := range extra {
|
||||
b[k] = v
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func init() {
|
||||
for i := range redfishProfiles {
|
||||
p := &redfishProfiles[i]
|
||||
if v, ok := fwVendors[p.Driver]; ok {
|
||||
p.FWPlan, p.FWMethods, p.FWClaims, p.FWUnsupported = v.plan, v.methods, v.claims, v.unsupported
|
||||
p.FWFormats, p.FWNotes, p.FWSources = v.formats, v.notes, v.sources
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// vendorBMC behaves the way one vendor's documentation (or the field notes in
|
||||
// Ironic, MAAS, bmclib) says it does; each flag is one such behaviour.
|
||||
type vendorBMC struct {
|
||||
mu sync.Mutex
|
||||
vendor, manufacturer string
|
||||
oem []string
|
||||
system string
|
||||
trailingSlash bool // collection members end in "/" (iLO)
|
||||
allowed, targets []string
|
||||
mode, power, etag string
|
||||
needIfMatch bool // 428 without If-Match (AMI)
|
||||
strongOnly bool // 412 for a weak If-Match
|
||||
futureSD bool // PATCH on the system: 400 OperationSupportedInFutureStateURI (ASRock Rack)
|
||||
refuseMode bool // 400 when the body carries BootSourceOverrideMode (HPE DL160)
|
||||
inPOST int // the first N PATCHes: 400 UnableToModifyDuringSystemPOST (HPE)
|
||||
flipsWithoutMode bool // a PATCH without the mode switches to Legacy (Supermicro)
|
||||
async bool // reset answers 202 and a task monitor
|
||||
sessionOnly bool // basic auth refused: log in through the SessionService (iBMC clients)
|
||||
actionInfo bool // reset values only through @Redfish.ActionInfo (iBMC)
|
||||
numericETag bool // ETag only as a bare number in @odata.etag (iRMC)
|
||||
chassisReset bool // no ComputerSystem.Reset; Chassis/1 has the reset (KAYTUS SDK)
|
||||
successAs400 bool // a working PATCH answers 400 "completed successfully" (KAYTUS SDK)
|
||||
refuseDisabled bool // Enabled "Disabled" is 400; clearing is Target None (iRMC)
|
||||
sessions int // open sessions
|
||||
|
||||
patchPaths, ifMatch, resets []string
|
||||
patches []obj
|
||||
taskPolls int
|
||||
}
|
||||
|
||||
func (f *vendorBMC) fail(w http.ResponseWriter, code int, id, msg string) {
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(obj{"error": obj{"code": "Base.1.0.GeneralError", "message": "see ExtendedInfo",
|
||||
"@Message.ExtendedInfo": []obj{{"MessageId": id, "Message": msg}}}})
|
||||
}
|
||||
|
||||
func (f *vendorBMC) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if r.URL.Path == "/redfish/v1/SessionService/Sessions" && r.Method == "POST" {
|
||||
var c struct{ UserName, Password string }
|
||||
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||
if c.UserName != "admin" || c.Password != "secret" {
|
||||
f.fail(w, 401, "Base.1.0.NoValidSession", "bad credentials")
|
||||
return
|
||||
}
|
||||
f.sessions++
|
||||
w.Header().Set("X-Auth-Token", "tok")
|
||||
w.Header().Set("Location", "/redfish/v1/SessionService/Sessions/1")
|
||||
w.WriteHeader(201)
|
||||
return
|
||||
}
|
||||
authed := r.Header.Get("X-Auth-Token") == "tok"
|
||||
if u, p, ok := r.BasicAuth(); !f.sessionOnly && ok && u == "admin" && p == "secret" {
|
||||
authed = true
|
||||
}
|
||||
if r.URL.Path != "/redfish/v1/" && !authed {
|
||||
f.fail(w, 401, "Base.1.0.InsufficientPrivilege", "bad credentials")
|
||||
return
|
||||
}
|
||||
oem := obj{}
|
||||
for _, k := range f.oem {
|
||||
oem[k] = obj{}
|
||||
}
|
||||
sys := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch {
|
||||
case r.URL.Path == "/redfish/v1/":
|
||||
_ = json.NewEncoder(w).Encode(obj{"RedfishVersion": "1.15.0", "Vendor": f.vendor, "Oem": oem})
|
||||
case r.URL.Path == "/redfish/v1/SessionService/Sessions/1" && r.Method == "DELETE":
|
||||
f.sessions--
|
||||
w.WriteHeader(204)
|
||||
case r.URL.Path == "/redfish/v1/Systems/1/ResetActionInfo":
|
||||
_ = json.NewEncoder(w).Encode(obj{"Parameters": []obj{{"Name": "ResetType", "AllowableValues": f.allowed}}})
|
||||
case r.URL.Path == "/redfish/v1/Chassis/1" && r.Method == "GET":
|
||||
_ = json.NewEncoder(w).Encode(obj{"Actions": obj{"#Chassis.Reset": obj{"target": "/redfish/v1/Chassis/1/Actions/Chassis.Reset", "ResetType@Redfish.AllowableValues": f.allowed}}})
|
||||
case r.URL.Path == "/redfish/v1/Systems":
|
||||
m := f.system
|
||||
if f.trailingSlash {
|
||||
m += "/"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(obj{"Members": []obj{{"@odata.id": m}}})
|
||||
case (sys == f.system || sys == f.system+"/SD") && r.Method == "GET":
|
||||
if f.etag != "" && !f.numericETag {
|
||||
w.Header().Set("ETag", f.etag)
|
||||
}
|
||||
boot := obj{"BootSourceOverrideTarget@Redfish.AllowableValues": f.targets, "BootSourceOverrideEnabled": "Disabled"}
|
||||
if f.mode != "" {
|
||||
boot["BootSourceOverrideMode"] = f.mode
|
||||
}
|
||||
reset := obj{"target": f.system + "/Actions/ComputerSystem.Reset", "ResetType@Redfish.AllowableValues": f.allowed}
|
||||
if f.actionInfo {
|
||||
reset = obj{"target": f.system + "/Actions/ComputerSystem.Reset", "@Redfish.ActionInfo": "/redfish/v1/Systems/1/ResetActionInfo"}
|
||||
}
|
||||
res := obj{"PowerState": f.power, "Manufacturer": f.manufacturer, "Model": "Test", "Boot": boot,
|
||||
"Actions": obj{"#ComputerSystem.Reset": reset}, "Links": obj{"Chassis": []obj{{"@odata.id": "/redfish/v1/Chassis/1"}}}}
|
||||
if f.chassisReset {
|
||||
res["Actions"] = obj{}
|
||||
}
|
||||
if f.numericETag {
|
||||
res["@odata.etag"] = json.Number(f.etag)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
case (sys == f.system || sys == f.system+"/SD") && r.Method == "PATCH":
|
||||
im := r.Header.Get("If-Match")
|
||||
f.patchPaths, f.ifMatch = append(f.patchPaths, sys), append(f.ifMatch, im)
|
||||
var body obj
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
boot := asObj(body["Boot"])
|
||||
switch {
|
||||
case f.needIfMatch && im == "":
|
||||
f.fail(w, 428, "Ami.1.0.PreconditionHeaderMissing", "If-Match header is required")
|
||||
case f.strongOnly && strings.HasPrefix(im, "W/"):
|
||||
f.fail(w, 412, "HttpStatus.1.0.PreconditionFailed", "ETag does not match")
|
||||
case f.futureSD && sys == f.system:
|
||||
f.fail(w, 400, "Ami.1.0.OperationSupportedInFutureStateURI", "moved to FutureState URI(/redfish/v1/Systems/Self/SD)")
|
||||
case f.inPOST > 0:
|
||||
f.inPOST--
|
||||
f.fail(w, 400, "iLO.2.14.UnableToModifyDuringSystemPOST", "")
|
||||
case f.refuseDisabled && boot["BootSourceOverrideEnabled"] == "Disabled":
|
||||
f.fail(w, 400, "Base.1.0.PropertyValueNotInList", "Disabled")
|
||||
case f.refuseMode && boot["BootSourceOverrideMode"] != nil:
|
||||
f.fail(w, 400, "Base.1.0.PropertyNotWritable", "BootSourceOverrideMode")
|
||||
default:
|
||||
if boot["BootSourceOverrideTarget"] != nil && !hasTag(f.targets, str(boot["BootSourceOverrideTarget"])) {
|
||||
f.fail(w, 400, "Base.1.0.PropertyValueNotInList", "target")
|
||||
return
|
||||
}
|
||||
if m := str(boot["BootSourceOverrideMode"]); m != "" {
|
||||
f.mode = m
|
||||
} else if f.flipsWithoutMode {
|
||||
f.mode = "Legacy"
|
||||
}
|
||||
f.patches = append(f.patches, boot)
|
||||
if f.successAs400 {
|
||||
f.fail(w, 400, "Base.1.0.Success", "The request completed successfully.")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
case r.URL.Path == f.system+"/Actions/ComputerSystem.Reset" || (f.chassisReset && r.URL.Path == "/redfish/v1/Chassis/1/Actions/Chassis.Reset"):
|
||||
var body struct{ ResetType string }
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if !hasTag(f.allowed, body.ResetType) {
|
||||
f.fail(w, 400, "Base.1.0.ActionParameterNotSupported", "ResetType")
|
||||
return
|
||||
}
|
||||
f.resets = append(f.resets, body.ResetType)
|
||||
switch body.ResetType {
|
||||
case "On", "ForceOn":
|
||||
f.power = "On"
|
||||
case "ForceOff", "GracefulShutdown":
|
||||
f.power = "Off"
|
||||
}
|
||||
if f.async {
|
||||
w.Header().Set("Location", "/redfish/v1/TaskService/Tasks/7")
|
||||
w.WriteHeader(202)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
case r.URL.Path == "/redfish/v1/TaskService/Tasks/7":
|
||||
f.taskPolls++
|
||||
st := "Running"
|
||||
if f.taskPolls >= 2 {
|
||||
st = "Completed"
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(obj{"TaskState": st})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runBMC(t *testing.T, f *vendorBMC, driver string) (*bmcCreds, func()) {
|
||||
t.Helper()
|
||||
old := rfSleep
|
||||
rfSleep = func(context.Context, time.Duration) error { return nil }
|
||||
srv := httptest.NewTLSServer(f.handler())
|
||||
return creds(srv.URL, driver), func() { srv.Close(); rfSleep = old }
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, c *bmcCreds, op, arg string) string {
|
||||
t.Helper()
|
||||
out, err := powerRun(context.Background(), "", c, op, arg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", op, arg, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var stdTargets = []string{"None", "Pxe", "Hdd", "Cd", "BiosSetup", "UefiHttp"}
|
||||
|
||||
func TestMatchRedfishProfile(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
vendor, manufacturer string
|
||||
oem []string
|
||||
want string
|
||||
}{
|
||||
{"Dell", "Dell Inc.", []string{"Dell"}, "idrac"},
|
||||
{"HPE", "HPE", []string{"Hpe"}, "ilo"},
|
||||
{"Lenovo", "Lenovo", nil, "xcc"},
|
||||
{"Supermicro", "Supermicro", []string{"Supermicro"}, "supermicro"},
|
||||
{"", "GIGA-BYTE TECHNOLOGY CO., LTD", []string{"Ami"}, "gigabyte"},
|
||||
{"AsrockRack", "", []string{"Ami"}, "asrockrack"},
|
||||
{"", "To Be Filled By O.E.M.", []string{"Ami"}, "ami"},
|
||||
{"OpenBMC", "", nil, "openbmc"},
|
||||
{"Contoso", "Contoso", nil, "redfish"},
|
||||
} {
|
||||
if got := matchRedfishProfile(c.vendor, c.manufacturer, c.oem).Driver; got != c.want {
|
||||
t.Errorf("%q/%q/%v: %s, want %s", c.vendor, c.manufacturer, c.oem, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supermicro: a boot override without the mode can switch the board to Legacy.
|
||||
func TestRedfishSupermicroKeepsBootMode(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "Supermicro", manufacturer: "Supermicro", system: "/redfish/v1/Systems/1", mode: "UEFI", power: "On",
|
||||
targets: stdTargets, flipsWithoutMode: true, etag: `"1"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "Nmi", "ForceOn", "PowerCycle"}}
|
||||
c, done := runBMC(t, f, "supermicro")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.mode != "UEFI" || f.patches[0]["BootSourceOverrideMode"] != "UEFI" || f.patches[0]["BootSourceOverrideEnabled"] != "Once" || f.ifMatch[0] != `"1"` {
|
||||
t.Errorf("mode now %s, patch %v, if-match %v", f.mode, f.patches, f.ifMatch)
|
||||
}
|
||||
mustRun(t, c, "cycle", "")
|
||||
if f.resets[0] != "PowerCycle" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// Dell: changing the mode starts a BIOS job; it is not sent.
|
||||
func TestRedfishIDRACOmitsBootMode(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "Dell", manufacturer: "Dell Inc.", system: "/redfish/v1/Systems/System.Embedded.1", mode: "UEFI", power: "Off",
|
||||
targets: stdTargets, allowed: []string{"On", "ForceOff", "ForceRestart", "GracefulShutdown", "PushPowerButton", "Nmi"}}
|
||||
c, done := runBMC(t, f, "idrac")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if _, sent := f.patches[0]["BootSourceOverrideMode"]; sent {
|
||||
t.Errorf("idrac got the mode: %v", f.patches[0])
|
||||
}
|
||||
c.Extra["boot_mode"] = "Legacy" // the operator asks: then it goes
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.patches[1]["BootSourceOverrideMode"] != "Legacy" {
|
||||
t.Errorf("asked mode not sent: %v", f.patches[1])
|
||||
}
|
||||
mustRun(t, c, "on", "")
|
||||
mustRun(t, c, "cycle", "") // no PowerCycle on iDRAC9 3.x: ForceRestart
|
||||
if strings.Join(f.resets, ",") != "On,ForceRestart" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// HPE: no boot changes during POST; some refuse the mode; members end in "/".
|
||||
func TestRedfishILOPostStateAndMode(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "HPE", manufacturer: "HPE", oem: []string{"Hpe"}, system: "/redfish/v1/Systems/1", trailingSlash: true,
|
||||
mode: "UEFI", power: "On", targets: stdTargets, inPOST: 2, refuseMode: true, etag: `W/"9"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "Nmi", "PushPowerButton", "GracefulRestart"}}
|
||||
c, done := runBMC(t, f, "redfish") // generic driver, found through the collection
|
||||
defer done()
|
||||
out := mustRun(t, c, "bootdev", "pxe")
|
||||
if len(f.patches) != 1 || f.patches[0]["BootSourceOverrideTarget"] != "Pxe" || f.patches[0]["BootSourceOverrideMode"] != nil {
|
||||
t.Errorf("%s: patches %v", out, f.patches)
|
||||
}
|
||||
var p obj
|
||||
_ = json.Unmarshal([]byte(mustRun(t, c, "probe", "")), &p)
|
||||
if p["driver"] != "ilo" || p["system"] != "/redfish/v1/Systems/1" || p["boot_mode"] != "UEFI" {
|
||||
t.Errorf("probe %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// AMI (Gigabyte): Systems/Self, If-Match required, a short ResetType list.
|
||||
func TestRedfishAMIIfMatchAndCycle(t *testing.T) {
|
||||
f := &vendorBMC{manufacturer: "GIGA-BYTE TECHNOLOGY CO., LTD", oem: []string{"Ami"}, system: "/redfish/v1/Systems/Self",
|
||||
mode: "UEFI", power: "On", targets: stdTargets, needIfMatch: true, strongOnly: true, etag: `W/"abc"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart"}}
|
||||
c, done := runBMC(t, f, "gigabyte")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.ifMatch[len(f.ifMatch)-1] != `"abc"` || f.patches[0]["BootSourceOverrideMode"] != "UEFI" {
|
||||
t.Errorf("if-match tries %v, patch %v", f.ifMatch, f.patches)
|
||||
}
|
||||
mustRun(t, c, "cycle", "")
|
||||
if strings.Join(f.resets, ",") != "ForceRestart" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
// a build that offers neither PowerCycle nor a restart: off, then on
|
||||
f.allowed = []string{"On", "ForceOff"}
|
||||
f.resets = nil
|
||||
mustRun(t, c, "cycle", "")
|
||||
if strings.Join(f.resets, ",") != "ForceOff,On" {
|
||||
t.Errorf("emulated cycle %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// ASRock Rack: boot settings live in the future-state resource .../SD.
|
||||
func TestRedfishASRockFutureState(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "AsrockRack", oem: []string{"Ami"}, system: "/redfish/v1/Systems/Self", mode: "UEFI", power: "Off",
|
||||
targets: stdTargets, futureSD: true, etag: `"5"`, allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart"}}
|
||||
c, done := runBMC(t, f, "asrockrack")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if strings.Join(f.patchPaths, ",") != "/redfish/v1/Systems/Self,/redfish/v1/Systems/Self/SD" || len(f.patches) != 1 {
|
||||
t.Errorf("paths %v patches %v", f.patchPaths, f.patches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedfishTaskAndTargets(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "Contoso", system: "/redfish/v1/Systems/1", power: "Off", targets: []string{"None", "Pxe", "Hdd"},
|
||||
async: true, allowed: []string{"On", "ForceOff"}}
|
||||
c, done := runBMC(t, f, "redfish")
|
||||
defer done()
|
||||
mustRun(t, c, "on", "")
|
||||
if f.taskPolls < 2 {
|
||||
t.Errorf("task polled %d times", f.taskPolls)
|
||||
}
|
||||
if _, err := powerRun(context.Background(), "", c, "bootdev", "bios"); err == nil || !strings.Contains(err.Error(), "offers boot targets") {
|
||||
t.Errorf("an unoffered target: %v", err)
|
||||
}
|
||||
mustRun(t, c, "bootdev", "none")
|
||||
if f.patches[0]["BootSourceOverrideEnabled"] != "Disabled" {
|
||||
t.Errorf("clear %v", f.patches)
|
||||
}
|
||||
}
|
||||
|
||||
// iBMC: sessions, ActionInfo, ForcePowerCycle, If-Match on PATCH.
|
||||
func TestRedfishXFusionSession(t *testing.T) {
|
||||
f := &vendorBMC{oem: []string{"xFusion"}, manufacturer: "xFusion", system: "/redfish/v1/Systems/1", power: "On", mode: "UEFI",
|
||||
targets: stdTargets, sessionOnly: true, actionInfo: true, needIfMatch: true, etag: `W/"77"`,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "Nmi", "ForcePowerCycle"}}
|
||||
c, done := runBMC(t, f, "xfusion")
|
||||
defer done()
|
||||
mustRun(t, c, "cycle", "")
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if strings.Join(f.resets, ",") != "ForcePowerCycle" || len(f.patches) != 1 || f.ifMatch[0] != `W/"77"` || f.sessions != 0 {
|
||||
t.Errorf("resets %v patches %v if-match %v open sessions %d", f.resets, f.patches, f.ifMatch, f.sessions)
|
||||
}
|
||||
var p obj
|
||||
_ = json.Unmarshal([]byte(mustRun(t, c, "probe", "")), &p)
|
||||
if p["driver"] != "xfusion" || p["auth"] != "session" || f.sessions != 0 {
|
||||
t.Errorf("probe %v, sessions %d", p, f.sessions)
|
||||
}
|
||||
}
|
||||
|
||||
// iRMC: the ETag only in the body as a number; never Enabled Disabled.
|
||||
func TestRedfishIRMC(t *testing.T) {
|
||||
f := &vendorBMC{vendor: "ts_fujitsu", manufacturer: "FUJITSU", system: "/redfish/v1/Systems/0", power: "On", mode: "UEFI",
|
||||
targets: stdTargets, numericETag: true, etag: "1714", needIfMatch: true, refuseDisabled: true,
|
||||
allowed: []string{"ForceOff", "ForceRestart", "Nmi", "PushPowerButton"}}
|
||||
c, done := runBMC(t, f, "irmc")
|
||||
defer done()
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if f.ifMatch[0] != "1714" || f.patches[0]["BootSourceOverrideMode"] != nil {
|
||||
t.Errorf("if-match %v patch %v", f.ifMatch, f.patches)
|
||||
}
|
||||
mustRun(t, c, "bootdev", "none")
|
||||
if f.patches[1]["BootSourceOverrideTarget"] != "None" || f.patches[1]["BootSourceOverrideEnabled"] != nil {
|
||||
t.Errorf("clear %v", f.patches[1])
|
||||
}
|
||||
mustRun(t, c, "soft", "") // no GracefulShutdown: the power button
|
||||
if strings.Join(f.resets, ",") != "PushPowerButton" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// KAYTUS (third-party SDK): reset on the chassis; a PATCH that worked answers 400.
|
||||
func TestRedfishKaytus(t *testing.T) {
|
||||
f := &vendorBMC{oem: []string{"Public"}, manufacturer: "KAYTUS", system: "/redfish/v1/Systems/1", power: "Off", mode: "UEFI",
|
||||
targets: stdTargets, chassisReset: true, successAs400: true,
|
||||
allowed: []string{"On", "ForceOff", "GracefulShutdown", "ForceRestart", "PowerCycle"}}
|
||||
c, done := runBMC(t, f, "redfish")
|
||||
defer done()
|
||||
mustRun(t, c, "on", "")
|
||||
mustRun(t, c, "bootdev", "pxe")
|
||||
if strings.Join(f.resets, ",") != "On" || len(f.patches) != 1 {
|
||||
t.Errorf("resets %v patches %v", f.resets, f.patches)
|
||||
}
|
||||
if p := matchRedfishProfile("", "KAYTUS", []string{"Public"}); p.Driver != "kaytus" {
|
||||
t.Errorf("profile %s", p.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
// CIMC: /Systems/<serial>, found through the collection.
|
||||
func TestRedfishCIMCSerialPath(t *testing.T) {
|
||||
f := &vendorBMC{manufacturer: "Cisco Systems Inc", oem: []string{"Cisco"}, system: "/redfish/v1/Systems/WZP21330G5B", power: "On",
|
||||
targets: stdTargets, allowed: []string{"On", "ForceOff", "GracefulShutdown", "GracefulRestart", "ForceRestart", "Nmi", "PowerCycle"}}
|
||||
c, done := runBMC(t, f, "cimc")
|
||||
defer done()
|
||||
mustRun(t, c, "cycle", "")
|
||||
if strings.Join(f.resets, ",") != "PowerCycle" {
|
||||
t.Errorf("resets %v", f.resets)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Regressions from the security review.
|
||||
|
||||
func TestRackBinaryArchIsNoPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "secret"), []byte("key"), 0o600)
|
||||
a := &App{cfg: &Config{RackBinaries: filepath.Join(dir, "bins")}}
|
||||
for _, arch := range []string{"x/../../secret", "../secret", "amd64/../../secret", ""} {
|
||||
if p, _, ok := a.rackBinary(arch); ok {
|
||||
t.Errorf("arch %q served %s", arch, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormMAC(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"52:54:00:AB:cd:01": "52:54:00:ab:cd:01",
|
||||
"52-54-00-ab-cd-01": "52:54:00:ab:cd:01",
|
||||
"../../etc/x": "",
|
||||
"52:54:00:ab:cd:01\nx": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := normMAC(in); got != want {
|
||||
t.Errorf("normMAC(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if n := pxeFileName("../../../etc"); pxeNameRe.MatchString(n) {
|
||||
t.Errorf("%q passes as a pxelinux file name", n)
|
||||
}
|
||||
if !pxeNameRe.MatchString(pxeFileName("52:54:00:ab:cd:01")) {
|
||||
t.Error("a real MAC does not make a pxelinux file name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDomain(t *testing.T) {
|
||||
for _, ok := range []string{"node01", "node01.int.example.com", "a-b.c"} {
|
||||
if !validDomain(ok) {
|
||||
t.Errorf("%q refused", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "*.int.example", "a b", "x\ndhcp-script=/x", "-a", "a..b", "Node01"} {
|
||||
if validDomain(bad) {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
// a reservation name that is not a plain name never reaches DNS
|
||||
if n := dnsNameIn(IPAddr{Hostname: "victim.int.example. 300 IN A 6.6.6.6 ;.int.example"}, nil, "int.example"); n != "" {
|
||||
t.Errorf("dns name %q", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedfishResolveStaysOnBMC(t *testing.T) {
|
||||
r := &rfClient{base: "https://10.0.0.11:8443"}
|
||||
for _, ok := range []string{"/redfish/v1/Systems/1", "/redfish/v1/Systems/1/", "https://10.0.0.11:8443/redfish/v1/TaskService/Tasks/5"} {
|
||||
if _, err := r.resolve(ok); err != nil {
|
||||
t.Errorf("%s: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"@evil.example/x", "https://evil.example/redfish/v1", "/api/x", "/redfish/../etc", "//evil.example/redfish/v1"} {
|
||||
if p, err := r.resolve(bad); err == nil {
|
||||
t.Errorf("%s resolved to %s", bad, p)
|
||||
}
|
||||
}
|
||||
if b := bmcBase(&bmcCreds{Address: "https://u:p@bmc:8443/x/y"}, "https", 443); b != "https://invalid.invalid" {
|
||||
t.Errorf("userinfo address gave %s", b)
|
||||
}
|
||||
if b := bmcBase(&bmcCreds{Address: "https://bmc:8443/x"}, "https", 443); b != "https://bmc:8443" {
|
||||
t.Errorf("base %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitiesOnlyFromTheRoot(t *testing.T) {
|
||||
root, _ := newCA("test root")
|
||||
ikey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
dns, nets, _ := parsePermits("int.example.com")
|
||||
_, icert, err := root.intermediate(&ikey.PublicKey, "acme", dns, nets, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
_, direct, _ := root.sign(&k.PublicKey, "admin:alice", nil, x509.ExtKeyUsageClientAuth)
|
||||
_, viaInter, _ := (&CA{Cert: icert, Key: ikey}).sign(&k.PublicKey, "admin:alice", nil, x509.ExtKeyUsageClientAuth)
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{direct, root.Cert}}}
|
||||
if rootIssued(r) == nil {
|
||||
t.Error("a root-issued certificate was not accepted")
|
||||
}
|
||||
r.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{viaInter, icert, root.Cert}}}
|
||||
if rootIssued(r) != nil {
|
||||
t.Error("a certificate from an intermediate passed as an identity")
|
||||
}
|
||||
if err := regionLeaf(tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{direct, root.Cert}}}); err == nil {
|
||||
t.Error("an admin certificate passed as the region's")
|
||||
}
|
||||
}
|
||||
|
||||
// The hint API names the BMC but never hands out its password.
|
||||
func TestBMCHintHasNoPassword(t *testing.T) {
|
||||
a := &App{cfg: &Config{BMCHints: []BMCHint{{MAC: "52:54:00:AA:BB:01", Address: "10.0.0.11", User: "admin", Password: "s3cret"}}}}
|
||||
rec := httptest.NewRecorder()
|
||||
a.handleHint(rec, httptest.NewRequest("GET", "/api/v1/bmc-hint?macs=02:00:00:00:00:09,52-54-00-aa-bb-01", nil))
|
||||
var h map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &h)
|
||||
if h["address"] != "10.0.0.11" || h["user"] != "admin" || h["password"] != nil || strings.Contains(rec.Body.String(), "s3cret") {
|
||||
t.Errorf("hint %s", rec.Body.String())
|
||||
}
|
||||
if a.hintFor([]string{"../x", ""}) != nil {
|
||||
t.Error("garbage matched a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// X-Forwarded-For counts only from a rack controller, and only its own entry.
|
||||
func TestClientIP(t *testing.T) {
|
||||
a := &App{cfg: &Config{RackName: "region", RackSecret: "rs"}}
|
||||
r := httptest.NewRequest("GET", "/api/v1/jobs/self", nil)
|
||||
r.RemoteAddr = "10.213.77.60:41000"
|
||||
r.Header.Set("X-Forwarded-For", "10.213.77.50")
|
||||
if ip := a.clientIP(r); ip != "10.213.77.60" {
|
||||
t.Errorf("a machine's own X-Forwarded-For was believed: %s", ip)
|
||||
}
|
||||
r.RemoteAddr = "10.0.0.151:5000"
|
||||
r.Header.Set("X-Rollcall-Rack", "hall-b")
|
||||
r.Header.Set("X-Rollcall-Rack-Secret", "rs")
|
||||
r.Header.Set("X-Forwarded-For", "10.213.78.99, 10.213.78.50")
|
||||
if ip := a.clientIP(r); ip != "10.213.78.50" {
|
||||
t.Errorf("through a rack: %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// An HTTPS BMC is trusted by the certificate it showed first; another one
|
||||
// gets no credentials.
|
||||
func TestBMCCertificatePin(t *testing.T) {
|
||||
seen := 0
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, ok := r.BasicAuth(); ok {
|
||||
seen++
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"PowerState":"On","Members":[{"@odata.id":"/redfish/v1/Systems/1"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := creds(srv.URL, "redfish")
|
||||
fp, err := powerRun(context.Background(), "", c, "tls-fingerprint", "")
|
||||
if err != nil || fp != certSHA256(srv.Certificate()) {
|
||||
t.Fatalf("fingerprint %q %v", fp, err)
|
||||
}
|
||||
if seen != 0 {
|
||||
t.Error("credentials were sent while reading the certificate")
|
||||
}
|
||||
c.Extra["tls_fingerprint"] = fp
|
||||
if _, err := powerRun(context.Background(), "", c, "status", ""); err != nil {
|
||||
t.Fatalf("pinned and matching: %v", err)
|
||||
}
|
||||
c.Extra["tls_fingerprint"] = strings.Repeat("ab", 32)
|
||||
seen = 0
|
||||
if _, err := powerRun(context.Background(), "", c, "status", ""); err == nil || !strings.Contains(err.Error(), "certificate changed") || seen != 0 {
|
||||
t.Errorf("another certificate: %v (credentials sent %d times)", err, seen)
|
||||
}
|
||||
}
|
||||
|
||||
// A rack's HTTPS URL is its own host; the region's addresses get no rack certificate.
|
||||
func TestAgentTLSURLs(t *testing.T) {
|
||||
ok := RackController{URL: "http://10.213.78.1", TLSURL: "https://10.213.78.1"}
|
||||
if validTLSURL(ok) != "https://10.213.78.1" {
|
||||
t.Error("own host refused")
|
||||
}
|
||||
for _, bad := range []string{"https://10.0.0.234", "http://10.213.78.1", "https://10.213.78.1/x", "https://evil.example"} {
|
||||
if validTLSURL(RackController{URL: "http://10.213.78.1", TLSURL: bad}) != "" {
|
||||
t.Errorf("%s accepted", bad)
|
||||
}
|
||||
}
|
||||
a := &App{cfg: &Config{ServerURL: "http://10.213.77.1", TLSNames: []string{"region.example"}}}
|
||||
for _, h := range []string{"10.213.77.1", "region.example", "127.0.0.1", "localhost"} {
|
||||
if !a.isRegionName(h) {
|
||||
t.Errorf("%s is the region's", h)
|
||||
}
|
||||
}
|
||||
if a.isRegionName("10.213.78.1") {
|
||||
t.Error("a rack's address taken for the region's")
|
||||
}
|
||||
}
|
||||
+205
-36
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -39,6 +38,12 @@ type App struct {
|
||||
ipamSt *ipamState
|
||||
|
||||
adminCC adminCertCache
|
||||
|
||||
dnsp dnsProvider // nil = no direct DNS writes
|
||||
dnsSt *dnsState
|
||||
// fixed leases on the site's DHCP server (dhcpres.go); nil = none
|
||||
dhcpRes dhcpResProvider
|
||||
dhcpResSt *dhcpResState
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
@@ -148,9 +153,14 @@ 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}
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, 16<<20) // handlers set tighter limits; nothing takes more
|
||||
next.ServeHTTP(rw, r)
|
||||
if (!strings.HasPrefix(r.URL.Path, "/rollcall/") && !strings.HasPrefix(r.URL.Path, "/images/") && !strings.HasPrefix(r.URL.Path, "/os/")) || 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))
|
||||
uri := r.URL.RequestURI()
|
||||
if r.URL.Query().Has("token") { // /?token=... signs the UI in; it does not go to the journal
|
||||
uri = r.URL.Path + "?token=REDACTED"
|
||||
}
|
||||
log.Printf("%s %s %s %d %s", r.RemoteAddr, r.Method, uri, rw.code, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -193,6 +203,14 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/nodes/self/overlay", a.handleNodeOverlay) // node token auth inside
|
||||
// disk-based deployment
|
||||
mux.HandleFunc("GET /api/v1/os", a.adminAuth(a.handleOS))
|
||||
mux.HandleFunc("GET /api/v1/dhcp-reservations", a.adminAuth(a.handleDHCPRes))
|
||||
mux.HandleFunc("POST /api/v1/dhcp-reservations/sync", a.adminAuth(a.handleDHCPResSync))
|
||||
mux.HandleFunc("GET /api/v1/firmware", a.adminAuth(a.handleFirmwareRepo))
|
||||
mux.HandleFunc("POST /api/v1/firmware", a.adminAuth(a.handleFirmwareRepo))
|
||||
mux.HandleFunc("DELETE /api/v1/firmware/{pkg}", a.adminAuth(a.handleFirmwareRemove))
|
||||
mux.HandleFunc("GET /api/v1/machines/{id}/firmware", a.adminAuth(a.handleMachineFirmware))
|
||||
mux.HandleFunc("POST /api/v1/machines/{id}/firmware", a.adminAuth(a.handleMachineFirmware))
|
||||
mux.HandleFunc("POST /api/v1/machines/{id}/firmware/update", a.adminAuth(a.handleFirmwareUpdate))
|
||||
mux.HandleFunc("POST /api/v1/machines/{id}/deploy", a.adminAuth(a.handleDeploy))
|
||||
mux.HandleFunc("POST /api/v1/machines/{id}/release", a.adminAuth(a.handleRelease))
|
||||
mux.HandleFunc("GET /api/v1/machines/{id}/jobs", a.adminAuth(a.handleJobs))
|
||||
@@ -233,6 +251,13 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/ips", a.adminAuth(a.handleIPs))
|
||||
mux.HandleFunc("DELETE /api/v1/ips/{ip}", a.adminAuth(a.handleIPDelete))
|
||||
mux.HandleFunc("GET /api/v1/ipam", a.adminAuth(a.handleIPAM))
|
||||
mux.HandleFunc("GET /api/v1/ca/rotation", a.adminAuth(a.handleCARotation))
|
||||
mux.HandleFunc("GET /api/v1/ca/intermediates", a.adminAuth(a.handleIntermediates))
|
||||
mux.HandleFunc("POST /api/v1/ca/intermediates", a.adminAuth(a.handleIntermediates))
|
||||
mux.HandleFunc("DELETE /api/v1/ca/intermediates", a.adminAuth(a.handleIntermediates))
|
||||
mux.HandleFunc("POST /api/v1/ca/intermediates/{name}/renew", a.handleIntermediateRenew) // its own token (intermediates.go)
|
||||
mux.HandleFunc("GET /api/v1/dns", a.adminAuth(a.handleDNS))
|
||||
mux.HandleFunc("POST /api/v1/dns/sync", a.adminAuth(a.handleDNSSync))
|
||||
mux.HandleFunc("GET /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts))
|
||||
mux.HandleFunc("POST /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts))
|
||||
mux.HandleFunc("DELETE /api/v1/admin-certs/{ref}", a.adminAuth(a.handleAdminCertRevoke))
|
||||
@@ -248,9 +273,11 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/v1/controllers/{name}", a.adminAuth(a.handleControllerDelete))
|
||||
mux.HandleFunc("GET /api/v1/controllers/{name}/dhcp", a.adminAuth(a.handleRackDHCP))
|
||||
mux.HandleFunc("GET /ca.pem", a.handleCAPEM)
|
||||
mux.HandleFunc("GET /ca-bundle.pem", a.handleCABundle)
|
||||
mux.HandleFunc("POST /api/v1/controllers/join", a.adminAuth(a.handleJoinCode))
|
||||
mux.HandleFunc("POST /api/v1/rack/enroll", a.handleRackEnroll) // join-code secret inside, TLS only
|
||||
mux.HandleFunc("POST /api/v1/rack/renew", a.rackAuth(a.handleRackRenew))
|
||||
mux.HandleFunc("POST /api/v1/rack/server-cert", a.rackAuth(a.handleRackServerCert))
|
||||
mux.HandleFunc("POST /api/v1/rack/register", a.rackAuth(a.handleRackRegister))
|
||||
mux.HandleFunc("GET /api/v1/rack/state", a.rackAuth(a.handleRackState))
|
||||
mux.HandleFunc("GET /api/v1/rack/work", a.rackAuth(a.handleRackWork))
|
||||
@@ -262,6 +289,17 @@ func (a *App) routes() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/racks", a.adminAuth(a.handleRacks))
|
||||
mux.HandleFunc("POST /api/v1/racks", a.adminAuth(a.handleRacks))
|
||||
mux.HandleFunc("DELETE /api/v1/racks", a.adminAuth(a.handleRacks))
|
||||
mux.HandleFunc("POST /api/v1/racks/positions", a.adminAuth(a.handleRackPositions))
|
||||
mux.HandleFunc("GET /api/v1/cabling", a.adminAuth(a.handleCabling))
|
||||
mux.HandleFunc("POST /api/v1/switches", a.adminAuth(a.handleSwitches))
|
||||
mux.HandleFunc("POST /api/v1/cables", a.adminAuth(a.handleCables))
|
||||
mux.HandleFunc("DELETE /api/v1/cables", a.adminAuth(a.handleCables))
|
||||
mux.HandleFunc("DELETE /api/v1/switches", a.adminAuth(a.handleSwitches))
|
||||
mux.HandleFunc("GET /api/v1/sites", a.adminAuth(a.handleSites))
|
||||
mux.HandleFunc("POST /api/v1/sites", a.adminAuth(a.handleSites))
|
||||
mux.HandleFunc("DELETE /api/v1/sites", a.adminAuth(a.handleSites))
|
||||
mux.HandleFunc("GET /api/v1/rooms", a.adminAuth(a.handleRooms))
|
||||
mux.HandleFunc("POST /api/v1/rooms", a.adminAuth(a.handleRooms))
|
||||
// tags
|
||||
mux.HandleFunc("GET /api/v1/tags", a.adminAuth(a.handleTags))
|
||||
mux.HandleFunc("POST /api/v1/tags", a.adminAuth(a.handleTags))
|
||||
@@ -272,12 +310,37 @@ func (a *App) routes() http.Handler {
|
||||
mux.Handle("GET /rollcall/", http.StripPrefix("/rollcall/", http.FileServer(http.Dir(imgDir))))
|
||||
mux.Handle("GET /images/", http.StripPrefix("/images/", http.FileServer(http.Dir(a.cfg.ImagesDir))))
|
||||
mux.Handle("GET /os/", http.StripPrefix("/os/", http.FileServer(http.Dir(a.cfg.OSDir))))
|
||||
mux.HandleFunc("GET /firmware/{pkg}/{file}", a.handleFirmwareFile) // BMCs download packages (no listing)
|
||||
// embedded web UI
|
||||
sub, _ := fs.Sub(webFS, "web")
|
||||
mux.Handle("GET /", a.uiOverHTTPS(http.FileServer(http.FS(sub))))
|
||||
return logging(mux)
|
||||
mux.Handle("GET /", a.uiOverHTTPS(uiHeaders(http.FileServer(http.FS(sub)))))
|
||||
// Browsers send admin client certificates on their own, so a page elsewhere
|
||||
// could post to the API with the admin's identity: cross-origin requests
|
||||
// that change state are refused (Sec-Fetch-Site / Origin). The CLI, agents
|
||||
// and racks send neither header and are not affected.
|
||||
return logging(http.NewCrossOriginProtection().Handler(mux))
|
||||
}
|
||||
|
||||
// uiHeaders: the UI loads only its own scripts and cannot be framed, so
|
||||
// markup that slips through (machine-reported strings) does not run.
|
||||
func uiHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://tile.openstreetmap.org; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// the files are the binary's: browsers ask each time and get 304 until
|
||||
// another build runs (FileServer answers If-None-Match from this ETag)
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("ETag", uiETag)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// uiETag: one per build and start (a "dev" build changes without a new version)
|
||||
var uiETag = fmt.Sprintf(`"%s-%x"`, version, time.Now().Unix())
|
||||
|
||||
// ---------------------------------------------------------------- helpers over inventory JSON
|
||||
|
||||
type obj = map[string]any
|
||||
@@ -322,14 +385,23 @@ func listLen(v any) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func normMAC(s string) string { return strings.ToLower(strings.ReplaceAll(s, "-", ":")) }
|
||||
// normMAC is the canonical form of a hardware address, "" when s is not one.
|
||||
// MACs name files (pxelinux.cfg/01-...) and dnsmasq lines, so nothing else passes.
|
||||
func normMAC(s string) string {
|
||||
hw, err := net.ParseMAC(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return hw.String()
|
||||
}
|
||||
|
||||
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 u != "" && u != "00000000-0000-0000-0000-000000000000" && !strings.Contains(u, "not ") &&
|
||||
nonAlnum.ReplaceAllString(u, "") != "" {
|
||||
u = nonAlnum.ReplaceAllString(u, "") // the ID names files and URLs: letters and digits only
|
||||
if len(u) > 12 {
|
||||
u = u[:12]
|
||||
}
|
||||
@@ -381,6 +453,7 @@ type Summary struct {
|
||||
ManualTags []string `json:"manual_tags"`
|
||||
AutoTags []string `json:"auto_tags"`
|
||||
Location Location `json:"location"`
|
||||
Size rackSize `json:"size"` // rack units (hwsize.go)
|
||||
Rack string `json:"rack_controller"`
|
||||
NetLayout string `json:"net_layout"`
|
||||
}
|
||||
@@ -459,7 +532,7 @@ func (a *App) writePXE(m *Machine) error {
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20))
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
@@ -486,20 +559,53 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// identity strings come from the machine: one line each, they go into PXE menus and logs
|
||||
line := func(v any) *string {
|
||||
s := strings.Map(func(c rune) rune {
|
||||
if c < ' ' || c == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return c
|
||||
}, str(v))
|
||||
if s = strings.TrimSpace(s); len(s) > 128 {
|
||||
s = s[:128]
|
||||
}
|
||||
return strPtr(s)
|
||||
}
|
||||
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")),
|
||||
UUID: line(ident["uuid"]),
|
||||
Serial: line(ident["serial"]),
|
||||
Hostname: line(inv["hostname"]),
|
||||
Manufacturer: line(dig(inv, "system", "system-manufacturer")),
|
||||
Product: line(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, _ := inv["bmc"].(obj); bmc == nil || str(bmc["source"]) == "hint" || str(bmc["address"]) == "" {
|
||||
// a configured out-of-band BMC: the region's credentials, whatever the agent sent
|
||||
if h := a.hintFor(macs); h != nil {
|
||||
port := h.Port
|
||||
if port == 0 {
|
||||
port = 623
|
||||
}
|
||||
nb := obj{"source": "hint", "address": h.Address, "port": port, "user": h.User, "configured": true, "password": "<from bmc_hints>"}
|
||||
if bmc != nil {
|
||||
nb["agent"] = obj{"source": bmc["source"], "errors": bmc["errors"]}
|
||||
}
|
||||
inv["bmc"] = nb
|
||||
if h.Password != "" {
|
||||
if secret, err = a.sealer.Seal(h.Password, m.ID); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bmc, ok := inv["bmc"].(obj); ok {
|
||||
if pw := str(bmc["password"]); pw != "" {
|
||||
if pw := str(bmc["password"]); pw != "" && str(bmc["source"]) != "hint" {
|
||||
secret, err = a.sealer.Seal(pw, m.ID)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
@@ -512,9 +618,20 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
m.Inventory, _ = json.Marshal(inv)
|
||||
|
||||
// Anyone on the provisioning network can post here, so a known machine is
|
||||
// only re-commissioned when that is expected: an operator asked
|
||||
// (recommission), or it has nothing to lose (ready, a failed job). A report
|
||||
// for a deployed or running machine is refused, not merged.
|
||||
old, oldErr := a.store.Get(r.Context(), m.ID)
|
||||
if oldErr == nil && !recommissionable(old.State) {
|
||||
_ = a.store.AddEvent(r.Context(), m.ID, "commission refused", fmt.Sprintf("state %s; from %s", old.State, r.RemoteAddr))
|
||||
log.Printf("commission refused for %s (state %s) from %s", m.ID, old.State, r.RemoteAddr)
|
||||
fail(w, 409, fmt.Errorf("machine %s is %s; recommission it first", m.ID, old.State))
|
||||
return
|
||||
}
|
||||
// a BMC an operator configured (driver_set) survives commissioning; what the agent
|
||||
// found is kept next to it for reference
|
||||
if old, err := a.store.Get(r.Context(), m.ID); err == nil && old.BMC != nil {
|
||||
if oldErr == nil && old.BMC != nil {
|
||||
var ob obj
|
||||
if json.Unmarshal(old.BMC, &ob) == nil && ob["driver_set"] == true {
|
||||
var found obj
|
||||
@@ -533,10 +650,18 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
_ = a.store.AddEvent(r.Context(), m.ID, "commissioned", detail)
|
||||
_ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r))
|
||||
if full, err := a.store.Get(r.Context(), m.ID); err == nil {
|
||||
go a.probeBMC(full)
|
||||
if isNew || oldErr != nil || old.State == "recommission" || old.RackController == "" {
|
||||
_ = a.store.SetRackController(r.Context(), m.ID, a.fromRack(r)) // a ready machine is not moved by whoever reports it
|
||||
}
|
||||
go func(id string) { // check a hinted BMC from where it is reachable, then look for Redfish
|
||||
ctx := context.Background()
|
||||
if full, err := a.store.Get(ctx, id); err == nil {
|
||||
a.verifyHintedBMC(full)
|
||||
}
|
||||
if full, err := a.store.Get(ctx, id); err == nil {
|
||||
a.probeBMC(full)
|
||||
}
|
||||
}(m.ID)
|
||||
if full, err := a.store.Get(r.Context(), m.ID); err == nil {
|
||||
m = full // carries profile / node token when this is a re-commission of a stateless node
|
||||
}
|
||||
@@ -550,22 +675,41 @@ func (a *App) handleCommission(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"id": m.ID, "state": "ready", "new": isNew})
|
||||
}
|
||||
|
||||
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) {
|
||||
func recommissionable(state string) bool {
|
||||
return state == "new" || state == "ready" || state == "recommission" || strings.HasSuffix(state, "-failed")
|
||||
}
|
||||
|
||||
// hintFor is the configured out-of-band BMC for one of these MACs.
|
||||
func (a *App) hintFor(macs []string) *BMCHint {
|
||||
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
|
||||
for _, m := range macs {
|
||||
if n := normMAC(m); n != "" {
|
||||
want[n] = true
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, obj{})
|
||||
for i, h := range a.cfg.BMCHints {
|
||||
if want[normMAC(h.MAC)] {
|
||||
return &a.cfg.BMCHints[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHint tells a commissioning agent that a BMC is configured for it: the
|
||||
// address, never the password (the agent token is public, MACs are visible on
|
||||
// the wire). The region attaches the credentials itself when the machine
|
||||
// reports in (handleCommission) and checks them from the rack controller.
|
||||
func (a *App) handleHint(w http.ResponseWriter, r *http.Request) {
|
||||
h := a.hintFor(strings.Split(r.URL.Query().Get("macs"), ","))
|
||||
if h == nil {
|
||||
writeJSON(w, 200, obj{})
|
||||
return
|
||||
}
|
||||
port := h.Port
|
||||
if port == 0 {
|
||||
port = 623
|
||||
}
|
||||
writeJSON(w, 200, obj{"address": h.Address, "port": port, "user": h.User, "credentials": "on the region"})
|
||||
}
|
||||
|
||||
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -585,6 +729,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
s := summarize(m)
|
||||
s.Size = machineSize(m, a.cfg.RackUnits)
|
||||
s.Job = active[m.ID]
|
||||
out = append(out, s)
|
||||
}
|
||||
@@ -600,6 +745,7 @@ func (a *App) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
evs, _ := a.store.Events(r.Context(), m.ID)
|
||||
jobs, _ := a.store.Jobs(r.Context(), m.ID, 10)
|
||||
s := summarize(m)
|
||||
s.Size = machineSize(m, a.cfg.RackUnits)
|
||||
if j, err := a.store.ActiveJob(r.Context(), m.ID); err == nil {
|
||||
s.Job = j
|
||||
}
|
||||
@@ -672,7 +818,11 @@ func (a *App) handlePower(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
res := obj{"id": m.ID, "action": action, "ok": err == nil, "output": out}
|
||||
if err != nil {
|
||||
res["error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, 200, res)
|
||||
}
|
||||
|
||||
func (a *App) handleBootdev(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -725,7 +875,14 @@ func serve(cfgPath string) error {
|
||||
if err := app.startIPAM(); err != nil {
|
||||
return fmt.Errorf("ipam: %w", err)
|
||||
}
|
||||
if err := app.startDHCPRes(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.startDNS(); err != nil {
|
||||
return fmt.Errorf("dns: %w", err)
|
||||
}
|
||||
go app.jobWatchdog()
|
||||
go app.resumeFirmware()
|
||||
handler := app.routes()
|
||||
if cfg.TLSListen != "" {
|
||||
names := append([]string{}, cfg.TLSNames...)
|
||||
@@ -736,9 +893,8 @@ func serve(cfgPath string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("region certificate: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(app.ca.Cert)
|
||||
tsrv := &http.Server{Addr: cfg.TLSListen, Handler: handler, ReadHeaderTimeout: 10 * time.Second,
|
||||
pool := app.ca.pool() // a rotation's other root too: racks and admins keep working
|
||||
tsrv := &http.Server{Addr: cfg.TLSListen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute,
|
||||
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{pair}, ClientCAs: pool, ClientAuth: tls.VerifyClientCertIfGiven}}
|
||||
go func() {
|
||||
log.Printf("rollcall HTTPS on %s (CA %s)", cfg.TLSListen, app.ca.Fingerprint())
|
||||
@@ -746,8 +902,21 @@ func serve(cfgPath string) error {
|
||||
log.Printf("HTTPS listener: %v", err)
|
||||
}
|
||||
}()
|
||||
if app.netboxUIPath() != "" { // NetBox's UI on its own origin (nbproxy.go)
|
||||
if nh, err := app.netboxUIHandler(); err != nil {
|
||||
log.Printf("netbox ui: %v; not served", err)
|
||||
} else {
|
||||
nsrv := &http.Server{Addr: cfg.IPAM.uiListen(), Handler: nh, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute, TLSConfig: tsrv.TLSConfig.Clone()}
|
||||
go func() {
|
||||
log.Printf("netbox ui on %s%s", cfg.IPAM.uiListen(), app.netboxUIPath())
|
||||
if err := nsrv.ListenAndServeTLS("", ""); err != nil {
|
||||
log.Printf("netbox ui listener: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second}
|
||||
srv := &http.Server{Addr: cfg.Listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
|
||||
log.Printf("rollcall listening on %s (image=%s tftp=%s agent_token=%v)", cfg.Listen, cfg.ImageDir, cfg.TFTPRoot, cfg.AgentToken != "")
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// UI files are revalidated every time and come back 304 while the build is the same
|
||||
func TestUIRevalidates(t *testing.T) {
|
||||
h := uiHeaders(http.FileServer(http.FS(mustSub())))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/room3d.js", nil))
|
||||
if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-cache" || rec.Header().Get("ETag") != uiETag {
|
||||
t.Fatalf("%d %q %q", rec.Code, rec.Header().Get("Cache-Control"), rec.Header().Get("ETag"))
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/room3d.js", nil)
|
||||
req.Header.Set("If-None-Match", uiETag)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Fatalf("revalidation: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func mustSub() fs.FS { sub, _ := fs.Sub(webFS, "web"); return sub }
|
||||
@@ -124,6 +124,9 @@ type nodeAssignment struct {
|
||||
}
|
||||
|
||||
func (s *Store) Assign(ctx context.Context, id string, n nodeAssignment) error {
|
||||
if !validDomain(n.Name) {
|
||||
return fmt.Errorf("node name %q is not a host name (letters, digits, hyphens)", n.Name)
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE machines SET profile=$2, node_name=$3, mgmt_ip=$4, ib_ip=$5,
|
||||
node_token_hash=$6, node_token=$7, state='stateless', booted_at=NULL WHERE id=$1`,
|
||||
id, n.Profile, n.Name, nilIfEmpty(n.MgmtIP), nilIfEmpty(n.IBIP), n.TokenHash, n.Token)
|
||||
@@ -379,6 +382,7 @@ var tmplFuncs = template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"pct": func(v, p int) int { return v * p / 100 },
|
||||
"seq": func(n int) []int {
|
||||
n = max(0, min(n, 4096)) // match rules run on every commissioning: no unbounded loops
|
||||
s := make([]int, n)
|
||||
for i := range s {
|
||||
s[i] = i
|
||||
@@ -469,7 +473,7 @@ func (a *App) buildCtx(ctx context.Context, m *Machine) (*overlayCtx, error) {
|
||||
}
|
||||
caPEM := ""
|
||||
if a.ca != nil {
|
||||
caPEM = string(a.ca.PEM)
|
||||
caPEM = string(a.ca.TrustPEM) // during a root rotation both
|
||||
}
|
||||
return &overlayCtx{Node: me, Nodes: nodes, Profile: *p, Overlays: overlays, CA: caPEM, Vars: vars, Server: a.urlFor(m),
|
||||
Now: time.Now().Format(time.RFC3339)}, nil
|
||||
@@ -650,6 +654,7 @@ func (a *App) statelessAppend(m *Machine, p *Profile, token string) string {
|
||||
"hostname=" + deref(m.NodeName),
|
||||
"live-config.nocomponents=user-setup,sudo",
|
||||
"rollcall.server=" + base, "rollcall.node=" + token}
|
||||
parts = append(parts, a.agentTLSArgs(base)...)
|
||||
if p.Append != "" {
|
||||
parts = append(parts, p.Append)
|
||||
}
|
||||
@@ -743,6 +748,11 @@ func (a *App) handleAssign(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 400, fmt.Errorf("profile is required"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.ToLower(req.Name)
|
||||
if req.Name != "" && !validDomain(req.Name) {
|
||||
fail(w, 400, fmt.Errorf("name %q is not a host name (letters, digits, hyphens)", req.Name))
|
||||
return
|
||||
}
|
||||
p, err := a.store.Profile(r.Context(), req.Profile)
|
||||
if err != nil {
|
||||
fail(w, 404, fmt.Errorf("profile %s: %w", req.Profile, err))
|
||||
@@ -867,6 +877,10 @@ func (a *App) handleNodeOverlay(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 401, errors.New("unknown node token"))
|
||||
return
|
||||
}
|
||||
if err := a.tokenSource(r.Context(), r, m, nil, "node"); err != nil {
|
||||
fail(w, 403, err)
|
||||
return
|
||||
}
|
||||
if m.State != "booted" {
|
||||
_ = a.store.AddEvent(r.Context(), m.ID, "booted", r.RemoteAddr)
|
||||
log.Printf("node %s (%s) booted from %s", deref(m.NodeName), m.ID, r.RemoteAddr)
|
||||
@@ -893,7 +907,12 @@ func (a *App) handleRender(w http.ResponseWriter, r *http.Request) {
|
||||
if stage == "" {
|
||||
stage = "system"
|
||||
}
|
||||
data, err := a.renderFile(r.Context(), r.PathValue("profile"), stage, path.Clean(r.PathValue("path")))
|
||||
rel := path.Clean("/" + r.PathValue("path"))[1:] // rooted first, so ".." cannot climb out of the overlay
|
||||
if (stage != "system" && stage != "runtime") || rel == "" {
|
||||
fail(w, 400, errors.New("stage is system or runtime, path a file in the overlay"))
|
||||
return
|
||||
}
|
||||
data, err := a.renderFile(r.Context(), r.PathValue("profile"), stage, rel)
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
|
||||
@@ -73,6 +73,7 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
finished timestamptz,
|
||||
error text
|
||||
);
|
||||
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS bound_ip text;
|
||||
CREATE INDEX IF NOT EXISTS jobs_machine ON jobs(machine_id, created);
|
||||
CREATE INDEX IF NOT EXISTS jobs_token ON jobs(token_hash);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
@@ -100,6 +101,57 @@ CREATE TABLE IF NOT EXISTS racks (
|
||||
comment text,
|
||||
PRIMARY KEY (building, room, name)
|
||||
);
|
||||
ALTER TABLE racks ADD COLUMN IF NOT EXISTS x int;
|
||||
ALTER TABLE racks ADD COLUMN IF NOT EXISTS y int;
|
||||
ALTER TABLE racks ADD COLUMN IF NOT EXISTS facing text;
|
||||
CREATE TABLE IF NOT EXISTS switches (
|
||||
name text PRIMARY KEY,
|
||||
building text,
|
||||
room text,
|
||||
rack text,
|
||||
unit int,
|
||||
height int,
|
||||
model text,
|
||||
source text NOT NULL DEFAULT 'local'
|
||||
);
|
||||
ALTER TABLE switches ADD COLUMN IF NOT EXISTS role text;
|
||||
CREATE TABLE IF NOT EXISTS intermediates (
|
||||
name text PRIMARY KEY,
|
||||
permits text NOT NULL,
|
||||
days int NOT NULL,
|
||||
cert text NOT NULL,
|
||||
not_after timestamptz NOT NULL,
|
||||
issuer text NOT NULL,
|
||||
token_hash text NOT NULL,
|
||||
created timestamptz NOT NULL DEFAULT now(),
|
||||
renewed timestamptz,
|
||||
last_seen timestamptz,
|
||||
last_error text
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS cables (
|
||||
machine_id text NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
|
||||
port text NOT NULL,
|
||||
switch text NOT NULL,
|
||||
switch_port text NOT NULL DEFAULT '',
|
||||
source text NOT NULL,
|
||||
PRIMARY KEY (machine_id, port, source)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
name text PRIMARY KEY,
|
||||
slug text,
|
||||
lat double precision,
|
||||
lon double precision,
|
||||
address text,
|
||||
source text NOT NULL DEFAULT 'local'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
building text NOT NULL,
|
||||
name text NOT NULL,
|
||||
width int NOT NULL DEFAULT 0,
|
||||
depth int NOT NULL DEFAULT 0,
|
||||
source text NOT NULL DEFAULT 'local',
|
||||
PRIMARY KEY (building, name)
|
||||
);
|
||||
ALTER TABLE machines ADD COLUMN IF NOT EXISTS rack_controller text;
|
||||
ALTER TABLE machines ADD COLUMN IF NOT EXISTS net_layout text;
|
||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS net_layout text;
|
||||
@@ -164,8 +216,15 @@ CREATE TABLE IF NOT EXISTS rack_controllers (
|
||||
registered timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_serial text;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS tls_url text;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS prev_cert_serial text;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS prev_cert_until timestamptz;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS cert_expires timestamptz;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS arch text;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS ca_bundle_sha256 text;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS client_ca text;
|
||||
ALTER TABLE rack_controllers ADD COLUMN IF NOT EXISTS server_ca text;
|
||||
ALTER TABLE vlans ADD COLUMN IF NOT EXISTS dhcp_mode text NOT NULL DEFAULT '';
|
||||
CREATE TABLE IF NOT EXISTS join_tokens (
|
||||
hash text PRIMARY KEY,
|
||||
name text,
|
||||
@@ -235,6 +294,12 @@ func openStore(ctx context.Context, url string) (*Store, error) {
|
||||
if _, err := pool.Exec(ctx, adminCertSchema); err != nil {
|
||||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, dnsRecordsSchema); err != nil {
|
||||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, firmwareSchema); err != nil {
|
||||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
|
||||
+171
-39
@@ -2,6 +2,21 @@
|
||||
// Rollcall web UI: a fleet view first (filters, selection, bulk actions), machine detail second.
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
|
||||
// nbUI: where NetBox's UI is: ui_url, or Rollcall's NetBox listener on this
|
||||
// host (another port: its own origin), "" without one
|
||||
function nbUI() {
|
||||
const ui = S.ipam && S.ipam.provider === 'netbox' && S.ipam.ui; if (!ui) return '';
|
||||
if (ui.url) return ui.url;
|
||||
if (!ui.port) return '';
|
||||
const u = new URL(location.href); u.port = ui.port; u.pathname = ui.path; u.search = ''; u.hash = '';
|
||||
return u.href.replace(/\/$/, '');
|
||||
}
|
||||
// nbLink: "open in NetBox" for a NetBox page (path and query), when NetBox is the IPAM
|
||||
function nbLink(path, label = 'NetBox ↗') {
|
||||
const ui = nbUI();
|
||||
return ui ? `<a class="nb-link" href="${esc(ui + path)}" target="_blank" rel="noopener" title="open in NetBox">${esc(label)}</a>` : '';
|
||||
}
|
||||
const nbQ = o => '?' + new URLSearchParams(Object.entries(o).filter(([, v]) => v)).toString();
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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`; if (s < 5400) return `${(s / 60) | 0}m`; if (s < 172800) return `${(s / 3600) | 0}h`; return `${(s / 86400) | 0}d`; };
|
||||
@@ -14,7 +29,7 @@ let token = '';
|
||||
try { token = localStorage.getItem('rollcall.token') || ''; } catch (e) { /* storage unavailable */ }
|
||||
// ?token=… on the URL signs in once and is removed from the address bar (for bookmarks/kiosks)
|
||||
{ const u = new URL(location.href); const t = u.searchParams.get('token'); if (t) { token = t; try { localStorage.setItem('rollcall.token', t); } catch (e) { /* ignore */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + u.search + u.hash); } }
|
||||
const S = { machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], subnets: [], controllers: [], layouts: [], fabrics: [], ips: [], ipam: null, sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false };
|
||||
const S = { sites: [], rooms: [], machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], subnets: [], controllers: [], layouts: [], fabrics: [], ips: [], ipam: null, sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false };
|
||||
const locStr = l => { if (!l) return ''; const p = [l.building, l.room, l.rack].filter(Boolean).join(' / '); return p + (l.unit ? ` U${l.unit}` : ''); };
|
||||
let timer = null;
|
||||
|
||||
@@ -76,11 +91,13 @@ async function refresh(full) {
|
||||
$('#n-net').textContent = off ? `${off} offline` : (subnets.length || '');
|
||||
const page = route().page;
|
||||
if (full || page === 'overview' || page === 'jobs') S.jobs = await api('GET', '/api/v1/jobs');
|
||||
if (full || page === 'overview') S.events = await api('GET', '/api/v1/events');
|
||||
if (full || page === 'overview') [S.events, S.inter] = await Promise.all([api('GET', '/api/v1/events'), api('GET', '/api/v1/ca/intermediates').catch(() => [])]);
|
||||
if (full || page === 'sites') [S.sites, S.rooms, S.cabling] = await Promise.all([api('GET', '/api/v1/sites'), api('GET', '/api/v1/rooms'), api('GET', '/api/v1/cabling').catch(() => null)]);
|
||||
if (full || page === 'overview' || page === 'network') {
|
||||
[S.ips, S.ipam] = await Promise.all([api('GET', '/api/v1/ips'), api('GET', '/api/v1/ipam').catch(() => null)]);
|
||||
}
|
||||
$('#n-machines').textContent = machines.length;
|
||||
const nb = $('#nav-netbox'), ui = nbUI(); nb.style.display = ui ? '' : 'none'; if (ui) nb.href = ui + '/'; // .side a sets display: hidden would not hold
|
||||
const active = S.jobs.filter(j => j.state === 'pending' || j.state === 'running').length;
|
||||
$('#n-jobs').textContent = active ? `${active} active` : '';
|
||||
$('#status').textContent = `updated ${new Date().toLocaleTimeString()}`;
|
||||
@@ -102,7 +119,7 @@ function schedule() {
|
||||
function route() {
|
||||
const h = location.hash.replace(/^#\/?/, '').split('/');
|
||||
if (h[0] === 'machine' && h[1]) return { page: 'machine', id: h[1], tab: h[2] || 'overview' };
|
||||
return { page: h[0] || 'overview', arg: h[1] };
|
||||
return { page: h[0] || 'overview', arg: h[1], arg2: h[2], arg3: h[3] };
|
||||
}
|
||||
window.addEventListener('hashchange', () => { S.sel.clear(); render(); });
|
||||
function nav(page) { location.hash = '#/' + page; }
|
||||
@@ -117,15 +134,17 @@ function render() {
|
||||
const r = route();
|
||||
$$('.side a[data-nav]').forEach(a => a.classList.toggle('active', a.dataset.nav === r.page || (r.page === 'machine' && a.dataset.nav === 'machines')));
|
||||
const main = $('#main');
|
||||
if (r.page !== 'sites') { main.dataset.view = ''; if (SITE.map) { SITE.map.remove(); SITE.map = null; } SITE.edit = null; room3dStop(); }
|
||||
switch (r.page) {
|
||||
case 'machines': main.innerHTML = pageMachines(); wireMachines(); break;
|
||||
case 'machine': if (!detailCache[r.id]) { main.innerHTML = '<p class="muted">loading…</p>'; loadDetail(r.id); } else { main.innerHTML = pageMachine(detailCache[r.id], r.tab); wireMachine(detailCache[r.id]); } break;
|
||||
case 'jobs': main.innerHTML = pageJobs(); wireJobs(); break;
|
||||
case 'racks': main.innerHTML = pageRacks(); wireRacks(); break;
|
||||
case 'sites': renderSites(); break;
|
||||
case 'network': main.innerHTML = pageNetwork(); wireNetwork(); break;
|
||||
case 'tags': main.innerHTML = pageTags(); wireTags(); break;
|
||||
case 'profiles': main.innerHTML = pageProfiles(); wireProfiles(); break;
|
||||
case 'images': main.innerHTML = pageImages(); break;
|
||||
case 'images': main.innerHTML = pageImages(); wireImages(); break;
|
||||
default: main.innerHTML = pageOverview(); wireOverview();
|
||||
}
|
||||
}
|
||||
@@ -162,7 +181,10 @@ function pageOverview() {
|
||||
['Jobs failed (last 100)', failed.length ? `<span class="bad">${failed.length}</span>` : '0'], ['Oldest ready', (() => { const r = ready.slice().sort((a, b) => new Date(a.last_seen) - new Date(b.last_seen))[0]; return r ? `${esc(nameOf(r))} · ${ago(r.last_seen)}` : '—'; })()]])}</div></div>`;
|
||||
|
||||
// attention
|
||||
const inter = S.inter || [], interBad = inter.filter(x => x.problem);
|
||||
const attention = kv([
|
||||
...(inter.length ? [['Intermediate CAs', interBad.length ? interBad.map(x => `<span class="warn">${esc(x.name)}: ${esc(x.problem)}</span>`).join('<br>')
|
||||
: `<span class="ok">${inter.length} renewed by their hosts</span> <span class="muted">(next end ${esc(inter.map(x => x.not_after).sort()[0].slice(0, 10))})</span>`]] : []),
|
||||
['Rack controllers', offline.length ? `<span class="bad">${offline.map(c => esc(c.name)).join(', ')} offline</span> <a href="#/network">show</a>` : `<span class="ok">${S.controllers.length} online</span>`],
|
||||
['External IPAM', !S.ipam || !S.ipam.provider ? '<span class="muted">built-in only</span>' : ipamLast && ipamLast.error ? `<span class="bad">${esc(ipamLast.error.slice(0, 80))}</span>` : `<span class="ok">${esc(S.ipam.provider)} in sync</span>${ipamLast && ipamLast.conflicts && ipamLast.conflicts.length ? ` <span class="warn">${ipamLast.conflicts.length} conflicts</span>` : ''} <span class="muted">${ipamLast && ipamLast.at ? ago(ipamLast.at) + ' ago' : ''}</span>`],
|
||||
['BMC not configured', bmcBad ? `<span class="bad">${bmcBad}</span> <a href="#/machines">show</a>` : '<span class="ok">none</span>'],
|
||||
@@ -175,7 +197,8 @@ function pageOverview() {
|
||||
const subRows = S.subnets.filter(s => s.size).sort((a, b) => b.used / b.size - a.used / a.size).slice(0, 6)
|
||||
.map(s => [`<span class="mono">${esc(s.cidr)}</span>`, s.used, `${s.purpose} · ${s.used} of ${s.size} addresses`, '', s.size, `${s.used}/${s.size}`]);
|
||||
const maxPct = S.subnets.filter(s => s.size).map(s => [s, s.used / s.size]).sort((a, b) => b[1] - a[1])[0];
|
||||
const network = `<div class="topo-wrap">${topoSVG({ compact: true })}</div>
|
||||
// a drawing for a small site; one row per fabric for a large one (netview.js)
|
||||
const network = `${nvLarge() ? nvFabricSummaryHTML() : `<div class="topo-wrap">${topoSVG({ compact: true })}</div>`}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||||
<div><div class="muted" style="font-size:11.5px;margin-bottom:4px">ADDRESSES IN USE</div>${hbars(subRows)}</div>
|
||||
<div>${kv([['Subnets', `${S.subnets.length} (${S.subnets.filter(s => s.family === 6).length} IPv6)`], ['VLANs with DHCP', String(new Set(S.subnets.filter(s => s.dhcp).map(s => s.vlan_id)).size)],
|
||||
@@ -226,6 +249,7 @@ function pageOverview() {
|
||||
}
|
||||
function wireOverview() {
|
||||
wireTopo();
|
||||
wireFabricSummary();
|
||||
$$('[data-go]').forEach(el => { if (el.closest('.topo') && !el.dataset.go.includes('/')) return; el.onclick = ev => { ev.preventDefault(); const [p, st] = el.dataset.go.split('/'); S.states.clear(); S.tagf.clear(); if (st) S.states.add(st); nav(p); }; });
|
||||
$$('[data-tag]').forEach(el => el.onclick = () => { S.states.clear(); S.tagf.clear(); S.tagf.add(el.dataset.tag); nav('machines'); });
|
||||
}
|
||||
@@ -281,7 +305,7 @@ function pageMachines() {
|
||||
<td class="check"><input type="checkbox" data-sel="${esc(m.id)}"${S.sel.has(m.id) ? ' checked' : ''}></td>
|
||||
<td><b>${esc(nameOf(m))}</b><br><span class="muted mono">${esc(m.mgmt_ip || m.id)}</span></td>
|
||||
<td>${stateCell(m)}</td>
|
||||
<td>${m.location && (m.location.rack || m.location.room || m.location.building) ? `${esc([m.location.building, m.location.room].filter(Boolean).join(' / '))}<br><b>${esc(m.location.rack || '')}</b>${m.location.unit ? ` <span class="muted">U${m.location.unit}</span>` : ''}` : '<span class="muted">—</span>'}</td>
|
||||
<td>${m.location && (m.location.rack || m.location.room || m.location.building) ? `${esc([m.location.building, m.location.room].filter(Boolean).join(' / '))}<br><b>${esc(m.location.rack || '')}</b>${m.location.unit ? ` <span class="muted">U${esc(m.location.unit)}</span>` : ''}` : '<span class="muted">—</span>'}</td>
|
||||
<td>${m.profile ? esc(m.profile) : m.os ? `${esc(m.os)}<br><span class="muted mono">${esc(m.os_version || '')}</span>` : '<span class="muted">—</span>'}</td>
|
||||
<td>${tagPills(m)}</td>
|
||||
<td>${esc(((m.manufacturer || '') + ' ' + (m.product || '')).trim().slice(0, 26))}<br><span class="muted mono">${esc((m.serial || '').slice(0, 20))}</span></td>
|
||||
@@ -355,7 +379,7 @@ async function each(ids, label, fn) {
|
||||
await refresh(true);
|
||||
}
|
||||
const act = {
|
||||
power: (ids, action) => each(ids, `power ${action}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/power`, { action }); return r.output || (r.ok ? 'ok' : 'failed'); }),
|
||||
power: (ids, action) => each(ids, `power ${action}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/power`, { action }); if (!r.ok) throw new Error(r.error || r.output || 'the BMC command failed'); return r.output || 'ok'; }),
|
||||
bootdev: (ids, device) => each(ids, `bootdev ${device}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/bootdev`, { device }); return r.output || (r.ok ? 'ok' : 'failed'); }),
|
||||
async deploy(ids) {
|
||||
const one = ids.length === 1 ? S.machines.find(m => m.id === ids[0]) : null;
|
||||
@@ -446,14 +470,14 @@ function table(cols, rows) { return rows.length ? `<table><thead><tr>${cols.map(
|
||||
function jobsTable(jobs, withMachine) {
|
||||
return table([...(withMachine ? ['Machine'] : []), 'Job', 'Kind', 'State', 'Target', 'Progress', 'Started', 'Finished', 'Error'], jobs.map(j => {
|
||||
const sp = j.spec || {}, p = j.progress || {}; const m = S.machines.find(x => x.id === j.machine_id);
|
||||
const prog = (j.state === 'running' || j.state === 'pending') ? `<div class="bar"><span style="width:${Math.round(p.pct || 0)}%"></span></div><span class="muted">${esc(p.phase || '')} ${Math.round(p.pct || 0)}% ${esc((p.msg || '').slice(0, 60))}</span>` : (p.details && p.details.seconds ? `<span class="muted">${p.details.seconds}s</span>` : '');
|
||||
const prog = (j.state === 'running' || j.state === 'pending') ? `<div class="bar"><span style="width:${Math.round(p.pct || 0)}%"></span></div><span class="muted">${esc(p.phase || '')} ${Math.round(p.pct || 0)}% ${esc((p.msg || '').slice(0, 60))}</span>` : (p.details && p.details.seconds ? `<span class="muted">${esc(p.details.seconds)}s</span>` : '');
|
||||
return [...(withMachine ? [`<a href="#/machine/${esc(j.machine_id)}">${esc(m ? nameOf(m) : j.machine_id)}</a>`] : []), `<span class="mono">${esc(j.id)}</span>`, esc(j.kind), pill(j.state), `${esc(sp.os || '')}${sp.version ? ' <span class="muted mono">' + esc(sp.version) + '</span>' : ''}${sp.disk ? ' → <span class="mono">' + esc(sp.disk) + '</span>' : ''}`, prog, `<span class="muted" title="${esc(when(j.started || j.created))}">${ago(j.started || j.created)}</span>`, `<span class="muted">${esc(when(j.finished))}</span>`, j.error ? `<span class="bad">${esc(j.error)}</span>` : ''];
|
||||
}));
|
||||
}
|
||||
function pageMachine(d, tab) {
|
||||
const s = d.summary, inv = d.inventory || {}, sys = inv.system || {}, cpu = inv.cpu || {}, mem = inv.memory || {}, st = inv.storage || {}, b = d.bmc || {};
|
||||
const m = S.machines.find(x => x.id === s.id) || s;
|
||||
const tabs = ['overview', 'hardware', 'network', 'bmc', 'jobs', 'events', 'raw'];
|
||||
const tabs = ['overview', 'hardware', 'network', 'bmc', 'firmware', 'jobs', 'events', 'raw'];
|
||||
let body = '';
|
||||
if (tab === 'overview') {
|
||||
const job = s.job || (d.jobs || [])[0]; const p = (job && job.progress) || {}; const active = job && (job.state === 'pending' || job.state === 'running');
|
||||
@@ -471,15 +495,15 @@ function pageMachine(d, tab) {
|
||||
['BMC', b.address ? `<span class="dot ${b.configured ? 'ok' : 'bad'}"></span><span class="mono">${esc(b.address)}</span> ${esc(b.user || '')} · ${d.has_bmc_secret ? '<span class="ok">credentials stored</span>' : '<span class="bad">no credentials</span>'}` : '<span class="bad">none</span>'],
|
||||
['Current job', job ? `<span class="mono">${esc(job.id)}</span> ${esc(job.kind)} ${pill(job.state)}${active ? `<div class="bar" style="margin-top:4px"><span style="width:${Math.round(p.pct || 0)}%"></span></div><span class="muted">${esc(p.phase || '')} ${Math.round(p.pct || 0)}% ${esc(p.msg || '')}</span>` : ''}${job.error ? `<br><span class="bad">${esc(job.error)}</span>` : ''}` : ''],
|
||||
]))}
|
||||
${card(`Storage · ${st.disk_count ?? 0} disks · ${human(st.total_bytes)}`, table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', '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>`, sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; })))}
|
||||
${card(`Storage · ${esc(st.disk_count ?? 0)} disks · ${human(st.total_bytes)}`, table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', '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>`, sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; })))}
|
||||
${card(`Network · ${(inv.network || []).length} NICs`, table(['If', 'MAC', 'Speed', 'Link', 'Switch port'], (inv.network || []).map(n => { const l = ((inv.lldp || {})[n.name]); const e = l ? (Array.isArray(l) ? l[0] : l) : null; const sw = e ? `${Object.keys(e.chassis || {})[0] || ''} ${((e.port || {}).id || {}).value || ''}` : ''; return [esc(n.name), `<span class="mono">${esc(n.mac)}</span>`, esc(n.speed), n.link === true ? '<span class="ok">up</span>' : n.link === false ? '<span class="muted">down</span>' : '', esc(sw)]; })))}
|
||||
${card('Recent events', table(['When', 'Event', 'Detail'], (d.events || []).slice(-8).reverse().map(e => [`<span class="muted" title="${esc(when(e.ts))}">${ago(e.ts)}</span>`, esc(e.event), `<span class="mono muted">${esc(e.detail.slice(0, 90))}</span>`])), true)}
|
||||
</div>`;
|
||||
} else if (tab === 'hardware') {
|
||||
body = `<div class="grid">
|
||||
${card('CPU', kv([['Model', esc(cpu['Model name'])], ['Sockets × cores × threads', `${esc(cpu['Socket(s)'])} × ${esc(cpu['Core(s) per socket'])} × ${esc(cpu['Thread(s) per core'])} = ${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(' '))]]))}
|
||||
${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>`])))}
|
||||
${card(`Storage · ${st.disk_count ?? 0} disks · ${human(st.total_bytes)}`, table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', 'FW', 'Hours', '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), esc(sm.power_on_hours), sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; })) + ((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)}
|
||||
${card(`Memory ${human(mem.total_bytes)} · ${esc(mem.dimms_populated ?? '?')}/${esc(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>`])))}
|
||||
${card(`Storage · ${esc(st.disk_count ?? 0)} disks · ${human(st.total_bytes)}`, table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', 'FW', 'Hours', '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), esc(sm.power_on_hours), sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; })) + ((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)}
|
||||
${(inv.gpus || []).length ? 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)])), true) : ''}
|
||||
${card('System', kv([['Board', esc([sys['baseboard-manufacturer'], sys['baseboard-product-name'], sys['baseboard-serial-number']].filter(Boolean).join(' / '))], ['SKU', esc(sys['system-sku-number'])], ['Chassis', esc([sys['chassis-type'], sys['chassis-serial-number'], sys['chassis-asset-tag']].filter(Boolean).join(' / '))], ['TPM', esc((inv.tpm || []).join(', ') || 'none')], ['Kernel (commissioning)', esc((inv.kernel || '').split(' (')[0])], ['Agent run', esc(inv.duration_s ? inv.duration_s + ' s' : '')]]))}
|
||||
${(inv.pci || []).length ? 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) : ''}
|
||||
@@ -496,17 +520,19 @@ function pageMachine(d, tab) {
|
||||
} else if (tab === 'bmc') {
|
||||
body = `<div class="actions" style="margin-bottom:12px"><button class="btn primary" id="bmc-edit">Edit BMC…</button><button class="btn" id="bmc-probe" title="ask the BMC whether it speaks Redfish with the stored account">Detect Redfish</button><span class="muted">Commands run on rack controller ${esc(s.rack_controller || '')}</span></div>
|
||||
<div class="grid">${card('BMC', kv([['Driver', `<b>${esc(b.driver || 'ipmi')}</b>${b.driver_set ? ' <span class="muted">(set by operator)</span>' : ''}${b.redfish ? ` · Redfish ${esc(b.redfish.redfish)} ${esc(b.redfish.manufacturer || '')} ${esc(b.redfish.model || '')}` : ''}`],
|
||||
['Redfish offers', b.redfish && (b.redfish.reset_types || b.redfish.boot_targets) ? `<span class="mono">${esc((b.redfish.reset_types || []).join(' '))}</span><br><span class="muted">boot</span> <span class="mono">${esc((b.redfish.boot_targets || []).join(' '))}</span>${b.redfish.boot_mode ? ` · mode ${esc(b.redfish.boot_mode)}` : ''}${b.redfish.profile ? `<br><span class="muted">profile ${esc(b.redfish.profile)}</span>` : ''}` : ''],
|
||||
['Settings', b.extra && Object.keys(b.extra).length ? `<span class="mono">${esc(Object.entries(b.extra).map(([k, v]) => k + '=' + v).join(' '))}</span>` : ''],
|
||||
['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 the server</span>' : '<span class="bad">none</span>'], ['Configured', b.configured ? '<span class="ok">yes</span>' : '<span class="bad">no</span>'], ['Verified out-of-band', 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 ? card('User table before commissioning', table(['ID', 'Name', 'Priv', 'IPMI'], b.users_before.map(u => [u.id, esc(u.name), esc(u.priv), u.ipmi_msg ? 'yes' : 'no']))) : ''}
|
||||
${(b.users_before || []).length ? card('User table before commissioning', table(['ID', 'Name', 'Priv', 'IPMI'], b.users_before.map(u => [esc(u.id), esc(u.name), esc(u.priv), u.ipmi_msg ? 'yes' : 'no']))) : ''}
|
||||
${b.fru ? card('FRU', `<pre class="json">${esc(JSON.stringify(b.fru, null, 1))}</pre>`) : ''}</div>`;
|
||||
} else if (tab === 'jobs') body = jobsTable(d.jobs || [], false);
|
||||
} else if (tab === 'firmware') body = `<div class="actions" style="margin-bottom:12px"><button class="btn" id="fw-read" title="read the firmware inventory from the BMC (Redfish)">Read from BMC</button><span class="muted">Packages come from the repository (OS images page); the update runs on rack controller ${esc(s.rack_controller || '')}</span></div><div id="fwbox" class="muted">loading…</div>`;
|
||||
else if (tab === 'jobs') body = jobsTable(d.jobs || [], false);
|
||||
else if (tab === 'events') body = table(['Time', 'Event', 'Detail'], (d.events || []).slice().reverse().map(e => [esc(when(e.ts)), esc(e.event), `<span class="mono">${esc(e.detail)}</span>`]));
|
||||
else body = `<pre class="json">${esc(JSON.stringify(d, null, 1))}</pre>`;
|
||||
|
||||
const hasJob = !!s.job;
|
||||
return `
|
||||
<div class="d-head"><a href="#/machines" class="btn ghost">← Machines</a><h1>${esc(nameOf(s))}</h1>${pill(s.state)}<span class="id">${esc(s.id)}</span>
|
||||
<div class="d-head"><a href="#/machines" class="btn ghost">← Machines</a><h1>${esc(nameOf(s))}</h1>${pill(s.state)}<span class="id">${esc(s.id)}</span>${nbLink('/dcim/devices/' + nbQ({ ['cf_' + ((S.ipam || {}).machine_field || 'rollcall_machine')]: s.id }), 'NetBox device ↗')}
|
||||
<div class="right" style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap">
|
||||
${menuBtn('Power', [['status', 'Status'], ['on', 'Power on'], ['off', 'Power off'], ['soft', 'Soft off'], ['cycle', 'Power cycle'], ['reset', 'Reset']], 'dpower')}
|
||||
${menuBtn('Boot device', [['pxe', 'PXE'], ['disk', 'Disk'], ['bios', 'BIOS setup'], ['cdrom', 'CD-ROM']], 'dboot')}
|
||||
@@ -521,6 +547,7 @@ function pageMachine(d, tab) {
|
||||
function wireMachine(d) {
|
||||
const id = d.summary.id, ids = [id];
|
||||
if (route().tab === 'network') wireNetPlan(id);
|
||||
if (route().tab === 'firmware') loadFirmware(id, false);
|
||||
wireMenus();
|
||||
$$('#dpower [data-item]').forEach(b => b.onclick = () => act.power(ids, b.dataset.item));
|
||||
$$('#dboot [data-item]').forEach(b => b.onclick = () => act.bootdev(ids, b.dataset.item));
|
||||
@@ -534,6 +561,54 @@ function wireMachine(d) {
|
||||
$$('[data-untag]').forEach(a => a.onclick = ev => { ev.preventDefault(); each(ids, 'untag', async () => (await api('POST', `/api/v1/machines/${id}/tags`, { remove: [a.dataset.untag] })).effective.join(' ')); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- firmware
|
||||
async function loadFirmware(id, read) {
|
||||
const box = $('#fwbox'); if (!box) return;
|
||||
const btn = $('#fw-read'); if (btn) btn.onclick = () => loadFirmware(id, true);
|
||||
if (read) box.innerHTML = '<span class="muted">reading the inventory from the BMC…</span>';
|
||||
let d;
|
||||
try { d = await api(read ? 'POST' : 'GET', `/api/v1/machines/${id}/firmware`); } catch (e) { box.innerHTML = `<span class="bad">${esc(e.message)}</span>`; return; }
|
||||
const inv = (d.inventory || {}).components || [];
|
||||
const offers = d.offers || [];
|
||||
box.className = '';
|
||||
box.innerHTML = `<div class="grid">
|
||||
${card(`Installed · ${inv.length} components${d.inventory && d.inventory.at && inv.length ? ` · read ${esc(ago(d.inventory.at))} ago` : ''}`, inv.length ? table(['Component', 'Name', 'Version', 'Updateable'], inv.map(c => [esc(c.component), esc(c.name), `<span class="mono">${esc(c.version)}</span>`, c.updateable ? 'yes' : '<span class="muted">no</span>'])) : '<span class="muted">not read yet: Read from BMC</span>', true)}
|
||||
${card(`Repository for ${esc(d.driver || '?')} ${esc(d.model || '')}`, offers.length ? table(['Component', 'Version', 'Installed now', '', ''], offers.map(o => [esc(o.package.component), `<span class="mono">${esc(o.package.version)}</span>`, `<span class="mono">${esc(o.installed || '—')}</span>`, o.same ? '<span class="ok">installed</span>' : '<span class="warn">differs</span>', `<button class="btn sm" data-fwpkg="${esc(o.package.id)}">${o.same ? 'Reinstall…' : 'Update…'}</button>`])) : '<span class="muted">no package in the repository for this vendor and model</span>', true)}
|
||||
</div>`;
|
||||
$$('[data-fwpkg]').forEach(b => b.onclick = () => fwUpdate(id, offers.find(o => o.package.id === b.dataset.fwpkg).package));
|
||||
}
|
||||
async function fwUpdate(id, p) {
|
||||
const v = await dialog(`Update ${p.component} to ${p.version}`, [
|
||||
{ name: 'apply', label: 'Apply', type: 'select', value: '', options: [['', "the BMC's default"], ['OnReset', 'at the next reset'], ['Immediate', 'immediately']] },
|
||||
{ name: 'reboot', label: 'Power-cycle the host afterwards and verify the version', type: 'checkbox', value: p.component === 'bios' },
|
||||
{ name: 'force', label: 'Send even if vendor or model does not match', type: 'checkbox', value: false },
|
||||
], `${p.file} (${human(p.size)}). The BMC checks the package itself; an update cannot be cancelled once the BMC has it. A BMC update restarts the BMC for a few minutes.`, 'Start update');
|
||||
if (!v) return;
|
||||
try { const r = await api('POST', `/api/v1/machines/${id}/firmware/update`, { package: p.id, apply: v.apply, reboot: v.reboot, force: v.force }); toast(`firmware job ${r.job} started`); await loadDetail(id); } catch (e) { toast(e.message, true); }
|
||||
}
|
||||
function wireImages() {
|
||||
const el = $('#fwrepo'); if (!el) return;
|
||||
const show = () => api('GET', '/api/v1/firmware').then(ps => {
|
||||
el.className = '';
|
||||
el.innerHTML = ps.length ? table(['Package', 'Vendor', 'Component', 'Version', 'Models', 'Size', 'Imported', ''], ps.map(p => [`<span class="mono">${esc(p.id)}</span><br><span class="muted">${esc(p.file)}</span>`, esc(p.vendor), esc(p.component), `<span class="mono">${esc(p.version)}</span>`, esc((p.models || []).join(', ') || 'any'), human(p.size), esc(when(p.imported)), `<button class="btn sm" data-fwrm="${esc(p.id)}">remove</button>`])) : '<span class="muted">no packages yet</span>';
|
||||
$$('[data-fwrm]').forEach(b => b.onclick = async () => { if (confirm(`Remove ${b.dataset.fwrm}?`)) { try { await api('DELETE', '/api/v1/firmware/' + b.dataset.fwrm); show(); } catch (e) { toast(e.message, true); } } });
|
||||
}).catch(e => { el.innerHTML = `<span class="bad">${esc(e.message)}</span>`; });
|
||||
show();
|
||||
$('#fwimport').onclick = async () => {
|
||||
const drivers = await api('GET', '/api/v1/power-drivers').then(ds => ds.filter(d => d.family === 'redfish' && d.name !== 'redfish').map(d => [d.name, d.description]), () => []);
|
||||
const v = await dialog('Import a firmware package', [
|
||||
{ name: 'url', label: 'Download URL', placeholder: 'https://dl.dell.com/FOLDER.../BIOS_XXXX_WN64_2.19.1.EXE' },
|
||||
{ name: 'vendor', label: 'Vendor (BMC driver)', type: 'select', options: drivers },
|
||||
{ name: 'component', label: 'Component', type: 'select', options: ['bios', 'bmc', 'nic', 'raid', 'cpld', 'psu', 'drive', 'other'].map(c => [c, c]) },
|
||||
{ name: 'version', label: 'Version', placeholder: 'as the component reports it once installed' },
|
||||
{ name: 'models', label: 'Models', placeholder: 'R650, R750 (empty: any model of this vendor)' },
|
||||
{ name: 'sha256', label: 'SHA-256', placeholder: "from the vendor's download page (recommended)" },
|
||||
], 'The region downloads the package; rack controllers serve it to the BMCs. A local file: rollcall firmware import FILE on the region.', 'Import');
|
||||
if (!v) return;
|
||||
try { const p = await api('POST', '/api/v1/firmware', { url: v.url.trim(), vendor: v.vendor, component: v.component, version: v.version.trim(), models: v.models.split(',').map(x => x.trim()).filter(Boolean), sha256: v.sha256.trim() }); toast(`imported ${p.id}`); show(); } catch (e) { toast(e.message, true); }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- jobs
|
||||
function pageJobs() {
|
||||
const active = S.jobs.filter(j => j.state === 'pending' || j.state === 'running'), rest = S.jobs.filter(j => !(j.state === 'pending' || j.state === 'running'));
|
||||
@@ -544,27 +619,71 @@ function pageJobs() {
|
||||
function wireJobs() { $('#refresh').onclick = () => refresh(true); }
|
||||
|
||||
// ---------------------------------------------------------------- racks
|
||||
// rackElev: a rack's front, servers spanning their height (shared by the
|
||||
// Racks page and the floor plans in sites.js)
|
||||
// rackBlocks: a rack's machines as blocks of units. A server spans its height
|
||||
// (size from the firmware or the model, 1U and !known when unknown); sleds of
|
||||
// one multi-node chassis share one block. top: the blocks drawn, by top unit;
|
||||
// a block overlapping one below it is left out and named in clash.
|
||||
function rackBlocks(rk) {
|
||||
const placed = S.machines.filter(m => m.location && m.location.rack);
|
||||
const lo = x => String(x || '').toLowerCase(); // a rack typed in by hand and the same from NetBox may differ in case
|
||||
const ms = placed.filter(m => lo(m.location.building) === lo(rk.building) && lo(m.location.room) === lo(rk.room) && lo(m.location.rack) === lo(rk.name));
|
||||
const H = rk.height || 42, noU = [], blocks = [];
|
||||
for (const m of ms) {
|
||||
if (!(m.location.unit > 0)) { noU.push(m); continue; }
|
||||
const sz = m.size || {}, h = Math.max(1, sz.u || 1), unit = m.location.unit;
|
||||
const b = sz.node && blocks.find(x => x.node && x.unit === unit);
|
||||
if (b) b.ms.push(m); else blocks.push({ unit, h, node: !!sz.node, known: !!sz.u, ms: [m] });
|
||||
}
|
||||
const clash = [];
|
||||
blocks.forEach((a, i) => blocks.slice(i + 1).forEach(b => { if (a.unit <= b.unit + b.h - 1 && b.unit <= a.unit + a.h - 1) clash.push(`${a.ms.map(nameOf).join('+')} and ${b.ms.map(nameOf).join('+')} overlap at U${Math.max(a.unit, b.unit)}`); }));
|
||||
const top = {}, covered = new Set();
|
||||
for (const b of blocks) { const t = Math.min(H, b.unit + b.h - 1); if (top[t] || [...Array(t - b.unit + 1).keys()].some(k => covered.has(b.unit + k))) continue; top[t] = b; for (let u = b.unit; u <= t; u++) covered.add(u); }
|
||||
return { ms, H, noU, top, covered, clash };
|
||||
}
|
||||
const worstState = ms => ms.map(x => x.state).sort((x, y) => ['bad', 'warn', 'ok', 'idle'].indexOf(stateClass(x)) - ['bad', 'warn', 'ok', 'idle'].indexOf(stateClass(y)))[0];
|
||||
|
||||
function rackElev(rk) {
|
||||
const keyOf = (b, rm, n) => `${b}|${rm}|${n}`;
|
||||
const { ms, H, noU, top, covered, clash } = rackBlocks(rk);
|
||||
const swAt = typeof switchesAt === 'function' ? switchesAt(rk) : {};
|
||||
let rows = '';
|
||||
for (let u = H; u >= 1; u--) {
|
||||
rows += `<div class="u">${u}</div>`;
|
||||
const b = top[u];
|
||||
if (b) {
|
||||
const m = b.ms[0], worst = worstState(b.ms);
|
||||
const span = Math.min(H, b.unit + b.h - 1) - b.unit + 1;
|
||||
const label = b.node ? `${b.ms.length} node${b.ms.length > 1 ? 's' : ''}: ${b.ms.map(nameOf).join(', ')}` : nameOf(m);
|
||||
const tip = b.ms.map(x => `${nameOf(x)} · ${x.state} · ${(x.manufacturer || '') + ' ' + (x.product || '')}`).join('\n') + (b.known ? `\n${b.h}U${b.node ? ' chassis' : ''} (${(m.size || {}).source})` : '\nheight unknown: set rack_units for this model');
|
||||
rows += `<div class="slot m ${esc(worst)}${b.node ? ' node' : ''}" data-id="${esc(m.id)}" style="grid-row:span ${span}" title="${esc(tip)}">${esc(label)} <span class="muted">${b.node ? '' : esc(m.os || m.profile || '')}${b.known ? '' : ' ?U'}</span></div>`;
|
||||
} else if (!covered.has(u) && swAt[u]) { // a switch the cabling puts here (sites.js)
|
||||
const s = swAt[u];
|
||||
rows += `<div class="slot sw" style="border-left-color:${esc(s.color || 'var(--accent)')}" title="${esc(s.name + (s.model ? ' · ' + s.model : '') + (s.estimated ? ' · place estimated' : ''))}">⇄ ${esc(s.name)} <span class="muted">${s.role === 'oob' ? 'OOB ' : ''}switch${s.estimated ? ' ?' : ''}</span></div>`;
|
||||
} else if (!covered.has(u)) rows += '<div class="slot"></div>';
|
||||
}
|
||||
return `<div class="rack"><h4><span>${esc(rk.name)} <span class="muted">${esc([rk.building, rk.room].filter(Boolean).join(' / '))}</span></span><span class="muted">${ms.length} · ${H}U <a href="#" data-rackedit="${esc(keyOf(rk.building, rk.room, rk.name))}" title="height / comment">✎</a></span></h4>
|
||||
<div class="elev">${rows}</div>${clash.length ? `<div class="bad" style="margin-top:6px">${clash.map(esc).join('<br>')}</div>` : ''}${noU.length ? `<div class="muted" style="margin-top:6px">no U: ${noU.map(m => `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`).join(', ')}</div>` : ''}${rk.comment ? `<div class="muted" style="margin-top:4px">${esc(rk.comment)}</div>` : ''}</div>`;
|
||||
}
|
||||
|
||||
function pageRacks() {
|
||||
const r = route(); const sel = r.arg ? decodeURIComponent(r.arg) : '';
|
||||
const placed = S.machines.filter(m => m.location && m.location.rack);
|
||||
const unplaced = S.machines.length - placed.length;
|
||||
// racks from definitions plus any rack a machine mentions
|
||||
const keyOf = (b, rm, n) => `${b}|${rm}|${n}`;
|
||||
const racks = new Map(S.racks.map(x => [keyOf(x.building, x.room, x.name), { ...x }]));
|
||||
// racks from definitions plus any rack a machine mentions, one per name whatever its case
|
||||
const keyOf = (b, rm, n) => `${b}|${rm}|${n}`.toLowerCase();
|
||||
const racks = new Map();
|
||||
for (const x of S.racks) { // a rack typed in by hand and the same from NetBox: NetBox's spelling (its rooms are in S.rooms)
|
||||
const k = keyOf(x.building, x.room, x.name);
|
||||
if (!racks.has(k) || (S.rooms || []).some(r => r.building === x.building && r.name === x.room)) racks.set(k, { ...x });
|
||||
}
|
||||
for (const m of placed) { const l = m.location; const k = keyOf(l.building || '', l.room || '', l.rack); if (!racks.has(k)) racks.set(k, { building: l.building || '', room: l.room || '', name: l.rack, height: 42, machines: 0 }); }
|
||||
const list = [...racks.values()].sort((a, b) => keyOf(a.building, a.room, a.name).localeCompare(keyOf(b.building, b.room, b.name)));
|
||||
const buildings = [...new Set(list.map(x => x.building))].sort();
|
||||
const tree = buildings.map(b => { const rooms = [...new Set(list.filter(x => x.building === b).map(x => x.room))].sort(); return `<span class="chip${sel === b ? ' on' : ''}" data-rsel="${esc(b)}"><b>${esc(b || '(no building)')}</b><span class="n">${list.filter(x => x.building === b).length} racks</span></span>` + rooms.map(rm => `<span class="chip${sel === b + '/' + rm ? ' on' : ''}" data-rsel="${esc(b + '/' + rm)}">${esc(rm || '(no room)')}<span class="n">${list.filter(x => x.building === b && x.room === rm).length}</span></span>`).join(''); }).join('<span class="sep" style="width:12px"></span>');
|
||||
const shown = list.filter(x => !sel || x.building === sel || x.building + '/' + x.room === sel);
|
||||
const elevation = rk => {
|
||||
const ms = placed.filter(m => (m.location.building || '') === rk.building && (m.location.room || '') === rk.room && m.location.rack === rk.name);
|
||||
const byU = {}; const noU = [];
|
||||
for (const m of ms) { if (m.location.unit > 0) byU[m.location.unit] = m; else noU.push(m); }
|
||||
const H = rk.height || 42; let rows = '';
|
||||
for (let u = H; u >= 1; u--) { const m = byU[u]; rows += `<div class="u">${u}</div>` + (m ? `<div class="slot m ${esc(m.state)}" data-id="${esc(m.id)}" title="${esc(nameOf(m))} · ${esc(m.state)} · ${esc((m.manufacturer || '') + ' ' + (m.product || ''))}">${esc(nameOf(m))} <span class="muted">${esc(m.os || m.profile || '')}</span></div>` : '<div class="slot"></div>'); }
|
||||
return `<div class="rack"><h4><span>${esc(rk.name)} <span class="muted">${esc([rk.building, rk.room].filter(Boolean).join(' / '))}</span></span><span class="muted">${ms.length} · ${H}U <a href="#" data-rackedit="${esc(keyOf(rk.building, rk.room, rk.name))}" title="height / comment">✎</a></span></h4>
|
||||
<div class="elev">${rows}</div>${noU.length ? `<div class="muted" style="margin-top:6px">no U: ${noU.map(m => `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`).join(', ')}</div>` : ''}${rk.comment ? `<div class="muted" style="margin-top:4px">${esc(rk.comment)}</div>` : ''}</div>`;
|
||||
};
|
||||
const elevation = rk => rackElev(rk);
|
||||
return `<div class="page-head"><h1>Racks</h1><span class="sub">${list.length} racks · ${placed.length} machines placed${unplaced ? ` · <a href="#/machines" id="go-unplaced">${unplaced} without location</a>` : ''}</span>
|
||||
<div class="right"><button class="btn" id="import-loc">Import CSV…</button><button class="btn" id="new-rack">New rack…</button></div></div>
|
||||
${list.length ? `<div class="tree">${tree}${sel ? '<button class="btn ghost sm" id="rclear">all</button>' : ''}</div><div class="racks">${shown.map(elevation).join('')}</div>`
|
||||
@@ -637,6 +756,9 @@ function wireProfiles() {
|
||||
api('GET', '/api/v1/images').then(imgs => { const el = $('#rootimgs'); if (el) el.innerHTML = table(['Image', 'Complete', 'Squashfs', 'Built'], imgs.map(i => [esc(i.name), i.complete ? '<span class="ok">yes</span>' : '<span class="bad">no</span>', human(i['filesystem.squashfs']), esc(when(i.built))])); }).catch(() => { });
|
||||
}
|
||||
function pageImages() {
|
||||
return pageOSImages() + `<div class="card wide" style="margin-top:16px"><h3>Firmware packages <button class="btn sm" id="fwimport" style="margin-left:8px">Import…</button></h3><div id="fwrepo" class="muted">loading…</div><p class="muted" style="margin:8px 0 0">Updates run from a machine's Firmware tab, or <code>rollcall firmware update MACHINE|tag:T PACKAGE</code>.</p></div>`;
|
||||
}
|
||||
function pageOSImages() {
|
||||
return `<div class="page-head"><h1>OS images</h1><span class="sub">disk images for deployment · refreshed nightly by <code>rollcall os sync</code> from the vendors' checksum files</span></div>
|
||||
${table(['OS', 'Current version', 'Family', 'Compressed', 'Disk needed', 'Imported', 'Track', 'Keep', 'Versions', 'Status'], S.os.map(i => { const c = i.current || {}; const s = i.source || {}; return [`<b>${esc(i.name)}</b>${s.description ? `<br><span class="muted">${esc(s.description)}</span>` : ''}`, `<span class="mono">${esc(c.version || '—')}</span>`, esc(s.family || c.family), c.size ? human(c.size) : '', c.raw_size ? human(c.raw_size) : '', c.imported ? `${esc(when(c.imported))} <span class="muted">(${ago(c.imported)})</span>` : '', esc(s.track || ''), s.keep || '', (i.versions || []).map(v => `<span class="mono${v.current ? '' : ' muted'}">${esc(v.version)}</span>`).join('<br>'), i.warning ? `<span class="warn">${esc(i.warning)}</span>` : '<span class="ok">ok</span>']; }))}
|
||||
<p class="muted" style="margin-top:10px">Pinned sources keep <code>current</code> until <code>rollcall os promote NAME VERSION</code>; a deployment always uses the exact version it started with. Import a local file with <code>rollcall os import NAME FILE</code>.</p>`;
|
||||
@@ -688,25 +810,33 @@ function wireNetPlan(id) {
|
||||
// ---------------------------------------------------------------- network page
|
||||
function pageNetwork() {
|
||||
const regionVer = (S.controllers.find(c => c.kind === 'embedded') || {}).version;
|
||||
const ctl = table(['Controller', 'Kind', 'State', 'URL', 'Machines', 'Version', 'Last seen', 'Interfaces', ''], S.controllers.map(c => [`<b>${esc(c.name)}</b>`, esc(c.kind), c.online ? '<span class="dot ok"></span>online' : '<span class="dot bad"></span><span class="bad">offline</span>', `<span class="mono">${esc(c.url)}</span>`, c.machines,
|
||||
const f = nvFilter(), large = nvLarge(), view = NV.view || (large ? 'matrix' : 'topology');
|
||||
const subs = S.subnets.filter(f.subnet);
|
||||
const ctls = S.controllers.filter(c => f.rack(c.name) && (!f.active || NV.rack || topoModel(f).racks.some(r => r.c.name === c.name && r.at.length)));
|
||||
const ctl = table(['Controller', 'Kind', 'State', 'URL', 'Machines', 'Version', 'Last seen', 'Interfaces', ''], ctls.map(c => [`<b>${esc(c.name)}</b>`, esc(c.kind), c.online ? '<span class="dot ok"></span>online' : '<span class="dot bad"></span><span class="bad">offline</span>', `<span class="mono">${esc(c.url)}</span>`, c.machines,
|
||||
`<span class="mono${regionVer && c.version && c.version !== regionVer ? ' warn" title="differs from the region; it updates itself' : ''}">${esc(c.version || '—')}</span>`,
|
||||
`<span class="muted" title="${esc(when(c.last_seen))}">${ago(c.last_seen)}</span>`, `<span class="mono muted">${esc((c.interfaces || []).map(i => i.name + ' ' + (i.addrs || []).join(',')).join(' · '))}</span>`, `<button class="btn sm" data-dhcp="${esc(c.name)}">dnsmasq</button>${c.kind === 'remote' ? ` <button class="btn sm danger" data-rmctl="${esc(c.name)}">remove</button>` : ''}`]));
|
||||
const sub = table(['Subnet', 'Purpose', 'Fabric / VLAN', 'DHCP', 'Gateway', 'DNS', 'Usage', 'Ranges', ''], S.subnets.map(s => {
|
||||
const sub = table(['Subnet', 'Purpose', 'Fabric / VLAN', 'DHCP', 'Gateway', 'DNS', 'Usage', 'Ranges', ''], subs.map(s => {
|
||||
const pct = s.size ? Math.round(s.used * 100 / s.size) : 0;
|
||||
const sizeTxt = s.size ? s.size : '∞';
|
||||
const ranges = (s.ranges || []).map(r => `<span class="pill tag" title="${esc(r.comment || '')}">${esc(r.kind)} ${esc(r.start)}–${esc(r.end.split('.').pop())} <a href="#" data-rmrange="${r.id}" title="remove">×</a></span>`).join(' ');
|
||||
return [`<b class="mono">${esc(s.cidr)}</b>${s.name ? `<br><span class="muted">${esc(s.name)}</span>` : ''}`, esc(s.purpose), `${esc(s.fabric || '—')} / ${s.vid || 'untagged'}${s.mtu && s.mtu !== 1500 ? ` · mtu ${s.mtu}` : ''}`,
|
||||
s.dhcp ? `<span class="ok">on</span> · ${esc(s.primary_rack)}${s.secondary_rack ? ' + ' + esc(s.secondary_rack) : ''}` : '<span class="muted">off</span>', `<span class="mono">${esc(s.gateway || '—')}</span>`,
|
||||
s.dhcp ? `<span class="ok">${s.dhcp_mode === 'proxy' ? 'proxy' : 'on'}</span> · ${esc(s.primary_rack)}${s.secondary_rack ? ' + ' + esc(s.secondary_rack) : ''}` : '<span class="muted">off</span>', `<span class="mono">${esc(s.gateway || '—')}</span>`,
|
||||
`<span class="mono">${esc((s.dns || []).join(', '))}</span>${s.domain ? `<br><span class="muted">${esc(s.domain)}</span>` : ''}`, `<div class="bar" title="${s.used}/${s.size}"><span style="width:${pct}%"></span></div><span class="muted">${s.used} / ${sizeTxt}</span>${s.family === 6 ? ' <span class="pill tag">IPv6</span>' : ''}`,
|
||||
ranges + ` <button class="btn sm" data-addrange="${esc(s.cidr)}">+ range</button>`, `<button class="btn sm" data-ips="${esc(s.cidr)}">addresses</button> <button class="btn sm" data-editsubnet="${esc(s.cidr)}">edit</button> <button class="btn sm danger" data-rmsubnet="${esc(s.cidr)}">delete</button>`];
|
||||
}));
|
||||
const lay = table(['Layout', 'Machines', 'Interfaces', 'Comment', ''], S.layouts.map(l => [`<b>${esc(l.name)}</b>`, l.machines || 0, `<span class="mono">${esc(layoutSummary(l))}</span>`, esc(l.comment || ''), `<button class="btn sm" data-editlayout="${esc(l.name)}">edit</button> <button class="btn sm danger" data-rmlayout="${esc(l.name)}">delete</button>`]));
|
||||
return `<div class="page-head"><h1>Network</h1><span class="sub">fabrics, VLANs and subnets (MAAS-style); rack controllers serve DHCP/PXE on their VLANs</span>
|
||||
<div class="right"><button class="btn primary" id="new-subnet">New subnet…</button><button class="btn" id="new-layout">New layout…</button></div></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Topology</h3><div class="topo-wrap">${topoSVG()}</div>
|
||||
<div class="legend"><span><i></i>VLAN with DHCP/PXE</span><span><i class="plain"></i>VLAN</span><span><i class="dash"></i>storage / IB</span><span><i class="ctl"></i>rack ↔ region (mTLS)</span><span><i class="down"></i>rack offline</span><span><b class="sq"></b>rack serves/sits on the VLAN</span><span><b class="ci"></b>machine address</span><span>click a bus for its addresses, a machine for its page</span></div></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Rack controllers</h3><div class="tw">${ctl}</div><p class="muted" style="margin:8px 0 0">Add one next to a segment: <code>rollcall controller join-code -name NAME -addr REGION:443</code> here, then on that host <code>./rollcall rack join CODE -url http://ITS-ADDRESS</code> and <code>sh install-rack.sh</code>. It talks to the region over mutual TLS.</p></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Subnets</h3><div class="tw">${sub}</div></div>
|
||||
${nvToolbarHTML(large)}
|
||||
${nvIssuesHTML()}
|
||||
${nvPathHTML()}
|
||||
${view === 'matrix'
|
||||
? `<div class="card wide" style="margin-bottom:12px"><h3>VLANs × rack controllers${f.active ? ' <span class="muted">(filtered)</span>' : ''}</h3>${nvMatrixHTML(f)}</div>`
|
||||
: `<div class="card wide" style="margin-bottom:12px"><h3>Topology${f.active ? ' <span class="muted">(filtered)</span>' : ''}</h3><div class="topo-wrap">${topoSVG({ filter: f })}</div>
|
||||
<div class="legend"><span><i></i>VLAN with DHCP/PXE</span><span><i class="plain"></i>VLAN</span><span><i class="dash"></i>storage / IB</span><span><i class="ctl"></i>rack ↔ region (mTLS)</span><span><i class="down"></i>rack offline</span><span><b class="sq"></b>rack serves/sits on the VLAN</span><span><b class="ci"></b>machine address</span><span>click a bus for its addresses, a machine for its page${large && !f.active ? '; filter by fabric or rack for a readable part of a large site' : ''}</span></div></div>`}
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Rack controllers${ctls.length < S.controllers.length ? ` <span class="muted">(${ctls.length} of ${S.controllers.length})</span>` : ''}</h3><div class="tw">${ctl}</div><p class="muted" style="margin:8px 0 0">Add one next to a segment: <code>rollcall controller join-code -name NAME -addr REGION:443</code> here, then on that host <code>./rollcall rack join CODE -url http://ITS-ADDRESS</code> and <code>sh install-rack.sh</code>. It talks to the region over mutual TLS.</p></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Subnets${subs.length < S.subnets.length ? ` <span class="muted">(${subs.length} of ${S.subnets.length})</span>` : ''}</h3><div class="tw">${sub}</div></div>
|
||||
<div class="card wide"><h3>Interface layouts</h3>${lay}<p class="muted" style="margin:8px 0 0">A layout applies to a machine directly (More → Network layout…) or through a tag (Tags → layout). Without one, the boot NIC gets an address on its subnet.</p></div>`;
|
||||
}
|
||||
const layoutExample = JSON.stringify({ interfaces: [
|
||||
@@ -723,7 +853,7 @@ async function editSubnet(s) {
|
||||
{ name: 'purpose', label: 'Purpose', type: 'select', value: s ? s.purpose : 'provisioning', options: ['provisioning', 'mgmt', 'bmc', 'storage', 'ib', 'other'].map(x => [x, x]) },
|
||||
{ name: 'gateway', label: 'Gateway', value: s ? s.gateway : '' }, { name: 'dns', label: 'DNS servers', value: s ? (s.dns || []).join(',') : '', placeholder: 'comma separated' },
|
||||
{ name: 'domain', label: 'Domain', value: s ? s.domain : '' }, { name: 'mtu', label: 'VLAN MTU', value: s ? String(s.mtu || 1500) : '1500' },
|
||||
{ name: 'dhcp', label: 'Serve DHCP/PXE', type: 'checkbox', value: s ? s.dhcp : false },
|
||||
{ name: 'dhcp', label: 'DHCP / PXE', type: 'select', value: s ? (s.dhcp ? (s.dhcp_mode || 'serve') : 'off') : 'off', options: [['off', 'off: another network, or later'], ['serve', 'serve: this rack hands out addresses and PXE'], ['proxy', 'proxy: the site\'s DHCP server hands out addresses; this rack only answers PXE']] },
|
||||
{ name: 'rack', label: 'Rack controller', type: 'select', value: s ? s.primary_rack : '', options: S.controllers.map(c => [c.name, `${c.name}${c.online ? '' : ' (offline)'}`]) },
|
||||
], 'Ranges are added afterwards: dynamic (PXE pool), reserved (never handed out), static (where machines get addresses).', 'Save');
|
||||
if (!v) return;
|
||||
@@ -732,7 +862,7 @@ async function editSubnet(s) {
|
||||
if (fabric && !S.fabrics.some(f => f.name === fabric)) await api('POST', '/api/v1/fabrics', { name: fabric });
|
||||
const vid = Number(v.vid) || 0;
|
||||
await api('POST', '/api/v1/subnets', { cidr: v.cidr.trim(), name: v.name, fabric, vid, purpose: v.purpose, gateway: v.gateway.trim(), dns: v.dns.split(',').map(x => x.trim()).filter(Boolean), domain: v.domain.trim() });
|
||||
if (fabric) await api('POST', '/api/v1/vlans', { fabric, vid, mtu: Number(v.mtu) || 1500, dhcp: v.dhcp, primary_rack: v.dhcp ? v.rack : '' });
|
||||
if (fabric) await api('POST', '/api/v1/vlans', { fabric, vid, mtu: Number(v.mtu) || 1500, dhcp: v.dhcp !== 'off', dhcp_mode: v.dhcp === 'proxy' ? 'proxy' : '', primary_rack: v.dhcp !== 'off' ? v.rack : '' });
|
||||
toast(`subnet ${v.cidr} saved`); refresh(true);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
@@ -746,6 +876,7 @@ async function editLayout(l) {
|
||||
}
|
||||
function wireNetwork() {
|
||||
wireTopo();
|
||||
wireNetView();
|
||||
let dl = $('#dl-fabrics'); if (!dl) { dl = document.createElement('datalist'); dl.id = 'dl-fabrics'; document.body.appendChild(dl); }
|
||||
dl.innerHTML = S.fabrics.map(f => `<option value="${esc(f.name)}">`).join('');
|
||||
$('#new-subnet').onclick = () => editSubnet(null);
|
||||
@@ -777,15 +908,16 @@ function wireNetwork() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- BMC driver settings
|
||||
const powerDriverNames = [['ipmi', 'IPMI 2.0 (ipmitool)'], ['redfish', 'Redfish, any vendor'], ['idrac', 'Dell iDRAC 8/9'], ['ilo', 'HPE iLO 4/5/6'], ['supermicro', 'Supermicro (Redfish)'], ['xcc', 'Lenovo XClarity'], ['openbmc', 'OpenBMC'], ['proxmox', 'Proxmox VE VM'], ['manual', 'manual (no power control)']];
|
||||
async function editBMC(id, b) {
|
||||
// the server's list: one entry per vendor profile it knows
|
||||
const drivers = await api('GET', '/api/v1/power-drivers').then(ds => ds.map(d => [d.name, d.description]), () => [['ipmi', 'IPMI 2.0'], ['redfish', 'Redfish']]);
|
||||
const v = await dialog('BMC settings', [
|
||||
{ name: 'driver', label: 'Driver', type: 'select', value: b.driver || 'ipmi', options: powerDriverNames },
|
||||
{ name: 'driver', label: 'Driver', type: 'select', value: b.driver || 'ipmi', options: drivers },
|
||||
{ name: 'address', label: 'Address', value: b.address || '', placeholder: '10.0.0.5, bmc.example or https://host:8443' },
|
||||
{ name: 'port', label: 'Port', value: b.port ? String(b.port) : '', placeholder: "driver's default" },
|
||||
{ name: 'user', label: 'User', value: b.user || '', help: 'proxmox: token id, e.g. root@pam!rollcall' },
|
||||
{ name: 'password', label: 'Password', placeholder: 'leave empty to keep the stored one', help: 'proxmox: the token secret' },
|
||||
{ name: 'extra', label: 'Settings', type: 'textarea', rows: 3, value: Object.entries(b.extra || {}).map(([k, x]) => `${k}=${x}`).join('\n'), placeholder: 'system=/redfish/v1/Systems/1\nverify_tls=true\ncipher=17\nnode=pve1\nvmid=211', help: 'one KEY=VALUE per line; a line KEY= removes it' },
|
||||
{ name: 'extra', label: 'Settings', type: 'textarea', rows: 3, value: Object.entries(b.extra || {}).map(([k, x]) => `${k}=${x}`).join('\n'), placeholder: 'system=/redfish/v1/Systems/1\nboot_mode=UEFI\nverify_tls=true\ncipher=17\nnode=pve1\nvmid=211', help: 'one KEY=VALUE per line; a line KEY= removes it' },
|
||||
{ name: 'verify', label: 'Check with a status call before saving', type: 'checkbox', value: true },
|
||||
], 'The password is sealed on the region and only travels to the rack controller over mutual TLS.', 'Save');
|
||||
if (!v) return;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
'use strict';
|
||||
// Server fronts drawn from the model: the drive bays the model family comes
|
||||
// with (2.5" bays standing, 3.5" bays lying, in their groups), the rack ears
|
||||
// with the control panel, vents where there are no bays, in the vendor's
|
||||
// colours. No photos and no logos: the layout is what tells servers apart.
|
||||
// A family comes in several bay options (an R750 has 8 or 16 or 24 2.5" bays
|
||||
// or 12 3.5" ones); the first option with room for the machine's disks is
|
||||
// drawn. A multi-node chassis shows one bay group per sled (Dell C-series:
|
||||
// six bays each), lit in that sled's state.
|
||||
|
||||
const MM = { W: 482.6, EAR: 22, U: 44.45 };
|
||||
const FRONT_STYLE = { // body, ears, drive carriers, latches
|
||||
dell: ['#1d1f22', '#8f959b', '#3a3e44', '#9aa2aa'],
|
||||
hpe: ['#34383d', '#26292d', '#555b62', '#b0b6bc'],
|
||||
lenovo: ['#141517', '#1f2023', '#34373b', '#3d7be0'],
|
||||
smc: ['#18191b', '#a8acb1', '#383b40', '#a0a6ac'],
|
||||
xfusion: ['#26282c', '#c9ccd0', '#42464b', '#a0a6ac'],
|
||||
fujitsu: ['#222427', '#c3c6ca', '#3e4145', '#a0a6ac'],
|
||||
inspur: ['#1c1e22', '#2a2c30', '#3a3e44', '#3aa0d8'],
|
||||
gigabyte: ['#1b1c20', '#34363a', '#393c41', '#e0a030'],
|
||||
generic: ['#212428', '#3a3e43', '#3c4046', '#9aa0a6'],
|
||||
};
|
||||
const sff = (n, group) => ({ n, ff: 's', group }), lff = n => ({ n, ff: 'l' });
|
||||
const FRONT_RULES = [ // vendor, model, style, bay options
|
||||
[/dell/i, /\bC6[456]\d{2}\b/i, 'dell', [sff(24, 6)]],
|
||||
[/dell/i, /\bR6\d{2,3}\b/i, 'dell', [sff(8), sff(10)]],
|
||||
[/dell/i, /\bR7\d{2,3}\b/i, 'dell', [sff(8), lff(12), sff(16, 8), sff(24, 8)]],
|
||||
[/dell/i, /\bR(860|940|960)\b/i, 'dell', [sff(8), sff(16, 8), sff(24, 8)]],
|
||||
[/hpe?\b|hewlett/i, /\bDL3(25|60)\b/i, 'hpe', [sff(8), sff(10)]],
|
||||
[/hpe?\b|hewlett/i, /\bDL(345|380|560)\b/i, 'hpe', [sff(8, 8), lff(12), sff(24, 8)]],
|
||||
[/lenovo/i, /\bSR63\d\b/i, 'lenovo', [sff(8), sff(10)]],
|
||||
[/lenovo/i, /\bSR6[45]\d\b/i, 'lenovo', [sff(8, 8), lff(12), sff(24, 8)]],
|
||||
[/lenovo/i, /\bSR(850|860|950)\b/i, 'lenovo', [sff(8, 8), sff(24, 8)]],
|
||||
[/xfusion|huawei/i, /\b1288H\b/i, 'xfusion', [sff(8), sff(10)]],
|
||||
[/xfusion|huawei/i, /\b2[24]88H\b/i, 'xfusion', [lff(12), sff(24, 8)]],
|
||||
[/fujitsu/i, /\bRX2530\b/i, 'fujitsu', [lff(4), sff(8), sff(10)]],
|
||||
[/fujitsu/i, /\bRX2540\b/i, 'fujitsu', [lff(12), sff(24, 8)]],
|
||||
[/fujitsu/i, /\bRX4770\b/i, 'fujitsu', [sff(8), sff(16, 8)]],
|
||||
[/inspur|kaytus/i, /\bNF5180/i, 'inspur', [lff(4), sff(10)]],
|
||||
[/inspur|kaytus/i, /\bNF5280/i, 'inspur', [lff(12), sff(24, 8)]],
|
||||
[/inspur|kaytus/i, /\bNF5468/i, 'inspur', [sff(8)]],
|
||||
[/giga-?byte/i, /^R1/i, 'gigabyte', [lff(4), sff(10)]],
|
||||
[/giga-?byte/i, /^R2/i, 'gigabyte', [lff(12), sff(24, 8)]],
|
||||
[/giga-?byte/i, /^G/i, 'gigabyte', [sff(8)]],
|
||||
];
|
||||
const FRONT_BY_U = { 1: [lff(4), sff(10)], 2: [lff(12), sff(24, 8)], 3: [lff(16)], 4: [lff(24)] };
|
||||
|
||||
// frontProfile: style and bays for a block of rackBlocks (one server, or the sleds of a chassis)
|
||||
function frontProfile(b, span) {
|
||||
const m = b.ms[0], mfr = m.manufacturer || '', model = m.product || '';
|
||||
const rule = FRONT_RULES.find(r => r[0].test(mfr) && r[1].test(model));
|
||||
const style = rule ? rule[2] : /supermicro/i.test(mfr) ? 'smc' : 'generic';
|
||||
let opts = rule ? rule[3] : FRONT_BY_U[Math.min(4, span)];
|
||||
if (b.node && !rule) opts = [sff(24, Math.max(1, Math.floor(24 / b.ms.length)))];
|
||||
const disks = b.node ? Math.max(...b.ms.map(x => x.disks || 0)) * b.ms.length : (m.disks || 0);
|
||||
let bays = opts.find(o => o.n >= disks) || opts[opts.length - 1];
|
||||
if (bays.ff === 'l' && Math.ceil(bays.n / 4) * 26.1 > span * MM.U - 6) bays = sff(Math.min(24, bays.n * 2), 8); // no room for that many 3.5" rows
|
||||
return { style, bays, disks: b.node ? 0 : disks, key: `${style}|${bays.ff}${bays.n}/${bays.group || 0}|${span}` };
|
||||
}
|
||||
|
||||
// drawFront paints the front on a canvas, px wide; cls: state class of each
|
||||
// sled (one for a server). Returns the canvas and, for a chassis, the x (mm)
|
||||
// where each sled's part of the front ends.
|
||||
function drawFront(p, span, cls, colors, px = 768) {
|
||||
const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
const [body, ear, carrier, latch] = FRONT_STYLE[p.style];
|
||||
const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); };
|
||||
const dots = (a, b, w, h, step) => { // perforation
|
||||
x.fillStyle = 'rgba(0,0,0,.55)';
|
||||
for (let yy = b + step / 2, row = 0; yy < b + h; yy += step * 0.87, row++) for (let xx = a + step / 2 + (row % 2) * step / 2; xx < a + w; xx += step) { x.beginPath(); x.arc(xx, yy, step * 0.3, 0, 7); x.fill(); }
|
||||
};
|
||||
const bevel = (a, b, w, h) => { x.lineWidth = 0.5; x.strokeStyle = 'rgba(255,255,255,.18)'; x.beginPath(); x.moveTo(a, b + h); x.lineTo(a, b); x.lineTo(a + w, b); x.stroke(); x.strokeStyle = 'rgba(0,0,0,.7)'; x.beginPath(); x.moveTo(a + w, b); x.lineTo(a + w, b + h); x.lineTo(a, b + h); x.stroke(); };
|
||||
const led = (a, b, r, col) => { x.fillStyle = col; x.shadowColor = col; x.shadowBlur = r * 3; x.beginPath(); x.arc(a, b, r, 0, 7); x.fill(); x.shadowBlur = 0; };
|
||||
rect(body, 0, 0, MM.W, H);
|
||||
// ears: a state strip and LED on the left, the power button on the right
|
||||
rect(ear, 0, 0, MM.EAR, H); rect(ear, MM.W - MM.EAR, 0, MM.EAR, H);
|
||||
rect(colors[cls[0]], 1, 1, 5.5, H - 2);
|
||||
if (cls.some(c => c === 'bad' || c === 'warn')) { // something to see from across the room
|
||||
const worst = colors[cls.includes('bad') ? 'bad' : 'warn'];
|
||||
x.strokeStyle = worst; x.lineWidth = 1.6; x.strokeRect(0.8, 0.8, MM.W - 1.6, H - 1.6);
|
||||
}
|
||||
led(13, Math.min(9, H / 2 - 6), 1.8, colors[cls[0]]);
|
||||
x.strokeStyle = 'rgba(0,0,0,.6)'; x.lineWidth = 0.8; x.beginPath(); x.arc(MM.W - 11, Math.min(10, H / 2), 3.2, 0, 7); x.stroke();
|
||||
led(MM.W - 11, Math.min(10, H / 2), 1, '#7fb6ff');
|
||||
rect('rgba(0,0,0,.5)', MM.W - 15, H - 9, 8, 4.5); // USB
|
||||
// bays
|
||||
const b = p.bays, inner = [MM.EAR + 2, MM.W - MM.EAR - 2], ends = [];
|
||||
const filled = i => !p.disks || i < p.disks;
|
||||
const sled = i => b.group && cls.length > 1 ? Math.min(cls.length - 1, Math.floor(i / b.group)) : 0;
|
||||
let right = inner[0];
|
||||
if (b.ff === 's') {
|
||||
const bw = 17.6, bh = Math.min(H - 5, 70), y0 = (H - bh) / 2, gap = 4;
|
||||
let xx = inner[0];
|
||||
for (let i = 0; i < b.n; i++) {
|
||||
if (i && b.group && i % b.group === 0) { ends.push(xx + gap / 2); xx += gap; }
|
||||
if (filled(i)) {
|
||||
rect(carrier, xx + 0.4, y0, bw - 0.8, bh);
|
||||
bevel(xx + 0.4, y0, bw - 0.8, bh);
|
||||
rect(latch, xx + 2, y0 + bh * 0.62, bw - 4, Math.min(3, bh * 0.08)); // the release latch
|
||||
x.fillStyle = 'rgba(0,0,0,.45)'; for (let v = y0 + 4; v < y0 + bh * 0.55; v += 2.2) x.fillRect(xx + 3, v, bw - 6, 0.8); // vents
|
||||
led(xx + 4, y0 + bh - 3, 0.9, colors[cls[sled(i)]]); led(xx + bw - 4, y0 + bh - 3, 0.9, '#2a2d31');
|
||||
} else { rect('rgba(0,0,0,.35)', xx + 0.4, y0, bw - 0.8, bh); dots(xx + 1, y0 + 1, bw - 2, bh - 2, 2.4); }
|
||||
xx += bw;
|
||||
}
|
||||
right = xx;
|
||||
} else {
|
||||
const bw = 103, bh = 26.1, cols = Math.min(4, b.n), rows = Math.ceil(b.n / 4);
|
||||
const x0 = inner[0] + (inner[1] - inner[0] - cols * bw) / 2, y0 = (H - rows * bh) / 2;
|
||||
for (let i = 0; i < b.n; i++) {
|
||||
const xx = x0 + (i % 4) * bw, yy = y0 + Math.floor(i / 4) * bh;
|
||||
if (filled(i)) {
|
||||
rect(carrier, xx + 0.5, yy + 0.5, bw - 1, bh - 1);
|
||||
bevel(xx + 0.5, yy + 0.5, bw - 1, bh - 1);
|
||||
rect(latch, xx + 3, yy + 3, 4, bh - 6);
|
||||
x.fillStyle = 'rgba(0,0,0,.45)'; for (let v = xx + 12; v < xx + bw - 14; v += 2.4) x.fillRect(v, yy + 5, 0.9, bh - 10);
|
||||
led(xx + bw - 6, yy + 6, 1, colors[cls[0]]); led(xx + bw - 6, yy + bh - 6, 1, '#2a2d31');
|
||||
} else { rect('rgba(0,0,0,.35)', xx + 0.5, yy + 0.5, bw - 1, bh - 1); dots(xx + 2, yy + 2, bw - 4, bh - 4, 2.6); }
|
||||
}
|
||||
right = b.n >= 4 ? inner[1] : x0 + cols * bw;
|
||||
}
|
||||
if (inner[1] - right > 12) dots(right + 3, 3, inner[1] - right - 3, H - 6, 3.2); // vents where there are no bays
|
||||
return { canvas: c, ends };
|
||||
}
|
||||
|
||||
// drawRear paints a server's back: PSUs at the right end, then per server
|
||||
// (or per sled of a chassis) the BMC port, USB and VGA and its network
|
||||
// cards with their ports (SFP cages from 10G up, RJ45 below), cover plates
|
||||
// where there is no card. sleds: [{ports}] from /api/v1/cabling. Returns the
|
||||
// canvas and each port's centre in mm from the top left: {sled, name, x, y}.
|
||||
function drawRear(p, span, sleds, colors, px = 768) {
|
||||
const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
const [body, ear] = FRONT_STYLE[p.style];
|
||||
const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); };
|
||||
const frame = (a, b, w, h, col = 'rgba(255,255,255,.16)') => { x.lineWidth = 0.5; x.strokeStyle = col; x.strokeRect(a, b, w, h); };
|
||||
const grille = (a, b, w, h, step = 2.6) => { x.fillStyle = 'rgba(0,0,0,.6)'; for (let yy = b + 1; yy < b + h - 1; yy += step) x.fillRect(a + 1, yy, w - 2, step * 0.45); };
|
||||
const out = [];
|
||||
rect(body, 0, 0, MM.W, H);
|
||||
rect(ear, 0, 0, 6, H); rect(ear, MM.W - 6, 0, 6, H); // the rails' flanges
|
||||
// power supplies at the right end: side by side in 1U, stacked above
|
||||
const psuW = span === 1 ? 72 : 86, psuH = span === 1 ? H - 4 : Math.min(40, H / 2 - 3);
|
||||
const psus = span === 1 ? [[MM.W - 8 - 2 * psuW - 2, 2], [MM.W - 8 - psuW, 2]] : [[MM.W - 8 - psuW, H / 2 - psuH - 1], [MM.W - 8 - psuW, H / 2 + 1]];
|
||||
for (const [a, b] of psus) {
|
||||
rect('#2b2e33', a, b, psuW, psuH); frame(a, b, psuW, psuH);
|
||||
const r = Math.min(psuH, 34) / 2 - 2; x.fillStyle = 'rgba(0,0,0,.65)'; x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r, 0, 7); x.fill();
|
||||
x.strokeStyle = 'rgba(255,255,255,.12)'; x.lineWidth = 0.6; for (let q = 0; q < 4; q++) { x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r * (0.3 + q * 0.22), 0, 7); x.stroke(); }
|
||||
rect('#1a1c1f', a + 4, b + psuH / 2 - 4, 12, 8); // the inlet
|
||||
x.fillStyle = colors.ok; x.beginPath(); x.arc(a + psuW - 6, b + 5, 1, 0, 7); x.fill();
|
||||
}
|
||||
const left = 8, right = psus[0][0] - 3;
|
||||
const n = Math.max(1, sleds.length), sw = (right - left) / n;
|
||||
sleds.forEach((s, si) => {
|
||||
const x0 = left + si * sw;
|
||||
if (n > 1) frame(x0 + 0.5, 1, sw - 1, H - 2, 'rgba(0,0,0,.8)');
|
||||
let xx = x0 + 3;
|
||||
const yMid = Math.min(H / 2, 20);
|
||||
// BMC port, two USB, VGA
|
||||
rect('#0d0e10', xx, yMid - 5, 12, 10); frame(xx, yMid - 5, 12, 10); out.push({ sled: si, name: 'bmc', x: xx + 6, y: yMid }); xx += 15;
|
||||
rect('#0d0e10', xx, yMid - 7, 6, 6); rect('#0d0e10', xx, yMid + 1, 6, 6); xx += 9;
|
||||
if (n === 1) { rect('#13243f', xx, yMid - 4, 16, 8); xx += 20; }
|
||||
// network cards, one plate each, ports along it
|
||||
const cards = new Map();
|
||||
for (const pt of (s.ports || []).filter(pt => pt.kind !== 'bmc')) { const kk = pt.card || pt.name; if (!cards.has(kk)) cards.set(kk, []); cards.get(kk).push(pt); }
|
||||
for (const ports of cards.values()) {
|
||||
const fast = ports.some(pt => parseInt(pt.speed) >= 10000), pw = fast ? 14 : 12.5, ph = fast ? 9 : 10.5;
|
||||
const perRow = Math.max(1, Math.floor((x0 + sw - 4 - xx - 6) / (pw + 2)));
|
||||
const rows = Math.ceil(ports.length / perRow), cols = Math.min(ports.length, perRow);
|
||||
const cw = cols * (pw + 2) + 6, ch = Math.min(H - 4, rows * (ph + 3) + 6), cy = Math.max(2, yMid - ch / 2);
|
||||
if (xx + cw > x0 + sw - 2) break; // no room left on this sled
|
||||
rect('#2f3338', xx, cy, cw, ch); frame(xx, cy, cw, ch);
|
||||
ports.forEach((pt, i) => {
|
||||
const a = xx + 4 + (i % perRow) * (pw + 2), b = cy + 3 + Math.floor(i / perRow) * (ph + 3);
|
||||
rect('#08090a', a, b, pw, ph); frame(a, b, pw, ph, 'rgba(255,255,255,.25)');
|
||||
x.fillStyle = pt.link ? colors.ok : '#3a3d42'; x.fillRect(a + 1, b - 1.6, 2.4, 1.2);
|
||||
out.push({ sled: si, name: pt.name, x: a + pw / 2, y: b + ph / 2 });
|
||||
});
|
||||
xx += cw + 3;
|
||||
}
|
||||
// cover plates for the empty slots
|
||||
for (; n === 1 && xx + 18 < right; xx += 20) { rect('#2a2d31', xx, 3, 18, H - 6); grille(xx, 5, 18, H - 10); }
|
||||
});
|
||||
return { canvas: c, ports: out };
|
||||
}
|
||||
|
||||
// drawSwitch paints a switch's port side: two rows of ports (odd above, even
|
||||
// below), uplinks to the right. Returns where port number i is (mm).
|
||||
function drawSwitch(count, colors, px = 768) {
|
||||
const H = MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
x.fillStyle = '#1f2226'; x.fillRect(0, 0, MM.W, H);
|
||||
x.fillStyle = '#3a3e43'; x.fillRect(0, 0, 18, H); x.fillRect(MM.W - 18, 0, 18, H);
|
||||
const cols = Math.ceil(count / 2), pw = Math.min(12, (MM.W - 140) / cols - 1.2), at = {};
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const col = Math.floor((i - 1) / 2), a = 24 + col * (pw + 1.2) + Math.floor(col / 6) * 2, b = (i % 2 ? 9 : 24);
|
||||
x.fillStyle = '#07080a'; x.fillRect(a, b, pw, 10); x.strokeStyle = 'rgba(255,255,255,.2)'; x.lineWidth = 0.4; x.strokeRect(a, b, pw, 10);
|
||||
at[i] = { x: a + pw / 2, y: b + 5 };
|
||||
}
|
||||
for (let u = 0; u < 6; u++) { const a = MM.W - 110 + u * 15; x.fillStyle = '#07080a'; x.fillRect(a, 14, 13, 13); x.strokeStyle = 'rgba(255,255,255,.25)'; x.strokeRect(a, 14, 13, 13); at['u' + u] = { x: a + 6.5, y: 20.5 }; }
|
||||
x.fillStyle = colors.ok; x.beginPath(); x.arc(MM.W - 10, 8, 1.2, 0, 7); x.fill();
|
||||
return { canvas: c, at };
|
||||
}
|
||||
|
||||
// drawSwitchFront: the side of a switch towards the cold aisle, at its unit
|
||||
// on the front rails: its name on a label strip in the switch's colour (the
|
||||
// colour of its cables), status LEDs and fan grilles, so it reads from the front
|
||||
function drawSwitchFront(name, color, colors, px = 768) {
|
||||
const H = MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
x.fillStyle = '#1f2226'; x.fillRect(0, 0, MM.W, H);
|
||||
x.fillStyle = '#3a3e43'; x.fillRect(0, 0, 18, H); x.fillRect(MM.W - 18, 0, 18, H);
|
||||
x.fillStyle = color; x.fillRect(22, 4, 150, H - 8); // the label strip
|
||||
x.fillStyle = '#101214'; x.font = '600 22px ui-monospace, Menlo, Consolas, monospace'; x.textBaseline = 'middle';
|
||||
const t = name.split('.')[0]; x.fillText(t.length > 14 ? t.slice(0, 13) + '…' : t, 28, H / 2 + 1, 140);
|
||||
for (let i = 0; i < 4; i++) { x.fillStyle = i ? '#2a2d31' : colors.ok; x.beginPath(); x.arc(186 + i * 7, H / 2, 1.4, 0, 7); x.fill(); }
|
||||
for (let f = 0; f < 4; f++) { // fans
|
||||
const cx = MM.W - 50 - f * 40; x.fillStyle = 'rgba(0,0,0,.6)'; x.beginPath(); x.arc(cx, H / 2, 16, 0, 7); x.fill();
|
||||
x.strokeStyle = 'rgba(255,255,255,.12)'; x.lineWidth = 0.6; for (let q = 1; q < 4; q++) { x.beginPath(); x.arc(cx, H / 2, q * 4.5, 0, 7); x.stroke(); }
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -12,12 +12,14 @@
|
||||
<div class="brand">Rollcall<span class="muted">bare-metal fleet</span></div>
|
||||
<a href="#/overview" data-nav="overview">Overview</a>
|
||||
<a href="#/machines" data-nav="machines">Machines <span class="count" id="n-machines"></span></a>
|
||||
<a href="#/sites" data-nav="sites">Sites</a>
|
||||
<a href="#/racks" data-nav="racks">Racks <span class="count" id="n-racks"></span></a>
|
||||
<a href="#/network" data-nav="network">Network <span class="count" id="n-net"></span></a>
|
||||
<a href="#/jobs" data-nav="jobs">Jobs <span class="count" id="n-jobs"></span></a>
|
||||
<a href="#/tags" data-nav="tags">Tags</a>
|
||||
<a href="#/profiles" data-nav="profiles">Profiles</a>
|
||||
<a href="#/images" data-nav="images">OS images</a>
|
||||
<a id="nav-netbox" href="#" target="_blank" rel="noopener" style="display:none" title="NetBox, through Rollcall: its own login">NetBox ↗</a>
|
||||
<div class="side-foot">
|
||||
<span id="status" class="muted"></span>
|
||||
<button id="logout" class="btn ghost" title="forget the admin token">Token</button>
|
||||
@@ -49,6 +51,10 @@
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
<script src="topo.js"></script>
|
||||
<script src="netview.js"></script>
|
||||
<script src="sites.js"></script>
|
||||
<script src="fronts.js"></script>
|
||||
<script src="room3d.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
// The network page for sites of any size: problems first, filters, then a
|
||||
// VLAN x rack-controller matrix (the default once a site is large) or the
|
||||
// topology drawing (small sites, or a filtered part of a large one), and the
|
||||
// path of a machine or address that was searched for.
|
||||
|
||||
const NV = { view: '', fabric: '', purpose: '', rack: '', q: '', allIssues: false };
|
||||
try { Object.assign(NV, JSON.parse(localStorage.getItem('rollcall.netview') || '{}'), { q: '', allIssues: false }); } catch (e) { /* storage unavailable */ }
|
||||
// ?view=matrix&fabric=…&purpose=…&rack=…&q=… on the URL: a view to share or bookmark
|
||||
{ const u = new URLSearchParams(location.search); for (const k of ['view', 'fabric', 'purpose', 'rack', 'q']) if (u.has(k)) NV[k] = u.get(k); }
|
||||
function nvSave() { try { localStorage.setItem('rollcall.netview', JSON.stringify({ view: NV.view, fabric: NV.fabric, purpose: NV.purpose, rack: NV.rack })); } catch (e) { /* ignore */ } }
|
||||
|
||||
// nvFilter: what the filters keep, for topoModel and the tables
|
||||
function nvFilter() {
|
||||
const hit = nvSearch();
|
||||
const keepSub = s => (!NV.fabric || s.fabric === NV.fabric) && (!NV.purpose || s.purpose === NV.purpose) &&
|
||||
(!NV.rack || s.primary_rack === NV.rack || s.secondary_rack === NV.rack || (S.controllers.find(c => c.name === NV.rack)?.interfaces || []).some(i => (i.addrs || []).some(a => inCidr(a, s.cidr)))) &&
|
||||
(!hit || hit.subnets.has(s.cidr));
|
||||
return {
|
||||
subnet: keepSub,
|
||||
machine: m => (!NV.rack || m.rack_controller === NV.rack) && (!hit || hit.machines.has(m.id)),
|
||||
rack: name => !NV.rack || name === NV.rack,
|
||||
active: !!(NV.fabric || NV.purpose || NV.rack || hit),
|
||||
};
|
||||
}
|
||||
|
||||
// nvSearch: machines and subnets matching the search box (name, id, IP or CIDR)
|
||||
function nvSearch() {
|
||||
const q = NV.q.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const machines = new Set(), subnets = new Set();
|
||||
for (const m of S.machines) if (nameOf(m).toLowerCase().includes(q) || m.id.toLowerCase() === q || m.mgmt_ip === q) machines.add(m.id);
|
||||
for (const x of S.ips) if (x.ip === q || (x.hostname || '').toLowerCase() === q) { if (x.machine_id) machines.add(x.machine_id); subnets.add(x.subnet); }
|
||||
for (const s of S.subnets) if (s.cidr.toLowerCase() === q || (s.name || '').toLowerCase() === q || (parseIP(q) && inCidr(q, s.cidr))) subnets.add(s.cidr);
|
||||
for (const x of S.ips) if (machines.has(x.machine_id)) subnets.add(x.subnet);
|
||||
return { machines, subnets };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- problems
|
||||
// nvIssues: what needs attention, worst first: [{sev, text, cidr?, rack?}]
|
||||
function nvIssues() {
|
||||
const out = [];
|
||||
const ctl = Object.fromEntries(S.controllers.map(c => [c.name, c]));
|
||||
const regionVer = (S.controllers.find(c => c.kind === 'embedded') || {}).version;
|
||||
const segs = topoModel().segs;
|
||||
for (const c of S.controllers) {
|
||||
if (!c.online) {
|
||||
const serves = segs.filter(g => g.racks.has(c.name));
|
||||
const alone = serves.filter(g => ![...g.racks].some(r => r !== c.name && ctl[r]?.online));
|
||||
out.push({ sev: 'bad', rack: c.name, text: `rack controller ${c.name} is offline${alone.length ? `: no DHCP/PXE on ${alone.map(segLabel).join(', ')}` : ''}${c.machines ? ` (${c.machines} machines)` : ''}` });
|
||||
} else if (regionVer && c.version && c.version !== regionVer) {
|
||||
out.push({ sev: 'warn', rack: c.name, text: `${c.name} runs ${c.version}, the region ${regionVer} (it updates itself)` });
|
||||
}
|
||||
}
|
||||
for (const g of segs) if (g.purpose === 'provisioning' && !g.dhcp) out.push({ sev: 'warn', cidr: g.subnets[0].cidr, text: `${segLabel(g)} (provisioning) has no DHCP/PXE: its machines cannot network-boot` });
|
||||
for (const s of S.subnets) {
|
||||
if (!s.size) continue;
|
||||
const pct = s.used * 100 / s.size;
|
||||
if (pct >= 80) out.push({ sev: pct >= 90 ? 'bad' : 'warn', cidr: s.cidr, text: `${s.cidr}${s.name ? ' ' + s.name : ''} is ${Math.round(pct)}% used (${s.used}/${s.size})` });
|
||||
}
|
||||
const last = S.ipam && S.ipam.last_sync;
|
||||
if (last && last.error) out.push({ sev: 'bad', text: `${S.ipam.provider} IPAM: ${last.error}` });
|
||||
if (last && last.conflicts && last.conflicts.length) out.push({ sev: 'warn', text: `${S.ipam.provider} IPAM: ${last.conflicts.length} conflict(s): ${last.conflicts.slice(0, 3).join('; ')}` });
|
||||
return out.sort((a, b) => (a.sev === 'bad' ? 0 : 1) - (b.sev === 'bad' ? 0 : 1));
|
||||
}
|
||||
const segLabel = g => `${g.fabric || 'no fabric'}/${g.vid ? 'vlan ' + g.vid : 'untagged'}`;
|
||||
|
||||
function nvIssuesHTML() {
|
||||
const is = nvIssues();
|
||||
if (!is.length) return `<div class="nv-issues ok"><span class="dot ok"></span>No network problems found: every rack controller online, provisioning VLANs served, subnets below 80%.</div>`;
|
||||
const show = NV.allIssues ? is : is.slice(0, 5);
|
||||
return `<div class="nv-issues">${show.map(i => `<div class="nv-issue ${i.sev}"${i.cidr ? ` data-ips="${esc(i.cidr)}"` : ''}${i.rack ? ` data-nvrack="${esc(i.rack)}"` : ''}><span class="dot ${i.sev}"></span>${esc(i.text)}</div>`).join('')}
|
||||
${is.length > 5 ? `<a href="#" id="nv-more">${NV.allIssues ? 'fewer' : `${is.length - 5} more`}</a>` : ''}</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- matrix
|
||||
// One row per VLAN (grouped by fabric), one column per rack controller:
|
||||
// what the rack does there, then subnets, machines and how full it is.
|
||||
function nvMatrixHTML(f) {
|
||||
const { segs: drawn, racks, ms } = topoModel(f);
|
||||
if (!drawn.length) return `<div class="empty">${NV.q.trim() && !nvSearch().machines.size && !nvSearch().subnets.size ? `No machine, address or subnet matches “${esc(NV.q.trim())}”.` : 'No VLAN matches the filters.'}</div>`;
|
||||
// fabric by fabric, VLANs in order (topoModel puts DHCP VLANs first, for the drawing)
|
||||
const order = drawn.map((g, i) => i).sort((a, b) => String(drawn[a].fabric).localeCompare(String(drawn[b].fabric)) || drawn[a].vid - drawn[b].vid);
|
||||
const segs = order.map(i => drawn[i]), pos = {}; order.forEach((i, j) => { pos[i] = j; });
|
||||
for (const r of racks) r.at = r.at.map(i => pos[i]);
|
||||
for (const x of ms) x.at = x.at.map(i => pos[i]);
|
||||
const cols = racks.filter(r => r.at.length && f.rack(r.c.name));
|
||||
const perSeg = segs.map(() => ({}));
|
||||
for (const x of ms) for (const i of x.at) perSeg[i][x.m.state] = (perSeg[i][x.m.state] || 0) + 1;
|
||||
const hit = nvSearch();
|
||||
const cell = (r, i, g) => {
|
||||
const s0 = g.subnets.find(s => s.dhcp) || {};
|
||||
let sym = '', cls = '', tip = '';
|
||||
if (s0.primary_rack === r.c.name) { sym = s0.dhcp_mode === 'proxy' ? '◐' : '●'; cls = 'serve'; tip = s0.dhcp_mode === 'proxy' ? 'proxy DHCP: PXE only' : 'DHCP + PXE'; }
|
||||
else if (s0.secondary_rack === r.c.name) { sym = '●'; cls = 'serve second'; tip = 'DHCP + PXE (secondary)'; }
|
||||
else if (r.at.includes(i)) { sym = '○'; cls = 'on'; tip = 'has an address on this VLAN'; }
|
||||
if (sym && !r.c.online) { cls += ' down'; tip += ' · rack OFFLINE'; }
|
||||
return `<td class="nv-cell ${cls}" title="${esc(r.c.name + ': ' + (tip || 'not on this VLAN'))}">${sym}</td>`;
|
||||
};
|
||||
const rows = [];
|
||||
let fab = null;
|
||||
segs.forEach((g, i) => {
|
||||
if (g.fabric !== fab) { fab = g.fabric; rows.push(`<tr class="nv-fab"><td colspan="${6 + cols.length}">${esc(fab || 'no fabric')}</td></tr>`); }
|
||||
const used = g.subnets.reduce((a, s) => a + (s.used || 0), 0), size = g.subnets.reduce((a, s) => a + (s.size || 0), 0);
|
||||
const pct = Math.max(0, ...g.subnets.map(s => s.size ? s.used * 100 / s.size : 0));
|
||||
const n = Object.values(perSeg[i]).reduce((a, b) => a + b, 0);
|
||||
const hl = hit && g.subnets.some(s => hit.subnets.has(s.cidr));
|
||||
rows.push(`<tr class="nv-row${hl ? ' hl' : ''}" data-ips="${esc(g.subnets[0].cidr)}">
|
||||
<td class="mono">${g.vid || '<span class="muted">untagged</span>'}</td>
|
||||
<td>${g.subnets.map(s => `<span class="mono">${esc(s.cidr)}</span>${s.name ? ` <span class="muted">${esc(s.name)}</span>` : ''}`).join('<br>')}</td>
|
||||
<td>${esc(g.purpose)}${g.mtu && g.mtu !== 1500 ? ` <span class="pill tag">mtu ${g.mtu}</span>` : ''}</td>
|
||||
${cols.map(r => cell(r, i, g)).join('')}
|
||||
<td class="num">${n ? `${n}<div class="nv-states">${nvStateBar(perSeg[i], n)}</div>` : '<span class="muted">0</span>'}</td>
|
||||
<td><div class="bar${pct >= 90 ? ' bad' : pct >= 80 ? ' warn' : ''}" title="${used}/${size || '∞'}"><span style="width:${Math.round(pct)}%"></span></div><span class="muted">${size ? Math.round(pct) + '%' : used + ' addr'}</span></td>
|
||||
<td class="muted">${g.dhcp ? (g.subnets.some(s => s.dhcp_mode === 'proxy') ? 'proxy' : 'DHCP') : ''}</td></tr>`);
|
||||
});
|
||||
return `<div class="tw"><table class="nv-matrix"><thead><tr><th>VLAN</th><th>Subnets</th><th>Purpose</th>
|
||||
${cols.map(r => `<th class="nv-rackh${r.c.online ? '' : ' down'}" title="${esc(`${r.c.name} (${r.c.kind}) ${r.c.online ? 'online' : 'OFFLINE'}`)}"><span>${esc(r.c.name)}</span></th>`).join('')}
|
||||
<th class="num">Machines</th><th>Used</th><th></th></tr></thead><tbody>${rows.join('')}</tbody></table></div>
|
||||
<div class="legend"><span><b class="g">●</b> serves DHCP + PXE</span><span><b class="g">◐</b> proxy DHCP (PXE only)</span><span><b>○</b> has an address there</span><span><b class="r">●</b> rack offline</span><span>click a row for its addresses</span></div>`;
|
||||
}
|
||||
function nvStateBar(by, n) {
|
||||
const order = ['bad', 'warn', 'ok', 'idle'];
|
||||
const agg = {};
|
||||
for (const [st, c] of Object.entries(by)) agg[stateClass(st)] = (agg[stateClass(st)] || 0) + c;
|
||||
return order.filter(k => agg[k]).map(k => `<i class="${k}" style="width:${(agg[k] * 100 / n).toFixed(1)}%" title="${agg[k]} ${k === 'bad' ? 'failed' : k === 'warn' ? 'busy / new' : k === 'ok' ? 'deployed / ready' : 'other'}"></i>`).join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- path
|
||||
// The way from a machine to the region: interface, address, VLAN, the rack
|
||||
// controllers serving it, the region.
|
||||
function nvPathHTML() {
|
||||
const hit = nvSearch();
|
||||
if (!hit || hit.machines.size !== 1) return hit && hit.machines.size > 1 ? `<p class="muted">${hit.machines.size} machines match; the views show their VLANs.</p>` : '';
|
||||
const m = S.machines.find(x => x.id === [...hit.machines][0]);
|
||||
if (!m) return '';
|
||||
const ctl = Object.fromEntries(S.controllers.map(c => [c.name, c]));
|
||||
const lines = S.ips.filter(x => x.machine_id === m.id).map(x => {
|
||||
const s = S.subnets.find(y => y.cidr === x.subnet) || {};
|
||||
const serving = [s.primary_rack, s.secondary_rack].filter(Boolean);
|
||||
const rk = serving.length ? serving : [m.rack_controller].filter(Boolean);
|
||||
return `<div class="nv-path"><span class="pill tag">${esc(x.iface || '?')}</span><span class="arr">→</span><span class="mono">${esc(x.ip)}</span><span class="arr">→</span>
|
||||
<span>${esc(s.fabric || 'no fabric')} / ${s.vid ? 'vlan ' + s.vid : 'untagged'} <span class="muted">${esc(s.cidr || x.subnet)} ${esc(s.purpose || '')}</span></span>
|
||||
${rk.length ? `<span class="arr">→</span>${rk.map(r => `<span class="${ctl[r] && !ctl[r].online ? 'bad' : ''}">${serving.includes(r) ? '' : '<span class="muted">via</span> '}${esc(r)}${ctl[r] && !ctl[r].online ? ' (offline)' : ''}${s.dhcp && serving.includes(r) ? ' <span class="muted">DHCP/PXE</span>' : ''}</span>`).join(', ')}<span class="arr">→</span><span>region</span>` : ''}</div>`;
|
||||
});
|
||||
return `<div class="card wide nv-pathcard"><h3>Path of <a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a> ${pill(m.state)}</h3>${lines.join('') || '<p class="muted">no addresses on modelled subnets</p>'}</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- page parts
|
||||
function nvToolbarHTML(large) {
|
||||
const view = NV.view || (large ? 'matrix' : 'topology');
|
||||
const opt = (v, l, cur) => `<option value="${esc(v)}"${v === cur ? ' selected' : ''}>${esc(l)}</option>`;
|
||||
const fabrics = [...new Set(S.subnets.map(s => s.fabric).filter(Boolean))].sort();
|
||||
const purposes = [...new Set(S.subnets.map(s => s.purpose).filter(Boolean))].sort();
|
||||
return `<div class="nv-bar">
|
||||
<input id="nv-q" type="search" placeholder="machine, IP, subnet…" value="${esc(NV.q)}">
|
||||
<select id="nv-fabric">${opt('', 'all fabrics', NV.fabric)}${fabrics.map(x => opt(x, x, NV.fabric)).join('')}</select>
|
||||
<select id="nv-purpose">${opt('', 'all purposes', NV.purpose)}${purposes.map(x => opt(x, x, NV.purpose)).join('')}</select>
|
||||
<select id="nv-rack">${opt('', 'all rack controllers', NV.rack)}${S.controllers.map(c => opt(c.name, c.name + (c.online ? '' : ' (offline)'), NV.rack)).join('')}</select>
|
||||
${NV.fabric || NV.purpose || NV.rack || NV.q ? '<button class="btn sm ghost" id="nv-clear">clear</button>' : ''}
|
||||
<span class="right"><span class="seg-btns"><button class="btn sm${view === 'matrix' ? ' on' : ''}" data-nvview="matrix">Matrix</button><button class="btn sm${view === 'topology' ? ' on' : ''}" data-nvview="topology">Topology</button></span></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function nvLarge() { return S.subnets.length > 12 || S.machines.length > 60 || S.controllers.length > 4; }
|
||||
|
||||
function wireNetView() {
|
||||
const again = () => { nvSave(); render(); };
|
||||
const q = $('#nv-q');
|
||||
if (q) {
|
||||
q.oninput = () => { NV.q = q.value; clearTimeout(q._t); q._t = setTimeout(() => { render(); const n = $('#nv-q'); if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } }, 250); };
|
||||
}
|
||||
for (const k of ['fabric', 'purpose', 'rack']) { const el = $('#nv-' + k); if (el) el.onchange = () => { NV[k] = el.value; again(); }; }
|
||||
const c = $('#nv-clear'); if (c) c.onclick = () => { Object.assign(NV, { fabric: '', purpose: '', rack: '', q: '' }); again(); };
|
||||
$$('[data-nvview]').forEach(b => b.onclick = () => { NV.view = b.dataset.nvview; again(); });
|
||||
$$('[data-nvrack]').forEach(d => d.onclick = () => { NV.rack = d.dataset.nvrack; again(); });
|
||||
const more = $('#nv-more'); if (more) more.onclick = ev => { ev.preventDefault(); NV.allIssues = !NV.allIssues; render(); };
|
||||
}
|
||||
|
||||
// nvFabricSummaryHTML: the overview's network card on a large site, one row
|
||||
// per fabric instead of a drawing; a row opens the network page filtered to it.
|
||||
function nvFabricSummaryHTML() {
|
||||
const { segs, racks, ms } = topoModel();
|
||||
const issues = nvIssues();
|
||||
const fabs = [...new Set(segs.map(g => g.fabric || ''))].sort();
|
||||
const rows = fabs.map(fab => {
|
||||
const idx = segs.map((g, i) => (g.fabric || '') === fab ? i : -1).filter(i => i >= 0);
|
||||
const rk = racks.filter(r => r.at.some(i => idx.includes(i)));
|
||||
const down = rk.filter(r => !r.c.online).length;
|
||||
const n = ms.filter(x => x.at.some(i => idx.includes(i))).length;
|
||||
const subs = idx.flatMap(i => segs[i].subnets);
|
||||
const full = subs.filter(s => s.size).map(s => [s, s.used / s.size]).sort((a, b) => b[1] - a[1])[0];
|
||||
const cidrs = new Set(subs.map(s => s.cidr)), names = new Set(rk.map(r => r.c.name));
|
||||
const probs = issues.filter(i => (i.cidr && cidrs.has(i.cidr)) || (i.rack && names.has(i.rack))).length;
|
||||
const pct = full ? Math.round(full[1] * 100) : 0;
|
||||
return `<tr class="nv-row" data-nvfabric="${esc(fab)}"><td><b>${esc(fab || 'no fabric')}</b></td><td class="num">${idx.length}</td><td class="num">${idx.filter(i => segs[i].dhcp).length}</td>
|
||||
<td>${rk.length - down}/${rk.length}${down ? ` <span class="bad">${down} offline</span>` : ''}</td><td class="num">${n}</td>
|
||||
<td>${full ? `<span class="mono">${esc(full[0].cidr)}</span> <span class="${pct >= 90 ? 'bad' : pct >= 80 ? 'warn' : 'muted'}">${pct}%</span>` : '—'}</td>
|
||||
<td>${probs ? `<span class="${issues.some(i => i.sev === 'bad' && ((i.cidr && cidrs.has(i.cidr)) || (i.rack && names.has(i.rack)))) ? 'bad' : 'warn'}">${probs}</span>` : '<span class="ok">none</span>'}</td></tr>`;
|
||||
});
|
||||
return `<table class="nv-matrix"><thead><tr><th>Fabric</th><th class="num">VLANs</th><th class="num">with DHCP</th><th>Rack controllers</th><th class="num">Machines</th><th>Fullest subnet</th><th>Problems</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
||||
}
|
||||
function wireFabricSummary() {
|
||||
$$('[data-nvfabric]').forEach(r => r.onclick = () => { NV.fabric = r.dataset.nvfabric; nvSave(); nav('network'); });
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
'use strict';
|
||||
// A room in 3D (three.js, loaded on first use): the floor's 600 mm tiles,
|
||||
// racks at their places and real height, each server a block on the rack's
|
||||
// front at its units, coloured by state, the sleds of one chassis side by
|
||||
// side. The same data as the floor plan (sites.js); racks are placed in the
|
||||
// 2D editor. The renderer outlives the page's re-render: its canvas moves
|
||||
// into the new page and the scene is rebuilt only when what it shows changed.
|
||||
|
||||
const R3 = { gl: null, T: null, key: '', sig: '', orb: null, pick: [], trash: [], ctx: null, ro: null, queued: false };
|
||||
const U_M = 0.04445, TILE = 0.6, PLINTH = 0.1, TOPCAP = 0.08, RAIL = 0.483; // 19" between the rails
|
||||
|
||||
const roomView = {
|
||||
get() { try { return localStorage.getItem('rollcall.roomview') === '3d' ? '3d' : '2d'; } catch (e) { return '2d'; } },
|
||||
set(v) { try { localStorage.setItem('rollcall.roomview', v); } catch (e) { /* the choice is not kept */ } },
|
||||
};
|
||||
|
||||
// frontView: servers as their drawn fronts (fronts.js) or as blocks in their state's colour
|
||||
const frontView = {
|
||||
get() { try { return localStorage.getItem('rollcall.fronts') === 'state' ? 'state' : 'real'; } catch (e) { return 'real'; } },
|
||||
set(v) { try { localStorage.setItem('rollcall.fronts', v); } catch (e) { /* the choice is not kept */ } },
|
||||
};
|
||||
|
||||
// cablesView: the cables from the servers' ports to their switches, on or off
|
||||
const cablesView = {
|
||||
get() { try { return localStorage.getItem('rollcall.cables') !== 'off'; } catch (e) { return true; } },
|
||||
set(v) { try { localStorage.setItem('rollcall.cables', v ? 'on' : 'off'); } catch (e) { /* the choice is not kept */ } },
|
||||
};
|
||||
const SWITCH_COLORS = ['#f2a33a', '#3fbf8f', '#c470e0', '#e05d5d', '#46b8d1', '#b5c43a', '#e07fb0', '#4f8cff']; // the selection's blue last
|
||||
|
||||
function loadThree() {
|
||||
if (!loadThree.p) loadThree.p = import('./vendor/three/three.module.min.js').catch(e => { loadThree.p = null; throw e; });
|
||||
return loadThree.p;
|
||||
}
|
||||
|
||||
// room3dStop frees the renderer when no 3D room is shown
|
||||
function room3dStop() {
|
||||
if (!R3.gl) return;
|
||||
clearScene(); R3.gl.dispose(); R3.gl.forceContextLoss(); R3.gl.domElement.remove();
|
||||
if (R3.ro) R3.ro.disconnect();
|
||||
Object.assign(R3, { gl: null, key: '', sig: '', orb: null, ro: null });
|
||||
}
|
||||
|
||||
async function room3d(site, roomArg, racks) {
|
||||
const el = $('#room3d'); if (!el) return;
|
||||
let T;
|
||||
try { T = await loadThree(); } catch (e) { el.innerHTML = `<div class="empty">3D did not load: ${esc(e.message)}</div>`; return; }
|
||||
if (!el.isConnected) return; // the page moved on while loading
|
||||
if (!R3.gl) {
|
||||
try { R3.gl = new T.WebGLRenderer({ antialias: true }); } catch (e) {
|
||||
el.innerHTML = '<div class="empty">This browser has no WebGL: use the 2D floor plan.</div>'; return;
|
||||
}
|
||||
R3.T = T; R3.scene = new T.Scene(); R3.camera = new T.PerspectiveCamera(45, 1, 0.05, 1000); R3.ray = new T.Raycaster();
|
||||
R3.tip = document.createElement('div'); R3.tip.className = 'r3-tip';
|
||||
R3.gl.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||||
controls(R3.gl.domElement);
|
||||
}
|
||||
R3.ctx = { site: site.name, roomArg };
|
||||
el.append(R3.gl.domElement, R3.tip); R3.tip.hidden = true;
|
||||
if (R3.ro) R3.ro.disconnect();
|
||||
R3.ro = new ResizeObserver(() => { size(); draw(); }); R3.ro.observe(el);
|
||||
const room = roomArg === '-' ? '' : roomArg, key = site.name + '|' + lc(room);
|
||||
const blocks = racks.map(r => rackBlocks(r));
|
||||
const css = getComputedStyle(document.body);
|
||||
const sig = JSON.stringify([racks.map((r, i) => [r.name, r.x, r.y, r.facing, r.height, r.auto, Object.values(blocks[i].top).map(b => [b.unit, b.h, b.known, b.ms.map(m => m.id + m.state)])]),
|
||||
racks.map((r, i) => Object.values(blocks[i].top).map(b => b.ms.map(m => [m.manufacturer, m.product, m.disks]))),
|
||||
SITE.sel, frontView.get(), cablesView.get(), S.cabling, css.getPropertyValue('--bg'), css.getPropertyValue('--accent')]);
|
||||
if (key !== R3.key) { R3.key = key; R3.orb = null; }
|
||||
if (sig !== R3.sig) {
|
||||
build(site, room, racks, blocks); R3.sig = sig;
|
||||
const sw = JSON.stringify(R3.switches); // the side panel lists them: draw it again once they are known
|
||||
if (sw !== R3.swSig) { R3.swSig = sw; render(); return; } // the switch list and the rack's elevation show them
|
||||
}
|
||||
if (!R3.orb) fit();
|
||||
size(); draw();
|
||||
}
|
||||
|
||||
function clearScene() {
|
||||
for (const x of R3.trash) x.dispose();
|
||||
R3.trash = []; R3.pick = [];
|
||||
if (R3.scene) R3.scene.clear();
|
||||
}
|
||||
|
||||
function build(site, room, racks, blocks) {
|
||||
const T = R3.T, sc = R3.scene;
|
||||
clearScene();
|
||||
const keep = x => { R3.trash.push(x); return x; };
|
||||
const css = getComputedStyle(document.body), cv = n => new T.Color(css.getPropertyValue(n).trim() || '#888888');
|
||||
sc.background = cv('--bg');
|
||||
const mats = {}; // one material per colour and look
|
||||
const mat = (k, make) => mats[k] || (mats[k] = keep(make()));
|
||||
const stateMat = (cls, known) => mat(cls + known, () => {
|
||||
const c = cv({ ok: '--ok', warn: '--warn', bad: '--danger' }[cls] || '--muted');
|
||||
return new T.MeshStandardMaterial({ color: c, emissive: c, emissiveIntensity: 0.25, roughness: 0.55 });
|
||||
});
|
||||
const lineMat = name => mat('l' + name, () => new T.LineBasicMaterial({ color: cv(name) }));
|
||||
const box = (w, h, d) => keep(new T.BoxGeometry(w, h, d));
|
||||
const real = frontView.get() === 'real', fronts = {}; // one texture per look of front
|
||||
const colorOf = Object.fromEntries(['ok', 'warn', 'bad', 'idle'].map(k => [k, css.getPropertyValue({ ok: '--ok', warn: '--warn', bad: '--danger', idle: '--muted' }[k]).trim()]));
|
||||
|
||||
sc.add(new T.HemisphereLight(0xffffff, 0x50555c, 1.7));
|
||||
const sun = new T.DirectionalLight(0xffffff, 1.3); sun.position.set(-6, 12, 8); sc.add(sun);
|
||||
|
||||
// the floor: tiles and the room's outline
|
||||
const { W, D } = roomSize(site, room, racks);
|
||||
const floor = new T.Mesh(keep(new T.PlaneGeometry(W * TILE, D * TILE)), mat('floor', () => new T.MeshStandardMaterial({ color: cv('--panel'), roughness: 1 })));
|
||||
floor.rotation.x = -Math.PI / 2; floor.position.set(W * TILE / 2, 0, D * TILE / 2); sc.add(floor);
|
||||
const grid = [];
|
||||
for (let x = 1; x < W; x++) grid.push(x * TILE, 0.001, 0, x * TILE, 0.001, D * TILE);
|
||||
for (let y = 1; y < D; y++) grid.push(0, 0.001, y * TILE, W * TILE, 0.001, y * TILE);
|
||||
const lines = (pts, m) => { const g = keep(new T.BufferGeometry()); g.setAttribute('position', new T.Float32BufferAttribute(pts, 3)); return new T.LineSegments(g, m); };
|
||||
sc.add(lines(grid, lineMat('--line')));
|
||||
const w = W * TILE, d = D * TILE;
|
||||
R3.room = { w, d, racks: racks.length ? [Math.min(...racks.map(r => r.x)) * TILE, Math.min(...racks.map(r => r.y)) * TILE,
|
||||
Math.max(...racks.map(r => r.x + footprint(r).w)) * TILE, Math.max(...racks.map(r => r.y + footprint(r).h)) * TILE] : null };
|
||||
sc.add(lines([0, 0.002, 0, w, 0.002, 0, w, 0.002, 0, w, 0.002, d, w, 0.002, d, 0, 0.002, d, 0, 0.002, d, 0, 0.002, 0], lineMat('--muted')));
|
||||
|
||||
const cab = S.cabling || { machines: [], switches: [] }, portsBy = new Map(cab.machines.map(x => [x.id, x.ports]));
|
||||
const rears = {}, rackAt = {}, portAt = new Map();
|
||||
racks.forEach((r, i) => {
|
||||
const f = footprint(r), g = new T.Group(), H = r.height || 42, h = PLINTH + H * U_M + TOPCAP, sel = SITE.sel === r.name;
|
||||
g.position.set((r.x + f.w / 2) * TILE, 0, (r.y + f.h / 2) * TILE);
|
||||
g.rotation.y = { N: 0, S: Math.PI, E: -Math.PI / 2, W: Math.PI / 2 }[r.facing]; // the front is local -z
|
||||
const st = rackState(site, r);
|
||||
// the cabinet: steel, see-through like a perforated door, so the servers inside and the cables behind show
|
||||
const body = new T.Mesh(box(TILE - 0.02, h, 2 * TILE - 0.02), mat('rack' + r.auto, () => new T.MeshStandardMaterial({ color: 0x7d8794, roughness: 0.6, metalness: 0.3, transparent: true, opacity: r.auto ? 0.18 : 0.32, depthWrite: false })));
|
||||
body.position.y = h / 2;
|
||||
body.userData = { rack: r.name, tip: `${r.name} · ${st.ms.length} machines · ${st.used}/${H}U used${r.auto ? ' · not placed yet' : ''}` };
|
||||
const edges = new T.LineSegments(keep(new T.EdgesGeometry(body.geometry)), lineMat(sel ? '--accent' : '--muted'));
|
||||
edges.position.copy(body.position);
|
||||
// the four rail posts, front and back: where a unit is, without a panel to look through
|
||||
const post = mat('post', () => new T.MeshStandardMaterial({ color: 0x4b535e, roughness: 0.6, metalness: 0.5 }));
|
||||
const posts = [-1, 1].flatMap(sx => [-1, 1].map(sz => { const p = new T.Mesh(box(0.016, H * U_M, 0.016), post); p.position.set(sx * (RAIL / 2 + 0.01), PLINTH + H * U_M / 2, sz * (TILE - 0.014)); return p; }));
|
||||
g.add(body, edges, ...posts); R3.pick.push(body);
|
||||
g.updateMatrixWorld(true);
|
||||
rackAt[lc(r.name)] = { g, H, h, r, used: blocks[i].covered };
|
||||
for (const b of Object.values(blocks[i].top)) {
|
||||
const span = Math.min(H, b.unit + b.h - 1) - b.unit + 1, hh = span * U_M - 0.004, y = PLINTH + (b.unit - 1) * U_M + 0.002 + hh / 2;
|
||||
const n = b.node ? b.ms.length : 1;
|
||||
// each server (or sled) gets the part of the front between cuts[k] and cuts[k+1], in metres
|
||||
let cuts = [...Array(n + 1).keys()].map(k => -RAIL / 2 + RAIL * k / n), front = null;
|
||||
if (real) {
|
||||
const p = frontProfile(b, span), cls = b.ms.map(m => stateClass(m.state)), key = p.key + '|' + p.disks + '|' + cls.join(',');
|
||||
front = fronts[key] || (fronts[key] = (() => {
|
||||
const f = drawFront(p, span, cls, colorOf), tex = keep(new T.CanvasTexture(f.canvas));
|
||||
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
return { tex, ends: f.ends };
|
||||
})());
|
||||
if (n > 1 && front.ends.length === n - 1) cuts = [0, ...front.ends, MM.W].map(mm => -RAIL / 2 + RAIL * mm / MM.W);
|
||||
}
|
||||
b.ms.slice(0, n).forEach((m, k) => {
|
||||
const sw = cuts[k + 1] - cuts[k];
|
||||
let look = stateMat(stateClass(m.state), b.known);
|
||||
if (front) { // the drawn front on the face towards the aisle (-z), the cabinet colour elsewhere
|
||||
let tex = front.tex;
|
||||
if (n > 1) { tex = keep(front.tex.clone()); tex.repeat.set(sw / RAIL, 1); tex.offset.set((cuts[k] + RAIL / 2) / RAIL, 0); tex.needsUpdate = true; }
|
||||
const face = keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.3, roughness: 0.7 }));
|
||||
const side = mat('side', () => new T.MeshStandardMaterial({ color: 0x1c1e22, roughness: 0.8 }));
|
||||
look = [side, side, side, side, side, face];
|
||||
}
|
||||
const s = new T.Mesh(box(sw - (front ? 0.001 : 0.006), hh, 0.03), look);
|
||||
s.position.set(-(cuts[k] + cuts[k + 1]) / 2, y, -TILE - 0.006); // seen from the aisle, local +x is on the left
|
||||
s.userData = { rack: r.name, id: m.id, tip: `${nameOf(m)} · ${m.state} · U${b.unit}${span > 1 ? '–' + (b.unit + span - 1) : ''}${b.known ? '' : ' (height unknown)'} · ${[m.manufacturer, m.product].filter(Boolean).join(' ')}` };
|
||||
g.add(s); R3.pick.push(s);
|
||||
if (!b.known) { // height unknown (drawn as 1U): an amber outline, as "?U" in the elevation
|
||||
const o = new T.LineSegments(keep(new T.EdgesGeometry(s.geometry)), mat('unknownU', () => new T.LineBasicMaterial({ color: new T.Color(colorOf.warn) })));
|
||||
o.position.copy(s.position); o.scale.set(1.01, 1.08, 1.2); g.add(o);
|
||||
}
|
||||
});
|
||||
// the chassis between front and back: solid and dark, apart from the cabinet
|
||||
const chassis = new T.Mesh(box(RAIL - 0.004, hh - 0.002, 2 * TILE - 0.07), mat('chassis', () => new T.MeshStandardMaterial({ color: 0x202328, roughness: 0.7, metalness: 0.4 })));
|
||||
chassis.position.set(0, y, -0.006);
|
||||
g.add(chassis);
|
||||
// the back: PSUs, the BMC port and the network cards with their ports
|
||||
const p = frontProfile(b, span), sleds = b.ms.slice(0, n).map(m => ({ ports: portsBy.get(m.id) || [] }));
|
||||
const rkey = p.style + '|' + span + '|' + JSON.stringify(sleds);
|
||||
const rear = rears[rkey] || (rears[rkey] = (() => {
|
||||
const f = drawRear(p, span, sleds, colorOf), tex = keep(new T.CanvasTexture(f.canvas));
|
||||
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
const side = mat('rside', () => new T.MeshStandardMaterial({ color: 0x1c1e22, roughness: 0.8 }));
|
||||
return { look: [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.3, roughness: 0.7 })), side], ports: f.ports };
|
||||
})());
|
||||
const rm = new T.Mesh(box(RAIL - 0.001, hh, 0.02), rear.look);
|
||||
rm.position.set(0, y, TILE - 0.004);
|
||||
rm.userData = { rack: r.name, id: n === 1 ? b.ms[0].id : '', tip: `${b.ms.map(nameOf).join(', ')} · the back · ${sleds.reduce((a, x) => a + x.ports.length, 0)} network ports` };
|
||||
g.add(rm); R3.pick.push(rm);
|
||||
for (const pt of rear.ports) {
|
||||
const m = b.ms[pt.sled]; if (!m) continue;
|
||||
portAt.set(m.id + '|' + pt.name, { rack: lc(r.name), lx: -RAIL / 2 + RAIL * pt.x / MM.W, ly: y + hh / 2 - hh * pt.y / (span * MM.U - 1) });
|
||||
}
|
||||
}
|
||||
if (sel) { // the selected rack's footprint, in the accent colour
|
||||
const p = new T.Mesh(keep(new T.PlaneGeometry(TILE + 0.1, 2 * TILE + 0.1)), mat('selp', () => new T.MeshBasicMaterial({ color: cv('--accent'), transparent: true, opacity: 0.35 })));
|
||||
p.rotation.x = -Math.PI / 2; p.position.y = 0.003; g.add(p);
|
||||
}
|
||||
const label = labelSprite(r.name, css.getPropertyValue(sel ? '--accent' : '--fg').trim(), css.getPropertyValue('--panel').trim());
|
||||
label.position.y = h + 0.22; g.add(label);
|
||||
sc.add(g);
|
||||
});
|
||||
R3.switches = placeSwitches(site, room, cab, rackAt, portAt);
|
||||
for (const sw of R3.switches) if (sw.at) drawSwitchIn(sw, rackAt[sw.at.rack], keep, mat, colorOf);
|
||||
if (cablesView.get()) cables(cab, rackAt, portAt, keep, mat);
|
||||
}
|
||||
|
||||
// placeSwitches: where each switch the room's machines lead to stands. Its
|
||||
// stored place (NetBox, or set by hand) when that is a rack of this room;
|
||||
// else, when it has none, the rack with most of its links, at the highest
|
||||
// free unit (on top of the rack when none is free): an estimate. A switch
|
||||
// placed in another room is reached by cables leaving through the ceiling.
|
||||
function placeSwitches(site, room, cab, rackAt, portAt) {
|
||||
const links = {}; // switch -> rack -> links
|
||||
for (const mp of cab.machines) for (const pt of mp.ports) {
|
||||
const at = pt.switch && portAt.get(mp.id + '|' + pt.name); if (!at) continue;
|
||||
(links[pt.switch] = links[pt.switch] || {})[at.rack] = (links[pt.switch][at.rack] || 0) + 1;
|
||||
}
|
||||
const taken = {}; // rack -> units switches took, the placed ones first so estimates go around them
|
||||
for (const s of cab.switches) if (links[s.name] && s.rack && s.unit && rackAt[lc(s.rack)] && sameRoom(s.room || '', room)) (taken[lc(s.rack)] = taken[lc(s.rack)] || []).push(s.unit);
|
||||
return cab.switches.filter(s => links[s.name]).map((s, i) => {
|
||||
const out = { ...s, color: SWITCH_COLORS[i % SWITCH_COLORS.length], here: Object.values(links[s.name]).reduce((a, b) => a + b, 0) };
|
||||
const inRoom = s.rack && (!s.building || inSite(site, s.building)) && sameRoom(s.room || '', room) && rackAt[lc(s.rack)];
|
||||
if (s.rack && !inRoom) { out.away = [s.building, s.room, s.rack].filter(Boolean).join(' / '); return out; }
|
||||
const rack = inRoom ? lc(s.rack) : Object.entries(links[s.name]).sort((a, b) => b[1] - a[1])[0][0];
|
||||
const ra = rackAt[rack]; let unit = inRoom ? s.unit : 0;
|
||||
if (!unit) { for (let u = ra.H; u >= 1; u--) if (!ra.used.has(u) && !(taken[rack] || []).includes(u)) { unit = u; break; } }
|
||||
if (!inRoom || !s.unit) (taken[rack] = taken[rack] || []).push(unit);
|
||||
out.at = { rack, unit: unit || ra.H + 1 + (taken[rack].filter(u => !u).length - 1), estimated: !inRoom || !s.unit };
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// switchPort: where a switch's port named like "ethernet1/1/12" or "Gi1/0/24" is on its drawing
|
||||
function switchPortNo(name) { const m = /(\d+)\D*$/.exec(name || ''); return m ? Number(m[1]) : 0; }
|
||||
|
||||
function drawSwitchIn(sw, ra, keep, mat, colorOf) {
|
||||
const T = R3.T, css = getComputedStyle(document.body);
|
||||
const max = Math.max(48, ...((S.cabling || {}).machines || []).flatMap(mp => mp.ports.filter(p => p.switch === sw.name).map(p => switchPortNo(p.switch_port))));
|
||||
const count = Math.min(128, Math.ceil(max / 2) * 2), d = drawSwitch(count, colorOf), tex = keep(new T.CanvasTexture(d.canvas));
|
||||
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
const side = mat('swside', () => new T.MeshStandardMaterial({ color: 0x15171a, roughness: 0.7 }));
|
||||
const top = sw.at.unit > ra.H, y = top ? ra.h + U_M / 2 + 0.005 : PLINTH + (sw.at.unit - 1) * U_M + U_M / 2;
|
||||
const look = [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.35, roughness: 0.6 })), side];
|
||||
const m = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.45)), look);
|
||||
m.position.set(0, y, TILE + 0.006 - 0.225);
|
||||
if (SITE.sel && lc(SITE.sel) === lc(ra.r.name)) { // names on the selected rack only: switches sit a unit apart, labels would pile up
|
||||
const tag = labelSprite(sw.name.split('.')[0], sw.color, css.getPropertyValue('--panel').trim());
|
||||
tag.scale.set(0.5, 0.125, 1); tag.position.set((sw.at.unit % 2 ? 1 : -1) * 0.42, y, TILE + 0.1); ra.g.add(tag);
|
||||
}
|
||||
const edge = new T.LineSegments(keep(new T.EdgesGeometry(m.geometry)), mat('swl' + sw.color, () => new T.LineBasicMaterial({ color: new T.Color(sw.color) })));
|
||||
edge.position.copy(m.position);
|
||||
m.userData = { rack: ra.r.name, tip: `${sw.name}${sw.role === 'oob' ? ' (out-of-band)' : ''}${sw.model ? ' · ' + sw.model : ''} · ${sw.here} cables here · ${sw.at.estimated ? 'place estimated: ' + (sw.source ? 'no unit' : 'not in NetBox') + ', set it in the panel' : 'U' + sw.at.unit + ' (' + sw.source + ')'}` };
|
||||
// its front at its unit on the front rails, named, so the switch reads from the cold aisle too
|
||||
const ftex = keep(new T.CanvasTexture(drawSwitchFront(sw.name, sw.color, colorOf))); ftex.colorSpace = T.SRGBColorSpace; ftex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
const fm = keep(new T.MeshStandardMaterial({ map: ftex, emissiveMap: ftex, emissive: 0xffffff, emissiveIntensity: 0.35, roughness: 0.6 }));
|
||||
const front = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.03)), [side, side, side, side, side, fm]);
|
||||
front.position.set(0, y, -TILE - 0.006); front.userData = m.userData;
|
||||
ra.g.add(m, edge, front); R3.pick.push(m, front);
|
||||
sw.y = y;
|
||||
sw.port = name => { const n = switchPortNo(name), p = d.at[n] || d.at['u' + (n % 6)]; return { lx: -RAIL / 2 + RAIL * p.x / MM.W, ly: y + (U_M - 0.004) / 2 - (U_M - 0.004) * p.y / (MM.U - 1) }; };
|
||||
}
|
||||
|
||||
// cables, drawn like busbars so they read from across the room: per rack
|
||||
// and switch, one bus up the back of the rack (thicker with more cables),
|
||||
// a short stub from each port into it, a comb under the switch with a tail
|
||||
// into each switch port; to a switch in another rack the bus crosses over
|
||||
// the tops as a tray, to one in another room it rises to the ceiling. One
|
||||
// colour per switch; a port without link has a grey stub; with a rack
|
||||
// selected, the other racks' buses fade. All of it is a handful of
|
||||
// instanced meshes (thousands of stubs would otherwise be thousands of draws).
|
||||
function cables(cab, rackAt, portAt, keep, mat) {
|
||||
const T = R3.T, byName = Object.fromEntries(R3.switches.map(s => [s.name, s]));
|
||||
const W = (ra, x, y, z) => ra.g.localToWorld(new T.Vector3(x, y, z));
|
||||
const look = (col, dim) => mat('bus' + col + dim, () => new T.MeshStandardMaterial({ color: new T.Color(col), emissive: new T.Color(col), emissiveIntensity: 0.4, roughness: 0.45, metalness: 0.2, transparent: dim, opacity: dim ? 0.12 : 1 }));
|
||||
const bins = new Map(), dummy = new T.Object3D();
|
||||
const bar = (a, b, t, m, data) => { // a square bar from a to b (world), t thick, overrunning the joints
|
||||
const len = a.distanceTo(b); if (len < 1e-4) return;
|
||||
dummy.position.copy(a).add(b).multiplyScalar(0.5); dummy.lookAt(b); dummy.scale.set(t, t, len + t); dummy.updateMatrix();
|
||||
if (!bins.has(m)) bins.set(m, { m, mats: [], data: [] });
|
||||
bins.get(m).mats.push(dummy.matrix.clone()); bins.get(m).data.push(data);
|
||||
};
|
||||
// the cables of each rack to each switch
|
||||
const groups = new Map();
|
||||
for (const mp of cab.machines) for (const pt of mp.ports) {
|
||||
const a = pt.switch && portAt.get(mp.id + '|' + pt.name), sw = byName[pt.switch]; if (!a || !sw) continue;
|
||||
const k = a.rack + '|' + sw.name;
|
||||
if (!groups.has(k)) groups.set(k, { ra: rackAt[a.rack], sw, ports: [] });
|
||||
groups.get(k).ports.push({ mp, pt, a });
|
||||
}
|
||||
// lanes behind each rack: its switches alternate left and right, further back each pair
|
||||
const lanes = {};
|
||||
const laneOf = (ra, sw) => {
|
||||
const k = lc(ra.r.name), names = lanes[k] || (lanes[k] = [...new Set([...groups.values()].filter(g => g.ra === ra).map(g => g.sw.name)
|
||||
.concat(R3.switches.filter(s => s.at && s.at.rack === k).map(s => s.name)))].sort());
|
||||
const i = Math.max(0, names.indexOf(sw.name));
|
||||
return { x: (i % 2 ? 1 : -1) * 0.255, z: TILE + 0.06 + 0.04 * Math.floor(i / 2) };
|
||||
};
|
||||
const combs = new Map(); // switch -> the ports reaching it, for its comb
|
||||
for (const g of groups.values()) {
|
||||
const { ra, sw, ports } = g, L = laneOf(ra, sw), n = ports.length;
|
||||
const t = Math.min(0.045, 0.018 + 0.0035 * Math.sqrt(n));
|
||||
const dim = SITE.sel && lc(SITE.sel) !== lc(ra.r.name) && !(sw.at && lc(SITE.sel) === sw.at.rack);
|
||||
const busM = look(sw.color, dim), busData = dim ? null : { rack: ra.r.name, tip: `${n} cable${n > 1 ? 's' : ''} from ${ra.r.name} to ${sw.name}${sw.role === 'oob' ? ' (OOB)' : ''}${sw.away ? ' in ' + sw.away : ''}` };
|
||||
// stubs: out of the back of each port, sideways into the bus
|
||||
for (const p of ports) {
|
||||
const m0 = S.machines.find(x => x.id === p.mp.id), pt = p.pt;
|
||||
const data = dim ? null : { rack: ra.r.name, id: p.mp.id, tip: `${m0 ? nameOf(m0) : p.mp.id} ${pt.kind === 'bmc' ? 'BMC' : pt.name}${pt.speed ? ' ' + pt.speed : ''} → ${pt.switch}${sw.role === 'oob' ? ' (OOB)' : ''} ${pt.switch_port}${pt.vlan ? ' · VLAN ' + pt.vlan : ''}${pt.link === false ? ' · no link' : ''}${sw.away ? ' (in ' + sw.away + ')' : ''} · ${pt.source === 'lldp' ? 'seen by LLDP' : pt.source === 'netbox' ? 'NetBox cable' : 'entered by hand'}${pt.netbox_says ? ' · NetBox says ' + pt.netbox_says : ''}` };
|
||||
const sm = look(pt.link === false ? '#8a8f96' : sw.color, dim);
|
||||
bar(W(ra, p.a.lx, p.a.ly, TILE + 0.008), W(ra, p.a.lx, p.a.ly, L.z), 0.007, sm, data);
|
||||
bar(W(ra, p.a.lx, p.a.ly, L.z), W(ra, L.x, p.a.ly, L.z), 0.007, sm, data);
|
||||
if (sw.at) { const c = combs.get(sw) || []; c.push({ pt, dim, data }); combs.set(sw, c); }
|
||||
}
|
||||
const y0 = Math.min(...ports.map(p => p.a.ly));
|
||||
const rb = sw.at && rackAt[sw.at.rack];
|
||||
if (rb === ra) { // up the back to the comb under the switch
|
||||
bar(W(ra, L.x, y0, L.z), W(ra, L.x, sw.y - U_M / 2 - 0.02, L.z), t, busM, busData);
|
||||
continue;
|
||||
}
|
||||
const yT = Math.max(ra.h, rb ? rb.h : 0) + 0.14 + 0.03 * (R3.switches.indexOf(sw) % 5); // the tray over the tops
|
||||
bar(W(ra, L.x, y0, L.z), W(ra, L.x, yT, L.z), t, busM, busData);
|
||||
if (!rb) { bar(W(ra, L.x, yT, L.z), W(ra, L.x, 2.9, L.z), t, busM, busData); continue; } // another room: through the ceiling
|
||||
const LB = laneOf(rb, sw);
|
||||
bar(W(ra, L.x, yT, L.z), W(rb, LB.x, yT, LB.z), t, busM, busData);
|
||||
bar(W(rb, LB.x, yT, LB.z), W(rb, LB.x, sw.y - U_M / 2 - 0.02, LB.z), t, busM, busData);
|
||||
}
|
||||
// combs: under each switch from its lane to its farthest port, a tail up into each port
|
||||
for (const [sw, list] of combs) {
|
||||
const rb = rackAt[sw.at.rack], L = laneOf(rb, sw), yc = sw.y - U_M / 2 - 0.02;
|
||||
const xs = list.map(c => sw.port(c.pt.switch_port).lx), far = L.x < 0 ? Math.max(...xs) : Math.min(...xs);
|
||||
const dim = list.every(c => c.dim), t = Math.min(0.035, 0.014 + 0.003 * Math.sqrt(list.length));
|
||||
bar(W(rb, L.x, yc, L.z), W(rb, far, yc, L.z), t, look(sw.color, dim), dim ? null : { rack: rb.r.name, tip: `${sw.name}: ${list.length} cables here` });
|
||||
for (const c of list) {
|
||||
const p = sw.port(c.pt.switch_port), m = look(c.pt.link === false ? '#8a8f96' : sw.color, c.dim);
|
||||
bar(W(rb, p.lx, yc, L.z), W(rb, p.lx, p.ly, L.z), 0.006, m, c.data);
|
||||
bar(W(rb, p.lx, p.ly, L.z), W(rb, p.lx, p.ly, TILE + 0.012), 0.006, m, c.data);
|
||||
}
|
||||
}
|
||||
const geo = keep(new T.BoxGeometry(1, 1, 1));
|
||||
for (const b of bins.values()) {
|
||||
const im = new T.InstancedMesh(geo, b.m, b.mats.length);
|
||||
b.mats.forEach((mx, i) => im.setMatrixAt(i, mx));
|
||||
im.userData = { items: b.data }; R3.scene.add(im);
|
||||
if (b.data.some(Boolean)) R3.pick.push(im);
|
||||
}
|
||||
}
|
||||
|
||||
// labelSprite: a rack's name on a pill, drawn over everything so a rack in
|
||||
// front never hides it
|
||||
function labelSprite(text, color, bg) {
|
||||
const T = R3.T, c = document.createElement('canvas'); c.width = 256; c.height = 64;
|
||||
const x = c.getContext('2d'), t = text.length > 12 ? '…' + text.slice(-11) : text;
|
||||
x.font = '600 34px ui-monospace, Menlo, Consolas, monospace';
|
||||
const w = Math.min(248, x.measureText(t).width + 28);
|
||||
x.globalAlpha = 0.85; x.fillStyle = bg; x.beginPath(); x.roundRect(128 - w / 2, 8, w, 48, 12); x.fill();
|
||||
x.globalAlpha = 1; x.fillStyle = color; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(t, 128, 33);
|
||||
const tex = new T.CanvasTexture(c); tex.colorSpace = T.SRGBColorSpace; R3.trash.push(tex);
|
||||
const m = new T.SpriteMaterial({ map: tex, transparent: true, depthTest: false, depthWrite: false }); R3.trash.push(m);
|
||||
const s = new T.Sprite(m); s.scale.set(0.9, 0.225, 1); s.renderOrder = 10;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- camera
|
||||
// orbit around a target on the floor: theta 0 looks from the south (+z)
|
||||
// fit: the racks in view (the room when it has none)
|
||||
function fit() {
|
||||
const { w, d, racks } = R3.room || { w: 5, d: 4 };
|
||||
const b = racks || [0, 0, w, d], sw = b[2] - b[0], sd = b[3] - b[1];
|
||||
R3.orb = { theta: 0.35, phi: 0.95, r: Math.max(sw, sd, 2) * 1.25 + 2.5, t: new R3.T.Vector3((b[0] + b[2]) / 2, 0.8, (b[1] + b[3]) / 2) };
|
||||
R3.maxR = Math.max(w, d) * 4 + 10;
|
||||
}
|
||||
|
||||
function size() {
|
||||
const el = R3.gl && R3.gl.domElement.parentElement; if (!el) return;
|
||||
const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;
|
||||
R3.gl.setSize(w, h); R3.camera.aspect = w / h; R3.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (R3.queued || !R3.gl) return;
|
||||
R3.queued = true;
|
||||
requestAnimationFrame(() => {
|
||||
R3.queued = false; if (!R3.gl || !R3.orb) return;
|
||||
const o = R3.orb, c = R3.camera;
|
||||
c.position.set(o.t.x + o.r * Math.sin(o.phi) * Math.sin(o.theta), o.t.y + o.r * Math.cos(o.phi), o.t.z + o.r * Math.sin(o.phi) * Math.cos(o.theta));
|
||||
c.lookAt(o.t);
|
||||
R3.gl.render(R3.scene, c);
|
||||
});
|
||||
}
|
||||
|
||||
function hitAt(ev) {
|
||||
const rc = R3.gl.domElement.getBoundingClientRect();
|
||||
R3.ray.setFromCamera(new R3.T.Vector2((ev.clientX - rc.left) / rc.width * 2 - 1, -(ev.clientY - rc.top) / rc.height * 2 + 1), R3.camera);
|
||||
const h = R3.ray.intersectObjects(R3.pick, false).find(x => !x.object.isInstancedMesh || x.object.userData.items[x.instanceId]);
|
||||
if (!h) return null;
|
||||
const d = h.object.isInstancedMesh ? h.object.userData.items[h.instanceId] : h.object.userData; // a bus or stub: what it carries
|
||||
return { ...d, point: h.point, obj: h.object };
|
||||
}
|
||||
|
||||
// controls: drag turns, right-drag or shift-drag moves, wheel or pinch zooms;
|
||||
// a click selects a rack (and on the selected rack's servers opens the
|
||||
// machine), a double click brings the rack close
|
||||
function controls(cv) {
|
||||
const pts = new Map(); let drag = null, hoverQ = false;
|
||||
const clampPhi = p => Math.min(1.5, Math.max(0.08, p));
|
||||
const spread = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
|
||||
cv.style.touchAction = 'none';
|
||||
cv.addEventListener('contextmenu', ev => ev.preventDefault());
|
||||
cv.addEventListener('pointerdown', ev => {
|
||||
try { cv.setPointerCapture(ev.pointerId); } catch (e) { /* not a live pointer */ }
|
||||
pts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
if (pts.size === 1) drag = { moved: 0, pan: ev.button === 2 || ev.shiftKey || ev.ctrlKey || ev.metaKey, pinch: 0 };
|
||||
else if (drag) { drag.pinch = spread(); drag.moved = 99; }
|
||||
R3.tip.hidden = true;
|
||||
});
|
||||
cv.addEventListener('pointermove', ev => {
|
||||
const p = pts.get(ev.pointerId);
|
||||
if (!p) { // hover: what is under the pointer
|
||||
if (hoverQ) return; hoverQ = true;
|
||||
requestAnimationFrame(() => {
|
||||
hoverQ = false; if (!R3.gl || !R3.orb) return;
|
||||
const h = hitAt(ev), rc = cv.getBoundingClientRect();
|
||||
cv.style.cursor = h ? 'pointer' : 'grab';
|
||||
R3.tip.hidden = !h;
|
||||
if (h) { R3.tip.textContent = h.tip; R3.tip.style.left = (ev.clientX - rc.left + 14) + 'px'; R3.tip.style.top = (ev.clientY - rc.top + 14) + 'px'; }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dx = ev.clientX - p.x, dy = ev.clientY - p.y, o = R3.orb; if (!o) return;
|
||||
p.x = ev.clientX; p.y = ev.clientY; drag.moved += Math.abs(dx) + Math.abs(dy);
|
||||
if (pts.size >= 2) { const s = spread(); if (drag.pinch) o.r = Math.min(R3.maxR, Math.max(0.8, o.r * drag.pinch / s)); drag.pinch = s; }
|
||||
else if (drag.pan) {
|
||||
const k = o.r * 0.0016, st = Math.sin(o.theta), ct = Math.cos(o.theta);
|
||||
o.t.x += -ct * dx * k - st * dy * k; o.t.z += st * dx * k - ct * dy * k;
|
||||
} else { o.theta -= dx * 0.008; o.phi = clampPhi(o.phi - dy * 0.008); }
|
||||
draw();
|
||||
});
|
||||
const up = ev => {
|
||||
if (!pts.delete(ev.pointerId)) return;
|
||||
if (pts.size || !drag) return;
|
||||
const click = drag.moved < 6 && ev.type === 'pointerup' && ev.button === 0; drag = null;
|
||||
if (!click) return;
|
||||
const at = { clientX: ev.clientX, clientY: ev.clientY }; // a double click is not two clicks
|
||||
clearTimeout(R3.clickT); R3.clickT = setTimeout(() => { if (R3.gl) clicked(at); }, 240);
|
||||
};
|
||||
cv.addEventListener('pointerup', up); cv.addEventListener('pointercancel', up);
|
||||
cv.addEventListener('pointerleave', () => { if (!pts.size) R3.tip.hidden = true; });
|
||||
cv.addEventListener('wheel', ev => {
|
||||
ev.preventDefault(); const o = R3.orb; if (!o) return;
|
||||
o.r = Math.min(R3.maxR, Math.max(0.8, o.r * Math.exp(ev.deltaY * 0.0015))); draw();
|
||||
}, { passive: false });
|
||||
cv.addEventListener('dblclick', ev => {
|
||||
clearTimeout(R3.clickT);
|
||||
const h = hitAt(ev), o = R3.orb; if (!h || !o) return;
|
||||
const p = new R3.T.Vector3(); (h.obj.parent === R3.scene ? h.obj : h.obj.parent).getWorldPosition(p);
|
||||
if (h.obj.isInstancedMesh) p.copy(h.point); // a cable: close in where it was clicked
|
||||
o.t.set(p.x, 1, p.z); o.r = 3.2; draw();
|
||||
});
|
||||
}
|
||||
|
||||
function clicked(ev) {
|
||||
const h = hitAt(ev), c = R3.ctx; if (!c) return;
|
||||
const base = '#/sites/' + encodeURIComponent(c.site) + '/' + encodeURIComponent(c.roomArg);
|
||||
if (!h) { if (SITE.sel) location.hash = base; return; }
|
||||
if (h.id && SITE.sel === h.rack) { location.hash = '#/machine/' + h.id; return; }
|
||||
location.hash = SITE.sel === h.rack ? base : base + '/' + encodeURIComponent(h.rack);
|
||||
}
|
||||
|
||||
function room3dReset() { R3.orb = null; fit(); draw(); }
|
||||
function room3dTurn() { if (R3.orb) { R3.orb.theta += Math.PI; draw(); } }
|
||||
@@ -0,0 +1,363 @@
|
||||
'use strict';
|
||||
// Sites on a map (OpenStreetMap through Leaflet, loaded on first use), a
|
||||
// site's rooms, and a room's floor plan: racks on 600 mm floor tiles, one
|
||||
// tile wide and two deep, coloured by their machines; a rack opens its front
|
||||
// (rackElev). Positions are edited by dragging; racks without one are laid
|
||||
// out in rows facing alternate aisles until someone places them.
|
||||
|
||||
const SITE = { map: null, markers: null, placing: '', sel: '', edit: null };
|
||||
const lc = s => String(s || '').toLowerCase();
|
||||
|
||||
// siteOf: the site a building name belongs to (name or slug, any case)
|
||||
function siteOf(building) {
|
||||
const b = lc(building);
|
||||
return S.sites.find(s => lc(s.name) === b || (s.slug && lc(s.slug) === b));
|
||||
}
|
||||
const inSite = (site, building) => { const so = siteOf(building); return so ? so.name === site.name : lc(building) === lc(site.name); };
|
||||
|
||||
// siteList: the registered sites plus buildings machines or racks name without one
|
||||
function siteList() {
|
||||
const out = S.sites.map(s => ({ ...s }));
|
||||
const extra = new Set([...S.machines.map(m => m.location && m.location.building), ...S.racks.map(r => r.building)].filter(b => b && !siteOf(b)));
|
||||
for (const b of [...extra].sort()) out.push({ name: b, lat: null, lon: null, source: 'unregistered' });
|
||||
return out;
|
||||
}
|
||||
|
||||
// siteRacks: a site's racks, one per room and name whatever their case (a
|
||||
// rack typed in by hand and the same rack from NetBox), NetBox's spelling and
|
||||
// a placed one preferred
|
||||
function siteRacks(site) {
|
||||
const by = new Map();
|
||||
for (const r of S.racks.filter(x => inSite(site, x.building))) {
|
||||
const k = lc(r.room) + '|' + lc(r.name), cur = by.get(k);
|
||||
const score = x => (x.x != null ? 2 : 0) + (S.rooms.some(rm => rm.name === x.room && inSite(site, rm.building)) ? 1 : 0);
|
||||
if (!cur || score(r) > score(cur)) by.set(k, r);
|
||||
}
|
||||
return [...by.values()];
|
||||
}
|
||||
const sameRoom = (a, b) => lc(a) === lc(b);
|
||||
|
||||
function siteStats(site) {
|
||||
const ms = S.machines.filter(m => m.location && inSite(site, m.location.building));
|
||||
const racks = siteRacks(site);
|
||||
const by = {}; for (const m of ms) by[stateClass(m.state)] = (by[stateClass(m.state)] || 0) + 1;
|
||||
const worst = ['bad', 'warn', 'ok', 'idle'].find(k => by[k]) || 'idle';
|
||||
const named = new Map(); // one room per name whatever its case; NetBox's (S.rooms) spelling first
|
||||
for (const n of [...S.rooms.filter(r => inSite(site, r.building)).map(r => r.name), ...racks.map(r => r.room), ...ms.map(m => m.location.room || '')]) if (!named.has(lc(n))) named.set(lc(n), n);
|
||||
const rooms = [...named.values()].sort();
|
||||
return { ms, racks, by, worst, rooms };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- map page
|
||||
function loadLeaflet() {
|
||||
if (window.L) return Promise.resolve();
|
||||
if (!loadLeaflet.p) {
|
||||
loadLeaflet.p = new Promise((ok, bad) => {
|
||||
const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'vendor/leaflet/leaflet.css'; document.head.appendChild(css);
|
||||
const js = document.createElement('script'); js.src = 'vendor/leaflet/leaflet.js'; js.onload = ok; js.onerror = () => bad(new Error('leaflet did not load')); document.head.appendChild(js);
|
||||
});
|
||||
}
|
||||
return loadLeaflet.p;
|
||||
}
|
||||
|
||||
function pageSites() {
|
||||
const list = siteList();
|
||||
const rows = list.map(s => {
|
||||
const st = siteStats(s);
|
||||
const where = s.lat != null ? `<span class="mono muted">${s.lat.toFixed(4)}, ${s.lon.toFixed(4)}</span>` : '<span class="muted">not on the map</span>';
|
||||
return [`<a href="#/sites/${encodeURIComponent(s.name)}"><b>${esc(s.name)}</b></a>${s.address ? `<br><span class="muted">${esc(s.address)}</span>` : ''}`,
|
||||
`<span class="pill tag">${esc(s.source)}</span>${s.source === 'netbox' ? ' ' + nbLink('/dcim/sites/' + nbQ({ slug: s.slug || '', name: s.slug ? '' : s.name }), '↗') : ''}`, st.rooms.filter(Boolean).length || '—', st.racks.length, st.ms.length,
|
||||
`<span class="dot ${st.worst}"></span>${esc(groupLine(Object.fromEntries(st.ms.map(m => [m.state, st.ms.filter(x => x.state === m.state).length])), 40) || '—')}`,
|
||||
where, `<button class="btn sm" data-place="${esc(s.name)}">${s.lat != null ? 'move' : 'place on map'}</button>`];
|
||||
});
|
||||
return `<div class="page-head"><h1>Sites</h1><span class="sub">${list.length} sites · map © OpenStreetMap contributors${S.ipam && S.ipam.provider === 'netbox' ? ' · sites, rooms and racks come from NetBox' : ''}</span></div>
|
||||
${SITE.placing ? `<div class="nv-issues"><span class="dot warn"></span>Click the map where <b>${esc(SITE.placing)}</b> is. <a href="#" id="place-cancel">cancel</a></div>` : ''}
|
||||
<div class="card wide" style="margin-bottom:12px;padding:0;overflow:hidden"><div id="site-map"></div></div>
|
||||
<div class="card wide" id="site-list"><h3>Sites</h3><div class="tw">${table(['Site', 'Source', 'Rooms', 'Racks', 'Machines', 'State', 'Location', ''], rows)}</div></div>`;
|
||||
}
|
||||
|
||||
function wireSites() {
|
||||
$$('[data-place]').forEach(b => b.onclick = () => { SITE.placing = b.dataset.place; render(); });
|
||||
const c = $('#place-cancel'); if (c) c.onclick = ev => { ev.preventDefault(); SITE.placing = ''; render(); };
|
||||
loadLeaflet().then(() => {
|
||||
const el = $('#site-map'); if (!el) return;
|
||||
if (SITE.map) { SITE.map.remove(); SITE.map = null; }
|
||||
const map = L.map(el, { worldCopyJump: true, attributionControl: true });
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, referrerPolicy: 'strict-origin-when-cross-origin', // OSM's tile policy asks for a referrer
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
SITE.map = map; SITE.markers = L.layerGroup().addTo(map);
|
||||
sitesMarkers(true);
|
||||
map.on('click', async ev => {
|
||||
if (!SITE.placing) return;
|
||||
const name = SITE.placing; SITE.placing = '';
|
||||
const lat = Math.round(ev.latlng.lat * 1e5) / 1e5, lon = Math.round(L.Util.wrapNum(ev.latlng.lng, [-180, 180], true) * 1e5) / 1e5;
|
||||
try { await api('POST', '/api/v1/sites', { name, lat, lon }); toast(`${name} placed at ${lat}, ${lon}`); refresh(true); } catch (e) { toast(e.message, true); render(); }
|
||||
});
|
||||
if (SITE.placing) el.style.cursor = 'crosshair';
|
||||
}).catch(e => { const el = $('#site-map'); if (el) el.innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
||||
}
|
||||
|
||||
// sitesMarkers draws (or redraws, on refresh) one marker per site with coordinates
|
||||
function sitesMarkers(fit) {
|
||||
if (!SITE.map) return;
|
||||
SITE.markers.clearLayers();
|
||||
const pts = [];
|
||||
const color = { bad: getComputedStyle(document.body).getPropertyValue('--danger'), warn: getComputedStyle(document.body).getPropertyValue('--warn'), ok: getComputedStyle(document.body).getPropertyValue('--ok'), idle: getComputedStyle(document.body).getPropertyValue('--muted') };
|
||||
for (const s of siteList()) {
|
||||
if (s.lat == null) continue;
|
||||
const st = siteStats(s);
|
||||
const mk = L.circleMarker([s.lat, s.lon], { radius: 7 + Math.min(14, Math.log2(1 + st.ms.length) * 2), color: color[st.worst].trim(), weight: 2, fillOpacity: 0.45 });
|
||||
mk.bindTooltip(`<b>${esc(s.name)}</b><br>${st.ms.length} machines · ${st.racks.length} racks${st.by.bad ? `<br>${st.by.bad} failed` : ''}`, { direction: 'top' });
|
||||
mk.on('click', () => { if (!SITE.placing) location.hash = '#/sites/' + encodeURIComponent(s.name); });
|
||||
mk.addTo(SITE.markers); pts.push([s.lat, s.lon]);
|
||||
}
|
||||
if (!fit) return;
|
||||
if (pts.length > 1) SITE.map.fitBounds(pts, { padding: [40, 40], maxZoom: 12 });
|
||||
else if (pts.length === 1) SITE.map.setView(pts[0], 12);
|
||||
else SITE.map.setView([30, 15], 2);
|
||||
}
|
||||
|
||||
// refreshSites keeps the map (and the tiles it loaded) on the 5 s refresh
|
||||
function refreshSites() {
|
||||
if (!SITE.map || !$('#site-map') || SITE.placing) return false;
|
||||
sitesMarkers(false);
|
||||
const tmp = document.createElement('div'); tmp.innerHTML = pageSites();
|
||||
const fresh = $('#site-list', tmp); if (fresh) { $('#site-list').replaceWith(fresh); $$('[data-place]').forEach(b => b.onclick = () => { SITE.placing = b.dataset.place; render(); }); }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- one site
|
||||
function pageSite(name) {
|
||||
const site = siteList().find(s => s.name === name);
|
||||
if (!site) return `<div class="empty">No site ${esc(name)}. <a href="#/sites">Sites</a></div>`;
|
||||
const st = siteStats(site);
|
||||
const cards = st.rooms.map(rm => {
|
||||
const racks = st.racks.filter(r => sameRoom(r.room, rm)), ms = st.ms.filter(m => sameRoom(m.location.room || '', rm));
|
||||
const bad = ms.filter(m => stateClass(m.state) === 'bad').length;
|
||||
return `<a class="card room-card" href="#/sites/${encodeURIComponent(site.name)}/${encodeURIComponent(rm || '-')}"><h3>${esc(rm || '(no room)')}</h3>
|
||||
<div class="room-mini">${floorSVG(site, rm, { mini: true })}</div>
|
||||
<div class="muted">${racks.length} racks · ${ms.length} machines${bad ? ` · <span class="bad">${bad} failed</span>` : ''}</div></a>`;
|
||||
});
|
||||
return `<div class="page-head"><h1><a href="#/sites" class="muted" style="font-weight:400">Sites</a> / ${esc(site.name)}</h1><span class="sub">${esc(site.address || '')} · ${st.racks.length} racks · ${st.ms.length} machines</span>
|
||||
<div class="right">${site.source === 'netbox' ? nbLink('/dcim/sites/' + nbQ({ slug: site.slug || '', name: site.slug ? '' : site.name })) : ''}<button class="btn" id="new-room">New room…</button></div></div>
|
||||
${cards.length ? `<div class="room-cards">${cards.join('')}</div>` : '<div class="empty">No rooms or racks here yet: add a room, or set machines\' location to this site.</div>'}`;
|
||||
}
|
||||
function wireSite(name) {
|
||||
const b = $('#new-room'); if (b) b.onclick = async () => {
|
||||
const v = await dialog(`New room in ${name}`, [{ name: 'name', label: 'Room' }, { name: 'width', label: 'Width (tiles)', value: '0', help: '600 mm floor tiles; 0 = as far as the racks go' }, { name: 'depth', label: 'Depth (tiles)', value: '0' }], '', 'Add');
|
||||
if (!v) return;
|
||||
try { await api('POST', '/api/v1/rooms', { building: name, name: v.name.trim(), width: Number(v.width) || 0, depth: Number(v.depth) || 0 }); refresh(true); } catch (e) { toast(e.message, true); }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- floor plan
|
||||
// racksOnFloor: the room's racks with positions; racks without one get
|
||||
// automatic places in rows of ten below the placed ones, fronts facing
|
||||
// alternate aisles (cold aisle between rows 0/1, 2/3, ...).
|
||||
function racksOnFloor(site, room) {
|
||||
const racks = siteRacks(site).filter(r => sameRoom(r.room, room))
|
||||
.map(r => ({ ...r, auto: r.x == null, facing: r.facing || 'N' }));
|
||||
const placed = racks.filter(r => !r.auto);
|
||||
let y0 = placed.length ? Math.max(...placed.map(r => r.y + footprint(r).h)) + 2 : 1;
|
||||
racks.filter(r => r.auto).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).forEach((r, i) => {
|
||||
const row = Math.floor(i / 10);
|
||||
r.x = 1 + (i % 10); r.y = y0 + row * 4 - (row % 2); r.facing = row % 2 ? 'N' : 'S';
|
||||
});
|
||||
return racks;
|
||||
}
|
||||
const footprint = r => (r.facing === 'E' || r.facing === 'W') ? { w: 2, h: 1 } : { w: 1, h: 2 };
|
||||
|
||||
function rackState(site, r) {
|
||||
const ms = S.machines.filter(m => m.location && inSite(site, m.location.building) && sameRoom(m.location.room || '', r.room) && lc(m.location.rack) === lc(r.name));
|
||||
const used = ms.reduce((a, m) => a + (m.size && m.size.node ? 0 : Math.max(1, (m.size && m.size.u) || 1)), 0) +
|
||||
new Set(ms.filter(m => m.size && m.size.node).map(m => m.location.unit)).size * 2;
|
||||
const worst = ['bad', 'warn', 'ok', 'idle'].find(k => ms.some(m => stateClass(m.state) === k)) || 'vacant';
|
||||
return { ms, used, worst };
|
||||
}
|
||||
|
||||
// roomSize: the room's floor in tiles, as set or as far as its racks go
|
||||
function roomSize(site, room, racks) {
|
||||
const rm = S.rooms.find(r => inSite(site, r.building) && sameRoom(r.name, room)) || {};
|
||||
return { W: Math.max(rm.width || 0, ...racks.map(r => r.x + footprint(r).w + 1), 8), D: Math.max(rm.depth || 0, ...racks.map(r => r.y + footprint(r).h + 1), 6) };
|
||||
}
|
||||
|
||||
// floorSVG draws the room; opt.mini: a thumbnail for the site page
|
||||
function floorSVG(site, room, opt = {}) {
|
||||
const racks = SITE.edit && !opt.mini ? SITE.edit.racks : racksOnFloor(site, room);
|
||||
const T = opt.mini ? 9 : 40;
|
||||
const { W, D } = roomSize(site, room, racks);
|
||||
const o = [`<svg class="floor${opt.mini ? ' mini' : ''}" viewBox="0 0 ${W * T} ${D * T}" width="${W * T}" height="${D * T}"${opt.mini ? ' style="max-width:100%;height:auto"' : ''}>`];
|
||||
o.push(`<rect class="room" x="0.5" y="0.5" width="${W * T - 1}" height="${D * T - 1}"/>`);
|
||||
if (!opt.mini) { for (let x = 1; x < W; x++) o.push(`<line class="tile" x1="${x * T}" y1="0" x2="${x * T}" y2="${D * T}"/>`); for (let y = 1; y < D; y++) o.push(`<line class="tile" x1="0" y1="${y * T}" x2="${W * T}" y2="${y * T}"/>`); }
|
||||
for (const r of racks) {
|
||||
const f = footprint(r), st = rackState(site, r), x = r.x * T, y = r.y * T, w = f.w * T, h = f.h * T;
|
||||
const pct = r.height ? Math.min(1, st.used / r.height) : 0;
|
||||
const front = { N: [x, y, x + w, y], S: [x, y + h, x + w, y + h], E: [x + w, y, x + w, y + h], W: [x, y, x, y + h] }[r.facing];
|
||||
const fillBar = (r.facing === 'E' || r.facing === 'W') ? `<rect class="fill" x="${x + 2}" y="${y + h - 4}" width="${(w - 4) * pct}" height="2"/>` : `<rect class="fill" x="${x + w - 4}" y="${y + 2 + (h - 4) * (1 - pct)}" width="2" height="${(h - 4) * pct}"/>`;
|
||||
const sel = SITE.sel === r.name && !opt.mini;
|
||||
o.push(`<g class="rk ${st.worst}${r.auto ? ' auto' : ''}${sel ? ' sel' : ''}" data-rack="${esc(r.name)}"><title>${esc(`${r.name} · ${st.ms.length} machines · ${st.used}/${r.height}U used${r.auto ? ' · not placed yet (automatic)' : ''}`)}</title>
|
||||
<rect class="body" x="${x + 1}" y="${y + 1}" width="${w - 2}" height="${h - 2}" rx="${opt.mini ? 1 : 3}"/>${opt.mini ? '' : fillBar}
|
||||
<line class="front" x1="${front[0]}" y1="${front[1]}" x2="${front[2]}" y2="${front[3]}"/>
|
||||
${opt.mini ? '' : `<text x="${x + w / 2}" y="${y + h / 2 + 4}" text-anchor="middle">${esc(r.name.length > 6 ? r.name.slice(-6) : r.name)}</text>`}</g>`);
|
||||
}
|
||||
o.push('</svg>');
|
||||
return o.join('');
|
||||
}
|
||||
|
||||
function pageRoom(siteName, roomArg) {
|
||||
const site = siteList().find(s => s.name === siteName);
|
||||
if (!site) return `<div class="empty">No site ${esc(siteName)}.</div>`;
|
||||
const room = roomArg === '-' ? '' : roomArg;
|
||||
const racks = racksOnFloor(site, room);
|
||||
const autoN = racks.filter(r => r.auto).length, v3 = !SITE.edit && roomView.get() === '3d';
|
||||
const selRack = racks.find(r => r.name === SITE.sel);
|
||||
const nbSite = (siteOf(site.name) || {}).slug || '';
|
||||
const panel = selRack ? `<div class="card floor-side"><h3>${esc(selRack.name)} <span class="muted">${selRack.height}U · front ${({ N: 'north', S: 'south', E: 'east', W: 'west' })[selRack.facing]}</span> ${nbLink('/dcim/racks/' + nbQ({ name: selRack.name, site: nbSite }))}</h3>
|
||||
${SITE.edit ? `<div class="actions" style="margin-bottom:8px"><button class="btn sm" id="rk-rotate">Rotate</button></div>` : ''}
|
||||
${rackElev(selRack)}${cablingTable(site, selRack)}</div>`
|
||||
: `<div class="card floor-side"><span class="muted">Click a rack for its front.${autoN ? `<br><br>${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}</span>${v3 ? switchTable() : ''}</div>`;
|
||||
const legend = v3 ? `<span>drag: turn · right or shift drag: move · wheel: zoom · double click: go to a rack · a click on a selected rack\'s server opens it · amber outline: height unknown (rack_units)${frontView.get() === 'real' ? ' · fronts: the drive bays of the model, the strip on the left ear is the state' : ''}${cablesView.get() ? ' · the cables run at the back: Turn around' : ''}</span>`
|
||||
: `<span><b class="lg vacant"></b>empty</span><span>thick edge: the front · bar: units used${SITE.edit ? ' · drag to move, select and Rotate' : ''}</span>`;
|
||||
return `<div class="page-head"><h1><a href="#/sites" class="muted" style="font-weight:400">Sites</a> / <a href="#/sites/${encodeURIComponent(site.name)}" class="muted" style="font-weight:400">${esc(site.name)}</a> / ${esc(room || '(no room)')}</h1>
|
||||
<span class="sub">${racks.length} racks · floor tiles of 600 mm</span>
|
||||
<div class="right">${room ? nbLink('/dcim/locations/' + nbQ({ name: room, site: nbSite })) : ''}${SITE.edit ? '<button class="btn" id="fl-cancel">Cancel</button><button class="btn" id="fl-auto">Automatic again</button><button class="btn primary" id="fl-save">Save layout</button>'
|
||||
: `<span class="seg-btns"><button class="btn${v3 ? '' : ' on'}" data-roomview="2d">2D</button><button class="btn${v3 ? ' on' : ''}" data-roomview="3d">3D</button></span>${v3 ? `<span class="seg-btns" title="servers: their fronts as the model has them, or blocks in their state's colour"><button class="btn${frontView.get() === 'real' ? ' on' : ''}" data-fronts="real">Fronts</button><button class="btn${frontView.get() === 'state' ? ' on' : ''}" data-fronts="state">State</button></span><button class="btn${cablesView.get() ? ' on' : ''}" id="r3-cables" title="cables from the servers' ports to their switches (LLDP)">Cables</button><button class="btn" id="r3-turn" title="look from the other side: the backs, the switches and the cables">Turn around</button><button class="btn" id="r3-reset">Reset view</button>` : ''}<button class="btn" id="fl-edit">Edit layout</button>`}</div></div>
|
||||
<div class="floor-wrap"><div class="card floor-card">${v3 ? '<div id="room3d" class="room3d"></div>' : floorSVG(site, room)}
|
||||
<div class="legend"><span><b class="lg ok"></b>deployed / ready</span><span><b class="lg warn"></b>busy / new</span><span><b class="lg bad"></b>failed</span>${legend}</div></div>${panel}</div>`;
|
||||
}
|
||||
|
||||
function wireRoom(siteName, roomArg) {
|
||||
const site = siteList().find(s => s.name === siteName); if (!site) return;
|
||||
const room = roomArg === '-' ? '' : roomArg;
|
||||
$$('[data-roomview]').forEach(b => b.onclick = () => { roomView.set(b.dataset.roomview); render(); });
|
||||
$$('[data-fronts]').forEach(b => b.onclick = () => { frontView.set(b.dataset.fronts); render(); });
|
||||
$$('.floor-side .elev .slot.m').forEach(s => s.onclick = () => { location.hash = '#/machine/' + s.dataset.id; });
|
||||
const e = $('#fl-edit'); if (e) e.onclick = () => { SITE.edit = { racks: racksOnFloor(site, room).map(r => ({ ...r })) }; render(); };
|
||||
const rs = $('#r3-reset'); if (rs) rs.onclick = () => room3dReset();
|
||||
const tu = $('#r3-turn'); if (tu) tu.onclick = () => room3dTurn();
|
||||
const cb = $('#r3-cables'); if (cb) cb.onclick = () => { cablesView.set(!cablesView.get()); render(); };
|
||||
$$('[data-swplace]').forEach(b => b.onclick = () => placeSwitch(site, room, b.dataset.swplace));
|
||||
$$('[data-cable]').forEach(a => a.onclick = ev => { ev.preventDefault(); const [id, port] = a.dataset.cable.split('|'); editCable(id, port); });
|
||||
if ($('#room3d')) { room3d(site, roomArg, racksOnFloor(site, room)); return; }
|
||||
room3dStop();
|
||||
const svg = $('svg.floor'); if (!svg) return;
|
||||
// the selected rack is part of the address (#/sites/SITE/ROOM/RACK): a rack can be linked to
|
||||
$$('svg.floor .rk').forEach(g => g.onclick = () => {
|
||||
if (SITE.dropped) return; // the click that ends a drag is not a selection
|
||||
const base = '#/sites/' + encodeURIComponent(siteName) + '/' + encodeURIComponent(roomArg);
|
||||
if (SITE.edit) { SITE.sel = SITE.sel === g.dataset.rack ? '' : g.dataset.rack; render(); return; }
|
||||
location.hash = SITE.sel === g.dataset.rack ? base : base + '/' + encodeURIComponent(g.dataset.rack);
|
||||
});
|
||||
const c = $('#fl-cancel'); if (c) c.onclick = () => { SITE.edit = null; render(); };
|
||||
const save = async (auto) => {
|
||||
const body = SITE.edit.racks.map(r => {
|
||||
return { building: r.building, room: r.room, name: r.name, x: auto ? null : r.x, y: auto ? null : r.y, facing: auto ? '' : r.facing };
|
||||
});
|
||||
try { await api('POST', '/api/v1/racks/positions', body); SITE.edit = null; toast(auto ? 'racks laid out automatically again' : `layout of ${room || site.name} saved`); refresh(true); } catch (err) { toast(err.message, true); }
|
||||
};
|
||||
const s = $('#fl-save'); if (s) s.onclick = () => save(false);
|
||||
const a = $('#fl-auto'); if (a) a.onclick = () => { if (confirm('Forget the places of every rack in this room?')) save(true); };
|
||||
const rot = $('#rk-rotate'); if (rot) rot.onclick = () => { const r = SITE.edit.racks.find(x => x.name === SITE.sel); if (r) { r.facing = { N: 'E', E: 'S', S: 'W', W: 'N' }[r.facing]; r.auto = false; render(); } };
|
||||
if (!SITE.edit) return;
|
||||
const T = 40;
|
||||
svg.onpointerdown = ev => {
|
||||
const g = ev.target.closest('.rk'); if (!g) return;
|
||||
const r = SITE.edit.racks.find(x => x.name === g.dataset.rack); if (!r) return;
|
||||
SITE.drag = { r, g, x0: ev.clientX, y0: ev.clientY, nx: r.x, ny: r.y, moved: false };
|
||||
try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* not a live pointer */ }
|
||||
};
|
||||
svg.onpointermove = ev => { // only the dragged rack moves until it is dropped
|
||||
const d = SITE.drag; if (!d) return;
|
||||
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
||||
d.nx = Math.max(0, d.r.x + Math.round((ev.clientX - d.x0) / scale / T));
|
||||
d.ny = Math.max(0, d.r.y + Math.round((ev.clientY - d.y0) / scale / T));
|
||||
d.moved = d.moved || d.nx !== d.r.x || d.ny !== d.r.y;
|
||||
d.g.setAttribute('transform', `translate(${(d.nx - d.r.x) * T},${(d.ny - d.r.y) * T})`);
|
||||
};
|
||||
svg.onpointerup = () => {
|
||||
const d = SITE.drag; if (!d) return;
|
||||
SITE.drag = null; // before render: renderSites holds off while a drag is on
|
||||
if (!d.moved) return;
|
||||
SITE.dropped = true; setTimeout(() => { SITE.dropped = false; }, 0);
|
||||
d.r.x = d.nx; d.r.y = d.ny; d.r.auto = false; SITE.sel = d.r.name; render();
|
||||
};
|
||||
}
|
||||
|
||||
function renderSites() {
|
||||
const r = route();
|
||||
const main = $('#main');
|
||||
if (r.arg2) {
|
||||
if (SITE.edit && SITE.drag) return;
|
||||
if (!SITE.edit) SITE.sel = r.arg3 ? decodeURIComponent(r.arg3) : ''; main.innerHTML = pageRoom(decodeURIComponent(r.arg), decodeURIComponent(r.arg2)); wireRoom(decodeURIComponent(r.arg), decodeURIComponent(r.arg2)); return; }
|
||||
SITE.edit = null; room3dStop();
|
||||
if (r.arg) { main.innerHTML = pageSite(decodeURIComponent(r.arg)); wireSite(decodeURIComponent(r.arg)); return; }
|
||||
if (main.dataset.view === 'sites' && refreshSites()) return;
|
||||
main.innerHTML = pageSites(); main.dataset.view = 'sites'; wireSites();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cabling
|
||||
// cablingTable: the selected rack's ports and the switch port each is plugged into (LLDP)
|
||||
function cablingTable(site, rk) {
|
||||
const cab = S.cabling; if (!cab) return '';
|
||||
const ms = S.machines.filter(m => m.location && inSite(site, m.location.building) && sameRoom(m.location.room || '', rk.room) && lc(m.location.rack) === lc(rk.name))
|
||||
.sort((a, b) => (b.location.unit || 0) - (a.location.unit || 0));
|
||||
const rows = [], src = { lldp: 'LLDP', netbox: 'NetBox', local: 'by hand' };
|
||||
for (const m of ms) {
|
||||
const ports = [...((cab.machines.find(x => x.id === m.id) || {}).ports || [])];
|
||||
if (!ports.some(p => p.kind === 'bmc')) ports.push({ name: 'bmc', kind: 'bmc' }); // the BMC's cable is NetBox's or entered here
|
||||
for (const p of ports) {
|
||||
const to = p.switch ? `${esc(p.switch)} <span class="mono">${esc(p.switch_port)}</span> <span class="muted">${src[p.source] || ''}</span>` : `<span class="muted">${p.kind === 'bmc' ? 'none recorded' : p.link === false ? 'no link' : 'no LLDP'}</span>`;
|
||||
rows.push(`<div class="cab-p"><span class="mono">${p.kind === 'bmc' ? 'BMC' : esc(p.name)}</span><span>${to}${p.vlan ? ` <span class="muted">VLAN ${esc(p.vlan)}</span>` : ''}${p.netbox_says ? `<br><span class="warn">NetBox: ${esc(p.netbox_says)}</span>` : ''}</span>
|
||||
${p.source === 'lldp' ? '<span></span>' : `<a href="#" data-cable="${esc(m.id)}|${esc(p.name)}" title="set the switch port by hand">✎</a>`}</div>`);
|
||||
}
|
||||
rows.push('</div>');
|
||||
rows.splice(rows.length - ports.length - 1, 0, `<div class="cab-m"><a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a> <span class="muted">U${m.location.unit || '?'}</span>`);
|
||||
}
|
||||
return rows.length ? `<h3 style="margin-top:12px">Cabling</h3>${rows.join('')}` : '';
|
||||
}
|
||||
|
||||
async function editCable(id, port) {
|
||||
const m = S.machines.find(x => x.id === id), cur = (((S.cabling || {}).machines || []).find(x => x.id === id) || { ports: [] }).ports.find(p => p.name === port) || {};
|
||||
const v = await dialog(`${m ? nameOf(m) : id} ${port === 'bmc' ? 'BMC' : port}`, [{ name: 'switch', label: 'Switch', value: cur.source === 'local' ? cur.switch : '' , placeholder: 'oob-1' },
|
||||
{ name: 'switch_port', label: 'Switch port', value: cur.source === 'local' ? cur.switch_port : '', placeholder: 'Gi1/0/7' }],
|
||||
'What the port is plugged into, when LLDP cannot see it (the BMC\'s port always) and NetBox has no cable. Empty: forget the one entered here.', 'Save');
|
||||
if (!v) return;
|
||||
try {
|
||||
if (!v.switch.trim()) await api('DELETE', `/api/v1/cables?machine=${encodeURIComponent(id)}&port=${encodeURIComponent(port)}`);
|
||||
else await api('POST', '/api/v1/cables', { machine: id, port, switch: v.switch.trim(), switch_port: v.switch_port.trim() });
|
||||
refresh(true);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
// switchTable: the switches this room's cables lead to, where they stand
|
||||
function switchTable() {
|
||||
const sws = (typeof R3 !== 'undefined' && R3.switches) || [];
|
||||
if (!sws.length) return '<p class="muted" style="margin-top:12px">No cabling known in this room: the machines\' LLDP saw no switch, and NetBox records no cables for them. Cables can be entered per port (select a rack, ✎ in its cabling list).</p>';
|
||||
const where = s => s.away ? `in ${esc(s.away)}` : s.at ? `${esc(rackLabel(s.at.rack))} U${s.at.unit}${s.at.estimated ? ' <span class="warn">estimated</span>' : ` <span class="muted">${esc(s.source)}</span>`}` : '';
|
||||
return `<h3 style="margin-top:12px">Switches</h3><div class="sw-list">${sws.map(s => `<div class="sw-row"><b class="sw-dot" style="background:${esc(s.color)}"></b>
|
||||
<div><div><b>${esc(s.name)}</b>${s.role === 'oob' ? ' <span class="pill tag" title="out-of-band: BMC ports">OOB</span>' : ''}${s.model ? ` <span class="muted">${esc(s.model)}</span>` : ''} ${s.source === 'netbox' ? nbLink('/dcim/devices/' + nbQ({ name: s.name }), '↗') : ''}</div><div class="muted">${where(s)} · ${s.here} cables</div></div>
|
||||
<button class="btn sm" data-swplace="${esc(s.name)}">place…</button></div>`).join('')}</div>
|
||||
<p class="muted">A switch's place comes from NetBox (its rack and position) or is set here; without either it is drawn where most of its cables are.</p>`;
|
||||
}
|
||||
// switchesAt: the switches in a rack by unit, as the 3D room placed them
|
||||
// (estimates included) or as stored, for the rack's elevation
|
||||
function switchesAt(rk) {
|
||||
const out = {}, k = lc(rk.name);
|
||||
const placed = (typeof R3 !== 'undefined' && R3.switches && R3.switches.length) ? R3.switches.filter(s => s.at && s.at.rack === k && s.at.unit <= (rk.height || 42)).map(s => ({ ...s, unit: s.at.unit, estimated: s.at.estimated }))
|
||||
: ((S.cabling || {}).switches || []).filter(s => s.unit && lc(s.rack) === k && lc(s.room || '') === lc(rk.room || '')).map(s => ({ ...s }));
|
||||
for (const s of placed) out[s.unit] = s;
|
||||
return out;
|
||||
}
|
||||
const rackLabel = k => ((S.racks.find(r => lc(r.name) === k) || {}).name || k);
|
||||
|
||||
async function placeSwitch(site, room, name) {
|
||||
const sw = (R3.switches || []).find(s => s.name === name) || {};
|
||||
const racks = racksOnFloor(site, room);
|
||||
const v = await dialog(`Place ${name}`, [{ name: 'rack', label: 'Rack', type: 'select', value: sw.at ? rackLabel(sw.at.rack) : '', options: racks.map(r => [r.name, r.name]) },
|
||||
{ name: 'unit', label: 'Unit', value: sw.at && sw.at.unit <= 60 ? sw.at.unit : '', help: 'lowest U it takes' }], 'Set by hand, the place stands even when NetBox has another.', 'Place');
|
||||
if (!v) return;
|
||||
const rk = racks.find(r => r.name === v.rack);
|
||||
try { await api('POST', '/api/v1/switches', { name, building: rk.building, room: rk.room, rack: rk.name, unit: Number(v.unit) || 0, height: 1 }); toast(`${name} placed in ${rk.name}`); refresh(true); } catch (e) { toast(e.message, true); }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ a { color: var(--accent); }
|
||||
.side a:hover { background: var(--side-act); } .side a.active { background: var(--side-act); color: #fff; }
|
||||
.side .count { font-size: 11px; opacity: .8; }
|
||||
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 6px; padding: 10px; font-size: 11px; }
|
||||
main { padding: 18px 22px; max-width: 1500px; width: 100%; }
|
||||
main { padding: 18px 22px; max-width: 1500px; width: 100%; min-width: 0; }
|
||||
.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.page-head h1 { font-size: 18px; margin: 0; } .page-head .sub { color: var(--muted); }
|
||||
.page-head .right { margin-left: auto; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
@@ -105,6 +105,8 @@ td.check, th.check { width: 28px; padding-right: 0; }
|
||||
.elev .slot.m.deploying, .elev .slot.m.releasing, .elev .slot.m.stateless { border-left-color: var(--warn); }
|
||||
.elev .slot.m.deploy-failed, .elev .slot.m.release-failed { border-left-color: var(--danger); }
|
||||
.elev .slot.m:hover { filter: brightness(1.08); }
|
||||
.elev .u { grid-column: 1; } .elev .slot { grid-column: 2; display: flex; align-items: center; gap: 4px; }
|
||||
.elev .slot.node { background-image: repeating-linear-gradient(90deg, transparent 0 24%, var(--line) 24% 25%); }
|
||||
.tree { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.tree .chip.on { background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
@@ -176,3 +178,70 @@ svg.topo .mach.group.ok rect:first-child { stroke: var(--ok); } svg.topo .mach.g
|
||||
.statebar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--pill); margin: 4px 0 10px; }
|
||||
.statebar span { height: 100%; } .statebar .ok { background: var(--ok); } .statebar .warn { background: var(--warn); } .statebar .bad { background: var(--danger); } .statebar .idle { background: var(--line2); }
|
||||
@media (max-width: 1100px) { .ov > .card, .ov > .card.w6, .ov > .card.w8 { grid-column: 1 / -1; } }
|
||||
|
||||
/* network page for large sites (netview.js) */
|
||||
.nv-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.nv-bar #nv-q { width: 240px; } .nv-bar .right { margin-left: auto; }
|
||||
.seg-btns { display: inline-flex; } .seg-btns .btn { border-radius: 0; } .seg-btns .btn:first-child { border-radius: 6px 0 0 6px; } .seg-btns .btn:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; }
|
||||
.seg-btns .btn.on { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
.nv-issues { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; margin-bottom: 12px; }
|
||||
.nv-issues.ok { color: var(--muted); padding: 9px 12px; }
|
||||
.nv-issue { padding: 4px 0; } .nv-issue[data-ips], .nv-issue[data-nvrack] { cursor: pointer; } .nv-issue[data-ips]:hover, .nv-issue[data-nvrack]:hover { text-decoration: underline; }
|
||||
.nv-issue.bad { color: var(--danger); } .nv-issue.warn { color: var(--warn); }
|
||||
.nv-issues > a { display: inline-block; margin: 2px 0 4px; font-size: 12px; }
|
||||
table.nv-matrix { min-width: 0; } .nv-matrix td, .nv-matrix th { padding: 5px 8px; }
|
||||
.nv-matrix th.nv-rackh { height: 92px; vertical-align: bottom; padding: 4px 2px; width: 26px; min-width: 26px; text-align: center; }
|
||||
.nv-matrix th.nv-rackh span { writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap; font-family: var(--mono); text-transform: none; letter-spacing: 0; font-size: 11.5px; }
|
||||
.nv-matrix th.nv-rackh.down span { color: var(--danger); }
|
||||
.nv-matrix td.nv-cell { text-align: center; padding: 5px 2px; font-size: 14px; color: var(--muted); border-left: 1px solid var(--line); }
|
||||
.nv-matrix td.nv-cell.serve { color: var(--ok); } .nv-matrix td.nv-cell.second { opacity: .65; } .nv-matrix td.nv-cell.down { color: var(--danger); opacity: 1; }
|
||||
.nv-matrix tr.nv-fab td { background: var(--hover); font-weight: 600; font-size: 12px; padding: 5px 8px; }
|
||||
.nv-matrix tr.nv-row { cursor: pointer; } .nv-matrix tr.nv-row:hover td { background: var(--hover); }
|
||||
.nv-matrix tr.nv-row.hl td { background: var(--sel); }
|
||||
.nv-matrix td.num, .nv-matrix th.num { text-align: right; }
|
||||
.nv-states { display: flex; height: 4px; border-radius: 2px; overflow: hidden; margin-top: 2px; min-width: 60px; background: var(--pill); }
|
||||
.nv-states i { display: block; } .nv-states i.ok { background: var(--ok); } .nv-states i.warn { background: var(--warn); } .nv-states i.bad { background: var(--danger); } .nv-states i.idle { background: var(--line2); }
|
||||
.nv-matrix .bar { min-width: 70px; }
|
||||
.bar.warn > span { background: var(--warn); } .bar.bad > span { background: var(--danger); }
|
||||
.legend b.g { color: var(--ok); font-weight: 400; } .legend b.r { color: var(--danger); font-weight: 400; }
|
||||
.nv-pathcard { margin-bottom: 12px; } .nv-path { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; padding: 4px 0; }
|
||||
.nv-path .arr { color: var(--muted); }
|
||||
|
||||
/* sites, map, floor plans (sites.js) */
|
||||
#site-map { height: 440px; background: var(--bg); }
|
||||
.room-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.room-card { display: block; text-decoration: none; color: inherit; } .room-card:hover { background: var(--hover); }
|
||||
.room-mini { margin: 4px 0 8px; }
|
||||
.floor-wrap { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 12px; align-items: start; }
|
||||
.floor-card { overflow: auto; } .floor-side { position: sticky; top: 12px; max-height: calc(100vh - 40px); overflow: auto; }
|
||||
@media (max-width: 1100px) { .floor-wrap { grid-template-columns: 1fr; } }
|
||||
svg.floor { display: block; touch-action: none; user-select: none; }
|
||||
svg.floor .room { fill: var(--bg); stroke: var(--line2); }
|
||||
svg.floor .tile { stroke: var(--line); stroke-width: 0.5; }
|
||||
svg.floor .rk { cursor: pointer; } svg.floor .rk .body { fill: var(--pill); stroke: var(--line2); }
|
||||
svg.floor .rk.ok .body { fill: color-mix(in srgb, var(--ok) 28%, var(--panel)); stroke: var(--ok); }
|
||||
svg.floor .rk.warn .body { fill: color-mix(in srgb, var(--warn) 28%, var(--panel)); stroke: var(--warn); }
|
||||
svg.floor .rk.bad .body { fill: color-mix(in srgb, var(--danger) 32%, var(--panel)); stroke: var(--danger); }
|
||||
svg.floor .rk.idle .body { fill: var(--pill); stroke: var(--muted); }
|
||||
svg.floor .rk.auto .body { stroke-dasharray: 3 2; }
|
||||
svg.floor .rk.sel .body { stroke: var(--accent); stroke-width: 2.5; }
|
||||
svg.floor .rk .front { stroke: var(--fg); stroke-width: 3; stroke-linecap: round; }
|
||||
svg.floor.mini .rk .front { stroke-width: 1.5; }
|
||||
svg.floor .rk .fill { fill: var(--accent); }
|
||||
svg.floor .rk text { font-size: 9px; fill: var(--fg); pointer-events: none; font-family: var(--mono); }
|
||||
.legend b.lg { display: inline-block; width: 12px; height: 12px; border-radius: 2px; margin-right: 5px; vertical-align: middle; border: 1px solid var(--line2); }
|
||||
.legend b.lg.ok { background: color-mix(in srgb, var(--ok) 28%, var(--panel)); border-color: var(--ok); }
|
||||
.legend b.lg.warn { background: color-mix(in srgb, var(--warn) 28%, var(--panel)); border-color: var(--warn); }
|
||||
.legend b.lg.bad { background: color-mix(in srgb, var(--danger) 32%, var(--panel)); border-color: var(--danger); }
|
||||
.legend b.lg.vacant { background: var(--pill); }
|
||||
.room3d { position: relative; height: min(72vh, 680px); min-height: 360px; border-radius: 6px; overflow: hidden; }
|
||||
.room3d canvas { display: block; cursor: grab; } .room3d canvas:active { cursor: grabbing; }
|
||||
.r3-tip { position: absolute; pointer-events: none; background: var(--panel); border: 1px solid var(--line2); border-radius: 6px; padding: 4px 8px; font-size: 12px; white-space: nowrap; box-shadow: 0 4px 14px rgba(0,0,0,.18); }
|
||||
.sw-list { display: grid; gap: 6px; } .sw-row { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.sw-row > div { min-width: 0; overflow-wrap: anywhere; } .sw-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.nb-link { font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; white-space: nowrap; align-self: center; }
|
||||
.page-head .right .nb-link { margin-right: 6px; }
|
||||
.cab-m { margin-top: 8px; border-top: 1px solid var(--line); padding-top: 6px; }
|
||||
.cab-p { display: grid; grid-template-columns: 58px minmax(0, 1fr) 14px; gap: 6px; font-size: 12px; padding: 1px 0; } .cab-p > span { overflow-wrap: anywhere; }
|
||||
.elev .slot.sw { background: var(--hover); border-left: 3px solid var(--accent); padding: 0 6px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.btn.on { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } /* a toggle that is on (Cables), outside a segmented group too */
|
||||
+35
-18
@@ -30,10 +30,12 @@ function inCidr(ip, cidr) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
// topoModel groups subnets into segments (one per VLAN), attaches racks and machines.
|
||||
function topoModel() {
|
||||
// topoModel groups subnets into segments (one per VLAN), attaches racks and
|
||||
// machines; f (netview.js nvFilter) keeps part of the site.
|
||||
function topoModel(f = {}) {
|
||||
const keepSub = f.subnet || (() => true), keepRack = f.rack || (() => true), keepM = f.machine || (() => true);
|
||||
const segs = [], byVlan = {};
|
||||
for (const s of S.subnets) {
|
||||
for (const s of S.subnets.filter(keepSub)) {
|
||||
const key = s.vlan_id ? 'v' + s.vlan_id : 'c' + s.cidr;
|
||||
if (!byVlan[key]) { byVlan[key] = { key, fabric: s.fabric, vid: s.vid, mtu: s.mtu, subnets: [], racks: new Set(), dhcp: false, purpose: s.purpose }; segs.push(byVlan[key]); }
|
||||
const g = byVlan[key];
|
||||
@@ -44,7 +46,7 @@ function topoModel() {
|
||||
segs.sort((a, b) => (b.dhcp - a.dhcp) || String(a.fabric).localeCompare(String(b.fabric)) || a.vid - b.vid);
|
||||
const segOf = ip => segs.findIndex(g => g.subnets.some(s => inCidr(ip, s.cidr)));
|
||||
// rack controllers: the segments they serve DHCP on, plus those their interfaces sit on
|
||||
const racks = S.controllers.map(c => {
|
||||
const racks = S.controllers.filter(c => keepRack(c.name)).map(c => {
|
||||
const at = new Set();
|
||||
segs.forEach((g, i) => { if (g.racks.has(c.name)) at.add(i); });
|
||||
for (const itf of (c.interfaces || [])) for (const a of (itf.addrs || [])) { const i = segOf(a); if (i >= 0) at.add(i); }
|
||||
@@ -53,7 +55,7 @@ function topoModel() {
|
||||
// machines: every address Rollcall knows for them
|
||||
const ipsBy = {};
|
||||
for (const x of S.ips) if (x.machine_id) (ipsBy[x.machine_id] = ipsBy[x.machine_id] || []).push(x.ip);
|
||||
const ms = S.machines.map(m => {
|
||||
const ms = S.machines.filter(keepM).map(m => {
|
||||
const at = new Set();
|
||||
for (const ip of [...(ipsBy[m.id] || []), m.mgmt_ip, m.ib_ip]) { if (!ip) continue; const i = segOf(ip); if (i >= 0) at.add(i); }
|
||||
return { m, at: [...at].sort((a, b) => a - b) };
|
||||
@@ -75,22 +77,36 @@ const stateClass = st => /failed/.test(st) ? 'bad' : /deploying|releasing|recomm
|
||||
|
||||
// topoSVG({compact}) -> svg markup. compact: overview card (fewer labels, collapsed groups).
|
||||
function topoSVG(opt = {}) {
|
||||
const { segs, racks, ms } = topoModel();
|
||||
if (!segs.length && !racks.length) return '<div class="empty">No subnets or rack controllers yet.</div>';
|
||||
const model = topoModel(opt.filter), { segs } = model;
|
||||
let { racks, ms } = model;
|
||||
const filtered = !!(opt.filter && opt.filter.active);
|
||||
if (filtered) ms = ms.filter(x => x.at.length); // machines elsewhere are not part of this view
|
||||
if (!segs.length && !racks.length) return '<div class="empty">No subnets or rack controllers match.</div>';
|
||||
const compact = !!opt.compact;
|
||||
const BW = compact ? 112 : 156, BH = compact ? 40 : 50, GAP = compact ? 12 : 16, M = 24;
|
||||
const collapseAt = compact ? 4 : 8;
|
||||
const maxItems = compact ? 10 : 28; // more boxes than this and the drawing stops being readable
|
||||
|
||||
// machine items: one box per machine, or one per group of machines with the same wiring
|
||||
const groups = {};
|
||||
for (const x of ms) { const k = (x.m.rack_controller || '') + '|' + x.at.join(','); (groups[k] = groups[k] || []).push(x); }
|
||||
// machine items: one box per machine, or one per group of machines with the
|
||||
// same wiring; when that still makes too many, coarser groups: by rack and
|
||||
// first VLAN, then by rack alone (a group is wired to every VLAN of its members)
|
||||
const rackIdx = name => { const i = racks.findIndex(r => r.c.name === name); return i < 0 ? 999 : i; };
|
||||
let items = [];
|
||||
for (const [k, xs] of Object.entries(groups)) {
|
||||
const rack = k.split('|')[0];
|
||||
if (xs.length >= collapseAt) items.push({ group: xs, at: xs[0].at, rack });
|
||||
else for (const x of xs) items.push({ m: x.m, at: x.at, rack });
|
||||
}
|
||||
const build = (key, at) => {
|
||||
const groups = {};
|
||||
for (const x of ms) { const k = (x.m.rack_controller || '') + '|' + key(x); (groups[k] = groups[k] || []).push(x); }
|
||||
const out = [];
|
||||
for (const [k, xs] of Object.entries(groups)) {
|
||||
const rack = k.split('|')[0];
|
||||
if (xs.length >= collapseAt || at) out.push({ group: xs, at: [...new Set(xs.flatMap(x => x.at))].sort((a, b) => a - b), rack });
|
||||
else for (const x of xs) out.push({ m: x.m, at: x.at, rack });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
let items = build(x => x.at.join(','));
|
||||
if (items.length > maxItems) items = build(x => String(x.at[0] ?? ''), true);
|
||||
if (items.length > maxItems) items = build(() => '', true);
|
||||
for (const it of items) if (it.group && it.group.length === 1) { it.m = it.group[0].m; delete it.group; }
|
||||
if (filtered) racks = racks.filter(r => r.at.length || items.some(it => it.rack === r.c.name));
|
||||
items.sort((a, b) => rackIdx(a.rack) - rackIdx(b.rack) || (a.at[0] ?? 99) - (b.at[0] ?? 99) || String(a.m ? nameOf(a.m) : '').localeCompare(String(b.m ? nameOf(b.m) : '')));
|
||||
|
||||
const minW = compact ? 640 : 980;
|
||||
@@ -119,7 +135,8 @@ function topoSVG(opt = {}) {
|
||||
if (hasIPAM) W = Math.max(W, ix + iw / 2 + M);
|
||||
|
||||
const o = [];
|
||||
o.push(`<svg class="topo${compact ? ' compact' : ''}" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" style="max-width:100%;height:auto" role="img" aria-label="network topology">`);
|
||||
// the overview card shrinks it to fit; the network page scrolls instead (text stays readable)
|
||||
o.push(`<svg class="topo${compact ? ' compact' : ''}" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"${compact ? ' style="max-width:100%;height:auto"' : ''} role="img" aria-label="network topology">`);
|
||||
// segments (buses) with their labels in the left gutter
|
||||
segs.forEach((g, i) => {
|
||||
const y = segY[i];
|
||||
@@ -130,7 +147,7 @@ function topoSVG(opt = {}) {
|
||||
o.push(`<line x1="${M + 250}" y1="${y}" x2="${W - M}" y2="${y}"/>`);
|
||||
o.push(`<text class="t1" x="${M}" y="${y - 4}">${esc((g.fabric || 'no fabric') + ' · ' + (g.vid ? 'vlan ' + g.vid : 'untagged'))}</text>`);
|
||||
if (!compact) o.push(`<text class="t2" x="${M}" y="${y + 11}">${esc([...v4, ...v6].map(s => s.cidr).join(' '))}</text>`);
|
||||
o.push(`<text class="t3" x="${W - M}" y="${y - 5}" text-anchor="end">${esc(g.purpose)}${g.dhcp ? ' · DHCP/PXE' : ''}${g.mtu && g.mtu !== 1500 ? ' · mtu ' + g.mtu : ''} · ${used} addr</text>`);
|
||||
o.push(`<text class="t3" x="${W - M}" y="${y - 5}" text-anchor="end">${esc(g.purpose)}${g.dhcp ? (g.subnets.some(s => s.dhcp_mode === 'proxy') ? ' · proxy DHCP (PXE only)' : ' · DHCP/PXE') : ''}${g.mtu && g.mtu !== 1500 ? ' · mtu ' + g.mtu : ''} · ${used} addr</text>`);
|
||||
o.push('</g>');
|
||||
});
|
||||
// region + external IPAM
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/* @preserve
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
|
||||
//# sourceMappingURL=leaflet.js.map
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2023 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2010-2023 Three.js Authors
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
const t="160",e={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},n={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:3},i=0,r=1,s=2,a=3,o=0,l=1,c=2,h=3,u=0,d=1,p=2,m=2,f=0,g=1,_=2,v=3,x=4,y=5,M=100,S=101,b=102,E=103,T=104,w=200,A=201,R=202,C=203,P=204,L=205,I=206,U=207,N=208,D=209,O=210,F=211,B=212,z=213,H=214,V=0,k=1,G=2,W=3,X=4,j=5,q=6,Y=7,Z=0,J=1,K=2,$=0,Q=1,tt=2,et=3,nt=4,it=5,rt=6,st="attached",at="detached",ot=300,lt=301,ct=302,ht=303,ut=304,dt=306,pt=1e3,mt=1001,ft=1002,gt=1003,_t=1004,vt=1004,xt=1005,yt=1005,Mt=1006,St=1007,bt=1007,Et=1008,Tt=1008,wt=1009,At=1010,Rt=1011,Ct=1012,Pt=1013,Lt=1014,It=1015,Ut=1016,Nt=1017,Dt=1018,Ot=1020,Ft=1021,Bt=1023,zt=1024,Ht=1025,Vt=1026,kt=1027,Gt=1028,Wt=1029,Xt=1030,jt=1031,qt=1033,Yt=33776,Zt=33777,Jt=33778,Kt=33779,$t=35840,Qt=35841,te=35842,ee=35843,ne=36196,ie=37492,re=37496,se=37808,ae=37809,oe=37810,le=37811,ce=37812,he=37813,ue=37814,de=37815,pe=37816,me=37817,fe=37818,ge=37819,_e=37820,ve=37821,xe=36492,ye=36494,Me=36495,Se=36283,be=36284,Ee=36285,Te=36286,we=2200,Ae=2201,Re=2202,Ce=2300,Pe=2301,Le=2302,Ie=2400,Ue=2401,Ne=2402,De=2500,Oe=2501,Fe=0,Be=1,ze=2,He=3e3,Ve=3001,ke=3200,Ge=3201,We=0,Xe=1,je="",qe="srgb",Ye="srgb-linear",Ze="display-p3",Je="display-p3-linear",Ke="linear",$e="srgb",Qe="rec709",tn="p3",en=0,nn=7680,rn=7681,sn=7682,an=7683,on=34055,ln=34056,cn=5386,hn=512,un=513,dn=514,pn=515,mn=516,fn=517,gn=518,_n=519,vn=512,xn=513,yn=514,Mn=515,Sn=516,bn=517,En=518,Tn=519,wn=35044,An=35048,Rn=35040,Cn=35045,Pn=35049,Ln=35041,In=35046,Un=35050,Nn=35042,Dn="100",On="300 es",Fn=1035,Bn=2e3,zn=2001;class Hn{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const Vn=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let kn=1234567;const Gn=Math.PI/180,Wn=180/Math.PI;function Xn(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(Vn[255&t]+Vn[t>>8&255]+Vn[t>>16&255]+Vn[t>>24&255]+"-"+Vn[255&e]+Vn[e>>8&255]+"-"+Vn[e>>16&15|64]+Vn[e>>24&255]+"-"+Vn[63&n|128]+Vn[n>>8&255]+"-"+Vn[n>>16&255]+Vn[n>>24&255]+Vn[255&i]+Vn[i>>8&255]+Vn[i>>16&255]+Vn[i>>24&255]).toLowerCase()}function jn(t,e,n){return Math.max(e,Math.min(n,t))}function qn(t,e){return(t%e+e)%e}function Yn(t,e,n){return(1-n)*t+n*e}function Zn(t){return 0==(t&t-1)&&0!==t}function Jn(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function Kn(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function $n(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const Qn={DEG2RAD:Gn,RAD2DEG:Wn,generateUUID:Xn,clamp:jn,euclideanModulo:qn,mapLinear:function(t,e,n,i,r){Line truncated
|
||||
+31
-13
@@ -25,7 +25,9 @@
|
||||
# --nat UPLINK this host routes for them: forward + masquerade out of UPLINK
|
||||
# (default with --iface and no --gateway: the default route's interface)
|
||||
# --domain D DNS domain handed to machines (default rollcall.internal)
|
||||
# --no-dhcp model the network but leave DHCP to the existing server
|
||||
# --no-dhcp model the network but leave DHCP to the existing server (no PXE from here)
|
||||
# --proxy-dhcp the site's DHCP server keeps handing out addresses; this host only answers
|
||||
# PXE clients with where to boot from (no ranges, no NAT)
|
||||
# --ssh-key K SSH public key (or a file of them) for root on deployed machines; repeatable.
|
||||
# Default: the keys of the user running sudo, then root's.
|
||||
# --os NAME import one OS image now (e.g. debian-13); a nightly timer syncs the catalog
|
||||
@@ -46,6 +48,7 @@ while [ $# -gt 0 ]; do
|
||||
--os) OS=$2; shift ;;
|
||||
--ssh-key) SSHKEYS+=("$2"); shift ;;
|
||||
--no-dhcp) DHCP=0 ;;
|
||||
--proxy-dhcp) DHCP=2 ;;
|
||||
--yes|-y) YES=1 ;;
|
||||
-h|--help) sed -n '2,31p' "$0"; exit 0 ;;
|
||||
*) echo "unknown option $1 (--help)" >&2; exit 2 ;;
|
||||
@@ -70,9 +73,10 @@ if [ -n "$IFACE" ]; then
|
||||
[ -n "$CIDR_ADDR" ] || die "$IFACE has no IPv4 address; give it its address on the provisioning network first"
|
||||
read -r ADDR NET PREFIX FIRST <<<"$(python3 -c 'import ipaddress,sys;i=ipaddress.ip_interface(sys.argv[1]);print(i.ip,i.network,i.network.prefixlen,i.network.network_address)' "$CIDR_ADDR")"
|
||||
[ "$PREFIX" -le 24 ] || die "$NET is smaller than a /24; model it by hand (rollcall subnet add / range add)"
|
||||
if [ -z "$GATEWAY" ]; then GATEWAY=$ADDR; NAT=${NAT:-$UPLINK}; fi
|
||||
case $DHCP in 1) DHCPTXT="served here" ;; 2) DHCPTXT="proxy (the site's server hands out addresses)" ;; *) DHCPTXT=off ;; esac
|
||||
if [ -z "$GATEWAY" ] && [ $DHCP != 2 ]; then GATEWAY=$ADDR; NAT=${NAT:-$UPLINK}; fi
|
||||
[ -n "$NAT" ] && [ "$NAT" = "$IFACE" ] && die "--nat must be the uplink, not $IFACE"
|
||||
echo " provisioning $IFACE $CIDR_ADDR (subnet $NET), machines' gateway $GATEWAY${NAT:+ via NAT out of $NAT}, DHCP $([ $DHCP = 1 ] && echo on || echo off)"
|
||||
echo " provisioning $IFACE $CIDR_ADDR (subnet $NET), machines' gateway ${GATEWAY:-from the site DHCP server}${NAT:+ via NAT out of $NAT}, DHCP $DHCPTXT"
|
||||
else
|
||||
[ -n "$UPLINK" ] || die "no default route: give this host network access first"
|
||||
ADDR=$(addr_of "$UPLINK" | cut -d/ -f1)
|
||||
@@ -91,7 +95,7 @@ fi
|
||||
# ------------------------------------------------------------------ 1. packages
|
||||
say "1/9 packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
PKGS="postgresql dnsmasq pxelinux syslinux-common syslinux-efi qemu-utils ipmitool jq zstd curl ca-certificates python3 nftables lldpd"
|
||||
PKGS="postgresql dnsmasq pxelinux syslinux-common syslinux-efi shim-signed grub-efi-amd64-signed qemu-utils ipmitool jq zstd curl ca-certificates python3 nftables lldpd"
|
||||
[ $BUILD = 1 ] && PKGS="$PKGS live-build debootstrap squashfs-tools xorriso"
|
||||
apt-get update -q >/dev/null
|
||||
apt-get install -yq $PKGS >/dev/null
|
||||
@@ -111,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"
|
||||
@@ -237,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-
|
||||
@@ -247,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
|
||||
@@ -271,17 +279,27 @@ else
|
||||
FAB=provisioning
|
||||
rollcall fabric list 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$FAB" || rollcall fabric add "$FAB" >/dev/null
|
||||
if ! rollcall subnet list 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$NET"; then
|
||||
rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning -gateway "$GATEWAY" -dns "$ADDR" -domain "$DOMAIN" -name provisioning >/dev/null
|
||||
rollcall range add "$NET" reserved "$(off 1)" "$(off 20)" >/dev/null
|
||||
rollcall range add "$NET" static "$(off 50)" "$(off 99)" >/dev/null
|
||||
rollcall range add "$NET" dynamic "$(off 100)" "$(off 200)" >/dev/null
|
||||
ok "subnet $NET: reserved $(off 1)-$(off 20), static $(off 50)-$(off 99), dynamic $(off 100)-$(off 200)"
|
||||
if [ $DHCP = 2 ]; then
|
||||
# the site's DHCP server owns the addresses: no ranges (machines keep DHCP);
|
||||
# set a static range aside later (rollcall range add) to give them fixed ones
|
||||
rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning ${GATEWAY:+-gateway "$GATEWAY"} -domain "$DOMAIN" -name provisioning >/dev/null
|
||||
ok "subnet $NET (addresses from the site's DHCP server)"
|
||||
else
|
||||
rollcall subnet add "$NET" -fabric "$FAB" -vid 0 -purpose provisioning -gateway "$GATEWAY" -dns "$ADDR" -domain "$DOMAIN" -name provisioning >/dev/null
|
||||
rollcall range add "$NET" reserved "$(off 1)" "$(off 20)" >/dev/null
|
||||
rollcall range add "$NET" static "$(off 50)" "$(off 99)" >/dev/null
|
||||
rollcall range add "$NET" dynamic "$(off 100)" "$(off 200)" >/dev/null
|
||||
ok "subnet $NET: reserved $(off 1)-$(off 20), static $(off 50)-$(off 99), dynamic $(off 100)-$(off 200)"
|
||||
fi
|
||||
else
|
||||
ok "subnet $NET exists, kept"
|
||||
fi
|
||||
if [ $DHCP = 1 ]; then
|
||||
rollcall vlan add "$FAB" 0 -dhcp -rack "$RACK" >/dev/null
|
||||
ok "DHCP/PXE on $IFACE"
|
||||
elif [ $DHCP = 2 ]; then
|
||||
rollcall vlan add "$FAB" 0 -proxy-dhcp -rack "$RACK" >/dev/null
|
||||
ok "proxy DHCP on $IFACE: PXE answers only; addresses from the site's DHCP server"
|
||||
fi
|
||||
if [ -n "$NAT" ]; then
|
||||
echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/60-rollcall-forward.conf
|
||||
|
||||
+13
-2
@@ -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 shim-signed grub-efi-amd64-signed 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
|
||||
@@ -38,6 +38,14 @@ for f in ldlinux.c32 menu.c32 libutil.c32 libcom32.c32 poweroff.c32; do cp /usr/
|
||||
cp /usr/lib/SYSLINUX.EFI/efi64/syslinux.efi "$TFTP/efi64/"
|
||||
for f in ldlinux.e64 menu.c32 libutil.c32 libcom32.c32; do cp /usr/lib/syslinux/modules/efi64/$f "$TFTP/efi64/"; done
|
||||
ln -sfn ../pxelinux.cfg "$TFTP/efi64/pxelinux.cfg"
|
||||
# UEFI, Secure Boot included: signed shim and GRUB; rollcall writes the GRUB menus next to pxelinux's
|
||||
if [ -f /usr/lib/shim/shimx64.efi.signed ] && [ -f /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed ]; then
|
||||
install -m 0644 /usr/lib/shim/shimx64.efi.signed "$TFTP"/bootx64.efi
|
||||
install -m 0644 /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed "$TFTP"/grubx64.efi
|
||||
install -d -o root -g rollcall -m 2775 "$TFTP"/grub
|
||||
else
|
||||
echo "WARNING: shim-signed or grub-efi-amd64-signed missing: UEFI machines cannot PXE-boot (apt install shim-signed grub-efi-amd64-signed)" >&2
|
||||
fi
|
||||
ln -sfn "$CACHE/rollcall" "$TFTP/rollcall"
|
||||
ln -sfn "$CACHE/images" "$TFTP/images"
|
||||
ln -sfn ../rollcall "$TFTP/efi64/rollcall"
|
||||
@@ -48,15 +56,18 @@ 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
|
||||
install -d -m 0700 -o rollcall -g rollcall /var/lib/rollcall/rack-cert # renewed certificates (the joined ones are root's)
|
||||
install -m 0644 rollcall-dnsmasq.path rollcall-dnsmasq.service rollcall-rack.service rollcall-rack-update.path rollcall-rack-update.service /etc/systemd/system/
|
||||
|
||||
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
|
||||
|
||||
+14
-3
@@ -16,6 +16,14 @@ 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
|
||||
# UEFI, Secure Boot included: signed shim and GRUB; rollcall writes the GRUB menus next to pxelinux's
|
||||
if [ -f /usr/lib/shim/shimx64.efi.signed ] && [ -f /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed ]; then
|
||||
install -m 0644 /usr/lib/shim/shimx64.efi.signed /srv/tftp/bootx64.efi
|
||||
install -m 0644 /usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed /srv/tftp/grubx64.efi
|
||||
install -d -o root -g rollcall -m 2775 /srv/tftp/grub
|
||||
else
|
||||
echo "WARNING: shim-signed or grub-efi-amd64-signed missing: UEFI machines cannot PXE-boot (apt install shim-signed grub-efi-amd64-signed)" >&2
|
||||
fi
|
||||
sed -e "s/@AGENT_TOKEN@/$(jq -r .agent_token /etc/rollcall/config.json)/g" -e "s#@SERVER@#$(jq -r .server_url /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
|
||||
@@ -39,9 +47,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
|
||||
|
||||
@@ -8,15 +8,18 @@ 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)]')
|
||||
# build-image.sh writes SHA256SUMS; check it, or make it for an image without one
|
||||
if [ -f SHA256SUMS ]; then sha256sum -c --quiet SHA256SUMS; else sha256sum vmlinuz initrd.img filesystem.squashfs > SHA256SUMS; fi
|
||||
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"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
# rollcall-intermediate-renew [-force] NAME: keep a delegated intermediate CA
|
||||
# current. Runs daily on the host that holds the intermediate's key (an ACME
|
||||
# server: Caddy, step-ca), from /etc/rollcall-intermediate/NAME.env:
|
||||
# URL=https://region.example the region (HTTPS)
|
||||
# TOKEN_FILE=/etc/rollcall-intermediate/NAME.token from `rollcall ca intermediate`
|
||||
# CERT=/etc/caddy/pki/int.pem the intermediate's certificate (replaced)
|
||||
# KEY=/etc/caddy/pki/int-key.pem its key (stays; the CSR is made from it)
|
||||
# ROOT=/etc/caddy/pki/root.pem optional: kept to the active root
|
||||
# CA=/etc/rollcall-intermediate/NAME-bundle.pem the region's roots (seeded from ROOT, kept current)
|
||||
# RELOAD='systemctl restart caddy' optional: after a new certificate
|
||||
# The region decides whether a new certificate is due (the last third of its
|
||||
# life, or a root rotation) and signs with the constraints it was given.
|
||||
set -eu
|
||||
force=
|
||||
[ "${1:-}" = -force ] && { force=1; shift; }
|
||||
NAME=${1:?usage: rollcall-intermediate-renew [-force] NAME}
|
||||
. "/etc/rollcall-intermediate/$NAME.env"
|
||||
: "${URL:?}" "${TOKEN_FILE:?}" "${CERT:?}" "${KEY:?}"
|
||||
CA=${CA:-/etc/rollcall-intermediate/$NAME-bundle.pem}
|
||||
if [ ! -s "$CA" ]; then
|
||||
[ -n "${ROOT:-}" ] && [ -s "$ROOT" ] || { echo "rollcall: $CA missing and no ROOT to start it from" >&2; exit 1; }
|
||||
cp "$ROOT" "$CA"
|
||||
fi
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
|
||||
# the region's roots, checked against the ones held: a rotation reaches here during prepare
|
||||
if curl -fsS --max-time 30 --cacert "$CA" -o "$T/bundle" "$URL/ca-bundle.pem" && grep -q 'BEGIN CERTIFICATE' "$T/bundle"; then
|
||||
cmp -s "$T/bundle" "$CA" || { cat "$T/bundle" > "$CA"; echo "rollcall: $NAME: the region's roots changed; $CA updated"; }
|
||||
fi
|
||||
|
||||
openssl req -new -key "$KEY" -subj "/CN=$NAME" -out "$T/csr" 2>/dev/null
|
||||
q=; [ -n "$force" ] && q='?force=1'
|
||||
code=$(curl -sS --max-time 60 --cacert "$CA" -o "$T/cert" -w '%{http_code}' -H "Authorization: Bearer $(cat "$TOKEN_FILE")" \
|
||||
-H 'Accept: application/x-pem-file' --data-binary @"$T/csr" "$URL/api/v1/ca/intermediates/$NAME/renew$q") || { echo "rollcall: $NAME: the region did not answer" >&2; exit 1; }
|
||||
case $code in
|
||||
204) exit 0 ;; # nothing due
|
||||
200) ;;
|
||||
*) echo "rollcall: $NAME: renewal refused (HTTP $code): $(cat "$T/cert")" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# what came back: a CA certificate for this key, from a root held here
|
||||
openssl x509 -in "$T/cert" -noout -text | grep -q 'CA:TRUE' || { echo "rollcall: $NAME: the answer is not a CA certificate" >&2; exit 1; }
|
||||
[ "$(openssl x509 -in "$T/cert" -noout -pubkey)" = "$(openssl pkey -in "$KEY" -pubout)" ] || { echo "rollcall: $NAME: the certificate is not for $KEY" >&2; exit 1; }
|
||||
openssl verify -CAfile "$CA" "$T/cert" >/dev/null || { echo "rollcall: $NAME: the certificate does not verify against $CA" >&2; exit 1; }
|
||||
put() { # $1 new content file, $2 target: same owner and mode, the old one kept as .prev
|
||||
[ -f "$2" ] && cp -p "$2" "$2.prev"
|
||||
if [ -f "$2" ]; then cat "$1" > "$2"; else install -m 0644 "$1" "$2"; fi
|
||||
}
|
||||
put "$T/cert" "$CERT"
|
||||
if [ -n "${ROOT:-}" ]; then # the active root: the first certificate of the bundle
|
||||
awk '/-----BEGIN CERTIFICATE-----/{n++} n==1{print} /-----END CERTIFICATE-----/ && n==1{exit}' "$CA" > "$T/root"
|
||||
cmp -s "$T/root" "$ROOT" || put "$T/root" "$ROOT"
|
||||
fi
|
||||
echo "rollcall: $NAME renewed: $(openssl x509 -in "$CERT" -noout -enddate)"
|
||||
[ -n "${RELOAD:-}" ] && sh -c "$RELOAD"
|
||||
exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=rollcall: renew the delegated intermediate CA %i
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/rollcall-intermediate-renew %i
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=rollcall: renew the delegated intermediate CA %i daily
|
||||
|
||||
[Timer]
|
||||
OnBootSec=15min
|
||||
OnUnitActiveSec=1d
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,8 +1,9 @@
|
||||
[Unit]
|
||||
Description=rollcall: install a rollcall binary the rack controller staged
|
||||
Description=rollcall: install a rollcall binary or trust bundle the rack controller staged
|
||||
|
||||
[Path]
|
||||
PathExists=/var/lib/rollcall/update/ready
|
||||
PathExists=/var/lib/rollcall/update/ca-ready
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,27 +1,82 @@
|
||||
#!/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.
|
||||
# Installs what the rack controller staged (rollcall-rack-update.path): a new
|
||||
# trust bundle (root rotation) and a new rollcall binary.
|
||||
#
|
||||
# The staging directory belongs to the rack's unprivileged user, so nothing in
|
||||
# it is trusted: files are copied into a directory of root's and installed only
|
||||
# if the region's signature over them verifies against one of the roots in the
|
||||
# CA file (root's, from rack.json ca_file; written at join and by this script).
|
||||
# A bundle replaces the CA file and restarts the rack. A 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" ] || [ -f "$D/ca-ready" ] || exit 0
|
||||
# root's work directory, on a file system that allows executing (/run is noexec)
|
||||
install -d -m 0700 -o root -g root /var/lib/rollcall-update
|
||||
W=$(mktemp -d /var/lib/rollcall-update/w.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 "no root-owned CA certificate ($CA), nothing installed"; exit 1; }
|
||||
# split PEM certificates of $1 into $2.1, $2.2, ...
|
||||
splitpem() { awk -v p="$2" '/-----BEGIN CERTIFICATE-----/{n++; f=p "." n} f{print > f} /-----END CERTIFICATE-----/{close(f); f=""}' "$1"; }
|
||||
# signed FILE SIG: the signature verifies against one of the roots in $CA
|
||||
signed() {
|
||||
rm -f "$W"/root.*; splitpem "$CA" "$W/root"
|
||||
for c in "$W"/root.*; do
|
||||
[ -f "$c" ] || continue
|
||||
openssl x509 -in "$c" -pubkey -noout > "$W/pub" 2>/dev/null &&
|
||||
openssl dgst -sha256 -verify "$W/pub" -signature "$2" "$1" >/dev/null 2>&1 && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -f "$D/ca-ready" ]; then
|
||||
rm -f "$D/ca-ready"
|
||||
if cp -- "$D/ca-bundle.pem" "$W/bundle" && cp -- "$D/ca-bundle.sig" "$W/bundle.sig"; then
|
||||
if ! signed "$W/bundle" "$W/bundle.sig"; then
|
||||
result "trust bundle: signature does not verify against $CA, not installed"
|
||||
else
|
||||
# only CA certificates, rewritten from what was parsed
|
||||
rm -f "$W"/new.*; splitpem "$W/bundle" "$W/new"; : > "$W/ca.new"; n=0; ok=1
|
||||
for c in "$W"/new.*; do
|
||||
[ -f "$c" ] || continue
|
||||
openssl x509 -in "$c" -noout -text 2>/dev/null | grep -q "CA:TRUE" || ok=
|
||||
openssl x509 -in "$c" >> "$W/ca.new" 2>/dev/null || ok=
|
||||
n=$((n+1))
|
||||
done
|
||||
if [ -z "$ok" ] || [ $n -eq 0 ]; then
|
||||
result "trust bundle: not CA certificates only, not installed"
|
||||
else
|
||||
cp -p "$CA" "$CA.prev"
|
||||
install -m 0644 -o root -g root "$W/ca.new" "$CA.tmp" && mv -f "$CA.tmp" "$CA"
|
||||
rm -f "$D/ca-bundle.pem" "$D/ca-bundle.sig"
|
||||
systemctl restart rollcall-rack.service
|
||||
result "trust bundle installed ($n roots)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -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"
|
||||
cp -- "$D/rollcall.new" "$W/bin" && cp -- "$D/rollcall.new.sig" "$W/sig" || { result "$want: staged files missing, not installed"; exit 1; }
|
||||
signed "$W/bin" "$W/sig" || { 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
|
||||
|
||||
@@ -15,7 +15,7 @@ NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/srv/tftp/pxelinux.cfg /var/lib/rollcall /var/cache/rollcall
|
||||
ReadWritePaths=/srv/tftp/pxelinux.cfg -/srv/tftp/grub /var/lib/rollcall /var/cache/rollcall
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -16,7 +16,7 @@ NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/etc/rollcall /srv/tftp/pxelinux.cfg /var/lib/rollcall
|
||||
ReadWritePaths=/etc/rollcall /srv/tftp/pxelinux.cfg -/srv/tftp/grub /var/lib/rollcall
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
--- a/src/rfc2131.c
|
||||
+++ b/src/rfc2131.c
|
||||
@@ -977,7 +977,12 @@
|
||||
int redirect4011 = 0;
|
||||
struct dhcp_opt *option;
|
||||
|
||||
- /* OK only dhcp-option-pxe options. */
|
||||
+ /* OK only dhcp-option-pxe options.
|
||||
+ option_filter() appends netid to the end of the context's tag list.
|
||||
+ tmp->netid is static and still links to the previous request's
|
||||
+ (stack) tags here; start it afresh, as the non-proxy path does,
|
||||
+ or the list can become circular and the tag walks never end. */
|
||||
+ tmp->netid.next = NULL;
|
||||
tagif_netid = option_filter(netid, tmp->netid.net ? &tmp->netid : NULL, daemon->dhcp_opts, 2);
|
||||
boot = find_boot(tagif_netid);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# dnsmasq: busy loop in proxy DHCP with a tagged range
|
||||
|
||||
Found 2026-10-29 while testing Rollcall's proxy DHCP mode (gdb on a stuck lab
|
||||
rack controller). Affects dnsmasq 2.91 (Debian 13) and 2.93 (latest release).
|
||||
Rollcall works around it by leaving the tag off proxy ranges
|
||||
(`cmd/rollcall/network.go`, `dnsmasqConfig`).
|
||||
|
||||
| File | |
|
||||
|---|---|
|
||||
| `report.txt` | the report to send upstream (plain text, patch inline) |
|
||||
| `repro.sh` | reproducer: `sudo ./repro.sh /path/to/dnsmasq` (two netns + veth, two PXE DISCOVERs) |
|
||||
| `0001-proxy-dhcp-reset-context-tags.patch` | one-line fix against 2.93, applies with `patch -p1` or `git apply` |
|
||||
|
||||
Results on a Debian 13 VM:
|
||||
|
||||
| dnsmasq | Configuration | CPU after two PXE requests |
|
||||
|---|---|---|
|
||||
| 2.91 (Debian) | tagged proxy range, `log-dhcp` | 100% |
|
||||
| 2.93 | same | 100% |
|
||||
| 2.93 + patch | same | 0% |
|
||||
| 2.93 | no `log-dhcp`, `dhcp-option` for an absent tag | 69% |
|
||||
| 2.93 + patch | same | 0% |
|
||||
|
||||
Status: not reported yet.
|
||||
@@ -0,0 +1,97 @@
|
||||
Subject: [PATCH] proxy DHCP: busy loop when a proxy dhcp-range has a tag
|
||||
|
||||
Hello Simon,
|
||||
|
||||
dnsmasq can be put into a busy loop by two PXE DHCPDISCOVERs from any client
|
||||
on a network with a tagged proxy range. dnsmasq then stops answering DHCP,
|
||||
TFTP and DNS until it is restarted. Reproduced on 2.91 (Debian 13,
|
||||
2.91-1+deb13u2) and on 2.93 built from the release tarball; a one-line patch
|
||||
is below.
|
||||
|
||||
Configuration (the relevant part):
|
||||
|
||||
dhcp-range=set:prov,192.0.2.0,proxy,255.255.255.0
|
||||
pxe-service=tag:prov,x86PC,"boot",pxelinux
|
||||
log-dhcp
|
||||
|
||||
After the second PXE request dnsmasq sits at 100% CPU, makes no system
|
||||
calls, and its sockets fill up. gdb on the stuck process:
|
||||
|
||||
#0 __strcmp_sse42 ()
|
||||
#1 log_tags (netid=..., xid=...) at dhcp-common.c:276
|
||||
#2 dhcp_reply (...) at rfc2131.c:1028 (proxy branch)
|
||||
#3 dhcp_packet (...) at dhcp.c:340
|
||||
#4 main (...) at dnsmasq.c:1299
|
||||
|
||||
The tag list that log_tags() walks is circular. Walking it in gdb on two
|
||||
occasions:
|
||||
|
||||
prov -> eth1 -> eth1 -> eth1 -> ... (iface tag points at itself)
|
||||
prov -> known -> bios -> eth1 -> known -> ... (with dhcp-host and dhcp-match)
|
||||
|
||||
Cause, as far as I can see: in the proxy branch of dhcp_reply(),
|
||||
|
||||
tagif_netid = option_filter(netid, tmp->netid.net ? &tmp->netid : NULL, ...);
|
||||
|
||||
option_filter() walks the context tags to their end and appends the request's
|
||||
tags:
|
||||
|
||||
for (last_tag = context_tags; last_tag->next; last_tag = last_tag->next);
|
||||
last_tag->next = tags;
|
||||
|
||||
tmp->netid is part of the (static) dhcp_context, so after the first request
|
||||
tmp->netid.next still points at the previous request's iface_id, a stack
|
||||
variable of dhcp_reply(). The next request has its iface_id at the same
|
||||
address with next == NULL, so the walk stops on it and sets
|
||||
iface_id.next = &iface_id. The non-proxy path does not have this problem
|
||||
because it assigns context->netid.next before use.
|
||||
|
||||
It is not only log_tags(): without log-dhcp, any walk that looks for a tag
|
||||
that is not in the list never ends. With
|
||||
|
||||
dhcp-option=tag:nosuch,option:ntp-server,192.0.2.9
|
||||
|
||||
added (and log-dhcp removed) 2.93 loops the same way.
|
||||
|
||||
The attached repro.sh shows it in a few seconds (Linux, root): it creates two
|
||||
network namespaces joined by a veth pair, runs the given dnsmasq binary with
|
||||
the configuration above in one and sends two PXE DISCOVERs from the other:
|
||||
|
||||
stock 2.93: dnsmasq CPU over 5 s: 100% RESULT: BUSY LOOP
|
||||
2.93 + patch: dnsmasq CPU over 5 s: 0% RESULT: ok
|
||||
stock 2.93, no log-dhcp, dhcp-option for an absent tag: 69% BUSY LOOP
|
||||
2.93 + patch, same: 0% ok
|
||||
|
||||
We found it on a PXE provisioning system whose rack controllers run dnsmasq
|
||||
in proxy mode next to a site's own DHCP server; the clients were ordinary
|
||||
iPXE ROMs. Our generated configurations now leave the tag off proxy ranges.
|
||||
|
||||
The patch resets the context tag before option_filter() appends to it, which
|
||||
is what the non-proxy path effectively does:
|
||||
|
||||
--- a/src/rfc2131.c
|
||||
+++ b/src/rfc2131.c
|
||||
@@ -977,7 +977,12 @@
|
||||
int redirect4011 = 0;
|
||||
struct dhcp_opt *option;
|
||||
|
||||
- /* OK only dhcp-option-pxe options. */
|
||||
+ /* OK only dhcp-option-pxe options.
|
||||
+ option_filter() appends netid to the end of the context's tag list.
|
||||
+ tmp->netid is static and still links to the previous request's
|
||||
+ (stack) tags here; start it afresh, as the non-proxy path does,
|
||||
+ or the list can become circular and the tag walks never end. */
|
||||
+ tmp->netid.next = NULL;
|
||||
tagif_netid = option_filter(netid, tmp->netid.net ? &tmp->netid : NULL, daemon->dhcp_opts, 2);
|
||||
boot = find_boot(tagif_netid);
|
||||
|
||||
|
||||
option_filter() might be the better place for a general fix (the DHCPv6 path
|
||||
in rfc3315.c passes state->context_tags the same way), but I have only
|
||||
tested the proxy case.
|
||||
|
||||
Since a single client can stop dnsmasq on the whole segment, you may want to
|
||||
treat this as a denial-of-service issue; I have not posted it anywhere else.
|
||||
|
||||
Regards,
|
||||
<name>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
# dnsmasq proxy-DHCP busy loop reproducer. Run as root on Linux.
|
||||
# ./repro.sh [/path/to/dnsmasq]
|
||||
# Creates two network namespaces joined by a veth pair, runs dnsmasq with one
|
||||
# tagged proxy range in one, sends two PXE DHCPDISCOVERs from the other, then
|
||||
# reports dnsmasq's CPU use. A healthy dnsmasq stays near 0%.
|
||||
set -eu
|
||||
DNSMASQ=${1:-dnsmasq}
|
||||
D=$(mktemp -d)
|
||||
cleanup() { [ -f "$D/pid" ] && kill "$(cat "$D/pid")" 2>/dev/null || true; ip netns del dq-srv 2>/dev/null || true; ip netns del dq-cli 2>/dev/null || true; rm -rf "$D"; }
|
||||
trap cleanup EXIT
|
||||
cleanup 2>/dev/null || true; D=$(mktemp -d)
|
||||
ip netns add dq-srv; ip netns add dq-cli
|
||||
ip link add dq0 type veth peer name dq1
|
||||
ip link set dq0 netns dq-srv; ip link set dq1 netns dq-cli
|
||||
ip -n dq-srv addr add 192.0.2.1/24 dev dq0; ip -n dq-srv link set dq0 up; ip -n dq-srv link set lo up
|
||||
ip -n dq-cli link set dq1 up; ip -n dq-cli link set lo up
|
||||
mkdir -p "$D/tftp"
|
||||
cat > "$D/dnsmasq.conf" <<EOF
|
||||
port=0
|
||||
interface=dq0
|
||||
bind-interfaces
|
||||
log-dhcp
|
||||
enable-tftp
|
||||
tftp-root=$D/tftp
|
||||
dhcp-range=set:prov,192.0.2.0,proxy,255.255.255.0
|
||||
pxe-service=tag:prov,x86PC,"boot",pxelinux
|
||||
EOF
|
||||
ip netns exec dq-srv "$DNSMASQ" --conf-file="$D/dnsmasq.conf" --pid-file="$D/pid" --log-facility="$D/log" -u root
|
||||
sleep 1
|
||||
"$DNSMASQ" --version | head -1
|
||||
# two PXE DISCOVERs (vendor class PXEClient, client arch 0 = x86 BIOS), like a PXE/iPXE ROM sends
|
||||
ip netns exec dq-cli python3 - <<'P'
|
||||
import os, socket, struct, time
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
s.setsockopt(socket.SOL_SOCKET, 25, b"dq1") # SO_BINDTODEVICE
|
||||
s.bind(("0.0.0.0", 68))
|
||||
mac = bytes.fromhex("020000000001")
|
||||
for n in range(2):
|
||||
xid = os.urandom(4)
|
||||
p = bytearray(240)
|
||||
p[0:4] = bytes([1, 1, 6, 0]); p[4:8] = xid
|
||||
struct.pack_into(">H", p, 10, 0x8000)
|
||||
p[28:34] = mac; p[236:240] = bytes([99, 130, 83, 99])
|
||||
vc = b"PXEClient:Arch:00000:UNDI:002001"
|
||||
p += bytes([53, 1, 1, 60, len(vc)]) + vc + bytes([93, 2, 0, 0, 94, 3, 1, 2, 1, 97, 17, 0]) + os.urandom(16) + bytes([255])
|
||||
s.sendto(bytes(p), ("255.255.255.255", 67))
|
||||
time.sleep(1)
|
||||
P
|
||||
sleep 2
|
||||
P=$(cat "$D/pid")
|
||||
a=$(awk '{print $14+$15}' /proc/$P/stat); sleep 5; b=$(awk '{print $14+$15}' /proc/$P/stat)
|
||||
cpu=$(( (b - a) * 100 / (5 * $(getconf CLK_TCK)) ))
|
||||
echo "dnsmasq CPU over 5 s: ${cpu}%"
|
||||
grep -c "proxy" "$D/log" | sed 's/^/proxy replies logged: /'
|
||||
[ "$cpu" -lt 50 ] && echo "RESULT: ok" || echo "RESULT: BUSY LOOP"
|
||||
@@ -3,13 +3,18 @@
|
||||
# Fetches this node's overlay tarball from the rollcall server (identified by
|
||||
# the rollcall.node token on the kernel command line), applies it over / when
|
||||
# it changed, then runs /run/rollcall/post-<stage>.sh if the overlay shipped one.
|
||||
# With rollcall.tls= and rollcall.ca= on the command line it talks HTTPS: the
|
||||
# CA certificate is accepted only by that SHA-256, and there is no fallback
|
||||
# to HTTP (the overlay carries secrets such as munge.key).
|
||||
set -eu
|
||||
stage=${1:-system}
|
||||
SERVER=""; TOKEN=""
|
||||
SERVER=""; TOKEN=""; TLS=""; CA=""
|
||||
for t in $(cat /proc/cmdline); do
|
||||
case $t in
|
||||
rollcall.server=*) SERVER=${t#*=} ;;
|
||||
rollcall.node=*) TOKEN=${t#*=} ;;
|
||||
rollcall.tls=*) TLS=${t#*=} ;;
|
||||
rollcall.ca=*) CA=$(echo "${t#*=}" | tr -d ':' | tr 'A-F' 'a-f') ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$SERVER" ] || [ -z "$TOKEN" ]; then
|
||||
@@ -17,9 +22,30 @@ if [ -z "$SERVER" ] || [ -z "$TOKEN" ]; then
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p /run/rollcall
|
||||
CURL_CA=""
|
||||
if [ -n "$TLS" ] && [ -n "$CA" ]; then
|
||||
cafile=/run/rollcall/ca.pem
|
||||
if [ ! -s "$cafile" ]; then
|
||||
i=0
|
||||
until curl -fsS --connect-timeout 5 "$SERVER/ca.pem" -o "$cafile.new"; do
|
||||
i=$((i+1)); [ "$i" -ge 30 ] && { echo "rollcall-overlay: cannot fetch the CA certificate" >&2; exit 1; }
|
||||
sleep 5
|
||||
done
|
||||
got=$(openssl x509 -in "$cafile.new" -outform DER | sha256sum | cut -d' ' -f1)
|
||||
if [ "$got" != "$CA" ]; then
|
||||
rm -f "$cafile.new"
|
||||
echo "rollcall-overlay: the CA certificate does not match rollcall.ca; not fetching the overlay" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$cafile.new" "$cafile"
|
||||
fi
|
||||
SERVER=$TLS
|
||||
CURL_CA="--cacert $cafile"
|
||||
fi
|
||||
tmp=/run/rollcall/$stage.tar.new
|
||||
i=0
|
||||
until curl -fsS --connect-timeout 5 -H "X-Rollcall-Node: $TOKEN" \
|
||||
# shellcheck disable=SC2086 # CURL_CA is two words or none
|
||||
until curl -fsS --connect-timeout 5 $CURL_CA -H "X-Rollcall-Node: $TOKEN" \
|
||||
"$SERVER/api/v1/nodes/self/overlay?stage=$stage" -o "$tmp"; do
|
||||
i=$((i+1))
|
||||
if [ "$i" -ge 60 ]; then echo "rollcall-overlay: server unreachable" >&2; exit 1; fi
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/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/
|
||||
@@ -11,6 +11,7 @@ Restart=always
|
||||
RestartSec=3
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
User=pve-vbmc
|
||||
StateDirectory=pve-vbmc
|
||||
Group=pve-vbmc
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user