25 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 704ac61a27 ca intermediate: the renewal hint names the region's HTTPS address, not loopback
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:31:17 +01:00
Ilker ManapandClaude Opus 5.5 05861bd413 Intermediates renew themselves through the region
CI / go (push) Successful in 51s
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
The region keeps every intermediate it signs: its name constraints,
certificate, the root that signed it and a renewal token (stored hashed,
printed once). `rollcall ca intermediate` goes through the API now (so it
works from any admin client) and writes NAME.token beside NAME.pem;
`rollcall ca intermediates` lists them.

The host with the intermediate's key runs deploy/rollcall-intermediate-
renew.sh daily (rollcall-intermediate-renew@NAME.timer): it posts a CSR
from the same key with the token to /api/v1/ca/intermediates/NAME/renew.
The region signs again in the last third of the certificate's life, or
at once when another root signs since a rotation's switch, always with
the constraints it was first given; otherwise 204. The script checks the
answer (a CA certificate for its key, from a root it holds), keeps the
old file, keeps the region's roots and the active root current from
/ca-bundle.pem, and reloads (RELOAD).

Attention: an intermediate under 30 days from its end, whose last renewal
failed or whose host has not asked for three days shows in rollcall
doctor, on the web UI's overview, and one from the old root in the
rotation status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:33:31 +01:00
Ilker ManapandClaude Opus 5.5 a5378dc291 Release 0.4.0
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 43s
release / release (push) Successful in 34s
release / image (push) Successful in 9m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:00:47 +01:00
Ilker ManapandClaude Opus 5.5 0569ecf1cc Review fixes before v0.4.0: NetBox on its own origin, pinned root refresh
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 49s
From a review of everything since v0.3.0:

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:35:45 +01:00
Ilker ManapandClaude Opus 5.5 6a7b158644 3D rooms: rail posts, solid servers of unknown height, a word when no cabling is known
CI / go (push) Successful in 44s
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
Through the see-through cabinet both rail faces showed as two grey
boards, and a server of unknown height (drawn 1U, see-through) read as
an empty shelf. Racks now have their four rail posts instead of panels;
every server is solid, one of unknown height carries an amber outline
(the elevation's ?U). A room whose machines' LLDP saw no switch and
that NetBox has no cables for says so in the side panel, and how to
enter cables by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:56:16 +01:00
Ilker ManapandClaude Opus 5.5 1d41a83f27 3D rooms: switches show from the front; UI files revalidate
CI / site (push) Successful in 2s
CI / agent (push) Successful in 3s
CI / go (push) Successful in 42s
Switches stood only at the back of the rack and read as a dark slab
from the cold aisle. Each now has a front at its unit on the front
rails, with its name on a label in its cable colour, LEDs and fans, and
clicks and hovers like the rest of it.

The web UI's files carry Cache-Control: no-cache and an ETag per build
(and start), so a browser asks each time, gets 304 while nothing
changed, and the new files right after a deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:51:34 +01:00
Ilker ManapandClaude Opus 5.5 7111327dd7 Web UI: a toggle button that is on looks on outside a segmented group
CI / go (push) Successful in 43s
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
The 3D room's Cables button turned the cables on and off but looked the
same either way: the "on" style was only defined inside .seg-btns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:44:42 +01:00
Ilker ManapandClaude Opus 5.5 3c7af23be2 3D rooms: cables as busbars, switches in the rack elevation
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 43s
Single thin cables were lost at room distance. Per rack and switch the
cables now run as one bus up the back of the rack, thicker the more
cables it carries, with a short stub from each port into it and a comb
under the switch with a tail into each switch port; a bus to a switch in
another rack crosses over the tops as a tray, to one in another room it
rises to the ceiling. Each switch's bus has its own lane behind the
rack. Everything is drawn as a few instanced meshes, picking resolves
the instance (a bus names its cable count, a stub its port).

Switches now show in the side panel's rack elevation at their unit (the
3D room's placement, estimates marked), their body runs deeper into the
rack, and on the selected rack they carry a short name. The panel is
drawn again once the switches are placed, also with a rack selected.
A switch placed by hand mid-rack (place…) gets its bus there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:39:54 +01:00
Ilker ManapandClaude Opus 5.5 dc1ca9f2c6 3D rooms: cabinets apart from servers, cables you can see
CI / go (push) Successful in 50s
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
The cabinet and the servers were both dark grey, and the cables behind
an opaque cabinet could only be seen from the back. Cabinets are now
light steel and see-through like a perforated door; every server has a
solid dark chassis between its front and its back, so it stands out
from the cabinet. Cables are thicker and lit, each switch's cables run
up their own lane beside the rack instead of on top of one another, and
the switch colours start away from the selection's blue. The legend
says the cables are at the back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:28:16 +01:00
Ilker ManapandClaude Opus 5.5 44ac453bde NetBox through Rollcall: the admin certificate signs in to NetBox
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 43s
With ipam.ui_login_secret_file, the proxy tells NetBox who the admin is
(X-Rollcall-User: the certificate's admin name) together with a shared
secret (X-Rollcall-Proxy-Secret). NetBox takes the user from that header
(REMOTE_AUTH) and creates it on first use; the web server in front of
NetBox drops the header from any request that lacks the secret or does
not come from the region, so reaching NetBox directly claims no one.
Both headers coming from a browser are always dropped by Rollcall; an
admin signed in with a token instead of a certificate logs in to NetBox
by hand. A secret under 16 characters or an unreadable file leaves the
proxy off, with a log line.

The lab NetBox install sets it up: REMOTE_AUTH settings, a guarded
Caddyfile (readable by caddy only: the secret is in it), the secret in
secrets.env, and a rollcall-admins group that may do everything. Rollcall's
NetBox user may now view cables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:19:10 +01:00
Ilker ManapandClaude Opus 5.5 a613014073 Cabling: BMC ports to the out-of-band switches, NetBox cables, cables by hand
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 43s
The host's LLDP never sees the BMC's own port. A port's cable now comes
from LLDP first (what is really there), then from a cable recorded in
NetBox on the machine's device (read with the layout, traced through
patch panels to the far interface via connected_endpoints), then from
one entered by hand (POST/DELETE /api/v1/cables, the pencil in the
cabling list). A NetBox interface marked management-only or named like
a BMC (iDRAC, iLO, XCC, IMM, IPMI, BMC, OOB, mgmt) is the machine's
"bmc" port, so BMCs get their cable to the out-of-band switch. When
NetBox records another switch port than LLDP sees, the port says what
NetBox says.

A switch whose NetBox role names out-of-band or management, or that
only BMCs are cabled to, is marked OOB. In 3D the BMC cable leaves the
BMC port on the back (thinner); the side panel lists every machine's
ports with their source, the BMC always, and switches carry an OOB tag.
Estimated switch places now go around the units placed switches take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:05:17 +01:00
Ilker ManapandClaude Opus 5.5 d2624b2fc2 NetBox proxy: one route per method, beside the UI's "GET /"
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 43s
A bare "/netbox/" pattern conflicts with "GET /" in ServeMux (neither is
more specific), and the region panicked at start once ipam.url carried a
path. The proxy is registered per method now, and a test builds the
routes with it. The lab install script's own API calls use the new
/netbox/ base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:29:13 +01:00
Ilker ManapandClaude Opus 5.5 cc1245da1e NetBox's web UI through Rollcall, and links to it from the web UI
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 42s
With NetBox as the IPAM under a path (its BASE_PATH) and ipam.url
carrying it, Rollcall serves NetBox's UI at that same path, for admins
only (the admin certificate, as for the API); NetBox keeps its own
login. Redirects that name NetBox's own address are turned back to
Rollcall's, Rollcall's credentials are not passed on. NetBox opens
wherever Rollcall does: a lab NetBox on a segment the browser cannot
reach, say.

The web UI links to NetBox: the sidebar, sites, rooms, racks, switches
and a machine's device (by its rollcall_machine field). ipam.ui_url
points the links at NetBox directly instead.

NetBox paging under a BASE_PATH doubled the path on the next page
(the next link carries it already); fixed. The lab NetBox moves under
/netbox/ and trusts the region's addresses for its forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:21:22 +01:00
Ilker ManapandClaude Opus 5.5 c31a89482e 3D rooms: server backs, switches and the cables between them (LLDP)
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s
GET /api/v1/cabling gives each located machine's ports (from its
inventory: name, speed, link, the PCI card they sit on) and the switch
port each is plugged into, from the LLDP its commissioning saw (lldpcli's
neighbours, either JSON shape). Switches get a place: NetBox's (the
device's site, location, rack and position, read with the layout on
every reconcile) or one set by hand (POST /api/v1/switches, which NetBox
does not move); a switch with neither is drawn where most of its cables
are, at the highest free unit (on top of the rack when none is free),
and marked as estimated.

In 3D every server now has a back too: power supplies, the BMC port, USB
and VGA, and its network cards with their ports (SFP cages from 10G,
RJ45 below), per sled for a multi-node chassis. Switches stand at the
back with their ports in two rows. Cables run from each port out of the
back, down the rack's side, over the top to another rack's switch, into
the switch port; one colour per switch, grey without link, and up
through the ceiling to a switch in another room. Hovering a cable names
both ends and the VLAN; with a rack selected the other racks' cables
fade. "Cables" turns them off, "Turn around" looks from the other side.
The side panel lists the room's switches with their place (place… sets
it) and, for a selected rack, every port and the switch port it goes to,
in 2D as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:41:40 +01:00
Ilker ManapandClaude Opus 5.5 0d6f227819 3D rooms: server fronts drawn from the model
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s
Servers in the 3D room now show the front their model family has: 2.5"
bays standing or 3.5" bays lying in their groups, rack ears with the
power button, vents where there are no bays, in the vendor's colours
(fronts.js; drawn, no photos or logos). A family's bay options are tried
in order and the first with room for the machine's disks is drawn, empty
bays as blanks. A multi-node chassis shows one bay group per sled (six
each on Dell C-series), each sled clickable and lit in its own state.
The state shows as a strip and LED on the left ear and, when busy or
failed, a frame around the front. "Fronts / State" switches back to the
coloured blocks.

Seen from the aisle a rack's local +x is on the left: sleds were laid
out mirrored; fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:16:26 +01:00
Ilker ManapandClaude Opus 5.5 d9546fe264 Racks: one rack whatever the case of its name
CI / agent (push) Successful in 2s
CI / site (push) Successful in 3s
CI / go (push) Successful in 45s
A rack created from a machine's location ("lab / pxe hall / R1") and the
same rack from NetBox ("Lab / PXE Hall / R1") showed twice on the Racks
page. The NetBox layout sync now folds such a rack into NetBox's spelling:
its floor place and comment carry over where the NetBox one has none, and
the machines located in it move to the NetBox spelling. A machine's
location no longer creates a rack that differs only in case, rack machine
counts ignore case, and the Racks page keys racks without case, NetBox's
spelling first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 08:59:16 +01:00
Ilker ManapandClaude Opus 5.5 6da78311f3 Rooms in 3D: racks at their places and height, servers on their units
CI / agent (push) Successful in 4s
CI / site (push) Successful in 3s
CI / go (push) Successful in 49s
The room page gets a 2D / 3D switch (remembered per browser). The 3D view
draws the floor's 600 mm tiles, each rack at its place, facing and real
height (see-through while it has no place yet), and every server as a block
on the rack's front over its units, coloured by state; the sleds of a
multi-node chassis sit side by side, servers of unknown height are
see-through. Drag turns, right or shift drag moves, wheel or pinch zooms,
a double click brings a rack close; a click selects a rack (same address
and side panel as the floor plan), and a click on a server of the selected
rack opens the machine. The servers in the side panel's elevation now open
their machine on the floor plan page too.

The renderer outlives the 5 s re-render: its canvas moves into the new page
and the scene is rebuilt only when racks, machines, the selection or the
theme changed, so the camera stays where it was. Leaving the page frees the
WebGL context. Racks are still placed in the 2D editor.

three.js 0.160.0 (MIT) is vendored as web/vendor/three/three.module.min.js,
taken from the npm tarball whose sha512 matched the registry's integrity
field, and loaded with import() on first use: nothing is fetched from
outside. rackElev's block placement is split out as rackBlocks so the
elevation and the 3D fronts agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 08:47:25 +01:00
Ilker ManapandClaude Opus 5.5 54bda28b82 Sites: rooms and racks match whatever their case
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 44s
A rack typed in by hand (lab / pxe hall / R1) and the same rack from
NetBox (Lab / PXE Hall / R1) showed as two rooms and two racks. Site
views and the rack front match room and rack names ignoring case, as the
NetBox device sync does; one rack is shown, NetBox's spelling and a
placed one first. Found on the lab region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 00:25:14 +01:00
Ilker ManapandClaude Opus 5.5 148c602b82 Sites on a map, rooms, floor plans with racks you can place
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s
CI / agent (push) Successful in 3s
The Sites page shows sites on an OpenStreetMap map (Leaflet 1.9.4 in the
binary, checked against cdnjs's SRI; the CSP allows OSM tiles and the
tile layer sends a referrer as OSM's policy asks). A site opens its
rooms, a room its floor plan: racks on 600 mm tiles, one wide and two
deep, front edge marked, coloured by their machines with a bar for the
units used; a rack opens its front, and the rack is part of the URL.
An edit mode drags racks into place and rotates them; racks nobody
placed are laid out in rows facing alternate aisles.

With NetBox as the IPAM authority, sites (coordinates, address),
locations and racks are read on every reconcile, never written back.
Otherwise sites are placed by clicking the map. Rack positions are
Rollcall's (NetBox has none).

Checked in headless Chrome with a synthetic site, including a scripted
drag, rotate and save of a rack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 00:20:56 +01:00
Ilker ManapandClaude Opus 5.5 9b0b27fce6 Server heights from firmware and model; rack elevation at real size
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 41s
Nobody types in how many units a server takes. Commissioning reads the
SMBIOS chassis height; without one the model decides, from a table of
heights checked against vendor documentation (Dell, HPE, Lenovo,
xFusion, Fujitsu, Inspur/KAYTUS, GIGABYTE; Supermicro model numbers do
not carry the height), and rack_units in config.json covers the rest and
overrides both. Sleds of a multi-node chassis share its units.

NetBox device types get the height (0 for sleds, which go into the rack
without a position; Rollcall's own types are corrected). The rack
elevation spans each server over its height, draws a chassis's sleds as
one block, marks unknown heights and lists overlaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:16:10 +01:00
Ilker ManapandClaude Opus 5.5 355401465c Network page for large sites: problems, filters, matrix, machine path
CI / agent (push) Successful in 2s
CI / site (push) Successful in 3s
CI / go (push) Successful in 41s
The topology drawing became an unreadable strip at a few hundred
machines and dozens of VLANs. The page now starts with what needs
attention (offline rack controllers and the VLANs they leave without
DHCP/PXE, provisioning VLANs without it, nearly full subnets, IPAM
errors), has filters by fabric, purpose and rack controller and a
search that shows a machine's path to the region, and a VLAN x rack
controller matrix that is the default once a site is large. The
topology groups machines by rack and VLAN, then by rack, when boxes
would not fit, and scrolls instead of shrinking; the overview shows one
row per fabric on a large site. Filters can come from the URL.

Checked with a synthetic site (3 fabrics, 9 rack controllers, 43
subnets, 360 machines) and the lab in headless Chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:55:42 +01:00
Ilker ManapandClaude Opus 5.5 a25b5c1f33 Rotation: refresh says when it is too late, admin certificates noted
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 42s
rollcall-ca-refresh reports a server it no longer trusts (the rotation
went past prepare before the machine refreshed) instead of exiting
quietly; the README asks for a day between prepare and switch. The
status lists admin certificates from before the switch, and retire
prints them: they stop working with the old root.

Tested end to end in the lab: prepare, switch and retire on pxe-server
with pxe-rack2 following without a new join (bundle installed by its
root unit, client and server certificates renewed), node001 (Debian) and
node002 (Rocky) moving to the new root with the refresh timer, and the
netops Caddy intermediate signed again by the new root.

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:36:24 +01:00
Ilker ManapandClaude Opus 5.5 a9235c85d8 Rack state re-pins rollcall.ca in stored boot entries
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 41s
Commission, deploy and stateless entries are stored with the root's
fingerprint of the moment; after a rotation switch a machine booting
from one refused its rack. The state now writes the root the rack serves.
Found in the lab rotation (node002's entry on pxe-rack2).

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:40:13 +01:00
Ilker ManapandClaude Opus 5.5 2f708e8f69 Root rotation without re-joining: prepare, switch, retire
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 47s
The CA directory holds the active root and, during a rotation, the next
or the previous one; everything that trusts the region gets the trust
bundle (all of them). Rack controllers receive it in their state, signed
by the active root; the root update unit installs it only when the
signature verifies against the roots it already trusts, and binary
signatures verify against any of them. After switch racks renew their
client and server certificates from the new root by themselves and
report what they run on; switch and retire wait for every rack (-force).

/ca.pem stays one certificate, the root of that server's certificate, and
the PXE command line names that root, so agents in older images keep
working; /ca-bundle.pem is the bundle. Deployed machines get one file per
root and a daily timer that refreshes them from their rack over HTTPS.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:37:44 +01:00
Ilker ManapandClaude Opus 5.5 ff0de7e0b2 Release image upload: the image directory belongs to the deploy user
CI / go (push) Successful in 53s
CI / agent (push) Successful in 4s
CI / site (push) Successful in 2s
The image job built as root and uploaded as the deploy user, so
publish-image.sh could not write SHA256SUMS (v0.3.0's image job failed).
The job hands the directory over after the build, and publish-image.sh
checks the SHA256SUMS build-image.sh wrote instead of rewriting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:32:54 +01:00
53 changed files with 5803 additions and 220 deletions

No files matched your search

+1
View File
@@ -97,6 +97,7 @@ jobs:
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; }
+72
View File
@@ -6,6 +6,78 @@ 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
+166 -4
View File
@@ -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
@@ -558,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
@@ -822,6 +906,84 @@ The NetBox user needs `view/add/change/delete` on `dcim.device`,
`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
+7
View File
@@ -90,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
+1 -1
View File
@@ -214,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)})
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ func (a *App) handleRackServerCert(w http.ResponseWriter, r *http.Request, rack
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.PEM)})
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).
@@ -157,7 +157,7 @@ func (a *App) agentTLSArgs(base string) []string {
if t == "" {
return nil
}
fp := certFingerprint(a.ca.Cert)
fp := certFingerprint(a.serverCAFor(base)) // the root the rack serves as /ca.pem
return []string{"rollcall.tls=" + t, "rollcall.ca=" + hex.EncodeToString(fp[:])}
}
+23 -107
View File
@@ -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
}
@@ -609,7 +618,7 @@ func (a *App) handleRackEnroll(w http.ResponseWriter, r *http.Request) {
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.
@@ -633,7 +642,7 @@ func (a *App) handleRackRenew(w http.ResponseWriter, r *http.Request, rack strin
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)
@@ -654,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...)
@@ -668,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:
@@ -707,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)
+541
View File
@@ -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
}
+126
View File
@@ -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
}
+717
View File
@@ -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) }
+194
View File
@@ -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")
}
}
+4 -1
View File
@@ -34,7 +34,10 @@ type Config struct {
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"`
OSSources string `json:"os_sources"` // JSON list of upstream cloud images for `rollcall os sync`
// 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
+15 -4
View File
@@ -311,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 {
@@ -327,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})
+18
View File
@@ -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()
}
+106
View 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
}
+81
View File
@@ -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")
}
}
+425
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+33 -16
View File
@@ -24,6 +24,7 @@ import (
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"net/url"
@@ -37,18 +38,21 @@ import (
)
type IPAMConfig struct {
Provider string `json:"provider"` // "" = built-in only | netbox | infoblox
URL string `json:"url"` // https://netbox.example
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
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"`
}
@@ -327,12 +331,17 @@ 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)
rep.Devices = nb.syncDevices(ctx, *a.cfg.IPAM.DeviceSync, ms, local, a.cfg.RackUnits)
}
}
return rep
@@ -410,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})
}
@@ -532,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
}
+333
View File
@@ -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
}
+44
View File
@@ -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)
}
}
+8 -4
View File
@@ -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)
@@ -147,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
}
}
@@ -165,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++
}
}
+3 -1
View File
@@ -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
+43 -4
View File
@@ -16,6 +16,7 @@ package main
import (
"context"
"fmt"
"log"
"net/url"
"regexp"
"sort"
@@ -135,7 +136,7 @@ func nbIfaceType(n invNIC) string {
}
// syncDevices brings NetBox's devices in line with the machines.
func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines []*Machine, ips []IPAddr) *devReport {
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"
@@ -213,14 +214,14 @@ func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines
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.ensure(ctx, "dcim/device-types/", url.Values{"manufacturer_id": {strconv.FormatInt(mid, 10)}, "slug": {nbSlug(model)}},
obj{"manufacturer": mid, "model": model, "slug": nbSlug(model), "u_height": 1, "tags": tags})
tid, err := n.ensureType(ctx, mid, model, size, tags)
if err != nil {
rep.fail(fmt.Sprintf("%s: device type: %v", name, err))
continue
@@ -239,7 +240,7 @@ func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines
}
if id, ok := rackBy[siteName{site, strings.ToLower(m.Location.Rack)}]; ok && m.Location.Rack != "" {
body["rack"] = id
if m.Location.Unit > 0 {
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"
}
}
@@ -463,3 +464,41 @@ func dmiValue(s string) string {
}
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
}
+5 -5
View File
@@ -250,7 +250,7 @@ func TestNetBoxDeviceSync(t *testing.T) {
ns := "Not Specified"
b.Serial = &ns
rep := nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips)
rep := nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips, nil)
if rep.Error != "" {
t.Fatal(rep.Error)
}
@@ -302,7 +302,7 @@ func TestNetBoxDeviceSync(t *testing.T) {
// a second pass changes nothing
f.reqs = nil
rep = nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips)
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)
@@ -313,7 +313,7 @@ func TestNetBoxDeviceSync(t *testing.T) {
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)
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)
}
@@ -324,10 +324,10 @@ func TestNetBoxDeviceSync(t *testing.T) {
func TestNetBoxDeviceSyncSite(t *testing.T) {
nb, _ := testDCIM(t)
if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{}, nil, nil); !strings.Contains(rep.Error, "required") {
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); !strings.Contains(rep.Error, "no site") {
if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{Site: "mars"}, nil, nil, nil); !strings.Contains(rep.Error, "no site") {
t.Fatal(rep.Error)
}
}
+108
View File
@@ -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
}
+131
View File
@@ -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")
}
}
+25 -2
View File
@@ -15,6 +15,7 @@ package main
import (
"context"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -52,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.
@@ -64,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 {
@@ -91,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,''), coalesce(tls_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
}
@@ -100,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.TLSURL, &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)
@@ -465,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)
@@ -473,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
@@ -856,6 +878,7 @@ func (a *App) handleRackRegister(w http.ResponseWriter, r *http.Request, rack st
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))
+12 -4
View File
@@ -476,9 +476,8 @@ 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})
})
mux.HandleFunc("GET /ca.pem", func(w http.ResponseWriter, req *http.Request) { // agents check it by its fingerprint
http.ServeFile(w, req, cfg.CAFile)
})
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))
}
@@ -534,8 +533,10 @@ func (r *rackd) register() error {
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}, nil)
Interfaces: localInterfaces(), DHCPInterfaces: r.cfg.DHCPInterfaces, TFTPRoot: r.cfg.TFTPRoot,
CABundleSHA256: bundle, ServerCA: serverCA}, nil)
return err
}
@@ -571,6 +572,7 @@ func (r *rackd) loop() {
continue
}
go r.maybeUpdate(st.Update)
go r.maybeTrust(st.Trust)
if st.Gen == r.gen {
continue
}
@@ -790,6 +792,12 @@ func (r *rackd) serverCert() (*tls.Certificate, error) {
}
}
}
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
+42 -4
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"embed"
"encoding/json"
"errors"
@@ -252,6 +251,11 @@ 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))
@@ -269,6 +273,7 @@ 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))
@@ -284,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))
@@ -310,14 +326,21 @@ func (a *App) routes() http.Handler {
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:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
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
@@ -430,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"`
}
@@ -705,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)
}
@@ -720,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
}
@@ -867,8 +893,7 @@ func serve(cfgPath string) error {
if err != nil {
return fmt.Errorf("region certificate: %w", err)
}
pool := x509.NewCertPool()
pool.AddCert(app.ca.Cert)
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() {
@@ -877,6 +902,19 @@ 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, 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 != "")
+27
View File
@@ -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 }
+1 -1
View File
@@ -473,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
+54
View File
@@ -101,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;
@@ -170,6 +221,9 @@ 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,
+100 -23
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = n => { n = Number(n || 0); const u = ['B', 'K', 'M', 'G', 'T', 'P']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i <= 2 ? n.toFixed(0) : n.toFixed(1)) + u[i]; };
const ago = ts => { if (!ts) return '-'; const s = (Date.now() - new Date(ts)) / 1000; if (s < 90) return `${s | 0}s`; 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,11 +134,13 @@ 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;
@@ -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'); });
}
@@ -508,7 +532,7 @@ function pageMachine(d, tab) {
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')}
@@ -595,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>`
@@ -742,10 +810,13 @@ 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(' ');
@@ -757,10 +828,15 @@ function pageNetwork() {
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: [
@@ -800,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);
+225
View File
@@ -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;
}
+6
View File
@@ -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>
+205
View File
@@ -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'); });
}
+466
View File
@@ -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(); } }
+363
View File
@@ -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: '&copy; <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); }
}
+70 -1
View File
@@ -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 */
+34 -17
View File
@@ -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];
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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
+2 -1
View File
@@ -11,7 +11,8 @@ IMG=${IMG:-/srv/rollcall/image}
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
# 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
+57
View File
@@ -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
+11
View File
@@ -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
+2 -1
View File
@@ -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
+53 -12
View File
@@ -1,17 +1,18 @@
#!/bin/sh
# Installs a rollcall binary the rack controller staged (rollcall-rack-update.path).
# 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: the binary is copied into a directory of root's and installed
# only if the region's signature over it verifies against the CA certificate
# written at join (root's, from rack.json ca_file). Then the running binary is
# kept as .prev, the rack restarted, and the previous binary put back if the
# rack does not register with the region in 60 s.
# 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" ] || exit 0
want=$(head -c 64 "$D/ready" | tr -cd 'A-Za-z0-9._+-'); rm -f "$D/ready"
[ -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
@@ -19,11 +20,51 @@ trap 'rm -rf "$W"' EXIT
# last-result is read by the rack (backoff); renamed in, so a link planted there is replaced, not followed
result() { echo "$(date -Is) $*" > "$W/result" && mv -fT "$W/result" "$D/last-result"; echo "$*"; }
CA=$(jq -r '.ca_file // "/etc/rollcall/rack/ca.pem"' /etc/rollcall/rack.json 2>/dev/null)
[ -n "$CA" ] && [ "$(stat -c %u "$CA" 2>/dev/null)" = 0 ] || { result "$want: no root-owned CA certificate ($CA), not installed"; exit 1; }
[ -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=$(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; }
openssl x509 -in "$CA" -pubkey -noout > "$W/pub" 2>/dev/null &&
openssl dgst -sha256 -verify "$W/pub" -signature "$W/sig" "$W/bin" >/dev/null 2>&1 ||
{ result "$want: signature does not verify against $CA, not installed"; exit 1; }
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; }
+38 -5
View File
@@ -3,6 +3,14 @@
# 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)
@@ -16,6 +24,8 @@ 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
@@ -37,6 +47,7 @@ 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
@@ -69,7 +80,13 @@ REDIS = {
SECRET_KEY = '$NETBOX_SECRET_KEY'
API_TOKEN_PEPPERS = {1: '$NETBOX_PEPPER'}
TIME_ZONE = 'Europe/Stockholm'
CSRF_TRUSTED_ORIGINS = ['http://$MYIP']
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; }
@@ -84,13 +101,22 @@ systemctl restart netbox netbox-rq
cat > /etc/caddy/Caddyfile <<EOF
# pxestuff lab/netbox/install.sh
http://$MYIP, http://localhost {
handle_path /static/* {
# 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
@@ -122,13 +148,20 @@ perms = {
"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"], ["view"]),
"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})
@@ -138,7 +171,7 @@ 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/api/users/tokens/provision/ -H "Host: $MYIP" \
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
@@ -147,4 +180,4 @@ 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/api/ipam/prefixes/
curl -fsS -o /dev/null -w "rollcall token: %{http_code}\n" -H "Authorization: Bearer $NETBOX_TOKEN_ROLLCALL" http://$MYIP/netbox/api/ipam/prefixes/